O
All pages

C++

Header-only C++17 over libotio, shaped like upstream's own C++ API, with objects as small values over an arena handle.

The C++ SDK is a set of C++17 headers over libotio, the C ABI built from the Rust core. It is generated from that ABI, so it carries the whole data model: the schemas, the composition algorithms, the ten edit operations and the file-format adapters. Its shape is upstream OpenTimelineIO's own C++ API: a class per schema deriving as the schemas derive, snake_case members, values as structs, and no document in the surface. Everything lives in namespace otio.

#include <opentimelineio/otio.hpp>

Install

The headers are header-only. They call a static libotio, which the CMake target expects to find in sdk/cpp/lib/. The library is not checked in, so build it from the Rust core first:

cargo build -p otio-capi --release
cp target/release/libotio.a sdk/cpp/lib/

cmake -S sdk/cpp -B sdk/cpp/build
cmake --build sdk/cpp/build
ctest --test-dir sdk/cpp/build --output-on-failure

Each GitHub release also carries the C++ SDK's sources, laid out as in the repository with an empty lib/, and a prebuilt libotio-<version>-<target>.tar.gz per target whose lib/ holds the library to put there.

Your first program

#include <iostream>

#include <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;
}

Reading hands back the file's root object, with no container around it to hold. otio::open infers the format from the filename instead of taking one, and otio::save writes back the same way. The rate is the one option an EDL cannot do without; see CMX 3600 EDL.

What an object really is, the library knows rather than the compiler: node.is<otio::Clip>() asks, and node.as<otio::Clip>() hands back an std::optional<otio::Clip> holding the clip where the answer is yes. They take the place of dynamic_cast, because an object here is a value rather than a retained pointer.

Building a timeline

#include <iostream>

#include <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;
}

Each object is made on its own with a static create and joins a timeline when you put it in one. RationalTime and TimeRange are structs.

How objects and documents work here

An object is a small value over an arena handle. Underneath, the core keeps a timeline's objects in an arena; the SDK does that bookkeeping, and objects keep their timeline alive between them, so there is nothing to close. close() exists for releasing a large one early, and every object that lived in it then fails with Status::NULL_POINTER rather than reading freed memory.

Objects made apart stay apart until one takes the other in. Appending or inserting an object that is still a child in another timeline is refused as the library refuses it, with Status::CORE_ERROR and the library's own message, and so is placing one whose handle has gone stale, with Status::STALE_HANDLE. Both are refused before that timeline is brought over, so both timelines stay whole and releasing one leaves the other working.

Errors and missing values

A call that can fail throws an otio::Error carrying a Status, where upstream's C++ takes an ErrorStatus * out-parameter.

A call that only names an object, such as detach_child, index_of_child or has_child, refuses one that belongs to a different timeline before asking the library, because merging the two and failing afterwards would already have done the damage. That refusal is an otio::OtherTimelineError: an otio::Error with Status::INVALID_ARGUMENT and a type of its own, so it can be caught apart from the library's failures with catch (const otio::OtherTimelineError&).

Where "there is nothing here" is one of the answers, such as an item with no source range or a clip with no active media reference, the call answers an empty std::optional, because that is an answer rather than a failure:

if (std::optional<otio::TimeRange> span = clip.source_range()) {
    std::cout << span->duration.value << "\n";
}

Editing and algorithms

#include <iostream>
#include <string>

#include <opentimelineio/otio.hpp>

/// One second of picture, named.
static otio::Clip second(const std::string &name) {
    otio::Clip clip = otio::Clip::create(name);
    clip.set_source_range(
        otio::TimeRange(otio::RationalTime(0, 24), otio::RationalTime(24, 24)));
    return clip;
}

static void show(const otio::Track &track) {
    std::string names;
    for (const otio::SerializableObject &child : track.children()) {
        if (!names.empty()) names += " ";
        names += child.name();
    }
    std::cout << names << " - " << track.duration().value << " frames\n";
}

