MP4 MP4 Analyzer by MediaOnDeck

// engineering · container

269 of 271 frames had the wrong reference list

269 of 271 frames disagreed with bitstream ground truth on their reference lists — some truncated to a single entry, others never pruned at all. No decoder error, no crash, just every number slightly off. The cause was choosing the wrong framing when locating NAL boundaries: treating MP4's length prefix as an Annex-B start code.

1. The symptom: numbers that are "close, but wrong"

The frame table was fine, frame types were mostly right, parameter sets parsed — only the reference lists (L0 / L1) were systematically off. Comparing against ground truth frame by frame: 269 of 271 mismatched.

The failure shapes were scattered, too:

ShapeBehaviourWhat it implies
Truncated The reference list is cut down to one entry The "start" we found sits after the real slice header
No pruning An entry that should have been removed from the list was not The value we read is not that field at all

Both shapes appearing at once points away from "one field's formula is wrong" and towards where we are reading from — the same code lands on different wrong offsets depending on what the data happens to look like.

2. One bitstream, two framings

The background matters: H.264 / H.265 NAL units can be framed two ways, and the rules for splitting them apart are entirely different.

FramingHow boundaries are foundTypically seen in
Annex B Scan for the start code 00 00 01 or 00 00 00 01 Raw bitstream files, broadcast streams
Length prefix A fixed-width length field precedes each NAL; the width comes from the container Samples inside MP4 / ISOBMFF

In MP4 the width of that length field lives in the parameter-set box (lengthSizeMinusOne in avcC, with an equivalent in hvcC), commonly 4 bytes. Which means: MP4 samples contain no start codes at all. Boundaries come from a chain — read 4 bytes, skip that many, read 4 bytes again.

3. How the mis-detection happens: a length prefix that looks like a start code

Flip the detection order — try Annex-B scanning first, fall back to length prefixes only if that fails — and you walk straight into an awkward coincidence.

A 4-byte length prefix says how many bytes the NAL occupies. For a NAL a few hundred bytes long, the high bytes are zero, so the byte stream reads as 00 00 01 xx — exactly a 3-byte Annex-B start code followed by one byte.

actual bytes (length-prefixed):
  00 00 01 2C  |  <-- length field: this NAL is 300 bytes
  40 01 ...    |  <-- the NAL header starts here

what an Annex-B scanner sees:
  00 00 01     |  <-- "start code"
  2C 40 01 ... |  <-- so it reads a NAL header from 0x2C (wrong)

The NAL header is then read from the wrong offset. Worse, the resulting "type" is plausible: a 2-byte H.265 NAL header might yield something like 23 — which in the spec is a reserved random-access VCL type. It does not look like obvious garbage, and it does not run off the end.

The lesson worth keeping: the wrong answer looked legal, so no assertion stopped it. Visibly garbled data would at least have been caught by a range check.

4. Why it surfaced in the reference chain, not as a parse failure

With NAL headers misread, the first casualties are random-access-point detection and slice-header start:

  • Parsing a slice header from the wrong offset reads reference-list operations that are not the real ones;
  • Random-access detection drifts, so the walk-back may start on a non-random-access frame — the chain is wrong from its root;
  • All of it shows up as numeric drift, never as an exception, so the program runs to completion.

5. The fix: trust the container first, and only then fall back

There is exactly one correct detection order:

  1. Read the length-field width from the container (lengthSizeMinusOne) and split the NAL chain by length prefix. This is the only evidence-backed path.
  2. Only when that information is unavailable, fall back to Annex-B scanning. It should be a fallback, never the first choice.
  3. Add a cross-check: after walking one sample by length prefix, the bytes consumed must equal the sample size exactly. One byte off means the split is wrong — and that assertion catches this exact bug immediately.

Rule 3 is what turns "quietly wrong" into "fails on the spot". It needs no external ground truth, only an identity that must hold.

6. A spec correction found along the way

Working through this chain turned up a second "runs, but is not correct" item: the codec string used to describe a stream to the browser must carry a profile prefix that matches what the container's sample entry declares. hvc1 and hev1 do not mean the same thing (one requires out-of-band parameter sets, the other permits them in-band), so one fixed template cannot be pasted over both.

Separately, the compatibility flags stored in the parameter-set box are written in the opposite bit order to how they appear in the string, so the string needs the reversed value (the common Main-profile combination is written 6).

This one differs from everything above: the old form was out of spec, yet the browser accepted it anyway. So it is a spec correction, not a bug fix — nothing visibly changes. Worth doing, but do not expect it to cure any symptom, or you will spend a day hunting a regression that does not exist.

7. Transferable lessons

  • "It happens to work" is not "it is correct". A tolerant decoder accepting your input does not make the input right — it may simply never have exercised that path.
  • Prefer explicit declarations over heuristics when detecting formats. The container already states the length-field width; do not start by guessing at start codes. Heuristics are always the fallback.
  • Add an identity assertion to anything that can drift silently. "Consumed bytes == sample size" costs nothing to check and needs no external truth.
  • Scattered failure shapes mean a position error, not a logic error. If a field is sometimes long, sometimes short, sometimes truncated, suspect the read origin before auditing formulas one by one — this single rule saved the most time here.

Related: 623 keyframes, zero detected · One skipped bit field · Building motion-vector ground truth · Open the analyzer