Python
A drop-in replacement for upstream's opentimelineio package, built on the Rust core with PyO3.
The Python package is called opentimelineio, and the name is the point: it
aims to be a drop-in replacement for upstream OpenTimelineIO's own package, so
that code written against upstream runs against this one without changes. It
is built with PyO3 straight over the Rust crates rather
than over the C ABI, and the claim is measured the strongest way available:
upstream's own test files, and four of its adapter suites, are vendored into
the repository and run unmodified.
Install
From a clone of the repository:
cd crates/otio-python
pip install .pip install . runs maturin, which builds the Rust
extension module and packages it with the Python sources under python/.
Installing it also installs upstream's console tools: otiocat,
otioconvert, otiostat, otiotool, otiopluginfo and
otioautogen_serialized_schema_docs.
To run upstream's tests and the adapter suites against what you built:
python tests/run_upstream_tests.py
pip install pytest
python tests/run_adapter_tests.pyYour first program
import opentimelineio as otio
# 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. The adapter is picked from the suffix, as upstream picks it.
timeline = otio.adapters.read_from_file("cut.edl", rate=24)
for clip in timeline.find_clips():
print(clip.name)This is upstream's code, unchanged. otio.adapters.read_from_file picks the
EDL adapter from the suffix, and rate is upstream's keyword argument for it.
The rate is the one thing to get right with an EDL; see
CMX 3600 EDL.
Building a timeline
import opentimelineio as otio
timeline = otio.schema.Timeline(name="Cut")
track = otio.schema.Track(name="V1", kind="Video")
timeline.tracks.append(track)
for index, name in enumerate(["A", "B", "C"]):
clip = otio.schema.Clip(name=name)
clip.source_range = otio.opentime.TimeRange(
start_time=otio.opentime.RationalTime(index * 24, 24),
duration=otio.opentime.RationalTime(24, 24),
)
track.append(clip)
# Three seconds of picture, written as canonical OpenTimelineIO JSON.
print(track.duration().to_seconds())
otio.adapters.otio_json.write_to_file(timeline, "cut.otio")otio.schema has the whole object model: Clip, Gap, Track, Stack,
Timeline, Transition, SerializableCollection, Marker, Effect,
TimeEffect, LinearTimeWarp, FreezeFrame, ExternalReference,
MissingReference, GeneratorReference, ImageSequenceReference, V2d and
Box2d. otio.opentime has RationalTime, TimeRange and
TimeTransform, with upstream's module-level helpers. A composition is a
mutable sequence, slices and all.
How objects and documents work here
Underneath, the core keeps every object in a document's arena and names it with a handle. Upstream's API has no document in it, so the bindings reconcile the two:
- Each object built from Python gets a document of its own, holding just it and whatever hangs off it.
- Appending moves it. The document it came from is left as a forwarding note, so every wrapper already handed out keeps working.
- Identity is kept by hand.
track[0] is track[0]isTrue, as upstream's is: each document caches a weak reference to the wrapper it handed out for each node. metadatais a view that reads and writes through to the document, soobj.metadata["k"] = vchanges the object, and a list read out of it is a liveAnyVector.- Objects live as long as upstream's would. An object built in Python is freed when its wrapper goes; a child removed from a composition lives on without a parent while Python still holds it.
Two things differ from upstream on purpose. Writing an object writes that
object, even when its document holds more. And a SerializableCollection
parents what it holds, so a clip in one reports the collection as its
parent(), where upstream's reports none.
Classes registered with register_type and serializable_field work as
upstream's do, including subclasses of built-ins such as Clip, and round-trip
through any document.
Errors and missing values
Errors are upstream's exceptions. Every error from the core reaches Python as
the exception upstream's error handler picks (NotAChildError,
CannotComputeAvailableRangeError, IndexError, NotImplementedError,
otherwise ValueError), in upstream's words. The adapters raise what
upstream's adapters raise: the EDL adapter its own EDLParseError, ALE its
own ALEParseError, both FCP XML flavours plain ValueError. The one
improvement is that an argument no adapter knows is a TypeError rather than
silently ignored.
A missing value is what it is upstream, None: the source_range of an
untrimmed clip, for one.
The odd corners of upstream's pybind11 bindings are reproduced rather than
tidied, because a binding that is nearly the same surfaces its differences as
bugs in somebody else's code. Operators raise TypeError instead of returning
NotImplemented, += on a time rebinds rather than mutates, and str() and
repr() format numbers with C's %g.
Editing and algorithms
import opentimelineio as otio
def second(name):
"""One second of picture, named."""
return otio.schema.Clip(
name=name,
source_range=otio.opentime.TimeRange(
otio.opentime.RationalTime(0, 24), otio.opentime.RationalTime(24, 24)
),
)
def show(track):
names = " ".join(child.name for child in track)
print(names, "-", track.duration().value, "frames")
track = otio.schema.Track(name="V1", kind="Video")
for name in ["A", "B", "C"]:
track.append(second(name))
show(track)
# Insert makes room: everything from the insertion point onwards moves later,
# and the track gets longer.
otio.algorithms.insert(
second("D"), track, otio.opentime.RationalTime(24, 24), remove_transitions=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.algorithms.overwrite(
second("E"),
track,
otio.opentime.TimeRange(
otio.opentime.RationalTime(48, 24), otio.opentime.RationalTime(24, 24)
),
remove_transitions=False,
)
show(track)opentimelineio.algorithms is upstream's module, with its filter, stack,
track and timeline functions. It also has the ten edit operations,
overwrite, insert, trim, slice, slip, slide, ripple, roll,
fill and remove, with a ReferencePoint enum. Upstream's Python does not
bind those; their names, parameters and defaults follow upstream's C++
editAlgorithm.h. Editing a timeline walks through
them.
Formats
Files are read and written through opentimelineio.adapters, as upstream
reads and writes them: read_from_file, read_from_string, write_to_file,
write_to_string and the rest. Behind them are otio_json, cmx_3600,
ale, fcp_xml, fcpx_xml and AAF, under upstream's names and suffixes,
each keeping the keyword arguments, defaults and exception types of the
upstream adapter it stands in for. The .otioz and .otiod bundle adapters
are there too.
AAF is the adapter held closest to upstream: reading matches upstream's adapter byte for byte, and writing, given the same times and identifiers, writes the file upstream's adapter writes.
import opentimelineio as otio
# An AAF clip is cut from media of a known length, so each clip's media says
# how much of it there is. A cut read from an AAF already carries that, and
# what the reader found under metadata["AAF"], and both are written back.
timeline = otio.schema.Timeline(name="Cut")
track = otio.schema.Track(name="V1", kind="Video")
timeline.tracks.append(track)
for name in ["A001C003", "A001C004"]:
one_second = otio.opentime.TimeRange(
start_time=otio.opentime.RationalTime(0, 24),
duration=otio.opentime.RationalTime(24, 24),
)
track.append(otio.schema.Clip(
name=name,
media_reference=otio.schema.ExternalReference(
target_url=f"file:///media/{name}.mov",
available_range=one_second,
),
source_range=one_second,
))
# Every clip needs a MobID, from its metadata, its media's metadata or the
# AAF its media names. A cut built from scratch has none, so let the writer
# make them up rather than refuse the clip.
otio.adapters.write_to_file(timeline, "cut.aaf", use_empty_mob_ids=True)
print([clip.name for clip in otio.adapters.read_from_file("cut.aaf").find_clips()])Upstream's plugin system is ported as well. Manifests are loaded from
OTIO_PLUGIN_MANIFEST_PATH and from installed packages' entry points, and the
formats written in Rust are declared as plugins themselves, so a third-party
adapter, media linker, hook or schemadef composes with them as it would
upstream. A media linker named with media_linker_name, or set as
OTIO_DEFAULT_MEDIA_LINKER, runs on every clip a read returns; hooks run at
post_adapter_read, post_media_linker, pre_adapter_write and
post_adapter_write with hook_function_argument_map; and a schemadef's
module is loaded the first time otio.schemadef.<name> is reached. The AAF
adapter's own four hooks run too, as described under AAF.
Linkers and hook scripts can also be registered in Rust, in the core's
registry. Python lists them after the ones manifests declare, a read can
name one as its media_linker_name or through OTIO_DEFAULT_MEDIA_LINKER,
and otio.hooks.run runs any attached to a hook after the manifest's. A
native one is handed its arguments as OTIO values, so an argument that has
none, such as an open file, is left out of what it sees.
See Reading and writing files, AAF and Bundles.
Platforms
CI builds the package and runs the tests on Linux, macOS and Windows.
Reference
crates/otio-python/README.md: the upstream test results file by file, every adapter's behaviour, and what makes the object model harder to bind thanopentime.- Getting started for building the core alongside it.