//! 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#" "#, 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#" "#; 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#" "#; 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("\n \n \n ", "\n ", 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#" "# ); 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!("", 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::>(), ps.markers.iter().map(|m| (&m.name, m.start, m.duration, m.kind)).collect::>() ); 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![(&"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 { (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; 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"); }