上一篇搭好了 Web 串流链路,但 MSE(fMP4)路径上画面有两个诡异的毛病:右下角一块 3×2 CTU(编码树单元,HEVC 把画面切成方块逐块编码,本配置下每块 32×32 像素)大小的纯黑块,以及**「只播关键帧」**——P 帧硬解全部失败,画面一秒才动一下(GOP=30)。软解(ffmpeg、PotPlayer)一切正常,硬解(NVDEC/DXVA/系统扩展)全军覆没。这是本系列最曲折的一次调试,真凶小到只有 3 个字节。

先把破案路线一图剧透:

flowchart TD
    S["症状:右下角黑块 + 只播关键帧<br/>软解正常,硬解全废"] --> A["假设①:编码器帧末 CTU 缺陷<br/>叠多 slice + WPP 规避<br/>→ 黑块变小但不消失"]
    A --> B["假设②:路径对比<br/>annexb 直发完好,仅 fMP4 中招"]
    B --> C["锁定封装层 split_annexb"]
    C --> D["真凶:末 NAL 被截 3 字节<br/>CABAC 尾部残缺"]

弯路:先给编码器叠了一堆规避参数

黑块的位置固定在右下角——「帧尾」两个字让嫌疑第一时间指向编码器固件:wave5 处理帧末 CTU 有缺陷?按这个假设,我们在编码参数上叠了两层规避(至今保留在 cam_hevc.c 里):

  1. 多 sliceindependSliceMode=1,每 slice(一帧内可独立解码的条带)128 CTU(1080p 每行 30 CTB,约 4 行)。单一大 slice(~100KB IDR)的尾部在部分 DXVA 硬解路径解码失败,右下角最后 1/16 区域输出纯黑块;多 slice 每片独立结束可规避。
  2. CTB 32×32 + WPPcuSizeMode=4(32×32)加波前并行,改变 CTU 网格/波前结构,试图绕开帧末 CTU 的假想缺陷。

这些改动让黑块变小了(从 1/16 屏缩到 3×2 CTU),但始终没消失。与此同时另一组症状越来越明显:fMP4 路径的 P 帧全废,只有 IDR 能出画面。

转机:只有 fMP4 路径中招

真正的突破口是路径对比。同一份编码输出:

  • annexb 模式(WebCodecs hev1,AnnexB 原样直发)——画面完好;
  • fmp4 模式(MSE)——黑块 + 只播关键帧。

两条路径的码流来自同一个编码器,唯一差别在封装层:fMP4 要把 AnnexB 拆成 NAL、换成 4 字节长前缀。嫌疑锁定到 split_annexb

根因:一个边界条件,3 个字节

旧实现的思路是「边扫描边切」:内层循环找下一个 start code,找到就切出当前 NAL。问题出在内层扫描条件 j + 3 < len——当当前 NAL 是码流里最后一个、后面再没有 start code 时,扫描停在 len-3 就退出了,于是切分点落在倒数第 3 字节:

每一帧的最后一个 slice 都被截掉了 3 字节。

flowchart LR
    SC1["00 00 01"] --> N1["slice 1"]
    N1 --> SC2["00 00 01"]
    SC2 --> N2["slice 2 …"]
    N2 --> SC3["00 00 01"]
    SC3 --> N3["最后一个 slice"]
    N3 -.->|"旧实现丢掉的 3 字节"| X["❌ CABAC 尾部"]

这 3 字节是 CABAC(HEVC 的熵编码)码流的尾部。软解器容错,糊弄过去;硬解器严格校验,对无法解码的末尾 CTU 直接输出黑块——右下角 3×2 CTU 就是这么来的。而 P 帧整体比 IDR 更脆:末 slice 截断后硬解干脆整帧失败,播放器只剩关键帧能播,「一秒动一下」。两个看似无关的症状,根因是同一个。

修复版改成先收集全部 start code 位置,再按区间切,末 NAL 延伸到数据尾:

