跳到主要内容

关键帧请求PLI与FIR

视频流是有"状态"的:P 帧依赖之前的关键帧(I 帧/IDR),一旦关键帧之前的某个 P 帧链断裂,后续 P 帧都解不出来——画面会绿屏、马赛克、冻结。关键帧请求(PLI/FIR)就是接收端告诉发送端"我状态丢了,请重新给我一个 I 帧"的机制。

但关键帧成本高:一个 IDR 的码率通常是 P 帧的 5~20 倍。请求频繁就等于打爆带宽。本文讲清楚 何时需要 / 谁触发 / 频次控制 / SFU 多订阅者场景

1. 为什么需要关键帧

1.1 视频帧依赖链

  • I 帧(IDR,Instantaneous Decoder Refresh):完全独立解码,码率高。
  • P 帧:依赖前一帧。
  • B 帧:双向依赖(WebRTC 一般禁用,因延迟代价大)。

断链 意味着:丢失任意一个 P 帧 → 后续所有 P 帧解码失败 → 画面静止/花屏直到下一个 I 帧。

1.2 关键帧请求的四种典型场景

场景触发方期待行为
接收端连续丢包,NACK 救不回接收端发送端立刻发一个 IDR
新订阅者加入 SFU 拉流SFU发布端补一个 IDR 让新订阅者起播
Simulcast 切层(订阅者从低层切到高层)SFU高层 SSRC 给一个 IDR
编码器内部错误恢复发送端自身自动产生 IDR

失败时的样子

  • 不发 PLI:接收端永久花屏,等下一个 GOP 才恢复(默认 GOP 1~10s,体感差)。
  • PLI 发太勤:码率周期性飙到平时 5 倍,反而拖垮 GCC。

2. PLI 与 FIR 的差别

2.1 PLI(Picture Loss Indication, RFC 4585)

RTCP PT=206, FMT=1
仅含一个语义:"Picture Loss",请发关键帧。
不带任何序号、原因。

PLI 是 请求,发送端可以拒绝(比如已经在生成 IDR)。

2.2 FIR(Full Intra Request, RFC 5104)

RTCP PT=206, FMT=4
带 sequence number(防重复触发),含 SSRC 列表。
语义更强:"必须发 IDR 给我"。

FIR 主要用于 会议混合 场景:MCU 切换主讲人时强制重新同步。SFU 转发模式下基本只用 PLI。

2.3 SDP 协商

a=rtcp-fb:96 nack pli # 启用 PLI
a=rtcp-fb:96 ccm fir # 启用 FIR

ccm = Codec Control Messages,FIR 属于 ccm 子类。

2.4 对比

维度PLIFIR
RFC45855104
是否带序号
现代 SFU 用法主要兼容
Chrome 默认启用启用
触发场景普通丢包切换主讲人/复杂场景

结论:日常用 PLI,FIR 留给 MCU 或必须强制刷新的场景。

3. 谁该触发关键帧

3.1 接收端的触发条件

case A: jitter buffer 容量耗尽,仍有连续丢包
case B: NACK 重试 N 次无应答
case C: 解码器返回 corrupt frame,帧链已断
case D: 主动切到更高分辨率(自定义场景)

Chrome 默认在 case A/B/C 自动触发 PLI。

3.2 SFU 端的触发条件

3.3 节流:避免 PLI 风暴

100 个订阅者同时缺帧 → 100 个 PLI → 发布端码率瞬间炸。SFU 必须做节流:

class PliThrottler {
constructor(intervalMs = 1000) {
this.intervalMs = intervalMs;
this.lastPliAt = new Map(); // SSRC -> timestamp
}

shouldSendPli(ssrc) {
const now = Date.now();
const last = this.lastPliAt.get(ssrc) ?? 0;
if (now - last < this.intervalMs) return false;
this.lastPliAt.set(ssrc, now);
return true;
}
}

// SFU 内部
const throttler = new PliThrottler(1000);
function onSubscriberNeedsKeyframe(producerSsrc) {
if (throttler.shouldSendPli(producerSsrc)) {
sendPliToProducer(producerSsrc);
}
}

