# OpenTimelineIO for Rust: the C# documentation > A pure-Rust OpenTimelineIO: the data model, the time math, the file-format adapters, and an SDK for every language they are generated into. The documentation with every code sample in C#. A .NET class library over the same library, with exceptions and no unsafe code. The binding lives in `sdk/csharp` (https://github.com/alchemist-editor/otio-rust/blob/main/sdk/csharp). Index of everything: https://otio-rust-docs.vercel.app/llms.txt ## C# > A .NET class library that loads libotio at run time, with a class per schema, exceptions, and nothing to dispose. The C# SDK is a .NET class library over `libotio`, the C ABI built from the Rust core, which it loads at run time rather than linking. It is generated from that ABI, so it carries the whole data model: the schemas, the composition algorithms, the ten edit operations and the file-format adapters. C# has no upstream OpenTimelineIO binding to copy, so what things are *called* follows upstream's Python and C++, spelled the way .NET spells names, and what the binding *is* follows upstream's Java bindings, the nearest thing upstream has to a managed language. ```csharp using OpenTimelineIO; ``` ### Install The assembly loads a native `libotio` it expects to find beside itself, which the project copies out of `sdk/csharp/lib/`. Because it is loaded rather than linked, this is the shared library, not the static one: ```sh cargo build -p otio-capi --release cp target/release/libotio.so sdk/csharp/lib/ # libotio.dylib on macOS cd sdk/csharp dotnet run --project tests ``` The library is not checked in. The project targets .NET 8 (`net8.0`). Each [GitHub release](https://github.com/alchemist-editor/otio-rust/releases) also carries the C# SDK's sources, laid out as in the repository with an empty `lib/`, and a prebuilt `libotio--.tar.gz` per target whose `lib/` holds the library, shared where it was built. ### Your first program ```csharp using System; using OpenTimelineIO; // 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. // A value here is a `readonly struct`, as one in .NET should be, so the // rate is chosen when the options are made rather than set afterwards. var defaults = Otio.ReadOptionsDefault(); var options = new ReadOptions(24, defaults.NameColumn, defaults.IgnoreTimecodeMismatch); // Reading hands back the object the file is about, which for an EDL is // the timeline it describes. var timeline = Otio.ReadFromFile(Format.Cmx3600, "cut.edl", options); foreach (var node in timeline.FindClips()) { if (node is Clip clip) { Console.WriteLine(clip.Name()); } } ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/read-an-edl/csharp.cs Reading hands back the object the file is about, which for an EDL is the timeline it describes. `Otio.Open` infers the format from the filename instead of taking one, and `Otio.Save` writes back the same way. Values are `readonly struct`s, so the EDL's rate is chosen when the `ReadOptions` are made rather than set afterwards; it is the one option an EDL cannot do without (see [CMX 3600 EDL](https://otio-rust-docs.vercel.app/docs/formats/edl/)). An object is a class of its schema, so a pattern match asks what one really is: ```csharp foreach (var child in track.Children()) { if (child is Clip clip && clip.MediaReference(null) is ExternalReference reference) { Console.WriteLine(reference.TargetUrl()); } } ``` ### Building a timeline ```csharp using System; using OpenTimelineIO; // Each object is made on its own and joins a timeline when you put it // into one. Nothing has to exist before the thing it goes into. var timeline = new Timeline("Cut"); var stack = new Stack("tracks"); var track = new Track("V1", "Video"); timeline.SetTracks(stack); stack.AppendChild(track); var names = new[] { "A", "B", "C" }; for (var index = 0; index < names.Length; index++) { var clip = new Clip(names[index]); var start = new RationalTime(index * 24, 24); clip.SetSourceRange(new TimeRange(start, new RationalTime(24, 24))); track.AppendChild(clip); } // Three seconds of picture, written as canonical OpenTimelineIO JSON. // The objects keep their timeline alive between them, so there is // nothing to dispose. Console.WriteLine(track.Duration().ToSeconds()); Otio.Save(timeline, "cut.otio"); ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/build-a-timeline/csharp.cs Every object is made on its own with `new` and joins a timeline when you put it into one, so nothing has to exist before the thing it goes into. ### How objects and documents work here There is no document in the surface, as there is none in upstream's own bindings. Underneath, the core keeps a timeline's objects in an arena and an object is an index into one; the SDK does that bookkeeping. The objects hold the arena between them, so it goes when the last of them does and there is nothing to dispose. `Close()` is there for releasing a large timeline at a moment you chose; every object that lived in it fails afterwards rather than reading freed memory. An object that has not joined anything is a timeline of one. Putting it into another moves it there. An object that is still a child in another timeline is refused when it is appended or inserted, and so is one whose handle has gone stale, wherever it is placed: the refusal is the library's own, `Status.CoreError` or `Status.StaleHandle` and its message, but it is made before that timeline is brought over, so both stay whole. ### Errors and missing values A call that can fail throws an `OtioException` carrying a `Status`. A call that only names an object refuses one from another timeline before asking the library, with `OtherTimelineException`, which derives from `OtioException` and so can be caught on its own with `catch (OtherTimelineException)`. Where "there is nothing here" is one of the answers, such as an item with no source range or a clip with no active media reference, the call answers `null`, because that is an answer rather than a failure: ```csharp if (clip.SourceRange() is TimeRange span) { Console.WriteLine(span); } ``` ### Editing and algorithms ```csharp using System; using System.Linq; using OpenTimelineIO; // One second of picture, named. static Clip Second(string name) { var clip = new Clip(name); clip.SetSourceRange(new TimeRange(new RationalTime(0, 24), new RationalTime(24, 24))); return clip; } static void Show(Track track) { var names = string.Join(" ", track.Children().Select(child => child.Name())); Console.WriteLine($"{names} - {track.Duration().Value} frames"); } var track = new Track("V1", "Video"); foreach (var name in new[] { "A", "B", "C" }) { track.AppendChild(Second(name)); } Show(track); // Insert makes room: everything from the insertion point onwards moves later, // and the track gets longer. Otio.Insert(Second("D"), track, new RationalTime(24, 24), false, null); 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.Overwrite( Second("E"), track, new TimeRange(new RationalTime(48, 24), new RationalTime(24, 24)), false, null); Show(track); ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/edit-operations/csharp.cs The ten edit operations and the algorithms are static methods on `Otio`, such as `Otio.Insert`, `Otio.Overwrite` and `Otio.FlattenTracks`, because each is about two objects and belongs to neither. [Editing a timeline](https://otio-rust-docs.vercel.app/docs/guides/editing/) says what each edit does. ### Formats `Otio.ReadFromFile` and `Otio.WriteToFile` take a `Format`: `OtioJson`, `Ale`, `Cmx3600`, `Fcp7Xml`, `FcpxXml`, `Aaf`, and the two bundles, `Otioz` and `Otiod`, which are read and written through a path only. ```csharp using OpenTimelineIO; // 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. // A value here is a `readonly struct`, as one in .NET should be, so the rate // is chosen when the options are made rather than set afterwards. var defaults = Otio.ReadOptionsDefault(); var options = new ReadOptions(24, defaults.NameColumn, defaults.IgnoreTimecodeMismatch); var timeline = Otio.ReadFromFile(Format.Cmx3600, "cut.edl", options); // Nothing happens in between. The timeline an EDL parses to is the same // timeline FCP X writes out, so converting is a read and a write: the object // model is the interchange, and the file formats are two ways of spelling it. Otio.WriteToFile(Format.FcpxXml, timeline, "cut.fcpxml", null); ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/convert-a-format/csharp.cs 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/). ### Media linkers and hooks Upstream's two plugin points are delegates here. `Otio.RegisterMediaLinker` takes a `Func`, handed each clip a read produced and the arguments the read was given for it, that answers with the media the clip should use, or `null` to leave it alone. `Otio.RegisterHookScript` takes a `Func` that answers with what to go on with. A read runs the linker its `ReadOptions` name, and both carry their arguments as JSON: ```csharp using System; using System.Text; using OpenTimelineIO; // A cut of two clips, as an .otio file would hold it. const string cut = """ { "OTIO_SCHEMA": "Track.1", "name": "V1", "kind": "Video", "children": [ {"OTIO_SCHEMA": "Clip.2", "name": "A"}, {"OTIO_SCHEMA": "Clip.2", "name": "B"} ] } """; // A media linker is handed each clip as it is read, with the arguments // the read was given, and answers with the media the clip should use. // Answering null leaves the clip as it was. Otio.RegisterMediaLinker("proxies", (clip, arguments) => { var root = arguments.GetString("root"); return new ExternalReference(clip.Name(), $"{root}/{clip.Name()}.mov"); }); // A hook script is handed the whole result, and answers with what the // read goes on with: here the same object, stamped. Otio.RegisterHookScript("stamp", (target, arguments) => { var stamped = (SerializableObjectWithMetadata)target; stamped.Metadata.SetString("read_by", arguments.GetString("who")); return target; }); Otio.AttachHookScript("post_adapter_read", "stamp"); try { // The read names the linker, and carries both sets of arguments as JSON. var track = (Track)Otio.ReadFromBytes( Format.OtioJson, Encoding.UTF8.GetBytes(cut), new ReadOptions( mediaLinker: "proxies", mediaLinkerArguments: """{"root": "/proxies"}""", hookArguments: """{"who": "the conform"}""")); Console.WriteLine($"read by {track.Metadata.GetString("read_by")}"); foreach (var node in track.FindClips()) { if (node is Clip clip && clip.MediaReference(null) is ExternalReference media) { Console.WriteLine($"{clip.Name()} -> {media.TargetUrl()}"); } } } finally { Otio.DetachHookScript("post_adapter_read", "stamp"); Otio.UnregisterHookScript("stamp"); Otio.UnregisterMediaLinker("proxies"); } ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/link-media-and-run-hooks/csharp.cs A script runs at a hook once `Otio.AttachHookScript` attaches it there: the four every read and write runs, or a hook of your own, which `RunHook` runs on an object. A write runs its hooks on a copy, so the timeline written is left as it was. An exception the delegate throws never reaches the library. It stops the read, write or `RunHook`, which throws an `OtioException` with `Status.PluginError` and the delegate's own message. What a delegate is handed is the library's, lent for the call only: keep none of it, and do not close it or put it into another timeline. What it answers with may be built fresh, and joins the timeline it was handed. The registry belongs to the whole process, so a delegate must be safe to call from any thread that reads or writes; it is kept alive until it is unregistered or its name registered again. ### Platforms CI builds and tests the SDK on Linux and macOS. Windows is left out, as it is for the other SDKs except Zig. ### Reference - [`sdk/csharp/README.md`](https://github.com/alchemist-editor/otio-rust/blob/main/sdk/csharp/README.md). - The [C ABI reference](https://otio-rust-docs.vercel.app/reference/) the library is generated from, and [ADR 0003](https://github.com/alchemist-editor/otio-rust/blob/main/docs/adr/0003-sdk-generation.md) for every deliberate departure from upstream. --- Page: https://otio-rust-docs.vercel.app/docs/languages/csharp/ ## Start here ### Overview > A pure-Rust OpenTimelineIO, and a binding for every language it is generated into. [OpenTimelineIO](https://opentimeline.io) is how editorial decisions move between programs. A cut assembled in one tool, a conform in another, a review in a third: each holds the same idea — these shots, in this order, trimmed here, at this rate — and OTIO is the written form of that idea, so the three can agree about it without agreeing about anything else. This library is a port of it to Rust. Not a reimplementation with its own ideas about how timelines should work: the arithmetic, the rounding and the timecode behaviour follow upstream exactly, measured against upstream's own test suites, because a file written here has to open unchanged in every tool that already reads OTIO. #### What you get The whole data model, the time math beneath it, and the interchange formats post actually uses: | Piece | What it does | | --- | --- | | `opentime` | Rational time, time ranges, SMPTE timecode, drop-frame and all | | `otio-core` | The schemas, the compositions, the algorithms, and `.otio` JSON | | `otio-ale` | Avid Log Exchange | | `otio-cmx3600` | CMX 3600 Edit Decision Lists | | `otio-fcp7` | Final Cut Pro 7 interchange XML | | `otio-fcpx` | Final Cut Pro X XML | | `otio-aaf` | AAF, read and written as upstream's adapter writes it | Everything above sits on Rust and nothing else. There is no C++ library underneath and no Python interpreter: a binding links one static library. #### Why the bindings all agree Each language SDK is generated from the C interface rather than written by hand, and that interface describes itself — the generator reads the Rust source of the ABI, not a header and not a schema somebody maintains alongside it. So a call added to the core is a call every SDK has, and a call whose meaning changed cannot keep an old signature in one language and a new one in another. Drift fails the build. That is also why this site can promise a switcher rather than a paragraph apologising for the languages it did not get to. ```csharp using System; using OpenTimelineIO; // 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. // A value here is a `readonly struct`, as one in .NET should be, so the // rate is chosen when the options are made rather than set afterwards. var defaults = Otio.ReadOptionsDefault(); var options = new ReadOptions(24, defaults.NameColumn, defaults.IgnoreTimecodeMismatch); // Reading hands back the object the file is about, which for an EDL is // the timeline it describes. var timeline = Otio.ReadFromFile(Format.Cmx3600, "cut.edl", options); foreach (var node in timeline.FindClips()) { if (node is Clip clip) { Console.WriteLine(clip.Name()); } } ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/read-an-edl/csharp.cs Reading is the same shape everywhere: reading a file hands back the object it is about, and you ask that object what is under it. Only Rust, C and Zig show you the document that owns the objects on the way; every other SDK keeps it out of sight, as upstream's own bindings do. What differs is what each language calls a failure and what it calls an absence — an `error` in Go, a `throws` in Swift, an `NSError` in Objective-C, an optional in Zig — and the SDK for each follows that language rather than the C interface it came from. #### Where to go next - [Getting started](https://otio-rust-docs.vercel.app/docs/getting-started/) builds the core and reaches it from each language, and each language has [its own page](https://otio-rust-docs.vercel.app/languages/). - [Reading and writing files](https://otio-rust-docs.vercel.app/docs/guides/reading-and-writing/) is the guide to the adapters, with a page per format: [EDL](https://otio-rust-docs.vercel.app/docs/formats/edl/), [ALE](https://otio-rust-docs.vercel.app/docs/formats/ale/), [Final Cut Pro XML](https://otio-rust-docs.vercel.app/docs/formats/final-cut-pro/), [AAF](https://otio-rust-docs.vercel.app/docs/formats/aaf/) and [bundles](https://otio-rust-docs.vercel.app/docs/formats/bundles/). - [Editing a timeline](https://otio-rust-docs.vercel.app/docs/guides/editing/) covers the ten edit operations. - [The data model](https://otio-rust-docs.vercel.app/docs/concepts/data-model/) and [Time ranges](https://otio-rust-docs.vercel.app/docs/concepts/time-ranges/) are the concepts every other page assumes. - [The C ABI reference](https://otio-rust-docs.vercel.app/reference/) lists every call the SDKs are generated from. For a language model, [`/llms.txt`](https://otio-rust-docs.vercel.app/llms.txt) indexes every page as Markdown, and `/llms/.txt` is the whole of the docs with every sample in one language. --- Page: https://otio-rust-docs.vercel.app/docs/ ### Getting started > Building the core, and reaching it from your language. Everything starts with the Rust core. The bindings other than Python link a static library built from it, so that is the first build in every case. The TypeScript package is the exception: it is [on npm](#typescript), and needs none of this. This page is the short version for every language at once. Each language has a page of its own with its install steps, a first program, and how it spells failure and absence: [Rust](https://otio-rust-docs.vercel.app/docs/languages/rust/), [Python](https://otio-rust-docs.vercel.app/docs/languages/python/), [TypeScript](https://otio-rust-docs.vercel.app/docs/languages/typescript/), [Go](https://otio-rust-docs.vercel.app/docs/languages/go/), [Swift](https://otio-rust-docs.vercel.app/docs/languages/swift/), [C++](https://otio-rust-docs.vercel.app/docs/languages/cpp/), [Zig](https://otio-rust-docs.vercel.app/docs/languages/zig/), [C](https://otio-rust-docs.vercel.app/docs/languages/c/), [C#](https://otio-rust-docs.vercel.app/docs/languages/csharp/) and [Objective-C](https://otio-rust-docs.vercel.app/docs/languages/objectivec/). ```sh git clone https://github.com/alchemist-editor/otio-rust cd otio-rust cargo build --workspace cargo test --workspace ``` > [!NOTE] > Build the workspace before running the tests that include the C ABI's own C > program. It links against `libotio`, so a test run that is also the first > build can reach for a library that is not there yet. #### Rust Add the crates you need. `otio-core` is the data model; each adapter is its own crate so that a program reading EDLs does not compile an AAF parser. ```toml [dependencies] otio-core = { git = "https://github.com/alchemist-editor/otio-rust" } otio-cmx3600 = { git = "https://github.com/alchemist-editor/otio-rust" } ``` #### Python The package is called `opentimelineio`, and the name is the point: it aims to be a drop-in replacement for upstream's own package, measured by running upstream's test files unmodified. ```sh cd crates/otio-python pip install . ``` Files are read and written the way upstream reads and writes them, through `opentimelineio.adapters`: `read_from_file("cut.edl", rate=24)` picks the EDL adapter from the suffix, and each adapter takes upstream's keyword arguments. Every format on the [reading and writing](https://otio-rust-docs.vercel.app/docs/guides/reading-and-writing/) page is there, AAF included. The rest of upstream's package is there as well. Its plugin system loads adapters, media linkers, hooks, schemadefs and version manifests from `OTIO_PLUGIN_MANIFEST_PATH` and from installed packages' entry points, the same way upstream's does, and the built-in formats are declared as plugins themselves. Installing the package also installs upstream's console tools: `otiocat`, `otioconvert`, `otiostat`, `otiotool` and `otiopluginfo`. #### The SDKs Go, Swift, Zig, C++, C# and Objective-C each link `libotio`, which is not checked in. Build it and put it where the package expects it: ```sh cargo build -p otio-capi --release cp target/release/libotio.a sdk/go/lib/ # or sdk/swift, sdk/zig, sdk/cpp, sdk/objc ``` C# is the one that loads the library at run time rather than linking it, so it wants the shared one instead — `libotio.so`, or `libotio.dylib` on macOS — copied into `sdk/csharp/lib/`. Then each is ordinary for its language — `go test ./...`, `swift test`, `zig build test`, `cmake --build`, `dotnet run --project tests`, `make check`. The README beside each SDK has the exact invocation, including the linker flag Swift needs and the GNUstep packages Objective-C needs on Linux. #### TypeScript The TypeScript package is different: it has no static library at all. The C ABI is compiled to WebAssembly and the package ships the module, so it runs in a browser and in Node with nothing native to install. It is published to npm as `@alchemist-edit/otio`, so none of the build above is needed to use it: ```sh npm install @alchemist-edit/otio ``` Call `init()` once before anything else. In Node it reads the module off disk; in a browser it fetches it from beside the JavaScript, in the form every bundler understands, so there is nothing to configure. To build it from this repository instead, which needs Rust and the `wasm32-unknown-unknown` target: ```sh cd crates/otio-wasm/ts npm install npm run build ``` There is no filesystem behind a WebAssembly module, so the adapters there take and return bytes and leave getting hold of them to the host — a `fetch` in a browser, `fs` in Node. That is a line of code, and it is the host's line rather than the library's. --- Page: https://otio-rust-docs.vercel.app/docs/getting-started/ ## Guides ### Reading and writing files > The adapters, which formats each language can read and write, and what a round trip preserves. An adapter turns a file of some interchange format into a document and back. Upstream expresses this as a plugin with four loosely specified entry points and a dictionary of keyword arguments; here it is one trait per format, with each format's options named and typed — so a misspelled option is a compile error rather than a silent no-op. The Python package puts upstream's own `opentimelineio.adapters` back on top of those, with each adapter's names, keyword arguments and exceptions, so code written against upstream runs unchanged; there an unknown keyword is a `TypeError` rather than ignored. | Format | Suffix | Reads | Writes | Guide | | --- | --- | --- | --- | --- | | OpenTimelineIO JSON | `.otio` | Yes | Yes | [The .otio file format](https://otio-rust-docs.vercel.app/docs/concepts/otio-file-format/) | | CMX 3600 EDL | `.edl` | Yes | Yes, one video track | [EDL](https://otio-rust-docs.vercel.app/docs/formats/edl/) | | Avid Log Exchange | `.ale` | Yes | Yes | [ALE](https://otio-rust-docs.vercel.app/docs/formats/ale/) | | Final Cut Pro 7 XML | `.xml` | Yes | Yes | [Final Cut Pro XML](https://otio-rust-docs.vercel.app/docs/formats/final-cut-pro/) | | Final Cut Pro X XML | `.fcpxml` | Yes | Yes | [Final Cut Pro XML](https://otio-rust-docs.vercel.app/docs/formats/final-cut-pro/) | | AAF | `.aaf` | Yes | Yes | [AAF](https://otio-rust-docs.vercel.app/docs/formats/aaf/) | | OTIO zip bundle | `.otioz` | Yes, except from TypeScript | Yes, except from TypeScript | [Bundles](https://otio-rust-docs.vercel.app/docs/formats/bundles/) | | OTIO directory bundle | `.otiod` | Yes, except from TypeScript | Yes, except from TypeScript | [Bundles](https://otio-rust-docs.vercel.app/docs/formats/bundles/) | ```csharp using System; using OpenTimelineIO; // 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. // A value here is a `readonly struct`, as one in .NET should be, so the // rate is chosen when the options are made rather than set afterwards. var defaults = Otio.ReadOptionsDefault(); var options = new ReadOptions(24, defaults.NameColumn, defaults.IgnoreTimecodeMismatch); // Reading hands back the object the file is about, which for an EDL is // the timeline it describes. var timeline = Otio.ReadFromFile(Format.Cmx3600, "cut.edl", options); foreach (var node in timeline.FindClips()) { if (node is Clip clip) { Console.WriteLine(clip.Name()); } } ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/read-an-edl/csharp.cs Reading hands back whatever the file was about, usually a timeline, and the same call shape works for every format: name the file, and pass the format's options when it has any. Each format has its own page for what it carries and what it cannot, linked from the table above. Two of them have a trap worth knowing before you start: an [EDL](https://otio-rust-docs.vercel.app/docs/formats/edl/) does not know its own rate, and an [AAF](https://otio-rust-docs.vercel.app/docs/formats/aaf/) clip cannot be written without a MobID. #### What a round trip keeps Reading and writing the same file back should produce the same file. Where a format carries something the data model has no place for, the adapter keeps it on the object's metadata under the format's own key — `metadata["cmx_3600"]` for an EDL — so that writing it out reproduces the line it came from rather than dropping it. The opposite direction has limits that are the format's rather than this library's. An EDL describes a single strand of picture, so a timeline with two video tracks has no EDL form at all; a wipe reads as a wipe and writes back out as a dissolve, because CMX 3600's vocabulary for wipes is not one anybody agrees about. These are upstream's limits too, and they are documented on each adapter rather than discovered at runtime. #### Converting between formats Converting is a read and a write with nothing in between. There is no conversion step, and there is no per-pair code: an EDL and an FCP X XML are two spellings of the same object model, so once a file is read the format it came from stops mattering. ```csharp using OpenTimelineIO; // 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. // A value here is a `readonly struct`, as one in .NET should be, so the rate // is chosen when the options are made rather than set afterwards. var defaults = Otio.ReadOptionsDefault(); var options = new ReadOptions(24, defaults.NameColumn, defaults.IgnoreTimecodeMismatch); var timeline = Otio.ReadFromFile(Format.Cmx3600, "cut.edl", options); // Nothing happens in between. The timeline an EDL parses to is the same // timeline FCP X writes out, so converting is a read and a write: the object // model is the interchange, and the file formats are two ways of spelling it. Otio.WriteToFile(Format.FcpxXml, timeline, "cut.fcpxml", null); ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/convert-a-format/csharp.cs What survives the trip is the intersection of the two formats, and the table above is how to predict it. Going to an EDL narrows a cut to one video strand whatever it came from; going the other way, an EDL carries no media references beyond reel names, so nothing downstream can relink without being told where the media is. Upstream's own adapters fail outright on several of these conversions, and here they work. An EDL whose tracks start anywhere but `00:00:00:00`, or an audio-only EDL, could not be written as FCP X XML; neither could an FCP 7 timeline with a 15 fps clip. And FCP X XML written as FCP 7 XML produced a file that could not be read back. Each is a documented departure on the adapter it concerns. So is one fix to plain reading: an FCP 7 nested sequence reads with its tracks, where upstream reads every one as an empty stack. #### Writing what you built `.otio` is the format with no limits — it is the data model's own serialization, so anything you can build, it can hold. ```csharp using System; using OpenTimelineIO; // Each object is made on its own and joins a timeline when you put it // into one. Nothing has to exist before the thing it goes into. var timeline = new Timeline("Cut"); var stack = new Stack("tracks"); var track = new Track("V1", "Video"); timeline.SetTracks(stack); stack.AppendChild(track); var names = new[] { "A", "B", "C" }; for (var index = 0; index < names.Length; index++) { var clip = new Clip(names[index]); var start = new RationalTime(index * 24, 24); clip.SetSourceRange(new TimeRange(start, new RationalTime(24, 24))); track.AppendChild(clip); } // Three seconds of picture, written as canonical OpenTimelineIO JSON. // The objects keep their timeline alive between them, so there is // nothing to dispose. Console.WriteLine(track.Duration().ToSeconds()); Otio.Save(timeline, "cut.otio"); ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/build-a-timeline/csharp.cs --- Page: https://otio-rust-docs.vercel.app/docs/guides/reading-and-writing/ ### Editing a timeline > The ten edit operations, what each one does to its neighbours, and which of them change a track's length. Appending clips to a track builds a cut. Changing one is a different problem, because every change has a question attached: what happens to everything after it? There are ten operations, and they are the ones a non-linear editor has. They are functions rather than methods, because each is about two objects and belongs to neither — which is how upstream arranges them too. ```csharp using System; using System.Linq; using OpenTimelineIO; // One second of picture, named. static Clip Second(string name) { var clip = new Clip(name); clip.SetSourceRange(new TimeRange(new RationalTime(0, 24), new RationalTime(24, 24))); return clip; } static void Show(Track track) { var names = string.Join(" ", track.Children().Select(child => child.Name())); Console.WriteLine($"{names} - {track.Duration().Value} frames"); } var track = new Track("V1", "Video"); foreach (var name in new[] { "A", "B", "C" }) { track.AppendChild(Second(name)); } Show(track); // Insert makes room: everything from the insertion point onwards moves later, // and the track gets longer. Otio.Insert(Second("D"), track, new RationalTime(24, 24), false, null); 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.Overwrite( Second("E"), track, new TimeRange(new RationalTime(48, 24), new RationalTime(24, 24)), false, null); Show(track); ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/edit-operations/csharp.cs #### The ten | Operation | What it does | | --- | --- | | `insert` | Puts an item at an instant, pushing what follows later. | | `overwrite` | Lays an item over a span, replacing what was there. | | `trim` | Moves an item's in and out points without moving its neighbours. | | `ripple` | Moves an item's in and out points, sliding everything after it. | | `roll` | Moves the cut between an item and its neighbour. | | `slip` | Moves the media inside an item without moving the item. | | `slide` | Moves an item along its track, taking the time from its neighbours. | | `slice` | Cuts whatever sits at an instant into two. | | `fill` | Drops an item into a gap, fitted as the reference point says. | | `remove` | Takes whatever sits at an instant out. | #### The question each one answers The useful way to hold these is by what they do to the track's length and to the neighbours. **Length changes.** `insert` makes the track longer by the length of what went in. `remove` without a fill makes it shorter, because what follows moves up; `remove` with a fill leaves a gap and the length alone. `ripple` changes it by however much the in or out point moved. **Length does not change.** `overwrite` replaces rather than displaces. `trim`, `roll`, `slip` and `slide` all move boundaries around within the track without altering how much track there is. `slice` puts a cut in and changes nothing else — two items where there was one, occupying the same span. **Neighbours move.** `insert`, `ripple` and `remove` without a fill. Those are the three that shift material you did not name. **Neighbours do not move.** `overwrite`, `trim`, `slip`, `slice` and `fill`. `roll` and `slide` are the interesting pair: both change a neighbour, but only by moving the boundary they share, so nothing further along the track notices. The sample above is `insert` and `overwrite` side by side, because that is the distinction people reach for first and the one that decides whether a cut gets longer. #### Filling what an edit opens Several of these can open a hole — `overwrite` past the end of a track, `remove` with a fill, `insert` at a point past the end. Each takes a **fill template**: the item to put in that hole. Pass nothing and it is an ordinary `Gap`, which is transparent, so on a multi-track timeline whatever is underneath shows through. When you want something else — solid black, a slate, colour bars — pass a clip with a `GeneratorReference` as the template and that is what fills it instead. #### Slip and slide These two are named after the editing desk and the names are worth learning the way round. **`slip`** keeps the item where it is on the track and moves the media inside it: same four seconds of the cut, a different four seconds of the shot. The track does not change at all, and neither does any neighbour. **`slide`** keeps the media and moves the item along the track: the same four seconds of the shot, arriving earlier or later. The time has to come from somewhere, so the neighbours on either side absorb it — the one before grows or shrinks, and the one after does the opposite. #### Where they are, and where they are not All ten are in the core, in the C ABI, in every SDK generated from it, and in Python as `opentimelineio.algorithms.overwrite`, `insert` and the rest, with a `ReferencePoint` enum for `fill`'s four-point edits. Upstream's own Python package does not bind them; only its C++ has them. The Python functions here follow that C++ `editAlgorithm.h` in their names, parameters and defaults, and raise what its error handler raises. An object an edit takes out of a track stays usable while Python holds it, with no parent, as any removed child does. #### What the copied piece holds `slice`, `insert` and `overwrite` copy the piece of an item a split leaves over, and `fill` with `ReferencePoint.Sequence` copies the clip it drops in. The copy is made as upstream makes it, by the equivalent of writing the item out and reading it back, so it shares nothing with the original: an object the item holds in two places — one object under two metadata keys, one effect listed twice, one media reference under two keys — becomes two separate objects in the copy. The item left in place keeps what it held. #### An item that holds itself Metadata can hold whole objects, including the item it belongs to: `clip.metadata["self"] = clip`. `slice`, `insert` and `overwrite` copy the piece of an item a split leaves over, and `fill` copies the clip it drops in; here that copy keeps the cycle, so the leftover piece holds itself just as the original does, and the edit goes through like any other. That is a deliberate difference from upstream. Upstream copies by writing the item out and reading it back, which cannot carry a cycle, so the same edits fail there with a cycle error — and `slice`, `insert` and `overwrite` fail only after changing the track, leaving the clip cut short and the rest of it lost. The cycle still cannot be saved: writing it as `.otio` is refused, here as upstream. --- Page: https://otio-rust-docs.vercel.app/docs/guides/editing/ ### Media linkers and hooks > Pointing every clip a read produces at the right media, and running code of your own at fixed points around a read or a write. A file that comes from somewhere else rarely points at media where it lives here. An EDL names a reel, an AAF names a MobID, an `.otio` from another facility names paths on its disks. Upstream OpenTimelineIO answers that with two plugin points, and every language here has both. A **media linker** is handed each clip a read produced, and answers with the media reference that clip should use instead, or with nothing to leave it alone. A read names the linker it wants. A **hook script** is handed the whole result at a named point, and answers with what the read or write goes on with: the same object changed, or another one. Scripts are attached to a **hook**, in order, and each is handed what the one before it returned. ```csharp using System; using System.Text; using OpenTimelineIO; // A cut of two clips, as an .otio file would hold it. const string cut = """ { "OTIO_SCHEMA": "Track.1", "name": "V1", "kind": "Video", "children": [ {"OTIO_SCHEMA": "Clip.2", "name": "A"}, {"OTIO_SCHEMA": "Clip.2", "name": "B"} ] } """; // A media linker is handed each clip as it is read, with the arguments // the read was given, and answers with the media the clip should use. // Answering null leaves the clip as it was. Otio.RegisterMediaLinker("proxies", (clip, arguments) => { var root = arguments.GetString("root"); return new ExternalReference(clip.Name(), $"{root}/{clip.Name()}.mov"); }); // A hook script is handed the whole result, and answers with what the // read goes on with: here the same object, stamped. Otio.RegisterHookScript("stamp", (target, arguments) => { var stamped = (SerializableObjectWithMetadata)target; stamped.Metadata.SetString("read_by", arguments.GetString("who")); return target; }); Otio.AttachHookScript("post_adapter_read", "stamp"); try { // The read names the linker, and carries both sets of arguments as JSON. var track = (Track)Otio.ReadFromBytes( Format.OtioJson, Encoding.UTF8.GetBytes(cut), new ReadOptions( mediaLinker: "proxies", mediaLinkerArguments: """{"root": "/proxies"}""", hookArguments: """{"who": "the conform"}""")); Console.WriteLine($"read by {track.Metadata.GetString("read_by")}"); foreach (var node in track.FindClips()) { if (node is Clip clip && clip.MediaReference(null) is ExternalReference media) { Console.WriteLine($"{clip.Name()} -> {media.TargetUrl()}"); } } } finally { Otio.DetachHookScript("post_adapter_read", "stamp"); Otio.UnregisterHookScript("stamp"); Otio.UnregisterMediaLinker("proxies"); } ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/link-media-and-run-hooks/csharp.cs #### Where they run A read parses the file, then runs, in this order, which is upstream's: | Step | Handed | | --- | --- | | `post_adapter_read` hook | the hook arguments, plus `adapter_arguments` and `media_linker_argument_map` | | the media linker, once per clip | the media linker arguments | | `post_media_linker` hook | the media linker arguments, not the hook ones, as upstream hands them | A write runs `pre_adapter_write` before it writes and `post_adapter_write` after. Writing to a file adds the path as `_filepath`. The pre-write hook works on a copy, so the object you asked to write is left exactly as it was, and what the hook returns is what gets written. Any other hook name is one of your own. Attaching a script to it declares it, and `run_hook` on an object runs it there and then. #### Choosing a linker A read with no linker named uses the one the `OTIO_DEFAULT_MEDIA_LINKER` environment variable names, or none, as upstream does. Naming one that was never registered fails the read rather than skipping the step, because a conform that silently did not link is worse than one that stopped. Asking for no linking at all (`do_not_link_media`, upstream's `MediaLinkingPolicy.DoNotLinkMedia`) leaves every clip alone, and the hooks still run. #### Arguments Each read or write can carry two argument maps: one for the linker and one for the hooks. Outside Rust and Python they cross as JSON text, an object whose values may be anything OTIO metadata can hold, timeline objects included. Your function receives them as metadata it can read with the metadata calls it already knows. A timeline object among the arguments is moved into the document the plugin works on; one the plugin puts into the timeline stays there, and the rest are removed once it has run. Arguments that are not a JSON object fail the read or write, whether or not any plugin is registered to take them. #### Failures A linker or script that fails stops the read or write, and the error carries its own message, prefixed with the plugin's name. In every language a failure is whatever that language fails with, an error, an exception, a panic, and it is turned into the library's `PLUGIN_ERROR` at the boundary rather than crossing into code that cannot handle it. #### Registering Plugins live in one registry per process, as upstream's do. Registering a name again replaces what it named; unregistering says whether there was anything to remove. A script that is unregistered while a hook still lists it fails that hook when it next runs, as upstream fails one its manifest names and cannot find, so detach it as well. Python finds plugins the way upstream does, through plugin manifests that name `.py` modules, and also sees every linker and script registered from any other language in the same process. Every other language registers functions in code, which is how a program in that language would load them anyway. [ADR 0008](https://github.com/alchemist-editor/otio-rust/blob/main/docs/adr/0008-native-linkers-and-hooks.md) and [ADR 0009](https://github.com/alchemist-editor/otio-rust/blob/main/docs/adr/0009-linkers-and-hooks-across-the-c-abi.md) say why. --- Page: https://otio-rust-docs.vercel.app/docs/guides/media-linkers-and-hooks/ ## Formats ### EDL (CMX 3600) > Reading and writing Edit Decision Lists: the rate you must supply, the dialects, and what writing refuses. An Edit Decision List is the oldest interchange format in post and the most widely understood: a title, then a numbered list of events, each one or two lines of timecode with free-form comments beneath. A file reads as a `Timeline`. ```csharp using System; using OpenTimelineIO; // 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. // A value here is a `readonly struct`, as one in .NET should be, so the // rate is chosen when the options are made rather than set afterwards. var defaults = Otio.ReadOptionsDefault(); var options = new ReadOptions(24, defaults.NameColumn, defaults.IgnoreTimecodeMismatch); // Reading hands back the object the file is about, which for an EDL is // the timeline it describes. var timeline = Otio.ReadFromFile(Format.Cmx3600, "cut.edl", options); foreach (var node in timeline.FindClips()) { if (node is Clip clip) { Console.WriteLine(clip.Name()); } } ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/read-an-edl/csharp.cs #### An EDL does not know its own rate This is the one thing to get right. A CMX 3600 file is timecode and nothing else: it never states the rate that timecode is at, and nothing can infer it from the contents. The rate you pass is believed. Pass the wrong one and the file still reads — every event simply lands somewhere it should not. There is a usual guess, 24, and it is a guess rather than a safe default. #### What becomes what | EDL | Document | | --- | --- | | `TITLE:` | the timeline's name | | each channel the events name (`V`, `A1`, `AA/V`) | a track | | an event | a clip on every track its channel maps to | | a hole in the record timecode | a gap | | `D` and `W###` edits | a `Transition` | | `* FROM CLIP NAME:` | the clip's name | | `* FROM CLIP:` / `* FROM FILE:` / `* OTIO REFERENCE FROM:` | the clip's media | | a path with a `[1001-1020]` range in it | an `ImageSequenceReference` | | `* LOC:` | a `Marker`, with its colour | | `*ASC_SOP` and `*ASC_SAT` | the clip's `metadata["cdl"]` | | `M2` | a `LinearTimeWarp` | | a freeze-frame comment | a `FreezeFrame` | | `BL`, `BLACK`, `BARS` reels | a `GeneratorReference` | | everything else | the clip's `metadata["cmx_3600"]` | Keeping the unrecognized comments is what lets a file survive a round trip: reading each of upstream's samples, writing it back and reading it again gives the same timeline. #### Dialects The three systems that read EDLs disagree about the comment that names a clip's media, so the writer takes a style: | Style | Names media with | | --- | --- | | Avid (the default) | `* FROM CLIP:` | | Nucoda | `* FROM FILE:` | | Premiere | nothing, with the path in `* OTIO REFERENCE FROM:` | Premiere reads any `FROM` comment as meaning the clip has no name and calls it `UNKNOWN`, so that dialect writes `AX` as every reel and puts the path in a comment Premiere ignores and this adapter can read back. From Rust the style is an enum, so an unknown dialect cannot be spelled at all; from Python it is upstream's `style` keyword argument. #### Reel names Most systems will only read a reel name of eight characters, so the writer pads or truncates to that by default. A truncated name is recorded in an `* OTIO TRUNCATED REEL NAME FROM:` comment, so reading the file back gets the original. Asking for no limit writes the name in full, which loses nothing but which most systems will not read. #### What writing will not do Writing is narrower than reading, as it is upstream: - Only one enabled video track, and at most two audio tracks. An EDL describes a single strand of picture, so a timeline with two video tracks has no EDL form and is refused. - Dissolves, but not wipes. A wipe reads, and writes back out as a dissolve. - One timing effect per clip, and only a speed change or a freeze frame. #### Where it differs from upstream The adapter is a port of upstream's `otio-cmx3600-adapter` and reproduces its behaviour, with two differences: - The writer works on a copy of the document, so writing does not change what you handed it. Upstream rewrites the timeline in place, and a caller who writes twice gets different text the second time. - A marker's colour is a real colour here rather than the string `"RED"`, so the name is canonicalized to `"Red"` and given its components. The name the file used is still on the marker's `metadata["cmx_3600"]`. The [crate's README](https://github.com/alchemist-editor/otio-rust/blob/main/crates/otio-cmx3600/README.md) has the Rust options in full. --- Page: https://otio-rust-docs.vercel.app/docs/formats/edl/ ### ALE (Avid Log Exchange) > Reading and writing shot logs, and where each column lands on a clip. An ALE is a tab-separated shot log: a `Heading` of key/value pairs, a `Column` line naming the fields, and a `Data` section with one row per clip. It is a list of clips rather than a cut, so a file reads as a `SerializableCollection` of clips, not a timeline. #### What becomes what | ALE | Document | | --- | --- | | `Heading` pairs | the collection's `metadata["ALE"]["header"]` | | `Column` order | the collection's `metadata["ALE"]["columns"]` | | `Name` | the clip's name (and stays as a column) | | `Start`, `Duration`, `End` | the clip's `source_range` | | `Source File` | an `ExternalReference` on the clip | | `CDL`, `ASC_SOP`, `ASC_SAT` | the clip's `metadata["cdl"]` | | every other column | the clip's `metadata["ALE"]` | Keeping the unrecognized columns and the column order is what makes a round trip lossless: upstream's own `sample.ale` reads and writes back byte for byte. #### Rates Unlike an EDL, an ALE states its rate, in the heading's `FPS`, and that rate wins over the one you pass, because the file was written for it. Timecode exists only at SMPTE rates, so a heading saying `23.976` is read at 24000/1001; the heading keeps the spelling it arrived with. A rate that is not near a SMPTE rate is refused rather than quietly rounded. #### Quirks kept from upstream - A value containing a tab is written as-is and becomes two columns when read back. Upstream means to replace tabs with spaces and throws the result away. - The `Name` column stays in `metadata["ALE"]` as well as becoming the clip's name, so it is in the document twice. The writer depends on this: it discovers columns from that metadata. #### Where it differs from upstream - A doubled sign such as `--1.0` in an `ASC_SOP` column simply does not start a number. Upstream matches it and then fails to convert, failing the whole row. - Reading moves `ASC_SOP`, `ASC_SAT` and `CDL` into `metadata["cdl"]`, and the writer rebuilds those columns from there. Upstream's writer looks only at the `ALE` metadata, so a graded file it has just read writes those columns blank. The numbers go back out through a float, so `-0.0870` is written as `-0.087`. - The writer snaps the heading's rate to a SMPTE rate the same way the reader does. Upstream writes at the stated decimal, so a file at `23.976` comes back with every time slid by a couple of frames. The [crate's README](https://github.com/alchemist-editor/otio-rust/blob/main/crates/otio-ale/README.md) has the Rust options. --- Page: https://otio-rust-docs.vercel.app/docs/formats/ale/ ### Final Cut Pro XML > FCP 7 interchange XML and FCP X XML: what each reads as, and what is kept for the round trip. Two different formats share the name. FCP 7's `.xml` is the older one, and still how a great many tools hand an edit to one another; FCP X's `.fcpxml` is Final Cut's current format, and it does not think in tracks. Both are ports of upstream's adapters, and both keep what OTIO has no field for in metadata so a file read and written again keeps it. ```csharp using OpenTimelineIO; // 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. // A value here is a `readonly struct`, as one in .NET should be, so the rate // is chosen when the options are made rather than set afterwards. var defaults = Otio.ReadOptionsDefault(); var options = new ReadOptions(24, defaults.NameColumn, defaults.IgnoreTimecodeMismatch); var timeline = Otio.ReadFromFile(Format.Cmx3600, "cut.edl", options); // Nothing happens in between. The timeline an EDL parses to is the same // timeline FCP X writes out, so converting is a read and a write: the object // model is the interchange, and the file formats are two ways of spelling it. Otio.WriteToFile(Format.FcpxXml, timeline, "cut.fcpxml", null); ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/convert-a-format/csharp.cs #### Final Cut Pro 7 XML FCP 7 itself is long gone, but its XML is not: Premiere Pro, Resolve, Hiero and Media Composer all read or write some dialect of it. The format carries far more per-element detail than OTIO has fields for. Everything the adapter does not turn into a real OTIO field is kept under the `fcp_xml` key in the relevant object's metadata and written back out on the way past, so a file read and written again keeps its colour settings, its effect parameters and its host application's bookkeeping. One fix to plain reading departs from upstream: a nested sequence reads with its tracks, where upstream reads every one as an empty stack. #### Final Cut Pro X XML Final Cut does not think in tracks. A sequence holds one `spine`, the main storyline, and everything layered over or under it hangs off whichever storyline item it overlaps, carrying a `lane` number saying how far above or below it sits. Reading works out where each element really starts and groups the results by lane, one track per lane. Writing puts lane zero back into the spine and reattaches the rest. What comes back from a read depends on what the file holds: | The file holds | It reads as | | --- | --- | | a library or an event | a `SerializableCollection` of timelines | | a bare project | a `Timeline` | | loose clips | a collection of clips and compound clips | What Final Cut knows about a piece of media that OTIO has no field for (its note, its keywords and its Spotlight metadata) is kept under the `fcpx` key in the media reference's metadata and written back out on the way past. #### Conversions upstream cannot do Several conversions that fail outright with upstream's adapters work here, each a documented departure on the adapter it concerns: - An EDL whose tracks start anywhere but `00:00:00:00`, or an audio-only EDL, written as FCP X XML. - An FCP 7 timeline with a 15 fps clip, written as FCP X XML. - FCP X XML written as FCP 7 XML, which upstream wrote as a file that could not be read back. --- Page: https://otio-rust-docs.vercel.app/docs/formats/final-cut-pro/ ### AAF > Reading, writing and changing AAF files, the options each takes, and how a written file relinks in Media Composer. AAF is how a cut reaches Avid Media Composer. Reading and writing are ports of upstream's AAF adapter, checked byte for byte against what upstream reads and writes, and AAF is available from every language. #### Reading an AAF An AAF reads as upstream's adapter reads it, byte for byte once written as OTIO JSON, on every sample file in upstream's test data. Upstream's four reading options are all here, with upstream's defaults: - `simplify` (on) collapses the nesting AAF has and OTIO does not need. - `attach_markers` (on) moves each marker from the slot that carries it onto the item it points at. - `bake_keyframed_properties` (off) records each keyframed effect parameter's value at every frame of its effect as `keyframe_baked_values`, interpolated the way pyaaf2 interpolates it. - `transcribe_log` (off) prints a line for each thing the reader makes, word for word what upstream prints. From Rust it takes a `TranscribeLog`, which hands each line to a function of your choosing. It is not in the C ABI or the SDKs built on it, since it would need a callback across it. From C and the SDKs, the first two are spelled as what turning them off does, `aaf_keep_nesting` and `aaf_markers_on_slots`, so that options left at zero read an AAF the way upstream does. The third is `aaf_bake_keyframes`. Baked curves go through the platform's `pow`, `acos` and `cos`, so off Linux the last digit of a baked value can differ from upstream's. Three log lines upstream prints for rare failures embed a Python object's memory address or a whole track, and those are worded differently here. #### Writing an AAF AAF is how a cut reaches Avid Media Composer, and writing one is a port of upstream's AAF adapter rather than a design of its own. Each track becomes a slot of a composition. Each clip becomes a chain of three objects: a master clip, a file describing the media, and a tape carrying its timecode, which is the chain Media Composer expects to relink through. Gaps, dissolves, nested tracks, stacks and markers all have an AAF form; any other transition is left out, as upstream leaves it out. ```csharp using System; using OpenTimelineIO; var timeline = new Timeline("Cut"); var stack = new Stack("tracks"); var track = new Track("V1", "Video"); timeline.SetTracks(stack); stack.AppendChild(track); var oneSecond = new TimeRange(new RationalTime(0, 24), new RationalTime(24, 24)); // An AAF clip is cut from media of a known length, so each clip's media says // how much of it there is. A new clip has no media at all, so its reference // goes in under upstream's key and is made the active one. foreach (var name in new[] { "A001C003", "A001C004" }) { var media = new ExternalReference(targetUrl: $"file:///media/{name}.mov"); media.SetAvailableRange(oneSecond); var clip = new Clip(name); clip.SetMediaReference("DEFAULT_MEDIA", media); clip.SetActiveMediaReferenceKey("DEFAULT_MEDIA"); clip.SetSourceRange(oneSecond); track.AppendChild(clip); } // 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.WriteToFile(Format.Aaf, timeline, "cut.aaf", new WriteOptions(aafUseEmptyMobIds: true)); foreach (var node in Otio.ReadFromFile(Format.Aaf, "cut.aaf", null).FindClips()) { if (node is Clip clip) { Console.WriteLine(clip.Name()); } } ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/write-an-aaf/csharp.cs A clip is tied to its media by a MobID. A cut read from an AAF already has one on every clip, kept under `metadata["AAF"]`, and it is written back, so the file relinks to the media it came from. A clip whose media is itself an AAF holding one master clip takes that clip's MobID. Anything else, such as a cut built from scratch, has none, and writing it needs `use_empty_mob_ids`, which makes MobIDs up. A made-up MobID links to no media Media Composer knows, which is why upstream refuses a clip without one by default, and so does this. Before writing anything, the writer checks the whole timeline the way upstream does: every item at the timeline's rate, every clip saying how much media it has, and every transition carrying what an AAF dissolve is built from. It reports everything it finds wrong at once rather than stopping at the first. The file is the same, byte for byte, as the one upstream's adapter writes from the same timeline, given the same clock and the same random identifiers. The tests check that on twelve files, and it holds on all 33 samples in upstream's own test data that upstream can write. That parity is the evidence the file suits Media Composer: none of the files has been imported into Media Composer as part of testing. Embedding the media in the file works as upstream's `embed_essence` does. Each clip's media URL is taken as a path, relative to the working directory unless it is absolute. An `.aaf` there has the master mob with the clip's MobID copied out of it, with its source mob and essence. A `.dnx` on a video track is imported as a raw DNxHD stream. Anything else is refused as upstream refuses it, and that includes a WAV file, which upstream sends to the DNxHD import too. From Python, upstream's four AAF hooks run where upstream runs them: `otio_aaf_pre_read_transcribe` and `otio_aaf_post_read_transcribe` before and after the file is transcribed, and `otio_aaf_pre_write_transcribe` and `otio_aaf_post_write_transcribe` before and after it is written. A `pre_write` hook is where to transcode other media into something `embed_essence` can embed, as upstream suggests: what it returns is what is written. Each hook is handed upstream's arguments, except that `aaf_handle`, upstream's open pyaaf2 file, is `None`, because there is no pyaaf2 file here. From Rust, `ReadOptions::with_post_transcribe` runs a callback at the one point inside the read; the other three run around the read or write, so a caller runs them itself. AAF writing is available from every language. From Python, `otio.adapters.write_to_file(timeline, "cut.aaf")` takes upstream's keyword arguments, `embed_essence=True` among them. From C and the SDKs, the same options are `aaf_`-prefixed fields of the write options, along with three a library caller needs and a Python one does not: `aaf_user`, whom a new marker is credited to when no login name is set; `aaf_time`, the time the file records; and `aaf_id_seed`, which seeds the identifiers it makes up. The same time, seed and timeline write the same file. WebAssembly has no clock or randomness of its own, so the TypeScript package passes the time and a fresh seed on every write unless you give your own. Nor has it a file system, so it cannot embed media: with `aafEmbedEssence` set, a clip whose media names a file stops the write, as the file cannot be found. #### Changing an existing AAF Below the adapter, the `aaf` crate can open an AAF file that already exists, change it and save it, as pyaaf2 does when it opens a file with `'r+'`. The same edits in the same order leave the same bytes pyaaf2 leaves, which twenty scenarios in its tests check against files pyaaf2 changed: properties changed, added and removed, mobs and slots added and taken out, new definitions and classes, and essence moved and dropped. It works on the AAF object model, not on a timeline, and is available from Rust only. The [crate's README](https://github.com/alchemist-editor/otio-rust/blob/main/crates/aaf/README.md#changing-a-file) has an example, and [ADR 0005](https://github.com/alchemist-editor/otio-rust/blob/main/docs/adr/0005-aaf-modify-path.md) the design. --- Page: https://otio-rust-docs.vercel.app/docs/formats/aaf/ ### 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. ```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"); } } ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/write-a-bundle/csharp.cs 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/ ## Concepts ### The data model > Timelines, stacks, tracks, clips, gaps, transitions — and the handles that name them. An OTIO document is a tree of objects that occupy time. There are not many kinds, and the names are the ones upstream uses. ```text Timeline └── Stack ("tracks") ├── Track ("V1", kind "Video") │ ├── Clip ("shot_01") │ ├── Gap │ └── Clip ("shot_02") └── Track ("A1", kind "Audio") └── Clip ("dialogue") ``` A **Timeline** is the whole cut. It holds one **Stack**, conventionally called `tracks`, and a stack lays its children over the same span of time — which is what a track being "above" another means. A **Track** is a strand: its children run one after another, and a **Gap** is the hole where nothing plays. A **Clip** is a piece of media with a range taken out of it, and a **Transition** is the overlap between two of them. #### Time, and what a range means Every duration in OTIO is a `RationalTime`: a value and a rate, kept apart. 24 frames at 24fps is not the number 1 — it is `24/24`, and the rate travels with it. This is the single most consequential decision in the format, because it is what stops a 23.976 sequence from drifting into a 24 one by accident. ```csharp using System; using OpenTimelineIO; // A time is a value and a rate, not a number of seconds. Four seconds at // 24 is 96 units; the rate travels with it so nothing has to guess later. var start = RationalTime.FromTimecode("01:00:00:00", 24); var duration = RationalTime.FromFrames(96, 24); var end = start.Add(duration); Console.WriteLine($"{end.ToTimecode()} for {duration.ToSeconds()} seconds"); // Comparison rescales first, so the same instant at two rates is equal. return new RationalTime(24, 24).Equals(new RationalTime(48, 48)) ? 0 : 1; ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/time-math/csharp.cs An item has up to three ranges, and the difference between them is the thing people get wrong: - **`available_range`** — everything the media has. A two-hour rush. - **`source_range`** — the piece of it this item uses. Four seconds of that rush. An item without one uses all of its media. - **`trimmed_range`** — the source range, or the available one when there is no source range. What the item actually contributes. A clip's `duration` is its trimmed range's duration; a composition's is the sum of its children's. That is why a track knows how long it is without anyone saying so. There are two more — `visible_range` and `range_in_parent` — and the thing that actually trips people up is which clock each one answers in. [Time ranges](https://otio-rust-docs.vercel.app/docs/concepts/time-ranges/) is that, in full. #### Handles rather than pointers Objects live in a `Document`, which owns them. You never hold an object directly: you hold a handle, which is an index and a generation, and the document resolves it. A handle to something that has gone away fails the lookup rather than reading freed memory, and a handle from one document cannot be used against another. This is worth knowing because it shapes every binding. Every SDK but one hides the document and lets a clip look like an object; Zig keeps it in the open, because an arena you hand to the things that use it is how Zig already works. Either way, what is underneath is the same arena. ```csharp using System; using OpenTimelineIO; // Each object is made on its own and joins a timeline when you put it // into one. Nothing has to exist before the thing it goes into. var timeline = new Timeline("Cut"); var stack = new Stack("tracks"); var track = new Track("V1", "Video"); timeline.SetTracks(stack); stack.AppendChild(track); var names = new[] { "A", "B", "C" }; for (var index = 0; index < names.Length; index++) { var clip = new Clip(names[index]); var start = new RationalTime(index * 24, 24); clip.SetSourceRange(new TimeRange(start, new RationalTime(24, 24))); track.AppendChild(clip); } // Three seconds of picture, written as canonical OpenTimelineIO JSON. // The objects keep their timeline alive between them, so there is // nothing to dispose. Console.WriteLine(track.Duration().ToSeconds()); Otio.Save(timeline, "cut.otio"); ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/build-a-timeline/csharp.cs #### Schemas, and what happens to ones we do not know Every object carries a schema name and version — `Clip.2`, `Marker.3` — and this port reads the versions upstream 0.19 writes, upgrading older ones as it reads. An object whose schema is not recognised at all is kept verbatim, so a third-party plugin's data survives a read and a rewrite untouched. Writing works in the other direction too. A document can be written for an older release by naming the schema versions it should carry, and each object is downgraded on the way out, as upstream's serializer does. The upgrade and downgrade steps live in one registry in the core, keyed by schema and version, and from Python `register_upgrade_function` and `register_downgrade_function` add to it. A schema defined in Python with `register_type` is held in the core the way upstream's C++ holds it: a generic object carrying its schema name, version and fields. Python keeps the class for each schema name and hands that class back when the object is read, so a type defined in Python round-trips through any document without the core ever holding a Python object. A Python subclass of a concrete schema, such as a `Clip` that also records which take it is, stays a clip in the core: it carries the subclass's schema name, version and extra fields beside its own, so every composition and algorithm treats it as the clip it is, and it is written under the subclass's name. A program that has not registered the subclass reads it as an unknown schema, kept verbatim. Any built-in object read from a file keeps the fields it does not know in the same place, and writes them back. --- Page: https://otio-rust-docs.vercel.app/docs/concepts/data-model/ ### Timeline structure > How stacks, tracks, clips, gaps and transitions nest, and what a flattened timeline looks like. A timeline holds tracks, and tracks hold clips — and then somebody nests a stack inside a track and it stops being obvious. This page is the shapes: what contains what, and what the result means when something plays it. Upstream's figures are pictures; these are the same structures as text, which is the same information and survives being copied into a terminal. #### A simple cut list The smallest useful timeline is a single track of clips laid end to end. ```text Timeline └── Stack "tracks" └── Track "Track-001" ├── Clip "Clip-001" source_range 0+10 → Media-001 available 0+20 ├── Clip "Clip-002" source_range 5+10 → Media-002 available 0+15 ├── Clip "Clip-003" source_range 2+8 → Media-003 available 100+30 └── Clip "Clip-004" source_range none → Media-004 available 0+12 ``` A timeline always has that top-level stack, even with one track in it, because a timeline can hold several and something has to lay them over each other. At the bottom, each media reference has a `target_url` saying where the media is and an `available_range` saying how much of it there is. Ranges are written here as `start+duration` in frames; `Media-003` starting at 100 means its first frame is timecode 00:00:04:04 at 24fps, not that it is offset in the cut. Above them, each clip's `source_range` picks the piece it uses. `Clip-004` has none, so it uses all of `Media-004` — and asking any of them for `trimmed_range` answers without the caller having to know which case it is in. A `source_range` may point outside the `available_range`. That happens for real — only the first half of a shot has been rendered — and nothing here snaps it back or complains. What a player does about it is the player's business. The track's own length is the sum of its children's trimmed ranges. Nobody sets it. #### Transitions A transition blends two neighbours on the same track: a dissolve, a wipe. It can sit between any two composable items, not only clips. ```text Track "Track-001" ├── Clip "Clip-002" ends at frame 10 ├── Transition in_offset 2, out_offset 3 └── Clip "Clip-003" starts at frame 0 ``` `out_offset` reaches back into the clip before it, `in_offset` forward into the clip after. So `Clip-002` is on screen for three frames longer than its own range says, and `Clip-003` for two frames earlier — which is exactly what `visible_range` reports for each while `trimmed_range` does not. See [Time ranges](https://otio-rust-docs.vercel.app/docs/concepts/time-ranges/) for the difference. **A transition changes no length.** The track is as long with it as without, so a tool that cannot render one may ignore it and still agree about where everything is. The format expects that a transition's offsets stay inside its neighbours, that a clip with transitions at both ends has them not overlapping, and that two transitions are never adjacent. Nothing in this library enforces any of that — upstream does not either — so a file can carry all three and will be read back as written. A fade to or from black is usually a transition against a `Gap`, which may have zero duration. A gap is *transparent* rather than black, so with tracks underneath it the thing below shows through; for real black, use a clip with a `GeneratorReference`. #### Several tracks Tracks in a stack play at the same time, lower entries first. ```text Stack "tracks" ├── Track-001 [ Clip-001 ][ Gap 4 ][ Clip-002 ] ├── Track-002 [ Gap ][ Clip-003 ][ Clip-005 ] └── Track-003 [ Clip-006 ] (trimmed by its own source_range) flattened [ Clip-001 ][Clip-003][ Clip-002 ] ``` Where `Track-001` has a gap, the track below shows through — those four frames of `Clip-003` are what the flattened result carries. `Clip-005` is covered by `Clip-003` the whole time, so it does not appear at all. The gap at the head of `Track-002` is doing nothing but offsetting what follows it. That is the usual way to shift a track's contents; setting the track's own `source_range` is the other, and `Track-003` does it that way. Tracks in a stack need not be the same length. Making a short one match means appending a gap, or extending its `source_range` — either way `trimmed_range` ends up the same, which is the only thing downstream cares about. Image tracks composite in painter order, bottom to top, over a background that is zero in colour and full in alpha. Audio tracks sum. Neither is something OTIO does — it is what OTIO *means*, and the application does it. What an effect on a clip does is not specified at all. #### Nesting A track's children can be any composable item, and that includes other tracks and stacks. ```text Track "Track-001" ├── Clip "Clip-001" ├── Stack "Nested Stack" source_range 2+6 │ ├── Track-A [ Clip-003 ] │ └── Track-B [ Clip-005 ] (trimmed by its own source_range) ├── Gap └── Clip "Clip-004" ``` A nested composition behaves exactly like a clip that happens to have complicated contents instead of a media reference. Give it a `source_range` and only that slice of it is included — frames 2 to 7 here. Give it none and its whole `available_range` is computed and used. The consequence worth remembering: **nesting is opaque**. The gap in `Track-001` cannot see `Clip-003`, because `Clip-003` is not its peer — it is inside `Nested Stack`, and contents do not spill out into the neighbouring gaps of the outer track. --- This page is adapted from [Timeline Structure](https://github.com/AcademySoftwareFoundation/OpenTimelineIO/blob/main/docs/tutorials/otio-timeline-structure.md) in upstream OpenTimelineIO's documentation, which is Apache-2.0 like this project. Its figures have been redrawn as text, and the claims about what is and is not enforced were checked against this implementation. --- Page: https://otio-rust-docs.vercel.app/docs/concepts/timeline-structure/ ### Time ranges > The five ranges an item has, which clock each one answers in, and why they differ. A timeline is a one-dimensional coordinate system, and laying clips end-to-end in a track is the easy case. Trimming, nesting, dissolves and speed changes are where it stops being obvious, and the confusion is almost always the same one: two different ranges were read as though they were measured against the same clock. They are not. Every range below belongs either to the **item's own clock** — the media's frame numbering — or to its **parent's clock**, the track it sits in. Getting this right is most of getting OTIO right. ```csharp using System; using OpenTimelineIO; static string Frames(TimeRange span) => $"{span.StartTime.Value} for {span.Duration.Value}"; // Ten seconds of rushes on disk. The available range belongs to the media, // not to the clip: it is what the file offers, whoever uses it. var media = new ExternalReference("A001", "file:///A001.mov"); media.SetAvailableRange(new TimeRange(new RationalTime(0, 24), new RationalTime(240, 24))); // Three seconds of it, starting two seconds in. A source range is in the // media's clock, which is why it starts at 48 rather than at 0. var clip = new Clip("shot"); clip.SetMediaReference("DEFAULT_MEDIA", media); clip.SetSourceRange(new TimeRange(new RationalTime(48, 24), new RationalTime(72, 24))); // A second of black in front of it, so the clip does not start the track. var head = new Gap(); head.SetSourceRange(new TimeRange(new RationalTime(0, 24), new RationalTime(24, 24))); var track = new Track("V1", "Video"); track.AppendChild(head); track.AppendChild(clip); // The same clip, asked four questions. The first three answer in the media's // clock; the last answers in the track's. Console.WriteLine("available: " + Frames(clip.AvailableRange())); Console.WriteLine("trimmed: " + Frames(clip.TrimmedRange())); Console.WriteLine("visible: " + Frames(clip.VisibleRange())); Console.WriteLine("in parent: " + Frames(clip.RangeInParent())); ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/ranges-of-a-clip/csharp.cs That sample prints four different answers about one clip, and the rest of this page is why. #### The ranges in the item's own clock ##### `available_range` Everything behind the item, before anything trims it. For a clip that is the active media reference's own `available_range`: if the clip points at `wedding.mov`, this is how long that file is and what timecode it starts at. For a track it is the sum of its children; for a stack, the longest of them. It can be unknown. Media goes missing, and a URL can be expensive enough to query that nobody has. Upstream answers `None` in that case; here asking a clip whose media says nothing about its length is an error rather than an answer — `NoAvailableRange` in Rust, which reaches C as `OTIO_STATUS_CORE_ERROR` and each SDK as whatever that SDK makes of a failure. Note the difference from `source_range` below, where absence *is* one of the answers and the status is `OTIO_STATUS_NO_VALUE`. ##### `source_range` The piece of the media this item uses — the trim, and the only one of these ranges you set rather than ask for. It may be absent, which means the item uses all of its media. An item with neither a `source_range` nor an `available_range` is invalid: something has to say how long it is. It is usually shorter than what is available, but it does not have to be. Asking for a range that runs past the end of the media is a real thing to do — "this shot needs to be four seconds and only two have been rendered" — and neither upstream nor this port will stop you or snap it back. What a player does about it is the player's business. ##### `trimmed_range` The `source_range` if there is one, and the `available_range` if there is not. This is how long the item is as far as its parent is concerned, and it is what `duration` reports. ##### `visible_range` The `trimmed_range`, widened by any transition that reaches into the item. A clip trimmed to end at frame 10, followed by a dissolve with an `out_offset` of 5, is on screen until frame 15 — so its `visible_range` ends at 15 while its `trimmed_range` ends at 10. With no transition touching it, the two are equal, which is why the sample above prints the same numbers for both. #### The ranges in the parent's clock ##### `range_in_parent` Where the item sits in the composition holding it. In a **track** this is what you would expect, and one invariant follows from it: each child begins exactly where the previous one ended, so for adjacent clips A and B in a track, ```text A.range_in_parent().end_time_exclusive() == B.range_in_parent().start_time() ``` In a **stack** it is duller, because a stack lays its children over the same span: the start is always zero and the duration is the child's own. That is what "these tracks are on top of each other" means. To offset something, put it in a track and put a gap in front of it — or trim the track itself. ##### `trimmed_range_in_parent` The same, clipped to the parent's own `source_range`. Most parents have no `source_range`, so most of the time this is `range_in_parent` again. When the parent *is* trimmed, this is the honest answer about where the child is relative to what survives the trim, and when the parent's trim excludes the child entirely there is no answer at all: `None` in Rust, `OTIO_STATUS_NO_VALUE` through the C ABI. One inherited quirk: a *direct* child trimmed out of its composition raises `InvalidTimeRange` rather than answering "nothing". That is upstream's behaviour and it is deliberate here, because the alternative — handing back a zero-length range — would quietly place the item at the head of the track. #### Compositions A track or a stack is an item too, so it has all of the above. Two more are worth naming: - **`range_of_child_at_index`** — where the nth child sits, transitions included. - **`trimmed_range_of_child_at_index`** — the same, relative to the composition's own trim. Asking a track or a stack for its `duration` sums its children, which is why nobody has to tell a track how long it is. #### Markers A marker can hang off any item, and its `marked_range` is in **that item's** clock — the same clock as the item's `source_range`. A marker on a clip is positioned in the media's numbering; a marker on a track is positioned along the track. Its duration may be zero, which means an instant rather than a span. Both are ordinary. #### Transitions A transition has `in_offset` and `out_offset` rather than a range of its own: how far it reaches back into the item before it and forward into the item after. `range_in_parent` and `trimmed_range_in_parent` work on one, and both defer to the parent. A transition does not make its track any longer or shorter. A tool that cannot render one can ignore it and still get the timeline's length right. --- This page is adapted from [Time Ranges](https://github.com/AcademySoftwareFoundation/OpenTimelineIO/blob/main/docs/tutorials/time-ranges.md) in upstream OpenTimelineIO's documentation, which is Apache-2.0 like this project. The behaviour described has been checked against this implementation rather than carried over on trust; where the two differ — the shape of "there is no answer", chiefly — it says so above. --- Page: https://otio-rust-docs.vercel.app/docs/concepts/time-ranges/ ### The .otio file format > What is in an OTIO JSON file, what this implementation guarantees about it, and where it departs from strict JSON. `.otio` is the format the rest of OpenTimelineIO exists to read and write. It is JSON, it is meant to be read by a person as well as a program, and it is the contract between this implementation and every other one. That contract matters more here than it does upstream. Upstream is the reference implementation, so whatever it writes is by definition right; this is a second implementation, and a file it writes has to open unchanged in tools built on the first. #### The shape An OTIO file is a tree of objects. Each object is a JSON dictionary whose fields are either plain JSON values or more OTIO objects. Every object carries an `OTIO_SCHEMA` key naming its type and version: ```json { "OTIO_SCHEMA": "Timeline.1", "name": "my timeline", "metadata": {}, "tracks": { "OTIO_SCHEMA": "Stack.1", "name": "tracks", "children": [ { "OTIO_SCHEMA": "Track.1", "name": "video track", "kind": "Video", "children": [] } ] } } ``` There is **no file-level version**. Each type is versioned on its own, so a new `Clip.3` can land without renumbering everything else. That is also why reading starts by looking at whatever the top-level object says it is rather than at a header. Children live under `children`, everywhere except a timeline, whose one child is `tracks`. #### The top level can be anything Most files hold a timeline, and most code assumes one. The format does not require it: a bare `Clip`, a `Track`, even a `RationalTime` is a valid `.otio` file, and this library writes one as readily. ```text otio_core::to_string starts at whatever it is pointed at. Hand it a clip and you get a clip. ``` Code that reads a file should say so when the top-level object is not what it wanted, rather than assuming. A `SerializableCollection` is the right container for several objects, because a bare JSON array cannot carry metadata of its own. #### No instancing There are no references to an object from two places. If the same clip or the same media appears twice in a timeline, it appears as two identical copies. This is worth knowing before you go looking for a way to share one: the format does not have it, and neither do the objects in memory — see [the data model](https://otio-rust-docs.vercel.app/docs/concepts/data-model/) for how handles work here. The reader does understand the `OTIO_REF_ID` tags and `SerializableObjectRef` pointers that some files carry in metadata, and holds them to upstream's rule: two objects declaring the same `OTIO_REF_ID` make the file unreadable, with `Duplicated object reference while reading: near line N`, because a reference could not say which of the two it meant. #### Metadata Nearly every object has a `metadata` dictionary, holding anything JSON can hold, nested as deeply as you like, including other OTIO objects. The core does nothing with it at all. It carries it, unchanged, so adapters and tools can keep what the schema has no field for — which several of the adapters here do. **Namespace it.** Two workflows writing to the same file will collide unless each keeps to its own key: ```json "metadata": { "my_playback_tool": { "loop": false }, "my_production_tracking_system": { "status": "IP", "owner": "rose" } } ``` It is also where a new schema usually starts life: put the field in metadata, see whether it earns its place, then move it. #### What this implementation guarantees - **Field order is upstream's**, not alphabetical and not this library's convenience. Fields come out in the order upstream's own `write_to` methods emit them, so a diff between a file written here and one written there is empty rather than a reordering. - **Indentation is four spaces** by default, as upstream's is. The format permits minifying and the project recommends against it: gzip the file if size matters, and keep it readable. - **Round trips are byte-for-byte** on upstream's own sample documents, which is how that first point is measured rather than asserted. #### Where it is not quite JSON Upstream configures RapidJSON with `kWriteNanAndInfFlag`, so a non-finite number is written as a bare `NaN`, `Infinity` or `-Infinity` literal. None of those is valid JSON, and a strict parser will refuse the file — **including upstream's own**, unless it is the one doing the reading. This library reproduces it exactly, in both directions: it writes those literals for non-finite values and accepts them when reading, in the spellings RapidJSON accepts (`Inf` as well as `Infinity`). That is deliberate, and it is the project's rule in miniature. Writing a tidier `null` instead would quietly change a document that somebody else's tool produced, and agreeing with upstream is the only way to agree with every other reader. The place to be surprised by this is here, in the documentation, rather than in a file that will not load. #### Naming Files should end in `.otio`. Not `.json` — the suffix is how every adapter in the ecosystem, this one included, decides what it is holding. --- This page is adapted from the [File Format Specification](https://github.com/AcademySoftwareFoundation/OpenTimelineIO/blob/main/docs/tutorials/otio-file-format-specification.md) in upstream OpenTimelineIO's documentation, which is Apache-2.0 like this project. The guarantees section describes this implementation and has no counterpart upstream. --- Page: https://otio-rust-docs.vercel.app/docs/concepts/otio-file-format/ ## Extensions ### Extensions > Work built on otio-rust's core that is not OpenTimelineIO, kept apart from it and off until a program turns it on. Everything else on this site is OpenTimelineIO: the object model, the time library, the file formats, ported from upstream and checked against upstream's own tests. An **extension** is something more, built in the same repository on the same core, that upstream OpenTimelineIO does not have. Extensions are kept apart so nobody mistakes one for OpenTimelineIO: - **Off until enabled.** An extension does nothing until a program turns it on. While it is off, any schema it owns reads as an unknown schema and is written back unchanged, as upstream OpenTimelineIO treats a schema whose plugin is not loaded. A file written with an extension on still opens in upstream's tools. - **A module of its own in every language.** Importing OpenTimelineIO never pulls an extension in. You import the extension's module separately. - **Its own C header.** The functions are in the same `libotio`, prefixed `otio_ext__`, and declared in `otio_ext_.h` beside `otio.h`. | Extension | What it does | | --- | --- | | [Timeline sync](https://otio-rust-docs.vercel.app/docs/extensions/timeline-sync/) | Shares a timeline between programs while it is edited | | [ORI Annotations](https://otio-rust-docs.vercel.app/docs/extensions/ori-annotations/) | Reads, writes and edits the ORI project's review annotations | #### Turning one on Every language has the same two calls on the core, by extension id, and each extension's module has its own `enable` and `is_enabled`: | Language | Core | Sync's module | | --- | --- | --- | | Rust | `otio_ext::enable("sync")` | `otio_ext_sync::install()`, then `otio_ext::enable(otio_ext_sync::ID)` | | Python | `opentimelineio.ext.enable("sync")` | `from opentimelineio.ext import sync; sync.enable()` | | C | `otio_ext_enable("sync", &error)` | `otio_ext_sync_enable(&error)`, from `otio_ext_sync.h` | Enabling an extension that is already on does nothing. Enabling one turns on the extensions it depends on first. Enabling is for the whole process and cannot be undone, as registering a schema cannot be. --- Page: https://otio-rust-docs.vercel.app/docs/extensions/overview/ ### Timeline sync > Sharing an OpenTimelineIO timeline between programs while it is edited, over Alchemist's Furnace or ORI Sync. Timeline sync keeps one timeline the same in several programs while people edit it: an editor in the browser, a reviewer on an iPad, a desktop editor, RV or xStudio. It is otio-rust's first extension. Two systems already do this. ORI Sync, from the ORI Annotations project, runs between OpenRV and xStudio over RabbitMQ. Alchemist's Furnace is a WebSocket server between Alchemist's clients. Both name every object with a GUID in `metadata.sync.guid` and send changes as patches addressed by GUID. Timeline sync is one implementation of that model, written once in Rust and reached from every language, that speaks both systems' wire formats. #### Where it stands Sync is being built in phases, tracked in [epic #115](https://github.com/alchemist-editor/otio-rust/issues/115): 1. **The extension itself**: sync can be enabled, and every SDK has its module. 2. **Identity and patches**: GUIDs, patches made by transactions or by comparing two versions of a timeline, and applying them. 3. **Sessions over Furnace**: a session that takes bytes in and hands bytes out, with no networking of its own, Furnace's wire format, a WebSocket client, and compression, in Rust and, through the C interface, in every SDK. 4. **Furnace relays patches** instead of whole timelines. 5. **ORI's wire format** over RabbitMQ, for RV and xStudio, in Rust. #### Turning it on | Language | Import | Enable | | --- | --- | --- | | Rust | `otio-ext-sync` | `otio_ext_sync::install(); otio_ext::enable(otio_ext_sync::ID)?;` | | Python | `from opentimelineio.ext import sync` | `sync.enable()` | | C | `#include "otio_ext_sync.h"` | `otio_ext_sync_enable(&error);` | | Go | `github.com/alchemist-editor/otio-rust/sdk/go/ext/sync` | `sync.Enable()` | | Swift | `import OTIOExtSync` | `try Sync.enable()` | | TypeScript | `import * as sync from "@alchemist-edit/otio/ext/sync"` | `await init(); sync.enable();` | | C++ | `opentimelineio/ext/sync.hpp` | `otio::ext::sync::enable();` | | C# | `using OpenTimelineIO.Ext.Sync;` | `SyncExtension.Enable();` | | Objective-C | `#import ` | `OTIOExtSyncEnable(&error);` | | Zig | `otio.ext.sync` | `try otio.ext.sync.enable();` | #### Identity Every object sync addresses carries a GUID in `metadata.sync.guid`: the timeline, its stack, and every track, stack and item under it. Markers, effects and media references travel inside the item that holds them. A GUID is ordinary metadata, so a file carrying them opens anywhere OpenTimelineIO does. Sync gives an object a GUID when it needs one. Beginning a transaction stamps every object that lacks one, and an object that shares a GUID with one met earlier (usually a copy made with its metadata) gets a new one; the first keeps it. You can also stamp a timeline yourself, read or set one object's GUID, make a new GUID, and find an object by its GUID. #### Patches A change to a timeline travels as a list of patches, in Alchemist's Furnace format: `insert_child`, `move_child`, `remove_child` and `set_property`, each naming the objects it is about by GUID. A change too big for patches (more than 500 of them, or more than 40% of the timeline's objects), a change between two sequences, or one that goes back a generation, is sent as one `replace_timeline` patch holding the whole timeline instead. There are two ways to get patches, and both give the same ones. **Compare two versions.** Diffing two snapshots, each a timeline with its sequence id and generation as Furnace sends it, gives the patches between them. It is a port of Furnace's own diff, checked against Furnace's golden files in Rust and in every SDK, so both ends agree byte for byte. ```csharp using System; using OpenTimelineIO.Ext.Sync; // Two versions of one sequence, as Furnace sends them: in the second, clip B // has moved in front of clip A. Every object carries its GUID in // `metadata.sync.guid`, which is how a patch names it. const string Before = """ {"sequenceId": "main", "generation": 1, "otioJson": { "OTIO_SCHEMA": "Timeline.1", "name": "cut", "metadata": {"sync": {"guid": "cut"}}, "tracks": {"OTIO_SCHEMA": "Stack.1", "metadata": {"sync": {"guid": "tracks"}}, "children": [ {"OTIO_SCHEMA": "Track.1", "name": "V1", "kind": "Video", "metadata": {"sync": {"guid": "v1"}}, "children": [ {"OTIO_SCHEMA": "Clip.2", "name": "A", "metadata": {"sync": {"guid": "a"}}}, {"OTIO_SCHEMA": "Clip.2", "name": "B", "metadata": {"sync": {"guid": "b"}}}]}]}}} """; const string After = """ {"sequenceId": "main", "generation": 2, "otioJson": { "OTIO_SCHEMA": "Timeline.1", "name": "cut", "metadata": {"sync": {"guid": "cut"}}, "tracks": {"OTIO_SCHEMA": "Stack.1", "metadata": {"sync": {"guid": "tracks"}}, "children": [ {"OTIO_SCHEMA": "Track.1", "name": "V1", "kind": "Video", "metadata": {"sync": {"guid": "v1"}}, "children": [ {"OTIO_SCHEMA": "Clip.2", "name": "B", "metadata": {"sync": {"guid": "b"}}}, {"OTIO_SCHEMA": "Clip.2", "name": "A", "metadata": {"sync": {"guid": "a"}}}]}]}}} """; SyncExtension.Enable(); // One move, of A to after B: a patch names objects by GUID. Console.WriteLine(SyncExtension.Diff(Before, After, true)); Console.WriteLine(SyncExtension.DiffStats(Before, After)); ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/diff-two-timelines/csharp.cs **Record a transaction.** Begin a transaction on a timeline, edit it with any of the usual calls, and commit it for the patches. Rolling back puts the timeline back as it was. The transaction remembers the timeline as it began and diffs it against the timeline as it is, so its patches are exactly what comparing the two versions gives. Applying patches to a timeline changes it in place. The timeline keeps its identity, and so does every track and item the patches keep, so anything your program holds onto still refers to the same object afterwards. An object the patches removed is gone, and one they added can be found by its GUID. ```csharp using System; using OpenTimelineIO; using OpenTimelineIO.Ext.Sync; // A cut, as an .otio file would hold it. Nothing in it has a GUID yet. const string Cut = """ { "OTIO_SCHEMA": "Timeline.1", "name": "cut", "tracks": {"OTIO_SCHEMA": "Stack.1", "children": [ {"OTIO_SCHEMA": "Track.1", "name": "V1", "kind": "Video", "children": [ {"OTIO_SCHEMA": "Clip.2", "name": "A"}, {"OTIO_SCHEMA": "Clip.2", "name": "B"}]}]} } """; SyncExtension.Enable(); // Beginning a transaction gives every object a GUID, and returns the // timeline as it was as a snapshot: that snapshot is the transaction. A // second program starts from the same stamped timeline. var timeline = Otio.FromJson(Cut); var before = SyncExtension.Begin(timeline, "main", 1); var shown = Otio.FromJson(timeline.ToJson(0)); // Edit as usual, then commit for the patches that say what changed. timeline.SetName("fine cut"); var patches = SyncExtension.Commit(timeline, before); Console.WriteLine(patches); // The other copy applies them in place: what it held still names the same // objects. SyncExtension.ApplyTo(shown, patches); Console.WriteLine($"the viewer now shows {shown.Name()}"); ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/apply-patches/csharp.cs #### Noticing changes A program that edits a timeline through code it does not own, such as an editing operation or a script, can turn on the document's change journal. The journal records which objects any edit touched. Committing it runs the `document_changed` hook, with the GUIDs of the touched objects as its `touched` argument, so a hook script can send the change on. The journal is off unless you turn it on, and costs nothing while it is off. The design is in [ADR 0011](https://github.com/alchemist-editor/otio-rust/blob/main/docs/adr/0011-timeline-sync.md). #### Sessions A session joins a shared timeline and keeps a copy of it in step. It does no networking itself: a program hands it what arrived and the session hands back what to send and what happened. The `otio-ext-sync-transport` crate puts it on a wire. Its first wire format is Furnace's protocol v2, and it has a blocking WebSocket client that runs the whole loop: ```rust use std::time::Duration; use otio_ext_sync::session::{self, ApplyOptions, Config, Event, Session}; use otio_ext_sync_transport::websocket::Client; let config = Config { token: join_token, ..Config::default() }; let mut client = Client::connect(&ws_url, Session::new(config))?; while let Some(event) = client.next_event(Duration::from_secs(1))? { // Applies timeline changes to `timeline` in place and runs the hooks. session::handle(&mut document, timeline, &event, &ApplyOptions::default())?; } ``` The host publishes with `session.publish(&mut document, timeline, "main")`, which works out the patches since the last publish and sends them. Viewers receive each change as patches and apply them in place, so handles to the timeline, its tracks and its items keep working. A host that reconnects to a Furnace holding an older generation than its own, because the connection dropped as an edit went out, sends its timeline again. ##### Patches on the wire When Furnace's welcome lists `timeline.patch`, the host sends each change as a `patch` message holding the patches and the generations they go from and to. Furnace applies them to its copy, so anyone who joins later still gets the whole timeline in their welcome, and relays them to every client whose hello listed `timeline.patch.receive` (sessions list it unless told not to). Everyone else, older clients included, gets the timeline Furnace rebuilt, as before. The host still sends the whole timeline for its first publish, every 64th change after that as a checkpoint, when the patches would be bigger than the timeline, when the change replaces the timeline outright, and to a Furnace that does not list `timeline.patch`. If Furnace's copy is not the generation a patch starts from, or the patch does not apply to it, Furnace answers `stale_patch` or `patch_rejected`, and the host sends its whole timeline in reply. A viewer that receives patches from a generation it does not have is out of sync: `receive` fails with an out-of-sync error (a `ConnectionError` in Python, `IoError` over the C interface), and the program reconnects to get the whole timeline in the welcome. Furnace's conformance corpus, revision 5, holds a patch and the timeline it makes, and Go and Rust must both reach the same timeline. Everything a session sends travels on a channel with a delivery rule: | Channel | Rule | Over Furnace | | --- | --- | --- | | timeline | in order | `patch`, or `snapshot`, from the host only | | playback | newest wins: a playhead not yet sent is replaced | `presence` | | presence | newest wins | `welcome` and `roster` | | review | kept until acknowledged, refused or cancelled, sent again after a reconnect | `review_commit` | | signal | sent once, never replayed | `signal` | Only the host may change the timeline, and only a client with the `review.comment.create` capability may commit a review comment. A session refuses these itself rather than letting Furnace close the connection. For the same reason `session.send` carries only Furnace's message types that have no call of their own (`signal`, and from the host only `bin` and the LAN messages), and review comments are refused when an older Furnace chooses protocol v1, which has no review ledger. Selections and other extensions' channels need Furnace to relay them, which it does not do yet. Four hooks run around a session: `sync_before_send` before the host's changes leave (a script that fails stops them), `sync_before_apply` before a remote change is applied (a script that fails rejects it), `sync_after_apply` after, and `sync_joined` once the join is done. The media linker runs on the clips a remote change inserts. ##### From other languages Every SDK has the same session, over the C interface. The program owns the socket: open a WebSocket to Furnace with whatever your language offers, connect the session, and then keep three things going. Send every frame the session gives you (text frames as text, binary ones as binary), hand it every frame that arrives, and act on each event it reports. Passing a `timeline` or `joined` event to `handle` with your copy of the timeline applies it in place and runs the hooks, as `session::handle` does in Rust. A session is a number from `session_new`, released with `session_close`. Its settings, its events and its state are JSON objects, in the field names Furnace uses: | | Fields | | --- | --- | | Settings | `token`, `identity` (`displayName`, `app`, `appVersion`, `credential`, `deviceId`, `clientKind`, `formFactor`, `deviceName`, `capabilities`), `encodings` (`br`, `gzip`); all optional | | Event | `type`: `joined`, `timeline`, `timeline_removed`, `playhead`, `selection`, `roster`, `leader`, `review_relay`, `review_committed`, `review_rejected`, `error`, `ended` or `message`, and that event's fields | | State | `joined`, `ended`, `sessionId`, `selfId`, `role`, `leader`, `peers`, `activeSequence`, `sequences`, `pendingReviews`, `version`, `encodings` | Python wraps this in a `Session` class that takes and returns dictionaries, and `poll_transmit` there returns `str` for a text frame and `bytes` for a binary one. ```csharp using System; using System.Text; using System.Text.Json; using OpenTimelineIO; using OpenTimelineIO.Ext.Sync; // The frame Furnace sends back to a viewer joining a session whose host // shared one timeline. const string Welcome = """{"v":2,"type":"welcome","sessionId":"s_1","seq":0,"ts":0,"order":1,"payload":{"selfId":"p_2","role":"viewer","roster":[{"id":"p_1","displayName":"Host","role":"host","joinedAt":0,"connectionState":"connected","muted":false}],"session":{"id":"s_1","createdAt":0},"snapshot":{"sequenceId":"main","generation":1,"otioJson":{"OTIO_SCHEMA":"Timeline.1","name":"Coastline","metadata":{"sync":{"guid":"t-1"}},"tracks":{"OTIO_SCHEMA":"Stack.1","metadata":{"sync":{"guid":"s-1"}},"children":[{"OTIO_SCHEMA":"Track.1","name":"V1","kind":"Video","metadata":{"sync":{"guid":"v-1"}},"children":[{"OTIO_SCHEMA":"Clip.2","name":"Waves","metadata":{"sync":{"guid":"c-1"}}}]}]}}}}}"""; SyncExtension.Enable(); // A session owns no socket. Make one with the join code, then connect it // once the socket is open. var session = SyncExtension.SessionNew("""{"token":"join-code","identity":{"displayName":"Ada"}}"""); SyncExtension.SessionConnect(session); SendAll(session); // Hand the session every frame that arrives. This is the one Furnace sends // back. SyncExtension.SessionReceive(session, Encoding.UTF8.GetBytes(Welcome), false); // The program's copy of the timeline starts empty. Handling the events puts // the host's timeline in it, and keeps it current from then on. var timeline = Otio.FromJson("""{"OTIO_SCHEMA": "Timeline.1"}"""); while (SyncExtension.SessionPollEvent(session) is string occurrence) { using var parsed = JsonDocument.Parse(occurrence); var handled = SyncExtension.Handle(timeline, occurrence); Console.WriteLine($"{parsed.RootElement.GetProperty("type")} {handled}"); } Console.WriteLine($"the viewer now shows {timeline.Name()}"); // Moving the playhead tells everyone else where it is. SyncExtension.SessionSetPlayhead(session, "main", 24, true, 1); SendAll(session); // Leaving queues a goodbye; send it, then the socket can close. SyncExtension.SessionLeave(session); SendAll(session); SyncExtension.SessionClose(session); // Sends every frame the session has. A real program writes each one to its // WebSocket to Furnace; this one only says what it would send. static void SendAll(ulong session) { var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); while (SyncExtension.SessionPollTransmit(session, now) is var (bytes, binary)) { Console.WriteLine($"send {bytes.Length} bytes as {(binary ? "binary" : "text")}"); } } ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/join-a-session/csharp.cs ##### Compression Each side lists the encodings it accepts when it joins: the client in its hello, the server in its welcome. Messages of 1 KiB or more go as Brotli or gzip when the other side accepts it, in a binary frame whose first byte names the encoding (1 for gzip, 2 for Brotli); everything else stays JSON text. Furnace does not list any encodings yet, so today every frame is text; the in-memory test room does, and CI publishes a table of what each encoding saves on the repository's timelines. A 103 KiB timeline goes over the wire as about 3 KiB. ##### Checked against Furnace Furnace's conformance corpus, every frame it says a client or server sends, is vendored into the crate's tests: each client frame is read and written again and must match it value for value, and each server frame is read as what it says. A CI job also builds furnaced and has a host and a viewer join a real session. #### Sessions over ORI Sync The ORI profile speaks ORI Sync's wire, so a session can join the same RabbitMQ session as OpenRV and xStudio running ORI's plugins. It is in Rust for now. There is no server: every peer binds a queue to the session's fanout exchange, `sync_session_`, and hears everything. The first peer finds no master within two seconds and elects itself; later peers ask the master for its state and join with it, and if the master stops answering another peer takes over. ```rust use std::time::Duration; use otio_ext_sync::session::{self, ApplyOptions, Config, Session}; use otio_ext_sync_transport::{amqp::Client, ori}; let options = ori::Options::new("review-42"); let mut client = Client::connect("amqp://guest:guest@localhost:5672/", Session::new(Config::default()), options)?; loop { // `None` is a quiet second, not the end: keep going so the client can // elect itself, answer joiners and send its heartbeat. if let Some(event) = client.next_event(Duration::from_secs(1))? { session::handle(&mut document, timeline, &event, &ApplyOptions::default())?; } } ``` Any peer may change the timelines, as in ORI. What ORI's peers send arrives as patches like Furnace's, and what a session publishes goes out as ORI's own edits (`SET_PROPERTY`, `INSERT_CHILD`, `MOVE_CHILD`, `REMOVE_CHILD`), or as the whole timeline when ORI's edits cannot say it, such as inserting a track with clips in it. A changed annotation clip goes out as ORI's `REPLACE_ANNOTATION_COMMANDS`; see [ORI Annotations](https://otio-rust-docs.vercel.app/docs/extensions/ori-annotations/#on-ori-s-wire). Several timelines, which one is active, the playhead and ORI's display settings cross too. Selections are retired in ORI, and review comments, leaders and muting have no ORI message, so sending them fails as unsupported. Snapshots and messages over 1 MB can be zlib-compressed, but ORI's own peers cannot read either yet, so both are off unless `Options` turns them on. Compressed input is always read. ##### Checked against ORI ORI's own recordings of real sessions are vendored into the crate's tests and replayed as ORI's recording player replays them; the session must end with the same timelines, active timeline, playhead and display settings as ORI's own peer, which CI checks against ORI itself. CI also runs sessions on a real RabbitMQ, between Rust peers and with ORI's Python peer, with each side as master. --- Page: https://otio-rust-docs.vercel.app/docs/extensions/timeline-sync/ ### ORI Annotations > Reading, writing and editing the ORI project's review annotations on an OpenTimelineIO timeline. ORI Annotations are drawings and notes made on a frame during review: paint strokes, text and shapes. The schemas belong to the ASWF's ORI project, whose [ORI Annotations](https://github.com/richardssam/ORIAnnotations) repository defines them as an OpenTimelineIO plugin in Python and exchanges them live between OpenRV and xStudio. This extension reads and writes the same objects, under the same names, so a file written here opens in ORI's tools and the other way round. It is built on [timeline sync](https://otio-rust-docs.vercel.app/docs/extensions/timeline-sync/): enabling it enables sync, and every object it creates carries a sync GUID. #### Where it stands The extension is being built in phases, tracked in [epic #126](https://github.com/alchemist-editor/otio-rust/issues/126): 1. **Schemas, the annotation track and coordinates**, in Rust. 2. **Every language**: the C interface, a module in every SDK, and Python. 3. **Live review over Furnace**: review comments with their strokes, folded into the host's timeline, and strokes in progress. 4. **ORI's wire**, for live review with RV and xStudio. All four are here. One piece waits on the server: Furnace does not yet pass strokes in progress along, so over Furnace only finished comments travel today; over ORI's wire both do. See [Live review](#live-review). #### Turning it on | Language | Import | Enable | | --- | --- | --- | | Rust | `otio-ext-ori-annotations` | `otio_ext_ori_annotations::install(); otio_ext::enable(otio_ext_ori_annotations::ID)?;` | | Python | `from opentimelineio.ext import ori` | `ori.enable()` | | C | `#include "otio_ext_ori.h"` | `otio_ext_ori_enable(&error);` | | Go | `github.com/alchemist-editor/otio-rust/sdk/go/ext/ori` | `ori.Enable()` | | Swift | `import OTIOExtOri` | `try Ori.enable()` | | TypeScript | `import * as ori from "@alchemist-edit/otio/ext/ori"` | `await init(); ori.enable();` | | C++ | `opentimelineio/ext/ori.hpp` | `otio::ext::ori::enable();` | | C# | `using OpenTimelineIO.Ext.Ori;` | `OriExtension.Enable();` | | Objective-C | `#import ` | `OTIOExtOriEnable(&error);` | | Zig | `otio.ext.ori` | `try otio.ext.ori.enable();` | The id is `ori-annotations`. While it is off, `PaintStart.1` and the rest read as unknown schemas and are written back unchanged, exactly as upstream OpenTimelineIO does when ORI's plugin is not on its path; the annotation calls refuse to run. With it on, ORI's files still come out byte for byte as they went in. #### Commands An annotation is a list of commands. A stroke is three or more commands that share one `uuid`: a `PaintStart.1` with the colour, brush and who drew it, one or more `PaintPoint.1` batches of points, and a `PaintEnd.1`. Text and shapes are one command each. | Schema | Holds | | --- | --- | | `PaintStart.1` | Opens a stroke: `uuid`, `rgba`, `brush`, `type`, `participant_hash`, `friendly_name`, and ORI's ghosting and hold fields | | `PaintPoint.1` | A batch of points: a `PaintVertices.1` with parallel `x`, `y`, `size` and `alpha` arrays | | `PaintEnd.1` | Closes a stroke | | `TextAnnotation.1` | Text at a `position`, with `font`, `font_size`, `scale`, `rotation` and `spacing` | | `EllipseAnnotation.1`, `RectangleAnnotation.1` | A shape in the box from `min` to `max`, with outline and fill colours | | `ArrowAnnotation.1` | An arrow from `start` to `end` | The batch of points is written `PaintPoint.1`, singular, as ORI's code and files write it; ORI's prose sometimes says `PaintPoints.1`. ORI's playback events (`Play.1`, `SetCurrentFrame.1`, `SyncPlayback.1` and the others) are registered too, so ORI's recordings read as typed objects. In Rust, `Command::read` gives a typed copy of a command and keeps every field it does not name; a new command built with a constructor such as `PaintStart::new` has the same defaults ORI's Python class gives it. Every other language passes commands as JSON text: a list of ORI objects, exactly as an annotation clip's `annotation_commands` holds them in a file. A field a command leaves out takes the default ORI's class gives it, as reading it with ORI's plugin would, so `{"OTIO_SCHEMA": "PaintStart.1"}` has ORI's `"brush": "circle"`. Python's calls also take a list of dicts. #### Where annotations live A frame's annotations sit on a one-frame clip in a track named `Annotations`, beside the media: ```text Stack ├─ Track "V1" Clip "shot" metadata.sync.guid = "shot" └─ Track "Annotations" Gap Clip "Annotation_10" source_range: frame 10, one frame long metadata.clip_guid = "shot" metadata.annotation_commands = [PaintStart, PaintPoint, PaintEnd, …] ``` The annotation clip's start time is the annotated frame of the media clip, and `clip_guid` names that clip by its sync GUID. This is the form ORI's sync protocol and Alchemist's review design both chose. It follows ORI exactly, including one surprise: an annotation clip for a frame later than the end of the track is placed after a gap at its own frame, but one for an earlier frame is simply added at the end, so annotations are found by `clip_guid` and frame, never by where they sit on the track. Reading also understands two older forms ORI has written: its review export, where the track is named after the review and each clip names the media in `annotated_clip_name`, and `AnnotationEffect.1`, an effect on the media clip that holds its commands. Neither is written. ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using OpenTimelineIO; using OpenTimelineIO.Ext.Ori; // A cut with one shot, which sync knows by the GUID `shot`. const string Cut = """ { "OTIO_SCHEMA": "Timeline.1", "name": "review", "tracks": {"OTIO_SCHEMA": "Stack.1", "children": [ {"OTIO_SCHEMA": "Track.1", "name": "V1", "kind": "Video", "children": [ {"OTIO_SCHEMA": "Clip.2", "name": "shot", "metadata": {"sync": {"guid": "shot"}}}]}]} } """; // ORI Annotations is an extension; enabling it enables sync too. OriExtension.Enable(); var timeline = Otio.FromJson(Cut); // The Annotations track, and the clip on it for frame 10 of the shot. var track = OriExtension.EnsureTrack(timeline); var frame = OriExtension.EnsureClip(track, "shot", 10, 24); // ORI's coordinates are height-normalised with the origin at the centre, // so convert from the pixels of the 1920x1080 picture. var points = new[] { (200.0, 200.0), (960.0, 540.0), (1720.0, 880.0) } .Select(pixel => OriExtension.PixelsToOri(1920, 1080, pixel.Item1, pixel.Item2)) .ToArray(); // A stroke is a PaintStart, its points and a PaintEnd sharing one uuid. // Fields left out take the defaults ORI's classes give them. var uuid = OriExtension.NewUuid(); var stroke = JsonSerializer.Serialize(new object[] { new Dictionary { ["OTIO_SCHEMA"] = "PaintStart.1", ["uuid"] = uuid, ["rgba"] = new[] { 1.0, 0.25, 0.25, 1.0 }, ["participant_hash"] = "reviewer-1", }, new Dictionary { ["OTIO_SCHEMA"] = "PaintPoint.1", ["uuid"] = uuid, ["points"] = new Dictionary { ["OTIO_SCHEMA"] = "PaintVertices.1", ["x"] = points.Select(point => point.x).ToArray(), ["y"] = points.Select(point => point.y).ToArray(), ["size"] = points.Select(_ => 0.01).ToArray(), ["alpha"] = points.Select(_ => 1.0).ToArray(), }, }, new Dictionary { ["OTIO_SCHEMA"] = "PaintEnd.1", ["uuid"] = uuid }, }); OriExtension.AddCommands(frame, stroke); // Read them back: every annotation on the shot. foreach (var holder in OriExtension.ListForClip(timeline, "shot")) { Console.WriteLine(OriExtension.Describe(holder)); } // The file is plain OpenTimelineIO with ORI's schemas in it. Console.WriteLine(timeline.ToJson(4)); ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/annotate-a-frame/csharp.cs The calls, named as in Rust's `annotations` module and in every language's own case: | Call | Does | | --- | --- | | `find_track`, `ensure_track` | Finds the timeline's annotation track, or adds one | | `find_clip`, `ensure_clip` | Finds the annotation clip for a media clip GUID and frame, or adds one | | `add_commands` | Adds commands at the end of an annotation clip | | `replace_commands` | Replaces every command with a uuid, such as one whole stroke, in place | | `remove_commands` | Removes every command with any of a list of uuids | | `set_commands` | Replaces all of a clip's commands, as ORI's replace does; an empty list clears it | | `list`, `list_for_clip`, `list_at_frame` | The object holding every annotation on the timeline, or those for a clip or a frame (`for_clip` and `at_frame` in Rust, which also has `by_participant`) | | `describe`, `commands` | What an annotation says about itself, as JSON, and its commands | Edits go by uuid, so one person's edit never disturbs another person's strokes. #### Live review Annotations travel between the people in a [sync session](https://otio-rust-docs.vercel.app/docs/extensions/timeline-sync/) in two ways. **A finished comment** is a note with the strokes drawn for it. A reviewer commits it with `commit`, giving the session, their copy of the timeline, the note, the commands, and an anchor saying where they drew: ```json {"sequenceId": "main", "capturedGeneration": 1, "clipGuid": "main/shot", "sourceTime": {"value": 10, "rate": 24}, "capturedSequenceTime": {"value": 10, "rate": 24}, "capturedAt": 1787742000000} ``` The comment goes on the session's review ledger, so it is sent again after every reconnect until Furnace stores it. Furnace then relays it to everyone as a `review_relay` event, and the host folds it into its timeline with `fold`: the strokes merge into the annotation clip for that source frame, and the comment is recorded on the clip: ```text Clip "Annotation_10" metadata.clip_guid = "main/shot" metadata.annotation_commands = [PaintStart s1, PaintPoint s1, PaintEnd s1] metadata.note = "Too dark here." the comments' notes, joined metadata.status = "open" metadata.alchemist.comments = [{commentId, authorUserId, bodyMarkdown, commandUuids: ["s1"], state, revision}] ``` Folding the same comment twice changes nothing, and `fold` says so. A stroke belongs to the user whose comment added it (`owner_of`): `commit` and `fold` both refuse a comment that would replace someone else's stroke, or a stroke no comment owns. The host then publishes its timeline as usual, so everyone sees the annotation. **A stroke in progress** is what ORI calls a `PARTIAL`: the commands drawn so far, shown to the others while the pen is still down and then dropped. It never reaches a timeline. `send_partial` sends one, and `read_partial` reads one out of a session event: ```json {"clip_guid": "main/shot", "frame": 10, "fps": 24, "events": [{"OTIO_SCHEMA": "PaintStart.1", "uuid": "s2"}]} ``` Over Furnace a stroke in progress has nowhere to go yet: Furnace closes the connection on a message type it does not know, so the session refuses to send one (an unsupported failure) rather than lose the connection. Over ORI's wire it travels as `Annotation.1/PARTIAL`, as RV and xStudio send it. ```csharp using System; using System.Text; using System.Text.Json; using OpenTimelineIO; using OpenTimelineIO.Ext.Ori; using OpenTimelineIO.Ext.Sync; // The cut both sides have: one shot, which sync knows by the GUID // main/shot. const string Cut = """{"OTIO_SCHEMA":"Timeline.1","name":"review","metadata":{"sync":{"guid":"main"}},"tracks":{"OTIO_SCHEMA":"Stack.1","children":[{"OTIO_SCHEMA":"Track.1","name":"V1","kind":"Video","children":[{"OTIO_SCHEMA":"Clip.2","name":"shot","metadata":{"sync":{"guid":"main/shot"}},"source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","value":0,"rate":24},"duration":{"OTIO_SCHEMA":"RationalTime.1","value":48,"rate":24}}}]}]}}"""; // What Furnace tells a reviewer who joins: who they are, and that they may // comment. const string Welcome = """{"v":2,"type":"welcome","sessionId":"s_1","seq":0,"ts":0,"order":1,"payload":{"selfId":"p_2","selfUserId":"u_bea","role":"viewer","roster":[],"session":{"id":"s_1","createdAt":0}}}"""; // What Furnace tells the host when it opens the session. const string HostWelcome = """{"v":2,"type":"welcome","sessionId":"s_1","seq":0,"ts":0,"order":1,"payload":{"selfId":"p_1","selfUserId":"u_ari","role":"host","roster":[],"session":{"id":"s_1","createdAt":0}}}"""; // Where the reviewer drew: frame 10 of the shot, as the program shows it. const string Anchor = """{"sequenceId":"main","capturedGeneration":1,"clipGuid":"main/shot","sourceTime":{"value":10,"rate":24},"capturedSequenceTime":{"value":10,"rate":24},"capturedAt":1787742000000}"""; // One stroke: a PaintStart, its points and a PaintEnd sharing a uuid. const string Stroke = """[{"OTIO_SCHEMA":"PaintStart.1","uuid":"s1","rgba":[1,0.25,0.25,1]},{"OTIO_SCHEMA":"PaintPoint.1","uuid":"s1","points":{"OTIO_SCHEMA":"PaintVertices.1","x":[-0.5,0,0.5],"y":[0.2,0,-0.2],"size":[0.01,0.01,0.01],"alpha":[1,1,1]}},{"OTIO_SCHEMA":"PaintEnd.1","uuid":"s1"}]"""; // The comment as Furnace relays it once it has stored it, to everyone in // the session. A real host reads this from its own socket. const string Relay = """{"v":2,"type":"review_commit","sessionId":"s_1","senderId":"p_2","seq":1,"ts":0,"order":2,"payload":{"clientMutationId":"m1","commentId":"c1","authorUserId":"u_bea","authorPresenceId":"p_2","serverOrder":1,"revision":1,"createdAt":0,"anchor":{"sequenceId":"main","capturedGeneration":1,"clipGuid":"main/shot","sourceTime":{"value":10,"rate":24},"capturedSequenceTime":{"value":10,"rate":24},"capturedAt":1787742000000},"bodyMarkdown":"Too dark here.","annotationCommands":[{"OTIO_SCHEMA":"PaintStart.1","uuid":"s1","rgba":[1,0.25,0.25,1]},{"OTIO_SCHEMA":"PaintPoint.1","uuid":"s1","points":{"OTIO_SCHEMA":"PaintVertices.1","x":[-0.5,0,0.5],"y":[0.2,0,-0.2],"size":[0.01,0.01,0.01],"alpha":[1,1,1]}},{"OTIO_SCHEMA":"PaintEnd.1","uuid":"s1"}]}}"""; // ORI Annotations is an extension; enabling it enables sync too. OriExtension.Enable(); // The reviewer joins with the right to comment. var reviewer = SyncExtension.SessionNew( """{"token":"join-code","identity":{"capabilities":["review.comment.create","review.annotation.create"]}}"""); SyncExtension.SessionConnect(reviewer); SendAll(reviewer); SyncExtension.SessionReceive(reviewer, Encoding.UTF8.GetBytes(Welcome), false); while (SyncExtension.SessionPollEvent(reviewer) is not null) { } // Commit the stroke with a note. The session sends it again after every // reconnect until Furnace stores it, so it is not lost. var seen = Otio.FromJson(Cut); Console.WriteLine($"committed {OriExtension.Commit(reviewer, seen, Anchor, "Too dark here.", Stroke, "")}"); SendAll(reviewer); // The host hears the stored comment and folds it into its timeline. var host = SyncExtension.SessionNew("""{"token":"host-token","identity":{"capabilities":["review.commit.receive"]}}"""); SyncExtension.SessionConnect(host); SendAll(host); SyncExtension.SessionReceive(host, Encoding.UTF8.GetBytes(HostWelcome), false); SyncExtension.SessionReceive(host, Encoding.UTF8.GetBytes(Relay), false); var timeline = Otio.FromJson(Cut); while (SyncExtension.SessionPollEvent(host) is string occurrence) { // A stroke still being drawn: draw it over the picture, then drop it. if (OriExtension.ReadPartial(occurrence) is string partial) { Console.WriteLine($"drawing {partial}"); continue; } using var parsed = JsonDocument.Parse(occurrence); if (parsed.RootElement.GetProperty("type").GetString() != "review_relay") { continue; } var (clip, already) = OriExtension.Fold(timeline, occurrence); Console.WriteLine($"folded {OriExtension.Describe(clip)} already there: {already}"); Console.WriteLine($"the stroke belongs to {OriExtension.OwnerOf(clip, "s1") ?? "nobody"}"); } SyncExtension.SessionClose(reviewer); SyncExtension.SessionClose(host); // Stands in for the network: a real program writes every frame the session // has waiting to its WebSocket to Furnace. static void SendAll(ulong session) { var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); while (SyncExtension.SessionPollTransmit(session, now) is var (bytes, _)) { Console.WriteLine($"send {bytes.Length} bytes"); } } ``` Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/share-an-annotation/csharp.cs ##### Merging Several people annotating one frame is the normal case, so edits merge rather than overwrite. These are the rules Alchemist's review uses, and the calls are there for any program that keeps annotations itself: | Call | Does | | --- | --- | | `merge_commands` | Merges commands into a clip's by uuid: a uuid it has is replaced where it stands, a new one goes at the end | | `replace_by_kind` | Replaces a clip's strokes, or its text, with new ones, keeping the kinds the new list lacks; an empty list clears all of them. This is how ORI's `REPLACE_ANNOTATION_COMMANDS` reads | | `merge_duplicates` | Folds every second annotation clip for one clip and frame into the first | | `commit` | Commits a comment with its strokes on a session | | `fold` | Folds a relayed comment into the timeline, saying whether it was there already | | `owner_of` | The user whose comment added a stroke | | `send_partial`, `read_partial` | Sends a stroke in progress, and reads one from a session event | ##### On ORI's wire With the [ORI profile](https://otio-rust-docs.vercel.app/docs/extensions/timeline-sync/#sessions-over-ori-sync), a session talks to RV and xStudio directly. An annotation edit from them (`REPLACE_ANNOTATION_COMMANDS`, or an annotation clip in `INSERT_CHILD`) arrives as a timeline change, merged by the rules above. Publishing a timeline whose annotation clip changed sends `REPLACE_ANNOTATION_COMMANDS` for that clip. Strokes in progress go both ways. ORI's roles still apply. A peer is a driver unless its options say otherwise, and only a driver may clear a clip's annotations. A peer joined as a reviewer can send strokes in progress, but cannot yet publish finished ones, because sync lets only the host or a driver publish timeline changes. #### Coordinates ORI places every point in height-normalised coordinates with Y up and the origin at the centre of the picture: `y` runs from -0.5 at the bottom to 0.5 at the top, and `x` from minus to plus half the aspect ratio, ±8/9 on a 16:9 frame. ```text x = (px - width / 2) / height y = -(py - height / 2) / height ``` `coords::ImageSize` converts to and from pixels, a port of ORI's `coords.py` that gives ORI's own test chart its numbers bit for bit; every language has it as `pixels_to_ori` and `ori_to_pixels`. Where a clip's media has `available_image_bounds`, `ori_to_bounds` and `bounds_to_ori` map ORI's space onto OpenTimelineIO's: the bounds' centre is ORI's origin and their height ORI's unit. Without bounds ORI assumes 16:9. Drawing the strokes is a renderer's job, not this extension's: it stores and moves the commands. #### Checked against ORI - ORI's own `annotationreview.otio` and test chart round-trip byte for byte, with the extension on and off. - A review built through these calls is byte for byte the file ORI's `SyncManager` and annotation builder write for the same strokes, generated with ORI's plugin on upstream OpenTimelineIO 0.18.1. - Every command in ORI's test chart reads as a typed command and writes back the same. - ORI's own test suite, run on otio-rust's Python bindings with ORI's plugin, gives exactly what it gives on upstream OpenTimelineIO 0.18.1. - ORI's plugin and this extension work in the same Python program: ORI's classes still read ORI's commands, this extension finds them, and the file writes back byte for byte. - The same review is built and edited by each SDK's own tests. - All 450 strokes in progress in ORI's 26 recorded sessions read as typed commands, and every recorded session's end state reads back the same. - A comment committed by a reviewer over a loopback Furnace room, and the server's own conformance fixture, fold into the host's timeline, in Rust, through the C interface, and in every SDK. See [ADR 0012](https://github.com/alchemist-editor/otio-rust/blob/main/docs/adr/0012-ori-annotations.md) for the design. --- Page: https://otio-rust-docs.vercel.app/docs/extensions/ori-annotations/ ## Internals ### How the SDKs are made > One description of the C ABI, read out of its own source, becomes every binding. Nobody writes these SDKs by hand, and nobody writes an interface description by hand either. ```text crates/otio-capi/src the C ABI, in Rust │ │ otio-sdk-model reads it ▼ sdk/api.json every call, its parameters, its docs, its role │ │ otio-sdk-gen writes one SDK per language ▼ sdk/go sdk/swift sdk/zig sdk/cpp sdk/csharp sdk/objc … and this site's reference pages ``` The description is committed as [`sdk/api.json`](https://github.com/alchemist-editor/otio-rust/blob/main/sdk/api.json), so a change to the C ABI shows up as a readable diff rather than as a surprise inside generated code. `cargo test -p otio-sdk-gen` fails when what is checked in is not what the generator would write today, which is what CI runs. Seven kinds of drift fail that check rather than reaching a user: a C function that fits no naming convention, one whose body does something the description cannot express, a schema the generator does not know about, two functions that would collide in a target language, a call that consumes a document, a struct whose computed layout disagrees with the size the ABI asserts, and generated files that differ from the ones committed. #### What "idiomatic" means here A generated API that reads like a transliteration of C is a failure even if every function is present. Each backend owns the shape of its own language: Go gets methods, embedded structs for the schema ladder, slices, strings, an `error` and a sentinel for "there is no value" — not two-pass out-parameters and status codes. And the shape of the API itself — what things are called, which members exist, which are properties and which are methods — follows upstream OpenTimelineIO's own bindings for that language rather than being invented here. Where a generated SDK departs from upstream's shape, the departure is written down in [ADR 0003](https://github.com/alchemist-editor/otio-rust/blob/main/docs/adr/0003-sdk-generation.md) with its reason. Some of the C ABI is plumbing no SDK user should see. The options structs open with a `struct_size` field so that the library can tell which header a caller was built against; every SDK fills it in as it hands the options over and leaves it out of its own `ReadOptions` and `WriteOptions`, except Zig, whose options are the C struct itself and so keep the field with the package filling it in. The defaults come from `otio_read_options_init` and `otio_write_options_init`, which fill a struct rather than return one, so an SDK sets the size on the struct it passes before the call. The generator does that for any out-parameter of a struct that carries its size, and the SDKs' own `ReadOptionsDefault` calls keep their names. Zig is the interesting exception, because it has no upstream binding to copy. It is also the one target that keeps the document visible, and the reasoning is worth reading if you want to know what the other bindings are hiding. #### Every SDK is held to the same behaviour Each SDK tests its own surface in its own language, which catches a binding that is broken but not one that works and quietly disagrees with the others. So a small set of scenarios is written once, as data beside the description ([`sdk/conformance.json`](https://github.com/alchemist-editor/otio-rust/blob/main/sdk/conformance.json)), and rendered by the generator into every SDK's own test framework: build this timeline and it writes exactly this JSON; a removed object's handle is stale; an object from another timeline is refused before the library is asked, and the other timeline is left whole; an object that already has a parent, or whose handle has gone stale, is refused before its timeline is brought over, so the two never merge. Each language's CI job runs them with the rest of its tests. A failure in them is named by kind — the library's status, or the binding's own refusal — never by message text, so a scenario asks the same question in Go's `error`, Swift's `throws` and Zig's error unions. Zig keeps the document visible, so the scenarios about what hiding it obliges a binding to do are marked as not applying there, and the generator checks that the ones every language runs mean the same thing on both sides of that fork. That rule means every SDK lets you recognise the refusal of another timeline's object without reading its message: | SDK | How to recognise it | | --- | --- | | Go | `errors.Is(err, otio.ErrOtherTimeline)` | | Swift | `error.isOtherTimeline` on `OTIOError` | | Zig | `error.ForeignObject` | | TypeScript | `err instanceof OtherTimelineError` | | C++ | `catch (const otio::OtherTimelineError&)` | | C# | `catch (OtherTimelineException)` | | Objective-C | `OTIOIsOtherTimeline(error)` | In Swift, C++, C# and Objective-C the refusal still carries the invalid-argument status it always had, so code that checked for that keeps working. #### This site is generated from the same file The [reference section](https://otio-rust-docs.vercel.app/reference/) reads `sdk/api.json` at build time: the groups, the calls, their parameters and their documentation all come from the description, and each C declaration is lifted verbatim out of the committed header rather than reassembled from parts. A page here cannot describe a call the library does not have. The prose and the code samples are written by hand, because they are explanation rather than interface. What keeps the samples honest is that each one is compiled — or, for Python, run — by its own language's toolchain against the real SDK, in the same CI job that tests that SDK. A change that leaves a sample describing an API that no longer exists fails the build that made it. A build-time check also requires every sample to exist in every language the switcher offers, or to say in writing why it cannot. --- Page: https://otio-rust-docs.vercel.app/docs/internals/how-the-sdks-are-made/