// engineering
工程笔记
这个工具在开发过程中真实踩过的坑,以及每个坑最后是怎么被证明「修好了」的。全部是位流、内存与像素层面的第一手记录—— 包括那些一开始以为是 FFmpeg 的 bug、最后发现是自己写错的地方。
一、为什么把这些写出来
视频编码这块的调试有个特点:失败的样子几乎都一样,原因却完全不在一个地方。 块分析返回空、画面整幅变黑、某个文件永远报「GOP 过长」——这些症状可以来自 NAL 类型判错、来自一个右移量、 也可能来自 GB 级文件在 2 GB 堆里装不下。光看症状会一路猜偏。
所以这里的每一篇都按同一个结构写:症状是什么 → 怎么定位到根因 → 改了什么 → 用什么证据证明改对了。 最后一节通常最有用,因为「改完看着好了」和「改完确实对了」是两件事。
二、笔记目录
623 个关键帧,一个都没认出来
容器里明明声明了 623 个 sync sample,代码却一个 IDR 都没识别到,导致整片分析结果为空。
根因是「只看样本的第一个 NAL」这个想当然的假设——x264 的实际输出是
[SEI, SEI, IDR]。附带 H.264 与 H.265 的 NAL 类型表,以及为什么不能直接信
stss。
10bit 素材整幅变黑:一个右移量的差别
8bit 素材导出像素逐字节正确,换成 10bit 素材整幅全黑(全图平均差 ≈ 92,正是「全黑」的特征值)。
根因是把 16bit 容器取 8bit 的右移量一律写成 >>8,而
P010LE 与 yuv420p10le 的位对齐方式根本不同。
GB 级文件怎么塞进 2 GB 的 wasm 堆
1.7 GB 的影片,块分析要装进 WebAssembly 线性内存里跑。做法是只装载覆盖目标 GOP 的字节窗口 ——实测只要 0.46 MB。以及三个真实炸过的地方:收尾时的资源释放会把文件缓冲 整个放掉、堆只增不减、以及「按帧数设安全阀」为什么是错的。
用 ffprobe 建运动矢量真值,别急着啃源码
怀疑自己的运动矢量导出有 bug 时,一条 ffprobe -export_side_data mvs
就能建出原生真值。实测结论:所谓「没有 MV 的帧」是合法结果,我们的实现与原生逐帧一致。
另一条同源技巧:显示序 ≠ 解码序,用 pkt_pos 反查。
校验像素导出,为什么必须比 YUV 平面
拿 RGBA 对拍时,10bit 素材出现单像素差 97 / 255 的「严重 bug」,全图平均差却小于 1.4。 原因是 RGBA 里混进了色度上采样策略这一层。改比 YUV 平面之后:8bit 源 3110400 / 3110400 字节完全相同,差超过 1 的字节 0 个。
浏览器解码器整片拒收,那就自己解
某个 HEVC 码流让 Chrome 的 WebCodecs 解码器全片报错,而其他播放器正常。 与其告诉用户「你的文件不支持」,不如回落到内置的 FFmpeg 软解——代价是每帧约 9 ms, 所以配了进度与取消。
一个漏读的位字段,让后面 32 位全部错位
解析出来的色度采样格式是 655392,而合法域只有 0 到 3。根因是漏读了参数集结构里 的一段保留位,导致后面所有字段整体错位 32 bit。附「为什么自己写的解析器不能当真值」。
269 / 271 帧参考链错位:把长度前缀当成了起始码
MP4 的样本用长度前缀切分 NAL,里面根本没有起始码。探测顺序反了就会从错误偏移读 NAL 头:271 帧里 269 帧的参考列表与真值对不上,而且没有任何报错。
为了读 4 个字节,搬了 92% 的文件
读每帧的 I / P / B 类型,曾经把整个样本读出来:1.7 GB 影片搬了 1506 MB、耗时 6.9 秒。 改成按需放大的字节窗口后降到 0.84% / 460 ms,帧类型逐字节不变。
三、每条结论都带验证方法
这些笔记里的数字不是印象,是能重跑的。四条反复用到的方法学,贯穿了上面所有文章:
| 方法 | 解决的问题 |
|---|---|
| 比平面,不比 RGBA | 把「色彩转换策略差异」从「导出错误」里分离出去,拿到逐字节级别的硬证据 |
| 先用原生工具建真值 | 断言「是本项目的问题」之前,用 ffmpeg / ffprobe 建一份原生输出对拍——一条命令通常就够了 |
| 窗口化 vs 整文件 A/B | 证明「只装一个字节窗口」这种等价变换没有改变结果,而不是假设它没改变 |
| 先量,再推断 | 要判断「这条路径会不会读大文件」,加个字节计数器跑一次;从数据结构推断会得出相反的结论 |
四、已知边界
这些问题里有两个至今没有彻底查清,如实记在这里,免得被当成已经解决:
- Chrome 整片拒收某个 HEVC 码流的根本原因未定位。怀疑落在码流的 profile / tier / level 子层约束位上,但没查到确证。产品内已不再暴露——用户能直接看到画面,只是走的是软解那条路。
- 超过 1 GB 的文件在手机上不可行。这是单进程内存预算的硬限制,不是可以优化的性能问题。 桌面端没事。
五、相关页面
// engineering
Engineering notes
Real problems hit while building this tool, and how each fix was actually proven. Bitstream, memory and pixel-level write-ups — including the cases that looked like an FFmpeg bug and turned out to be ours.
1. Why these are written down
Debugging video coding has a particular shape: failures all look alike, but the causes are nowhere near each other. An empty block analysis, a black frame, a file that always reports "GOP too long" — any of those can come from a misread NAL type, from a single shift amount, or from a GB-scale file that does not fit in a 2 GB heap. Symptoms alone lead you the wrong way.
So every note follows the same structure: symptom → how the cause was isolated → what changed → what evidence proved it. The last part is usually the useful one, because "it looks right now" and "it is right" are different claims.
2. The notes
623 keyframes, zero detected
The container declared 623 sync samples; the code recognised none of them, and every analysis came
back empty. The cause was the assumption that the first NAL in a sample tells you the frame type —
x264's actual output is [SEI, SEI, IDR]. Includes the H.264 and H.265 NAL type tables,
and why stss alone is not enough.
10-bit footage renders black: one shift amount
8-bit sources exported byte-for-byte correct; a 10-bit source came out entirely black (mean
difference ≈ 92, the signature of a flat black frame). The cause: always shifting the 16-bit
container right by 8, when P010LE and yuv420p10le align their bits
differently.
Fitting a GB-scale file into a 2 GB wasm heap
A 1.7 GB film has to be parsed inside WebAssembly linear memory. The approach is to load only the byte window covering the target GOP — measured at 0.46 MB. Plus three things that really did blow up: teardown frees the file buffer, the heap only ever grows, and why a frame-count safety valve is the wrong shape.
Building motion-vector ground truth with ffprobe
When you suspect your MV export is broken, one ffprobe -export_side_data mvs produces
native ground truth. The result: "frames with no motion vectors" is a legitimate outcome, and our
output matched natively, frame for frame. Same family of trick: display order ≠ decode order, so
look it up via pkt_pos.
Why pixel export has to be verified on YUV planes
Comparing RGBA showed per-pixel differences up to 97 of 255 on 10-bit material — a "serious bug" — while the whole-frame mean difference stayed under 1.4. The reason is that RGBA already contains a chroma upsampling policy. Comparing planes instead: 3110400 / 3110400 bytes identical on 8-bit, and zero bytes differing by more than 1 on 10-bit.
When the browser decoder rejects a stream outright
One HEVC stream made Chrome's WebCodecs decoder fail on every frame, while other players handled it fine. Rather than telling the user "unsupported", the tool falls back to its bundled software decoder — at roughly 9 ms per frame, which is why it ships with progress and cancellation.
One skipped bit field shifted the next 32 bits
The parsed chroma format came out as 655392 against a legal range of 0 to 3. One reserved group inside a parameter-set structure had been skipped, shifting every later field by 32 bits. Includes why your own parser can never be its own ground truth.
269 of 271 frames had the wrong reference list
MP4 samples split NAL units by length prefix — there are no start codes at all. Flip the detection order and every NAL header is read from the wrong offset: 269 of 271 frames disagreed with ground truth, with no error raised.
Moving 92% of a file to read four bytes
Reading each frame's I / P / B type once meant reading the whole sample: 1506 MB moved across a 1.7 GB film, in 6.9 seconds. A grow-on-demand byte window took it to 0.84% and 460 ms, with byte-identical frame types.
3. Every claim comes with a way to check it
The numbers in these notes are reproducible, not impressions. Four techniques recur across all of them:
| Technique | What it settles |
|---|---|
| Compare planes, not RGBA | Separates "different colour-conversion policy" from "broken export", yielding byte-level evidence |
| Build ground truth with native tools first | Before asserting "this is our bug", diff against ffmpeg / ffprobe output — usually one command is enough |
| Windowed vs whole-file A/B | Proves an equivalent transform really is equivalent, instead of assuming it |
| Measure, don't infer | To know whether a path reads large files, add a byte counter and run it once — inferring from data structures gives the opposite answer |
4. Known boundaries
Two of these are still not fully understood. Recorded honestly so they are not mistaken for solved:
- The root cause of Chrome rejecting that HEVC stream is still unknown. Suspicion points at the stream's profile / tier / level sub-layer constraint flags, but it was never confirmed. It is no longer exposed in the product — you see the picture, just via the software path.
- Files over 1 GB are infeasible on phones. That is a single-process memory budget, not a performance problem to optimise. Desktop is unaffected.
5. Related pages
- How it works — the overall architecture: three layers and the data flow.
- Guide — the end-to-end workflow, from opening a file to reading QP and MV overlays.
- About — project background, verification methodology and the full list of limitations.
- Open the analyzer — reproduce any of the above with your own files.