O
All pages

Swift

A SwiftPM package over libotio, with a class per schema, real enums and throwing calls.

The Swift package links libotio, the C ABI built from the Rust core, and 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. Its shape is OpenTimelineIO's own Swift bindings: a class per schema deriving as the schemas derive, an initializer per schema, values as structs, real enums, throws for failure, and no document in the surface.

import OpenTimelineIO

Install

The package links against a static libotio it expects to find in sdk/swift/lib/, and the linker is told where that is on the command line. The library is not checked in, so build it from the Rust core first:

cargo build -p otio-capi --release
cp target/release/libotio.a sdk/swift/lib/

cd sdk/swift
swift test -Xlinker -L"$PWD/lib"

Each GitHub release also carries the Swift 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 to put there.

Your first program

import 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.
let options = ReadOptions(rate: 24, nameColumn: "", ignoreTimecodeMismatch: false)

// Reading hands back what the file is about, not a container to look inside.
let root = try OTIO.readFromFile(.cmx3600, path: "cut.edl", options: options)

for case let clip as Clip in try root.findClips() {
    print(try clip.name())
}

Reading hands back what the file is about, its root object, rather than a container to look inside. OTIO.open infers the format from the filename instead of taking one, and OTIO.save writes back the same way. The rate is the one option an EDL cannot do without; see CMX 3600 EDL.

for case let clip as Clip works because every object arrives as the class of its schema, so as? asks what one really is:

for child in try track.children() {
    if let clip = child as? Clip, let reference = try clip.mediaReference() as? ExternalReference {
        print(try reference.targetURL())
    }
}

Building a timeline

import 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.
let timeline = try Timeline(name: "Cut")
let stack = try Stack(name: "tracks")
let track = try Track(name: "V1", kind: "Video")

try timeline.setTracks(stack)
try stack.appendChild(track)

for (index, name) in ["A", "B", "C"].enumerated() {
    let clip = try Clip(name: name)
    try clip.setSourceRange(
        TimeRange(
            startTime: RationalTime(value: Double(index * 24), rate: 24),
            duration: RationalTime(value: 24, rate: 24)))
    try track.appendChild(clip)
}

// Three seconds of picture, written as canonical OpenTimelineIO JSON.
// Objects keep their timeline alive between them, so there is nothing to
// close.
print(try track.duration().toSeconds)
try OTIO.save(timeline, to: "cut.otio")

Each object is made on its own and joins a timeline when you put it in one. Values such as RationalTime and TimeRange are structs.

How objects and documents work here

There is no document to hold. Underneath, the core keeps a timeline's objects in an arena; the package does that bookkeeping, and objects keep their timeline alive between them, so there is nothing to close. close() exists for releasing a large one early, and every object that lived in it then fails with .nullPointer rather than reading freed memory.

Objects made apart stay apart until one takes the other in. Appending or inserting an object that is still a child in another timeline is refused as the library refuses it, with .coreError and the library's own message, and so is placing one whose handle has gone stale, with .staleHandle. Both are refused before that timeline is brought over, so both stay whole, and closing one leaves the other working.

Compositions are deliberately not Swift collections; children are read with children().

Errors and missing values

A call that can fail throws an OTIOError carrying a Status.

A call that only names an object, such as detachChild, indexOfChild or hasChild, refuses one that belongs to a different timeline, and refuses it before asking the library, because merging the two and failing afterwards would already have done the damage. That refusal is an OTIOError with .invalidArgument and isOtherTimeline set; the other timeline is untouched.

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 nil, because that is an answer rather than a failure:

if let span = try clip.sourceRange() {
    print(span)
}

Editing and algorithms

import OpenTimelineIO

/// One second of picture, named.
func second(_ name: String) throws -> Clip {
    let clip = try Clip(name: name)
    try clip.setSourceRange(
        TimeRange(
            startTime: RationalTime(value: 0, rate: 24),
            duration: RationalTime(value: 24, rate: 24)))
    return clip
}

func show(_ track: Track) throws {
    let names = try track.children().map { try $0.name() }.joined(separator: " ")
    print(names, "-", try track.duration().value, "frames")
}

