// engineering · pixels
10bit 素材整幅变黑:差在一个右移量
同一段导出代码,8bit 素材逐字节正确,10bit 素材整幅全黑。根因是把「16bit 容器取 8bit」的右移量
一律写成了 8,而 P010LE 与 yuv420p10le 把 10bit 值放在 16bit 字里的
不同位置。
一、症状:一幅全黑的画面
当时在给帧预览加一条通路:浏览器解码器不可用时,让内置的 FFmpeg wasm 自己把目标帧解出来当预览图。 验证方式是导出像素,与 ffmpeg 的命令行输出对拍。
结果很干净,也很反常:
| 测试素材 | 像素格式 | 结果 |
|---|---|---|
| 8bit 源 | yuv420p | 逐字节完全相同 |
| 10bit 源 | yuv420p10le | 整幅全黑(全图平均差 ≈ 92 / 255) |
这里有个关键观察:平均差约 92、满量程 255,这个量级说明画面不是「差一点」,而是塌成了 一片常量。但同时又有个反直觉的细节——轮廓、运动、字形其实都在,只是所有亮度都被压到了 最底部。画面结构对、数值映射错,这直接把嫌疑从「解码错了」排除出去,指向了导出时的数值换算。
二、10bit 像素在内存里长什么样
没有原生的 10bit 字节类型,所以 10bit 样本统一装在 16bit 容器里(每个样本 2 字节,小端)。 但「10bit 值放在这 16 位里的哪一段」,由像素格式决定,存在两种约定:
| 约定 | 含义 | 典型格式 |
|---|---|---|
| 左对齐 | 10bit 值占高 10 位(15..6),低位是填充;对应 shift = 6 |
P010LE · P016LE |
| 右对齐 | 10bit 值就占低 10 位(9..0),直接可用;对应 shift = 0 |
yuv420p10le · yuv422p10le · yuv444p10le |
这两种约定在代码里长得一模一样——都是 uint16_t。它们的区别只存在于
格式元数据里,肉眼看字节流是看不出来的。
三、根因:右移量被写死成 8
原代码把 16bit 取 8bit 一律写成 v >> 8。这个写法对其中一种约定是对的:
- P010LE:值占 15..6 位,右移 8 位取到的正好是最高 8 位 → 正确。
- yuv420p10le:值占 9..0 位,右移 8 位只留下最高 2 位 → 绝大部分样本塌成 0。
把数字代进去就很直观了:
10bit 黑电平 64 >> 8 → 0
10bit 中间亮度 512 >> 8 → 2
10bit 白电平 1023 >> 8 → 3
整幅画面的亮度被压进 0–3 这 4 个值里,视觉上就是全黑。这就是那个「平均差 92」的来源。
这个 bug 最不友好的地方在于:它在 8bit 素材上完全不可见。因为 8bit 格式的
正确右移量本来就是 0,而 >> 8 只对 P010LE 这类左对齐格式刚好凑巧正确。
一份 10bit 测试素材是整个检查的全部成本。
四、正确的写法:从像素格式描述符里算
答案不用自己推:FFmpeg 的像素格式描述符里,已经逐个平面给出了所需的两项信息。
- depth — 该平面的有效位数(8 / 10 / 12 / 16)
- shift — 该平面的值在容器里被左移了多少位
于是通用公式是:
rshift = shift + depth - 8; // 取 8bit 要右移多少
if (rshift < 0) rshift = 0; // 8bit 格式时为 0
逐格式验算一遍,正好覆盖真正的所有情况:
| 像素格式 | depth | shift | rshift |
|---|---|---|---|
| yuv420p | 8 | 0 | 0 |
| yuv420p10le | 10 | 0 | 2 |
| P010LE | 10 | 6 | 8 |
| yuv420p12le | 12 | 0 | 4 |
| P016LE | 16 | 0 | 8 |
同一段代码还顺带解决了「要不要为每种格式写一个分支」的问题。因为循环是按平面和偏移走的,
不是按格式名 switch 的:
- 这个分量落在第几块内存里;
- 同一平面内相邻样本的字节跨度(8bit 是 1,16bit 是 2);
- 平面内的起始偏移——NV12 的 UV 交错就是靠它和「跨度 = 2」表达出来的。
结果是 yuv420p / yuv422p / yuv444p / nv12 /
p010le / yuv420p10le 共用同一段打包代码,判断条件是
「这个格式是不是平面格式」,而不是格式名字。
还有两处容易被忽略:一是大端变体(如 yuv420p10be)字节序相反,
读 16bit 时得按描述符里的大端标志决定拼装顺序;二是这条通路只覆盖
平面与半平面 YUV,遇到 RGB 这类打包格式要另走一路。
五、怎么证明修对了
判据的选择比修改本身更关键。这里不能比 RGBA——RGBA 里已经混进了色度上采样策略这一层, 会让「数值映射错误」和「上采样策略差异」混在一起(这一点值得单独一篇,见 校验像素导出,为什么必须比 YUV 平面)。
正确的做法是直接比平面。用 ffmpeg 生成参考数据:
ffmpeg -i input.mp4 \
-vf "select='eq(n\,<显示序>)'" -vsync 0 \
-pix_fmt yuv420p -f rawvideo ref.yuv
然后与导出的 Y | U | V 三段逐字节比较:
| 测试素材 | 判据 | 结果 |
|---|---|---|
| 8bit 源 | 逐字节相同 | 3110400 / 3110400 字节完全相同 |
| 10bit 源 | 按新公式右移后,差 > 1 的字节数 | 0 个 |
10bit 那一条之所以允许「差 1」:源本身是 10bit,ffmpeg 输出 8bit 参考值时会有一次舍入, 我们的路径也有一次——两次舍入方向不同就会差 1。这不构成错误。
最后补一件事:把分片喂帧也复刻进来跑一遍。同一份数据,分片大小取 1 / 3 / 8 / 1000, 结果必须完全一致。如果改分片大小会改变像素,那才是真 bug。
六、可迁移的经验
- 「看起来一样的 uint16」不代表语义一样。位对齐方式、字节序、有效位数都是格式的属性。 任何一个写死,都会在某类素材上炸,而这类素材往往不是你手边那一份。
- 症状的量级能提示根因类型。「全图平均差 ≈ 92」是整幅塌成常量的特征;「全图平均差 1.4 但个别像素差 97」是策略差异的特征。这两个数字分得清,定位就能一步到位。
- 支持新像素格式时,8bit 与 10bit 必须各测一遍。这条检查成本极低,漏掉的代价极高—— 它一次就够,而且一旦建立了清单,后面每次改数据流都能复用。
// engineering · pixels
10-bit footage renders black: one shift amount
The same export code was byte-for-byte correct on 8-bit sources and produced a fully black frame on a
10-bit source. The cause: a hardcoded right shift of 8 for converting 16-bit samples to 8-bit, when
P010LE and yuv420p10le place their 10-bit values at
different positions inside the 16-bit word.
1. Symptom: a completely black frame
This happened while adding a path that lets the bundled FFmpeg wasm decode the target frame itself, for use as a preview when the browser's decoder is unavailable. The verification method was to export pixels and diff them against ffmpeg's command-line output.
The result was clean, and strange:
| Test clip | Pixel format | Result |
|---|---|---|
| 8-bit source | yuv420p | Byte-for-byte identical |
| 10-bit source | yuv420p10le | Entirely black (mean difference ≈ 92 of 255) |
The magnitude is the clue: a mean difference of about 92 on a 255 scale means the picture is not "slightly off" — it has collapsed into a constant. Yet there was a counter-intuitive detail: outlines, motion and glyph shapes were all still there, just crushed to the bottom of the range. Structure correct, values wrong — which rules out "the decode is broken" and points squarely at the numeric conversion during export.
2. What 10-bit pixels actually look like in memory
There is no native 10-bit byte type, so 10-bit samples live in 16-bit containers (two bytes per sample, little-endian). But which part of those 16 bits carries the 10-bit value is decided by the pixel format, and there are two conventions:
| Convention | Meaning | Typical formats |
|---|---|---|
| MSB-aligned | 10-bit value occupies the high bits (15..6); low bits are padding — shift = 6 |
P010LE · P016LE |
| LSB-aligned | 10-bit value sits in the low bits (9..0), usable as-is — shift = 0 |
yuv420p10le · yuv422p10le · yuv444p10le |
In code the two are indistinguishable — both are uint16_t. The
difference exists only in format metadata; you cannot see it by eyeballing the byte stream.
3. Root cause: the shift amount was hardcoded to 8
The original code converted 16-bit to 8-bit with a blanket v >> 8. For one convention that is exactly right:
- P010LE: the value occupies bits 15..6, so shifting right by 8 yields precisely the top 8 bits → correct.
- yuv420p10le: the value occupies bits 9..0, so shifting right by 8 keeps only the top 2 bits → nearly every sample becomes 0.
10-bit black level 64 >> 8 → 0
10-bit mid-grey 512 >> 8 → 2
10-bit white 1023 >> 8 → 3
The whole picture's luminance is squeezed into four values, 0 through 3. That is what a black frame looks like — and where the "mean difference ≈ 92" comes from.
The unkind part: this bug is invisible on 8-bit material. The correct shift for
8-bit formats is 0 anyway, and >> 8 happens to be right for MSB-aligned formats
like P010LE. One 10-bit test clip is the entire cost of catching it.
4. The correct approach: derive it from the pixel format descriptor
You do not have to work it out yourself: FFmpeg's pixel format descriptor already gives you, per plane, the two values you need.
- depth — effective bits for that component (8 / 10 / 12 / 16)
- shift — how far the value was left-shifted inside the container
rshift = shift + depth - 8; // how far right to reach 8 bits
if (rshift < 0) rshift = 0; // 0 for 8-bit formats
Checking it against every format covers the real cases exactly:
| Pixel format | depth | shift | rshift |
|---|---|---|---|
| yuv420p | 8 | 0 | 0 |
| yuv420p10le | 10 | 0 | 2 |
| P010LE | 10 | 6 | 8 |
| yuv420p12le | 12 | 0 | 4 |
| P016LE | 16 | 0 | 8 |
The same code also removes the need for a per-format branch, because the loop walks planes and offsets rather than switching on a format name:
- which memory block this component lives in;
- byte stride between neighbours in the same plane (1 for 8-bit, 2 for 16-bit);
- start offset within the plane; NV12's interleaved UV is expressed with this plus a stride of 2.
The result: yuv420p, yuv422p, yuv444p, nv12,
p010le and yuv420p10le share one packing routine, gated on
whether the format is planar rather than on format names.
Two details that are easy to miss: big-endian variants (such as
yuv420p10be) reverse the byte order, so 16-bit reads must consult the
descriptor's big-endian flag; and this path only covers
planar and semi-planar YUV — packed RGB formats need their own branch.
5. How the fix was proven
Choosing the right evidence mattered more than the fix itself. Here you must not compare RGBA — RGBA already contains a chroma upsampling policy, which blends "wrong numeric mapping" together with "different upsampling strategy". That deserves its own note: why pixel export has to be verified on YUV planes.
Compare planes directly. Generate the reference with ffmpeg:
ffmpeg -i input.mp4 \
-vf "select='eq(n\,<display index>)'" -vsync 0 \
-pix_fmt yuv420p -f rawvideo ref.yuv
| Test clip | Criterion | Result |
|---|---|---|
| 8-bit source | Byte-for-byte identical | 3110400 / 3110400 bytes identical |
| 10-bit source | Bytes differing by more than 1 after the new shift | Zero |
The 10-bit case tolerates a difference of 1 because the source is 10-bit: ffmpeg rounds once when producing the 8-bit reference, and our path rounds once too. Two roundings in opposite directions differ by one. That is not an error.
One more step: replay the chunked feed as well. Same data, chunk sizes 1 / 3 / 8 / 1000 — the output must be identical every time. If changing the chunk size changes the pixels, that would be a real bug.
6. Transferable lessons
- "The same-looking uint16" is not the same semantics. Bit alignment, byte order and effective depth are all properties of the format. Hardcoding any of them breaks on some class of material — usually not the file you happen to have open.
- The magnitude of the symptom hints at the class of cause. "Mean difference ≈ 92" says the frame collapsed to a constant; "mean difference 1.4 but individual pixels differ by 97" says a policy difference. Tell those two apart and you can locate the cause in one step.
- When adding a pixel format, test both 8-bit and 10-bit. This check is nearly free and skipping it is expensive. Once the checklist exists, it pays off on every future change to the pixel path.
Related: verifying pixel export on YUV planes · when the browser decoder rejects a stream · Open the analyzer