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:
parent
e778ff73cc
commit
192513dd2a
@ -110,6 +110,8 @@ struct Stats {
|
|||||||
agent_sent: AtomicU64,
|
agent_sent: AtomicU64,
|
||||||
/// H.264 解码失败计数(关键帧自愈验证指标)
|
/// H.264 解码失败计数(关键帧自愈验证指标)
|
||||||
dec_fail: AtomicU64,
|
dec_fail: AtomicU64,
|
||||||
|
/// 当前是否处于「参考链断裂、等待 IDR」状态(HUD 显示 RECOVER)
|
||||||
|
awaiting_idr: AtomicBool,
|
||||||
/// 帧到达间隔环形缓冲
|
/// 帧到达间隔环形缓冲
|
||||||
gaps: std::sync::Mutex<GapRing>,
|
gaps: std::sync::Mutex<GapRing>,
|
||||||
}
|
}
|
||||||
@ -129,6 +131,7 @@ impl Default for Stats {
|
|||||||
agent_lost: AtomicU64::new(0),
|
agent_lost: AtomicU64::new(0),
|
||||||
agent_sent: AtomicU64::new(0),
|
agent_sent: AtomicU64::new(0),
|
||||||
dec_fail: AtomicU64::new(0),
|
dec_fail: AtomicU64::new(0),
|
||||||
|
awaiting_idr: AtomicBool::new(true),
|
||||||
gaps: std::sync::Mutex::new(GapRing {
|
gaps: std::sync::Mutex::new(GapRing {
|
||||||
last: None,
|
last: None,
|
||||||
ring: [0; 64],
|
ring: [0; 64],
|
||||||
@ -275,6 +278,15 @@ fn session(
|
|||||||
let mut last_kfreq = Instant::now();
|
let mut last_kfreq = Instant::now();
|
||||||
let mut skip_stale = 0u64;
|
let mut skip_stale = 0u64;
|
||||||
let mut last_arr: Option<Instant> = None;
|
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 {
|
tokio::spawn(async move {
|
||||||
let mut size_logged = false;
|
let mut size_logged = false;
|
||||||
loop {
|
loop {
|
||||||
@ -322,7 +334,10 @@ fn session(
|
|||||||
"[viewer] backlog: stale burst frame skipped (total {skip_stale})"
|
"[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) {
|
if last_kfreq.elapsed() >= Duration::from_millis(200) {
|
||||||
last_kfreq = Instant::now();
|
last_kfreq = Instant::now();
|
||||||
if let Ok(mut slot) = latest.lock() {
|
if let Ok(mut slot) = latest.lock() {
|
||||||
@ -331,6 +346,22 @@ fn session(
|
|||||||
}
|
}
|
||||||
continue;
|
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) {
|
match decoder.decode(&payload) {
|
||||||
Ok(Some(yuv)) => {
|
Ok(Some(yuv)) => {
|
||||||
let (dw, dh) = yuv.dimensions();
|
let (dw, dh) = yuv.dimensions();
|
||||||
@ -352,7 +383,8 @@ fn session(
|
|||||||
}
|
}
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// 解码异常:请求被控端强制关键帧
|
// 解码异常:参考链已不可信,转入等待 IDR
|
||||||
|
stats.awaiting_idr.store(true, Ordering::Relaxed);
|
||||||
stats.dec_fail.fetch_add(1, Ordering::Relaxed);
|
stats.dec_fail.fetch_add(1, Ordering::Relaxed);
|
||||||
eprintln!("[viewer] decode failed: {e}, request keyframe");
|
eprintln!("[viewer] decode failed: {e}, request keyframe");
|
||||||
if let Ok(mut slot) = latest.lock() {
|
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),
|
("P95", &hud_p95.map(|p| format!("{p}ms")).unwrap_or("--".into()), p95_c),
|
||||||
("LOSS", &loss_str, loss_c),
|
("LOSS", &loss_str, loss_c),
|
||||||
("DECFAIL", &format!("{hud_dec}"), if hud_dec == 0 { C_GRAY } else { C_RED }),
|
("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),
|
("RATE", &format!("{:.0}MBP", hud_mbps), C_GRAY),
|
||||||
("RES", &format!("{w}x{h}"), C_GRAY),
|
("RES", &format!("{w}x{h}"), C_GRAY),
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user