Bundles (.otioz and .otiod)
Packaging a timeline with its media, and what happens to media that is not a file.
A bundle is how a cut travels with its media: the timeline and every file it references, in one archive or one directory, so it can be moved to another machine and opened there.
cut.otioz / cut.otiod
├── version.txt "1.0.0"
├── content.otio the timeline, references rewritten to media/...
└── media/
├── shot_010.mov
└── render.0001.exr ...An .otioz is a zip archive, with content.otio deflated and the media
stored uncompressed so it can be read in place; an .otiod is the same
layout as a directory. The otio-bundle crate ports upstream's bundle.cpp.
Writing a bundle
A bundle is how a cut travels with its media. Writing one copies every file
the timeline's media references name into media/ and points each reference
at its copy, so the bundle can be moved to another machine and opened there.
Every file lands directly under media/, so two media files with the same
name in different directories cannot both go in, and the write fails rather
than dropping one. Nor is a bundle ever written over: a path that already
exists stops the write.
use std::path::Path;
use otio_bundle::{MediaReferencePolicy, ReadOptions, WriteOptions};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let document = otio_core::from_str(&std::fs::read_to_string("cut.otio")?)?;
let timeline = document.root().ok_or("cut.otio holds nothing")?;
// Every clip whose media is a file on disk has the file copied into the
// bundle and its reference pointed at the copy. Media that is not a file,
// such as a URL on the web, would stop the write, so it is made missing
// instead. write_otiod writes the same layout as a directory.
let options = WriteOptions {
policy: MediaReferencePolicy::MissingIfNotFile,
..WriteOptions::default()
};
otio_bundle::write_otioz(&document, timeline, Path::new("cut.otioz"), &options)?;
// Unpacked, with each reference made absolute, the media is ready to use.
let unpacked = otio_bundle::read_otioz(
Path::new("cut.otioz"),
&ReadOptions {
extract_path: Some("cut".into()),
absolute_media_reference_paths: true,
},
)?;
println!("{}", otio_core::to_string(&unpacked)?);
Ok(())
}import opentimelineio as otio
# A cut of two clips: one whose media is a file beside this script, and one
# whose media is on the web.
timeline = otio.schema.Timeline(name="Cut")
track = otio.schema.Track(name="V1", kind="Video")
timeline.tracks.append(track)
for name, url in [("A001C003", "shot.mov"), ("A001C004", "https://example.com/remote.mov")]:
track.append(otio.schema.Clip(
name=name,
media_reference=otio.schema.ExternalReference(target_url=url),
))
# Every clip whose media is a file has the file copied into the bundle and
# its reference pointed at the copy. Media that is not a file would stop the
# write, so it is made missing instead. "cut.otiod" writes the same layout as
# a directory.
otio.adapters.write_to_file(
timeline,
"cut.otioz",
media_policy=otio._otio.bundle.MediaReferencePolicy.missing_if_not_file,
)
# Unpacked, the media is ready to use beside the timeline.
bundled = otio.adapters.read_from_file("cut.otioz", extract_to_directory="cut")
for clip in bundled.find_clips():
print(clip.name, type(clip.media_reference).__name__,
getattr(clip.media_reference, "target_url", None))Not in TypeScript yet. A bundle is a directory, or an archive of media files copied in from disk, and the WebAssembly module has no file system to find either on. So the TypeScript package offers neither .otioz nor .otiod: write the timeline as .otio with writeToString("otioJson", timeline), and bundle it where there is a file system.
package main
import (
"fmt"
"log"
otio "github.com/alchemist-editor/otio-rust/sdk/go"
)
func main() {
timeline, err := otio.NewTimeline("Cut")
if err != nil {
log.Fatal(err)
}
defer timeline.Close()
tracks, err := timeline.Tracks()
if err != nil {
log.Fatal(err)
}
stack, _ := tracks.AsStack()
track, err := otio.NewTrack("V1", "Video")
if err != nil {
log.Fatal(err)
}
if err := stack.AppendChild(track.Node); err != nil {
log.Fatal(err)
}
// A cut of two clips: one whose media is a file beside the program, and
// one whose media is on the web.
for _, source := range []struct{ name, url string }{
{"A001C003", "shot.mov"},
{"A001C004", "https://example.com/remote.mov"},
} {
media, err := otio.NewExternalReference("", source.url)
if err != nil {
log.Fatal(err)
}
clip, err := otio.NewClip(source.name)
if err != nil {
log.Fatal(err)
}
if err := clip.SetMediaReference("DEFAULT_MEDIA", media.Node); err != nil {
log.Fatal(err)
}
if err := clip.SetActiveMediaReferenceKey("DEFAULT_MEDIA"); err != nil {
log.Fatal(err)
}
if err := track.AppendChild(clip.Node); err != nil {
log.Fatal(err)
}
}
// Every clip whose media is a file has the file copied into the bundle
// and its reference pointed at the copy. Media that is not a file would
// stop the write, so it is made missing instead. FormatOTIOD writes the
// same layout as a directory.
options := &otio.WriteOptions{BundleMediaPolicy: otio.BundleMediaPolicyMissingIfNotFile}
if err := otio.WriteToFile(otio.FormatOTIOZ, timeline.Node, "cut.otioz", options); err != nil {
log.Fatal(err)
}
// Unpacked, with each reference made absolute, the media is ready to use.
read := &otio.ReadOptions{BundleExtractPath: "cut", BundleAbsoluteMediaPaths: true}
root, err := otio.ReadFromFile(otio.FormatOTIOZ, "cut.otioz", read)
if err != nil {
log.Fatal(err)
}
defer root.Close()
clips, err := root.FindClips()
if err != nil {
log.Fatal(err)
}
for _, clip := range otio.Filter(clips, otio.Node.AsClip) {
media, err := clip.MediaReference("")
if err != nil {
log.Fatal(err)
}
if external, ok := media.AsExternalReference(); ok {
url, _ := external.TargetURL()
fmt.Println(url)
} else {
fmt.Println("missing")
}
}
}import OpenTimelineIO
let timeline = try Timeline(name: "Cut")
let track = try Track(name: "V1", kind: "Video")
try (timeline.tracks() as? Stack)?.appendChild(track)
// A cut of two clips: one whose media is a file beside the program, and one
// whose media is on the web.
for (name, url) in [("A001C003", "shot.mov"), ("A001C004", "https://example.com/remote.mov")] {
let clip = try Clip(name: name)
try clip.setMediaReference("DEFAULT_MEDIA", reference: try ExternalReference(targetURL: url))
try clip.setActiveMediaReferenceKey("DEFAULT_MEDIA")
try track.appendChild(clip)
}
// Every clip whose media is a file has the file copied into the bundle and
// its reference pointed at the copy. Media that is not a file would stop the
// write, so it is made missing instead. `.otiod` writes the same layout as a
// directory.
try OTIO.writeToFile(
.otioz, root: timeline, path: "cut.otioz",
options: WriteOptions(bundleMediaPolicy: .missingIfNotFile))
// Unpacked, with each reference made absolute, the media is ready to use.
let bundled = try OTIO.readFromFile(
.otioz, path: "cut.otioz",
options: ReadOptions(bundleExtractPath: "cut", bundleAbsoluteMediaPaths: true))
for case let clip as Clip in try bundled.findClips() {
if let media = try clip.mediaReference() as? ExternalReference {
print(try media.targetURL())
} else {
print("missing")
}
} <iostream>
<utility>
<vector>
<opentimelineio/otio.hpp>
int main() {
otio::Timeline timeline = otio::Timeline::create("Cut");
otio::Track track = otio::Track::create("V1", "Video");
timeline.tracks()->as<otio::Stack>()->append_child(track);
// A cut of two clips: one whose media is a file beside the program, and
// one whose media is on the web.
const std::vector<std::pair<std::string, std::string>> sources = {
{"A001C003", "shot.mov"},
{"A001C004", "https://example.com/remote.mov"},
};
for (const auto &[name, url] : sources) {
otio::Clip clip = otio::Clip::create(name);
clip.set_media_reference("DEFAULT_MEDIA", otio::ExternalReference::create(std::nullopt, url));
clip.set_active_media_reference_key("DEFAULT_MEDIA");
track.append_child(clip);
}
// Every clip whose media is a file has the file copied into the bundle
// and its reference pointed at the copy. Media that is not a file would
// stop the write, so it is made missing instead. Format::OTIOD writes
// the same layout as a directory.
otio::WriteOptions options = otio::write_options_default();
options.bundle_media_policy = otio::BundleMediaPolicy::MISSING_IF_NOT_FILE;
otio::write_to_file(otio::Format::OTIOZ, timeline, "cut.otioz", options);
// Unpacked, with each reference made absolute, the media is ready to use.
otio::ReadOptions read = otio::read_options_default();
read.bundle_extract_path = "cut";
read.bundle_absolute_media_paths = true;
const otio::SerializableObject root =
otio::read_from_file(otio::Format::OTIOZ, "cut.otioz", read);
for (const otio::SerializableObject &node : root.find_clips()) {
const std::optional<otio::SerializableObject> media =
node.as<otio::Clip>()->media_reference();
if (auto external = media->as<otio::ExternalReference>()) {
std::cout << external->target_url() << "\n";
} else {
std::cout << "missing\n";
}
}
return 0;
}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();
const document = try otio.Document.init();
defer document.deinit();
const timeline = try otio.Timeline.init(document, "Cut");
const stack = ((try timeline.tracks()) orelse return error.Empty).asStack().?;
const track = try otio.Track.init(document, "V1", "Video");
try stack.appendChild(track.asNode());
// A cut of two clips: one whose media is a file beside the program, and
// one whose media is on the web.
inline for (.{
.{ "A001C003", "shot.mov" },
.{ "A001C004", "https://example.com/remote.mov" },
}) |source| {
const media = try otio.ExternalReference.init(document, null, source[1]);
const clip = try otio.Clip.init(document, source[0]);
try clip.setMediaReference("DEFAULT_MEDIA", media.asNode());
try clip.setActiveMediaReferenceKey("DEFAULT_MEDIA");
try track.appendChild(clip.asNode());
}
// A bundle holds a timeline, and what is written is the document's root.
try document.setRoot(timeline.asNode());
// Every clip whose media is a file has the file copied into the bundle
// and its reference pointed at the copy. Media that is not a file would
// stop the write, so it is made missing instead. `.otiod` writes the same
// layout as a directory.
try document.writeToFile(.otioz, "cut.otioz", .{ .bundle_media_policy = .missing_if_not_file });
// Unpacked, with each reference made absolute, the media is ready to use.
const bundled = try otio.Document.readFromFile(.otioz, "cut.otioz", .{
.bundle_extract_path = "cut",
.bundle_absolute_media_paths = true,
});
defer bundled.deinit();
const root = (try bundled.root()) orelse return error.Empty;
for (try root.findClips(allocator)) |node| {
const media = (try node.asClip().?.mediaReference(null)) orelse continue;
if (media.asExternalReference()) |external| {
std.debug.print("{s}\n", .{try external.targetUrl(allocator)});
} else {
std.debug.print("missing\n", .{});
}
}
}
int main(void) {
OtioDocument *document = otio_document_new();
OtioNode timeline, stack, track;
otio_timeline_new(document, "Cut", &timeline, NULL);
otio_timeline_tracks(document, timeline, &stack, NULL);
otio_track_new(document, "V1", "Video", &track, NULL);
otio_composition_append_child(document, stack, track, NULL);
/* A cut of two clips: one whose media is a file beside the program, and
* one whose media is on the web. */
const char *names[] = {"A001C003", "A001C004"};
const char *urls[] = {"shot.mov", "https://example.com/remote.mov"};
for (size_t index = 0; index < 2; index += 1) {
OtioNode reference, clip;
otio_external_reference_new(document, NULL, urls[index], &reference, NULL);
otio_clip_new(document, names[index], &clip, NULL);
otio_clip_set_media_reference(document, clip, "DEFAULT_MEDIA", reference, NULL);
otio_clip_set_active_media_reference_key(document, clip, "DEFAULT_MEDIA", NULL);
otio_composition_append_child(document, track, clip, NULL);
}
/* A bundle holds a timeline, and what is written is the document's root. */
otio_document_set_root(document, timeline, NULL);
/* Every clip whose media is a file has the file copied into the bundle
* and its reference pointed at the copy. Media that is not a file would
* stop the write, so it is made missing instead. OTIO_FORMAT_OTIOD
* writes the same layout as a directory. */
OtioWriteOptions options;
options.struct_size = sizeof options;
otio_write_options_init(&options);
options.bundle_media_policy = OTIO_BUNDLE_MEDIA_POLICY_MISSING_IF_NOT_FILE;
OtioBuffer error;
if (otio_write_to_file(OTIO_FORMAT_OTIOZ, document, "cut.otioz", &options, &error) != OTIO_STATUS_OK) {
fprintf(stderr, "%s\n", error.data);
otio_buffer_free(error);
return 1;
}
otio_document_free(document);
/* Unpacked, with each reference made absolute, the media is ready to
* use. */
OtioReadOptions read;
read.struct_size = sizeof read;
otio_read_options_init(&read);
read.bundle_extract_path = "cut";
read.bundle_absolute_media_paths = true;
OtioDocument *bundled = NULL;
if (otio_read_from_file(OTIO_FORMAT_OTIOZ, "cut.otioz", &read, &bundled, &error) != OTIO_STATUS_OK) {
fprintf(stderr, "%s\n", error.data);
otio_buffer_free(error);
return 1;
}
OtioNode root, clips[2];
size_t count = 0;
otio_document_root(bundled, &root, NULL);
otio_node_find_clips(bundled, root, clips, 2, &count, NULL);
for (size_t index = 0; index < count; index += 1) {
OtioNode media;
OtioNodeKind kind;
OtioBuffer url;
otio_clip_media_reference(bundled, clips[index], NULL, &media, NULL);
otio_node_kind(bundled, media, &kind, NULL);
if (kind == OTIO_NODE_KIND_EXTERNAL_REFERENCE
&& otio_external_reference_target_url(bundled, media, &url, NULL) == OTIO_STATUS_OK) {
printf("%s\n", url.data);
otio_buffer_free(url);
} else {
printf("missing\n");
}
}
otio_document_free(bundled);
return 0;
}using System;
using OpenTimelineIO;
var timeline = new Timeline("Cut");
var track = new Track("V1", "Video");
((Stack)timeline.Tracks()!).AppendChild(track);
// A cut of two clips: one whose media is a file beside the program, and one
// whose media is on the web.
foreach (var (name, url) in new[]
{
("A001C003", "shot.mov"),
("A001C004", "https://example.com/remote.mov"),
})
{
var clip = new Clip(name);
clip.SetMediaReference("DEFAULT_MEDIA", new ExternalReference(targetUrl: url));
clip.SetActiveMediaReferenceKey("DEFAULT_MEDIA");
track.AppendChild(clip);
}
// Every clip whose media is a file has the file copied into the bundle and
// its reference pointed at the copy. Media that is not a file would stop the
// write, so it is made missing instead. Format.Otiod writes the same layout
// as a directory.
Otio.WriteToFile(
Format.Otioz,
timeline,
"cut.otioz",
new WriteOptions(bundleMediaPolicy: BundleMediaPolicy.MissingIfNotFile));
// Unpacked, with each reference made absolute, the media is ready to use.
var bundled = Otio.ReadFromFile(
Format.Otioz,
"cut.otioz",
new ReadOptions(bundleExtractPath: "cut", bundleAbsoluteMediaPaths: true));
foreach (var node in bundled.FindClips())
{
if (node is Clip clip)
{
Console.WriteLine(
clip.MediaReference(null) is ExternalReference media ? media.TargetUrl() : "missing");
}
}
int main(void) {
{
NSError *error = nil;
OTIOTimeline *timeline = [OTIOTimeline timelineWithName:@"Cut" error:&error];
OTIOTrack *track = [OTIOTrack trackWithName:@"V1" kind:@"Video" error:&error];
[(OTIOStack *)[timeline tracks:&error] appendChild:track error:&error];
// A cut of two clips: one whose media is a file beside the program,
// and one whose media is on the web.
NSArray<NSString *> *names = @[ @"A001C003", @"A001C004" ];
NSArray<NSString *> *urls = @[ @"shot.mov", @"https://example.com/remote.mov" ];
for (NSUInteger index = 0; index < names.count; index++) {
OTIOExternalReference *media =
[OTIOExternalReference externalReferenceWithName:nil
targetURL:[urls objectAtIndex:index]
error:&error];
OTIOClip *clip = [OTIOClip clipWithName:[names objectAtIndex:index] error:&error];
[clip setMediaReference:@"DEFAULT_MEDIA" reference:media error:&error];
[clip setActiveMediaReferenceKey:@"DEFAULT_MEDIA" error:&error];
[track appendChild:clip error:&error];
}
// Every clip whose media is a file has the file copied into the
// bundle and its reference pointed at the copy. Media that is not a
// file would stop the write, so it is made missing instead.
// OTIOFormatOTIOD writes the same layout as a directory.
OTIOWriteOptions options = OTIOWriteOptionsDefault();
options.bundleMediaPolicy = OTIOBundleMediaPolicyMissingIfNotFile;
if (!OTIOWriteToFile(OTIOFormatOTIOZ, timeline, @"cut.otioz", &options, &error)) {
NSLog(@"%@", error.localizedDescription);
return 1;
}
// Unpacked, with each reference made absolute, the media is ready to
// use.
OTIOReadOptions read = OTIOReadOptionsDefault();
read.bundleExtractPath = @"cut";
read.bundleAbsoluteMediaPaths = YES;
OTIOSerializableObject *root =
OTIOReadFromFile(OTIOFormatOTIOZ, @"cut.otioz", &read, &error);
for (OTIOSerializableObject *node in [root findClips:&error]) {
OTIOSerializableObject *media = [(OTIOClip *)node mediaReference:nil error:&error];
if ([media isKindOfClass:[OTIOExternalReference class]]) {
NSLog(@"%@", [(OTIOExternalReference *)media targetURL:&error]);
} else {
NSLog(@"missing");
}
}
}
return 0;
}Media that is not a file on disk, such as a URL on the web or a generator,
is what upstream's media reference policy is for. By default it stops the
write; told to, the writer replaces each such reference with a missing
reference instead, or replaces every reference and bundles no media at all.
A relative media path is found from the working directory unless you name
another. Reading an .otioz reads only the timeline out of the archive
unless you ask for it to be unpacked into a directory, which must not exist
yet; either bundle can then have its references rewritten as absolute paths
into it.
From Rust these are otio_bundle::WriteOptions and ReadOptions, and from
Python upstream's media_policy, relative_media_base_dir and
extract_to_directory keyword arguments. From C and the SDKs, the bundle
formats are OTIO_FORMAT_OTIOZ and OTIO_FORMAT_OTIOD, and the options are
fields of the read and write options: bundle_media_policy and
bundle_media_base_dir for writing, bundle_extract_path and
bundle_absolute_media_paths for reading. A bundle is read and written
through a path, never as bytes, and the calls that take bytes refuse one.
The TypeScript package has no file system to keep a bundle on, so it has no
bundle formats at all.
Media URLs with unusual characters
Media URLs are percent-decoded exactly as upstream decodes them, so a URL
with a % that is not followed by a hex digit, such as a%zz.mov, fails the
write (ValueError("stoi") from Python) whatever the policy, as it does
upstream.
An escape that spells a byte that is not UTF-8, such as %E9 in
caf%E9.mov, names a file whose name has that raw byte, and on Linux (or any
Unix filesystem that allows such a name) that file is found and bundled.
Inside the bundle it is named caf%E9.mov, with the escape spelled out,
because upstream's raw name makes content.otio invalid JSON. From Python,
url_utils.filepath_from_url on such a URL raises UnicodeDecodeError, as
upstream's does.
The crate's README covers the zip implementation and the Rust options.