// engineering · fallback
浏览器解码器整片拒收,那就自己解
一份 HEVC 影片让 Chrome 的内置解码器从头到尾一帧都出不来,而它在别的播放器里完全正常。 与其告诉用户「你的文件不支持」,不如让工具里本来就带着的 FFmpeg 自己解—— 代价是每帧约 9 ms,所以配了进度与取消。
一、症状:只有这一份文件解不出画面
反馈来自一位用户,症状很干脆:进入帧分析时报 EncodingError: Decoding error.
但其它部分完全正常:
- Box 结构树完整可展开;
- 帧列表列出了全部样本,帧类型、大小、偏移都对;
- 二进制视图能看到每帧的原始字节;
- 换成别的文件,帧预览正常工作;
- 同一份文件在别的播放器里播放正常。
这几条加起来,范围已经收得很紧了:问题在「这份码流」与「浏览器的解码器」之间, 与容器解析、帧表、以及我们的其它通路都无关。
二、定位:不是我们的代码
帧预览走的是 WebCodecs 的 VideoDecoder:配置之后逐帧送进去解码。
这次的失败形态是整片拒收——不是某一帧失败,而是配置完就从第一帧开始一直失败,
一帧都产不出来。
同一份文件用 ffmpeg 命令行解码完全正常。也就是说位流本身是合法的、可解的,是 Chrome 的解码器 不接受这份流的某个参数。
怀疑点落在码流的 profile / tier / level(PTL)与子层约束位上—— 但这一点没有查到确证。如实记在这里:这是一个未定位的根因,不是一个已解决的问题。 下面做的是绕过它,不是修好它。
三、选择:与其说「不支持」,不如自己解
站在用户角度,那个报错的含义是「你的文件不能用」。但文件其实是好的——问题只在浏览器的解码器上。
同时有一个现成的条件:这个项目本来就带了一个内置 FFmpeg 的 wasm 模块(块分析用的就是它)。 它有完整解码能力,只是以前没有把「解出来的像素」这条路打开——它只把 MV / QP 导出成 JSON。
于是决定给它加一条像素导出通路,在 WebCodecs 失败时回落过去。设计目标只有一句话: 任何 FFmpeg 能解的,都能出画面。
四、实现:C 侧的像素导出
块分析的流程是:从目标帧向前回溯到 IDR,把这段字节喂给解码器,逐帧解码。 原来的实现在命中目标帧时做一件事——把这一帧的 MV / QP 序列化成 JSON 返回给 JS。
现在新增了第二件事:把这一帧的像素导出。两条路径互斥:
| 模式 | 命中目标帧时做什么 | 用途 |
|---|---|---|
| 块分析 | 序列化 MV / QP 为 JSON | 量化参数热力图与运动矢量箭头 |
| 帧预览 | 打包像素平面 | 画面本身;JSON 对预览没有用 |
「命中目标帧」的判定两条路径完全一致,所以互斥是安全的。之所以要互斥,是因为那个 JSON 的序列化 开销不小,而预览完全用不到它。
像素打包由像素格式描述符驱动:三个平面各自去掉行 stride,按
Y | U | V 拼成一段连续内存。因为走的是描述符而不是按格式名 switch,
yuv420p / yuv422p / yuv444p / nv12 /
p010le / yuv420p10le 共用同一段打包代码。
两个容易踩的点。一是 16bit 容器取 8bit 的右移量必须按
shift + depth - 8 算,不能写死成 8——这个坑单独有一篇
(10bit 素材整幅变黑)。
二是「开启像素捕获」这个开关必须在开始任务之后设置,因为开始的步骤会清零它;
顺序反了会静默地什么都不捕获。
五、JS 侧:为什么色彩转换写在 JS 里
这一侧的 wasm 在构建时就没有编入色彩转换库,也就是说里面没有色彩转换器。 所以 YUV → RGBA 这一步放在 JS:
- 缩放用 box-average——把源块内的样本取平均。缩到预览尺寸时,这比最近邻明显干净, 尤其是缩小比例较大时;
- 色彩矩阵按容器的 colour space 选(BT.601 / 709 / 2020),并按 range 区分 limited 与 full。选错矩阵的典型症状是整体偏色,而画面结构完全正确。
这也顺带说明了一条边界:这条通路只处理平面与半平面的 YUV。其它格式需要另写转换。
六、为什么分片喂帧
这里是整件事里最容易被误解的一点:分片不是为了省内存。
C 侧每解码一帧之前会复用同一个帧对象,所以内存不随 喂入的帧数累积。一次喂完整条链和分十次喂,峰值内存几乎一样。
分片的真实目的是:让 worker 能在每片之间处理进度上报与取消消息。 如果一次把整条链喂完,worker 会被 wasm 占死最坏几秒——进度条不动、取消按钮点了没反应, UI 看起来像崩了。
于是协议拆成四步:
| 步骤 | 作用 |
|---|---|
| ① 准备 | 回溯、装载窗口,返回总帧数 |
| ② 喂帧 | 喂 N 帧,返回已喂帧数与总数 |
| ③ 收尾 | 补喂剩余帧,导出像素,返回 RGBA 数据 |
| ④ 取消 | 放弃任务并释放资源 |
配套的是令牌机制:每个任务一个 token,取消之后旧 token 的后续请求一律失效。 这是为了避免「用户切了帧、取消了旧任务,但旧任务还在后台跑」这类竞态——旧任务在新画面出来之后 才完成,把新画面覆盖掉。
七、代价:慢,但它是兜底
| 指标 | 值 |
|---|---|
| 单帧软解耗时 | 约 9.0 ms |
| 最坏情况(目标帧距 IDR 250 帧) | 约 2.2 s |
| WebCodecs 走硬件解码 | 快得多——这是快路径,不是被替代的对象 |
| 字节量安全阀 | 256 MB |
所以这条通路的存在意义是「让『不支持』不是死路」,而不是替代快路径。具体行为:
- 先尝试 WebCodecs;只有失败或不可用时才回落;
- 按轨道记住「这条轨道解不了」,同一轨道的后续帧不再白等一遍注定失败的尝试;
- 解析出的窗口超过 256 MB 时退化成原本的错误提示,而不是把内存吃光。
八、可迁移的经验
- 平台能力的失败要分类。「我们的代码错了」和「平台不接受这个输入」需要不同的响应: 前者要修,后者要兜底。把两者混在一起,会让人花大量时间去修一个修不了的东西。
- 兜底必须配进度与取消。用户能接受「慢 2 秒」,不能接受「界面卡住 2 秒而且不知道 在干什么」。分片是为了这个,不是为了内存。
- 兜底路径不能和主路径共享假设。如果兜底也依赖同一个解码器,它就什么也兜不住。 这条通路之所以有效,恰恰因为它用的是完全不同的实现(FFmpeg 软解 vs 平台硬解)。
- 把没查清的根因如实写下来。「怀疑在 PTL 约束位」不是结论。写清楚它是一个未定位的 根因,比含糊地说「已解决」有用得多——下一个人(包括未来的自己)才知道该从哪里继续。
相关:校验像素导出,为什么必须比 YUV 平面 · 10bit 素材整幅变黑 · 使用指南 · 打开分析器
// engineering · fallback
When the browser decoder rejects a stream outright
One HEVC film made Chrome's built-in decoder produce nothing at all, from the first frame to the last, while the same file played fine in other players. Rather than telling the user "unsupported", the tool hands the job to the FFmpeg it already ships — at roughly 9 ms per frame, which is why it comes with progress and cancellation.
1. Symptom: exactly one file with no picture
A user reported it plainly: entering frame analysis raised
EncodingError: Decoding error.
Everything else was fine:
- the box tree was complete and expandable;
- the frame list enumerated every sample with correct type, size and offset;
- the binary view showed each frame's raw bytes;
- other files decoded for preview without issue;
- the same file played normally in other players.
Together, those narrow the scope sharply: the problem sits between this bitstream and the browser's decoder. Container parsing, the frame tables and our other paths are all uninvolved.
2. Diagnosis: not our code
Frame preview uses WebCodecs VideoDecoder: configure, then feed frames one at a time. This
failure was wholesale rejection — not one bad frame, but every frame failing from the
start, producing nothing.
The same file decoded perfectly from the ffmpeg command line. So the bitstream is valid and decodable; Chrome's decoder simply will not accept one of its parameters.
Suspicion points at the stream's profile / tier / level (PTL) and sub-layer constraint bits — but that was never confirmed. Recorded honestly: this is an unlocated root cause, not a solved problem. What follows routes around it; it does not fix it.
3. The choice: decode it ourselves rather than call it unsupported
From the user's side, that error message means "your file cannot be used". But the file was fine — the problem was purely with the browser's decoder.
And one thing was already in place: the project ships a wasm module built from FFmpeg (block analysis runs on it). It has full decoding capability; it simply never exposed a path for "the decoded pixels", only MV / QP as JSON.
So we added a pixel-export path and fall back to it when WebCodecs fails. The design goal fits in one sentence: anything FFmpeg can decode can be shown.
4. Implementation: pixel export on the C side
Block analysis walks back from the target frame to an IDR, feeds that byte range to the decoder and decodes frame by frame. Originally, on hitting the target frame it did exactly one thing: serialise that frame's MV / QP into JSON for the JavaScript side.
Now there is a second option, and the two are mutually exclusive:
| Mode | On hitting the target frame | Purpose |
|---|---|---|
| Block analysis | Serialise MV / QP to JSON | QP heatmap and motion-vector arrows |
| Frame preview | Pack the pixel planes | The picture itself; the JSON is useless here |
The "is this the target frame" test is identical in both, so making them exclusive is safe. The reason to make them exclusive is that JSON serialisation is not cheap and preview never needs it.
Packing is driven by the pixel format descriptor: strip the row stride from each of the three
planes and lay them out as one contiguous Y | U | V block. Because the loop follows the
descriptor rather than switching on format names, yuv420p, yuv422p,
yuv444p, nv12, p010le and yuv420p10le share a
single packing routine.
Two easy traps. First, converting a 16-bit container to 8-bit requires a shift of
shift + depth - 8, never a hardcoded 8 — that trap has
its own note. Second, the "capture pixels" switch
must be enabled after the job begins, because the begin step clears it. Get the
order wrong and nothing is captured, silently.
5. The JavaScript side: why colour conversion lives there
This wasm is built without a colour conversion library — meaning there is no colour converter inside it. So YUV → RGBA happens in JavaScript:
- Downscaling uses box averaging — averaging the samples inside each source block. At preview sizes this is visibly cleaner than nearest-neighbour, especially at larger reduction ratios;
- the colour matrix comes from the container's colour space (BT.601 / 709 / 2020), with limited versus full range handled separately. Choosing the wrong matrix shows up as a global colour cast with perfectly correct structure.
This also states a boundary: the path handles planar and semi-planar YUV only. Other formats need their own conversion.
6. Why the feed is chunked
The most misread part of this whole thing: chunking is not about memory.
Before decoding each frame, the C side reuses a single frame object, so memory does not accumulate with the number of frames fed. Feeding the whole chain at once and feeding it in ten pieces have nearly identical peak memory.
Chunking exists so the worker can handle progress reporting and cancellation messages between chunks. Feeding the entire chain in one call blocks the worker for up to several seconds — the progress bar never moves and the cancel button does nothing, so the UI appears frozen.
| Step | Purpose |
|---|---|
| 1. Set up | Walk back, load the window; returns the total frame count |
| 2. Feed | Feed N frames; returns frames fed and total |
| 3. Finish | Feed the remainder, export pixels, return RGBA data |
| 4. Cancel | Abandon the job and release resources |
Alongside it is a token mechanism: one token per job, and once cancelled, later requests carrying the old token are rejected. This guards against a race where the user switches frames and cancels, but the old job keeps running — finishing after the new picture arrives and overwriting it.
7. The cost: slow, but it is a fallback
| Metric | Value |
|---|---|
| Software decode per frame | ≈ 9.0 ms |
| Worst case (target 250 frames past an IDR) | ≈ 2.2 s |
| WebCodecs hardware decode | Far faster — that is the fast path, not the one being replaced |
| Byte-level safety valve | 256 MB |
- WebCodecs is attempted first; the fallback runs only on failure or unavailability;
- the tool remembers, per track, that a track cannot be decoded this way, so later frames skip a doomed attempt instead of waiting through it;
- if the resolved window exceeds 256 MB it degrades back to the original error message rather than consuming all the memory.
8. Transferable lessons
- Classify platform failures. "Our code is wrong" and "the platform will not accept this input" demand different responses: fix versus fall back. Conflating them leads to long hours spent trying to fix something unfixable.
- A fallback needs progress and cancellation. Users tolerate "slow by two seconds"; they do not tolerate "frozen for two seconds with no indication of why". Chunking serves that, not memory.
- A fallback must not share assumptions with the primary path. If the fallback depends on the same decoder, it cannot fall back from anything. This path works precisely because it uses a completely different implementation: FFmpeg software decode versus platform hardware decode.
- Write down root causes you have not located. "Suspected PTL constraint bits" is not a conclusion. Stating clearly that it remains unlocated is far more useful than vaguely implying it is fixed — the next person, including future you, needs to know where to pick up.
Related: verifying pixel export on YUV planes · 10-bit footage renders black · Guide · Open the analyzer