# 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-<version>-<target>.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());
    }
}
```

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

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

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

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

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

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

<sub>Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/convert-a-format/csharp.cs</sub>

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

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

<sub>Source: https://github.com/alchemist-editor/otio-rust/blob/main/site/content/samples/link-media-and-run-hooks/csharp.cs</sub>

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/
