Overview
A pure-Rust OpenTimelineIO, and a binding for every language it is generated into.
OpenTimelineIO is how editorial decisions move between programs. A cut assembled in one tool, a conform in another, a review in a third: each holds the same idea — these shots, in this order, trimmed here, at this rate — and OTIO is the written form of that idea, so the three can agree about it without agreeing about anything else.
This library is a port of it to Rust. Not a reimplementation with its own ideas about how timelines should work: the arithmetic, the rounding and the timecode behaviour follow upstream exactly, measured against upstream's own test suites, because a file written here has to open unchanged in every tool that already reads OTIO.
What you get
The whole data model, the time math beneath it, and the interchange formats post actually uses:
| Piece | What it does |
|---|---|
opentime | Rational time, time ranges, SMPTE timecode, drop-frame and all |
otio-core | The schemas, the compositions, the algorithms, and .otio JSON |
otio-ale | Avid Log Exchange |
otio-cmx3600 | CMX 3600 Edit Decision Lists |
otio-fcp7 | Final Cut Pro 7 interchange XML |
otio-fcpx | Final Cut Pro X XML |
otio-aaf | AAF, read and written as upstream's adapter writes it |
Everything above sits on Rust and nothing else. There is no C++ library underneath and no Python interpreter: a binding links one static library.
Why the bindings all agree
Each language SDK is generated from the C interface rather than written by hand, and that interface describes itself — the generator reads the Rust source of the ABI, not a header and not a schema somebody maintains alongside it. So a call added to the core is a call every SDK has, and a call whose meaning changed cannot keep an old signature in one language and a new one in another. Drift fails the build.
That is also why this site can promise a switcher rather than a paragraph apologising for the languages it did not get to.
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(())
}import opentimelineio as otio
# 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. The adapter is picked from the suffix, as upstream picks it.
timeline = otio.adapters.read_from_file("cut.edl", rate=24)
for clip in timeline.find_clips():
print(clip.name)import { readFile } from "node:fs/promises";
import { init, readTimelineFromString } from "@alchemist-edit/otio";
await init();
// There is no filesystem behind a WebAssembly module, so getting hold of the
// bytes is the host's line of code: `fs` in Node, `fetch` in a browser.
const edl = await readFile("cut.edl", "utf8");
// 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.
const timeline = readTimelineFromString("cmx3600", edl, { rate: 24 });
for (const clip of timeline.findClips()) {
console.log(clip.name);
}package main
import (
"fmt"
"log"
otio "github.com/alchemist-editor/otio-rust/sdk/go"
)
func main() {
// 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.
//
// Reading answers with what the file was about — the root object — and
// there is no container to hold on to besides it.
root, err := otio.ReadFromFile(otio.FormatCMX3600, "cut.edl", &otio.ReadOptions{Rate: 24})
if err != nil {
log.Fatal(err)
}
defer root.Close()
clips, err := root.FindClips()
if err != nil {
log.Fatal(err)
}
for _, clip := range otio.Filter(clips, otio.Node.AsClip) {
name, _ := clip.Name()
fmt.Println(name)
}
}import OpenTimelineIO
// 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, nameColumn: "", ignoreTimecodeMismatch: false)
// Reading hands back what the file is about, not a container to look inside.
let root = try OTIO.readFromFile(.cmx3600, path: "cut.edl", options: options)
for case let clip as Clip in try root.findClips() {
print(try clip.name())
} <iostream>
<opentimelineio/otio.hpp>
int main() {
// 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.
otio::ReadOptions options = otio::read_options_default();
options.rate = 24;
// Reading hands back the file's root object; there is no container
// around it to hold.
const otio::SerializableObject root =
otio::read_from_file(otio::Format::CMX_3600, "cut.edl", options);
for (const otio::SerializableObject &node : root.find_clips()) {
if (std::optional<otio::Clip> clip = node.as<otio::Clip>()) {
std::cout << clip->name() << "\n";
}
}
return 0;
}const std = ("std");
const otio = ("otio");
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
// 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.
const options = otio.ReadOptions{
.rate = 24,
.name_column = null,
.ignore_timecode_mismatch = false,
};
const document = try otio.Document.readFromFile(.cmx3600, "cut.edl", options);
defer document.deinit();
const root = (try document.root()) orelse return error.Empty;
// Anything the library hands back that has to be freed is copied into an
// allocator you pass, and freed with `allocator.free`.
const clips = try root.findClips(allocator);
defer allocator.free(clips);
for (clips) |node| {
if (node.asClip()) |clip| {
const name = try clip.name(allocator);
defer allocator.free(name);
std.debug.print("{s}\n", .{name});
}
}
}
int main(void) {
/* 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. */
OtioReadOptions options;
options.struct_size = sizeof options;
otio_read_options_init(&options);
options.rate = 24;
/* A call that can fail says why in the buffer passed last, which is
* yours to free. Pass NULL where the status is all you want. */
OtioDocument *document = NULL;
OtioBuffer error;
if (otio_read_from_file(OTIO_FORMAT_CMX_3600, "cut.edl", &options, &document, &error) != OTIO_STATUS_OK) {
fprintf(stderr, "%s\n", error.data);
otio_buffer_free(error);
return 1;
}
OtioNode root;
otio_document_root(document, &root, NULL);
/* A list call is asked twice: once with no buffer, to learn the count,
* and once to fill one. */
size_t count = 0;
otio_node_find_clips(document, root, NULL, 0, &count, NULL);
OtioNode *clips = malloc(count * sizeof *clips);
otio_node_find_clips(document, root, clips, count, &count, NULL);
for (size_t index = 0; index < count; index += 1) {
OtioBuffer name;
if (otio_node_name(document, clips[index], &name, NULL) == OTIO_STATUS_OK) {
printf("%s\n", name.data);
otio_buffer_free(name);
}
}
free(clips);
otio_document_free(document);
return 0;
}using System;
using OpenTimelineIO;
// 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.
// A value here is a `readonly struct`, as one in .NET should be, so the
// rate is chosen when the options are made rather than set afterwards.
var defaults = Otio.ReadOptionsDefault();
var options = new ReadOptions(24, defaults.NameColumn, defaults.IgnoreTimecodeMismatch);
// Reading hands back the object the file is about, which for an EDL is
// the timeline it describes.
var timeline = Otio.ReadFromFile(Format.Cmx3600, "cut.edl", options);
foreach (var node in timeline.FindClips())
{
if (node is Clip clip)
{
Console.WriteLine(clip.Name());
}
}
int main(void) {
{
NSError *error = nil;
// 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.
OTIOReadOptions options = OTIOReadOptionsDefault();
options.rate = 24;
// Reading hands back the object the file is about, which for an EDL
// is the timeline it describes.
OTIOSerializableObject *timeline = OTIOReadFromFile(OTIOFormatCMX3600,
@"cut.edl",
&options,
&error);
if (timeline == nil) {
NSLog(@"%@", error);
return 1;
}
for (OTIOSerializableObject *node in [timeline findClips:&error]) {
// Every object arrives as the class its schema names, so this is
// an ordinary Cocoa class test rather than a cast.
if ([node isKindOfClass:[OTIOClip class]]) {
NSLog(@"%@", [node name:&error]);
}
}
}
return 0;
}Reading is the same shape everywhere: reading a file hands back the object it
is about, and you ask that object what is under it. Only Rust, C and Zig show
you the document that owns the objects on the way; every other SDK keeps it
out of sight, as upstream's own bindings do. What differs is what each
language calls a failure and what it calls an absence — an error in Go, a
throws in Swift, an NSError in Objective-C, an optional in Zig — and the
SDK for each follows that language rather than the C interface it came from.
Where to go next
- Getting started builds the core and reaches it from each language, and each language has its own page.
- Reading and writing files is the guide to the adapters, with a page per format: EDL, ALE, Final Cut Pro XML, AAF and bundles.
- Editing a timeline covers the ten edit operations.
- The data model and Time ranges are the concepts every other page assumes.
- The C ABI reference lists every call the SDKs are generated from.
For a language model, /llms.txt indexes every page as Markdown,
and /llms/<language>.txt is the whole of the docs with every sample in one
language.