From 7abf3186dac8d34434ed858274b868eaa0ed6b6b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 19:44:17 -0500 Subject: [PATCH] craft-timeline: a neutral timeline model and the import builder (#977) 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). --- crates/timeline/README.md | 8 +- crates/timeline/src/build.rs | 500 +++++++++++++++++++ crates/timeline/src/fx.rs | 100 ++++ crates/timeline/src/lib.rs | 63 +++ crates/timeline/src/model.rs | 910 +++++++++++++++++++++++++++++++++++ 5 files changed, 1579 insertions(+), 2 deletions(-) create mode 100644 crates/timeline/src/build.rs create mode 100644 crates/timeline/src/fx.rs create mode 100644 crates/timeline/src/model.rs diff --git a/crates/timeline/README.md b/crates/timeline/README.md index 21706da3e..f3a932d31 100644 --- a/crates/timeline/README.md +++ b/crates/timeline/README.md @@ -9,12 +9,16 @@ text formats that are documents of their own. | `rate` | Frame-rate conventions of documents: `timebase` + `ntsc`, floating rates snapped to exact ones, the highest rate an importer accepts (`MAX_RATE`). | | `report` | `Report`: what an import or export could not represent exactly, identical messages merged with a count. | | `path` | Media references: `file://` URLs (`localhost`, drive letters, UNC hosts, percent-encoding) to and from paths, and relative references joined to a document's folder. Lexical only. | +| `model` | A neutral timeline: a `Project` of bins and items (media with what a document says about it, sequences, subclips), sequences of video and audio `Track`s holding `TrackItem`s (speed, reverse, freeze frame, links, effects with keyframes, markers) and `Transition`s. `Catalog` is the application's effects, supplied by the caller. Every object has an `ext` slot for application JSON a format can carry unread. | +| `fx` | The effect and parameter ids the formats name themselves (motion, opacity, time remap, volume, pan, the common dissolves) and `Standard`, a catalog of just those. | +| `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. | | crate root | `Format` (EDL, xmeml, FCPXML, OTIO, AAF, OMF), `detect_text`, `decode_text`. | -No file I/O. Each format module gives the document as the format writes it; mapping it onto an -application's timeline (which media a reel is, what a dissolve overlaps) stays in the application. +No file I/O. EDL and ALE give the document as the format writes it. The formats that describe a +whole edit read into and write from `model`; mapping `model` onto an application's own project +stays in the application. AAF and OMF are in `craft-aaf`. Feature `serde`: `Serialize` / `Deserialize` for the time, report and `Format` types. diff --git a/crates/timeline/src/build.rs b/crates/timeline/src/build.rs new file mode 100644 index 000000000..597040de6 --- /dev/null +++ b/crates/timeline/src/build.rs @@ -0,0 +1,500 @@ +//! Building a [`Project`] from a document, and the helpers importers and exporters share. + +use std::collections::HashMap; + +use crate::model::{ + AudioStreamInfo, BinId, Catalog, ClipId, EffectInstance, EffectKind, Generator, ItemId, ItemKind, Label, MediaClip, MediaInfo, MediaKind, + MediaRef, Param, ParamValue, Project, Sequence, SequenceSettings, Track, TrackId, TrackItem, TrackKind, VideoStreamInfo, +}; +use crate::path::extension; +use crate::rate::plausible_rate; +use crate::report::Report; +use crate::time::{FrameRate, Tick}; + +/// A timeline import may not materialise an unbounded number of sparse track +/// labels. This remains well above common editorial timelines while keeping +/// hostile lane/channel numbers from allocating every intervening track. +pub const MAX_IMPORTED_TRACKS: usize = 256; + +/// Start timecodes beyond this many frames (about 100 hours at the highest accepted rate) are not +/// timecodes: a value near `i64::MAX` made an export overflow adding frame numbers to it. +pub const MAX_START_TIMECODE: u64 = 360_000_000_000; + +/// The result of an import: a standalone project. +#[derive(Clone, Debug, PartialEq)] +pub struct Imported { + /// Bins, media items and sequences read from the document (ids local to this project). + pub project: Project, + /// Top-level sequences, in document order (nested sequences are items too but not listed here). + pub sequences: Vec, +} + +impl Imported { + /// Reset what a document carries beyond what frame arithmetic can hold, and say so: frame rates + /// above [`crate::rate::MAX_RATE`] per second and start timecodes beyond [`MAX_START_TIMECODE`] + /// frames. Every importer ends with this pass, so no format can let such a number through. + pub fn bound(&mut self, report: &mut Report) { + for item in self.project.items.values_mut() { + let name = item.name.clone(); + if let Some(seq) = item.as_sequence_mut() { + if !plausible_rate(seq.settings.frame_rate) { + report.warn(format!("sequence \"{name}\" has an implausible frame rate ({}); the default was used", seq.settings.frame_rate)); + seq.settings.frame_rate = FrameRate::default(); + } + if seq.start_timecode.unsigned_abs() > MAX_START_TIMECODE { + report.warn(format!("sequence \"{name}\" has an out-of-range start timecode ({} frames); it starts at 0", seq.start_timecode)); + seq.start_timecode = 0; + } + } else if let Some(media) = item.as_media_mut() { + if let Some(v) = media.info.video.as_mut() + && !plausible_rate(v.frame_rate) + { + report.warn(format!("media \"{name}\" has an implausible frame rate ({}); the default was used", v.frame_rate)); + v.frame_rate = FrameRate::default(); + } + if media.info.start_timecode.is_some_and(|tc| tc.unsigned_abs() > MAX_START_TIMECODE) { + report.warn(format!("media \"{name}\" has an out-of-range start timecode; it was ignored")); + media.info.start_timecode = None; + } + } + } + } +} + +/// Media kind guess from a file extension. +pub fn kind_from_ext(path: &str) -> MediaKind { + match extension(path).as_str() { + "wav" | "aif" | "aiff" | "mp3" | "aac" | "m4a" | "flac" | "ogg" | "opus" | "bwf" => MediaKind::AudioOnly, + "png" | "jpg" | "jpeg" | "tif" | "tiff" | "psd" | "bmp" | "gif" | "webp" | "exr" | "dpx" | "tga" => MediaKind::Still, + _ => MediaKind::Movie, + } +} + +/// What a document says about a media file. +#[derive(Clone, Debug, Default)] +pub struct MediaSpec { + pub duration: Option, + /// (width, height, rate). + pub video: Option<(u32, u32, FrameRate)>, + /// (sample rate, channels). + pub audio: Option<(u32, u32)>, + pub start_tc: Option, + pub kind: Option, +} + +pub fn media_info(name: &str, spec: &MediaSpec) -> MediaInfo { + let kind = spec.kind.unwrap_or(match (spec.video.is_some(), spec.audio.is_some()) { + (false, true) => MediaKind::AudioOnly, + _ => MediaKind::Movie, + }); + MediaInfo { + name: name.to_string(), + kind, + duration: spec.duration.unwrap_or(Tick::ZERO), + video: spec.video.map(|(w, h, r)| VideoStreamInfo { width: w, height: h, frame_rate: r, par: (1, 1) }), + audio_streams: spec.audio.map(|(sr, ch)| AudioStreamInfo { sample_rate: sr, channels: ch, bits_per_sample: None }).into_iter().collect(), + start_timecode: spec.start_tc, + } +} + +pub fn media_clip(media: MediaRef, info: MediaInfo) -> MediaClip { + MediaClip { media, info, interpret_frame_rate: None, markers: Vec::new(), offline: false } +} + +/// The file path behind an item (following subclips), if it is file media. +pub fn item_path(p: &Project, id: ItemId) -> Option<&str> { + match &item_media(p, id)?.media { + MediaRef::File { path } => Some(path), + MediaRef::Generator(_) => None, + } +} + +/// The media clip behind an item (following subclips). +pub fn item_media(p: &Project, id: ItemId) -> Option<&MediaClip> { + p.resolve_media(id).map(|(_, m, _)| m) +} + +/// The underlying item for exports (subclip → parent media). +pub fn base_item(p: &Project, id: ItemId) -> ItemId { + let mut cur = id; + for _ in 0..16 { + match p.item(cur).map(|i| &i.kind) { + Some(ItemKind::Subclip { parent, .. }) => cur = *parent, + _ => return cur, + } + } + cur +} + +/// How an export report names a clip whose source no format here can carry (a title, an +/// adjustment layer): `graphic clip "Title" at frame 48`. `None` for every other clip. A lost title +/// must be named, not folded into a count. +pub fn uncarried_clip(p: &Project, c: &TrackItem, rate: FrameRate) -> Option { + let ItemKind::Uncarried { what, .. } = &p.item(c.item)?.kind else { return None }; + Some(format!("{what} \"{}\" at frame {}", c.name, rate.frame_at(c.start))) +} + +pub fn generator_of(p: &Project, id: ItemId) -> Option<&Generator> { + match &item_media(p, id)?.media { + MediaRef::Generator(g) => Some(g), + MediaRef::File { .. } => None, + } +} + +/// Builds the project of an import. +pub struct Builder<'c> { + pub p: Project, + pub catalog: &'c dyn Catalog, + media: HashMap, + pub top: Vec, + next_link: u64, +} + +impl<'c> Builder<'c> { + pub fn new(name: &str, catalog: &'c dyn Catalog) -> Self { + Self { p: Project::new(name), catalog, media: HashMap::new(), top: Vec::new(), next_link: 1 } + } + + pub fn bin(&mut self, name: &str, parent: Option) -> BinId { + self.p.add_bin(name, parent) + } + + pub fn find_media(&self, key: &str) -> Option { + self.media.get(key).copied() + } + + pub fn register_media(&mut self, key: &str, id: ItemId) { + self.media.insert(key.to_string(), id); + } + + /// File media deduplicated by `key` (normally the resolved path). + pub fn file_media(&mut self, key: &str, name: &str, path: &str, spec: &MediaSpec, bin: Option) -> ItemId { + if let Some(id) = self.media.get(key) { + return *id; + } + let info = media_info(name, spec); + let id = self.p.add_item(name, Label::Iris, ItemKind::Media(Box::new(media_clip(MediaRef::File { path: path.to_string() }, info))), bin); + self.media.insert(key.to_string(), id); + id + } + + pub fn generator_media(&mut self, key: &str, name: &str, g: Generator, spec: &MediaSpec, bin: Option) -> ItemId { + if let Some(id) = self.media.get(key) { + return *id; + } + let mut spec = spec.clone(); + spec.kind = Some(MediaKind::Synthetic); + let info = media_info(name, &spec); + let id = self.p.add_item(name, Label::Lavender, ItemKind::Media(Box::new(media_clip(MediaRef::Generator(g), info))), bin); + self.media.insert(key.to_string(), id); + id + } + + /// Add an empty sequence item (filled later with [`Builder::put_sequence`]). + pub fn reserve_sequence(&mut self, name: &str, settings: SequenceSettings, bin: Option) -> ItemId { + let seq = empty_sequence(settings); + self.p.add_item(name, Label::Forest, ItemKind::Sequence(Box::new(seq)), bin) + } + + pub fn put_sequence(&mut self, id: ItemId, mut seq: Sequence) { + for t in seq.all_tracks_mut() { + t.sort(); + } + if let Some(s) = self.p.sequence_mut(id) { + *s = seq; + } + } + + pub fn track(&mut self, kind: TrackKind, index: usize) -> Track { + let id = TrackId(self.p.alloc_id()); + let name = match kind { + TrackKind::Video => format!("Video {}", index + 1), + TrackKind::Audio => format!("Audio {}", index + 1), + }; + Track::new(id, kind, name) + } + + /// Ensure `n` tracks of one kind exist. Returns false when an imported + /// label would exceed the supported per-kind track count. + pub fn ensure_tracks(&mut self, seq: &mut Sequence, kind: TrackKind, n: usize) -> bool { + if n > MAX_IMPORTED_TRACKS { + return false; + } + while seq.tracks(kind).len() < n { + let i = seq.tracks(kind).len(); + let t = self.track(kind, i); + seq.tracks_mut(kind).push(t); + } + true + } + + pub fn link_id(&mut self) -> u64 { + let l = self.next_link; + self.next_link += 1; + l + } + + pub fn alloc(&mut self) -> u64 { + self.p.alloc_id() + } + + /// A track item with the intrinsic effects of its track kind. + pub fn clip(&mut self, item: ItemId, kind: TrackKind, name: &str, start: Tick, duration: Tick, source_in: Tick) -> TrackItem { + let label = self.p.item(item).map(|i| i.label).unwrap_or(Label::Iris); + TrackItem { + id: ClipId(self.p.alloc_id()), + item, + name: name.to_string(), + label, + start, + duration, + source_in, + speed: 1.0, + reverse: false, + enabled: true, + link: None, + group: None, + effects: self.catalog.intrinsic(kind), + markers: Vec::new(), + gain_db: 0.0, + frame_hold: None, + scale_to_frame: false, + source_channels: Vec::new(), + ext: None, + } + } + + /// Place `item` on the first track at or after `preferred` where it does not overlap anything, + /// creating tracks as needed. Returns the track index. + pub fn place(&mut self, seq: &mut Sequence, kind: TrackKind, preferred: usize, item: TrackItem) -> Option { + let mut i = preferred; + loop { + if i >= MAX_IMPORTED_TRACKS || !self.ensure_tracks(seq, kind, i + 1) { + return None; + } + let t = &mut seq.tracks_mut(kind)[i]; + if !t.items.iter().any(|o| o.start < item.end() && item.start < o.end()) { + t.items.push(item); + t.sort(); + return Some(i); + } + i += 1; + } + } + + /// Finish: grow media durations to cover every use, return the fragment. + pub fn finish(mut self) -> Imported { + let mut need: HashMap = HashMap::new(); + for it in self.p.items.values() { + if let Some(seq) = it.as_sequence() { + for t in seq.all_tracks() { + for ti in &t.items { + let e = need.entry(ti.item).or_insert(Tick::ZERO); + *e = (*e).max(ti.source_out()).max(ti.source_in + Tick(1)); + } + } + } + } + for (id, end) in need { + if let Some(m) = self.p.item_mut(id).and_then(|i| i.as_media_mut()) + && m.info.duration < end + { + m.info.duration = end; + } + } + Imported { project: self.p, sequences: self.top } + } +} + +pub fn empty_sequence(settings: SequenceSettings) -> Sequence { + Sequence::new(settings) +} + +pub fn settings_for(rate: FrameRate, width: u32, height: u32, drop_frame: bool) -> SequenceSettings { + SequenceSettings { width, height, frame_rate: rate, drop_frame: drop_frame && rate.supports_drop_frame(), ..SequenceSettings::default() } +} + +/// Source time advanced by a timeline duration at `speed` (exact for integral speeds). +/// +/// This is the one place the importers multiply a `Tick` by a number from a file: `Tick` has no +/// `Mul`, and its `Add`/`Sub` saturate, so every other sum of durations already clamps. It saturates +/// at the `i64` bounds like those operators do (not at `Tick::MAX`, which the editing code uses as +/// its "no next item" sentinel). +pub fn scaled(d: Tick, speed: f64) -> Tick { + let s = speed.abs(); + // saturating: a duration read from a file times a speed from the same file can exceed an i64 of ticks + if s == s.round() && s < 1e6 { Tick(d.0.saturating_mul(s as i64)) } else { Tick((d.0 as f64 * s).round() as i64) } +} + +/// Timeline duration of a source duration at `speed`. +pub fn unscaled(d: Tick, speed: f64) -> Tick { + let s = speed.abs(); + if s == 0.0 { + return d; + } + if s == 1.0 { d } else { Tick((d.0 as f64 / s).round() as i64) } +} + +pub fn param<'a>(ti: &'a TrackItem, effect: &str, param: &str) -> Option<&'a Param> { + ti.effect(effect).and_then(|e| e.param(param)) +} + +/// Set a parameter, adding the effect instance (defaults) if the item lacks it. +pub fn set_param(catalog: &dyn Catalog, ti: &mut TrackItem, effect: &str, param: &str, value: Param) { + if ti.effect(effect).is_none() + && let Some(d) = catalog.effect(effect) + { + ti.effects.push(d.instance()); + } + if let Some(e) = ti.effect_mut(effect) { + e.params.insert(param.to_string(), value); + } +} + +/// Whether a parameter differs from its definition default or is animated. +pub fn param_modified(catalog: &dyn Catalog, ti: &TrackItem, effect: &str, param_id: &str) -> bool { + let Some(p) = param(ti, effect, param_id) else { return false }; + if p.is_animated() { + return true; + } + let def = catalog.effect(effect).and_then(|d| d.param(param_id).map(|p| p.default.clone())); + match (&p.value, def) { + (ParamValue::Vec2(v), Some(ParamValue::Vec2(_))) => !(v.x.is_nan() && v.y.is_nan()), + (v, Some(d)) => *v != d, + _ => false, + } +} + +/// Non-intrinsic effects on an item. +pub fn standard_effects<'a>(catalog: &'a dyn Catalog, ti: &'a TrackItem) -> impl Iterator { + ti.effects.iter().filter(move |e| !catalog.effect(&e.effect).is_some_and(|d| d.intrinsic)) +} + +/// A transition effect for a document's transition name. Unknown names become Cross Dissolve +/// (Constant Power on audio) with a warning. +pub fn transition_effect(catalog: &dyn Catalog, name: &str, audio: bool, report: &mut Report) -> EffectInstance { + let n = name.trim().to_ascii_lowercase(); + let alias = match n.as_str() { + "dissolve" | "cross dissolve" | "smpte_dissolve" | "d" | "crossdissolve" => Some(if audio { "constant_power" } else { "cross_dissolve" }), + "cross fade (+3db)" | "cross fade +3db" | "crossfade (+3db)" | "cross fade" => Some("constant_power"), + "cross fade (0db)" | "cross fade 0db" | "crossfade (0db)" => Some("constant_gain"), + "dip to color dissolve" | "fade in fade out dissolve" | "fade to color" => Some("dip_to_black"), + "edge wipe" | "wipe" | "smpte_wipe" => Some("wipe"), + _ => None, + }; + if let Some(id) = alias + && let Some(d) = catalog.effect(id) + { + return d.instance(); + } + let want = if audio { EffectKind::AudioTransition } else { EffectKind::VideoTransition }; + if let Some(d) = catalog.effect_by_name(name.trim()).filter(|d| d.kind == want) { + return d.instance(); + } + let fallback = if audio { "constant_power" } else { "cross_dissolve" }; + let def = catalog.effect(fallback); + report.warn(format!("transition \"{name}\" is not supported; imported as {}", def.as_ref().map(|d| d.name.as_str()).unwrap_or(fallback))); + def.map(|d| d.instance()).unwrap_or_else(|| EffectInstance { effect: fallback.into(), enabled: true, params: Default::default(), ext: None }) +} + +/// Name of a transition in a document (the catalog's stable name, so a rename in an application's +/// interface does not change the file). +pub fn transition_name(catalog: &dyn Catalog, e: &EffectInstance) -> String { + catalog.effect(&e.effect).map(|d| d.name).unwrap_or_else(|| e.effect.clone()) +} + +/// Label from a document label name (the sixteen label names; names such as "Good Take" are no label). +pub fn label_from(s: &str) -> Option