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 <namespace>:<payload> in effectid
  (FilmCraft wrote filmcraft:<json>); 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 <namespace>.<effect id> (FilmCraft wrote
filmcraft.<id>). 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.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 20:52:52 -05:00
1 parent d197b1e72f
commit d59bcf56c9
9 files changed
+11 -25

No files matched your search

Generated
-1
View File
@@ -1859,7 +1859,6 @@ dependencies = [
"craft-xml",
"proptest",
"serde",
"serde_json",
]
[[package]]
+1 -5
View File
@@ -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"]
+1 -2
View File
@@ -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.<namespace>`, 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
+1 -1
View File
@@ -1325,7 +1325,7 @@ mod tests {
<asset-clip ref="a1" offset="3600s" duration="5s"><marker start="1s" duration="1/25s" value="m"/></asset-clip></spine></sequence></project></event></library></fcpxml>"#
);
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,
+1 -6
View File
@@ -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<String>) -> Error {
Error::Parse { format: format.name(), message: message.into() }
}
@@ -145,8 +142,6 @@ pub type Result<T> = std::result::Result<T, Error>;
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,
+3 -2
View File
@@ -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<Marker>,
/// 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 {
+2 -2
View File
@@ -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() {
</clipitem></track></video></media></sequence></xmeml>"#;
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));
-1
View File
@@ -2,5 +2,4 @@
mod fcp7;
mod fcpxml;
mod otio;
mod support;
+2 -5
View File
@@ -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<EffectDef> {
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))