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.
<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-failureEach 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
<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;
}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
<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;
}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
<iostream>
<string>
<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.
<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
<cstdint>
<iostream>
<optional>
<string>
<vector>
<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
sdk/cpp/README.md.- The C ABI reference the headers are generated from, and ADR 0003 for every deliberate departure from upstream's C++ API.