O
All pages

TypeScript

The core compiled to WebAssembly and published to npm, for the browser and for Node, with no document to hold.

The TypeScript package, @alchemist-edit/otio, is the C ABI compiled to wasm32-unknown-unknown with a TypeScript object model on top. The module has no imports, so one package serves both the browser and Node with nothing native to install. The TypeScript is written by the same generator as the other SDKs, from the same description of the C ABI, and the class hierarchy and names are upstream OpenTimelineIO's, spelled the way TypeScript spells things: sourceRange where Python says source_range.

Install

npm install @alchemist-edit/otio

The package declares Node 20 or newer. Call init() once before anything else; calling it again does nothing. In Node it reads the module off disk. In a browser it fetches it from beside the JavaScript with new URL("otio_wasm.wasm", import.meta.url), which Vite, webpack, Rollup and esbuild all understand.

When the module is served from somewhere else, such as a CDN, pass its URL: init(url). When you already have the bytes or a compiled module, hand them to initAsync or initSync instead. The module itself is exported as @alchemist-edit/otio/wasm for bundlers that want to be told where it is.

To build it from this repository instead, which needs Rust and the wasm32-unknown-unknown target:

cd crates/otio-wasm/ts
npm install
npm run build        # cargo build --target wasm32-unknown-unknown, then tsc
npm test             # the suite, in Node
npm run test:browser # the same suite, in Chromium

Versions are published from an npm-v<version> tag with npm's trusted publishing, so each version on npm carries a provenance statement that npm audit signatures checks. A pre-release such as 0.2.0-rc.1 goes to the next dist-tag, so a plain npm install never picks one up.

Your first program

import { readFile } from "node:fs/promises";
import { init, readTimelineFromString } from "@alchemist-edit/otio";

await init();

// There is no filesystem behind a WebAssembly module, so getting hold of the
// bytes is the host's line of code: `fs` in Node, `fetch` in a browser.
const edl = await readFile("cut.edl", "utf8");

// 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.
const timeline = readTimelineFromString("cmx3600", edl, { rate: 24 });

for (const clip of timeline.findClips()) {
  console.log(clip.name);
}

There is no file system behind a WebAssembly module, so the adapters take and return strings or bytes, and getting hold of them is the host's line of code: fs in Node, fetch in a browser. readTimelineFromString is readFromString with a check that the file held a timeline, which most files do and most callers want. The rate is the one option an EDL cannot do without; see CMX 3600 EDL.

Building a timeline

import { writeFile } from "node:fs/promises";
import { init, Clip, RationalTime, Timeline, TimeRange, Track, serializeJsonToString } from "@alchemist-edit/otio";

await init();

// A fresh timeline arrives with an empty stack called `tracks`, as
// upstream's does, so there is nothing to build before appending to it.
const timeline = new Timeline({ name: "Cut" });
const track = new Track({ name: "V1", kind: "Video" });
timeline.tracks?.appendChild(track);

["A", "B", "C"].forEach((name, index) => {
  const clip = new Clip({ name });
  clip.sourceRange = new TimeRange(
    new RationalTime(index * 24, 24),
    new RationalTime(24, 24),
  );
  track.appendChild(clip);
});

// Three seconds of picture, written as canonical OpenTimelineIO JSON.
console.log(track.duration().toSeconds());
await writeFile("cut.otio", serializeJsonToString(timeline));

A new Timeline arrives with an empty stack called tracks, as upstream's does. Constructors take an object of named fields, and stored fields such as name and sourceRange are properties.

How objects and documents work here

The core keeps every object in a document's arena and names it with a handle. Upstream's API has no document in it, and neither does this one:

  • each object built on its own gets a document of its own;
  • putting one inside another moves it there, and the emptied document keeps a note saying where its contents went, so a wrapper handed out before the move goes on working;
  • reading the same object twice gives the same wrapper, so === means what a JavaScript programmer expects;
  • a document is released by a FinalizationRegistry, which is late but correct, or by dispose() for code that would rather say when. Everything that lived in it throws afterwards rather than reading memory that has been handed back.

