From 192513dd2a6d94d2386115e3f784615a2b900f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E5=8D=9A=E6=96=87?= <1323515022@qq.com> Date: Wed, 28 Oct 2026 16:10:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(viewer):=20=E8=B7=B3=E5=B8=A7=E5=90=8E?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E3=80=8C=E7=AD=89=E5=BE=85=20IDR=E3=80=8D?= =?UTF-8?q?=E7=8A=B6=E6=80=81=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=8A=8A=E5=BF=85?= =?UTF-8?q?=E7=84=B6=E5=A4=B1=E8=B4=A5=E7=9A=84=20P=20=E5=B8=A7=E5=96=82?= =?UTF-8?q?=E7=BB=99=E8=A7=A3=E7=A0=81=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 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 或降低跳帧触发频率是后续优化项。 --- viewer/src/main.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/viewer/src/main.rs b/viewer/src/main.rs index d81a04e..a7c6ef8 100644 --- a/viewer/src/main.rs +++ b/viewer/src/main.rs @@ -110,6 +110,8 @@ struct Stats { agent_sent: AtomicU64, /// H.264 解码失败计数(关键帧自愈验证指标) dec_fail: AtomicU64, + /// 当前是否处于「参考链断裂、等待 IDR」状态(HUD 显示 RECOVER) + awaiting_idr: AtomicBool, /// 帧到达间隔环形缓冲 gaps: std::sync::Mutex, } @@ -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 = 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), ],