Five stages, each with a second way to do it
Pulling and pushing: one set from ffmpeg, one set from zlmediakit, and other protocol sources can be added too. Decoding: software or hardware, ffmpeg or the platform's native (MediaCodec, VideoToolbox). Encoding the same. Image processing is a layer hung independently: scaling, rotation, mirroring, flipping, watermark and subtitles can all be stacked in this stage. Data sources are extensible too: screen capture, camera and microphone each have an existing device source; how each platform captures was covered in the earlier capture articles.
How the stages are stitched together is decided by the need. A media source can skip decoding and forward the encoded data directly; or decode and then encode, with image processing in between. On low-power platforms, the decode-to-render and image-processing-to-hardware-encode stages can stay on the GPU for the whole stretch. When you limit yourself to AAC, H264 and H265, the whole chain can avoid ffmpeg entirely.

The same bitstream, two packings
H264/H265 encodes into a series of NAL units, and there are two ways to separate them — the root of every seam problem. One is called annexb, with a start code in front of each unit (four-byte 0001 or three-byte 001); the other is avcc/hvcc, with a four-byte length in front of each unit. The packing depends on the source: local files and HLS use the length-header kind; network streams use the start-code kind. What encoders emit also splits: Android's native hardware encoder emits the start-code kind, Apple's native hardware encoder emits the length-header kind.
Pulling with ffmpeg keeps pretty much the source's original form, and its packets often combine several units — a parameter-set group plus an I frame squeezed into one packet, or a very large I frame split across several packets. Pulling with zlmediakit unifies to the start-code kind; multiple units of the same picture frame are merged by the project side into one large packet before going downstream, so that a large I frame is not scattered and hard to manage.
If you go all the way with ffmpeg — pull, decode, encode, push — none of this needs attention; whichever stage changes implementation is the stage that has to face the packing problem itself.
Decide the format first, then whether to convert
The trouble is: a four-byte start code and a four-byte length header cannot be told apart by eye; and data beginning with a three-byte start code may happen to collide with the low bytes of a length header. The approach is to try the length-header reading first: take the first four bytes as a length, jump to the next unit, and see whether it can walk self-consistently to the end of the packet — if it lines up, it is the length-header kind; if not, the start-code kind. The decision only needs to be made once per packet; no stream has been seen switching back and forth.
For convenience, when a packet is enqueued, a three-byte start code is padded to four bytes, which also makes later conversion to a length header smooth.
This decision function also had a pitfall fixed: when garbage data gets mixed into a packet, the cursor jumping forward by length can wrap around, the loop cannot get out, and the IO thread hangs there. Bounds were later added to the cursor — out of bounds judges false and advances one loop at a time — which plugged the infinite loop.

Splitting and ordering the configuration frames
Configuration frames are the VPS, SPS and PPS parameter sets that carry no picture and only describe how the picture is decoded; they hide in the extradata field ffmpeg hands over. For the start-code kind, cutting at start codes is enough; the length-header kind is another structure and has to be parsed field by field: skip the version header, read the length-field width, then take the parameter sets one by one. The order given to the decoder matters: parameters in PPS read SPS, and SPS reads VPS, so VPS comes first, SPS next, PPS last — get it wrong and initialization fails.
The conversion principle follows what downstream wants: Apple platform decoding wants the length-header kind, Android decoding wants the start-code kind, pushing wants the start-code kind; when forwarding to a media source, converting uniformly to the start-code kind is the most stable — in measurements, a stream that was originally length-header sometimes had problems going to a start-code source, while the reverse direction always worked. So the stage where data enters the source only splits parameter sets and merges frames that were split apart, in principle not touching the packing; only in the situations listed above does it actually convert.
One pass on each of two devices
One iOS device and one Android device in a call. On the iOS side: ffmpeg pulls the stream, the parameter sets are taken from the codec parameters, the packing is decided, and they are stored separately; the native decoder is initialized with the parameter sets (the four bytes at the front have to be removed for this step); packets are read, start-code ones are converted to length headers before going to the decoder, and Apple hardware decoding also has to filter out non-data units such as SEI; the decoded picture goes through image processing together with the camera picture; the encoder is started by size, frame rate and bitrate, and the parameter sets it produces are taken separately through the corresponding interface; after the push object is initialized, the parameter sets get start-code headers added and are sent in, and encoded packets are converted to the start-code kind before being enqueued.
On the Android side: zlmediakit pulls the stream, the parameter sets usually follow in front of the first I frame, the pull object stores them, and when new parameter sets arrive they are compared to see whether they changed, deciding whether the decoder needs resetting; the native decoder is initialized with parameter sets carrying four-byte start codes; packets are read and the packing is decided directly, converting when it is not start codes; the decoded picture is processed together with the picture captured by Camera2; MediaCodec starts the encoder, which sends the parameter sets automatically before the first I frame; when the push object is initialized, the parameter sets are combined in order into one large packet and written into the codec parameters; encoded packets are already the start-code kind and are enqueued directly.

Audio configuration frames: G711 has none, AAC relies on adts or asc
Audio is not this much trouble. G711 and the like have no concept of configuration frames; for AAC, knowing the sample rate, channels, bitrate and profile is enough to hand-build the configuration frame — of its two packings, adts carries a header on every frame and asc gives one global configuration, and asc is used more these days — the basic information of sample rate and profile is enough for initialization.
At this point the two-way call chain is done. The next article is the last link in the opposite direction: wiring this chapter's capture and hardware encoding formally onto WebRTC's tracks and encoders and pushing it out over its network.
The avox engine is open source on GitHub: github.com/xxxzhou/avox, with the decision function, the parameter-set splitting and both device combinations inside.