diff --git a/docs/technical/container-contracts.md b/docs/technical/container-contracts.md new file mode 100644 index 0000000..4bb2c2e --- /dev/null +++ b/docs/technical/container-contracts.md @@ -0,0 +1,100 @@ +# Container contracts at a smart-render seam + +Smart-rendered pictures must agree with both the source timeline and the +description carried by their output container. Successful decoding alone does +not check either contract. + +## Source clock and aspect ratio + +For progressive, non-transport inputs, the writer refines its existing frame +grid to represent the source container's ticks. A rounded average frame rate +does not describe a 40/40/80 ms cadence or the millisecond-rounded timestamps +in Matroska. Repeated holds are detected against the median measured gap as +well as the average. The seek-index version changes because older caches can +misclassify that cadence. + +The refinement keeps the existing overflow bounds: an unrepresentable clock +or a duration that would exceed the signed 32-bit encoder grid retains the +previous grid. It is not a promise of exact timing for every possible clock. +Transport streams and interlaced material retain their previous clock rules. + +Selection slack is separate from display coverage. The coarse-clock planner +deliberately moves CFR selection bounds before a picture; that must not make +each output range a few milliseconds short. A re-encoded range uses the next +measured picture for its end, where that is within a frame, or the real file +end. VFR ranges can still end inside a hold. + +`av_guess_sample_aspect_ratio` supplies the container's effective SAR. It is +propagated to codec parameters, the boundary encoder and the output stream. +This matters particularly for AV1, which has no bitstream SAR to recover when +only `AVStream.sample_aspect_ratio` carries the MP4/Matroska display aspect. + +## AV1 configuration + +FFmpeg's `extract_extradata` bitstream filter extracts the Sequence Header from +each output packet. A small AV1 bit reader compares the public `av1C` fields; +it does not parse frame references or rewrite the coded pictures. The initial +track configuration comes from the first sequence actually written, with the +source extradata as a fallback when that sequence is carried only there. + +- MP4: every sequence must agree with the track's `av1C` profile, first + operating-point level/tier and bit-depth/chroma fields. Different sequence + bytes with those same fields can remain in the stream. +- Matroska/WebM: require the same Sequence Header payload throughout. This is + deliberately conservative about the `operating_parameters_info` exception + permitted by the mapping. + +The FFmpeg 7.1 MOV muxer writes one sample description. Updating its extradata +does not create another `stsd` entry. Therefore an incompatible seam cannot +be fixed merely by attaching the encoder's header. When the guard detects +one, the failed output context is closed and the selection is retried with +all its video ranges re-encoded. The log explicitly reports zero copied video +and the fallback. A second incompatible configuration remains an error. +Audio and subtitle modes still use the existing pipeline. + +This fallback costs an extra attempt and loses the original copied video. +The analysis/GUI plan can still show the initial copy estimate; the rendering +log is authoritative after a fallback. Multiple MP4 sample descriptions or a +provably identical Matroska sequence could avoid that cost in future work. + +## M2TS arrivals + +After muxing and any SI graft, M2TS arrival timestamps are rebuilt from the +PCR clock in the final packet layout. PCR wrap is unwrapped, arrival times +are interpolated between PCR anchors, and packets before/after the anchors +share the nearest arrival (a burst). A one-PCR, one-picture cut is supported. +Only the four-byte prefix changes; every 188-byte transport packet and the +two copy-permission bits are preserved. + +This avoids the invalid arrival clock produced by FFmpeg 7.1's variable-rate +M2TS muxing without selecting an arbitrary high fixed mux rate or adding null +packets. It adds a sequential read pass and an in-place read/write pass, with +memory proportional to PCR anchors. No PCR, malformed packet alignment or a +backward PCR is an error. This is not T-STD or Blu-ray compliance certification +and does not establish a maximum arrival/decoder-buffer rate. + +## Regression suite + +```bash +cd rust && cargo build --release --locked && cd .. +bash tests/run_container_contract_tests.sh +# Or select this suite through the normal runner: +bash tests/ci.sh container_contract +``` + +The standard-library Python suite needs FFmpeg/ffprobe with libx264, libx265, +libaom-av1 and librav1e. It generates its own 240-picture fixtures, checks 28 +exports, and retains their logs and `results.json` under the printed temporary +directory. `python3 tests/container_contract_tests.py --help` allows explicit +tool paths and a parent work directory, including a native Windows build. +Checks cover strict decoding, frame count, source display PTS, video duration, +SAR, AV1 descriptions and all three M2TS table modes. MP4 duration uses the +video track's duration: a B picture's `stts` decode duration is not necessarily +its display duration. + +Specifications and implementation references: + +- [AV1 in ISO BMFF](https://aomediacodec.github.io/av1-isobmff/v1.3.0.html) +- [Matroska AV1 mapping](https://github.com/ietf-wg-cellar/matroska-specification/blob/master/codec/av1.md) +- [FFmpeg 7.1.1 MOV muxer](https://github.com/FFmpeg/FFmpeg/blob/n7.1.1/libavformat/movenc.c) +- [FFmpeg 7.1.1 extradata filter](https://github.com/FFmpeg/FFmpeg/blob/n7.1.1/libavcodec/bsf/extract_extradata.c) diff --git a/docs/technical/validation.md b/docs/technical/validation.md index 74d2c59..5a029fe 100644 --- a/docs/technical/validation.md +++ b/docs/technical/validation.md @@ -4,6 +4,10 @@ ## Validation results +The [container contract regressions](container-contracts.md) additionally check +source-clock timing, display aspect, AV1 configuration and M2TS arrival clocks. +They generate their own fixtures and run through `tests/ci.sh`. + `tests/run_tests.sh` decodes the output, matches it against the source's frame hashes, and checks the frame count, the alignment, and how many frames are bit-exact. diff --git a/rust/crates/core/src/av1_config.rs b/rust/crates/core/src/av1_config.rs new file mode 100644 index 0000000..d057f30 --- /dev/null +++ b/rust/crates/core/src/av1_config.rs @@ -0,0 +1,306 @@ +//! Match a container's AV1 description to the sequence actually being written. +//! FFmpeg extracts the header; only its public container fields are read here. +use anyhow::{bail, Context, Result}; +use ff::packet::{Mut, Ref}; +use ffmpeg_next as ff; +use std::ffi::{c_char, c_int, c_void}; + +// ffmpeg-sys-next 7.1 omits bsf.h. These are the public FFmpeg 7.1 +// declarations, not private codec/muxer internals. +#[repr(C)] +struct Bsf { + class: *const c_void, + filter: *const c_void, + private: *mut c_void, + par_in: *mut ff::ffi::AVCodecParameters, + par_out: *mut ff::ffi::AVCodecParameters, + time_base_in: ff::ffi::AVRational, + time_base_out: ff::ffi::AVRational, +} +extern "C" { + fn av_bsf_get_by_name(name: *const c_char) -> *const c_void; + fn av_bsf_alloc(filter: *const c_void, context: *mut *mut Bsf) -> c_int; + fn av_bsf_init(context: *mut Bsf) -> c_int; + fn av_bsf_free(context: *mut *mut Bsf); + fn av_bsf_send_packet(context: *mut Bsf, packet: *mut ff::ffi::AVPacket) -> c_int; + fn av_bsf_receive_packet(context: *mut Bsf, packet: *mut ff::ffi::AVPacket) -> c_int; +} + +#[derive(Debug)] +pub(crate) struct Changed; +impl std::fmt::Display for Changed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("AV1 configurations cannot share this container description") + } +} +impl std::error::Error for Changed {} + +pub(crate) struct Guard { + filter: *mut Bsf, + sequence: Option>, + fields: Option<[u8; 3]>, + single_sequence: bool, + initial: Vec, +} +impl Drop for Guard { + fn drop(&mut self) { + unsafe { av_bsf_free(&mut self.filter) } + } +} +fn check(code: i32) -> Result<()> { + if code < 0 { + return Err(ff::Error::from(code).into()); + } + Ok(()) +} +impl Guard { + pub(crate) fn new(params: &ff::codec::Parameters, single_sequence: bool) -> Result { + let mut result = Self { + filter: std::ptr::null_mut(), + sequence: None, + fields: None, + single_sequence, + initial: Vec::new(), + }; + unsafe { + let p = params.as_ptr(); + if !(*p).extradata.is_null() && (*p).extradata_size > 0 { + let data = std::slice::from_raw_parts((*p).extradata, (*p).extradata_size as usize); + result.initial = if data.first() == Some(&0x81) { + data.get(4..).unwrap_or_default().to_vec() + } else { + data.to_vec() + }; + } + let filter = av_bsf_get_by_name(c"extract_extradata".as_ptr()); + if filter.is_null() { + bail!("FFmpeg has no extract_extradata filter"); + } + check(av_bsf_alloc(filter, &mut result.filter))?; + check(ff::ffi::avcodec_parameters_copy( + (*result.filter).par_in, + params.as_ptr(), + ))?; + // Writer assigns final timestamps after this data-only filter. + (*result.filter).time_base_in = ff::ffi::AVRational { num: 1, den: 1 }; + check(av_bsf_init(result.filter))?; + } + Ok(result) + } + + pub(crate) fn packet(&mut self, packet: &mut ff::Packet) -> Result<()> { + unsafe { + check(av_bsf_send_packet(self.filter, packet.as_mut_ptr()))?; + check(av_bsf_receive_packet(self.filter, packet.as_mut_ptr()))?; + let mut size = 0; + let mut extra = ff::ffi::av_packet_get_side_data( + packet.as_ptr(), + ff::ffi::AVPacketSideDataType::AV_PKT_DATA_NEW_EXTRADATA, + &mut size, + ); + if extra.is_null() { + if self.sequence.is_some() { + return Ok(()); + } + if self.initial.is_empty() { + bail!("first AV1 picture has no sequence configuration"); + } + size = self.initial.len(); + extra = ff::ffi::av_packet_new_side_data( + packet.as_mut_ptr(), + ff::ffi::AVPacketSideDataType::AV_PKT_DATA_NEW_EXTRADATA, + size, + ); + if extra.is_null() { + bail!("cannot allocate initial AV1 configuration"); + } + std::ptr::copy_nonoverlapping(self.initial.as_ptr(), extra, size); + } + let data = std::slice::from_raw_parts(extra, size); + let sequence = sequence(data).context("invalid extracted AV1 sequence header")?; + let fields = fields(sequence).context("truncated AV1 sequence header")?; + if self.fields.is_some_and(|old| old != fields) + || (self.single_sequence + && self.sequence.as_deref().is_some_and(|old| old != sequence)) + { + return Err(Changed.into()); + } + self.fields = Some(fields); + if self.sequence.is_none() { + self.sequence = Some(sequence.to_vec()); + } + } + Ok(()) + } +} + +fn sequence(mut data: &[u8]) -> Option<&[u8]> { + while let Some((&head, rest)) = data.split_first() { + if head & 0x81 != 0 { + return None; + } + let mut at = usize::from(head & 4 != 0); + let mut size = 0usize; + if head & 2 != 0 { + for n in 0..8 { + let byte = *rest.get(at)?; + at += 1; + size = size.checked_add(usize::from(byte & 127).checked_shl(n * 7)?)?; + if byte & 128 == 0 { + break; + } + if n == 7 { + return None; + } + } + } else { + size = rest.len().checked_sub(at)?; + } + let end = at.checked_add(size)?; + let body = rest.get(at..end)?; + if (head >> 3) & 15 == 1 { + return Some(body); + } + data = rest.get(end..)?; + } + None +} + +/// AV1 5.5.1/5.5.2, only as far as the av1C colour/subsampling fields. +/// The shared bit reader must not unescape AV1 as though it were a NAL. +fn fields(data: &[u8]) -> Option<[u8; 3]> { + let mut b = crate::bitstream::Bits::raw(data); + let profile = b.u(3)?; + b.u(1)?; + let reduced = b.u(1)? != 0; + let (mut level, mut tier) = (0, 0); + if reduced { + level = b.u(5)?; + } else { + let mut buffer_bits = 0; + let mut model = false; + if b.u(1)? != 0 { + b.skip(64)?; + if b.u(1)? != 0 { + let mut zeros = 0; + while b.u(1)? == 0 { + zeros += 1; + if zeros == 32 { + break; + } + } + if zeros < 32 { + b.skip(zeros)?; + } + } + model = b.u(1)? != 0; + if model { + buffer_bits = b.u(5)? as usize + 1; + b.skip(42)?; + } + } + let delay = b.u(1)? != 0; + let count = b.u(5)? + 1; + for i in 0..count { + b.skip(12)?; + let l = b.u(5)?; + let t = if l > 7 { b.u(1)? } else { 0 }; + if i == 0 { + level = l; + tier = t; + } + if model && b.u(1)? != 0 { + b.skip(buffer_bits * 2 + 1)?; + } + if delay && b.u(1)? != 0 { + b.skip(4)?; + } + } + } + let width = b.u(4)? as usize + 1; + let height = b.u(4)? as usize + 1; + b.skip(width + height)?; + if !reduced && b.u(1)? != 0 { + b.skip(7)?; + } + b.skip(3)?; + if !reduced { + b.skip(4)?; + let order_hint = b.u(1)? != 0; + if order_hint { + b.skip(2)?; + } + let screen = if b.u(1)? != 0 { 2 } else { b.u(1)? }; + if screen != 0 && b.u(1)? == 0 { + b.u(1)?; + } + if order_hint { + b.skip(3)?; + } + } + b.skip(3)?; + let high = b.u(1)?; + let twelve = if profile == 2 && high != 0 { + b.u(1)? + } else { + 0 + }; + let mono = if profile == 1 { 0 } else { b.u(1)? }; + let colour = if b.u(1)? != 0 { + (b.u(8)?, b.u(8)?, b.u(8)?) + } else { + (2, 2, 2) + }; + let (sx, sy, position) = if mono != 0 { + b.u(1)?; + (1, 1, 0) + } else if colour == (1, 13, 0) { + (0, 0, 0) + } else { + b.u(1)?; + let (x, y) = match profile { + 0 => (1, 1), + 1 => (0, 0), + _ if twelve != 0 => { + let x = b.u(1)?; + (x, if x != 0 { b.u(1)? } else { 0 }) + } + _ => (1, 0), + }; + let p = if x != 0 && y != 0 { b.u(2)? } else { 0 }; + (x, y, p) + }; + Some([ + 0x81, + ((profile << 5) | level) as u8, + ((tier << 7) | (high << 6) | (twelve << 5) | (mono << 4) | (sx << 3) | (sy << 2) | position) + as u8, + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn container_fields_follow_real_eight_and_ten_bit_headers() { + // Headers from tiny libaom testsrc2 fixtures. 00 00 03 is literal + // AV1 data; treating it as H.26x emulation prevention corrupts it. + let ten = [0x0a, 0x0a, 0, 0, 0, 3, 0xb5, 0xfe, 0xf3, 0x6b, 0xe5, 1]; + let eight = [ + 0x0a, 0x0d, 0, 0, 0, 3, 0xb5, 0xfe, 0xf3, 0x6b, 0xe4, 0x40, 0x80, 0x80, 0x61, + ]; + assert_eq!(fields(sequence(&ten).unwrap()), Some([0x81, 0, 0x4c])); + assert_eq!(fields(sequence(&eight).unwrap()), Some([0x81, 0, 0x0c])); + } + + #[test] + fn malformed_obu_lengths_are_rejected() { + assert!(sequence(&[0x0a, 0x80]).is_none()); + assert!(sequence(&[0x0a, 255, 255, 255, 255, 255, 255, 255, 255]).is_none()); + assert!(fields(&[]).is_none()); + assert!(fields(&[0]).is_none()); + // AV1 bytes are not subject to H.264 emulation-prevention removal. + let mut b = crate::bitstream::Bits::raw(&[0, 0, 3]); + assert_eq!(b.u(24), Some(3)); + } +} diff --git a/rust/crates/core/src/bitstream.rs b/rust/crates/core/src/bitstream.rs index df65ac6..42be32c 100644 --- a/rust/crates/core/src/bitstream.rs +++ b/rust/crates/core/src/bitstream.rs @@ -865,6 +865,10 @@ pub(crate) struct Bits { } impl Bits { + pub(crate) fn raw(data: &[u8]) -> Self { + Self { data: data.to_vec(), pos: 0 } + } + pub(crate) fn new(nal: &[u8]) -> Self { let mut data = Vec::with_capacity(nal.len()); let mut i = 0; diff --git a/rust/crates/core/src/cut.rs b/rust/crates/core/src/cut.rs index cf8f6e7..69e60b3 100644 --- a/rust/crates/core/src/cut.rs +++ b/rust/crates/core/src/cut.rs @@ -635,17 +635,33 @@ struct Span { /// `seg.end` is the answer, held to the last picture's place and no /// further. A recording of fields of a declared rate (2:3 pulldown in an /// MP4) is on that grid too, and is answered the same way. -fn held_to_the_end(span: Span, anchor: Option, seg: &Segment, src: &Source, field: f64) -> Span { +fn held_to_the_end(span: Span, anchor: Option, seg: &Segment, src: &Source, field: f64, + next_picture: Option) -> Span { let Some(a) = anchor else { return span }; - let until = if src.duration > 0.0 { + let mut until = if src.duration > 0.0 { seg.end.min(src.duration) } else { seg.end }; + // The planner moves coarse-clock CFR bounds slightly before a picture + // to settle selection. That slack must not shorten its display coverage. + // Use the next measured picture, or the real file end, rather than the + // selection bound. VFR ranges may intentionally end inside a hold. + if !src.on_a_ts && !src.video.interlaced() && !src.video.variable_rate { + let tick = src.video.time_base; + let frame = src.video.frame_duration(); + if let Some(next) = next_picture.filter(|t| *t >= until && *t - until < frame) { + until = next; + } else if src.duration > until && src.duration - until <= 3.5 * tick { + until = src.duration; + } + } // Held to [`FAR`] for the reason the copy's own placing is. let held = ((until - a).min(FAR) / field).round() as i64; let own = Grid::of(src); - if coded_length_unbounded(&src.video) && own.unit() == field { + if (coded_length_unbounded(&src.video) || (!src.on_a_ts && !src.video.interlaced())) + && own.unit() == field + { // The last picture starts at most a frame of this grid before the // end its coded length gives (a field before, for the second field // of a pair), and it is up for at least a unit. @@ -753,6 +769,7 @@ struct SegmentCtx<'a> { /// Everything the writer needs that is shared across segments. struct Writer { octx: ff::format::context::Output, + av1_config: Option, /// Whether a track's `pid` is really a PID, for the messages that name /// one. See [`crate::Source::on_a_ts`]. on_a_ts: bool, @@ -1324,7 +1341,10 @@ impl Writer { /// each picture's own length: under pulldown the lengths differ, and a /// decode-order sum overtakes the presentation time it is supposed to /// precede. The muxer rejects that outright -- `pts < dts`. - fn push(&mut self, e: Emitted) -> Result<()> { + fn push(&mut self, mut e: Emitted) -> Result<()> { + if let Some(config) = self.av1_config.as_mut() { + config.packet(&mut e.packet)?; + } self.opening = false; if let Some(held) = self.held.as_mut() { if !held.sealed { @@ -3438,12 +3458,23 @@ fn take_graphics( } fn open_input(path: &str) -> Result<(crate::input::Demux, usize)> { - let ictx = crate::input::demux(&path)?; + let mut ictx = crate::input::demux(&path)?; let index = ictx .streams() .best(ff::media::Type::Video) .ok_or_else(|| anyhow!("no video stream"))? .index(); + // Propagate the container's display aspect to both copied parameters + // and the boundary encoder. AV1 has no bitstream SAR to fall back on. + unsafe { + let stream = *(*ictx.as_mut_ptr()).streams.add(index); + let sar = ff::ffi::av_guess_sample_aspect_ratio( + ictx.as_mut_ptr(), stream, std::ptr::null_mut(), + ); + if sar.num > 0 && sar.den > 0 { + (*(*stream).codecpar).sample_aspect_ratio = sar; + } + } Ok((ictx, index)) } @@ -4664,7 +4695,7 @@ fn copy_segment( } bail!("could not find the entry point at {:.3}s", seg.start); } - Ok(held_to_the_end(span, anchor, seg, src, field)) + Ok(held_to_the_end(span, anchor, seg, src, field, None)) } /// How the pictures of a re-encoded segment are produced. @@ -5438,7 +5469,43 @@ struct Grid { impl Grid { fn of(src: &Source) -> Grid { let v = &src.video; - Grid::from_rates(v.frame_rate, v.base_rate, v.variable_rate).held_within(src.duration) + let grid = Grid::from_rates(v.frame_rate, v.base_rate, v.variable_rate).held_within(src.duration); + if !src.on_a_ts && !v.interlaced() { + grid.on_clock(v.time_base, src.duration) + } else { + grid + } + } + + /// Keep the container's timestamp ticks representable, including on a + /// nominally constant recording. A rounded average is not its clock. + fn on_clock(self, seconds_per_tick: f64, duration: f64) -> Grid { + if !(seconds_per_tick.is_finite() && seconds_per_tick > 0.0) { + return self; + } + let clock = ff::Rational::from(seconds_per_tick).reduce(); + let (n, d) = (i64::from(clock.numerator()), i64::from(clock.denominator())); + if n <= 0 || d <= 0 { return self; } + let Some(needed) = d.checked_mul(self.den) else { return self }; + let Some(available) = n.checked_mul(2 * self.num) else { return self }; + let step = needed / unsafe { ff::ffi::av_gcd(needed, available) }; + // Any multiple of `step` represents both the field and source clock. + // Keep at least the existing precision, without taking the LCM with + // FINE: that can needlessly multiply the clock and exhaust the bound + // on a long recording whose GOP-sized timestamps fit comfortably. + let Some(groups) = self.sub.checked_add(step - 1).map(|s| s / step) else { return self }; + let Some(sub) = groups.checked_mul(step) else { return self }; + if self.num.checked_mul(sub).and_then(|v| v.checked_mul(2)).is_none() { + return self; + } + let aligned = Grid { sub, ..self }; + let scale = aligned.timescale(); + if scale > i64::from(i32::MAX) + || (duration.is_finite() && duration > 0.0 && scale as f64 * duration > f64::from(i32::MAX)) + { + return self; + } + aligned } /// The grid, with ticks coarse enough that the longest gap the recording @@ -5543,7 +5610,12 @@ impl Grid { /// Ticks per second in the output's own time base. fn timescale(self) -> i64 { - 2 * self.num * self.sub + let raw = 2 * self.num * self.sub; + raw / unsafe { ff::ffi::av_gcd(raw, self.den) } + } + + fn ticks_per_unit(self) -> i64 { + self.den / unsafe { ff::ffi::av_gcd(2 * self.num * self.sub, self.den) } } fn time_base(self) -> ff::Rational { @@ -5552,7 +5624,7 @@ impl Grid { /// How long one unit lasts, in seconds. fn unit(self) -> f64 { - self.den as f64 / self.timescale() as f64 + self.ticks_per_unit() as f64 / self.timescale() as f64 } /// What a picture shown for `fields` fields occupies. @@ -6014,6 +6086,7 @@ fn reencode_segment( // the gap to the previous picture is a frame by construction and the // range is answered by the picture it already had. let mut still_on_screen: Option = None; + let mut next_picture: Option = None; let least = src.video.least_gap(); macro_rules! place { @@ -6081,6 +6154,7 @@ fn reencode_segment( let Some(pts) = frame.pts() else { continue }; let t = pts as f64 * in_tb - src.start_time; if t >= seg.end - tol { + next_picture = Some(next_picture.map_or(t, |old| old.min(t))); past_end = true; continue; } @@ -6303,7 +6377,7 @@ fn reencode_segment( seg.start, seg.end, span.pictures, ); } - Ok(held_to_the_end(span, anchor, seg, src, field)) + Ok(held_to_the_end(span, anchor, seg, src, field, next_picture)) } /// Where the streams go on the way out. @@ -9148,7 +9222,41 @@ pub fn join_with_progress( // that file is gone. By then the output context has been dropped on the // way out, which is what lets Windows remove it. let done = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - cut_into(reels, master.min(reels.len().saturating_sub(1)), output, opts, progress) + let master = master.min(reels.len().saturating_sub(1)); + let progress: Option = + progress.map(std::sync::Arc::from); + let run = |reels: &[Reel]| { + let report = progress.clone().map(|f| { + Box::new(move |p, job, pass| f(p, job, pass)) as Report + }); + cut_into(reels, master, output, opts, report) + }; + let first = run(reels); + if !first.as_ref().err().is_some_and(|e| e.is::()) { + return first; + } + // FFmpeg 7.1's MOV muxer writes one sample description, and + // Matroska/WebM requires one AV1 sequence for the whole track. + // Retry the selection with a consistent encoder configuration; + // never claim the incompatible smart-rendered file was valid. + crate::say!( + "note: this container cannot describe the AV1 configuration change at a seam. \ + The entire selected video is re-encoded to keep one valid configuration." + ); + crate::say!(" copied 0.000s (0.0%), re-encoded the entire selected video (AV1 configuration fallback)"); + let full: Vec> = reels.iter().map(|r| { + r.plans.iter().map(|p| { + let mut full = crate::plan::reencode_range(&r.src.points, p.t_in, p.t_out); + if let Some(s) = full.segments.first_mut() { + s.frames = p.segments.iter().map(|s| s.frames).sum(); + } + full + }).collect() + }).collect(); + let fallback: Vec = reels.iter().zip(&full).map(|(r, plans)| Reel { + src: r.src, plans, after: r.after.clone(), + }).collect(); + run(&fallback).context("writing AV1 with one configuration after full-video fallback") })); if !matches!(done, Ok(Ok(()))) && ours { let _ = std::fs::remove_file(output); @@ -9199,6 +9307,7 @@ const WRITING_SHARE: f64 = 0.7; /// How a cut says where it has got to: which pass it is in, how far through /// the whole job that is, and how far through the pass itself. pub type Report = Box; +type SharedReport = std::sync::Arc; /// As [`cut`], reporting how far along it is. /// @@ -9407,14 +9516,12 @@ fn cut_into( } }; let grid = Grid::of(src); - let den = grid.den; - - // Output timebase: one tick per 1/(2*num*sub) second, so a *unit* is - // exactly `den` ticks, a field is `sub` of them and a normal frame twice - // that. 30000/1001 lands on 1/60000 with 1001 ticks per field -- integer - // arithmetic throughout, no rounding anywhere on the timeline, and - // 3-field pictures are representable. See [`Grid`] for the recording - // that needs the units smaller than a field. + let den = grid.ticks_per_unit(); + + // Normalize the field grid and container clock together. A unit is + // `den` ticks after reduction, a field is `sub` units, and 30000/1001 + // still lands on exactly 1001/60000 s per field. Container ticks remain + // expressible instead of being rounded to a grid derived from an average. let mut muxer_opts = ff::Dictionary::new(); let timescale = grid.timescale().to_string(); let to_ts = writing_ts(output); @@ -10051,10 +10158,16 @@ fn cut_into( let stated_rate = matches!(octx.format().name(), "matroska" | "webm") .then(|| rate_to_state(reels, grid)) .flatten(); + let av1_config = if src.video.codec == "av1" && (iso || matches!(octx.format().name(), "matroska" | "webm")) { + Some(crate::av1_config::Guard::new(¶ms, !iso)?) + } else { None }; { let mut ost = octx.add_stream(ff::encoder::find(ff::codec::Id::None))?; ost.set_parameters(params.clone()); ost.set_time_base(grid.time_base()); + unsafe { + (*ost.as_mut_ptr()).sample_aspect_ratio = (*params.as_ptr()).sample_aspect_ratio; + } if let Some((num, den)) = stated_rate { ost.set_avg_frame_rate(ff::Rational::new(num, den)); } @@ -10074,6 +10187,12 @@ fn cut_into( // writes an empty box: a file that says "wrote" and that nothing // can open. The version said as 1, the layout being the same. let par = ost.parameters().as_mut_ptr(); + if av1_config.is_some() { + // Let the first actual output sequence describe the track, + // including when every selected picture is re-encoded. + ff::ffi::av_freep(std::ptr::addr_of_mut!((*par).extradata).cast()); + (*par).extradata_size = 0; + } if src.video.codec == "hevc" && !(*par).extradata.is_null() && (*par).extradata_size >= 23 @@ -10851,6 +10970,7 @@ fn cut_into( }); let mut writer = Writer { + av1_config, on_a_ts: src.on_a_ts, into_ts: octx.format().name().contains("mpegts"), octx, @@ -11409,7 +11529,7 @@ fn cut_into( // [`Writer::ends`] -- on its own grid only, as [`held_to_the_end`]. writer.keep_last = reel_no + 1 == reels.len() && plan_no + 1 == reel_plans[reel_no].len() - && last_held_to_the_end(&rsrc.video) + && (last_held_to_the_end(&rsrc.video) || (!rsrc.on_a_ts && !rsrc.video.interlaced())) && Grid::of(rsrc).unit() == grid.unit(); for (n, seg) in plan.segments.iter().enumerate() { let first_segment = n == 0; @@ -12021,6 +12141,10 @@ fn cut_into( } } + if writing_m2ts(output) { + crate::m2ts::restamp(output).context("clocking M2TS packet arrivals")?; + } + // Whichever pass was the last one, ending on the end of it. A cut whose // tables were not going back in never left the first. if let Some(report) = &progress { @@ -12037,6 +12161,24 @@ fn cut_into( mod tests { use super::*; + #[test] + fn container_ticks_survive_a_rate_derived_grid() { + for (rate, base, variable, tick) in [ + (6000.0 / 319.0, 25.0, true, 1.0 / 12800.0), + (30000.0 / 1001.0, 30000.0 / 1001.0, false, 0.001), + ] { + let before = Grid::from_rates(rate, base, variable); + let grid = before.on_clock(tick, 6000.0); + assert!(grid.unit() <= before.unit()); + for i in 0..10000 { + let t = i as f64 * tick; + let restored = (t / grid.unit()).round() * grid.unit(); + assert!((restored - t).abs() < 1e-10); + } + assert!(grid.timescale() <= i64::from(i32::MAX)); + } + } + /// Every `.sup` of one cut has a name of its own. A transport stream's /// are named as they always were, by language or else by PID; a Matroska /// file's, whose streams all sit on 0, by their stream where the language diff --git a/rust/crates/core/src/index.rs b/rust/crates/core/src/index.rs index 0538fce..32a6e22 100644 --- a/rust/crates/core/src/index.rs +++ b/rust/crates/core/src/index.rs @@ -417,6 +417,13 @@ fn varies(shown: &[f64], fd: f64) -> bool { if fd <= 0.0 || shown.len() < ENOUGH { return false; } + // The average can hide repeated holds: 40/40/80 ms averages to + // 53 ms, below the old 1.75-frame threshold. Use the typical measured + // gap as well; a 2:3 cadence and millisecond rounding still fit. + let mut gaps: Vec = shown.windows(2) + .map(|w| w[1] - w[0]).filter(|g| g.is_finite() && *g > 0.0).collect(); + gaps.sort_by(f64::total_cmp); + let fd = gaps.get(gaps.len().saturating_sub(1) / 2).copied().map_or(fd, |g| fd.min(g)); let (mut held, mut dense, mut counted) = (0usize, 0usize, 0usize); for w in shown.windows(2) { let gap = w[1] - w[0]; @@ -2064,6 +2071,26 @@ pub fn sample_bit_rate( mod tests { use super::*; + #[test] + fn repeated_holds_are_not_hidden_by_the_average() { + let mut shown = vec![0.0]; + for i in 0..239 { + shown.push(shown.last().unwrap() + if i % 3 == 2 { 0.08 } else { 0.04 }); + } + assert!(varies(&shown, 319.0 / 6000.0)); + } + + #[test] + fn rounded_ntsc_and_two_three_cadence_are_not_variable() { + let ntsc: Vec<_> = (0..240).map(|i| (i as f64 * 1001.0 / 30.0).round() / 1000.0).collect(); + assert!(!varies(&ntsc, 1001.0 / 30000.0)); + let mut film = vec![0.0]; + for i in 0..239 { + film.push(film.last().unwrap() + if i % 2 == 0 { 0.04 } else { 0.06 }); + } + assert!(!varies(&film, 0.04)); + } + /// Entry points every `every` seconds from `from` to `to`. fn every(from: f64, to: f64, every: f64) -> Vec { let mut out = Vec::new(); diff --git a/rust/crates/core/src/lib.rs b/rust/crates/core/src/lib.rs index 79a6dad..f4ddf4a 100644 --- a/rust/crates/core/src/lib.rs +++ b/rust/crates/core/src/lib.rs @@ -14,6 +14,7 @@ pub mod aac; pub mod adts; pub mod arib; pub mod audio; +pub(crate) mod av1_config; pub mod bdav; pub mod bitstream; pub mod blank; @@ -38,6 +39,7 @@ pub(crate) mod leadrefs; pub mod log; pub mod logo; pub mod mediainfo; +pub(crate) mod m2ts; pub mod netpath; pub mod nice; pub mod pgs; @@ -1777,7 +1779,14 @@ fn outline_of(path: &str) -> Result<(Outline, input::Demux)> { } else { std::slice::from_raw_parts((*p).extradata, (*p).extradata_size as usize).to_vec() }; - let sar = (*p).sample_aspect_ratio; + // AV1 can carry SAR only on AVStream (MP4 pasp / Matroska display + // dimensions), with no corresponding value in codecpar. + let sar = ff::ffi::av_guess_sample_aspect_ratio( + ictx.as_ptr() as *mut _, + stream.as_ptr() as *mut _, + std::ptr::null_mut(), + ); + let sar = if sar.num > 0 && sar.den > 0 { sar } else { (*p).sample_aspect_ratio }; ( (*p).width as u32, (*p).height as u32, diff --git a/rust/crates/core/src/m2ts.rs b/rust/crates/core/src/m2ts.rs new file mode 100644 index 0000000..6ca2dea --- /dev/null +++ b/rust/crates/core/src/m2ts.rs @@ -0,0 +1,179 @@ +//! Rebuild M2TS arrival prefixes from the muxed PCR clock, not a fictitious +//! VBR mux_rate of one bit/second. PES, PCR and all 188-byte packets stay put. +use anyhow::{bail, Context, Result}; +use std::io::{BufReader, Read, Seek, SeekFrom, Write}; + +const STRIDE: usize = 192; +const WRAP: u64 = (1u64 << 33) * 300; + +fn pcr(packet: &[u8]) -> Option { + if packet.len() != 188 + || packet[0] != 0x47 + || packet[3] & 0x20 == 0 + || packet[4] < 7 + || packet[4] > 183 + || packet[5] & 0x10 == 0 + { + return None; + } + let b = &packet[6..12]; + let base = (u64::from(b[0]) << 25) + | (u64::from(b[1]) << 17) + | (u64::from(b[2]) << 9) + | (u64::from(b[3]) << 1) + | u64::from(b[4] >> 7); + let ext = (u64::from(b[4] & 1) << 8) | u64::from(b[5]); + (ext < 300).then_some(base * 300 + ext) +} + +/// PCR anchors refer to the completed output, after SI insertion/PID changes. +/// Between anchors packets are spread along that clock. Prefix/suffix packets +/// share the nearest anchor's arrival (a burst), including a one-picture cut. +#[allow(clippy::chunks_exact_to_as_chunks)] // Keep the workspace's Rust 1.82 MSRV. +pub(crate) fn restamp(path: &str) -> Result<()> { + let size = std::fs::metadata(path)?.len(); + if size == 0 || size % STRIDE as u64 != 0 { + bail!("M2TS output is not 192-byte aligned"); + } + let mut input = BufReader::with_capacity(1 << 20, std::fs::File::open(path)?); + let mut frame = [0u8; STRIDE]; + let mut anchors = Vec::new(); + let mut selected = None; + let mut last = None; + let mut clock = 0u64; + for at in 0..size / STRIDE as u64 { + input.read_exact(&mut frame)?; + if frame[4] != 0x47 { + bail!("lost M2TS packet sync"); + } + let pid = (u16::from(frame[5] & 31) << 8) | u16::from(frame[6]); + let Some(raw) = pcr(&frame[4..]) else { + continue; + }; + let pid_chosen = *selected.get_or_insert(pid); + if pid != pid_chosen { + continue; + } + if let Some(before) = last { + let delta = (raw + WRAP - before) % WRAP; + if delta > WRAP / 2 { + bail!("M2TS PCR runs backwards"); + } + clock += delta; + } else { + clock = raw; + } + last = Some(raw); + anchors.push((at, clock)); + } + drop(input); + if anchors.is_empty() { + bail!("M2TS output has no PCR to clock its arrivals"); + } + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path)?; + let mut block = vec![0u8; STRIDE * 4096]; + let mut at = 0; + let mut next = 0; + while at < size { + let take = (size - at).min(block.len() as u64) as usize; + file.read_exact(&mut block[..take]) + .context("reading M2TS arrival prefixes")?; + for (i, frame) in block[..take].chunks_exact_mut(STRIDE).enumerate() { + let packet = at / STRIDE as u64 + i as u64; + while next + 1 < anchors.len() && anchors[next + 1].0 <= packet { + next += 1; + } + let (from, time) = anchors[next]; + let arrival = if packet <= from || next + 1 == anchors.len() { + time + } else { + let (until, end) = anchors[next + 1]; + time + ((u128::from(end - time) * u128::from(packet - from)) + / u128::from(until - from)) as u64 + }; + let flags = u32::from_be_bytes(frame[..4].try_into().unwrap()) & 0xc0000000; + let prefix = flags | (arrival as u32 & 0x3fffffff); + frame[..4].copy_from_slice(&prefix.to_be_bytes()); + } + file.seek(SeekFrom::Start(at))?; + file.write_all(&block[..take]) + .context("writing M2TS arrival prefixes")?; + at += take as u64; + } + file.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn packet(clock: Option) -> [u8; STRIDE] { + let mut p = [0xff; STRIDE]; + p[..4].copy_from_slice(&0x80000000u32.to_be_bytes()); + p[4..8].copy_from_slice(&[0x47, 0x10, 0x11, 0x20]); + p[8] = 183; + p[9] = if clock.is_some() { 0x10 } else { 0 }; + if let Some(t) = clock { + let base = t / 300; + let ext = t % 300; + p[10..16].copy_from_slice(&[ + (base >> 25) as u8, + (base >> 17) as u8, + (base >> 9) as u8, + (base >> 1) as u8, + ((base & 1) << 7) as u8 | 0x7e | (ext >> 8) as u8, + ext as u8, + ]); + } + p + } + #[test] + #[allow(clippy::chunks_exact_to_as_chunks)] // as_chunks requires Rust 1.88. + fn arrival_wrap_does_not_change_a_transport_packet() { + let path = + std::env::temp_dir().join(format!("smartcut-arrivals-{}.m2ts", std::process::id())); + let begin = (1u64 << 30) - 27000; + let frames = [ + packet(None), + packet(Some(begin)), + packet(None), + packet(Some(begin + 54000)), + packet(None), + ]; + let before: Vec = frames.iter().flatten().copied().collect(); + std::fs::write(&path, &before).unwrap(); + restamp(path.to_str().unwrap()).unwrap(); + let after = std::fs::read(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + let mut last = None; + for (a, b) in before.chunks_exact(192).zip(after.chunks_exact(192)) { + assert_eq!(&a[4..], &b[4..]); + let t = u32::from_be_bytes(b[..4].try_into().unwrap()); + assert_eq!(t & 0xc0000000, 0x80000000); + let t = t & 0x3fffffff; + if let Some(prev) = last { + assert!((t.wrapping_sub(prev) & 0x3fffffff) < 1 << 29); + } + last = Some(t); + } + } + #[test] + fn pcr_wrap_and_a_single_pcr_are_usable() { + for (n, frames) in [ + vec![packet(Some(1000)), packet(None)], + vec![packet(Some(WRAP - 300)), packet(None), packet(Some(300))], + ] + .iter() + .enumerate() + { + let path = + std::env::temp_dir().join(format!("smartcut-pcr-{}-{n}.m2ts", std::process::id())); + std::fs::write(&path, frames.iter().flatten().copied().collect::>()).unwrap(); + restamp(path.to_str().unwrap()).unwrap(); + std::fs::remove_file(path).unwrap(); + } + } +} diff --git a/rust/crates/core/src/seek_index.rs b/rust/crates/core/src/seek_index.rs index 27972bf..2784e5a 100644 --- a/rust/crates/core/src/seek_index.rs +++ b/rust/crates/core/src/seek_index.rs @@ -144,7 +144,9 @@ static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new( /// the walk sees that its pictures come three times as close: an index /// written before has no such figure, and a cut opened from it would be /// planned on the average where a fresh open plans on the pictures. -pub const VERSION: u32 = 18; +/// 19: repeated holds are measured against the typical gap as well as the +/// average. Older caches may have classified 40/40/80 ms as constant. +pub const VERSION: u32 = 19; const MAGIC: &[u8; 4] = b"SCIX"; diff --git a/tests/ci.sh b/tests/ci.sh index 55166ad..bc4aa70 100755 --- a/tests/ci.sh +++ b/tests/ci.sh @@ -28,7 +28,7 @@ SUITES=( audio_codec audio_smart audio_format audio_head bilingual index proxy disc bdav udf dvd bd_audio - vp9_av1 vfr demux transrate join + vp9_av1 vfr container_contract demux transrate join ) [ $# -gt 0 ] && SUITES=("$@") diff --git a/tests/container_contract_tests.py b/tests/container_contract_tests.py new file mode 100644 index 0000000..9c09cb0 --- /dev/null +++ b/tests/container_contract_tests.py @@ -0,0 +1,204 @@ +"""Synthetic regressions for container SAR, source clocks, AV1 config and ATS. + +Requires only Python's standard library and FFmpeg/ffprobe. Generated media +stay in a new work directory, printed at the end for failure investigation. +""" +import argparse +from fractions import Fraction +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--cut', type=Path, default=Path('rust/target/release/smartcut' + ('.exe' if os.name == 'nt' else ''))) + parser.add_argument('--ffmpeg', default=os.environ.get('FFMPEG', 'ffmpeg')) + parser.add_argument('--ffprobe', default=os.environ.get('FFPROBE', 'ffprobe')) + parser.add_argument('--work-dir', type=Path) + args = parser.parse_args() + work = Path(tempfile.mkdtemp(prefix='smartcut-container-', dir=args.work_dir)) + cut = args.cut.resolve() + results = [] + + def run(command): + p = subprocess.run(list(map(str, command)), capture_output=True, timeout=240) + if p.returncode: + raise AssertionError(f'exit {p.returncode}: {command}\n{p.stderr.decode(errors="replace")[-2500:]}') + return p + + def probe(path): + return json.loads(run([args.ffprobe, '-v', 'error', '-select_streams', 'v:0', '-show_streams', + '-show_packets', '-show_data', '-show_format', '-of', 'json', path]).stdout) + + def unhex(text): + return b''.join(bytes.fromhex(line.split(':', 1)[1].split(' ')[0].replace(' ', '')) + for line in text.splitlines() if ':' in line) + + def sequences(d): + headers = set() + for p in d['packets']: + data = unhex(p.get('data', '')) + while data: + h, data = data[0], data[1:] + if h & 4: + data = data[1:] + size = len(data) + if h & 2: + size = 0 + for i in range(8): + v, data = data[0], data[1:] + size |= (v & 127) << (7*i) + if not v & 128: + break + body, data = data[:size], data[size:] + if (h >> 3) & 15 == 1: + headers.add(body) + return headers + + def av1_contract(path, d): + # FFmpeg's independent CBS reader reports every actual sequence level. + trace = run([args.ffmpeg, '-hide_banner', '-loglevel', 'info', '-i', path, '-map', '0:v:0', + '-c', 'copy', '-bsf:v', 'trace_headers', '-f', 'null', '-']).stderr.decode(errors='replace') + levels = [int(x) for x in re.findall(r'\bseq_level_idx\[0\]\s+\S+\s+=\s*(\d+)', trace)] + assert levels, 'CBS did not report a sequence level' + if path.suffix in ('.mkv', '.webm'): + assert len(sequences(d)) == 1, 'one Matroska AV1 track contains different Sequence Headers' + extra = unhex(d['streams'][0]['extradata']) + else: + def boxes(data): + pos = 0 + while pos + 8 <= len(data): + n = int.from_bytes(data[pos:pos+4], 'big'); kind = data[pos+4:pos+8]; head = 8 + if n == 1: + n = int.from_bytes(data[pos+8:pos+16], 'big'); head = 16 + if n == 0: + n = len(data) - pos + assert n >= head and pos+n <= len(data) + yield kind, data[pos+head:pos+n] + pos += n + def find(data, wanted): + for kind, body in boxes(data): + if kind == wanted: + yield body + elif kind in (b'moov', b'trak', b'mdia', b'minf', b'stbl'): + yield from find(body, wanted) + descriptions = list(find(path.read_bytes(), b'stsd')) + video = [body for body in descriptions if body[12:16] == b'av01'] + assert len(video) == 1 + entries = list(boxes(video[0][8:])) + assert len(entries) == 1, 'this regression targets the FFmpeg 7.1 single-description route' + configs = [b for k, b in boxes(entries[0][1][78:]) if k == b'av1C'] + assert len(configs) == 1 + extra = configs[0] + assert extra[0] == 0x81 + assert all(level == extra[1] & 31 for level in levels), (levels, extra[:4].hex()) + + def check(path, src, ranges): + d = probe(path) + s = src['streams'][0]; st = Fraction(s['time_base']) + packets = sorted(src['packets'], key=lambda p: int(p['pts'])) + times = [int(p['pts'])*st for p in packets] + times.append(times[-1]+int(packets[-1]['duration'])*st) + expected = []; duration = Fraction(0) + for a, b in ranges: + expected += [duration+t-times[a] for t in times[a:b]] + duration += times[b]-times[a] + tb = Fraction(d['streams'][0]['time_base']) + observed = sorted(int(p['pts'])*tb for p in d['packets']) + origin = observed[0] + assert len(observed) == len(expected), (len(observed), len(expected)) + assert all(abs(a-origin-b) <= tb for a, b in zip(observed, expected)), 'source display PTS changed' + # MP4 stts durations advance the decode clock. With reordered B + # pictures, pts + that packet's stts duration is not its display end. + # Use the video track duration; these fixtures have no other tracks. + end = (Fraction(d['format']['duration'])-origin if path.suffix == '.mkv' + else Fraction(d['streams'][0]['duration'])) + assert abs(end-duration) <= max(tb, Fraction(1, 1000)), (float(end), float(duration)) + assert d['streams'][0].get('sample_aspect_ratio') == s.get('sample_aspect_ratio'), 'SAR changed' + run([args.ffmpeg, '-v', 'error', '-xerror', '-err_detect', 'explode', '-i', path, + '-map', '0:v:0', '-f', 'null', '-']) + if s['codec_name'] == 'av1': + av1_contract(path, d) + return d + + def export(name, source, ranges, suffix=None, extra=()): + src = probe(source); tb = Fraction(src['streams'][0]['time_base']) + ps = sorted(src['packets'], key=lambda p: int(p['pts'])) + times = [int(p['pts'])*tb for p in ps] + times.append(times[-1]+int(ps[-1]['duration'])*tb) + out = work/(name+(suffix or source.suffix)) + cmd = [cut, source, '--no-chapters', '-o', out, *extra] + for a, b in ranges: + cmd += ['--keep', f'{float(times[a]-times[0]):.12f}-{float(times[b]-times[0]):.12f}'] + log = run(cmd); out.with_suffix(out.suffix+'.log').write_bytes(log.stdout+log.stderr) + d = check(out, src, ranges) + results.append({'name': name, 'frames': len(d['packets']), + 'full_video_fallback': b'The entire selected video is re-encoded' in log.stdout+log.stderr}) + print('ok', name, flush=True) + return out + + def generate(name, encoder, rate='25', vfr=False, sar=False): + out = work/(name+'.mp4') + filters = [] + if vfr: + filters.append('setpts=(N+floor(N/3))/(25*TB)') + if sar: + filters.append('setsar=4/3') + enc = {'libx264':['-preset','medium','-crf','14','-bf','3'], + 'libx265':['-preset','medium','-crf','18','-x265-params','log-level=error:pools=none'], + 'libaom-av1':['-cpu-used','8','-crf','22','-b:v','0'], + 'librav1e':['-speed','10','-qp','70']}[encoder] + cmd = [args.ffmpeg, '-v', 'error', '-y', '-f', 'lavfi', '-i', f'testsrc2=size=192x112:rate={rate}', + '-frames:v', '240', '-an', '-c:v', encoder, *enc, '-g', '24', '-pix_fmt', 'yuv420p'] + if filters: + cmd += ['-vf', ','.join(filters)] + cmd += ['-fps_mode', 'passthrough', out] + run(cmd) + return out + + for codec in ('libx264', 'libx265', 'libaom-av1'): + source = generate(codec+'-vfr', codec, vfr=True) + for suffix in ('.mp4', '.mkv'): + current = source + if suffix == '.mkv': + current = source.with_suffix(suffix) + run([args.ffmpeg, '-v', 'error', '-y', '-i', source, '-c', 'copy', current]) + for label, ranges in [('partial', [(13,227)]), ('multi', [(13,81),(100,227)]), ('one', [(23,24)])]: + export(codec+'-vfr-'+suffix[1:]+'-'+label, current, ranges) + ntsc = generate('ntsc', 'libx264', rate='30000/1001') + run([args.ffmpeg, '-v', 'error', '-y', '-i', ntsc, '-c', 'copy', ntsc.with_suffix('.mkv')]) + export('rounded-ntsc', ntsc.with_suffix('.mkv'), [(13,81),(100,227)]) + sar = generate('sar', 'libaom-av1', sar=True) + export('sar-copy', sar, [(0,240)]) + export('sar-reencode', sar, [(13,227)]) + changed = generate('level31', 'librav1e') + export('av1-description', changed, [(13,227)]) + + source = generate('transport', 'libx264') + ts = source.with_suffix('.ts') + run([args.ffmpeg, '-v', 'error', '-y', '-i', source, '-c', 'copy', ts]) + # Transport clocks have a lead-in, so check frame count/decoding here; + # the native-file research matrix checks their relative PTS separately. + for mode in ('muxer', 'partial', 'broadcast'): + for label, bounds in [('partial','0.52-9.08'),('one','0.92-0.96')]: + out = work/f'arrivals-{mode}-{label}.m2ts' + run([cut, ts, '--keep', bounds, '--tables', mode, '-o', out]) + data = out.read_bytes(); assert len(data)%192 == 0 + stamps = [] + for pos in range(0,len(data),192): + assert data[pos+4] == 0x47 + stamps.append(int.from_bytes(data[pos:pos+4],'big') & 0x3fffffff) + assert all(((b-a) & 0x3fffffff) < 1 << 29 for a,b in zip(stamps,stamps[1:])), 'arrival clock runs backwards' + d = probe(out); assert len(d['packets']) == (214 if label == 'partial' else 1) + run([args.ffmpeg, '-v', 'error', '-xerror', '-err_detect', 'explode', '-i', out, '-map', '0:v', '-f', 'null', '-']) + results.append({'name':out.stem,'frames':len(d['packets'])}); print('ok',out.stem,flush=True) + (work/'results.json').write_text(json.dumps(results, indent=2)+'\n', encoding='utf-8') + print(f'{len(results)} passed; evidence: {work}',flush=True) + + +if __name__ == '__main__': + main() diff --git a/tests/run_container_contract_tests.sh b/tests/run_container_contract_tests.sh new file mode 100644 index 0000000..671b89d --- /dev/null +++ b/tests/run_container_contract_tests.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -eu +cd "$(dirname "$0")/.." +python3 tests/container_contract_tests.py