The data model
Timelines, stacks, tracks, clips, gaps, transitions — and the handles that name them.
An OTIO document is a tree of objects that occupy time. There are not many kinds, and the names are the ones upstream uses.
Timeline
└── Stack ("tracks")
├── Track ("V1", kind "Video")
│ ├── Clip ("shot_01")
│ ├── Gap
│ └── Clip ("shot_02")
└── Track ("A1", kind "Audio")
└── Clip ("dialogue")A Timeline is the whole cut. It holds one Stack, conventionally
called tracks, and a stack lays its children over the same span of time —
which is what a track being "above" another means. A Track is a strand:
its children run one after another, and a Gap is the hole where nothing
plays. A Clip is a piece of media with a range taken out of it, and a
Transition is the overlap between two of them.
Time, and what a range means
Every duration in OTIO is a RationalTime: a value and a rate, kept apart.
24 frames at 24fps is not the number 1 — it is 24/24, and the rate travels
with it. This is the single most consequential decision in the format,
because it is what stops a 23.976 sequence from drifting into a 24 one by
accident.
use opentime::RationalTime;
fn main() -> Result<(), opentime::TimeError> {
// A time is a value and a rate, not a number of seconds. Four seconds at
// 24 is 96 units; the rate travels with it so nothing has to guess later.
let start = RationalTime::from_timecode("01:00:00:00", 24.0)?;
let duration = RationalTime::from_frames(96.0, 24.0);
let end = start + duration;
println!("{} for {} seconds", end.to_timecode()?, duration.to_seconds());
// Comparison rescales first, so the same instant at two rates is equal.
assert_eq!(RationalTime::new(24.0, 24.0), RationalTime::new(48.0, 48.0));
Ok(())
}import opentimelineio as otio
# A time is a value and a rate, not a number of seconds. Four seconds at 24 is
# 96 units; the rate travels with it so nothing has to guess later.
start = otio.opentime.RationalTime.from_timecode("01:00:00:00", 24)
duration = otio.opentime.RationalTime.from_frames(96, 24)
end = start + duration
print(end.to_timecode(), "for", duration.to_seconds(), "seconds")
# Comparison rescales first, so the same instant at two rates is equal.
assert otio.opentime.RationalTime(24, 24) == otio.opentime.RationalTime(48, 48)import { init, RationalTime } from "@alchemist-edit/otio";
await init();
// A time is a value and a rate, not a number of seconds. Four seconds at 24
// is 96 units; the rate travels with it so nothing has to guess later.
const start = RationalTime.fromTimecode("01:00:00:00", 24);
const duration = RationalTime.fromFrames(96, 24);
const end = start.add(duration);
console.log(`${end.toTimecode()} for ${duration.toSeconds()} seconds`);
// Comparison rescales first, so the same instant at two rates is equal.
console.assert(new RationalTime(24, 24).equal(new RationalTime(48, 48)));package main
import (
"fmt"
"log"
otio "github.com/alchemist-editor/otio-rust/sdk/go"
)
func main() {
// A time is a value and a rate, not a number of seconds. Four seconds at
// 24 is 96 units; the rate travels with it so nothing has to guess later.
start, err := otio.RationalTimeFromTimecode("01:00:00:00", 24)
if err != nil {
log.Fatal(err)
}
duration := otio.RationalTimeFromFrames(96, 24)
end := start.Add(duration)
timecode, err := end.ToTimecode()
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s for %g seconds\n", timecode, duration.ToSeconds())
// Comparison rescales first, so the same instant at two rates is equal.
fmt.Println(otio.RationalTime{Value: 24, Rate: 24}.Equals(otio.RationalTime{Value: 48, Rate: 48}))
}import OpenTimelineIO
// A time is a value and a rate, not a number of seconds. Four seconds at 24
// is 96 units; the rate travels with it so nothing has to guess later.
let start = try RationalTime.fromTimecode("01:00:00:00", rate: 24)
let duration = RationalTime.fromFrames(96, rate: 24)
let end = start.add(duration)
print(try end.toTimecode(), "for", duration.toSeconds, "seconds")
// Comparison rescales first, so the same instant at two rates is equal.
assert(RationalTime(value: 24, rate: 24).equals(RationalTime(value: 48, rate: 48))) <iostream>
<opentimelineio/otio.hpp>
int main() {
// A time is a value and a rate, not a number of seconds. Four seconds at
// 24 is 96 units; the rate travels with it so nothing has to guess later.
const otio::RationalTime start = otio::RationalTime::from_timecode("01:00:00:00", 24);
const otio::RationalTime duration = otio::RationalTime::from_frames(96, 24);
const otio::RationalTime end = start.add(duration);
std::cout << end.to_timecode() << " for " << duration.to_seconds() << " seconds\n";
// Comparison rescales first, so the same instant at two rates is equal.
return otio::RationalTime{24, 24}.equals(otio::RationalTime{48, 48}) ? 0 : 1;
}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();
// A time is a value and a rate, not a number of seconds. Four seconds at
// 24 is 96 units; the rate travels with it so nothing has to guess later.
const start = try otio.RationalTime.fromTimecode("01:00:00:00", 24);
const duration = otio.RationalTime.fromFrames(96, 24);
const end = start.add(duration);
// A call that has to allocate takes the allocator first, which is the
// only signal you need that something has to be freed.
const timecode = try end.toTimecode(allocator);
defer allocator.free(timecode);
std.debug.print("{s} for {d} seconds\n", .{ timecode, duration.toSeconds() });
// Comparison rescales first, so the same instant at two rates is equal.
const a = otio.RationalTime{ .value = 24, .rate = 24 };
const b = otio.RationalTime{ .value = 48, .rate = 48 };
std.debug.assert(a.equals(b));
}
int main(void) {
/* A time is a value and a rate, not a number of seconds. Four seconds at
* 24 is 96 units; the rate travels with it so nothing has to guess
* later. */
OtioRationalTime start;
OtioBuffer error;
if (otio_rational_time_from_timecode("01:00:00:00", 24.0, &start, &error) != OTIO_STATUS_OK) {
fprintf(stderr, "%s\n", error.data);
otio_buffer_free(error);
return 1;
}
OtioRationalTime duration = otio_rational_time_from_frames(96.0, 24.0);
OtioRationalTime end = otio_rational_time_add(start, duration);
OtioBuffer timecode;
if (otio_rational_time_to_timecode(end, &timecode, &error) != OTIO_STATUS_OK) {
fprintf(stderr, "%s\n", error.data);
otio_buffer_free(error);
return 1;
}
printf("%s for %g seconds\n", timecode.data, otio_rational_time_to_seconds(duration));
otio_buffer_free(timecode);
/* Comparison rescales first, so the same instant at two rates is
* equal. */
OtioRationalTime a = { 24.0, 24.0 };
OtioRationalTime b = { 48.0, 48.0 };
return otio_rational_time_equal(a, b) ? 0 : 1;
}using System;
using OpenTimelineIO;
// A time is a value and a rate, not a number of seconds. Four seconds at
// 24 is 96 units; the rate travels with it so nothing has to guess later.
var start = RationalTime.FromTimecode("01:00:00:00", 24);
var duration = RationalTime.FromFrames(96, 24);
var end = start.Add(duration);
Console.WriteLine($"{end.ToTimecode()} for {duration.ToSeconds()} seconds");
// Comparison rescales first, so the same instant at two rates is equal.
return new RationalTime(24, 24).Equals(new RationalTime(48, 48)) ? 0 : 1;
int main(void) {
{
NSError *error = nil;
// A time is a value and a rate, not a number of seconds. Four seconds
// at 24 is 96 units; the rate travels with it so nothing has to guess
// later.
//
// A call that answers a struct has no nil to fail with, so it answers
// through an out-parameter and says NO. That is the one place this
// SDK reads like C rather than like Cocoa, and why.
OTIORationalTime start;
if (!OTIORationalTimeFromTimecode(@"01:00:00:00", 24, &start, &error)) {
NSLog(@"%@", error);
return 1;
}
OTIORationalTime duration = OTIORationalTimeFromFrames(96, 24);
OTIORationalTime end = OTIORationalTimeAdd(start, duration);
NSLog(@"%@ for %f seconds",
OTIORationalTimeToTimecode(end, &error),
OTIORationalTimeToSeconds(duration));
// Comparison rescales first, so the same instant at two rates is equal.
return OTIORationalTimeEquals(OTIORationalTimeMake(24, 24),
OTIORationalTimeMake(48, 48))
? 0
: 1;
}
}An item has up to three ranges, and the difference between them is the thing people get wrong:
available_range— everything the media has. A two-hour rush.source_range— the piece of it this item uses. Four seconds of that rush. An item without one uses all of its media.trimmed_range— the source range, or the available one when there is no source range. What the item actually contributes.
A clip's duration is its trimmed range's duration; a composition's is the
sum of its children's. That is why a track knows how long it is without
anyone saying so.
There are two more — visible_range and range_in_parent — and the thing
that actually trips people up is which clock each one answers in.
Time ranges is that, in full.
Handles rather than pointers
Objects live in a Document, which owns them. You never hold an object
directly: you hold a handle, which is an index and a generation, and the
document resolves it. A handle to something that has gone away fails the
lookup rather than reading freed memory, and a handle from one document
cannot be used against another.
This is worth knowing because it shapes every binding. Every SDK but one hides the document and lets a clip look like an object; Zig keeps it in the open, because an arena you hand to the things that use it is how Zig already works. Either way, what is underneath is the same arena.
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(())
}import opentimelineio as otio
timeline = otio.schema.Timeline(name="Cut")
track = otio.schema.Track(name="V1", kind="Video")
timeline.tracks.append(track)
for index, name in enumerate(["A", "B", "C"]):
clip = otio.schema.Clip(name=name)
clip.source_range = otio.opentime.TimeRange(
start_time=otio.opentime.RationalTime(index * 24, 24),
duration=otio.opentime.RationalTime(24, 24),
)
track.append(clip)
# Three seconds of picture, written as canonical OpenTimelineIO JSON.
print(track.duration().to_seconds())
otio.adapters.otio_json.write_to_file(timeline, "cut.otio")import { writeFile } from "node:fs/promises";
import { init, Clip, RationalTime, Timeline, TimeRange, Track, serializeJsonToString } from "@alchemist-edit/otio";
await init();
// A fresh timeline arrives with an empty stack called `tracks`, as
// upstream's does, so there is nothing to build before appending to it.
const timeline = new Timeline({ name: "Cut" });
const track = new Track({ name: "V1", kind: "Video" });
timeline.tracks?.appendChild(track);
["A", "B", "C"].forEach((name, index) => {
const clip = new Clip({ name });
clip.sourceRange = new TimeRange(
new RationalTime(index * 24, 24),
new RationalTime(24, 24),
);
track.appendChild(clip);
});
// Three seconds of picture, written as canonical OpenTimelineIO JSON.
console.log(track.duration().toSeconds());
await writeFile("cut.otio", serializeJsonToString(timeline));package main
import (
"fmt"
"log"
otio "github.com/alchemist-editor/otio-rust/sdk/go"
)
func main() {
// An object is built on its own and put together with the others
// afterwards. Nothing has to exist before the thing it goes into.
timeline, err := otio.NewTimeline("Cut")
if err != nil {
log.Fatal(err)
}
// A timeline is released when it is collected, so this is not required.
// It is worth doing anyway: it frees the whole thing at once, at a
// moment you chose.
defer timeline.Close()
stack, err := otio.NewStack("tracks")
if err != nil {
log.Fatal(err)
}
track, err := otio.NewTrack("V1", "Video")
if err != nil {
log.Fatal(err)
}
if err := timeline.SetTracks(&stack.Node); err != nil {
log.Fatal(err)
}
if err := stack.AppendChild(track.Node); err != nil {
log.Fatal(err)
}
for index, name := range []string{"A", "B", "C"} {
clip, err := otio.NewClip(name)
if err != nil {
log.Fatal(err)
}
span := otio.TimeRange{
StartTime: otio.RationalTime{Value: float64(index) * 24, Rate: 24},
Duration: otio.RationalTime{Value: 24, Rate: 24},
}
if err := clip.SetSourceRange(span); err != nil {
log.Fatal(err)
}
// Appending moves the clip into the timeline's arena. That is
// bookkeeping this package does for you, not something to hold.
if err := track.AppendChild(clip.Node); err != nil {
log.Fatal(err)
}
}
// Three seconds of picture, written as canonical OpenTimelineIO JSON.
duration, _ := track.Duration()
fmt.Println(duration.ToSeconds())
if err := otio.Save(timeline.Node, "cut.otio"); err != nil {
log.Fatal(err)
}
}import OpenTimelineIO
// Each object is made on its own and joins a timeline when you put it into
// one. Nothing has to exist before the thing it goes into.
let timeline = try Timeline(name: "Cut")
let stack = try Stack(name: "tracks")
let track = try Track(name: "V1", kind: "Video")
try timeline.setTracks(stack)
try stack.appendChild(track)
for (index, name) in ["A", "B", "C"].enumerated() {
let clip = try Clip(name: name)
try clip.setSourceRange(
TimeRange(
startTime: RationalTime(value: Double(index * 24), rate: 24),
duration: RationalTime(value: 24, rate: 24)))
try track.appendChild(clip)
}
// Three seconds of picture, written as canonical OpenTimelineIO JSON.
// Objects keep their timeline alive between them, so there is nothing to
// close.
print(try track.duration().toSeconds)
try OTIO.save(timeline, to: "cut.otio") <iostream>
<opentimelineio/otio.hpp>
int main() {
// Each object is made on its own and joins a timeline when you put it
// into one. Nothing has to exist before the thing it goes into.
otio::Timeline timeline = otio::Timeline::create("Cut");
otio::Stack stack = otio::Stack::create("tracks");
otio::Track track = otio::Track::create("V1", "Video");
timeline.set_tracks(stack);
stack.append_child(track);
const std::vector<std::string> names = {"A", "B", "C"};
for (std::size_t index = 0; index < names.size(); ++index) {
otio::Clip clip = otio::Clip::create(names[index]);
const otio::RationalTime start(static_cast<double>(index) * 24, 24);
clip.set_source_range(otio::TimeRange(start, otio::RationalTime(24, 24)));
track.append_child(clip);
}
// Three seconds of picture, written as canonical OpenTimelineIO JSON.
// Objects keep their timeline alive between them, so there is nothing
// to close.
std::cout << track.duration().to_seconds() << "\n";
otio::save(timeline, "cut.otio");
return 0;
}const std = ("std");
const otio = ("otio");
pub fn main() !void {
// A document is an arena, and it is held in the open the way a Zig
// programmer holds any other arena.
const document = try otio.Document.init();
defer document.deinit();
const timeline = try otio.Timeline.init(document, "Cut");
const stack = try otio.Stack.init(document, "tracks");
const track = try otio.Track.init(document, "V1", "Video");
try timeline.setTracks(stack.asNode());
try stack.appendChild(track.asNode());
for ([_][:0]const u8{ "A", "B", "C" }, 0..) |name, index| {
const clip = try otio.Clip.init(document, name);
try clip.setSourceRange(.{
.start_time = .{ .value = (index * 24), .rate = 24 },
.duration = .{ .value = 24, .rate = 24 },
});
try track.appendChild(clip.asNode());
}
try document.setRoot(timeline.asNode());
// Three seconds of picture, written as canonical OpenTimelineIO JSON.
const duration = try track.duration();
std.debug.print("{d}\n", .{duration.toSeconds()});
try document.save("cut.otio");
}
int main(void) {
OtioDocument *document = otio_document_new();
OtioNode timeline, stack, track;
otio_timeline_new(document, "Cut", &timeline, NULL);
otio_stack_new(document, "tracks", &stack, NULL);
otio_track_new(document, "V1", "Video", &track, NULL);
otio_timeline_set_tracks(document, timeline, stack, NULL);
otio_composition_append_child(document, stack, track, NULL);
const char *names[] = {"A", "B", "C"};
for (size_t index = 0; index < 3; index += 1) {
OtioNode clip;
otio_clip_new(document, names[index], &clip, NULL);
OtioTimeRange span;
span.start_time.value = (double)index * 24.0;
span.start_time.rate = 24.0;
span.duration.value = 24.0;
span.duration.rate = 24.0;
otio_item_set_source_range(document, clip, span, NULL);
otio_composition_append_child(document, track, clip, NULL);
}
otio_document_set_root(document, timeline, NULL);
/* Three seconds of picture, written as canonical OpenTimelineIO JSON. */
OtioRationalTime duration;
otio_item_duration(document, track, &duration, NULL);
printf("%g\n", otio_rational_time_to_seconds(duration));
otio_document_write_to_file(document, "cut.otio", otio_default_indent(), NULL);
otio_document_free(document);
return 0;
}using System;
using OpenTimelineIO;
// Each object is made on its own and joins a timeline when you put it
// into one. Nothing has to exist before the thing it goes into.
var timeline = new Timeline("Cut");
var stack = new Stack("tracks");
var track = new Track("V1", "Video");
timeline.SetTracks(stack);
stack.AppendChild(track);
var names = new[] { "A", "B", "C" };
for (var index = 0; index < names.Length; index++)
{
var clip = new Clip(names[index]);
var start = new RationalTime(index * 24, 24);
clip.SetSourceRange(new TimeRange(start, new RationalTime(24, 24)));
track.AppendChild(clip);
}
// Three seconds of picture, written as canonical OpenTimelineIO JSON.
// The objects keep their timeline alive between them, so there is
// nothing to dispose.
Console.WriteLine(track.Duration().ToSeconds());
Otio.Save(timeline, "cut.otio");
int main(void) {
{
NSError *error = nil;
// Each object is made on its own and joins a timeline when you put
// it into one. Nothing has to exist before the thing it goes into,
// so these are ordinary Cocoa class factories.
OTIOTimeline *timeline = [OTIOTimeline timelineWithName:@"Cut" error:&error];
OTIOStack *stack = [OTIOStack stackWithName:@"tracks" error:&error];
OTIOTrack *track = [OTIOTrack trackWithName:@"V1" kind:@"Video" error:&error];
[timeline setTracks:stack error:&error];
[stack appendChild:track error:&error];
NSArray<NSString *> *names = @[ @"A", @"B", @"C" ];
for (NSUInteger index = 0; index < names.count; index++) {
OTIOClip *clip = [OTIOClip clipWithName:[names objectAtIndex:index] error:&error];
OTIORationalTime start = OTIORationalTimeMake((double)index * 24, 24);
OTIOTimeRange span = OTIOTimeRangeMake(start, OTIORationalTimeMake(24, 24));
[clip setSourceRange:span error:&error];
[track appendChild:clip error:&error];
}
// Three seconds of picture, written as canonical OpenTimelineIO JSON.
// The objects keep their timeline alive between them, so there is
// nothing to close.
OTIORationalTime duration;
if ([track getDuration:&duration error:&error]) {
NSLog(@"%f", OTIORationalTimeToSeconds(duration));
}
OTIOSave(timeline, @"cut.otio", &error);
}
return 0;
}Schemas, and what happens to ones we do not know
Every object carries a schema name and version — Clip.2, Marker.3 — and
this port reads the versions upstream 0.19 writes, upgrading older ones as it
reads. An object whose schema is not recognised at all is kept verbatim, so a
third-party plugin's data survives a read and a rewrite untouched.
Writing works in the other direction too. A document can be written for an
older release by naming the schema versions it should carry, and each object
is downgraded on the way out, as upstream's serializer does. The upgrade and
downgrade steps live in one registry in the core, keyed by schema and
version, and from Python register_upgrade_function and
register_downgrade_function add to it.
A schema defined in Python with register_type is held in the core the way
upstream's C++ holds it: a generic object carrying its schema name, version
and fields. Python keeps the class for each schema name and hands that class
back when the object is read, so a type defined in Python round-trips through
any document without the core ever holding a Python object.
A Python subclass of a concrete schema, such as a Clip that also records
which take it is, stays a clip in the core: it carries the subclass's schema
name, version and extra fields beside its own, so every composition and
algorithm treats it as the clip it is, and it is written under the subclass's
name. A program that has not registered the subclass reads it as an unknown
schema, kept verbatim. Any built-in object read from a file keeps the fields
it does not know in the same place, and writes them back.