#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:
parent
4a81aa1742
commit
b9f60842bb
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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|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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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);
|
||||
|
|
|
|||
|
|
@ -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 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()
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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 (`<stem>_A1.mxf` …, `Report::extra_files`) |
|
||||
| `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |
|
||||
|
|
|
|||
|
|
@ -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% |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue