From e283f4dee7726599ca568bc20bb8a8881fa7daf8 Mon Sep 17 00:00:00 2001 From: Mara Johnson Date: Sat, 10 Oct 2026 23:57:14 -0400 Subject: [PATCH] fix(media): RF64 trailing metadata stays outside audio duration Use ds64.dataSize for an RF64 data-size sentinel instead of the rest of the file, so chunks after the audio are no longer decoded as samples. Fixes #925 --- crates/codecs/tests/rf64_metadata.rs | 69 ++++++++++++++++++++++++++++ crates/media/src/wav.rs | 14 +++++- 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 crates/codecs/tests/rf64_metadata.rs diff --git a/crates/codecs/tests/rf64_metadata.rs b/crates/codecs/tests/rf64_metadata.rs new file mode 100644 index 00000000..164c01d6 --- /dev/null +++ b/crates/codecs/tests/rf64_metadata.rs @@ -0,0 +1,69 @@ +//! #925: a chunk after an RF64 `data` sentinel must not become audio. + +use std::sync::Arc; + +use filmcraft_time::Tick; + +fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut out = id.to_vec(); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend_from_slice(body); + if body.len() % 2 == 1 { + out.push(0); + } + out +} + +/// Four 16-bit mono frames of silence at 48 kHz, as RIFF or RF64, optionally with a trailing iXML. +fn fixture(rf64: bool, trailing: bool) -> Vec { + let mut fmt = Vec::new(); + for v in [1u16, 1] { + fmt.extend_from_slice(&v.to_le_bytes()); + } + fmt.extend_from_slice(&48_000u32.to_le_bytes()); + fmt.extend_from_slice(&96_000u32.to_le_bytes()); + fmt.extend_from_slice(&2u16.to_le_bytes()); + fmt.extend_from_slice(&16u16.to_le_bytes()); + let mut out = if rf64 { b"RF64\xff\xff\xff\xffWAVE".to_vec() } else { b"RIFF\0\0\0\0WAVE".to_vec() }; + if rf64 { + let mut ds = vec![0u8; 28]; + ds[8..16].copy_from_slice(&8u64.to_le_bytes()); + ds[16..24].copy_from_slice(&4u64.to_le_bytes()); + out.extend(chunk(b"ds64", &ds)); + } + out.extend(chunk(b"fmt ", &fmt)); + let offset = out.len(); + out.extend(chunk(b"data", &[0u8; 8])); + if rf64 { + out[offset + 4..offset + 8].copy_from_slice(&u32::MAX.to_le_bytes()); + } + if trailing { + out.extend(chunk(b"iXML", b"")); + } + let size = out.len() - 8; + if rf64 { + out[20..28].copy_from_slice(&(size as u64).to_le_bytes()); + } else { + out[4..8].copy_from_slice(&(size as u32).to_le_bytes()); + } + out +} + +#[test] +fn rf64_metadata_stays_outside_audio() { + for (name, rf64, trailing) in [("rf64.wav", true, false), ("riff_ixml.wav", false, true), ("rf64_ixml.wav", true, true)] { + let bytes: Arc<[u8]> = fixture(rf64, trailing).into(); + let src = filmcraft_codecs::open_bytes(name, bytes.clone()).unwrap(); + let stream = filmcraft_media::reader::open_reader( + name, + Arc::new(filmcraft_media::reader::MemReader(bytes)), + &filmcraft_codecs::reader_openers(), + &filmcraft_codecs::openers(), + ) + .unwrap(); + for s in [&src, &stream] { + assert_eq!(s.info().duration, Tick::from_units(4, 48_000), "{name}"); + assert_eq!(s.audio(0, 20, 48_000).unwrap().channels.remove(0), vec![0.0; 20], "{name}"); + } + } +} diff --git a/crates/media/src/wav.rs b/crates/media/src/wav.rs index 4a0df3d2..a15f0d6b 100644 --- a/crates/media/src/wav.rs +++ b/crates/media/src/wav.rs @@ -62,6 +62,7 @@ impl WavSource { let mut data = None; let mut bext_tr = None; let mut ixml_tr = None; + let mut ds64_data = None; for _ in 0..MAX_CHUNKS { let Some(body) = pos.checked_add(8).filter(|&b| b <= len) else { break }; let mut hdr = [0u8; 8]; @@ -69,8 +70,12 @@ impl WavSource { let id = &hdr[0..4]; let mut clen = u64::from(le32(&hdr, 4)); let rest = len - body; - if id == b"data" && (clen == 0xFFFF_FFFF || clen > rest) { - clen = rest; + if id == b"data" { + // RF64 sentinel: the real size is ds64.dataSize (EBU 3306 ยง3.4), else the rest of the file + if clen == 0xFFFF_FFFF { + clen = ds64_data.unwrap_or(rest); + } + clen = clen.min(rest); } if clen > rest { break; @@ -87,6 +92,11 @@ impl WavSource { } fmt = Some((tag, ch, rate, bits)); } + // ds64: riffSize u64, dataSize u64, sampleCount u64, ... + b"ds64" if clen >= 16 => { + let b = read_range(&*reader, body + 8, 8).map_err(io)?; + ds64_data = Some(u64::from(le32(&b, 0)) | u64::from(le32(&b, 4)) << 32); + } b"data" => data = Some((body, clen)), // bext: Description 256, Originator 32, OriginatorReference 32, OriginationDate 10, // OriginationTime 8, then TimeReference (low u32, high u32)