Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 141 additions & 48 deletions crates/coreav-cli/tests/ffmpeg_audio_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,14 +301,16 @@ fn audio_timing(path: &Path) -> Vec<Vec<u8>> {
]
}

fn decoded_audio(path: &Path) -> Vec<u8> {
fn decoded_audio(path: &Path, stream: usize) -> Vec<u8> {
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"])
.arg("-map")
.arg(format!("0:a:{stream}"))
.args(["-f", "f32le", "pipe:1"])
.output()
.unwrap(),
)
Expand Down Expand Up @@ -350,7 +352,11 @@ fn aac_terminal_timing_matches_the_complete_oracle_boxes() {
.unwrap(),
);
assert_eq!(audio_timing(&actual), audio_timing(&reference), "{name}");
assert_eq!(decoded_audio(&actual), decoded_audio(&reference), "{name}");
assert_eq!(
decoded_audio(&actual, 0),
decoded_audio(&reference, 0),
"{name}"
);
}
}
}
Expand Down Expand Up @@ -407,8 +413,8 @@ fn aac_terminal_timing_matches_the_complete_oracle_boxes() {
);
assert_eq!(audio_timing(&actual), audio_timing(&reference), "Matroska");
assert_eq!(
decoded_audio(&actual),
decoded_audio(&reference),
decoded_audio(&actual, 0),
decoded_audio(&reference, 0),
"Matroska"
);
}
Expand Down Expand Up @@ -755,55 +761,142 @@ fn copied_aac_and_opus_match_oracle_metadata_without_other_file_changes() {
#[test]
fn matroska_copy_bitrate_keeps_complete_native_files() {
for encoder in ["aac", "libopus"] {
let dir = fixture(&format!("copied-matroska-{encoder}"));
let encoded = dir.0.join("encoded.mkv");
oracle(
&dir.0,
&encoded,
&[
for container in ["mkv", "mp4"] {
let dir = fixture(&format!("copied-matroska-{encoder}-{container}"));
let encoded = dir.0.join(format!("encoded.{container}"));
oracle(
&dir.0,
&encoded,
&[
"-map",
"0:v",
"-map",
"0:a",
"-c:v",
"copy",
"-c:a",
encoder,
"-ar",
"48000",
"-b:a",
"72k",
"-disposition:a",
"0",
],
);
std::fs::rename(encoded, dir.0.join("source.mp4")).unwrap();
let options = [
"-map",
"0:v",
"-map",
"0:a",
"-c:v",
"copy",
"libx264",
"-threads",
"1",
"-fps_mode",
"passthrough",
"-c:a",
encoder,
"-ar",
"48000",
"-b:a",
"72k",
"-disposition:a",
"0",
],
);
std::fs::rename(encoded, dir.0.join("source.mp4")).unwrap();
let options = [
"-map",
"0:v",
"-map",
"0:a",
"-c:v",
"libx264",
"-threads",
"1",
"-fps_mode",
"passthrough",
"-c:a",
"aac",
"-c:a",
"copy",
];
let control = dir.0.join("control.mp4");
success(native(&dir.0, &control, &options, false));
let control = std::fs::read(control).unwrap();
assert_eq!(audio_entries(&control).len(), 2);
for rate in ["0", "16k", "96k", "128k", "garbage", "4294967296"] {
let mut args = options.to_vec();
args.extend(["-b:a", rate]);
let actual = dir.0.join("actual.mp4");
success(native(&dir.0, &actual, &args, false));
assert_eq!(std::fs::read(actual).unwrap(), control, "{encoder} {rate}");
"aac",
"-c:a",
"copy",
];
let input = if container == "mp4" {
let bytes = std::fs::read(dir.0.join("source.mp4")).unwrap();
let moov = boxes(&bytes, b"moov")[0];
let movie = child(moov, b"mvhd");
assert_eq!(movie[8], 0);
let clock = u64::from(u32::from_be_bytes(movie[20..24].try_into().unwrap()));
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(), 2);
let mut input = Vec::new();
for (stream, channels) in [(0, 1), (1, 2)] {
let edit = child(child(tracks[stream], b"edts"), b"elst");
assert_eq!(&edit[8..16], &[0, 0, 0, 0, 0, 0, 0, 1]);
assert_eq!(&edit[24..28], &[0, 1, 0, 0]);
let duration = u64::from(u32::from_be_bytes(edit[16..20].try_into().unwrap()));
assert_eq!(duration * 48_000 % clock, 0);
let samples = usize::try_from(duration * 48_000 / clock).unwrap();
assert_eq!(samples, 12_000);
let mut pcm = decoded_audio(&dir.0.join("source.mp4"), stream);
let tail = if encoder == "libopus" { 168 } else { 0 };
assert_eq!(pcm.len(), (samples + tail) * channels * 4);
pcm.truncate(samples * channels * 4);
input.push(pcm);
}
Some(input)
} else {
None
};
if container == "mp4" {
let matroska = dir.0.join("source.mkv");
success(native(&dir.0, &matroska, &options, false));
std::fs::rename(matroska, dir.0.join("source.mp4")).unwrap();
}
let control = dir.0.join("control.mp4");
success(native(&dir.0, &control, &options, false));
let reference = dir.0.join("presentation-reference.mp4");
oracle(&dir.0, &reference, &options);
for (stream, channels) in [(0, 1), (1, 2)] {
let source = decoded_audio(&dir.0.join("source.mp4"), stream);
let samples = if encoder == "aac" && container == "mkv" {
12_288
} else {
12_000
};
assert_eq!(
source.len(),
samples * channels * 4,
"{encoder} {container} {stream}"
);
if let Some(input) = &input {
assert!(
source == input[stream],
"Matroska intermediate: {encoder} {stream}"
);
}
let actual = decoded_audio(&control, stream);
assert!(
actual == source,
"Matroska copied presentation: {encoder} {container} {stream}; bytes {} vs {}; first mismatch {:?}",
actual.len(),
source.len(),
actual.iter().zip(&source).position(|(a, b)| a != b)
);
{
let reference = decoded_audio(&reference, stream);
let stride = channels * 4;
if encoder == "aac" {
assert_eq!(reference.len(), 12_000 * stride);
assert!(
reference != source,
"AAC oracle displacement disappeared; update the divergence record"
);
assert!(
reference[16 * stride..] == source[..reference.len() - 16 * stride],
"AAC oracle displacement is not 16 samples: {stream}"
);
} else {
assert_eq!(reference.len(), source.len() + 144 * stride);
assert!(
reference[..source.len() - 24 * stride] == source[24 * stride..],
"Opus oracle displacement is not 24 samples: {stream}"
);
}
}
}
let control = std::fs::read(control).unwrap();
assert_eq!(audio_entries(&control).len(), 2);
for rate in ["0", "16k", "96k", "128k", "garbage", "4294967296"] {
let mut args = options.to_vec();
args.extend(["-b:a", rate]);
let actual = dir.0.join("actual.mp4");
success(native(&dir.0, &actual, &args, false));
assert_eq!(std::fs::read(actual).unwrap(), control, "{encoder} {rate}");
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions docs/agents/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ ISO BMFF, Matroska, MPEG-TS, and AVI.

| Rule | Enforced by |
|---|---|
| A copied-audio presentation control retains the independent input before a CoreAV-written intermediate and derives terminal trim from that input's edit | `matroska_copy_bitrate_keeps_complete_native_files` decodes both original MP4 tracks before replacement, reads the declared `elst` presentation, and checks the intermediate against the original PCM or its declared Opus prefix before checking the final output. Both source-construction paths also expose the pinned oracle's displaced samples. A production type cannot prove an external decoder's complete PCM identity, so the regression owns this check |
| Copied-audio bitrate normalization distinguishes absent and explicit-zero requests and preserves unproved descriptions or MP3 clocks without new refusals | The shared policy type separates source-clock hints from explicit requests; the writer owns a spare-work-only MP3 header pass. Complete-file oracle controls cover default, CBR and VBR paths; synthetic fallback and descriptor/version tests pin unchanged output for unproved cases. Codec labels alone cannot prove a packet's complete frame clock |
| 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 |
Expand Down
32 changes: 32 additions & 0 deletions docs/design/copied-matroska-audio-clocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Copied Matroska audio presentation

## Plan and scope

Lock presentation preservation when `coreav ffmpeg` encodes video and copies AAC or Opus from Matroska into MP4. Decode the complete input and output audio through the same checksum-verified FFmpeg 9.0.2 decoder and compare every F32 PCM byte, including the leading and terminal samples. Exercise both mono and stereo tracks and retain the existing complete-file controls across bitrate options. Refs coreav-dev/coreav#25.

Production behavior does not change. MP4-sourced copies, native transcode and remux, and Matroska output remain unchanged. No command admission, decoder qualification, measurement registry, roster, judging rule, baseline or coverage count changes.

## Specification

[RFC 9559 section 5.1.4.1.25](https://www.rfc-editor.org/rfc/rfc9559.html#section-5.1.4.1.25) defines CodecDelay in nanoseconds as the codec samples discarded during playback. [Section 11.2](https://www.rfc-editor.org/rfc/rfc9559.html#section-11.2) requires subtracting that delay from the Block timestamp. [Section 5.1.3.5.7](https://www.rfc-editor.org/rfc/rfc9559.html#section-5.1.3.5.7) separately defines terminal DiscardPadding. Preserve the presented sample sequence rather than equating a transport-clock tick or a sum of MP4 sample durations with a decoded sample count.

## Known oracle divergence

Generated 0.25-second, 48 kHz mono controls expose a difference in FFmpeg 9.0.2's Matroska-to-MP4 stream copy. The AAC source declares CodecDelay of 21,333,333 ns, corresponding to 1,024 samples; the copied oracle MP4 skips 1,008. The Opus source declares 6,500,000 ns, or 312 samples; the oracle MP4 skips 336. These match the delays rounded first to the one-millisecond transport clock, rather than preserving their sample precision.

| Codec | Input PCM samples | CoreAV copied-output PCM samples | FFmpeg copied-output PCM samples | Oracle presentation difference |
|---|---:|---:|---:|---|
| AAC | 12,000 | 12,000 | 12,000 | The sequence is displaced by 16 samples; an 11,984-sample overlap is byte-identical |
| Opus | 12,000 | 12,000 | 12,144 | The first 24 samples are lost; an 11,976-sample overlap is byte-identical |

For each codec, CoreAV's complete decoded output is byte-identical to the input's decoded audio. The AAC oracle output retains 16 preceding samples and loses 16 terminal samples. The Opus oracle output loses 24 leading samples and retains 168 samples beyond the input presentation. Equal AAC sample counts alone cannot detect the displacement.

The AAC MP4 sample-duration sum is 13,312 ticks in CoreAV and 13,000 in the oracle. That aggregate difference is not evidence of incorrect CoreAV presentation: adopting the oracle clock and trims changes the decoded sequence. These observations do not assert that every MP4 sample delta must equal the codec frame length.

The regression extends `matroska_copy_bitrate_keeps_complete_native_files` in `crates/coreav-cli/tests/ffmpeg_audio_options.rs`. Four generated two-track inputs cover both direct Matroska encoding and an MP4-to-Matroska copy that retains terminal trims. All eight mono/stereo stream checks require complete input/output F32 byte identity. The trimmed controls require 12,000 samples per channel; directly encoded AAC retains its untrimmed final frame and presents 12,288. Twenty-four bitrate-option outputs must also retain the complete native file bytes.

Before the MP4-origin fixtures become Matroska, both original MP4 tracks are decoded and retained as independent anchors. Each original track's single `elst` entry has a `segment_duration` of 250 at the `mvhd` timescale of 1,000, declaring 12,000 presented samples at 48 kHz. AAC's entire decoded input must match the intermediate. The pinned decoder emits 12,168 samples from the Opus MP4, including 168 beyond that edit's endpoint; the intermediate must match exactly the first declared 12,000. The test checks those original lengths before selecting the Opus prefix. A wrong intermediate offset cannot pass by surviving a round trip through two CoreAV writers.

Both directly encoded and MP4-origin controls reproduce the oracle displacement and length differences as negative controls. Directly encoded AAC presents 12,288 source samples, while its oracle copy presents 12,000; the same 16-sample displacement remains visible in the complete overlapping values. A future oracle pin that fixes these differences should update this divergence record and its tripwires, not change CoreAV to retain the old oracle result. Fixture helpers name their input `source.mp4` even when it contains Matroska; both commands probe the format from its bytes.

The same decoder is used on both containers, so this establishes presentation preservation, not bit-exactness between independent lossy decoders. The oracle's differing stream-copy output is documented, not adopted as an acceptance target or registered as a measurement exception.
Loading