M11.6: OTIO — OpenTimelineIO JSON import/export

Timeline/Stack/Track/Clip.1+2/Gap/Transition/Marker/External/Missing/GeneratorReference,
LinearTimeWarp/FreezeFrame, SerializableCollection; exact RationalTime↔Tick by rate snapping;
nested sequences as Stacks; metadata.filmcraft round-trips effects, labels, links, groups,
gain, track state, sequence settings and media info. Golden plain-OTIO document with gaps
and transitions; lossless property round trips including sample-accurate audio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 22:38:33 -04:00
parent d6908b6c0d
commit 4e2fefdcda
3 changed files with 1172 additions and 9 deletions

View File

@ -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};

View File

@ -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<Imported> {
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<String> {
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<Tick> {
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<FrameRate> {
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<ItemId>,
}
pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: &mut Report) -> Result<String> {
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<Value> = 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<Value> = 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<Value> = 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<Vec<EffectInstance>> {
let mut v = v?.clone();
null_to_sentinel(&mut v);
let mut effects: Vec<EffectInstance> = 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<u64, u64>,
groups: HashMap<u64, u64>,
}
pub(crate) fn import(text: &str, opts: &ImportOptions, report: &mut Report) -> Result<Imported> {
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<FrameRate> {
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::<Label>(l.clone()).ok())
&& let Some(it) = self.b.p.item_mut(id)
{
it.label = l;
}
let mut seq = empty_sequence(settings);
if let Some(m) = meta {
seq.start_timecode = m.get("start_timecode").and_then(Value::as_i64).unwrap_or(0);
seq.master_volume_db = m.get("master_volume_db").and_then(Value::as_f64).unwrap_or(0.0);
seq.master_effects = effects_from(m.get("master_effects")).unwrap_or_default();
}
seq.markers = self.markers(stack);
for t in stack.get("children").and_then(Value::as_array).into_iter().flatten() {
match schema_name(t) {
"Track" => {
let kind = if t.get("kind").and_then(Value::as_str) == Some("Audio") { TrackKind::Audio } else { TrackKind::Video };
self.track(&mut seq, t, kind);
}
other => self.report.warn(format!("OTIO {other} directly in a stack is not supported")),
}
}
self.b.ensure_tracks(&mut seq, TrackKind::Video, 1);
self.b.ensure_tracks(&mut seq, TrackKind::Audio, 1);
self.b.put_sequence(id, seq);
id
}
fn track(&mut self, seq: &mut Sequence, t: &Value, kind: TrackKind) {
let idx = seq.tracks(kind).len();
let mut track = self.b.track(kind, idx);
let n = name_of(t);
if !n.is_empty() {
track.name = n;
}
track.enabled = t.get("enabled").and_then(Value::as_bool).unwrap_or(true);
if let Some(m) = fc(t) {
let g = |k: &str| m.get(k).and_then(Value::as_bool);
track.locked = g("locked").unwrap_or(false);
track.sync_lock = g("sync_lock").unwrap_or(true);
track.muted = g("muted").unwrap_or(false);
track.solo = g("solo").unwrap_or(false);
track.volume_db = m.get("volume_db").and_then(Value::as_f64).unwrap_or(0.0);
track.pan = m.get("pan").and_then(Value::as_f64).unwrap_or(0.0);
if let Some(c) = m.get("channels").and_then(|c| serde_json::from_value(c.clone()).ok()) {
track.channels = c;
}
track.effects = effects_from(m.get("effects")).unwrap_or_default();
}
if t.get("markers").and_then(Value::as_array).is_some_and(|m| !m.is_empty()) {
let ms = self.markers(t);
seq.markers.extend(ms);
seq.markers.sort_by_key(|m| m.start);
}
// (position in children list, clip at that slot or None for gaps)
let mut slots: Vec<(Option<ClipId>, Tick, Tick)> = Vec::new();
let mut pending: Vec<(usize, &Value)> = Vec::new();
let mut pos = Tick::ZERO;
let kids = t.get("children").and_then(Value::as_array).cloned().unwrap_or_default();
for c in &kids {
match schema_name(c) {
"Transition" => {
pending.push((slots.len(), c));
continue;
}
"Gap" => {
let d = c.get("source_range").and_then(read_tr).map(|r| r.1).unwrap_or(Tick::ZERO);
slots.push((None, pos, pos + d));
pos += d;
}
"Clip" | "Stack" => {
let item = if schema_name(c) == "Clip" { self.clip(c, kind, pos) } else { self.nested(c, kind, pos) };
match item {
Some(ti) => {
let (s, e, id) = (ti.start, ti.end(), ti.id);
track.items.push(ti);
slots.push((Some(id), s, e));
pos = e;
}
None => {
let d = c.get("source_range").and_then(read_tr).map(|r| r.1).unwrap_or(Tick::ZERO);
slots.push((None, pos, pos + d));
pos += d;
}
}
}
other => {
self.report.warn(format!("OTIO {other} items are not supported and were skipped"));
}
}
}
for (slot, c) in pending {
let cut = if slot < slots.len() { slots[slot].1 } else { pos };
let from = slot.checked_sub(1).and_then(|i| slots[i].0);
let to = slots.get(slot).and_then(|s| s.0);
if from.is_none() && to.is_none() {
continue;
}
let inn = c.get("in_offset").and_then(read_rt).unwrap_or(Tick::ZERO);
let out = c.get("out_offset").and_then(read_rt).unwrap_or(Tick::ZERO);
let meta = fc(c);
let effect: EffectInstance = match meta.and_then(|m| m.get("effect")).and_then(|e| effects_from(Some(&json!([e])))).and_then(|mut v| v.pop()) {
Some(e) => e,
None => {
let n = name_of(c);
let ty = c.get("transition_type").and_then(Value::as_str).unwrap_or("SMPTE_Dissolve");
transition_effect(if n.is_empty() { ty } else { &n }, kind == TrackKind::Audio, self.report)
}
};
let align = meta.and_then(|m| m.get("align")).and_then(|a| serde_json::from_value(a.clone()).ok()).unwrap_or(if inn == Tick::ZERO {
TransitionAlign::StartAtCut
} else if out == Tick::ZERO {
TransitionAlign::EndAtCut
} else {
TransitionAlign::CenterAtCut
});
let reverse = meta.and_then(|m| m.get("reverse")).and_then(Value::as_bool).unwrap_or(false);
track.transitions.push(Transition { id: TransitionId(self.b.alloc()), effect, start: cut - inn, duration: inn + out, from, to, align, reverse });
}
track.sort();
seq.tracks_mut(kind).push(track);
}
fn apply_clip_meta(&mut self, ti: &mut TrackItem, c: &Value, meta: Option<&Value>) {
ti.enabled = c.get("enabled").and_then(Value::as_bool).unwrap_or(true);
for e in c.get("effects").and_then(Value::as_array).into_iter().flatten() {
match schema_name(e) {
"LinearTimeWarp" => {
let s = e.get("time_scalar").and_then(Value::as_f64).unwrap_or(1.0);
if s == 0.0 {
ti.frame_hold = Some(ti.source_in);
} else {
ti.speed = s.abs();
ti.reverse = s < 0.0;
}
}
"FreezeFrame" => {
ti.frame_hold = Some(fc(e).and_then(|m| m.get("hold")).and_then(|h| h.as_i64()).map(Tick).unwrap_or(ti.source_in));
}
_ => {
if meta.and_then(|m| m.get("effects")).is_none() {
let n = e.get("effect_name").and_then(Value::as_str).unwrap_or("effect");
self.report.warn(format!("OTIO effect \"{n}\" is not supported"));
}
}
}
}
let Some(m) = meta else { return };
if let Some(l) = m.get("label").and_then(|l| serde_json::from_value::<Label>(l.clone()).ok()) {
ti.label = l;
}
if let Some(effects) = effects_from(m.get("effects")) {
ti.effects = effects;
}
if let Some(l) = m.get("link").and_then(Value::as_u64) {
let next = self.b.link_id();
ti.link = Some(*self.links.entry(l).or_insert(next));
}
if let Some(g) = m.get("group").and_then(Value::as_u64) {
let next = self.b.alloc();
ti.group = Some(*self.groups.entry(g).or_insert(next));
}
ti.gain_db = m.get("gain_db").and_then(Value::as_f64).unwrap_or(0.0);
ti.scale_to_frame = m.get("scale_to_frame").and_then(Value::as_bool).unwrap_or(false);
// Exact ticks for sub-frame values (audio) when the rational values were rounded.
if let Some(s) = m.get("source_in_ticks").and_then(Value::as_i64)
&& (Tick(s) - ti.source_in).abs() < Tick(TICKS_PER_SECOND / 1000)
{
ti.source_in = Tick(s);
if let Some(h) = &mut ti.frame_hold
&& c.get("effects").and_then(Value::as_array).is_some_and(|e| e.iter().any(|x| schema_name(x) == "LinearTimeWarp"))
{
*h = Tick(s);
}
}
if let Some(d) = m.get("duration_ticks").and_then(Value::as_i64)
&& (Tick(d) - ti.duration).abs() < Tick(TICKS_PER_SECOND / 1000)
{
ti.duration = Tick(d);
}
}
fn clip(&mut self, c: &Value, kind: TrackKind, pos: Tick) -> Option<TrackItem> {
let mref = match c.get("media_references") {
Some(refs) => {
let key = c.get("active_media_reference_key").and_then(Value::as_str).unwrap_or("DEFAULT_MEDIA");
refs.get(key).or_else(|| refs.as_object().and_then(|o| o.values().next()))
}
None => c.get("media_reference"),
}
.cloned()
.unwrap_or(Value::Null);
let name = name_of(c);
let avail = mref.get("available_range").and_then(read_tr);
let (src_in, dur) = c.get("source_range").and_then(read_tr).or(avail)?;
if dur <= Tick::ZERO {
return None;
}
let item = self.media(&mref, &name, kind, avail)?;
// Sources are media time: subtract the media's start time (available_range start).
let origin = avail.map(|a| a.0).unwrap_or(Tick::ZERO);
let mut ti = self.b.clip(item, kind, if name.is_empty() { "Clip" } else { &name }, pos, dur, src_in - origin);
ti.markers = self.markers(c);
for m in &mut ti.markers {
m.start -= origin;
}
let meta = fc(c).cloned();
self.apply_clip_meta(&mut ti, c, meta.as_ref());
Some(ti)
}
fn nested(&mut self, c: &Value, kind: TrackKind, pos: Tick) -> Option<TrackItem> {
let (src_in, dur) = c.get("source_range").and_then(read_tr)?;
let meta = fc(c);
let key = format!("stack:{}", meta.and_then(|m| m.get("item")).and_then(Value::as_u64).map(|i| i.to_string()).unwrap_or_else(|| name_of(c)));
let item = match self.b.find_media(&key) {
Some(i) => i,
None => {
let rate = first_rate(c).unwrap_or_default();
let mut inner = c.clone();
inner["source_range"] = Value::Null;
let name = name_of(c);
let id = self.sequence(&inner, if name.is_empty() { "Nested Sequence" } else { &name }, meta, rate);
self.b.register_media(&key, id);
id
}
};
let name = name_of(c);
let mut ti = self.b.clip(item, kind, if name.is_empty() { "Nested Sequence" } else { &name }, pos, dur, src_in);
let clip_meta = meta.and_then(|m| m.get("clip")).cloned();
// Stack markers belong to the nested sequence, not the clip instance.
self.apply_clip_meta(&mut ti, c, clip_meta.as_ref());
Some(ti)
}
fn media(&mut self, r: &Value, clip_name: &str, kind: TrackKind, avail: Option<(Tick, Tick)>) -> Option<ItemId> {
let meta = fc(r);
let info: Option<MediaInfo> = meta.and_then(|m| m.get("info")).and_then(|i| serde_json::from_value(i.clone()).ok());
let name = {
let n = name_of(r);
if n.is_empty() { clip_name.to_string() } else { n }
};
let spec = MediaSpec {
duration: avail.map(|a| a.1),
video: (kind == TrackKind::Video).then(|| (1920, 1080, FrameRate::default())),
audio: (kind == TrackKind::Audio).then_some((48_000, 2)),
start_tc: None,
kind: None,
};
let id = match schema_name(r) {
"ExternalReference" | "ImageSequenceReference" => {
let url = r.get("target_url").and_then(Value::as_str).or_else(|| r.get("target_url_base").and_then(Value::as_str)).unwrap_or("");
let path = resolve_path(url, self.opts.base_dir.as_deref());
let display = if name.is_empty() { file_name(&path).to_string() } else { name.clone() };
let key = format!("file:{path}");
let existed = self.b.find_media(&key);
let id = self.b.file_media(&key, &display, &path, &spec, None);
if existed.is_none() {
self.fill_media(id, info, meta, kind);
}
id
}
"GeneratorReference" => {
let g: Generator = meta.and_then(|m| m.get("generator")).and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| {
match r.get("generator_kind").and_then(Value::as_str).unwrap_or("").to_ascii_lowercase().as_str() {
"solidcolor" | "solid_color" => {
let c = r
.get("parameters")
.and_then(|p| p.get("color"))
.and_then(|c| serde_json::from_value::<[f32; 4]>(c.clone()).ok())
.unwrap_or([0.0, 0.0, 0.0, 1.0]);
Generator::ColorMatte { color: c }
}
"smptebars" | "bars" => Generator::BarsAndTone,
"black" => Generator::BlackVideo,
other => {
self.report.warn(format!("OTIO generator \"{other}\" is not supported; imported as Black Video"));
Generator::BlackVideo
}
}
});
let key = format!("gen:{}", serde_json::to_string(&g).unwrap_or_default());
let existed = self.b.find_media(&key);
let id = self.b.generator_media(&key, if name.is_empty() { "Generator" } else { &name }, g, &spec, None);
if existed.is_none() {
self.fill_media(id, info, None, kind);
}
id
}
_ => {
if schema_name(r) != "MissingReference" && !r.is_null() {
self.report.warn(format!("OTIO media reference {} is not supported; imported offline", schema(r)));
}
let key = format!("missing:{name}");
let existed = self.b.find_media(&key);
let path = resolve_path(&name, self.opts.base_dir.as_deref());
let id = self.b.file_media(&key, &name, &path, &spec, None);
if existed.is_none() {
self.fill_media(id, info, meta, kind);
if let Some(m) = self.b.p.item_mut(id).and_then(|i| i.as_media_mut()) {
m.offline = true;
}
}
id
}
};
Some(id)
}
fn fill_media(&mut self, id: ItemId, info: Option<MediaInfo>, meta: Option<&Value>, kind: TrackKind) {
let label = meta.and_then(|m| m.get("label")).and_then(|l| serde_json::from_value::<Label>(l.clone()).ok());
let offline = meta.and_then(|m| m.get("offline")).and_then(Value::as_bool);
let Some(it) = self.b.p.item_mut(id) else { return };
if let Some(l) = label {
it.label = l;
}
if let Some(m) = it.as_media_mut() {
match info {
Some(i) => m.info = i,
None => {
// A clip on the other kind of track proves the media has that stream too.
let _ = kind;
}
}
if let Some(o) = offline {
m.offline = o;
}
}
}
fn markers(&mut self, v: &Value) -> Vec<Marker> {
let mut out = Vec::new();
for m in v.get("markers").and_then(Value::as_array).into_iter().flatten() {
let (mut start, mut duration) = m.get("marked_range").and_then(read_tr).unwrap_or((Tick::ZERO, Tick::ZERO));
let meta = fc(m);
if let Some(s) = meta.and_then(|x| x.get("start_ticks")).and_then(Value::as_i64)
&& (Tick(s) - start).abs() < Tick(TICKS_PER_SECOND / 1000)
{
start = Tick(s);
}
if let Some(d) = meta.and_then(|x| x.get("duration_ticks")).and_then(Value::as_i64)
&& (Tick(d) - duration).abs() < Tick(TICKS_PER_SECOND / 1000)
{
duration = Tick(d);
}
let color = meta
.and_then(|x| x.get("label"))
.and_then(|l| serde_json::from_value::<Label>(l.clone()).ok())
.unwrap_or_else(|| color_label(m.get("color").and_then(Value::as_str).unwrap_or("")));
out.push(Marker {
id: MarkerId(self.b.alloc()),
start,
duration,
name: name_of(m),
comment: m.get("comment").and_then(Value::as_str).unwrap_or("").to_string(),
kind: meta.and_then(|x| x.get("kind")).and_then(|k| serde_json::from_value::<MarkerKind>(k.clone()).ok()).unwrap_or_default(),
color,
});
}
out.sort_by_key(|m| m.start);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rational_time() {
for r in FrameRate::COMMON {
for f in [0i64, 1, 1799, 86_400, 10_000_001] {
let t = r.tick_of(f);
assert_eq!(read_rt(&rt(t, r)), Some(t), "{r} {f}");
}
}
// 23.976 written by other tools as a rounded float
let v = json!({"OTIO_SCHEMA": "RationalTime.1", "rate": 23.976, "value": 48.0});
assert_eq!(read_rt(&v), Some(FrameRate::FPS_23_976.tick_of(48)));
// sub-frame audio time survives
let t = Tick(TICKS_PER_SECOND / 48_000 * 12_345);
let back = read_rt(&rt(t, FrameRate::FPS_24)).unwrap();
assert!((back - t).abs() <= Tick(1));
}
}

View File

@ -0,0 +1,362 @@
//! OpenTimelineIO: a hand-written plain OTIO document (from the public OTIO schema docs) with gaps,
//! transitions, markers and legacy `Clip.1`; lossless round trips through `metadata.filmcraft`.
mod support;
use filmcraft_interchange::{ExportOptions, Format, detect, import};
use filmcraft_media::Generator;
use filmcraft_project::{Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, TrackKind, TransitionAlign};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use proptest::prelude::*;
use support::*;
fn rt(v: f64, rate: f64) -> String {
format!(r#"{{"OTIO_SCHEMA": "RationalTime.1", "rate": {rate}, "value": {v}}}"#)
}
fn tr(s: f64, d: f64, rate: f64) -> String {
format!(r#"{{"OTIO_SCHEMA": "TimeRange.1", "start_time": {}, "duration": {}}}"#, rt(s, rate), rt(d, rate))
}
fn doc() -> String {
let r = 24.0;
format!(
r#"{{
"OTIO_SCHEMA": "Timeline.1",
"name": "River Story",
"global_start_time": {gst},
"metadata": {{}},
"tracks": {{
"OTIO_SCHEMA": "Stack.1",
"name": "tracks",
"children": [
{{
"OTIO_SCHEMA": "Track.1",
"name": "V1",
"kind": "Video",
"children": [
{{"OTIO_SCHEMA": "Gap.1", "name": "", "source_range": {gap}}},
{{"OTIO_SCHEMA": "Transition.1", "name": "Fade In", "transition_type": "SMPTE_Dissolve", "in_offset": {z}, "out_offset": {t6}}},
{{
"OTIO_SCHEMA": "Clip.2",
"name": "river_a",
"source_range": {src_a},
"media_references": {{"DEFAULT_MEDIA": {{"OTIO_SCHEMA": "ExternalReference.1", "target_url": "file:///footage/river%20a.mov", "available_range": {avail_a}}}}},
"active_media_reference_key": "DEFAULT_MEDIA",
"markers": [{{"OTIO_SCHEMA": "Marker.2", "name": "kingfisher", "color": "RED", "comment": "", "marked_range": {mk}}}]
}},
{{"OTIO_SCHEMA": "Transition.1", "name": "", "transition_type": "SMPTE_Dissolve", "in_offset": {t6}, "out_offset": {t6}}},
{{
"OTIO_SCHEMA": "Clip.1",
"name": "river_b",
"source_range": {src_b},
"media_reference": {{"OTIO_SCHEMA": "ExternalReference.1", "target_url": "clips/river_b.mov", "available_range": null}},
"effects": [{{"OTIO_SCHEMA": "LinearTimeWarp.1", "effect_name": "LinearTimeWarp", "time_scalar": 2.0}}]
}},
{{"OTIO_SCHEMA": "Gap.1", "source_range": {gap2}}},
{{
"OTIO_SCHEMA": "Clip.2",
"name": "lost",
"source_range": {src_c},
"media_references": {{"DEFAULT_MEDIA": {{"OTIO_SCHEMA": "MissingReference.1", "name": "lost_shot.mov"}}}},
"active_media_reference_key": "DEFAULT_MEDIA",
"enabled": false
}},
{{"OTIO_SCHEMA": "Transition.1", "name": "Fade Out", "transition_type": "SMPTE_Dissolve", "in_offset": {t6}, "out_offset": {z}}}
]
}},
{{
"OTIO_SCHEMA": "Track.1",
"name": "A1",
"kind": "Audio",
"children": [
{{
"OTIO_SCHEMA": "Clip.2",
"name": "ambience",
"source_range": {src_amb},
"media_references": {{"DEFAULT_MEDIA": {{"OTIO_SCHEMA": "ExternalReference.1", "target_url": "file:///footage/amb.wav"}}}},
"active_media_reference_key": "DEFAULT_MEDIA",
"effects": [{{"OTIO_SCHEMA": "Effect.1", "effect_name": "Reverb"}}]
}}
]
}}
],
"markers": [{{"OTIO_SCHEMA": "Marker.2", "name": "act two", "color": "BLUE", "comment": "", "marked_range": {smk}}}]
}}
}}"#,
gst = rt(86_400.0, r),
z = rt(0.0, r),
t6 = rt(6.0, r),
gap = tr(0.0, 12.0, r),
avail_a = tr(86_400.0, 2400.0, r),
src_a = tr(86_448.0, 48.0, r),
mk = tr(86_460.0, 0.0, r),
src_b = tr(10.0, 36.0, r),
gap2 = tr(0.0, 24.0, r),
src_c = tr(0.0, 30.0, r),
src_amb = tr(0.0, 150.0, r),
smk = tr(60.0, 0.0, r),
)
}
#[test]
fn golden_gaps_and_transitions() {
let d = doc();
assert_eq!(detect(d.as_bytes(), Some("otio")), Some(Format::Otio));
let (imp, rep) = import(d.as_bytes(), Format::Otio, Some("/proj")).unwrap();
let p = &imp.project;
let sid = only_seq(&imp);
let s = p.sequence(sid).unwrap();
let r = FrameRate::FPS_24;
assert_eq!(p.item(sid).unwrap().name, "River Story");
assert_eq!(s.settings.frame_rate, r);
assert_eq!(s.start_timecode, 86_400);
let v1 = &s.video_tracks[0];
assert_eq!(v1.items.len(), 3);
let (a, b, c) = (&v1.items[0], &v1.items[1], &v1.items[2]);
// source times are relative to the reference's available range start
assert_eq!((a.start, a.duration, a.source_in), (r.tick_of(12), r.tick_of(48), r.tick_of(48)));
assert_eq!(media_key(p, a.item), "/footage/river a.mov");
assert_eq!(a.markers[0].name, "kingfisher");
assert_eq!(a.markers[0].start, r.tick_of(60));
assert_eq!(a.markers[0].color, Label::Rose);
assert_eq!((b.start, b.duration, b.source_in, b.speed), (r.tick_of(60), r.tick_of(36), r.tick_of(10), 2.0));
assert_eq!(media_key(p, b.item), "/proj/clips/river_b.mov");
assert_eq!(c.start, r.tick_of(120));
assert!(!c.enabled);
assert!(p.item(c.item).unwrap().as_media().unwrap().offline);
let ts = &v1.transitions;
assert_eq!(ts.len(), 3);
assert_eq!((ts[0].from, ts[0].to, ts[0].start, ts[0].duration, ts[0].align), (None, Some(a.id), r.tick_of(12), r.tick_of(6), TransitionAlign::StartAtCut));
assert_eq!(
(ts[1].from, ts[1].to, ts[1].start, ts[1].duration, ts[1].align),
(Some(a.id), Some(b.id), r.tick_of(54), r.tick_of(12), TransitionAlign::CenterAtCut)
);
assert_eq!((ts[2].from, ts[2].to, ts[2].start, ts[2].align), (Some(c.id), None, r.tick_of(144), TransitionAlign::EndAtCut));
assert!(ts.iter().all(|t| t.effect.effect == "cross_dissolve"), "{rep}");
let amb = &s.audio_tracks[0].items[0];
assert_eq!(amb.duration, r.tick_of(150));
assert!(rep.mentions("Reverb"), "{rep}");
assert_eq!(s.markers[0].name, "act two");
assert_eq!(s.markers[0].start, r.tick_of(60));
}
fn project() -> (Project, filmcraft_project::ItemId) {
let r = FrameRate::FPS_23_976;
let mut p = Project::new("P");
let a = media(&mut p, "/m/a.mov", true, true, r);
if let Some(m) = p.item_mut(a).and_then(|i| i.as_media_mut()) {
m.info.start_timecode = Some(86_400);
}
let b = media(&mut p, "/m/b.mov", true, true, r);
let g = generator(&mut p, "Matte", Generator::ColorMatte { color: [0.2, 0.4, 0.6, 1.0] });
let nest = sequence(&mut p, "Inner", r, false);
clip(&mut p, nest, TrackKind::Video, 0, b, 0, 50, 5);
let s = sequence(&mut p, "Outer", r, false);
let v1 = clip(&mut p, s, TrackKind::Video, 0, a, 0, 48, 24);
let a1 = clip(&mut p, s, TrackKind::Audio, 0, a, 0, 48, 24);
link(&mut p, s, &[v1, a1]);
let v2 = clip(&mut p, s, TrackKind::Video, 0, nest, 48, 40, 3);
transition(&mut p, s, TrackKind::Video, 0, "iris_round", Some(v1), Some(v2), 40, 16, TransitionAlign::CenterAtCut);
clip(&mut p, s, TrackKind::Video, 1, g, 10, 20, 0);
{
let q = p.sequence_mut(s).unwrap();
// sub-frame audio position
let mut ai = q.audio_tracks[0].items[0].clone();
ai.id = filmcraft_project::ClipId(9999);
ai.link = None;
ai.start = Tick(TICKS_PER_SECOND * 3 + TICKS_PER_SECOND / 48_000 * 7);
ai.duration = Tick(TICKS_PER_SECOND / 48_000 * 33_333);
ai.gain_db = 4.5;
q.audio_tracks[1].items.push(ai);
let (_, it) = q.find_item_mut(v1).unwrap();
it.label = Label::Teal;
let mut op = Param::new(ParamValue::Float(100.0));
op.keyframes.push(Keyframe::new(r.tick_of(24), ParamValue::Float(0.0)));
op.keyframes.push(Keyframe::new(r.tick_of(36), ParamValue::Float(100.0)));
it.effect_mut("opacity").unwrap().params.insert("opacity".into(), op);
it.effects.push(filmcraft_project::find_effect("gaussian_blur").unwrap().instance());
it.markers.push(Marker {
id: MarkerId(5),
start: r.tick_of(30),
duration: r.tick_of(2),
name: "m".into(),
comment: "c".into(),
kind: MarkerKind::Comment,
color: Label::Yellow,
});
let (_, n) = q.find_item_mut(v2).unwrap();
n.reverse = true;
n.speed = 1.5;
q.video_tracks[1].locked = true;
q.audio_tracks[1].volume_db = -3.0;
q.markers.push(Marker {
id: MarkerId(6),
start: r.tick_of(70),
duration: Tick::ZERO,
name: "chap".into(),
comment: String::new(),
kind: MarkerKind::Chapter,
color: Label::Mango,
});
}
(p, s)
}
#[test]
fn lossless_roundtrip_with_filmcraft_metadata() {
let (p, s) = project();
let (imp, text, rep) = roundtrip(&p, s, Format::Otio, &ExportOptions::default());
assert!(!rep.has_warnings(), "{rep}");
assert!(text.contains("\"OTIO_SCHEMA\": \"Timeline.1\""));
assert!(text.contains("\"target_url\": \"file:///m/a.mov\""));
let si = only_seq(&imp);
let q = &imp.project;
let (qs, ps) = (q.sequence(si).unwrap(), p.sequence(s).unwrap());
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));
assert_eq!(qs.settings, ps.settings);
assert_eq!(
qs.markers.iter().map(|m| (&m.name, m.start, m.kind, m.color)).collect::<Vec<_>>(),
ps.markers.iter().map(|m| (&m.name, m.start, m.kind, m.color)).collect::<Vec<_>>()
);
assert!(qs.video_tracks[1].locked);
assert_eq!(qs.audio_tracks[1].volume_db, -3.0);
// clip-level fields restored exactly
let (qa, pa) = (&qs.video_tracks[0].items[0], &ps.video_tracks[0].items[0]);
assert_eq!(format!("{:?}", qa.effects), format!("{:?}", pa.effects)); // NaN auto points compare unequal
assert_eq!(qa.label, Label::Teal);
assert_eq!(
qa.markers.iter().map(|m| (&m.name, m.start, m.duration)).collect::<Vec<_>>(),
pa.markers.iter().map(|m| (&m.name, m.start, m.duration)).collect::<Vec<_>>()
);
assert_eq!(qs.audio_tracks[1].items[0].gain_db, 4.5);
assert_eq!(qs.audio_tracks[1].items[0].start, ps.audio_tracks[1].items[0].start);
assert_eq!(qs.video_tracks[0].transitions[0].effect.effect, "iris_round");
// media info and generator restored
let qa_media = q.item(qa.item).unwrap().as_media().unwrap();
assert_eq!(qa_media.info, p.item(pa.item).unwrap().as_media().unwrap().info);
let gm = q.item(qs.video_tracks[1].items[0].item).unwrap().as_media().unwrap();
assert_eq!(gm.media, MediaRef::Generator(Generator::ColorMatte { color: [0.2, 0.4, 0.6, 1.0] }));
// nested sequence
let nested = qs.video_tracks[0].items[1].item;
assert_eq!(q.item(nested).unwrap().name, "Inner");
assert_eq!(q.sequence(nested).unwrap().video_tracks[0].items[0].source_in, FrameRate::FPS_23_976.tick_of(5));
// relative media paths
let o = ExportOptions { relative_to: Some("/m/otio".into()), ..Default::default() };
let (bytes, _) = filmcraft_interchange::export(&p, s, Format::Otio, &o).unwrap();
let t = String::from_utf8(bytes.clone()).unwrap();
assert!(t.contains("\"target_url\": \"../a.mov\""), "{t}");
let (imp2, _) = import(&bytes, Format::Otio, Some("/m/otio")).unwrap();
assert_eq!(structure(&imp2.project, imp2.sequences[0], TrackKind::Video), structure(&p, s, TrackKind::Video));
}
#[test]
fn collections_and_bare_tracks() {
let one = r#"{"OTIO_SCHEMA": "SerializableCollection.1", "name": "Bundle", "children": [
{"OTIO_SCHEMA": "Timeline.1", "name": "T1", "tracks": {"OTIO_SCHEMA": "Stack.1", "children": [
{"OTIO_SCHEMA": "Track.1", "kind": "Video", "children": [
{"OTIO_SCHEMA": "Clip.2", "name": "x", "source_range": {"OTIO_SCHEMA": "TimeRange.1", "start_time": {"OTIO_SCHEMA": "RationalTime.1", "rate": 25, "value": 0}, "duration": {"OTIO_SCHEMA": "RationalTime.1", "rate": 25, "value": 50}},
"media_references": {"DEFAULT_MEDIA": {"OTIO_SCHEMA": "GeneratorReference.1", "generator_kind": "SMPTEBars"}}, "active_media_reference_key": "DEFAULT_MEDIA"}]}]}},
{"OTIO_SCHEMA": "Timeline.1", "name": "T2", "tracks": {"OTIO_SCHEMA": "Stack.1", "children": []}}
]}"#;
let (imp, _) = import(one.as_bytes(), Format::Otio, None).unwrap();
assert_eq!(imp.sequences.len(), 2);
let s = imp.project.sequence(imp.sequences[0]).unwrap();
assert_eq!(s.settings.frame_rate, FrameRate::FPS_25);
assert_eq!(s.video_tracks[0].items[0].duration, FrameRate::FPS_25.tick_of(50));
let m = imp.project.item(s.video_tracks[0].items[0].item).unwrap().as_media().unwrap();
assert_eq!(m.media, MediaRef::Generator(Generator::BarsAndTone));
assert!(import(b"{\"OTIO_SCHEMA\": \"Clip.2\"}", Format::Otio, None).is_err());
assert!(import(b"not json", Format::Otio, None).is_err());
}
#[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,
subframe: i64,
}
fn gen_clip() -> impl Strategy<Value = G> {
(0usize..3, 0i64..3, 20i64..150, 0usize..3, 0i64..2000, 0u8..5, prop::bool::weighted(0.9), 0u8..6, 1i64..9, any::<bool>(), 0i64..2000).prop_map(
|(track, gap, dur, media, src, speed, enabled, tr, tr_len, audio, subframe)| G {
track,
gap: gap * 5,
dur,
media,
src,
speed,
enabled,
tr,
tr_len,
audio,
subframe,
},
)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(48))]
#[test]
fn otio_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, rate == FrameRate::FPS_29_97);
let mut ends = [0i64; 3];
let mut prev: [Option<filmcraft_project::ClipId>; 3] = [None; 3];
let mut aend = [Tick::ZERO; 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, 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,
4 => it.frame_hold = Some(rate.tick_of(g.src + 3)),
_ => {}
}
}
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, d, TransitionAlign::CenterAtCut),
1 if adjacent => transition(&mut p, s, TrackKind::Video, g.track, "dip_to_white", prev[g.track], Some(v), t, d, TransitionAlign::StartAtCut),
2 if adjacent => transition(&mut p, s, TrackKind::Video, g.track, "push", prev[g.track], Some(v), t - d, d, TransitionAlign::EndAtCut),
3 if !adjacent => transition(&mut p, s, TrackKind::Video, g.track, "cross_dissolve", None, Some(v), t, d, TransitionAlign::StartAtCut),
_ => {}
}
// Audio at sample (not frame) positions, linked to the video.
let at = rate.tick_of(t) + Tick(TICKS_PER_SECOND / 48_000 * g.subframe % rate.frame_duration().0.max(1));
if g.audio && aend[g.track] <= at {
let a = clip(&mut p, s, TrackKind::Audio, g.track, ms[g.media], t, g.dur, g.src);
let q = p.sequence_mut(s).unwrap();
let (_, ai) = q.find_item_mut(a).unwrap();
ai.start = at;
aend[g.track] = ai.end();
link(&mut p, s, &[v, a]);
}
ends[g.track] = t + g.dur;
prev[g.track] = Some(v);
}
let (imp, text, _) = roundtrip(&p, s, Format::Otio, &ExportOptions::default());
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));
}
}