Fix audio export hang when a video's audio is shorter than the video

Offline export blocks each streaming source until decoded frames are
available. For a video whose audio track ends before the video (or a
container like WebM/Opus with no exact stream duration), the tail chunks
wait for frames that never arrive — the 10s safety valve fires per chunk,
so the export appears to hang indefinitely.

Add a `finished` flag to ReadAheadBuffer, set when the disk reader hits
EOF (cleared on seek). The export-mode wait now breaks immediately once
the source is finished and the requested frames aren't present, rendering
silence for the missing tail instead of timing out chunk-by-chunk. Only
the export path reads the flag, so playback is unaffected.
This commit is contained in:
Skyler Lehmkuhl 2026-06-23 19:05:58 -04:00
parent 5844a0f070
commit 70ac0cde00
2 changed files with 34 additions and 1 deletions

View File

@ -77,6 +77,12 @@ pub struct ReadAheadBuffer {
/// When true, `render_from_file` will block-wait for frames instead of /// When true, `render_from_file` will block-wait for frames instead of
/// returning silence on buffer miss. Used during offline export. /// returning silence on buffer miss. Used during offline export.
export_mode: AtomicBool, export_mode: AtomicBool,
/// Set by the disk reader when the source reaches EOF (no more frames will
/// ever be decoded past the current valid range). Lets export-mode block-waits
/// give up immediately for frames past the end of the audio (e.g. a video whose
/// audio track is shorter than the video) instead of timing out per chunk.
/// Cleared on `reset` (a seek may decode fresh data again).
finished: AtomicBool,
} }
// SAFETY: See the doc comment on ReadAheadBuffer for the full safety argument. // SAFETY: See the doc comment on ReadAheadBuffer for the full safety argument.
@ -112,6 +118,7 @@ impl ReadAheadBuffer {
sample_rate, sample_rate,
target_frame: AtomicU64::new(0), target_frame: AtomicU64::new(0),
export_mode: AtomicBool::new(false), export_mode: AtomicBool::new(false),
finished: AtomicBool::new(false),
} }
} }
@ -216,11 +223,24 @@ impl ReadAheadBuffer {
self.export_mode.load(Ordering::Acquire) self.export_mode.load(Ordering::Acquire)
} }
/// Mark that the source has reached EOF (set by the disk reader).
pub fn set_finished(&self, finished: bool) {
self.finished.store(finished, Ordering::Release);
}
/// True once the source hit EOF: no frames past the current valid range
/// will ever be decoded (until a `reset`/seek).
pub fn is_finished(&self) -> bool {
self.finished.load(Ordering::Acquire)
}
/// Reset the buffer to start at `new_start` with zero valid frames. /// Reset the buffer to start at `new_start` with zero valid frames.
/// Called by the **disk reader thread** (producer) after a seek. /// Called by the **disk reader thread** (producer) after a seek.
pub fn reset(&self, new_start: u64) { pub fn reset(&self, new_start: u64) {
self.valid_frames.store(0, Ordering::Release); self.valid_frames.store(0, Ordering::Release);
self.start_frame.store(new_start, Ordering::Release); self.start_frame.store(new_start, Ordering::Release);
// A seek may decode fresh data past the previous EOF position.
self.finished.store(false, Ordering::Release);
} }
/// Write interleaved samples into the buffer, extending the valid range. /// Write interleaved samples into the buffer, extending the valid range.
@ -1138,7 +1158,12 @@ impl DiskReader {
// Decode more data into the buffer. // Decode more data into the buffer.
match reader.decode_next(&mut decode_buf) { match reader.decode_next(&mut decode_buf) {
Ok(0) => {} // EOF Ok(0) => {
// EOF: no more frames will be decoded for this buffer until a
// seek. Tell export-mode waiters so they stop blocking on
// past-the-end frames (e.g. video longer than its audio).
buffer.set_finished(true);
}
Ok(frames) => { Ok(frames) => {
let was_empty = buffer.valid_frames_count() == 0; let was_empty = buffer.valid_frames_count() == 0;
buffer.write_samples(&decode_buf, frames); buffer.write_samples(&decode_buf, frames);

View File

@ -577,6 +577,14 @@ impl AudioClipPool {
// Spin-wait with small sleeps until the disk reader fills the buffer // Spin-wait with small sleeps until the disk reader fills the buffer
let mut wait_iters = 0u64; let mut wait_iters = 0u64;
while !ra.has_range(src_start, frames_needed) { while !ra.has_range(src_start, frames_needed) {
// The source reached EOF and these frames are past the end of the
// audio (e.g. a video whose audio track is shorter than the video,
// or a container without exact stream duration). They will never
// arrive — stop waiting and let the render fill silence, instead of
// burning the 10s safety valve on every remaining chunk.
if ra.is_finished() {
break;
}
std::thread::sleep(std::time::Duration::from_micros(100)); std::thread::sleep(std::time::Duration::from_micros(100));
wait_iters += 1; wait_iters += 1;
if wait_iters > 100_000 { if wait_iters > 100_000 {