#814: AAC Audio export (audio-only .m4a, or ADTS .aac)

Problem: Premiere's "AAC Audio" format has no FilmCraft equivalent. AAC
was only written inside H.264 / H.265 / AV1 movies; the only audio-only
exports were Waveform Audio and AIFF.

Fix: a new `aac` export format ("AAC Audio") that encodes the sequence
mix with the existing AAC-LC encoder into an MPEG-4 audio file (.m4a,
encoder delay in an edit list), or a raw ADTS stream when the path ends
in .aac. Bitrate, sample rate, channels and loudness normalisation follow
the audio settings. Adds an "AAC Audio 48 kHz 320 kbps" Audio Only
preset, CLI extension guessing (.m4a / .aac), es/fr/ja/zh-cn strings, and
updates G13 (also noting ProRes 4444 / XQ is already in the export UI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 00:56:35 +02:00
parent 4a81aa1742
commit b9f60842bb
16 changed files with 382 additions and 13 deletions

View File

@ -79,7 +79,7 @@ SUBCOMMANDS
[--scale f] [--quality 0-100] [--no-audio] [--queue] [--scale f] [--quality 0-100] [--no-audio] [--queue]
export the active sequence and wait for it to finish: with an export the active sequence and wait for it to finish: with an
export preset (`export --list-presets`; built-in or the user's), or 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 from the extension); --range entire|inOut|workArea, or a custom
range in seconds; --settings is ExportSettings JSON merged over the range in seconds; --settings is ExportSettings JSON merged over the
preset; --scale renders at a fraction of the frame size (0.5 = 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", "tif" | "tiff" => "tiff",
"bmp" => "bmp", "bmp" => "bmp",
"aif" | "aiff" => "aiff", "aif" | "aiff" => "aiff",
"m4a" | "aac" => "aac",
_ => return None, _ => return None,
}) })
} }

View File

@ -1285,7 +1285,7 @@ fn build() -> Vec<CommandSpec> {
"Media…", "Media…",
["File", "Export"], ["File", "Export"],
None, 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, has_seq,
crate::export_tools::export_media crate::export_tools::export_media
), ),

View File

