From 384da3e9512c05f5a02a59f8c546df3fab37efc9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:13:25 -0500 Subject: [PATCH] craft-timeline: Final Cut Pro 7 XML (xmeml) on the neutral model (#978) * 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 : in effectid (FilmCraft wrote filmcraft:); 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. --- Cargo.lock | 1 + crates/timeline/Cargo.toml | 7 + crates/timeline/README.md | 3 +- crates/timeline/src/fcp7.rs | 1572 +++++++++++++++++++++++++++ crates/timeline/src/lib.rs | 14 + crates/timeline/src/xml.rs | 199 ++++ crates/timeline/tests/it/fcp7.rs | 758 +++++++++++++ crates/timeline/tests/it/main.rs | 4 + crates/timeline/tests/it/support.rs | 300 +++++ 9 files changed, 2857 insertions(+), 1 deletion(-) create mode 100644 crates/timeline/src/fcp7.rs create mode 100644 crates/timeline/src/xml.rs create mode 100644 crates/timeline/tests/it/fcp7.rs create mode 100644 crates/timeline/tests/it/main.rs create mode 100644 crates/timeline/tests/it/support.rs diff --git a/Cargo.lock b/Cargo.lock index e6d3d3885..acfce94ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1763,6 +1763,7 @@ dependencies = [ name = "craft-timeline" version = "0.1.0" dependencies = [ + "craft-xml", "proptest", "serde", ] diff --git a/crates/timeline/Cargo.toml b/crates/timeline/Cargo.toml index 324a60712..59a79ed4b 100644 --- a/crates/timeline/Cargo.toml +++ b/crates/timeline/Cargo.toml @@ -13,9 +13,16 @@ workspace = true [features] # Serialize and Deserialize for the time, report and document types. serde = ["dep:serde"] +# Final Cut Pro 7 XML (`fcp7`), read with craft-xml's bounded parser. +xml = ["dep:craft-xml"] [dependencies] serde = { workspace = true, optional = true } +craft-xml = { workspace = true, optional = true } [dev-dependencies] proptest = { workspace = true } + +[[test]] +name = "it" +required-features = ["xml"] diff --git a/crates/timeline/README.md b/crates/timeline/README.md index f3a932d31..f8bbd469a 100644 --- a/crates/timeline/README.md +++ b/crates/timeline/README.md @@ -14,6 +14,7 @@ text formats that are documents of their own. | `build` | Building a `Project` while reading a document (`Builder`, media deduplicated by key, track placement bounded at 256 tracks) and the bounds every import ends with (`Imported::bound`: implausible rates and start timecodes reset and reported). | | `ale` | Avid Log Exchange: `AleDoc` (heading, columns, rows), `parse`, `write`, the standard heading and columns. | | `edl` | CMX 3600 edit decision lists: `parse` to the list as written (events, their lines and timecode fields, `M2`, `AUD`, clip-name, source-file, effect-name and locator comments), `write` for events the caller built, channel codes, reel names, the frame-rate guess. | +| `fcp7` (feature `xml`) | Final Cut Pro 7 XML (`xmeml` 4 and 5, the dialect Premiere Pro reads and writes) to and from `model`. Read with craft-xml, strictly and bounded. | | crate root | `Format` (EDL, xmeml, FCPXML, OTIO, AAF, OMF), `detect_text`, `decode_text`. | No file I/O. EDL and ALE give the document as the format writes it. The formats that describe a @@ -21,7 +22,7 @@ whole edit read into and write from `model`; mapping `model` onto an application stays in the application. AAF and OMF are in `craft-aaf`. -Feature `serde`: `Serialize` / `Deserialize` for the time, report and `Format` types. +Feature `xml`: the XML formats, on craft-xml. Feature `serde`: `Serialize` / `Deserialize` for the time, report and `Format` types. ## Specifications diff --git a/crates/timeline/src/fcp7.rs b/crates/timeline/src/fcp7.rs new file mode 100644 index 000000000..41243baf6 --- /dev/null +++ b/crates/timeline/src/fcp7.rs @@ -0,0 +1,1572 @@ +//! Final Cut Pro 7 XML interchange (`xmeml` versions 4 and 5), the dialect Premiere Pro imports +//! and exports. +//! +//! Supported: `project`/`bin`/`clip`/`sequence` structure, video and audio tracks (enabled, +//! locked), `clipitem` (start/end/in/out, `pproTicksIn/Out` for tick-exact source times, enabled, +//! labels, markers, links), master-clip `` media markers, `file` (pathurl, rate, duration, +//! timecode, media characteristics, references by id), nested sequences (inline or by id), +//! `generatoritem` (Slug, Color, …), +//! `transitionitem` (alignment, Cross Dissolve & friends), and the filters Basic Motion (scale, +//! rotation, center, anchor, anti-flicker), Opacity, Time Remap (constant speed, reverse, freeze), +//! Audio Levels and Audio Pan, all with keyframes. Other effects are written with our effect ids +//! and read back by id or display name; anything unknown is reported. +//! +//! Coordinates: Basic Motion `center` is written relative to the frame centre as a fraction of the +//! frame width/height (0,0 = centred); `centerOffset` (anchor) likewise relative to the source. +//! Keyframe `when` values and clip markers are source (media) frames, like `in`/`out`. + +use std::collections::{HashMap, HashSet}; + +use crate::build::{ + Builder, Imported, MAX_IMPORTED_TRACKS, MediaSpec, base_item, db_to_gain, empty_sequence, gain_to_db, item_media, item_path, kind_from_ext, + label_from, param, param_modified, set_param, settings_for, standard_effects, transition_effect, transition_name, unscaled, +}; +use crate::model::{ + BinId, ClipId, EffectInstance, Generator, Interpolation, ItemId, ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaKind, Param, + ParamValue, Project, Sequence, TrackItem, TrackKind, Transition, TransitionAlign, TransitionId, Vec2, +}; +use crate::path::{file_name, path_to_file_url, resolve_path, resolve_url}; +use crate::rate::{MAX_RATE, frames_round, on_frame, rate_from_timebase, timebase}; +use crate::report::Report; +use crate::time::{FrameRate, Tick, format_timecode_frames}; +use crate::xml::{Node, XmlWriter, bool_str, child, child_bool, child_f64, child_i64, child_text, children, elements, num, path_text}; +use crate::{Error, ExportOptions, Format, ImportOptions, Result}; + +// --------------------------------------------------------------------------------------------- +// Shared vocabulary +// --------------------------------------------------------------------------------------------- + +/// Most members of a link group written into each clip's `` list (a group is a clip's own +/// video and a few audio channels, so real ones are far smaller). +const MAX_LINK_MEMBERS: usize = 64; + +struct ClipRemap { + speed: f64, + reverse: bool, + hold: bool, + has_remap: bool, +} + +impl Default for ClipRemap { + fn default() -> Self { + Self { speed: 1.0, reverse: false, hold: false, has_remap: false } + } +} + +const BLEND_XML: &[(&str, u32)] = &[ + ("normal", 0), + ("darken", 2), + ("multiply", 3), + ("lighten", 7), + ("screen", 8), + ("add", 10), + ("overlay", 12), + ("softlight", 13), + ("hardlight", 14), + ("difference", 19), + ("subtract", 21), +]; + +/// The generator of a `generatoritem`: an application's own payload (`:` as +/// the effect id), one of the generators every editor has by name, or one the catalog knows. +fn generator_from(name: &str, id: &str, opts: &ImportOptions, report: &mut Report) -> Generator { + if let Some(data) = id.strip_prefix(opts.namespace).and_then(|rest| rest.strip_prefix(':')) + && !opts.namespace.is_empty() + { + return Generator::Other { name: name.to_string(), data: data.to_string() }; + } + match name.to_ascii_lowercase().as_str() { + "slug" | "black video" => Generator::BlackVideo, + "color" | "color matte" | "colour matte" => Generator::ColorMatte { color: [0.0, 0.0, 0.0, 1.0] }, + "transparent video" => Generator::TransparentVideo, + "counting leader" | "universal counting leader" => Generator::CountingLeader, + n if n.starts_with("bars and tone") => Generator::BarsAndTone, + n => { + if let Some(g) = opts.catalog.generator_by_name(n) { + return g; + } + report.warn(format!("generator \"{name}\" is not supported; imported as Black Video")); + Generator::BlackVideo + } + } +} + +fn read_rate(n: Node) -> Option { + let r = child(n, "rate")?; + let tb = child_i64(r, "timebase")?; + Some(rate_from_timebase(tb, child_bool(r, "ntsc").unwrap_or(false))) +} + +fn write_rate(w: &mut XmlWriter, rate: FrameRate) { + let (tb, ntsc) = timebase(rate); + w.open("rate", &[]); + w.text("timebase", tb); + w.text("ntsc", bool_str(ntsc)); + w.close(); +} + +// --------------------------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------------------------- + +struct PendingLink { + seq: ItemId, + kind: TrackKind, + track: usize, + clip: ClipId, + refs: Vec, +} + +struct Imp<'a, 'c, 'r> { + b: Builder<'c>, + opts: &'a ImportOptions<'a>, + report: &'r mut Report, + files: HashMap>, + seq_defs: HashMap>, + file_items: HashMap, + seq_items: HashMap, + in_progress: HashSet, + links: Vec, + xml_clip_ids: HashMap<(ItemId, String), ClipId>, + /// `(sequence key, file key)` pairs where some audio clip item of that file uses `sourcetrack/trackindex` > 1, + /// i.e. the file's channels are split onto mono tracks in that sequence (filmcraft#463). + split_files: HashSet<(String, String)>, + /// The key of the sequence each clip item is in (the innermost one), by the clip item's node. + clip_seqs: HashMap, +} + +/// The 1-based `sourcetrack/trackindex` of an audio clip item. +fn audio_track_index(c: Node) -> Option { + let st = child(c, "sourcetrack")?; + if child_text(st, "mediatype").is_some_and(|m| m != "audio") { + return None; + } + child_i64(st, "trackindex") +} + +fn node_key(n: Node) -> String { + match n.attribute("id") { + Some(id) => id.to_string(), + None => format!("#node{}", n.id()), + } +} + +/// Read an `xmeml` document. Media paths are resolved against `opts.base_dir`; effects the format +/// does not name itself are looked up in `opts.catalog`. What cannot be represented is in `report`. +pub fn import(text_in: &str, opts: &ImportOptions, report: &mut Report) -> Result { + let doc = crate::xml::parse(text_in).map_err(|e| Error::parse(Format::Fcp7Xml, e))?; + let root = Node(&doc); + if root.name() != "xmeml" { + return Err(Error::parse(Format::Fcp7Xml, "root element is not ")); + } + if root + .descendants() + .any(|n| n.has_tag_name("timebase") && n.text().and_then(|t| t.trim().parse::().ok()).is_some_and(|tb| tb > MAX_RATE as f64)) + { + report.warn(format!("a timebase above {MAX_RATE} per second is not a frame rate; it was limited to {MAX_RATE}")); + } + let mut imp = Imp { + b: Builder::new( + root.descendants().find(|n| n.has_tag_name("project")).and_then(|p| child_text(p, "name")).unwrap_or("Imported XML"), + opts.catalog, + ), + opts, + report, + files: HashMap::new(), + seq_defs: HashMap::new(), + file_items: HashMap::new(), + seq_items: HashMap::new(), + in_progress: HashSet::new(), + links: Vec::new(), + xml_clip_ids: HashMap::new(), + split_files: HashSet::new(), + clip_seqs: HashMap::new(), + }; + // every element with the sequence it is in; the document is at most `xml::LIMITS.max_depth` deep + let mut todo: Vec<(Node, Option)> = vec![(root, None)]; + while let Some((n, seq)) = todo.pop() { + match n.name() { + "clipitem" => { + if let Some(seq) = &seq { + imp.clip_seqs.insert(n.id(), seq.clone()); + } + if audio_track_index(n).is_some_and(|i| i > 1) + && let Some(key) = imp.clip_seq_file(n) + { + imp.split_files.insert(key); + } + } + "file" if elements(n).next().is_some() => { + imp.files.entry(node_key(n)).or_insert(n); + } + "sequence" if child(n, "media").is_some() => { + imp.seq_defs.entry(node_key(n)).or_insert(n); + } + _ => {} + } + let inner = if n.has_tag_name("sequence") { Some(node_key(n)) } else { seq }; + // reversed, so that elements are visited in document order (the first definition of an id wins) + let kids: Vec = n.children().collect(); + todo.extend(kids.into_iter().rev().map(|c| (c, inner.clone()))); + } + imp.walk_container(root, None, true); + imp.resolve_links(); + if imp.b.p.items.is_empty() { + return Err(Error::Empty); + } + let mut imported = imp.b.finish(); + imported.bound(report); + Ok(imported) +} + +impl<'a> Imp<'a, '_, '_> { + /// The `(sequence key, file key)` an audio clip item belongs to, if it references a file. + fn clip_seq_file(&self, c: Node) -> Option<(String, String)> { + Some((self.clip_seqs.get(&c.id())?.clone(), node_key(child(c, "file")?))) + } + + /// Walk `xmeml`, `project`, `bin` and `children` containers. + fn walk_container(&mut self, n: Node<'a>, bin: Option, top: bool) { + for c in elements(n) { + match c.name() { + "project" => { + let target = c.children().find(|x| x.has_tag_name("children")).unwrap_or(c); + self.walk_container(target, bin, top); + } + "children" => self.walk_container(c, bin, top), + "bin" => { + let name = child_text(c, "name").unwrap_or("Bin"); + let b = self.b.bin(name, bin); + let target = child(c, "children").unwrap_or(c); + self.walk_container(target, Some(b), top); + } + "sequence" => { + if let Some(id) = self.sequence(c, bin) + && top + && !self.b.top.contains(&id) + { + self.b.top.push(id); + } + } + "clip" => self.master_clip(c, bin), + _ => {} + } + } + } + + /// A master clip in a bin: its file(s) become media items. + fn master_clip(&mut self, n: Node<'a>, bin: Option) { + let file = n.descendants().find(|d| d.has_tag_name("file")); + match file { + Some(f) => { + let id = self.file(f, child_text(n, "name"), bin); + // A sequence's clip items file their media at the top level, so a sequence earlier in the + // document that used this file first left it there; the master clip's bin is its home. + if let (Some(item), Some(b)) = (id, bin) + && self.b.p.root.parent_of(item) == Some(self.b.p.root.id) + { + self.b.p.move_item_to_bin(item, Some(b)); + } + if let Some(item) = id.and_then(|i| self.b.p.item_mut(i)) + && let Some(l) = path_text(n, &["labels", "label2"]).and_then(label_from) + { + item.label = l; + } + if let (Some(item), Some(rate)) = (id, read_rate(n)) { + let markers = read_markers(n, rate, &mut self.b); + if let Some(m) = self.b.p.item_mut(item).and_then(|i| i.as_media_mut()) + && m.markers.is_empty() + { + m.markers = markers; + } + } + } + None => { + if let Some(s) = n.descendants().find(|d| d.has_tag_name("sequence")) { + self.sequence(s, bin); + } + } + } + } + + /// Media item for a `` element (definition or reference by id). + fn file(&mut self, n: Node<'a>, fallback_name: Option<&str>, bin: Option) -> Option { + let key = node_key(n); + if let Some(id) = self.file_items.get(&key) { + return Some(*id); + } + let def = if elements(n).next().is_some() { n } else { *self.files.get(&key)? }; + let name = child_text(def, "name").filter(|s| !s.is_empty()).or(fallback_name).unwrap_or("Untitled").to_string(); + let rate = read_rate(def); + let video = child(def, "media").and_then(|m| child(m, "video")); + let audio = child(def, "media").and_then(|m| child(m, "audio")); + let dims = video.map(|v| { + let sc = child(v, "samplecharacteristics").unwrap_or(v); + (child_i64(sc, "width").unwrap_or(1920) as u32, child_i64(sc, "height").unwrap_or(1080) as u32) + }); + let aud = audio.map(|a| { + let sc = child(a, "samplecharacteristics").unwrap_or(a); + (child_i64(sc, "samplerate").unwrap_or(48_000) as u32, child_i64(a, "channelcount").unwrap_or(2) as u32) + }); + let r = rate.unwrap_or_default(); + let path = child_text(def, "pathurl").filter(|s| !s.is_empty()); + let mut spec = MediaSpec { + duration: child_i64(def, "duration").filter(|d| *d > 0).map(|d| r.tick_of(d)), + video: dims.map(|(w, h)| (w, h, r)), + audio: aud, + start_tc: child(def, "timecode").and_then(|t| child_i64(t, "frame")), + kind: None, + }; + if spec.video.is_none() && spec.audio.is_none() { + // No media characteristics: guess from the extension. + match kind_from_ext(path.unwrap_or(&name)) { + MediaKind::AudioOnly => spec.audio = Some((48_000, 2)), + k => { + spec.video = Some((1920, 1080, r)); + spec.kind = Some(k); + } + } + } + let id = match path { + Some(url) => { + let p = resolve_url(url, self.opts.base_dir); + self.b.file_media(&format!("file:{p}"), &name, &p, &spec, bin) + } + None => { + self.report.warn(format!("file \"{name}\" has no pathurl; imported offline")); + let p = resolve_path(&name, self.opts.base_dir); + let id = self.b.file_media(&format!("fileid:{key}"), &name, &p, &spec, bin); + if let Some(m) = self.b.p.item_mut(id).and_then(|i| i.as_media_mut()) { + m.offline = true; + } + id + } + }; + self.file_items.insert(key, id); + Some(id) + } + + /// Sequence item for a `` element (definition or reference). + fn sequence(&mut self, n: Node<'a>, bin: Option) -> Option { + let key = node_key(n); + if let Some(id) = self.seq_items.get(&key) { + return Some(*id); + } + let def = if child(n, "media").is_some() { n } else { *self.seq_defs.get(&key)? }; + if !self.in_progress.insert(key.clone()) { + self.report.warn("recursive nested sequence ignored"); + return None; + } + let name = child_text(def, "name").unwrap_or("Sequence").to_string(); + let rate = read_rate(def).unwrap_or_default(); + let tc = child(def, "timecode"); + let df = tc.and_then(|t| child_text(t, "displayformat")).is_some_and(|d| d.eq_ignore_ascii_case("DF")); + let vfmt = + child(def, "media").and_then(|m| child(m, "video")).and_then(|v| child(v, "format")).and_then(|f| child(f, "samplecharacteristics")); + let (w, h) = + vfmt.map(|s| (child_i64(s, "width").unwrap_or(1920) as u32, child_i64(s, "height").unwrap_or(1080) as u32)).unwrap_or((1920, 1080)); + let mut settings = settings_for(rate, w, h, df); + if let Some(sr) = child(def, "media") + .and_then(|m| child(m, "audio")) + .and_then(|a| child(a, "format")) + .and_then(|f| child(f, "samplecharacteristics")) + .and_then(|s| child_i64(s, "samplerate")) + { + settings.sample_rate = sr as u32; + } + let id = self.b.reserve_sequence(&name, settings.clone(), bin); + if let Some(l) = path_text(def, &["labels", "label2"]).and_then(label_from) + && let Some(it) = self.b.p.item_mut(id) + { + it.label = l; + } + self.seq_items.insert(key.clone(), id); + let mut seq = empty_sequence(settings); + seq.start_timecode = tc.and_then(|t| child_i64(t, "frame")).unwrap_or(0); + seq.markers = read_markers(def, rate, &mut self.b); + let media = child(def, "media"); + for (kind, tag) in [(TrackKind::Video, "video"), (TrackKind::Audio, "audio")] { + let Some(m) = media.and_then(|m| child(m, tag)) else { continue }; + for (ti, tn) in children(m, "track").enumerate() { + if !self.b.ensure_tracks(&mut seq, kind, ti + 1) { + self.report.warn(format!("FCP7 {kind:?} tracks beyond {MAX_IMPORTED_TRACKS} were skipped")); + break; + } + self.track(&mut seq, id, kind, ti, tn, TrackFormat { rate, frame: (w, h) }); + } + } + if seq.video_tracks.is_empty() { + self.b.ensure_tracks(&mut seq, TrackKind::Video, 1); + } + if seq.audio_tracks.is_empty() { + self.b.ensure_tracks(&mut seq, TrackKind::Audio, 1); + } + self.b.put_sequence(id, seq); + self.in_progress.remove(&key); + Some(id) + } + + fn track(&mut self, seq: &mut Sequence, seq_id: ItemId, kind: TrackKind, ti: usize, tn: Node<'a>, fmt: TrackFormat) { + let TrackFormat { rate, frame } = fmt; + { + let t = &mut seq.tracks_mut(kind)[ti]; + t.enabled = child_bool(tn, "enabled").unwrap_or(true); + t.locked = child_bool(tn, "locked").unwrap_or(false); + } + let mut trans_nodes = Vec::new(); + for c in elements(tn) { + match c.name() { + "clipitem" | "generatoritem" => { + if let Some((ti_item, refs)) = self.clip_item(c, kind, rate, frame) { + let xml_id = node_key(c); + self.xml_clip_ids.insert((seq_id, xml_id), ti_item.id); + if !refs.is_empty() { + self.links.push(PendingLink { seq: seq_id, kind, track: ti, clip: ti_item.id, refs }); + } + let t = &mut seq.tracks_mut(kind)[ti]; + if t.items.iter().any(|o| o.start < ti_item.end() && ti_item.start < o.end()) { + self.report.warn("overlapping clip items on one track; the later one was moved to a new track"); + if self.b.place(seq, kind, ti + 1, ti_item).is_none() { + self.report + .warn(format!("FCP7 {kind:?} track exceeds the {MAX_IMPORTED_TRACKS}-track import limit; clip was skipped")); + } + } else { + t.items.push(ti_item); + } + } + } + "transitionitem" => trans_nodes.push(c), + _ => {} + } + } + seq.tracks_mut(kind)[ti].sort(); + for c in trans_nodes { + self.transition(seq, kind, ti, c, rate); + } + } + + fn clip_item(&mut self, c: Node<'a>, kind: TrackKind, seq_rate: FrameRate, frame: (u32, u32)) -> Option<(TrackItem, Vec)> { + let name = child_text(c, "name").unwrap_or("").to_string(); + let rate = read_rate(c).unwrap_or(seq_rate); + let item = self.clip_media(c, kind, seq_rate, frame, &name)?; + + let start = child_i64(c, "start").unwrap_or(-1); + let end = child_i64(c, "end").unwrap_or(-1); + let in_f = child_i64(c, "in").unwrap_or(0); + let out_f = child_i64(c, "out").unwrap_or(in_f); + let src_in = child_i64(c, "pproTicksIn").map(Tick).unwrap_or_else(|| rate.tick_of(in_f)); + let src_out = child_i64(c, "pproTicksOut").map(Tick).unwrap_or_else(|| rate.tick_of(out_f)); + let remap = self.time_remap(c); + let (mut speed, reverse, hold) = (remap.speed, remap.reverse, remap.hold); + let src_len = (src_out - src_in).abs(); + let (start_t, dur) = match (start >= 0, end >= 0) { + (true, true) => (seq_rate.tick_of(start), seq_rate.tick_of(end) - seq_rate.tick_of(start)), + (true, false) => { + let d = if hold { rate.tick_of(out_f - in_f) } else { unscaled(src_len, speed) }; + (seq_rate.tick_of(start), d) + } + (false, true) => { + let d = if hold { rate.tick_of(out_f - in_f) } else { unscaled(src_len, speed) }; + (seq_rate.tick_of(end) - d, d) + } + (false, false) => { + self.report.warn(format!("clip \"{name}\" has neither start nor end; skipped")); + return None; + } + }; + if dur <= Tick::ZERO { + return None; + } + if !remap.has_remap && !hold && src_len != dur && src_len > Tick::ZERO { + speed = (src_len.0 as f64 / dur.0 as f64 * 10_000.0).round() / 10_000.0; + } + let mut ti = self.b.clip(item, kind, if name.is_empty() { "Clip" } else { &name }, start_t, dur, src_in.min(src_out)); + ti.speed = speed; + ti.reverse = reverse; + if hold { + ti.frame_hold = Some(src_in); + } + ti.enabled = child_bool(c, "enabled").unwrap_or(true); + if let Some(l) = path_text(c, &["labels", "label2"]).and_then(label_from) { + ti.label = l; + } + self.apply_clip_channels(&mut ti, c, item, kind); + if let Some(mode) = child_text(c, "compositemode") { + match BLEND_XML.iter().find(|(n, _)| n.eq_ignore_ascii_case(mode)) { + Some((_, v)) if *v != 0 => set_param(self.b.catalog, &mut ti, "opacity", "blend", Param::new(ParamValue::Choice(*v))), + Some(_) => {} + None => self.report.warn(format!("composite mode \"{mode}\" is not supported")), + } + } + ti.markers = read_markers(c, rate, &mut self.b); + let src_frame = self.source_frame(item).unwrap_or(frame); + for f in c.children().filter(|x| x.has_tag_name("filter")) { + self.filter(&mut ti, f, rate, frame, src_frame); + } + let refs: Vec = children(c, "link").filter_map(|l| child_text(l, "linkclipref")).map(str::to_string).collect(); + Some((ti, refs)) + } + + fn clip_media(&mut self, c: Node<'a>, kind: TrackKind, seq_rate: FrameRate, frame: (u32, u32), name: &str) -> Option { + if c.has_tag_name("generatoritem") { + let eff = child(c, "effect"); + let gname = eff.and_then(|e| child_text(e, "name")).unwrap_or(name).to_string(); + let gid = eff.and_then(|e| child_text(e, "effectid")).unwrap_or("").to_string(); + let mut g = generator_from(&gname, &gid, self.opts, self.report); + if let (Generator::ColorMatte { color }, Some(e)) = (&mut g, eff) + && let Some(v) = e.descendants().find(|d| d.has_tag_name("value") && child(*d, "red").is_some()) + { + *color = read_color(v); + } + let spec = MediaSpec { video: Some((frame.0, frame.1, seq_rate)), ..Default::default() }; + let key = format!("gen:{g:?}"); + let label = if name.is_empty() { g.label() } else { name.to_string() }; + Some(self.b.generator_media(&key, &label, g, &spec, None)) + } else if let Some(f) = child(c, "file") { + match self.file(f, Some(name), None) { + Some(item) => Some(item), + None => { + self.report.warn(format!("clip \"{name}\" refers to a file the document does not define; skipped")); + None + } + } + } else if let Some(s) = child(c, "sequence") { + self.sequence(s, None) + } else { + self.report.warn(format!("clip \"{name}\" has no media reference; imported offline")); + let spec = MediaSpec { + video: (kind == TrackKind::Video).then_some((frame.0, frame.1, seq_rate)), + audio: (kind == TrackKind::Audio).then_some((48_000, 2)), + ..Default::default() + }; + let key = format!("offline:{name}"); + let p = resolve_path(name, self.opts.base_dir); + let id = self.b.file_media(&key, name, &p, &spec, None); + if let Some(m) = self.b.p.item_mut(id).and_then(|i| i.as_media_mut()) { + m.offline = true; + } + Some(id) + } + } + + fn time_remap(&mut self, c: Node<'a>) -> ClipRemap { + let mut remap = ClipRemap::default(); + for eff in c.children().filter(|x| x.has_tag_name("filter")).filter_map(|f| child(f, "effect")) { + if child_text(eff, "effectid") == Some("timeremap") { + remap.has_remap = true; + for p in children(eff, "parameter") { + match child_text(p, "parameterid") { + Some("speed") => { + let v = child_f64(p, "value").unwrap_or(100.0); + if v == 0.0 { + remap.hold = true; + } else { + remap.speed = v.abs() / 100.0; + remap.reverse |= v < 0.0; + } + if child(p, "keyframe").is_some() { + self.report.warn("variable speed (time remap keyframes) is not supported; a constant speed was used"); + } + } + Some("reverse") => remap.reverse |= child_bool(p, "value").unwrap_or(false), + Some("variablespeed") if child_bool(p, "value") == Some(true) || child_f64(p, "value").is_some_and(|v| v != 0.0) => { + self.report.warn("variable speed (time remap keyframes) is not supported; a constant speed was used"); + } + _ => {} + } + } + } + } + remap + } + + fn apply_clip_channels(&self, ti: &mut TrackItem, c: Node<'a>, item: ItemId, kind: TrackKind) { + // `sourcetrack/trackindex` (1-based) picks the source channel of a mono track split off a multichannel file (filmcraft#463). + // Index 1 is also what a stereo clip on a stereo track carries, so it means channel 0 only when another clip + // item of the same file in this sequence uses a higher index (the file is split); otherwise it keeps the + // default (empty) channel mapping. + if kind == TrackKind::Audio + && let Some(idx) = audio_track_index(c) + && (idx > 1 || (idx == 1 && self.clip_seq_file(c).is_some_and(|k| self.split_files.contains(&k)))) + && let Ok(ch) = u16::try_from(idx - 1) + { + ti.source_channels = vec![ch]; + // The two halves of a split stereo file play on their own side, as the stereo clip did (filmcraft#371): + // a mono clip is centred (both sides at unity), so the halves would otherwise sum on both + // sides, +3 dB for uncorrelated channels, +6 dB for dual mono. An `audiopan` filter + // (read below) still overrides this. + let stereo = matches!(&self.b.p.item(item).map(|i| &i.kind), Some(ItemKind::Media(m)) if m.info.audio().is_some_and(|a| a.channels == 2)); + if stereo && ch <= 1 { + set_param(self.b.catalog, ti, "panner", "balance", Param::new(ParamValue::Float(if ch == 0 { -100.0 } else { 100.0 }))); + } + } + } + + fn source_frame(&self, item: ItemId) -> Option<(u32, u32)> { + match &self.b.p.item(item)?.kind { + ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)), + ItemKind::Sequence(s) => Some((s.settings.width, s.settings.height)), + _ => None, + } + } + + fn filter(&mut self, ti: &mut TrackItem, f: Node, rate: FrameRate, frame: (u32, u32), src: (u32, u32)) { + let Some(e) = child(f, "effect") else { return }; + let enabled = child_bool(f, "enabled").unwrap_or(true); + let id = child_text(e, "effectid").unwrap_or(""); + let name = child_text(e, "name").unwrap_or(id); + let params: Vec = children(e, "parameter").collect(); + let find = |pid: &str| { + params + .iter() + .copied() + .find(|p| child_text(*p, "parameterid") == Some(pid) || child_text(*p, "name").is_some_and(|n| n.eq_ignore_ascii_case(pid))) + }; + let (fw, fh) = (frame.0 as f64, frame.1 as f64); + let (sw, sh) = (src.0 as f64, src.1 as f64); + match id { + "timeremap" => {} + "basic" => { + if let Some(p) = find("scale") { + set_param(self.b.catalog, ti, "motion", "scale", read_param(p, rate, |v| v)); + } + if let Some(p) = find("rotation") { + set_param(self.b.catalog, ti, "motion", "rotation", read_param(p, rate, |v| v)); + } + if let Some(p) = find("center") { + let pr = read_point_param(p, rate, |x, y| Vec2::new(fw / 2.0 + x * fw, fh / 2.0 + y * fh)); + if pr.is_animated() || pr.value.as_vec2().is_some_and(|v| (v.x - fw / 2.0).abs() > 1e-9 || (v.y - fh / 2.0).abs() > 1e-9) { + set_param(self.b.catalog, ti, "motion", "position", pr); + } + } + if let Some(p) = find("centerOffset") { + let pr = read_point_param(p, rate, |x, y| Vec2::new(sw / 2.0 + x * sw, sh / 2.0 + y * sh)); + if pr.is_animated() || pr.value.as_vec2().is_some_and(|v| (v.x - sw / 2.0).abs() > 1e-9 || (v.y - sh / 2.0).abs() > 1e-9) { + set_param(self.b.catalog, ti, "motion", "anchor", pr); + } + } + if let Some(p) = find("antiflicker") { + set_param(self.b.catalog, ti, "motion", "anti_flicker", read_param(p, rate, |v| v)); + } + if let Some(m) = ti.effect_mut("motion") { + m.enabled = enabled; + } + } + "opacity" => { + if let Some(p) = find("opacity") { + set_param(self.b.catalog, ti, "opacity", "opacity", read_param(p, rate, |v| v)); + } + } + "audiolevels" => { + if let Some(p) = find("level") { + set_param(self.b.catalog, ti, "volume", "level", read_param(p, rate, gain_to_db)); + } + } + "audiopan" => { + if let Some(p) = find("pan") { + set_param(self.b.catalog, ti, "panner", "balance", read_param(p, rate, |v| v * 100.0)); + } + } + _ => { + let catalog = self.b.catalog; + let def = catalog.effect(id).filter(|d| !d.intrinsic).or_else(|| catalog.effect_by_name(name).filter(|d| !d.intrinsic)); + let Some(def) = def else { + self.report.warn(format!("effect \"{name}\" is not supported and was skipped")); + return; + }; + let mut inst = def.instance(); + inst.enabled = enabled; + for pd in &def.params { + let Some(p) = find(&pd.id).or_else(|| find(&pd.label)) else { continue }; + let pr = match &pd.default { + ParamValue::Float(_) => read_param(p, rate, |v| v), + ParamValue::Vec2(_) => read_point_param(p, rate, Vec2::new), + ParamValue::Bool(_) => Param::new(ParamValue::Bool(child_bool(p, "value").unwrap_or(false))), + ParamValue::Choice(_) => Param::new(ParamValue::Choice(child_i64(p, "value").unwrap_or(0).max(0) as u32)), + ParamValue::Color(_) => Param::new(ParamValue::Color(child(p, "value").map(read_color).unwrap_or([0.0, 0.0, 0.0, 1.0]))), + ParamValue::Text(_) => Param::new(ParamValue::Text(child_text(p, "value").unwrap_or("").to_string())), + ParamValue::Opaque(_) => continue, + }; + inst.params.insert(pd.id.clone(), pr); + } + ti.effects.push(inst); + } + } + } + + fn transition(&mut self, seq: &mut Sequence, kind: TrackKind, ti: usize, c: Node, rate: FrameRate) { + let start = child_i64(c, "start").unwrap_or(0); + let end = child_i64(c, "end").unwrap_or(start); + let (s, e) = (rate.tick_of(start), rate.tick_of(end)); + if e <= s { + return; + } + let eff = child(c, "effect"); + let name = eff.and_then(|x| child_text(x, "name")).or_else(|| eff.and_then(|x| child_text(x, "effectid"))).unwrap_or("Cross Dissolve"); + let effect = transition_effect(self.b.catalog, name, kind == TrackKind::Audio, self.report); + let align_s = child_text(c, "alignment").unwrap_or("").to_ascii_lowercase(); + let track = &mut seq.tracks_mut(kind)[ti]; + let (mut from, mut to) = (None, None); + if align_s != "start-black" { + from = track.items.iter().filter(|i| i.end() >= s && i.end() <= e).max_by_key(|i| i.end()).map(|i| (i.id, i.end())); + } + if align_s != "end-black" { + to = track.items.iter().filter(|i| i.start >= s && i.start <= e).min_by_key(|i| i.start).map(|i| (i.id, i.start)); + } + // A cut needs both sides to meet. + if let (Some((_, fe)), Some((_, ts))) = (from, to) + && fe != ts + { + if align_s == "start-black" || (align_s.is_empty() && ts == s) { + from = None; + } else { + to = None; + } + } + if from.is_none() && to.is_none() { + self.report.warn(format!("transition \"{name}\" at frame {start} has no adjacent clip; skipped")); + return; + } + let cut = from.map(|f| f.1).or(to.map(|t| t.1)).unwrap_or(s); + let align = match align_s.as_str() { + "center" => TransitionAlign::CenterAtCut, + "start" | "start-black" => TransitionAlign::StartAtCut, + "end" | "end-black" => TransitionAlign::EndAtCut, + _ if cut == s => TransitionAlign::StartAtCut, + _ if cut == e => TransitionAlign::EndAtCut, + _ => TransitionAlign::CenterAtCut, + }; + let id = TransitionId(self.b.alloc()); + track.transitions.push(Transition { + id, + effect, + start: s, + duration: e - s, + from: from.map(|f| f.0), + to: to.map(|t| t.0), + align, + reverse: false, + ext: None, + }); + track.sort(); + } + + fn resolve_links(&mut self) { + // Union clip items that reference each other. + let mut group_of: HashMap<(ItemId, ClipId), u64> = HashMap::new(); + let pending = std::mem::take(&mut self.links); + for pl in &pending { + let mut members: Vec = pl.refs.iter().filter_map(|r| self.xml_clip_ids.get(&(pl.seq, r.clone())).copied()).collect(); + members.push(pl.clip); + members.sort(); + members.dedup(); + if members.len() < 2 { + continue; + } + let existing = members.iter().find_map(|m| group_of.get(&(pl.seq, *m)).copied()); + let g = existing.unwrap_or_else(|| self.b.link_id()); + for m in members { + group_of.insert((pl.seq, m), g); + } + let _ = (pl.kind, pl.track); + } + for ((seq, clip), g) in group_of { + if let Some(s) = self.b.p.sequence_mut(seq) + && let Some((_, it)) = s.find_item_mut(clip) + { + it.link = Some(g); + } + } + } +} + +fn read_color(v: Node) -> [f32; 4] { + let c = |n: &str, d: f64| (child_f64(v, n).unwrap_or(d) / 255.0) as f32; + [c("red", 0.0), c("green", 0.0), c("blue", 0.0), c("alpha", 255.0)] +} + +fn read_param(p: Node, rate: FrameRate, map: impl Fn(f64) -> f64) -> Param { + let v = child_f64(p, "value").unwrap_or(0.0); + let mut pr = Param::new(ParamValue::Float(map(v))); + for k in children(p, "keyframe") { + let when = child_i64(k, "when").unwrap_or(0); + let kv = child_f64(k, "value").unwrap_or(v); + let mut kf = Keyframe::new(rate.tick_of(when), ParamValue::Float(map(kv))); + kf.interp = keyframe_interp(k); + pr.keyframes.push(kf); + } + pr.keyframes.sort_by_key(|k| k.time); + if let Some(k) = pr.keyframes.first() { + pr.value = k.value.clone(); + } + pr +} + +fn keyframe_interp(k: Node) -> Interpolation { + match path_text(k, &["interpolation", "name"]).map(str::to_ascii_lowercase).as_deref() { + Some("hold") => Interpolation::Hold, + Some("bezier") | Some("fcpcurve") => Interpolation::Bezier, + _ => Interpolation::Linear, + } +} + +fn read_xy(v: Node) -> (f64, f64) { + (child_f64(v, "horiz").unwrap_or(0.0), child_f64(v, "vert").unwrap_or(0.0)) +} + +fn read_point_param(p: Node, rate: FrameRate, map: impl Fn(f64, f64) -> Vec2) -> Param { + let (x, y) = child(p, "value").map(read_xy).unwrap_or((0.0, 0.0)); + let mut pr = Param::new(ParamValue::Vec2(map(x, y))); + for k in children(p, "keyframe") { + let when = child_i64(k, "when").unwrap_or(0); + let (kx, ky) = child(k, "value").map(read_xy).unwrap_or((x, y)); + let mut kf = Keyframe::new(rate.tick_of(when), ParamValue::Vec2(map(kx, ky))); + kf.interp = keyframe_interp(k); + pr.keyframes.push(kf); + } + pr.keyframes.sort_by_key(|k| k.time); + if let Some(k) = pr.keyframes.first() { + pr.value = k.value.clone(); + } + pr +} + +fn read_markers(n: Node, rate: FrameRate, b: &mut Builder) -> Vec { + let mut out = Vec::new(); + for m in children(n, "marker") { + let in_f = child_i64(m, "in").unwrap_or(0); + let out_f = child_i64(m, "out").unwrap_or(-1); + let start = rate.tick_of(in_f); + let duration = if out_f > in_f { rate.tick_of(out_f) - start } else { Tick::ZERO }; + let color = child_text(m, "color").and_then(label_from).unwrap_or(Label::Green); + out.push(Marker { + url: String::new(), + author: String::new(), + ext: None, + id: MarkerId(b.alloc()), + start, + duration, + name: child_text(m, "name").unwrap_or("").to_string(), + comment: child_text(m, "comment").unwrap_or("").to_string(), + kind: MarkerKind::Comment, + color, + }); + } + out.sort_by_key(|m| m.start); + out +} + +// --------------------------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------------------------- + +struct Exp<'a, 'r> { + p: &'a Project, + opts: &'a ExportOptions<'a>, + report: &'r mut Report, + w: XmlWriter, + file_ids: HashMap, + seq_ids: HashMap, + master_ids: HashMap, + writing: HashSet, + written_seqs: HashSet, + next_clip: usize, +} + +pub fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: &mut Report) -> Result { + p.sequence(seq_id).ok_or(Error::NoSequence(seq_id))?; + let mut x = Exp { + p, + opts, + report, + w: XmlWriter::new(Some("xmeml")), + file_ids: HashMap::new(), + seq_ids: HashMap::new(), + master_ids: HashMap::new(), + writing: HashSet::new(), + written_seqs: HashSet::new(), + next_clip: 1, + }; + let v = if opts.xmeml_version == 5 { "5" } else { "4" }; + x.w.open("xmeml", &[("version", v)]); + x.write_master_clips(seq_id); + x.write_sequence(seq_id, true); + x.w.close(); + Ok(x.w.finish()) +} + +impl Exp<'_, '_> { + fn seq_id(&mut self, id: ItemId) -> String { + let n = self.seq_ids.len() + 1; + self.seq_ids.entry(id).or_insert_with(|| format!("sequence-{n}")).clone() + } + + fn write_timecode(&mut self, frame: i64, rate: FrameRate, df: bool) { + self.w.open("timecode", &[]); + write_rate(&mut self.w, rate); + self.w.text("string", format_timecode_frames(frame, rate, df)); + self.w.text("frame", frame); + self.w.text("displayformat", if df { "DF" } else { "NDF" }); + self.w.close(); + } + + fn write_sequence(&mut self, id: ItemId, top: bool) { + let sid = self.seq_id(id); + if self.written_seqs.contains(&id) || self.writing.contains(&id) { + self.w.empty("sequence", &[("id", &sid)]); + return; + } + let Some((it, seq)) = self.p.item(id).and_then(|it| Some((it, it.as_sequence()?))) else { + self.w.empty("sequence", &[("id", &sid)]); + return; + }; + self.writing.insert(id); + let rate = seq.settings.frame_rate; + let df = seq.settings.drop_frame && rate.supports_drop_frame(); + self.w.open("sequence", &[("id", &sid)]); + self.w.text("name", self.opts.name.filter(|_| top).unwrap_or(&it.name)); + self.w.text("duration", frames_round(rate, seq.duration())); + write_rate(&mut self.w, rate); + self.write_timecode(seq.start_timecode, rate, df); + self.w.open("labels", &[]); + self.w.text("label2", it.label.name()); + self.w.close(); + self.w.open("media", &[]); + // Link table: ClipId -> (xml id, kind, track index, clip index). + let mut clip_xml: HashMap = HashMap::new(); + for kind in [TrackKind::Video, TrackKind::Audio] { + for (ti, t) in seq.tracks(kind).iter().enumerate() { + for (ci, c) in t.items.iter().enumerate() { + let xid = format!("clipitem-{}", self.next_clip); + self.next_clip += 1; + clip_xml.insert(c.id, (xid, kind, ti + 1, ci + 1)); + } + } + } + let mut link_members: HashMap> = HashMap::new(); + for t in seq.all_tracks() { + for c in &t.items { + if let Some(l) = c.link { + link_members.entry(l).or_default().push(c.id); + } + } + } + // Each clip lists every member of its group, so a group of n clips writes n * n links. + if let Some(n) = link_members.values().map(Vec::len).filter(|&n| n > MAX_LINK_MEMBERS).max() { + self.report.warn(format!("a link group of {n} clips was exported with its first {MAX_LINK_MEMBERS} members")); + link_members.values_mut().for_each(|m| m.truncate(MAX_LINK_MEMBERS)); + } + if seq.all_tracks().any(|t| t.items.iter().any(|i| i.group.is_some())) { + self.report.info("clip groups have no FCP7 XML equivalent and were not exported"); + } + for kind in [TrackKind::Video, TrackKind::Audio] { + let tag = if kind == TrackKind::Video { "video" } else { "audio" }; + self.w.open(tag, &[]); + self.w.open("format", &[]); + self.w.open("samplecharacteristics", &[]); + if kind == TrackKind::Video { + write_rate(&mut self.w, rate); + self.w.text("width", seq.settings.width); + self.w.text("height", seq.settings.height); + self.w.text("anamorphic", "FALSE"); + self.w.text("pixelaspectratio", if seq.settings.par == (1, 1) { "square" } else { "custom" }); + self.w.text("fielddominance", "none"); + } else { + self.w.text("depth", 16); + self.w.text("samplerate", seq.settings.sample_rate); + } + self.w.close(); + self.w.close(); + for t in seq.tracks(kind) { + self.w.open("track", &[]); + // Clip and transition items in time order. + let mut order: Vec<(Tick, u8, usize)> = t.items.iter().enumerate().map(|(i, c)| (c.start, 1, i)).collect(); + order.extend(t.transitions.iter().enumerate().map(|(i, tr)| (tr.start, 0, i))); + order.sort(); + for (_, k, i) in order { + if k == 1 { + let c = &t.items[i]; + let links: Vec<(String, TrackKind, usize, usize)> = c + .link + .and_then(|l| link_members.get(&l)) + // a clip cut from an over-large group stays unlinked, or its links would restore a larger group on import + .filter(|m| m.contains(&c.id)) + .map(|m| m.iter().filter_map(|id| clip_xml.get(id).cloned()).collect()) + .unwrap_or_default(); + let xid = clip_xml[&c.id].0.clone(); + self.write_clip(c, kind, seq, &xid, &links); + } else { + self.write_transition(&t.transitions[i], kind, rate, t); + } + } + self.w.text("enabled", bool_str(t.enabled)); + self.w.text("locked", bool_str(t.locked)); + self.w.close(); + } + self.w.close(); + } + self.w.close(); // media + write_markers(&mut self.w, &seq.markers, rate); + self.w.close(); // sequence + self.writing.remove(&id); + self.written_seqs.insert(id); + } + + fn write_transition(&mut self, tr: &Transition, kind: TrackKind, rate: FrameRate, t: &crate::model::Track) { + if !on_frame(rate, tr.start) || !on_frame(rate, tr.duration) { + self.report.warn("sub-frame transition positions were rounded to frames"); + } + let align = match (tr.from, tr.to, tr.align) { + (None, Some(_), _) => "start-black", + (Some(_), None, _) => "end-black", + (_, _, TransitionAlign::CenterAtCut) => "center", + (_, _, TransitionAlign::StartAtCut) => "start", + (_, _, TransitionAlign::EndAtCut) => "end", + }; + let _ = t; + self.w.open("transitionitem", &[]); + write_rate(&mut self.w, rate); + self.w.text("start", frames_round(rate, tr.start)); + self.w.text("end", frames_round(rate, tr.end())); + self.w.text("alignment", align); + self.w.open("effect", &[]); + let name = transition_name(self.opts.catalog, &tr.effect); + self.w.text("name", &name); + self.w.text("effectid", &name); + let category = self.opts.catalog.effect(&tr.effect.effect).map(|d| d.category).filter(|c| !c.is_empty()).unwrap_or_else(|| "Dissolve".into()); + self.w.text("effectcategory", category); + self.w.text("effecttype", "transition"); + self.w.text("mediatype", if kind == TrackKind::Video { "video" } else { "audio" }); + self.w.close(); + self.w.close(); + } + + fn master_id(&mut self, item: ItemId) -> String { + let n = self.master_ids.len() + 1; + self.master_ids.entry(item).or_insert_with(|| format!("masterclip-{n}")).clone() + } + + /// Source-media (master clip) markers live on ``, not on timeline ``s. + fn write_master_clips(&mut self, seq_id: ItemId) { + let mut seen = HashSet::new(); + let mut items = Vec::new(); + self.collect_file_media(seq_id, &mut seen, &mut items); + for item in items { + let Some(m) = item_media(self.p, item) else { continue }; + if m.markers.is_empty() { + continue; + } + let rate = m + .info + .video + .as_ref() + .map(|v| m.interpret_frame_rate.unwrap_or(v.frame_rate)) + .unwrap_or_else(|| self.p.sequence(seq_id).map(|s| s.settings.frame_rate).unwrap_or_default()); + let name = self.p.item(item).map(|i| i.name.clone()).unwrap_or_default(); + let label = self.p.item(item).map(|i| i.label).unwrap_or(Label::Iris); + let duration = m.info.duration; + let markers = m.markers.clone(); + let mid = self.master_id(item); + self.w.open("clip", &[("id", &mid)]); + self.w.text("name", name); + self.w.text("duration", frames_round(rate, duration)); + write_rate(&mut self.w, rate); + self.w.open("labels", &[]); + self.w.text("label2", label.name()); + self.w.close(); + self.write_file(item, rate); + write_markers(&mut self.w, &markers, rate); + self.w.close(); + } + } + + fn collect_file_media(&self, seq: ItemId, seen: &mut HashSet, out: &mut Vec) { + if !seen.insert(seq) { + return; + } + let Some(s) = self.p.sequence(seq) else { return }; + for t in s.all_tracks() { + for c in &t.items { + let base = base_item(self.p, c.item); + match self.p.item(base).map(|i| &i.kind) { + Some(ItemKind::Sequence(_)) => self.collect_file_media(base, seen, out), + Some(ItemKind::Media(_)) if crate::build::generator_of(self.p, base).is_none() && !out.contains(&base) => out.push(base), + _ => {} + } + } + } + } + + fn write_clip(&mut self, c: &TrackItem, kind: TrackKind, seq: &Sequence, xid: &str, links: &[(String, TrackKind, usize, usize)]) { + let rate = seq.settings.frame_rate; + if !on_frame(rate, c.start) || !on_frame(rate, c.duration) { + self.report.warn("sub-frame clip positions were rounded to frames"); + } + if let Some(clip) = crate::build::uncarried_clip(self.p, c, rate) { + self.report.warn(format!( + "{clip} has no Final Cut Pro 7 XML equivalent: it is written as a clip item without media and is not read back on import" + )); + } + let base = base_item(self.p, c.item); + let generator = crate::build::generator_of(self.p, base).cloned(); + let is_seq = matches!(self.p.item(base).map(|i| &i.kind), Some(ItemKind::Sequence(_))); + let tag = if generator.is_some() { "generatoritem" } else { "clipitem" }; + let hold = c.frame_hold; + let src_in = hold.unwrap_or(c.source_in); + let src_out = if hold.is_some() { src_in + c.duration } else { c.source_out() }; + let (start_f, end_f, in_f) = (frames_round(rate, c.start), frames_round(rate, c.end()), frames_round(rate, src_in)); + // At normal speed (and on a freeze frame) the source span is the record span. Rounded on its own + // it can come out a frame shorter or longer than `end - start` (filmcraft#340), so derive it from them. + let out_f = + if hold.is_some() || c.speed.abs() == 1.0 { in_f.saturating_add(end_f.saturating_sub(start_f)) } else { frames_round(rate, src_out) }; + self.w.open(tag, &[("id", xid)]); + if generator.is_none() && !is_seq { + let mid = self.master_id(base); + self.w.text("masterclipid", mid); + } + self.w.text("name", &c.name); + self.w.text("enabled", bool_str(c.enabled)); + let media_dur = self.p.item(base).map(|i| i.duration()).unwrap_or(Tick::ZERO).max(src_out); + self.w.text("duration", frames_round(rate, media_dur).max(out_f)); + write_rate(&mut self.w, rate); + self.w.text("start", start_f); + self.w.text("end", end_f); + self.w.text("in", in_f); + self.w.text("out", out_f); + self.w.text("pproTicksIn", src_in.0); + self.w.text("pproTicksOut", src_out.0); + if let Some(g) = &generator { + self.write_generator(g); + } else if is_seq { + self.write_sequence(base, false); + } else { + self.write_file(base, rate); + } + if kind == TrackKind::Audio { + self.w.open("sourcetrack", &[]); + self.w.text("mediatype", "audio"); + self.w.text("trackindex", c.source_channels.first().map_or(1, |ch| u32::from(*ch).saturating_add(1))); + self.w.close(); + } + if let Some(blend) = param(c, "opacity", "blend").and_then(|p| p.value.as_f64()).map(|v| v as u32).filter(|v| *v != 0) { + match BLEND_XML.iter().find(|(_, v)| *v == blend) { + Some((n, _)) => self.w.text("compositemode", n), + None => self.report.warn("blend mode not representable in FCP7 XML; Normal used"), + } + } + self.write_filters(c, kind, seq, base); + for (lid, lkind, ti, ci) in links { + self.w.open("link", &[]); + self.w.text("linkclipref", lid); + self.w.text("mediatype", if *lkind == TrackKind::Video { "video" } else { "audio" }); + self.w.text("trackindex", ti); + self.w.text("clipindex", ci); + self.w.close(); + } + self.w.open("labels", &[]); + self.w.text("label2", c.label.name()); + self.w.close(); + write_markers(&mut self.w, &c.markers, rate); + self.w.close(); + } + + fn write_generator(&mut self, g: &Generator) { + self.w.open("effect", &[]); + match g { + Generator::BlackVideo => { + self.w.text("name", "Slug"); + self.w.text("effectid", "slug"); + } + Generator::ColorMatte { color } => { + self.w.text("name", "Color"); + self.w.text("effectid", "Color"); + self.w.text("effectcategory", "Matte"); + self.w.text("effecttype", "generator"); + self.w.text("mediatype", "video"); + self.w.open("parameter", &[]); + self.w.text("parameterid", "fillcolor"); + self.w.text("name", "Color"); + write_color(&mut self.w, *color); + self.w.close(); + self.w.close(); + return; + } + // by the name the reader (and other editors) know them by + Generator::TransparentVideo | Generator::CountingLeader | Generator::BarsAndTone => { + self.w.text("name", g.label()); + self.w.text("effectid", g.label()); + } + Generator::Other { name, data } => { + self.report.info(format!("generator \"{name}\" is written with an effect id only {} reads", self.opts.namespace)); + self.w.text("name", name); + self.w.text("effectid", format!("{}:{data}", self.opts.namespace)); + } + } + self.w.text("effectcategory", "Matte"); + self.w.text("effecttype", "generator"); + self.w.text("mediatype", "video"); + self.w.close(); + } + + fn write_file(&mut self, item: ItemId, seq_rate: FrameRate) { + if let Some(fid) = self.file_ids.get(&item) { + let fid = fid.clone(); + self.w.empty("file", &[("id", &fid)]); + return; + } + let fid = format!("file-{}", self.file_ids.len() + 1); + self.file_ids.insert(item, fid.clone()); + let Some(m) = item_media(self.p, item) else { + self.w.empty("file", &[("id", &fid)]); + return; + }; + let name = self.p.item(item).map(|i| i.name.clone()).unwrap_or_default(); + let frate = m.info.video.as_ref().map(|v| m.interpret_frame_rate.unwrap_or(v.frame_rate)).unwrap_or(seq_rate); + self.w.open("file", &[("id", &fid)]); + let path = item_path(self.p, item).unwrap_or(""); + self.w.text("name", if name.is_empty() { file_name(path).to_string() } else { name }); + if crate::path::is_absolute(path) { + self.w.text("pathurl", path_to_file_url(path, true)); + } else { + self.w.text("pathurl", path); + } + write_rate(&mut self.w, frate); + self.w.text("duration", frames_round(frate, m.info.duration)); + let tc = m.info.start_timecode.unwrap_or(0); + self.write_timecode(tc, frate, false); + self.w.open("media", &[]); + if let Some(v) = &m.info.video { + self.w.open("video", &[]); + self.w.open("samplecharacteristics", &[]); + write_rate(&mut self.w, frate); + self.w.text("width", v.width); + self.w.text("height", v.height); + self.w.text("anamorphic", "FALSE"); + self.w.text("pixelaspectratio", if v.par == (1, 1) { "square" } else { "custom" }); + self.w.text("fielddominance", "none"); + self.w.close(); + self.w.close(); + } + if let Some(a) = m.info.audio() { + self.w.open("audio", &[]); + self.w.open("samplecharacteristics", &[]); + self.w.text("depth", a.bits_per_sample.unwrap_or(16)); + self.w.text("samplerate", a.sample_rate); + self.w.close(); + self.w.text("channelcount", a.channels); + self.w.close(); + } + self.w.close(); + self.w.close(); + } + + fn write_filters(&mut self, c: &TrackItem, kind: TrackKind, seq: &Sequence, base: ItemId) { + let rate = seq.settings.frame_rate; + let (fw, fh) = (seq.settings.width as f64, seq.settings.height as f64); + let (sw, sh) = match self.p.item(base).map(|i| &i.kind) { + Some(ItemKind::Media(m)) => m.info.video.as_ref().map(|v| (v.width as f64, v.height as f64)).unwrap_or((fw, fh)), + Some(ItemKind::Sequence(s)) => (s.settings.width as f64, s.settings.height as f64), + _ => (fw, fh), + }; + if kind == TrackKind::Video { + let motion_changed = ["position", "scale", "scale_width", "rotation", "anchor", "anti_flicker"] + .iter() + .any(|p| param_modified(self.opts.catalog, c, "motion", p)); + if motion_changed { + if param_modified(self.opts.catalog, c, "motion", "scale_width") + || param(c, "motion", "uniform_scale").and_then(|p| p.value.as_bool()) == Some(false) + { + self.report.warn("non-uniform scale is not supported by FCP7 Basic Motion; uniform scale written"); + } + let enabled = c.effect("motion").is_none_or(|e| e.enabled); + self.open_effect("Basic Motion", "basic", "motion", "motion", "video", enabled); + self.float_param( + c, + FloatParam { effect: "motion", pid: "scale", label: "Scale", min: 0.0, max: 1000.0, xml_id: "scale" }, + rate, + |v| v, + ); + self.float_param( + c, + FloatParam { effect: "motion", pid: "rotation", label: "Rotation", min: -8640.0, max: 8640.0, xml_id: "rotation" }, + rate, + |v| v, + ); + self.point_param(c, PointParam { effect: "motion", pid: "position", label: "Center", xml_id: "center", size: (fw, fh) }, rate, |v| { + ((v.x - fw / 2.0) / fw, (v.y - fh / 2.0) / fh) + }); + self.point_param( + c, + PointParam { effect: "motion", pid: "anchor", label: "Anchor Point", xml_id: "centerOffset", size: (sw, sh) }, + rate, + |v| ((v.x - sw / 2.0) / sw, (v.y - sh / 2.0) / sh), + ); + if param_modified(self.opts.catalog, c, "motion", "anti_flicker") { + self.float_param( + c, + FloatParam { effect: "motion", pid: "anti_flicker", label: "Anti-flicker Filter", min: 0.0, max: 1.0, xml_id: "antiflicker" }, + rate, + |v| v, + ); + } + self.close_effect(); + } + if param_modified(self.opts.catalog, c, "opacity", "opacity") { + self.open_effect("Opacity", "opacity", "motion", "motion", "video", true); + self.float_param( + c, + FloatParam { effect: "opacity", pid: "opacity", label: "opacity", min: 0.0, max: 100.0, xml_id: "opacity" }, + rate, + |v| v, + ); + self.close_effect(); + } + if c.speed != 1.0 || c.reverse || c.frame_hold.is_some() { + if param(c, "time_remap", "speed").is_some_and(|p| p.is_animated()) { + self.report.warn("speed ramps (time remapping keyframes) are not exported to FCP7 XML"); + } + self.open_effect("Time Remap", "timeremap", "motion", "motion", "video", true); + let sp = if c.frame_hold.is_some() { 0.0 } else { c.speed * 100.0 }; + self.simple_param("variablespeed", "variablespeed", "0"); + self.simple_param("speed", "speed", &num(sp)); + self.simple_param("reverse", "reverse", bool_str(c.reverse)); + self.simple_param("frameblending", "frameblending", "FALSE"); + self.close_effect(); + } + } else { + if c.gain_db != 0.0 { + self.report.info("clip gain was folded into Audio Levels"); + } + if param_modified(self.opts.catalog, c, "volume", "level") || c.gain_db != 0.0 { + let g = c.gain_db; + self.open_effect("Audio Levels", "audiolevels", "audiolevels", "audiolevels", "audio", true); + self.float_param( + c, + FloatParam { effect: "volume", pid: "level", label: "Level", min: 0.0, max: 3.98109, xml_id: "level" }, + rate, + move |v| db_to_gain(v + g), + ); + self.close_effect(); + } + // A one-channel clip says where it is panned even when centred: without Audio Pan the + // importer pans the halves of a split stereo file to their sides (filmcraft#371). + if param_modified(self.opts.catalog, c, "panner", "balance") || c.source_channels.len() == 1 { + self.open_effect("Audio Pan", "audiopan", "audiopan", "audiopan", "audio", true); + self.float_param(c, FloatParam { effect: "panner", pid: "balance", label: "Pan", min: -1.0, max: 1.0, xml_id: "pan" }, rate, |v| { + v / 100.0 + }); + self.close_effect(); + } + if param_modified(self.opts.catalog, c, "channel_volume", "left") || param_modified(self.opts.catalog, c, "channel_volume", "right") { + self.report.warn("Channel Volume is not supported by FCP7 XML and was not exported"); + } + } + for e in standard_effects(self.opts.catalog, c) { + self.generic_effect(e, kind, rate); + } + } + + fn open_effect(&mut self, name: &str, id: &str, category: &str, etype: &str, media: &str, enabled: bool) { + self.w.open("filter", &[]); + if !enabled { + self.w.text("enabled", "FALSE"); + } + self.w.open("effect", &[]); + self.w.text("name", name); + self.w.text("effectid", id); + self.w.text("effectcategory", category); + self.w.text("effecttype", etype); + self.w.text("mediatype", media); + } + + fn close_effect(&mut self) { + self.w.close(); + self.w.close(); + } + + fn simple_param(&mut self, id: &str, name: &str, value: &str) { + self.w.open("parameter", &[]); + self.w.text("parameterid", id); + self.w.text("name", name); + self.w.text("value", value); + self.w.close(); + } + + fn float_param(&mut self, c: &TrackItem, spec: FloatParam, rate: FrameRate, map: impl Fn(f64) -> f64) { + let FloatParam { effect, pid, label, min, max, xml_id } = spec; + let default = self.opts.catalog.effect(effect).and_then(|d| d.param(pid).and_then(|p| p.default.as_f64())).unwrap_or(0.0); + let p = param(c, effect, pid).cloned().unwrap_or_else(|| Param::new(ParamValue::Float(default))); + self.w.open("parameter", &[]); + self.w.text("parameterid", xml_id); + self.w.text("name", label); + self.w.text("valuemin", num(min)); + self.w.text("valuemax", num(max)); + self.w.text("value", num(map(p.value.as_f64().unwrap_or(default)))); + self.keyframes(&p, rate, |v| v.as_f64().map(|x| num(map(x))), None::); + self.w.close(); + } + + fn point_param(&mut self, c: &TrackItem, spec: PointParam, rate: FrameRate, map: impl Fn(Vec2) -> (f64, f64)) { + let PointParam { effect, pid, label, xml_id, size } = spec; + let centre = Vec2::new(size.0 / 2.0, size.1 / 2.0); + let fix = |v: Vec2| Vec2::new(if v.x.is_nan() { centre.x } else { v.x }, if v.y.is_nan() { centre.y } else { v.y }); + let p = param(c, effect, pid).cloned().unwrap_or_else(|| Param::new(ParamValue::Vec2(centre))); + self.w.open("parameter", &[]); + self.w.text("parameterid", xml_id); + self.w.text("name", label); + let (x, y) = map(fix(p.value.as_vec2().unwrap_or(centre))); + self.w.open("value", &[]); + self.w.text("horiz", num(x)); + self.w.text("vert", num(y)); + self.w.close(); + for k in &p.keyframes { + let (kx, ky) = map(fix(k.value.as_vec2().unwrap_or(centre))); + self.w.open("keyframe", &[]); + self.w.text("when", frames_round(rate, k.time)); + self.w.open("value", &[]); + self.w.text("horiz", num(kx)); + self.w.text("vert", num(ky)); + self.w.close(); + self.write_interp(k.interp); + self.w.close(); + } + self.w.close(); + } + + fn write_interp(&mut self, i: Interpolation) { + match i { + Interpolation::Linear => {} + Interpolation::Hold => { + self.w.open("interpolation", &[]); + self.w.text("name", "hold"); + self.w.close(); + } + _ => { + self.report.info("Bezier keyframe easing is written as FCP curve keyframes (handles not preserved)"); + self.w.open("interpolation", &[]); + self.w.text("name", "FCPCurve"); + self.w.close(); + } + } + } + + fn keyframes( + &mut self, + p: &Param, + rate: FrameRate, + val: impl Fn(&ParamValue) -> Option, + _custom: Option, + ) { + for k in &p.keyframes { + let Some(v) = val(&k.value) else { continue }; + if !on_frame(rate, k.time) { + self.report.info("sub-frame keyframes were rounded to frames"); + } + self.w.open("keyframe", &[]); + self.w.text("when", frames_round(rate, k.time)); + self.w.text("value", v); + self.write_interp(k.interp); + self.w.close(); + } + } + + fn generic_effect(&mut self, e: &EffectInstance, kind: TrackKind, rate: FrameRate) { + let Some(def) = self.opts.catalog.effect(&e.effect) else { + self.report.warn(format!("unknown effect \"{}\" was not exported", e.effect)); + return; + }; + let media = if kind == TrackKind::Video { "video" } else { "audio" }; + // the name an export has always written, so a UI rename does not change the file + let category = if def.category.is_empty() { "Filter" } else { &def.category }; + self.open_effect(&def.name, &def.id, category, "filter", media, e.enabled); + for pd in &def.params { + let Some(p) = e.params.get(&pd.id) else { continue }; + match &p.value { + ParamValue::Float(v) => { + self.w.open("parameter", &[]); + self.w.text("parameterid", &pd.id); + self.w.text("name", &pd.label); + self.w.text("value", num(*v)); + self.keyframes(p, rate, |v| v.as_f64().map(num), None::); + self.w.close(); + } + ParamValue::Vec2(v) => { + self.w.open("parameter", &[]); + self.w.text("parameterid", &pd.id); + self.w.text("name", &pd.label); + self.w.open("value", &[]); + self.w.text("horiz", num(if v.x.is_nan() { 0.0 } else { v.x })); + self.w.text("vert", num(if v.y.is_nan() { 0.0 } else { v.y })); + self.w.close(); + self.w.close(); + if p.is_animated() { + self.report.warn(format!("point keyframes of \"{}\" were not exported", def.name)); + } + } + ParamValue::Bool(b) => self.simple_param(&pd.id, &pd.label, bool_str(*b)), + ParamValue::Choice(c) => self.simple_param(&pd.id, &pd.label, &c.to_string()), + ParamValue::Text(t) => self.simple_param(&pd.id, &pd.label, t), + ParamValue::Color(c) => { + self.w.open("parameter", &[]); + self.w.text("parameterid", &pd.id); + self.w.text("name", &pd.label); + write_color(&mut self.w, *c); + self.w.close(); + } + ParamValue::Opaque(_) => { + self.report.warn(format!("curve parameters of \"{}\" are not supported by FCP7 XML", def.name)); + } + } + } + self.close_effect(); + } +} + +fn write_color(w: &mut XmlWriter, c: [f32; 4]) { + let b = |v: f32| ((v.clamp(0.0, 1.0) * 255.0).round() as i64).to_string(); + w.open("value", &[]); + w.text("alpha", b(c[3])); + w.text("red", b(c[0])); + w.text("green", b(c[1])); + w.text("blue", b(c[2])); + w.close(); +} + +fn write_markers(w: &mut XmlWriter, markers: &[Marker], rate: FrameRate) { + for m in markers { + w.open("marker", &[]); + w.text("comment", &m.comment); + w.text("name", &m.name); + let i = frames_round(rate, m.start); + w.text("in", i); + w.text("out", if m.duration > Tick::ZERO { frames_round(rate, m.start + m.duration) } else { -1 }); + w.text("color", m.color.name()); + w.close(); + } +} + +/// Frame rate and frame size an imported track's clips are read against. +#[derive(Clone, Copy)] +struct TrackFormat { + rate: FrameRate, + frame: (u32, u32), +} + +/// Identity, label and range of a float effect parameter in the exported XML. +struct FloatParam<'a> { + effect: &'a str, + pid: &'a str, + label: &'a str, + min: f64, + max: f64, + xml_id: &'a str, +} + +/// Identity, label and frame size of a point effect parameter in the exported XML. +struct PointParam<'a> { + effect: &'a str, + pid: &'a str, + label: &'a str, + xml_id: &'a str, + size: (f64, f64), +} diff --git a/crates/timeline/src/lib.rs b/crates/timeline/src/lib.rs index b916743b2..3ad1cca5f 100644 --- a/crates/timeline/src/lib.rs +++ b/crates/timeline/src/lib.rs @@ -13,6 +13,7 @@ //! builds a [`model::Project`] while reading. //! - [`ale`]: Avid Log Exchange clip logs. //! - [`edl`]: CMX 3600 edit decision lists. +//! - [`fcp7`] (feature `xml`): Final Cut Pro 7 XML (`xmeml`). //! - [`Format`], [`detect_text`], [`decode_text`]: which interchange format a document is. //! //! No file I/O: documents are text or bytes, and media references are strings. Each format module @@ -22,12 +23,16 @@ pub mod ale; pub mod build; pub mod edl; +#[cfg(feature = "xml")] +pub mod fcp7; pub mod fx; pub mod model; pub mod path; pub mod rate; pub mod report; pub mod time; +#[cfg(feature = "xml")] +mod xml; pub use build::Imported; pub use report::{Level, Report, ReportEntry}; @@ -107,6 +112,13 @@ pub enum Error { Other(String), } +impl Error { + #[cfg_attr(not(feature = "xml"), allow(dead_code))] + pub(crate) fn parse(format: Format, message: impl Into) -> Error { + Error::Parse { format: format.name(), message: message.into() } + } +} + impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -145,6 +157,8 @@ pub struct ExportOptions<'a> { pub catalog: &'a dyn model::Catalog, /// See [`ImportOptions::namespace`]. pub namespace: &'a str, + /// `xmeml` version attribute (4 or 5). + pub xmeml_version: u32, } /// Sniff which text format (EDL, xmeml, FCPXML, OTIO) a document is from its bytes, using the file diff --git a/crates/timeline/src/xml.rs b/crates/timeline/src/xml.rs new file mode 100644 index 000000000..ddec3265a --- /dev/null +++ b/crates/timeline/src/xml.rs @@ -0,0 +1,199 @@ +//! XML for the xmeml and FCPXML modules: bounded reading and pretty writing through craft-xml, +//! with the small node view their readers are written against. + +use craft_xml::{Doctype, El, Limits, Options, UnknownEntity}; + +/// The reader's limits: craft-xml's defaults, which stop a document at 256 levels of nesting +/// (real timelines nest a few levels per nested sequence), 2 000 000 nodes and 256 MiB. +pub const LIMITS: Limits = Limits::DEFAULT; + +/// Read a document strictly. A plain `` is skipped; a declaration with an +/// internal subset is refused, and so is a reference to an entity XML does not define, so +/// nothing in a document can expand beyond its own size. +pub(crate) fn parse(text: &str) -> Result { + let options = + Options { doctype: Doctype::RejectInternalSubset, unknown_entity: UnknownEntity::Error, ..Options::default() }.strict().with_limits(LIMITS); + options.parse(text.as_bytes()).map_err(|e| e.to_string()) +} + +/// An element of a parsed document. +#[derive(Clone, Copy)] +pub(crate) struct Node<'a>(pub &'a El); + +impl<'a> Node<'a> { + pub fn name(self) -> &'a str { + self.0.local() + } + pub fn has_tag_name(self, name: &str) -> bool { + self.0.local() == name + } + pub fn attribute(self, name: &str) -> Option<&'a str> { + self.0.attr(name) + } + /// Child elements. + pub fn children(self) -> impl Iterator> { + self.0.elements().map(Node) + } + /// This element and every element below it, in document order. + pub fn descendants(self) -> impl Iterator> { + std::iter::once(self).chain(self.0.descendants().map(Node)) + } + /// The text this element starts with, if it starts with text. + pub fn text(self) -> Option<&'a str> { + match self.0.children.first() { + Some(craft_xml::Node::Text(t) | craft_xml::Node::CData(t)) => Some(t.as_str()), + _ => None, + } + } + /// A number that is this element's alone while the document is alive. + pub fn id(self) -> usize { + std::ptr::from_ref(self.0) as usize + } +} + +pub(crate) fn child<'a>(n: Node<'a>, name: &str) -> Option> { + n.children().find(|c| c.name() == name) +} + +pub(crate) fn children<'a>(n: Node<'a>, name: &'static str) -> impl Iterator> { + n.children().filter(move |c| c.name() == name) +} + +pub(crate) fn elements<'a>(n: Node<'a>) -> impl Iterator> { + n.children() +} + +/// Trimmed text of a node's direct text content. +pub(crate) fn text(n: Node<'_>) -> &str { + n.text().map(str::trim).unwrap_or("") +} + +/// Text of the element at `path` below `n`. +pub(crate) fn path_text<'a>(n: Node<'a>, path: &[&str]) -> Option<&'a str> { + let mut cur = n; + for p in path { + cur = child(cur, p)?; + } + Some(text(cur)) +} + +pub(crate) fn child_text<'a>(n: Node<'a>, name: &str) -> Option<&'a str> { + child(n, name).map(text) +} + +pub(crate) fn child_i64(n: Node<'_>, name: &str) -> Option { + child_text(n, name).and_then(|t| t.parse::().ok().or_else(|| t.parse::().ok().map(|f| f.round() as i64))) +} + +pub(crate) fn child_f64(n: Node<'_>, name: &str) -> Option { + child_text(n, name).and_then(|t| t.parse::().ok()) +} + +pub(crate) fn child_bool(n: Node<'_>, name: &str) -> Option { + child_text(n, name).map(|t| t.eq_ignore_ascii_case("true") || t == "1") +} + +pub(crate) fn bool_str(b: bool) -> &'static str { + if b { "TRUE" } else { "FALSE" } +} + +/// Format an f64 compactly (no trailing zeros, no exponent for normal ranges). +pub(crate) fn num(v: f64) -> String { + if v == v.round() && v.abs() < 1e15 { + return format!("{}", v as i64); + } + if !v.is_finite() { + return "0".into(); + } + // Shortest representation that parses back to the same f64. + format!("{v}") +} + +/// Pretty-printing XML writer (two-space indent, one element per line). +pub(crate) struct XmlWriter(craft_xml::Writer); + +impl XmlWriter { + pub fn new(doctype: Option<&str>) -> Self { + let mut w = craft_xml::Writer::pretty(" "); + w.raw("\n"); + if let Some(d) = doctype { + w.doctype(d); + } + Self(w) + } + pub fn open(&mut self, name: &str, attrs: &[(&str, &str)]) { + self.0.open(name, attrs); + } + pub fn close(&mut self) { + self.0.close(); + } + pub fn empty(&mut self, name: &str, attrs: &[(&str, &str)]) { + self.0.empty(name, attrs); + } + /// `value`. + pub fn text_attrs(&mut self, name: &str, attrs: &[(&str, &str)], value: &str) { + self.0.leaf(name, attrs, value); + } + pub fn text(&mut self, name: &str, value: impl std::fmt::Display) { + self.text_attrs(name, &[], &value.to_string()); + } + pub fn finish(self) -> String { + self.0.finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writer_roundtrip() { + let mut w = XmlWriter::new(Some("xmeml")); + w.open("a", &[("x", "1 & 2")]); + w.text("b", ""); + w.empty("c", &[]); + let s = w.finish(); + assert_eq!(s, "\n\n\n <hi>\n \n\n"); + let d = parse(&s).unwrap(); + let a = Node(&d); + assert_eq!(a.attribute("x"), Some("1 & 2")); + assert_eq!(child_text(a, "b"), Some("")); + assert!(child(a, "c").is_some()); + assert_eq!(a.descendants().map(Node::name).collect::>(), ["a", "b", "c"]); + assert_ne!(child(a, "b").map(Node::id), child(a, "c").map(Node::id)); + assert_eq!(num(1.5), "1.5"); + assert_eq!(num(100.0), "100"); + assert_eq!(num(-0.0), "0"); + assert_eq!(num(0.1 + 0.2).parse::().unwrap(), 0.1 + 0.2); + } + + #[test] + fn attr_whitespace_roundtrip() { + let mut w = XmlWriter::new(None); + w.open("a", &[("name", "x\ny\tz\r")]); + w.text("b", "a\nb"); + let s = w.finish(); + let d = parse(&s).unwrap(); + let a = Node(&d); + assert_eq!(a.attribute("name"), Some("x\ny\tz\r")); + assert_eq!(child(a, "b").and_then(Node::text), Some("a\nb")); + } + + #[test] + fn rejects_internal_dtd_entity_expansion() { + let text = "]>&word;"; + assert!(parse(text).is_err()); + assert!(parse("&word;").is_err(), "an entity XML does not define"); + assert!(parse("\n").is_ok()); + } + + /// Elements nested beyond the limit are an error, not a deep recursion. + #[test] + fn nesting_is_bounded() { + let nested = |depth: usize| format!("{}{}", "".repeat(depth), "".repeat(depth)); + assert!(parse(&nested(LIMITS.max_depth)).is_ok()); + let e = parse(&nested(LIMITS.max_depth + 1)).unwrap_err(); + assert!(e.contains("deeper"), "{e}"); + assert!(parse(&nested(100_000)).is_err()); + } +} diff --git a/crates/timeline/tests/it/fcp7.rs b/crates/timeline/tests/it/fcp7.rs new file mode 100644 index 000000000..57e19de97 --- /dev/null +++ b/crates/timeline/tests/it/fcp7.rs @@ -0,0 +1,758 @@ +//! 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 = "24TRUE"; + +fn premiere_style_doc() -> String { + format!( + r#" + + + + Lighthouse + + + Footage + + + tower.mov + 2400 + {RATE} + Mango + + + + + + Main Edit + 130 + {RATE} + {RATE}01:00:00:0086400NDF + + + + + Check sky30-1 + + + Titles Nest + {RATE} + + + + + + + +"# + ) +} + +#[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("\n\n")); + assert!(text.contains("file://localhost/m/a%20b.mov"), "{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::>(), + ps.markers.iter().map(|m| (&m.name, m.start, m.color)).collect::>() + ); + 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::>(), + pv2.markers.iter().map(|m| (&m.name, m.start, m.duration)).collect::>() + ); + 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("Lumetri Color"), "{fcp7}"); + assert!(fcp7.contains("Color Correction"), "{fcp7}"); +} + +#[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("")); + assert!(text.contains("slug")); + 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 { + (0usize..3, 0i64..3, 20i64..150, 0usize..3, 0i64..2000, 0u8..5, prop::bool::weighted(0.9), 0u8..6, 1i64..9, any::()).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; 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#"take.movfile://localhost/media/take.mov{RATE}120"# + ); + let doc = format!( + r#" + +Order + Stringouts + All takes120{RATE} + + + + FootageComedy + take120{RATE} + + + +"# + ); + 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("")).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("2"), "{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::>(); + 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("2")); +} + +#[test] +fn audio_sourcetrack_trackindex_2_imports_as_source_channel_1() { + let doc = format!( + r#" + +S48{RATE} + +"# + ); + 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#" + +S48{RATE} + +"# + ); + 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#" + +S48{RATE} + +"# + ) +} + +fn balances(imp: &Imported) -> Vec { + 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#"Audio Panaudiopanaudiopanaudiopanaudio + panPan-110"#; + 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#"S{tb}{ntsc} +{tb}{ntsc}01:00:00:0090000 +"# + ); + let (imported, _) = fcp7_import(&doc, None); + for seq in &imported.sequences { + fcp7_export(&imported.project, *seq, &xopts()); + } + } +} + +/// 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#"S25FALSE +25FALSE00:00:00:0018446744073709551615 +"#; + let (imported, report) = fcp7_import(doc, None); + for seq in &imported.sequences { + fcp7_export(&imported.project, *seq, &xopts()); + 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("").count(), 64 * 64); + assert!(rep.has_warnings(), "{rep}"); +} diff --git a/crates/timeline/tests/it/main.rs b/crates/timeline/tests/it/main.rs new file mode 100644 index 000000000..9a6f0a01f --- /dev/null +++ b/crates/timeline/tests/it/main.rs @@ -0,0 +1,4 @@ +//! Integration tests, built as one test binary: each file is a module. + +mod fcp7; +mod support; diff --git a/crates/timeline/tests/it/support.rs b/crates/timeline/tests/it/support.rs new file mode 100644 index 000000000..7b225bc64 --- /dev/null +++ b/crates/timeline/tests/it/support.rs @@ -0,0 +1,300 @@ +//! Test helpers: building projects, a catalog with a few application effects, and comparing edit +//! structure. +#![allow(dead_code)] + +use std::ops::Range; + +use craft_timeline::fx::Standard; +use craft_timeline::model::{ + AudioStreamInfo, Catalog, ClipId, EffectDef, EffectInstance, EffectKind, Generator, ItemId, ItemKind, Label, MediaClip, MediaInfo, MediaKind, + MediaRef, ParamDef, ParamValue, Project, Sequence, SequenceSettings, Track, TrackId, TrackItem, TrackKind, Transition, TransitionAlign, + TransitionId, VideoStreamInfo, +}; +use craft_timeline::time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; +use craft_timeline::{ExportOptions, ImportOptions, Imported, Report, fcp7}; + +/// [`Standard`] and a few effects an application adds: a blur, a grade, two transitions. +pub struct TestCatalog; + +fn extra() -> Vec { + let def = |id: &str, name: &str, kind, category: &str, params: Vec| EffectDef { + id: id.into(), + name: name.into(), + kind, + category: category.into(), + intrinsic: false, + params, + }; + let float = |id: &str, label: &str, v: f64| ParamDef { id: id.into(), label: label.into(), default: ParamValue::Float(v) }; + vec![ + def("gaussian_blur", "Gaussian Blur", EffectKind::Video, "Blur & Sharpen", vec![float("blurriness", "Blurriness", 0.0)]), + def("lumetri", "Lumetri Color", EffectKind::Video, "Color Correction", vec![float("exposure", "Exposure", 0.0)]), + def("dip_to_white", "Dip to White", EffectKind::VideoTransition, "Dissolve", vec![]), + def("push", "Push", EffectKind::VideoTransition, "Slide", vec![]), + ] +} + +impl Catalog for TestCatalog { + fn effect(&self, id: &str) -> Option { + Standard.effect(id).or_else(|| extra().into_iter().find(|d| d.id == id)) + } + fn effect_by_name(&self, name: &str) -> Option { + Standard.effect_by_name(name).or_else(|| extra().into_iter().find(|d| d.name.eq_ignore_ascii_case(name))) + } + fn intrinsic(&self, kind: TrackKind) -> Vec { + Standard.intrinsic(kind) + } +} + +pub const NS: &str = "testapp"; + +pub fn iopts(base_dir: Option<&str>) -> ImportOptions<'_> { + ImportOptions { base_dir, catalog: &TestCatalog, namespace: NS } +} + +pub fn xopts() -> ExportOptions<'static> { + ExportOptions { relative_to: None, name: None, catalog: &TestCatalog, namespace: NS, xmeml_version: 4 } +} + +pub fn instance(id: &str) -> EffectInstance { + TestCatalog.effect(id).unwrap().instance() +} + +pub fn media_info(name: &str, video: bool, audio: bool, rate: FrameRate, secs: i64) -> MediaInfo { + MediaInfo { + name: name.into(), + kind: if video { MediaKind::Movie } else { MediaKind::AudioOnly }, + duration: Tick(secs * TICKS_PER_SECOND), + video: video.then_some(VideoStreamInfo { width: 1920, height: 1080, frame_rate: rate, par: (1, 1) }), + audio_streams: audio.then_some(AudioStreamInfo { sample_rate: 48_000, channels: 2, bits_per_sample: None }).into_iter().collect(), + start_timecode: None, + } +} + +fn media_clip(media: MediaRef, info: MediaInfo) -> ItemKind { + ItemKind::Media(Box::new(MediaClip { media, info, interpret_frame_rate: None, markers: vec![], offline: false })) +} + +pub fn media(p: &mut Project, path: &str, video: bool, audio: bool, rate: FrameRate) -> ItemId { + let name = path.rsplit('/').next().unwrap().to_string(); + let info = media_info(&name, video, audio, rate, 600); + p.add_item(&name, Label::Iris, media_clip(MediaRef::File { path: path.into() }, info), None) +} + +pub fn generator(p: &mut Project, name: &str, g: Generator) -> ItemId { + let mut info = media_info(name, true, false, FrameRate::FPS_24, 600); + info.kind = MediaKind::Synthetic; + p.add_item(name, Label::Lavender, media_clip(MediaRef::Generator(g), info), None) +} + +/// A sequence with three video and four audio tracks. +pub fn sequence(p: &mut Project, name: &str, rate: FrameRate, df: bool) -> ItemId { + let mut seq = Sequence::new(SequenceSettings { frame_rate: rate, drop_frame: df, ..Default::default() }); + for (kind, n, word) in [(TrackKind::Video, 3, "Video"), (TrackKind::Audio, 4, "Audio")] { + for i in 0..n { + let id = TrackId(p.alloc_id()); + seq.tracks_mut(kind).push(Track::new(id, kind, format!("{word} {}", i + 1))); + } + } + p.add_item(name, Label::Forest, ItemKind::Sequence(Box::new(seq)), None) +} + +/// A clip of `item` as an editor puts one on a track: its intrinsic effects, its length snapped to frames. +pub fn track_item(p: &mut Project, item: ItemId, kind: TrackKind, start: Tick, source: TimeRange, rate: FrameRate) -> TrackItem { + let (name, label) = p.item(item).map(|i| (i.name.clone(), i.label)).unwrap(); + TrackItem { + id: ClipId(p.alloc_id()), + item, + name, + label, + start, + duration: rate.snap_nearest(source.duration).max(rate.frame_duration()), + source_in: source.start, + speed: 1.0, + reverse: false, + enabled: true, + link: None, + group: None, + effects: TestCatalog.intrinsic(kind), + markers: vec![], + gain_db: 0.0, + frame_hold: None, + scale_to_frame: false, + source_channels: vec![], + ext: None, + } +} + +/// Place a clip (frames at the sequence rate). Returns its id. +pub fn clip(p: &mut Project, seq: ItemId, kind: TrackKind, track: usize, item: ItemId, frames: Range, src: i64) -> ClipId { + let rate = p.sequence(seq).unwrap().settings.frame_rate; + let duration = frames.end - frames.start; + let ti = track_item(p, item, kind, rate.tick_of(frames.start), TimeRange::new(rate.tick_of(src), rate.tick_of(duration)), rate); + let id = ti.id; + let t = &mut p.sequence_mut(seq).unwrap().tracks_mut(kind)[track]; + t.items.push(ti); + t.sort(); + id +} + +pub fn link(p: &mut Project, seq: ItemId, clips: &[ClipId]) { + let l = p.alloc_id(); + let s = p.sequence_mut(seq).unwrap(); + for c in clips { + s.find_item_mut(*c).unwrap().1.link = Some(l); + } +} + +pub fn transition( + p: &mut Project, + seq: ItemId, + track: (TrackKind, usize), + effect: &str, + clips: (Option, Option), + frames: Range, + align: TransitionAlign, +) { + let id = TransitionId(p.alloc_id()); + let rate = p.sequence(seq).unwrap().settings.frame_rate; + let t = &mut p.sequence_mut(seq).unwrap().tracks_mut(track.0)[track.1]; + t.transitions.push(Transition { + id, + effect: instance(effect), + start: rate.tick_of(frames.start), + duration: rate.tick_of(frames.end - frames.start), + from: clips.0, + to: clips.1, + align, + reverse: false, + ext: None, + }); + t.sort(); +} + +/// A comparable description of one clip. +#[derive(Clone, Debug, PartialEq)] +pub struct C { + pub start: Tick, + pub dur: Tick, + pub src: Tick, + pub media: String, + pub speed: String, + pub reverse: bool, + pub enabled: bool, +} + +/// A comparable description of one transition (from/to as clip indices on the track). +#[derive(Clone, Debug, PartialEq)] +pub struct T { + pub start: Tick, + pub dur: Tick, + pub effect: String, + pub align: TransitionAlign, + pub from: Option, + pub to: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Tr { + pub clips: Vec, + pub transitions: Vec, +} + +pub fn media_key(p: &Project, item: ItemId) -> String { + match &p.item(item).unwrap().kind { + ItemKind::Media(m) => match &m.media { + MediaRef::File { path } => path.clone(), + MediaRef::Generator(g) => format!("gen:{}", g.label()), + }, + ItemKind::Sequence(_) => format!("seq:{}", p.item(item).unwrap().name), + _ => "other".into(), + } +} + +/// Non-empty tracks of a sequence (trailing empty tracks ignored). +pub fn structure(p: &Project, seq: ItemId, kind: TrackKind) -> Vec { + let s = p.sequence(seq).unwrap(); + let mut out: Vec = s + .tracks(kind) + .iter() + .map(|t| Tr { + clips: t + .items + .iter() + .map(|i| C { + start: i.start, + dur: i.duration, + src: i.frame_hold.unwrap_or(i.source_in), + media: media_key(p, i.item), + speed: if i.frame_hold.is_some() { "hold".into() } else { format!("{:.3}", i.speed) }, + reverse: i.reverse, + enabled: i.enabled, + }) + .collect(), + transitions: t + .transitions + .iter() + .map(|x| T { + 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)), + }) + .collect(), + }) + .collect(); + while out.last().is_some_and(|t| t.clips.is_empty()) { + out.pop(); + } + out +} + +/// Link partition as sets of (kind, track, clip index). +pub fn links(p: &Project, seq: ItemId) -> Vec> { + let s = p.sequence(seq).unwrap(); + let mut groups: std::collections::BTreeMap> = Default::default(); + for (k, kind) in [(0u8, TrackKind::Video), (1, TrackKind::Audio)] { + for (ti, t) in s.tracks(kind).iter().enumerate() { + for (ci, c) in t.items.iter().enumerate() { + if let Some(l) = c.link { + groups.entry(l).or_default().push((k, ti, ci)); + } + } + } + } + let mut v: Vec> = groups.into_values().filter(|g| g.len() > 1).collect(); + for g in &mut v { + g.sort(); + } + v.sort(); + v +} + +pub fn fcp7_export(p: &Project, seq: ItemId, opts: &ExportOptions) -> (String, Report) { + let mut report = Report::default(); + let text = fcp7::export(p, seq, opts, &mut report).expect("export"); + (text, report) +} + +pub fn fcp7_import(text: &str, base_dir: Option<&str>) -> (Imported, Report) { + let mut report = Report::default(); + let imp = fcp7::import(text, &iopts(base_dir), &mut report).unwrap_or_else(|e| panic!("import failed: {e}\n{text}")); + (imp, report) +} + +pub fn fcp7_roundtrip(p: &Project, seq: ItemId, opts: &ExportOptions) -> (Imported, String, Report) { + let (text, _) = fcp7_export(p, seq, opts); + let (imp, rep) = fcp7_import(&text, None); + (imp, text, rep) +} + +pub fn only_seq(imp: &Imported) -> ItemId { + assert_eq!(imp.sequences.len(), 1, "expected one top-level sequence"); + imp.sequences[0] +} + +pub fn f(rate: FrameRate, n: i64) -> Tick { + rate.tick_of(n) +}