diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 9ead306d5..10592444f 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -79,7 +79,7 @@ SUBCOMMANDS [--scale f] [--quality 0-100] [--no-audio] [--queue] export the active sequence and wait for it to finish: with an export preset (`export --list-presets`; built-in or the user's), or - a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed + a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|aac, guessed from the extension); --range entire|inOut|workArea, or a custom range in seconds; --settings is ExportSettings JSON merged over the preset; --scale renders at a fraction of the frame size (0.5 = @@ -209,6 +209,7 @@ fn format_for(path: &str) -> Option<&'static str> { "tif" | "tiff" => "tiff", "bmp" => "bmp", "aif" | "aiff" => "aiff", + "m4a" | "aac" => "aac", _ => return None, }) } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e3b162fdc..c31894f38 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1285,7 +1285,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff|aac"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 6a8c5d711..82245e97a 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -177,6 +177,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.01, "{name}: {}", j["format"]["duration"]); continue; } + "aac" => { + // AAC Audio: an MPEG-4 audio file, sound only + assert!(out.ends_with(".m4a"), "{name}: {out}"); + assert!(fmt_name.contains("m4a"), "{name}: {fmt_name}"); + assert_eq!(j["streams"].as_array().unwrap().len(), 1, "{name}"); + let a = stream(&j, "audio").unwrap(); + assert_eq!((a["codec_name"].as_str(), a["sample_rate"].as_str(), a["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2)), "{name}"); + assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.03, "{name}: {}", j["format"]["duration"]); + continue; + } "gif" => { let v = stream(&j, "video").unwrap(); assert_eq!(v["codec_name"], "gif"); @@ -281,6 +291,25 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { /// H.264 bitrate control over three seconds of the demo: the adaptive Match Source preset /// targets width × height × fps × bpp, the 1080p delivery presets their fixed targets; ffprobe's /// measured video bitrate stays within the VBR maximum and close to the target. +#[test] +fn aac_audio_keeps_a_dot_aac_path_as_adts_and_adds_m4a_otherwise() { + let mut s = demo(); + let dir = Scratch::new("aac-audio"); + let range = json!({"range": "custom", "startSeconds": 1.0, "endSeconds": 1.25, "wait": true, "format": "aac"}); + let run = |s: &mut Session, path: String| { + let mut p = range.clone(); + p["path"] = json!(path); + s.execute("file.exportMedia", p).unwrap()["path"].as_str().unwrap().to_string() + }; + let adts = run(&mut s, dir.path("Mix.aac")); + assert!(adts.ends_with("Mix.aac"), "{adts}"); + let b = std::fs::read(&adts).unwrap(); + assert_eq!((b[0], b[1]), (0xFF, 0xF1), "an ADTS header"); + let m4a = run(&mut s, dir.path("Mix")); + assert!(m4a.ends_with("Mix.m4a"), "{m4a}"); + assert_eq!(&std::fs::read(&m4a).unwrap()[4..8], b"ftyp"); +} + #[test] fn h264_presets_hit_their_bitrates() { let mut s = demo(); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 9e37a7809..b714e284f 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -363,7 +363,8 @@ fn with_extension(path: &str, settings: &ExportSettings) -> String { Some(e) if e.eq_ignore_ascii_case(ext) || (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff")) - || (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg")) => + || (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg")) + || (settings.format == Format::Aac && e.eq_ignore_ascii_case("aac")) => { path.to_string() } diff --git a/crates/export/src/aac_out.rs b/crates/export/src/aac_out.rs new file mode 100644 index 000000000..0e18b1fdf --- /dev/null +++ b/crates/export/src/aac_out.rs @@ -0,0 +1,201 @@ +//! AAC Audio: the sequence mix alone, AAC-LC from the same encoder as the MPEG-4 movies, in an +//! MPEG-4 audio file (`.m4a`, the encoder delay signalled by an edit list) or, when the path ends +//! in `.aac`, a raw ADTS stream (ISO/IEC 14496-3 §1.A.2; one header per access unit, no CRC). ADTS +//! cannot signal the encoder delay, so a player starts it with the priming samples. + +use std::io::Write; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use filmcraft_isobmff::{Brand, CodecConfig, Mp4Writer, TrackConfig, WriteSample, WriterOptions}; +use filmcraft_project::{ItemId, Project}; +use filmcraft_render::SourceProvider; +use filmcraft_time::TimeRange; + +use crate::audio_out::AudioOut; +use crate::{AudioEncoder, ExportError, ExportSettings, Format, Out, Progress, Result, audio_factories}; + +/// Whether `path` asks for a raw ADTS stream rather than an MPEG-4 file. +pub(crate) fn is_adts_path(path: &str) -> bool { + std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("aac")) +} + +/// Where the encoded access units go. +enum Sink { + Mp4 { mux: Mp4Writer, track: usize, frame: u32 }, + Adts { out: Out, header: AdtsHeader }, +} + +impl Sink { + fn write(&mut self, units: Vec>) -> Result<()> { + let io = |e: std::io::Error| ExportError::Io(e.to_string()); + match self { + Sink::Mp4 { mux, track, frame } => { + for au in units { + mux.write_sample(*track, WriteSample { data: &au, duration: *frame, composition_offset: 0, is_sync: true }) + .map_err(|e| ExportError::Io(e.to_string()))?; + } + } + Sink::Adts { out, header } => { + for au in units { + out.write_all(&header.bytes(au.len())?).map_err(io)?; + out.write_all(&au).map_err(io)?; + } + } + } + Ok(()) + } + + fn finish(self, settings: &ExportSettings) -> Result { + match self { + Sink::Mp4 { mux, .. } => mux.finish().map_err(|e| ExportError::Io(e.to_string()))?.finish(settings), + Sink::Adts { out, .. } => out.finish(settings), + } + } +} + +/// The fixed fields of an ADTS header, from the AudioSpecificConfig. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct AdtsHeader { + /// `profile_ObjectType`: the audio object type minus one (1 = AAC-LC). + profile: u8, + sf_index: u8, + channel_config: u8, +} + +impl AdtsHeader { + /// Read the object type, sampling frequency index and channel configuration from an + /// AudioSpecificConfig; ADTS can carry only object types 1–4 with an indexed rate and a + /// channel configuration of 1–7. + pub(crate) fn from_asc(asc: &[u8]) -> Result { + let (Some(&a), Some(&b)) = (asc.first(), asc.get(1)) else { + return Err(ExportError::Encode("AAC: the encoder gave no AudioSpecificConfig".into())); + }; + let object_type = a >> 3; + let sf_index = ((a & 0x07) << 1) | (b >> 7); + let channel_config = (b >> 3) & 0x0F; + if !(1..=4).contains(&object_type) || sf_index > 12 || !(1..=7).contains(&channel_config) { + return Err(ExportError::Unsupported(format!( + "an ADTS .aac stream cannot carry this AAC configuration (object type {object_type}, rate index {sf_index}, channels {channel_config}): export .m4a instead" + ))); + } + Ok(AdtsHeader { profile: object_type - 1, sf_index, channel_config }) + } + + /// The 7-byte header (`protection_absent`) of an access unit of `payload` bytes. + pub(crate) fn bytes(&self, payload: usize) -> Result<[u8; 7]> { + // aac_frame_length is 13 bits and counts the header + let too_big = || ExportError::Encode(format!("AAC: a {payload}-byte frame does not fit ADTS")); + let len = u32::try_from(payload).ok().and_then(|n| n.checked_add(7)).filter(|n| *n < 1 << 13).ok_or_else(too_big)?; + Ok([ + 0xFF, + 0xF1, // syncword, MPEG-4, layer 0, protection absent + (self.profile << 6) | (self.sf_index << 2) | (self.channel_config >> 2), + ((self.channel_config & 0x03) << 6) | ((len >> 11) & 0x03) as u8, + ((len >> 3) & 0xFF) as u8, + (((len & 0x07) << 5) as u8) | 0x1F, // buffer fullness 0x7FF: variable rate + 0xFC, // … and one raw data block + ]) + } +} + +/// Encode the mix of `range` to `settings.path`; returns (bytes written, samples per channel). +pub(crate) fn export( + project: &Arc, + seq: ItemId, + settings: &ExportSettings, + range: TimeRange, + sources: &dyn SourceProvider, + progress: &Progress, +) -> Result<(u64, u64)> { + let cancelled = || progress.cancel.load(Ordering::Relaxed); + let mut a = AudioOut::new(project.clone(), seq, settings, range)?; + let mut enc: Box = audio_factories() + .read() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .find_map(|f| f(Format::Aac, a.sr, a.channels as u32, settings)) + .ok_or_else(|| ExportError::Unsupported("AAC encoder not available".into()))??; + let entry = enc.sample_entry(); + let frames = a.remaining(); + // one second per chunk: memory stays flat however long the range is + let chunk = i64::from(a.sr.max(1)); + if !settings.part_of_batch { + progress.total.store(frames.div_ceil(chunk as u64).max(1), Ordering::Relaxed); + progress.set_status(format!("Exporting audio ({})", settings.format.label())); + } + a.measure(settings, sources, &cancelled)?; + *progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness; + let mut sink = if is_adts_path(&settings.path) { + let CodecConfig::Aac(cfg) = &entry.codec else { + return Err(ExportError::Encode("AAC: the encoder's sample entry is not AAC".into())); + }; + Sink::Adts { header: AdtsHeader::from_asc(&cfg.asc)?, out: Out::create(settings)? } + } else { + let mut opts = WriterOptions::new(Brand::Mp4); + opts.metadata = settings.metadata.udta(); + let mut mux = Mp4Writer::new(Out::create(settings)?, opts).map_err(|e| ExportError::Io(e.to_string()))?; + let mut track = TrackConfig::new(entry, a.sr); + track.media_start = Some(i64::from(enc.priming())); + let track = mux.add_track(track).map_err(|e| ExportError::Io(e.to_string()))?; + Sink::Mp4 { mux, track, frame: enc.frame_size() } + }; + while let Some(planar) = a.pull(a.out.saturating_add(chunk), sources) { + if cancelled() { + return Err(ExportError::Cancelled); + } + sink.write(enc.encode(&planar)?)?; + if !settings.part_of_batch { + progress.done.fetch_add(1, Ordering::Relaxed); + } + } + sink.write(enc.flush()?)?; + let n = sink.finish(settings)?; + if settings.part_of_batch { + progress.done.store(1, Ordering::Relaxed); + } + Ok((n, frames)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adts_header_fields_follow_the_asc() { + // AAC-LC (2), 48 kHz (index 3), stereo: 00010 0011 0010 000 + let h = AdtsHeader::from_asc(&[0x11, 0x90]).unwrap(); + assert_eq!(h, AdtsHeader { profile: 1, sf_index: 3, channel_config: 2 }); + let b = h.bytes(371).unwrap(); + assert_eq!(&b[..2], &[0xFF, 0xF1]); + assert_eq!(b[2] >> 6, 1); + assert_eq!((b[2] >> 2) & 0x0F, 3); + assert_eq!(((b[2] & 1) << 2) | (b[3] >> 6), 2); + let len = (u32::from(b[3] & 0x03) << 11) | (u32::from(b[4]) << 3) | u32::from(b[5] >> 5); + assert_eq!(len, 378); + assert_eq!(((u32::from(b[5] & 0x1F)) << 6) | u32::from(b[6] >> 2), 0x7FF); + assert_eq!(b[6] & 0x03, 0); + } + + #[test] + fn adts_refuses_what_it_cannot_signal() { + assert!(AdtsHeader::from_asc(&[]).is_err()); + assert!(AdtsHeader::from_asc(&[0x11]).is_err()); + // explicit sampling frequency (index 15) + assert!(AdtsHeader::from_asc(&[0x17, 0x90, 0, 0, 0]).is_err()); + // object type 5 (SBR) has no ADTS profile + assert!(AdtsHeader::from_asc(&[0x29, 0x90]).is_err()); + let h = AdtsHeader::from_asc(&[0x11, 0x90]).unwrap(); + assert!(h.bytes(8184).is_ok()); + assert!(h.bytes(8185).is_err()); + assert!(h.bytes(usize::MAX).is_err()); + } + + #[test] + fn only_a_dot_aac_path_is_adts() { + assert!(is_adts_path("/x/mix.aac")); + assert!(is_adts_path("mix.AAC")); + assert!(!is_adts_path("mix.m4a")); + assert!(!is_adts_path("aac")); + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 279384f2d..9a7d15dcf 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -15,6 +15,7 @@ use std::io::Write; +mod aac_out; mod audio_out; mod job; mod mxf_out; @@ -112,6 +113,10 @@ pub enum Format { Wav, #[serde(rename = "aiff", alias = "Aiff")] Aiff, + /// AAC Audio: the mix alone, AAC-LC in an MPEG-4 audio file (`.m4a`), or a raw ADTS stream when + /// the path ends in `.aac`. + #[serde(rename = "aac", alias = "Aac")] + Aac, /// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM. #[serde(rename = "mxf-op1a", alias = "MxfOp1a")] MxfOp1a, @@ -143,6 +148,7 @@ impl Format { "gif" | "animatedgif" => Format::Gif, "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, + "aac" | "aacaudio" | "m4a" | "adts" => Format::Aac, _ => return None, }) } @@ -165,6 +171,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", + Format::Aac => "aac", Format::MxfOp1a => "mxf-op1a", Format::MxfOpAtom => "mxf-opatom", } @@ -182,6 +189,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", + Format::Aac => "m4a", Format::MxfOp1a | Format::MxfOpAtom => "mxf", } } @@ -203,6 +211,7 @@ impl Format { Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", + Format::Aac => "AAC Audio", Format::MxfOp1a => "MXF OP1a", Format::MxfOpAtom => "MXF OP-Atom", } @@ -220,6 +229,10 @@ impl Format { pub fn has_builtin_encoder(self) -> bool { !matches!(self, Format::Hevc | Format::Av1) } + /// Sound without picture: Waveform Audio, AIFF and AAC Audio. + pub fn is_audio_only(self) -> bool { + matches!(self, Format::Wav | Format::Aiff | Format::Aac) + } /// H.264 or H.265: MPEG-4 (or QuickTime) with AAC audio, set up with the same bitrate controls. pub fn is_h26x(self) -> bool { matches!(self, Format::H264 | Format::Hevc) @@ -232,7 +245,7 @@ impl Format { pub fn is_mp4_with(self, mux: Multiplexer) -> bool { self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) } - pub const ALL: [Format; 18] = [ + pub const ALL: [Format; 19] = [ Format::H264, Format::Hevc, Format::Av1, @@ -249,6 +262,7 @@ impl Format { Format::Gif, Format::Wav, Format::Aiff, + Format::Aac, Format::MxfOp1a, Format::MxfOpAtom, ]; @@ -1719,6 +1733,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let cancelled = || progress.cancel.load(Ordering::Relaxed); let batch = rayon::current_num_threads().clamp(2, 16) as i64; let (bytes, nframes) = match settings.format { + Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?, Format::Wav | Format::Aiff => { let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits); diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 2a021d499..c6f049799 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -169,6 +169,16 @@ pub fn builtin_presets() -> Vec { p("Waveform Audio 48 kHz 16-bit", "Audio Only", "WAV, stereo 48 kHz 16-bit PCM", audio_only(Format::Wav, 16)), p("Waveform Audio 48 kHz 24-bit", "Audio Only", "WAV, stereo 48 kHz 24-bit PCM", audio_only(Format::Wav, 24)), p("AIFF 48 kHz 16-bit", "Audio Only", "AIFF, stereo 48 kHz 16-bit PCM", audio_only(Format::Aiff, 16)), + p( + "AAC Audio 48 kHz 320 kbps", + "Audio Only", + "MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps", + ExportSettings { + format: Format::Aac, + audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16 }, + ..Default::default() + }, + ), ] } diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 61ee9dbd0..4bfa28ecb 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -160,7 +160,7 @@ impl MxfVideoCodec { } } -/// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF. +/// Audio codec. `Auto` = AAC in MP4 and AAC Audio, PCM in QuickTime / WAV / AIFF. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AudioCodec { @@ -432,6 +432,7 @@ impl ExportSettings { pub fn audio_codec(&self) -> AudioCodec { match (self.format, self.audio.codec) { (Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm, + (Format::Aac, _) => AudioCodec::Aac, (f, _) if f.is_mp4_with(self.multiplexer) => AudioCodec::Aac, (_, AudioCodec::Auto) if self.format.is_mp4_video() => AudioCodec::Aac, (_, AudioCodec::Auto) => AudioCodec::Pcm, @@ -446,7 +447,7 @@ impl ExportSettings { /// Whether the format carries video. pub fn has_video(&self) -> bool { - !matches!(self.format, Format::Wav | Format::Aiff) + !self.format.is_audio_only() } /// File extension of the output. @@ -461,6 +462,7 @@ impl ExportSettings { let fps = r.rate.num as f64 / r.rate.den as f64; let px = r.width as f64 * r.height as f64; let video_bps = match self.video_format() { + Format::Aac => 0.0, Format::H264 | Format::Hevc | Format::Av1 => r.target_kbps as f64 * 1000.0, Format::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97, Format::DnxHr => { @@ -491,7 +493,7 @@ impl ExportSettings { Format::Gif => px * 0.6 * 8.0 * fps, Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; - let audio_bps = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { + let audio_bps = if self.has_audio() || self.format.is_audio_only() { match self.audio_codec() { AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0, _ => r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 }, @@ -575,7 +577,7 @@ impl ExportSettings { } v }; - let audio = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { + let audio = if self.has_audio() || self.format.is_audio_only() { let ch = match r.channels { 1 => "Mono", 6 => "5.1", diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index 8cfb31702..1b72f10ba 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -218,6 +218,103 @@ fn wav_and_aiff_audio_only() { } } +/// Root-mean-square level of decoded planar audio. +fn rms(planes: &[Vec]) -> f32 { + let n = planes.iter().map(Vec::len).sum::().max(1); + (planes.iter().flatten().map(|s| s * s).sum::() / n as f32).sqrt() +} + +#[test] +fn aac_audio_writes_m4a_and_adts() { + let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("aac-audio"); + let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND))); + // .m4a: one AAC track, no video, the encoder delay in an edit list + let m4a = dir.path("a.m4a"); + let s = ExportSettings { format: Format::Aac, path: m4a.clone(), range, ..Default::default() }; + assert!(!s.has_video()); + assert_eq!(s.audio_codec(), AudioCodec::Aac); + assert_eq!(s.extension(), "m4a"); + let r = export(&p, seq, &s, &m, &Progress::default()).unwrap(); + assert_eq!(r.frames, 48_000, "one second of samples"); + let bytes = std::fs::read(&m4a).unwrap(); + let file = filmcraft_isobmff::open(bytes.clone()).unwrap(); + assert_eq!(file.tracks.len(), 1, "audio only"); + let t = &file.tracks[0]; + let filmcraft_isobmff::CodecConfig::Aac(cfg) = &t.entries[0].codec else { panic!("not AAC: {:?}", t.entries[0].codec) }; + assert_eq!((cfg.object_type, cfg.sample_rate, cfg.channel_config), (2, 48_000, 2)); + assert!(t.samples.len() >= 48_000 / 1024, "{} access units", t.samples.len()); + let mut dec = filmcraft_aac::Decoder::new(&cfg.asc).unwrap(); + let mut pcm: Vec> = Vec::new(); + for i in 0..t.samples.len() { + let out = dec.decode(&file.read_sample(&bytes, 0, i).unwrap()).unwrap(); + pcm.resize(out.len(), Vec::new()); + pcm.iter_mut().zip(out).for_each(|(a, b)| a.extend(b)); + } + // the -6 dBFS tone (RMS about 0.35) survives the round trip + assert!((0.2..0.5).contains(&rms(&pcm)), "rms {}", rms(&pcm)); + if let Some(j) = ffprobe_json(&["-show_streams", "-show_format"], &m4a) { + assert_eq!(j["streams"].as_array().unwrap().len(), 1); + let st = &j["streams"][0]; + assert_eq!(st["codec_name"], "aac"); + assert_eq!(st["sample_rate"], "48000"); + assert_eq!(st["channels"], 2); + let d: f64 = j["format"]["duration"].as_str().unwrap().parse().unwrap(); + assert!((d - 1.0).abs() < 0.03, "duration {d}"); + } + + // .aac: raw ADTS, mono 44.1 kHz at 128 kbps + let adts = dir.path("a.aac"); + let mut s = ExportSettings { format: Format::Aac, path: adts.clone(), range, ..Default::default() }; + s.audio = AudioSettings { sample_rate: Some(44_100), channels: 1, bitrate_kbps: 128, ..Default::default() }; + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let b = std::fs::read(&adts).unwrap(); + // AAC-LC 44.1 kHz (index 4) mono: 00010 0100 0001 000 + let mut dec = filmcraft_aac::Decoder::new(&[0x12, 0x08]).unwrap(); + let (mut pos, mut units, mut pcm) = (0usize, 0usize, vec![Vec::new()]); + while pos < b.len() { + let h = &b[pos..pos + 7]; + assert_eq!((h[0], h[1] & 0xF6), (0xFF, 0xF0), "ADTS sync at {pos}"); + assert_eq!(h[1] & 0x01, 1, "no CRC"); + assert_eq!(((h[2] >> 2) & 0x0F, ((h[2] & 1) << 2) | (h[3] >> 6)), (4, 1), "44.1 kHz mono"); + let len = ((h[3] as usize & 0x03) << 11) | ((h[4] as usize) << 3) | (h[5] as usize >> 5); + assert!(len > 7 && pos + len <= b.len(), "frame length {len} at {pos}"); + pcm[0].extend(dec.decode(&b[pos + 7..pos + len]).unwrap().swap_remove(0)); + pos += len; + units += 1; + } + assert!(units >= 44_100 / 1024, "{units} frames"); + assert!((0.2..0.5).contains(&rms(&pcm)), "rms {}", rms(&pcm)); + if let Some(j) = ffprobe_json(&["-show_streams", "-show_format"], &adts) { + assert_eq!(j["format"]["format_name"], "aac"); + let st = &j["streams"][0]; + assert_eq!(st["codec_name"], "aac"); + assert_eq!(st["sample_rate"], "44100"); + assert_eq!(st["channels"], 1); + } + if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("AAC Audio decode") { + for f in [&m4a, &adts] { + let out = std::process::Command::new(&ffmpeg).args(["-v", "error", "-xerror", "-i", f, "-f", "null", "-"]).output().unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{f}: {}", String::from_utf8_lossy(&out.stderr)); + } + } +} + +#[test] +fn aac_audio_settings_and_names() { + assert_eq!(Format::from_name("m4a"), Some(Format::Aac)); + assert_eq!(Format::from_name("AAC Audio"), Some(Format::Aac)); + assert_eq!(Format::from_name(Format::Aac.id()), Some(Format::Aac)); + assert!(Format::ALL.contains(&Format::Aac) && Format::Aac.is_audio_only() && !Format::H264.is_audio_only()); + let s = ExportSettings { format: Format::Aac, include_audio: false, ..Default::default() }; + let sum = s.summary(1920, 1080, FrameRate::FPS_24, 48_000, Tick(10 * TICKS_PER_SECOND)); + assert_eq!(sum.video, "No video"); + assert_eq!(sum.audio, "AAC, 320 kbps, 48000 Hz, Stereo"); + assert_eq!(sum.estimated_bytes, 400_000, "320 kbps for 10 s, no picture"); + let preset = presets::find_builtin("AAC Audio 48 kHz 320 kbps").unwrap(); + assert_eq!(preset.settings.format, Format::Aac); +} + #[test] fn wav_export_honours_explicit_sample_rates() { let (project, sequence, sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); @@ -658,7 +755,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 18); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 19); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 51d1cd030..4f76c350c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2974,6 +2974,9 @@ Step Forward 1 Frame Avanzar 1 fotograma Match Playhead Igualar cabezal Gang to Program Vincular al programa + AAC Audio AAC Audio + AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Audio MPEG-4 (.m4a), estéreo, AAC-LC de 48 kHz a 320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz y 16 bits diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f9f8c6601..5f17a4f44 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2976,6 +2976,9 @@ Step Forward 1 Frame Avancer d'une image Match Playhead Faire correspondre la tête de lecture Gang to Program Coupler au moniteur programme + AAC Audio AAC Audio + AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Audio MPEG-4 (.m4a), AAC-LC stéréo 48 kHz à 320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits AIFF, stereo 48 kHz 16-bit PCM AIFF, PCM stéréo 48 kHz 16 bits diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 79c7d025d..630cbd4d2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2963,6 +2963,9 @@ Step Forward 1 Frame 1 フレーム進む Match Playhead 再生ヘッドに合わせる Gang to Program プログラムと連動 + AAC Audio AAC Audio + AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps MPEG-4 オーディオ (.m4a)、ステレオ 48 kHz AAC-LC 320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit AIFF, stereo 48 kHz 16-bit PCM AIFF、ステレオ 48 kHz 16-bit PCM diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 16472ef07..2d5188664 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -2957,6 +2957,9 @@ Step Forward 1 Frame 前进 1 帧 Match Playhead 匹配播放头 Gang to Program 联动到节目 + AAC Audio AAC 音频 + AAC Audio 48 kHz 320 kbps AAC 音频 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps MPEG-4 音频 (.m4a),立体声 48 kHz AAC-LC,320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 位 AIFF, stereo 48 kHz 16-bit PCM AIFF,立体声 48 kHz 16 位 PCM diff --git a/docs/architecture.md b/docs/architecture.md index ed1486af9..cbd1e6ed7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -664,6 +664,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui | `prores` | `filmcraft-prores` (Proxy / LT / 422 / HQ) | MOV | | `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) | | `mjpeg` | built in | MOV | +| `aac` | `filmcraft-aac` (AAC-LC, the audio settings' bitrate) | audio only: MPEG-4 audio `.m4a` (`isobmff`, priming in an edit list), or raw ADTS when the path ends in `.aac` | | `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) | | `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`_A1.mxf` …, `Report::extra_files`) | | `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `000.`, `001.` … | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 58c45cb6b..ee18a834e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -68,7 +68,7 @@ only). "Tested" names the evidence. | WAV / BWF (timecode, iXML partial) | read + write | yes | yes | 90% | | AIFF / AIFC | read + write | yes | yes | 95% | | MP3 | read + write | read | **no** | 60% | -| AAC / M4A | read + write | yes | inside MP4 / MOV only, **no AAC-only file** | 70% | +| AAC / M4A | read + write | yes | yes (in MP4 / MOV, and AAC Audio: `.m4a` or ADTS `.aac`, #814) | 95% | | FLAC, Ogg, Opus | partial | yes | no | ahead on read | | WMA | read (Windows) | no | no | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index aec13ce2c..c5baf693b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -187,10 +187,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G13. Delivery formats - **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF, - AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 and AAC-only audio, - ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding, + AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 audio, interlaced encoding, smart render, HEVC software encoder (HEVC exists only where a hardware encoder does). -- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779). +- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779); AAC Audio + (audio-only `.m4a`, or ADTS `.aac`; #814); ProRes 4444 / 4444 XQ in the export UI (#342). - **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md). ## G14. Localization: 8 of the 12 key languages missing