An object that is still a child in another timeline is refused when it is appended or inserted, and one whose handle has gone stale is refused wherever it is placed, both before anything moves, so both timelines stay whole.

Errors and missing values

A call the library fails throws an OtioError, whose status says which kind of failure it was: "staleHandle" for an object used after it was removed, "parseError" for a file that was not what it claimed to be, "wrongKind" for a call made on the wrong kind of object or metadata value, "coreError" for a refusal from the core, and the rest of the C ABI's statuses spelled the same way.

A call that only names an object, such as hasChild, detachChild or flattenTracks, refuses one from another timeline before the library is asked, with an OtherTimelineError. It carries no status and is not an OtioError, so err instanceof OtherTimelineError is how to recognise it.

"There is nothing here" is undefined: the sourceRange of an untrimmed item, the parent() of an object that is in nothing.

One failure is particular to WebAssembly. wasm32-unknown-unknown has no unwinding, so a panic in the Rust core traps instead of being caught at the boundary. The module is then marked poisoned and every later call throws an OtioPanic; load a fresh one with init().

Editing and algorithms

import { init, Clip, edit, RationalTime, TimeRange, Track } from "@alchemist-edit/otio";

await init();

/** One second of picture, named. */
function second(name: string): Clip {
  const clip = new Clip({ name });
  clip.sourceRange = new TimeRange(
    new RationalTime(0, 24),
    new RationalTime(24, 24),
  );
  return clip;
}

function show(track: Track) {
  const names = track.children().map((child) => child.name).join(" ");
  console.log(names, "-", track.duration().value, "frames");
}

