mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
`(writes * MIB) as u64` multiplies in `usize` before widening: 4,097 MiB
overflows a 32-bit `usize`, so `cargo test --target i686-pc-windows-msvc`
failed to compile ("this arithmetic operation will overflow"). Widen first.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
14 KiB
Rust
317 lines
14 KiB
Rust
//! Property tests: write with our muxers, read back, compare sample tables and bytes.
|
|
|
|
use craft_media::isobmff::*;
|
|
use proptest::prelude::*;
|
|
use std::collections::BTreeMap;
|
|
use std::io::{Cursor, Seek, SeekFrom, Write};
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct S {
|
|
size: usize,
|
|
duration: u32,
|
|
cto: i32,
|
|
sync: bool,
|
|
}
|
|
|
|
fn sample_strategy() -> impl Strategy<Value = S> {
|
|
(0usize..300, 1u32..3000, -200i32..2000, any::<bool>()).prop_map(|(size, duration, cto, sync)| S { size, duration, cto, sync })
|
|
}
|
|
|
|
fn payload(track: usize, i: usize, n: usize) -> Vec<u8> {
|
|
(0..n).map(|k| (track * 31 + i * 7 + k) as u8).collect()
|
|
}
|
|
|
|
fn entry(kind: u8) -> SampleEntry {
|
|
match kind {
|
|
0 => SampleEntry::avc(AvcConfig::new(vec![vec![0x67, 0x64, 0, 0x28, 1, 2]], vec![vec![0x68, 3]], 4), 320, 240),
|
|
1 => SampleEntry::prores(FourCc(*b"apcn"), 1920, 1080),
|
|
2 => SampleEntry::aac(vec![0x11, 0x90], 2, 48000),
|
|
_ => SampleEntry::jpeg(64, 64),
|
|
}
|
|
}
|
|
|
|
proptest! {
|
|
#![proptest_config(ProptestConfig::with_cases(64))]
|
|
|
|
#[test]
|
|
fn progressive_roundtrip(
|
|
tracks in prop::collection::vec((0u8..4, prop::collection::vec(sample_strategy(), 0..40)), 1..4),
|
|
order in prop::collection::vec(0usize..4, 0..200),
|
|
mov in any::<bool>(),
|
|
fast in any::<bool>(),
|
|
with_pcm in any::<bool>(),
|
|
start in prop::option::of(0i64..500),
|
|
) {
|
|
let brand = if mov { Brand::Mov } else { Brand::Mp4 };
|
|
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(brand)).unwrap();
|
|
for (k, (kind, _)) in tracks.iter().enumerate() {
|
|
let mut cfg = TrackConfig::new(entry(*kind), 1000 + k as u32 * 11);
|
|
cfg.media_start = start;
|
|
w.add_track(cfg).unwrap();
|
|
}
|
|
let pcm_track = if with_pcm {
|
|
let p = PcmConfig { bits: 24, float: false, big_endian: false, signed: true, channels: 2, sample_rate: 48000.0 };
|
|
Some(w.add_track(TrackConfig::new(SampleEntry::pcm(p), 48000)).unwrap())
|
|
} else { None };
|
|
// Interleave: follow `order`, then flush the rest.
|
|
let mut next = vec![0usize; tracks.len()];
|
|
let mut pcm_chunks = Vec::new();
|
|
let emit = |w: &mut Mp4Writer<Cursor<Vec<u8>>>, t: usize, next: &mut Vec<usize>| {
|
|
let i = next[t];
|
|
let s = &tracks[t].1[i];
|
|
let d = payload(t, i, s.size);
|
|
w.write_sample(t, WriteSample { data: &d, duration: s.duration, composition_offset: s.cto, is_sync: s.sync }).unwrap();
|
|
next[t] += 1;
|
|
};
|
|
for (step, &o) in order.iter().enumerate() {
|
|
if let Some(p) = pcm_track && step % 5 == 0 {
|
|
let d = payload(9, step, 6 * (1 + step % 7));
|
|
w.write_sample(p, WriteSample { data: &d, duration: 0, composition_offset: 0, is_sync: true }).unwrap();
|
|
pcm_chunks.push(d);
|
|
}
|
|
let t = o % tracks.len();
|
|
if next[t] < tracks[t].1.len() {
|
|
emit(&mut w, t, &mut next);
|
|
}
|
|
}
|
|
for t in 0..tracks.len() {
|
|
while next[t] < tracks[t].1.len() {
|
|
emit(&mut w, t, &mut next);
|
|
}
|
|
}
|
|
let out = if fast { w.finish_faststart() } else { w.finish() }.unwrap().into_inner();
|
|
let f = open(out.as_slice()).unwrap();
|
|
prop_assert_eq!(f.is_quicktime, mov);
|
|
prop_assert_eq!(f.tracks.len(), tracks.len() + pcm_track.is_some() as usize);
|
|
for (k, (kind, samples)) in tracks.iter().enumerate() {
|
|
let t = &f.tracks[k];
|
|
prop_assert_eq!(t.codec(), Some(&entry(*kind).codec));
|
|
prop_assert_eq!(t.samples.len(), samples.len());
|
|
let mut dts = 0i64;
|
|
for (i, (s, r)) in samples.iter().zip(&t.samples).enumerate() {
|
|
prop_assert_eq!(r.size as usize, s.size);
|
|
prop_assert_eq!(r.duration, s.duration);
|
|
prop_assert_eq!(r.dts, dts);
|
|
prop_assert_eq!(r.pts, dts + s.cto as i64);
|
|
prop_assert_eq!(r.is_sync, s.sync);
|
|
prop_assert_eq!(f.read_sample(out.as_slice(), k, i).unwrap(), payload(k, i, s.size));
|
|
dts += s.duration as i64;
|
|
}
|
|
prop_assert_eq!(t.duration, dts as u64);
|
|
match start {
|
|
Some(st) if !samples.is_empty() || st == 0 => prop_assert_eq!(t.edit_offset, -st),
|
|
_ => {}
|
|
}
|
|
}
|
|
if let Some(p) = pcm_track {
|
|
let t = &f.tracks[p];
|
|
let got: Vec<u8> = (0..t.samples.len()).flat_map(|i| f.read_sample(out.as_slice(), p, i).unwrap()).collect();
|
|
prop_assert_eq!(got, pcm_chunks.concat());
|
|
let frames: u64 = t.samples.iter().map(|s| s.duration as u64).sum();
|
|
prop_assert_eq!(frames, pcm_chunks.iter().map(|c| c.len() as u64 / 6).sum::<u64>());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fragmented_roundtrip(
|
|
samples in prop::collection::vec(sample_strategy(), 1..60),
|
|
cuts in prop::collection::vec(any::<bool>(), 60),
|
|
) {
|
|
let mut w = FragmentedWriter::new(Vec::new(), WriterOptions::new(Brand::Mp4));
|
|
let v = w.add_track(TrackConfig::new(entry(0), 90000)).unwrap();
|
|
let a = w.add_track(TrackConfig::new(entry(2), 48000)).unwrap();
|
|
for (i, s) in samples.iter().enumerate() {
|
|
w.write_sample(v, WriteSample { data: &payload(0, i, s.size), duration: s.duration, composition_offset: s.cto, is_sync: s.sync }).unwrap();
|
|
w.write_sample(a, WriteSample { data: &payload(1, i, s.size / 2), duration: 1024, composition_offset: 0, is_sync: true }).unwrap();
|
|
if cuts[i] { w.flush_fragment().unwrap(); }
|
|
}
|
|
let out = w.finish().unwrap();
|
|
let f = open(out.as_slice()).unwrap();
|
|
prop_assert!(f.fragmented);
|
|
let mut dts = 0i64;
|
|
for (i, s) in samples.iter().enumerate() {
|
|
let r = f.tracks[0].samples[i];
|
|
prop_assert_eq!((r.size as usize, r.dts, r.pts, r.is_sync), (s.size, dts, dts + s.cto as i64, s.sync));
|
|
prop_assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), payload(0, i, s.size));
|
|
prop_assert_eq!(f.read_sample(out.as_slice(), 1, i).unwrap(), payload(1, i, s.size / 2));
|
|
prop_assert_eq!(f.tracks[1].samples[i].dts, 1024 * i as i64);
|
|
dts += s.duration as i64;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sample_lookup() {
|
|
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
|
let t = w.add_track(TrackConfig::new(entry(0), 25)).unwrap();
|
|
// I P B B pattern: decode order I0 P3 B1 B2 | I4 P7 B5 B6
|
|
let cto = [1, 3, 0, 0, 1, 3, 0, 0];
|
|
for (i, c) in cto.iter().enumerate() {
|
|
w.write_sample(t, WriteSample { data: &[i as u8], duration: 1, composition_offset: *c, is_sync: i % 4 == 0 }).unwrap();
|
|
}
|
|
let out = w.finish().unwrap().into_inner();
|
|
let f = open(out.as_slice()).unwrap();
|
|
let tr = &f.tracks[0];
|
|
// pts: 1,4,2,3,5,8,6,7
|
|
assert_eq!(tr.sample_at_pts(0), Some(0));
|
|
assert_eq!(tr.sample_at_pts(2), Some(2));
|
|
assert_eq!(tr.sample_at_pts(4), Some(1));
|
|
assert_eq!(tr.sample_at_pts(7), Some(7));
|
|
assert_eq!(tr.sample_at_pts(100), Some(5));
|
|
assert_eq!(tr.sync_sample_before(3), 0);
|
|
assert_eq!(tr.sync_sample_before(4), 4);
|
|
assert_eq!(tr.sync_sample_before(7), 4);
|
|
assert_eq!(tr.sync_samples(), &[0, 4]);
|
|
}
|
|
|
|
#[test]
|
|
fn patched_co64_and_largesize_mdat_are_read() {
|
|
// A small file patched into the layout of a file past 4 GiB: co64 chunk offsets and a
|
|
// 16-byte large-size mdat header.
|
|
let mut moov = Vec::new();
|
|
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
|
let t = w.add_track(TrackConfig::new(entry(3), 30)).unwrap();
|
|
w.write_sample(t, WriteSample { data: b"abc", duration: 1, composition_offset: 0, is_sync: true }).unwrap();
|
|
let out = w.finish().unwrap().into_inner();
|
|
// Replace stco with co64 (same entry count, 64-bit offsets) and rebuild.
|
|
let f = open(out.as_slice()).unwrap();
|
|
let off = f.tracks[0].samples[0].offset;
|
|
let stco = out.windows(4).position(|w| w == b"stco").unwrap() - 4;
|
|
let moov_at = out.windows(4).position(|w| w == b"moov").unwrap() - 4;
|
|
moov.extend_from_slice(&out[moov_at..stco]);
|
|
moov.extend_from_slice(&24u32.to_be_bytes());
|
|
moov.extend_from_slice(b"co64");
|
|
moov.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 1]);
|
|
moov.extend_from_slice(&off.to_be_bytes());
|
|
// Fix up sizes of moov/trak/mdia/minf/stbl (+4 bytes each).
|
|
for name in [b"moov", b"trak", b"mdia", b"minf", b"stbl"] {
|
|
let p = moov.windows(4).position(|w| w == name).unwrap() - 4;
|
|
let s = u32::from_be_bytes(moov[p..p + 4].try_into().unwrap()) + 4;
|
|
moov[p..p + 4].copy_from_slice(&s.to_be_bytes());
|
|
}
|
|
let mut file = out[..moov_at].to_vec();
|
|
file.extend_from_slice(&moov);
|
|
// ftyp is 32 bytes for MP4; then `free` (8) + `mdat` (8) → one large-size mdat header.
|
|
let ftyp_len = u32::from_be_bytes(file[0..4].try_into().unwrap()) as usize;
|
|
assert_eq!(&file[ftyp_len + 4..ftyp_len + 8], b"free");
|
|
let mdat_len = (moov_at - ftyp_len) as u64;
|
|
file[ftyp_len..ftyp_len + 4].copy_from_slice(&1u32.to_be_bytes());
|
|
file[ftyp_len + 4..ftyp_len + 8].copy_from_slice(b"mdat");
|
|
file[ftyp_len + 8..ftyp_len + 16].copy_from_slice(&mdat_len.to_be_bytes());
|
|
let g = open(file.as_slice()).unwrap();
|
|
assert_eq!(g.read_sample(file.as_slice(), 0, 0).unwrap(), b"abc");
|
|
// size == 0 ("extends to end of file") on a trailing box.
|
|
let mut z = file.clone();
|
|
z.extend_from_slice(&[0, 0, 0, 0, b'f', b'r', b'e', b'e', 1, 2, 3]);
|
|
assert!(open(z.as_slice()).is_ok());
|
|
}
|
|
|
|
/// Found by `progressive_roundtrip`: only zero-byte samples must not be written as uniform
|
|
/// `stsz` size 0, which readers take to mean "a size table follows".
|
|
#[test]
|
|
fn all_empty_samples_roundtrip() {
|
|
for n in [1usize, 3] {
|
|
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
|
w.add_track(TrackConfig::new(entry(0), 1000)).unwrap();
|
|
for _ in 0..n {
|
|
w.write_sample(0, WriteSample { data: &[], duration: 1, composition_offset: 0, is_sync: false }).unwrap();
|
|
}
|
|
let out = w.finish().unwrap().into_inner();
|
|
let f = open(out.as_slice()).unwrap();
|
|
assert_eq!(f.tracks[0].samples.len(), n);
|
|
assert!(f.tracks[0].samples.iter().all(|s| s.size == 0));
|
|
}
|
|
}
|
|
|
|
/// An output that keeps only writes under 1 MiB and reads everything else as zeros, so tests can
|
|
/// write files past 4 GiB from a reused zero buffer.
|
|
#[derive(Default)]
|
|
struct Sparse {
|
|
pos: u64,
|
|
len: u64,
|
|
kept: BTreeMap<u64, Vec<u8>>,
|
|
}
|
|
|
|
impl Write for Sparse {
|
|
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
|
|
let end = self.pos + b.len() as u64;
|
|
self.kept.retain(|&at, _| at < self.pos || at >= end);
|
|
if b.len() < 1 << 20 {
|
|
self.kept.insert(self.pos, b.to_vec());
|
|
}
|
|
self.pos += b.len() as u64;
|
|
self.len = self.len.max(self.pos);
|
|
Ok(b.len())
|
|
}
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Seek for Sparse {
|
|
fn seek(&mut self, to: SeekFrom) -> std::io::Result<u64> {
|
|
self.pos = match to {
|
|
SeekFrom::Start(p) => p,
|
|
SeekFrom::End(d) => self.len.checked_add_signed(d).unwrap(),
|
|
SeekFrom::Current(d) => self.pos.checked_add_signed(d).unwrap(),
|
|
};
|
|
Ok(self.pos)
|
|
}
|
|
}
|
|
|
|
impl ByteSource for Sparse {
|
|
fn len(&self) -> u64 {
|
|
self.len
|
|
}
|
|
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
|
|
buf.fill(0);
|
|
let end = offset + buf.len() as u64;
|
|
for (&at, data) in &self.kept {
|
|
let (lo, hi) = (at.max(offset), (at + data.len() as u64).min(end));
|
|
if lo < hi {
|
|
buf[(lo - offset) as usize..(hi - offset) as usize].copy_from_slice(&data[(lo - at) as usize..(hi - at) as usize]);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
const MIB: usize = 1 << 20;
|
|
|
|
#[test]
|
|
fn writer_uses_co64_and_largesize_mdat_past_4_gib() {
|
|
let zeros = vec![0u8; MIB];
|
|
let mut w = Mp4Writer::new(Sparse::default(), WriterOptions::new(Brand::Mp4)).unwrap();
|
|
let t = w.add_track(TrackConfig::new(entry(3), 30)).unwrap();
|
|
let n = 4 * 1024 + 1;
|
|
for _ in 0..n {
|
|
w.write_sample(t, WriteSample { data: &zeros, duration: 1, composition_offset: 0, is_sync: true }).unwrap();
|
|
}
|
|
w.write_sample(t, WriteSample { data: b"abc", duration: 1, composition_offset: 0, is_sync: true }).unwrap();
|
|
let out = w.finish().unwrap();
|
|
let f = open(&out).unwrap();
|
|
let s = &f.tracks[0].samples;
|
|
assert_eq!(s.len(), n + 1);
|
|
assert!(s[n].offset > u64::from(u32::MAX));
|
|
assert_eq!(f.read_sample(&out, 0, n).unwrap(), b"abc");
|
|
}
|
|
|
|
#[test]
|
|
fn pcm_past_4_gib_reads_back_whole() {
|
|
let pcm = PcmConfig { bits: 16, float: false, big_endian: true, signed: true, channels: 2, sample_rate: 48000.0 };
|
|
let zeros = vec![0u8; MIB];
|
|
let mut w = Mp4Writer::new(Sparse::default(), WriterOptions::new(Brand::Mov)).unwrap();
|
|
let t = w.add_track(TrackConfig::new(SampleEntry::pcm(pcm), 48000)).unwrap();
|
|
let writes = 4 * 1024 + 1;
|
|
for _ in 0..writes {
|
|
w.write_sample(t, WriteSample { data: &zeros, duration: 0, composition_offset: 0, is_sync: true }).unwrap();
|
|
}
|
|
let out = w.finish().unwrap();
|
|
let f = open(&out).unwrap();
|
|
let s = &f.tracks[0].samples;
|
|
let bytes = writes as u64 * MIB as u64;
|
|
assert_eq!(s.iter().map(|s| u64::from(s.size)).sum::<u64>(), bytes);
|
|
assert_eq!(s.iter().map(|s| u64::from(s.duration)).sum::<u64>(), bytes / 4);
|
|
assert!(s.iter().all(|s| s.size <= 1 << 20));
|
|
}
|