O
All pages

Go

A cgo package over libotio, with methods, embedded structs for the schema ladder, and an error for every failure.

The Go package is cgo over libotio, the C ABI built from the Rust core. 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. What things are called follows upstream OpenTimelineIO's own bindings; what the package is follows Go, with methods, slices, strings and an error rather than out-parameters and status codes.

import otio "github.com/alchemist-editor/otio-rust/sdk/go"

Install

The package links against a static libotio it expects to find in sdk/go/lib/. The library is not checked in, so build it from the Rust core and copy it there:

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

cd sdk/go
go test ./...

Each GitHub release also carries the Go 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

package main

import (
	"fmt"
	"log"

	otio "github.com/alchemist-editor/otio-rust/sdk/go"
)

func main() {
	// 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.
	//
	// Reading answers with what the file was about — the root object — and
	// there is no container to hold on to besides it.
	root, err := otio.ReadFromFile(otio.FormatCMX3600, "cut.edl", &otio.ReadOptions{Rate: 24})
	if err != nil {
		log.Fatal(err)
	}
	defer root.Close()

	clips, err := root.FindClips()
	if err != nil {
		log.Fatal(err)
	}
	for _, clip := range otio.Filter(clips, otio.Node.AsClip) {
		name, _ := clip.Name()
		fmt.Println(name)
	}
}

Reading answers with what the file was about, the root object, and there is no container around it to hold. otio.Open infers the format from the filename instead of taking one, and otio.Save writes back the same way. The rate in ReadOptions is the one thing an EDL cannot tell you; see CMX 3600 EDL.

otio.Filter narrows a list of nodes with one of the As methods, here keeping only the clips.

Building a timeline

package main

import (
	"fmt"
	"log"

	otio "github.com/alchemist-editor/otio-rust/sdk/go"
)

func main() {
	// An object is built on its own and put together with the others
	// afterwards. Nothing has to exist before the thing it goes into.
	timeline, err := otio.NewTimeline("Cut")
	if err != nil {
		log.Fatal(err)
	}
	// A timeline is released when it is collected, so this is not required.
	// It is worth doing anyway: it frees the whole thing at once, at a
	// moment you chose.
	defer timeline.Close()

	stack, err := otio.NewStack("tracks")
	if err != nil {
		log.Fatal(err)
	}
	track, err := otio.NewTrack("V1", "Video")
	if err != nil {
		log.Fatal(err)
	}
	if err := timeline.SetTracks(&stack.Node); err != nil {
		log.Fatal(err)
	}
	if err := stack.AppendChild(track.Node); err != nil {
		log.Fatal(err)
	}

	for index, name := range []string{"A", "B", "C"} {
		clip, err := otio.NewClip(name)
		if err != nil {
			log.Fatal(err)
		}
		span := otio.TimeRange{
			StartTime: otio.RationalTime{Value: float64(index) * 24, Rate: 24},
			Duration:  otio.RationalTime{Value: 24, Rate: 24},
		}
		if err := clip.SetSourceRange(span); err != nil {
			log.Fatal(err)
		}
		// Appending moves the clip into the timeline's arena. That is
		// bookkeeping this package does for you, not something to hold.
		if err := track.AppendChild(clip.Node); err != nil {
			log.Fatal(err)
		}
	}

	// Three seconds of picture, written as canonical OpenTimelineIO JSON.
	duration, _ := track.Duration()
	fmt.Println(duration.ToSeconds())
	if err := otio.Save(timeline.Node, "cut.otio"); err != nil {
		log.Fatal(err)
	}
}

Objects are built on their own with New functions and put together afterwards. The schemas are Go types that embed one another the way the schemas derive from one another, so a Clip has every method of Item, Composable and Node, and clip.Node is what a call taking any object wants. Ask a node what it really is with its As method:

if clip, ok := node.AsClip(); ok {
	reference, err := clip.MediaReference("")
}

How objects and documents work here

