O
All pages

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" }
CrateWhat it holds
opentimeRationalTime, TimeRange and TimeTransform
otio-coreThe object model, the .otio reader and writer, the algorithms and the edits
otio-adapterThe Adapter trait every format implements, and the error they share
otio-cmx3600CMX 3600 EDL (Cmx3600)
otio-aleAvid Log Exchange (Ale)
otio-fcp7Final Cut Pro 7 XML (Fcp7Xml)
otio-fcpxFinal Cut Pro X XML (FcpxXml)
otio-aafAAF (Aaf)
otio-bundleThe .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 --workspace

Your 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