C
libotio and otio.h, the C ABI every generated SDK is built on, with handles, out-parameters and status codes.
The C ABI is libotio, plus the header in crates/otio-capi/include/otio.h.
It is the layer every language binding other than Python sits on: Go, Swift,
Zig, C++, C# and Objective-C link it, and TypeScript is the same ABI compiled
to WebAssembly. From C you use it directly, which means the document is
explicit, values are copied out into buffers you free, and every call that can
fail returns a status.
Install
cargo build -p otio-capi --releaseThat writes libotio.so, libotio.dylib or otio.dll alongside the static
libotio.a, under target/release/. Compile against include/otio.h and link
otio.
Each GitHub release
carries a prebuilt libotio-<version>-<target>.tar.gz per target, holding
include/otio.h, lib/ (static, and shared where built) and the licence. The
targets are x86_64-linux-gnu, aarch64-linux-gnu, aarch64-macos,
x86_64-macos, x86_64-windows-msvc, aarch64-windows-msvc and
x86_64-windows-gnu, the last static only. A SHA256SUMS file covers every
asset.
Your first program
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;
}Most of what the C ABI asks of a caller is in that program. The format is an
OtioFormat, and the options are sized and then filled with their defaults
by otio_read_options_init(); the rate is the one an EDL cannot do without (see CMX 3600 EDL).
A list is asked for twice, once with a capacity of zero to learn the count and
once to fill a buffer of that size. A string comes back as an OtioBuffer the
caller frees with otio_buffer_free.
Building a timeline
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;
}otio_document_new makes a document, and every constructor, such as
otio_clip_new, takes the document to build the object in. The root is set
explicitly with otio_document_set_root, and otio_document_free releases the
document and everything in it.
How objects and documents work here
An object is a handle, not a pointer. OtioNode is the index of a slot in
the document's arena and the generation of that slot. Nothing hands out a
pointer into a document, so an edit that moves objects around cannot leave a
caller holding a dangling one, and a handle to something that has been removed
fails a lookup instead of reaching whatever took the slot. Within one document,
two OtioNode values compare equal when they name the same object, and that
is the whole of object identity here. A handle is scoped to the document that
issued it: in a copy made by otio_document_clone the same handle names the
copy of the same object, and a handle passed to some other document is a
programming error the library cannot always catch.
A document is explicit. There is one document, created and freed when the
caller says. Each document owns its objects. otio_document_absorb moves
every object of one document into another and hands back each object's old
and new handle; otherwise, build in one document, or write and read.
A value is copied out, never borrowed. Times are plain structs passed by
value; strings and whole written files come back as owned OtioBuffers.
Returning a pointer into the document would dangle the moment anything was
edited.
Threads. A document is not internally synchronized. Several threads may read one at the same time; a thread that edits one must be the only thread touching it. Two threads working on two documents never interfere.
Errors and missing values
Every call that can fail returns an OtioStatus, delivers its result through
out-parameters, and takes one more out-parameter last: OtioBuffer *out_error,
where it writes the sentence saying what went wrong. OTIO_STATUS_OK is zero,
so if (otio_...(...)) reads as "if it failed". Pass NULL to get only the
status. When it is not NULL it is written on every return, empty after
success, so a caller can free it unconditionally.
The message comes back from the call that failed rather than from a second call asking about "the last failure", so it does not matter which thread asks.
OTIO_STATUS_NO_VALUE means the question was answered and the answer is
"nothing", which is what an item with no source range reports. It is not an
error, but it still carries a message saying what had no value. A panic in the
Rust core is caught at the boundary and reported as OTIO_STATUS_PANIC rather
than unwinding into C.
OTIO_STATUS_WRONG_KIND means the call was asked of the wrong kind of thing:
a clip asked for its children or a track's kind, a metadata list stepped into
by key, or a metadata string read as a number. That is almost always a mistake
in the calling code, so it is kept apart from OTIO_STATUS_CORE_ERROR, which
is the document itself refusing, such as a clip with no media asked for its
available range.
Editing and algorithms
/* One second of picture, named. */
static OtioNode second(OtioDocument *document, const char *name) {
OtioNode clip;
otio_clip_new(document, name, &clip, NULL);
OtioTimeRange span;
span.start_time.value = 0.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);
return clip;
}
static void show(OtioDocument *document, OtioNode track) {
size_t count = 0;
otio_node_children(document, track, NULL, 0, &count, NULL);
OtioNode *children = malloc(count * sizeof *children);
otio_node_children(document, track, children, count, &count, NULL);
for (size_t index = 0; index < count; index += 1) {
OtioBuffer name;
if (otio_node_name(document, children[index], &name, NULL) == OTIO_STATUS_OK) {
printf("%s%s", index == 0 ? "" : " ", name.data);
otio_buffer_free(name);
}
}
free(children);
OtioRationalTime duration;
otio_item_duration(document, track, &duration, NULL);
printf(" - %g frames\n", duration.value);
}
int main(void) {
OtioDocument *document = otio_document_new();
OtioNode track;
otio_track_new(document, "V1", "Video", &track, NULL);
const char *names[] = {"A", "B", "C"};
for (size_t index = 0; index < 3; index += 1) {
otio_composition_append_child(document, track, second(document, names[index]), NULL);
}
show(document, track);
/* Insert makes room: everything from the insertion point onwards moves
* later, and the track gets longer. */
OtioRationalTime at = {24.0, 24.0};
otio_edit_insert(document, second(document, "D"), track, at, false, otio_node_none(), NULL);
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. */
OtioTimeRange over;
over.start_time.value = 48.0;
over.start_time.rate = 24.0;
over.duration.value = 24.0;
over.duration.rate = 24.0;
otio_edit_overwrite(document, second(document, "E"), track, over, false, otio_node_none(), NULL);
show(document, track);
otio_document_free(document);
return 0;
}The ten edit operations are otio_edit_* calls, such as otio_edit_insert and
otio_edit_overwrite, and the algorithms are otio_algorithm_*, such as
otio_algorithm_flatten_stack. Editing a timeline says
what each edit does.
Formats
Every format goes through the same four calls, otio_read_from_bytes,
otio_read_from_file, otio_write_to_bytes and otio_write_to_file, picked
by an OtioFormat: OTIO JSON, ALE, CMX 3600 EDL, both FCP XML flavours, AAF,
and the two bundle formats, .otioz and .otiod. A bundle is read and written
through a path only; the calls that take bytes refuse one with
OTIO_STATUS_UNSUPPORTED. What is written is the document's root, which for a
bundle has to be a timeline.
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;
}
/* 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. */
if (otio_write_to_file(OTIO_FORMAT_FCPX_XML, document, "cut.fcpxml", NULL, &error) != OTIO_STATUS_OK) {
fprintf(stderr, "%s\n", error.data);
otio_buffer_free(error);
otio_document_free(document);
return 1;
}
otio_document_free(document);
return 0;
}A few adapter options are not exposed yet: ALE's explicit column order, AAF's
transcribe_log, and upstream's bundle dry_run. See
Reading and writing files,
AAF and Bundles.
Metadata is named by a path rather than a key, such as "cmx_3600.reel" or
"comments[0]", read with calls such as otio_metadata_get_string. A key
that holds a ., a [ or a \ writes it after a \: "com\.avid\.reel"
is the single key com.avid.reel. The header states the full grammar under
METADATA PATHS.
Options structs
OtioReadOptions and OtioWriteOptions gain fields as formats gain options,
so each opens with size_t struct_size. Set it to the struct's size, call
otio_read_options_init() or otio_write_options_init() to fill in every
field's default, and change only the fields you need. The library reads only
the fields that size covers and takes its defaults for the rest, so a program
built against an older header keeps working with a newer library. A struct
zeroed by hand has no size and is refused with OTIO_STATUS_INVALID_ARGUMENT.
The defaults are filled into the caller's struct rather than returned, because a struct returned by value has the size the library was built with. A newer library returning its larger struct would write past the end of an older program's.
Media linkers and hooks
otio_register_media_linker and otio_register_hook_script take an
OtioPluginFn, a void *context handed to every call, and an
OtioPluginReleaseFn the library calls with that context once nothing will
call the plugin again. The function is handed the document, the clip or the
hook's target, an object whose metadata is the arguments, a place for its
answer, and room for a message. Return a status other than OTIO_STATUS_OK,
with the reason written into message, to fail the read; the caller sees
OTIO_STATUS_PLUGIN_ERROR. A linker that leaves out_result alone leaves
the clip as it was.
A read names its linker in OtioReadOptions.media_linker and carries the
argument maps as JSON in media_linker_arguments and hook_arguments. The
document is the library's for the length of the call: answer with an object
in it, and keep no handle past the call. See
Media linkers and hooks.
Platforms
CI compiles and runs the C ABI's own C test program on Linux and macOS. The library builds on Windows and its struct layouts are asserted from the Rust side, but no C program is linked there in CI.
A WebAssembly build for the browser has no file system, so every call that
takes a path answers OTIO_STATUS_UNSUPPORTED there; the calls that take
bytes work everywhere. A WASI build has a file system and keeps the path
calls.
Versions
otio_version() is the release. OTIO_ABI_VERSION, from the header, and
otio_abi_version(), from the library, are the version of the binary
interface; check at startup that they agree:
if (otio_abi_version() != OTIO_ABI_VERSION) {
fprintf(stderr, "libotio %s is not the one this program was built for\n",
otio_version());
return 1;
}The ABI version is 0 until the ABI is declared stable, so for now any release
may change it; insist on the exact otio_version() you built against too.
From version 1, a release names the shared library for its ABI version
(libotio.so.1, libotio.1.dylib, otio1.dll) so that two can be installed
side by side.
Every enum is open: a later release may add values, such as a new
OtioStatus or OtioNodeKind, without changing the ABI version. Handle a
value you do not know with a default: rather than assuming the set is
closed. OTIO_NODE_KIND_OTHER and OTIO_VALUE_OTHER exist so that a newer
core reports something honest to a caller built against an older header.
That promise is to C callers: the generated SDKs are made from one release's
header and are meant to run with that release's library, which they ship
with or pin.
otio_node_kind reports the most specific kind it knows: a clip is
OTIO_NODE_KIND_CLIP, never OTIO_NODE_KIND_ITEM.
Reference
crates/otio-capi/README.md: the four decisions the ABI makes, and how the header is kept honest.- The reference, generated from the same description as every
SDK, with each declaration lifted verbatim from
otio.h. - How the SDKs are made.