4. 浏览器端如何触发与响应

4.1 接收端主动请求关键帧(实验 API)

RTCRtpReceiver.generateKeyFrame()(Chrome 117+,部分场景需 Insertable Streams):

const receiver = pc.getReceivers().find(r => r.track?.kind === 'video');
// 这是发送端 API;接收端只能通过 RTCP 反馈触发,没有标准前端 API 直接发 PLI
// 实际生产中由浏览器内核自行决定

发送端可以主动产关键帧(W3C RTCRtpScriptTransform 提供,主流可用方式):

const sender = pc.getSenders().find(s => s.track?.kind === 'video');
// Chrome 117+
if (sender.generateKeyFrame) {
await sender.generateKeyFrame();
}

4.2 关键帧间隔配置(编码器层)

const params = sender.getParameters();
params.encodings.forEach(e => {
// 关键帧间隔:不开放统一字段,依赖编码器默认(VP8/VP9 是 3000 帧 ~ 100s + 按需 IDR)
// WebRTC 实际依赖 PLI 触发,"周期性 IDR" 默认关闭
});
await sender.setParameters(params);

WebRTC 的 libwebrtc 编码器 默认不周期性发 IDR,只在以下情况发:

  • 编码器初始化时第一帧。
  • 收到 PLI / FIR。
  • 分辨率/码率剧烈变化时。
  • Simulcast 层激活/反激活。

4.3 监控 PLI / FIR 计数

async function inspectKeyframe(pc) {
const stats = await pc.getStats();
for (const r of stats.values()) {
if (r.type === 'inbound-rtp' && r.kind === 'video') {
console.log('接收端发出 PLI', r.pliCount);
console.log('接收端发出 FIR', r.firCount);
console.log('收到的关键帧数', r.keyFramesDecoded);
}
if (r.type === 'outbound-rtp' && r.kind === 'video') {
console.log('发送端收到 PLI', r.pliCount);
console.log('发送端收到 FIR', r.firCount);
console.log('发出的关键帧数', r.keyFramesEncoded);
}
}
}
setInterval(() => inspectKeyframe(pc), 5000);

5. SFU 实战:mediasoup 与 LiveKit

5.1 mediasoup

// SFU 主动让发布端发关键帧
await producer.requestKeyFrame();

// 监听新订阅者并请求关键帧
consumer.on('producerresume', async () => {
await producer.requestKeyFrame();
});

// 监听 PLI 计数
producer.on('trace', (trace) => {
if (trace.type === 'pli' || trace.type === 'fir') {
console.log('PLI/FIR 转给发布端', trace.info);
}
});

5.2 LiveKit Server

LiveKit 内部对 PLI 做了 1s 默认节流,并支持 keyframe 缓存:当新订阅者订阅时优先用缓存里的最近 IDR,避免高频请求。

5.3 SFU 关键帧策略对比

策略优点代价
透传(订阅者 PLI 直发发布端)实现简单PLI 风暴
节流(默认 1s 内一次)防风暴慢订阅者要等
缓存最近 IDR + delta 重放新订阅起播无延迟SFU 内存占用
周期性 IDR(如 2s 一个)容错强浪费 10%~30% 带宽

推荐:节流 + 最近 IDR 缓存。新订阅者 100ms 内拿到 IDR,老订阅者不被打扰。

6. 关键帧间隔与码率的取舍

GOP(Group of Pictures)= 两个 IDR 之间的距离
GOP 越短:抗丢包越强,但码率越高
GOP 越长:码率越低,但断链恢复慢
场景推荐 GOP原因
1v1 视频通话不固定(按需)依赖 PLI 即可
大会议 SFU2~3s 周期 IDR新订阅者多,节流 PLI
直播低延迟1~2s可能有大量观众加入
录播兼容2s录像切片对齐
弱网会议1s链路抖动大,断链频繁

周期性 IDR 不是 WebRTC 默认行为,需 SFU 端控制。例如 mediasoup:

