From e2e89c9f139d278710762707f1e5ab65744bf41f Mon Sep 17 00:00:00 2001 From: superkc2026 Date: Fri, 21 Aug 2026 10:49:36 +0800 Subject: [PATCH 1/3] fix(audio): guard the decode loop against pathological stalls A container whose audio track is truncated/corrupt at EOS can make av_read_frame never return AVERROR_EOF, so decoder_eof never propagates and the demux loop spins at 100% CPU forever. Cap the loop with a TIME budget (scaled on the requested window, x8, floor 60 s) instead of an iteration count, which would either never fire or cut healthy long clips. Review fixes over the originally-proposed version: - hard ceiling (u64::MAX cap) so a WebM reporting duration = Infinity cannot disable the guard via f64-as-u64 saturation. - on budget exhaustion, bail! (real error) instead of forcing EOF and exporting a silent-but-'successful' clip; the pipelines already degrade a bail! into their documented silent-fallback path in one place. - log av_seek_frame failure instead of silently resuming from t=0. Split out of the atempo PR (getopenscreen/openscreen#371) to keep that PR single-concern (the WSOLA -> atempo stretch replacement). --- crates/compositor/src/audio.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs index 1b51a7a99..c5ba97dc6 100644 --- a/crates/compositor/src/audio.rs +++ b/crates/compositor/src/audio.rs @@ -273,9 +273,25 @@ unsafe fn decode_clip_audio_inner( for track in tracks.iter_mut() { avcodec_flush_buffers(track.dctx); } + } else { + eprintln!( + "[openscreen-compositor] decode_clip_audio: av_seek_frame a échoué (target={target}), démux repart de t=0" + ); } } + // Anti-loop guard: a container whose audio track is truncated or corrupt at + // end-of-stream can make `av_read_frame` never return AVERROR_EOF, so + // `decoder_eof` never propagates and the loop spins at 100% CPU forever. + // A TIME budget, not an iteration count: `av_read_frame` can be slow on a + // corrupt stream, so a count would either never fire or cut healthy long + // clips short. The budget scales with the requested window (x8, floor 60 s, + // hard ceiling so a WebM reporting duration = Infinity cannot disable it). + let loop_start = std::time::Instant::now(); + let span_sec = (source_end_sec - source_start_sec).max(0.0); + let loop_budget_secs = ((span_sec * 8.0) as u64).max(60).min(3600 * 8); + let loop_budget = std::time::Duration::from_secs(loop_budget_secs); + let mut packet = av_packet_alloc(); let mut frame = av_frame_alloc(); let mut input_eof = false; @@ -284,6 +300,20 @@ unsafe fn decode_clip_audio_inner( // piste dont il porte l'index. On continue tant qu'AU MOINS une piste a encore quelque // chose à produire. while tracks.iter().any(|t| !t.reached_end && !t.decoder_eof) { + if loop_start.elapsed() > loop_budget { + // A real stall, not a slow decode: abort rather than emit a + // truncated/silent clip. The downstream pipelines already degrade a + // `bail!` here into their documented silent-fallback path in one + // place, instead of inventing an invisible one. + av_frame_free(&mut frame); + av_packet_free(&mut packet); + avformat_close_input(&mut fmt); + bail!( + "decode_clip_audio: decode loop exceeded {loop_budget_secs}s budget \ + (source_end={source_end_sec}s) — aborting to avoid exporting a \ + truncated clip" + ); + } if !input_eof { let read = av_read_frame(fmt, packet); if read == AVERROR_EOF { From cb3aed5082e091adafa33b08f671bcf44a5a3e09 Mon Sep 17 00:00:00 2001 From: superkc2026 Date: Fri, 21 Aug 2026 17:33:02 +0800 Subject: [PATCH 2/3] fix(audio): reset the demuxer when av_seek_frame fails (CodeRabbit #430) A failed seek can flush the packet queue and leave the demuxer mid-way through its fallback scan, so the next av_read_frame is not guaranteed to start at t=0 and leading audio could be silently omitted. On seek failure, seek back to timestamp 0, flush every decoder, and bail! if even that reset fails. --- crates/compositor/src/audio.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs index c5ba97dc6..29d9fb99e 100644 --- a/crates/compositor/src/audio.rs +++ b/crates/compositor/src/audio.rs @@ -275,8 +275,23 @@ unsafe fn decode_clip_audio_inner( } } else { eprintln!( - "[openscreen-compositor] decode_clip_audio: av_seek_frame a échoué (target={target}), démux repart de t=0" + "[openscreen-compositor] decode_clip_audio: av_seek_frame a échoué (target={target}), tentative de retour à t=0" ); + // A failed seek can flush the demuxer's packet queue and leave it + // mid-way through its fallback scan, so the next `av_read_frame` is + // not guaranteed to resume at t=0 — leading audio could be silently + // omitted. Reset to the start and flush every decoder; if even that + // reset fails, abort rather than risk an export that starts + // mid-stream. + if av_seek_frame(fmt, seek_stream_index, 0, AVSEEK_FLAG_BACKWARD) < 0 { + avformat_close_input(&mut fmt); + bail!( + "decode_clip_audio: av_seek_frame a échoué (target={target}) puis le retour à t=0 a échoué — abandon" + ); + } + for track in tracks.iter_mut() { + avcodec_flush_buffers(track.dctx); + } } } From 6d0305745daf35ef7529bddf99fdc789913fa4e4 Mon Sep 17 00:00:00 2001 From: superkc2026 Date: Fri, 21 Aug 2026 18:15:01 +0800 Subject: [PATCH 3/3] fix(audio): size the decode budget from the actual decode start (CodeRabbit #430) When a failed seek forces a reset to t=0, the loop must decode the whole file from the start while mix_aligned_tracks trims to the requested window. Sizing the budget on the window alone starved an unseekable-but-healthy long file: a 1 s window inside a 3 h recording got the 60 s floor while having to decode 3 h of input, so the guard could error out a file that was fine and push it into the silent fallback. Track decode_start_sec and compute span_sec from it. --- crates/compositor/src/audio.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs index 29d9fb99e..8b2de72bc 100644 --- a/crates/compositor/src/audio.rs +++ b/crates/compositor/src/audio.rs @@ -267,6 +267,14 @@ unsafe fn decode_clip_audio_inner( // décalage inter-pistes est absorbé là, pas ici. let seek_tb_sec = tracks[0].tb_sec; let seek_stream_index = tracks[0].stream_index; + // Where decoding actually starts. The budget below scales with the amount + // of input the loop will consume, which is the requested window on the + // happy path — but when a failed seek forces a reset to t=0 the loop must + // decode the whole file from the start (mix_aligned_tracks then trims the + // samples before source_start_sec). Sizing the budget on the window alone + // would starve an unseekable-but-healthy long file: a 1 s window inside a + // 3 h recording would get the 60 s floor while having to decode 3 h. + let mut decode_start_sec = source_start_sec; if seek_tb_sec > 0.0 { let target = (source_start_sec / seek_tb_sec).floor() as i64; if av_seek_frame(fmt, seek_stream_index, target, AVSEEK_FLAG_BACKWARD) >= 0 { @@ -289,6 +297,7 @@ unsafe fn decode_clip_audio_inner( "decode_clip_audio: av_seek_frame a échoué (target={target}) puis le retour à t=0 a échoué — abandon" ); } + decode_start_sec = 0.0; for track in tracks.iter_mut() { avcodec_flush_buffers(track.dctx); } @@ -303,7 +312,7 @@ unsafe fn decode_clip_audio_inner( // clips short. The budget scales with the requested window (x8, floor 60 s, // hard ceiling so a WebM reporting duration = Infinity cannot disable it). let loop_start = std::time::Instant::now(); - let span_sec = (source_end_sec - source_start_sec).max(0.0); + let span_sec = (source_end_sec - decode_start_sec).max(0.0); let loop_budget_secs = ((span_sec * 8.0) as u64).max(60).min(3600 * 8); let loop_budget = std::time::Duration::from_secs(loop_budget_secs);