@ -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"]); assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.01, "{name}: {}", j["format"]["duration"]);
continue; 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" => { "gif" => {
let v = stream(&j, "video").unwrap(); let v = stream(&j, "video").unwrap();
assert_eq!(v["codec_name"], "gif"); 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 /// 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 /// 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. /// 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] #[test]
fn h264_presets_hit_their_bitrates() { fn h264_presets_hit_their_bitrates() {
let mut s = demo(); let mut s = demo();

View File

@ -363,7 +363,8 @@ fn with_extension(path: &str, settings: &ExportSettings) -> String {
Some(e) Some(e)
if e.eq_ignore_ascii_case(ext) if e.eq_ignore_ascii_case(ext)
|| (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff")) || (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() path.to_string()
} }

View File

@ -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<Out>, track: usize, frame: u32 },
Adts { out: Out, header: AdtsHeader },
}
impl Sink {
fn write(&mut self, units: Vec<Vec<u8>>) -> 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<u64> {
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<AdtsHeader> {
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<Project>,
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<dyn AudioEncoder> = 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"));
}
}

View File

@ -15,6 +15,7 @@
use std::io::Write; use std::io::Write;
mod aac_out;
mod audio_out; mod audio_out;
mod job; mod job;
mod mxf_out; mod mxf_out;
@ -112,6 +113,10 @@ pub enum Format {
Wav, Wav,
#[serde(rename = "aiff", alias = "Aiff")] #[serde(rename = "aiff", alias = "Aiff")]
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. /// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM.
#[serde(rename = "mxf-op1a", alias = "MxfOp1a")] #[serde(rename = "mxf-op1a", alias = "MxfOp1a")]
MxfOp1a, MxfOp1a,
@ -143,6 +148,7 @@ impl Format {
"gif" | "animatedgif" => Format::Gif, "gif" | "animatedgif" => Format::Gif,
"wav" | "waveform" | "waveformaudio" => Format::Wav, "wav" | "waveform" | "waveformaudio" => Format::Wav,
"aif" | "aiff" | "aifc" => Format::Aiff, "aif" | "aiff" | "aifc" => Format::Aiff,
"aac" | "aacaudio" | "m4a" | "adts" => Format::Aac,
_ => return None, _ => return None,
}) })
} }
@ -165,6 +171,7 @@ impl Format {
Format::Gif => "gif", Format::Gif => "gif",
Format::Wav => "wav", Format::Wav => "wav",
Format::Aiff => "aiff", Format::Aiff => "aiff",
Format::Aac => "aac",
Format::MxfOp1a => "mxf-op1a", Format::MxfOp1a => "mxf-op1a",
Format::MxfOpAtom => "mxf-opatom", Format::MxfOpAtom => "mxf-opatom",
} }
@ -182,6 +189,7 @@ impl Format {
Format::Gif => "gif", Format::Gif => "gif",
Format::Wav => "wav", Format::Wav => "wav",
Format::Aiff => "aif", Format::Aiff => "aif",
Format::Aac => "m4a",
Format::MxfOp1a | Format::MxfOpAtom => "mxf", Format::MxfOp1a | Format::MxfOpAtom => "mxf",
} }
} }
@ -203,6 +211,7 @@ impl Format {
Format::Gif => "Animated GIF", Format::Gif => "Animated GIF",
Format::Wav => "Waveform Audio", Format::Wav => "Waveform Audio",
Format::Aiff => "AIFF", Format::Aiff => "AIFF",
Format::Aac => "AAC Audio",
Format::MxfOp1a => "MXF OP1a", Format::MxfOp1a => "MXF OP1a",
Format::MxfOpAtom => "MXF OP-Atom", Format::MxfOpAtom => "MXF OP-Atom",
} }
@ -220,6 +229,10 @@ impl Format {
pub fn has_builtin_encoder(self) -> bool { pub fn has_builtin_encoder(self) -> bool {
!matches!(self, Format::Hevc | Format::Av1) !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. /// 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 { pub fn is_h26x(self) -> bool {
matches!(self, Format::H264 | Format::Hevc) matches!(self, Format::H264 | Format::Hevc)
@ -232,7 +245,7 @@ impl Format {
pub fn is_mp4_with(self, mux: Multiplexer) -> bool { pub fn is_mp4_with(self, mux: Multiplexer) -> bool {
self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4)
} }
pub const ALL: [Format; 18] = [ pub const ALL: [Format; 19] = [
Format::H264, Format::H264,
Format::Hevc, Format::Hevc,
Format::Av1, Format::Av1,
@ -249,6 +262,7 @@ impl Format {
Format::Gif, Format::Gif,
Format::Wav, Format::Wav,
Format::Aiff, Format::Aiff,
Format::Aac,
Format::MxfOp1a, Format::MxfOp1a,
Format::MxfOpAtom, Format::MxfOpAtom,
]; ];
@ -1719,6 +1733,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
let cancelled = || progress.cancel.load(Ordering::Relaxed); let cancelled = || progress.cancel.load(Ordering::Relaxed);
let batch = rayon::current_num_threads().clamp(2, 16) as i64; let batch = rayon::current_num_threads().clamp(2, 16) as i64;
let (bytes, nframes) = match settings.format { let (bytes, nframes) = match settings.format {
Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?,
Format::Wav | Format::Aiff => { Format::Wav | Format::Aiff => {
let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; 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); let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);

View File

@ -169,6 +169,16 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
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 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("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("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()
},
),
] ]
} }

View File

@ -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)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum AudioCodec { pub enum AudioCodec {
@ -432,6 +432,7 @@ impl ExportSettings {
pub fn audio_codec(&self) -> AudioCodec { pub fn audio_codec(&self) -> AudioCodec {
match (self.format, self.audio.codec) { match (self.format, self.audio.codec) {
(Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm, (Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm,
(Format::Aac, _) => AudioCodec::Aac,
(f, _) if f.is_mp4_with(self.multiplexer) => AudioCodec::Aac, (f, _) if f.is_mp4_with(self.multiplexer) => AudioCodec::Aac,
(_, AudioCodec::Auto) if self.format.is_mp4_video() => AudioCodec::Aac, (_, AudioCodec::Auto) if self.format.is_mp4_video() => AudioCodec::Aac,
(_, AudioCodec::Auto) => AudioCodec::Pcm, (_, AudioCodec::Auto) => AudioCodec::Pcm,
@ -446,7 +447,7 @@ impl ExportSettings {
/// Whether the format carries video. /// Whether the format carries video.
pub fn has_video(&self) -> bool { pub fn has_video(&self) -> bool {
!matches!(self.format, Format::Wav | Format::Aiff) !self.format.is_audio_only()
} }
/// File extension of the output. /// File extension of the output.
@ -461,6 +462,7 @@ impl ExportSettings {
let fps = r.rate.num as f64 / r.rate.den as f64; let fps = r.rate.num as f64 / r.rate.den as f64;
let px = r.width as f64 * r.height as f64; let px = r.width as f64 * r.height as f64;
let video_bps = match self.video_format() { 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::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::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97,
Format::DnxHr => { Format::DnxHr => {
@ -491,7 +493,7 @@ impl ExportSettings {
Format::Gif => px * 0.6 * 8.0 * fps, Format::Gif => px * 0.6 * 8.0 * fps,
Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, 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() { match self.audio_codec() {
AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0, 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 }, _ => 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 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 { let ch = match r.channels {
1 => "Mono", 1 => "Mono",
6 => "5.1", 6 => "5.1",

View File

@ -218,6 +218,103 @@ fn wav_and_aiff_audio_only() {
} }
} }
/// Root-mean-square level of decoded planar audio.
fn rms(planes: &[Vec<f32>]) -> f32 {
let n = planes.iter().map(Vec::len).sum::<usize>().max(1);
(planes.iter().flatten().map(|s| s * s).sum::<f32>() / 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<f32>> = 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] #[test]
fn wav_export_honours_explicit_sample_rates() { 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)); 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::from_name(Format::Hevc.id()), Some(Format::Hevc));
assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (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(); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap();
assert_eq!(s.format, Format::Hevc); assert_eq!(s.format, Format::Hevc);
assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc");

View File

@ -2974,6 +2974,9 @@
Step Forward 1 Frame Avanzar 1 fotograma Step Forward 1 Frame Avanzar 1 fotograma
Match Playhead Igualar cabezal Match Playhead Igualar cabezal
Gang to Program Vincular al programa 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 AIFF
AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits 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 AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz y 16 bits

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -2976,6 +2976,9 @@
Step Forward 1 Frame Avancer d'une image Step Forward 1 Frame Avancer d'une image
Match Playhead Faire correspondre la tête de lecture Match Playhead Faire correspondre la tête de lecture
Gang to Program Coupler au moniteur programme 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 AIFF
AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits 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 AIFF, stereo 48 kHz 16-bit PCM AIFF, PCM stéréo 48 kHz 16 bits

Can't render this file because it contains an unexpected character in line 5 and column 83.

View File

@ -2963,6 +2963,9 @@
Step Forward 1 Frame 1 フレーム進む Step Forward 1 Frame 1 フレーム進む
Match Playhead 再生ヘッドに合わせる Match Playhead 再生ヘッドに合わせる
Gang to Program プログラムと連動 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 AIFF
AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit
AIFF, stereo 48 kHz 16-bit PCM AIFF、ステレオ 48 kHz 16-bit PCM AIFF, stereo 48 kHz 16-bit PCM AIFF、ステレオ 48 kHz 16-bit PCM

Can't render this file because it contains an unexpected character in line 11 and column 59.

View File

@ -2957,6 +2957,9 @@
Step Forward 1 Frame 前进 1 帧 Step Forward 1 Frame 前进 1 帧
Match Playhead 匹配播放头 Match Playhead 匹配播放头
Gang to Program 联动到节目 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 AIFF
AIFF 48 kHz 16-bit AIFF 48 kHz 16 位 AIFF 48 kHz 16-bit AIFF 48 kHz 16 位
AIFF, stereo 48 kHz 16-bit PCM AIFF,立体声 48 kHz 16 位 PCM AIFF, stereo 48 kHz 16-bit PCM AIFF,立体声 48 kHz 16 位 PCM

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -664,6 +664,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
| `prores` | `filmcraft-prores` (Proxy / LT / 422 / HQ) | MOV | | `prores` | `filmcraft-prores` (Proxy / LT / 422 / HQ) | MOV |
| `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) | | `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) |
| `mjpeg` | built in | MOV | | `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-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 (`<stem>_A1.mxf` …, `Report::extra_files`) | | `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`<stem>_A1.mxf` …, `Report::extra_files`) |
| `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … | | `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |

View File

@ -68,7 +68,7 @@ only). "Tested" names the evidence.
| WAV / BWF (timecode, iXML partial) | read + write | yes | yes | 90% | | WAV / BWF (timecode, iXML partial) | read + write | yes | yes | 90% |
| AIFF / AIFC | read + write | yes | yes | 95% | | AIFF / AIFC | read + write | yes | yes | 95% |
| MP3 | read + write | read | **no** | 60% | | 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 | | FLAC, Ogg, Opus | partial | yes | no | ahead on read |
| WMA | read (Windows) | no | no | 0% | | WMA | read (Windows) | no | no | 0% |

View File

@ -187,10 +187,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
## G13. Delivery formats ## G13. Delivery formats
- **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF, - **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, AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 audio, interlaced encoding,
ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding,
smart render, HEVC software encoder (HEVC exists only where a hardware encoder does). 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). - **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md).
## G14. Localization: 8 of the 12 key languages missing ## G14. Localization: 8 of the 12 key languages missing