// engineering · container
269 / 271 帧参考链错位:把长度前缀当成了起始码
271 帧里有 269 帧的参考列表和位流真值对不上——有的被截断成 1 项,有的干脆不减。 解码器没报错,程序没崩溃,只是每个数字都偏了。根因是「判断 NAL 边界」这一步选错了封装格式: 把 MP4 的长度前缀当成了 Annex B 的起始码。
一、症状:数字全都「差一点」
帧表正常,帧类型大体也对,参数集读得出来——只有参考列表(L0 / L1)系统性偏掉。 和真值逐帧比对的结果是 269 / 271 不符。
失败形态还很分散:
| 形态 | 表现 | 指向 |
|---|---|---|
| 截断 | 参考列表被砍到只剩 1 项 | 读到的「起点」比真实 slice 头更靠后 |
| 不减 | 该项本该从列表里移除却没有移除 | 读到的字段根本不是那个字段 |
两种形态同时出现,说明问题不在「某个字段的公式」,而在读数位置—— 同一份代码会因为数据长得不同而落到不同的错误偏移上。
二、同一种码流,两种封装
这是整件事的背景知识,值得先讲清:H.264 / H.265 的 NAL 单元可以有两种封装方式, 而拆分 NAL 边界的规则完全不同。
| 封装 | 怎么划边界 | 常见于 |
|---|---|---|
| Annex B | 靠起始码 00 00 01 或 00 00 00 01 扫描 |
裸码流文件、广播流 |
| 长度前缀 | 每个 NAL 前面有固定字节数的长度字段,字节数由容器声明 | MP4 / ISOBMFF 里的样本 |
在 MP4 里,这个长度字段的宽度存在参数集盒子里(avcC 的
lengthSizeMinusOne,hvcC 里同样有一个),常见取值是 4 字节。
也就是说:MP4 的样本里根本没有起始码,边界是由「读 4 字节长度、跳这么多字节、
再读 4 字节」这样一条链走出来的。
三、误判是怎么发生的:长度前缀「恰好长得像」起始码
如果探测顺序反了——先试着按 Annex B 扫描、扫不到再退回长度前缀——就会踩到一个很刁钻的巧合:
一个 4 字节长度前缀,表示的是「这个 NAL 有多长」。一段几百字节的 NAL,它的长度字段
高位是 0,于是字节流看起来就是 00 00 01 xx——
正好是 Annex B 的 3 字节起始码后面跟一个字节。
实际字节(长度前缀封装):
00 00 01 2C | <-- 长度字段,表示这个 NAL 有 300 字节
40 01 ... | <-- NAL 头从这里才开始
按 Annex B 扫描器看到的:
00 00 01 | <-- "起始码"
2C 40 01 ... | <-- 于是从 0x2C 这个位置开始读 NAL 头(错的)
于是 NAL 头从错误偏移开始读。更麻烦的是,解码出来的「类型」看起来还挺合理——
比如 H.265 的两字节 NAL 头会读出一个像 23 这样的值(在标准里属于「保留的
随机访问点 VCL 类型」),既不像明显的垃圾,也不会触发越界。
这里最值得记的是:错误看起来是「合法」的,所以没有任何断言会拦下它。 一个显而易见的乱码至少还能被范围检查抓住。
四、为什么症状出现在「参考链」而不是「解析失败」
NAL 头读错之后,最直接受影响的是随机访问点的判定与 slice 头的起点。 于是:
- 从错误的偏移开始解析 slice 头 → 读到的「参考项增减」操作不是真的那几条;
- 随机访问点判定偏掉 → 回溯起点可能落在非随机访问帧上,参考链从根上就错了;
- 而这些错误全都表现为数值层面的偏差,不是异常——所以程序一路跑到底。
五、修法:先信容器声明,再谈兜底
正确的探测顺序只有一种:
-
先从容器读出长度字段的宽度(
lengthSizeMinusOne), 按长度前缀切 NAL 链。这是唯一有依据的路径。 - 只有在容器里拿不到这个信息时,才退回 Annex B 扫描。 它应该是兜底,绝不能是首选。
- 加一条交叉校验:按长度前缀走完一个样本后,消费的字节数必须 恰好等于样本大小。差一个字节就说明切错了——这条断言能在第一时间抓住本次这种 bug。
第 3 条是把「悄悄错」变成「当场错」的关键。它不依赖任何外部真值,只依赖一个必然成立的恒等式。
六、顺带记下的一处规范纠错
排查这条链路时还发现另一处「能跑但不对」的地方:向浏览器描述码流时用的 codec 字符串,
profile 前缀必须与容器里 sample entry 的声明一致——
hvc1 与 hev1 的语义不同(一个要求参数集带外,一个允许带内),
不能拿一个固定模板套过去。
另外,参数集盒子里存的那组兼容性标志,位序与写进字符串时相反,
所以字符串里要写反序后的值(Main profile 的常见组合写作 6)。
这一处和前面不同:旧的写法虽然不合规范,浏览器却照常接受了。 所以它属于「纠规范」而不是「修 bug」——改完功能没有任何可见变化。 这类修改同样值得做,但不要指望它能修复任何症状,否则你会为了找一个「不存在的回归」浪费一天。
七、可迁移的经验
- 「碰巧能跑」不能当作「符合规范」。浏览器或某个解码器宽容地接受了你的输入, 不代表输入是对的——它可能只是恰好没触发那条路径。
- 探测顺序要先信显式声明,再试启发式。容器已经明确告诉你长度字段多宽, 就不该先去猜起始码。启发式永远是兜底。
- 给「 silently 偏掉」的结构加恒等式断言。「消费字节数 == 样本大小」这种 不用外部真值就能验的等式,比事后对拍便宜得多。
- 失败形态分散,指向的是位置错误,不是逻辑错误。如果同一个字段有时多、有时少、 有时截断,先怀疑读数起点,而不是逐个字段去查公式——这一条在这次省了最多时间。
相关:623 个关键帧一个都没认出来 · 一个漏读的位字段 · 用 ffprobe 建运动矢量真值 · 打开分析器
// 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:
| Shape | Behaviour | What 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.
| Framing | How boundaries are found | Typically 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:
-
Read the length-field width from the container (
lengthSizeMinusOne) and split the NAL chain by length prefix. This is the only evidence-backed path. - Only when that information is unavailable, fall back to Annex-B scanning. It should be a fallback, never the first choice.
- 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