int main() {
    otio::Track track = otio::Track::create("V1", "Video");
    for (const char *name : {"A", "B", "C"}) {
        track.append_child(second(name));
    }
    show(track);

    // Insert makes room: everything from the insertion point onwards moves
    // later, and the track gets longer.
    otio::insert(second("D"), track, otio::RationalTime(24, 24), false);
    show(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.
    otio::overwrite(
        second("E"), track,
        otio::TimeRange(otio::RationalTime(48, 24), otio::RationalTime(24, 24)),
        false);
    show(track);

    return 0;
}

The ten edit operations and the algorithms are free functions in otio, such as otio::insert, otio::overwrite and otio::flatten_tracks, because each is about two objects and belongs to neither. Editing a timeline says what each edit does.

Formats

otio::read_from_file and otio::write_to_file take an otio::Format: OTIO_JSON, ALE, CMX_3600, FCP7_XML, FCPX_XML, AAF, and the two bundles, OTIOZ and OTIOD, which are read and written through a path only.

#include <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;

    const otio::SerializableObject root =
        otio::read_from_file(otio::Format::CMX_3600, "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.
    otio::write_to_file(otio::Format::FCPX_XML, root, "cut.fcpxml");

    return 0;
}

See Reading and writing files, AAF and Bundles.

Media linkers and hooks

#include <cstdint>
#include <iostream>
#include <optional>
#include <string>
#include <vector>

#include <opentimelineio/otio.hpp>

// A cut of two clips, as an .otio file would hold it.
const std::string cut = R"({
  "OTIO_SCHEMA": "Track.1",
  "name": "V1",
  "kind": "Video",
  "children": [
    {"OTIO_SCHEMA": "Clip.2", "name": "A"},
    {"OTIO_SCHEMA": "Clip.2", "name": "B"}
  ]
})";

int main() {
    // A media linker is handed each clip as it is read, with the arguments
    // the read was given, and answers with the media the clip should use.
    // An empty optional leaves the clip as it was.
    otio::register_media_linker(
        "proxies",
        [](const otio::Clip &clip, const otio::Metadata &arguments)
            -> std::optional<otio::MediaReference> {
            const std::string root = arguments.get_string("root");
            return otio::ExternalReference::create(
                clip.name(), root + "/" + clip.name() + ".mov");
        });

    // A hook script is handed the whole result, and answers with what the
    // read goes on with: here the same object, stamped. An exception thrown
    // from either fails the read with Status::PLUGIN_ERROR and its message.
    otio::register_hook_script(
        "stamp", [](const otio::SerializableObject &target, const otio::Metadata &arguments) {
            otio::Metadata(target).set_string("read_by", arguments.get_string("who"));
            return target;
        });
    otio::attach_hook_script("post_adapter_read", "stamp");

    // The read names the linker, and carries both sets of arguments as JSON.
    otio::ReadOptions options = otio::read_options_default();
    options.media_linker = "proxies";
    options.media_linker_arguments = R"({"root": "/proxies"})";
    options.hook_arguments = R"({"who": "the conform"})";
    const otio::SerializableObject track = otio::read_from_bytes(
        otio::Format::OTIO_JSON, std::vector<std::uint8_t>(cut.begin(), cut.end()), options);

    std::cout << "read by " << otio::Metadata(track).get_string("read_by") << "\n";
    for (const otio::SerializableObject &node : track.find_clips()) {
        const otio::Clip clip = *node.as<otio::Clip>();
        const otio::ExternalReference media =
            *clip.media_reference()->as<otio::ExternalReference>();
        std::cout << clip.name() << " -> " << media.target_url() << "\n";
    }

    // The registry is the whole process's, so take them out again.
    otio::detach_hook_script("post_adapter_read", "stamp");
    otio::unregister_hook_script("stamp");
    otio::unregister_media_linker("proxies");
    return 0;
}

A media linker and a hook script are std::functions, registered by name with otio::register_media_linker and otio::register_hook_script. A linker is handed each clip a read produced and the read's media_linker_arguments as otio::Metadata, and answers an std::optional<otio::MediaReference>: the reference the clip should use, or empty to leave the clip alone. A hook script is handed what the hook runs on and the hook_arguments, and answers the object to go on with, which may be the one it was handed or another built fresh. Hook scripts run only at the hooks otio::attach_hook_script attaches them to; run_hook runs a hook of your own on any object.

An exception a linker or a script throws stops the read or write there, which then throws an otio::Error with Status::PLUGIN_ERROR and the exception's what(); nothing unwinds into the library. The registry is the whole process's, so a name registered again replaces what was there, and the std::function and whatever it captured are released when the name is replaced or unregistered. A read may run on any thread, so a plugin must be safe to call from any of them.

What a plugin is handed belongs to the timeline being read, and is valid only for the call: keep nothing from it. That timeline is the library's to free while the call runs, even where it is your own, as it is for run_hook. So moving it into another, by appending what the plugin was handed to a track built inside the call, is refused, and closing it does nothing.

Platforms

CI builds and tests the SDK on Linux and macOS. Windows is left out, as it is for the other SDKs that link the Rust static library through the platform's C/C++ toolchain.

Reference