const track = new Track({ name: "V1", kind: "Video" });
for (const name of ["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.
edit.insert(second("D"), track, new RationalTime(24, 24), false);
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.
edit.overwrite(
  second("E"),
  track,
  new TimeRange(new RationalTime(48, 24), new RationalTime(24, 24)),
  false,
);
show(track);

The ten edit operations are functions on the exported edit object, such as edit.insert, edit.overwrite and edit.fill, and the algorithms are on algorithms: flattenStack, flattenTracks and trackTrimmedToRange. Editing a timeline says what each edit does.

Formats

readFromString, readFromBytes, writeToString and writeToBytes take a format by name: "otioJson", "ale", "cmx3600", "fcp7Xml", "fcpxXml" or "aaf". serializeJsonToString and deserializeJsonFromString are upstream's names for .otio JSON, and start wherever they are pointed, so a bare clip serialises as happily as a whole timeline.

EDL, ALE, both FCP XML flavours and AAF are Python-only plugins upstream, so a WebAssembly build of upstream's C++ core cannot read them. Here they are Rust and compile to wasm32 unchanged. AAF is the one that wanted something from the host: a written AAF records when it was made and gives itself random identifiers, and the module has neither a clock nor randomness, so writeToBytes passes the time and a fresh seed on every write.

import { writeFile } from "node:fs/promises";
import {
  init,
  Clip,
  ExternalReference,
  RationalTime,
  Timeline,
  TimeRange,
  Track,
  readFromBytes,
  writeToBytes,
} from "@alchemist-edit/otio";

await init();

const timeline = new Timeline({ name: "Cut" });
const track = new Track({ name: "V1", kind: "Video" });
timeline.tracks?.appendChild(track);

const oneSecond = new TimeRange(new RationalTime(0, 24), new RationalTime(24, 24));

// An AAF clip is cut from media of a known length, so each clip's media says
// how much of it there is. A new clip has no media at all, so its reference
// goes in under upstream's key and is made the active one.
for (const name of ["A001C003", "A001C004"]) {
  const media = new ExternalReference({ targetUrl: `file:///media/${name}.mov` });
  media.availableRange = oneSecond;

  const clip = new Clip({ name });
  clip.setMediaReference("DEFAULT_MEDIA", media);
  clip.activeMediaReferenceKey = "DEFAULT_MEDIA";
  clip.sourceRange = oneSecond;
  track.appendChild(clip);
}

// Every clip needs a MobID, from its metadata, its media's metadata or the
// AAF its media names. A cut built from scratch has none, so let the writer
// make them up rather than refuse the clip. The module has no clock or
// randomness of its own, so this side supplies the time and a seed.
const aaf = writeToBytes("aaf", timeline, { aafUseEmptyMobIds: true });
await writeFile("cut.aaf", aaf);

for (const clip of readFromBytes("aaf", aaf).findClips()) {
  console.log(clip.name);
}

The bundle formats, .otioz and .otiod, are not available here. A bundle is a directory, or an archive of media copied in from files on disk, and the module has no file system to find either on. Rather than offer a format that can only fail, the package leaves them out of its surface: write the timeline as .otio and bundle it where there is a file system. See Bundles and Reading and writing files.

Media linkers and hooks

import {
  init,
  attachHookScript,
  detachHookScript,
  ExternalReference,
  readFromString,
  registerHookScript,
  registerMediaLinker,
  unregisterHookScript,
  unregisterMediaLinker,
} from "@alchemist-edit/otio";

await init();

// A cut of two clips, as an .otio file would hold it.
const 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
// nothing leaves the clip as it was; throwing stops the read.
registerMediaLinker("proxies", (clip, args) =>
  new ExternalReference({ name: clip.name, targetUrl: `${args.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.
registerHookScript("stamp", (target, args) => {
  target.metadata.set("read_by", String(args.who));
  return target;
});
attachHookScript("post_adapter_read", "stamp");

try {
  // The read names the linker, and carries both sets of arguments as JSON.
  const track = readFromString("otioJson", cut, {
    mediaLinker: "proxies",
    mediaLinkerArguments: JSON.stringify({ root: "/proxies" }),
    hookArguments: JSON.stringify({ who: "the conform" }),
  });

  console.log("read by", track.metadata.get("read_by"));
  for (const clip of track.findClips()) {
    const media = clip.mediaReference();
    if (media instanceof ExternalReference) {
      console.log(clip.name, "->", media.targetUrl);
    }
  }
} finally {
  detachHookScript("post_adapter_read", "stamp");
  unregisterHookScript("stamp");
  unregisterMediaLinker("proxies");
}

registerMediaLinker(name, (clip, args) => ...) registers a media linker, as upstream's link_media_reference: it answers the media reference the clip should use, or nothing to leave the clip alone. A read runs it when its options' mediaLinker names it, unless doNotLinkMedia is set. registerHookScript(name, (target, args) => ...) registers a hook script, as upstream's hook_function: it answers the object to go on with, the one it was handed or another. attachHookScript puts it at a hook: the four every read and write runs, or one of your own that node.runHook(hook, args) runs. args is the JSON object the read, write or runHook was given, as a plain object.

A plugin's failure is a thrown error. It stops the read or write, which throws an OtioError with status "pluginError" and the plugin's own message. A plugin runs inside the call that needs it, so it cannot be async.

What a plugin is handed belongs to the read, write or hook that called it, and throws once the plugin returns, so keep values out of it rather than the objects themselves. What it answers may be built fresh, with new ExternalReference(...) and the like, and joins that timeline. Moving what it was handed into another timeline, or disposing of the timeline a hook is running on, is refused.

A WebAssembly module cannot be handed a JavaScript function, so the module imports one dispatcher from the package instead, and each registration is a number the package looks the function up by. The registry belongs to the module, so a fresh init after an OtioPanic starts with none registered.

Platforms

Browsers and Node. CI runs the same test suite in both, in Node and in Chromium, and npm run check:pack installs the packed tarball into an empty project and runs it there before a release.

Reference