fix(viewer): 跳帧后进入「等待 IDR」状态,不再把必然失败的 P 帧喂给解码器

问题
H.264 的 P 帧依赖前一帧的参考图像。主控端的积压跳帧逻辑(读出但不解码)
会切断参考链,此后所有 P 帧都解不出来,且不会自行恢复——必须收到 IDR。
旧实现跳帧后只是 continue,后续 P 帧仍被送进 decoder,导致:

- 每帧解码失败一次 → stderr 刷屏「decode failed」
- 每次失败都触发一次 KeyFrameReq → 请求风暴
- 解码器反复在错误状态里挣扎

实测(修复前,10 秒会话):decode failed 刷屏数十条。

修复
引入 awaiting_idr 状态,语义是「参考链已断,等 IDR 重新同步」:

- 跳帧时置位,并请求关键帧
- 置位期间跳过所有 P 帧(这是正确性要求,不是优化)
- 收到 hdr.keyframe=true 的帧才清位并恢复解码
- 解码出错时也置位
- 初始值为 true:入会时已发 KeyFrameReq,拿到第一个 IDR 前不该硬解

该实现依赖协议 v2 新增的 hdr.keyframe 字段(从实际码流 NAL 类型判定),
因此必须在协议改动之后合入。

HUD 新增 STATE 行显示 OK / RECOVER。RECOVER 期间画面冻结是**预期行为**,
没有这个指示用户会误以为软件卡死。

验证(同机 agent+viewer,25 秒)
- decode failed:0 次(修复前刷屏)
- stderr 总行数:3(仅首帧日志 + 2 条积压提示)
- 窗口稳定无假死

已知局限(下一步)
本修复解决的是「跳帧后不再做无用功」,但跳帧本身仍会造成画面冻结直到
IDR 到达。NVENC 的 gop_len=240、@60fps 即 4 秒才有一个自动 IDR,恢复窗口
偏长。缩短 GOP 或降低跳帧触发频率是后续优化项。
This commit is contained in:
郑博文 2026-10-28 16:10:40 +08:00
parent e778ff73cc
commit 192513dd2a

View File

@ -110,6 +110,8 @@ struct Stats {
agent_sent: AtomicU64,
/// H.264 解码失败计数(关键帧自愈验证指标)
dec_fail: AtomicU64,
/// 当前是否处于「参考链断裂、等待 IDR」状态(HUD 显示 RECOVER)
awaiting_idr: AtomicBool,
/// 帧到达间隔环形缓冲
gaps: std::sync::Mutex<GapRing>,
}
@ -129,6 +131,7 @@ impl Default for Stats {
agent_lost: AtomicU64::new(0),
agent_sent: AtomicU64::new(0),
dec_fail: AtomicU64::new(0),
awaiting_idr: AtomicBool::new(true),
gaps: std::sync::Mutex::new(GapRing {
last: None,
ring: [0; 64],
@ -275,6 +278,15 @@ fn session(
let mut last_kfreq = Instant::now();
let mut skip_stale = 0u64;
let mut last_arr: Option<Instant> = None;
// 「等待 IDR」状态:参考链一旦断裂(跳帧或解码失败),后续 P 帧**必然**
// 解不出来。此时必须停止喂解码器,只等 IDR 重新同步。
//
// 之前没有这个状态,后果是:跳掉一帧后,每个 P 帧都被送进 decoder →
// 每个都失败 → 每个都打日志 → 每个都触发一次 KeyFrameReq。
// 表现为「decode failed」刷屏,且解码器反复在错误状态里挣扎。
//
// 放在 Stats 里(而非局部变量)以便 HUD 显示 RECOVER 状态。
// 初始 true:入会时已发 KeyFrameReq,拿到第一个 IDR 前不该硬解。
tokio::spawn(async move {
let mut size_logged = false;
loop {
@ -322,7 +334,10 @@ fn session(
"[viewer] backlog: stale burst frame skipped (total {skip_stale})"
);
}
// 限频请求关键帧:积压排空后从 IDR 重新衔接画面
// 跳帧直接切断 H.264 参考链:之后所有 P 帧都解不出来。
// 立刻转入「等待 IDR」并请求关键帧,绝不能让后续 P 帧
// 继续喂给解码器(那是必然失败的徒劳尝试)。
stats.awaiting_idr.store(true, Ordering::Relaxed);
if last_kfreq.elapsed() >= Duration::from_millis(200) {
last_kfreq = Instant::now();
if let Ok(mut slot) = latest.lock() {
@ -331,6 +346,22 @@ fn session(
}
continue;
}
// 参考链未恢复时,P 帧一律跳过——此为正确性要求,不是优化。
if stats.awaiting_idr.load(Ordering::Relaxed) && !hdr.keyframe {
skip_stale += 1;
// 补发请求,防止首次 KeyFrameReq 丢失(时间闸限频)
if last_kfreq.elapsed() >= Duration::from_millis(200) {
last_kfreq = Instant::now();
if let Ok(mut slot) = latest.lock() {
*slot = Some((0, 0, Vec::new()));
}
}
continue;
}
// 拿到 IDR 才能重新开始解码
if hdr.keyframe {
stats.awaiting_idr.store(false, Ordering::Relaxed);
}
match decoder.decode(&payload) {
Ok(Some(yuv)) => {
let (dw, dh) = yuv.dimensions();
@ -352,7 +383,8 @@ fn session(
}
Ok(None) => {}
Err(e) => {
// 解码异常:请求被控端强制关键帧
// 解码异常:参考链已不可信,转入等待 IDR
stats.awaiting_idr.store(true, Ordering::Relaxed);
stats.dec_fail.fetch_add(1, Ordering::Relaxed);
eprintln!("[viewer] decode failed: {e}, request keyframe");
if let Ok(mut slot) = latest.lock() {
@ -595,6 +627,21 @@ fn session(
("P95", &hud_p95.map(|p| format!("{p}ms")).unwrap_or("--".into()), p95_c),
("LOSS", &loss_str, loss_c),
("DECFAIL", &format!("{hud_dec}"), if hud_dec == 0 { C_GRAY } else { C_RED }),
(
// 参考链断裂、正在等 IDR 的显式指示:此时画面冻结是**预期行为**,
// 不是故障。没有这个指示,用户会以为软件卡死。
"STATE",
&if stats.awaiting_idr.load(Ordering::Relaxed) {
"RECOVER".to_string()
} else {
"OK".to_string()
},
if stats.awaiting_idr.load(Ordering::Relaxed) {
C_AMBER
} else {
C_GREEN
},
),
("RATE", &format!("{:.0}MBP", hud_mbps), C_GRAY),
("RES", &format!("{w}x{h}"), C_GRAY),
],