From 1fe4a2eec625a3acd2169eccb2b13da0a2dc1d6a Mon Sep 17 00:00:00 2001 From: Gunwoo Kim Date: Sun, 8 Nov 2026 00:13:34 +0900 Subject: [PATCH 1/2] docs(mp4): plan copied audio bitrate metadata Signed-off-by: Gunwoo Kim --- docs/design/copied-audio-bitrate.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/design/copied-audio-bitrate.md diff --git a/docs/design/copied-audio-bitrate.md b/docs/design/copied-audio-bitrate.md new file mode 100644 index 000000000..1eefceaf9 --- /dev/null +++ b/docs/design/copied-audio-bitrate.md @@ -0,0 +1,19 @@ +# Copied AAC and Opus bitrate metadata + +`coreav ffmpeg` can encode video while copying audio after an earlier AAC selection has been overridden by `copy`. Its existing admission rules stay unchanged. On this path a representable `-b:a` value also controls the copied AAC or Opus track's MP4 bitrate metadata; it does not encode or alter audio packets. + +The writer derives the average from complete copied sample tables as `data_bytes * 8 * timescale / duration`, rounded down. The maximum is the larger of that average and the requested bitrate. The decoder buffer size is zero. These are the pinned FFmpeg's observed values for generated AAC and Opus controls, including requests below and above the average, stale source metadata and missing `btrt` boxes. + +## Ownership + +The ffmpeg adapter parses the existing unsigned integer and decimal SI-suffix grammar without introducing a new error. Text that does not parse or fit the 32-bit bitrate fields retains its previous ignored behavior. Native `coreav transcode` and the ordinary remux entry points default to no override. The request passes the optional rate to the shared transcode options and then to the MP4-source or Matroska-source remux options. + +After all samples of a copied AAC or Opus track have been listed, a single `Muxer` operation updates its sample entry from the completed tables. Encoded replacements, video and copied MP3 never enter this operation. The `esds` parser remains owned by `coreav-isobmff`; the writer obtains validated rate-field offsets from that owner rather than adding another descriptor parser. Existing `btrt` fields are updated in place, or one bounded `btrt` is appended when absent. Added bytes stay within the writer's existing box and retained-metadata allowances. An incomplete or malformed entry, insufficient allowance or an unrepresentable average leaves the complete entry unchanged rather than adding a refusal. + +No packet, sample table, edit, priming field, tag or top-level layout policy changes. Matroska output ignores the MP4 metadata option. The complete legacy native-transcode comparison remains unchanged. + +## Verification + +Generated pinned-oracle references cover AAC and Opus, low and high rates, MP4 and Matroska input, multiple copied tracks, stale fields, missing `btrt`, and overridden codec selections. Tests compare complete output files after the existing ordinary remux gives oracle and candidate media the same container representation, or compare a complete native control with only independently observed oracle metadata differences applied. Direct metadata and packet checks prevent unrelated differences from being normalized away. Default, invalid, out-of-range and absent-audio controls preserve native output and command admission. Copied MP3 output remains byte-identical. + +The copied MP3 bitrate clock is a separate follow-up. In a generated control, its stored durations imply 67,511 bits/s, while the pinned oracle's complete frame clock implies 64,000. Applying the AAC/Opus formula to it would replace one incorrect value with another; this change does not guess frame timing or add packet-header admission. From 12b057460666f64a19775411561b47b59007faa9 Mon Sep 17 00:00:00 2001 From: Gunwoo Kim Date: Sun, 8 Nov 2026 00:40:42 +0900 Subject: [PATCH 2/2] fix(mp4): match copied audio bitrate metadata Signed-off-by: Gunwoo Kim --- crates/coreav-cli/src/ffmpeg/encode.rs | 9 + crates/coreav-cli/src/main.rs | 2 + crates/coreav-cli/src/transcode.rs | 3 + .../coreav-cli/tests/ffmpeg_audio_options.rs | 336 +++++++++++++++++- crates/coreav-isobmff-write/src/audio.rs | 164 +++++++++ crates/coreav-isobmff-write/src/lib.rs | 3 + crates/coreav-isobmff-write/src/mux.rs | 101 ++++++ crates/coreav-isobmff-write/src/remux.rs | 9 + crates/coreav-isobmff/src/codec.rs | 68 +++- crates/coreav-isobmff/src/lib.rs | 10 +- crates/coreav-py/src/lib.rs | 1 + crates/coreav-transcode/src/pipeline.rs | 4 + crates/coreav-wasm/src/ops.rs | 1 + docs/design/copied-audio-bitrate.md | 6 +- 14 files changed, 700 insertions(+), 17 deletions(-) diff --git a/crates/coreav-cli/src/ffmpeg/encode.rs b/crates/coreav-cli/src/ffmpeg/encode.rs index a0b67ee1c..5dc19f81c 100644 --- a/crates/coreav-cli/src/ffmpeg/encode.rs +++ b/crates/coreav-cli/src/ffmpeg/encode.rs @@ -313,6 +313,15 @@ pub fn request( opus, aac, aac_mp4_tail: true, + copied_audio_bit_rate: audio_encoder + .is_none() + .then(|| { + audio_bitrate + .as_deref() + .and_then(bits) + .and_then(|b| u32::try_from(b).ok()) + }) + .flatten(), #[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 b9cb30f1c..7542a0529 100644 --- a/crates/coreav-cli/src/main.rs +++ b/crates/coreav-cli/src/main.rs @@ -1179,6 +1179,7 @@ fn run_transcode( bitrate: args.audio_bitrate.map(|k| k * 1000), }), aac_mp4_tail: false, + copied_audio_bit_rate: None, #[cfg(feature = "input-capture")] input_capture: None, pixel_format: None, @@ -1598,6 +1599,7 @@ fn run_remux(args: &RemuxArgs, metadata: Metadata) -> ExitCode { } }; let options = RemuxOptions { + copied_audio_bit_rate: None, interleave_packets: false, video_origin: None, rebuild_durations: false, diff --git a/crates/coreav-cli/src/transcode.rs b/crates/coreav-cli/src/transcode.rs index 604d858e9..9547302a1 100644 --- a/crates/coreav-cli/src/transcode.rs +++ b/crates/coreav-cli/src/transcode.rs @@ -103,6 +103,8 @@ pub struct Request { pub aac: Option, /// Match the FFmpeg adapter's terminal AAC sample timing in MP4. pub aac_mp4_tail: bool, + /// Optional metadata bitrate for copied AAC and Opus in MP4. + pub copied_audio_bit_rate: Option, /// Optional actual AAC-encoder stdin capture. #[cfg(feature = "input-capture")] pub input_capture: Option, @@ -213,6 +215,7 @@ pub fn make( }, left_out_covers: request.left_out_covers.clone(), aac_mp4_tail: request.aac_mp4_tail, + copied_audio_bit_rate: request.copied_audio_bit_rate, 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 cdfaabf0f..6d85c7281 100644 --- a/crates/coreav-cli/tests/ffmpeg_audio_options.rs +++ b/crates/coreav-cli/tests/ffmpeg_audio_options.rs @@ -91,6 +91,20 @@ fn native(dir: &Path, output: &Path, options: &[&str], strict: bool) -> Output { .unwrap() } +fn oracle(dir: &Path, output: &Path, options: &[&str]) { + success( + Command::new(test_oracle::require().ffmpeg()) + .args(["-y", "-nostdin", "-v", "error"]) + .args(policy()) + .arg("-i") + .arg(dir.join("source.mp4")) + .args(options) + .arg(output) + .output() + .unwrap(), + ); +} + fn audio_properties(path: &Path) -> serde_json::Value { let bytes = success( Command::new(test_oracle::require().ffprobe()) @@ -130,6 +144,130 @@ fn child<'a>(bytes: &'a [u8], kind: &[u8; 4]) -> &'a [u8] { found[0] } +fn audio_entries(bytes: &[u8]) -> Vec<&[u8]> { + boxes(&boxes(bytes, b"moov")[0][8..], b"trak") + .into_iter() + .filter_map(|track| { + let media = child(track, b"mdia"); + if &child(media, b"hdlr")[16..20] != b"soun" { + return None; + } + let stsd = child(child(child(media, b"minf"), b"stbl"), b"stsd"); + assert_eq!(&stsd[12..16], &1u32.to_be_bytes()); + Some(&stsd[16..]) + }) + .collect() +} + +fn esds_rates(payload: &[u8]) -> usize { + let mut at = 4; + for tag in [3, 4] { + assert_eq!(payload[at], tag); + at += 1; + loop { + let byte = payload[at]; + at += 1; + if byte & 0x80 == 0 { + break; + } + } + if tag == 3 { + assert_eq!(payload[at + 2], 0); + at += 3; + } + } + assert_eq!(payload[at], 0x40); + at + 2 +} + +fn bitrate_reference(control: &[u8], reference: &[u8]) -> Vec { + let rates: Vec<[u8; 12]> = audio_entries(reference) + .into_iter() + .map(|entry| { + let btrt = boxes(&entry[36..], b"btrt"); + assert_eq!(btrt.len(), 1); + let rate: [u8; 12] = btrt[0][8..].try_into().unwrap(); + assert_eq!(&rate[..4], &[0; 4]); + if &entry[4..8] == b"mp4a" { + let esds = boxes(&entry[36..], b"esds"); + assert_eq!(esds.len(), 1); + let esds = &esds[0][8..]; + let at = esds_rates(esds); + assert_eq!(&esds[at..at + 11], &rate[1..]); + } + rate + }) + .collect(); + fn rewrite(bytes: &[u8], rates: &[[u8; 12]], next: &mut usize, delta: u32) -> Vec { + let mut output = Vec::new(); + let mut at = 0; + while at < bytes.len() { + let size = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; + let item = &bytes[at..at + size]; + let kind = &item[4..8]; + let mut body = match kind { + b"moov" | b"trak" | b"mdia" | b"minf" | b"stbl" => { + rewrite(&item[8..], rates, next, delta) + } + b"stsd" => [ + item[8..16].to_vec(), + rewrite(&item[16..], rates, next, delta), + ] + .concat(), + b"mp4a" | b"Opus" => { + let rate = rates[*next]; + *next += 1; + let mut entry = item[8..36].to_vec(); + let mut offset = 36; + let mut has_btrt = false; + while offset < item.len() { + let len = u32::from_be_bytes(item[offset..offset + 4].try_into().unwrap()) + as usize; + let mut child = item[offset..offset + len].to_vec(); + match &child[4..8] { + b"esds" => { + let start = 8 + esds_rates(&child[8..]); + child[start..start + 11].copy_from_slice(&rate[1..]); + } + b"btrt" => { + child[8..].copy_from_slice(&rate); + has_btrt = true; + } + _ => {} + } + entry.extend_from_slice(&child); + offset += len; + } + if !has_btrt { + entry.extend_from_slice(&20u32.to_be_bytes()); + entry.extend_from_slice(b"btrt"); + entry.extend_from_slice(&rate); + } + entry + } + _ => item[8..].to_vec(), + }; + if kind == b"stco" { + for offset in body[8..].chunks_exact_mut(4) { + let value = u32::from_be_bytes(offset.try_into().unwrap()) + delta; + offset.copy_from_slice(&value.to_be_bytes()); + } + } + output.extend_from_slice(&u32::try_from(body.len() + 8).unwrap().to_be_bytes()); + output.extend_from_slice(kind); + output.extend_from_slice(&body); + at += size; + } + output + } + let mut next = 0; + let first = rewrite(control, &rates, &mut next, 0); + assert_eq!(next, rates.len()); + let growth = u32::try_from(first.len() - control.len()).unwrap(); + let mut next = 0; + rewrite(control, &rates, &mut next, growth) +} + fn audio_timing(path: &Path) -> Vec> { let bytes = std::fs::read(path).unwrap(); let moov = boxes(&bytes, b"moov")[0]; @@ -461,8 +599,13 @@ fn default_bitrate_admission_preserves_legacy_native_commands() { args.extend(["-b:a", "96k"]); let actual = dir.0.join("actual.mp4"); success(native(&dir.0, &actual, &args, false)); + let reference = dir.0.join("reference.mp4"); + oracle(&dir.0, &reference, &args); assert_eq!( - std::fs::read(control).unwrap(), + bitrate_reference( + &std::fs::read(control).unwrap(), + &std::fs::read(reference).unwrap() + ), std::fs::read(actual).unwrap(), "{options}" ); @@ -492,6 +635,197 @@ fn default_bitrate_admission_preserves_legacy_native_commands() { } } +#[test] +fn copied_aac_and_opus_match_oracle_metadata_without_other_file_changes() { + for (encoder, container, variant) in [ + ("aac", "mp4", "ordinary"), + ("aac", "mp4", "absent"), + ("aac", "mp4", "dirty"), + ("libopus", "mp4", "ordinary"), + ("libopus", "mp4", "absent"), + ("libopus", "mp4", "dirty"), + ] { + let dir = fixture(&format!("copied-{encoder}-{container}-{variant}")); + let encoded = dir.0.join(format!("encoded.{container}")); + let mut encode = vec![ + "-map", + "0:v", + "-map", + "0:a", + "-c:v", + "copy", + "-c:a", + encoder, + "-ar", + "48000", + "-b:a", + "72k", + "-disposition:a", + "0", + ]; + if variant == "absent" { + encode.extend(["-write_btrt", "0"]); + } + oracle(&dir.0, &encoded, &encode); + let source = dir.0.join("source.mp4"); + std::fs::rename(encoded, &source).unwrap(); + if variant == "dirty" { + let mut bytes = std::fs::read(&source).unwrap(); + let mut edits = Vec::new(); + for entry in audio_entries(&bytes) { + let btrt = boxes(&entry[36..], b"btrt")[0]; + edits.push((btrt.as_ptr() as usize - bytes.as_ptr() as usize + 8, false)); + if encoder == "aac" { + let esds = &boxes(&entry[36..], b"esds")[0][8..]; + edits.push(( + esds.as_ptr() as usize - bytes.as_ptr() as usize + esds_rates(esds), + true, + )); + } + } + let dirty: Vec = [1234u32, 220_000, 1000] + .into_iter() + .flat_map(u32::to_be_bytes) + .collect(); + for (at, esds) in edits { + let data = if esds { &dirty[1..] } else { &dirty }; + bytes[at..at + data.len()].copy_from_slice(data); + } + std::fs::write(&source, bytes).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"] { + let mut args = options.to_vec(); + args.extend(["-b:a", rate]); + let actual = dir.0.join("actual.mp4"); + let reference = dir.0.join("reference.mp4"); + success(native(&dir.0, &actual, &args, false)); + oracle(&dir.0, &reference, &args); + let expected = bitrate_reference(&control, &std::fs::read(reference).unwrap()); + let actual = std::fs::read(actual).unwrap(); + assert_eq!( + actual.len(), + expected.len(), + "{encoder} {container} {variant} {rate}" + ); + assert!( + actual == expected, + "complete file differs: {encoder} {container} {variant} {rate}; first mismatch {:?}", + actual.iter().zip(&expected).position(|(a, b)| a != b) + ); + } + } +} + +#[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, + &[ + "-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", + "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}"); + } + } +} + +#[test] +fn ignored_copy_bitrates_and_copied_mp3_keep_complete_native_files() { + for encoder in ["aac", "libmp3lame"] { + let dir = fixture(&format!("copied-preserved-{encoder}")); + let encoded = dir.0.join("encoded.mp4"); + oracle( + &dir.0, + &encoded, + &[ + "-map", "0:v", "-map", "0:a:0", "-c:v", "copy", "-c:a", encoder, "-ar", "48000", + "-b:a", "64k", + ], + ); + std::fs::rename(encoded, dir.0.join("source.mp4")).unwrap(); + let options = [ + "-c:v", "libx264", "-threads", "1", "-c:a:9", "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(); + let mut rates = vec!["garbage", "4294967296", "1e6", "-1"]; + if encoder == "libmp3lame" { + rates.extend(["16k", "96k", "128k"]); + } + for rate in rates { + 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}"); + } + } +} + #[test] fn nonidentity_and_copy_audio_options_preserve_destinations() { let dir = fixture("refuse"); diff --git a/crates/coreav-isobmff-write/src/audio.rs b/crates/coreav-isobmff-write/src/audio.rs index 1a61b9f5c..26cd4691c 100644 --- a/crates/coreav-isobmff-write/src/audio.rs +++ b/crates/coreav-isobmff-write/src/audio.rs @@ -8,6 +8,92 @@ use coreav_isobmff::{Codec, CodecConfig, FourCc, LosslessConfig, SampleDescription}; +pub(crate) fn set_bit_rate( + entry: &mut Vec, + average: u32, + maximum: u32, + room: u64, + box_limit: u64, +) -> Option { + let aac = match entry.get(4..8) { + Some(b"mp4a") => true, + Some(b"Opus") => false, + _ => return None, + }; + let original_size = u32::try_from(entry.len()).ok()?; + if entry.len() < 36 + || entry.get(..4) != Some(original_size.to_be_bytes().as_slice()) + || entry.get(16..18) != Some(&[0, 0]) + { + return None; + } + let (mut esds, mut btrt) = (None, None); + let mut at = 36usize; + while at < entry.len() { + let Ok(coreav_isobmff::Parsed::Done(header)) = coreav_isobmff::parse_box_header( + entry.get(at..)?, + at as u64, + (entry.len() - at) as u64, + ) else { + return None; + }; + let end = usize::try_from(header.size) + .ok() + .and_then(|n| at.checked_add(n))?; + let start = at + usize::from(header.header_len); + let body = entry.get(start..end)?; + match &header.kind.0 { + b"esds" if aac => { + if esds.is_some() { + return None; + } + let offset = coreav_isobmff::aac_esds_bit_rate_offset(body)?; + esds = Some(start + offset); + } + b"btrt" => { + if btrt.is_some() || body.len() != 12 { + return None; + } + btrt = Some(start); + } + _ => {} + } + at = end; + } + if aac && esds.is_none() { + return None; + } + let growth = if btrt.is_none() { 20 } else { 0 }; + let size = entry + .len() + .checked_add(growth) + .and_then(|n| u32::try_from(n).ok())?; + if growth as u64 > room || u64::from(size) > box_limit { + return None; + } + if growth != 0 { + entry.try_reserve_exact(growth).ok()?; + } + let mut rates = [0u8; 12]; + rates[4..8].copy_from_slice(&maximum.to_be_bytes()); + rates[8..].copy_from_slice(&average.to_be_bytes()); + if let Some(offset) = esds { + entry + .get_mut(offset..offset + 11)? + .copy_from_slice(&rates[1..]); + } + match btrt { + Some(offset) => entry.get_mut(offset..offset + 12)?.copy_from_slice(&rates), + None => { + entry.extend_from_slice(&20u32.to_be_bytes()); + entry.extend_from_slice(b"btrt"); + entry.extend_from_slice(&rates); + entry.get_mut(..4)?.copy_from_slice(&size.to_be_bytes()); + } + } + Some(growth as u64) +} + /// FLAC's `STREAMINFO` body (RFC 9639 8.2): 34 bytes. const STREAMINFO: usize = 34; @@ -609,6 +695,84 @@ fn mixing_metadata( #[cfg(test)] mod tests { use super::*; + use crate::mux::{RawBox, audio_sample_entry}; + + fn bitrate_entry(kind: [u8; 4], btrt: bool) -> Vec { + let mut esds = vec![0, 0, 0, 0, 3, 25, 0, 1, 0, 4, 17, 0x40, 0x15]; + esds.extend_from_slice(&[0; 11]); + esds.extend_from_slice(&[5, 2, 0x11, 0x90, 6, 1, 2]); + let mut children = vec![RawBox { + kind: FourCc(if kind == *b"mp4a" { *b"esds" } else { *b"dOps" }), + payload: if kind == *b"mp4a" { esds } else { vec![0; 11] }, + }]; + if btrt { + children.push(RawBox { + kind: FourCc(*b"btrt"), + payload: vec![0xAA; 12], + }); + } + audio_sample_entry(FourCc(kind), 2, 16, 48_000, &children).unwrap() + } + + #[test] + fn bitrate_growth_is_bounded_and_malformed_entries_stay_whole() { + for kind in [*b"mp4a", *b"Opus"] { + let entry = bitrate_entry(kind, false); + for (room, limit) in [(19, u64::MAX), (20, entry.len() as u64 + 19)] { + let mut actual = entry.clone(); + assert_eq!(set_bit_rate(&mut actual, 72_000, 96_000, room, limit), None); + assert_eq!(actual, entry); + } + let mut actual = entry.clone(); + assert_eq!( + set_bit_rate(&mut actual, 72_000, 96_000, 20, u64::MAX), + Some(20) + ); + assert_eq!(actual.len(), entry.len() + 20); + assert_eq!( + &actual[actual.len() - 20..actual.len() - 12], + b"\0\0\0\x14btrt" + ); + let rates: Vec = [0u32, 96_000, 72_000] + .into_iter() + .flat_map(u32::to_be_bytes) + .collect(); + assert_eq!(&actual[actual.len() - 12..], &rates); + if kind == *b"mp4a" { + assert_eq!(&actual[57..68], &rates[1..]); + } + let mut malformed = bitrate_entry(kind, true); + malformed.push(0); + let size = (malformed.len() as u32).to_be_bytes(); + malformed[..4].copy_from_slice(&size); + let mut actual = malformed.clone(); + assert_eq!( + set_bit_rate(&mut actual, 72_000, 96_000, 20, u64::MAX), + None + ); + assert_eq!(actual, malformed); + let mut duplicate = bitrate_entry(kind, true); + duplicate.extend_from_slice(&20u32.to_be_bytes()); + duplicate.extend_from_slice(b"btrt"); + duplicate.extend_from_slice(&[0; 12]); + let size = (duplicate.len() as u32).to_be_bytes(); + duplicate[..4].copy_from_slice(&size); + let mut actual = duplicate.clone(); + assert_eq!( + set_bit_rate(&mut actual, 72_000, 96_000, 20, u64::MAX), + None + ); + assert_eq!(actual, duplicate); + } + let mut mp3 = bitrate_entry(*b"mp4a", true); + mp3[55] = 0x6B; + let mut actual = mp3.clone(); + assert_eq!( + set_bit_rate(&mut actual, 72_000, 96_000, 20, u64::MAX), + None + ); + assert_eq!(actual, mp3); + } fn pack(fields: &[(u32, u32)]) -> Vec { let mut packer = Packer::default(); diff --git a/crates/coreav-isobmff-write/src/lib.rs b/crates/coreav-isobmff-write/src/lib.rs index f6908ddab..38478fa16 100644 --- a/crates/coreav-isobmff-write/src/lib.rs +++ b/crates/coreav-isobmff-write/src/lib.rs @@ -271,6 +271,9 @@ pub enum OnUnsupported { /// What [`remux_mp4`] copies. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RemuxOptions { + /// Recompute copied AAC and Opus bitrate metadata, with this requested + /// maximum in bits per second. Other codecs and replacements are unchanged. + pub copied_audio_bit_rate: Option, /// Store copied packets in edited decode-time order, with output track /// order breaking ties. Uses one sample per chunk under the existing /// table budget. Unsupported edit clocks fail before writing; native diff --git a/crates/coreav-isobmff-write/src/mux.rs b/crates/coreav-isobmff-write/src/mux.rs index 7b3175665..7241cb738 100644 --- a/crates/coreav-isobmff-write/src/mux.rs +++ b/crates/coreav-isobmff-write/src/mux.rs @@ -417,6 +417,33 @@ impl Muxer { } } + pub(crate) fn set_audio_bit_rate( + &mut self, + track: usize, + requested: u32, + room: u64, + limits: &Limits, + ) -> Result { + self.open()?; + let t = self.tracks.get_mut(track).ok_or(Error::NoSuchTrack)?; + if t.tables.duration == 0 { + return Ok(0); + } + let average = u128::from(t.tables.data_bytes) * 8 * u128::from(t.header.timescale) + / u128::from(t.tables.duration); + let Ok(average) = u32::try_from(average) else { + return Ok(0); + }; + Ok(crate::audio::set_bit_rate( + &mut t.header.sample_entry, + average, + average.max(requested), + room, + limits.max_box_bytes, + ) + .unwrap_or(0)) + } + /// Samples pushed to a track. pub fn samples(&self, track: usize) -> Option { self.tracks.get(track).map(|t| t.tables.samples()) @@ -882,6 +909,80 @@ mod tests { } } + #[test] + fn copied_bitrate_uses_complete_tables_and_keeps_unrepresentable_entries() { + let limits = Limits::default(); + let mut muxer = Muxer::new( + FileType { + major: FourCc(*b"isom"), + minor: 0, + compatible: Vec::new(), + }, + MovieHeader { + timescale: 1000, + creation_time: 0, + boxes: Vec::new(), + }, + &limits, + ) + .unwrap(); + let entry = audio_sample_entry(FourCc(*b"Opus"), 2, 16, 48_000, &[]).unwrap(); + let mut h = header(TrackMedia::Audio { volume: 0x100 }, entry.clone()); + h.timescale = 48_000; + let index = muxer.add_track(h).unwrap(); + assert_eq!( + muxer + .set_audio_bit_rate(index, 96_000, 20, &limits) + .unwrap(), + 0 + ); + assert_eq!(muxer.tracks[index].header.sample_entry, entry); + muxer + .push_sample( + index, + Sample { + size: 100, + duration: 480, + sync: true, + composition_offset: 0, + }, + ) + .unwrap(); + muxer + .push_sample( + index, + Sample { + size: 200, + duration: 480, + sync: true, + composition_offset: 0, + }, + ) + .unwrap(); + assert_eq!( + muxer + .set_audio_bit_rate(index, 96_000, 20, &limits) + .unwrap(), + 20 + ); + let actual = &muxer.tracks[index].header.sample_entry; + let expected: Vec = [0u32, 120_000, 120_000] + .into_iter() + .flat_map(u32::to_be_bytes) + .collect(); + assert_eq!(&actual[actual.len() - 12..], &expected); + let before = actual.clone(); + muxer.tracks[index].tables.data_bytes = u64::MAX; + muxer.tracks[index].tables.duration = 1; + assert_eq!( + muxer + .set_audio_bit_rate(index, 96_000, 20, &limits) + .unwrap(), + 0 + ); + assert_eq!(muxer.tracks[index].header.sample_entry, before); + } + #[test] fn codec_entries_preserve_configuration_colour_aspect_and_avc_bytes() { let avcc = [1, 0x64, 0, 0x1f, 0xff, 0xe0, 0]; diff --git a/crates/coreav-isobmff-write/src/remux.rs b/crates/coreav-isobmff-write/src/remux.rs index 55feeb850..aee7b3bb2 100644 --- a/crates/coreav-isobmff-write/src/remux.rs +++ b/crates/coreav-isobmff-write/src/remux.rs @@ -896,6 +896,15 @@ fn remux( } .into()); } + if let Some(rate) = options.copied_audio_bit_rate { + for (index, k) in kept.iter().enumerate() { + if k.replaced.is_none() && matches!(k.track.description.codec, Codec::Aac | Codec::Opus) + { + let growth = muxer.set_audio_bit_rate(index, rate, retained.left, limits)?; + retained.charge(growth, 0)?; + } + } + } let (dropped_boxes, not_listed) = report(&file, &movie_gone, &kept, &retained); let not_listed = kept .iter() diff --git a/crates/coreav-isobmff/src/codec.rs b/crates/coreav-isobmff/src/codec.rs index 29c8c5b2c..0a26c78f3 100644 --- a/crates/coreav-isobmff/src/codec.rs +++ b/crates/coreav-isobmff/src/codec.rs @@ -1738,20 +1738,34 @@ fn frame_length(object_type: u8, short: bool) -> Option { /// `AudioSpecificConfig` a decoder needs. The information is `None` when /// the descriptor is truncated or lacks it. pub fn esds_decoder_specific_info(esds: &[u8]) -> Option<(u8, Option<&[u8]>)> { - esds_parts(esds).map(|(object_type, _, specific)| (object_type, specific)) + esds_parts(esds).map(|parts| (parts.object_type, parts.specific)) +} + +/// Offset of the complete eleven-byte buffer-size and bitrate fields in an +/// AAC `esds` payload. Other object types and incomplete fields return `None`. +pub fn aac_esds_bit_rate_offset(esds: &[u8]) -> Option { + let parts = esds_parts(esds)?; + (parts.object_type == 0x40) + .then_some(parts.rate_offset) + .flatten() } fn parse_esds(payload: &[u8]) -> Option { - let (object_type, rates, specific) = esds_parts(payload)?; + let parts = esds_parts(payload)?; Some(Esds { - object_type, - rates, - aac: specific.and_then(aac_profile), - layout: specific.and_then(aac_layout), + object_type: parts.object_type, + rates: parts.rates, + aac: parts.specific.and_then(aac_profile), + layout: parts.specific.and_then(aac_layout), }) } -type EsdsParts<'a> = (u8, Option<(u32, u32, u32)>, Option<&'a [u8]>); +struct EsdsParts<'a> { + object_type: u8, + rates: Option<(u32, u32, u32)>, + specific: Option<&'a [u8]>, + rate_offset: Option, +} /// The object type, rates and decoder-specific information of an `esds` /// payload. @@ -1761,7 +1775,8 @@ fn esds_parts(payload: &[u8]) -> Option> { if tag != 3 { return None; } - let end = at.checked_add(len)?.min(payload.len()); + let declared_end = at.checked_add(len)?; + let end = declared_end.min(payload.len()); let flags = *payload.get(at.checked_add(2)?)?; at = at.checked_add(3)?; if flags & 0x80 != 0 { @@ -1806,7 +1821,14 @@ fn esds_parts(payload: &[u8]) -> Option> { } inner = stop; } - return Some((object_type, rates, specific)); + return Some(EsdsParts { + object_type, + rates, + specific, + rate_offset: rates + .filter(|_| declared_end <= payload.len()) + .map(|_| at + 2), + }); } at = body_end; } @@ -1833,6 +1855,34 @@ fn descriptor(data: &[u8], at: &mut usize) -> Option<(u8, usize)> { mod tests { use super::*; + #[test] + fn aac_rate_offsets_require_complete_descriptors() { + let mut payload = vec![0, 0, 0, 0, 3, 25, 0, 1, 0, 4, 17, 0x40, 0x15]; + payload.extend_from_slice(&[0; 11]); + payload.extend_from_slice(&[5, 2, 0x11, 0x90, 6, 1, 2]); + assert_eq!(aac_esds_bit_rate_offset(&payload), Some(13)); + assert_eq!( + esds_decoder_specific_info(&payload), + Some((0x40, Some(&[0x11, 0x90][..]))) + ); + for end in 0..payload.len() { + assert_eq!(aac_esds_bit_rate_offset(&payload[..end]), None, "{end}"); + } + let mut flags = payload.clone(); + flags[5] += 8; + flags[8] = 0xE0; + flags.splice(9..9, [0, 2, 3, b'u', b'r', b'l', 0, 3]); + assert_eq!(aac_esds_bit_rate_offset(&flags), Some(21)); + payload[11] = 0x6B; + assert_eq!(aac_esds_bit_rate_offset(&payload), None); + let mut expanded = vec![ + 0, 0, 0, 0, 3, 0x80, 0x80, 0x80, 28, 0, 1, 0, 4, 0x80, 0x80, 0x80, 17, 0x40, 0x15, + ]; + expanded.extend_from_slice(&[0; 11]); + expanded.extend_from_slice(&[5, 2, 0x11, 0x90, 6, 1, 2]); + assert_eq!(aac_esds_bit_rate_offset(&expanded), Some(19)); + } + #[test] fn child_walk_completeness_preserves_the_existing_visits() { let boxed = |kind: &[u8; 4], payload: &[u8]| { diff --git a/crates/coreav-isobmff/src/lib.rs b/crates/coreav-isobmff/src/lib.rs index a5c927492..cee8c223f 100644 --- a/crates/coreav-isobmff/src/lib.rs +++ b/crates/coreav-isobmff/src/lib.rs @@ -39,11 +39,11 @@ pub use bitstream::{AvcCopyFacts, avc_copy_facts}; pub use codec::{ AacFrameConfig, Allowlist, Codec, CodecConfig, Color, ConfigRecord, DeclaredBitRate, DolbyVision, LosslessConfig, MAX_OTHER_BOXES, PcmFormat, ProResProfile, Profile, - SampleDescription, aac_frame_config, aac_has_explicit_no_sbr, avc_max_num_ref_frames, - avc_max_num_ref_frames_with_planning, avc_parameter_sets, avc_parameter_sets_with_planning, - avc_timing, describe_audio_specific_config, describe_config_record, - describe_config_record_with_planning, esds_decoder_specific_info, hevc_window_empty, - lossless_config, parameter_set_color, parameter_set_color_with_planning, + SampleDescription, aac_esds_bit_rate_offset, aac_frame_config, aac_has_explicit_no_sbr, + avc_max_num_ref_frames, avc_max_num_ref_frames_with_planning, avc_parameter_sets, + avc_parameter_sets_with_planning, avc_timing, describe_audio_specific_config, + describe_config_record, describe_config_record_with_planning, esds_decoder_specific_info, + hevc_window_empty, lossless_config, parameter_set_color, parameter_set_color_with_planning, }; pub use error::{Error, LimitKind, Warning}; pub use fragment::{FragmentSamples, TrackDefaults, subsegment_samples}; diff --git a/crates/coreav-py/src/lib.rs b/crates/coreav-py/src/lib.rs index 0a42e38f6..435dfa355 100644 --- a/crates/coreav-py/src/lib.rs +++ b/crates/coreav-py/src/lib.rs @@ -836,6 +836,7 @@ fn run_remux( }; let streams: Option> = args.streams.map(|s| s.into_iter().map(|i| i.0).collect()); let options = RemuxOptions { + copied_audio_bit_rate: None, interleave_packets: false, video_origin: None, rebuild_durations: false, diff --git a/crates/coreav-transcode/src/pipeline.rs b/crates/coreav-transcode/src/pipeline.rs index 3721326af..a646924b7 100644 --- a/crates/coreav-transcode/src/pipeline.rs +++ b/crates/coreav-transcode/src/pipeline.rs @@ -142,6 +142,8 @@ pub struct TranscodeOptions { /// 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, + /// Optional metadata bitrate for copied AAC and Opus in MP4. + pub copied_audio_bit_rate: Option, /// 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. @@ -192,6 +194,7 @@ impl Default for TranscodeOptions { one_size: false, audio: AudioMode::Copy, aac_mp4_tail: false, + copied_audio_bit_rate: None, left_out_covers: Vec::new(), encoder: EncoderChoice::Auto, x264: None, @@ -763,6 +766,7 @@ fn execute( &options.isobmff_limits, &isobmff_allowlist(video.stream.codec, audio.as_ref().map(|a| a.summary.codec)), &RemuxOptions { + copied_audio_bit_rate: options.copied_audio_bit_rate, interleave_packets: false, video_origin: None, rebuild_durations: false, diff --git a/crates/coreav-wasm/src/ops.rs b/crates/coreav-wasm/src/ops.rs index 996cd6e4b..c2ad9ab91 100644 --- a/crates/coreav-wasm/src/ops.rs +++ b/crates/coreav-wasm/src/ops.rs @@ -613,6 +613,7 @@ fn remux_document( remux::matroska_document(&summary) } else { let remux_options = RemuxOptions { + copied_audio_bit_rate: None, interleave_packets: false, video_origin: None, rebuild_durations: false, diff --git a/docs/design/copied-audio-bitrate.md b/docs/design/copied-audio-bitrate.md index 1eefceaf9..f4d7d9c54 100644 --- a/docs/design/copied-audio-bitrate.md +++ b/docs/design/copied-audio-bitrate.md @@ -6,7 +6,7 @@ The writer derives the average from complete copied sample tables as `data_bytes ## Ownership -The ffmpeg adapter parses the existing unsigned integer and decimal SI-suffix grammar without introducing a new error. Text that does not parse or fit the 32-bit bitrate fields retains its previous ignored behavior. Native `coreav transcode` and the ordinary remux entry points default to no override. The request passes the optional rate to the shared transcode options and then to the MP4-source or Matroska-source remux options. +The ffmpeg adapter parses the existing unsigned integer and decimal SI-suffix grammar without introducing a new error. Text that does not parse or fit the 32-bit bitrate fields retains its previous ignored behavior. Native `coreav transcode` and the ordinary remux entry points default to no override. The request passes the optional rate to the shared transcode options and then only to the MP4-source remux options. Matroska-source copies keep their existing output byte for byte. After all samples of a copied AAC or Opus track have been listed, a single `Muxer` operation updates its sample entry from the completed tables. Encoded replacements, video and copied MP3 never enter this operation. The `esds` parser remains owned by `coreav-isobmff`; the writer obtains validated rate-field offsets from that owner rather than adding another descriptor parser. Existing `btrt` fields are updated in place, or one bounded `btrt` is appended when absent. Added bytes stay within the writer's existing box and retained-metadata allowances. An incomplete or malformed entry, insufficient allowance or an unrepresentable average leaves the complete entry unchanged rather than adding a refusal. @@ -14,6 +14,8 @@ No packet, sample table, edit, priming field, tag or top-level layout policy cha ## Verification -Generated pinned-oracle references cover AAC and Opus, low and high rates, MP4 and Matroska input, multiple copied tracks, stale fields, missing `btrt`, and overridden codec selections. Tests compare complete output files after the existing ordinary remux gives oracle and candidate media the same container representation, or compare a complete native control with only independently observed oracle metadata differences applied. Direct metadata and packet checks prevent unrelated differences from being normalized away. Default, invalid, out-of-range and absent-audio controls preserve native output and command admission. Copied MP3 output remains byte-identical. +Generated pinned-oracle references cover MP4-sourced AAC and Opus, low and high rates, multiple copied tracks, stale fields, missing `btrt`, and overridden codec selections. Tests compare each complete output file with a native control that has only the independently observed oracle metadata applied, including dependent box sizes and chunk offsets. This preserves packet bytes, timing, tags and layout rather than hiding their differences through normalization. Raw CoreAV and FFmpeg files still differ in the deliberately unchanged tags and layout. Default, invalid, out-of-range and absent-audio controls preserve native output and command admission. Copied MP3 and Matroska-sourced audio output remain byte-identical. + +Matroska copy-clock parity is a known gap, not a passing oracle case. A generated AAC witness has 13,312 stored ticks in CoreAV and 13,000 in FFmpeg, yielding different averages for the same packets. Opus also differs. Replacing only the metadata would not reconcile those clocks, so both the Matroska tables and its existing metadata remain unchanged in this change. Exact Matroska copy-clock parity is a separate follow-up. The copied MP3 bitrate clock is a separate follow-up. In a generated control, its stored durations imply 67,511 bits/s, while the pinned oracle's complete frame clock implies 64,000. Applying the AAC/Opus formula to it would replace one incorrect value with another; this change does not guess frame timing or add packet-header admission.