# 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.

```text
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.

Rust:

```rust
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(())
}
```

<sub>Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/write-a-bundle/rust.rs</sub>

Python:

```python
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))
```

<sub>Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/write-a-bundle/python.py</sub>

TypeScript:

> Not available 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.

Go:

```go
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")
		}
	}
}
```

<sub>Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/write-a-bundle/go.go</sub>

Swift:

```swift
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")
    }
}
```

<sub>Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/write-a-bundle/swift.swift</sub>

C++:

```cpp
#include <iostream>
#include <utility>
#include <vector>

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

<sub>Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/write-a-bundle/cpp.cpp</sub>

Zig:

```zig
const std = @import("std");
const otio = @import("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", .{});
        }
    }
}
```

<sub>Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/write-a-bundle/zig.zig</sub>

C:

```c
#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_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;
}
```

<sub>Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/write-a-bundle/c.c</sub>

C#:

```csharp
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");
    }
}
```

<sub>Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/write-a-bundle/csharp.cs</sub>

Objective-C:

```objectivec
#import <Foundation/Foundation.h>

#import <OpenTimelineIO/OpenTimelineIO.h>

int main(void) {
    @autoreleasepool {
        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;
}
```

<sub>Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/write-a-bundle/objectivec.m</sub>

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](https://github.com/alchemist-editor/otio-rust/blob/main/crates/otio-bundle/README.md)
covers the zip implementation and the Rust options.

---

Page: https://otio-rust-docs.vercel.app/docs/formats/bundles/
