Files
filmcraft/crates/engine/src/aaf_omf.rs
T

453 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File ▸ Export ▸ AAF… / OMF… and File ▸ Import of AAF / OMF documents.
//!
//! The interchange crate writes the documents; this module prepares their audio essence the way
//! Premiere's AAF and OMF export dialogs offer it: embedded in the document or written as separate
//! WAV / AIFF files next to it, at a chosen sample rate and bit depth, trimmed to the used ranges
//! plus handles (or whole), with clip effects rendered in, broken out to mono; and for AAF an
//! optional video mixdown rendered to one file.
//!
//! `file.exportAaf {path, sequence?, mixdownVideo?, breakoutToMono?, audio?: "embedded"|"separate"|
//! "linked", audioFormat?: "wav"|"aiff"|"mxf" (OP-Atom, AAF only), sampleRate?, bitDepth?, trimAudio?, handles? (frames),
//! renderAudioEffects?, smallSectors?}`
//!
//! `file.exportOmf {path, sequence?, audio?: "embedded"|"separate", audioFormat?, sampleRate?,
//! bitDepth?, trimAudio? (default on), handles?, renderAudioEffects?, breakoutToMono?}`
//!
//! `file.importAaf {path}` (also `file.import` of `.aaf` / `.omf` files).
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use filmcraft_interchange::essence::{AudioEssence, AudioNeed, EssenceData, EssenceKey, MediaOptions, MixdownVideo, NeedOptions, NestNeeds, audio_needs};
use filmcraft_project::{ClipId, ItemId, Project, Sequence, SequenceSettings, Track, TrackItem, TrackKind};
use filmcraft_time::{Tick, TimeRange};
use crate::commands::{bad, bool_p, str_p, u64_p};
use crate::{EngineError, Result, Session};
/// Where exported audio goes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AudioMode {
/// Inside the document.
Embedded,
/// Separate WAV / AIFF files next to the document.
Separate,
/// The original media files (AAF only; untrimmed, unrendered).
Linked,
}
/// The audio settings of the AAF / OMF export dialogs.
#[derive(Clone, Debug)]
pub struct AudioPlan {
pub mode: AudioMode,
pub aiff: bool,
/// Separate files as OP-Atom MXF (AAF only; Avid-style consolidated media).
pub mxf: bool,
pub sample_rate: u32,
pub bits: u16,
pub trim: bool,
pub handles: Tick,
pub render_effects: bool,
pub breakout: bool,
/// Nested sequences on the audio tracks. OMF: their sound is rendered into the export, as
/// Premiere Pro does. AAF: they are written as compositions, so the media of the clips inside
/// them is prepared like the rest.
pub nests: NestNeeds,
}
fn plan_from(s: &Session, p: &Value, seq: ItemId, cmd: &str, omf: bool) -> Result<AudioPlan> {
let q = s.project.sequence(seq).ok_or(EngineError::NoSequence)?;
let mode = match str_p(p, "audio").unwrap_or("embedded") {
"embedded" | "embed" | "encapsulate" => AudioMode::Embedded,
"separate" | "separateAudio" => AudioMode::Separate,
"linked" | "link" if !omf => AudioMode::Linked,
other => return Err(bad(cmd, format!("unknown audio mode {other:?} (embedded, separate{})", if omf { "" } else { " or linked" }))),
};
let (aiff, mxf) = match str_p(p, "audioFormat").unwrap_or("wav") {
"wav" | "bwf" | "broadcastWave" => (false, false),
"aiff" | "aif" => (true, false),
"mxf" | "opatom" if !omf => (false, true),
other => return Err(bad(cmd, format!("unknown audio format {other:?} (wav, aiff{})", if omf { "" } else { " or mxf" }))),
};
let sample_rate = u64_p(p, "sampleRate").map(|r| r as u32).unwrap_or(q.settings.sample_rate.max(1));
if !(8_000..=192_000).contains(&sample_rate) {
return Err(bad(cmd, "sampleRate must be 8000–192000"));
}
let bits = u64_p(p, "bitDepth").unwrap_or(16) as u16;
if bits != 16 && bits != 24 {
return Err(bad(cmd, "bitDepth must be 16 or 24"));
}
let frames = u64_p(p, "handles").unwrap_or(if omf { 30 } else { 0 }) as i64;
Ok(AudioPlan {
mode,
aiff,
mxf,
sample_rate,
bits,
trim: bool_p(p, "trimAudio").unwrap_or(omf),
handles: q.settings.frame_rate.tick_of(frames),
render_effects: bool_p(p, "renderAudioEffects").unwrap_or(false),
breakout: bool_p(p, "breakoutToMono").unwrap_or(false),
nests: if omf { NestNeeds::Render } else { NestNeeds::Inside },
})
}
fn quantize(planar: &[&Vec<f32>], bits: u16) -> Vec<u8> {
let n = planar.first().map_or(0, |c| c.len());
let bps = if bits >= 24 { 3 } else { 2 };
let mut out = Vec::with_capacity(n * planar.len() * bps);
for i in 0..n {
for c in planar {
let x = c[i].clamp(-1.0, 1.0);
if bps == 3 {
out.extend_from_slice(&((x * 8_388_607.0).round() as i32).to_le_bytes()[..3]);
} else {
out.extend_from_slice(&((x * 32_767.0).round() as i16).to_le_bytes());
}
}
}
out
}
fn dequantize(pcm: &[u8], channels: usize, bits: u16) -> Vec<Vec<f32>> {
let bps = bits.div_ceil(8) as usize;
let ch = channels.max(1);
let n = pcm.len() / (bps * ch).max(1);
let mut out = vec![Vec::with_capacity(n); ch];
for i in 0..n {
for (c, o) in out.iter_mut().enumerate() {
let s = &pcm[(i * ch + c) * bps..(i * ch + c + 1) * bps];
let v = match bps {
1 => (s[0] as f32 - 128.0) / 128.0,
2 => i16::from_le_bytes([s[0], s[1]]) as f32 / 32_768.0,
3 => (i32::from_le_bytes([0, s[0], s[1], s[2]]) >> 8) as f32 / 8_388_608.0,
_ => i32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f32 / 2_147_483_648.0,
};
o.push(v);
}
}
out
}
/// Decode a media range at `sample_rate` (planar).
fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result<Vec<Vec<f32>>> {
let src = s
.media
.full_res_source(&s.project, need.item, &*s.services)
.ok_or_else(|| EngineError::Other(format!("media {} is offline", need.path.as_deref().unwrap_or("?"))))?;
let start = need.start.to_units_floor(sample_rate as i64);
let frames = (need.end.to_units_floor(sample_rate as i64) - start).max(0) as usize;
let mut out: Vec<Vec<f32>> = vec![Vec::with_capacity(frames); need.channels.max(1) as usize];
let chunk = sample_rate as usize * 10;
let mut done = 0usize;
while done < frames {
let n = chunk.min(frames - done);
let b = src.audio_stream(need.audio_stream, start + done as i64, n, sample_rate).map_err(|e| EngineError::Other(e.to_string()))?;
for (c, o) in out.iter_mut().enumerate() {
match b.channels.get(c).or(b.channels.first()) {
Some(x) => o.extend(x.iter().copied().chain(std::iter::repeat(0.0)).take(n)),
None => o.extend(std::iter::repeat_n(0.0, n)),
}
}
done += n;
}
Ok(out)
}
/// Render one clip (with its effects, gain and volume) over a media range, through the export
/// pipeline's audio mixer.
fn render_clip(s: &Session, seq: ItemId, need: &AudioNeed, sample_rate: u32, bits: u16) -> Result<Vec<Vec<f32>>> {
let EssenceKey::Clip(clip_id) = need.key else { return decode(s, need, sample_rate) };
let (q, track, c) = audio_clip(&s.project, seq, clip_id).ok_or_else(|| EngineError::Other("clip not found".into()))?;
let c = c.clone();
let mut p: Project = (*s.project).clone();
let settings = SequenceSettings { sample_rate, ..q.settings.clone() };
let rs = p.new_sequence("AAF audio render", settings, 0, 1, None);
let dur = need.end - need.start;
let mut c = c;
c.start = Tick::ZERO;
c.source_in = need.start;
c.duration = dur;
c.speed = 1.0;
c.reverse = false;
c.link = None;
let channels = match track.channels {
filmcraft_project::AudioChannels::Mono => 1,
filmcraft_project::AudioChannels::Surround51 => 6,
_ => 2,
};
if let Some(t) = p.sequence_mut(rs).and_then(|r| r.audio_tracks.first_mut()) {
t.channels = track.channels;
t.items = vec![c];
}
let buf: Arc<Mutex<Vec<u8>>> = Arc::default();
let sink_buf = buf.clone();
let mut settings = filmcraft_export::ExportSettings {
format: filmcraft_export::Format::Wav,
path: "render.wav".into(),
range: Some(TimeRange::new(Tick::ZERO, dur)),
part_of_batch: true,
sink: Some(filmcraft_export::OutputSink(Arc::new(move |_: &str, d: Vec<u8>| {
*sink_buf.lock().unwrap_or_else(|e| e.into_inner()) = d;
Ok(())
}))),
..Default::default()
};
settings.audio.sample_rate = Some(sample_rate);
settings.audio.channels = channels;
settings.audio.bits = bits;
let project = Arc::new(p);
let provider = s.media.full_res_provider(project.clone(), s.services.clone());
filmcraft_export::export(&project, rs, &settings, &provider, &Default::default()).map_err(|e| EngineError::Other(e.to_string()))?;
let wav = std::mem::take(&mut *buf.lock().unwrap_or_else(|e| e.into_inner()));
let (pcm, ch, _, b) = filmcraft_interchange::wav::parse_wav(&wav).ok_or_else(|| EngineError::Other("audio render produced no WAVE data".into()))?;
Ok(dequantize(&pcm, ch as usize, b))
}
/// The audio clip `clip` with its track and sequence: in `seq`, or in a sequence nested in it
/// (clip ids are unique in a project).
fn audio_clip(p: &Project, seq: ItemId, clip: ClipId) -> Option<(&Sequence, &Track, &TrackItem)> {
let first = p.sequence(seq).into_iter();
let others = p.sequences().filter(|i| i.id != seq).filter_map(|i| p.sequence(i.id));
for q in first.chain(others) {
if let Some((t, c)) = q.tracks(TrackKind::Audio).iter().find_map(|t| t.items.iter().find(|c| c.id == clip).map(|c| (t, c))) {
return Some((q, t, c));
}
}
None
}
fn safe_name(s: &str) -> String {
let n: String = s.chars().map(|c| if c.is_alphanumeric() || " -_.".contains(c) { c } else { '_' }).collect();
let n = n.trim().trim_matches('.').to_string();
if n.is_empty() { "audio".into() } else { n }
}
fn ensure_dir(s: &Session, dir: &str) {
if !s.services.export_in_memory() && !dir.is_empty() {
let _ = std::fs::create_dir_all(dir);
}
}
fn check_linked_streams(s: &Session, seq: ItemId, plan: &AudioPlan) -> Result<()> {
if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
let needs = audio_needs(&s.project, seq, &NeedOptions { nests: plan.nests, ..Default::default() });
if needs.iter().any(|need| need.audio_stream > 0) {
return Err(EngineError::Other("linked AAF media cannot select container audio streams; choose embedded or separate audio".into()));
}
}
Ok(())
}
/// Prepare the audio essence of an export; returns the essence and the files written.
pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str) -> Result<(Vec<AudioEssence>, Vec<String>)> {
check_linked_streams(s, seq, plan)?;
if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
return Ok((Vec::new(), Vec::new()));
}
let needs = audio_needs(
&s.project,
seq,
&NeedOptions { handles: plan.handles, per_clip: plan.render_effects, whole_media: !plan.trim && !plan.render_effects, nests: plan.nests },
);
let mut essence = Vec::new();
let mut files = Vec::new();
let mut used = std::collections::HashSet::new();
for need in &needs {
// a nested sequence has no file to read: its sound is mixed, with what is on the clip
let nest = s.project.sequence(need.item).is_some();
let planar = if plan.render_effects || nest { render_clip(s, seq, need, plan.sample_rate, plan.bits)? } else { decode(s, need, plan.sample_rate)? };
let frames = planar.first().map_or(0, Vec::len) as u64;
let groups: Vec<(Option<u32>, Vec<&Vec<f32>>)> =
if plan.breakout { planar.iter().enumerate().map(|(c, x)| (Some(c as u32), vec![x])).collect() } else { vec![(None, planar.iter().collect())] };
let base = match need.key {
EssenceKey::Clip(c) => {
let name = audio_clip(&s.project, seq, c).map(|(_, _, ti)| ti.name.clone()).unwrap_or_default();
format!("{} {}", safe_name(&name), c.0)
}
EssenceKey::Media(_) | EssenceKey::MediaStream { .. } => safe_name(
need.path
.as_deref()
.map(|p| std::path::Path::new(p).file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default())
.as_deref()
.unwrap_or("audio"),
),
};
for (ch, chans) in groups {
let pcm = quantize(&chans, plan.bits);
let n = chans.len() as u32;
let data = match plan.mode {
AudioMode::Embedded => EssenceData::Embedded(pcm),
AudioMode::Separate | AudioMode::Linked => {
let ext = if plan.mxf {
"mxf"
} else if plan.aiff {
"aif"
} else {
"wav"
};
let stem = match ch {
Some(c) => format!("{base}_A{}", c + 1),
None => base.clone(),
};
let mut name = format!("{stem}.{ext}");
let mut k = 1;
while !used.insert(name.to_ascii_lowercase()) {
k += 1;
name = format!("{stem} ({k}).{ext}");
}
let path = if media_dir.is_empty() { name.clone() } else { format!("{media_dir}/{name}") };
let bytes = if plan.mxf {
let bps = plan.bits.div_ceil(8) as usize;
let samples: Vec<i32> = pcm
.chunks_exact(bps)
.map(|b| if bps == 3 { i32::from_le_bytes([0, b[0], b[1], b[2]]) >> 8 } else { i16::from_le_bytes([b[0], b[1]]) as i32 })
.collect();
let opts = filmcraft_mxf::OpAtomPcm {
sample_rate: plan.sample_rate,
bits: plan.bits as u32,
channels: n,
edit_rate: None,
ids: filmcraft_mxf::PackageIds::from_seed(&path, &name),
timecode: None,
};
filmcraft_mxf::write_opatom_pcm(&opts, &samples).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
} else if plan.aiff {
filmcraft_interchange::wav::aiff_file(&pcm, n as u16, plan.sample_rate, plan.bits)
} else {
filmcraft_interchange::wav::wav_file(&pcm, n as u16, plan.sample_rate, plan.bits)
};
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
files.push(path.clone());
EssenceData::File { path }
}
};
essence.push(AudioEssence {
key: need.key,
channel: ch,
start: need.start,
frames,
sample_rate: plan.sample_rate,
bits: plan.bits,
channels: n,
data,
effects_rendered: plan.render_effects || nest,
});
}
}
Ok((essence, files))
}
/// Render the video of `seq` to one file for an AAF video mixdown.
fn mixdown(s: &Session, seq: ItemId, path: &str) -> Result<MixdownVideo> {
let q = s.project.sequence(seq).ok_or(EngineError::NoSequence)?;
let dur = q.duration();
if dur <= Tick::ZERO {
return Err(EngineError::Other("the sequence is empty".into()));
}
let format = if path.to_ascii_lowercase().ends_with(".mxf") {
filmcraft_export::Format::from_name("mxf-opatom").unwrap_or(filmcraft_export::Format::DnxHr)
} else {
filmcraft_export::Format::DnxHr
};
let mut settings = filmcraft_export::ExportSettings {
format,
path: path.to_string(),
range: Some(TimeRange::new(Tick::ZERO, dur)),
include_audio: false,
part_of_batch: true,
dnx_profile: "hq".into(),
..Default::default()
};
if s.services.export_in_memory() {
let services = s.services.clone();
settings.sink = Some(filmcraft_export::OutputSink(Arc::new(move |p: &str, d: Vec<u8>| services.write_file(p, &d))));
}
let project = s.project.clone();
let provider = s.media.full_res_provider(project.clone(), s.services.clone());
filmcraft_export::export(&project, seq, &settings, &provider, &Default::default()).map_err(|e| EngineError::Other(format!("video mixdown: {e}")))?;
Ok(MixdownVideo { path: path.to_string(), start: Tick::ZERO, duration: dur, width: q.settings.width, height: q.settings.height })
}
fn report_json(r: &filmcraft_interchange::Report) -> Vec<String> {
r.entries.iter().map(|e| if e.count > 1 { format!("{} (×{})", e.message, e.count) } else { e.message.clone() }).collect()
}
fn split(path: &str) -> (String, String) {
let p = std::path::Path::new(path);
let dir = p.parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
let stem = p.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Export".into());
(dir, stem)
}
fn join(dir: &str, name: &str) -> String {
if dir.is_empty() { name.to_string() } else { format!("{dir}/{name}") }
}
/// `file.exportAaf`
pub fn export_aaf(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.exportAaf";
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
let seq = crate::export_tools::sequence_param(s, p, cmd)?;
let plan = plan_from(s, p, seq, cmd, false)?;
check_linked_streams(s, seq, &plan)?;
let (dir, stem) = split(&path);
let media_dir = join(&dir, &format!("{stem} Media"));
let mut files = Vec::new();
if plan.mode != AudioMode::Embedded || bool_p(p, "mixdownVideo").unwrap_or(false) {
ensure_dir(s, &media_dir);
}
let (essence, written) = prepare_audio(s, seq, &plan, &media_dir)?;
files.extend(written);
let mixdown_video = if bool_p(p, "mixdownVideo").unwrap_or(false) {
let ext = if str_p(p, "mixdownFormat") == Some("mxf") { "mxf" } else { "mov" };
let m = mixdown(s, seq, &join(&media_dir, &format!("{} Video Mixdown.{ext}", safe_name(&stem))))?;
files.push(m.path.clone());
Some(m)
} else {
None
};
let opts = filmcraft_interchange::aaf::AafOptions {
name: s.project.item(seq).map(|i| i.name.clone()),
media: MediaOptions { breakout_to_mono: plan.breakout, audio_only: false, essence, mixdown_video },
small_sectors: bool_p(p, "smallSectors").unwrap_or(false),
};
let (bytes, report) = filmcraft_interchange::aaf::export(&s.project, seq, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "mediaFiles": files, "report": report_json(&report)}))
}
/// `file.exportOmf`
pub fn export_omf(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.exportOmf";
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
let seq = crate::export_tools::sequence_param(s, p, cmd)?;
let plan = plan_from(s, p, seq, cmd, true)?;
let (dir, stem) = split(&path);
let media_dir = join(&dir, &format!("{stem} Audio Files"));
if plan.mode == AudioMode::Separate {
ensure_dir(s, &media_dir);
}
let (essence, files) = prepare_audio(s, seq, &plan, &media_dir)?;
let opts = filmcraft_interchange::omf::OmfOptions {
name: str_p(p, "title").map(str::to_string).or_else(|| s.project.item(seq).map(|i| i.name.clone())),
media: MediaOptions { breakout_to_mono: plan.breakout, audio_only: true, essence, mixdown_video: None },
};
let (bytes, report) = filmcraft_interchange::omf::export(&s.project, seq, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
if bytes.len() as u64 > i32::MAX as u64 {
return Err(EngineError::Other("the OMF file would exceed 2 GB; use separate audio files or trim the audio".into()));
}
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "mediaFiles": files, "report": report_json(&report)}))
}
/// `file.importAaf` (and OMF): import a document as sequences.
pub fn import_document(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let format = crate::interchange::detect(&path, &bytes).filter(|f| matches!(f, filmcraft_interchange::Format::Aaf | filmcraft_interchange::Format::Omf));
let format = format.ok_or_else(|| bad(cmd, format!("{path} is not an AAF or OMF file")))?;
crate::interchange::import(s, &path, &bytes, format)
}