diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5254da1d2..fdc11ab03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1107,6 +1107,7 @@ jobs: python3 -I tools/compat-corpus/copy_check.py --records "$COREAV_FFMPEG_OUT/records.json" \ --mediainfo "$RUNNER_TEMP/coreav-oracle-bin/mediainfo" \ --report "$RUNNER_TEMP/coreav-ffmpeg.json" + COREAV_CURRENT_CI_ORACLE=1 python3 -I -m unittest discover -s tools/compat-corpus/tests -p test_copy_check.py - name: Preserve the writers' reports if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/crates/coreav-cli/tests/ffmpeg.rs b/crates/coreav-cli/tests/ffmpeg.rs index ddd9adcda..15a901100 100644 --- a/crates/coreav-cli/tests/ffmpeg.rs +++ b/crates/coreav-cli/tests/ffmpeg.rs @@ -2081,16 +2081,17 @@ const FAMILIES: [(&str, &[&str]); 2] = [ ("matroska-webm", &["mkv", "webm"]), ]; -const INPUT_SEEK_REFUSALS: [(&str, usize, &str); 17] = [ +const INPUT_SEEK_SUCCESSES: [(&str, usize, &str); 2] = + [("C18", 2, "hls_h264.mp4"), ("C18", 2, "hls_long_gop.mp4")]; + +const INPUT_SEEK_REFUSALS: [(&str, usize, &str); 15] = [ ("C18", 2, "edit_multi_empty.mp4"), ("C18", 2, "edit_multi_audio.mp4"), ("C18", 2, "edit_multi_audio_average.mp4"), ("C18", 2, "edit_multi_av.mp4"), ("C18", 2, "chapters_sized.mp4"), - ("C18", 2, "hls_h264.mp4"), ("C18", 2, "hls_open_gop.mp4"), ("C18", 2, "hls_hevc.mp4"), - ("C18", 2, "hls_long_gop.mp4"), ("C18", 2, "fragmented_sidx.mp4"), ("C18", 2, "fragmented_many.mp4"), ("C19", 0, "edit_multi_audio.mp4"), @@ -2108,9 +2109,16 @@ fn input_seek_refusal_contract_keeps_its_exact_command_scope() { )) .unwrap(); let unique: BTreeSet<_> = INPUT_SEEK_REFUSALS.into_iter().collect(); - assert_eq!(unique.len(), 17); - assert_eq!(unique.iter().filter(|(id, _, _)| *id == "C18").count(), 11); + assert_eq!(unique.len(), 15); + assert_eq!(unique.iter().filter(|(id, _, _)| *id == "C18").count(), 9); assert_eq!(unique.iter().filter(|(id, _, _)| *id == "C19").count(), 6); + let successes: BTreeSet<_> = INPUT_SEEK_SUCCESSES.into_iter().collect(); + assert_eq!( + successes, + BTreeSet::from([("C18", 2, "hls_h264.mp4"), ("C18", 2, "hls_long_gop.mp4")]) + ); + assert!(unique.is_disjoint(&successes)); + assert_eq!(unique.union(&successes).count(), 17); for number in [0, 1] { assert_eq!( unique @@ -2120,7 +2128,7 @@ fn input_seek_refusal_contract_keeps_its_exact_command_scope() { 3 ); } - for (id, number, _) in unique { + for &(id, number, _) in unique.union(&successes) { let entry = set["entries"] .as_array() .unwrap() @@ -2333,6 +2341,7 @@ fn the_0_1_0_commands_on_the_fixtures() { let mut records = Vec::new(); let mut native = BTreeSet::new(); let mut required_refusals = BTreeSet::new(); + let mut required_successes = BTreeSet::new(); for entry in set["entries"].as_array().unwrap() { if entry["tool"] != "ffmpeg" { continue; @@ -2419,6 +2428,13 @@ fn the_0_1_0_commands_on_the_fixtures() { verify_input_seek_refusal(&args, &output); required_refusals.insert((id, n, name.as_str())); } + if family == "mp4-mov" && INPUT_SEEK_SUCCESSES.contains(&(id, n, name.as_str())) { + assert_eq!( + status, "written", + "{label}: exact-success witness did not run natively" + ); + required_successes.insert((id, n, name.as_str())); + } // Not valid HEVC (ITU-T H.265 7.4.3.2.1): no command writes // an output from these, a copy included. if ["size_hevc_mixed_window.mp4", "size_hevc_window.mp4"].contains(&name.as_str()) { @@ -2446,6 +2462,15 @@ fn the_0_1_0_commands_on_the_fixtures() { } } for family in families.iter().filter(|f| present.contains(*f)) { + for witness in INPUT_SEEK_SUCCESSES + .iter() + .filter(|(entry, _, _)| *entry == id && *family == "mp4-mov") + { + assert!( + required_successes.contains(witness), + "{witness:?}: exact-success witness was not verified" + ); + } let required: Vec<_> = INPUT_SEEK_REFUSALS .iter() .filter(|(entry, _, _)| *entry == id && *family == "mp4-mov") diff --git a/crates/coreav-cli/tests/ffmpeg_input_seek.rs b/crates/coreav-cli/tests/ffmpeg_input_seek.rs index 48ad7d764..a01729da6 100644 --- a/crates/coreav-cli/tests/ffmpeg_input_seek.rs +++ b/crates/coreav-cli/tests/ffmpeg_input_seek.rs @@ -54,6 +54,565 @@ fn pixels(path: &Path, ffmpeg: &Path) -> Vec { result.stdout } +fn pcm(path: &Path, ffmpeg: &Path) -> Vec { + let result = Command::new(ffmpeg) + .args(["-nostdin", "-v", "error"]) + .args(include_str!("../../../scripts/oracle-audio-args.txt").split_whitespace()) + .arg("-i") + .arg(path) + .args([ + "-map", + "0:a:0", + "-vn", + "-c:a", + "pcm_f32le", + "-f", + "f32le", + "pipe:1", + ]) + .output() + .unwrap(); + assert!(result.status.success() && result.stderr.is_empty()); + assert!(!result.stdout.is_empty()); + result.stdout +} + +mod iso { + #[derive(Clone, Copy)] + struct BoxSpan { + kind: [u8; 4], + body: usize, + end: usize, + } + + fn u32_at(bytes: &[u8], at: usize) -> u32 { + u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) + } + + fn u64_at(bytes: &[u8], at: usize) -> u64 { + u64::from_be_bytes(bytes[at..at + 8].try_into().unwrap()) + } + + fn children(bytes: &[u8], start: usize, end: usize) -> Vec { + let mut boxes = Vec::new(); + let mut at = start; + while at < end { + assert!(at + 8 <= end); + let kind = bytes[at + 4..at + 8].try_into().unwrap(); + let (header, size) = match u32_at(bytes, at) { + 0 => (8, end - at), + 1 => (16, usize::try_from(u64_at(bytes, at + 8)).unwrap()), + n => (8, n as usize), + }; + assert!(size >= header && size <= end - at); + boxes.push(BoxSpan { + kind, + body: at + header, + end: at + size, + }); + at += size; + } + boxes + } + + fn child(bytes: &[u8], parent: BoxSpan, kind: &[u8; 4]) -> BoxSpan { + let found: Vec<_> = children(bytes, parent.body, parent.end) + .into_iter() + .filter(|b| &b.kind == kind) + .collect(); + assert_eq!(found.len(), 1, "one {:?} box", kind); + found[0] + } + + fn tracks(bytes: &[u8]) -> Vec { + let root = BoxSpan { + kind: *b"root", + body: 0, + end: bytes.len(), + }; + let moov = child(bytes, root, b"moov"); + children(bytes, moov.body, moov.end) + .into_iter() + .filter(|b| &b.kind == b"trak") + .collect() + } + + fn stbl(bytes: &[u8], track: BoxSpan) -> BoxSpan { + let mdia = child(bytes, track, b"mdia"); + child(bytes, child(bytes, mdia, b"minf"), b"stbl") + } + + fn edit(bytes: &[u8], track: BoxSpan) -> BoxSpan { + child(bytes, child(bytes, track, b"edts"), b"elst") + } + + fn clock_duration(bytes: &[u8], header: BoxSpan) -> (u32, u64) { + match bytes[header.body] { + 0 => ( + u32_at(bytes, header.body + 12), + u64::from(u32_at(bytes, header.body + 16)), + ), + 1 => ( + u32_at(bytes, header.body + 20), + u64_at(bytes, header.body + 24), + ), + _ => panic!("time header version"), + } + } + + fn runs(bytes: &[u8], table: BoxSpan, samples: usize, signed: bool) -> Vec { + let count = u32_at(bytes, table.body + 4) as usize; + assert_eq!(table.end - table.body, 8 + count * 8); + let mut result = Vec::new(); + for index in 0..count { + let at = table.body + 8 + index * 8; + let n = u32_at(bytes, at) as usize; + assert!(n > 0 && n <= samples - result.len()); + let value = if signed { + i64::from(u32_at(bytes, at + 4) as i32) + } else { + i64::from(u32_at(bytes, at + 4)) + }; + result.extend(std::iter::repeat_n(value, n)); + } + assert_eq!(result.len(), samples); + result + } + + pub(super) fn validate(bytes: &[u8]) { + let root = BoxSpan { + kind: *b"root", + body: 0, + end: bytes.len(), + }; + let moov = child(bytes, root, b"moov"); + let mdat = child(bytes, root, b"mdat"); + let (movie_clock, movie_duration) = clock_duration(bytes, child(bytes, moov, b"mvhd")); + assert!(movie_clock > 0); + let tracks = tracks(bytes); + assert_eq!(tracks.len(), 2); + let mut movie_end = 0; + let mut extents = Vec::new(); + for (index, track) in tracks.into_iter().enumerate() { + let mdia = child(bytes, track, b"mdia"); + let (clock, media_duration) = clock_duration(bytes, child(bytes, mdia, b"mdhd")); + assert!(clock > 0); + let table = stbl(bytes, track); + let sizes = child(bytes, table, b"stsz"); + let constant = u32_at(bytes, sizes.body + 4); + let n = u32_at(bytes, sizes.body + 8) as usize; + assert!(n > 0 && n <= bytes.len()); + assert_eq!( + sizes.end - sizes.body, + 12 + if constant == 0 { n * 4 } else { 0 } + ); + let sizes: Vec<_> = (0..n) + .map(|i| { + if constant != 0 { + constant + } else { + u32_at(bytes, sizes.body + 12 + i * 4) + } + }) + .collect(); + assert!(sizes.iter().all(|size| *size > 0)); + let durations = runs(bytes, child(bytes, table, b"stts"), n, false); + assert!(durations.iter().all(|delta| *delta > 0)); + assert_eq!(durations.iter().sum::() as u64, media_duration); + let offsets = match children(bytes, table.body, table.end) + .into_iter() + .find(|b| &b.kind == b"ctts") + { + Some(ctts) => { + assert!(bytes[ctts.body] <= 1); + runs(bytes, ctts, n, bytes[ctts.body] == 1) + } + None => vec![0; n], + }; + assert!(offsets.iter().all(|offset| *offset >= 0)); + let mut decode = 0; + let mut composition = std::collections::HashSet::new(); + let mut end = 0; + for (delta, offset) in durations.iter().zip(offsets) { + let time = decode + offset; + assert!(composition.insert(time), "unique composition times"); + end = end.max(time + delta); + decode += delta; + } + let elst = edit(bytes, track); + assert_eq!(u32_at(bytes, elst.body + 4), 1); + let (duration, media_time, rate) = match bytes[elst.body] { + 0 => ( + u64::from(u32_at(bytes, elst.body + 8)), + i64::from(u32_at(bytes, elst.body + 12) as i32), + u32_at(bytes, elst.body + 16), + ), + 1 => ( + u64_at(bytes, elst.body + 8), + u64_at(bytes, elst.body + 16) as i64, + u32_at(bytes, elst.body + 24), + ), + _ => panic!("edit version"), + }; + assert!(duration > 0 && media_time >= 0); + assert_eq!(rate, 65_536); + assert_eq!( + i128::from(end - media_time) * i128::from(movie_clock), + i128::from(duration) * i128::from(clock), + "complete presentation span" + ); + let tkhd = child(bytes, track, b"tkhd"); + let volume_at = tkhd.body + if bytes[tkhd.body] == 0 { 36 } else { 48 }; + assert_eq!( + u16::from_be_bytes(bytes[volume_at..volume_at + 2].try_into().unwrap()), + if index == 0 { 0 } else { 256 } + ); + let track_duration = if bytes[tkhd.body] == 0 { + u64::from(u32_at(bytes, tkhd.body + 20)) + } else { + u64_at(bytes, tkhd.body + 28) + }; + assert_eq!(track_duration, duration); + movie_end = movie_end.max(duration); + let offsets_box = children(bytes, table.body, table.end) + .into_iter() + .find(|b| matches!(&b.kind, b"stco" | b"co64")) + .unwrap(); + let chunks = u32_at(bytes, offsets_box.body + 4) as usize; + let width = if &offsets_box.kind == b"stco" { 4 } else { 8 }; + assert_eq!(offsets_box.end - offsets_box.body, 8 + width * chunks); + let stsc = child(bytes, table, b"stsc"); + let entries = u32_at(bytes, stsc.body + 4) as usize; + assert!(entries > 0 && entries <= chunks); + assert_eq!(stsc.end - stsc.body, 8 + entries * 12); + let runs: Vec<_> = (0..entries) + .map(|i| { + let at = stsc.body + 8 + i * 12; + assert_eq!(u32_at(bytes, at + 8), 1, "sample description"); + (u32_at(bytes, at) as usize, u32_at(bytes, at + 4) as usize) + }) + .collect(); + assert_eq!(runs[0].0, 1); + assert!(runs.windows(2).all(|w| w[0].0 < w[1].0)); + assert!( + runs.iter() + .all(|(first, count)| *first <= chunks && *count > 0) + ); + let mut run = 0; + let mut sample = 0; + for chunk in 1..=chunks { + while run + 1 < runs.len() && runs[run + 1].0 <= chunk { + run += 1; + } + let at = offsets_box.body + 8 + (chunk - 1) * width; + let mut offset = if width == 4 { + u64::from(u32_at(bytes, at)) + } else { + u64_at(bytes, at) + }; + for _ in 0..runs[run].1 { + assert!(sample < n); + let end = offset + u64::from(sizes[sample]); + assert!( + offset >= mdat.body as u64 && end <= mdat.end as u64, + "sample extent" + ); + extents.push((offset, end)); + offset = end; + sample += 1; + } + } + assert_eq!(sample, n); + if index == 1 { + let sgpd = child(bytes, table, b"sgpd"); + assert_eq!( + &bytes[sgpd.body..sgpd.end], + &[ + 1, 0, 0, 0, b'r', b'o', b'l', b'l', 0, 0, 0, 2, 0, 0, 0, 1, 255, 255 + ] + ); + let sbgp = child(bytes, table, b"sbgp"); + assert_eq!(sbgp.end - sbgp.body, 20); + assert_eq!( + &bytes[sbgp.body..sbgp.body + 8], + &[0, 0, 0, 0, b'r', b'o', b'l', b'l'] + ); + assert_eq!(u32_at(bytes, sbgp.body + 8), 1); + assert_eq!(u32_at(bytes, sbgp.body + 12), n as u32, "roll membership"); + assert_eq!(u32_at(bytes, sbgp.body + 16), 1); + } + } + assert_eq!(movie_duration, movie_end); + extents.sort_unstable(); + assert_eq!(extents.first().unwrap().0, mdat.body as u64); + assert_eq!(extents.last().unwrap().1, mdat.end as u64); + assert!( + extents.windows(2).all(|pair| pair[0].1 == pair[1].0), + "complete nonoverlapping extents" + ); + } + + pub(super) fn mutations(bytes: &[u8]) { + let tracks = tracks(bytes); + let video = stbl(bytes, tracks[0]); + let audio = stbl(bytes, tracks[1]); + let elst = edit(bytes, tracks[0]); + assert_eq!(bytes[elst.body], 0); + let ctts = child(bytes, video, b"ctts"); + assert_eq!(u32_at(bytes, ctts.body + 8), 1); + let delta = u32_at(bytes, child(bytes, video, b"stts").body + 12); + let duplicate = delta + u32_at(bytes, ctts.body + 20); + let video_offset = child(bytes, video, b"stco").body + 8; + for (at, value) in [ + (elst.body + 12, 0), + (elst.body + 8, u32_at(bytes, elst.body + 8) + 1), + (ctts.body + 12, duplicate), + (ctts.body + 12, u32::MAX), + ( + child(bytes, video, b"stsz").body + 8, + u32_at(bytes, child(bytes, video, b"stsz").body + 8) + 1, + ), + (child(bytes, video, b"stsc").body + 16, 2), + ( + child(bytes, audio, b"stco").body + 8, + u32_at(bytes, video_offset), + ), + (child(bytes, audio, b"sbgp").body + 12, 1), + ] { + let mut changed = bytes.to_vec(); + changed[at..at + 4].copy_from_slice(&value.to_be_bytes()); + assert!( + std::panic::catch_unwind(|| validate(&changed)).is_err(), + "mutation at {at}" + ); + } + } +} + +fn exact_copy( + ffmpeg: &Path, + ffprobe: &Path, + args: &[&str], + output: &Path, + reference: &Path, + existing: bool, +) -> Vec { + let oracle = Command::new(ffmpeg) + .args(["-nostdin", "-v", "error"]) + .args(include_str!("../../../scripts/oracle-audio-args.txt").split_whitespace()) + .args(args) + .arg(reference) + .output() + .unwrap(); + assert!( + oracle.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&oracle.stderr) + ); + if existing { + std::fs::write(output, b"old output").unwrap(); + } + let entries = std::fs::read_dir(output.parent().unwrap()).unwrap().count(); + let result = Command::new(env!("CARGO_BIN_EXE_coreav")) + .arg("ffmpeg") + .args(args) + .arg(output) + .env("COREAV_STRICT_SAFE", "1") + .env("COREAV_FALLBACK", output.with_extension("missing")) + .output() + .unwrap(); + assert!( + result.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert!(result.stdout.is_empty() && result.stderr.is_empty()); + assert_eq!( + std::fs::read_dir(output.parent().unwrap()).unwrap().count(), + entries + usize::from(!existing) + ); + assert_eq!( + snapshot(output, ffprobe), + snapshot(reference, ffprobe), + "{args:?}" + ); + assert_eq!( + pixels(output, ffmpeg), + pixels(reference, ffmpeg), + "{args:?}" + ); + assert_eq!(pcm(output, ffmpeg), pcm(reference, ffmpeg), "{args:?}"); + let bytes = std::fs::read(output).unwrap(); + iso::validate(&bytes); + iso::validate(&std::fs::read(reference).unwrap()); + bytes +} + +#[test] +fn closed_gop_input_seek_witnesses_match_complete_outputs() { + let pair = test_oracle::require(); + let ffmpeg = pair.ffmpeg(); + let owner = FixtureDir( + Path::new(env!("CARGO_TARGET_TMPDIR")) + .join(format!("ffmpeg-input-seek-c18-{}", std::process::id())), + ); + std::fs::create_dir(&owner.0).unwrap(); + for (name, parameters) in [ + ( + "hls_h264.mp4", + "keyint=50:min-keyint=50:scenecut=0:bframes=3:b-pyramid=normal", + ), + ("hls_long_gop.mp4", "keyint=125:min-keyint=125:scenecut=0"), + ] { + let input = owner.0.join(name); + let generated = Command::new(ffmpeg) + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "testsrc2=size=320x240:rate=25:duration=20", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=20:sample_rate=48000", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-x264-params", + parameters, + "-c:a", + "aac", + "-shortest", + ]) + .arg(&input) + .output() + .unwrap(); + assert!( + generated.status.success(), + "{}", + String::from_utf8_lossy(&generated.stderr) + ); + let output = owner.0.join("native.mp4"); + let reference = owner.0.join("reference.mp4"); + let args = [ + "-ss", + "00:00:09", + "-i", + input.to_str().unwrap(), + "-t", + "00:00:03", + "-vcodec", + "copy", + "-acodec", + "copy", + "-y", + ]; + for existing in [false, true] { + exact_copy(ffmpeg, pair.ffprobe(), &args, &output, &reference, existing); + std::fs::remove_file(&output).unwrap(); + } + } +} + +#[test] +fn audio_bearing_input_seek_matches_complete_packets_and_presented_samples() { + let pair = test_oracle::require(); + let ffmpeg = pair.ffmpeg(); + let ffprobe = ffmpeg.with_file_name("ffprobe"); + let owner = FixtureDir( + Path::new(env!("CARGO_TARGET_TMPDIR")) + .join(format!("ffmpeg-input-seek-av-{}", std::process::id())), + ); + std::fs::create_dir(&owner.0).unwrap(); + for (rate, audio_rate, between_keys) in [ + ("24", "44100", "0.5"), + ("30", "48000", "0.95"), + ("10", "44100", "0.2"), + ("10", "48000", "0.2"), + ] { + for bframes in ["0", "2"] { + let input = owner + .0 + .join(format!("source-{rate}-{audio_rate}-{bframes}.mp4")); + let video_input = format!("testsrc2=size=96x64:rate={rate}:duration=4"); + let audio_input = format!("sine=frequency=713:sample_rate={audio_rate}:duration=4"); + let generated = Command::new(ffmpeg) + .args([ + "-nostdin", + "-v", + "error", + "-f", + "lavfi", + "-i", + &video_input, + "-f", + "lavfi", + "-i", + &audio_input, + "-c:v", + "libx264", + "-threads:v", + "1", + "-bf", + bframes, + "-g", + rate, + "-keyint_min", + rate, + "-sc_threshold", + "0", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + ]) + .arg(&input) + .output() + .unwrap(); + assert!( + generated.status.success(), + "{}", + String::from_utf8_lossy(&generated.stderr) + ); + for seek in ["0", between_keys, "1"] { + for bound in ["-t", "-to"] { + for existing in [false, true] { + let output = owner.0.join("native.mp4"); + let reference = owner.0.join("reference.mp4"); + let args = [ + "-y", + "-ss", + seek, + "-i", + input.to_str().unwrap(), + "-c", + "copy", + bound, + "1.25", + ]; + let bytes = + exact_copy(ffmpeg, &ffprobe, &args, &output, &reference, existing); + if rate == "24" + && bframes == "2" + && seek == "0.5" + && bound == "-t" + && !existing + { + iso::mutations(&bytes); + } + std::fs::remove_file(output).unwrap(); + } + } + } + } + } +} + #[test] fn input_seek_copy_keeps_only_exact_packet_timelines() { let pair = test_oracle::require(); @@ -168,6 +727,9 @@ fn input_seek_copy_keeps_only_exact_packet_timelines() { pixels(&reference, &ffmpeg), "{args:?}" ); + if !after.contains(&"-an") { + assert_eq!(pcm(&output, &ffmpeg), pcm(&reference, &ffmpeg), "{args:?}"); + } } else { assert!( String::from_utf8_lossy(&strict.stderr).contains(if seek.starts_with('-') { @@ -246,7 +808,8 @@ fn input_seek_copy_keeps_only_exact_packet_timelines() { seek, &[], &after, - *bframes == "0" && selection == "video" && seek != "0.2", + seek != "0.2" + && (selection == "av" || (*bframes == "0" && selection == "video")), ); } } @@ -269,6 +832,35 @@ fn input_seek_copy_keeps_only_exact_packet_timelines() { .unwrap(); assert!(result.status.success()); check(&tagged, "0.5", &[], &["-an", "-t", "1"], false); + check(&tagged, "0.5", &[], &["-t", "1"], false); + let custom_handler = dir.join("audio-handler.mp4"); + let made = Command::new(&ffmpeg) + .args(["-nostdin", "-v", "error", "-i"]) + .arg(inter) + .args([ + "-c", + "copy", + "-metadata:s:a:0", + "handler_name=Unpreserved audio handler", + ]) + .arg(&custom_handler) + .output() + .unwrap(); + assert!(made.status.success()); + check(&custom_handler, "0.5", &[], &["-t", "1"], false); + let volume = dir.join("audio-volume.mp4"); + let mut bytes = std::fs::read(inter).unwrap(); + let tkhd = bytes + .windows(4) + .enumerate() + .filter(|(_, kind)| *kind == b"tkhd") + .nth(1) + .unwrap() + .0; + assert_eq!(bytes[tkhd + 4], 0); + bytes[tkhd + 40..tkhd + 42].copy_from_slice(&128u16.to_be_bytes()); + std::fs::write(&volume, bytes).unwrap(); + check(&volume, "0.5", &[], &["-t", "1"], false); for (scale, source, seek, native) in [ ("1000", inter, "0.5", false), ("8000", inter, "0.5", false), diff --git a/crates/coreav-isobmff-write/src/clip.rs b/crates/coreav-isobmff-write/src/clip.rs index 8a5e99496..c5af8bd2b 100644 --- a/crates/coreav-isobmff-write/src/clip.rs +++ b/crates/coreav-isobmff-write/src/clip.rs @@ -26,6 +26,8 @@ use coreav_isobmff::{ use std::collections::VecDeque; use std::io::Write; +mod input_seek; + /// Output track IDs: the video is track 1, the audio track 2; audio alone /// is track 1. const VIDEO_ID: u32 = 1; @@ -61,9 +63,11 @@ pub fn clip_mp4( } /// Copies an input-seek interval only when native clipping preserves its -/// complete packet timeline. Audio, composition offsets, shifted cut points, -/// several edits, video clocks below 10,000 ticks per second and partial-sample -/// bounds refuse before any output is written. +/// complete packet timeline. Video-only copies refuse composition offsets, +/// shifted cut points and partial-sample bounds. Bounded AVC/AAC copies use +/// complete decode-ordered packets and presentation edits. Several edits, +/// unqualified metadata and video clocks below 10,000 ticks per second refuse +/// before any output is written. pub fn clip_mp4_input_seek( source: S, limits: &Limits, @@ -95,8 +99,20 @@ fn clip_mp4_inner( return Err(unsupported("the clip ends at or before its start").into()); } } - if input_seek && selected_audio.is_some() { - return Err(unsupported("an input-seek copy with audio").into()); + if input_seek { + if let Some(audio) = selected_audio { + return input_seek::copy_av( + &movie, + video, + audio, + start, + end, + source, + limits, + out, + (video_entry, audio_entry.ok_or_else(overflow)?), + ); + } } if input_seek && video.edit_list == EditList::Multiple { return Err(unsupported("an input-seek copy with several video edits").into()); @@ -842,10 +858,10 @@ pub(crate) fn audio_track<'t>( /// the chunks in `mdat`, through `window`. Fails with /// [`ExtractError::SourceChanged`] when a run holds other samples than its /// tables. -pub(crate) fn copy_cut<'p, 'a, S: Source, W: Write>( +pub(crate) fn copy_cut<'p, 'a, S: Source, W: Write, const STORED: bool>( shared: &'a RefCell, - mut first: Run<'p, 'a, S>, - mut second: Option>, + mut first: Run<'p, 'a, S, STORED>, + mut second: Option>, window: &mut Window, out: &mut W, ) -> Result<(), ExtractError> { @@ -889,7 +905,7 @@ pub(crate) fn copy_cut<'p, 'a, S: Source, W: Write>( Ok(()) }, )?; - let complete = |run: &Run<'_, '_, S>| run.copied == run.tables.samples(); + let complete = |run: &Run<'_, '_, S, STORED>| run.copied == run.tables.samples(); if !complete(&first) || second.as_ref().is_some_and(|run| !complete(run)) { return Err(ExtractError::SourceChanged); } @@ -2541,7 +2557,7 @@ pub(crate) fn media_duration( } /// One track's kept run in the copy pass. -pub(crate) struct Run<'p, 'a, S: Source> { +pub(crate) struct Run<'p, 'a, S: Source, const STORED: bool = false> { packets: &'p mut Packets<'a, Shared<'a, S>>, tables: &'p Tables, copied: u32, @@ -2557,10 +2573,42 @@ impl<'p, 'a, S: Source> Run<'p, 'a, S> { packets: &'p mut Packets<'a, Shared<'a, S>>, skip: u64, tables: &'p Tables, + ) -> Result { + Self::prepare(packets, skip, tables) + } +} + +impl<'p, 'a, S: Source> Run<'p, 'a, S, true> { + pub(crate) fn stored( + packets: &'p mut Packets<'a, Shared<'a, S>>, + skip: u64, + tables: &'p Tables, + ) -> Result { + Self::prepare(packets, skip, tables) + } +} + +impl<'p, 'a, S: Source, const STORED: bool> Run<'p, 'a, S, STORED> { + fn packet(packets: &mut Packets<'a, Shared<'a, S>>) -> Option> { + if STORED { + packets + .next_run(1) + .map(|run| run.map(|run| (run.offset, run.sample_size))) + } else { + packets + .next() + .map(|packet| packet.map(|packet| (packet.offset, packet.size))) + } + } + + fn prepare( + packets: &'p mut Packets<'a, Shared<'a, S>>, + skip: u64, + tables: &'p Tables, ) -> Result { let mut skipped = 0u64; while skipped < skip { - match packets.next() { + match Self::packet(packets) { Some(packet) => { packet?; } @@ -2571,10 +2619,9 @@ impl<'p, 'a, S: Source> Run<'p, 'a, S> { let first = match tables.samples() { 0 => None, _ => { - let packet = packets - .next() + let packet = Self::packet(packets) .ok_or(unsupported("the source changed during the clip"))??; - Some((packet.offset, packet.size)) + Some(packet) } }; Ok(Self { @@ -2599,10 +2646,7 @@ impl<'p, 'a, S: Source> Run<'p, 'a, S> { fn next(&mut self) -> Result<(u64, u32), ExtractError> { let (offset, size) = match self.first.take() { Some(first) => first, - None => { - let packet = self.packets.next().ok_or(ExtractError::SourceChanged)??; - (packet.offset, packet.size) - } + None => Self::packet(self.packets).ok_or(ExtractError::SourceChanged)??, }; if self.tables.size(self.copied) != Some(size) { return Err(ExtractError::SourceChanged); diff --git a/crates/coreav-isobmff-write/src/clip/input_seek.rs b/crates/coreav-isobmff-write/src/clip/input_seek.rs new file mode 100644 index 000000000..ffeb5d76d --- /dev/null +++ b/crates/coreav-isobmff-write/src/clip/input_seek.rs @@ -0,0 +1,665 @@ +use super::{ + AUDIO_ID, Budget, COPY_BUFFER, ClipSummary, ClipTrack, Codec, EditEntry, EditList, + ExtractError, Limits, Media, Movie, MovieBox, Rational, RefCell, Run, Shared, Source, Tables, + Ties, Track, TrackBox, TrackFlags, UNIT_RATE, VIDEO_ID, Window, add, copy_cut, ftyp, int, + language, movie_timescale, overflow, scale, to_i64, to_u64, unsupported, write_head, +}; +use crate::moov::{CopiedBox, edited_decode_start}; +use crate::replace::roll_boxes_all; +use crate::scan; +use coreav_isobmff::{Error, Packets, Profile}; +use std::borrow::Cow; +use std::io::Write; + +fn unqualified() -> Error { + unsupported("an input-seek copy with an unqualified audio-bearing packet timeline") +} + +fn ticks(time: Rational, clock: u32) -> Result { + let value = scale(time, clock, 1)?; + if value.den != 1 { + return Err(unqualified()); + } + to_i64(value.num) +} + +fn edit(track: &Track) -> Result { + match track.edit_list { + EditList::Simple { + empty: 0, + media_time, + segment, + } if media_time >= 0 + && segment > 0 + && track.edits.len() == 1 + && track + .edits + .first() + .is_some_and(|e| e.media_rate == UNIT_RATE) => + { + Ok(media_time) + } + _ => Err(unqualified()), + } +} + +#[derive(Clone, Copy)] +struct Sample { + sample: u64, + offset: u64, + size: u32, + dts: i64, + cts: i64, + duration: u32, + keyframe: bool, +} + +fn next_sample(packets: &mut Packets<'_, S>) -> Option> { + packets.next_run(1).map(|run| { + let run = run?; + let cts = run + .dts + .checked_add(run.composition_offset) + .ok_or(Error::Overflow { offset: run.offset })?; + Ok(Sample { + sample: run.first_sample, + offset: run.offset, + size: run.sample_size, + dts: run.dts, + cts, + duration: run.sample_duration, + keyframe: run.keyframe, + }) + }) +} + +struct Composition { + duration: Option, + next: Option, + times: Vec, + samples: usize, +} + +impl Composition { + fn new(samples: u64, limit: u64) -> Result { + let bytes = samples.checked_mul(8).ok_or_else(overflow)?; + if bytes > limit { + return Err(table_limit()); + } + let samples = usize::try_from(samples).map_err(|_| table_limit())?; + let mut times = Vec::new(); + times + .try_reserve_exact(samples) + .map_err(|_| table_limit())?; + Ok(Self { + duration: None, + next: None, + times, + samples, + }) + } + + fn push(&mut self, packet: &Sample) -> Result<(), Error> { + if packet.duration == 0 + || self.duration.is_some_and(|d| d != packet.duration) + || self.next.is_some_and(|d| d != packet.dts) + || packet.cts < packet.dts + || self.times.len() == self.samples + { + return Err(unqualified()); + } + self.duration = Some(packet.duration); + self.next = Some( + packet + .dts + .checked_add(i64::from(packet.duration)) + .ok_or_else(overflow)?, + ); + self.times.push(packet.cts); + Ok(()) + } + + fn finish(mut self) -> Result<(), Error> { + self.times.sort_unstable(); + if self.times.len() != self.samples + || self + .times + .windows(2) + .any(|pair| pair.first() == pair.get(1)) + { + return Err(unqualified()); + } + Ok(()) + } +} + +fn table_limit() -> Error { + Error::LimitExceeded { + limit: coreav_isobmff::LimitKind::TableBytes, + offset: 0, + } +} + +fn full_span(movie: &Movie, track: &Track, media: i64, end: i64) -> Result<(), Error> { + let EditList::Simple { segment, .. } = track.edit_list else { + return Err(unqualified()); + }; + let span = end.checked_sub(media).ok_or_else(overflow)?; + if i128::from(span) * i128::from(movie.timescale) + != i128::from(segment) * i128::from(track.timescale) + { + return Err(unqualified()); + } + Ok(()) +} + +fn video_start( + movie: &Movie, + video: &Track, + media: i64, + start: i64, + source: &mut S, + limits: &Limits, + budget: &mut Budget, +) -> Result { + let format = movie.access_format(video.index).ok_or_else(unqualified)?; + let mut packets = movie.packets(video.index, source, limits, budget)?; + let mut chosen = None; + let mut composition = Composition::new(u64::from(video.sample_count), limits.max_table_bytes)?; + let mut end = 0; + let mut last_sync = None; + let target = media.checked_add(start).ok_or_else(overflow)?; + while let Some(packet) = next_sample(&mut packets) { + let packet = packet?; + composition.push(&packet)?; + if packet.sample == 0 && packet.cts != media { + return Err(unqualified()); + } + end = end.max( + packet + .cts + .checked_add(i64::from(packet.duration)) + .ok_or_else(overflow)?, + ); + if packet.keyframe { + if last_sync.is_some_and(|last| last >= packet.cts) { + return Err(unqualified()); + } + last_sync = Some(packet.cts); + if packet.cts <= target { + if packets.starts_decoding_at( + format, + packet.offset, + packet.size, + movie.max_num_ref_frames(video.index), + )? != Some(true) + { + return Err(unqualified()); + } + chosen = Some(packet); + } + } + } + composition.finish()?; + full_span(movie, video, media, end)?; + chosen.ok_or_else(unqualified) +} + +fn audio_start( + movie: &Movie, + audio: &Track, + media: i64, + target: Rational, + source: &mut S, + limits: &Limits, + budget: &mut Budget, +) -> Result { + let mut packets = movie.packets(audio.index, source, limits, budget)?; + let denominator = i128::from(target.den); + let target = target.num; + let mut chosen = None; + let mut first = None; + let mut next = None; + let mut end = 0; + while let Some(packet) = next_sample(&mut packets) { + let packet = packet?; + if packet.duration == 0 || packet.cts != packet.dts || next.is_some_and(|d| d != packet.dts) + { + return Err(unqualified()); + } + next = Some( + packet + .dts + .checked_add(i64::from(packet.duration)) + .ok_or_else(overflow)?, + ); + end = next.ok_or_else(overflow)?; + first.get_or_insert(packet.sample); + let time = i128::from(packet.dts.checked_sub(media).ok_or_else(overflow)?) * denominator; + if target % denominator != 0 && time > target && time - target < denominator { + return Err(unqualified()); + } + if time <= target { + chosen = Some(packet.sample); + } + } + full_span(movie, audio, media, end)?; + chosen.or(first).ok_or_else(unqualified) +} + +struct Kept { + first: u64, + tables: Tables, + edits: Vec, + presented: u64, +} + +#[allow(clippy::too_many_arguments)] +fn build( + movie: &Movie, + track: &Track, + first: u64, + shift: i64, + end: i64, + media: i64, + movie_clock: u32, + video: bool, + source: &mut S, + limits: &Limits, + budget: &mut Budget, + table_budget: u64, +) -> Result { + let until = media.checked_add(end).ok_or_else(overflow)?; + let mut tables = Tables::with_chunk_bytes(track.timescale, table_budget, 0); + let mut packets = movie.packets(track.index, source, limits, budget)?; + let mut initial = None; + let mut max_end = i64::MIN; + while let Some(packet) = next_sample(&mut packets) { + let packet = packet?; + if packet.sample < first { + continue; + } + if packet.dts >= until { + break; + } + let (_, first_cts) = *initial.get_or_insert((packet.dts, packet.cts)); + if video && packet.cts < first_cts { + return Err(unqualified().into()); + } + max_end = max_end.max( + packet + .cts + .checked_add(i64::from(packet.duration)) + .ok_or_else(overflow)?, + ); + tables.push_sample( + packet.size, + packet.duration, + packet.keyframe, + packet.cts.checked_sub(packet.dts).ok_or_else(overflow)?, + )?; + } + tables.finish(); + let (first_dts, _) = initial.ok_or_else(unqualified)?; + let origin = media.checked_add(shift).ok_or_else(overflow)?; + let media_time = origin + .checked_sub(first_dts) + .filter(|v| *v >= 0) + .ok_or_else(unqualified)?; + let presented = max_end + .checked_sub(origin) + .filter(|v| *v > 0) + .ok_or_else(unqualified)?; + let scaled = scale(int(presented), movie_clock, track.timescale)?; + if scaled.den != 1 { + return Err(unqualified().into()); + } + let duration = to_u64(scaled.num)?; + let edits = vec![EditEntry { + duration, + media_time, + rate: UNIT_RATE, + }]; + tables.decode_start = edited_decode_start(&edits, track.timescale, movie_clock)?; + Ok(Kept { + first, + tables, + edits, + presented: u64::try_from(presented).map_err(|_| overflow())?, + }) +} + +fn groups( + video: &Track, + audio: &Track, + source: &mut S, + limits: &Limits, +) -> Result<(TrackFlags, TrackFlags), Error> { + let scan = scan::scan(source, limits)?; + if scan.skipped.more > 0 || !scan.skipped.listed.is_empty() { + return Err(unqualified()); + } + let v = scan.traks.get(video.stream_index).ok_or_else(unqualified)?; + let a = scan.traks.get(audio.stream_index).ok_or_else(unqualified)?; + for track in [v, a] { + if track.skipped.more > 0 || !track.skipped.listed.is_empty() || !track.tags.is_empty() { + return Err(unqualified()); + } + } + if !v.sample_info.is_empty() { + return Err(unqualified()); + } + let mut description = false; + let mut membership = false; + for header in &a.sample_info { + match &header.kind.0 { + b"sgpd" if !description && header.payload_len() == 18 => { + let mut bytes = [0; 18]; + source.read_exact_at(header.payload_offset(), &mut bytes)?; + if bytes + != [ + 1, 0, 0, 0, b'r', b'o', b'l', b'l', 0, 0, 0, 2, 0, 0, 0, 1, 255, 255, + ] + { + return Err(unqualified()); + } + description = true; + } + b"sbgp" if !membership && header.payload_len() == 20 => { + let mut bytes = [0; 20]; + source.read_exact_at(header.payload_offset(), &mut bytes)?; + let mut expected = [ + 0, 0, 0, 0, b'r', b'o', b'l', b'l', 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, + ]; + expected + .get_mut(12..16) + .ok_or_else(overflow)? + .copy_from_slice(&audio.sample_count.to_be_bytes()); + if bytes != expected { + return Err(unqualified()); + } + membership = true; + } + _ => return Err(unqualified()), + } + } + if description != membership { + return Err(unqualified()); + } + let video_flags = v.flags.ok_or_else(unqualified)?; + let audio_flags = a.flags.ok_or_else(unqualified)?; + if audio_flags.volume != TrackFlags::DEFAULT.volume { + return Err(unqualified()); + } + Ok((video_flags, audio_flags)) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn copy_av( + movie: &Movie, + video: &Track, + audio: &Track, + start: Rational, + end: Option, + mut source: S, + limits: &Limits, + mut out: W, + entries: (Vec, Vec), +) -> Result { + if movie.has_fragments() + || movie.tracks.len() != 2 + || !movie.dropped.is_empty() + || video.description.fourcc.0 != *b"avc1" + || video.timescale < 10_000 + || audio.description.fourcc.0 != *b"mp4a" + || audio.description.codec != Codec::Aac + || !matches!( + audio.description.profile, + Some(Profile::Aac { + object_type: 2, + sbr: false, + ps: false + }) + ) + || video.handler_name.as_deref() != Some("VideoHandler") + || audio.handler_name.as_deref() != Some("SoundHandler") + { + return Err(unqualified().into()); + } + let end = end.ok_or_else(unqualified)?; + let video_edit = edit(video)?; + let audio_edit = edit(audio)?; + let sv = ticks(start, video.timescale)?; + let sa = ticks(start, audio.timescale)?; + let ev = ticks(end, video.timescale)?; + let ea = ticks(end, audio.timescale)?; + let clock = movie_timescale(video.timescale, audio.timescale); + if clock % video.timescale != 0 || clock % audio.timescale != 0 { + return Err(unqualified().into()); + } + let flags = groups(video, audio, &mut source, limits)?; + let mut budget = Budget::new(limits); + let key = video_start( + movie, + video, + video_edit, + sv, + &mut source, + limits, + &mut budget, + )?; + let target = i128::from(key.dts.checked_sub(video_edit).ok_or_else(overflow)?) + * i128::from(audio.timescale); + let first_audio = audio_start( + movie, + audio, + audio_edit, + Rational::new(target, u64::from(video.timescale)).ok_or_else(overflow)?, + &mut source, + limits, + &mut budget, + )?; + let listing_work = budget.used(); + let video_kept = build( + movie, + video, + key.sample, + sv, + ev, + video_edit, + clock, + true, + &mut source, + limits, + &mut budget, + limits.max_table_bytes, + )?; + let audio_kept = build( + movie, + audio, + first_audio, + sa, + ea, + audio_edit, + clock, + false, + &mut source, + limits, + &mut budget, + limits + .max_table_bytes + .saturating_sub(video_kept.tables.used()), + )?; + if budget + .used() + .checked_add(listing_work) + .is_none_or(|work| work > limits.max_sample_work) + { + return Err(Error::LimitExceeded { + limit: coreav_isobmff::LimitKind::SampleWork, + offset: 0, + } + .into()); + } + let roll = roll_boxes_all(audio_kept.tables.samples(), 1) + .into_iter() + .map(|entry| CopiedBox { + kind: entry.kind.0, + payload: Cow::Owned(entry.payload), + }) + .collect(); + let video_duration = video_kept.edits.first().ok_or_else(overflow)?.duration; + let audio_duration = audio_kept.edits.first().ok_or_else(overflow)?.duration; + let tracks = vec![ + TrackBox { + track_id: VIDEO_ID, + media: Media::Video { + matrix: video.matrix, + size: video.presentation_size, + }, + handler_name: None, + header: flags.0, + created: video.creation_time, + modified: None, + media_created: video.media_creation_time, + media_modified: None, + language: language(video.language), + media_timescale: video.timescale, + media_duration: video_kept.tables.duration, + duration: video_duration, + edits: Cow::Borrowed(&video_kept.edits), + stsd: entries.0, + tables: &video_kept.tables, + extra: Vec::new(), + stbl_extra: Vec::new(), + }, + TrackBox { + track_id: AUDIO_ID, + media: Media::Audio, + handler_name: None, + header: flags.1, + created: audio.creation_time, + modified: None, + media_created: audio.media_creation_time, + media_modified: None, + language: language(audio.language), + media_timescale: audio.timescale, + media_duration: audio_kept.tables.duration, + duration: audio_duration, + edits: Cow::Borrowed(&audio_kept.edits), + stsd: entries.1, + tables: &audio_kept.tables, + extra: Vec::new(), + stbl_extra: roll, + }, + ]; + let (data_start, data_bytes) = write_head( + &mut out, + ftyp(b"isom", &[b"isom", b"mp42", b"avc1"]), + &MovieBox { + timescale: clock, + created: movie.creation_time, + modified: None, + duration: video_duration.max(audio_duration), + extra: Vec::new(), + }, + tracks, + Ties::TrackOrder, + )?; + let remaining = limits.max_sample_work.saturating_sub(budget.used()); + let copy_limits = Limits { + max_sample_work: remaining, + ..*limits + }; + let shared = RefCell::new(source); + let mut vb = Budget::new(©_limits); + let mut ab = Budget::new(©_limits); + let mut vp = movie.packets(video.index, Shared(&shared), ©_limits, &mut vb)?; + let mut ap = movie.packets(audio.index, Shared(&shared), ©_limits, &mut ab)?; + let vr = Run::stored(&mut vp, video_kept.first, &video_kept.tables)?; + let ar = Run::stored(&mut ap, audio_kept.first, &audio_kept.tables)?; + let mut window = Window::new(COPY_BUFFER); + copy_cut(&shared, vr, Some(ar), &mut window, &mut out)?; + drop((vp, ap, window)); + if vb.used().saturating_add(ab.used()) > remaining { + return Err(Error::LimitExceeded { + limit: coreav_isobmff::LimitKind::SampleWork, + offset: 0, + } + .into()); + } + out.flush()?; + let summary = |track: &Track, kept: &Kept| ClipTrack { + stream_index: track.stream_index, + track_id: track.track_id, + codec: track.description.codec, + first_sample: kept.first, + samples: kept.tables.samples(), + timescale: track.timescale, + duration: kept.tables.duration, + }; + Ok(ClipSummary { + video: summary(video, &video_kept), + audio: Some(summary(audio, &audio_kept)), + start, + snapped_start: start, + end: Some(end), + written_end: add( + start, + scale( + int(to_i64(i128::from(video_kept.presented))?), + 1, + video.timescale, + )?, + )?, + bytes_written: data_start.checked_add(data_bytes).ok_or_else(overflow)?, + left_out: Vec::new(), + boxes_left_out: video.description.other_boxes.clone(), + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + #[test] + fn composition_authority_requires_complete_unique_positive_intervals() { + let packet = |dts, cts| Sample { + sample: 0, + offset: 0, + size: 1, + dts, + cts, + duration: 2, + keyframe: true, + }; + for (times, valid) in [ + ([(0, 2), (2, 6), (4, 4)], true), + ([(0, 2), (2, 6), (4, 6)], false), + ([(0, 500), (2, 4), (4, 6)], true), + ] { + let mut proof = Composition::new(3, 24).unwrap(); + for (dts, cts) in times { + proof.push(&packet(dts, cts)).unwrap(); + } + assert_eq!(proof.finish().is_ok(), valid); + } + let mut proof = Composition::new(2, 16).unwrap(); + proof.push(&packet(0, 2)).unwrap(); + assert!(proof.push(&packet(3, 4)).is_err()); + assert!(proof.finish().is_err()); + assert!(Composition::new(3, 23).is_err()); + assert!(Composition::new(1, 8).unwrap().push(&packet(2, 1)).is_err()); + assert!( + Composition::new(1, 8) + .unwrap() + .push(&Sample { + duration: 0, + ..packet(0, 0) + }) + .is_err() + ); + } +} diff --git a/crates/coreav-isobmff-write/src/lib.rs b/crates/coreav-isobmff-write/src/lib.rs index c6bf5670c..64ffd78aa 100644 --- a/crates/coreav-isobmff-write/src/lib.rs +++ b/crates/coreav-isobmff-write/src/lib.rs @@ -239,8 +239,9 @@ pub struct ClipSummary { pub audio: Option, /// The requested start. pub start: Rational, - /// Where the clip starts: the presentation time of its first sync - /// sample; the output's time 0. + /// Where presentation starts on the source timeline; the output's time 0. + /// Native clipping may snap to an earlier sync sample. Audio-bearing + /// input-seek copies use the requested start and hide decode pre-roll. pub snapped_start: Rational, /// The requested end, `None` for the end of the video. pub end: Option, diff --git a/crates/coreav-isobmff-write/src/replace.rs b/crates/coreav-isobmff-write/src/replace.rs index 1f69bde3c..0a8a3b4b1 100644 --- a/crates/coreav-isobmff-write/src/replace.rs +++ b/crates/coreav-isobmff-write/src/replace.rs @@ -446,6 +446,21 @@ pub(crate) fn audio_edits_exact( exact(replacement.start) && exact(replacement.presented) } +pub(crate) fn roll_boxes_all(samples: u32, roll: u16) -> Vec { + let mut boxes = roll_boxes(samples, roll); + for entry in &mut boxes { + if entry.kind.0 == *b"sbgp" { + let mut payload = vec![0, 0, 0, 0]; + payload.extend_from_slice(b"roll"); + payload.extend_from_slice(&1u32.to_be_bytes()); + payload.extend_from_slice(&samples.to_be_bytes()); + payload.extend_from_slice(&1u32.to_be_bytes()); + entry.payload = payload; + } + } + boxes +} + /// The audio replacement's `roll` sample group (ISO/IEC 14496-12 8.9.3 /// and 8.9.2): a version 1 description of one `AudioRollRecoveryEntry`, /// and the first `roll` of `samples` samples outside the group, every diff --git a/crates/coreav-isobmff-write/tests/clip.rs b/crates/coreav-isobmff-write/tests/clip.rs index cf2fe311e..3f7481d3d 100644 --- a/crates/coreav-isobmff-write/tests/clip.rs +++ b/crates/coreav-isobmff-write/tests/clip.rs @@ -77,6 +77,173 @@ fn audio_clip( Ok((summary, out)) } +#[test] +fn audio_bearing_input_seek_plans_complete_samples_before_writing() { + let samples = |count, duration| { + (0..count) + .map(|index| S { + size: 20, + duration, + offset: 0, + sync: index % 4 == 0, + }) + .collect() + }; + let mut original = movie(&[ + TrackSpec { + video: true, + timescale: 10_000, + samples: samples(20, 1024), + edits: vec![], + entry: *b"avc1", + }, + TrackSpec { + video: false, + timescale: 48_000, + samples: samples(97, 1024), + edits: vec![(2048, 1024)], + entry: *b"mp4a", + }, + ]); + h264_frames(&mut original, "IPPPIPPPIPPPIPPPIPPP"); + let (_, source) = clip(&original, &options(q(0, 1), Some(q(256, 125)))).unwrap(); + let request = options(q(64, 125), Some(q(128, 125))); + let run = |limits: &Limits, out: &mut Vec| { + clip_mp4_input_seek( + SliceSource::new(&source), + limits, + &Allowlist::default(), + &request, + out, + ) + }; + let mut output = Vec::new(); + let summary = run(&Limits::default(), &mut output).unwrap(); + assert_eq!(summary.video.first_sample, 4); + assert_eq!(summary.video.samples, 6); + assert_eq!(summary.audio.as_ref().unwrap().first_sample, 20); + assert_eq!(summary.audio.as_ref().unwrap().samples, 29); + assert_eq!(summary.snapped_start, request.start); + assert_eq!(summary.written_end, q(128, 125)); + let parsed = probe( + SliceSource::new(&output), + &Limits::default(), + &Allowlist::default(), + ) + .unwrap(); + assert_eq!( + parsed.tracks[0].edit_list, + EditList::Simple { + empty: 0, + media_time: 1024, + segment: 122_880 + } + ); + assert_eq!( + parsed.tracks[1].edit_list, + EditList::Simple { + empty: 0, + media_time: 5120, + segment: 122_880 + } + ); + for limits in [ + Limits { + max_table_bytes: 1, + ..Limits::default() + }, + Limits { + max_sample_work: 1, + ..Limits::default() + }, + ] { + let mut output = Vec::new(); + assert!(matches!( + run(&limits, &mut output), + Err(ExtractError::Source(Error::LimitExceeded { .. })) + )); + assert!(output.is_empty()); + } + let mut failures = 0; + let mut successes = 0; + for work in [64, 128, 256, 512, 1024, 2048, 4096] { + let mut output = Vec::new(); + match run( + &Limits { + max_sample_work: work, + ..Limits::default() + }, + &mut output, + ) { + Ok(_) => { + assert!(!output.is_empty()); + successes += 1; + } + Err(ExtractError::Source(Error::LimitExceeded { .. })) => { + assert!(output.is_empty(), "work limit {work} failed after writing"); + failures += 1; + } + other => panic!("unexpected planning result: {other:?}"), + } + } + assert!(failures > 0 && successes > 0); + let input = probe( + SliceSource::new(&source), + &Limits::default(), + &Allowlist::default(), + ) + .unwrap(); + let mut budget = Budget::new(&Limits::default()); + let at = input + .packets( + 0, + SliceSource::new(&source), + &Limits::default(), + &mut budget, + ) + .unwrap() + .next() + .unwrap() + .unwrap() + .offset; + let mut output = Vec::new(); + let failed = clip_mp4_input_seek( + FailingSeekSource { + data: SliceSource::new(&source), + at, + }, + &Limits::default(), + &Allowlist::default(), + &request, + &mut output, + ); + assert_eq!( + failed, + Err(ExtractError::Source(Error::Io( + std::io::ErrorKind::PermissionDenied + ))) + ); + assert!(output.is_empty()); +} + +struct FailingSeekSource<'a> { + data: SliceSource<'a>, + at: u64, +} + +impl Source for FailingSeekSource<'_> { + fn len(&mut self) -> Result { + self.data.len() + } + + fn read_exact_at(&mut self, offset: u64, bytes: &mut [u8]) -> Result<(), Error> { + if offset <= self.at && self.at < offset + bytes.len() as u64 { + return Err(Error::Io(std::io::ErrorKind::PermissionDenied)); + } + self.data.read_exact_at(offset, bytes) + } +} + #[test] fn input_seek_admission_uses_the_retained_packet_tables() { let video = |offset, edits| TrackSpec { diff --git a/docs/agents/ci.md b/docs/agents/ci.md index 943cd96bc..20b30ab39 100644 --- a/docs/agents/ci.md +++ b/docs/agents/ci.md @@ -8,6 +8,7 @@ Workflow required checks, path filters, DCO, pinned actions, merge-group reuse, | Full-suite failure reporting reads only trusted Actions metadata and final-head evidence names its selected suites | `test_ci_full.py` covers trusted workflow wiring, complete proof steps, identity, pagination, races, deduplication and closed suite selection. Runtime API evidence cannot be established by a declared type | | The macOS crop case executes real media with qualified tools rather than passing with zero selected tests | The explicit `check-macos` step requires the exact crop test success line after oracle and x264 provisioning. `test_macos_crop_runs_real_media_with_qualified_tools` checks its command and rejects zero-test and failed-test logs; Rust types cannot select workflow features or prove execution | | The macOS SAR media case actually runs with its qualified tools rather than disappearing in an oracle-free workspace build | The explicit step in `check-macos` installs pinned x264 after oracle provisioning, selects the native-H.264 media test and requires its success line, so zero tests fail. `test_macos_runs_the_declared_sar_media_case_with_qualified_tools` pins the command, success witness, required-oracle environment and provisioning order; Rust types cannot select Cargo features or prevent a zero-test workflow invocation | +| Every test in the input-seek oracle fixture target is excluded on unregistered oracle platforms | The shared `test-support/oracle-dependent.json` roster owns the exact selection; `test_input_seek_oracle_tests_are_excluded_on_unregistered_platforms` derives the target's tests and checks each skip. Rust types cannot select test functions in a platform's Cargo invocation | | Conformance archive retries recognize dropped connections even when the exception has no errno | `fetch` classifies `ConnectionError` alongside transient errno values; `test_transient_errors_recover_with_backoff_and_source_logs` covers raw and URL-wrapped `RemoteDisconnected`. External exceptions require runtime classification, not only an errno list | | Unix socket fixtures fit macOS's 104-byte socket-address path even under long checkout or temporary-directory paths | `wav_non_regular_destinations_refuse_without_staging_or_replacement` and `output::tests::special_destinations_are_never_staged_or_replaced` each create one exclusive mode-0700 directory under a short Unix root and assert the encoded socket path is shorter than 104 bytes before binding. The bounded construction removes both external prefixes; Rust's `Path` type cannot constrain encoded address length | | AAC qualification keeps its complete JSON report even when the comparison fails | The existing always-run artifact upload owns retention; `test_aac_qualification_report_survives_failure` checks its condition and report path. Rust types cannot govern workflow artifact paths | diff --git a/docs/agents/compat-corpus.md b/docs/agents/compat-corpus.md index 3919f97a5..27509485b 100644 --- a/docs/agents/compat-corpus.md +++ b/docs/agents/compat-corpus.md @@ -12,7 +12,8 @@ Corpus exclusions, compatibility workflows, fixture comparisons, the HEVC confor | HEVC oracle-derived picture references require prior complete published-whole-output MD5 agreement and retain their original stream, coded/output indices and anchor | `anchored_reference_frames` owns derivation after `confirm_reference`; `test_complete_anchor_derives_all_frames_with_original_provenance`, `test_failed_changed_or_incomplete_anchor_never_starts_picture_derivation` and `test_partial_or_wrong_sized_picture_output_is_not_a_reference` cover provenance, rejected anchors and incomplete output. Shared oracle parsing owns count, packing and progress checks; external output and file identities require runtime checks | | HEVC driver tests that enter a Linux-only runtime path retain their full Linux coverage and skip on other hosts | The child owners reject non-Linux execution before launch. Platform guards cover the real-child environment test, online-census cleanup test and fixture `ProcessBounds` class; `test_non_linux_refuses_before_a_child_is_started` checks the fixture boundary. Other unit tests use mocked media children. Linux CI still runs the complete resource-limit bodies; types cannot select a host's subprocess-limit APIs | | HEVC oracle stderr wording never adds a failure class beyond nonzero exit, incomplete output or published-reference mismatch | `classify_diagnostics` attaches verified-output evidence without a message registry; `probe` checks exit status and `reference_progress` checks completeness independently of diagnostic collection. `test_unregistered_informational_messages_on_verified_output_do_not_gate_success` and `test_complete_hash_confirmation_records_unknown_stderr_without_gating_the_result` cover unregistered messages with and without collection; the exit/completeness/hash negatives and independent terminal-packet regressions remain blocking. The runtime proof is the owner because types cannot establish an external process's output; the message registry and strict-stderr alternative were removed rather than given another exception | -| R3 input-seek required refusals stay bound to the 17 approved command/fixture witnesses and receive no native credit | `INPUT_SEEK_REFUSALS` owns the exact tuples; `input_seek_refusal_contract_keeps_its_exact_command_scope` pins their command arguments and excludes neighboring cases. The fixture runner requires every witness, rejects a native result before insertion, and proves exit 4, absent/existing destination and staging preservation, and real pinned fallback before recording the ordinary `refused` status. The verifier resolves PATH's oracle to an absolute executable for both the reference and `COREAV_FALLBACK`, whose configured value is a file, not a PATH search name; the writer CI runs this path without an oracle override. Every other native floor is unchanged. External process behavior requires runtime checks, not a codec or command-shape label | +| Development input-seek expectation changes do not alter the registered R3 snapshot or its seventeen historical refusal witnesses | Development `INPUT_SEEK_REFUSALS` retains fifteen tuples, while `INPUT_SEEK_SUCCESSES` requires native C18 success for `hls_h264.mp4` and `hls_long_gop.mp4`. `input_seek_refusal_contract_keeps_its_exact_command_scope` pins their disjoint seventeen-cell union and unchanged command arguments. The separate `closed_gop_input_seek_witnesses_match_complete_outputs` compares both unchanged recipes against the pin's complete records, pixels and AAC PCM, plus independent ISO checks, without expanding the frozen harness module graph. Every remaining refusal still proves exit 4, destination/staging preservation and real pinned fallback; other native floors are unchanged. The registered candidate snapshot, frozen-source list and judging files retain their existing owners. External process behavior requires runtime checks, not a command-shape label | +| A bounded AVC/AAC input-seek copy is not judged as native gapless clipping, other source codecs retain their legacy oracle, and the summary is not selection authority | `copy_check.input_seek_operation` owns command and source-codec dispatch, using the shared AAC configuration parser rather than only track handlers. The legacy-codec routing regression covers FLAC, ALAC, AC-3, HEVC and non-LC AAC; unreadable AVC/AAC configuration stays fail-closed. Independent run/edit/layout checks and complete pinned records, pixels and AAC PCM enforce the model. Summary, serialized-mutation and pin-failure regressions run on the existing generated writer records in `ci.yml`. Python types cannot prove external media bytes or codec configuration; the dispatch owner prevents non-AAC records entering the AAC-only model. The first-audio-sample edit regression prevents inventing a complete-recovery requirement that ISO edit and roll semantics do not impose | | A GitHub-shape probe counts only substantive requested data, not equal empty serialization or wholly unavailable results | `probe_witness_invocation` asks the pinned oracle for the same selection in JSON, and `validate_receipt` binds that witness to capture and replay. `probe_available` checks its sections and explicit fields once, independently of the requested writer; nonempty byte-identical stdout and successful exits still decide equality. `PinnedProbeWriters` covers 16 real writer variants, broad default/flat sections with optional N/A fields, and empty/unavailable controls in PR CI. External process data needs runtime validation; separate writer grammars are removed rather than patched | | An aborted input-summary comparison retains encrypted partial evidence and never becomes a completed zero-mismatch result | Prior artifacts are invalidated before preparation; bounded scheduling stops new work on a closed stage/reason fault; one finalizer publishes `complete=false`, reconciled aggregate counts and encrypted private records. Preparation, capture, validation-exit, later-worker, cancellation half-pair and encryption-failure regressions in `test_input_summary_development.py` require exit 2, retained stdout/stderr, no plaintext diagnostics and no partial ciphertext. Runtime process and filesystem faults cannot be excluded by types | | Development diagnostic plans explicitly exclude judged and held-out inputs and require encrypted details with counts-only public output | `input_summary_development.select` and its admission tests require explicit non-held-out real entries. The checker fails on encryption errors, and `input-summary.yml` uploads only aggregate counts and encrypted details. Whether the plan states those boundaries remains review judgment | diff --git a/docs/agents/containers.md b/docs/agents/containers.md index 7b05be9f0..9bd678248 100644 --- a/docs/agents/containers.md +++ b/docs/agents/containers.md @@ -4,6 +4,7 @@ ISO BMFF, Matroska, MPEG-TS, and AVI. | Rule | Enforced by | |---|---| +| An audio-bearing input-seek summary reports the edit's presentation origin, not the earlier decode access point | `clip::input_seek` uses the requested start for both the edit origin and `snapped_start`; `audio_bearing_input_seek_plans_complete_samples_before_writing` checks that summary alongside the retained sample ranges and serialized edits. A rational type cannot distinguish these two source-clock positions | | A joined sample-entry child is reported as omitted only when the writer did not retain it | `join_mp4` derives `fiel` omission reporting from the same optional two-byte declaration used by the visual-entry builder. The complete native-video regression checks both serialized output and CLI summary, retains absent/short declaration reporting, and `h264_field_metadata_still_reports_its_omission` preserves the excluded codec boundary. A source-only list cannot establish what the output copied | | Native AV1, VP9 and HEVC joins retain the selected first input's visual declarations without changing admission or edit presentation | The existing bounded scan retains one qualified `fiel` header independently of its summary-list cap, and `visual_sample_entry` owns compressor-name and two-byte field serialization. `scan::tests` covers missing/short metadata and box limits; `native_video_metadata_compares_complete_records_and_all_packet_bytes` checks all three codecs, differing/absent declarations, native execution with unavailable fallback, complete records and mutation rejection. External source metadata and oracle packets require runtime checks rather than a codec enum | | Non-wrapping AVC transport copy cannot present an access unit before it is decoded | `ts_output::presentation` owns the PTS-at-least-DTS guard before serialization; `presentation_cannot_precede_its_decode_time` preserves valid reordered slots and rejects the same slots shifted before decode. A reorder-distance bound alone cannot establish absolute timestamp order | @@ -11,7 +12,7 @@ ISO BMFF, Matroska, MPEG-TS, and AVI. | Compatibility Matroska AAC copy qualifies the selected source's packet clocks rather than inheriting native reconstructed frame cadence | `AacCopyClock` owns the bounded pre-staging check and the compatibility writer's listing guard. `ffmpeg_mkv_aac_copy` checks aligned packet clocks, one-tick refusals, destination preservation and complete pinned fallback; `mkv_aac_copy` covers packet flags, padding and terminal late I/O/limits. Native remux does not enable this guard; external packet timing cannot be established by a stream codec enum | | Compatibility video copy from several contributing inputs validates each selected source timeline and uses complete packet order, while native defaults retain their old layout | The mapped CLI plan enables both policies only after resolving contributors; `remux_mp4_inputs_with_options` reuses the source-specific list pass and shared scheduler. `contributing_inputs_preserve_complete_packet_order_and_decoded_output` checks complete ordered packets, frames, metadata, decoded bytes and atomic fallback; `mapped_input_policies_preserve_defaults_and_apply_source_timing_admission` checks unchanged defaults, both source positions and existing budgets. Types cannot prove input sample/edit validity | | A Matroska early scan that stops at a packet or span cap with audio/video windows pending cannot certify a complete input summary | `early_packets` marks span stops incomplete and uses one bounded packet-header lookahead to distinguish a packet cap from actual EOF. `summary_distinguishes_early_scan_caps_from_end_of_input` covers both caps, short/exact-cap EOF and completed windows; generated `mkv-analysis-packet-cap` checks atomic refusal and exact pinned fallback in all three spellings. Existing element-work and retained-packet bounds remain; a counter limit alone cannot prove EOF | -| Input-seek compatibility must not inherit native clipping's gapless origin or partial-sample edit bounds | `clip_mp4_input_seek` admits only video without retained composition offsets, several edits, a shifted cut point or an incomplete packet endpoint, using the existing plan/tables before writing. `input_seek_admission_uses_the_retained_packet_tables` checks byte-empty refusals and identical native defaults; the pinned CLI regression checks exact packets, frame timing, pixels and preserved fallback. The native entry point and limits stay unchanged; a reported decoder reorder count cannot prove packet-table eligibility | +| Input-seek compatibility must not inherit native clipping's gapless origin or partial-sample edit bounds | `clip_mp4_input_seek` retains the existing video-only table admission and delegates bounded AVC/AAC-LC to `clip::input_seek`. Its plan validates complete source edits, unique composition times, access points, whole-packet DTS bounds and AAC roll membership before emission. `input_seek_admission_uses_the_retained_packet_tables` preserves video-only/native bytes; `audio_bearing_input_seek_plans_complete_samples_before_writing` covers selected runs, edits, source I/O and pre-output budgets. `ffmpeg_input_seek` checks complete pinned records, pixels, PCM and independently parsed ISO tables with mutations. Runtime packet and source facts cannot be established by a codec label | | A simple edit alone does not establish exact compatibility-copy timing: its endpoint must cover the complete copied presentation, except for trimming within the final AAC sample | The bounded remux `list` compares exact cross-multiplied clocks against sample endpoints. Overlong AAC edits can be shortened only to an end exactly representable in the source movie clock; other out-of-range ends refuse. `rebuilt_durations_refuse_shortened_and_extended_simple_edits` and `rebuilt_durations_clamp_exact_extended_aac_edits_without_changing_samples` cover refusal, canonicalization and unchanged native behavior. Pinned `ordinary_copy_refuses_trimmed_simple_edits` checks video, AV, audio, final-frame cuts, extended edits, destination preservation and byte-identical fallback. Input-dependent sample endpoints cannot be proved by the edit enum or a static type | | Compatibility video-origin normalization requires the first decode-ordered sample to have the earliest composition time; a minimum by itself cannot establish the external demuxer's origin | The bounded remux `list` owns the pre-output guard. `video_origin_requires_the_first_decoded_sample_to_present_first` and the pinned `copied_video_refuses_a_later_sample_before_its_first_composition` regression enforce refusal and exact fallback. Composition order depends on input packet tables, so a static type cannot prove this admission fact | | Compatibility copy derives header durations from copied samples and edits instead of preserving conflicting declarations; native remux retains its separate default preservation policy | The remux table pass preserves overrides only when neither duration rebuilding nor an origin is selected. `rebuilt_durations_keep_samples_edits_and_native_header_policy` checks the native boundary, and pinned `ordinary_copy_rebuilds_declared_durations_for_video_and_audio` covers edited and unedited AV, video, short, long and unspecified declarations. A duration's integer type cannot establish agreement with input samples | diff --git a/docs/agents/ffmpeg-cli.md b/docs/agents/ffmpeg-cli.md index 7e873538b..c16cc8194 100644 --- a/docs/agents/ffmpeg-cli.md +++ b/docs/agents/ffmpeg-cli.md @@ -25,7 +25,7 @@ CLI and ffprobe behavior, the FFmpeg pin, and fallback names. | The AAC qualification driver derives native input threads from the shared audio policy rather than maintaining a second count | `native` adapts the shared audio selector to the existing native option spelling; `test_native_input_threads_inherit_the_shared_audio_policy` changes the shared value and checks the resulting command. The actual-script oracle-policy regression checks oracle calls without an exemption; Python types cannot govern command contents | | The map-order and first-input-copy oracle regressions own exclusive per-invocation directories and remove only their own artifacts on exit | `oracle_scratch` creates process/call-qualified paths with `create_dir`, and `FixtureDir` owns cleanup. `oracle_scratch_owns_isolated_directories_until_drop` rejects the former shared-path deletion and proves independent lifetimes. The legacy generated-fixture directory contract retains its existing owner and is not changed by this regression-fixture lifetime fix | | AAC packet identity cannot qualify an explicit concat filter when the complete stream-copy output differs from the pin | The option parser retains its bitstream-filter refusal. `ffmpeg_aac_filter` requires strict-safe refusal without destination/staging changes and compares complete real-fallback output bytes with the pin for AAC, video-only and unsupported variants. It removes no video hashes, sizes or bitrate fields to establish acceptance; codec-local identity is not whole-output equality | -| Input seeking selects compatibility clip admission, not ordinary gapless clipping, and unsupported source metadata or combined bounds refuse before publication | `plan` refuses unsupported audio-only/bound/metadata cases and `write` uses `clip_mp4_input_seek` only when input `-ss` is present. `ffmpeg_input_seek` covers zero/nonzero seek, reordering, AV/audio/video selection, fractional endpoints, tail copies, source titles, destination/staging preservation and original-argument fallback. Codec display fields do not encode exact retained packet bounds | +| Input seeking selects compatibility clip admission, not ordinary gapless clipping, and unsupported source metadata or combined bounds refuse before publication | `plan` refuses unsupported audio-only/bound/metadata cases and `write` uses `clip_mp4_input_seek` only when input `-ss` is present. `ffmpeg_input_seek` covers zero/nonzero seek, reordering, AV/audio/video selection, fractional endpoints, tail copies, source titles/handlers/volume, destination/staging preservation and original-argument fallback. Its bounded AV controls and two closed-GOP C18 witnesses compare complete records, pixels, AAC PCM and independent ISO layout/presentation checks. Codec display fields cannot establish retained packet bounds or presentation metadata | | A compatibility fixture's intentional refusal is not a native pass and must prove destination preservation and exact pinned fallback | `audio_copy_check.py` binds the AC-3/E-AC-3 concat fixtures to their timing refusal, while FLAC/ALAC retain native packet and decode checks. `test_audio_copy_check.py` pins that scope and rejects wrong reasons, stale references and failed or unequal fallback. Types cannot prove an external program's exit, filesystem effects or bytes | | Compatibility concat admits several audio edits only for the qualified H.264 and primed AAC class, preserving each input's presentation rather than claiming exact FFmpeg concat clocks | `join_mp4` reuses parsed single-description AAC-LC configuration and requires an integral skipped prefix of at least its configured frame length. Writer regressions and `ffmpeg_concat_primed` independently check stored packets, edit windows, continuous raw audio and complete metadata; `concat_audio_gaps_are_refused_with_exact_pinned_fallback` retains unprimed gap refusals. A source edit enum cannot establish the constructed timeline | | H.264 compatibility concat writes avc1 with configuration-only parameter sets and keeps every real oracle difference explicit | `join::avc::Sample` validates NAL framing during staged copying and refuses in-sample parameter sets; the existing configuration comparison refuses differing avcC records. The shared `concat_output` comparator checks every packet, decoded output, format and stream field, and stored mdhd, permitting only asserted byte-identical oracle SPS/PPS insertions and named metadata/layout differences. Hostile in-band copies prove atomic refusal and exact fallback. External media requires runtime validation rather than a codec label | diff --git a/docs/design/compat-cli.md b/docs/design/compat-cli.md index b889b5e31..372ab93b3 100644 --- a/docs/design/compat-cli.md +++ b/docs/design/compat-cli.md @@ -1499,6 +1499,8 @@ failure does. B1 is judged on the other `out-of-scope` `ffmpeg` commands, `-h OUTPUT` and `-help OUTPUT` included, which are help on a topic. The `ffprobe` commands of the set hold no help query. +Bounded AVC/AAC input-side seek copies use a separate registered model in `copy_check.py`, not native gapless clipping. Command arguments and source tracks select it; the writer summary is checked against the independently computed run and edit interval, never used as its authority. Complete ordered pinned packet/stream/frame/format records, raw pixels and AAC PCM must match, and independent ISO sample, edit, group and extent checks must pass. Other operations keep their existing oracle and grouping. The registered candidate and historical results are unchanged. + **R3 on the fixtures (CI).** The `the_0_1_0_commands_on_the_fixtures` test of `coreav-cli` runs every `ffmpeg` command of `commands-0.1.0` on every generated fixture of its container families that has none of the stream diff --git a/docs/design/input-seek-av-copy.md b/docs/design/input-seek-av-copy.md new file mode 100644 index 000000000..bbad0b569 --- /dev/null +++ b/docs/design/input-seek-av-copy.md @@ -0,0 +1,48 @@ +# Exact audio-bearing input-seek copies + +## Goal and scope + +Support a bounded `-ss S -i INPUT -c copy -t D OUTPUT.mp4` path with one H.264 `avc1` video track and one AAC-LC audio track. The output-side `-to D` spelling reaches the same interval. Previously the input-seek writer refused selected audio because native clipping's snapped origin and presentation-end trimming do not model this packet-copy operation. The first commit records this plan before its implementation. Refs coreav-dev/coreav#25. + +Keep native clip, video-only input seeks, output-side seeking, ordinary remux and concat unchanged. Use the existing compatibility entry point, without changing its public signature or CLI dispatch. Other codecs, fragments, multiple or empty edits, nonzero source presentation origins and unqualified metadata remain outside this extension. The first admitted subset has constant video durations, nonnegative composition offsets and bounds exactly representable in both selected media clocks. Existing limits are unchanged. + +## Packet and presentation model + +Build a separate audio-bearing packet plan before writing. Select the latest qualified video sync sample whose presentation time is not after the seek. Select the corresponding AAC start from that sync sample's decode time, retaining the required source pre-roll. Preserve per-track clock shifts instead of inventing a common rounded media offset. For a finite bound, stop each stream before the first packet whose shifted DTS reaches it. Copy complete retained packets, including reordered pictures whose composition end exceeds the requested decode bound. + +Each output media timeline starts decoding at zero. Preserve each retained sample's composition-minus-decode offset. A unit-rate edit starts presentation at the requested media position relative to the first retained decode time; samples outside that presentation interval are decode pre-roll, not extra output. Derive the edit duration from complete retained composition spans, not from a truncated last packet. Reconstruct `mdhd`, `tkhd` and `mvhd` from those media and presentation spans in exactly representable clocks. + +The summary reports that same presentation interval: `snapped_start` is the requested start, and `written_end` is that start plus the complete retained video presentation span. The earlier decode access point belongs to the retained sample range, not the reported presentation origin. Native clipping's existing snapped-sync summary remains unchanged. + +The container proof is independent of oracle equality. ISO/IEC 14496-12:2015 supplies these rules: + +- Sections 8.6.1.1 and 8.6.1.2 define decode time from the accumulated `stts` durations. Retained decode intervals must be contiguous and have positive duration. +- Section 8.6.1.3 defines `CT = DT + CTTS`, signed versus unsigned offset versions, and unique composition timestamps. Verify the retained offsets and unique composition times before publication; do not assume that readable or nonnegative offsets establish validity. +- Sections 8.6.5 and 8.6.6 define `edts` and the movie-to-media presentation map in `elst`. Validate that each media edit hides the retained pre-roll, presents the declared retained interval and uses a unit media rate. Decode pre-roll must not become an unintended initial presentation. +- Section 8.6.6.1 defines an explicit timeline map and permits decoding from an earlier sync point when entering an edit. Section 8.6.6.3 defines `media_time` as the starting media composition time of that edit segment. Decode pre-roll may therefore remain stored outside the mapped presentation interval; it need not be shown. The [public 2012 edition, pages 43–44](https://ossrs.io/lts/zh-cn/assets/files/ISO_IEC_14496-12-base-format-2012-b70dd5f101daecd072700609842c9649.pdf) supplies the directly checked clause text. The summary follows that edit origin, not the earlier decode access point. +- Sections 8.7.3, 8.7.4 and 8.7.5 bind sample sizes, sample-to-chunk runs and chunk offsets. Every retained sample must appear exactly once, use the correct description, and occupy an in-bounds, nonoverlapping output extent. +- Sections 8.9.2 and 8.9.3 define sample-group membership and descriptions. Qualify source AAC roll information and rebuild the supported output membership for the retained samples rather than copying counts that describe a different range. + +The common multiple of media clocks may be used only when it fits the existing header width and represents both edit spans exactly. Reject incomplete, contradictory or unsupported source facts before output. If a pinned-reference choice for an otherwise qualifying input violates the format rules, stop and report the conflict instead of reproducing an invalid presentation. + +## Ownership and bounds + +`clip_mp4_input_seek` delegates selected-audio cases to a private child module of the clip owner. The old video-only branch remains structurally unchanged. A completed plan owns packet ranges, edit bounds, composition validity and output order. Reuse the existing bounded metadata scan, sample iterator, table builder API, header serializer and copy window; do not add a second ISO reader grammar or buffer packet payloads. + +Build the new path's tables through existing chunk-policy APIs so copied packets are ordered by edited rational DTS, with video first on exact ties. Validate sample counts, offsets and descriptions against the completed plan. Use fallible, charged metadata storage for any composition-validity proof; source I/O, arithmetic and budget failures cannot establish successful qualification. Preparation precedes the existing atomic file publication boundary. + +Keep AAC roll serialization with its existing owner, preserving every current caller's bytes. `Track.handler_name` and the bounded writer scan provide the metadata and group locations required for admission. Omitted or over-cap metadata cannot prove absence. + +The implementation qualifies complete zero-origin source edits against their composition spans, constant video durations and unique composition times. A fallible vector of timestamps is sorted to establish uniqueness and charged to `max_table_bytes`, then released before output table construction. `Movie` owns the immutable nonfragmented sample-table snapshot, so the output pass does not allocate and sort the same proof again. It still checks that the first retained video sample presents before every later retained sample, and the selected access point must independently start decoding. AAC has no composition offsets, and a fractional video-to-audio clock conversion refuses if an AAC boundary could change the selected start after rounding. Source groups are absent or the complete one-entry roll-minus-one form. Custom handlers, track tags, omitted metadata and non-unit audio presentation volume refuse. The pin normalizes that volume to unity, so copying a different stored volume would change playback despite equal PCM. + +Output tables use one packet per chunk and the existing edited-DTS scheduler. Planning and opt-in copying consume stored samples through the existing bounded run iterator: they need raw timestamps and offsets, not repeated construction of mapped presentation times. A compile-time copy mode retains the original packet iterator for every existing caller. AAC membership covers every copied sample, rather than the replacement encoder's separate initial-roll exclusion. Before emission, the planner reserves a complete source-listing work allowance as an upper bound for the copy prefix. The existing atomic output owner still protects the destination against later source or output failures. + +Keep `mux.rs`, `tables.rs`, concat, raw-audio planning, `ffmpeg/mod.rs`, oracle pins, frozen fixtures, command rosters and baselines unchanged. Current development fixture expectations are separate from the registered candidate snapshot: only the C18 third-command cells for `hls_h264.mp4` and `hls_long_gop.mp4` change from required refusal to required native success. `closed_gop_input_seek_witnesses_match_complete_outputs` generates both unchanged fixture recipes and compares complete records, pixels, AAC PCM and independent ISO layout/presentation checks. The other fifteen required-refusal cells remain. The separate C18 basic-input instruction-guard command also runs natively and is counted, while C19 remains a required refusal; its command, fixture recipe, baseline and tolerance are unchanged. The registered candidate snapshot, frozen-source list and historical verdict do not change. The registered copy checker now distinguishes this bounded input-seek operation from native gapless clipping. It independently selects complete source runs, validates serialized ISO presentation and layout, and requires complete same-command pinned records, every video pixel and AAC PCM. Its copy-checker hash is updated through the existing judging registration owner; native clip judging remains unchanged. The checker bounds each track at 200,000 samples and each raw decode at 1 GiB; a budget excess fails the check and never becomes a passing result, an exclusion or a product refusal. + +## Verification + +Use independent generated media only. Add failing-before exact-success controls for AAC with reordered and nonreordered video, AAC primer inclusion and exclusion, exact DTS ties, different clocks, both bound spellings and packet-boundary endpoints. Compare complete ordered payload hashes, rational packet timing, stream and format records, every decoded video pixel and every presented AAC sample against pinned FFmpeg 9.0.2. Preserve the shared oracle audio-thread policy. + +Independently parse serialized edits, `stts`, `ctts`, sample sizes, sample-to-chunk records and offsets to check the ISO presentation and layout invariants. Mutations must detect exposed pre-roll, duplicate composition times, wrong composition offsets, incorrect sample counts or descriptions, overlapping extents and incorrect edit durations. Cover source-read failures, limits and incomplete metadata before publication. + +Previously refused AV controls that now qualify become stronger exact-success assertions. Keep all remaining negative controls, byte-exact original-command fallback, absent/existing destination preservation, and every earlier native path. A native-to-fallback move stops the work. Verify affected writer and CLI suites, both relevant feature configurations, Clippy, formatting and public text; then require final-head CI, independent review and the canonical similarity guard. No measured coverage or performance claim follows from these development controls. diff --git a/docs/design/input-seek-copy.md b/docs/design/input-seek-copy.md index ce0edd719..335b027e1 100644 --- a/docs/design/input-seek-copy.md +++ b/docs/design/input-seek-copy.md @@ -8,17 +8,17 @@ Independent generated controls also disagree for audio-only input seeks, reorder ## Decision -Keep native `clip`, its existing public options and summary, split, output-only seeking and no-input-seek trimming unchanged. Add a compatibility-only input-seek entry point over the existing clip implementation; the CLI chooses it only when an input `-ss` is present, including zero. It rejects selected audio, several edits, composition offsets in the retained video, a start that snaps elsewhere, and an end that does not equal the complete retained sample duration. A finite requested end must equal the planned end; without one the plan must retain the source tail. The existing parser refusal for negative seek values stays unchanged. Negative input timeline origins, combined input/output seeking and additional input duration/until bounds refuse rather than collapsing their meanings. Audio-only input-seek copies take the existing refusal path. Source chapters or non-tool tags the clip writer does not preserve also refuse. Existing multi-input input-option refusal remains unchanged. +Keep native `clip`, its existing public options and summary, split, output-only seeking and no-input-seek trimming unchanged. The CLI chooses a compatibility-only input-seek entry point only when an input `-ss` is present, including zero. Its video-only branch rejects several edits, composition offsets in the retained video, a start that snaps elsewhere, and an end that does not equal the complete retained sample duration. A finite requested end must equal the planned end; without one the plan must retain the source tail. Bounded AVC/AAC-LC copies use the separate [audio-bearing packet plan](input-seek-av-copy.md), including decode pre-roll and complete reordered packets. The existing parser refusal for negative seek values stays unchanged. Negative input timeline origins, combined input/output seeking and additional input duration/until bounds refuse rather than collapsing their meanings. Audio-only input-seek copies take the existing refusal path. Source chapters or non-tool tags the clip writer does not preserve also refuse. Existing multi-input input-option refusal remains unchanged. -The writer validates its own already-built tables and exact rational plan before writing any bytes. It does not trust a codec's displayed reorder count, parse boxes again, add a source pass, decode samples, increase a read/work/table limit, or change native clip semantics. The old clip API delegates to the unchanged native mode; a new compatibility entry point has a current CLI consumer. Existing table storage already knows whether any retained composition offset is nonzero, so no new per-sample storage is needed. +The video-only branch validates its own already-built tables and exact rational plan before writing any bytes. It does not trust a codec's displayed reorder count, parse boxes again, add a source pass, decode samples, increase a read/work/table limit, or change native clip semantics. The old clip API delegates to the unchanged native mode; the compatibility entry point has a current CLI consumer. Existing video-only table storage already knows whether any retained composition offset is nonzero, so that branch needs no new per-sample storage. -This chooses reversible refusal over a new FFmpeg packet-copy reconstruction algorithm. Strict-safe mode exits 4 without publishing output or replacing an existing destination. Regular mode forwards the original arguments to the pin. The admitted subset retains existing clip output and must match complete stream fields, decoded-frame timing and decoded values exactly. Native input-seek audio, reordered pre-roll and fractional bounds remain follow-ups; output-seek differences are a separate unit. +Outside the audio-bearing extension, this chooses reversible refusal over a new packet-copy reconstruction algorithm. Strict-safe mode exits 4 without publishing output or replacing an existing destination. Regular mode forwards the original arguments to the pin. Admitted video-only copies retain existing clip output and must match complete stream fields, decoded-frame timing and decoded values exactly. Audio-only seeking and fractional bounds remain outside the admitted subset; output-seek differences are a separate unit. Clock admission also refuses source video timescales below 10,000 ticks per second, which the pin rescales, and CLI time bounds not representable in whole microseconds. Independent generated sources at 1,000 and 8,000 ticks per second retained their native clocks while the pin wrote 16,000; controls at 10,000 and 24,000 retained equal clocks. An all-intra billion-tick source sought at `0.541666667` produced 24 native frames and 25 pinned frames. These are generated development diagnostics, not measured coverage. Incomplete source metadata snapshots refuse through the existing metadata-completeness owner; video side data and non-default dispositions also refuse rather than assuming they survive clipping. ## Verification -The maintainer approved current-CI required-refusal contracts for the `basic.mp4` C18 benchmark command (`-ss 0.5 -i INPUT -to 1.5 -c copy`), the `aac.m4a` C19 benchmark command (`-ss 0.5 -i INPUT -t 1 -c:a copy`), and the synthetic format-span input-seek test. A separate approval replaces the R3 MP4/MOV native floor for C18 and C19 with the 17 command/fixture witnesses in `INPUT_SEEK_REFUSALS`: 11 for C18's third command and three each for C19's first two commands. Those former native outputs differ from pinned FFmpeg 9.0.2 in packet counts, clocks or duration. Every named witness must be present and prove strict-safe exit 4, absent/existing destination and staging preservation, and exact real pinned fallback. Other command/fixture outcomes and native floors remain unchanged. The existing format-span checksum remains attached to unchanged output seeking. Required refusals receive no native credit or instruction count. Fixture inputs, the command roster, baselines, caps and tolerances remain unchanged. +The `basic.mp4` C18 benchmark command (`-ss 0.5 -i INPUT -to 1.5 -c copy`) now runs natively and is instruction-counted against the unchanged baseline and tolerance. The `aac.m4a` C19 benchmark command (`-ss 0.5 -i INPUT -t 1 -c:a copy`) and the synthetic format-span input-seek test retain their required refusals. The registered R3 snapshot replaces the MP4/MOV native floor for C18 and C19 with seventeen required-refusal witnesses: eleven for C18's third command and three each for C19's first two commands. Current development fixture CI now requires exact native success for the C18 `hls_h264.mp4` and `hls_long_gop.mp4` cells, independently qualified by the audio-bearing comparator. Its other fifteen `INPUT_SEEK_REFUSALS` witnesses must still prove strict-safe exit 4, absent/existing destination and staging preservation, and exact real pinned fallback. Other command/fixture outcomes and native floors remain unchanged. The existing format-span checksum remains attached to unchanged output seeking. Required refusals receive no native credit or instruction count. The registered snapshot, frozen-source list, judging material, fixture inputs, command roster, baselines, caps and tolerances remain unchanged. - Add a pinned red regression before implementation for the reproduced reordered AV input seek and neighboring inter/all-intra, video-only/audio-only, zero/nonzero seek and aligned/unaligned endpoints. - Require strict-safe refusal with absent/existing destination and staging preservation wherever admission cannot prove equality; verify byte-identical real pinned fallback and exact original argument forwarding. diff --git a/docs/measurements/compat-0.1.0/judging.json b/docs/measurements/compat-0.1.0/judging.json index bc8e92773..9e28e3c44 100644 --- a/docs/measurements/compat-0.1.0/judging.json +++ b/docs/measurements/compat-0.1.0/judging.json @@ -28,7 +28,7 @@ "scripts/split_check.py": "1cd37d7d66608a3d74230a247369d733deb0a13cb51ab1b9ce32a024107f3c61", "scripts/track_boxes.py": "97965c1b8f23758c75fedc0eb1d0815fa01f51469ad50c7ae6b5d02fe007a967", "scripts/writer_boxes.py": "2aaa08ab4e82c78c240b9b594a4ace6639eaf478bef2dde99a50dd6b22acfbb9", - "tools/compat-corpus/copy_check.py": "e73b39820ded04968f9c5cae7ac7a9157e1207a5b9e7351c24a6e6b007f20a75", + "tools/compat-corpus/copy_check.py": "f1069a134d374c0ad02c3f5db1e6379975aae07cd22685fd35c49ffa286c534a", "tools/compat-corpus/ffmpeg9.json": "12204af1619cf806ede5e009dee43edc51edc361b5c9a93c96f0838ef9d0028d", "tools/compat-corpus/r3.py": "0549a6b9f429e0cadd61d6d10498c971f17b901a7e89143d5d3043fa3b58d913", "tools/compat-corpus/release_round.py": "b5c959bbfb004b8f4cffc65d25bdaf442d5f8e2920b968b844618c0fdfb6a2fa", diff --git a/scripts/bench_guard.py b/scripts/bench_guard.py index 9cf791fc4..8ad3337fc 100755 --- a/scripts/bench_guard.py +++ b/scripts/bench_guard.py @@ -309,7 +309,7 @@ REFS = re.compile(r"^==\d+== I\s+refs:\s+([\d,]+)\s*$", re.MULTILINE) -REQUIRED_REFUSALS = {"ffmpeg-C18-clip-input", "ffmpeg-C19-clip-audio-input"} +REQUIRED_REFUSALS = {"ffmpeg-C19-clip-audio-input"} def required_refusal(coreav, command): diff --git a/scripts/tests/test_bench_guard.py b/scripts/tests/test_bench_guard.py index f9ed76fb0..8684db3f0 100644 --- a/scripts/tests/test_bench_guard.py +++ b/scripts/tests/test_bench_guard.py @@ -59,7 +59,8 @@ def test_exact_boundary_and_adjacent_counts(self): class FfmpegGuard(unittest.TestCase): def test_required_refusals_are_only_the_two_approved_commands(self): self.assertEqual(bench_guard.REQUIRED_REFUSALS, - {"ffmpeg-C18-clip-input", "ffmpeg-C19-clip-audio-input"}) + {"ffmpeg-C19-clip-audio-input"}) + self.assertNotIn("ffmpeg-C18-clip-input", bench_guard.REQUIRED_REFUSALS) self.assertEqual(bench_guard.CASES["ffmpeg-C02-join"], ("video.mp4", ["ffmpeg", "-f", "concat", "-i", "{inputs:concat}", "-c", "copy", "{out}/out.mp4"])) @@ -140,7 +141,7 @@ def test_required_refusals_are_not_native_instruction_counts(self): def test_required_refusal_cannot_disappear_with_its_fixture_or_old_count(self): with tempfile.TemporaryDirectory() as directory: work = pathlib.Path(directory) - name = "ffmpeg-C18-clip-input" + name = "ffmpeg-C19-clip-audio-input" args = SimpleNamespace(coreav="coreav", decoder_coreav=None, fixtures=work, report_only=False, write=None) with patch.multiple(bench_guard, CASES={name: bench_guard.CASES[name]}, URL_CASES={}), \ @@ -150,6 +151,26 @@ def test_required_refusal_cannot_disappear_with_its_fixture_or_old_count(self): contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): self.assertEqual(bench_guard.run_cases(args, {"startup": 100}, 0.05, {}, {}, work), 1) + def test_c18_native_success_is_counted_instead_of_required_to_refuse(self): + with tempfile.TemporaryDirectory() as directory: + work = pathlib.Path(directory) + name = "ffmpeg-C18-clip-input" + (work / "basic.mp4").write_bytes(b"fixture") + args = SimpleNamespace(coreav="coreav", decoder_coreav=None, fixtures=work, + report_only=False, write=work / "counts.json") + with patch.multiple(bench_guard, CASES={name: bench_guard.CASES[name]}, URL_CASES={}), \ + patch.object(bench_guard, "derive"), patch.object(bench_guard, "generate", return_value={}), \ + patch.object(bench_guard, "craft", return_value={}), \ + patch.object(bench_guard, "required_refusal") as refused, \ + patch.object(bench_guard, "instructions", side_effect=[(100, None), (1000, None)]) as counted, \ + contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(bench_guard.run_cases(args, {"startup": 100}, 0.05, {}, {}, work), 0) + refused.assert_not_called() + self.assertEqual(counted.call_count, 2) + report = json.loads(args.write.read_text()) + self.assertEqual(report["cases"][name]["instructions"], 900) + self.assertEqual(report["required_refusals"], {}) + def test_every_selected_ffmpeg_shape_has_its_own_case(self): entries = {e["id"]: e for e in json.loads(release_set.OUT.read_text())["entries"] if e["tool"] == "ffmpeg"} found = {} diff --git a/scripts/tests/test_oracle_free.py b/scripts/tests/test_oracle_free.py index 5e4c84da2..cef7877d2 100644 --- a/scripts/tests/test_oracle_free.py +++ b/scripts/tests/test_oracle_free.py @@ -29,6 +29,16 @@ def test_filter_is_explicit_and_exact_without_excluding_mock_helpers(self): with self.assertRaises(ValueError): oracle_free.command(['cargo', 'check']) + def test_input_seek_oracle_tests_are_excluded_on_unregistered_platforms(self): + source = (ROOT / 'crates/coreav-cli/tests/ffmpeg_input_seek.rs').read_text() + names = re.findall(r'#\[test\]\s*fn\s+(\w+)', source) + self.assertTrue(names) + args = oracle_free.command(['cargo', 'test', '-p', 'coreav-cli', + '--test', 'ffmpeg_input_seek']) + skipped = {args[i + 1] for i, arg in enumerate(args[:-1]) if arg == '--skip'} + for name in names: + self.assertIn(name, skipped, 'Register the oracle test in test-support/oracle-dependent.json') + if __name__ == '__main__': unittest.main() diff --git a/test-support/oracle-dependent.json b/test-support/oracle-dependent.json index 0a3bb0039..ccdb5bdd7 100644 --- a/test-support/oracle-dependent.json +++ b/test-support/oracle-dependent.json @@ -102,6 +102,8 @@ "y4m_refuses_autorotation_that_would_misstate_chroma_siting" ], "crates/coreav-cli/tests/ffmpeg_input_seek.rs": [ + "audio_bearing_input_seek_matches_complete_packets_and_presented_samples", + "closed_gop_input_seek_witnesses_match_complete_outputs", "input_seek_copy_keeps_only_exact_packet_timelines" ], "crates/coreav-cli/tests/ffmpeg_matroska.rs": [ diff --git a/tools/compat-corpus/copy_check.py b/tools/compat-corpus/copy_check.py index a35972d05..4ef19d12e 100755 --- a/tools/compat-corpus/copy_check.py +++ b/tools/compat-corpus/copy_check.py @@ -20,7 +20,9 @@ `tools/oracle/writer_corpus.py` builds it for the native commands: `scripts/remux_check.py` (MP4 and MOV sources), `scripts/mkv_remux_check.py` (Matroska and WebM; the streams outside the - selection are not expected in the output), `clip_check.py`, + selection are not expected in the output), `clip_check.py` for native + gapless clips, the bounded AVC/AAC input-seek model here (complete pinned + output and independent ISO checks), `split_check.py`, `extract_check.py` and `scripts/join_check.py` (a join also against `ffmpeg -f concat` on the same inputs), each with its known divergences. A clip's requested range must also be the one the command @@ -40,6 +42,7 @@ import re import pathlib import subprocess +import struct import sys import tempfile import time @@ -50,6 +53,8 @@ import track_boxes # noqa: E402 import writer_corpus # noqa: E402 +import writer_boxes # noqa: E402 +from oracle_audio import INPUT_ARGS as AUDIO_DECODE_INPUT # noqa: E402 TIMEOUT = 600 MATROSKA = (".mkv", ".webm") @@ -404,6 +409,362 @@ def entry(record, src_movie, source_streams): "keep": None} +INPUT_SEEK_MAX_SAMPLES = 200_000 +INPUT_SEEK_MAX_RAW = 1 << 30 +INPUT_SEEK_ROLL = bytes.fromhex('01000000726f6c6c0000000200000001ffff') +INPUT_SEEK_ENTRIES = ('stream:stream_tags=language,handler_name:packet=stream_index,pts,dts,duration,size,flags,' + 'data_hash:frame=stream_index,best_effort_timestamp,pkt_dts,duration:format=start_time,' + 'duration:format_tags=') + + +def input_seek_args(record): + if record['job'] != 'clip' or record['source'].endswith(MATROSKA): + return None + args = record['args'] + if args.count('-i') != 1 or args[-1:] != ['OUTPUT']: + return None + at = args.index('-i') + if args[at + 1:at + 2] != ['INPUT']: + return None + values, coverage = {}, set() + codecs = {'-c': 'va', '-codec': 'va', '-c:v': 'v', '-vcodec': 'v', '-c:a': 'a', '-acodec': 'a'} + for tokens, allowed in ((args[:at], {'-ss'}), (args[at + 2:-1], {*codecs, '-t', '-to'})): + index = 0 + while index < len(tokens): + name = tokens[index] + if name in ('-y', '-hide_banner'): + index += 1 + continue + if name not in allowed or index + 1 == len(tokens): + return None + value = tokens[index + 1] + if name in codecs: + if value != 'copy': + return None + coverage.update(codecs[name]) + elif name in values: + return None + else: + values[name] = value + index += 2 + bounds = set(values) & {'-t', '-to'} + if '-ss' not in values or len(bounds) != 1 or coverage != {'v', 'a'}: + return None + try: + return seconds(values['-ss']), seconds(values[bounds.pop()]) + except (ValueError, ZeroDivisionError): + return None + + +def input_seek_operation(record): + operation = input_seek_args(record) + if operation is None: + return None + try: + tracks = track_boxes.read_movie(record['source'])['tracks'] + except (track_boxes.Unreadable, OSError, struct.error): + return operation + if not any(t['handler'] == 'vide' and (t.get('entry') or {}).get('type') == 'avc1' for t in tracks): + return None + for track in tracks: + entry = track.get('entry') or {} + if track['handler'] == 'soun' and entry.get('type') == 'mp4a': + config = writer_boxes.aac_config(entry) + if not config or config[0] >> 3 == 2: + return operation + return None + + +def input_seek_facts(path): + layout = track_boxes.top_level(path) + moov = [b for b in layout if b[0] == b'moov'] + mdat = [b for b in layout if b[0] == b'mdat'] + if len(moov) != 1 or not mdat or not layout: + raise ValueError('one movie and bounded media data required') + if layout[-1][1] + layout[-1][3] != pathlib.Path(path).stat().st_size: + raise ValueError('incomplete top-level layout') + with open(path, 'rb') as handle: + for _, offset, header, size in layout: + handle.seek(offset) + raw = handle.read(header) + declared = struct.unpack_from('>Q' if header == 16 else '>I', raw, 8 if header == 16 else 0)[0] + if declared and declared != size: + raise ValueError('declared box extent exceeds the file') + data = memoryview(track_boxes.read_box(path, *moov[0][1:], track_boxes.MAX_MOOV)) + + def one(parent, kind, minimum=0): + found = track_boxes.children(data, parent, kind) + if len(found) != 1 or found[0][1] - found[0][0] < minimum: + raise ValueError('missing or duplicated required box') + return found[0] + + tracks = [] + root = (0, len(data)) + mvhd = one(root, b'mvhd', 1) + if data[mvhd[0]] > 1 or mvhd[1] - mvhd[0] < (112 if data[mvhd[0]] else 100): + raise ValueError('invalid movie header') + for trak in track_boxes.children(data, root, b'trak'): + tkhd = one(trak, b'tkhd', 1) + if data[tkhd[0]] > 1 or tkhd[1] - tkhd[0] < (96 if data[tkhd[0]] else 84): + raise ValueError('invalid track header') + mdia = one(trak, b'mdia') + mdhd = one(mdia, b'mdhd', 1) + if data[mdhd[0]] > 1 or mdhd[1] - mdhd[0] < (36 if data[mdhd[0]] else 24): + raise ValueError('invalid media header') + stbl = one(one(mdia, b'minf'), b'stbl') + for kind in (b'stsd', b'stts', b'ctts', b'stss', b'stsz', b'stz2', b'stsc', b'stco', b'co64'): + tables = track_boxes.children(data, stbl, kind) + if len(tables) > 1 or any(end - start < 4 or data[start] not in ((0, 1) if kind == b'ctts' else (0,)) + for start, end in tables): + raise ValueError('duplicate sample table or unsupported table version') + if len(track_boxes.children(data, stbl, b'stco') + track_boxes.children(data, stbl, b'co64')) != 1: + raise ValueError('one chunk-offset table required') + stsd = one(stbl, b'stsd', 8) + stsc = one(stbl, b'stsc', 8) + count = track_boxes.u32(data, stsc[0] + 4) + if count > INPUT_SEEK_MAX_SAMPLES or stsc[0] + 8 + count * 12 != stsc[1]: + raise ValueError('sample-to-chunk table exceeds its bounds') + sizes = track_boxes.children(data, stbl, b'stsz') + track_boxes.children(data, stbl, b'stz2') + if (len(sizes) != 1 or sizes[0][1] - sizes[0][0] < 12 + or track_boxes.u32(data, sizes[0][0] + 8) > INPUT_SEEK_MAX_SAMPLES): + raise ValueError('input-seek sample budget exceeded') + rates = [] + elst = track_boxes.child(data, track_boxes.child(data, trak, b'edts'), b'elst') + if elst is not None: + width = 20 if data[elst[0]] == 1 else 12 + entries = track_boxes.u32(data, elst[0] + 4) + if data[elst[0]] > 1 or elst[0] + 8 + width * entries != elst[1]: + raise ValueError('invalid edit table extent') + rates = [track_boxes.u32(data, elst[0] + 8 + i * width + width - 4) for i in range(entries)] + groups = {} + for kind in (b'sgpd', b'sbgp'): + found = track_boxes.children(data, stbl, kind) + if len(found) > 1: + raise ValueError('multiple sample-group boxes') + groups[kind] = bytes(data[slice(*found[0])]) if found else None + tracks.append(dict(volume=track_boxes.u16(data, tkhd[0] + (48 if data[tkhd[0]] else 36)), + descriptions=track_boxes.u32(data, stsd[0] + 4), rates=rates, + indices=[track_boxes.u32(data, stsc[0] + 16 + i * 12) for i in range(count)], + groups=groups)) + brand = track_boxes.read_box(path, *layout[0][1:], track_boxes.MAX_MOOV)[:4] if layout[0][0] == b'ftyp' else None + media = (mdat[0][1] + mdat[0][2], mdat[0][1] + mdat[0][3]) if len(mdat) == 1 else None + return dict(tracks=tracks, brand=brand, media=media, + fragmented=any(b[0] == b'moof' for b in layout) or track_boxes.child(data, root, b'mvex') is not None) + + +def input_seek_times(track): + samples = track['samples'] + if samples['base_dts'] != 0 or samples['gap'] or not samples['sizes']: + raise ValueError('noncontiguous or empty sample timeline') + if any(d <= 0 for d in samples['durations']) or any(s <= 0 for s in samples['sizes']): + raise ValueError('nonpositive sample duration or size') + if sum(samples['sizes']) > INPUT_SEEK_MAX_RAW: + raise ValueError('input-seek sample byte budget exceeded') + dts, now = [], 0 + for duration in samples['durations']: + dts.append(now) + now += duration + compositions = [d + c for d, c in zip(dts, samples['cto'])] + if any(c < 0 for c in samples['cto']) or len(set(compositions)) != len(compositions): + raise ValueError('negative or duplicate composition time') + return dts, compositions + + +def input_seek_plan(record, operation, movie, facts): + seek, span = operation + tracks = movie['tracks'] + if seek < 0 or span <= 0 or facts['fragmented'] or len(tracks) != 2: + raise ValueError('outside the bounded two-track input-seek model') + positions = {t['handler']: i for i, t in enumerate(tracks)} + if set(positions) != {'vide', 'soun'} or movie['movie_timescale'] <= 0: + raise ValueError('one video and one audio track required') + runs = [] + for kind, handler in (('video', 'vide'), ('audio', 'soun')): + index = positions[handler] + track, extra = tracks[index], facts['tracks'][index] + entry = track['entry'] + config = writer_boxes.aac_config(entry) if kind == 'audio' else None + if (kind == 'video' and (entry['type'] != 'avc1' or track['timescale'] < 10_000) + or kind == 'audio' and (not config or config[0] >> 3 != 2)): + raise ValueError('AVC and AAC-LC source required') + if (extra['descriptions'] != 1 or set(extra['indices']) != {1} + or extra['volume'] != (0 if kind == 'video' else 256)): + raise ValueError('unsupported source sample description or volume') + edits = track['edits'] + if not edits or len(edits) != 1 or edits[0][1] < 0 or extra['rates'] != [65536]: + raise ValueError('one complete unit-rate source edit required') + clock, media = track['timescale'], edits[0][1] + if clock <= 0 or (seek * clock).denominator != 1 or (span * clock).denominator != 1: + raise ValueError('inexact media-clock bound') + dts, cts = input_seek_times(track) + samples = track['samples'] + last = max(c + d for c, d in zip(cts, samples['durations'])) + if edits[0][0] * clock != (last - media) * movie['movie_timescale']: + raise ValueError('incomplete source presentation edit') + if kind == 'video': + if cts[0] != media or len(set(samples['durations'])) != 1: + raise ValueError('nonzero origin or variable video cadence') + keys = [i for i, c in enumerate(cts) if samples['sync'][i] and c <= media + seek * clock] + if not keys or any(cts[a] >= cts[b] for a, b in zip(keys, keys[1:])): + raise ValueError('no decode access point') + first = max(keys, key=lambda i: cts[i]) + size = writer_boxes._length_size(entry) + packet = next(track_boxes.iter_sample_bytes(record['source'], samples['offsets'], samples['sizes'], first, 1)) + if size is None or writer_boxes.access('h264', packet, size, writer_boxes.h264_max_ref_frames(entry)) != 'must': + raise ValueError('unproved decode access point') + video_origin = fractions.Fraction(dts[first] - media, clock) + else: + if any(samples['cto']) or (video_origin * clock).denominator != 1: + raise ValueError('unqualified audio decode origin') + first = max((i for i, d in enumerate(dts) if d <= media + video_origin * clock), default=0) + expected = b'\0' * 4 + b'roll' + struct.pack('>III', 1, len(dts), 1) + if extra['groups'] not in ({b'sgpd': None, b'sbgp': None}, {b'sgpd': INPUT_SEEK_ROLL, b'sbgp': expected}): + raise ValueError('unqualified source audio roll') + end = next((i for i in range(first, len(dts)) if dts[i] >= media + (seek + span) * clock), len(dts)) + if end <= first: + raise ValueError('empty retained run') + if kind == 'video' and any(c < cts[first] for c in cts[first:end]): + raise ValueError('leading pictures before the retained access point') + runs.append(dict(kind=kind, source_index=index, first=first, count=end - first, + media_time=int(media + seek * clock - dts[first]))) + return runs + + +def input_seek_iso(record, operation, movie, plan, output, written): + if (written['brand'] != b'isom' or written['fragmented'] or len(output['tracks']) != 2 + or [t['handler'] for t in output['tracks']] != ['vide', 'soun']): + raise ValueError('invalid output layout or track selection') + problems, extents, durations, presented = [], [], [], 0 + streams = record['detail']['streams'] + if len(streams) != 2 or {s['kind'] for s in streams} != {'video', 'audio'}: + raise ValueError('invalid summary selection') + for run, track, extra in zip(plan, output['tracks'], written['tracks']): + kind = run['kind'] + src = movie['tracks'][run['source_index']] + dts, cts = input_seek_times(track) + samples = track['samples'] + found = writer_boxes.compare_samples(kind, record['source'], src, [(record['output'], samples)], run['first'], run['count']) + found += writer_boxes.compare_entry(kind, src, track) + writer_boxes.compare_timescale(kind, src, track) + found += writer_boxes.compare_media_duration(kind, track, sum(samples['durations'])) + problems.extend(message for _, message in found) + if extra['descriptions'] != 1 or set(extra['indices']) != {1} or extra['rates'] != [65536]: + raise ValueError('invalid output sample descriptions or edit rate') + span = max(c + d for c, d in zip(cts, samples['durations'])) - run['media_time'] + duration = fractions.Fraction(span * output['movie_timescale'], track['timescale']) + if (span <= 0 or run['media_time'] < 0 or duration.denominator != 1 + or track['edits'] != [(int(duration), run['media_time'], 1)] + or track['tkhd_duration'] != duration or extra['volume'] != (0 if kind == 'video' else 256)): + raise ValueError('output edit does not present the complete retained span') + durations.append(duration) + if kind == 'video': + presented = sum(c >= run['media_time'] for c in cts) + else: + expected = b'\0' * 4 + b'roll' + struct.pack('>III', 1, len(dts), 1) + if extra['groups'] != {b'sgpd': INPUT_SEEK_ROLL, b'sbgp': expected}: + raise ValueError('invalid audio roll membership') + summary = next(s for s in streams if s['kind'] == kind) + for key, value in (('stream_index', run['source_index']), ('first_sample', run['first']), ('samples', run['count'])): + if summary[key] != value: + raise ValueError('summary does not describe the independently selected samples') + extents.extend(zip(samples['offsets'], (o + s for o, s in zip(samples['offsets'], samples['sizes'])))) + extents.sort() + if (not extents or (extents[0][0], extents[-1][1]) != written['media'] + or any(a[1] != b[0] for a, b in zip(extents, extents[1:]))): + raise ValueError('sample extents do not tile the media box exactly') + if output['movie_duration'] != max(durations): + raise ValueError('movie duration is not its longest track') + def ratio(value): + exact = writer_corpus.ratio(value) + return fractions.Fraction(exact['num'], exact['den']) + summary = record['detail'] + seek, bound = operation + if (ratio(summary['start']['snapped']) != seek + or ratio(summary['end']['written']) != seek + durations[0] / output['movie_timescale'] + or fractions.Fraction(summary['start']['requested']['num'], summary['start']['requested']['den']) != seek + or fractions.Fraction(summary['end']['requested']['num'], summary['end']['requested']['den']) != seek + bound): + raise ValueError('summary presentation interval differs from the file and command') + return problems, presented + + +def input_seek_snapshot(ffprobe, path): + result = subprocess.run([ffprobe, '-v', 'error', '-show_streams', '-show_packets', '-show_frames', '-show_format', + '-show_data_hash', 'sha256', '-show_entries', INPUT_SEEK_ENTRIES, '-of', 'json', str(path)], + capture_output=True, timeout=TIMEOUT, check=False) + if result.returncode or result.stderr or len(result.stdout) > INPUT_SEEK_MAX_RAW: + raise ValueError('incomplete pinned snapshot') + return json.loads(result.stdout) + + +def input_seek_pin(record, tools, presented, directory): + import clip_check + reference = directory / pathlib.Path(record['output']).name + if pathlib.Path(record['input']).resolve() != pathlib.Path(record['source']).resolve(): + raise ValueError('copy command input differs from the independently checked source') + args = [record['input'] if a == 'INPUT' else str(reference) if a == 'OUTPUT' else a for a in record['args']] + result = subprocess.run([tools['ffmpeg'], '-nostdin', '-v', 'error', *AUDIO_DECODE_INPUT, *args], + capture_output=True, timeout=TIMEOUT, check=False) + if result.returncode or result.stderr or not reference.is_file(): + raise ValueError('pinned unchanged copy command failed') + ours = input_seek_snapshot(tools['ffprobe'], record['output']) + theirs = input_seek_snapshot(tools['ffprobe'], reference) + if ours != theirs: + return ['input-seek complete packet, stream, frame or format records differ from the pin'] + frames = [f for f in theirs.get('packets_and_frames', []) if f['type'] == 'frame' and f['stream_index'] == 0] + streams = theirs.get('streams', []) + if (len(streams) != 2 or streams[0].get('codec_name') != 'h264' or streams[1].get('codec_name') != 'aac' + or streams[1].get('profile') != 'LC' or len(frames) != presented): + raise ValueError('pinned decoded selection differs from the ISO presentation') + width, height = int(streams[0]['width']), int(streams[0]['height']) + pixels = len(frames) * (width * height + 2 * ((width + 1) // 2) * ((height + 1) // 2)) + audio = int(streams[1]['nb_frames']) * 1024 * int(streams[1]['channels']) * 4 + if not 0 < pixels <= INPUT_SEEK_MAX_RAW or not 0 < audio <= INPUT_SEEK_MAX_RAW: + raise ValueError('input-seek raw decode budget exceeded') + for kind in ('video', 'audio'): + raws = [] + for label, path in (('output', record['output']), ('reference', reference)): + raw = directory / (kind + '-' + label) + if kind == 'video': + command = [tools['ffmpeg'], '-nostdin', '-v', 'error', *AUDIO_DECODE_INPUT, '-i', str(path), + '-map', '0:v:0', '-an', '-fps_mode', 'passthrough', '-pix_fmt', 'yuv420p', '-f', 'rawvideo', str(raw)] + else: + command = [tools['ffmpeg'], '-nostdin', '-v', 'error', *AUDIO_DECODE_INPUT, '-i', str(path), + '-map', '0:a:0', '-vn', '-c:a', 'pcm_f32le', '-f', 'f32le', str(raw)] + result = subprocess.run(command, capture_output=True, timeout=TIMEOUT, check=False) + if result.returncode or result.stderr or not raw.is_file() or not 0 < raw.stat().st_size <= INPUT_SEEK_MAX_RAW: + raise ValueError('incomplete or over-budget input-seek decode') + if kind == 'video' and raw.stat().st_size != pixels: + raise ValueError('decoded video does not contain every presented frame') + raws.append(raw) + if raws[0].stat().st_size != raws[1].stat().st_size or not clip_check.same_bytes(raws[0], 0, raws[1]): + return [f'input-seek {kind} decoded bytes differ from the pin'] + if tools['mediainfo']: + ours = clip_check.mediainfo_tracks(tools['mediainfo'], record['output']) + theirs = clip_check.mediainfo_tracks(tools['mediainfo'], reference) + if any([t.get('Format') for t in rows if t.get('@type') == 'General'] != ['MPEG-4'] for rows in (ours, theirs)): + return ['input-seek MediaInfo container format differs'] + for kind, fields in (('Video', clip_check.MEDIAINFO_VIDEO), ('Audio', clip_check.MEDIAINFO_AUDIO)): + a, b = ([t for t in rows if t.get('@type') == kind] for rows in (ours, theirs)) + if len(a) != 1 or len(b) != 1 or any(a[0].get(k) != b[0].get(k) for k in fields): + return [f'input-seek {kind} MediaInfo fields differ from the pin'] + return [] + + +def input_seek_problems(record, operation, tools): + try: + source_facts = input_seek_facts(record['source']) + source = track_boxes.read_movie(record['source']) + plan = input_seek_plan(record, operation, source, source_facts) + written = input_seek_facts(record['output']) + output = track_boxes.read_movie(record['output']) + problems, presented = input_seek_iso(record, operation, source, plan, output, written) + if problems: + return problems + with tempfile.TemporaryDirectory(prefix='copy-check-input-seek-') as tmp: + return input_seek_pin(record, tools, presented, pathlib.Path(tmp)) + except (ValueError, track_boxes.Unreadable, struct.error) as error: + return [f'input-seek model: {error}'] + + def oracle(record, tools): """The problems the job's oracle finds in one written record.""" import clip_check @@ -419,6 +780,9 @@ def oracle(record, tools): selected = [s["stream_index"] for s in summary["streams"]] result = mkv_remux_check.check(source, record["output"], summary, mediainfo, selected=selected) return [json.dumps(p) for p in result["problems"]] + operation = input_seek_operation(record) + if operation is not None: + return input_seek_problems(record, operation, tools) src_movie = track_boxes.read_movie(source) if record["job"] in ("clip", "split") else None if record["job"] == "join": problems = join_check.check(entry(record, None, []), ffprobe, ffmpeg, mediainfo, concat=True)[0] @@ -472,7 +836,9 @@ def main(): # runs per command, since each spelling is its own ffmpeg command. groups = collections.defaultdict(list) for record in written: - groups[(record["source"], record["job"], digest(outputs(record)))].append(record) + operation = input_seek_operation(record) + key = tuple(record["args"]) if operation is not None else None + groups[(record["source"], record["job"], digest(outputs(record)), key)].append(record) failures = [] sequence = os.getpid() with concurrent.futures.ProcessPoolExecutor(max_workers=args.jobs) as pool: diff --git a/tools/compat-corpus/tests/test_copy_check.py b/tools/compat-corpus/tests/test_copy_check.py index 3ec389939..7ac50527c 100644 --- a/tools/compat-corpus/tests/test_copy_check.py +++ b/tools/compat-corpus/tests/test_copy_check.py @@ -1,9 +1,12 @@ """copy_check.py's reading of ffmpeg's stream mapping and of the summaries (docs/design/compat-cli.md, "coreav ffmpeg").""" +import copy import json +import os import pathlib import subprocess import sys +import tempfile import unittest from unittest import mock @@ -185,5 +188,319 @@ def at_5(command): self.assertEqual(at_5("-i INPUT -ss 6 -c copy OUTPUT"), (11, None)) +def seek_record(args=None): + return {'job': 'clip', 'source': 'source.mp4', 'input': 'source.mp4', 'output': 'out.mp4', + 'args': args or ['-ss', '9', '-i', 'INPUT', '-t', '3', '-c', 'copy', 'OUTPUT']} + + +class InputSeekDispatch(unittest.TestCase): + def test_both_finite_bound_spellings_and_copy_aliases(self): + F = copy_check.fractions.Fraction + self.assertEqual(copy_check.input_seek_args(seek_record()), (F(9), F(3))) + self.assertEqual(copy_check.input_seek_args(seek_record( + ['-y', '-ss', '00:00:09', '-i', 'INPUT', '-to', '3', '-vcodec', 'copy', '-acodec', 'copy', 'OUTPUT'])), + (F(9), F(3))) + + def test_outside_argument_shapes_stay_legacy(self): + for args in ( + ['-i', 'INPUT', '-ss', '9', '-t', '3', '-c', 'copy', 'OUTPUT'], + ['-ss', '9', '-t', '3', '-i', 'INPUT', '-c', 'copy', 'OUTPUT'], + ['-ss', '9', '-i', 'INPUT', '-c', 'copy', 'OUTPUT'], + ['-ss', '9', '-i', 'INPUT', '-t', '3', '-c:a', 'copy', 'OUTPUT'], + ['-ss', '9', '-i', 'INPUT', '-t', '3', '-c', 'copy', '-an', 'OUTPUT'], + ['-ss', '9', '-i', 'INPUT', '-t', '3', '-to', '3', '-c', 'copy', 'OUTPUT'], + ['-ss', '9', '-ss', '10', '-i', 'INPUT', '-t', '3', '-c', 'copy', 'OUTPUT'], + ['-ss', 'bad', '-i', 'INPUT', '-t', '3', '-c', 'copy', 'OUTPUT'], + ['-ss', '9', '-i', 'OTHER', '-t', '3', '-c', 'copy', 'OUTPUT'], + ['-ss', '9', '-i', 'INPUT', '-t', '3', '-c', 'copy', '-map', '0', 'OUTPUT'], + ): + with self.subTest(args=args): + self.assertIsNone(copy_check.input_seek_args(seek_record(args))) + + def test_source_facts_not_summary_or_fixture_name_choose_the_model(self): + record = seek_record() + record['detail'] = {'untrusted': 'not a selection authority'} + for handlers, admitted in ((['vide', 'soun'], True), (['soun', 'vide'], True), + (['vide'], False), (['soun'], False)): + with self.subTest(handlers=handlers), mock.patch.object(copy_check.track_boxes, 'read_movie', + return_value={'tracks': [{'handler': h, 'entry': {'type': 'avc1' if h == 'vide' else 'mp4a'}} + for h in handlers]}), \ + mock.patch.object(copy_check.writer_boxes, 'aac_config', return_value=b'\x12\x10'): + self.assertEqual(copy_check.input_seek_operation(record) is not None, admitted) + + def test_other_source_codecs_keep_the_legacy_input_seek_oracle(self): + import clip_check + tools = {'ffmpeg': 'ffmpeg', 'ffprobe': 'ffprobe', 'mediainfo': None} + for video, audio, config in (('avc1', 'fLaC', None), ('avc1', 'alac', None), + ('avc1', 'ac-3', None), ('hvc1', 'mp4a', b'\x12\x10'), + ('avc1', 'mp4a', b'\x2b\x90')): + movie = {'tracks': [{'handler': 'vide', 'entry': {'type': video}}, + {'handler': 'soun', 'entry': {'type': audio}}]} + with self.subTest(video=video, audio=audio, config=config), \ + mock.patch.object(copy_check.track_boxes, 'read_movie', return_value=movie), \ + mock.patch.object(copy_check.writer_boxes, 'aac_config', return_value=config), \ + mock.patch.object(copy_check, 'streams', return_value=[]), \ + mock.patch.object(copy_check, 'entry', return_value={'legacy': True}), \ + mock.patch.object(copy_check, 'range_problems', return_value=[]), \ + mock.patch.object(copy_check, 'input_seek_problems') as bounded, \ + mock.patch.object(clip_check, 'check', return_value=([], [], [], {})) as legacy: + self.assertIsNone(copy_check.input_seek_operation(seek_record())) + self.assertEqual(copy_check.oracle(seek_record(), tools), []) + legacy.assert_called_once_with({'legacy': True}, 'ffprobe', 'ffmpeg', None) + bounded.assert_not_called() + + def test_source_read_errors_cannot_pass_or_escape_to_legacy(self): + with mock.patch.object(copy_check.track_boxes, 'read_movie', side_effect=OSError('unreadable')): + self.assertIsNotNone(copy_check.input_seek_operation(seek_record())) + + def test_unreadable_aac_configuration_cannot_escape_to_legacy(self): + movie = {'tracks': [{'handler': 'vide', 'entry': {'type': 'avc1'}}, + {'handler': 'soun', 'entry': {'type': 'mp4a'}}]} + for config in (None, b''): + with self.subTest(config=config), \ + mock.patch.object(copy_check.track_boxes, 'read_movie', return_value=movie), \ + mock.patch.object(copy_check.writer_boxes, 'aac_config', return_value=config): + self.assertIsNotNone(copy_check.input_seek_operation(seek_record())) + + def test_legacy_dispatch_and_input_seek_dispatch_are_separate(self): + import clip_check + record = seek_record(['-i', 'INPUT', '-ss', '9', '-t', '3', '-c', 'copy', 'OUTPUT']) + tools = {'ffmpeg': 'ffmpeg', 'ffprobe': 'ffprobe', 'mediainfo': None} + with mock.patch.object(copy_check.track_boxes, 'read_movie', return_value={}), \ + mock.patch.object(copy_check, 'streams', return_value=[]), \ + mock.patch.object(copy_check, 'entry', return_value={'legacy': True}), \ + mock.patch.object(copy_check, 'range_problems', return_value=[]), \ + mock.patch.object(clip_check, 'check', return_value=([], [], [], {})) as legacy: + self.assertEqual(copy_check.oracle(record, tools), []) + legacy.assert_called_once_with({'legacy': True}, 'ffprobe', 'ffmpeg', None) + with mock.patch.object(copy_check, 'input_seek_operation', return_value=(9, 3)), \ + mock.patch.object(copy_check, 'input_seek_problems', return_value=['model failure']) as current, \ + mock.patch.object(clip_check, 'check') as legacy: + self.assertEqual(copy_check.oracle(seek_record(), tools), ['model failure']) + current.assert_called_once() + legacy.assert_not_called() + + +class InputSeekPin(unittest.TestCase): + def setUp(self): + self.owner = tempfile.TemporaryDirectory() + self.addCleanup(self.owner.cleanup) + self.directory = pathlib.Path(self.owner.name) + self.tools = {'ffmpeg': 'ffmpeg', 'ffprobe': 'ffprobe', 'mediainfo': None} + self.snapshot = {'packets_and_frames': [{'type': 'frame', 'stream_index': 0}], + 'streams': [{'codec_name': 'h264', 'width': 2, 'height': 2}, + {'codec_name': 'aac', 'profile': 'LC', 'channels': 1, 'nb_frames': '1'}]} + self.calls = [] + self.failure = None + + def run_command(self, args, **kwargs): + self.calls.append(args) + target = pathlib.Path(args[-1]) + if '-f' not in args: + target.write_bytes(b'pin') + return subprocess.CompletedProcess(args, int(self.failure == 'copy exit'), b'', + b'error' if self.failure == 'stderr' else b'') + data = b'pixels' if 'rawvideo' in args else b'pcm!' + if self.failure == target.name: + data = data[:-1] + b'?' + if self.failure == 'empty': + data = b'' + if self.failure == 'extra prefix' and target.name == 'audio-output': + data += b'extra' + target.write_bytes(data) + return subprocess.CompletedProcess(args, 0, b'', b'') + + def check_pin(self): + with mock.patch.object(copy_check.subprocess, 'run', side_effect=self.run_command), \ + mock.patch.object(copy_check, 'input_seek_snapshot', return_value=self.snapshot): + return copy_check.input_seek_pin(seek_record(), self.tools, 1, self.directory) + + def test_exact_copy_argv_and_complete_pixel_and_pcm_comparisons(self): + self.assertEqual(self.check_pin(), []) + expected = ['ffmpeg', '-nostdin', '-v', 'error', *copy_check.AUDIO_DECODE_INPUT, + '-ss', '9', '-i', 'source.mp4', '-t', '3', '-c', 'copy', str(self.directory / 'out.mp4')] + self.assertEqual(self.calls[0], expected) + self.assertEqual(len(self.calls), 5) + + def test_pixel_pcm_and_extra_prefix_changes_fail(self): + for failure in ('video-output', 'audio-output', 'extra prefix'): + self.failure = failure + with self.subTest(failure=failure): + self.assertTrue(self.check_pin()) + + def test_pin_failure_empty_decode_and_resource_cap_fail(self): + for failure in ('copy exit', 'stderr', 'empty'): + self.failure = failure + with self.subTest(failure=failure), self.assertRaises(ValueError): + self.check_pin() + self.failure = None + with mock.patch.object(copy_check, 'INPUT_SEEK_MAX_RAW', 1), self.assertRaises(ValueError): + self.check_pin() + + def test_complete_record_and_presented_frame_tie_cannot_be_skipped(self): + changed = copy.deepcopy(self.snapshot) + changed['streams'][0]['width'] = 3 + with mock.patch.object(copy_check.subprocess, 'run', side_effect=self.run_command), \ + mock.patch.object(copy_check, 'input_seek_snapshot', side_effect=[changed, self.snapshot]): + self.assertTrue(copy_check.input_seek_pin(seek_record(), self.tools, 1, self.directory)) + with mock.patch.object(copy_check.subprocess, 'run', side_effect=self.run_command), \ + mock.patch.object(copy_check, 'input_seek_snapshot', return_value=self.snapshot), self.assertRaises(ValueError): + copy_check.input_seek_pin(seek_record(), self.tools, 2, self.directory) + + def test_timeout_propagates_instead_of_becoming_success(self): + with mock.patch.object(copy_check.subprocess, 'run', side_effect=subprocess.TimeoutExpired('ffmpeg', 1)), \ + self.assertRaises(subprocess.TimeoutExpired): + copy_check.input_seek_pin(seek_record(), self.tools, 1, self.directory) + + +class InputSeekAudioEdit(unittest.TestCase): + def test_an_edit_inside_the_first_audio_sample_does_not_invent_a_recovery_requirement(self): + clock = 45_158_400 + video = {'handler': 'vide', 'timescale': 12288, 'mdhd_duration': 12288, 'tkhd_duration': clock, + 'edits': [(clock, 0, 1)], 'samples': {'base_dts': 0, 'gap': False, + 'sizes': [1] * 24, 'durations': [512] * 24, 'cto': [0] * 24, 'offsets': list(range(16, 40))}} + audio_span = (44 * 1024 - 546) * 1024 + audio = {'handler': 'soun', 'timescale': 44100, 'mdhd_duration': 44 * 1024, 'tkhd_duration': audio_span, + 'edits': [(audio_span, 546, 1)], 'samples': {'base_dts': 0, 'gap': False, + 'sizes': [1] * 44, 'durations': [1024] * 44, 'cto': [0] * 44, 'offsets': list(range(40, 84))}} + extra = {'descriptions': 1, 'indices': [1], 'rates': [65536], 'volume': 0} + a_extra = dict(extra, volume=256, groups={b'sgpd': copy_check.INPUT_SEEK_ROLL, + b'sbgp': b'\0' * 4 + b'roll' + copy_check.struct.pack('>III', 1, 44, 1)}) + written = {'brand': b'isom', 'fragmented': False, 'media': (16, 84), 'tracks': [extra, a_extra]} + movie = {'tracks': [video, audio], 'movie_timescale': clock, 'movie_duration': audio_span} + plan = [dict(kind='video', source_index=0, first=12, count=24, media_time=0), + dict(kind='audio', source_index=1, first=22, count=44, media_time=546)] + time = lambda ticks: {'ticks': {'num': ticks, 'den': 1}, 'timescale': 12288} + record = seek_record() + record['detail'] = {'streams': [dict(kind=r['kind'], stream_index=r['source_index'], + first_sample=r['first'], samples=r['count']) for r in plan], + 'start': {'requested': {'num': 1, 'den': 2}, 'snapped': time(6144)}, + 'end': {'requested': {'num': 3, 'den': 2}, 'written': time(18432)}} + F = copy_check.fractions.Fraction + with mock.patch.object(copy_check.writer_boxes, 'compare_samples', return_value=[]), \ + mock.patch.object(copy_check.writer_boxes, 'compare_entry', return_value=[]): + self.assertEqual(copy_check.input_seek_iso(record, (F(1, 2), F(1)), movie, plan, movie, written), ([], 24)) + + +@unittest.skipUnless(os.environ.get('COREAV_CURRENT_CI_ORACLE') == '1', 'requires the generated writer records and pinned oracle') +class InputSeekGenerated(unittest.TestCase): + @classmethod + def setUpClass(cls): + import test_oracle + pair = test_oracle.require() + cls.tools = {'ffmpeg': str(pair.ffmpeg), 'ffprobe': str(pair.ffprobe), 'mediainfo': None} + records = json.loads((pathlib.Path(os.environ['COREAV_FFMPEG_OUT']) / 'records.json').read_text()) + cls.records = [r for r in records if r['status'] == 'written' and copy_check.input_seek_operation(r) is not None] + if not cls.records: + raise AssertionError('no written generated input-seek records') + + def test_complete_generated_copies_pass_the_new_model(self): + for record in self.records: + with self.subTest(args=record['args'], source=record['source']): + self.assertEqual(copy_check.oracle(record, self.tools), []) + + def test_summary_tampering_fails_on_actual_outputs(self): + for original in self.records: + for field in ('start', 'end', 'count', 'first', 'index'): + record = copy.deepcopy(original) + if field == 'start': + record['detail']['start']['snapped']['ticks']['num'] -= 1 + elif field == 'end': + record['detail']['end']['written']['ticks']['num'] += 1 + else: + key = {'count': 'samples', 'first': 'first_sample', 'index': 'stream_index'}[field] + record['detail']['streams'][0][key] += 1 + with self.subTest(source=record['source'], field=field): + self.assertTrue(copy_check.oracle(record, self.tools)) + + def test_a_nonmatching_output_cannot_pass_through_the_input_seek_dispatch(self): + if len(self.records) < 2: + self.fail('two distinct generated input-seek outputs are required') + for index, original in enumerate(self.records): + record = copy.deepcopy(original) + record['output'] = self.records[(index + 1) % len(self.records)]['output'] + with self.subTest(source=record['source']): + self.assertIsNotNone(copy_check.input_seek_operation(record)) + self.assertTrue(copy_check.oracle(record, self.tools)) + + def test_non_avc_or_non_aac_sources_fail_the_bounded_model(self): + tb = copy_check.track_boxes + original = self.records[0] + before = pathlib.Path(original['source']).read_bytes() + moov = next(b for b in tb.top_level(original['source']) if b[0] == b'moov') + data = memoryview(tb.read_box(original['source'], *moov[1:], tb.MAX_MOOV)) + tracks = tb.children(data, (0, len(data)), b'trak') + for index, kind in ((0, b'hev1'), (1, b'ac-3')): + with self.subTest(codec=kind), tempfile.TemporaryDirectory() as tmp: + stbl = tb.child(data, tb.child(data, tb.child(data, tracks[index], b'mdia'), b'minf'), b'stbl') + stsd = tb.child(data, stbl, b'stsd') + entry = next(tb.boxes(data, stsd[0] + 8, stsd[1])) + changed = bytearray(before) + at = moov[1] + moov[2] + entry[1] - 4 + changed[at:at + 4] = kind + path = pathlib.Path(tmp) / 'changed-source.mp4' + path.write_bytes(changed) + record = copy.deepcopy(original) + record['source'] = record['input'] = str(path) + self.assertIsNone(copy_check.input_seek_operation(record)) + self.assertTrue(copy_check.input_seek_problems(record, copy_check.input_seek_args(record), self.tools)) + records = pathlib.Path(tmp) / 'records.json' + report = pathlib.Path(tmp) / 'report.json' + records.write_text(json.dumps([record])) + checked = subprocess.run([sys.executable, copy_check.__file__, '--records', str(records), + '--ffmpeg', self.tools['ffmpeg'], '--ffprobe', self.tools['ffprobe'], + '--jobs', '1', '--report', str(report)], capture_output=True, text=True, timeout=60) + self.assertEqual(checked.returncode, 1, checked.stdout + checked.stderr) + self.assertEqual(json.loads(report.read_text())['counts'][record['id']]['failed'], 1) + + def test_serialized_iso_mutations_fail_without_changing_the_source(self): + tb = copy_check.track_boxes + for original in self.records: + before = pathlib.Path(original['output']).read_bytes() + layout = tb.top_level(original['output']) + moov = next(b for b in layout if b[0] == b'moov') + data = memoryview(tb.read_box(original['output'], *moov[1:], tb.MAX_MOOV)) + tracks = tb.children(data, (0, len(data)), b'trak') + stbl = [tb.child(data, tb.child(data, tb.child(data, t, b'mdia'), b'minf'), b'stbl') for t in tracks] + elst = tb.child(data, tb.child(data, tracks[0], b'edts'), b'elst')[0] + ctts = tb.child(data, stbl[0], b'ctts')[0] + stsz = tb.child(data, stbl[0], b'stsz')[0] + stsc = tb.child(data, stbl[0], b'stsc')[0] + stco = [tb.child(data, t, b'stco')[0] for t in stbl] + sbgp = tb.child(data, stbl[1], b'sbgp')[0] + sgpd = tb.child(data, stbl[1], b'sgpd')[0] + stsd = tb.child(data, stbl[0], b'stsd')[0] + tkhd = tb.child(data, tracks[1], b'tkhd')[0] + delta = tb.u32(data, tb.child(data, stbl[0], b'stts')[0] + 12) + mutations = [(elst + 12, 0), (elst + 8, tb.u32(data, elst + 8) + 1), + (elst + 16, 65537), (ctts + 12, delta + tb.u32(data, ctts + 20)), + (ctts, 2 << 24), (ctts + 12, 0xffffffff), (stsz + 8, tb.u32(data, stsz + 8) + 1), + (stsc + 16, 2), (stco[1] + 8, tb.u32(data, stco[0] + 8)), + (sbgp + 12, 1), (sgpd + 14, 0x0001fffe), (stsd + 4, 2), (tkhd + 36, 128 << 16)] + for index, (at, value) in enumerate(mutations): + with self.subTest(source=original['source'], mutation=index), tempfile.TemporaryDirectory() as tmp: + changed = bytearray(before) + absolute = moov[1] + moov[2] + at + changed[absolute:absolute + 4] = value.to_bytes(4, 'big') + path = pathlib.Path(tmp) / 'changed.mp4' + path.write_bytes(changed) + record = copy.deepcopy(original) + record['output'] = str(path) + self.assertTrue(copy_check.oracle(record, self.tools)) + for field in ('sample byte', 'trailing byte'): + with self.subTest(source=original['source'], mutation=field), tempfile.TemporaryDirectory() as tmp: + changed = bytearray(before) + if field == 'sample byte': + mdat = next(b for b in layout if b[0] == b'mdat') + changed[mdat[1] + mdat[2]] ^= 1 + else: + changed += b'!' + path = pathlib.Path(tmp) / 'changed.mp4' + path.write_bytes(changed) + record = copy.deepcopy(original) + record['output'] = str(path) + self.assertTrue(copy_check.oracle(record, self.tools)) + + if __name__ == "__main__": unittest.main()