O
All pages

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.

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:

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 also carries the C# SDK's sources, laid out as in the repository with an empty lib/, and a prebuilt libotio-<version>-<target>.tar.gz per target whose lib/ holds the library, shared where it was built.

Your first program

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());
    }
}

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 structs, 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).

An object is a class of its schema, so a pattern match asks what one really is:

foreach (var child in track.Children())
{
    if (child is Clip clip && clip.MediaReference(null) is ExternalReference reference)
    {
        Console.WriteLine(reference.TargetUrl());
    }
}

Building a timeline

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

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:

if (clip.SourceRange() is TimeRange span)
{
    Console.WriteLine(span);
}

Editing and algorithms

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

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

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

See Reading and writing files, AAF and Bundles.

Media linkers and hooks

Upstream's two plugin points are delegates here. Otio.RegisterMediaLinker takes a Func<Clip, Metadata, MediaReference?>, 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<SerializableObject, Metadata, SerializableObject> that answers with what to go on with. A read runs the linker its ReadOptions name, and both carry their arguments as JSON:

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

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