Files
craft-libs/crates/timeline/tests/it/fcp7.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

767 lines
39 KiB
Rust

//! Final Cut Pro 7 XML (xmeml): a hand-written Premiere-style document (written from the public
//! xmeml element descriptions) and export → import round trips.
use crate::support;
use craft_timeline::model::{
BinEntry, ClipId, Generator, ItemId, ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, Sequence,
TrackKind, TransitionAlign,
};
use craft_timeline::time::{FrameRate, Tick};
use craft_timeline::{ExportOptions, Format, Imported, Report, detect_text};
use proptest::prelude::*;
use support::*;
const RATE: &str = "<rate><timebase>24</timebase><ntsc>TRUE</ntsc></rate>";
fn premiere_style_doc() -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xmeml>
<xmeml version="4">
<project>
<name>Lighthouse</name>
<children>
<bin>
<name>Footage</name>
<children>
<clip id="masterclip-1">
<name>tower.mov</name>
<duration>2400</duration>
{RATE}
<labels><label2>Mango</label2></labels>
<media><video><track><clipitem id="clipitem-m1"><name>tower.mov</name>
<file id="file-1">
<name>tower.mov</name>
<pathurl>file://localhost/Volumes/Shoot/tower%20wide.mov</pathurl>
{RATE}
<duration>2400</duration>
<timecode>{RATE}<string>01:00:00:00</string><frame>86400</frame><displayformat>NDF</displayformat></timecode>
<media>
<video><samplecharacteristics>{RATE}<width>3840</width><height>2160</height></samplecharacteristics></video>
<audio><samplecharacteristics><depth>24</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>
</media>
</file>
</clipitem></track></video></media>
</clip>
</children>
</bin>
<sequence id="sequence-1">
<name>Main Edit</name>
<duration>130</duration>
{RATE}
<timecode>{RATE}<string>01:00:00:00</string><frame>86400</frame><displayformat>NDF</displayformat></timecode>
<media>
<video>
<format><samplecharacteristics>{RATE}<width>1920</width><height>1080</height><pixelaspectratio>square</pixelaspectratio></samplecharacteristics></format>
<track>
<clipitem id="clipitem-1">
<masterclipid>masterclip-1</masterclipid>
<name>tower.mov</name>
<enabled>TRUE</enabled>
{RATE}
<start>0</start><end>-1</end><in>0</in><out>48</out>
<file id="file-1"/>
<link><linkclipref>clipitem-1</linkclipref><mediatype>video</mediatype><trackindex>1</trackindex><clipindex>1</clipindex></link>
<link><linkclipref>clipitem-3</linkclipref><mediatype>audio</mediatype><trackindex>1</trackindex><clipindex>1</clipindex></link>
<labels><label2>Cerulean</label2></labels>
</clipitem>
<transitionitem>
{RATE}
<start>36</start><end>60</end><alignment>center</alignment>
<effect><name>Cross Dissolve</name><effectid>Cross Dissolve</effectid><effectcategory>Dissolve</effectcategory><effecttype>transition</effecttype><mediatype>video</mediatype></effect>
</transitionitem>
<clipitem id="clipitem-2">
<name>waves</name>
{RATE}
<start>-1</start><end>120</end><in>100</in><out>172</out>
<file id="file-2">
<name>waves.mov</name>
<pathurl>media/waves.mov</pathurl>
{RATE}
<duration>1000</duration>
<media><video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video></media>
</file>
<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><valuemin>0</valuemin><valuemax>1000</valuemax><value>100</value>
<keyframe><when>100</when><value>100</value></keyframe>
<keyframe><when>160</when><value>150</value></keyframe>
</parameter>
<parameter><parameterid>rotation</parameterid><name>Rotation</name><value>-12.5</value></parameter>
<parameter><parameterid>center</parameterid><name>Center</name><value><horiz>0.25</horiz><vert>-0.1</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><valuemin>0</valuemin><valuemax>100</valuemax><value>50</value></parameter>
</effect></filter>
<filter><effect>
<name>Gaussian Blur</name><effectid>GaussianBlur</effectid><effecttype>filter</effecttype><mediatype>video</mediatype>
</effect></filter>
<filter><effect>
<name>Mystery Glow Pro</name><effectid>mystery</effectid><effecttype>filter</effecttype><mediatype>video</mediatype>
</effect></filter>
<marker><name>wave peak</name><comment>use this</comment><in>130</in><out>-1</out></marker>
</clipitem>
</track>
<track>
<clipitem id="clipitem-4">
<name>Titles Nest</name>
{RATE}
<start>24</start><end>72</end><in>0</in><out>48</out>
<sequence id="sequence-2"/>
</clipitem>
<generatoritem id="clipitem-7">
<name>Red Matte</name>
{RATE}
<start>100</start><end>130</end><in>0</in><out>30</out>
<effect><name>Color</name><effectid>Color</effectid><effectcategory>Matte</effectcategory><effecttype>generator</effecttype><mediatype>video</mediatype>
<parameter><parameterid>fillcolor</parameterid><name>Color</name><value><alpha>255</alpha><red>255</red><green>0</green><blue>0</blue></value></parameter>
</effect>
</generatoritem>
<enabled>TRUE</enabled>
<locked>TRUE</locked>
</track>
</video>
<audio>
<track>
<clipitem id="clipitem-3">
<name>tower.mov</name>
{RATE}
<start>0</start><end>48</end><in>0</in><out>48</out>
<file id="file-1"/>
<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>
<filter><effect>
<name>Audio Levels</name><effectid>audiolevels</effectid><effectcategory>audiolevels</effectcategory><effecttype>audiolevels</effecttype><mediatype>audio</mediatype>
<parameter><parameterid>level</parameterid><name>Level</name><valuemin>0</valuemin><valuemax>3.98109</valuemax><value>0.5</value>
<keyframe><when>0</when><value>1</value></keyframe>
<keyframe><when>24</when><value>0.5</value></keyframe>
</parameter>
</effect></filter>
<link><linkclipref>clipitem-1</linkclipref><mediatype>video</mediatype></link>
<link><linkclipref>clipitem-3</linkclipref><mediatype>audio</mediatype></link>
</clipitem>
</track>
<track>
<clipitem id="clipitem-5">
<name>music.wav</name>
<enabled>FALSE</enabled>
{RATE}
<start>0</start><end>120</end><in>0</in><out>120</out>
<file id="file-3"><name>music.wav</name><pathurl>file:///Volumes/Audio/music.wav</pathurl>
<media><audio><samplecharacteristics><samplerate>44100</samplerate></samplecharacteristics><channelcount>2</channelcount></audio></media>
</file>
</clipitem>
</track>
</audio>
</media>
<marker><name>Check sky</name><comment></comment><in>30</in><out>-1</out></marker>
</sequence>
<sequence id="sequence-2">
<name>Titles Nest</name>
{RATE}
<media>
<video>
<track>
<clipitem id="clipitem-6">
<name>waves fast</name>
{RATE}
<start>0</start><end>48</end><in>0</in><out>96</out>
<file id="file-2"/>
<filter><effect><name>Time Remap</name><effectid>timeremap</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>
<parameter><parameterid>speed</parameterid><name>speed</name><value>200</value></parameter>
<parameter><parameterid>reverse</parameterid><name>reverse</name><value>FALSE</value></parameter>
</effect></filter>
</clipitem>
</track>
</video>
</media>
</sequence>
</children>
</project>
</xmeml>
"#
)
}
#[test]
fn golden_premiere_style_nested_and_transitions() {
let doc = premiere_style_doc();
assert_eq!(detect_text(doc.as_bytes(), Some("xml")), Some(Format::Fcp7Xml));
let (imp, rep) = fcp7_import(&doc, Some("/proj"));
let p = &imp.project;
assert_eq!(p.name, "Lighthouse");
assert_eq!(imp.sequences.len(), 2);
let main = imp.sequences.iter().copied().find(|s| p.item(*s).unwrap().name == "Main Edit").unwrap();
let nest = imp.sequences.iter().copied().find(|s| p.item(*s).unwrap().name == "Titles Nest").unwrap();
let s = p.sequence(main).unwrap();
let r = FrameRate::FPS_23_976;
assert_eq!(s.settings.frame_rate, r);
assert_eq!(s.start_timecode, 86_400);
assert_master_clip(p, r);
assert_main_video_and_effects(p, s, r, &rep);
assert_nested_and_audio(p, s, r, nest);
}
fn assert_master_clip(p: &Project, r: FrameRate) {
// Master clip in the Footage bin, with its file characteristics.
let tower = p.items.values().find(|i| i.name == "tower.mov").unwrap();
let tm = tower.as_media().unwrap();
assert_eq!(tm.media, MediaRef::File { path: "/Volumes/Shoot/tower wide.mov".into() });
assert_eq!(tm.info.video.as_ref().unwrap().width, 3840);
assert_eq!(tm.info.start_timecode, Some(86_400));
assert_eq!(tm.info.duration, r.tick_of(2400));
assert_eq!(tower.label, Label::Mango);
let bin = p.root.children.iter().find_map(|c| if let BinEntry::Bin(b) = c { Some(b) } else { None }).unwrap();
assert_eq!(bin.name, "Footage");
assert_eq!(p.root.parent_of(tower.id), Some(bin.id));
}
fn assert_main_video_and_effects(p: &Project, s: &Sequence, r: FrameRate, rep: &Report) {
// V1: -1 start/end resolved from in/out, centred dissolve.
let v1 = &s.video_tracks[0];
assert_eq!(v1.items.len(), 2);
let (c1, c2) = (&v1.items[0], &v1.items[1]);
assert_eq!((c1.start, c1.duration), (r.tick_of(0), r.tick_of(48)));
assert_eq!((c2.start, c2.duration, c2.source_in), (r.tick_of(48), r.tick_of(72), r.tick_of(100)));
assert_eq!(c1.label, Label::Cerulean);
assert_eq!(media_key(p, c2.item), "/proj/media/waves.mov");
let t = &v1.transitions[0];
assert_eq!((t.start, t.duration, t.from, t.to, t.align), (r.tick_of(36), r.tick_of(24), Some(c1.id), Some(c2.id), TransitionAlign::CenterAtCut));
assert_eq!(t.effect.effect, "cross_dissolve");
// Motion / opacity / generic effect by name.
let scale = c2.effect("motion").unwrap().param("scale").unwrap();
assert_eq!(scale.keyframes.len(), 2);
assert_eq!(scale.keyframes[1].time, r.tick_of(160));
assert_eq!(scale.keyframes[1].value, ParamValue::Float(150.0));
assert_eq!(c2.effect("motion").unwrap().param("rotation").unwrap().value, ParamValue::Float(-12.5));
let pos = c2.effect("motion").unwrap().param("position").unwrap().value.as_vec2().unwrap();
assert!((pos.x - (960.0 + 480.0)).abs() < 1e-9 && (pos.y - (540.0 - 108.0)).abs() < 1e-9, "{pos:?}");
assert_eq!(c2.effect("opacity").unwrap().param("opacity").unwrap().value, ParamValue::Float(50.0));
assert!(c2.effect("gaussian_blur").is_some());
assert!(rep.mentions("Mystery Glow Pro"), "{rep}");
assert_eq!(c2.markers.len(), 1);
assert_eq!((c2.markers[0].name.as_str(), c2.markers[0].start), ("wave peak", r.tick_of(130)));
}
fn assert_nested_and_audio(p: &Project, s: &Sequence, r: FrameRate, nest: ItemId) {
// V2: nested sequence clip, colour matte generator, locked track.
let v2 = &s.video_tracks[1];
assert!(v2.locked);
assert_eq!(v2.items[0].item, nest);
assert!(matches!(p.item(v2.items[0].item).unwrap().kind, ItemKind::Sequence(_)));
let g = p.item(v2.items[1].item).unwrap().as_media().unwrap();
assert_eq!(g.media, MediaRef::Generator(Generator::ColorMatte { color: [1.0, 0.0, 0.0, 1.0] }));
let ns = p.sequence(nest).unwrap();
assert_eq!(ns.video_tracks[0].items[0].speed, 2.0);
assert_eq!(ns.video_tracks[0].items[0].item, s.video_tracks[0].items[1].item, "file-2 reference shared across sequences");
// Audio: levels as dB with keyframes, link to the video clip, disabled music.
let a1 = &s.audio_tracks[0].items[0];
let lvl = a1.effect("volume").unwrap().param("level").unwrap();
assert!((lvl.keyframes[1].value.as_f64().unwrap() + 6.0206).abs() < 1e-3);
assert_eq!(lvl.keyframes[0].value, ParamValue::Float(0.0));
assert!(a1.link.is_some());
assert_eq!(a1.link, s.video_tracks[0].items[0].link);
let music = &s.audio_tracks[1].items[0];
assert!(!music.enabled);
assert!(p.item(music.item).unwrap().as_media().unwrap().info.video.is_none());
assert_eq!(s.markers[0].name, "Check sky");
assert_eq!(s.markers[0].start, r.tick_of(30));
}
fn rich_project() -> (Project, ItemId) {
let r = FrameRate::FPS_29_97;
let mut p = Project::new("P");
let a = media(&mut p, "/m/a b.mov", true, true, r);
let b = media(&mut p, "/m/b.mov", true, true, r);
let m = media(&mut p, "/m/music.wav", false, true, r);
let nest = sequence(&mut p, "Nested", r, true);
clip(&mut p, nest, TrackKind::Video, 0, b, 0..90, 30);
let s = sequence(&mut p, "Main", r, true);
p.sequence_mut(s).unwrap().start_timecode = 107_892;
let v1 = clip(&mut p, s, TrackKind::Video, 0, a, 0..60, 10);
let a1 = clip(&mut p, s, TrackKind::Audio, 0, a, 0..60, 10);
link(&mut p, s, &[v1, a1]);
let v2 = clip(&mut p, s, TrackKind::Video, 0, b, 60..60 + (45), 300);
transition(&mut p, s, (TrackKind::Video, 0), "dip_to_black", (Some(v1), Some(v2)), 50..50 + (20), TransitionAlign::CenterAtCut);
transition(&mut p, s, (TrackKind::Video, 0), "cross_dissolve", (Some(v2), None), 90..90 + (15), TransitionAlign::EndAtCut);
let n = clip(&mut p, s, TrackKind::Video, 1, nest, 20..20 + (40), 0);
let mm = clip(&mut p, s, TrackKind::Audio, 1, m, 0..105, 0);
transition(&mut p, s, (TrackKind::Audio, 1), "constant_power", (None, Some(mm)), 0..10, TransitionAlign::StartAtCut);
{
let q = p.sequence_mut(s).unwrap();
let (_, it) = q.find_item_mut(v2).unwrap();
it.speed = 1.5;
it.label = Label::Rose;
let mut sc = Param::new(ParamValue::Float(100.0));
sc.keyframes.push(Keyframe::new(r.tick_of(300), ParamValue::Float(100.0)));
sc.keyframes.push(Keyframe::new(r.tick_of(330), ParamValue::Float(80.0)));
it.effect_mut("motion").unwrap().params.insert("scale".into(), sc);
it.effect_mut("opacity").unwrap().params.insert("opacity".into(), Param::new(ParamValue::Float(75.0)));
it.effects.push(instance("gaussian_blur"));
it.markers.push(Marker {
url: String::new(),
author: String::new(),
ext: None,
id: MarkerId(900),
start: r.tick_of(310),
duration: r.tick_of(5),
name: "hit".into(),
comment: "c".into(),
kind: MarkerKind::Comment,
color: Label::Yellow,
});
let (_, ni) = q.find_item_mut(n).unwrap();
ni.enabled = false;
ni.reverse = true;
let (_, ai) = q.find_item_mut(a1).unwrap();
ai.effect_mut("volume").unwrap().params.insert("level".into(), Param::new(ParamValue::Float(-6.0)));
q.markers.push(Marker {
url: String::new(),
author: String::new(),
ext: None,
id: MarkerId(901),
start: r.tick_of(12),
duration: Tick::ZERO,
name: "seq m".into(),
comment: String::new(),
kind: MarkerKind::Comment,
color: Label::Blue,
});
q.video_tracks[1].locked = true;
}
(p, s)
}
#[test]
fn rich_roundtrip() {
let (p, s) = rich_project();
let (imp, text, rep) = fcp7_roundtrip(&p, s, &xopts());
assert!(text.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE xmeml>\n<xmeml version=\"4\">"));
assert!(text.contains("<pathurl>file://localhost/m/a%20b.mov</pathurl>"), "{text}");
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, ps.start_timecode);
assert!(qs.settings.drop_frame);
assert_eq!(
qs.markers.iter().map(|m| (&m.name, m.start, m.color)).collect::<Vec<_>>(),
ps.markers.iter().map(|m| (&m.name, m.start, m.color)).collect::<Vec<_>>()
);
assert!(qs.video_tracks[1].locked);
let v2 = &qs.video_tracks[0].items[1];
let pv2 = &ps.video_tracks[0].items[1];
assert_eq!(v2.label, Label::Rose);
assert_eq!(v2.effect("motion").unwrap().param("scale"), pv2.effect("motion").unwrap().param("scale"));
assert_eq!(v2.effect("opacity").unwrap().param("opacity").unwrap().value, ParamValue::Float(75.0));
assert!(v2.effect("gaussian_blur").is_some());
assert_eq!(
v2.markers.iter().map(|m| (&m.name, m.start, m.duration)).collect::<Vec<_>>(),
pv2.markers.iter().map(|m| (&m.name, m.start, m.duration)).collect::<Vec<_>>()
);
let lvl = qs.audio_tracks[0].items[0].effect("volume").unwrap().param("level").unwrap().value.as_f64().unwrap();
assert!((lvl + 6.0).abs() < 1e-9);
// nested sequence survived as a sequence item with its own clip
let nested = qs.video_tracks[1].items[0].item;
assert_eq!(q.item(nested).unwrap().name, "Nested");
assert_eq!(q.sequence(nested).unwrap().video_tracks[0].items[0].source_in, FrameRate::FPS_29_97.tick_of(30));
// Re-export is stable.
let (again, _) = fcp7_export(q, si, &xopts());
let (imp2, _) = fcp7_import(&again, None);
assert_eq!(structure(&imp2.project, imp2.sequences[0], TrackKind::Video), structure(&p, s, TrackKind::Video));
}
/// filmcraft#1868: a filter is written by the catalog's stable name, which other applications read back.
#[test]
fn grading_keeps_its_interchange_name() {
let (mut p, s) = rich_project();
let first = p.sequence(s).unwrap().video_tracks[0].items[0].id;
let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(first).unwrap();
it.effects.push(instance("lumetri"));
let (fcp7, _) = fcp7_export(&p, s, &xopts());
assert!(fcp7.contains("<name>Lumetri Color</name>"), "{fcp7}");
assert!(fcp7.contains("<effectcategory>Color Correction</effectcategory>"), "{fcp7}");
let (fcpxml, _) = export(&p, s, Format::Fcpxml, &xopts()).unwrap();
let fcpxml = String::from_utf8(fcpxml).unwrap();
assert!(fcpxml.contains("name=\"Lumetri Color\""), "{fcpxml}");
assert!(fcpxml.contains("uid=\"testapp.lumetri\""), "{fcpxml}");
}
#[test]
fn version_5_and_generators() {
let (mut p, s) = rich_project();
let g = generator(&mut p, "Bars", Generator::BarsAndTone);
let k = generator(&mut p, "Black", Generator::BlackVideo);
clip(&mut p, s, TrackKind::Video, 2, g, 0..30, 0);
clip(&mut p, s, TrackKind::Video, 2, k, 30..30 + (30), 0);
let o = ExportOptions { xmeml_version: 5, ..xopts() };
let (imp, text, _) = fcp7_roundtrip(&p, s, &o);
assert!(text.contains("<xmeml version=\"5\">"));
assert!(text.contains("<effectid>slug</effectid>"));
let si = only_seq(&imp);
assert_eq!(structure(&imp.project, si, TrackKind::Video), structure(&p, s, TrackKind::Video));
}
// ---------------------------------------------------------------------------------------------
// Property round trip over multi-track timelines with transitions, speed, links, enable flags.
// ---------------------------------------------------------------------------------------------
#[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: bool,
}
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, any::<bool>()).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 xmeml_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 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();
match g.tr {
0 if adjacent => transition(&mut p, s, (TrackKind::Video, g.track), "cross_dissolve", (prev[g.track], Some(v)), t - d / 2..t - d / 2 + (d), TransitionAlign::CenterAtCut),
1 if adjacent => transition(&mut p, s, (TrackKind::Video, g.track), "dip_to_white", (prev[g.track], Some(v)), t..t + (d), TransitionAlign::StartAtCut),
2 if adjacent => transition(&mut p, s, (TrackKind::Video, g.track), "push", (prev[g.track], Some(v)), t - d..t - d + (d), TransitionAlign::EndAtCut),
3 if !adjacent => transition(&mut p, s, (TrackKind::Video, g.track), "cross_dissolve", (None, Some(v)), t..t + (d), TransitionAlign::StartAtCut),
_ => {}
}
if g.audio && aends[g.track] <= t {
let a = clip(&mut p, s, TrackKind::Audio, g.track, ms[g.media], t..t + (g.dur), g.src);
link(&mut p, s, &[v, a]);
aends[g.track] = t + g.dur;
}
ends[g.track] = t + g.dur;
prev[g.track] = Some(v);
}
let (imp, text, _) = fcp7_roundtrip(&p, s, &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));
prop_assert_eq!(links(&imp.project, si), links(&p, s));
}
}
#[test]
fn a_master_clip_used_by_an_earlier_sequence_still_lands_in_its_bin() {
// Premiere and other writers may put a stringout sequence ahead of the bins that hold its clips.
let file = format!(
r#"<file id="file-1"><name>take.mov</name><pathurl>file://localhost/media/take.mov</pathurl>{RATE}<duration>120</duration><media><video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video></media></file>"#
);
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xmeml>
<xmeml version="4"><project><name>Order</name><children>
<bin><name>Stringouts</name><children>
<sequence id="sequence-1"><name>All takes</name><duration>120</duration>{RATE}
<media><video><track><clipitem id="clipitem-1"><masterclipid>masterclip-1</masterclipid><name>take</name>{RATE}
<start>0</start><end>120</end><in>0</in><out>120</out>{file}</clipitem></track></video></media>
</sequence>
</children></bin>
<bin><name>Footage</name><children><bin><name>Comedy</name><children>
<clip id="masterclip-1"><name>take</name><duration>120</duration>{RATE}
<media><video><track><clipitem id="clipitem-2"><masterclipid>masterclip-1</masterclipid><name>take</name>{RATE}<file id="file-1"/></clipitem></track></video></media>
</clip>
</children></bin></children></bin>
</children></project></xmeml>"#
);
let (imp, _) = fcp7_import(&doc, None);
let p = &imp.project;
let mut items = Vec::new();
p.root.all_items(&mut items);
let take = items.iter().copied().find(|i| p.item(*i).unwrap().name == "take.mov").unwrap();
assert_eq!(items.iter().filter(|i| **i == take).count(), 1);
let bin = p.root.parent_of(take).unwrap();
assert_ne!(bin, p.root.id, "the take was left loose at the top");
assert_eq!(p.root.find_bin(bin).unwrap().name, "Comedy");
}
/// `start`/`end`/`in`/`out` of every sequence clip item in an exported document.
fn clip_spans(xml: &str) -> Vec<(i64, i64, i64, i64)> {
let tag = |block: &str, name: &str| -> i64 {
let open = format!("<{name}>");
let at = block.find(&open).unwrap_or_else(|| panic!("no <{name}> in {block}")) + open.len();
block[at..].split('<').next().unwrap().trim().parse().unwrap()
};
xml.split("<clipitem").skip(1).filter(|b| b.contains("<start>")).map(|b| (tag(b, "start"), tag(b, "end"), tag(b, "in"), tag(b, "out"))).collect()
}
/// filmcraft#340: a clip placed by seconds in a 23.976 sequence has sub-frame start, duration and source in.
/// Its record span and source span were rounded to frames independently, so `out - in` could come
/// out a frame shorter than `end - start` (Premiere then reads a different source range).
#[test]
fn sub_frame_clips_keep_equal_source_and_record_spans() {
const SECOND: i64 = 254_016_000_000;
let r = FrameRate::FPS_23_976;
let mut p = Project::new("P");
let a = media(&mut p, "/m/a.mov", true, true, r);
let s = sequence(&mut p, "t", r, false);
let c = clip(&mut p, s, TrackKind::Video, 0, a, 0..24, 0);
// the issue's case first (start 0, source in 20 s, 3 s long), then starts, ins and lengths in tenths of a second
let mut cases = vec![(0, 200, 30)];
for start in [0, 1, 5, 17, 33] {
for src in [0, 3, 7, 200, 413] {
for dur in [1, 4, 30, 71, 125] {
cases.push((start, src, dur));
}
}
}
for (start, src, dur) in cases {
let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(c).unwrap();
it.start = Tick(start * SECOND / 10);
it.source_in = Tick(src * SECOND / 10);
it.duration = Tick(dur * SECOND / 10);
let (xml, _) = fcp7_export(&p, s, &xopts());
let spans = clip_spans(&xml);
assert_eq!(spans.len(), 1, "{xml}");
let (start_f, end_f, in_f, out_f) = spans[0];
assert_eq!(out_f - in_f, end_f - start_f, "start {start}, in {src}, duration {dur} (tenths of a second): {spans:?}");
}
}
/// filmcraft#463: a stereo file split onto two mono tracks (A1 = channel 1, A2 = channel 2) lost the right channel,
/// because every audio clip item was written with `sourcetrack/trackindex` 1 and the importer ignored it.
#[test]
fn split_mono_tracks_keep_their_source_channel() {
let r = FrameRate::FPS_23_976;
let mut p = Project::new("P");
let a = media(&mut p, "/m/a.wav", false, true, r);
let s = sequence(&mut p, "t", r, false);
let left = clip(&mut p, s, TrackKind::Audio, 0, a, 0..24, 0);
let right = clip(&mut p, s, TrackKind::Audio, 1, a, 0..24, 0);
for (id, ch) in [(left, 0u16), (right, 1u16)] {
let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(id).unwrap();
it.source_channels = vec![ch];
}
let (imp, xml, _) = fcp7_roundtrip(&p, s, &xopts());
assert!(xml.contains("<trackindex>2</trackindex>"), "{xml}");
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
let channels = |t: usize| seq.tracks(TrackKind::Audio)[t].items.iter().map(|i| i.source_channels.clone()).collect::<Vec<_>>();
assert_eq!(channels(0), vec![vec![0u16]], "{xml}");
assert_eq!(channels(1), vec![vec![1u16]], "{xml}");
let (again, _) = fcp7_export(&imp.project, only_seq(&imp), &xopts());
assert!(again.contains("<trackindex>2</trackindex>"));
}
#[test]
fn audio_sourcetrack_trackindex_2_imports_as_source_channel_1() {
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xmeml>
<xmeml version="4"><sequence id="sequence-1"><name>S</name><duration>48</duration>{RATE}
<media><audio><track><clipitem id="clipitem-1"><name>take</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
<file id="file-1"><name>take.wav</name><pathurl>file://localhost/media/take.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
<sourcetrack><mediatype>audio</mediatype><trackindex>2</trackindex></sourcetrack>
</clipitem></track></audio></media>
</sequence></xmeml>"#
);
let (imp, _) = fcp7_import(&doc, None);
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
assert_eq!(seq.tracks(TrackKind::Audio)[0].items[0].source_channels, vec![1u16]);
}
/// filmcraft#463: Premiere writes a stereo file split onto two mono tracks as clip items with `trackindex` 1 and 2;
/// a file used only with `trackindex` 1 is a stereo clip and keeps the default (all channels) mapping.
#[test]
fn audio_trackindex_1_is_channel_0_only_when_the_file_is_split() {
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xmeml>
<xmeml version="4"><sequence id="sequence-1"><name>S</name><duration>48</duration>{RATE}
<media><audio>
<track><clipitem id="clipitem-1"><name>take</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
<file id="file-1"><name>take.wav</name><pathurl>file://localhost/media/take.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>
</clipitem>
<clipitem id="clipitem-3"><name>other</name>{RATE}<start>48</start><end>96</end><in>0</in><out>48</out>
<file id="file-2"><name>other.wav</name><pathurl>file://localhost/media/other.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>
</clipitem></track>
<track><clipitem id="clipitem-2"><name>take</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
<file id="file-1"/>
<sourcetrack><mediatype>audio</mediatype><trackindex>2</trackindex></sourcetrack>
</clipitem></track>
</audio></media>
</sequence></xmeml>"#
);
let (imp, _) = fcp7_import(&doc, None);
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
let a1 = &seq.tracks(TrackKind::Audio)[0].items;
assert_eq!(a1[0].source_channels, vec![0u16]);
assert!(a1[1].source_channels.is_empty(), "{:?}", a1[1].source_channels);
assert_eq!(seq.tracks(TrackKind::Audio)[1].items[0].source_channels, vec![1u16]);
}
/// A Premiere-style stereo file split onto two mono tracks (A1 = `trackindex` 1, A2 = 2).
fn split_stereo_doc(pan_a1: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xmeml>
<xmeml version="4"><sequence id="sequence-1"><name>S</name><duration>48</duration>{RATE}
<media><audio>
<track currentExplodedTrackIndex="0" totalExplodedTrackCount="2" premiereTrackType="Stereo"><clipitem id="clipitem-1"><name>sfx</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
<file id="file-1"><name>sfx.wav</name><pathurl>file://localhost/media/sfx.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>{pan_a1}
</clipitem><outputchannelindex>1</outputchannelindex></track>
<track currentExplodedTrackIndex="1" totalExplodedTrackCount="2" premiereTrackType="Stereo"><clipitem id="clipitem-2"><name>sfx</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
<file id="file-1"/>
<sourcetrack><mediatype>audio</mediatype><trackindex>2</trackindex></sourcetrack>
</clipitem><outputchannelindex>2</outputchannelindex></track>
</audio></media>
</sequence></xmeml>"#
)
}
fn balances(imp: &Imported) -> Vec<f64> {
let seq = imp.project.sequence(only_seq(imp)).unwrap();
seq.tracks(TrackKind::Audio)
.iter()
.flat_map(|t| &t.items)
.map(|i| i.effect("panner").and_then(|e| e.param("balance")).and_then(|p| p.value.as_f64()).unwrap_or(0.0))
.collect()
}
/// filmcraft#371: the halves of a split stereo file played centred, so both channels summed on both sides
/// (+3 dB for a stereo effect, +6 dB for dual-mono dialogue). They now play on their own side.
#[test]
fn split_stereo_halves_are_panned_to_their_side() {
let (imp, _) = fcp7_import(&split_stereo_doc(""), None);
assert_eq!(balances(&imp), vec![-100.0, 100.0]);
}
/// An Audio Pan filter on a split half still wins.
#[test]
fn split_stereo_half_keeps_its_audio_pan() {
let pan = r#"<filter><effect><name>Audio Pan</name><effectid>audiopan</effectid><effectcategory>audiopan</effectcategory><effecttype>audiopan</effecttype><mediatype>audio</mediatype>
<parameter><parameterid>pan</parameterid><name>Pan</name><valuemin>-1</valuemin><valuemax>1</valuemax><value>0</value></parameter></effect></filter>"#;
let (imp, _) = fcp7_import(&split_stereo_doc(pan), None);
assert_eq!(balances(&imp), vec![0.0, 100.0]);
}
/// A centred one-channel clip (Breakout to Mono) round-trips centred: export writes its Audio Pan.
#[test]
fn centred_mono_breakout_round_trips_centred() {
let r = FrameRate::FPS_23_976;
let mut p = Project::new("P");
let a = media(&mut p, "/m/a.wav", false, true, r);
let s = sequence(&mut p, "t", r, false);
let left = clip(&mut p, s, TrackKind::Audio, 0, a, 0..24, 0);
let right = clip(&mut p, s, TrackKind::Audio, 1, a, 0..24, 0);
for (id, ch) in [(left, 0u16), (right, 1u16)] {
let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(id).unwrap();
it.source_channels = vec![ch];
}
let (imp, xml, _) = fcp7_roundtrip(&p, s, &xopts());
assert_eq!(balances(&imp), vec![0.0, 0.0], "{xml}");
}
/// A `timebase` near `i64::MAX` made a rate of ~9e18 per second: with `ntsc` the importer multiplied it by
/// 1000 (overflow), without it the export overflowed adding 1 to a clamped frame index. The timebase is
/// limited instead, and the document imports and exports.
#[test]
fn absurd_timebase_is_bounded() {
for (tb, ntsc) in [("9223372036854775807", "TRUE"), ("9223372036854775807", "FALSE"), ("4294967296", "FALSE"), ("-5", "TRUE")] {
let doc = format!(
r#"<?xml version="1.0"?><xmeml version="4"><sequence id="s"><name>S</name><rate><timebase>{tb}</timebase><ntsc>{ntsc}</ntsc></rate>
<timecode><rate><timebase>{tb}</timebase><ntsc>{ntsc}</ntsc></rate><string>01:00:00:00</string><frame>90000</frame></timecode>
<media><video><track><clipitem id="c"><name>A</name><start>0</start><end>100</end><in>0</in><out>100</out>
<file id="f"><name>a.mov</name><pathurl>file:///tmp/a.mov</pathurl><rate><timebase>{tb}</timebase><ntsc>{ntsc}</ntsc></rate><media><video/></media></file>
<marker><name>m</name><in>5</in><out>-1</out></marker></clipitem></track></video></media></sequence></xmeml>"#
);
let (imported, _) = fcp7_import(&doc, None);
for seq in &imported.sequences {
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio] {
export(&imported.project, *seq, format, &xopts()).unwrap_or_else(|e| panic!("{format:?}: {e}"));
}
}
}
}
/// A start timecode of 18446744073709551615 frames saturates to `i64::MAX`; the EDL export then
/// overflowed adding frame numbers to it. Out-of-range start timecodes are reset with a warning.
#[test]
fn absurd_start_timecode_is_bounded() {
let doc = r#"<?xml version="1.0"?><xmeml version="4"><sequence id="s"><name>S</name><rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
<timecode><rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate><string>00:00:00:00</string><frame>18446744073709551615</frame></timecode>
<media><video><track><clipitem id="c"><name>A</name><start>0</start><end>100</end><in>0</in><out>100</out>
<file id="f"><name>a.mov</name><pathurl>file:///tmp/a.mov</pathurl><rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
<timecode><rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate><string>00:00:00:00</string><frame>18446744073709551615</frame></timecode><media><video/></media></file>
</clipitem></track></video></media></sequence></xmeml>"#;
let (imported, report) = fcp7_import(doc, None);
for seq in &imported.sequences {
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio] {
export(&imported.project, *seq, format, &xopts()).unwrap_or_else(|e| panic!("{format:?}: {e}"));
}
assert_eq!(imported.project.sequence(*seq).map(|s| s.start_timecode), Some(0));
}
assert!(report.mentions("out-of-range start timecode"), "{report}");
}
#[test]
fn a_huge_link_group_does_not_write_quadratic_links() {
let r = FrameRate::FPS_29_97;
let mut p = Project::new("P");
let a = media(&mut p, "/m/a.mov", true, true, r);
let s = sequence(&mut p, "Main", r, true);
let ids: Vec<_> = (0..300).map(|i| clip(&mut p, s, TrackKind::Audio, 0, a, 0..10, i * 10)).collect();
link(&mut p, s, &ids);
// the truncation warning is reported by the export, so it is the export's report that is checked
let (text, rep) = fcp7_export(&p, s, &xopts());
// only the first 64 clips are linked, each to the 64 members: the other 236 stay unlinked
assert_eq!(text.matches("<linkclipref>").count(), 64 * 64);
assert!(rep.has_warnings(), "{rep}");
}