O
All pages

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.

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

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.