Merge pull request #825 from vangel76/gap-aac-audio

#814: AAC Audio export (audio-only .m4a, or ADTS .aac)
This commit is contained in:
Brandon Thomas 2026-10-10 19:22:45 -04:00 committed by GitHub
commit 2a9f39db63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 383 additions and 14 deletions

View File

@ -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,
})
}

View File

@ -1285,7 +1285,7 @@ fn build() -> Vec<CommandSpec> {
"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|exr|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|exr|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
),

View File

@ -178,6 +178,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");
@ -282,6 +292,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();

View File

@ -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()
}

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;
mod aac_out;
mod audio_out;
mod job;
mod mxf_out;
@ -115,6 +116,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,
@ -147,6 +152,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,
})
}
@ -170,6 +176,7 @@ impl Format {
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aiff",
Format::Aac => "aac",
Format::MxfOp1a => "mxf-op1a",
Format::MxfOpAtom => "mxf-opatom",
}
@ -188,6 +195,7 @@ impl Format {
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aif",
Format::Aac => "m4a",
Format::MxfOp1a | Format::MxfOpAtom => "mxf",
}
}
@ -210,6 +218,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",
}
@ -236,6 +245,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)
@ -248,7 +261,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; 19] = [
pub const ALL: [Format; 20] = [
Format::H264,
Format::Hevc,
Format::Av1,
@ -266,6 +279,7 @@ impl Format {
Format::Gif,
Format::Wav,
Format::Aiff,
Format::Aac,
Format::MxfOp1a,
Format::MxfOpAtom,
];
@ -1739,6 +1753,7 @@ pub fn export(project: &Arc<Project>, 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);

View File

@ -175,6 +175,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 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()
},
),
]
}

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)]
#[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 => {
@ -493,7 +495,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 },
@ -578,7 +580,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",

View File

@ -261,6 +261,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]
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));
@ -701,7 +798,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() == 19);
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 20);
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");

View File

@ -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

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
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

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 フレーム進む
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

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 帧
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

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

View File

@ -665,6 +665,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 (`<stem>_A1.mxf` …, `Report::extra_files`) |
| `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% |
| 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% |

View File

@ -192,11 +192,11 @@ 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, compressed (ZIP / PIZ / DWA) OpenEXR, MP3 and AAC-only audio,
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).
AS-10 / AS-11, XDCAM HD MXF, P2, compressed (ZIP / PIZ / DWA) OpenEXR, 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); OpenEXR
sequences (half float, linear, uncompressed; #806).
sequences (half float, linear, uncompressed; #806); 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