The core keeps its objects in arenas and an object is an index into one. This package does that bookkeeping, so there is no document to hold: a new object gets an arena of its own, and putting it into a timeline moves it there.

A timeline is released when it is collected, so Close is not required. It is worth calling anyway, because it frees a whole timeline at once and at a moment you chose. A handle into a timeline that has been closed goes stale rather than dangling, and reports StatusStaleHandle.

Appending or inserting an object that is still a child in another timeline is refused as the library refuses it, with StatusCoreError and the library's own message. Placing an object whose handle has gone stale is refused with StatusStaleHandle. Both refusals come before the object's timeline is brought over, so both timelines stay whole.

Errors and missing values

Failure is a Go error. Two sentinels are worth knowing.

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 error is ErrNoValue. It means the question was answered, not that something went wrong:

span, err := clip.SourceRange()
if errors.Is(err, otio.ErrNoValue) {
	// the clip is untrimmed
}

A call that only names an object rather than placing one refuses one from another timeline before the library is asked, with ErrOtherTimeline. Detaching a child that belongs to another timeline is a mistake, not an instruction to merge the two:

if err := track.DetachChild(fromSomewhereElse); errors.Is(err, otio.ErrOtherTimeline) {
	// it was never in this track
}

ErrOtherTimeline carries no Status, because the library was never asked. Asking an object for something it does not have fails rather than answering with a zero value: a clip asked for a track's kind returns an error saying so. An absent string is the empty string, since the core draws no distinction between an unset name and an empty one.

Editing and algorithms

package main

import (
	"fmt"
	"log"
	"strings"

	otio "github.com/alchemist-editor/otio-rust/sdk/go"
)

// second makes one second of picture, named.
func second(name string) otio.Clip {
	clip, err := otio.NewClip(name)
	if err != nil {
		log.Fatal(err)
	}
	if err := clip.SetSourceRange(otio.TimeRange{
		StartTime: otio.RationalTime{Value: 0, Rate: 24},
		Duration:  otio.RationalTime{Value: 24, Rate: 24},
	}); err != nil {
		log.Fatal(err)
	}
	return clip
}

func show(track otio.Track) {
	children, err := track.Children()
	if err != nil {
		log.Fatal(err)
	}
	names := make([]string, 0, len(children))
	for _, child := range children {
		name, _ := child.Name()
		names = append(names, name)
	}
	duration, _ := track.Duration()
	fmt.Printf("%s - %v frames\n", strings.Join(names, " "), duration.Value)
}

func main() {
	track, err := otio.NewTrack("V1", "Video")
	if err != nil {
		log.Fatal(err)
	}
	for _, name := range []string{"A", "B", "C"} {
		if err := track.AppendChild(second(name).Node); err != nil {
			log.Fatal(err)
		}
	}
	show(track)

	// Insert makes room: everything from the insertion point onwards moves
	// later, and the track gets longer.
	at := otio.RationalTime{Value: 24, Rate: 24}
	if err := otio.Insert(second("D").Node, track.Node, at, false, nil); err != nil {
		log.Fatal(err)
	}
	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.
	over := otio.TimeRange{
		StartTime: otio.RationalTime{Value: 48, Rate: 24},
		Duration:  otio.RationalTime{Value: 24, Rate: 24},
	}
	if err := otio.Overwrite(second("E").Node, track.Node, over, false, nil); err != nil {
		log.Fatal(err)
	}
	show(track)
}

The algorithms and the ten edit operations are functions of the package rather than methods, because each is about two objects and belongs to neither, which is how upstream arranges them too: otio.Insert, otio.Overwrite and the rest, beside otio.FlattenStack, otio.FlattenTracks and otio.TrackTrimmedToRange. Editing a timeline says what each edit does.

Formats

otio.ReadFromFile and otio.WriteToFile take a format: FormatOTIOJSON, FormatALE, FormatCMX3600, FormatFcp7XML, FormatFcpxXML, FormatAAF, and the two bundles, FormatOTIOZ and FormatOTIOD. A bundle is read and written through a path only.

