上一篇让 WAVE420L 稳定吐出 HEVC 码流。本篇讲最后一公里:怎么把码流低延迟地送进浏览器并解码上屏。最终方案是 Rust 三线程流水线 + WebSocket 二进制推送 + 前端三级解码降级链,其中兜底的 MSE 路径需要一个 fMP4 封装器——392 行、零依赖、纯字节拼接,以及一串 Chrome 专属坑。

服务端架构:编码线程永不阻塞

整个程序是三条流水线线程:

flowchart LR
    CAP["采集线程<br/>DQBUF → 拷贝 → QBUF<br/>poll 3s 判管线死亡"] --> POOL["3 槽环形帧池<br/>满则覆盖最旧帧"]
    POOL --> ENC["编码线程<br/>白平衡 → VPU 硬编"]
    ENC --> HUB["WsHub.broadcast<br/>try_send,容量 2"]
    HUB --> C1["客户端线程 1"]
    HUB --> C2["客户端线程 2 …"]
  1. 采集线程:只做 DQBUF → 拷贝 → QBUF,绝不阻塞;poll 3 秒超时判管线死亡;
  2. 编码线程:从 3 槽环形帧池取帧 → 软件白平衡 → VPU 硬编 → WS 广播;
  3. WS 服务线程:单 accept 线程,每客户端独立收发线程。

两条设计原则值得单独说。

帧池满则覆盖最旧帧。 采集 30fps、编码 ~21fps,必然积压。3 槽环形缓冲满了就覆盖最旧的一帧——监控场景要的是「最新」而不是「最全」,宁可丢帧不可排队。

广播用容量 2 的 sync_channel + try_send 慢客户端的队列满了就丢这一帧(队列里本就有更新的帧等着),只有彻底断开才剔除:

/// 编码线程广播一帧。try_send:满则该客户端丢此帧;断开才剔除。
/// 保证编码线程永不阻塞。
pub fn broadcast(&self, f: FrameMsg) {
    let f = Arc::new(f);
    let mut clients = self.clients.lock().unwrap();
    clients.retain(|_, tx| match tx.try_send(f.clone()) {
        Ok(()) | Err(TrySendError::Full(_)) => true,
        Err(TrySendError::Disconnected(_)) => false,
    });
}

另外新客户端注册即 request_force_idr:编码线程下一帧强制编 IDR,客户端接入 ≤1 帧就能起播,不用干等 GOP 周期。

三种模式与三级降级链

WS 服务(端口 8081)按 URL 路径分三种模式,消息都是二进制、首字节为类型:

路径模式消息格式前端解码
/annexb0x01|参数集 + 0x02|flags|ts_µs(8B LE)|[IDR前插参数集]AnnexB AUWebCodecs hev1.*
/avccavcc0x05|flags|ts_µs|长前缀 NAL AUWebCodecs hvc1.*
/msefmp40x04|init(ftyp+moov),随后 0x03|flags|moof+mdatMSE hvc1

上表的两种打包格式先解释一下:AnnexB 是 HEVC 码流的打包格式——每个 NAL(网络抽象层单元,码流里的「一个包」)以起始码 00 00 01 分隔;「长前缀」(AVCC 风格)则把起始码换成 4 字节大端长度。MSE 只接受后者。

前端 pickMode()WebCodecs hev1(AnnexB 直喂,最优)→ WebCodecs hvc1(长前缀)→ MSE hvc1(fMP4,兜底) 的顺序探测。三级都要是因为:某些 Chromium 内核的 WebCodecs 只认 hvc1 不认 hev1;而 MSE 只要浏览器能播 <video> HEVC 就能走。(WebCodecs 是浏览器提供的底层硬解 API;MSE 即 Media Source Extensions,用 JS 向 <video> 持续喂媒体数据的机制。)

flowchart TD
    Q1{"WebCodecs<br/>支持 hev1.*?"} -->|是| M1["annexb 模式<br/>AnnexB 直喂(最优)"]
    Q1 -->|否| Q2{"WebCodecs<br/>支持 hvc1.*?"}
    Q2 -->|是| M2["avcc 模式<br/>长前缀 NAL"]
    Q2 -->|否| Q3{"MSE 支持 hvc1?"}
    Q3 -->|是| M3["fmp4 模式<br/>手工封装 fMP4(兜底)"]
    Q3 -->|否| M4["提示装 HEVC 视频扩展<br/>5 秒自动重试"]

探测的 codec 串有讲究——要和码流 SPS 实际参数匹配(wave5 输出 Main@L150、constraint=0),部分浏览器按串严格匹配能力,串不符会被拒:

for(const c of ['hev1.1.6.L150.90','hev1.1.6.L120.90','hev1.1.6.L93.90',
                'hev1.1.6.L150.B0','hev1.1.6.L120.B0']){
  const s = await VideoDecoder.isConfigSupported({codec:c, optimizeForLatency:true});
  if(s.supported) return {mode:'annexb', codec:c};
}

上面 hev1.1.6.L150.90 这串的含义:hev1 表示 AnnexB 打包的 HEVC,1.6 是 Main profile 与兼容位,L150 是 level_idc 150(Level 5.0,覆盖 1080p30)。

WebCodecs 路径还有两个运行时细节:

  • 追实时:解码队列 decodeQueueSize > 3 时丢 delta 帧(IDR 必喂,否则花屏到下个 GOP);
  • GPU 进程崩溃守卫:Chrome 的 GPU 进程崩了以后,渲染进程里的 WebCodecs 绑定可能失效——isConfigSupported 探测早已通过,但 VideoDecoder 构造器消失了。不处理就是每帧抛 ReferenceError。守卫逻辑是发现构造器消失就重置探测状态、重连走完整流程。

