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/otioThe 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 ChromiumVersions 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 bydispose()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
crates/otio-wasm/README.md: how the module and the package are made, and why there is no wasm-bindgen.crates/otio-wasm/ts/README.md: the README npm shows.- The C ABI reference the package is generated from.