# 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](https://pyo3.rs) 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:

```sh
cd crates/otio-python
pip install .
```

`pip install .` runs [maturin](https://www.maturin.rs), 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:

```sh
python tests/run_upstream_tests.py
pip install pytest
python tests/run_adapter_tests.py
```

## Your first program

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

<sub>Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/read-an-edl/python.py</sub>

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](https://otio-rust-docs.vercel.app/docs/formats/edl/).

## Building a timeline

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

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

`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]` is `True`, as upstream's
  is: each document caches a weak reference to the wrapper it handed out for
  each node.
- **`metadata` is a view** that reads and writes through to the document, so
  `obj.metadata["k"] = v` changes the object, and a list read out of it is a
  live `AnyVector`.
- **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

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

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

`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](https://otio-rust-docs.vercel.app/docs/guides/editing/) 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.

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

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

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](https://otio-rust-docs.vercel.app/docs/formats/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](https://otio-rust-docs.vercel.app/docs/guides/reading-and-writing/),
[AAF](https://otio-rust-docs.vercel.app/docs/formats/aaf/) and [Bundles](https://otio-rust-docs.vercel.app/docs/formats/bundles/).

## Platforms

CI builds the package and runs the tests on Linux, macOS and Windows.

## Reference

- [`crates/otio-python/README.md`](https://github.com/alchemist-editor/otio-rust/blob/main/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 than `opentime`.
- [Getting started](https://otio-rust-docs.vercel.app/docs/getting-started/) for building the core alongside it.

---

Page: https://otio-rust-docs.vercel.app/docs/languages/python/