两个编码侧的配合改动

浏览器任意时刻接入,对码流有两个硬性要求,都是在编码/服务端层解决的:

参数集前插。 上一篇提过,forcedIdrHeaderEnable=1 实测不生效——固件输出的 IDR AU 里只有 VCL NAL,没有 VPS/SPS/PPS。所以 open 时缓存一份参数集(ENC_PUT_VIDEO_HEADER 的产物),广播 IDR 时由服务端前插;annexb 模式还会在接入时额外发一条 0x01 参数集消息做双保险。

IDR NAL 类型改写。 wave5 固件把 IDR 标为 IDR_W_RADL(19),但其后直接跟 TRAIL 帧(没有 RADL),违反 HEVC 规范——软解宽容(PotPlayer/ffmpeg 流畅),硬解严格(NVDEC/DXVA/系统扩展直接失败,VLC/浏览器硬解全卡)。服务端把 AU 内所有 VCL NAL 类型改写成 IDR_N_LP(20):

if ((b0 >> 1) & 0x3f) == 19 {
    au_data[hdr_off] = (b0 & 0xC1) | (20 << 1);
}

多 slice 模式下每帧有多个 VCL NAL,必须全部一致——只改第一个,DXVA 会报 Non-matching NAL types

手工封装 fMP4:逐字节对齐 ffmpeg

MSE 兜底路径需要把 AnnexB 码流封装成 fragmented MP4。没引任何库,src/fmp4.rs 392 行纯字节拼接,方法是先用 ffmpeg 生成一个参照文件 ref_hvc1.mp4,逐 box 拆解对照着写

  • init segmentftyp + moov(mvhd/tkhd/mdhd/hdlr/vmhd/dinf 全部逐字节对齐参照输出,连 dinf>dref 的 36 字节都是直接抄的字节序列,避免手写字节序歧义)+ hvc1 样本条目 + hvcC;时间基 TIMESCALE=90000
  • 媒体段moof(mfhd + traf[tfhd 0x020038 + tfdt v1 + trun 0x305]) + mdat,trun 带 data-offset、first-sample-flags、逐帧 duration 和 size。这些 box 的名字看着唬人,分工很简单:moof 是碎片头(记录本段有哪些样本、各多大、什么时间),mdat 是媒体数据本体,hvcC 则是 init 里的解码配置(装着 VPS/SPS/PPS)。

两个基础认知缺一不可:MSE 要 length-prefixed NAL(AVCC 风格),不是 AnnexB——每个 NAL 前换 4 字节大端长度;hvcC 的参数集来自编码器缓存的 VPS/SPS/PPS

Chrome 坑三连

封装器写完,Chrome 给了三个「惊喜」,每一个都卡了至少半天:

坑一:单帧段拒收。 最自然的想法是一帧一个 moof,结果 Chrome 的 ChunkDemuxer 对 count=1 的 moof「样本准备失败」。参照 ffmpeg 的输出是多帧一段,于是改成按 5 帧/段攒(约 167ms 攒段延迟,可接受);段首帧必须是 IDR,接入后先丢非 IDR 帧直到首个 IDR。

坑二:duration = Infinity 变 1fps。 直播直觉上该设无限时长,但 Chrome 对 duration=Infinity 会进入 live 低延迟模式,把缓冲压到约 1 段(0.2s),于是每段边界必然 waiting,实测 1-2fps 断续。解法是设一个大而有限的值 ms.duration = 3600,按 VOD 式缓冲 2-4 秒平滑播放,延迟上限交给主动裁剪控制。

坑三:假时间戳 0.7 倍慢放。 编码实测只有 ~21fps,如果按 30fps 固定节奏给样本分配时间戳,Chrome 会忠实地按时间轴播放——画面变成 0.7 倍速慢放。时间戳必须用真实帧间隔:tfdt 记段首帧真实时间,trun 里逐帧 duration 用相邻帧 ts 差值换算(末帧沿用前帧间隔,且防 0——Chrome 拒绝 duration=0):

let gap = if i + 1 < n { ts_us[i + 1].saturating_sub(ts_us[i]) }
          else { ts_us[i].saturating_sub(ts_us[i.saturating_sub(1)]) };
let d = (gap * TIMESCALE as u64) / 1_000_000; // 90000/1e6 = 0.09 tick/µs
durs.push(d.max(1) as u32);

缓冲管理:裁剪与自愈

MSE 是「拉」模型,缓冲不管会越堆越多——实测曾堆到 294 秒延迟trimBuffer() 的策略:

  • 缓冲总长超 8s 时删最旧部分;判断基于缓冲总长而非播放点——video 卡住(currentTime 不动)时也持续生效;
  • 删除边界与播放点保持 ≥1.5s 距离,避免 remove 异步执行时误删播放点造成永久空洞。

还有一个怪病的兜底:用户浏览器偶发 MSE+HEVC 渲染卡死(解码在跑、画面和时钟不动)。自愈监控每 2 秒查一次:currentTime 约 3 秒没前进且缓冲堆积超 10 秒 → 关闭 WS 重建会话,服务端会重发 init、重发 IDR,自动恢复。

三级都不支持时(比如没装「HEVC 视频扩展」的 Windows),页面给出明确提示并 5 秒自动重试,不需要手动刷新。

小结与预告

到这里整条链路跑通了:板子 21fps 硬编,浏览器三级降级解码,MSE 路径延迟稳定在 2-4 秒。但如果你以为故事到此结束——下一篇《右下角黑块悬案》是本系列最曲折的一次调试:画面右下角一块巴掌大的黑块 + 「只播关键帧」,我们先是给编码器叠了一堆规避参数,最后发现真凶是 fMP4 拆分函数里一个差了 3 字节的边界条件。