let track = try Track(name: "V1", kind: "Video")
for name in ["A", "B", "C"] {
    try track.appendChild(try second(name))
}
try show(track)

// Insert makes room: everything from the insertion point onwards moves later,
// and the track gets longer.
try OTIO.insert(
    try second("D"), composition: track,
    time: RationalTime(value: 24, rate: 24), removeTransitions: false)
try 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.
try OTIO.overwrite(
    try second("E"), composition: track,
    range: TimeRange(
        startTime: RationalTime(value: 48, rate: 24),
        duration: RationalTime(value: 24, rate: 24)),
    removeTransitions: false)
try show(track)

The ten edit operations and the algorithms are static functions 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, such as .cmx3600 or .fcpxXML. Every format the C ABI has is here: OTIO JSON, ALE, CMX 3600 EDL, both FCP XML flavours, AAF, and the .otioz and .otiod bundles, which are read and written through a path only.

import 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.
let options = ReadOptions(rate: 24, nameColumn: "", ignoreTimecodeMismatch: false)
let root = try OTIO.readFromFile(.cmx3600, path: "cut.edl", options: 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.
try OTIO.writeToFile(.fcpxXML, root: root, path: "cut.fcpxml")

See Reading and writing files, AAF and Bundles.

Media linkers and hooks

import OpenTimelineIO

// A cut of two clips, as an .otio file would hold it.
let 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. nil leaves
// the clip as it was.
try OTIO.registerMediaLinker("proxies") { clip, arguments in
    let name = try clip.name()
    let root = try arguments.getString("root")
    return try ExternalReference(name: name, targetURL: "\(root)/\(name).mov")
}

// A hook script is handed the whole result, and answers with what the read
// goes on with: here the same object, stamped.
try OTIO.registerHookScript("stamp") { target, arguments in
    let who = try arguments.getString("who")
    if let named = target as? SerializableObjectWithMetadata {
        try named.metadata.setString("read_by", value: who)
    }
    return target
}
try OTIO.attachHookScript("post_adapter_read", script: "stamp")

// The read names the linker, and carries both sets of arguments as JSON.
let track = try OTIO.readFromBytes(
    .otioJSON, data: Array(cut.utf8),
    options: ReadOptions(
        mediaLinker: "proxies",
        mediaLinkerArguments: #"{"root": "/proxies"}"#,
        hookArguments: #"{"who": "the conform"}"#))

if let named = track as? SerializableObjectWithMetadata {
    let who = try named.metadata.getString("read_by")
    print("read by", who)
}
for case let clip as Clip in try track.findClips() {
    if let media = try clip.mediaReference() as? ExternalReference {
        let name = try clip.name()
        let url = try media.targetURL()
        print(name, "->", url)
    }
}

// The registry is the whole process's, so a program that is done with its
// plugins says so.
_ = OTIO.detachHookScript("post_adapter_read", script: "stamp")
_ = OTIO.unregisterHookScript("stamp")
_ = OTIO.unregisterMediaLinker("proxies")

A media linker and a hook script are closures, registered under a name with OTIO.registerMediaLinker and OTIO.registerHookScript. A linker is a MediaLinker, handed a Clip and the arguments as Metadata, and answers a MediaReference?, where nil leaves the clip alone. A hook script is a HookScript, handed what the hook runs on and answering what to go on with. ReadOptions names the linker and carries both sets of arguments as JSON; attachHookScript puts a script at a hook, and runHook runs a hook of your own on an object.

What a closure throws, of any error type, fails the read, write or runHook with .pluginError and the error's description; it is caught where the library called in and never crosses into it. What a closure is handed is lent for the call only and fails if kept. While it runs, the timeline it was handed cannot be closed or moved into another one, since the call that lent it is still using it. The library keeps the closure until the name is registered again or unregistered, and may call it from whichever thread reads.

Platforms

CI builds and tests the package on Linux and macOS. Windows is left out: the package links the Rust static library through the platform's toolchain, which CI does not have set up for MSVC.

Reference