From 6dd2213a2072f2bce6f458f0daa5f7aa61504be0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 20:53:34 +0200 Subject: [PATCH] fix(session): keyframe recovery when the decoder produces no output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under infinite GOP the pump only re-requested an IDR when the reassembler's drop count climbed. A lost initial IDR (or a mid-GOP join) delivers complete-but-undecodable delta frames instead — the reassembler never drops, so recovery never fired and the stream froze on the last good frame while libavcodec flooded stderr with missing-reference errors. Reproduced at 4K@144 (large IDRs, higher loss); lower modes hid it. Now a 3-frame no-output streak (~50 ms) forces a fresh IDR, throttled and re-armed across the request→IDR round trip. Verified on glass: 4K@144 recovers and holds. Also quiets libavcodec's raw stderr (it bypassed tracing) to fatal-only, PUNKTFUNK_FFMPEG_LOG restores it. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/pf-client-core/src/session.rs | 39 ++++++++++++++++++++++++++-- crates/pf-client-core/src/video.rs | 20 ++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index 2f412262..aa2436c0 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -82,6 +82,11 @@ pub struct Stats { pub decoder: &'static str, } +/// Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long +/// enough not to fire on a one-frame decoder hiccup, short enough that a lost initial +/// IDR (or a mid-GOP join) unfreezes almost immediately instead of never. +const NO_OUTPUT_KEYFRAME_STREAK: u32 = 3; + /// Frames the pump keeps waiting for their 0xCF host timing (pts → capture→received µs). /// ~2 s at 120 Hz — a timing arrives within a frame or two of its AU, and against an old /// host (no 0xCF at all) this just caps the dead-weight ring. @@ -280,6 +285,13 @@ fn pump( // The stats window keeps its own drop cursor — the OSD shows the per-window delta. let mut window_dropped = last_dropped; let mut last_kf_req: Option = None; + // Consecutive received AUs that produced NO decoded frame (decode error, or the + // decoder swallowed a reference-missing delta and returned nothing). Distinct from + // `frames_dropped`, which counts reassembler drops: when the initial IDR is lost (or + // we join mid-GOP) the reassembler delivers complete-but-undecodable deltas — it + // never drops, so the drop-count trigger below stays silent and the stream freezes + // on the last good frame. A short streak forces a fresh IDR to re-anchor. + let mut no_output_streak = 0u32; let end: Option = loop { if stop.load(Ordering::SeqCst) { @@ -297,6 +309,7 @@ fn pump( bytes_n += frame.data.len() as u64; match decoder.decode(&frame.data) { Ok(Some(image)) => { + no_output_streak = 0; // a decoded frame — the anchor holds total_frames += 1; dec_path = match &image { DecodedImage::Cpu(_) => "software", @@ -333,9 +346,31 @@ fn pump( image, }); } - Ok(None) => {} + Ok(None) => no_output_streak += 1, // Survivable (loss until the next IDR/RFI recovery) — keep feeding. - Err(e) => tracing::debug!(error = %e, "decode error (recovering)"), + Err(e) => { + no_output_streak += 1; + tracing::debug!(error = %e, "decode error (recovering)"); + } + } + // The decoder has produced nothing for a short run — under zero-reorder + // LOW_DELAY (one-in/one-out) that means it's wedged on missing references + // with no reassembler drop to trigger recovery below. Ask for a fresh IDR + // (throttled), then re-arm the streak so we wait out the request→IDR round + // trip before asking again instead of flooding. + if no_output_streak >= NO_OUTPUT_KEYFRAME_STREAK { + let now = Instant::now(); + if last_kf_req + .is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) + { + last_kf_req = Some(now); + let _ = connector.request_keyframe(); + tracing::debug!( + streak = no_output_streak, + "requested keyframe (decoder produced no output)" + ); + no_output_streak = 0; + } } // The presenter's verdict: hardware frames can't be displayed (GL converter // init failed / dmabuf import rejected) — demote to software here, on the diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index dd5dbc01..e3fa2a52 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -175,6 +175,25 @@ pub fn decodable_codecs() -> u8 { bits } +/// libavcodec logs reference-frame recovery to the process stderr very verbosely +/// (`First slice in a frame missing`, `Could not find ref with POC …`, `Error +/// constructing the frame RPS`) — normal chatter while the decoder waits for a keyframe +/// after loss, but a raw flood in the user's terminal (it bypasses our tracing). Default +/// it to fatal-only; `PUNKTFUNK_FFMPEG_LOG=` restores it +/// for decode debugging. Process-global; set once per decoder build (idempotent). +fn quiet_ffmpeg_log() { + use ffmpeg::util::log::Level; + let level = match std::env::var("PUNKTFUNK_FFMPEG_LOG").ok().as_deref() { + Some("quiet") => Level::Quiet, + Some("error") => Level::Error, + Some("warning") => Level::Warning, + Some("info") => Level::Info, + Some("debug" | "trace") => Level::Debug, + _ => Level::Fatal, + }; + ffmpeg::util::log::set_level(level); +} + impl Decoder { /// `codec_id` is the codec the host resolved in the Welcome (never assume HEVC). /// `pref` is the Settings "Video decoder" value (`auto`/`vaapi`/`software`). @@ -183,6 +202,7 @@ impl Decoder { /// (VAAPI → software). pub fn new(codec_id: ffmpeg::codec::Id, pref: &str) -> Result { ffmpeg::init().context("ffmpeg init")?; + quiet_ffmpeg_log(); let choice = std::env::var("PUNKTFUNK_DECODER") .ok() .filter(|v| !v.is_empty())