// 自定义周期性 PLI(不推荐高频)
setInterval(() => {
producer.requestKeyFrame();
}, 3000);

7. 完整代码:SFU 端关键帧管理

class KeyframeManager {
constructor(producer, opts = {}) {
this.producer = producer;
this.minIntervalMs = opts.minIntervalMs ?? 1000;
this.lastRequestAt = 0;
this.recentKeyframe = null; // 缓存最近一个关键帧
this.subscribersWaiting = new Set(); // 等待 IDR 的订阅者
}

/**
* 处理订阅者关键帧请求
* @param {string} subscriberId
*/
async onSubscriberRequest(subscriberId) {
// 1) 有近期缓存,直接重放
if (this.recentKeyframe && Date.now() - this.recentKeyframe.ts < 5000) {
this.replayTo(subscriberId, this.recentKeyframe);
return;
}
// 2) 加入等待集合,节流后请求
this.subscribersWaiting.add(subscriberId);
this.maybeRequest();
}

maybeRequest() {
const now = Date.now();
if (now - this.lastRequestAt < this.minIntervalMs) return;
this.lastRequestAt = now;
this.producer.requestKeyFrame();
}

/**
* 拦截到关键帧,缓存并清空等待集合
*/
onKeyframeFromProducer(packet) {
this.recentKeyframe = { ts: Date.now(), packet };
for (const id of this.subscribersWaiting) this.replayTo(id, { packet });
this.subscribersWaiting.clear();
}

replayTo(subscriberId, frame) {
// 在实际 SFU 里这里要把 frame.packet 转发给指定 subscriber 的 transport
sendToSubscriber(subscriberId, frame.packet);
}
}

8. 调试与排障

8.1 chrome://webrtc-internals

关注 pliCountfirCountkeyFramesEncodedkeyFramesDecoded 四个字段:

现象解读
pliCount 飙升接收端反复请求,链路质量差
keyFramesEncodedpliCount编码器拒绝了部分请求(节流)
keyFramesDecoded 远小于发送的 keyFramesEncoded关键帧丢了 → 检查传输层
全程 pliCount=0,但画面卡PLI 没启用,检查 SDP

8.2 抓包看 RTCP

用 Wireshark 过滤 rtcp.pt == 206 and rtcp.psfb.fmt == 1 看 PLI 包,看发送频率是否合理。

8.3 用 SDP 临时禁用 PLI(仅排障)

// 仅用于复现问题,生产严禁
const offer = await pc.createOffer();
offer.sdp = offer.sdp.replace(/a=rtcp-fb:\d+ nack pli\r\n/g, '');
await pc.setLocalDescription(offer);
// 关闭 PLI 后画面一旦丢包就长时间花屏,重现"没关键帧的世界"

9. 反模式

反模式后果正确做法
在前端轮询调 generateKeyFrame()码率周期性飙升让浏览器自己根据 PLI 决策
SFU 透传 PLI 不节流PLI 风暴打爆发布端码率1s 节流
关闭 nack pli、依赖 GOP 自然恢复弱网用户秒级花屏永远开 PLI
用 FIR 替代 PLI 做日常请求浪费 IDR,且 FIR 节流不一致日常用 PLI
强制 GOP=10 帧码率翻倍按需 + 缓存 IDR
切 Simulcast 层时不请求关键帧切换后第一秒花屏切层时主动 PLI
缓存所有历史 IDR 给所有新订阅SFU 内存爆缓存最近一个即可
把 PLI 当 NACK 用关键帧成本远高于重传先 NACK,再 PLI

10. 决策清单

加入会议时(订阅者):

  1. 订阅 producer。
  2. SFU 检查最近缓存的 IDR:有 → 直接发;无 → 给 producer 发 PLI。
  3. 100~300ms 内拿到 IDR,开始解码。

链路抖动期间(已建立连接):

  1. 接收端先尝试 NACK 重传。
  2. 重传超时 / jitter buffer 满 → 接收端发 PLI。
  3. SFU 节流:1s 内只放过一个 PLI。
  4. 发布端响应 PLI,编码器产 IDR。

11. 权威资料