视频 Jitter Buffer
概述
视频 Jitter Buffer 与音频 Jitter Buffer 的目标相同——吸收网络抖动,保证平滑播放——但面临的挑战截然不同:
视频帧**大小差异巨大**:I 帧可能是 P 帧的 10-50 倍
一个视频帧通常**跨多个 RTP 包**,需要全部到齐才能解码
视频帧之间有**依赖关系**:P/B 帧依赖参考帧
视频对**延迟的容忍度**比音频稍高,但对**帧完整性**要求更严格
flowchart TD
A[RTP 包到达] --> B[包排序 + 去重]
B --> C[帧组装<br/>收集同一帧的所有包]
C --> D{帧完整?}
D -->|是| E[放入解码队列]
D -->|否| F{超时?}
F -->|否| C
F -->|是| G[标记为不完整<br/>请求重传或丢弃]
E --> H[解码器]
H --> I[渲染队列<br/>按时间戳播放]
与音频 Jitter Buffer 的差异
特性 |
音频 Jitter Buffer |
视频 Jitter Buffer |
|---|---|---|
缓冲单位 |
单个 RTP 包 = 1 帧 |
多个 RTP 包 = 1 帧 |
帧大小 |
固定(如 160 bytes/20ms) |
变化极大(1KB - 100KB+) |
帧间依赖 |
无(每帧独立) |
有(P→I, B→I/P) |
丢帧影响 |
20ms 静音/PLC |
画面冻结/花屏/级联丢帧 |
缓冲深度 |
40-200 ms |
0-500 ms |
时间拉伸 |
支持(加速/减速播放) |
不支持(只能丢帧/重复帧) |
帧组装(Frame Assembly)
视频帧组装是 Jitter Buffer 的第一步——将属于同一帧的多个 RTP 包拼接成完整帧。
RTP 标记位
RTP 头部的 M(Marker)位 标记帧的最后一个包:
帧 N:
RTP seq=100, ts=90000, M=0 ← 帧的第 1 个包
RTP seq=101, ts=90000, M=0 ← 帧的第 2 个包
RTP seq=102, ts=90000, M=1 ← 帧的最后一个包(M=1)
帧 N+1:
RTP seq=103, ts=93000, M=0 ← 新帧开始(时间戳变化)
RTP seq=104, ts=93000, M=1
组装逻辑:
按 RTP 时间戳分组(相同时间戳 = 同一帧)
按序列号排序
检查完整性:从第一个包到 M=1 的包,序列号连续
拼接 payload 得到完整的编码帧
帧完整性判断
type FrameBuffer struct {
timestamp uint32
packets map[uint16][]byte // seq -> payload
firstSeq uint16
lastSeq uint16 // M=1 的包
hasFirst bool
hasLast bool
}
func (f *FrameBuffer) IsComplete() bool {
if !f.hasFirst || !f.hasLast {
return false
}
// 检查从 firstSeq 到 lastSeq 的所有包都已收到
expected := f.lastSeq - f.firstSeq + 1
return uint16(len(f.packets)) == expected
}
帧依赖管理
视频帧之间的依赖关系决定了丢帧的影响范围:
I ← P ← P ← P ← I ← P ← P ← P
| | | | | | | |
如果这个 P 帧丢失 ──┘
后续的 P 帧都无法正确解码 ──────┘
关键帧(I 帧)保护
I 帧丢失的代价最高(后续所有帧都受影响),因此需要特殊保护:
FEC 保护:对 I 帧使用更强的 FEC(如 FlexFEC)
NACK 重传:I 帧的包丢失时优先重传
PLI/FIR 请求:如果 I 帧无法恢复,请求发送端生成新的 I 帧
接收端检测到关键帧丢失:
1. 发送 NACK 请求重传丢失的包
2. 等待重传(超时 ~RTT)
3. 如果重传失败,发送 PLI(Picture Loss Indication)
4. 发送端收到 PLI,在下一个编码机会生成 I 帧
解码顺序 vs 显示顺序
当使用 B 帧时,解码顺序和显示顺序不同:
显示顺序: I0 B1 B2 P3 B4 B5 P6
解码顺序: I0 P3 B1 B2 P6 B4 B5
Jitter Buffer 需要按解码顺序输出帧给解码器,
解码器输出后再按显示顺序排列给渲染器。
WebRTC 通常不使用 B 帧(增加延迟),但在 SVC 和某些 H.264 配置中可能出现。
自适应缓冲策略
视频 Jitter Buffer 的深度需要动态调整:
最小缓冲
min_buffer = max(jitter_estimate, decode_time, render_time)
其中:
- jitter_estimate: 网络抖动估计(如 P95 抖动)
- decode_time: 解码一帧所需时间
- render_time: 渲染延迟
目标延迟
WebRTC 的视频 Jitter Buffer(VCMJitterEstimator)使用卡尔曼滤波器估计帧到达时间的抖动:
帧间延迟模型:
d(i) = t(i) - t(i-1) // 帧间到达时间差
D(i) = T(i) - T(i-1) // 帧间发送时间差
delta(i) = d(i) - D(i) // 传输延迟变化
卡尔曼滤波器估计 delta 的分布,
目标缓冲深度 = 均值 + k × 标准差(k 通常取 2-3)
丢帧策略
当缓冲区溢出或延迟过大时,需要丢弃帧来降低延迟:
策略 |
方法 |
影响 |
|---|---|---|
丢弃最旧帧 |
丢弃缓冲区头部的帧 |
可能丢弃参考帧,导致级联错误 |
丢弃非参考帧 |
只丢弃不被其他帧引用的帧 |
影响最小,但降低帧率 |
跳到最新 I 帧 |
丢弃所有旧帧,从最新 I 帧开始 |
画面跳跃,但快速恢复 |
请求新 I 帧 |
发送 PLI + 清空缓冲区 |
需要等待 RTT + 编码时间 |
WebRTC 中的实现
WebRTC 的视频 Jitter Buffer 实现在 modules/video_coding/ 目录下:
frame_buffer2.cc:帧缓冲区管理jitter_estimator.cc:抖动估计(卡尔曼滤波)timing.cc:渲染时间计算packet_buffer.cc:RTP 包缓冲和帧组装
关键流程:
PacketBuffer::InsertPacket()
→ 包排序、帧组装
→ FrameBuffer::InsertFrame()
→ 检查帧依赖(参考帧是否已解码)
→ JitterEstimator::UpdateEstimate()
→ Timing::RenderTimeMs() 计算渲染时间
→ 帧可解码时通知解码线程
小结
视频 Jitter Buffer 比音频复杂得多——它不仅要处理抖动,还要处理帧组装、帧依赖、丢帧策略等问题。理解这些机制,对于调优视频通话质量和排查画面卡顿问题至关重要。