From 2e21c4490498e4b38ea51ec0c2e389cbfb1d4a9f Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:31:36 +0700 Subject: [PATCH] Export: never crash on WAV/AIFF over 4 GiB; stream PCM to disk WAV and AIFF sizes were 32-bit with unchecked arithmetic: past 4 GiB (24-bit 5.1 at 96 kHz after about 41 minutes) a debug build panicked and a release build wrote a wrapped, corrupt header. The whole file was also built in memory first. - WAV past 4 GiB is written as RF64 (EBU Tech 3306) with a ds64 chunk; smaller files stay plain RIFF. - AIFF has no 64-bit form: a longer one is refused before anything is written, naming WAV instead. - The header is written from the known length, then the samples one second at a time, so memory stays flat; progress advances per second. - Odd-sized data chunks get their pad byte; hostile lengths, channel counts and sample rates are errors. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/audio_out.rs | 5 + crates/export/src/lib.rs | 39 +++-- crates/export/src/pcm.rs | 286 +++++++++++++++++++++++++-------- 3 files changed, 257 insertions(+), 73 deletions(-) diff --git a/crates/export/src/audio_out.rs b/crates/export/src/audio_out.rs index b92b6054..5fd2b90c 100644 --- a/crates/export/src/audio_out.rs +++ b/crates/export/src/audio_out.rs @@ -145,6 +145,11 @@ impl AudioOut { Some(buf) } + /// Output samples still to come (per channel). + pub fn remaining(&self) -> u64 { + u64::try_from(self.end.saturating_sub(self.out)).unwrap_or(0) + } + /// Everything still to come (to the end of the range). pub fn rest(&mut self, sources: &dyn SourceProvider) -> Option>> { self.pull(self.end, sources) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 6c918b4c..2821b57b 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -1474,20 +1474,41 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let batch = rayon::current_num_threads().clamp(2, 16) as i64; let (bytes, nframes) = match settings.format { Format::Wav | Format::Aiff => { + let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; + let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits); + let container = if settings.format == Format::Wav { pcm::PcmContainer::Wav } else { pcm::PcmContainer::Aiff }; + let frames = a.remaining(); + // the sizes are known up front: refuse an AIFF that cannot hold them before measuring + let head = pcm::header(container, ch, sr, bits, frames).map_err(ExportError::Unsupported)?; + // one second per chunk: memory stays flat however long the range is + let chunk = i64::from(sr.max(1)); if !settings.part_of_batch { - progress.total.store(1, Ordering::Relaxed); + progress.total.store(frames.div_ceil(chunk as u64).max(1), Ordering::Relaxed); progress.set_status(format!("Exporting audio ({})", settings.format.label())); } - let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; a.measure(settings, sources, &cancelled)?; *progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness; - let planar = a.rest(sources).unwrap_or_else(|| vec![Vec::new(); a.channels]); - let inter = audio_out::interleave(&planar); - let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits); - let data = if settings.format == Format::Wav { pcm::write_wav(&inter, ch, sr, bits) } else { pcm::write_aiff(&inter, ch, sr, bits) }; - let n = write_output(settings, &settings.path, data)?; - progress.done.store(1, Ordering::Relaxed); - (n, planar.first().map_or(0, Vec::len) as u64) + let mut out = Out::create(settings)?; + let io = |e: std::io::Error| ExportError::Io(e.to_string()); + out.write_all(&head).map_err(io)?; + let mut buf = Vec::new(); + while let Some(planar) = a.pull(a.out.saturating_add(chunk), sources) { + if cancelled() { + return Err(ExportError::Cancelled); + } + buf.clear(); + pcm::encode(container, &audio_out::interleave(&planar), bits, &mut buf); + out.write_all(&buf).map_err(io)?; + if !settings.part_of_batch { + progress.done.fetch_add(1, Ordering::Relaxed); + } + } + out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?; + let n = out.finish(settings)?; + if settings.part_of_batch { + progress.done.store(1, Ordering::Relaxed); + } + (n, frames) } Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::Gif => { let pipe = pipeline::Pipeline::new(project.clone(), seq, settings, false)?; diff --git a/crates/export/src/pcm.rs b/crates/export/src/pcm.rs index c479a017..03b88a74 100644 --- a/crates/export/src/pcm.rs +++ b/crates/export/src/pcm.rs @@ -1,5 +1,16 @@ -//! Audio-only files: WAV (RIFF, little-endian PCM) and AIFF (big-endian PCM), 16 or 24 bit, plus -//! image-sequence file naming. +//! Audio-only files: WAV (RIFF, little-endian PCM; RF64 past 4 GiB) and AIFF (big-endian PCM), +//! 16 or 24 bit, plus image-sequence file naming. +//! +//! The header is written first from the known length ([`header`]), then the samples in chunks +//! ([`encode`]) and the pad byte ([`pad`]), so an export streams to disk instead of building the +//! file in memory. + +/// The container of an audio-only export. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PcmContainer { + Wav, + Aiff, +} /// Quantise one sample to a signed integer of `bits` (16 or 24). fn quantise(s: f32, bits: u16) -> i32 { @@ -7,41 +18,128 @@ fn quantise(s: f32, bits: u16) -> i32 { (s.clamp(-1.0, 1.0) * max).round() as i32 } +/// The sample size written for a requested `bits`: 24, else 16. +fn sample_bits(bits: u16) -> u16 { + if bits >= 24 { 24 } else { 16 } +} + +/// Bytes of sample data for `frames` frames, or None when that does not fit 64 bits. +fn data_len(frames: u64, channels: u16, bits: u16) -> Option { + frames.checked_mul(u64::from(channels))?.checked_mul(u64::from(sample_bits(bits) / 8)) +} + +/// The pad byte after an odd-sized data chunk (RIFF and AIFF chunks are word-aligned). +pub fn pad(frames: u64, channels: u16, bits: u16) -> &'static [u8] { + if data_len(frames, channels, bits).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] } +} + +/// Everything before the samples of a file of `frames` frames. A WAV whose RIFF size would not +/// fit 32 bits (4 GiB: 24-bit 5.1 at 96 kHz after about 41 minutes) is written as RF64 (EBU Tech +/// 3306), with the 64-bit sizes in a `ds64` chunk. AIFF has no 64-bit form, so a longer AIFF is +/// an error naming WAV instead. +pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u16, frames: u64) -> Result, String> { + let too_long = || "the audio is too long to write".to_string(); + let bits = sample_bits(bits); + let bps = bits / 8; + let data = data_len(frames, channels, bits).ok_or_else(too_long)?; + let block_align = channels.checked_mul(bps).ok_or("too many audio channels")?; + match container { + PcmContainer::Wav => { + let byte_rate = u32::try_from(u64::from(sample_rate) * u64::from(block_align)).map_err(|_| "the audio sample rate is too high".to_string())?; + let ext = channels > 2; + let fmt_len: u32 = if ext { 40 } else { 16 }; + let padded = data.checked_add(data & 1).ok_or_else(too_long)?; + // "WAVE" + fmt chunk + data chunk header + samples + let riff = (4 + 8 + u64::from(fmt_len) + 8).checked_add(padded).ok_or_else(too_long)?; + let rf64 = riff > u64::from(u32::MAX); + let mut v = Vec::with_capacity(80); + if rf64 { + const DS64: u64 = 8 + 28; + v.extend_from_slice(b"RF64"); + v.extend_from_slice(&u32::MAX.to_le_bytes()); + v.extend_from_slice(b"WAVEds64"); + v.extend_from_slice(&28u32.to_le_bytes()); + v.extend_from_slice(&riff.checked_add(DS64).ok_or_else(too_long)?.to_le_bytes()); + v.extend_from_slice(&data.to_le_bytes()); + v.extend_from_slice(&frames.to_le_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); // no table entries + } else { + v.extend_from_slice(b"RIFF"); + v.extend_from_slice(&(riff as u32).to_le_bytes()); + v.extend_from_slice(b"WAVE"); + } + v.extend_from_slice(b"fmt "); + v.extend_from_slice(&fmt_len.to_le_bytes()); + v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes()); + v.extend_from_slice(&channels.to_le_bytes()); + v.extend_from_slice(&sample_rate.to_le_bytes()); + v.extend_from_slice(&byte_rate.to_le_bytes()); + v.extend_from_slice(&block_align.to_le_bytes()); + v.extend_from_slice(&bits.to_le_bytes()); + if ext { + // cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM + v.extend_from_slice(&22u16.to_le_bytes()); + v.extend_from_slice(&bits.to_le_bytes()); + let mask: u32 = if channels == 6 { 0x3F } else { 0 }; + v.extend_from_slice(&mask.to_le_bytes()); + v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]); + } + v.extend_from_slice(b"data"); + v.extend_from_slice(&(if rf64 { u32::MAX } else { data as u32 }).to_le_bytes()); + Ok(v) + } + PcmContainer::Aiff => { + let aiff_too_long = || "AIFF holds at most 4 GB of audio: export WAV, which has no such limit".to_string(); + let frames32 = u32::try_from(frames).map_err(|_| aiff_too_long())?; + // SSND: offset + block size + samples + let ssnd = u32::try_from(data.checked_add(8).ok_or_else(aiff_too_long)?).map_err(|_| aiff_too_long())?; + let form = (4u32 + (8 + 18)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?; + let mut v = Vec::with_capacity(54); + v.extend_from_slice(b"FORM"); + v.extend_from_slice(&form.to_be_bytes()); + v.extend_from_slice(b"AIFFCOMM"); + v.extend_from_slice(&18u32.to_be_bytes()); + v.extend_from_slice(&channels.to_be_bytes()); + v.extend_from_slice(&frames32.to_be_bytes()); + v.extend_from_slice(&bits.to_be_bytes()); + v.extend_from_slice(&extended(sample_rate)); + v.extend_from_slice(b"SSND"); + v.extend_from_slice(&ssnd.to_be_bytes()); + v.extend_from_slice(&0u32.to_be_bytes()); // offset + v.extend_from_slice(&0u32.to_be_bytes()); // block size + Ok(v) + } + } +} + +/// Append interleaved samples as `bits`-bit PCM (little-endian for WAV, big-endian for AIFF). +pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut Vec) { + let bits = sample_bits(bits); + let bps = usize::from(bits / 8); + out.reserve(interleaved.len().saturating_mul(bps)); + for &s in interleaved { + let q = quantise(s, bits); + match container { + PcmContainer::Wav => out.extend_from_slice(q.to_le_bytes().get(..bps).unwrap_or_default()), + PcmContainer::Aiff => out.extend_from_slice(q.to_be_bytes().get(4 - bps..).unwrap_or_default()), + } + } +} + +/// A whole file in memory (short audio; exports stream with [`header`] and [`encode`]). +fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result, String> { + let frames = (interleaved.len() / usize::from(channels.max(1))) as u64; + let mut v = header(container, channels, sample_rate, bits, frames)?; + encode(container, interleaved, bits, &mut v); + v.extend_from_slice(pad(frames, channels, bits)); + Ok(v) +} + /// A RIFF/WAVE file of interleaved samples. More than two channels are written as /// `WAVE_FORMAT_EXTENSIBLE` with the speaker mask of the layout (6 channels: L, R, C, LFE, Ls, Rs /// = `0x3F`). -pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Vec { - let bits: u16 = if bits >= 24 { 24 } else { 16 }; - let bps = (bits / 8) as u32; - let data_len = interleaved.len() as u32 * bps; - let ext = channels > 2; - let fmt_len: u32 = if ext { 40 } else { 16 }; - let mut v = Vec::with_capacity(28 + fmt_len as usize + data_len as usize); - v.extend_from_slice(b"RIFF"); - v.extend_from_slice(&(20 + fmt_len + data_len).to_le_bytes()); - v.extend_from_slice(b"WAVEfmt "); - v.extend_from_slice(&fmt_len.to_le_bytes()); - v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes()); - v.extend_from_slice(&channels.to_le_bytes()); - v.extend_from_slice(&sample_rate.to_le_bytes()); - v.extend_from_slice(&(sample_rate * channels as u32 * bps).to_le_bytes()); - v.extend_from_slice(&(channels * bps as u16).to_le_bytes()); - v.extend_from_slice(&bits.to_le_bytes()); - if ext { - // cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM - v.extend_from_slice(&22u16.to_le_bytes()); - v.extend_from_slice(&bits.to_le_bytes()); - let mask: u32 = if channels == 6 { 0x3F } else { 0 }; - v.extend_from_slice(&mask.to_le_bytes()); - v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]); - } - v.extend_from_slice(b"data"); - v.extend_from_slice(&data_len.to_le_bytes()); - for &s in interleaved { - let q = quantise(s, bits).to_le_bytes(); - v.extend_from_slice(&q[..bps as usize]); - } - v +pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result, String> { + write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, bits) } /// IEEE 754 80-bit extended encoding of a sample rate (AIFF `COMM`). @@ -59,34 +157,8 @@ fn extended(rate: u32) -> [u8; 10] { } /// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`) of interleaved samples. -pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Vec { - let bits: u16 = if bits >= 24 { 24 } else { 16 }; - let bps = (bits / 8) as usize; - let frames = interleaved.len() / channels.max(1) as usize; - let data_len = (interleaved.len() * bps) as u32; - let ssnd_len = 8 + data_len; - let form_len = 4 + (8 + 18) + (8 + ssnd_len) + (ssnd_len & 1); - let mut v = Vec::with_capacity(form_len as usize + 8); - v.extend_from_slice(b"FORM"); - v.extend_from_slice(&form_len.to_be_bytes()); - v.extend_from_slice(b"AIFFCOMM"); - v.extend_from_slice(&18u32.to_be_bytes()); - v.extend_from_slice(&channels.to_be_bytes()); - v.extend_from_slice(&(frames as u32).to_be_bytes()); - v.extend_from_slice(&bits.to_be_bytes()); - v.extend_from_slice(&extended(sample_rate)); - v.extend_from_slice(b"SSND"); - v.extend_from_slice(&ssnd_len.to_be_bytes()); - v.extend_from_slice(&0u32.to_be_bytes()); // offset - v.extend_from_slice(&0u32.to_be_bytes()); // block size - for &s in interleaved { - let q = quantise(s, bits).to_be_bytes(); - v.extend_from_slice(&q[4 - bps..]); - } - if ssnd_len & 1 == 1 { - v.push(0); - } - v +pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result, String> { + write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, bits) } /// File name of frame `index` of an image sequence of `count` frames exported to `path`: @@ -126,12 +198,98 @@ mod tests { #[test] fn wav_and_aiff_layout() { let s = [0.5f32, -0.5, 1.0, -1.0]; - let w = write_wav(&s, 2, 48_000, 24); + let w = write_wav(&s, 2, 48_000, 24).unwrap(); assert_eq!(w.len(), 44 + 12); assert_eq!(&w[34..36], &24u16.to_le_bytes()); - let a = write_aiff(&s, 2, 48_000, 16); + let a = write_aiff(&s, 2, 48_000, 16).unwrap(); assert_eq!(&a[..4], b"FORM"); assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8); assert_eq!(&a[a.len() - 2..], &(-32767i16).to_be_bytes()); } + + fn le32(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) + } + + fn le64(b: &[u8], at: usize) -> u64 { + u64::from_le_bytes(b[at..at + 8].try_into().unwrap()) + } + + /// 24-bit 5.1 at 96 kHz passes 4 GiB after about 41 minutes; the sizes overflowed `u32` + /// (a panic in debug builds, a corrupt header in release). + #[test] + fn wav_past_4_gib_is_rf64() { + let frames = 96_000u64 * 60 * 60; // an hour: 6.2 GB + let h = header(PcmContainer::Wav, 6, 96_000, 24, frames).unwrap(); + let data = frames * 6 * 3; + assert_eq!(&h[..4], b"RF64"); + assert_eq!(le32(&h, 4), u32::MAX); + assert_eq!(&h[8..16], b"WAVEds64"); + assert_eq!(le32(&h, 16), 28); + // the 64-bit RIFF size covers everything after the first 8 bytes + assert_eq!(le64(&h, 20), h.len() as u64 - 8 + data); + assert_eq!((le64(&h, 28), le64(&h, 36), le32(&h, 44)), (data, frames, 0)); + assert_eq!(&h[48..52], b"fmt "); + assert_eq!(&h[h.len() - 8..h.len() - 4], b"data"); + assert_eq!(le32(&h, h.len() - 4), u32::MAX); + } + + #[test] + fn wav_up_to_4_gib_stays_riff() { + // the longest stereo 16-bit RIFF: 36 bytes of header after the size field + let max = (u64::from(u32::MAX) - 36) / 4; + let h = header(PcmContainer::Wav, 2, 48_000, 16, max).unwrap(); + assert_eq!(&h[..4], b"RIFF"); + assert_eq!(u64::from(le32(&h, 4)), 36 + max * 4); + assert_eq!(u64::from(le32(&h, 40)), max * 4); + assert_eq!(&header(PcmContainer::Wav, 2, 48_000, 16, max + 1).unwrap()[..4], b"RF64"); + } + + #[test] + fn aiff_past_4_gib_is_refused_not_wrapped() { + let e = header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60 * 8).unwrap_err(); + assert!(e.contains("export WAV"), "{e}"); + assert!(header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60).is_ok()); + } + + #[test] + fn hostile_sizes_are_errors() { + for c in [PcmContainer::Wav, PcmContainer::Aiff] { + assert!(header(c, 2, 48_000, 24, u64::MAX).is_err()); + assert!(header(c, u16::MAX, 48_000, 24, 1).is_err()); + } + assert!(header(PcmContainer::Wav, 6, u32::MAX, 24, 1).is_err()); + } + + #[test] + fn odd_data_chunks_are_padded() { + // one 24-bit mono frame: 3 bytes of data, one pad byte, counted in the RIFF size + let w = write_wav(&[0.25], 1, 48_000, 24).unwrap(); + assert_eq!((w.len(), le32(&w, 4) as usize, le32(&w, 40)), (48, 40, 3)); + let a = write_aiff(&[0.25], 1, 48_000, 24).unwrap(); + assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8); + assert!(pad(2, 1, 24).is_empty()); + } + + /// A reader takes the RF64 sizes: ffprobe reports the duration from `ds64` (skipped without it). + #[test] + fn ffprobe_reads_rf64_duration() { + let dir = std::env::temp_dir().join(format!("filmcraft-rf64-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.wav"); + let frames = 48_000u64 * 60 * 60 * 5; // 5 hours of 24-bit stereo: 5.2 GB + let mut f = header(PcmContainer::Wav, 2, 48_000, 24, frames).unwrap(); + encode(PcmContainer::Wav, &[0.0; 9_600], 24, &mut f); + std::fs::write(&path, &f).unwrap(); + let out = std::process::Command::new("ffprobe") + .args(["-v", "error", "-show_entries", "format=format_name,duration", "-of", "default=nw=1"]) + .arg(&path) + .output(); + let _ = std::fs::remove_dir_all(&dir); + let Ok(out) = out else { return }; + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("format_name=wav"), "{text}"); + let secs: f64 = text.lines().find_map(|l| l.strip_prefix("duration=")).and_then(|d| d.parse().ok()).unwrap_or(0.0); + assert!((secs - 18_000.0).abs() < 1.0, "{text}"); + } }