Phase 2: bound video frame cache + stream the export mux

- VideoManager.frame_cache: unbounded HashMap (grew per distinct frame during
  playback) -> LruCache evicted by a 256MB byte budget. Byte-budget rather than
  frame count is robust across resolutions (a 4K frame is ~33MB vs ~2MB at
  800x600). unload_video pops per-clip keys (LruCache has no retain).
- mux_video_and_audio: stream-merge the two inputs by PTS with one pending
  packet per stream (O(1) memory) instead of collecting every packet into Vecs
  first (O(duration)). Output is byte-identical.
- export AAC: sanitize the planar-f32 path (non-finite -> 0, finite clamped to
  [-1,1]) like the integer paths, with a one-time warning. A stray NaN/Inf
  render sample no longer fails the whole export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-17 14:30:32 -04:00
parent 3d7cff9ad0
commit c784816615
3 changed files with 142 additions and 74 deletions

View File

@ -671,14 +671,39 @@ fn convert_chunk_to_planar_i16(interleaved: &[f32], channels: u32) -> Vec<Vec<i1
planar planar
} }
/// Convert a chunk of interleaved f32 samples to planar f32 format /// Convert a chunk of interleaved f32 samples to planar f32 format.
///
/// Non-finite samples (NaN/±Inf) are replaced with `0.0` and finite samples are
/// clamped to `[-1.0, 1.0]`: the float encoders (e.g. AAC, which takes `fltp`)
/// reject a frame outright on "(near) NaN/+-Inf", failing the whole export, so we
/// sanitize here exactly as the integer paths already clamp.
fn convert_chunk_to_planar_f32(interleaved: &[f32], channels: u32) -> Vec<Vec<f32>> { fn convert_chunk_to_planar_f32(interleaved: &[f32], channels: u32) -> Vec<Vec<f32>> {
let num_frames = interleaved.len() / channels as usize; let num_frames = interleaved.len() / channels as usize;
let mut planar = vec![vec![0.0f32; num_frames]; channels as usize]; let mut planar = vec![vec![0.0f32; num_frames]; channels as usize];
let mut non_finite = 0u64;
for (i, chunk) in interleaved.chunks(channels as usize).enumerate() { for (i, chunk) in interleaved.chunks(channels as usize).enumerate() {
for (ch, &sample) in chunk.iter().enumerate() { for (ch, &sample) in chunk.iter().enumerate() {
planar[ch][i] = sample; planar[ch][i] = if sample.is_finite() {
sample.clamp(-1.0, 1.0)
} else {
non_finite += 1;
0.0
};
}
}
if non_finite > 0 {
// One-time warning: we sanitized rather than failed, but a non-finite
// sample reaching here means something upstream (an effect, automation,
// or a source decode) produced NaN/Inf — worth chasing if audio is wrong.
use std::sync::atomic::{AtomicBool, Ordering};
static WARNED: AtomicBool = AtomicBool::new(false);
if !WARNED.swap(true, Ordering::Relaxed) {
eprintln!(
"⚠️ [EXPORT] sanitized {} non-finite (NaN/Inf) audio sample(s) in a chunk — \
check effects/automation/source decode",
non_finite
);
} }
} }

View File

@ -458,9 +458,14 @@ pub struct VideoManager {
/// Pool of video decoders, one per clip /// Pool of video decoders, one per clip
decoders: HashMap<Uuid, Arc<Mutex<VideoDecoder>>>, decoders: HashMap<Uuid, Arc<Mutex<VideoDecoder>>>,
/// Frame cache: (clip_id, timestamp_ms) -> frame /// Frame cache: (clip_id, timestamp_ms) -> frame. Stores decoded RGBA for
/// Stores raw RGBA data for zero-copy rendering /// zero-copy rendering. Bounded by a **byte budget** (not a frame count, which
frame_cache: HashMap<(Uuid, i64), Arc<VideoFrame>>, /// would be unsafe across resolutions — a 4K frame is ~33MB vs ~2MB at 800x600)
/// so playback of arbitrarily long video never grows unbounded.
frame_cache: LruCache<(Uuid, i64), Arc<VideoFrame>>,
/// Running total of bytes held in `frame_cache` (sum of each frame's RGBA len),
/// kept in sync on insert/evict/remove so eviction is O(1) per frame.
frame_cache_bytes: usize,
/// Thumbnail cache: clip_id -> Vec of (timestamp, rgba_data) /// Thumbnail cache: clip_id -> Vec of (timestamp, rgba_data)
/// Low-resolution (64px width) thumbnails for scrubbing /// Low-resolution (64px width) thumbnails for scrubbing
@ -470,6 +475,11 @@ pub struct VideoManager {
cache_size: usize, cache_size: usize,
} }
/// Byte budget for [`VideoManager::frame_cache`] (decoded full-resolution frames).
/// At ~2MB/frame (800x600) this holds ~128 frames; at ~33MB/frame (4K) ~8 — in
/// both cases enough for the current frame plus a scrub window, while bounding RAM.
const FRAME_CACHE_BYTE_BUDGET: usize = 256 * 1024 * 1024;
impl VideoManager { impl VideoManager {
/// Create a new video manager with default cache size /// Create a new video manager with default cache size
pub fn new() -> Self { pub fn new() -> Self {
@ -480,7 +490,8 @@ impl VideoManager {
pub fn with_cache_size(cache_size: usize) -> Self { pub fn with_cache_size(cache_size: usize) -> Self {
Self { Self {
decoders: HashMap::new(), decoders: HashMap::new(),
frame_cache: HashMap::new(), frame_cache: LruCache::unbounded(),
frame_cache_bytes: 0,
thumbnail_cache: HashMap::new(), thumbnail_cache: HashMap::new(),
cache_size, cache_size,
} }
@ -533,14 +544,16 @@ impl VideoManager {
return Some(Arc::clone(cached_frame)); return Some(Arc::clone(cached_frame));
} }
// Get decoder for this clip // Get decoder for this clip. Clone the Arc so we don't hold a borrow of
let decoder_arc = self.decoders.get(clip_id)?; // `self.decoders` across the `&mut self` cache insert below.
let decoder_arc = Arc::clone(self.decoders.get(clip_id)?);
let mut decoder = decoder_arc.lock().ok()?; let mut decoder = decoder_arc.lock().ok()?;
// Decode the frame // Decode the frame
let rgba_data = decoder.get_frame(timestamp).ok()?; let rgba_data = decoder.get_frame(timestamp).ok()?;
let width = decoder.output_width; let width = decoder.output_width;
let height = decoder.output_height; let height = decoder.output_height;
drop(decoder); // release the lock before touching `self`
// Create VideoFrame and cache it // Create VideoFrame and cache it
let frame = Arc::new(VideoFrame { let frame = Arc::new(VideoFrame {
@ -550,11 +563,29 @@ impl VideoManager {
timestamp, timestamp,
}); });
self.frame_cache.insert(cache_key, Arc::clone(&frame)); self.cache_frame(cache_key, Arc::clone(&frame));
Some(frame) Some(frame)
} }
/// Insert a frame into the byte-budgeted cache, evicting least-recently-used
/// frames until the total is within [`FRAME_CACHE_BYTE_BUDGET`].
fn cache_frame(&mut self, key: (Uuid, i64), frame: Arc<VideoFrame>) {
let bytes = frame.rgba_data.len();
if let Some(old) = self.frame_cache.put(key, frame) {
self.frame_cache_bytes = self.frame_cache_bytes.saturating_sub(old.rgba_data.len());
}
self.frame_cache_bytes += bytes;
// Keep at least one frame resident even if it alone exceeds the budget.
while self.frame_cache_bytes > FRAME_CACHE_BYTE_BUDGET && self.frame_cache.len() > 1 {
if let Some((_, evicted)) = self.frame_cache.pop_lru() {
self.frame_cache_bytes = self.frame_cache_bytes.saturating_sub(evicted.rgba_data.len());
} else {
break;
}
}
}
/// Get the decoder Arc for a clip (for external thumbnail generation) /// Get the decoder Arc for a clip (for external thumbnail generation)
/// This allows external code to decode frames without holding the VideoManager lock /// This allows external code to decode frames without holding the VideoManager lock
pub fn get_decoder(&self, clip_id: &Uuid) -> Option<Arc<Mutex<VideoDecoder>>> { pub fn get_decoder(&self, clip_id: &Uuid) -> Option<Arc<Mutex<VideoDecoder>>> {
@ -614,8 +645,19 @@ impl VideoManager {
pub fn unload_video(&mut self, clip_id: &Uuid) { pub fn unload_video(&mut self, clip_id: &Uuid) {
self.decoders.remove(clip_id); self.decoders.remove(clip_id);
// Remove all cached frames for this clip // Remove all cached frames for this clip (LruCache has no retain; collect
self.frame_cache.retain(|(id, _), _| id != clip_id); // matching keys, then pop each, keeping the byte total in sync).
let keys: Vec<(Uuid, i64)> = self
.frame_cache
.iter()
.filter(|((id, _), _)| id == clip_id)
.map(|(k, _)| *k)
.collect();
for key in keys {
if let Some(frame) = self.frame_cache.pop(&key) {
self.frame_cache_bytes = self.frame_cache_bytes.saturating_sub(frame.rgba_data.len());
}
}
// Remove thumbnails // Remove thumbnails
self.thumbnail_cache.remove(clip_id); self.thumbnail_cache.remove(clip_id);
@ -624,6 +666,7 @@ impl VideoManager {
/// Clear all frame caches (useful for memory management) /// Clear all frame caches (useful for memory management)
pub fn clear_frame_cache(&mut self) { pub fn clear_frame_cache(&mut self) {
self.frame_cache.clear(); self.frame_cache.clear();
self.frame_cache_bytes = 0;
} }
} }

View File

@ -384,90 +384,90 @@ impl ExportOrchestrator {
println!("🎵 [MUX] Audio stream - Input TB: {}/{}, Output TB: {}/{}", println!("🎵 [MUX] Audio stream - Input TB: {}/{}, Output TB: {}/{}",
audio_input_tb.0, audio_input_tb.1, audio_output_tb.0, audio_output_tb.1); audio_input_tb.0, audio_input_tb.1, audio_output_tb.0, audio_output_tb.1);
// Collect all packets with their stream info and timestamps // Stream-merge the two inputs by PTS, writing each packet as it's read —
let mut video_packets = Vec::new(); // O(1) memory (one pending packet per stream) instead of collecting every
for (stream, packet) in video_input.packets() { // packet first, so muxing a long export never grows unbounded.
if stream.index() == video_stream_index { let video_idx = video_stream_index;
video_packets.push(packet); let audio_idx = audio_stream_index;
let mut v_iter = video_input.packets();
let mut a_iter = audio_input.packets();
// Pull the next packet belonging to the desired stream from each input.
let mut next_video = move || -> Option<ffmpeg::Packet> {
loop {
match v_iter.next() {
Some((stream, packet)) => {
if stream.index() == video_idx {
return Some(packet);
} }
} }
None => return None,
let mut audio_packets = Vec::new();
for (stream, packet) in audio_input.packets() {
if stream.index() == audio_stream_index {
audio_packets.push(packet);
} }
} }
};
println!("🎬 [MUX] Collected {} video packets, {} audio packets", let mut next_audio = move || -> Option<ffmpeg::Packet> {
video_packets.len(), audio_packets.len()); loop {
match a_iter.next() {
// Report first and last timestamps Some((stream, packet)) => {
if !video_packets.is_empty() { if stream.index() == audio_idx {
println!("🎬 [MUX] Video PTS range: {} to {}", return Some(packet);
video_packets[0].pts().unwrap_or(0),
video_packets[video_packets.len()-1].pts().unwrap_or(0));
} }
if !audio_packets.is_empty() {
println!("🎵 [MUX] Audio PTS range: {} to {}",
audio_packets[0].pts().unwrap_or(0),
audio_packets[audio_packets.len()-1].pts().unwrap_or(0));
} }
None => return None,
}
}
};
// Interleave packets by comparing timestamps in a common time base (use microseconds) let mut pending_v = next_video();
let mut v_idx = 0; let mut pending_a = next_audio();
let mut a_idx = 0; let mut v_count = 0usize;
let mut interleave_log_count = 0; let mut a_count = 0usize;
let mut log_count = 0;
while v_idx < video_packets.len() || a_idx < audio_packets.len() { loop {
let write_video = if v_idx >= video_packets.len() { // Write whichever pending packet has the earlier PTS (in a common
false // No more video // microsecond base); when one stream is exhausted, drain the other.
} else if a_idx >= audio_packets.len() { let write_video = match (&pending_v, &pending_a) {
true // No more audio, write video (None, None) => break,
} else { (Some(_), None) => true,
// Compare timestamps - convert both to microseconds (None, Some(_)) => false,
let v_pts = video_packets[v_idx].pts().unwrap_or(0); (Some(v), Some(a)) => {
let a_pts = audio_packets[a_idx].pts().unwrap_or(0); let v_us = v.pts().unwrap_or(0) * 1_000_000 * video_input_tb.0 as i64
/ video_input_tb.1 as i64;
// Convert to microseconds: pts * 1000000 * tb.num / tb.den let a_us = a.pts().unwrap_or(0) * 1_000_000 * audio_input_tb.0 as i64
let v_us = v_pts * 1_000_000 * video_input_tb.0 as i64 / video_input_tb.1 as i64; / audio_input_tb.1 as i64;
let a_us = a_pts * 1_000_000 * audio_input_tb.0 as i64 / audio_input_tb.1 as i64; v_us <= a_us
}
v_us <= a_us // Write video if it comes before or at same time as audio
}; };
if write_video { if write_video {
let mut packet = video_packets[v_idx].clone(); let mut packet = pending_v.take().unwrap();
packet.set_stream(0); packet.set_stream(0);
packet.rescale_ts(video_input_tb, video_output_tb); packet.rescale_ts(video_input_tb, video_output_tb);
if log_count < 10 {
if interleave_log_count < 10 { println!("🎬 [MUX] Writing V packet - PTS={:?}, DTS={:?}", packet.pts(), packet.dts());
println!("🎬 [MUX] Writing V packet {} - PTS={:?}, DTS={:?}, Duration={:?}", log_count += 1;
v_idx, packet.pts(), packet.dts(), packet.duration());
interleave_log_count += 1;
} }
packet.write_interleaved(&mut output) packet.write_interleaved(&mut output)
.map_err(|e| format!("Failed to write video packet: {}", e))?; .map_err(|e| format!("Failed to write video packet: {}", e))?;
v_idx += 1; v_count += 1;
pending_v = next_video();
} else { } else {
let mut packet = audio_packets[a_idx].clone(); let mut packet = pending_a.take().unwrap();
packet.set_stream(1); packet.set_stream(1);
packet.rescale_ts(audio_input_tb, audio_output_tb); packet.rescale_ts(audio_input_tb, audio_output_tb);
if log_count < 10 {
if interleave_log_count < 10 { println!("🎵 [MUX] Writing A packet - PTS={:?}, DTS={:?}", packet.pts(), packet.dts());
println!("🎵 [MUX] Writing A packet {} - PTS={:?}, DTS={:?}, Duration={:?}", log_count += 1;
a_idx, packet.pts(), packet.dts(), packet.duration());
interleave_log_count += 1;
} }
packet.write_interleaved(&mut output) packet.write_interleaved(&mut output)
.map_err(|e| format!("Failed to write audio packet: {}", e))?; .map_err(|e| format!("Failed to write audio packet: {}", e))?;
a_idx += 1; a_count += 1;
pending_a = next_audio();
} }
} }
println!("🎬 [MUX] Wrote {} video packets, {} audio packets", v_idx, a_idx); println!("🎬 [MUX] Wrote {} video packets, {} audio packets", v_count, a_count);
// Write trailer // Write trailer
output.write_trailer().map_err(|e| format!("Failed to write trailer: {}", e))?; output.write_trailer().map_err(|e| format!("Failed to write trailer: {}", e))?;