diff --git a/crates/coreav-cli/src/ffmpeg/encode.rs b/crates/coreav-cli/src/ffmpeg/encode.rs index b2e05ff24..a0b67ee1c 100644 --- a/crates/coreav-cli/src/ffmpeg/encode.rs +++ b/crates/coreav-cli/src/ffmpeg/encode.rs @@ -312,6 +312,7 @@ pub fn request( left_out_covers: left_out_covers.clone(), opus, aac, + aac_mp4_tail: true, #[cfg(feature = "input-capture")] input_capture: None, pixel_format, diff --git a/crates/coreav-cli/src/main.rs b/crates/coreav-cli/src/main.rs index d02409e1b..b9cb30f1c 100644 --- a/crates/coreav-cli/src/main.rs +++ b/crates/coreav-cli/src/main.rs @@ -1178,6 +1178,7 @@ fn run_transcode( stream: None, bitrate: args.audio_bitrate.map(|k| k * 1000), }), + aac_mp4_tail: false, #[cfg(feature = "input-capture")] input_capture: None, pixel_format: None, diff --git a/crates/coreav-cli/src/transcode.rs b/crates/coreav-cli/src/transcode.rs index 8225c81d2..604d858e9 100644 --- a/crates/coreav-cli/src/transcode.rs +++ b/crates/coreav-cli/src/transcode.rs @@ -101,6 +101,8 @@ pub struct Request { pub opus: Option, /// `--audio aac`: one audio stream encoded to AAC-LC instead. pub aac: Option, + /// Match the FFmpeg adapter's terminal AAC sample timing in MP4. + pub aac_mp4_tail: bool, /// Optional actual AAC-encoder stdin capture. #[cfg(feature = "input-capture")] pub input_capture: Option, @@ -210,6 +212,7 @@ pub fn make( (None, false, None, None) => AudioMode::Copy, }, left_out_covers: request.left_out_covers.clone(), + aac_mp4_tail: request.aac_mp4_tail, encoder: request.encoder.choice(), x264: request.x264.clone(), ffmpeg: request.ffmpeg.clone(), diff --git a/crates/coreav-cli/tests/ffmpeg_audio_options.rs b/crates/coreav-cli/tests/ffmpeg_audio_options.rs index 75762b97c..cdfaabf0f 100644 --- a/crates/coreav-cli/tests/ffmpeg_audio_options.rs +++ b/crates/coreav-cli/tests/ffmpeg_audio_options.rs @@ -110,6 +110,155 @@ fn audio_properties(path: &Path) -> serde_json::Value { serde_json::from_slice(&bytes).unwrap() } +fn boxes<'a>(mut bytes: &'a [u8], kind: &[u8; 4]) -> Vec<&'a [u8]> { + let mut found = Vec::new(); + while !bytes.is_empty() { + let size = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize; + assert!(size >= 8 && size <= bytes.len()); + let (item, rest) = bytes.split_at(size); + if &item[4..8] == kind { + found.push(item); + } + bytes = rest; + } + found +} + +fn child<'a>(bytes: &'a [u8], kind: &[u8; 4]) -> &'a [u8] { + let found = boxes(&bytes[8..], kind); + assert_eq!(found.len(), 1, "{kind:?}"); + found[0] +} + +fn audio_timing(path: &Path) -> Vec> { + let bytes = std::fs::read(path).unwrap(); + let moov = boxes(&bytes, b"moov")[0]; + let tracks: Vec<_> = boxes(&moov[8..], b"trak") + .into_iter() + .filter(|track| &child(child(track, b"mdia"), b"hdlr")[16..20] == b"soun") + .collect(); + assert_eq!(tracks.len(), 1); + let track = tracks[0]; + let media = child(track, b"mdia"); + vec![ + child(child(child(media, b"minf"), b"stbl"), b"stts").to_vec(), + child(media, b"mdhd").to_vec(), + child(child(track, b"edts"), b"elst").to_vec(), + ] +} + +fn decoded_audio(path: &Path) -> Vec { + success( + Command::new(test_oracle::require().ffmpeg()) + .args(["-nostdin", "-v", "error"]) + .args(include_str!("../../../scripts/oracle-audio-args.txt").split_whitespace()) + .arg("-i") + .arg(path) + .args(["-map", "0:a:0", "-f", "f32le", "pipe:1"]) + .output() + .unwrap(), + ) +} + +#[test] +fn aac_terminal_timing_matches_the_complete_oracle_boxes() { + let dir = fixture("aac-timing"); + let source = std::fs::read(dir.0.join("source.mp4")).unwrap(); + let movie = boxes(&source, b"moov")[0]; + let source_clock = + u32::from_be_bytes(child(movie, b"mvhd")[20..24].try_into().unwrap()).to_string(); + for (stream, rate) in [("0:a:0", "48000"), ("0:a:1", "44100")] { + for video in [false, true] { + for explicit in [false, true] { + let name = format!("{rate}-{video}-{explicit}"); + let actual = dir.0.join(format!("{name}-actual.mp4")); + let reference = dir.0.join(format!("{name}-reference.mp4")); + let mut args = if video { + vec!["-map", "0:v", "-c:v", "libx264", "-threads", "1"] + } else { + vec!["-vn"] + }; + args.extend(["-map", stream, "-b:a", "96k"]); + if explicit { + args.extend(["-c:a", "aac"]); + } + success(native(&dir.0, &actual, &args, false)); + success( + Command::new(test_oracle::require().ffmpeg()) + .args(["-nostdin", "-v", "error"]) + .args(policy()) + .arg("-i") + .arg(dir.0.join("source.mp4")) + .args(&args) + .args(["-movie_timescale", if video { &source_clock } else { rate }]) + .arg(&reference) + .output() + .unwrap(), + ); + assert_eq!(audio_timing(&actual), audio_timing(&reference), "{name}"); + assert_eq!(decoded_audio(&actual), decoded_audio(&reference), "{name}"); + } + } + } + let source = dir.0.join("source.mkv"); + success( + Command::new(test_oracle::require().ffmpeg()) + .args(["-nostdin", "-v", "error"]) + .args(policy()) + .arg("-i") + .arg(dir.0.join("source.mp4")) + .args(["-map", "0:v", "-map", "0:a:0", "-c", "copy"]) + .arg(&source) + .output() + .unwrap(), + ); + let actual = dir.0.join("matroska-actual.mp4"); + let reference = dir.0.join("matroska-reference.mp4"); + let args = [ + "-c:v", + "libx264", + "-c:a", + "aac", + "-threads", + "1", + "-b:a", + "96k", + "-fps_mode", + "passthrough", + ]; + success( + Command::new(env!("CARGO_BIN_EXE_coreav")) + .args(["ffmpeg", "-i"]) + .arg(&source) + .args(args) + .arg(&actual) + .env("COREAV_FFMPEG", test_oracle::require().ffmpeg()) + .env("COREAV_FALLBACK", dir.0.join("no-whole-command-fallback")) + .env_remove("COREAV_FALLBACK_ACTIVE") + .env_remove("COREAV_STRICT_SAFE") + .output() + .unwrap(), + ); + success( + Command::new(test_oracle::require().ffmpeg()) + .args(["-nostdin", "-v", "error"]) + .args(policy()) + .arg("-i") + .arg(&source) + .args(args) + .args(["-movie_timescale", "48000"]) + .arg(&reference) + .output() + .unwrap(), + ); + assert_eq!(audio_timing(&actual), audio_timing(&reference), "Matroska"); + assert_eq!( + decoded_audio(&actual), + decoded_audio(&reference), + "Matroska" + ); +} + #[test] fn identity_audio_options_and_default_aac_bitrate_run_natively() { let dir = fixture("encode"); diff --git a/crates/coreav-isobmff-write/src/lib.rs b/crates/coreav-isobmff-write/src/lib.rs index 64ffd78aa..f6908ddab 100644 --- a/crates/coreav-isobmff-write/src/lib.rs +++ b/crates/coreav-isobmff-write/src/lib.rs @@ -67,7 +67,8 @@ pub use remux::{ remux_mp4, remux_mp4_inputs, remux_mp4_inputs_with_options, remux_mp4_replacing, sanitize_mp4, }; pub use replace::{ - AudioReplacement, AudioSample, Replacement, ReplacementSample, Replacements, roll_boxes, + AudioReplacement, AudioSample, Replacement, ReplacementSample, Replacements, mp4_audio_samples, + roll_boxes, }; pub use split::split_mp4; diff --git a/crates/coreav-isobmff-write/src/matroska.rs b/crates/coreav-isobmff-write/src/matroska.rs index 8639153a6..55ecdae51 100644 --- a/crates/coreav-isobmff-write/src/matroska.rs +++ b/crates/coreav-isobmff-write/src/matroska.rs @@ -896,7 +896,9 @@ fn remux( } } if let Some(a) = audio { - for sample in a.samples { + let aac_end = (a.aac_mp4_tail && a.sample_entry.get(4..8) == Some(b"mp4a")) + .then(|| u64::from(a.priming) + a.presented); + for sample in crate::mp4_audio_samples(a.samples, aac_end) { muxer.push_sample( id, Sample { diff --git a/crates/coreav-isobmff-write/src/remux.rs b/crates/coreav-isobmff-write/src/remux.rs index 9e600a79d..55feeb850 100644 --- a/crates/coreav-isobmff-write/src/remux.rs +++ b/crates/coreav-isobmff-write/src/remux.rs @@ -2146,7 +2146,9 @@ fn list_audio( index: usize, audio: &AudioReplacement<'_, R>, ) -> Result<(), ExtractError> { - for sample in audio.samples { + let aac_end = (audio.aac_mp4_tail && audio.sample_entry.get(4..8) == Some(b"mp4a")) + .then(|| u64::from(audio.priming) + audio.presented); + for sample in crate::mp4_audio_samples(audio.samples, aac_end) { muxer.push_sample( index, Sample { diff --git a/crates/coreav-isobmff-write/src/replace.rs b/crates/coreav-isobmff-write/src/replace.rs index 0a8a3b4b1..38398ac4e 100644 --- a/crates/coreav-isobmff-write/src/replace.rs +++ b/crates/coreav-isobmff-write/src/replace.rs @@ -263,6 +263,30 @@ pub struct AudioSample { pub duration: u32, } +/// Samples for an MP4 audio track. For AAC, `aac_end` is the media end +/// (`priming + presented`); a partially presented final sample ends there. +/// Wholly padded final samples and other codecs (`None`) keep their durations. +pub fn mp4_audio_samples( + samples: &[AudioSample], + mut aac_end: Option, +) -> impl Iterator + '_ { + samples.iter().enumerate().map(move |(index, sample)| { + let duration = if index + 1 == samples.len() { + aac_end + .and_then(|end| u32::try_from(end).ok()) + .filter(|&end| end > 0 && end <= sample.duration) + .unwrap_or(sample.duration) + } else { + sample.duration + }; + aac_end = aac_end.and_then(|end| end.checked_sub(u64::from(sample.duration))); + AudioSample { + duration, + ..*sample + } + }) +} + /// Samples that replace one audio stream of a remuxed source: an encoded /// stream on a timeline of its own (Opus in ISOBMFF, opus-codec.org /// "Encapsulation of Opus in ISO Base Media File Format", 4.3 and 4.4; AAC @@ -302,6 +326,9 @@ pub struct AudioReplacement<'a, R> { /// Media ticks presented from `priming` on; at least 1, and at most /// what the samples hold after `priming`. pub presented: u64, + /// Shorten a partially presented final AAC sample in MP4 to the media + /// end. Other codecs and Matroska output keep the supplied durations. + pub aac_mp4_tail: bool, /// The samples a decoder must decode before any one it starts at, at /// most 32,767: the `roll` group's distance (written negative). pub roll: u16, diff --git a/crates/coreav-isobmff-write/tests/mkv_output.rs b/crates/coreav-isobmff-write/tests/mkv_output.rs index 23bb98194..095a80b8b 100644 --- a/crates/coreav-isobmff-write/tests/mkv_output.rs +++ b/crates/coreav-isobmff-write/tests/mkv_output.rs @@ -337,6 +337,7 @@ fn wholly_padded_replacement_packets_are_not_written() { priming: 0, roll: 0, presented: 1536, + aac_mp4_tail: true, data: SliceSource::new(&[1, 2, 3]), }), }, diff --git a/crates/coreav-isobmff-write/tests/replace.rs b/crates/coreav-isobmff-write/tests/replace.rs index 479634da4..de751e089 100644 --- a/crates/coreav-isobmff-write/tests/replace.rs +++ b/crates/coreav-isobmff-write/tests/replace.rs @@ -1386,6 +1386,7 @@ impl EncodedStream { start, priming: 312, presented: total - 312, + aac_mp4_tail: false, roll: 4, data: SliceSource::new(&self.data), } @@ -1623,6 +1624,7 @@ fn aac_replacement( start: 0, priming: 1024, presented: aac.samples.len() as u64 * 1024 - 1024 - 300, + aac_mp4_tail: false, roll: 1, data: SliceSource::new(&aac.data), } @@ -1644,6 +1646,7 @@ fn assert_aac_track(out: &[u8], movie: &Movie, index: usize, aac: &EncodedStream assert_eq!((empty, media_time), (0, 1024)); let written = packets(out, movie, index); assert_eq!(written.len(), aac.samples.len()); + assert_eq!(track.mdhd_duration, aac.samples.len() as u64 * 1024); let mut at = 0; for (p, s) in written.iter().zip(&aac.samples) { assert_eq!((p.size, p.duration, p.keyframe), (s.size, 1024, true)); @@ -1711,3 +1714,84 @@ fn matroska_audio_replaced_with_aac() { assert_eq!(summary.tracks[1].codec, coreav_mkv::Codec::Aac); assert_aac_track(&out, &probe_out(&out), 1, &aac); } + +#[test] +fn aac_mp4_tail_does_not_change_padded_packets_or_other_codecs() { + for matroska in [false, true] { + let source = if matroska { + mkv_source("V_MPEG4/ISO/AVC") + } else { + av_movie() + }; + for tail in [0, 1, 736, 1024] { + let aac = aac_stream(5); + let mut replacement = aac_replacement(&aac, 1); + replacement.presented = 3 * 1024 + tail; + replacement.aac_mp4_tail = true; + let mut out = Vec::new(); + let replacements = Replacements { + video: None, + audio: Some(replacement), + }; + if matroska { + remux_matroska_replacing( + SliceSource::new(&source), + &coreav_mkv::Limits::default(), + &Limits::default(), + &coreav_mkv::Allowlist::default(), + &MatroskaRemuxOptions::default(), + replacements, + &mut out, + ) + .unwrap(); + } else { + remux_mp4_replacing( + SliceSource::new(&source), + &Limits::default(), + &Allowlist::default(), + &RemuxOptions::default(), + replacements, + &mut out, + ) + .unwrap(); + } + let movie = probe_out(&out); + let written = packets(&out, &movie, 1); + assert_eq!(written.len(), 5); + assert!(written[..4].iter().all(|p| p.duration == 1024)); + let duration = if tail == 0 { 1024 } else { tail as u32 }; + assert_eq!(written[4].duration, duration); + assert_eq!( + movie.tracks[1].mdhd_duration, + 4 * 1024 + u64::from(duration) + ); + let copied: Vec<_> = written + .iter() + .flat_map(|p| bytes(&out, p.offset, p.size)) + .collect(); + assert_eq!(copied, aac.data); + } + } + let opus = opus_stream(5, 960); + let mut replacement = opus.replacement(1, 0); + replacement.presented -= 200; + replacement.aac_mp4_tail = true; + let mut out = Vec::new(); + remux_mp4_replacing( + SliceSource::new(&av_movie()), + &Limits::default(), + &Allowlist::default(), + &RemuxOptions::default(), + Replacements { + video: None, + audio: Some(replacement), + }, + &mut out, + ) + .unwrap(); + assert!( + packets(&out, &probe_out(&out), 1) + .iter() + .all(|p| p.duration == 960) + ); +} diff --git a/crates/coreav-transcode/src/audio.rs b/crates/coreav-transcode/src/audio.rs index a7f31b157..413506ba4 100644 --- a/crates/coreav-transcode/src/audio.rs +++ b/crates/coreav-transcode/src/audio.rs @@ -322,7 +322,9 @@ impl EncodedAudio { boxes: Vec::new(), }) .map_err(writer)?; - for sample in &self.samples { + let aac_end = (entry.get(4..8) == Some(b"mp4a")) + .then(|| u64::from(self.summary.pre_skip) + self.summary.samples); + for sample in coreav_isobmff_write::mp4_audio_samples(&self.samples, aac_end) { if Instant::now() >= deadline { return Err(Error::Decode(coreav_decode::Error::new( ErrorKind::Timeout, diff --git a/crates/coreav-transcode/src/pipeline.rs b/crates/coreav-transcode/src/pipeline.rs index 7bc7796aa..3721326af 100644 --- a/crates/coreav-transcode/src/pipeline.rs +++ b/crates/coreav-transcode/src/pipeline.rs @@ -139,6 +139,9 @@ pub struct TranscodeOptions { pub one_size: bool, /// The source's audio. pub audio: AudioMode, + /// Use the FFmpeg-compatible terminal AAC delta in MP4 output. + /// The default retains complete frame durations and native transcode bytes. + pub aac_mp4_tail: bool, /// The cover pictures of an MP4 source to leave out, as /// `RemuxOptions::left_out_covers` names them; the others are copied /// with the movie's metadata. @@ -188,6 +191,7 @@ impl Default for TranscodeOptions { pixel_format: None, one_size: false, audio: AudioMode::Copy, + aac_mp4_tail: false, left_out_covers: Vec::new(), encoder: EncoderChoice::Auto, x264: None, @@ -709,6 +713,7 @@ fn execute( start: encoded.start, priming: u32::from(encoded.summary.pre_skip), presented: encoded.summary.samples, + aac_mp4_tail: options.aac_mp4_tail, roll: encoded.mp4_description()?.1, data: BufferedSource::new( FileSource::from_file(encoded.spool.handle()?).map_err(spool_read)?, diff --git a/docs/agents/transcode.md b/docs/agents/transcode.md index 8dbd8a0d9..f6651931c 100644 --- a/docs/agents/transcode.md +++ b/docs/agents/transcode.md @@ -4,6 +4,7 @@ Encode path, sandboxed workers, and audio-encode selection. | Rule | Enforced by | |---|---| +| The ffmpeg adapter's AAC MP4 timing ends a partially presented final sample at priming plus the submitted sample count without changing native transcode bytes, edits or Matroska frames | `mp4_audio_samples` owns the terminal delta, selected by the adapter-only `aac_mp4_tail` policy for replacement tracks. `aac_terminal_timing_matches_the_complete_oracle_boxes` compares complete stts, mdhd and elst bytes and full decoded audio with the pinned oracle. Replacement regressions preserve the default complete-frame timing, exact boundaries, wholly padded packets and Opus durations; the unchanged legacy-encoder comparison retains byte-strict native transcode verification. Runtime oracle bytes cannot be established by codec frame length alone | | Legacy encoder comparisons admit only a source-declared SAR delta, including its exact forced output length | `sar_delta::check` compares every other summary byte, exit code, stderr, box field and complete `mdat` payload exactly; SPS changes use the audited owner's bit boundary and exact RBSP splice. The full legacy gate retains its reference and decodes both outputs; pure mutations and an actual-case `mdat` mutation reject non-SAR changes with named fields. Structural growth, not a file allowlist, owns `output.bytes` | | Unscaled H.264 encoding retains valid source sample-aspect declarations, including square pixels, without changing scale normalization | `sample_aspect` receives an optional scale result rather than inferring a scale request from dimensions. The declared/absent/invalid unit matrix and `unscaled_encoding_preserves_declared_sar_without_changing_scale` cover summary, `pasp`, encoder signalling and exact declared-source output; identity-scale controls retain normalization. The strict-safe controls query `coreav_worker::enforces_memory_limit` and assert the exact closed refusal on unsupported hosts, while retaining success and pixel comparisons on supported hosts. A ratio's numerical value cannot encode declaration presence, and a decoder feature cannot prove sandbox capability | | Unscaled AV1 input retains CPU or shared storage through the common encoder writer, including its direct-pipe capability | `Process::spawn` delegates to the same retaining launch path; `shared_av1_input_is_not_downloaded_without_scaling` checks the original shared owner and the scaling control, while `shared_frame_pipe` checks complete protocol bytes, direct writes without parent reads and owner release. The required AV1 fixture also transcodes a real AV1 source. Storage kinds are explicit in `Pixels`; pipe support remains an external runtime capability | diff --git a/docs/design/remux.md b/docs/design/remux.md index 704c31e91..7c5223d18 100644 --- a/docs/design/remux.md +++ b/docs/design/remux.md @@ -509,6 +509,7 @@ in an `mp4a` sample entry (ISO/IEC 14496-14), laid out the same way: when one fits 32 bits (as for the video replacement, and not when another stream's computed value would move), else each boundary rounds to the nearest movie tick. +- **AAC terminal duration:** `AudioReplacement::aac_mp4_tail` opts into shortening a partially presented final MP4 sample to end at `priming + presented`, matching the pinned oracle. The `coreav ffmpeg` adapter enables this policy; native `coreav transcode` keeps complete frame durations. Earlier samples and wholly padded final samples keep their durations in both modes. ISO/IEC 14496-12 8.6.1.1 and 8.6.1.2 define the media clock separately from edit slicing (8.6.6.1); a full final frame with an edit is also valid. This choice changes neither the edits nor the encoded frames used by Matroska output. - **Roll group** (the spec's 4.3.6.2, ISO/IEC 14496-12 10.1): one `sgpd` of type `roll` (version 1, one `AudioRollRecoveryEntry` of `roll_distance` `-roll`) and an `sbgp` that leaves the first `roll` diff --git a/docs/design/transcode.md b/docs/design/transcode.md index d00448564..36f97cd99 100644 --- a/docs/design/transcode.md +++ b/docs/design/transcode.md @@ -879,7 +879,12 @@ terminal errors. The real encode retains its complete Matroska readback. entry (the stream's rate, 16-bit sample size) with an `esds` box (ISO/IEC 14496-1 7.2.6.5: object type 0x40, an audio stream, the `AudioSpecificConfig` as read), media timescale the sample rate, every - frame a 1,024-tick sync sample, an edit list that presents the decoded + frame a 1,024-tick sync sample by default. The `coreav ffmpeg` adapter + enables `TranscodeOptions::aac_mp4_tail`: a partially presented final + frame ends at the delay plus the submitted sample count. Native + `coreav transcode` retains complete frame durations and its existing + output bytes; both representations are valid. Wholly padded final + frames, stored encoder frames and Matroska output stay unchanged. An edit list presents the decoded samples from movie time 0 (from a Matroska stream's start, after an empty edit, as for Opus) and leaves out the delay and the padding (the edit's media time is the delay), and a `roll` group of distance −1,