mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-27 03:27:39 -06:00
- Update MediaInfo to store all discovered audio streams in audio_streams - Retain independent stream metadata and decoder state for MP4/MOV and Matroska/WebM - Update audio_placement_specs to allocate successive tracks via ensure_audio_tracks - Isolate clip rendering to read assigned audio stream rather than replaying stream 0 - Bump project schema to v13 with backwards compatibility for legacy v12 projects Resolves #312 Fixes #200 Fixes #315
679 lines
35 KiB
Rust
679 lines
35 KiB
Rust
//! AAF and OMF: export → import round trips (no external oracle exists for these formats).
|
|
|
|
mod support;
|
|
|
|
use filmcraft_interchange::aaf::{self, AafOptions};
|
|
use filmcraft_interchange::essence::{AudioEssence, EssenceData, EssenceKey, MediaOptions, MixdownVideo, NeedOptions, NestNeeds, audio_needs};
|
|
use filmcraft_interchange::omf::{self, OmfOptions};
|
|
use filmcraft_interchange::{Format, ImportOptions, Imported, detect};
|
|
use filmcraft_project::{
|
|
AudioChannels, Interpolation, ItemId, ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, TrackKind,
|
|
TransitionAlign,
|
|
};
|
|
use filmcraft_time::{FrameRate, Tick};
|
|
use support::*;
|
|
|
|
/// V1: a fade-in from black, a cross dissolve, a gap, a clip with a dip-to-black fade-out.
|
|
/// V2: one clip. A1/A2: the linked audio with a crossfade, constant gain and volume keyframes.
|
|
fn sample(rate: FrameRate, df: bool) -> (Project, ItemId) {
|
|
let mut p = Project::new("p");
|
|
let a = media(&mut p, "/media/A001.mov", true, true, rate);
|
|
let b = media(&mut p, "/media/B002.mov", true, true, rate);
|
|
let m = media(&mut p, "/media/music.wav", false, true, rate);
|
|
if let Some(mm) = p.item_mut(a).and_then(|i| i.as_media_mut()) {
|
|
mm.info.start_timecode = Some(rate.timecode_base() * 3600 + 12);
|
|
mm.markers.push(Marker {
|
|
id: MarkerId(9000),
|
|
start: rate.tick_of(40),
|
|
duration: Tick::ZERO,
|
|
name: "slate".into(),
|
|
comment: "take 3".into(),
|
|
kind: MarkerKind::Comment,
|
|
color: Label::Rose,
|
|
});
|
|
}
|
|
let s = sequence(&mut p, "Edit 1", rate, df);
|
|
let v1 = clip(&mut p, s, TrackKind::Video, 0, a, 0, 100, 20);
|
|
let v2 = clip(&mut p, s, TrackKind::Video, 0, b, 100, 80, 50);
|
|
let v3 = clip(&mut p, s, TrackKind::Video, 0, a, 220, 60, 300);
|
|
clip(&mut p, s, TrackKind::Video, 1, b, 30, 40, 0);
|
|
transition(&mut p, s, TrackKind::Video, 0, "cross_dissolve", None, Some(v1), 0, 12, TransitionAlign::StartAtCut);
|
|
transition(&mut p, s, TrackKind::Video, 0, "cross_dissolve", Some(v1), Some(v2), 90, 20, TransitionAlign::CenterAtCut);
|
|
transition(&mut p, s, TrackKind::Video, 0, "dip_to_black", Some(v3), None, 266, 14, TransitionAlign::EndAtCut);
|
|
let a1 = clip(&mut p, s, TrackKind::Audio, 0, a, 0, 100, 20);
|
|
let a2 = clip(&mut p, s, TrackKind::Audio, 0, b, 100, 80, 50);
|
|
let a3 = clip(&mut p, s, TrackKind::Audio, 0, a, 220, 60, 300);
|
|
transition(&mut p, s, TrackKind::Audio, 0, "constant_gain", Some(a1), Some(a2), 95, 10, TransitionAlign::CenterAtCut);
|
|
let mu = clip(&mut p, s, TrackKind::Audio, 1, m, 10, 200, 0);
|
|
link(&mut p, s, &[v1, a1]);
|
|
link(&mut p, s, &[v2, a2]);
|
|
link(&mut p, s, &[v3, a3]);
|
|
{
|
|
let q = p.sequence_mut(s).unwrap();
|
|
q.start_timecode = rate.timecode_base() * 3600;
|
|
q.markers.push(Marker {
|
|
id: MarkerId(9001),
|
|
start: rate.tick_of(50),
|
|
duration: rate.tick_of(10),
|
|
name: "Fix this".into(),
|
|
comment: "colour".into(),
|
|
kind: MarkerKind::Comment,
|
|
color: Label::Mango,
|
|
});
|
|
let (_, c) = q.find_item_mut(a2).unwrap();
|
|
c.effect_mut("volume").unwrap().params.insert("level".into(), Param::new(ParamValue::Float(-6.0)));
|
|
let (_, c) = q.find_item_mut(mu).unwrap();
|
|
let mut prm = Param::new(ParamValue::Float(0.0));
|
|
for (f, db) in [(0, -12.0), (48, 0.0), (150, -3.0)] {
|
|
let mut k = Keyframe::new(rate.tick_of(f), ParamValue::Float(db));
|
|
k.interp = Interpolation::Linear;
|
|
prm.keyframes.push(k);
|
|
}
|
|
c.effect_mut("volume").unwrap().params.insert("level".into(), prm);
|
|
}
|
|
(p, s)
|
|
}
|
|
|
|
fn level(p: &Project, seq: ItemId, track: usize, clip: usize) -> Param {
|
|
p.sequence(seq).unwrap().audio_tracks[track].items[clip]
|
|
.effect("volume")
|
|
.and_then(|e| e.param("level"))
|
|
.cloned()
|
|
.unwrap_or(Param::new(ParamValue::Float(0.0)))
|
|
}
|
|
|
|
fn close(a: f64, b: f64) -> bool {
|
|
(a - b).abs() < 1e-3
|
|
}
|
|
|
|
fn aaf_rt(p: &Project, s: ItemId, opts: &AafOptions) -> (Imported, Vec<filmcraft_interchange::ExtractedMedia>, filmcraft_interchange::Report) {
|
|
let (bytes, _) = aaf::export(p, s, opts).expect("export");
|
|
assert!(aaf::sniff(&bytes));
|
|
assert_eq!(detect(&bytes, Some("aaf")), Some(Format::Aaf));
|
|
aaf::import(&bytes, &ImportOptions { base_dir: Some("/proj".into()), name: Some("rt".into()), ..Default::default() }).expect("import")
|
|
}
|
|
|
|
#[test]
|
|
fn aaf_round_trip_preserves_the_edit() {
|
|
for (rate, df) in [(FrameRate::FPS_25, false), (FrameRate::FPS_29_97, true), (FrameRate::FPS_23_976, false), (FrameRate::FPS_59_94, true)] {
|
|
let (p, s) = sample(rate, df);
|
|
let (imp, extracted, rep) = aaf_rt(&p, s, &AafOptions::default());
|
|
assert!(extracted.is_empty());
|
|
let n = only_seq(&imp);
|
|
assert_eq!(structure(&imp.project, n, TrackKind::Video), structure(&p, s, TrackKind::Video), "{rate:?}: {rep}");
|
|
assert_eq!(structure(&imp.project, n, TrackKind::Audio), structure(&p, s, TrackKind::Audio), "{rate:?}: {rep}");
|
|
assert_eq!(links(&imp.project, n), links(&p, s));
|
|
let q = imp.project.sequence(n).unwrap();
|
|
assert_eq!(q.settings.frame_rate, rate);
|
|
assert_eq!(q.settings.drop_frame, df);
|
|
assert_eq!(q.start_timecode, rate.timecode_base() * 3600);
|
|
assert_eq!((q.settings.width, q.settings.height), (1920, 1080));
|
|
assert_eq!(imp.project.item(n).unwrap().name, "Edit 1");
|
|
// markers
|
|
assert_eq!(q.markers.len(), 1);
|
|
assert_eq!((q.markers[0].start, q.markers[0].duration), (rate.tick_of(50), rate.tick_of(10)));
|
|
assert_eq!((q.markers[0].name.as_str(), q.markers[0].comment.as_str(), q.markers[0].color), ("Fix this", "colour", Label::Mango));
|
|
// gains
|
|
assert!(close(level(&imp.project, n, 0, 1).value.as_f64().unwrap(), -6.0));
|
|
let k = level(&imp.project, n, 1, 0);
|
|
let want = level(&p, s, 1, 0);
|
|
assert_eq!(k.keyframes.len(), 3);
|
|
for (a, b) in k.keyframes.iter().zip(&want.keyframes) {
|
|
// control points are stored in samples: keyframes not on a sample move to the nearest one
|
|
assert!((a.time - b.time).abs() <= Tick(filmcraft_time::TICKS_PER_SECOND / 48_000 / 2), "{rate:?}: {a:?} {b:?}");
|
|
assert!(close(a.value.as_f64().unwrap(), b.value.as_f64().unwrap()), "{a:?} {b:?}");
|
|
}
|
|
// media: paths, timecode, markers
|
|
let a = imp
|
|
.project
|
|
.items
|
|
.values()
|
|
.find(|i| matches!(&i.kind, ItemKind::Media(m) if matches!(&m.media, MediaRef::File{path} if path == "/media/A001.mov")))
|
|
.unwrap();
|
|
let ma = a.as_media().unwrap();
|
|
assert_eq!(ma.info.start_timecode, Some(rate.timecode_base() * 3600 + 12));
|
|
assert!(ma.info.video.is_some() && ma.info.has_audio());
|
|
assert_eq!(ma.markers.len(), 1);
|
|
assert_eq!((ma.markers[0].name.as_str(), ma.markers[0].comment.as_str()), ("slate", "take 3"));
|
|
let mus = imp.project.items.values().find(|i| i.name == "music.wav").unwrap();
|
|
assert!(mus.as_media().unwrap().info.video.is_none());
|
|
// the same document again: byte-identical (reproducible)
|
|
assert_eq!(aaf::export(&p, s, &AafOptions::default()).unwrap().0, aaf::export(&p, s, &AafOptions::default()).unwrap().0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn aaf_small_sectors_and_generic_api() {
|
|
let (p, s) = sample(FrameRate::FPS_25, false);
|
|
let (b512, _) = aaf::export(&p, s, &AafOptions { small_sectors: true, ..Default::default() }).unwrap();
|
|
assert_eq!(u16::from_le_bytes([b512[30], b512[31]]), 9);
|
|
let (imp, _) = filmcraft_interchange::import(&b512, Format::Aaf, None).unwrap();
|
|
let n = only_seq(&imp);
|
|
assert_eq!(structure(&imp.project, n, TrackKind::Video), structure(&p, s, TrackKind::Video));
|
|
let (bytes, _) = filmcraft_interchange::export(&p, s, Format::Aaf, &Default::default()).unwrap();
|
|
assert_eq!(u16::from_le_bytes([bytes[30], bytes[31]]), 12);
|
|
}
|
|
|
|
#[test]
|
|
fn aaf_breakout_to_mono() {
|
|
let (p, s) = sample(FrameRate::FPS_25, false);
|
|
let opts = AafOptions { media: MediaOptions { breakout_to_mono: true, ..Default::default() }, ..Default::default() };
|
|
let (imp, _, _) = aaf_rt(&p, s, &opts);
|
|
let n = only_seq(&imp);
|
|
let q = imp.project.sequence(n).unwrap();
|
|
assert_eq!(q.audio_tracks.len(), 8, "four stereo tracks → eight mono tracks");
|
|
for (i, t) in q.audio_tracks.iter().enumerate() {
|
|
assert_eq!(t.channels, AudioChannels::Mono);
|
|
let ch = (i % 2) as u16;
|
|
assert!(t.items.iter().all(|c| c.source_channels == vec![ch]), "track {i}");
|
|
}
|
|
// left and right of the same clip use the same media item
|
|
assert_eq!(q.audio_tracks[0].items[0].item, q.audio_tracks[1].items[0].item);
|
|
let orig = structure(&p, s, TrackKind::Audio);
|
|
let got = structure(&imp.project, n, TrackKind::Audio);
|
|
assert_eq!(got[0], orig[0]);
|
|
assert_eq!(got[1], orig[0]);
|
|
assert_eq!(got[2], orig[1]);
|
|
}
|
|
|
|
fn pcm_ramp(frames: usize, channels: usize, seed: i32) -> Vec<u8> {
|
|
let mut v = Vec::new();
|
|
for i in 0..frames {
|
|
for c in 0..channels {
|
|
let s = ((i as i32 * 37 + c as i32 * 1000 + seed) % 30000) as i16;
|
|
v.extend_from_slice(&s.to_le_bytes());
|
|
}
|
|
}
|
|
v
|
|
}
|
|
|
|
#[test]
|
|
fn aaf_embedded_trimmed_audio() {
|
|
let (p, s) = sample(FrameRate::FPS_25, false);
|
|
let handles = FrameRate::FPS_25.tick_of(5);
|
|
let needs = audio_needs(&p, s, &NeedOptions { handles, ..Default::default() });
|
|
assert_eq!(needs.len(), 3);
|
|
let a = needs.iter().find(|n| n.path.as_deref() == Some("/media/A001.mov")).unwrap();
|
|
// A001 is used at source 20..120 and 300..360 (+ 5 frame handles, plus the crossfade's 5 frames)
|
|
assert_eq!(a.start, FrameRate::FPS_25.tick_of(15));
|
|
assert_eq!(a.end, FrameRate::FPS_25.tick_of(365));
|
|
let mut essence = Vec::new();
|
|
for (k, n) in needs.iter().enumerate() {
|
|
let frames = ((n.end - n.start).0 / (filmcraft_time::TICKS_PER_SECOND / 48_000)) as u64;
|
|
essence.push(AudioEssence {
|
|
key: n.key,
|
|
channel: None,
|
|
start: n.start,
|
|
frames,
|
|
sample_rate: 48_000,
|
|
bits: 16,
|
|
channels: 2,
|
|
data: EssenceData::Embedded(pcm_ramp(frames as usize, 2, k as i32)),
|
|
effects_rendered: false,
|
|
});
|
|
}
|
|
let opts = AafOptions { media: MediaOptions { essence: essence.clone(), ..Default::default() }, ..Default::default() };
|
|
let (imp, extracted, rep) = aaf_rt(&p, s, &opts);
|
|
assert_eq!(extracted.len(), 3, "{rep}");
|
|
let n = only_seq(&imp);
|
|
for e in &extracted {
|
|
let (pcm, ch, sr, bits) = filmcraft_interchange::wav::parse_wav(&e.wav).unwrap();
|
|
assert_eq!((ch, sr, bits), (2, 48_000, 16));
|
|
assert!(essence.iter().any(|x| matches!(&x.data, EssenceData::Embedded(d) if *d == pcm)));
|
|
assert!(e.path.starts_with("/proj/rt Media/"), "{}", e.path);
|
|
let it = imp.project.item(e.item).unwrap().as_media().unwrap();
|
|
assert!(matches!(&it.media, MediaRef::File { path } if *path == e.path));
|
|
}
|
|
// the audio clips now point into the trimmed essence: source times shift by the trim start
|
|
let q = imp.project.sequence(n).unwrap();
|
|
let orig = p.sequence(s).unwrap();
|
|
for (t, ot) in q.audio_tracks.iter().zip(&orig.audio_tracks) {
|
|
for (c, oc) in t.items.iter().zip(&ot.items) {
|
|
let need = needs.iter().find(|x| x.item == oc.item).unwrap();
|
|
assert_eq!(c.source_in, oc.source_in - need.start);
|
|
assert_eq!((c.start, c.duration), (oc.start, oc.duration));
|
|
}
|
|
}
|
|
// video still links to the original movies
|
|
assert_eq!(structure(&imp.project, n, TrackKind::Video), structure(&p, s, TrackKind::Video));
|
|
}
|
|
|
|
#[test]
|
|
fn aaf_linked_consolidated_audio_and_mixdown() {
|
|
let (p, s) = sample(FrameRate::FPS_25, false);
|
|
let needs = audio_needs(&p, s, &NeedOptions { per_clip: true, ..Default::default() });
|
|
assert_eq!(needs.len(), 4, "one range per audio clip");
|
|
let essence: Vec<AudioEssence> = needs
|
|
.iter()
|
|
.flat_map(|n| {
|
|
(0..2).map(move |c| AudioEssence {
|
|
key: n.key,
|
|
channel: Some(c),
|
|
start: n.start,
|
|
frames: 4800,
|
|
sample_rate: 48_000,
|
|
bits: 24,
|
|
channels: 1,
|
|
data: EssenceData::File { path: format!("/out/{:?}_{c}.wav", n.key).replace(['(', ')'], "") },
|
|
effects_rendered: true,
|
|
})
|
|
})
|
|
.collect();
|
|
let mix = MixdownVideo { path: "/out/mixdown.mxf".into(), start: Tick::ZERO, duration: FrameRate::FPS_25.tick_of(280), width: 1920, height: 1080 };
|
|
let opts = AafOptions { media: MediaOptions { essence, breakout_to_mono: true, mixdown_video: Some(mix), ..Default::default() }, ..Default::default() };
|
|
let (imp, extracted, rep) = aaf_rt(&p, s, &opts);
|
|
assert!(extracted.is_empty());
|
|
let n = only_seq(&imp);
|
|
let q = imp.project.sequence(n).unwrap();
|
|
assert_eq!(q.video_tracks.len(), 1);
|
|
assert_eq!(q.video_tracks[0].items.len(), 1);
|
|
assert_eq!(q.video_tracks[0].items[0].duration, FrameRate::FPS_25.tick_of(280));
|
|
assert_eq!(structure(&imp.project, n, TrackKind::Video)[0].clips[0].media, "/out/mixdown.mxf");
|
|
assert_eq!(q.audio_tracks.len(), 8);
|
|
let paths: Vec<String> = q.audio_tracks.iter().flat_map(|t| t.items.iter().map(|c| media_key(&imp.project, c.item))).collect();
|
|
assert!(paths.iter().all(|p| p.starts_with("/out/Clip")), "{paths:?} {rep}");
|
|
// rendered clip audio carries no gain any more
|
|
for t in &q.audio_tracks {
|
|
for c in &t.items {
|
|
assert!(c.effect("volume").and_then(|e| e.param("level")).is_none_or(|p| !p.is_animated() && p.value.as_f64() == Some(0.0)));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn omf_round_trip_audio_only() {
|
|
for rate in [FrameRate::FPS_25, FrameRate::FPS_29_97] {
|
|
let (p, s) = sample(rate, rate.is_ntsc());
|
|
let needs = audio_needs(&p, s, &NeedOptions { handles: rate.tick_of(10), ..Default::default() });
|
|
let essence: Vec<AudioEssence> = needs
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(k, n)| {
|
|
let frames = ((n.end - n.start).0 / (filmcraft_time::TICKS_PER_SECOND / 48_000)) as u64;
|
|
AudioEssence {
|
|
key: n.key,
|
|
channel: None,
|
|
start: n.start,
|
|
frames,
|
|
sample_rate: 48_000,
|
|
bits: 16,
|
|
channels: 2,
|
|
data: EssenceData::Embedded(pcm_ramp(frames as usize, 2, k as i32 * 7)),
|
|
effects_rendered: false,
|
|
}
|
|
})
|
|
.collect();
|
|
let opts = OmfOptions { media: MediaOptions { essence: essence.clone(), ..Default::default() }, ..Default::default() };
|
|
let (bytes, rep) = omf::export(&p, s, &opts).unwrap();
|
|
assert!(rep.mentions("markers"), "{rep}");
|
|
assert!(omf::sniff(&bytes));
|
|
assert_eq!(detect(&bytes, Some("omf")), Some(Format::Omf));
|
|
let (imp, extracted, rep) = omf::import(&bytes, &ImportOptions { base_dir: Some("/x".into()), name: Some("o".into()), ..Default::default() }).unwrap();
|
|
assert_eq!(extracted.len(), 3, "{rep}");
|
|
let n = only_seq(&imp);
|
|
let q = imp.project.sequence(n).unwrap();
|
|
assert!(q.video_tracks.iter().all(|t| t.items.is_empty()), "OMF exports are audio only");
|
|
assert_eq!(q.start_timecode, rate.timecode_base() * 3600);
|
|
assert_eq!(q.settings.frame_rate, rate);
|
|
let orig = p.sequence(s).unwrap();
|
|
assert_eq!(q.audio_tracks.len(), orig.audio_tracks.len());
|
|
for (t, ot) in q.audio_tracks.iter().zip(&orig.audio_tracks) {
|
|
assert_eq!(t.items.len(), ot.items.len());
|
|
for (c, oc) in t.items.iter().zip(&ot.items) {
|
|
let need = needs.iter().find(|x| x.item == oc.item).unwrap();
|
|
assert_eq!((c.start, c.duration, c.source_in), (oc.start, oc.duration, oc.source_in - need.start));
|
|
}
|
|
assert_eq!(t.transitions.len(), ot.transitions.len());
|
|
for (x, ox) in t.transitions.iter().zip(&ot.transitions) {
|
|
assert_eq!((x.start, x.duration, &x.effect.effect, x.align), (ox.start, ox.duration, &ox.effect.effect, ox.align));
|
|
}
|
|
}
|
|
assert!(close(level(&imp.project, n, 0, 1).value.as_f64().unwrap(), -6.0));
|
|
assert_eq!(level(&imp.project, n, 1, 0).keyframes.len(), 3);
|
|
for e in &extracted {
|
|
let (pcm, ..) = filmcraft_interchange::wav::parse_wav(&e.wav).unwrap();
|
|
assert!(essence.iter().any(|x| matches!(&x.data, EssenceData::Embedded(d) if *d == pcm)));
|
|
}
|
|
// the media keeps its source timecode
|
|
let a = imp.project.items.values().find(|i| i.name == "A001.mov").expect("master mob name");
|
|
assert_eq!(a.as_media().unwrap().info.start_timecode, Some(rate.timecode_base() * 3600 + 12));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn omf_separate_files_and_breakout() {
|
|
let (p, s) = sample(FrameRate::FPS_25, false);
|
|
let needs = audio_needs(&p, s, &NeedOptions::default());
|
|
let essence: Vec<AudioEssence> = needs
|
|
.iter()
|
|
.flat_map(|n| {
|
|
(0..2u32).map(move |c| AudioEssence {
|
|
key: n.key,
|
|
channel: Some(c),
|
|
start: n.start,
|
|
frames: 1000,
|
|
sample_rate: 48_000,
|
|
bits: 24,
|
|
channels: 1,
|
|
data: EssenceData::File { path: format!("/omf/media/{}_{c}.aif", n.item.0) },
|
|
effects_rendered: false,
|
|
})
|
|
})
|
|
.collect();
|
|
let opts = OmfOptions { media: MediaOptions { essence, breakout_to_mono: true, ..Default::default() }, ..Default::default() };
|
|
let (bytes, _) = omf::export(&p, s, &opts).unwrap();
|
|
let (imp, extracted, _) = omf::import(&bytes, &ImportOptions::default()).unwrap();
|
|
assert!(extracted.is_empty());
|
|
let n = only_seq(&imp);
|
|
let q = imp.project.sequence(n).unwrap();
|
|
assert_eq!(q.audio_tracks.len(), 8);
|
|
for t in &q.audio_tracks {
|
|
assert_eq!(t.channels, AudioChannels::Mono);
|
|
for c in &t.items {
|
|
let k = media_key(&imp.project, c.item);
|
|
assert!(k.starts_with("/omf/media/") && k.ends_with(".aif"), "{k}");
|
|
let m = imp.project.item(c.item).unwrap().as_media().unwrap();
|
|
assert_eq!(m.info.audio().map(|a| (a.channels, a.sample_rate)), Some((1, 48_000)));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_and_damaged_documents() {
|
|
let mut p = Project::new("e");
|
|
let s = sequence(&mut p, "Empty", FrameRate::FPS_24, false);
|
|
for small in [false, true] {
|
|
let (bytes, _) = aaf::export(&p, s, &AafOptions { small_sectors: small, ..Default::default() }).unwrap();
|
|
let (imp, _, _) = aaf::import(&bytes, &ImportOptions::default()).unwrap();
|
|
assert_eq!(imp.sequences.len(), 1);
|
|
}
|
|
let (bytes, _) = omf::export(&p, s, &OmfOptions::default()).unwrap();
|
|
assert_eq!(omf::import(&bytes, &ImportOptions::default()).unwrap().0.sequences.len(), 1);
|
|
assert!(aaf::import(b"not an aaf", &ImportOptions::default()).is_err());
|
|
assert!(omf::import(b"not an omf", &ImportOptions::default()).is_err());
|
|
assert!(!aaf::sniff(b"\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1 but short"));
|
|
|
|
// truncation and corruption: errors, never panics
|
|
let (p, s) = sample(FrameRate::FPS_25, false);
|
|
let (aaf_bytes, _) = aaf::export(&p, s, &AafOptions { small_sectors: true, ..Default::default() }).unwrap();
|
|
let needs = audio_needs(&p, s, &NeedOptions::default());
|
|
let essence = needs
|
|
.iter()
|
|
.map(|n| AudioEssence {
|
|
key: n.key,
|
|
channel: None,
|
|
start: n.start,
|
|
frames: 100,
|
|
sample_rate: 48_000,
|
|
bits: 16,
|
|
channels: 2,
|
|
data: EssenceData::Embedded(pcm_ramp(100, 2, 1)),
|
|
effects_rendered: false,
|
|
})
|
|
.collect();
|
|
let (omf_bytes, _) = omf::export(&p, s, &OmfOptions { media: MediaOptions { essence, ..Default::default() }, ..Default::default() }).unwrap();
|
|
for (bytes, is_aaf) in [(&aaf_bytes, true), (&omf_bytes, false)] {
|
|
for cut in (0..bytes.len()).step_by(97) {
|
|
let part = &bytes[..cut];
|
|
let _ = if is_aaf { aaf::import(part, &ImportOptions::default()).map(|_| ()) } else { omf::import(part, &ImportOptions::default()).map(|_| ()) };
|
|
}
|
|
let mut seed = 0xA5A5_1234_5678_9ABCu64;
|
|
for _ in 0..400 {
|
|
let mut g = bytes.clone();
|
|
for _ in 0..8 {
|
|
seed ^= seed << 13;
|
|
seed ^= seed >> 7;
|
|
seed ^= seed << 17;
|
|
let at = (seed as usize) % g.len();
|
|
g[at] = (seed >> 40) as u8;
|
|
}
|
|
let _ = if is_aaf { aaf::import(&g, &ImportOptions::default()).map(|_| ()) } else { omf::import(&g, &ImportOptions::default()).map(|_| ()) };
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_clips_become_gaps_with_a_report() {
|
|
let mut p = Project::new("g");
|
|
let s = sequence(&mut p, "S", FrameRate::FPS_25, false);
|
|
let g = generator(&mut p, "Bars", filmcraft_media::Generator::BarsAndTone);
|
|
let a = media(&mut p, "/m/a.mov", true, true, FrameRate::FPS_25);
|
|
clip(&mut p, s, TrackKind::Video, 0, g, 0, 25, 0);
|
|
clip(&mut p, s, TrackKind::Video, 0, a, 25, 25, 0);
|
|
let (bytes, rep) = aaf::export(&p, s, &AafOptions::default()).unwrap();
|
|
assert!(rep.mentions("synthetic"), "{rep}");
|
|
let (imp, _, _) = aaf::import(&bytes, &ImportOptions::default()).unwrap();
|
|
let n = only_seq(&imp);
|
|
let v = &imp.project.sequence(n).unwrap().video_tracks[0];
|
|
assert_eq!(v.items.len(), 1);
|
|
assert_eq!(v.items[0].start, FrameRate::FPS_25.tick_of(25));
|
|
}
|
|
|
|
// ---- nested sequences
|
|
//
|
|
// Premiere Pro 26.5.2 (observed in the files it exports): in AAF a nested sequence is a composition
|
|
// of its own that clips point at; in OMF its sound is rendered into the document as one clip named
|
|
// after the nested sequence.
|
|
|
|
/// "Outer": a media clip, then a nest ("Inner", picture and linked sound, trimmed in, -4 dB on
|
|
/// its sound clip), then the same nest again. "Inner": two clips with a dissolve, a second audio
|
|
/// track, and a nest of its own ("Deep").
|
|
fn nested(rate: FrameRate) -> (Project, [ItemId; 3]) {
|
|
let mut p = Project::new("n");
|
|
let a = media(&mut p, "/media/A001.mov", true, true, rate);
|
|
let b = media(&mut p, "/media/B002.mov", true, true, rate);
|
|
let m = media(&mut p, "/media/music.wav", false, true, rate);
|
|
let deep = sequence(&mut p, "Deep", rate, false);
|
|
clip(&mut p, deep, TrackKind::Video, 0, b, 0, 50, 200);
|
|
let inner = sequence(&mut p, "Inner", rate, false);
|
|
let i1 = clip(&mut p, inner, TrackKind::Video, 0, a, 0, 60, 10);
|
|
let i2 = clip(&mut p, inner, TrackKind::Video, 0, b, 60, 60, 100);
|
|
transition(&mut p, inner, TrackKind::Video, 0, "cross_dissolve", Some(i1), Some(i2), 54, 12, TransitionAlign::CenterAtCut);
|
|
clip(&mut p, inner, TrackKind::Video, 1, deep, 20, 30, 5);
|
|
let ia = clip(&mut p, inner, TrackKind::Audio, 0, a, 0, 60, 10);
|
|
clip(&mut p, inner, TrackKind::Audio, 1, m, 0, 120, 0);
|
|
link(&mut p, inner, &[i1, ia]);
|
|
let outer = sequence(&mut p, "Outer", rate, false);
|
|
let v0 = clip(&mut p, outer, TrackKind::Video, 0, a, 0, 40, 0);
|
|
let a0 = clip(&mut p, outer, TrackKind::Audio, 0, a, 0, 40, 0);
|
|
let v1 = clip(&mut p, outer, TrackKind::Video, 0, inner, 40, 70, 15);
|
|
let a1 = clip(&mut p, outer, TrackKind::Audio, 0, inner, 40, 70, 15);
|
|
let v2 = clip(&mut p, outer, TrackKind::Video, 1, inner, 150, 30, 0);
|
|
link(&mut p, outer, &[v0, a0]);
|
|
link(&mut p, outer, &[v1, a1]);
|
|
let (_, c) = p.sequence_mut(outer).unwrap().find_item_mut(a1).unwrap();
|
|
c.effect_mut("volume").unwrap().params.insert("level".into(), Param::new(ParamValue::Float(-4.0)));
|
|
let _ = v2;
|
|
(p, [outer, inner, deep])
|
|
}
|
|
|
|
fn sequence_named(p: &Project, name: &str) -> ItemId {
|
|
let mut found = p.items.values().filter(|i| i.name == name && matches!(i.kind, ItemKind::Sequence(_)));
|
|
let id = found.next().unwrap_or_else(|| panic!("no sequence {name}")).id;
|
|
assert!(found.next().is_none(), "{name} was imported more than once");
|
|
id
|
|
}
|
|
|
|
#[test]
|
|
fn aaf_nested_sequences_round_trip_as_compositions() {
|
|
for rate in [FrameRate::FPS_25, FrameRate::FPS_23_976] {
|
|
let (p, [outer, inner, deep]) = nested(rate);
|
|
let (bytes, rep) = aaf::export(&p, outer, &AafOptions::default()).unwrap();
|
|
assert!(!rep.has_warnings(), "{rep}");
|
|
let (imp, _, rep) = aaf::import(&bytes, &ImportOptions { base_dir: Some("/proj".into()), ..Default::default() }).unwrap();
|
|
assert!(!rep.has_warnings(), "{rep}");
|
|
// one sequence is imported as the edit; the nested ones are sequences in the project that
|
|
// it uses (each once, however many clips use it)
|
|
let q = &imp.project;
|
|
let n = only_seq(&imp);
|
|
assert_eq!(q.item(n).unwrap().name, "Outer");
|
|
let (ni, nd) = (sequence_named(q, "Inner"), sequence_named(q, "Deep"));
|
|
for kind in [TrackKind::Video, TrackKind::Audio] {
|
|
assert_eq!(structure(q, n, kind), structure(&p, outer, kind), "{rate:?} Outer {kind:?}");
|
|
assert_eq!(structure(q, ni, kind), structure(&p, inner, kind), "{rate:?} Inner {kind:?}");
|
|
assert_eq!(structure(q, nd, kind), structure(&p, deep, kind), "{rate:?} Deep {kind:?}");
|
|
}
|
|
let top = q.sequence(n).unwrap();
|
|
assert_eq!((top.video_tracks[0].items[1].item, top.video_tracks[1].items[0].item, top.audio_tracks[0].items[1].item), (ni, ni, ni));
|
|
assert_eq!(q.sequence(ni).unwrap().video_tracks[1].items[0].item, nd);
|
|
// the nest's picture and sound are linked again, and the level on its sound clip is kept
|
|
assert_eq!(links(q, n), links(&p, outer));
|
|
assert!(close(level(q, n, 0, 1).value.as_f64().unwrap(), -4.0));
|
|
// the nested sequences keep their own settings
|
|
assert_eq!(q.sequence(ni).unwrap().settings.frame_rate, rate);
|
|
// reproducible
|
|
assert_eq!(bytes, aaf::export(&p, outer, &AafOptions::default()).unwrap().0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn aaf_nested_sequence_of_another_rate_and_broken_out_to_mono() {
|
|
// the nest runs at 25 fps and 44.1 kHz inside a 23.976 fps, 48 kHz sequence: a clip's start in
|
|
// it counts the nested composition's edit units
|
|
let (mut p, [outer, inner, _]) = nested(FrameRate::FPS_23_976);
|
|
{
|
|
let q = p.sequence_mut(inner).unwrap();
|
|
q.settings.frame_rate = FrameRate::FPS_25;
|
|
q.settings.sample_rate = 44_100;
|
|
}
|
|
let before = (structure(&p, outer, TrackKind::Video), structure(&p, outer, TrackKind::Audio));
|
|
for breakout in [false, true] {
|
|
let opts = AafOptions { media: MediaOptions { breakout_to_mono: breakout, ..Default::default() }, ..Default::default() };
|
|
let (bytes, _) = aaf::export(&p, outer, &opts).unwrap();
|
|
let (imp, _, rep) = aaf::import(&bytes, &ImportOptions::default()).unwrap();
|
|
let (q, n) = (&imp.project, only_seq(&imp));
|
|
// picture starts are stored in frames of the nested composition: within half of one
|
|
let picture = structure(q, n, TrackKind::Video);
|
|
assert_eq!(picture.len(), before.0.len(), "{rep}");
|
|
for (t, o) in picture.iter().zip(&before.0) {
|
|
assert_eq!(t.clips.len(), o.clips.len());
|
|
for (c, o) in t.clips.iter().zip(&o.clips) {
|
|
assert_eq!((c.start, c.dur, &c.media), (o.start, o.dur, &o.media));
|
|
let grid = if c.media == "seq:Inner" { FrameRate::FPS_25.frame_duration() } else { Tick(1) };
|
|
assert!((c.src - o.src).abs() <= Tick(grid.0 / 2), "{c:?} {o:?}");
|
|
}
|
|
}
|
|
let ni = sequence_named(q, "Inner");
|
|
assert_eq!((q.sequence(ni).unwrap().settings.frame_rate, q.sequence(ni).unwrap().settings.sample_rate), (FrameRate::FPS_25, 44_100));
|
|
let sound = structure(q, n, TrackKind::Audio);
|
|
if breakout {
|
|
// each stereo track became two mono tracks; the nest is on both, at the same place
|
|
assert_eq!(sound.len(), 2);
|
|
assert_eq!(sound[0], sound[1]);
|
|
assert_eq!(
|
|
sound[0].clips.iter().map(|c| (c.start, c.dur, c.media.as_str())).collect::<Vec<_>>(),
|
|
before.1[0].clips.iter().map(|c| (c.start, c.dur, c.media.as_str())).collect::<Vec<_>>()
|
|
);
|
|
} else {
|
|
// sound starts are stored in samples of the nested composition: within one sample
|
|
assert_eq!(sound.len(), before.1.len());
|
|
for (c, o) in sound[0].clips.iter().zip(&before.1[0].clips) {
|
|
assert_eq!((c.start, c.dur, &c.media), (o.start, o.dur, &o.media));
|
|
assert!((c.src - o.src).abs() <= Tick(filmcraft_time::TICKS_PER_SECOND / 44_100), "{c:?} {o:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A project that claims a sequence is inside itself (FilmCraft refuses to make one, a damaged
|
|
/// file may hold one) exports without looping: the clip is left as a gap and named in the report.
|
|
#[test]
|
|
fn a_sequence_inside_itself_exports_as_a_gap() {
|
|
let rate = FrameRate::FPS_25;
|
|
let (mut p, [outer, inner, deep]) = nested(rate);
|
|
clip(&mut p, deep, TrackKind::Video, 1, outer, 0, 20, 0);
|
|
clip(&mut p, deep, TrackKind::Audio, 0, outer, 0, 20, 0);
|
|
clip(&mut p, inner, TrackKind::Audio, 2, inner, 0, 20, 0);
|
|
let (bytes, rep) = aaf::export(&p, outer, &AafOptions::default()).unwrap();
|
|
assert!(rep.mentions("inside itself"), "{rep}");
|
|
let (imp, _, _) = aaf::import(&bytes, &ImportOptions::default()).unwrap();
|
|
let q = &imp.project;
|
|
assert!(q.sequence(sequence_named(q, "Deep")).unwrap().video_tracks[1].items.is_empty());
|
|
// the lists of what to prepare end too
|
|
for nests in [NestNeeds::Skip, NestNeeds::Inside, NestNeeds::Render] {
|
|
let needs = audio_needs(&p, outer, &NeedOptions { nests, ..Default::default() });
|
|
assert!(needs.len() < 16, "{nests:?}: {}", needs.len());
|
|
}
|
|
let (_, rep) = omf::export(&p, outer, &OmfOptions::default()).unwrap();
|
|
assert!(rep.mentions("was not rendered"), "{rep}");
|
|
}
|
|
|
|
#[test]
|
|
fn audio_needs_of_nested_sequences() {
|
|
let rate = FrameRate::FPS_25;
|
|
let (p, [outer, inner, _]) = nested(rate);
|
|
let media_named = |name: &str| p.items.values().find(|i| i.name == name).unwrap().id;
|
|
let (a, m) = (media_named("A001.mov"), media_named("music.wav"));
|
|
let nest_clip = p.sequence(outer).unwrap().audio_tracks[0].items[1].clone();
|
|
// by default a nest needs nothing (as before)
|
|
let plain = audio_needs(&p, outer, &NeedOptions::default());
|
|
assert_eq!(plain.iter().map(|n| n.key).collect::<Vec<_>>(), [EssenceKey::Media(a)]);
|
|
// inside: the media of the clips in the nested sequence, each item once over all its uses
|
|
let inside = audio_needs(&p, outer, &NeedOptions { nests: NestNeeds::Inside, ..Default::default() });
|
|
assert_eq!(inside.iter().map(|n| n.key).collect::<Vec<_>>(), [EssenceKey::Media(a), EssenceKey::Media(m)]);
|
|
assert_eq!((inside[0].start, inside[0].end), (Tick::ZERO, rate.tick_of(70)), "0..40 in Outer and 10..70 in Inner");
|
|
// render: one range of the nested sequence per clip, with handles, not past the sequence's end
|
|
let handles = rate.tick_of(10);
|
|
let render = audio_needs(&p, outer, &NeedOptions { nests: NestNeeds::Render, handles, ..Default::default() });
|
|
let need = render.iter().find(|n| n.key == EssenceKey::Clip(nest_clip.id)).expect("the nest clip");
|
|
assert_eq!((need.item, need.path.as_deref()), (inner, None));
|
|
assert_eq!((need.start, need.end), (rate.tick_of(5), rate.tick_of(95)));
|
|
assert_eq!((need.channels, need.sample_rate), (2, 48_000));
|
|
let whole = audio_needs(&p, outer, &NeedOptions { nests: NestNeeds::Render, handles: rate.tick_of(100), ..Default::default() });
|
|
let need = whole.iter().find(|n| n.key == EssenceKey::Clip(nest_clip.id)).unwrap();
|
|
assert_eq!((need.start, need.end), (Tick::ZERO, p.sequence(inner).unwrap().duration()));
|
|
}
|
|
|
|
#[test]
|
|
fn omf_carries_the_rendered_sound_of_a_nested_sequence() {
|
|
let rate = FrameRate::FPS_25;
|
|
let (p, [outer, _, _]) = nested(rate);
|
|
let nest_clip = p.sequence(outer).unwrap().audio_tracks[0].items[1].clone();
|
|
let needs = audio_needs(&p, outer, &NeedOptions { handles: rate.tick_of(5), nests: NestNeeds::Render, ..Default::default() });
|
|
assert_eq!(needs.len(), 2);
|
|
let essence: Vec<AudioEssence> = needs
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(k, n)| {
|
|
let frames = ((n.end - n.start).0 / (filmcraft_time::TICKS_PER_SECOND / 48_000)) as u64;
|
|
AudioEssence {
|
|
key: n.key,
|
|
channel: None,
|
|
start: n.start,
|
|
frames,
|
|
sample_rate: 48_000,
|
|
bits: 16,
|
|
channels: 2,
|
|
data: EssenceData::Embedded(pcm_ramp(frames as usize, 2, k as i32 * 11)),
|
|
effects_rendered: matches!(n.key, EssenceKey::Clip(_)),
|
|
}
|
|
})
|
|
.collect();
|
|
let opts = OmfOptions { media: MediaOptions { essence: essence.clone(), ..Default::default() }, ..Default::default() };
|
|
let (bytes, rep) = omf::export(&p, outer, &opts).unwrap();
|
|
assert!(!rep.mentions("nested") && !rep.mentions("gap"), "{rep}");
|
|
let (imp, extracted, rep) = omf::import(&bytes, &ImportOptions { base_dir: Some("/x".into()), name: Some("o".into()), ..Default::default() }).unwrap();
|
|
let q = imp.project.sequence(only_seq(&imp)).unwrap();
|
|
let t = &q.audio_tracks[0];
|
|
assert_eq!(t.items.len(), 2, "{rep}");
|
|
// the nest is one clip named after the nested sequence, playing the rendered sound from where
|
|
// the clip starts in it (the render begins 5 frames earlier: the handle)
|
|
let c = &t.items[1];
|
|
assert_eq!((c.name.as_str(), imp.project.item(c.item).unwrap().name.as_str()), ("Inner", "Inner"));
|
|
assert_eq!((c.start, c.duration, c.source_in), (nest_clip.start, nest_clip.duration, rate.tick_of(5)));
|
|
assert!(matches!(imp.project.item(c.item).unwrap().kind, ItemKind::Media(_)), "media in the document, not a sequence");
|
|
// the clip's level is in the rendered sound, not written again
|
|
assert!(close(level(&imp.project, only_seq(&imp), 0, 1).value.as_f64().unwrap(), 0.0));
|
|
let nest_pcm = essence.iter().find(|e| e.key == EssenceKey::Clip(nest_clip.id)).map(|e| match &e.data {
|
|
EssenceData::Embedded(d) => d.clone(),
|
|
EssenceData::File { .. } => unreachable!(),
|
|
});
|
|
assert!(extracted.iter().any(|e| filmcraft_interchange::wav::parse_wav(&e.wav).map(|w| w.0) == nest_pcm));
|
|
|
|
// without the rendered sound the nest is a gap, and the report says which sequence
|
|
let (bytes, rep) = omf::export(&p, outer, &OmfOptions::default()).unwrap();
|
|
assert!(rep.mentions("\"Inner\" was not rendered"), "{rep}");
|
|
let (imp, _, _) = omf::import(&bytes, &ImportOptions::default()).unwrap();
|
|
assert_eq!(imp.project.sequence(only_seq(&imp)).unwrap().audio_tracks[0].items.len(), 1);
|
|
}
|