diff --git a/crates/interchange/src/lib.rs b/crates/interchange/src/lib.rs index 5e6bc9c2..6e57d825 100644 --- a/crates/interchange/src/lib.rs +++ b/crates/interchange/src/lib.rs @@ -23,9 +23,7 @@ pub mod fcp7; pub mod fcpxml; pub mod otio; -#[allow(dead_code)] // helpers shared by importers still being written mod common; -#[allow(dead_code)] mod xml; use std::collections::{BTreeMap, HashMap}; diff --git a/crates/interchange/src/otio.rs b/crates/interchange/src/otio.rs index 59810b9f..0c58d9fc 100644 --- a/crates/interchange/src/otio.rs +++ b/crates/interchange/src/otio.rs @@ -1,13 +1,816 @@ -//! (in progress) +//! OpenTimelineIO JSON (`.otio`). +//! +//! Schemas read and written: `Timeline.1`, `Stack.1`, `Track.1`, `Clip.1`/`Clip.2`, `Gap.1`, +//! `Transition.1`, `Marker.1`/`Marker.2`, `ExternalReference.1`, `MissingReference.1`, +//! `GeneratorReference.1`, `LinearTimeWarp.1`, `FreezeFrame.1`, `Effect.1`, `RationalTime.1`, +//! `TimeRange.1` and `SerializableCollection.1` (several timelines). +//! +//! Times are written as frames at the sequence rate (`rate` is the exact rate as a float, e.g. +//! 23.976023976023978) and read back exactly by snapping the rate to its rational form. Fields +//! OTIO has no schema for (effects and their keyframes, labels, links, groups, clip gain, track +//! state, sequence settings, media info, generators) round-trip through `metadata.filmcraft`. +//! Nested sequences are `Stack` items inside tracks. -use filmcraft_project::{ItemId, Project}; +use std::collections::HashMap; -use crate::{Error, ExportOptions, ImportOptions, Imported, Report, Result}; +use filmcraft_media::{Generator, MediaInfo}; +use filmcraft_project::{ + ClipId, EffectInstance, ItemId, ItemKind, Label, Marker, MarkerId, MarkerKind, MediaRef, ParamValue, Project, Sequence, SequenceSettings, Track, TrackItem, + TrackKind, Transition, TransitionAlign, TransitionId, +}; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use serde_json::{Value, json}; -pub(crate) fn import(_text: &str, _opts: &ImportOptions, _report: &mut Report) -> Result { - Err(Error::Other("not implemented".into())) +use crate::common::{ + Builder, MediaSpec, base_item, empty_sequence, file_name, generator_of, item_media, path_to_file_url, rate_from_f64, relative_path, resolve_path, + settings_for, transition_effect, transition_name, +}; +use crate::{Error, ExportOptions, Format, ImportOptions, Imported, Report, Result}; + +// --------------------------------------------------------------------------------------------- +// Time +// --------------------------------------------------------------------------------------------- + +fn rt(t: Tick, rate: FrameRate) -> Value { + let f = rate.frame_at(t); + let value = if rate.tick_of(f) == t { json!(f as f64) } else { json!(t.0 as f64 * rate.num as f64 / (rate.den as f64 * TICKS_PER_SECOND as f64)) }; + json!({"OTIO_SCHEMA": "RationalTime.1", "rate": rate.as_f64(), "value": value}) } -pub(crate) fn export(_p: &Project, _seq: ItemId, _opts: &ExportOptions, _report: &mut Report) -> Result { - Err(Error::Other("not implemented".into())) +fn tr(start: Tick, dur: Tick, rate: FrameRate) -> Value { + json!({"OTIO_SCHEMA": "TimeRange.1", "start_time": rt(start, rate), "duration": rt(dur, rate)}) +} + +/// RationalTime → ticks (exact for integral values at broadcast rates). +fn read_rt(v: &Value) -> Option { + let rate = v.get("rate")?.as_f64()?; + let value = v.get("value")?.as_f64()?; + let r = rate_from_f64(rate); + if value == value.round() && value.abs() < 9e15 { + return Some(Tick::from_rational(value as i64, r.den, r.num)); + } + Some(Tick((value * TICKS_PER_SECOND as f64 * r.den as f64 / r.num as f64).round() as i64)) +} + +fn read_rate(v: &Value) -> Option { + v.get("rate").and_then(Value::as_f64).map(rate_from_f64) +} + +fn read_tr(v: &Value) -> Option<(Tick, Tick)> { + if v.is_null() { + return None; + } + Some((read_rt(v.get("start_time")?)?, read_rt(v.get("duration")?)?)) +} + +fn schema(v: &Value) -> &str { + v.get("OTIO_SCHEMA").and_then(Value::as_str).unwrap_or("") +} + +fn schema_name(v: &Value) -> &str { + schema(v).split('.').next().unwrap_or("") +} + +fn fc(v: &Value) -> Option<&Value> { + v.get("metadata").and_then(|m| m.get("filmcraft")) +} + +fn name_of(v: &Value) -> String { + v.get("name").and_then(Value::as_str).unwrap_or("").to_string() +} + +fn color_name(l: Label) -> &'static str { + match l { + Label::Rose => "RED", + Label::Mango | Label::Tan => "ORANGE", + Label::Yellow => "YELLOW", + Label::Green | Label::Forest => "GREEN", + Label::Teal | Label::Caribbean => "CYAN", + Label::Blue | Label::Iris | Label::Cerulean => "BLUE", + Label::Violet | Label::Purple | Label::Lavender => "PURPLE", + Label::Magenta => "MAGENTA", + Label::Brown => "BLACK", + } +} + +fn color_label(s: &str) -> Label { + match s.to_ascii_uppercase().as_str() { + "RED" => Label::Rose, + "PINK" | "MAGENTA" => Label::Magenta, + "ORANGE" => Label::Mango, + "YELLOW" => Label::Yellow, + "GREEN" => Label::Green, + "CYAN" => Label::Teal, + "BLUE" => Label::Blue, + "PURPLE" => Label::Purple, + "BLACK" => Label::Brown, + "WHITE" => Label::Lavender, + _ => Label::Green, + } +} + +// --------------------------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------------------------- + +struct Exp<'a, 'r> { + p: &'a Project, + opts: &'a ExportOptions, + report: &'r mut Report, + stack: Vec, +} + +pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: &mut Report) -> Result { + let seq = p.sequence(seq_id).ok_or(Error::NoSequence(seq_id))?; + let rate = seq.settings.frame_rate; + let mut x = Exp { p, opts, report, stack: vec![seq_id] }; + let name = opts.name.clone().unwrap_or_else(|| p.item(seq_id).map(|i| i.name.clone()).unwrap_or_default()); + let stack = x.stack_json(seq_id, &name, None); + let tl = json!({ + "OTIO_SCHEMA": "Timeline.1", + "name": name, + "global_start_time": rt(rate.tick_of(seq.start_timecode), rate), + "metadata": {"filmcraft": x.seq_meta(seq_id)}, + "tracks": stack, + }); + serde_json::to_string_pretty(&tl).map_err(|e| Error::Other(e.to_string())) +} + +impl Exp<'_, '_> { + fn seq_meta(&self, id: ItemId) -> Value { + let it = self.p.item(id).unwrap(); + let s = it.as_sequence().unwrap(); + json!({ + "item": id.0, + "label": it.label, + "settings": s.settings, + "start_timecode": s.start_timecode, + "master_volume_db": s.master_volume_db, + "master_effects": s.master_effects, + }) + } + + /// The `Stack` for a sequence; `range` = (source in, duration) when nested in a track. + fn stack_json(&mut self, id: ItemId, name: &str, range: Option<(Tick, Tick, FrameRate)>) -> Value { + let seq = self.p.sequence(id).unwrap(); + let rate = seq.settings.frame_rate; + let mut tracks = Vec::new(); + for kind in [TrackKind::Video, TrackKind::Audio] { + for t in seq.tracks(kind) { + tracks.push(self.track_json(t, kind, seq)); + } + } + let markers: Vec = seq.markers.iter().map(|m| marker_json(m, rate)).collect(); + json!({ + "OTIO_SCHEMA": "Stack.1", + "name": name, + "children": tracks, + "source_range": range.map(|(s, d, r)| tr(s, d, r)).unwrap_or(Value::Null), + "effects": [], + "markers": markers, + "enabled": true, + "metadata": if range.is_some() { json!({"filmcraft": self.seq_meta(id)}) } else { json!({}) }, + }) + } + + fn track_json(&mut self, t: &Track, kind: TrackKind, seq: &Sequence) -> Value { + let rate = seq.settings.frame_rate; + let mut children = Vec::new(); + let mut pos = Tick::ZERO; + for it in &t.items { + if it.start > pos { + children.push(json!({"OTIO_SCHEMA": "Gap.1", "name": "", "source_range": tr(Tick::ZERO, it.start - pos, rate), "effects": [], "markers": [], "enabled": true, "metadata": {}})); + } + for x in t.transitions.iter().filter(|x| x.to == Some(it.id)) { + children.push(self.transition_json(x, it.start, rate)); + } + children.push(self.item_json(it, kind, rate)); + for x in t.transitions.iter().filter(|x| x.from == Some(it.id) && x.to.is_none()) { + children.push(self.transition_json(x, it.end(), rate)); + } + pos = it.end(); + } + json!({ + "OTIO_SCHEMA": "Track.1", + "name": t.name, + "kind": if kind == TrackKind::Video { "Video" } else { "Audio" }, + "children": children, + "source_range": null, + "effects": [], + "markers": [], + "enabled": t.enabled, + "metadata": {"filmcraft": { + "locked": t.locked, "sync_lock": t.sync_lock, "muted": t.muted, "solo": t.solo, + "channels": t.channels, "volume_db": t.volume_db, "pan": t.pan, "effects": t.effects, + }}, + }) + } + + fn transition_json(&mut self, x: &Transition, cut: Tick, rate: FrameRate) -> Value { + let dissolve = matches!(x.effect.effect.as_str(), "cross_dissolve" | "constant_power" | "constant_gain" | "film_dissolve"); + json!({ + "OTIO_SCHEMA": "Transition.1", + "name": transition_name(&x.effect), + "transition_type": if dissolve { "SMPTE_Dissolve" } else { "Custom_Transition" }, + "in_offset": rt(cut - x.start, rate), + "out_offset": rt(x.end() - cut, rate), + "metadata": {"filmcraft": {"effect": x.effect, "align": x.align, "reverse": x.reverse}}, + }) + } + + fn media_ref(&mut self, item: ItemId, rate: FrameRate) -> Value { + let name = self.p.item(item).map(|i| i.name.clone()).unwrap_or_default(); + let Some(m) = item_media(self.p, item) else { + return json!({"OTIO_SCHEMA": "MissingReference.1", "name": name, "available_range": null, "metadata": {}}); + }; + let avail = tr(m.info.start_timecode.map(|f| m.frame_rate().tick_of(f)).unwrap_or(Tick::ZERO), m.info.duration, rate); + let meta = json!({"filmcraft": {"item": item.0, "label": self.p.item(item).map(|i| i.label), "info": m.info, "offline": m.offline}}); + match &m.media { + MediaRef::Generator(g) => json!({ + "OTIO_SCHEMA": "GeneratorReference.1", + "name": name, + "generator_kind": match g { Generator::ColorMatte { .. } => "SolidColor", Generator::BlackVideo => "black", Generator::BarsAndTone => "SMPTEBars", _ => "FilmCraftGenerator" }, + "parameters": match g { Generator::ColorMatte { color } => json!({"color": color}), _ => json!({}) }, + "available_range": avail, + "metadata": {"filmcraft": {"item": item.0, "generator": g, "info": m.info}}, + }), + MediaRef::File { path } => { + let url = match &self.opts.relative_to { + Some(base) => relative_path(path, base).unwrap_or_else(|| abs_url(path)), + None => abs_url(path), + }; + json!({"OTIO_SCHEMA": "ExternalReference.1", "name": name, "target_url": url, "available_range": avail, "available_image_bounds": null, "metadata": meta}) + } + } + } + + fn item_json(&mut self, it: &TrackItem, kind: TrackKind, rate: FrameRate) -> Value { + let base = base_item(self.p, it.item); + let mut effects = Vec::new(); + if let Some(h) = it.frame_hold { + effects.push( + json!({"OTIO_SCHEMA": "FreezeFrame.1", "name": "", "effect_name": "FreezeFrame", "time_scalar": 0.0, "metadata": {"filmcraft": {"hold": h}}}), + ); + } else if it.speed != 1.0 || it.reverse { + let s = if it.reverse { -it.speed } else { it.speed }; + effects.push(json!({"OTIO_SCHEMA": "LinearTimeWarp.1", "name": "", "effect_name": "LinearTimeWarp", "time_scalar": s, "metadata": {}})); + } + let meta = json!({"filmcraft": { + "label": it.label, "link": it.link, "group": it.group, "effects": it.effects, + "gain_db": it.gain_db, "scale_to_frame": it.scale_to_frame, "source_in_ticks": it.source_in.0, + "duration_ticks": it.duration.0, + }}); + let markers: Vec = it.markers.iter().map(|m| marker_json(m, rate)).collect(); + let _ = kind; + if matches!(self.p.item(base).map(|i| &i.kind), Some(ItemKind::Sequence(_))) { + if self.stack.contains(&base) { + self.report.warn("recursive nested sequence skipped"); + return json!({"OTIO_SCHEMA": "Gap.1", "name": "", "source_range": tr(Tick::ZERO, it.duration, rate), "effects": [], "markers": [], "enabled": true, "metadata": {}}); + } + self.stack.push(base); + let mut s = self.stack_json(base, &it.name, Some((it.source_in, it.duration, rate))); + self.stack.pop(); + s["effects"] = json!(effects); + s["markers"] = json!(markers); + s["enabled"] = json!(it.enabled); + s["metadata"]["filmcraft"]["clip"] = meta["filmcraft"].clone(); + return s; + } + let _ = generator_of(self.p, base); + let mref = self.media_ref(base, rate); + // OTIO source times are in the reference's time (which starts at its available_range start). + let origin = item_media(self.p, base).and_then(|m| m.info.start_timecode.map(|f| m.frame_rate().tick_of(f))).unwrap_or(Tick::ZERO); + let markers: Vec = it.markers.iter().map(|m| marker_json(&Marker { start: m.start + origin, ..m.clone() }, rate)).collect(); + json!({ + "OTIO_SCHEMA": "Clip.2", + "name": it.name, + "source_range": tr(origin + it.source_in, it.duration, rate), + "effects": effects, + "markers": markers, + "enabled": it.enabled, + "media_references": {"DEFAULT_MEDIA": mref}, + "active_media_reference_key": "DEFAULT_MEDIA", + "metadata": meta, + }) + } +} + +fn abs_url(path: &str) -> String { + if crate::common::is_absolute(path) { path_to_file_url(path, false) } else { path.to_string() } +} + +fn marker_json(m: &Marker, rate: FrameRate) -> Value { + json!({ + "OTIO_SCHEMA": "Marker.2", + "name": m.name, + "comment": m.comment, + "color": color_name(m.color), + "marked_range": tr(m.start, m.duration, rate), + "metadata": {"filmcraft": {"kind": m.kind, "label": m.color, "start_ticks": m.start.0, "duration_ticks": m.duration.0}}, + }) +} + +/// JSON turns NaN (our "auto" point defaults) into `null`; restore it when reading effects back. +const NAN_SENTINEL: f64 = 1.0e308; + +fn null_to_sentinel(v: &mut Value) { + match v { + Value::Object(o) => { + for (k, x) in o.iter_mut() { + if x.is_null() && (k == "x" || k == "y") { + *x = json!(NAN_SENTINEL); + } else { + null_to_sentinel(x); + } + } + } + Value::Array(a) => a.iter_mut().for_each(null_to_sentinel), + _ => {} + } +} + +fn effects_from(v: Option<&Value>) -> Option> { + let mut v = v?.clone(); + null_to_sentinel(&mut v); + let mut effects: Vec = serde_json::from_value(v).ok()?; + let fix = |p: &mut ParamValue| { + if let ParamValue::Vec2(v) = p { + if v.x == NAN_SENTINEL { + v.x = f64::NAN; + } + if v.y == NAN_SENTINEL { + v.y = f64::NAN; + } + } + }; + for e in &mut effects { + for p in e.params.values_mut() { + fix(&mut p.value); + p.keyframes.iter_mut().for_each(|k| fix(&mut k.value)); + } + } + Some(effects) +} + +// --------------------------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------------------------- + +struct Imp<'a, 'r> { + b: Builder, + opts: &'a ImportOptions, + report: &'r mut Report, + links: HashMap, + groups: HashMap, +} + +pub(crate) fn import(text: &str, opts: &ImportOptions, report: &mut Report) -> Result { + let v: Value = serde_json::from_str(text).map_err(|e| Error::parse(Format::Otio, e.to_string()))?; + let mut timelines = Vec::new(); + collect_timelines(&v, &mut timelines); + if timelines.is_empty() { + return Err(Error::parse(Format::Otio, format!("no Timeline in a {} document", schema(&v)))); + } + let mut imp = Imp { b: Builder::new(&name_of(&v)), opts, report, links: HashMap::new(), groups: HashMap::new() }; + if imp.b.p.name.is_empty() { + imp.b.p.name = "Imported OTIO".into(); + } + for tl in timelines { + let id = imp.timeline(tl); + imp.b.top.push(id); + } + Ok(imp.b.finish()) +} + +fn collect_timelines<'v>(v: &'v Value, out: &mut Vec<&'v Value>) { + match schema_name(v) { + "Timeline" | "Stack" => out.push(v), + "SerializableCollection" => { + for c in v.get("children").and_then(Value::as_array).into_iter().flatten() { + collect_timelines(c, out); + } + } + "Track" => out.push(v), + _ => {} + } +} + +fn first_rate(v: &Value) -> Option { + if let Some(sr) = v.get("source_range").filter(|s| !s.is_null()) { + return sr.get("duration").and_then(read_rate); + } + v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(first_rate) +} + +impl Imp<'_, '_> { + fn timeline(&mut self, tl: &Value) -> ItemId { + let name = name_of(tl); + let name = if name.is_empty() { self.opts.name.clone().unwrap_or_else(|| "Timeline".into()) } else { name }; + let stack_owned; + let stack = match schema_name(tl) { + "Timeline" => tl.get("tracks").unwrap_or(&Value::Null), + "Track" => { + stack_owned = json!({"OTIO_SCHEMA": "Stack.1", "children": [tl.clone()]}); + &stack_owned + } + _ => tl, + }; + let meta = fc(tl).or_else(|| fc(stack)); + let rate = tl.get("global_start_time").and_then(read_rate).or_else(|| first_rate(stack)).unwrap_or_default(); + let id = self.sequence(stack, &name, meta, rate); + if let Some(st) = tl.get("global_start_time").and_then(read_rt) + && let Some(s) = self.b.p.sequence_mut(id) + { + s.start_timecode = s.settings.frame_rate.frame_at(st); + } + id + } + + fn sequence(&mut self, stack: &Value, name: &str, meta: Option<&Value>, rate: FrameRate) -> ItemId { + let settings: SequenceSettings = + meta.and_then(|m| m.get("settings")).and_then(|s| serde_json::from_value(s.clone()).ok()).unwrap_or_else(|| settings_for(rate, 1920, 1080, false)); + let id = self.b.reserve_sequence(name, settings.clone(), None); + if let Some(l) = meta.and_then(|m| m.get("label")).and_then(|l| serde_json::from_value::