Files
craft-libs/crates/timeline/tests/it/fcpxml.rs
Dylan M. Taylor 6cc226e454 craft-timeline: OpenTimelineIO on the neutral model (#1002)
* craft-timeline: a neutral timeline model and the import builder

model: a Project of bins and items (media, sequences, subclips, and items no
interchange format carries), sequences of tracks with clips, transitions,
markers and effects with keyframes. Cut down from FilmCraft's project model to
what Final Cut Pro 7 XML, FCPXML and OpenTimelineIO describe; EffectCraft
already uses that shape for its interchange. Three things stay the
application's: effects (looked up through a Catalog the caller supplies),
generators beyond the common ones (Generator::Other with an opaque payload),
and per-object extension JSON (ext) that a format can carry unread.

fx: the effect and parameter ids the formats name themselves, and Standard,
a catalog of just those.

build: FilmCraft's import Builder and shared helpers (filmcraft-interchange
common.rs at 4bec22bd) on the model, with the effect registry replaced by the
catalog, and the bounds every import ends with (Imported::bound, from lib.rs).

* craft-timeline: Final Cut Pro 7 XML (xmeml) on the neutral model

fcp7: FilmCraft's xmeml reader and writer (filmcraft-interchange fcp7.rs at
4bec22bd) moved onto craft_timeline::model, behind feature xml.

- XML is read with craft-xml, strictly: at most 256 levels of nesting (the
  limit EffectCraft checked with its own depth scan before handing documents
  over), 2 000 000 nodes, 256 MiB; a document type declaration with an internal
  subset and entities XML does not define are refused. It is written with
  craft-xml's Writer.
- Effects other than motion, opacity, time remap, audio levels and pan are
  looked up through the caller's Catalog, by id and by name, and written with
  the catalog's stable name and category.
- An application generator round-trips as <namespace>:<payload> in effectid
  (FilmCraft wrote filmcraft:<json>); transparent video, counting leader and
  bars and tone are written by name, which the reader already took.
- Finding the sequence a clip item is in is one walk of the document instead of
  an ancestors() lookup per clip item.

tests/it: FilmCraft's FCP7 integration tests (the hand-written Premiere-style
document, the rich round trip, the 48-case property round trip, split mono and
stereo channels, sub-frame spans, absurd timebases and start timecodes, the
link-group bound) on the model, with a test catalog for the four application
effects they use.

* craft-timeline: FCPXML on the neutral model

fcpxml: FilmCraft's FCPXML 1.9-1.11 reader and writer (filmcraft-interchange
fcpxml.rs at 4bec22bd) moved onto craft_timeline::model, behind feature xml.
XML is read with craft-xml as fcp7 is; filters other than the transform,
blend and volume adjustments go through the caller's Catalog by name, and an
effect resource's uid is <namespace>.<effect id> (FilmCraft wrote
filmcraft.<id>). ExportOptions gains fcpxml_version.

tests/it/fcpxml.rs: FilmCraft's FCPXML integration tests on the model (the
hand-written connected-clips document, hostile lane numbers, standalone video
stills, self-referencing compound media, round trips at 1.9, 1.10 and 1.11,
the property round trip with its regression seed, percent-decoded relative
URLs). The FCP7 tests export to FCPXML again where FilmCraft's did.

* craft-timeline: OpenTimelineIO on the neutral model

otio: FilmCraft's OTIO reader and writer (filmcraft-interchange otio.rs at
4bec22bd) moved onto craft_timeline::model, behind feature otio (serde_json).

FilmCraft wrote its own serde types into metadata.filmcraft. Here the model's
fields are written as JSON by hand under metadata.<namespace>, with the same
keys and value shapes FilmCraft's serde produced (labels, settings, media
info, generators, effects with externally tagged parameter values, alignment,
marker kinds, channel layouts), so files FilmCraft wrote read back. Every key
the reader does not know comes back in the object's ext, and ext's keys are
written next to the model's: an application's palette, track volume, masks and
the like round-trip without the crate knowing them. ParamValue::Opaque and
Generator::Other carry values only the application reads.

The model's Sequence loses its ext: a sequence's is its item's, so there is
one place for it. ImportOptions gains name (for an unnamed timeline), as
FilmCraft's had.

