Files
oak-editor/crates/oak-otio/src/model.rs
T
Mike-Solar 244d5e860f
CI / Build & test (Windows) (push) Failing after 7s
workspace: kebab-case crates, app under crates/oak-app, shared versions
All crates take the oak-* kebab-case naming (oak-audio, oak-codec,
oak-common, oak-core, oak-ffmpeg-link, oak-node, oak-otio, oak-plugin,
oak-render, oak-storage, oak-task, oak-timeline, oak-undo), with the
lib identifiers rewritten (oakrender:: -> oak_render::, oakcore_rs:: ->
oak_core::, ...) across all 226 referencing files.

The GUI application moves from the workspace root into
crates/oak-app/: src/, build.rs (paths fixed for the new location) and
tests/ travel with it, the root Cargo.toml becomes workspace-only
([workspace] + workspace.package + profiles), and the app package
inherits the workspace version. The screenshots example becomes a
standalone crate examples/simple_player/ with its own Cargo.toml.

Every crate now inherits the single workspace version
(version.workspace = true), and the workflows' crate paths and the
build docs follow the renames.

Validated with a clean cargo check --workspace.
2026-08-22 16:58:37 +08:00

1266 lines
36 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! A serde model of the OpenTimelineIO JSON format, covering exactly the
//! object graph the C++ load/save tasks use
//! (`src/task/src/project/loadotio/loadotio.cpp` and
//! `src/task/src/project/saveotio/saveotio.cpp`): `RationalTime`,
//! `TimeRange`, `Clip`, `Gap`, `Transition`, `Track`, `Stack`, `Timeline`,
//! `ExternalReference`, `MissingReference` and `SerializableCollection`.
//!
//! Design notes:
//!
//! - Every schema object is a plain struct whose first field is the private
//! `otio_schema` string (serialized as `"OTIO_SCHEMA"`). Declaring it first
//! makes the writer emit it first (the opentimelineio writer's order), and
//! declaring it before the trailing `#[serde(flatten)]` map keeps it out of
//! the unknown-field bucket on the way back in.
//! - Every schema object ends with `#[serde(flatten)] unknown`, so fields
//! this crate does not model are preserved verbatim across a load/save
//! round-trip (high-fidelity parity with the C++ writer, which also keeps
//! unknown fields).
//! - The three polymorphic containers (`Composable`, `Serializable`,
//! `MediaReference`) dispatch on the `OTIO_SCHEMA` string: known schema
//! names decode into their concrete struct, anything else is kept whole as
//! `Raw(serde_json::Value)` so it round-trips untouched.
//! - `#[serde(default)]` on every struct makes all fields optional on input,
//! matching the C++ reader's tolerance for hand-written files.
use std::collections::BTreeMap;
use std::path::Path;
use oak_core::Rational;
use serde::de::{self, Deserialize, Deserializer};
use serde::ser::{Serialize, Serializer};
use serde_json::Value;
use crate::error::OtioError;
pub(crate) type Map = serde_json::Map<String, Value>;
// Schema name constants (the "OTIO_SCHEMA" values written by opentimelineio).
const SCHEMA_RATIONAL_TIME: &str = "RationalTime.1";
const SCHEMA_TIME_RANGE: &str = "TimeRange.1";
const SCHEMA_CLIP: &str = "Clip.2";
const SCHEMA_GAP: &str = "Gap.1";
const SCHEMA_TRANSITION: &str = "Transition.1";
const SCHEMA_TRACK: &str = "Track.1";
const SCHEMA_STACK: &str = "Stack.1";
const SCHEMA_TIMELINE: &str = "Timeline.1";
const SCHEMA_EXTERNAL_REFERENCE: &str = "ExternalReference.1";
const SCHEMA_MISSING_REFERENCE: &str = "MissingReference.1";
const SCHEMA_SERIALIZABLE_COLLECTION: &str = "SerializableCollection.1";
/// Serialize `value` exactly like the opentimelineio C++ writer: 4-space
/// indentation, `": "` separators, inline `{}`/`[]`, shortest float
/// representation (ryu, identical to the C++ writer), no trailing newline.
pub(crate) fn to_json_string<T: serde::Serialize>(value: &T) -> Result<String, serde_json::Error> {
let mut buf = Vec::new();
let fmt = serde_json::ser::PrettyFormatter::with_indent(b" ");
let mut ser = serde_json::Serializer::with_formatter(&mut buf, fmt);
value.serialize(&mut ser)?;
String::from_utf8(buf).map_err(|e| serde_json::Error::io(std::io::Error::other(e)))
}
/// Serialize to a file (no trailing newline, matching the C++ writer).
pub(crate) fn to_json_file<T: serde::Serialize>(value: &T, path: &Path) -> Result<(), OtioError> {
std::fs::write(path, to_json_string(value)?)?;
Ok(())
}
/// Schema-name of an unknown (`Raw`) object: the raw `OTIO_SCHEMA` string,
/// or "" if absent.
fn raw_schema_name(v: &Value) -> &str {
v.get("OTIO_SCHEMA").and_then(Value::as_str).unwrap_or("")
}
// ---------------------------------------------------------------------------
// Value types
// ---------------------------------------------------------------------------
/// `RationalTime.1` — a time value and its frame rate.
///
/// Mirrors `opentimelineio::RationalTime`. Field order on disk is `rate`
/// then `value` (the opentimelineio writer's order).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct RationalTime {
#[serde(rename = "OTIO_SCHEMA", default)]
otio_schema: String,
rate: f64,
value: f64,
}
impl RationalTime {
/// Construct `RationalTime(value, rate)` (C++ argument order).
pub fn new(value: f64, rate: f64) -> RationalTime {
RationalTime {
otio_schema: SCHEMA_RATIONAL_TIME.to_string(),
rate,
value,
}
}
/// The value in the object's own rate.
pub fn value(&self) -> f64 {
self.value
}
/// The frame rate (ticks per second).
pub fn rate(&self) -> f64 {
self.rate
}
/// Convert to seconds (`value / rate`; non-finite for a non-positive
/// rate, same as C++).
pub fn to_seconds(self) -> f64 {
self.value / self.rate
}
/// The invalid-time sentinel (`RationalTime(0, 0)`, C++ `invalid_time`).
pub fn invalid_time() -> RationalTime {
RationalTime::new(0.0, 0.0)
}
/// True for a rate `<= 0` or a non-finite value/rate
/// (C++ `is_invalid_time`).
pub fn is_invalid_time(self) -> bool {
self.rate <= 0.0 || self.value.is_nan() || self.rate.is_nan()
}
/// Convert to another rate (`value * (new_rate / rate)`, C++
/// `rescaled_to`; returns `self` unchanged when the rates already
/// match).
pub fn rescaled_to(self, new_rate: f64) -> RationalTime {
if self.rate == new_rate {
return self;
}
RationalTime::new(self.value * (new_rate / self.rate), new_rate)
}
/// C++ `Rational::fromRationalTime` — the exact rational that
/// `to_seconds()` yields (`Rational::from_double(value / rate)`).
pub fn to_rational(self) -> Rational {
from_double(self.to_seconds())
}
/// C++ `Rational::toRationalTime(framerate)` — `RationalTime(num, den
/// or 1)` rescaled to `framerate`.
pub fn from_rational(r: Rational, framerate: f64) -> RationalTime {
let rate = if r.denominator() == 0 {
1.0
} else {
r.denominator() as f64
};
RationalTime::new(r.numerator() as f64, rate).rescaled_to(framerate)
}
}
impl Default for RationalTime {
fn default() -> Self {
RationalTime::new(0.0, 0.0)
}
}
/// `TimeRange.1` — a duration and its start time. Field order on disk is
/// `duration` then `start_time` (the opentimelineio writer's order).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct TimeRange {
#[serde(rename = "OTIO_SCHEMA", default)]
otio_schema: String,
duration: RationalTime,
start_time: RationalTime,
}
impl TimeRange {
/// Construct `TimeRange(start_time, duration)` (C++ argument order).
pub fn new(start_time: RationalTime, duration: RationalTime) -> TimeRange {
TimeRange {
otio_schema: SCHEMA_TIME_RANGE.to_string(),
duration,
start_time,
}
}
/// The start of the range.
pub fn start_time(&self) -> RationalTime {
self.start_time.clone()
}
/// The duration of the range.
pub fn duration(&self) -> RationalTime {
self.duration.clone()
}
}
impl Default for TimeRange {
fn default() -> Self {
TimeRange::new(RationalTime::invalid_time(), RationalTime::invalid_time())
}
}
// ---------------------------------------------------------------------------
// Media references
// ---------------------------------------------------------------------------
/// `ExternalReference.1` — media referenced by URL.
#[derive(Clone, Debug, PartialEq, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ExternalReference {
#[serde(rename = "OTIO_SCHEMA", default)]
otio_schema: String,
metadata: Map,
name: String,
available_range: Option<TimeRange>,
available_image_bounds: Option<Value>,
target_url: String,
#[serde(flatten)]
unknown: Map,
}
impl ExternalReference {
/// Construct `ExternalReference(target_url, available_range)` (C++
/// argument order; `None` serializes as `null`).
pub fn new(
target_url: impl Into<String>,
available_range: Option<TimeRange>,
) -> ExternalReference {
ExternalReference {
otio_schema: SCHEMA_EXTERNAL_REFERENCE.to_string(),
metadata: Map::new(),
name: String::new(),
available_range,
available_image_bounds: None,
target_url: target_url.into(),
unknown: Map::new(),
}
}
/// The media URL (C++ `target_url`).
pub fn target_url(&self) -> &str {
&self.target_url
}
/// The range of media available on disk, if any.
pub fn available_range(&self) -> Option<TimeRange> {
self.available_range.clone()
}
}
/// `MissingReference.1` — media referenced but not resolvable. The C++
/// writer emits `available_range` and `available_image_bounds` as `null`
/// and no `target_url` at all.
#[derive(Clone, Debug, PartialEq, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct MissingReference {
#[serde(rename = "OTIO_SCHEMA", default)]
otio_schema: String,
metadata: Map,
name: String,
available_range: Option<TimeRange>,
available_image_bounds: Option<Value>,
#[serde(flatten)]
unknown: Map,
}
impl MissingReference {
/// A bare `MissingReference` (the C++ default constructor).
pub fn new() -> MissingReference {
MissingReference {
otio_schema: SCHEMA_MISSING_REFERENCE.to_string(),
metadata: Map::new(),
name: String::new(),
available_range: None,
available_image_bounds: None,
unknown: Map::new(),
}
}
}
/// A clip's media reference: `ExternalReference`, `MissingReference`, or
/// anything else the file contains, kept whole as `Raw`.
#[derive(Clone, Debug, PartialEq)]
pub enum MediaReference {
ExternalReference(ExternalReference),
MissingReference(MissingReference),
/// An unrecognized media-reference schema, preserved verbatim.
Raw(Value),
}
impl MediaReference {
/// The base schema name ("ExternalReference", "MissingReference", or
/// the raw `OTIO_SCHEMA` string for unknown references).
pub fn schema_name(&self) -> &str {
match self {
MediaReference::ExternalReference(_) => "ExternalReference",
MediaReference::MissingReference(_) => "MissingReference",
MediaReference::Raw(v) => raw_schema_name(v),
}
}
/// Downcast to a concrete `ExternalReference`.
pub fn as_external_reference(&self) -> Option<&ExternalReference> {
match self {
MediaReference::ExternalReference(e) => Some(e),
_ => None,
}
}
/// Downcast to a concrete `MissingReference`.
pub fn as_missing_reference(&self) -> Option<&MissingReference> {
match self {
MediaReference::MissingReference(m) => Some(m),
_ => None,
}
}
}
impl Serialize for MediaReference {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
MediaReference::ExternalReference(e) => e.serialize(serializer),
MediaReference::MissingReference(m) => m.serialize(serializer),
MediaReference::Raw(v) => v.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for MediaReference {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
let schema = raw_schema_name(&value);
match schema {
SCHEMA_EXTERNAL_REFERENCE => serde_json::from_value(value)
.map(MediaReference::ExternalReference)
.map_err(de::Error::custom),
SCHEMA_MISSING_REFERENCE => serde_json::from_value(value)
.map(MediaReference::MissingReference)
.map_err(de::Error::custom),
_ => Ok(MediaReference::Raw(value)),
}
}
}
// ---------------------------------------------------------------------------
// Composable objects (blocks and containers)
// ---------------------------------------------------------------------------
/// `Clip.2` — a reference to a piece of media.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Clip {
#[serde(rename = "OTIO_SCHEMA", default)]
otio_schema: String,
metadata: Map,
name: String,
source_range: Option<TimeRange>,
effects: Vec<Value>,
markers: Vec<Value>,
enabled: bool,
media_references: BTreeMap<String, MediaReference>,
active_media_reference_key: Option<String>,
#[serde(flatten)]
unknown: Map,
}
impl Clip {
/// Construct a named clip (the C++ `Clip(name)` constructor). All
/// optional fields start unset; `enabled` defaults to true.
pub fn new(name: impl Into<String>) -> Clip {
Clip {
otio_schema: SCHEMA_CLIP.to_string(),
metadata: Map::new(),
name: name.into(),
source_range: None,
effects: Vec::new(),
markers: Vec::new(),
enabled: true,
media_references: BTreeMap::new(),
active_media_reference_key: None,
unknown: Map::new(),
}
}
/// The clip name (C++ `name`).
pub fn name(&self) -> &str {
&self.name
}
/// The range of the source media used by this clip, if set
/// (C++ `source_range`; null on disk when unset).
pub fn source_range(&self) -> Option<TimeRange> {
self.source_range.clone()
}
/// The active media reference, resolved by `active_media_reference_key`
/// (falling back to "DEFAULT_MEDIA", the C++ behavior).
pub fn media_reference(&self) -> Option<&MediaReference> {
let key = self
.active_media_reference_key
.as_deref()
.unwrap_or("DEFAULT_MEDIA");
self.media_references.get(key)
}
/// All media references by key.
pub fn media_references(&self) -> &BTreeMap<String, MediaReference> {
&self.media_references
}
/// Set the source range (C++ `set_source_range`).
pub fn set_source_range(&mut self, range: TimeRange) {
self.source_range = Some(range);
}
/// Attach a media reference under "DEFAULT_MEDIA" and make it active
/// (C++ `set_media_reference`).
pub fn set_media_reference(&mut self, reference: MediaReference) {
self.media_references
.insert("DEFAULT_MEDIA".to_string(), reference);
self.active_media_reference_key = Some("DEFAULT_MEDIA".to_string());
}
/// Whether the clip is enabled (C++ `enabled`; used by the FCPXML
/// layer for `asset-clip enabled="0"`).
pub fn enabled(&self) -> bool {
self.enabled
}
/// Set the enabled flag (C++ `set_enabled`).
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
}
impl Default for Clip {
fn default() -> Self {
Clip::new("")
}
}
/// `Gap.1` — an empty span of time on a track.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Gap {
#[serde(rename = "OTIO_SCHEMA", default)]
otio_schema: String,
metadata: Map,
name: String,
source_range: Option<TimeRange>,
effects: Vec<Value>,
markers: Vec<Value>,
enabled: bool,
#[serde(flatten)]
unknown: Map,
}
impl Gap {
/// Construct `Gap(source_range, name)` (C++ argument order).
pub fn new(source_range: TimeRange, name: impl Into<String>) -> Gap {
Gap {
otio_schema: SCHEMA_GAP.to_string(),
metadata: Map::new(),
name: name.into(),
source_range: Some(source_range),
effects: Vec::new(),
markers: Vec::new(),
enabled: true,
unknown: Map::new(),
}
}
/// The gap name.
pub fn name(&self) -> &str {
&self.name
}
/// The gap's source range (always set for gaps written by this crate).
pub fn source_range(&self) -> Option<TimeRange> {
self.source_range.clone()
}
}
impl Default for Gap {
fn default() -> Self {
Gap {
otio_schema: SCHEMA_GAP.to_string(),
metadata: Map::new(),
name: String::new(),
source_range: None,
effects: Vec::new(),
markers: Vec::new(),
enabled: true,
unknown: Map::new(),
}
}
}
/// `Transition.1` — a transition between two blocks. The C++ writer always
/// writes `transition_type` (empty string when unset).
#[derive(Clone, Debug, PartialEq, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Transition {
#[serde(rename = "OTIO_SCHEMA", default)]
otio_schema: String,
metadata: Map,
name: String,
in_offset: RationalTime,
out_offset: RationalTime,
transition_type: String,
#[serde(flatten)]
unknown: Map,
}
impl Transition {
/// Construct a named transition (the C++ `Transition(name)`
/// constructor; offsets default to `RationalTime(0, 1)`).
pub fn new(name: impl Into<String>) -> Transition {
Transition {
otio_schema: SCHEMA_TRANSITION.to_string(),
metadata: Map::new(),
name: name.into(),
in_offset: RationalTime::new(0.0, 1.0),
out_offset: RationalTime::new(0.0, 1.0),
transition_type: String::new(),
unknown: Map::new(),
}
}
/// The transition name.
pub fn name(&self) -> &str {
&self.name
}
/// How far into the previous block the transition reaches (C++
/// `in_offset`).
pub fn in_offset(&self) -> RationalTime {
self.in_offset.clone()
}
/// How far into the next block the transition reaches (C++
/// `out_offset`).
pub fn out_offset(&self) -> RationalTime {
self.out_offset.clone()
}
/// The transition type ("SMPTE_Dissolve", "SMPTE_Wipe", "" ...).
pub fn transition_type(&self) -> &str {
&self.transition_type
}
/// Set the in offset (C++ `set_in_offset`).
pub fn set_in_offset(&mut self, offset: RationalTime) {
self.in_offset = offset;
}
/// Set the out offset (C++ `set_out_offset`).
pub fn set_out_offset(&mut self, offset: RationalTime) {
self.out_offset = offset;
}
}
/// `Stack.1` — an ordered container of composables (used for a timeline's
/// tracks).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Stack {
#[serde(rename = "OTIO_SCHEMA", default)]
otio_schema: String,
metadata: Map,
name: String,
source_range: Option<TimeRange>,
effects: Vec<Value>,
markers: Vec<Value>,
enabled: bool,
children: Vec<Box<Composable>>,
#[serde(flatten)]
unknown: Map,
}
impl Default for Stack {
fn default() -> Self {
Stack {
otio_schema: SCHEMA_STACK.to_string(),
metadata: Map::new(),
name: String::new(),
source_range: None,
effects: Vec::new(),
markers: Vec::new(),
enabled: true,
children: Vec::new(),
unknown: Map::new(),
}
}
}
impl Stack {
/// The stack's children in order (C++ `children`).
pub fn children(&self) -> &[Box<Composable>] {
&self.children
}
/// Append a child (C++ `append_child`).
pub fn append_child(&mut self, child: Composable) {
self.children.push(Box::new(child));
}
}
/// `Track.1` — a stack with a track kind. Field order on disk is
/// `children` then `kind` (the opentimelineio writer's order).
#[derive(Clone, Debug, PartialEq, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Track {
#[serde(rename = "OTIO_SCHEMA", default)]
otio_schema: String,
metadata: Map,
name: String,
source_range: Option<TimeRange>,
effects: Vec<Value>,
markers: Vec<Value>,
enabled: bool,
children: Vec<Box<Composable>>,
kind: String,
#[serde(flatten)]
unknown: Map,
}
impl Track {
/// Construct a track of the given kind ("Video" or "Audio").
pub fn new(kind: impl Into<String>) -> Track {
Track {
otio_schema: SCHEMA_TRACK.to_string(),
metadata: Map::new(),
name: String::new(),
source_range: None,
effects: Vec::new(),
markers: Vec::new(),
enabled: true,
children: Vec::new(),
kind: kind.into(),
unknown: Map::new(),
}
}
/// The track kind ("Video", "Audio", ...).
pub fn kind(&self) -> &str {
&self.kind
}
/// The track's blocks in order (C++ `children`).
pub fn children(&self) -> &[Box<Composable>] {
&self.children
}
/// Append a child (C++ `append_child`).
pub fn append_child(&mut self, child: Composable) {
self.children.push(Box::new(child));
}
}
/// A composable in a track or stack: `Clip`, `Gap`, `Transition`, `Track`,
/// `Stack`, or an unrecognized schema kept whole as `Raw`.
#[derive(Clone, Debug, PartialEq)]
pub enum Composable {
Clip(Clip),
Gap(Gap),
Transition(Transition),
Track(Track),
Stack(Stack),
/// An unrecognized composable schema, preserved verbatim.
Raw(Value),
}
impl Composable {
/// The base schema name ("Clip", "Gap", "Transition", "Track",
/// "Stack", or the raw `OTIO_SCHEMA` string for unknown blocks).
pub fn schema_name(&self) -> &str {
match self {
Composable::Clip(_) => "Clip",
Composable::Gap(_) => "Gap",
Composable::Transition(_) => "Transition",
Composable::Track(_) => "Track",
Composable::Stack(_) => "Stack",
Composable::Raw(v) => raw_schema_name(v),
}
}
/// The object's name ("" for unknown blocks).
pub fn name(&self) -> &str {
match self {
Composable::Clip(c) => &c.name,
Composable::Gap(g) => &g.name,
Composable::Transition(t) => &t.name,
Composable::Track(t) => &t.name,
Composable::Stack(s) => &s.name,
Composable::Raw(_) => "",
}
}
/// The source range of items that carry one (Clip and Gap; the C++
/// `Item::source_range` cast for these two block kinds).
pub fn source_range(&self) -> Option<TimeRange> {
match self {
Composable::Clip(c) => c.source_range.clone(),
Composable::Gap(g) => g.source_range.clone(),
_ => None,
}
}
/// Downcast to a concrete `Clip`.
pub fn as_clip(&self) -> Option<&Clip> {
match self {
Composable::Clip(c) => Some(c),
_ => None,
}
}
/// Downcast to a concrete `Gap`.
pub fn as_gap(&self) -> Option<&Gap> {
match self {
Composable::Gap(g) => Some(g),
_ => None,
}
}
/// Downcast to a concrete `Transition`.
pub fn as_transition(&self) -> Option<&Transition> {
match self {
Composable::Transition(t) => Some(t),
_ => None,
}
}
/// Downcast to a concrete `Track`.
pub fn as_track(&self) -> Option<&Track> {
match self {
Composable::Track(t) => Some(t),
_ => None,
}
}
/// Downcast to a concrete `Stack`.
pub fn as_stack(&self) -> Option<&Stack> {
match self {
Composable::Stack(s) => Some(s),
_ => None,
}
}
}
impl Serialize for Composable {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Composable::Clip(c) => c.serialize(serializer),
Composable::Gap(g) => g.serialize(serializer),
Composable::Transition(t) => t.serialize(serializer),
Composable::Track(t) => t.serialize(serializer),
Composable::Stack(s) => s.serialize(serializer),
Composable::Raw(v) => v.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for Composable {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
let schema = raw_schema_name(&value);
match schema {
SCHEMA_CLIP => serde_json::from_value(value)
.map(Composable::Clip)
.map_err(de::Error::custom),
SCHEMA_GAP => serde_json::from_value(value)
.map(Composable::Gap)
.map_err(de::Error::custom),
SCHEMA_TRANSITION => serde_json::from_value(value)
.map(Composable::Transition)
.map_err(de::Error::custom),
SCHEMA_TRACK => serde_json::from_value(value)
.map(Composable::Track)
.map_err(de::Error::custom),
SCHEMA_STACK => serde_json::from_value(value)
.map(Composable::Stack)
.map_err(de::Error::custom),
_ => Ok(Composable::Raw(value)),
}
}
}
// ---------------------------------------------------------------------------
// Root serializable objects
// ---------------------------------------------------------------------------
/// `Timeline.1` — the root of a single-timeline document.
#[derive(Clone, Debug, PartialEq, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Timeline {
#[serde(rename = "OTIO_SCHEMA", default)]
otio_schema: String,
metadata: Map,
name: String,
global_start_time: Option<RationalTime>,
tracks: Stack,
#[serde(flatten)]
unknown: Map,
}
impl Timeline {
/// Construct a named timeline whose tracks stack is an empty
/// `Stack` named "tracks" (the C++ `Timeline(name)` constructor).
pub fn new(name: impl Into<String>) -> Timeline {
let mut tracks = Stack::default();
tracks.name = "tracks".to_string();
Timeline {
otio_schema: SCHEMA_TIMELINE.to_string(),
metadata: Map::new(),
name: name.into(),
global_start_time: None,
tracks,
unknown: Map::new(),
}
}
/// The timeline name.
pub fn name(&self) -> &str {
&self.name
}
/// The tracks stack (C++ `tracks`).
pub fn tracks(&self) -> &Stack {
&self.tracks
}
/// Mutable access to the tracks stack, for appending tracks.
pub fn tracks_mut(&mut self) -> &mut Stack {
&mut self.tracks
}
/// The global start time, if set (null on disk when unset).
pub fn global_start_time(&self) -> Option<RationalTime> {
self.global_start_time.clone()
}
/// Set the global start time (C++ `set_global_start_time`).
pub fn set_global_start_time(&mut self, start: RationalTime) {
self.global_start_time = Some(start);
}
/// The timeline metadata map (C++ `metadata`). The FCPXML layer stores
/// interchange hints (source version, tcFormat, ...) under the nested
/// "fcpxml" key.
pub fn metadata(&self) -> &Map {
&self.metadata
}
/// Mutable access to the timeline metadata map.
pub fn metadata_mut(&mut self) -> &mut Map {
&mut self.metadata
}
/// Serialize to the opentimelineio JSON string format.
pub fn to_json_string(&self) -> Result<String, OtioError> {
Ok(to_json_string(self)?)
}
/// Write to a file in the opentimelineio JSON format.
pub fn to_json_file(&self, path: impl AsRef<Path>) -> Result<(), OtioError> {
to_json_file(self, path.as_ref())
}
}
/// `SerializableCollection.1` — a root that groups multiple timelines.
#[derive(Clone, Debug, PartialEq, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SerializableCollection {
#[serde(rename = "OTIO_SCHEMA", default)]
otio_schema: String,
metadata: Map,
name: String,
children: Vec<Box<Serializable>>,
#[serde(flatten)]
unknown: Map,
}
impl SerializableCollection {
/// Construct `SerializableCollection(name, children)` (C++ argument
/// order).
pub fn new(name: impl Into<String>, children: Vec<Serializable>) -> SerializableCollection {
SerializableCollection {
otio_schema: SCHEMA_SERIALIZABLE_COLLECTION.to_string(),
metadata: Map::new(),
name: name.into(),
children: children.into_iter().map(Box::new).collect(),
unknown: Map::new(),
}
}
/// The collection name.
pub fn name(&self) -> &str {
&self.name
}
/// The collection's children in order.
pub fn children(&self) -> &[Box<Serializable>] {
&self.children
}
}
/// The root of an OpenTimelineIO document: `Timeline`,
/// `SerializableCollection`, or an unrecognized schema kept whole as `Raw`.
#[derive(Clone, Debug, PartialEq)]
pub enum Serializable {
Timeline(Timeline),
SerializableCollection(SerializableCollection),
/// An unrecognized root schema, preserved verbatim.
Raw(Value),
}
impl Serializable {
/// The base schema name ("Timeline", "SerializableCollection", or the
/// raw `OTIO_SCHEMA` string for unknown roots).
pub fn schema_name(&self) -> &str {
match self {
Serializable::Timeline(_) => "Timeline",
Serializable::SerializableCollection(_) => "SerializableCollection",
Serializable::Raw(v) => raw_schema_name(v),
}
}
/// Downcast to a concrete `Timeline`.
pub fn as_timeline(&self) -> Option<&Timeline> {
match self {
Serializable::Timeline(t) => Some(t),
_ => None,
}
}
/// Downcast to a concrete `SerializableCollection`.
pub fn as_collection(&self) -> Option<&SerializableCollection> {
match self {
Serializable::SerializableCollection(c) => Some(c),
_ => None,
}
}
/// Serialize to the opentimelineio JSON string format.
pub fn to_json_string(&self) -> Result<String, OtioError> {
Ok(to_json_string(self)?)
}
/// Write to a file in the opentimelineio JSON format.
pub fn to_json_file(&self, path: impl AsRef<Path>) -> Result<(), OtioError> {
to_json_file(self, path.as_ref())
}
}
impl Serialize for Serializable {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Serializable::Timeline(t) => t.serialize(serializer),
Serializable::SerializableCollection(c) => c.serialize(serializer),
Serializable::Raw(v) => v.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for Serializable {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
let schema = raw_schema_name(&value);
match schema {
SCHEMA_TIMELINE => serde_json::from_value(value)
.map(Serializable::Timeline)
.map_err(de::Error::custom),
SCHEMA_SERIALIZABLE_COLLECTION => serde_json::from_value(value)
.map(Serializable::SerializableCollection)
.map_err(de::Error::custom),
_ => Ok(Serializable::Raw(value)),
}
}
}
// ---------------------------------------------------------------------------
// Rational::from_double port
// ---------------------------------------------------------------------------
/// `olive::core::Rational::from_double` (core/src/util/rational.cpp), ported
/// on top of `oak_core::Rational::new`. `Rational::new` applies the exact
/// C++ `reduce_fraction(INT_MAX)` reduction, so the same raw numerator /
/// denominator pairs yield bit-identical rationals.
fn from_double(flt: f64) -> Rational {
// NaN and anything beyond the int32 range collapse to the null sentinel
// (0/0).
if flt.is_nan() || flt.abs() > i32::MAX as f64 + 3.0 {
return Rational::NULL;
}
// frexp(flt, &exp) with exponent = max(exp - 1, 0); exp - 1 equals the
// IEEE unbiased exponent, so `exponent = max(unbiased, 0)`.
let bits = flt.to_bits();
let unbiased = ((bits >> 52) & 0x7ff) as i64 - 1023;
let exponent = unbiased.max(0);
// den = 1 << (62 - exponent); num = floor(flt * den + 0.5).
let den = 1i64 << (62 - exponent);
let num = (flt * den as f64 + 0.5).floor() as i64;
let mut r = Rational::new(num, den);
// If the first pass reduced to zero, retry against INT64_MAX
// (C++: `if ((!rnum || !rden) && flt)`). Only triggered by tiny
// non-zero magnitudes; the product is always in int64 range, so the
// float-to-int cast truncates exactly like the C++ one.
if r.is_null() && flt != 0.0 {
r = Rational::new((flt * i64::MAX as f64) as i64, i64::MAX);
}
if r.is_nan() {
return Rational::NULL;
}
r
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_rt(value: f64, num: i64, den: i64) {
let r = from_double(value);
assert_eq!(
(r.numerator(), r.denominator()),
(num, den),
"from_double({value}) != {num}/{den}"
);
}
#[test]
fn from_double_basics() {
assert_rt(0.0, 0, 1);
assert_rt(1.0, 1, 1);
assert_rt(24.0, 24, 1);
assert_rt(0.1, 1, 10);
assert_rt(4.8, 24, 5);
assert_rt(-0.1, -1, 10);
assert_rt(1152.0 / 24.0, 48, 1);
assert_rt(123456789.125, 987654313, 8);
}
#[test]
fn from_double_sentinels() {
assert!(from_double(f64::NAN).is_null());
assert!(from_double(f64::INFINITY).is_null());
assert!(from_double(-f64::INFINITY).is_null());
assert!(from_double(i32::MAX as f64 + 4.0).is_null());
assert_rt(-0.0, 0, 1);
}
#[test]
fn from_double_tiny_retry() {
// 2^-63 < flt < 2^-62: the first pass reduces to 0/1; the retry
// against INT64_MAX is itself reduced back to 0/1 by the INT_MAX
// ceiling, matching the C++ behavior exactly.
let tiny = 1.5e-19;
let r = from_double(tiny);
assert_eq!((r.numerator(), r.denominator()), (0, 1));
// Positive zero of the first pass but nonzero value: retry fires.
assert_rt(0.0, 0, 1);
}
#[test]
fn to_rational_from_seconds() {
// C++ fromRationalTime: RationalTime(1152, 24).to_seconds() = 48.
assert_eq!(
RationalTime::new(1152.0, 24.0).to_rational(),
Rational::new(48, 1)
);
// RationalTime(12, 1).to_seconds() = 12.
assert_eq!(
RationalTime::new(12.0, 1.0).to_rational(),
Rational::new(12, 1)
);
// RationalTime(576, 24).to_seconds() = 24.
assert_eq!(
RationalTime::new(576.0, 24.0).to_rational(),
Rational::new(24, 1)
);
}
#[test]
fn from_rational_matches_cpp_to_rational_time() {
// Rational(576, 24).toRationalTime(24) stays {576, 24}.
let rt = RationalTime::from_rational(Rational::new(576, 24), 24.0);
assert_eq!((rt.value(), rt.rate()), (576.0, 24.0));
// Rational(12, 24).toRationalTime() (default 24) = {12, 24}.
let rt = RationalTime::from_rational(Rational::new(12, 24), 24.0);
assert_eq!((rt.value(), rt.rate()), (12.0, 24.0));
// Rational(0, 1).toRationalTime(24) = {0, 24}.
let rt = RationalTime::from_rational(Rational::new(0, 1), 24.0);
assert_eq!((rt.value(), rt.rate()), (0.0, 24.0));
// Rational(1, 25).toRationalTime(25) = {1, 25}.
let rt = RationalTime::from_rational(Rational::new(1, 25), 25.0);
assert_eq!((rt.value(), rt.rate()), (1.0, 25.0));
}
#[test]
fn rescaled_to_semantics() {
let rt = RationalTime::new(1.0, 25.0).rescaled_to(50.0);
assert_eq!((rt.value(), rt.rate()), (2.0, 50.0));
// Same rate: returned unchanged.
let rt = RationalTime::new(1.0, 25.0).rescaled_to(25.0);
assert_eq!((rt.value(), rt.rate()), (1.0, 25.0));
}
#[test]
fn invalid_time_semantics() {
assert!(RationalTime::new(0.0, 0.0).is_invalid_time());
assert!(RationalTime::new(1.0, -1.0).is_invalid_time());
assert!(RationalTime::new(f64::NAN, 24.0).is_invalid_time());
assert!(!RationalTime::new(1.0, 24.0).is_invalid_time());
}
#[test]
fn schema_names() {
let clip = Composable::Clip(Clip::new("c"));
assert_eq!(clip.schema_name(), "Clip");
assert_eq!(clip.name(), "c");
assert_eq!(
Composable::Gap(Gap::new(TimeRange::default(), "g")).schema_name(),
"Gap"
);
assert_eq!(
Composable::Transition(Transition::new("t")).schema_name(),
"Transition"
);
assert_eq!(
Composable::Track(Track::new("Video")).schema_name(),
"Track"
);
assert_eq!(Composable::Stack(Stack::default()).schema_name(), "Stack");
}
#[test]
fn unknown_field_round_trip() {
let src = r#"{
"OTIO_SCHEMA": "Timeline.1",
"metadata": {},
"name": "T",
"global_start_time": null,
"tracks": {
"OTIO_SCHEMA": "Stack.1",
"metadata": {"custom": 42},
"name": "tracks",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [],
"future_field": {"nested": [1, 2, 3]}
},
"some_future_root_field": "kept"
}"#;
let root: Serializable = serde_json::from_str(src).unwrap();
let out: Value = serde_json::from_str(&to_json_string(&root).unwrap()).unwrap();
assert_eq!(
out["tracks"]["future_field"].clone(),
serde_json::json!({"nested": [1, 2, 3]}),
"{out}"
);
assert_eq!(
out["some_future_root_field"].clone(),
serde_json::json!("kept"),
"{out}"
);
assert_eq!(
out["tracks"]["metadata"].clone(),
serde_json::json!({"custom": 42}),
"{out}"
);
}
#[test]
fn unknown_root_and_composable_stay_raw() {
let raw_root = r#"{"OTIO_SCHEMA": "WeirdThing.9", "a": 1}"#;
let root: Serializable = serde_json::from_str(raw_root).unwrap();
assert_eq!(root.schema_name(), "WeirdThing.9");
let out: Value = serde_json::from_str(&to_json_string(&root).unwrap()).unwrap();
assert_eq!(out, serde_json::from_str::<Value>(raw_root).unwrap());
let raw_block = r#"{"OTIO_SCHEMA": "FancyBlock.3", "b": [true]}"#;
let block: Composable = serde_json::from_str(raw_block).unwrap();
assert_eq!(block.schema_name(), "FancyBlock.3");
let out: Value = serde_json::from_str(&to_json_string(&block).unwrap()).unwrap();
assert_eq!(out, serde_json::from_str::<Value>(raw_block).unwrap());
let raw_media = r#"{"OTIO_SCHEMA": "FancyReference.2", "c": "x"}"#;
let media: MediaReference = serde_json::from_str(raw_media).unwrap();
assert_eq!(media.schema_name(), "FancyReference.2");
let out: Value = serde_json::from_str(&to_json_string(&media).unwrap()).unwrap();
assert_eq!(out, serde_json::from_str::<Value>(raw_media).unwrap());
}
#[test]
fn timeline_new_builds_named_tracks_stack() {
let t = Timeline::new("My Sequence");
assert_eq!(t.name(), "My Sequence");
assert_eq!(t.tracks().children().len(), 0);
assert!(t.global_start_time().is_none());
let out = to_json_string(&t).unwrap();
assert!(out.contains("\"name\": \"tracks\""), "{out}");
}
#[test]
fn clip_media_reference_resolution() {
let mut clip = Clip::new("c");
assert!(clip.media_reference().is_none());
clip.set_media_reference(MediaReference::ExternalReference(ExternalReference::new(
"file:///x.mp4",
None,
)));
let mr = clip.media_reference().unwrap();
assert_eq!(mr.schema_name(), "ExternalReference");
assert_eq!(
mr.as_external_reference().unwrap().target_url(),
"file:///x.mp4"
);
// An active key that is not in the map yields None (C++ behavior).
let mut clip2 = Clip::new("c2");
clip2.active_media_reference_key = Some("OTHER".to_string());
clip2.media_references.insert(
"OTHER".to_string(),
MediaReference::MissingReference(MissingReference::new()),
);
assert!(clip2.media_reference().is_some());
}
}