O
All pages

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 --release

That 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

#include <stdio.h>
#include <stdlib.h>

#include "otio.h"

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

#include <stdio.h>

#include "otio.h"

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

#include <stdio.h>
#include <stdlib.h>

#include "otio.h"

/* 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.

#include <stdio.h>

#include "otio.h"

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