Files
effectcraft/crates/interchange/src/tests.rs
T
Brandon ThomasandClaude Opus 5.5 ad5aa542b4 Never crash: reject timeline documents nested too deeply
FCP 7 XML / FCPXML nested about 1 000 elements deep overflowed an 8 MiB stack in the XML
parser (aborting the app). Import now scans XML and OTIO JSON nesting first and returns
Error::TooDeep beyond 256 levels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:05:57 -04:00

540 lines
26 KiB
Rust

//! Import of documents written in code with FilmCraft's writers, and export → re-import.
use std::collections::HashMap;
use std::sync::Arc;
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, Project};
use effectcraft_time::{FrameRate, Tick};
use filmcraft_project as fp;
use crate::*;
const W: u32 = 640;
const H: u32 = 360;
fn secs(s: f64) -> filmcraft_time::Tick {
filmcraft_time::Tick::from_seconds_f64(s)
}
fn t(s: f64) -> Tick {
Tick::from_seconds_f64(s)
}
fn info(kind: filmcraft_media::MediaKind, video: bool, audio: bool) -> filmcraft_media::MediaInfo {
filmcraft_media::MediaInfo {
name: String::new(),
kind,
duration: secs(20.0),
video: video.then(|| filmcraft_media::VideoStreamInfo {
width: W,
height: H,
frame_rate: filmcraft_time::FrameRate { num: 25, den: 1 },
par: (1, 1),
codec: "png".into(),
pixel_format: String::new(),
color: Default::default(),
has_alpha: false,
bitrate: None,
}),
audio: audio.then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }),
container: String::new(),
start_timecode: None,
file_size: None,
}
}
fn media(p: &mut fp::Project, name: &str, path: &str, kind: filmcraft_media::MediaKind, video: bool, audio: bool, bin: Option<fp::BinId>) -> fp::ItemId {
let clip = fp::MediaClip {
media: fp::MediaRef::File { path: path.into() },
info: info(kind, video, audio),
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
};
p.add_item(name, fp::Label::Iris, fp::ItemKind::Media(clip), bin)
}
fn rate() -> filmcraft_time::FrameRate {
filmcraft_time::FrameRate { num: 25, den: 1 }
}
fn item(p: &mut fp::Project, it: fp::ItemId, kind: fp::TrackKind, start: f64, src: f64, dur: f64) -> fp::TrackItem {
p.make_track_item(it, kind, secs(start), filmcraft_time::TimeRange::new(secs(src), secs(dur)), rate()).unwrap()
}
/// A Premiere-like edit: two clips with a cross dissolve on V1, a speed-changed, scaled and
/// faded clip on V2, a nested sequence on V3, and an audio clip at -6 dB on A1.
fn edit() -> (fp::Project, fp::ItemId) {
let mut p = fp::Project::new("Edit");
let bin = p.add_bin("Footage", None);
let a = media(&mut p, "a.png", "/media/a.mov", filmcraft_media::MediaKind::Movie, true, false, Some(bin));
let b = media(&mut p, "b.png", "/media/b.mov", filmcraft_media::MediaKind::Movie, true, false, Some(bin));
let s = media(&mut p, "tone.wav", "/media/tone.wav", filmcraft_media::MediaKind::AudioOnly, false, true, Some(bin));
let settings = fp::SequenceSettings { width: W, height: H, frame_rate: rate(), ..Default::default() };
let nested = p.new_sequence("Nested", settings.clone(), 1, 0, None);
let n_clip = item(&mut p, a, fp::TrackKind::Video, 0.0, 0.0, 2.0);
p.sequence_mut(nested).unwrap().video_tracks[0].items.push(n_clip);
let main = p.new_sequence("Main", settings, 3, 1, None);
let ca = item(&mut p, a, fp::TrackKind::Video, 0.0, 0.0, 2.0);
let cb = item(&mut p, b, fp::TrackKind::Video, 2.0, 1.0, 2.0);
let tr = fp::Transition {
id: fp::TransitionId(p.alloc_id()),
effect: fp::find_effect("cross_dissolve").unwrap().instance(),
start: secs(1.6),
duration: secs(0.8),
from: Some(ca.id),
to: Some(cb.id),
align: fp::TransitionAlign::CenterAtCut,
reverse: false,
};
let mut cc = item(&mut p, b, fp::TrackKind::Video, 1.0, 2.0, 2.0);
cc.speed = 2.0;
let m = cc.effect_mut("motion").unwrap();
m.params.insert("scale".into(), fp::Param::new(fp::ParamValue::Float(50.0)));
m.params.insert("rotation".into(), fp::Param::new(fp::ParamValue::Float(30.0)));
let mut pos = fp::Param::new(fp::ParamValue::Vec2(filmcraft_geom::Vec2 { x: 100.0, y: 100.0 }));
pos.keyframes = vec![
fp::Keyframe::new(secs(0.0), fp::ParamValue::Vec2(filmcraft_geom::Vec2 { x: 100.0, y: 100.0 })),
fp::Keyframe::new(secs(1.0), fp::ParamValue::Vec2(filmcraft_geom::Vec2 { x: 300.0, y: 200.0 })),
];
m.params.insert("position".into(), pos);
cc.effect_mut("opacity").unwrap().params.insert("opacity".into(), fp::Param::new(fp::ParamValue::Float(50.0)));
let cn = item(&mut p, nested, fp::TrackKind::Video, 4.0, 0.0, 2.0);
let mut cs = item(&mut p, s, fp::TrackKind::Audio, 0.0, 0.0, 3.0);
cs.effect_mut("volume").unwrap().params.insert("level".into(), fp::Param::new(fp::ParamValue::Float(-6.0)));
let seq = p.sequence_mut(main).unwrap();
seq.video_tracks[0].items = vec![ca, cb];
seq.video_tracks[0].transitions = vec![tr];
seq.video_tracks[1].items = vec![cc];
seq.video_tracks[2].items = vec![cn];
seq.audio_tracks[0].items = vec![cs];
(p, main)
}
fn probe(path: &str) -> Option<Footage> {
let audio = path.ends_with(".wav");
Some(Footage {
path: path.into(),
kind: if audio { FootageKind::Audio } else { FootageKind::Video },
width: if audio { 0 } else { W },
height: if audio { 0 } else { H },
frame_rate: FrameRate::new(25, 1),
duration: t(20.0),
has_video: !audio,
has_audio: audio,
..Default::default()
})
}
fn import_doc(bytes: &[u8], name: &str, probe_fn: &mut dyn FnMut(&str) -> Option<Footage>) -> (Project, ImportResult) {
let mut p = Project::default();
let r = import(&mut p, bytes, None, &ImportOptions { name: name.into(), ..Default::default() }, probe_fn).unwrap();
(p, r)
}
fn layer<'a>(c: &'a Comp, name: &str) -> &'a Layer {
c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}: {:?}", c.layers.iter().map(|l| &l.name).collect::<Vec<_>>()))
}
fn near(a: Tick, b: Tick) -> bool {
(a.0 - b.0).abs() <= Tick::from_seconds_f64(0.001).0
}
fn check_structure(p: &Project, r: &ImportResult, motion: bool) {
let main = r.comps.iter().copied().find(|c| p.item(*c).unwrap().name == "Main").expect("Main comp");
let c = p.comp(main).unwrap();
assert_eq!((c.width, c.height), (W, H));
assert_eq!(c.frame_rate, FrameRate::new(25, 1));
assert!(near(c.duration, t(6.0)), "{:?}", c.duration.seconds());
// V3 (nested) on top, then V2, then V1's later clip, then the earlier one; audio last.
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
let pos = |n: &str| names.iter().position(|x| x.starts_with(n)).unwrap_or_else(|| panic!("{n} in {names:?}"));
assert!(pos("Nested") < pos("b.png"), "{names:?}");
let nested = layer(c, "Nested");
assert!(matches!(nested.source, LayerSource::Comp { .. }));
assert!(near(nested.in_point, t(4.0)) && near(nested.out_point, t(6.0)));
// The faster clip: 200 % speed → 50 % stretch, source 2 s at its in point.
let fast = c.layers.iter().find(|l| (l.stretch - 50.0).abs() < 1e-6).expect("stretched layer");
assert!(near(fast.in_point, t(1.0)) && near(fast.out_point, t(3.0)));
assert!(near(fast.layer_time(t(1.0)), t(2.0)), "{}", fast.layer_time(t(1.0)).seconds());
// V1: b above a, overlapping over the dissolve; b fades in.
let v1: Vec<&Layer> =
c.layers.iter().filter(|l| (l.stretch - 100.0).abs() < 1e-6 && matches!(l.source, LayerSource::Footage { .. }) && l.switches.video).collect();
assert_eq!(v1.len(), 2, "{names:?}");
let (lb, la) = (v1[0], v1[1]);
assert!(near(la.in_point, t(0.0)) && near(la.out_point, t(2.4)), "{:?}", la.out_point.seconds());
assert!(near(lb.in_point, t(1.6)) && near(lb.out_point, t(4.0)), "{:?}", lb.in_point.seconds());
assert!(near(lb.layer_time(t(2.0)), t(1.0)));
let op = lb.transform().unwrap().get("opacity").unwrap();
assert_eq!(op.keys.len(), 2);
assert!(op.value_at(lb.layer_time(t(1.6))).as_f64() < 1e-6);
assert!((op.value_at(lb.layer_time(t(2.0))).as_f64() - 50.0).abs() < 1.0);
assert!((op.value_at(lb.layer_time(t(3.0))).as_f64() - 100.0).abs() < 1e-6);
// Audio: an audio-only layer at -6 dB.
let au = c.layers.iter().find(|l| !l.switches.video && l.switches.audio).expect("audio layer");
assert!(near(au.out_point, t(3.0)));
if motion {
let lv = au.props.prop("audio/levels").unwrap().value.as_vec2();
assert!((lv[0] + 6.0).abs() < 0.01, "{lv:?}");
let tr = fast.transform().unwrap();
assert_eq!(tr.get("scale").unwrap().value.as_vec3()[..2], [50.0, 50.0]);
assert!((tr.get("rotation").unwrap().value.as_f64() - 30.0).abs() < 1e-6);
assert!((tr.get("opacity").unwrap().value.as_f64() - 50.0).abs() < 1e-6);
let pk = &tr.get("position").unwrap().keys;
assert_eq!(pk.len(), 2);
// Clip time 1 s = comp 2 s = layer time 4 s at 200 % speed.
assert!(near(pk[1].time, fast.layer_time(t(2.0))));
assert_eq!(pk[1].value.as_vec3()[..2], [300.0, 200.0]);
}
// Everything lands in the document's folder.
assert!(p.item(r.folder).unwrap().is_folder());
assert!(p.items.values().filter(|i| i.id != r.folder).all(|i| i.parent.is_some()));
}
/// A Premiere-style xmeml export with a bin, a master clip, Basic Motion and Opacity keyframes.
const PREMIERE_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xmeml>
<xmeml version="4">
<project>
<name>Promo</name>
<children>
<bin>
<name>Shots</name>
<children>
<bin>
<name>Day 1</name>
<children>
<clip id="masterclip-1">
<name>wide.mov</name>
<duration>250</duration>
<rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
<media><video><track><clipitem id="clipitem-m1"><name>wide.mov</name>
<file id="file-1">
<name>wide.mov</name>
<pathurl>file://localhost/media/wide%20shot.mov</pathurl>
<rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
<duration>250</duration>
<media><video><samplecharacteristics><width>640</width><height>360</height></samplecharacteristics></video></media>
</file>
</clipitem></track></video></media>
</clip>
</children>
</bin>
</children>
</bin>
<sequence id="sequence-1">
<name>Promo Cut</name>
<duration>50</duration>
<rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
<media>
<video>
<format><samplecharacteristics><rate><timebase>25</timebase></rate><width>640</width><height>360</height><pixelaspectratio>square</pixelaspectratio></samplecharacteristics></format>
<track>
<clipitem id="clipitem-1">
<masterclipid>masterclip-1</masterclipid>
<name>wide.mov</name>
<enabled>TRUE</enabled>
<rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
<start>0</start><end>50</end><in>25</in><out>75</out>
<file id="file-1"/>
<filter>
<effect>
<name>Basic Motion</name><effectid>basic</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>
<parameter><parameterid>scale</parameterid><name>Scale</name><value>150</value></parameter>
<parameter><parameterid>center</parameterid><name>Center</name><value><horiz>0.25</horiz><vert>0</vert></value></parameter>
</effect>
</filter>
<filter>
<effect>
<name>Opacity</name><effectid>opacity</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>
<parameter><parameterid>opacity</parameterid><name>Opacity</name>
<keyframe><when>0</when><value>0</value></keyframe>
<keyframe><when>25</when><value>100</value></keyframe>
</parameter>
</effect>
</filter>
</clipitem>
</track>
</video>
</media>
</sequence>
</children>
</project>
</xmeml>
"#;
#[test]
fn premiere_xml_bins_motion_and_opacity() {
let mut paths = vec![];
let (p, r) = import_doc(PREMIERE_XML.as_bytes(), "Promo.xml", &mut |path| {
paths.push(path.to_string());
probe(path)
});
assert_eq!(paths, vec!["/media/wide shot.mov".to_string()]);
let shots = p.items.values().find(|i| i.name == "Shots").unwrap();
assert_eq!(shots.parent, Some(r.folder));
let day = p.items.values().find(|i| i.name == "Day 1").unwrap();
assert_eq!(day.parent, Some(shots.id));
assert!(p.items.values().any(|i| i.name == "wide.mov" && i.parent == Some(day.id)));
let c = p.comp(r.comps[0]).unwrap();
assert_eq!(p.item(r.comps[0]).unwrap().name, "Promo Cut");
assert!(near(c.duration, t(2.0)));
let l = &c.layers[0];
assert!(near(l.layer_time(Tick::ZERO), t(1.0)), "source in 1 s");
let tr = l.transform().unwrap();
assert_eq!(tr.get("scale").unwrap().value.as_vec3()[..2], [150.0, 150.0]);
// Center 0.25 of the width right of the frame centre.
assert_eq!(tr.get("position").unwrap().value.as_vec3()[..2], [320.0 + 160.0, 180.0]);
let op = tr.get("opacity").unwrap();
assert_eq!(op.keys.len(), 2);
assert!(near(op.keys[1].time, l.layer_time(t(1.0))));
assert!((op.value_at(l.layer_time(t(0.5))).as_f64() - 50.0).abs() < 1e-6);
}
#[test]
fn imports_fcp7_xml_fcpxml_and_otio() {
let (doc, main) = edit();
for (fmt, motion) in [(fc::Format::Fcp7Xml, true), (fc::Format::Fcpxml, false), (fc::Format::Otio, false)] {
let (bytes, _) = fc::export(&doc, main, fmt, &Default::default()).unwrap();
let name = format!("Main.{}", fmt.extension());
assert_eq!(TimelineFormat::detect(&bytes, Some(&name)).map(|f| f.fc()), Some(fmt));
let (p, r) = import_doc(&bytes, &name, &mut probe);
assert!(r.missing.is_empty(), "{fmt:?}: {:?}", r.missing);
check_structure(&p, &r, motion);
}
}
#[test]
fn imports_aaf_and_omf() {
let (doc, main) = edit();
for fmt in [fc::Format::Aaf, fc::Format::Omf] {
let (bytes, _) = fc::export(&doc, main, fmt, &Default::default()).unwrap();
let name = format!("Main.{}", fmt.extension());
assert_eq!(TimelineFormat::detect(&bytes, Some(&name)).map(|f| f.fc()), Some(fmt));
let (p, r) = import_doc(&bytes, &name, &mut probe);
let c = p.comp(r.comps[0]).unwrap();
if fmt == fc::Format::Aaf {
assert_eq!((c.width, c.height), (W, H));
}
// Audio: an audio-only layer for the A1 clip (OMF carries the audio tracks only).
let au = c.layers.iter().find(|l| !l.switches.video && l.switches.audio).expect("audio layer");
assert!(near(au.in_point, Tick::ZERO) && near(au.out_point, t(3.0)));
if fmt == fc::Format::Aaf {
// The dissolve's clips overlap over the transition, the incoming one on top.
let a = layer(c, "a.png");
assert!(near(a.out_point, t(2.4)), "{}", a.out_point.seconds());
let b = c.layers.iter().find(|l| near(l.in_point, t(1.6))).expect("incoming clip");
assert!(c.index_of(b.id) < c.index_of(a.id));
assert_eq!(b.transform().unwrap().get("opacity").unwrap().keys.len(), 2);
}
}
}
#[test]
fn missing_media_become_placeholders() {
let (doc, main) = edit();
let (bytes, _) = fc::export(&doc, main, fc::Format::Fcp7Xml, &Default::default()).unwrap();
let (p, r) = import_doc(&bytes, "Main.xml", &mut |_| None);
assert_eq!(r.missing.len(), 3, "{:?}", r.missing);
let a = p.items.values().find(|i| i.name == "a.png").unwrap();
match &a.kind {
ItemKind::Footage(f) => assert!(f.missing && f.width == W && f.height == H && f.has_video),
_ => panic!(),
}
check_structure(&p, &r, true);
}
#[test]
fn edl_imports_one_track() {
let (doc, main) = edit();
let (bytes, _) = fc::export(&doc, main, fc::Format::Edl, &Default::default()).unwrap();
let mut p = Project::default();
let r = import(&mut p, &bytes, None, &ImportOptions { name: "Main.edl".into(), edl_frame_rate: Some(25.0), ..Default::default() }, &mut probe).unwrap();
assert_eq!(r.format, Some(TimelineFormat::Edl));
let c = p.comp(r.comps[0]).unwrap();
assert!(c.layers.len() >= 2);
}
#[test]
fn rejects_unknown_documents() {
let mut p = Project::default();
assert!(matches!(import(&mut p, b"hello", None, &ImportOptions::default(), &mut probe), Err(Error::UnknownFormat)));
}
// ---------------------------------------------------------------- export
fn footage_item(p: &mut Project, path: &str) -> ItemId {
let f = probe(path).unwrap();
p.add_item(path.rsplit('/').next().unwrap(), effectcraft_color::Label::Aqua, None, ItemKind::Footage(f))
}
fn comp_item(p: &mut Project, name: &str, c: Comp) -> ItemId {
p.add_item(name, effectcraft_color::Label::Sandstone, None, ItemKind::Comp(Arc::new(c)))
}
/// A comp with a stretched, keyframed footage layer, a text layer, a precomp and an audio layer.
fn ae_project() -> (Project, ItemId) {
let mut p = Project::default();
let a = footage_item(&mut p, "/media/a.mov");
let s = footage_item(&mut p, "/media/tone.wav");
let mut inner = Comp::new(W, H, FrameRate::new(25, 1), t(4.0));
let li = effectcraft_project::build::layer(&mut p, &inner, "inner a", LayerSource::Footage { item: a }, (W, H), None);
inner.layers.push(li);
let inner_id = comp_item(&mut p, "Inner", inner);
let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(5.0));
let mut la = effectcraft_project::build::layer(&mut p, &c, "clip a", LayerSource::Footage { item: a }, (W, H), None);
la.stretch = 200.0; // half speed
la.start_time = t(-1.0); // layer time 1 s at comp 3 s… source 1 s at comp 1 s
la.in_point = t(1.0);
la.out_point = t(4.0);
{
let tr = la.transform_mut().unwrap();
let pos = tr.get_mut("position").unwrap();
pos.keys = vec![Keyframe::new(t(1.0), Value::Vec3([100.0, 120.0, 0.0])), Keyframe::new(t(2.0), Value::Vec3([500.0, 240.0, 0.0])).hold()];
tr.get_mut("scale").unwrap().value = Value::Vec3([80.0, 80.0, 100.0]);
tr.get_mut("rotation").unwrap().value = Value::Scalar(-15.0);
tr.get_mut("opacity").unwrap().value = Value::Scalar(75.0);
}
let mut lt = effectcraft_project::build::layer(&mut p, &c, "Title", LayerSource::Text, (W, H), None);
lt.in_point = t(0.6);
lt.out_point = t(2.6);
let mut lp = effectcraft_project::build::layer(&mut p, &c, "Inner", LayerSource::Comp { item: inner_id }, (W, H), None);
lp.start_time = t(2.0);
lp.in_point = t(2.0);
lp.out_point = t(5.0);
let mut ls = effectcraft_project::build::layer(&mut p, &c, "tone", LayerSource::Footage { item: s }, (0, 0), None);
ls.out_point = t(3.0);
ls.props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-3.0, -3.0]);
c.layers = vec![lt, lp, la, ls];
let cid = comp_item(&mut p, "Main", c);
(p, cid)
}
#[test]
fn plan_prerenders_rendered_only_layers() {
let (p, cid) = ae_project();
let opts = ExportOptions::default();
let plan = plan_prerender(&p, cid, &opts);
let c = p.comp(cid).unwrap();
assert_eq!(plan, vec![(cid, layer(c, "Title").id)]);
assert_eq!(prerender_reason(&p, cid, layer(c, "Title").id, &opts).as_deref(), Some("text layer"));
let all = plan_prerender(&p, cid, &ExportOptions { prerender: PrerenderMode::All, ..Default::default() });
assert_eq!(all.len(), 3, "audio-only layers are never rendered");
assert!(plan_prerender(&p, cid, &ExportOptions { prerender: PrerenderMode::None, ..Default::default() }).is_empty());
}
#[test]
fn export_then_reimport_keeps_the_timeline() {
let (p, cid) = ae_project();
let c = p.comp(cid).unwrap();
let title = layer(c, "Title").id;
let mut pre = HashMap::new();
pre.insert((cid, title), Prerendered { path: "/render/Title.mov".into(), width: W, height: H, frame_rate: FrameRate::new(25, 1), duration: t(2.0) });
for format in [TimelineFormat::Fcp7Xml, TimelineFormat::Fcpxml, TimelineFormat::Otio] {
let out = export(&p, cid, &ExportOptions { format, ..Default::default() }, &pre).unwrap();
assert_eq!(out.prerendered, 1);
assert_eq!(out.sequences, 2);
assert_eq!(out.video_tracks, 4, "3 in Main + 1 in Inner");
assert_eq!(out.audio_tracks, 1);
let text = String::from_utf8(out.bytes.clone()).unwrap();
assert!(text.contains("Title.mov"), "{format:?}");
let (q, r) = import_doc(&out.bytes, &format!("Main.{}", format.extension()), &mut probe);
let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main");
let m = q.comp(main).unwrap();
assert_eq!(m.layers.len(), 4, "{format:?}: {:?}", m.layers.iter().map(|l| &l.name).collect::<Vec<_>>());
// Stacking order kept: Title (pre-rendered) on top, then the precomp, then clip a.
assert_eq!(m.layers[0].name, "Title");
assert!(near(m.layers[0].in_point, t(0.6)) && near(m.layers[0].out_point, t(2.6)));
assert!(matches!(m.layers[1].source, LayerSource::Comp { .. }), "{format:?}");
assert!(near(m.layers[1].in_point, t(2.0)) && near(m.layers[1].layer_time(t(2.0)), Tick::ZERO));
let la = &m.layers[2];
assert!((la.stretch - 200.0).abs() < 1e-6, "{format:?} stretch {}", la.stretch);
assert!(near(la.in_point, t(1.0)) && near(la.out_point, t(4.0)));
let orig = layer(c, "clip a");
assert!(near(la.layer_time(t(1.0)), orig.layer_time(t(1.0))), "{format:?} {}", la.layer_time(t(1.0)).seconds());
assert!(!m.layers[3].switches.video && m.layers[3].switches.audio);
if format == TimelineFormat::Fcp7Xml {
let tr = la.transform().unwrap();
assert_eq!(tr.get("scale").unwrap().value.as_vec3()[..2], [80.0, 80.0]);
assert!((tr.get("rotation").unwrap().value.as_f64() + 15.0).abs() < 1e-6);
assert!((tr.get("opacity").unwrap().value.as_f64() - 75.0).abs() < 1e-6);
let pk = &tr.get("position").unwrap().keys;
assert_eq!(pk.len(), 2);
for (k, o) in pk.iter().zip(&tr_orig(orig).keys) {
assert!(near(la.comp_time(k.time), orig.comp_time(o.time)), "{} vs {}", k.time.seconds(), o.time.seconds());
assert_eq!(k.value.as_vec3()[..2], o.value.as_vec3()[..2]);
}
let lv = m.layers[3].props.prop("audio/levels").unwrap().value.as_vec2();
assert!((lv[0] + 3.0).abs() < 0.01, "{lv:?}");
}
}
}
fn tr_orig(l: &Layer) -> &effectcraft_project::Property {
l.transform().unwrap().get("position").unwrap()
}
#[test]
fn export_without_prerender_leaves_text_out() {
let (p, cid) = ae_project();
let out = export(&p, cid, &ExportOptions { prerender: PrerenderMode::None, ..Default::default() }, &HashMap::new()).unwrap();
assert!(out.warnings.iter().any(|w| w.contains("Title")), "{:?}", out.warnings);
assert_eq!(out.prerendered, 0);
assert_eq!(out.video_tracks, 3);
}
#[test]
fn formats_parse() {
assert_eq!(TimelineFormat::parse("premiere"), Some(TimelineFormat::Fcp7Xml));
assert_eq!(TimelineFormat::parse(".fcpxml"), Some(TimelineFormat::Fcpxml));
assert_eq!(TimelineFormat::from_path("/a/b.otio"), Some(TimelineFormat::Otio));
assert_eq!(TimelineFormat::Fcp7Xml.extension(), "xml");
assert_eq!(par_fraction(0.9), (9, 10));
assert_eq!(par_fraction(1.0), (1, 1));
}
#[test]
fn export_survives_a_precomp_layer_pointing_at_a_non_comp() {
// A damaged project: the precomp layer's source is a footage item (or a deleted item).
let (mut p, cid) = ae_project();
let footage = p.items.iter().find(|(_, i)| matches!(i.kind, ItemKind::Footage(_))).map(|(id, _)| *id).unwrap();
for bad in [footage, ItemId(9_999)] {
let mut c = (*p.comp_arc(cid).unwrap()).clone();
for l in &mut c.layers {
if matches!(l.source, LayerSource::Comp { .. }) {
l.source = LayerSource::Comp { item: bad };
}
}
p.item_mut(cid).unwrap().kind = ItemKind::Comp(Arc::new(c));
for format in [TimelineFormat::Fcp7Xml, TimelineFormat::Fcpxml, TimelineFormat::Otio] {
let out = export(&p, cid, &ExportOptions { format, prerender: PrerenderMode::None, ..Default::default() }, &HashMap::new()).unwrap();
assert_eq!(out.sequences, 1);
}
}
assert!(matches!(export(&p, footage, &ExportOptions::default(), &HashMap::new()), Err(Error::NoComp(_))));
}
#[test]
fn deeply_nested_documents_error_instead_of_overflowing_the_stack() {
// A few hundred kilobytes nested 100 000 levels deep overflowed the parsers' stack.
let n = 100_000;
let docs = [
(TimelineFormat::Fcp7Xml, format!("<?xml version=\"1.0\"?><xmeml version=\"5\">{}{}</xmeml>", "<a>".repeat(n), "</a>".repeat(n))),
(TimelineFormat::Fcpxml, format!("<?xml version=\"1.0\"?><fcpxml version=\"1.9\">{}{}</fcpxml>", "<a>".repeat(n), "</a>".repeat(n))),
(TimelineFormat::Otio, format!("{{\"OTIO_SCHEMA\":\"Timeline.1\",\"tracks\":{}{}}}", "[".repeat(n), "]".repeat(n))),
];
for (format, doc) in docs {
let mut p = Project::default();
let r = import(&mut p, doc.as_bytes(), Some(format), &ImportOptions::default(), &mut |_| None);
assert!(matches!(r, Err(Error::TooDeep(_))), "{format:?}");
}
assert_eq!(super::import::xml_depth(b"<a><!-- <b><b> --><c x='>'/><![CDATA[<d>]]><e></e></a>"), 2);
assert_eq!(super::import::json_depth(br#"{"a": "[[[{{", "b": [[1], {"c": "\\\"["}]}"#), 3);
}