tests: FilmCraft's OTIO integration tests on the model (the golden document,
the lossless round trip now through ext, collections and bare tracks, items at
an audio sample rate, generator labels and identity, the property round trip,
marker URLs, percent-decoded relative URLs); its palette tests stay with
FilmCraft, whose palette merge they test, and are replaced by one ext round
trip. The OTIO unit tests (exact rational times, the nested-copies limit) and
a JSON nesting test.
2026-11-11 20:24:37 -05:00

506 lines
24 KiB
Rust

//! FCPXML: a hand-written document (from the public FCPXML element reference) with connected
//! clips, a transition, a retimed clip, a gap and a compound clip; plus export → import round trips.
use crate::support;
use craft_timeline::model::{
BinEntry, ClipId, ItemId, ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, Sequence, TrackKind,
TransitionAlign,
};
use craft_timeline::time::{FrameRate, TICKS_PER_SECOND, Tick};
use craft_timeline::{ExportOptions, FcpxmlVersion, Format, Report, detect_text as detect};
use proptest::prelude::*;
use support::*;
/// Frame `n` at 23.976 as an FCPXML time, offset by `base` frames.
fn ft(n: i64) -> String {
format!("{}/24000s", n * 1001)
}
fn doc() -> String {
let tc = 86_400; // 01:00:00:00 at 23.976 NDF
let tm_t = format!("{}/24000s", 3610 * 24000 + 48 * 1001);
let tm_v = format!("{}/24000s", 3610 * 24000 + 96 * 1001);
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.10">
<resources>
<format id="r1" frameDuration="1001/24000s" width="1920" height="1080"/>
<asset id="r2" name="Pier" start="0s" duration="{d240}" hasVideo="1" hasAudio="1" format="r1" audioChannels="2" audioRate="48000">
<media-rep kind="original-media" src="file:///Media/pier%20wide.mov"/>
</asset>
<asset id="r3" name="Boats" start="3600s" duration="120s" hasVideo="1" format="r1">
<media-rep kind="original-media" src="boats.mov"/>
</asset>
<asset id="r4" name="Theme" start="0s" duration="60s" hasAudio="1" audioRate="44100">
<media-rep kind="original-media" src="file:///Media/theme.wav"/>
</asset>
<effect id="r5" name="Cross Dissolve" uid="example.dissolve"/>
<media id="r6" name="Compound">
<sequence format="r1" tcStart="0s">
<spine>
<asset-clip ref="r3" name="Boats" offset="0s" start="3600s" duration="{d48}"/>
</spine>
</sequence>
</media>
</resources>
<library>
<event name="Day 1">
<project name="Harbour">
<sequence format="r1" tcStart="{tc0}" tcFormat="NDF" audioRate="48k">
<spine>
<asset-clip ref="r2" name="Pier" offset="{tc0}" start="{d24}" duration="{d72}">
<adjust-transform position="10 -5" scale="1.5 1.5" rotation="90"/>
<asset-clip ref="r4" lane="-1" name="Theme" offset="{d48}" start="0s" duration="{d96}">
<adjust-volume amount="-6dB"/>
</asset-clip>
<marker start="{d48}" duration="{d1}" value="Seagull" note="nice"/>
</asset-clip>
<transition name="Cross Dissolve" offset="{t60}" duration="{d24}">
<filter-video ref="r5" name="Cross Dissolve"/>
</transition>
<asset-clip ref="r3" name="Boats" offset="{t72}" start="3610s" duration="{d48}">
<timeMap>
<timept time="3610s" value="3610s" interp="linear"/>
<timept time="{tm_t}" value="{tm_v}" interp="linear"/>
</timeMap>
<asset-clip ref="r2" lane="1" name="Pier Insert" offset="3610s" start="0s" duration="{d24}" srcEnable="video">
<adjust-blend amount="0.5">
<param name="amount">
<keyframeAnimation>
<keyframe time="0s" value="0"/>
<keyframe time="{d12}" value="1"/>
</keyframeAnimation>
</param>
</adjust-blend>
</asset-clip>
</asset-clip>
<gap name="Gap" offset="{t120}" start="3600s" duration="{d48}">
<ref-clip ref="r6" lane="1" name="Compound" offset="3600s" start="0s" duration="{d48}"/>
</gap>
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>
"#,
tc0 = ft(tc),
t60 = ft(tc + 60),
t72 = ft(tc + 72),
t120 = ft(tc + 120),
d1 = ft(1),
d12 = ft(12),
d24 = ft(24),
d48 = ft(48),
d72 = ft(72),
d96 = ft(96),
d240 = ft(240),
)
}
#[test]
fn self_referencing_compound_media_is_ignored_without_recursing() {
let xml = r#"<fcpxml version="1.10">
<resources>
<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>
<media id="r2" name="Loop"><sequence format="r1"><spine>
<ref-clip ref="r2" offset="0s" start="0s" duration="1s"/>
</spine></sequence></media>
</resources>
<library><event><project name="P"><sequence format="r1"><spine>
<ref-clip ref="r2" offset="0s" start="0s" duration="1s"/>
</spine></sequence></project></event></library>
</fcpxml>"#;
let (imported, report) = import(xml.as_bytes(), Format::Fcpxml, None).unwrap();
assert!(!imported.sequences.is_empty());
assert!(report.mentions("recursive FCPXML media"), "{report:?}");
}
#[test]
fn golden_connected_clips() {
let d = doc();
assert_eq!(detect(d.as_bytes(), Some("fcpxml")), Some(Format::Fcpxml));
let (imp, rep) = import(d.as_bytes(), Format::Fcpxml, Some("/proj")).unwrap();
let p = &imp.project;
let sid = only_seq(&imp);
assert_eq!(p.item(sid).unwrap().name, "Harbour");
let s = p.sequence(sid).unwrap();
let r = FrameRate::FPS_23_976;
assert_eq!(s.settings.frame_rate, r);
assert_eq!(s.start_timecode, 86_400);
assert_connected_primary_video(p, s, r);
assert_connected_secondary_video(p, s, r);
assert_connected_audio_markers_and_bins(p, s, r, &rep);
}
fn assert_connected_primary_video(p: &Project, s: &Sequence, r: FrameRate) {
let v1 = &s.video_tracks[0];
assert_eq!(v1.items.len(), 2);
let (pier, boats) = (&v1.items[0], &v1.items[1]);
assert_eq!((pier.start, pier.duration, pier.source_in), (r.tick_of(0), r.tick_of(72), r.tick_of(24)));
assert_eq!(media_key(p, pier.item), "/Media/pier wide.mov");
let pos = pier.effect("motion").unwrap().param("position").unwrap().value.as_vec2().unwrap();
assert!((pos.x - 1068.0).abs() < 1e-9 && (pos.y - 594.0).abs() < 1e-9, "{pos:?}");
assert_eq!(pier.effect("motion").unwrap().param("scale").unwrap().value, ParamValue::Float(150.0));
assert_eq!(pier.effect("motion").unwrap().param("rotation").unwrap().value, ParamValue::Float(-90.0));
// retimed clip: 2x, source in = 10 s into an asset whose timecode starts at 3600 s
assert_eq!(boats.speed, 2.0);
assert_eq!(boats.source_in, Tick(10 * TICKS_PER_SECOND));
assert_eq!(media_key(p, boats.item), "/proj/boats.mov");
let t = &v1.transitions[0];
assert_eq!(
(t.start, t.duration, t.from, t.to, t.align),
(r.tick_of(60), r.tick_of(24), Some(pier.id), Some(boats.id), TransitionAlign::CenterAtCut)
);
}
fn assert_connected_secondary_video(p: &Project, s: &Sequence, r: FrameRate) {
// connected video on lane 1 (V2) with keyframed opacity; compound clip in a gap
let v2 = &s.video_tracks[1];
assert_eq!(v2.items.len(), 2);
assert_eq!((v2.items[0].start, v2.items[0].duration), (r.tick_of(72), r.tick_of(24)));
let op = v2.items[0].effect("opacity").unwrap().param("opacity").unwrap();
assert_eq!(op.keyframes.len(), 2);
assert_eq!(op.keyframes[1].value, ParamValue::Float(100.0));
assert_eq!(op.keyframes[1].time, r.tick_of(12));
assert_eq!(v2.items[1].start, r.tick_of(120));
assert!(matches!(p.item(v2.items[1].item).unwrap().kind, ItemKind::Sequence(_)));
assert_eq!(p.item(v2.items[1].item).unwrap().name, "Compound");
}
fn assert_connected_audio_markers_and_bins(p: &Project, s: &Sequence, r: FrameRate, rep: &Report) {
// audio: pier's own audio on A1 (linked), the lane -1 music pushed to A2 by the overlap
let a1 = &s.audio_tracks[0].items;
let pier_link = s.video_tracks[0].items[0].link;
assert_eq!(a1.len(), 1);
assert_eq!(a1[0].link, pier_link);
assert!(pier_link.is_some());
let theme = &s.audio_tracks[1].items[0];
assert_eq!((theme.start, theme.duration), (r.tick_of(24), r.tick_of(96)));
assert_eq!(theme.effect("volume").unwrap().param("level").unwrap().value, ParamValue::Float(-6.0));
assert!(p.item(theme.item).unwrap().as_media().unwrap().info.video.is_none());
// the boats asset has no audio: nothing on audio from it
let boats = s.video_tracks[0].items[1].item;
assert!(s.audio_tracks.iter().flat_map(|t| &t.items).all(|i| i.item != boats));
// marker on a primary element is a timeline marker
assert_eq!(s.markers.len(), 1);
assert_eq!(
(s.markers[0].name.as_str(), s.markers[0].comment.as_str(), s.markers[0].start, s.markers[0].duration),
("Seagull", "nice", r.tick_of(24), Tick::ZERO)
);
// event became a bin
assert!(p.root.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.name == "Day 1")));
assert!(!rep.has_warnings(), "{rep}");
let m = p.items.values().find(|i| i.name == "Boats").unwrap().as_media().unwrap();
assert!(matches!(&m.media, MediaRef::File { path } if path == "/proj/boats.mov"));
}
#[test]
fn hostile_lane_numbers_do_not_create_sparse_tracks() {
let d = doc().replace("lane=\"-1\"", "lane=\"-2147483648\"").replace("lane=\"1\"", "lane=\"2147483647\"");
let (imp, rep) = import(d.as_bytes(), Format::Fcpxml, Some("/proj")).unwrap();
let s = imp.project.sequence(only_seq(&imp)).unwrap();
assert!(s.video_tracks.len() <= 256);
assert!(s.audio_tracks.len() <= 256);
assert!(rep.has_warnings());
}
/// Final Cut writes slideshow stills directly as video elements, including zero-duration assets.
#[test]
fn standalone_video_stills_keep_timing_paths_and_transitions() {
let xml = r#"<fcpxml version="1.11"><resources>
<format id="f" frameDuration="1/30s" width="1920" height="1080"/>
<asset id="a" name="Slide A" start="0s" duration="0s" hasVideo="1" format="f">
<media-rep kind="original-media" src="file:///Missing/slide%20A.jpg"/>
</asset>
<asset id="b" name="Slide B" start="0s" duration="0s" hasVideo="1" format="f">
<media-rep kind="original-media" src="slide-b.jpg"/>
</asset>
</resources><project name="Slideshow"><sequence format="f" tcStart="0s"><spine>
<video ref="a" name="First slide" offset="0s" start="3600s" duration="10s">
<marker start="3601s" duration="1/30s" value="Slide marker"/>
</video>
<transition name="Cross Dissolve" offset="19/2s" duration="1s"/>
<video ref="b" name="Second slide" offset="10s" start="3600s" duration="10s"/>
</spine></sequence></project></fcpxml>"#;
let (imp, report) = import(xml.as_bytes(), Format::Fcpxml, Some("/Slides")).unwrap();
assert!(!report.has_warnings(), "{report}");
let p = &imp.project;
let seq = p.sequence(only_seq(&imp)).unwrap();
let rate = FrameRate::FPS_30;
let clips = &seq.video_tracks[0].items;
assert_eq!(clips.len(), 2);
for (i, clip) in clips.iter().enumerate() {
assert_eq!((clip.start, clip.duration, clip.source_in), (rate.tick_of(i as i64 * 300), rate.tick_of(300), rate.tick_of(108_000)));
assert!(clip.link.is_none());
assert!(matches!(p.item(clip.item).unwrap().kind, ItemKind::Media(_)));
}
assert_eq!(media_key(p, clips[0].item), "/Missing/slide A.jpg");
assert_eq!(media_key(p, clips[1].item), "/Slides/slide-b.jpg");
assert!(seq.audio_tracks.iter().all(|t| t.items.is_empty()));
let transition = &seq.video_tracks[0].transitions[0];
assert_eq!((transition.from, transition.to), (Some(clips[0].id), Some(clips[1].id)));
assert_eq!((transition.start, transition.duration), (rate.tick_of(285), rate.tick_of(30)));
assert_eq!((seq.markers[0].name.as_str(), seq.markers[0].start), ("Slide marker", rate.tick_of(30)));
}
/// A video element selects only picture from an AV asset, including on connected lanes.
#[test]
fn standalone_video_uses_only_video_and_preserves_connected_placement() {
let xml = doc().replace("<asset-clip ref=\"r2\"", "<video ref=\"r2\"");
// The first r2 occurrence has children; the connected r2 occurrence does too.
let xml = xml.replacen("</asset-clip>\n <transition", "</video>\n <transition", 1).replacen(
"</asset-clip>\n </asset-clip>",
"</video>\n </asset-clip>",
1,
);
let (imp, report) = import(xml.as_bytes(), Format::Fcpxml, Some("/proj")).unwrap();
assert!(!report.has_warnings(), "{report}");
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
let main = &seq.video_tracks[0].items[0];
let connected = &seq.video_tracks[1].items[0];
assert_eq!(main.item, connected.item);
assert_eq!(connected.start, FrameRate::FPS_23_976.tick_of(72));
assert!(main.link.is_none() && connected.link.is_none());
assert!(seq.audio_tracks.iter().flat_map(|t| &t.items).all(|i| i.item != main.item));
assert_eq!(main.effect("motion").unwrap().param("scale").unwrap().value, ParamValue::Float(150.0));
assert_eq!(connected.effect("opacity").unwrap().param("opacity").unwrap().keyframes.len(), 2);
}
/// Unsupported generators, unresolved references and alternate sources keep an actionable warning.
#[test]
fn standalone_video_invalid_sources_are_reported_without_panicking() {
for attrs in ["", "ref=\"missing\"", "ref=\"effect\"", "ref=\"audio\"", "ref=\"picture\" srcID=\"2\""] {
let xml = format!(
r#"<fcpxml version="1.11"><resources>
<effect id="effect" name="Generator" uid="example.generator"/>
<asset id="audio" name="Audio" hasAudio="1" src="audio.wav"/>
<asset id="picture" name="Picture" hasVideo="1" src="picture.jpg"/>
</resources><project name="Unsupported"><sequence><spine>
<video {attrs} name="Unsupported source" duration="1s"/>
</spine></sequence></project></fcpxml>"#
);
let (imp, report) =
std::panic::catch_unwind(|| import(xml.as_bytes(), Format::Fcpxml, None)).expect("bad video sources must not panic").unwrap();
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
assert!(seq.all_tracks().all(|t| t.items.is_empty()));
assert!(report.entries.iter().any(|e| e.message.contains("Unsupported source") && e.message.contains("skipped")), "{report}");
}
}
fn project() -> (Project, ItemId) {
let r = FrameRate::FPS_29_97;
let mut p = Project::new("Lib");
let a = media(&mut p, "/m/a.mov", true, true, r);
let b = media(&mut p, "/m/b.mov", true, false, r);
let mus = media(&mut p, "/m/mus.wav", false, true, r);
let nest = sequence(&mut p, "Nest", r, false);
clip(&mut p, nest, TrackKind::Video, 0, b, 0..60, 0);
let s = sequence(&mut p, "Cut", r, true);
p.sequence_mut(s).unwrap().start_timecode = 107_892;
let v1 = clip(&mut p, s, TrackKind::Video, 0, a, 0..90, 30);
let a1 = clip(&mut p, s, TrackKind::Audio, 0, a, 0..90, 30);
link(&mut p, s, &[v1, a1]);
let v2 = clip(&mut p, s, TrackKind::Video, 0, b, 90..90 + (60), 0);
transition(&mut p, s, (TrackKind::Video, 0), "cross_dissolve", (Some(v1), Some(v2)), 80..80 + (20), TransitionAlign::CenterAtCut);
let v3 = clip(&mut p, s, TrackKind::Video, 0, a, 200..200 + (40), 500);
transition(&mut p, s, (TrackKind::Video, 0), "dip_to_black", (None, Some(v3)), 200..200 + (10), TransitionAlign::StartAtCut);
transition(&mut p, s, (TrackKind::Video, 0), "dip_to_black", (Some(v3), None), 230..230 + (10), TransitionAlign::EndAtCut);
let up = clip(&mut p, s, TrackKind::Video, 1, nest, 30..30 + (60), 0);
clip(&mut p, s, TrackKind::Video, 2, b, 160..160 + (30), 10);
clip(&mut p, s, TrackKind::Audio, 2, mus, 0..260, 0);
{
let q = p.sequence_mut(s).unwrap();
let (_, it) = q.find_item_mut(v2).unwrap();
it.speed = 2.0;
let mut sc = Param::new(ParamValue::Float(100.0));
sc.keyframes.push(Keyframe::new(r.tick_of(0), ParamValue::Float(100.0)));
sc.keyframes.push(Keyframe::new(r.tick_of(60), ParamValue::Float(120.0)));
it.effect_mut("motion").unwrap().params.insert("scale".into(), sc);
it.effect_mut("motion").unwrap().params.insert("rotation".into(), Param::new(ParamValue::Float(15.0)));
let (_, u) = q.find_item_mut(up).unwrap();
u.enabled = false;
u.effect_mut("opacity").unwrap().params.insert("opacity".into(), Param::new(ParamValue::Float(40.0)));
u.markers.push(Marker {
url: String::new(),
author: String::new(),
ext: None,
id: MarkerId(77),
start: r.tick_of(10),
duration: Tick::ZERO,
name: "in nest".into(),
comment: String::new(),
kind: MarkerKind::Comment,
color: Label::Blue,
});
q.markers.push(Marker {
url: String::new(),
author: String::new(),
ext: None,
id: MarkerId(78),
start: r.tick_of(100),
duration: r.tick_of(5),
name: "chapter".into(),
comment: "c".into(),
kind: MarkerKind::Chapter,
color: Label::Mango,
});
let (_, ai) = q.find_item_mut(a1).unwrap();
ai.effect_mut("volume").unwrap().params.insert("level".into(), Param::new(ParamValue::Float(-3.5)));
}
(p, s)
}
#[test]
fn roundtrip_all_versions() {
for v in [FcpxmlVersion::V1_9, FcpxmlVersion::V1_10, FcpxmlVersion::V1_11] {
let (p, s) = project();
let o = ExportOptions { fcpxml_version: v, ..xopts() };
let (imp, text, rep) = roundtrip(&p, s, Format::Fcpxml, &o);
assert!(text.contains(&format!("<fcpxml version=\"{}\">", v.as_str())));
assert!(text.contains("tcFormat=\"DF\""));
assert!(!rep.has_warnings(), "{rep}");
let si = only_seq(&imp);
let q = &imp.project;
for kind in [TrackKind::Video, TrackKind::Audio] {
assert_eq!(structure(q, si, kind), structure(&p, s, kind), "{text}");
}
assert_eq!(links(q, si), links(&p, s));
let qs = q.sequence(si).unwrap();
let ps = p.sequence(s).unwrap();
assert_eq!(qs.start_timecode, 107_892);
assert_eq!(
qs.markers.iter().map(|m| (&m.name, m.start, m.duration, m.kind)).collect::<Vec<_>>(),
ps.markers.iter().map(|m| (&m.name, m.start, m.duration, m.kind)).collect::<Vec<_>>()
);
let pv = &ps.video_tracks[0].items[1];
let qv = &qs.video_tracks[0].items[1];
assert_eq!(qv.effect("motion").unwrap().param("scale"), pv.effect("motion").unwrap().param("scale"), "{text}");
assert_eq!(qv.effect("motion").unwrap().param("rotation").unwrap().value, ParamValue::Float(15.0));
let up = &qs.video_tracks[1].items[0];
assert_eq!(up.effect("opacity").unwrap().param("opacity").unwrap().value, ParamValue::Float(40.0));
assert_eq!(
up.markers.iter().map(|m| (&m.name, m.start)).collect::<Vec<_>>(),
vec![(&"in nest".to_string(), FrameRate::FPS_29_97.tick_of(10))]
);
let lvl = qs.audio_tracks[0].items[0].effect("volume").unwrap().param("level").unwrap().value.as_f64().unwrap();
assert!((lvl + 3.5).abs() < 1e-9);
}
}
#[derive(Clone, Debug)]
struct G {
track: usize,
gap: i64,
dur: i64,
media: usize,
src: i64,
speed: u8,
enabled: bool,
tr: u8,
tr_len: i64,
audio: u8,
}
fn gen_clip() -> impl Strategy<Value = G> {
(0usize..3, 0i64..3, 20i64..150, 0usize..3, 0i64..2000, 0u8..5, prop::bool::weighted(0.9), 0u8..6, 1i64..9, 0u8..3).prop_map(
|(track, gap, dur, media, src, speed, enabled, tr, tr_len, audio)| G {
track,
gap: gap * 5,
dur,
media,
src,
speed,
enabled,
tr,
tr_len,
audio,
},
)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(48))]
#[test]
fn fcpxml_roundtrip(clips in prop::collection::vec(gen_clip(), 1..20), ri in 0usize..5) {
let rate = [FrameRate::FPS_23_976, FrameRate::FPS_25, FrameRate::FPS_29_97, FrameRate::FPS_59_94, FrameRate::FPS_24][ri];
let mut p = Project::new("P");
let ms = [media(&mut p, "/x/one.mov", true, true, rate), media(&mut p, "/x/two.mov", true, true, rate), media(&mut p, "/y/three four.mxf", true, true, rate)];
let wav = media(&mut p, "/y/vo.wav", false, true, rate);
let s = sequence(&mut p, "S", rate, false);
let mut ends = [0i64; 3];
let mut prev: [Option<ClipId>; 3] = [None; 3];
let mut aends = [0i64; 3];
for g in &clips {
let t = ends[g.track] + g.gap;
let v = clip(&mut p, s, TrackKind::Video, g.track, ms[g.media], t..t + (g.dur), g.src);
{
let q = p.sequence_mut(s).unwrap();
let (_, it) = q.find_item_mut(v).unwrap();
it.enabled = g.enabled;
match g.speed {
1 => it.speed = 2.0,
2 => it.speed = 0.5,
3 => it.reverse = true,
_ => {}
}
}
let d = g.tr_len;
let adjacent = g.gap == 0 && prev[g.track].is_some();
if g.track == 0 {
match g.tr {
0 if adjacent && d >= 2 => transition(&mut p, s, (TrackKind::Video, 0), "cross_dissolve", (prev[0], Some(v)), t - d / 2..t - d / 2 + (d), TransitionAlign::CenterAtCut),
1 if adjacent => transition(&mut p, s, (TrackKind::Video, 0), "dip_to_white", (prev[0], Some(v)), t..t + (d), TransitionAlign::StartAtCut),
2 if adjacent => transition(&mut p, s, (TrackKind::Video, 0), "push", (prev[0], Some(v)), t - d..t - d + (d), TransitionAlign::EndAtCut),
3 if !adjacent => transition(&mut p, s, (TrackKind::Video, 0), "cross_dissolve", (None, Some(v)), t..t + (d), TransitionAlign::StartAtCut),
_ => {}
}
}
if aends[g.track] <= t {
match g.audio {
// linked, same-numbered audio track, identical timing
1 => {
let a = clip(&mut p, s, TrackKind::Audio, g.track, ms[g.media], t..t + (g.dur), g.src);
{
let q = p.sequence_mut(s).unwrap();
let (sp, rv, en) = { let (_, it) = q.find_item(v).unwrap(); (it.speed, it.reverse, it.enabled) };
let (_, ai) = q.find_item_mut(a).unwrap();
ai.speed = sp;
ai.reverse = rv;
ai.enabled = en;
}
link(&mut p, s, &[v, a]);
aends[g.track] = t + g.dur;
}
// independent audio-only clip
2 => {
clip(&mut p, s, TrackKind::Audio, g.track, wav, t..t + (g.dur), g.src);
aends[g.track] = t + g.dur;
}
_ => {}
}
}
ends[g.track] = t + g.dur;
prev[g.track] = Some(v);
}
let (imp, text, _) = roundtrip(&p, s, Format::Fcpxml, &xopts());
let si = only_seq(&imp);
prop_assert_eq!(structure(&imp.project, si, TrackKind::Video), structure(&p, s, TrackKind::Video), "{}", text);
prop_assert_eq!(structure(&imp.project, si, TrackKind::Audio), structure(&p, s, TrackKind::Audio), "{}", text);
prop_assert_eq!(links(&imp.project, si), links(&p, s));
}
}
/// filmcraft#2195: a relative `src` is a URL like an absolute one: `boats%20b.mov` is the file `boats b.mov`.
#[test]
fn relative_media_urls_are_percent_decoded() {
let d = doc().replace(r#"src="boats.mov""#, r#"src="media/boats%20b.mov""#);
let (imp, _) = import(d.as_bytes(), Format::Fcpxml, Some("/proj")).unwrap();
let p = &imp.project;
let boats = &p.sequence(only_seq(&imp)).unwrap().video_tracks[0].items[1];
assert_eq!(media_key(p, boats.item), "/proj/media/boats b.mov");
}