package main

import (
	"fmt"
	"log"

	otio "github.com/alchemist-editor/otio-rust/sdk/go"
)

func main() {
	timeline, err := otio.NewTimeline("Cut")
	if err != nil {
		log.Fatal(err)
	}
	defer timeline.Close()

	tracks, err := timeline.Tracks()
	if err != nil {
		log.Fatal(err)
	}
	stack, _ := tracks.AsStack()
	track, err := otio.NewTrack("V1", "Video")
	if err != nil {
		log.Fatal(err)
	}
	if err := stack.AppendChild(track.Node); err != nil {
		log.Fatal(err)
	}

	// A cut of two clips: one whose media is a file beside the program, and
	// one whose media is on the web.
	for _, source := range []struct{ name, url string }{
		{"A001C003", "shot.mov"},
		{"A001C004", "https://example.com/remote.mov"},
	} {
		media, err := otio.NewExternalReference("", source.url)
		if err != nil {
			log.Fatal(err)
		}
		clip, err := otio.NewClip(source.name)
		if err != nil {
			log.Fatal(err)
		}
		if err := clip.SetMediaReference("DEFAULT_MEDIA", media.Node); err != nil {
			log.Fatal(err)
		}
		if err := clip.SetActiveMediaReferenceKey("DEFAULT_MEDIA"); err != nil {
			log.Fatal(err)
		}
		if err := track.AppendChild(clip.Node); err != nil {
			log.Fatal(err)
		}
	}

	// 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. FormatOTIOD writes the
	// same layout as a directory.
	options := &otio.WriteOptions{BundleMediaPolicy: otio.BundleMediaPolicyMissingIfNotFile}
	if err := otio.WriteToFile(otio.FormatOTIOZ, timeline.Node, "cut.otioz", options); err != nil {
		log.Fatal(err)
	}

	// Unpacked, with each reference made absolute, the media is ready to use.
	read := &otio.ReadOptions{BundleExtractPath: "cut", BundleAbsoluteMediaPaths: true}
	root, err := otio.ReadFromFile(otio.FormatOTIOZ, "cut.otioz", read)
	if err != nil {
		log.Fatal(err)
	}
	defer root.Close()

	clips, err := root.FindClips()
	if err != nil {
		log.Fatal(err)
	}
	for _, clip := range otio.Filter(clips, otio.Node.AsClip) {
		media, err := clip.MediaReference("")
		if err != nil {
			log.Fatal(err)
		}
		if external, ok := media.AsExternalReference(); ok {
			url, _ := external.TargetURL()
			fmt.Println(url)
		} else {
			fmt.Println("missing")
		}
	}
}

See Reading and writing files, AAF and Bundles.

Media linkers and hooks

A media linker is a func(clip otio.Clip, arguments otio.Metadata) (otio.Node, error) registered under a name with otio.RegisterMediaLinker; a read names it in ReadOptions.MediaLinker. Return the zero Node to leave a clip alone. A hook script is a func(target otio.Node, arguments otio.Metadata) (otio.Node, error) registered with otio.RegisterHookScript and attached to a hook with otio.AttachHookScript. The arguments arrive as metadata; a read carries them as JSON in MediaLinkerArguments and HookArguments.

A returned error, or a panic, fails the read with StatusPluginError and the function's own message. The objects a plugin is handed belong to the read in progress, so keep none of them past the call. See Media linkers and hooks.

Platforms

Linux and macOS, on whatever architectures the Rust core builds for. Windows is not tested: linking a Rust static library with MSVC needs an environment CI does not have set up.

Reference

  • sdk/go/README.md.
  • The package documentation: every method carries the C ABI's own documentation, rewritten for Go, and names the C function it calls.
  • The C ABI reference the package is generated from, and ADR 0003 for where it departs from upstream's shape.