// engineering · bitstream
623 个关键帧,一个都没认出来
容器里明确声明了 623 个 sync sample,代码却一个 IDR 都没识别到,导致这部 1.7 GB 影片的块分析结果 全部为空。根因藏在一个很自然的假设里:以为样本的第一个 NAL 单元能代表这一帧的类型。
一、症状:帧表正常,块分析全空
文件打开得很顺:Box 结构树完整,帧列表列出了全部 37 万个样本,参数集也从 avcC
里正常读了出来。但点任意一帧的「块分析」,结果只有两种——要么返回空,要么抛一句「GOP 过长」。
这两句话看起来像同一个问题,其实是两条完全不同的失败路径。先记下来,后面会用上。
当时做的第一件事是把范围缩小:把 stss box 里列出的 sync sample 逐个拿去试。
623 个,无一例外全部失败。这个数字本身就是最重要的线索——偶发的解析 bug 不会
100% 失败,只有当某个判定系统性地恒假时才会。
二、块分析为什么必须先「往前找 IDR」
H.264 与 H.265 的帧间预测意味着 P 帧和 B 帧不能独立解码,它们依赖已经解码过的参考帧。所以想分析第 N 帧, 必须从它之前最近的一个 IDR 帧开始,按顺序把中间每一帧都喂给解码器。
流程因此是:
目标帧
└─ 向前回溯,找到最近的 IDR 帧
└─ 把 [IDR 帧起点, 目标帧终点] 的连续字节喂进解码器
└─ 逐帧解码,直到目标帧
回溯的唯一依据就是帧类型判定。这一步错了,会走向两个截然不同的失败:
| 判定怎么错 | 行为 | 症状 |
|---|---|---|
| 把所有帧都判成非 IDR | 一路往前找到超出安全上限 | 抛「GOP 过长」 |
| 从一个非 IDR 帧起喂 | 解码器拿不到参考帧,一帧都产不出来 | 结果恒为空 |
两条路径、同一个症状,而且都不像是「我们在帧类型判断上写错了」——它们更像「这个文件坏了」。 这就是这个 bug 拖了很久的原因。
三、根因:只看第一个 NAL 单元
原实现是这样的:取出这个样本的字节流,解析出第一个 NAL 单元,如果它的类型是
5(H.264 的 IDR slice),就认定这一帧是 IDR。
这个假设看起来无可挑剔,直到把它打印出来:
样本 #0 NAL 链: 6 (SEI), 6 (SEI), 5 (IDR)
样本 #250 NAL 链: 6 (SEI), 6 (SEI), 5 (IDR)
样本 #500 NAL 链: 6 (SEI), 6 (SEI), 5 (IDR)
x264 的典型输出是每个 IDR 样本前面先放两个 SEI(补充增强信息,类型 6)。于是第一个 NAL 的类型永远是 6,
那个 === 5 的判断一次都不会成立。623 个 IDR,全部漏判。
这个 bug 有个很不友好的性质:它只影响那些「带 SEI 前缀」的编码器输出。换一份别的文件可能完全正常—— 所以它既不是 100% 复现,也不是随机失败,而是按来源文件成片成功或成片失败。这比随机失败更难发现。
四、正确的判定:扫 NAL 链,取第一个 VCL
NAL 单元分两大类:VCL(Video Coding Layer,承载真正的 slice 数据)与 非 VCL(参数集、SEI、访问单元分隔符等)。要判断帧类型,得跳过所有非 VCL, 找到第一个 VCL NAL 再看它的类型。
| 标准 | VCL 范围 | IDR | 常见非 VCL |
|---|---|---|---|
| H.264 | 1 – 5 | 5 | 6 SEI · 7 SPS · 8 PPS · 9 AUD |
| H.265 | 0 – 31 | 19 / 20 | 32 VPS · 33 SPS · 34 PPS · 35 AUD · 39/40 SEI |
遍历本身有两种打法,取决于码流封装方式:Annex B 用 00 00 01 起始码切分;
MP4 里的样本通常是长度前缀,前缀字节数来自 avcC / hvcC
里的 lengthSizeMinusOne(通常是 4 字节)。
修好之后的核心逻辑大约是这样:
扫过样本内的 NAL 链:
逐个读出 NAL 类型 nType
如果 nType 是 VCL: # H.264: 1..5 H.265: 0..31
这一帧的类型 = nType
break # 只取第一个 VCL,后面的 slice 属于同一帧
否则:
继续跳下一个 NAL # SEI / SPS / PPS / AUD 全部跳过
原代码里还有一处错误值得一并记下:判定式写成「类型 <= 9 或落在
16..21 区间就算 IDR」。这个区间对 H.264 和 H.265 两个标准都不成立——
对 H.264 它把 SEI/SPS/PPS/AUD 全划进了 VCL,对 H.265 又正好横跨了 BLA / IDR / CRA 三类不同的随机访问点。
写这种「拍脑袋区间」之前,先去查标准的表。
五、为什么不能直接信容器的 stss
MP4 的 stss box 列出的是「同步样本」,也就是随机访问点。看起来它已经把答案给你了,
但有两个理由不能直接拿它当 IDR 列表:
- 它是容器层面的概念,不是编码层面的。HEVC 里的 CRA(Clean Random Access,NAL 类型 16) 也是一个合法的随机访问点,但它不是 IDR——从 CRA 之后解码出来的画面,可能还会引用 CRA 之前的内容。 对「从哪一帧开始喂」这个问题,CRA 与 IDR 不等价。
- 它只给样本序号,不给字节偏移的语义。回溯要的是「从哪个字节开始喂」,这需要把样本序号 换算成文件偏移,而这一步同样依赖采样表。
所以这次的做法是:stss 用来做交叉验证——如果位流判定出的 IDR 集合与
stss 声明的同步样本集合完全不相交,那一定是判定代码有问题,直接报错而不是静默降级。
六、怎么证明修对了
这次用了三条互相独立的判据,避免「改完看着好了」:
-
覆盖性:
stss里的 623 个 sync sample,逐个跑块分析,全部返回非空结果。 修复前是 0 / 623,修复后是 623 / 623。 -
与原生真值对齐:用
ffprobe -show_entries frame=key_frame建一份原生的 关键帧清单,逐帧比对「我们回溯到的起点」。不允许出现「我们的起点比原生 IDR 更晚」——那意味着丢帧。 -
数值一致性:在一份 1634 MB 的素材上,修复前后的运动矢量与 QP 统计量都要与
ffprobe -export_side_data mvs的原生输出一致。这一条同时覆盖了「没搞坏别的功能」。
顺带记一个副产品数字:修好之后,1.7 GB 影片上每个 GOP 的窗口平均只有 0.46 MB。 回溯不再撞上限,装载量还是常数级——这一点在 另一篇笔记里展开。
七、可迁移的经验
- 「读第一个 X 就下结论」几乎总是错的。位流里允许任意数量、任意种类的非 VCL NAL 出现在 VCL 之前,数量和顺序都不受你控制。凡是「第一个」「最前面」这类假设,都要先打印真实数据验证。
- 100% 的失败率是礼物。它把排查范围从「整个解码链路」直接缩到「某个恒假的判断」。 反过来,当失败率不是 100% 时,第一件事是去看没失败的那部分样本有什么不同。
-
判定函数必须有独立真值。这次的真值是
ffprobe的关键帧标记。 用同一份代码既做判定又做验证,等于什么都没验证。
相关:用 ffprobe 建运动矢量真值 · 技术原理 · 打开分析器
// engineering · bitstream
623 keyframes, zero detected
The container declared 623 sync samples. The code recognised none of them, and block analysis on a 1.7 GB film came back empty every single time. The cause lived inside a very natural assumption: that the first NAL unit in a sample tells you what the frame is.
1. Symptom: a healthy frame list, empty analyses
The file opened fine. The box tree was complete, the frame list enumerated all 379,000 samples, and
the parameter sets came out of avcC without complaint. But clicking block analysis
on any frame produced one of two outcomes: an empty result, or the error "GOP too long".
Those look like the same problem. They are two different failure paths — worth remembering.
The first move was to narrow the scope: take every sample listed in the stss box and try
them one by one. All 623 failed. That number was the most valuable clue available,
because an intermittent parsing bug does not fail 100% of the time. Only a condition that is
systematically false does.
2. Why block analysis has to walk back to an IDR
Inter prediction in H.264 and H.265 means P and B frames cannot be decoded independently — they depend on reference frames already decoded. To analyse frame N you must start from the nearest preceding IDR and feed every frame in between, in order.
target frame
└─ walk backwards to the nearest IDR
└─ feed [start of IDR, end of target frame] as one byte range
└─ decode frame by frame until the target
The walk-back depends entirely on frame type detection. Get that wrong and you land on one of two very different failures:
| How detection fails | Behaviour | Symptom |
|---|---|---|
| Every frame judged non-IDR | Walks back until it exceeds the safety bound | "GOP too long" |
| Starts feeding from a non-IDR | The decoder never gets its reference, emits nothing | Always-empty result |
Two paths, one symptom — and neither of them looks like "we misread the NAL type". They look like "this file is broken". Which is why this bug survived so long.
3. Root cause: reading only the first NAL unit
The original implementation read the sample's bytes, parsed the first NAL unit, and declared the frame
an IDR if its type was 5 (H.264 IDR slice).
The assumption looks unassailable until you print the actual data:
sample #0 NAL chain: 6 (SEI), 6 (SEI), 5 (IDR)
sample #250 NAL chain: 6 (SEI), 6 (SEI), 5 (IDR)
sample #500 NAL chain: 6 (SEI), 6 (SEI), 5 (IDR)
x264 prefixes each IDR sample with two SEI messages (type 6, supplemental enhancement information).
The first NAL type therefore never needs to be 5, and the comparison
never fires once. All 623 IDRs, missed.
This bug has an unhelpful property: it only affects encoder output that carries a SEI prefix. A different file might work perfectly. So it is neither reliably reproducible nor random — it succeeds or fails wholesale, per source file. That is worse than intermittent.
4. The correct rule: scan the chain, take the first VCL
NAL units fall into two groups: VCL (Video Coding Layer — the actual slice data) and non-VCL (parameter sets, SEI, access unit delimiters, and so on). To classify a frame, skip every non-VCL and look at the first VCL unit.
| Standard | VCL range | IDR | Common non-VCL |
|---|---|---|---|
| H.264 | 1 – 5 | 5 | 6 SEI · 7 SPS · 8 PPS · 9 AUD |
| H.265 | 0 – 31 | 19 / 20 | 32 VPS · 33 SPS · 34 PPS · 35 AUD · 39/40 SEI |
Walking the chain itself has two variants, depending on how the bitstream is packaged:
Annex B splits on 00 00 01 start codes, while samples inside MP4 use
length prefixes whose byte width comes from lengthSizeMinusOne in
avcC / hvcC (usually 4 bytes).
scan the NAL chain inside the sample:
read the next NAL type nType
if nType is VCL: # H.264: 1..5 H.265: 0..31
frame type = nType
break # first VCL only; later slices belong to the same frame
else:
advance to the next NAL # skip SEI / SPS / PPS / AUD
One more mistake worth recording: the original predicate was "type <= 9, or anywhere
in 16..21, means IDR". That range is wrong for both standards — for
H.264 it sweeps SEI/SPS/PPS/AUD into the VCL set, and for H.265 it straddles BLA, IDR and CRA,
three distinct kinds of random access point. If you are about to write a magic range, open the
standard's table first.
5. Why the container's stss is not enough on its own
MP4's stss box lists sync samples — random access points. It looks like the answer is
handed to you. Two reasons it is not:
- It is a container concept, not a coding one. In HEVC, CRA (Clean Random Access, NAL type 16) is also a legitimate random access point, but it is not an IDR — pictures decoded after a CRA may still reference content from before it. For the question "where can I start feeding", CRA and IDR are not interchangeable.
- It gives sample numbers, not byte semantics. Walking back needs a file offset to start feeding from, and converting sample index to offset also depends on the sample tables.
So stss was used for cross-validation: if the IDR set derived from the
bitstream shares nothing with the declared sync samples, the detection code is broken — fail loudly
rather than degrade quietly.
6. How the fix was proven
-
Coverage. All 623 sync samples from
stssproduce a non-empty block analysis. Before: 0 / 623. After: 623 / 623. -
Alignment with native truth. Build a native keyframe list with
ffprobe -show_entries frame=key_frameand compare per frame against the start point we walked back to. Our start point must never be later than the native IDR — that would mean dropped frames. -
Numeric agreement. On a 1634 MB clip, motion-vector and QP statistics before and
after the change both agree with native
ffprobe -export_side_data mvsoutput. This also covers "we did not break anything else".
One useful side effect: after the fix, the average GOP window on that 1.7 GB film is 0.46 MB. Walking back no longer hits a bound, and the load stays constant-size — covered in the large-file note.
7. Transferable lessons
- "Read the first X and conclude" is nearly always wrong. A bitstream may carry any number of any kind of non-VCL NAL before the VCL one, in no order you control. Print the real data before trusting any "first" or "at the front" assumption.
- A 100% failure rate is a gift. It collapses the search space from "the whole decode path" to "one condition that is always false". Conversely, when the failure rate is not 100%, the first question is what the passing samples have in common.
-
A predicate needs an independent source of truth. Here it was
ffprobe's keyframe flag. Validating a predicate with the same code that produced it validates nothing.
Related: motion-vector ground truth with ffprobe · How it works · Open the analyzer