fix(session): keyframe recovery when the decoder produces no output
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) <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,11 @@ pub struct Stats {
|
|||||||
pub decoder: &'static str,
|
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).
|
/// 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
|
/// ~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.
|
/// 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.
|
// The stats window keeps its own drop cursor — the OSD shows the per-window delta.
|
||||||
let mut window_dropped = last_dropped;
|
let mut window_dropped = last_dropped;
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
let mut last_kf_req: Option<Instant> = 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<String> = loop {
|
let end: Option<String> = loop {
|
||||||
if stop.load(Ordering::SeqCst) {
|
if stop.load(Ordering::SeqCst) {
|
||||||
@@ -297,6 +309,7 @@ fn pump(
|
|||||||
bytes_n += frame.data.len() as u64;
|
bytes_n += frame.data.len() as u64;
|
||||||
match decoder.decode(&frame.data) {
|
match decoder.decode(&frame.data) {
|
||||||
Ok(Some(image)) => {
|
Ok(Some(image)) => {
|
||||||
|
no_output_streak = 0; // a decoded frame — the anchor holds
|
||||||
total_frames += 1;
|
total_frames += 1;
|
||||||
dec_path = match &image {
|
dec_path = match &image {
|
||||||
DecodedImage::Cpu(_) => "software",
|
DecodedImage::Cpu(_) => "software",
|
||||||
@@ -333,9 +346,31 @@ fn pump(
|
|||||||
image,
|
image,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(None) => {}
|
Ok(None) => no_output_streak += 1,
|
||||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
// 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
|
// The presenter's verdict: hardware frames can't be displayed (GL converter
|
||||||
// init failed / dmabuf import rejected) — demote to software here, on the
|
// init failed / dmabuf import rejected) — demote to software here, on the
|
||||||
|
|||||||
@@ -175,6 +175,25 @@ pub fn decodable_codecs() -> u8 {
|
|||||||
bits
|
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=<quiet|error|warning|info|debug>` 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 {
|
impl Decoder {
|
||||||
/// `codec_id` is the codec the host resolved in the Welcome (never assume HEVC).
|
/// `codec_id` is the codec the host resolved in the Welcome (never assume HEVC).
|
||||||
/// `pref` is the Settings "Video decoder" value (`auto`/`vaapi`/`software`).
|
/// `pref` is the Settings "Video decoder" value (`auto`/`vaapi`/`software`).
|
||||||
@@ -183,6 +202,7 @@ impl Decoder {
|
|||||||
/// (VAAPI → software).
|
/// (VAAPI → software).
|
||||||
pub fn new(codec_id: ffmpeg::codec::Id, pref: &str) -> Result<Decoder> {
|
pub fn new(codec_id: ffmpeg::codec::Id, pref: &str) -> Result<Decoder> {
|
||||||
ffmpeg::init().context("ffmpeg init")?;
|
ffmpeg::init().context("ffmpeg init")?;
|
||||||
|
quiet_ffmpeg_log();
|
||||||
let choice = std::env::var("PUNKTFUNK_DECODER")
|
let choice = std::env::var("PUNKTFUNK_DECODER")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
|
|||||||
Reference in New Issue
Block a user