实时视频(二):RTP 解包、Access Unit 与关键帧等待
RTP 一个包不等于一帧视频。H.264 和 H.265 都支持单 NAL、聚合和分片,网络还会产生乱序、重复、丢包和序列号回绕。播放器下游需要的是带时间戳、关键帧标记、参数集版本和完整性结果的 Access Unit,不能把 RTP 细节继续泄漏到渲染代码。
扩展序列号和乱序窗口
每个 SSRC 维护自己的序列号状态。RTP 序列号是 16 位,扩展序列号需要根据已记录的基准判断回绕方向。实现不应直接用普通整数比较,否则从 65535 到 0 会被当成大范围乱序。
乱序缓冲保存包、到达时间和期望序列号。窗口按包数或时间限制,窗口到期就记录缺口并决定丢弃当前 NAL。重复包不再次拼接,但计入重复指标。SSRC 变化时切换独立状态,不能把两路源的分片放进同一缓冲。
H.264 与 H.265 负载解析
H.264 常见单 NAL、STAP-A 和 FU-A。H.265 使用不同的 NAL 头以及聚合和分片类型,NAL 类型所在的位段不能直接复用 H.264 的掩码。聚合包每个单元带长度字段,解析前检查长度不超过剩余字节;一项越界,整个 RTP 包丢弃。
分片重组保存起始 NAL 头、起始标记、结束标记和累计大小。只有起始分片后按序收到中间片,直到结束标记,才形成完整 NAL。新的起始分片到来时清理未完成状态。累计长度设上限,防止畸形输入让内存无界增长。未支持的载荷模式返回明确错误,不按单 NAL 猜测。
拼成 Annex B 时,在 NAL 前添加起始码;交给长度前缀解码器时只做一次明确转换。转换层记录输入格式和输出格式,避免下游重复添加起始码,造成解码器把长度字段当作 NAL 头。
Access Unit 的边界
RTP 时间戳通常相同的一组 NAL 属于同一展示时刻,但不能只凭时间戳判断完整性。Marker 位能提供帧边界提示,仍需结合当前编码和分片状态。Access Unit 至少携带:
timestamp 媒体时钟时间
decodeOrder 解码提交顺序
nalUnits 完整 NAL 列表
isKey 经过 NAL 类型确认的关键帧标记
parameterSetId 当前 VPS/SPS/PPS 摘要
complete 是否存在丢包或解析错误视频时钟来自 RTP 负载的时钟频率,常见视频频率为 90 kHz,但实现应读取会话协商。32 位时间戳回绕转换为单调时间,网络到达时间只用于抖动和超时。存在 B 帧时,解码顺序和展示顺序可能不同,解包层不能按到达顺序重写展示时间。
音频拥有独立 SSRC 和时钟频率。若有 RTCP Sender Report,可以建立 RTP 时间戳到发送端时间的映射;没有可靠映射时,只能用首包建立相对时钟,并在诊断中写明精度限制。
参数集和关键帧恢复
参数集可以来自 SDP,也可以在带内出现。解析器按摘要缓存 VPS、SPS、PPS,变更时增加参数集版本。新订阅者先获取适用参数集,再等待关键帧。参数集变化后,旧解码器配置失效,旧队列全部清空。
一旦 Access Unit 缺少分片或含有格式错误,当前单元不能交给解码器。后续预测帧依赖损坏参考帧,继续提交会把错误扩散。播放器进入 waiting-keyframe,等待编码器发送恢复点。若链路支持 PLI/FIR,按节流策略请求关键帧;设备不支持时由会话层决定重拉流或提示 GOP 过长。
关键帧判断基于经过验证的 NAL 类型和编码规则,不能只依赖 Marker 位。H.264 与 H.265 的随机访问类型不同,能力矩阵要写清支持范围。缓存的关键帧还要与当前参数集版本匹配,旧参数集不能拿来初始化新解码器。
Rust/WASM 放在哪里
当前浏览器 demo 的 Rust/WASM 模块负责校验 Annex B 结构、去除 RBSP 防仿真字节,并从 H.264/H.265 SPS 提取宽高、profile、level、色度格式和位深等元数据。TypeScript 通过 rust-h26x-probe.ts 调用它,探测输出随帧统计进入 Worker 消息。
Rust 探测器只处理码流边界和元数据。像素路径由 h264decoder、@yume-chan/libde265 以及浏览器原生能力分别实现,解码器升级不会改变探测器的 ABI 契约。
测试样本
测试要覆盖序列号回绕、乱序窗口过期、重复包、STAP-A/FU-A 长度错误、分片缺失、参数集变更、关键帧等待和 SSRC 切换。每个失败样本保存输入包摘要、预期状态和释放后的缓冲大小。模糊测试验证截断头、超长聚合项和任意分片顺序不会崩溃或越界。
解包器能产出完整 Access Unit,不代表后续浏览器一定解码成功。编码 profile、description 格式和浏览器能力还要在 WebCodecs 层验证。