M11.4: FCP7 XML — xmeml v4/v5 import/export

Project/bin/clip/sequence, clipitems with pproTicks-exact source, files by id, nested
sequences, generatoritems, transitionitems with alignment, Basic Motion/Opacity/Time Remap/
Audio Levels/Audio Pan filters with keyframes, generic effects by id/name, labels, markers,
links, enabled/locked. Golden Premiere-style document and property round trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 12:27:48 -04:00
parent c14a7d44a4
commit b31c655338
4 changed files with 1762 additions and 11 deletions

File diff suppressed because it is too large Load Diff

View File

@ -136,9 +136,11 @@ pub(crate) fn num(v: f64) -> String {
if v == v.round() && v.abs() < 1e15 {
return format!("{}", v as i64);
}
let s = format!("{v:.6}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.to_string() }
if !v.is_finite() {
return "0".into();
}
// Shortest representation that parses back to the same f64.
format!("{v}")
}
pub(crate) fn parse(text: &str) -> Result<roxmltree::Document<'_>, String> {
@ -164,6 +166,7 @@ mod tests {
assert!(child(a, "c").is_some());
assert_eq!(num(1.5), "1.5");
assert_eq!(num(100.0), "100");
assert_eq!(num(-0.0000001), "0");
assert_eq!(num(-0.0), "0");
assert_eq!(num(0.1 + 0.2).parse::<f64>().unwrap(), 0.1 + 0.2);
}
}

View File

@ -0,0 +1,451 @@
//! Final Cut Pro 7 XML (xmeml): a hand-written Premiere-style document (written from the public
//! xmeml element descriptions) and export → import round trips.
mod support;
use filmcraft_interchange::{ExportOptions, Format, detect, export, import};
use filmcraft_media::Generator;
use filmcraft_project::{ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, TrackKind, TransitionAlign};
use filmcraft_time::{FrameRate, Tick};
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(doc.as_bytes(), Some("xml")), Some(Format::Fcp7Xml));
let (imp, rep) = import(doc.as_bytes(), Format::Fcp7Xml, Some("/proj")).unwrap();
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);
// 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 filmcraft_project::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));
// 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)));
// 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, c2.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, c1.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, filmcraft_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, 45, 300);
transition(&mut p, s, TrackKind::Video, 0, "dip_to_black", Some(v1), Some(v2), 50, 20, TransitionAlign::CenterAtCut);
transition(&mut p, s, TrackKind::Video, 0, "cross_dissolve", Some(v2), None, 90, 15, TransitionAlign::EndAtCut);
let n = clip(&mut p, s, TrackKind::Video, 1, nest, 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(filmcraft_project::find_effect("gaussian_blur").unwrap().instance());
it.markers.push(Marker {
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 {
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) = roundtrip(&p, s, Format::Fcp7Xml, &ExportOptions::default());
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, _) = export(q, si, Format::Fcp7Xml, &ExportOptions::default()).unwrap();
let (imp2, _) = import(&again, Format::Fcp7Xml, None).unwrap();
assert_eq!(structure(&imp2.project, imp2.sequences[0], TrackKind::Video), structure(&p, s, TrackKind::Video));
}
#[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, 0);
let o = ExportOptions { xmeml_version: 5, ..Default::default() };
let (imp, text, _) = roundtrip(&p, s, Format::Fcp7Xml, &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<filmcraft_project::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, 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, d, TransitionAlign::CenterAtCut),
1 if adjacent => transition(&mut p, s, TrackKind::Video, g.track, "dip_to_white", prev[g.track], Some(v), t, d, TransitionAlign::StartAtCut),
2 if adjacent => transition(&mut p, s, TrackKind::Video, g.track, "push", prev[g.track], Some(v), t - d, d, TransitionAlign::EndAtCut),
3 if !adjacent => transition(&mut p, s, TrackKind::Video, g.track, "cross_dissolve", None, Some(v), 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, 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, _) = roundtrip(&p, s, Format::Fcp7Xml, &ExportOptions::default());
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));
}
}

View File

@ -141,6 +141,7 @@ pub struct T {
pub start: Tick,
pub dur: Tick,
pub effect: String,
pub align: TransitionAlign,
pub from: Option<usize>,
pub to: Option<usize>,
}
@ -189,6 +190,7 @@ pub fn structure(p: &Project, seq: ItemId, kind: TrackKind) -> Vec<Tr> {
start: x.start,
dur: x.duration,
effect: x.effect.effect.clone(),
align: x.align,
from: x.from.and_then(|f| t.items.iter().position(|i| i.id == f)),
to: x.to.and_then(|f| t.items.iter().position(|i| i.id == f)),
})