上一篇让 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 …"]
- 采集线程:只做 DQBUF → 拷贝 → QBUF,绝不阻塞;poll 3 秒超时判管线死亡;
- 编码线程:从 3 槽环形帧池取帧 → 软件白平衡 → VPU 硬编 → WS 广播;
- 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 路径分三种模式,消息都是二进制、首字节为类型:
| 路径 | 模式 | 消息格式 | 前端解码 |
|---|---|---|---|
/ | annexb | 0x01|参数集 + 0x02|flags|ts_µs(8B LE)|[IDR前插参数集]AnnexB AU | WebCodecs hev1.* |
/avcc | avcc | 0x05|flags|ts_µs|长前缀 NAL AU | WebCodecs hvc1.* |
/mse | fmp4 | 0x04|init(ftyp+moov),随后 0x03|flags|moof+mdat | MSE 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 segment:
ftyp+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 字节的边界条件。