Zig
The one generated SDK that keeps the document in the open, because an arena is how Zig already works.
The Zig package links libotio, the C ABI built from the Rust core, and 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.
Zig has no upstream binding to copy, so what the binding is was decided
here, and it differs from the other SDKs in one large way: the document is
visible, held the way a Zig programmer holds any other arena. It needs Zig
0.16 or newer.
const otio = ("otio");There is no @cImport and no header is read at build time. The declarations
are written out directly from the same description the rest of the SDK is
generated from, so the package builds wherever Zig builds and cross-compiles
with nothing but the static library.
Install
Each GitHub release
carries the package with the static library for every target it is built for,
under lib/<target>/, so one fetch covers them all:
zig fetch --save https://github.com/alchemist-editor/otio-rust/releases/download/v0.1.0/otio-zig-0.1.0.tar.gzconst otio = b.dependency("otio", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("otio", otio.module("otio"));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.
To build from the repository instead, put a static library built from the Rust
core in lib/<target>/ or in lib/ itself:
cargo build -p otio-capi --release
cp target/release/libotio.a sdk/zig/lib/
cd sdk/zig
zig build testIf the library lives somewhere else, say so: zig build test -Dlibrary=/path/to/dir.
For an MSVC target the library is otio.lib, built against the static C
runtime and stripped of Rust's own copy of compiler-rt with
scripts/strip-msvc-builtins.sh; the
README
has the commands.
Your first program
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();
// 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.
const options = otio.ReadOptions{
.rate = 24,
.name_column = null,
.ignore_timecode_mismatch = false,
};
const document = try otio.Document.readFromFile(.cmx3600, "cut.edl", options);
defer document.deinit();
const root = (try document.root()) orelse return error.Empty;
// Anything the library hands back that has to be freed is copied into an
// allocator you pass, and freed with `allocator.free`.
const clips = try root.findClips(allocator);
defer allocator.free(clips);
for (clips) |node| {
if (node.asClip()) |clip| {
const name = try clip.name(allocator);
defer allocator.free(name);
std.debug.print("{s}\n", .{name});
}
}
}Reading hands back a Document, and defer document.deinit() is how it goes.
otio.open infers the format from the filename instead of taking one, and
document.save writes back the same way. The rate is the one option an EDL
cannot do without; see CMX 3600 EDL.
Building a timeline
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");
}Every object is made in a document, which each init takes first. An object is
a Node: a handle, and the document it can be resolved against. The schemas
are Zig types holding one, and each writes out the methods of every schema it
derives from, so a Clip answers to everything an Item, a Composable and
a Node answer to. Ask a node what it is with its as method, and cast back
up the ladder the same way:
if (node.asClip()) |clip| {
const reference = try clip.mediaReference(null);
}
try track.appendChild(clip.asNode());How objects and documents work here
The other SDKs hide the document, so that a clip is built on its own and adopts one when it is appended. That rests on a finalizer and a cache of live handles, which Zig has neither of and would have to pay for with an allocator inside every object. A Zig programmer already holds an arena and hands it to the things that allocate from it, which is exactly what a document is.
Allocation is the caller's: nothing here allocates behind your back. Anything
the library hands back that has to be freed, such as a name, a JSON document
or a list of children, is copied into an allocator you pass, and freed with
allocator.free. A call that needs one takes it as its first argument after
the receiver, which is the only signal you need that it allocates:
const name = try clip.name(allocator);
defer allocator.free(name);A call that answers with several things at once hands back a small record with
a deinit of its own:
const both = try track.rangesOfChildren(allocator);
defer both.deinit(allocator);Errors and missing values
A call that can fail answers with an error union over Error. A Zig error
carries no message, so the failing call's sentence is kept for the thread that
made the call and read with otio.lastErrorMessage(). Read it before the next
failure on that thread, which replaces it. Asking an object for something it
does not have fails rather than answering with a zero value: a clip asked for a
track's kind reports error.WrongKind.
An object from another document is refused with error.ForeignObject.
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 answer is an optional rather than an error, because that is what Zig has optionals for:
if (try clip.sourceRange()) |span| {
// the clip is trimmed to span
} else {
// it is untrimmed
}Editing and algorithms
const std = ("std");
const otio = ("otio");
/// One second of picture, named.
fn second(document: *otio.Document, name: [:0]const u8) !otio.Clip {
const clip = try otio.Clip.init(document, name);
try clip.setSourceRange(.{
.start_time = .{ .value = 0, .rate = 24 },
.duration = .{ .value = 24, .rate = 24 },
});
return clip;
}
fn show(allocator: std.mem.Allocator, track: otio.Track) !void {
// Anything the library hands back that has to be freed is copied into an
// allocator you pass, and freed with `allocator.free`.
const children = try track.children(allocator);
defer allocator.free(children);
for (children, 0..) |child, index| {
const name = try child.name(allocator);
defer allocator.free(name);
if (index > 0) std.debug.print(" ", .{});
std.debug.print("{s}", .{name});
}
const duration = try track.duration();
std.debug.print(" - {d} frames\n", .{duration.value});
}
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
const document = try otio.Document.init();
defer document.deinit();
const track = try otio.Track.init(document, "V1", "Video");
for ([_][:0]const u8{ "A", "B", "C" }) |name| {
try track.appendChild((try second(document, name)).asNode());
}
try show(allocator, track);
// Insert makes room: everything from the insertion point onwards moves
// later, and the track gets longer.
const inserted = try second(document, "D");
try document.insert(inserted.asNode(), track.asNode(), .{ .value = 24, .rate = 24 }, false, null);
try show(allocator, 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.
const laid = try second(document, "E");
try document.overwrite(laid.asNode(), track.asNode(), .{
.start_time = .{ .value = 48, .rate = 24 },
.duration = .{ .value = 24, .rate = 24 },
}, false, null);
try show(allocator, track);
}The ten edit operations and the algorithms are methods of the Document, such
as document.insert, document.overwrite and document.flattenTracks,
because each is about two objects and belongs to neither.
Editing a timeline says what each edit does.
Media linkers and hooks
A media linker and a hook script are Zig functions the library calls back in
the middle of a read or a write. Zig has no closures, so each is registered as
a function known at compile time and a context it is handed on every call, the
shape std.sort takes a comparison in: a pointer, or {} for none. A linker
takes the context, the clip and the arguments metadata, and answers with an
optional Node, null leaving the clip alone. A hook script takes the context,
the object and the arguments, and answers with the object to go on with.
const std = ("std");
const otio = ("otio");
// A cut of two clips, as an .otio file would hold it.
const cut =
\\{
\\ "OTIO_SCHEMA": "Track.1",
\\ "name": "V1",
\\ "kind": "Video",
\\ "children": [
\\ {"OTIO_SCHEMA": "Clip.2", "name": "A"},
\\ {"OTIO_SCHEMA": "Clip.2", "name": "B"}
\\ ]
\\}
;
// Zig has no closures, so a plugin is a function and a context it is handed
// on every call. Here the context carries the allocator.
const Plugins = struct {
allocator: std.mem.Allocator,
// 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,
// built in the clip's own document. Null leaves the clip as it was.
fn proxies(self: *const Plugins, clip: otio.Clip, arguments: otio.Metadata) !?otio.Node {
const name = try clip.name(self.allocator);
defer self.allocator.free(name);
const root = try arguments.getString(self.allocator, "root");
defer self.allocator.free(root);
const url = try std.fmt.allocPrintSentinel(self.allocator, "{s}/{s}.mov", .{ root, name }, 0);
defer self.allocator.free(url);
const proxy = try otio.ExternalReference.init(clip.node.doc.?, null, url);
return proxy.node;
}
// A hook script is handed the whole result, and answers with what the
// read goes on with: here the same object, stamped.
fn stamp(self: *const Plugins, target: otio.Node, arguments: otio.Metadata) !otio.Node {
const who = try arguments.getString(self.allocator, "who");
defer self.allocator.free(who);
const held = try self.allocator.dupeZ(u8, who);
defer self.allocator.free(held);
try target.metadata().setString("read_by", held);
return target;
}
};
pub fn main() !void {
const allocator = std.heap.smp_allocator;
const plugins: Plugins = .{ .allocator = allocator };
try otio.registerMediaLinker("proxies", &plugins, Plugins.proxies);
defer _ = otio.unregisterMediaLinker("proxies");
try otio.registerHookScript("stamp", &plugins, Plugins.stamp);
defer _ = otio.unregisterHookScript("stamp");
try otio.attachHookScript("post_adapter_read", "stamp");
defer _ = otio.detachHookScript("post_adapter_read", "stamp");
// The read names the linker, and carries both sets of arguments as JSON.
var options = otio.readOptionsDefault();
options.media_linker = "proxies";
options.media_linker_arguments = "{\"root\": \"/proxies\"}";
options.hook_arguments = "{\"who\": \"the conform\"}";
const document = try otio.Document.readFromBytes(.otio_json, cut, options);
defer document.deinit();
const track = (try document.root()) orelse return error.Empty;
const who = try track.metadata().getString(allocator, "read_by");
defer allocator.free(who);
std.debug.print("read by {s}\n", .{who});
const clips = try track.findClips(allocator);
defer allocator.free(clips);
for (clips) |node| {
const clip = node.asClip() orelse continue;
const media = (try clip.mediaReference(null)) orelse continue;
const external = media.asExternalReference() orelse continue;
const url = try external.targetUrl(allocator);
defer allocator.free(url);
const name = try clip.name(allocator);
defer allocator.free(name);
std.debug.print("{s} -> {s}\n", .{ name, url });
}
}The document a plugin is handed is lent for the call: build what you answer
with in it, clip.node.doc.?, and never free it. Freeing a lent document from
inside the call panics and absorbing one is refused, including the caller's own
document when runHook lends it. An answer built in a document of its own is
absorbed into the lent one, which consumes that document. An error the plugin
returns stops the read or write with error.PluginError, and its name is what
lastErrorMessage() then says. A Zig panic is not an error and still ends the
process. The context is the caller's: the library holds on to it until the
name is unregistered or registered again, and may use it from any thread that
reads or writes.
Formats
Document.readFromFile and document.writeToFile take a Format:
.otio_json, .ale, .cmx3600, .fcp7_xml, .fcpx_xml, .aaf, and the
two bundles, .otioz and .otiod, which are read and written through a path
only.
const std = ("std");
const otio = ("otio");
pub fn 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.
const options = otio.ReadOptions{
.rate = 24,
.name_column = null,
.ignore_timecode_mismatch = false,
};
const document = try otio.Document.readFromFile(.cmx3600, "cut.edl", options);
defer document.deinit();
// 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.
try document.writeToFile(.fcpx_xml, "cut.fcpxml", null);
}See Reading and writing files, AAF and Bundles.
Platforms
Wherever Zig builds and the Rust core builds. CI runs the tests on Linux, macOS and Windows (MSVC), and each release builds and runs a program that depends on the released package on every target a runner can run.
The description the package is generated from carries struct layouts for
64-bit targets and wasm32. A target that aligns a double to four bytes,
such as i386, lays the structs out differently, and the package refuses to
compile there with a message saying so rather than getting the offsets wrong.
Reference
sdk/zig/README.md.- How the SDKs are made, which says why Zig is the exception.
- The C ABI reference the package is generated from, and ADR 0003.