Merge pull request #825 from vangel76/gap-aac-audio
#814: AAC Audio export (audio-only .m4a, or ADTS .aac)
This commit is contained in:
commit
2a9f39db63
|
|
@ -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|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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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>` … |
|
||||
|
|
|
|||
|
|
@ -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% |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue