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) +}