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
1 change: 1 addition & 0 deletions crates/coreav-cli/src/ffmpeg/encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/coreav-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions crates/coreav-cli/src/transcode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ pub struct Request {
pub opus: Option<OpusRequest>,
/// `--audio aac`: one audio stream encoded to AAC-LC instead.
pub aac: Option<AacRequest>,
/// 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<coreav_transcode::capture::Target>,
Expand Down Expand Up @@ -210,6 +212,7 @@ pub fn make<S: Source>(
(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(),
Expand Down
149 changes: 149 additions & 0 deletions crates/coreav-cli/tests/ffmpeg_audio_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>> {
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<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"])
.output()
.unwrap(),
)
}
Comment thread
capy-ai[bot] marked this conversation as resolved.
Resolved

#[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");
Expand Down
3 changes: 2 additions & 1 deletion crates/coreav-isobmff-write/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
4 changes: 3 additions & 1 deletion crates/coreav-isobmff-write/src/matroska.rs
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,9 @@ fn remux<S: Source, R: Source, W: Write>(
}
}
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 {
Expand Down
4 changes: 3 additions & 1 deletion crates/coreav-isobmff-write/src/remux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2146,7 +2146,9 @@ fn list_audio<R>(
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 {
Expand Down
27 changes: 27 additions & 0 deletions crates/coreav-isobmff-write/src/replace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
) -> impl Iterator<Item = AudioSample> + '_ {
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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/coreav-isobmff-write/tests/mkv_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
}),
},
Expand Down
84 changes: 84 additions & 0 deletions crates/coreav-isobmff-write/tests/replace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,7 @@ impl EncodedStream {
start,
priming: 312,
presented: total - 312,
aac_mp4_tail: false,
roll: 4,
data: SliceSource::new(&self.data),
}
Expand Down Expand Up @@ -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),
}
Expand All @@ -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));
Expand Down Expand Up @@ -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)
);
}
4 changes: 3 additions & 1 deletion crates/coreav-transcode/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading