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 testsThe 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
sdk/csharp/README.md.- The C ABI reference the library is generated from, and ADR 0003 for every deliberate departure from upstream.