From d59bcf56c954a5a9097dc639947be01d34252dd1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:52:52 -0500 Subject: [PATCH] craft-timeline: FCPXML on the neutral model (#987) * craft-timeline: a neutral timeline model and the import builder model: a Project of bins and items (media, sequences, subclips, and items no interchange format carries), sequences of tracks with clips, transitions, markers and effects with keyframes. Cut down from FilmCraft's project model to what Final Cut Pro 7 XML, FCPXML and OpenTimelineIO describe; EffectCraft already uses that shape for its interchange. Three things stay the application's: effects (looked up through a Catalog the caller supplies), generators beyond the common ones (Generator::Other with an opaque payload), and per-object extension JSON (ext) that a format can carry unread. fx: the effect and parameter ids the formats name themselves, and Standard, a catalog of just those. build: FilmCraft's import Builder and shared helpers (filmcraft-interchange common.rs at 4bec22bd) on the model, with the effect registry replaced by the catalog, and the bounds every import ends with (Imported::bound, from lib.rs). * craft-timeline: Final Cut Pro 7 XML (xmeml) on the neutral model fcp7: FilmCraft's xmeml reader and writer (filmcraft-interchange fcp7.rs at 4bec22bd) moved onto craft_timeline::model, behind feature xml. - XML is read with craft-xml, strictly: at most 256 levels of nesting (the limit EffectCraft checked with its own depth scan before handing documents over), 2 000 000 nodes, 256 MiB; a document type declaration with an internal subset and entities XML does not define are refused. It is written with craft-xml's Writer. - Effects other than motion, opacity, time remap, audio levels and pan are looked up through the caller's Catalog, by id and by name, and written with the catalog's stable name and category. - An application generator round-trips as : in effectid (FilmCraft wrote filmcraft:); transparent video, counting leader and bars and tone are written by name, which the reader already took. - Finding the sequence a clip item is in is one walk of the document instead of an ancestors() lookup per clip item. tests/it: FilmCraft's FCP7 integration tests (the hand-written Premiere-style document, the rich round trip, the 48-case property round trip, split mono and stereo channels, sub-frame spans, absurd timebases and start timecodes, the link-group bound) on the model, with a test catalog for the four application effects they use. * craft-timeline: FCPXML on the neutral model fcpxml: FilmCraft's FCPXML 1.9-1.11 reader and writer (filmcraft-interchange fcpxml.rs at 4bec22bd) moved onto craft_timeline::model, behind feature xml. XML is read with craft-xml as fcp7 is; filters other than the transform, blend and volume adjustments go through the caller's Catalog by name, and an effect resource's uid is . (FilmCraft wrote filmcraft.). ExportOptions gains fcpxml_version. tests/it/fcpxml.rs: FilmCraft's FCPXML integration tests on the model (the hand-written connected-clips document, hostile lane numbers, standalone video stills, self-referencing compound media, round trips at 1.9, 1.10 and 1.11, the property round trip with its regression seed, percent-decoded relative URLs). The FCP7 tests export to FCPXML again where FilmCraft's did. --- Cargo.lock | 1 - crates/timeline/Cargo.toml | 6 +----- crates/timeline/README.md | 3 +-- crates/timeline/src/fcpxml.rs | 2 +- crates/timeline/src/lib.rs | 7 +------ crates/timeline/src/model.rs | 5 +++-- crates/timeline/tests/it/fcp7.rs | 4 ++-- crates/timeline/tests/it/main.rs | 1 - crates/timeline/tests/it/support.rs | 7 ++----- 9 files changed, 11 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa5ca1407..45ae4e5b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1859,7 +1859,6 @@ dependencies = [ "craft-xml", "proptest", "serde", - "serde_json", ] [[package]] diff --git a/crates/timeline/Cargo.toml b/crates/timeline/Cargo.toml index 3eca9bc5c..59a79ed4b 100644 --- a/crates/timeline/Cargo.toml +++ b/crates/timeline/Cargo.toml @@ -15,18 +15,14 @@ workspace = true serde = ["dep:serde"] # Final Cut Pro 7 XML (`fcp7`), read with craft-xml's bounded parser. xml = ["dep:craft-xml"] -# OpenTimelineIO JSON (`otio`), with serde_json. -otio = ["dep:serde_json"] [dependencies] serde = { workspace = true, optional = true } craft-xml = { workspace = true, optional = true } -serde_json = { workspace = true, optional = true } [dev-dependencies] proptest = { workspace = true } -serde_json = { workspace = true } [[test]] name = "it" -required-features = ["xml", "otio"] +required-features = ["xml"] diff --git a/crates/timeline/README.md b/crates/timeline/README.md index 6397d9d2e..f53be17d6 100644 --- a/crates/timeline/README.md +++ b/crates/timeline/README.md @@ -16,7 +16,6 @@ text formats that are documents of their own. | `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. | | `fcpxml` (feature `xml`) | FCPXML 1.9–1.11 to and from `model`: the primary storyline as V1, connected clips by lane, `ref-clip` compounds as nested sequences, rational times exact, transforms, opacity, volume and their keyframes, retiming through `timeMap`. | -| `otio` (feature `otio`) | OpenTimelineIO JSON to and from `model`: timelines, collections, stacks as nested sequences, gaps, transitions, markers, linear time warps and freeze frames. What OTIO has no schema for goes in `metadata.`, and the model's `ext` is merged into it, so an application's own data round-trips. | | 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 @@ -24,7 +23,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 `xml`: the XML formats, on craft-xml. Feature `otio`: OpenTimelineIO, on serde_json. 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/fcpxml.rs b/crates/timeline/src/fcpxml.rs index 198574718..81342f70c 100644 --- a/crates/timeline/src/fcpxml.rs +++ b/crates/timeline/src/fcpxml.rs @@ -1325,7 +1325,7 @@ mod tests { "# ); let mut report = Report::default(); - let iopts = ImportOptions { base_dir: None, name: None, catalog: &crate::fx::Standard, namespace: "test" }; + let iopts = ImportOptions { base_dir: None, catalog: &crate::fx::Standard, namespace: "test" }; let imported = import(&xml, &iopts, &mut report).expect("import succeeds"); let xopts = ExportOptions { relative_to: None, diff --git a/crates/timeline/src/lib.rs b/crates/timeline/src/lib.rs index b638fd3ec..4de152282 100644 --- a/crates/timeline/src/lib.rs +++ b/crates/timeline/src/lib.rs @@ -15,7 +15,6 @@ //! - [`edl`]: CMX 3600 edit decision lists. //! - [`fcp7`] (feature `xml`): Final Cut Pro 7 XML (`xmeml`). //! - [`fcpxml`] (feature `xml`): FCPXML 1.9–1.11. -//! - [`otio`] (feature `otio`): OpenTimelineIO JSON. //! - [`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 @@ -31,8 +30,6 @@ pub mod fcp7; pub mod fcpxml; pub mod fx; pub mod model; -#[cfg(feature = "otio")] -pub mod otio; pub mod path; pub mod rate; pub mod report; @@ -119,7 +116,7 @@ pub enum Error { } impl Error { - #[cfg_attr(not(any(feature = "xml", feature = "otio")), allow(dead_code))] + #[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() } } @@ -145,8 +142,6 @@ pub type Result = std::result::Result; pub struct ImportOptions<'a> { /// Directory relative media paths are resolved against (normally the document's directory). pub base_dir: Option<&'a str>, - /// Name for a timeline the document leaves unnamed. - pub name: Option<&'a str>, /// The application's effects and generators (see [`model::Catalog`]; [`fx::Standard`] knows /// only what the formats name themselves). pub catalog: &'a dyn model::Catalog, diff --git a/crates/timeline/src/model.rs b/crates/timeline/src/model.rs index 3f8db8c1c..6adff43dd 100644 --- a/crates/timeline/src/model.rs +++ b/crates/timeline/src/model.rs @@ -14,7 +14,7 @@ //! supplies. //! - **Generators** other than the ones every editor has: [`Generator::Other`] carries a name and //! an opaque payload. -//! - **Extensions.** `ext` on items (a sequence's is its item's), tracks, clips, transitions and markers is JSON +//! - **Extensions.** `ext` on items, sequences, tracks, clips, transitions and markers is JSON //! text the application owns. A format that can carry application metadata (OpenTimelineIO) //! writes it under the application's namespace and gives it back on reading; the others ignore it. @@ -540,11 +540,12 @@ pub struct Sequence { pub markers: Vec, /// Timecode of the first frame, in frames. pub start_timecode: i64, + pub ext: Ext, } impl Sequence { pub fn new(settings: SequenceSettings) -> Self { - Self { settings, video_tracks: Vec::new(), audio_tracks: Vec::new(), markers: Vec::new(), start_timecode: 0 } + Self { settings, video_tracks: Vec::new(), audio_tracks: Vec::new(), markers: Vec::new(), start_timecode: 0, ext: None } } /// The end of the last clip. pub fn duration(&self) -> Tick { diff --git a/crates/timeline/tests/it/fcp7.rs b/crates/timeline/tests/it/fcp7.rs index 00d0704ec..dfa3cc186 100644 --- a/crates/timeline/tests/it/fcp7.rs +++ b/crates/timeline/tests/it/fcp7.rs @@ -723,7 +723,7 @@ fn absurd_timebase_is_bounded() { ); let (imported, _) = fcp7_import(&doc, None); for seq in &imported.sequences { - for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio] { + for format in [Format::Fcp7Xml, Format::Fcpxml] { export(&imported.project, *seq, format, &xopts()).unwrap_or_else(|e| panic!("{format:?}: {e}")); } } @@ -742,7 +742,7 @@ fn absurd_start_timecode_is_bounded() { "#; let (imported, report) = fcp7_import(doc, None); for seq in &imported.sequences { - for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio] { + for format in [Format::Fcp7Xml, Format::Fcpxml] { export(&imported.project, *seq, format, &xopts()).unwrap_or_else(|e| panic!("{format:?}: {e}")); } assert_eq!(imported.project.sequence(*seq).map(|s| s.start_timecode), Some(0)); diff --git a/crates/timeline/tests/it/main.rs b/crates/timeline/tests/it/main.rs index f8d222b28..12e73ea72 100644 --- a/crates/timeline/tests/it/main.rs +++ b/crates/timeline/tests/it/main.rs @@ -2,5 +2,4 @@ mod fcp7; mod fcpxml; -mod otio; mod support; diff --git a/crates/timeline/tests/it/support.rs b/crates/timeline/tests/it/support.rs index 1afb703a0..4a3247d78 100644 --- a/crates/timeline/tests/it/support.rs +++ b/crates/timeline/tests/it/support.rs @@ -11,7 +11,7 @@ use craft_timeline::model::{ TransitionId, VideoStreamInfo, }; use craft_timeline::time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; -use craft_timeline::{Error, ExportOptions, Format, ImportOptions, Imported, Report, fcp7, fcpxml, otio}; +use craft_timeline::{Error, ExportOptions, Format, ImportOptions, Imported, Report, fcp7, fcpxml}; /// [`Standard`] and a few effects an application adds: a blur, a grade, two transitions. pub struct TestCatalog; @@ -31,7 +31,6 @@ fn extra() -> Vec { 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![]), - def("iris_round", "Iris Round", EffectKind::VideoTransition, "Iris", vec![]), ] } @@ -50,7 +49,7 @@ impl Catalog for TestCatalog { pub const NS: &str = "testapp"; pub fn iopts(base_dir: Option<&str>) -> ImportOptions<'_> { - ImportOptions { base_dir, name: None, catalog: &TestCatalog, namespace: NS } + ImportOptions { base_dir, catalog: &TestCatalog, namespace: NS } } pub fn xopts() -> ExportOptions<'static> { @@ -308,7 +307,6 @@ pub fn import(bytes: &[u8], format: Format, base_dir: Option<&str>) -> Result<(I let imported = match format { Format::Fcp7Xml => fcp7::import(&text, &opts, &mut report), Format::Fcpxml => fcpxml::import(&text, &opts, &mut report), - Format::Otio => otio::import(&text, &opts, &mut report), _ => Err(Error::Other(format!("{} is not read here", format.name()))), }?; Ok((imported, report)) @@ -320,7 +318,6 @@ pub fn export(p: &Project, seq: ItemId, format: Format, opts: &ExportOptions) -> let text = match format { Format::Fcp7Xml => fcp7::export(p, seq, opts, &mut report), Format::Fcpxml => fcpxml::export(p, seq, opts, &mut report), - Format::Otio => otio::export(p, seq, opts, &mut report), _ => Err(Error::Other(format!("{} is not written here", format.name()))), }?; Ok((text.into_bytes(), report))