/// 把 AnnexB 码流按 start code 拆成 NAL 载荷(不含 start code)。
/// 注意结尾:最后一个 NAL 必须延伸到 data.len()——此前内层扫描条件
/// `j + 3 < len` 导致扫不到下一个 start code 时 j 停在 len-3,
/// 每帧最后一个 slice 被截掉 3 字节(CABAC 尾部残缺)。
pub fn split_annexb(data: &[u8]) -> Vec<Vec<u8>> {
    let n = data.len();
    let mut starts: Vec<(usize, usize)> = Vec::new(); // (start code 偏移, 长度 3|4)
    let mut i = 0;
    while i + 3 <= n {
        if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
            starts.push((i, 3));
            i += 3;
        } else if i + 4 <= n && data[i] == 0 && data[i + 1] == 0
               && data[i + 2] == 0 && data[i + 3] == 1 {
            starts.push((i, 4));
            i += 4;
        } else {
            i += 1;
        }
    }
    let mut out = Vec::with_capacity(starts.len());
    for k in 0..starts.len() {
        let start = starts[k].0 + starts[k].1;
        let end = if k + 1 < starts.len() { starts[k + 1].0 } else { n }; // 末 NAL 到数据尾
        if end > start {
            out.push(data[start..end].to_vec());
        }
    }
    out
}

修复后黑块消失、P 帧恢复,之前为了「多叠 IDR 保底」缩短的 GOP 也恢复回 30。

连带冤案:被截成 3 字节的 PPS

调试中曾发现固件输出的 PPS(图像参数集,解码必需的参数包之一)「只有 3 字节」(44 01 e0),当时记了一笔「疑似固件截断」。破案后才明白:header 也是经 split_annexb 拆的,6 字节的完整 PPS 被同一个 bug 截成了 3 字节——根本不是固件的锅。

不过 Chrome 的 HEVC 解析器确实需要完整 PPS(否则报 Failed to prepare video sample for decode),所以封装层保留了一道兜底:拆出来的 PPS 不足 4 字节时,固定替换为 ffmpeg 从同码流提取的 6 字节版本(44 01 e0 71 81 32——1080p 固定配置下 PPS 不变):

if pps.len() <= 3 {
    pps = vec![0x44, 0x01, 0xe0, 0x71, 0x81, 0x32];
}

hvcC 的三颗小雷

构建 init segment 的 hvcC box(fMP4 里存放 HEVC 解码配置的容器)时,还踩了三颗和 Chrome/NVDEC(NVIDIA 硬解)严格校验相关的雷,一并记录:

雷一:emulation prevention byte(EPB)。 码流数据里一旦出现 00 00 00/01/02/03 这样的字节组合,编码器必须在中间插入一个 03,防止数据被误判为起始码——读取方再去掉它。wave5 固件输出的 SPS 里 constraint 区就含 EPB(00 00 03)。不去除 EPB 就按固定偏移切片,会把 level_idc 读成 0 → Chrome 报 level: not available、样本无法准备解码。去 EPB 要按规范把 00 00 03 还原为 00 00——旧实现只输出一个 00,字段整体错位。

雷二:把 NAL 头当 profile。 SPS 字段提取的入参是完整 NAL(含 1 字节 NAL 头),必须先跳过。误把 NAL 头 0x42 当 profile 字节,会得出「Main 10 + level 0」的离谱组合,Chrome 直接拒收。最终 compat/constraint/level 三个字段我们干脆采用 ffmpeg 对同码流的解析值(0x60000000 / 全 0 / 0x96=L15.0),不再相信自己的字节切片。

雷三:hvcC array 类型字节。 NAL 数组的类型字节就是 array_completeness(1) | reserved(1) | NAL_unit_type(6)直接写 NAL 类型值(32/33/34)即可。自作聪明写成 (ty<<1)|1 的话,32<<1 会溢出到 reserved 位变成 0x41、低 6 位变成 1——NVDEC/CUVID 严格校验,报 Invalid NAL unit type in extradata 并降级解码。

验证纪律:VLC 的 seek 伪影

最后记录一条验证方法论上的坑(实测经验,代码里没留注释):用 VLC 验证 fMP4 文件时不要用 --start-time--start-time>0 播放 fMP4 会产生 seek 伪影——花屏/黑块和你要排查的真 bug 长得一模一样,极易误判成「码流还有问题」。验证必须从头(t=0)播。我们排查黑块期间就被这个伪影晃点过,白怀疑了一轮封装层。

小结与预告

这次破案的收获清单:一个被修复的边界条件、一条「软硬解行为差异即线索」的调试心法、三个 hvcC 细节、一条 VLC 使用纪律,外加两个留在编码参数里的「冤枉但无害」的规避配置。

黑块消失后,画面只剩最后一个肉眼可见的瑕疵:底部一条绿带。下一篇《底部绿带两轮修复》——病根是 ISP 的 16 行对齐(1088)与实际有效行(1080)之间那 8 行,以及 NV12 平面布局被一记天真的 resize(0) 搅乱的故事。