Rust
Using the core directly, with a Document that owns its objects and one crate per file format.
Rust is not a binding. The crates in this repository are the library: a port of upstream OpenTimelineIO's C++ core, with each file format in a crate of its own. Every other language on this site reaches these same crates, through the C ABI or, for Python, through PyO3. From Rust you skip that layer and hold the document yourself.
Install
Add the crates you need. otio-core is the data model; each adapter is its
own crate so that a program reading EDLs does not compile an AAF parser.
[dependencies]
otio-core = { git = "https://github.com/alchemist-editor/otio-rust" }
otio-cmx3600 = { git = "https://github.com/alchemist-editor/otio-rust" }| Crate | What it holds |
|---|---|
opentime | RationalTime, TimeRange and TimeTransform |
otio-core | The object model, the .otio reader and writer, the algorithms and the edits |
otio-adapter | The Adapter trait every format implements, and the error they share |
otio-cmx3600 | CMX 3600 EDL (Cmx3600) |
otio-ale | Avid Log Exchange (Ale) |
otio-fcp7 | Final Cut Pro 7 XML (Fcp7Xml) |
otio-fcpx | Final Cut Pro X XML (FcpxXml) |
otio-aaf | AAF (Aaf) |
otio-bundle | The .otioz and .otiod bundles |
The workspace is edition 2024 with a rust-version of 1.85. otio-core
depends on nothing but opentime, and the workspace has no third-party
dependencies.
To build and test everything from a clone:
git clone https://github.com/alchemist-editor/otio-rust
cd otio-rust
cargo build --workspace
cargo test --workspaceYour first program
use otio_adapter::Adapter;
use otio_cmx3600::{Cmx3600, ReadOptions};
fn main() -> Result<(), otio_adapter::Error> {
// An EDL never says what rate its timecode is at, so this has to be
// right: a file read at the wrong rate puts every event in the wrong
// place rather than failing.
let options = ReadOptions {
rate: 24.0,
..Default::default()
};
let document = Cmx3600::read_from_file("cut.edl", &options)?;
let root = document.root().expect("a parsed document has a root");
for id in document.find_clips(root)? {
let clip = document.try_get(id)?;
println!("{}", clip.name());
}
Ok(())
}Reading goes through the Adapter trait from otio-adapter, implemented by a
unit struct per format. Each format's options are a struct of their own, so an
option meant for one format cannot be handed to another, and a misspelled one
is a compile error. The rate is the option that matters for an EDL: see
CMX 3600 EDL.
What comes back is a Document. Its root() is an Option<NodeId>, and
try_get resolves a handle to the Node it names.
Building a timeline
use opentime::{RationalTime, TimeRange};
use otio_core::schema::{Base, Clip, ItemData, Stack, Timeline, Track};
use otio_core::{Document, Node};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// A document is an arena. Objects go into it and are named by handles,
// so nothing here is a pointer into anything.
let mut document = Document::new();
let tracks = document.insert(Node::Stack(Stack::default()));
let timeline = document.insert(Node::Timeline(Timeline {
base: Base {
name: "Cut".into(),
..Base::default()
},
tracks: Some(tracks),
global_start_time: None,
}));
document.set_root(Some(timeline));
let track = document.insert(Node::Track(Track {
kind: "Video".into(),
..Track::default()
}));
document.append_child(tracks, track)?;
for (index, name) in ["A", "B", "C"].into_iter().enumerate() {
let clip = document.insert(Node::Clip(Clip {
item: ItemData {
base: Base {
name: name.into(),
..Base::default()
},
source_range: Some(TimeRange::new(
RationalTime::new(index as f64 * 24.0, 24.0),
RationalTime::new(24.0, 24.0),
)),
..ItemData::new()
},
..Clip::default()
}));
document.append_child(track, clip)?;
}
// Three seconds of picture, written as canonical OpenTimelineIO JSON.
println!("{}", document.duration(track)?.to_seconds());
std::fs::write("cut.otio", otio_core::to_string(&document)?)?;
Ok(())
}Objects are plain structs wrapped in the Node enum and inserted into the
document, which hands back a NodeId for each. Links between objects, such as
the timeline's tracks and a track's children, are handles too, so building
is inserting and then linking with append_child.
How objects and documents work here
Upstream's C++ core uses intrusive reference counting plus raw parent
back-pointers, which is a reference cycle. otio-core stores every object in
a generational arena inside a Document and refers to them by NodeId, a
Copy handle. Parent and child links are handles, so a cycle is not
representable and nothing leaks. Removing an object bumps its slot's
generation, so a stale handle reports itself as stale rather than silently
reaching whatever took its place. The reasoning is in
ADR 0001.
The other bindings hide this: an object built on its own gets a document of
its own, and putting it into another moves it. In Rust that move is explicit.
Document::absorb empties one document into another and hands back the map
from old handles to new ones, rewriting every link on the way, including the
ones inside metadata.
There are two deep copies, and they differ only for an object held in two
places. Document::clone_object is upstream's clone(): an object held twice
comes out as two objects, and an object that holds itself is refused with
ObjectCycle. Document::deep_clone copies an object held twice once, and
copies a cycle.
Errors and missing values
Failure is a Result. otio-core's Error displays as upstream's message,
character for character, because code in the wild matches on that text; where
upstream appends the object concerned, Error::object names it. Reading
errors follow upstream's reader too, including RapidJSON's message and
position for a JSON syntax error. The adapters return otio_adapter::Error.
"There is nothing here" is an Option: a document's root(), an item's
source_range. One case is an error rather than an answer: asking a bare
Composition where a child sits returns Error::NoLayout, because unlike a
track or a stack it does not say how its children are laid out.
The reader is more lenient than upstream's on purpose. A missing field or an
explicit null takes its default, and a schema otio-core does not know is
kept whole and written back out unchanged.
Editing and algorithms
use opentime::{RationalTime, TimeRange};
use otio_core::schema::{Base, Clip, ItemData, Track};
use otio_core::{Document, Node, NodeId};
/// One second of picture, named.
fn second(document: &mut Document, name: &str) -> NodeId {
document.insert(Node::Clip(Clip {
item: ItemData {
base: Base {
name: name.into(),
..Base::default()
},
source_range: Some(TimeRange::new(
RationalTime::new(0.0, 24.0),
RationalTime::new(24.0, 24.0),
)),
..ItemData::new()
},
..Clip::default()
}))
}
fn show(document: &Document, track: NodeId) -> Result<(), otio_core::Error> {
let names: Vec<&str> = document
.children_of(track)?
.iter()
.map(|id| document.try_get(*id).map(Node::name).unwrap_or(""))
.collect();
println!("{} - {} frames", names.join(" "), document.duration(track)?.value());
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut document = Document::new();
let track = document.insert(Node::Track(Track {
kind: "Video".into(),
..Track::default()
}));
for name in ["A", "B", "C"] {
let clip = second(&mut document, name);
document.append_child(track, clip)?;
}
show(&document, track)?;
// Insert makes room: everything from the insertion point onwards moves
// later, and the track gets longer.
let inserted = second(&mut document, "D");
otio_core::edit::insert(
&mut document,
inserted,
track,
RationalTime::new(24.0, 24.0),
false,
None,
)?;
show(&document, track)?;
// Overwrite does not: it lays an item over a span and whatever was in
// that span gives way. The track is the same length afterwards.
let laid = second(&mut document, "E");
otio_core::edit::overwrite(
&mut document,
laid,
track,
TimeRange::new(RationalTime::new(48.0, 24.0), RationalTime::new(24.0, 24.0)),
false,
None,
)?;
show(&document, track)?;
Ok(())
}The ten edit operations are functions in otio_core::edit, each taking the
document first: slice, overwrite, insert, trim, ripple, roll,
slip, slide, fill and remove. They are upstream's editAlgorithm,
working in place on a track; Editing a timeline says
what each does to its neighbours.
otio_core::algorithm holds upstream's stack and track algorithms:
flatten_stack collapses a stack into the track a viewer would see, and
track_trimmed_to_range cuts a track down to a span. Both leave the original
untouched and leave nothing behind in the document but the answer.
Formats
.otio itself is otio-core's: otio_core::from_str reads a document and
otio_core::to_string writes one. OTIO files are not quite JSON, since
upstream writes NaN and Inf as bare literals, so otio-core carries its
own parser and writer. Older files are upgraded as they are read, following
upstream's registered upgrade functions.
Every other format is its adapter crate, and converting is a read with one and a write with another:
use otio_adapter::Adapter;
use otio_cmx3600::{Cmx3600, ReadOptions};
use otio_fcpx::FcpxXml;
fn main() -> Result<(), otio_adapter::Error> {
// An EDL never says what rate its timecode is at, so this has to be
// right: a file read at the wrong rate puts every event in the wrong
// place rather than failing.
let options = ReadOptions {
rate: 24.0,
..Default::default()
};
let document = Cmx3600::read_from_file("cut.edl", &options)?;
// Nothing happens in between. The timeline an EDL parses to is the same
// timeline FCP X writes out, so converting is a read and a write: the
// object model is the interchange, and the file formats are two ways of
// spelling it.
FcpxXml::write_to_file(&document, "cut.fcpxml", &Default::default())?;
Ok(())
}Bundles are otio_bundle::write_otioz, write_otiod and read_otioz. See
Reading and writing files for what each
format carries, and AAF and Bundles
for those two.
Platforms
CI builds and tests the workspace on Linux, macOS and Windows.
Reference
crates/otio-core/README.md: ownership, copying, editing, old files and error messages in full.crates/otio-adapter/README.md: theAdapterandTextAdaptertraits.- The C ABI, which is the layer every generated SDK sits on, and its reference.