diff --git a/README.md b/README.md
index 310a163a..49f510c8 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,37 @@ Notable features:
* [keyboard shortcuts](https://docs.asciinema.org/manual/player/shortcuts/),
* [support for other recording formats](https://docs.asciinema.org/manual/player/parsers/) like ttyrec, typescript.
+## Experimental: alternative terminal backends
+
+The player can hand terminal rendering to an external engine via the experimental
+`terminalBackend` option, while keeping its own recording parser, playback clock,
+controls, seeking, markers and theming. The default build imports and depends on
+nothing new — the hook is inert unless a backend is provided.
+
+```js
+AsciinemaPlayer.create("demo.cast", el, {
+ terminalBackend: async ({ element, size, appearance }) => {
+ // mount your terminal into `element`, sized to `size` ({ cols, rows })
+ return {
+ write(data) {}, // feed VT output; retain parser state
+ resize({ cols, rows }) {}, // apply the recording's dimensions
+ reset({ size, init, theme }) {}, // discard state, re-init (used by seek/loop)
+ update(appearance) {}, // theme, font, cursor, display settings
+ present() {}, // draw pending changes; coalesced per frame
+ dispose() {}, // release resources
+ };
+ },
+});
+```
+
+The factory may be async (playback waits for it) and receives an owned DOM
+`element`, the initial `size`, and an `appearance` (font, line height, cursor,
+resolved theme). Mutations are delivered synchronously and in stream order. See
+[`index.d.ts`](index.d.ts) for the full `TerminalBackend` / `TerminalBackendFactory`
+types, and [`examples/`](examples/) for working adapters (ghostty-web, wterm,
+xterm.js, rioterm) plus a comparison launcher. This is an experimental hook, not a
+stable plugin ABI.
+
## Building
To build the player, first download the source code:
diff --git a/examples/.gitignore b/examples/.gitignore
new file mode 100644
index 00000000..a5620a07
--- /dev/null
+++ b/examples/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+*/dist/
+unicode.cast
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 00000000..a71b1ef6
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,68 @@
+# asciinema-player examples — pluggable terminal backends
+
+These examples demonstrate the experimental `terminalBackend` option: the player
+keeps its recording parser, playback clock, controls and seeking, but hands VT
+output to an alternate terminal engine/renderer. One comparison launcher runs any
+mix of backends side by side against the built-in `avt` terminal.
+
+## Build and run (one go)
+
+```sh
+# from the repository root
+npm ci && npm run build # build the player itself (dist/)
+
+cd examples
+npm ci && npm run build # install all backend deps + build every adapter
+
+cd ..
+npx serve -l 8077 # any static server from the repo root works
+```
+
+The last step just serves the repo root over HTTP so both `/examples/` and
+`/dist/` are reachable — use whatever static server you have
+(`npx serve -l 8077`, etc.).
+
+Open the launcher:
+
+Pick backends with the checkboxes, or via the URL:
+
+
+## What's shared / extra / different
+
+- **Shared** — the player core (`../dist`): recording parser, clock, control bar,
+ seeking, theming. Unchanged; none of the backends are in the player bundle.
+- **Extra** — one small adapter per backend, `/-backend.js`, plus that
+ engine's npm package and its WASM/CSS, copied into `/dist/`.
+- **Different** — the VT engine and how it paints (Canvas vs DOM).
+
+## Backends
+
+| `?backends=` id | engine | lang | renderer | notes |
+|-------------------|-------------------|---------|----------|-------|
+| `default` | avt (built-in) | Rust | DOM/Canvas | no `terminalBackend` |
+| `ghostty-web` | libghostty | Zig/C | Canvas | |
+| `wterm` | wterm built-in | Zig | DOM | |
+| `wterm-ghostty` | libghostty | Zig/C | DOM | renders Kitty images |
+| `xterm` | xterm.js | TS | DOM | |
+| `rioterm` | librio (Rio) | Rust | DOM | |
+| `rioterm-canvas` | librio (Rio) | Rust | Canvas | viewport fit broken |
+
+Each backend has its own README (contract mapping + limitations) in its folder.
+
+## Layout
+
+```
+examples/
+ index.html comparison launcher (?backends=… + checkbox picker)
+ package.json all backend deps (one install)
+ build.mjs bundles every adapter into /dist/
+ gen-cast.mjs generates unicode.cast (the recording played)
+ /
+ -backend.js the adapter (the only hand-written code)
+ README.md contract mapping + limitations
+ dist/ built adapter + its WASM/CSS (generated)
+```
+
+The adapters are bundled into separate `/dist/` folders on purpose: two
+backends ship a file literally named `ghostty-vt.wasm` (ghostty-web's libghostty
+build and `@wterm/ghostty`'s), so keeping them in separate folders avoids a clash.
diff --git a/examples/build.mjs b/examples/build.mjs
new file mode 100644
index 00000000..dcec8114
--- /dev/null
+++ b/examples/build.mjs
@@ -0,0 +1,38 @@
+// Builds every terminal-backend example in one go. Each adapter is bundled into
+// its own /dist/ so same-named assets (two different ghostty-vt.wasm files)
+// never collide. Run: `npm ci && npm run build` from examples/.
+import { build } from "esbuild";
+import { execSync } from "node:child_process";
+import { copyFileSync, mkdirSync } from "node:fs";
+import { dirname } from "node:path";
+
+// The recording the launcher plays.
+execSync("node gen-cast.mjs", { stdio: "inherit" });
+
+// adapter entry -> bundled output (one esbuild per adapter, into its own dist/).
+const adapters = [
+ "ghostty-web/ghostty-web-backend.js",
+ "wterm/wterm-backend.js",
+ "xterm/xterm-backend.js",
+ "rioterm/rioterm-backend.js",
+];
+for (const entry of adapters) {
+ const dir = entry.slice(0, entry.indexOf("/"));
+ const file = entry.slice(entry.indexOf("/") + 1);
+ await build({ entryPoints: [entry], bundle: true, format: "esm", outfile: `${dir}/dist/${file}` });
+}
+
+// Non-inlined assets each adapter fetches at runtime (WASM served separately, CSS).
+const assets = [
+ ["node_modules/ghostty-web/ghostty-vt.wasm", "ghostty-web/dist/ghostty-vt.wasm"],
+ ["node_modules/@wterm/dom/src/terminal.css", "wterm/dist/wterm.css"],
+ ["node_modules/@wterm/ghostty/wasm/ghostty-vt.wasm", "wterm/dist/ghostty-vt.wasm"],
+ ["node_modules/@xterm/xterm/css/xterm.css", "xterm/dist/xterm.css"],
+ ["node_modules/rioterm/wasm/librio_wasm_bg.wasm", "rioterm/dist/librio_wasm_bg.wasm"],
+];
+for (const [src, dst] of assets) {
+ mkdirSync(dirname(dst), { recursive: true });
+ copyFileSync(src, dst);
+}
+
+console.log("examples built");
diff --git a/examples/gen-cast.mjs b/examples/gen-cast.mjs
new file mode 100644
index 00000000..a0c2f187
--- /dev/null
+++ b/examples/gen-cast.mjs
@@ -0,0 +1,149 @@
+// Generates unicode.cast for the backend examples. Shared by ghostty-web and
+// wterm so both play an identical recording. The recording ends with a Kitty
+// Graphics Protocol image; terminals without graphics support swallow the escape.
+//
+// node gen-cast.mjs # writes ./ghostty-web/unicode.cast and ./wterm/unicode.cast
+// node gen-cast.mjs # writes a single file
+import { deflateSync } from "node:zlib";
+import { writeFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+
+const crcTable = Array.from({ length: 256 }, (_, n) => {
+ let c = n;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ return c >>> 0;
+});
+function crc32(buf) {
+ let c = 0xffffffff;
+ for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+}
+
+function pngChunk(type, data) {
+ const len = Buffer.alloc(4);
+ len.writeUInt32BE(data.length);
+ const typed = Buffer.concat([Buffer.from(type, "ascii"), data]);
+ const crc = Buffer.alloc(4);
+ crc.writeUInt32BE(crc32(typed));
+ return Buffer.concat([len, typed, crc]);
+}
+
+function makePng(W = 64, H = 64) {
+ const raw = Buffer.alloc(H * (1 + W * 3));
+ let o = 0;
+ for (let y = 0; y < H; y++) {
+ raw[o++] = 0; // filter: none
+ for (let x = 0; x < W; x++) {
+ const d = Math.hypot(x - W / 2, y - H / 2);
+ let r, g, b;
+ if (d < 28) [r, g, b] = d < 22 ? [90, 230, 160] : [20, 30, 40];
+ else [r, g, b] = [(40 + (180 * x) / W) | 0, 60, (120 + (120 * y) / H) | 0];
+ raw[o++] = r; raw[o++] = g; raw[o++] = b;
+ }
+ }
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(W, 0); ihdr.writeUInt32BE(H, 4);
+ ihdr[8] = 8; ihdr[9] = 2; // 8-bit, color type 2 (RGB)
+ return Buffer.concat([
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ pngChunk("IHDR", ihdr),
+ pngChunk("IDAT", deflateSync(raw, { level: 9 })),
+ pngChunk("IEND", Buffer.alloc(0)),
+ ]);
+}
+
+// Kitty Graphics Protocol, base64 payload chunked at 4096 bytes.
+function kitty(b64, ctrl) {
+ const size = 4096;
+ const parts = [];
+ for (let i = 0; i < b64.length; i += size) parts.push(b64.slice(i, i + size));
+ return parts
+ .map((p, i) => {
+ const more = i < parts.length - 1 ? 1 : 0;
+ if (i === 0) {
+ const c = Object.entries({ ...ctrl, m: more }).map(([k, v]) => `${k}=${v}`).join(",");
+ return `\x1b_G${c};${p}\x1b\\`;
+ }
+ return `\x1b_Gm=${more};${p}\x1b\\`;
+ })
+ .join("");
+}
+
+// Sixel (DCS q ... ST): a paletted image. Rendered by xterm.js + addon-image.
+// Most backends swallow it; wterm's built-in Zig core prints the payload as text
+// (left as-is — it shows plainly where a protocol isn't supported).
+// `pixel(x,y)` returns a palette index; each 6-row band overlays one color at a time.
+function sixel(w, h, pixel, palette) {
+ const pc = (v) => Math.round((v / 255) * 100);
+ let s = "\x1bP0;1;0q";
+ palette.forEach((c, i) => { s += `#${i};2;${pc(c[0])};${pc(c[1])};${pc(c[2])}`; });
+ const bands = Math.ceil(h / 6);
+ for (let b = 0; b < bands; b++) {
+ for (let ci = 0; ci < palette.length; ci++) {
+ s += `#${ci}`;
+ for (let x = 0; x < w; x++) {
+ let mask = 0;
+ for (let r = 0; r < 6; r++) { const y = b * 6 + r; if (y < h && pixel(x, y) === ci) mask |= 1 << r; }
+ s += String.fromCharCode(63 + mask);
+ }
+ if (ci < palette.length - 1) s += "$"; // carriage return: overlay next color on this band
+ }
+ if (b < bands - 1) s += "-"; // next band
+ }
+ return s + "\x1b\\";
+}
+
+// iTerm2 inline image (OSC 1337): base64 of an image file (reuse the PNG).
+// `size` (decoded byte length) is required by xterm's addon-image, else it drops it.
+function iterm2(pngBase64, size) {
+ return `\x1b]1337;File=inline=1;size=${size};width=auto;height=auto:${pngBase64}\x07`;
+}
+
+const png = makePng();
+const pngB64 = png.toString("base64");
+const img = kitty(pngB64, { f: 100, a: "T", c: 14, r: 7 });
+// Same green-circle-on-dark motif as the PNG, so all three images look alike.
+const sixelImg = sixel(48, 48, (x, y) => (Math.hypot(x - 24, y - 24) < 22 ? 1 : 0), [[20, 30, 40], [90, 230, 160]]);
+const iipImg = iterm2(pngB64, png.length);
+
+const header = { version: 2, width: 66, height: 18 }; // landscape, ~16:9; scrolls like a real terminal
+const events = [
+ [0, "o", "\u001b[2J\u001b[HUnicode playback comparison\r\n\r\n"],
+ [0.2, "o", "Combining: cafe"],
+ [0.4, "o", "\u0301 a\u0308 o\u0302\r\n"],
+ [0.8, "o", "Skin tones: 👍 👍🏽 👍🏿\r\n"],
+ [1.2, "o", "Flags: 🇩🇰 🇯🇵 🇺🇳\r\n"],
+ [1.6, "o", "Joined: 👩"],
+ [1.7, "o", "🏽"],
+ [1.8, "o", "💻 👨👩👧👦\r\n"],
+ [2.2, "o", "CJK: 日本語 中文 한국어\r\n"],
+ [2.6, "o", "Presentation: ♥︎ ♥️ 1️⃣\r\n\r\n"],
+ [3.0, "o", "Kitty image (wterm libghostty core):\r\n"],
+ [3.4, "o", img],
+ [3.6, "o", "\r\n\r\nSixel image (xterm.js + addon-image):\r\n"],
+ [4.0, "o", sixelImg],
+ [4.2, "o", "\r\n\r\niTerm2 inline image (xterm.js + addon-image):\r\n"],
+ [4.6, "o", iipImg],
+ [4.8, "o", "\r\n\r\n"],
+ [5.2, "o", "\u001b[32mDone. Seek backwards and replay.\u001b[0m"],
+ [7, "o", ""],
+ // Input ("i") events drive the keystroke overlay. They are NOT sent to the
+ // terminal backends (those only receive output/reset/resize), so they don't
+ // affect rendering — enable keystrokeOverlay (or press 'k') to see them.
+ [0.5, "i", "l"], [0.6, "i", "s"], [0.9, "i", "\r"],
+ [1.9, "i", "\u0003"], // Ctrl-C
+ [2.9, "i", "\u001b[A"], [3.1, "i", "\u001b[B"], // up / down
+ [4.1, "i", "v"], [4.2, "i", "i"], [4.3, "i", "m"], [4.5, "i", "\r"],
+ [5.0, "i", "\u001b"], // Esc
+ ].sort((a, b) => a[0] - b[0]);
+const cast = [header, ...events].map((line) => JSON.stringify(line)).join("\n") + "\n";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const targets = process.argv[2]
+ ? [resolve(process.argv[2])]
+ : [resolve(here, "unicode.cast")]; // used by the comparison launcher index.html
+for (const t of targets) {
+ writeFileSync(t, cast);
+ console.log("wrote", t);
+}
diff --git a/examples/ghostty-web/README.md b/examples/ghostty-web/README.md
new file mode 100644
index 00000000..a2841b2d
--- /dev/null
+++ b/examples/ghostty-web/README.md
@@ -0,0 +1,82 @@
+# Optional Ghostty terminal — experimental proof of concept
+
+This example keeps asciinema's recording parser, playback clock and controls,
+but opts into ghostty-web's WASM terminal and Canvas renderer. The default
+player does not import or depend on ghostty-web. The example pins version 0.4.0.
+
+From the repository root:
+
+```sh
+npm ci # player deps
+npm run build # build the player (dist/)
+cd examples
+npm ci # all backend example deps (one install)
+npm run build # build every backend adapter
+cd ..
+npx serve -l 8077 # or any static server from the repo root
+```
+
+Open the comparison launcher at
+http://localhost:8077/examples/?backends=default,ghostty-web and select
+**Play all** (or tick/untick backends in the picker).
+The two players read the same local `unicode.cast`, including clusters split
+across output events. No shell, PTY, external service or live process is needed.
+
+## Experimental backend hook
+
+```js
+import { create } from "asciinema-player";
+import { ghosttyBackend } from "./dist/backend.js";
+
+create("demo.cast", container, { terminalBackend: ghosttyBackend() });
+```
+
+`terminalBackend(context)` receives an owned DOM element, initial dimensions
+and appearance. It can return a promise; playback waits until it is ready.
+The factory must clean up resources itself if it rejects. If it resolves after
+the player is disposed, its returned backend is disposed immediately.
+
+The returned object implements:
+
+* `write(string)`: feed unmodified VT output; retain parser state between calls.
+* `resize({cols, rows})`: apply the recording's terminal dimensions.
+* `reset({size, init, theme})`: discard all VT state, restore dimensions and feed
+ the optional initialization string. This is essential for seek and loop.
+* `update(appearance)`: apply resolved theme, font, cursor and display settings.
+* `present()`: render pending changes; may return a promise. Calls are coalesced
+ and never overlap, but stream mutations may arrive during an async present.
+* `dispose()`: release listeners, timers, DOM and emulator resources.
+
+Mutations must be synchronous. Playback already batches output during seeks;
+the adapter receives each string in order, with resizes interleaved correctly.
+There is no per-character or per-escape-sequence callback. The player controls
+the outer viewport using its existing cell metrics; a backend must fit that
+viewport. The factory runs in the UI thread even with the split worker build.
+
+The option is experimental, not a stable plugin ABI. The current `seek()`
+promise does not promise completion of painting (the default renderer also
+paints on the next animation frame). A public export/flush API is outside this
+proof of concept.
+
+## Known limitations
+
+* **One instance per page.** ghostty-web 0.4.0 crashes with "memory access out of
+ bounds" when several players initialize concurrently on the same page. The WASM
+ module is loaded once and shared, but the library still can't run multiple
+ terminals reliably. For multi-player pages use the `wterm`, `xterm` or `rioterm`
+ backends instead. (The comparison launcher only shows a single ghostty-web panel.)
+* This uses Ghostty's VT plus **browser Canvas text rendering**, not Ghostty's
+ native font renderer. Fonts and browser shaping affect results; cross-cell
+ shaping and universal Unicode correctness are not claimed.
+* Canvas is fitted into asciinema's existing geometry. This can stretch glyphs
+ when the two renderers disagree on cell metrics or line height.
+* The adapter uses exposed renderer/WASM objects to force presentation because
+ ghostty-web 0.4.0 has no public flush method. This belongs in this pinned
+ example, not in the player contract.
+* Adaptive palettes, bold-as-bright, hidden cursor and full graphics-protocol
+ compatibility are not yet implemented by this example.
+* This is an opt-in comparison, not a proposed replacement for avt, and makes
+ no performance claim. GIF export is not included.
+
+The lifecycle tests in `tests/terminal-backend.spec.js` do not need this example
+installed.
diff --git a/examples/ghostty-web/ghostty-web-backend.js b/examples/ghostty-web/ghostty-web-backend.js
new file mode 100644
index 00000000..ea3e34df
--- /dev/null
+++ b/examples/ghostty-web/ghostty-web-backend.js
@@ -0,0 +1,79 @@
+import { Ghostty, Terminal } from "ghostty-web";
+
+const paletteNames = [
+ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
+ "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue",
+ "brightMagenta", "brightCyan", "brightWhite",
+];
+
+// Kept outside the player bundle: this is a pinned, independently replaceable PoC.
+// The ghostty-web WASM module is loaded once and shared across all terminals
+// (each `new Terminal({ghostty})` is a separate terminal). Loading it per player
+// crashes with "memory access out of bounds" when several init concurrently.
+let loaded;
+
+export function ghosttyBackend({ wasmUrl = new URL("./ghostty-vt.wasm", import.meta.url).href } = {}) {
+ return async ({ element, size, appearance }) => {
+ const ghostty = await (loaded ??= Ghostty.load(wasmUrl));
+ await document.fonts.ready;
+ let current = appearance;
+ let terminal;
+ function theme() {
+ const result = { foreground: current.theme.foreground, background: current.theme.background };
+ paletteNames.forEach((name, i) => { result[name] = current.theme.palette[i]; });
+ return result;
+ }
+ function fitCanvas() {
+ // The player owns viewport width. Match it, but scale height uniformly
+ // (height:auto) instead of stretching to the box's taller line-height,
+ // so glyphs keep ghostty's natural aspect instead of being distorted.
+ const canvas = terminal.renderer.getCanvas();
+ canvas.style.width = "100%";
+ canvas.style.height = "auto";
+ canvas.style.display = "block";
+ }
+ function mount(dimensions) {
+ terminal = new Terminal({
+ ghostty, ...dimensions, disableStdin: true, scrollback: 0,
+ fontFamily: current.fontFamily, fontSize: current.fontSize, theme: theme(),
+ cursorBlink: current.cursorMode === "blinking" && current.blinking,
+ });
+ const focused = document.activeElement;
+ terminal.open(element);
+ terminal.blur();
+ focused?.focus?.();
+ Object.assign(terminal.element.style, { position: "absolute", inset: "0", width: "100%", height: "100%" });
+ fitCanvas();
+ }
+ function update(next) {
+ current = next;
+ if (terminal.options.fontFamily !== next.fontFamily) terminal.options.fontFamily = next.fontFamily;
+ if (terminal.options.fontSize !== next.fontSize) terminal.options.fontSize = next.fontSize;
+ terminal.options.cursorBlink = next.cursorMode === "blinking" && next.blinking;
+ fitCanvas();
+ }
+
+ mount(size);
+ update(appearance);
+ return {
+ // 0.4.0 attempts a zero-length WASM allocation for empty writes.
+ write(data) { if (data.length) terminal.write(data); },
+ resize({ cols, rows }) { terminal.resize(cols, rows); fitCanvas(); },
+ reset({ size, init }) {
+ // Recreate to reset all state and apply embedded default colors: runtime
+ // theme changes are not fully supported by ghostty-web 0.4.0.
+ terminal.dispose();
+ mount(size);
+ if (init) terminal.write(init);
+ },
+ update,
+ present() {
+ // ghostty-web 0.4 exposes these objects but has no public flush method.
+ // Keeping this version-specific bridge in the example avoids baking it
+ // into asciinema's API. No screenshot/rAF timing guess is necessary.
+ terminal.renderer.render(terminal.wasmTerm, true, 0, terminal);
+ },
+ dispose() { terminal.dispose(); },
+ };
+ };
+}
diff --git a/examples/index.html b/examples/index.html
new file mode 100644
index 00000000..a4f98f25
--- /dev/null
+++ b/examples/index.html
@@ -0,0 +1,155 @@
+
+
+
+
+asciinema-player — terminal backends
+
+
+
+
asciinema-player — terminal backends
+
Same recording and player; only the terminalBackend differs. See the
+README.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/package-lock.json b/examples/package-lock.json
new file mode 100644
index 00000000..be76610f
--- /dev/null
+++ b/examples/package-lock.json
@@ -0,0 +1,552 @@
+{
+ "name": "examples",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "@wterm/dom": "0.5.4",
+ "@wterm/ghostty": "0.5.4",
+ "@xterm/addon-image": "0.9.0",
+ "@xterm/xterm": "5.5.0",
+ "ghostty-web": "0.4.0",
+ "rioterm": "0.1.8"
+ },
+ "devDependencies": {
+ "esbuild": "0.25.12"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@wterm/core": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/@wterm/core/-/core-0.5.4.tgz",
+ "integrity": "sha512-yIhbn27ELHDjEDtduDIycHWEEaobJGhqR+WqKgCoSPYReCIrR+skJS67PBk3Wp6+yLImfQ3Fv1CJ4hLXKLa7Fg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@wterm/dom": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/@wterm/dom/-/dom-0.5.4.tgz",
+ "integrity": "sha512-9zxZq52BFpBtVEkBEbZF5a+9dX+eIADQdlTRq6YgGxHP/G/aDXyRja+tebQtAYqx9KY8qJVm20vNOhQxwBe/FQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@wterm/core": "0.5.4"
+ }
+ },
+ "node_modules/@wterm/ghostty": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/@wterm/ghostty/-/ghostty-0.5.4.tgz",
+ "integrity": "sha512-W6wlGhHiGf/2tF/uZo8qHtaYqA3Ebf+6VhdPidiWuqEU37Kj+YSkuXCXP/qORSD0roec/5Z16qYRyF5HsC9XJg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@wterm/core": "0.5.4"
+ }
+ },
+ "node_modules/@xterm/addon-image": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0.tgz",
+ "integrity": "sha512-oYWA8/QAr5/Emwl1xL7WCoOqeG3IZfpzEz/OVq1j4Oi9934TQmHiyubClikRf0D/jL3JNiNuz/Lsqx0kXQ02BA==",
+ "license": "MIT"
+ },
+ "node_modules/@xterm/xterm": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
+ "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/ghostty-web": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/ghostty-web/-/ghostty-web-0.4.0.tgz",
+ "integrity": "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg==",
+ "license": "MIT"
+ },
+ "node_modules/rioterm": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/rioterm/-/rioterm-0.1.8.tgz",
+ "integrity": "sha512-JIy+x8Y1qt4mUm9KavsWeF2XtIEr0xBSiJTzx3bI0Oiv6ZK1W/+S+3GEcYHiOz4aMNx9C6niH5BesqiQ4RYbew==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/examples/package.json b/examples/package.json
new file mode 100644
index 00000000..ea3b8f1e
--- /dev/null
+++ b/examples/package.json
@@ -0,0 +1,18 @@
+{
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "node build.mjs"
+ },
+ "dependencies": {
+ "ghostty-web": "0.4.0",
+ "@wterm/dom": "0.5.4",
+ "@wterm/ghostty": "0.5.4",
+ "@xterm/xterm": "5.5.0",
+ "@xterm/addon-image": "0.9.0",
+ "rioterm": "0.1.8"
+ },
+ "devDependencies": {
+ "esbuild": "0.25.12"
+ }
+}
diff --git a/examples/rioterm/README.md b/examples/rioterm/README.md
new file mode 100644
index 00000000..68c0ad4f
--- /dev/null
+++ b/examples/rioterm/README.md
@@ -0,0 +1,81 @@
+# Optional rioterm terminal — experimental proof of concept
+
+A fourth reference backend for asciinema-player's experimental `terminalBackend`
+hook, alongside `../ghostty-web`, `../wterm` and `../xterm`. rioterm is
+[Rio](https://github.com/raphamorim/rio)'s Rust terminal core (**librio**)
+compiled to WASM — a different engine again — and it ships two interchangeable
+renderers (canvas and DOM). This example uses the **DOM renderer**. The default
+player does not import or depend on rioterm. It pins `rioterm` 0.1.8.
+
+**Canvas renderer status:** `riotermBackend({ renderer: "canvas" })` works and
+renders, but fitting/centering it into asciinema's viewport is **currently
+broken** and not shown in the comparison. rioterm owns the canvas element's
+size/position and only repaints on write, so scaling it to the player's box
+either leaves it off-center or clears it to black once playback pauses. A proper
+fix needs rioterm to expose a re-render/refresh hook. The DOM renderer has none
+of these issues.
+
+From the repository root:
+
+```sh
+npm ci # player deps
+npm run build # build the player (dist/)
+cd examples
+npm ci # all backend example deps (one install)
+npm run build # build every backend adapter
+cd ..
+npx serve -l 8077 # or any static server from the repo root
+```
+
+Open the comparison launcher at
+http://localhost:8077/examples/?backends=default,rioterm and select **Play all**.
+The players read the same local `unicode.cast`: the default terminal and
+rioterm's DOM renderer. (The canvas renderer is available as
+`?backends=rioterm-canvas` but its viewport fit is broken; see below.)
+
+## The adapter
+
+```js
+import { create } from "asciinema-player";
+import { riotermBackend } from "./dist/backend.js";
+
+// DOM renderer (default)
+create("demo.cast", container, { terminalBackend: riotermBackend() });
+// canvas renderer (works, but viewport fit/centering is currently broken)
+create("demo.cast", other, { terminalBackend: riotermBackend({ renderer: "canvas" }) });
+```
+
+See `../ghostty-web/README.md` for the full `terminalBackend` contract. It maps
+onto rioterm as:
+
+* `write` → `Terminal.write`.
+* `resize` → `Terminal.resize` (`fit: false`; the player owns cols/rows).
+* `reset` → `Terminal.write("\x1bc")` (RIS discards screen, scrollback and
+ attributes — rioterm has no `reset()` method) then `resize` and the init string.
+* `update` → re-fit; theme and font are set at `open()`.
+* `present` → no-op; the renderer repaints snapshots on its own animation frame.
+* `dispose` → the `open()` handle's `dispose`.
+
+librio's WASM is served separately (resolved via `import.meta.url`), so the build
+copies it to `dist/librio_wasm_bg.wasm` and the adapter points `initWasm` at it.
+
+## Geometry
+
+Like xterm.js, rioterm measures its own cell metrics, so its grid rarely matches
+asciinema's viewport exactly. The adapter uniformly scales the renderer's mount
+element to fit the player's box (centered, no distortion, no clipping).
+
+## Known limitations
+
+* Pinned to `rioterm` 0.1.8 (early release); this is an experimental hook, not a
+ stable ABI.
+* Theme and font are applied at `open()`; runtime theme/font changes are not
+ wired up (the adapter only re-fits the scale).
+* Adaptive palettes, bold-as-bright, blinking and hidden cursor are not wired up.
+* No terminal graphics protocol.
+* Like the other examples, `seek()` does not guarantee paint completion.
+* This is an opt-in comparison, not a proposed replacement for avt. No
+ performance claim; GIF export is not included.
+
+The lifecycle tests in `tests/terminal-backend.spec.js` do not need this example
+installed.
diff --git a/examples/rioterm/rioterm-backend.js b/examples/rioterm/rioterm-backend.js
new file mode 100644
index 00000000..5e9a3510
--- /dev/null
+++ b/examples/rioterm/rioterm-backend.js
@@ -0,0 +1,86 @@
+import { open, initWasm, defaultTheme } from "rioterm";
+
+// Fourth reference backend for asciinema-player's experimental `terminalBackend`
+// hook. rioterm is Rio's Rust terminal core (librio) compiled to WASM, with a
+// canvas or DOM renderer selectable per instance. Kept outside the player
+// bundle: a pinned, replaceable PoC.
+//
+// The example uses the DOM renderer. The canvas renderer also works, but fitting
+// it into asciinema's viewport is unresolved: rioterm owns the canvas element's
+// size/position and only repaints on write, so our scaling either leaves it
+// off-center or (if we restyle the canvas) clears it to black once paused. Until
+// rioterm exposes a re-render/refresh hook, canvas is left as `renderer: "canvas"`
+// for the curious but is not shown in the comparison.
+
+const paletteNames = [
+ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
+ "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue",
+ "brightMagenta", "brightCyan", "brightWhite",
+];
+
+function themeOf(appearance) {
+ const t = appearance.theme;
+ const theme = {
+ ...defaultTheme,
+ foreground: t.foreground,
+ background: t.background,
+ cursor: t.foreground,
+ selectionForeground: t.background,
+ selectionBackground: t.foreground,
+ };
+ paletteNames.forEach((name, i) => { theme[name] = t.palette[i]; });
+ return theme;
+}
+
+let wasmReady;
+
+export function riotermBackend({ renderer = "dom" } = {}) {
+ return async ({ element, size, appearance }) => {
+ // librio's wasm is served separately (resolved via import.meta.url); point
+ // initWasm at the copy in dist/ and load it once.
+ await (wasmReady ??= initWasm(new URL("./librio_wasm_bg.wasm", import.meta.url)));
+ const handle = await open(element, {
+ renderer,
+ fit: false, // the player owns cols/rows
+ autoFocus: false,
+ cols: size.cols,
+ rows: size.rows,
+ fontFamily: appearance.fontFamily,
+ fontSize: appearance.fontSize,
+ lineHeight: appearance.lineHeight,
+ theme: themeOf(appearance),
+ cursorStyle: "block",
+ });
+ const term = handle.terminal;
+ const root = handle.renderer.element;
+ element.style.display = "grid";
+
+ // rioterm measures its own cell metrics, so its grid rarely matches
+ // asciinema's viewport. Uniformly scale (transform only) to fit. The DOM
+ // renderer's mount is a zero-height container, so this no-ops for it and the
+ // rows sit at natural size; that renders cleanly, which is what the example uses.
+ function fit() {
+ if (!root.offsetWidth || !root.offsetHeight) return;
+ root.style.transform = "";
+ const k = Math.min(element.clientWidth / root.offsetWidth, element.clientHeight / root.offsetHeight);
+ root.style.transformOrigin = "center";
+ root.style.transform = `scale(${k})`;
+ }
+ const refit = () => requestAnimationFrame(fit);
+ fit();
+ return {
+ write(data) { term.write(data); refit(); },
+ resize({ cols, rows }) { term.resize(cols, rows); refit(); },
+ reset({ size, init }) {
+ term.write("\x1bc"); // RIS: librio discards screen, scrollback, attributes
+ term.resize(size.cols, size.rows);
+ if (init) term.write(init);
+ refit();
+ },
+ update() { fit(); }, // theme/font are set at open(); scale tracks the box
+ // The renderer repaints snapshots on its own animation frame after write().
+ present() {},
+ dispose() { handle.dispose(); },
+ };
+ };
+}
diff --git a/examples/wterm/README.md b/examples/wterm/README.md
new file mode 100644
index 00000000..578ed78b
--- /dev/null
+++ b/examples/wterm/README.md
@@ -0,0 +1,91 @@
+# Optional wterm terminal — experimental proof of concept
+
+A second reference backend for asciinema-player's experimental `terminalBackend`
+hook, alongside `../ghostty-web`. It exists to show the hook is renderer-agnostic
+and even core-agnostic:
+
+* **ghostty-web** renders to a **Canvas** and is fitted into asciinema's geometry.
+* **wterm** renders to the **DOM**. Because wterm lays out `1ch` cells and an
+ em-based row height, it matches asciinema's own cell metrics directly — no
+ canvas fitting, no glyph stretching.
+
+wterm has two interchangeable VT cores, and this example drives both through one
+adapter (`wtermBackend`):
+
+* **built-in Zig core** — lightweight VT100/VT220/xterm parser compiled to a
+ ~26 KB WASM core, embedded in `@wterm/dom`. Used by `wtermBackend()`.
+* **libghostty core** — full VT emulation from `@wterm/ghostty`. Used by
+ `wtermBackend({ ghostty: true })`.
+
+The default player does not import or depend on wterm. This example pins
+`@wterm/dom` and `@wterm/ghostty`. The Zig core's WASM is inlined into
+`dist/backend.js`; the libghostty WASM is served separately as
+`dist/ghostty-vt.wasm`.
+
+From the repository root:
+
+```sh
+npm ci # player deps
+npm run build # build the player (dist/)
+cd examples
+npm ci # all backend example deps (one install)
+npm run build # build every backend adapter
+cd ..
+npx serve -l 8077 # or any static server from the repo root
+```
+
+Open the comparison launcher at
+http://localhost:8077/examples/?backends=default,wterm,wterm-ghostty and select
+**Play all**. The players read the same local `unicode.cast`: the default
+terminal and wterm with each core.
+
+The recording ends with a **Kitty Graphics Protocol** image (a direct PNG sent as
+a chunked `APC _G` sequence). It renders only in the **libghostty** panel — the
+libghostty core implements the graphics protocol, while the built-in Zig core and
+the default asciinema terminal have no image support and silently swallow the
+escape, so no base64 text leaks into those panels.
+
+## The adapter
+
+```js
+import { create } from "asciinema-player";
+import { wtermBackend } from "./dist/backend.js";
+
+// built-in Zig core
+create("demo.cast", container, { terminalBackend: wtermBackend() });
+// libghostty core
+create("demo.cast", other, { terminalBackend: wtermBackend({ ghostty: true }) });
+```
+
+See `../ghostty-web/README.md` for the full `terminalBackend` contract. This
+adapter maps it onto wterm as follows:
+
+* `write` → `WTerm.write`.
+* `resize` → `WTerm.resize` (`autoResize: false`; the player owns dimensions).
+* `reset` → `WTerm.write("\x1bc")` (RIS discards screen, scrollback and
+ attributes) then `resize` and the optional init string. RIS is synchronous and
+ preserves stream order, so no terminal recreation is needed for seek/loop.
+* `update` → theme, font and cursor via wterm's CSS custom properties
+ (`--term-fg`, `--term-bg`, `--term-color-0..15`, `--term-font-*`,
+ `--term-line-height`, `--term-row-height`) plus `setThemeColors` so the core's
+ default/inverse colors stay in sync.
+* `present` → no-op; wterm repaints dirty rows on its own animation frame.
+* `dispose` → `WTerm.destroy`.
+
+The host element's 12px wterm padding is zeroed because the player already owns
+the viewport box.
+
+## Known limitations
+
+* Pinned to `@wterm/dom` / `@wterm/ghostty` 0.5.x; this is an experimental hook,
+ not a stable ABI.
+* Adaptive palettes, bold-as-bright and hidden-cursor are not wired up here.
+* Kitty graphics: only the libghostty core, and only direct PNG/RGB/RGBA (no
+ Sixel, iTerm2/OSC 1337, or animation). The image is a transient overlay.
+* Like the ghostty example, `seek()` does not guarantee paint completion; the
+ default renderer also paints on the next animation frame.
+* This is an opt-in comparison, not a proposed replacement for avt. No
+ performance claim; GIF export is not included.
+
+The lifecycle tests in `tests/terminal-backend.spec.js` do not need this example
+installed.
diff --git a/examples/wterm/wterm-backend.js b/examples/wterm/wterm-backend.js
new file mode 100644
index 00000000..08d81918
--- /dev/null
+++ b/examples/wterm/wterm-backend.js
@@ -0,0 +1,76 @@
+import { WTerm } from "@wterm/dom";
+import { GhosttyCore } from "@wterm/ghostty";
+
+// Second reference backend for asciinema-player's experimental `terminalBackend`
+// hook. Where the ghostty-web example renders to a Canvas, wterm renders to the
+// DOM, driven by CSS custom properties. Because wterm lays out 1ch cells and an
+// em-based row height, it matches asciinema's own cell geometry exactly with no
+// canvas fitting. Kept outside the player bundle: a pinned, replaceable PoC.
+//
+// wterm has two interchangeable VT cores. `wtermBackend()` uses the built-in
+// lightweight Zig core (embedded WASM); `wtermBackend({ ghostty: true })` uses
+// the libghostty core from @wterm/ghostty (its WASM is copied into dist/).
+
+const hex = (s) => parseInt(String(s).replace("#", ""), 16) || 0;
+
+function applyTheme(element, term, appearance) {
+ const t = appearance.theme;
+ const vars = {
+ "--term-font-family": appearance.fontFamily,
+ "--term-font-size": `${appearance.fontSize}px`,
+ "--term-line-height": `${appearance.lineHeight}`,
+ // Match asciinema's row pitch so the DOM grid fills the player's viewport.
+ "--term-row-height": `${appearance.lineHeight * appearance.fontSize}px`,
+ "--term-fg": t.foreground,
+ "--term-bg": t.background,
+ "--term-cursor": t.foreground,
+ };
+ t.palette.forEach((c, i) => { vars[`--term-color-${i}`] = c; });
+ for (const [k, v] of Object.entries(vars)) element.style.setProperty(k, v);
+ // Keep the core's notion of default colors in sync (inverse/default cells).
+ term?.setThemeColors({
+ foreground: hex(t.foreground),
+ background: hex(t.background),
+ cursor: hex(t.foreground),
+ palette: t.palette.map(hex),
+ });
+}
+
+export function wtermBackend({ ghostty = false } = {}) {
+ return async ({ element, size, appearance }) => {
+ applyTheme(element, null, appearance);
+ // Each terminal needs its OWN libghostty core (a GhosttyCore is a terminal with
+ // its own VT state, not just the WASM module). Sharing one across players mixes
+ // their content. The WASM module itself is cached internally, so per-instance
+ // load() is cheap. Its WASM is served separately (not inlined).
+ const core = ghostty
+ ? await GhosttyCore.load({
+ wasmPath: new URL("./ghostty-vt.wasm", import.meta.url).href,
+ })
+ : undefined;
+ const term = new WTerm(element, {
+ core,
+ cols: size.cols,
+ rows: size.rows,
+ autoResize: false,
+ cursorBlink: appearance.cursorMode === "blinking" && appearance.blinking,
+ onData: () => {}, // playback only; drop any terminal replies
+ });
+ await term.init();
+ element.style.padding = "0"; // the player already owns the viewport box
+ applyTheme(element, term, appearance);
+ return {
+ write(data) { if (data.length) term.write(data); },
+ resize({ cols, rows }) { term.resize(cols, rows); },
+ reset({ size, init }) {
+ term.write("\x1bc"); // RIS: discard screen, scrollback and attributes
+ term.resize(size.cols, size.rows);
+ if (init) term.write(init);
+ },
+ update(next) { applyTheme(element, term, next); },
+ // DOM renderer repaints dirty rows on its own animation frame after write().
+ present() {},
+ dispose() { term.destroy(); },
+ };
+ };
+}
diff --git a/examples/xterm/README.md b/examples/xterm/README.md
new file mode 100644
index 00000000..b06c390e
--- /dev/null
+++ b/examples/xterm/README.md
@@ -0,0 +1,66 @@
+# Optional xterm.js terminal — experimental proof of concept
+
+A third reference backend for asciinema-player's experimental `terminalBackend`
+hook, alongside `../ghostty-web` and `../wterm`. xterm.js is the de-facto web
+terminal (VS Code, Hyper, Theia, …), so hooking it in shows the hook is engine-
+agnostic. The default player does not import or depend on xterm.js. This example
+pins `@xterm/xterm` 5.5.0.
+
+From the repository root:
+
+```sh
+npm ci # player deps
+npm run build # build the player (dist/)
+cd examples
+npm ci # all backend example deps (one install)
+npm run build # build every backend adapter
+cd ..
+npx serve -l 8077 # or any static server from the repo root
+```
+
+Open the comparison launcher at
+http://localhost:8077/examples/?backends=default,xterm and select **Play all**
+(or tick/untick backends in the picker).
+
+## The adapter
+
+```js
+import { create } from "asciinema-player";
+import { xtermBackend } from "./dist/backend.js";
+
+create("demo.cast", container, { terminalBackend: xtermBackend() });
+```
+
+See `../ghostty-web/README.md` for the full `terminalBackend` contract. It maps
+onto xterm.js as:
+
+* `write` → `Terminal.write`.
+* `resize` → `Terminal.resize` (`disableStdin: true`, `scrollback: 0`; the
+ player owns dimensions).
+* `reset` → `Terminal.reset()` (clears screen, scrollback and attributes) then
+ `resize` and the optional init string.
+* `update` → `Terminal.options.{theme,fontFamily,fontSize,lineHeight,cursorBlink}`.
+* `present` → no-op; xterm renders on its own animation frame after `write()`.
+* `dispose` → `Terminal.dispose`.
+
+## Geometry
+
+xterm.js measures its own cell metrics from the font, so its grid rarely matches
+asciinema's viewport exactly. The adapter uniformly scales the `.xterm` element
+to fit the player's box (centered, no glyph distortion, no clipping) rather than
+letting taller rows overflow. Because the scale is uniform, xterm may letterbox
+slightly inside the box; this is intentional over distortion or per-font
+line-height tuning.
+
+## Known limitations
+
+* Pinned to `@xterm/xterm` 5.5.0; this is an experimental hook, not a stable ABI.
+* Adaptive palettes, bold-as-bright and hidden-cursor are not wired up here.
+* No terminal graphics. xterm.js has no Kitty support; its optional
+ `@xterm/addon-image` adds Sixel / iTerm inline images, not wired up here.
+* Like the other examples, `seek()` does not guarantee paint completion.
+* This is an opt-in comparison, not a proposed replacement for avt. No
+ performance claim; GIF export is not included.
+
+The lifecycle tests in `tests/terminal-backend.spec.js` do not need this example
+installed.
diff --git a/examples/xterm/xterm-backend.js b/examples/xterm/xterm-backend.js
new file mode 100644
index 00000000..160193b6
--- /dev/null
+++ b/examples/xterm/xterm-backend.js
@@ -0,0 +1,75 @@
+import { Terminal } from "@xterm/xterm";
+import { ImageAddon } from "@xterm/addon-image";
+
+// Third reference backend for asciinema-player's experimental `terminalBackend`
+// hook. xterm.js is the de-facto web terminal (VS Code et al.); hooking it in
+// shows the API is engine-agnostic. Kept outside the player bundle: a pinned,
+// replaceable PoC.
+
+const paletteNames = [
+ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
+ "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue",
+ "brightMagenta", "brightCyan", "brightWhite",
+];
+
+function themeOf(appearance) {
+ const t = appearance.theme;
+ const theme = { foreground: t.foreground, background: t.background, cursor: t.foreground };
+ paletteNames.forEach((name, i) => { theme[name] = t.palette[i]; });
+ return theme;
+}
+
+export function xtermBackend() {
+ return async ({ element, size, appearance }) => {
+ const term = new Terminal({
+ cols: size.cols,
+ rows: size.rows,
+ fontFamily: appearance.fontFamily,
+ fontSize: appearance.fontSize,
+ lineHeight: appearance.lineHeight,
+ theme: themeOf(appearance),
+ cursorBlink: appearance.cursorMode === "blinking" && appearance.blinking,
+ disableStdin: true,
+ scrollback: 0,
+ convertEol: false,
+ });
+ term.open(element);
+ term.loadAddon(new ImageAddon()); // Sixel + iTerm2 (OSC 1337) inline images
+ element.style.display = "grid"; // center the scaled grid in the player's box
+
+ // xterm measures its own cell metrics, so its grid rarely matches asciinema's
+ // viewport exactly. Uniformly scale it to fit the player's box (no glyph
+ // distortion, no clipping) instead of letting taller rows overflow.
+ const root = term.element;
+ function fit() {
+ if (!root || !root.offsetWidth || !root.offsetHeight) return;
+ root.style.transform = "";
+ const k = Math.min(element.clientWidth / root.offsetWidth, element.clientHeight / root.offsetHeight);
+ root.style.transformOrigin = "center";
+ root.style.transform = `scale(${k})`;
+ }
+ fit();
+ return {
+ write(data) { term.write(data, fit); },
+ resize({ cols, rows }) { term.resize(cols, rows); fit(); },
+ reset({ size, init }) {
+ term.reset(); // full reset: clears screen, scrollback and attributes
+ term.resize(size.cols, size.rows);
+ if (init) term.write(init);
+ fit();
+ },
+ update(next) {
+ const o = term.options;
+ o.theme = themeOf(next);
+ o.fontFamily = next.fontFamily;
+ o.fontSize = next.fontSize;
+ o.lineHeight = next.lineHeight;
+ o.cursorBlink = next.cursorMode === "blinking" && next.blinking;
+ fit();
+ },
+ // xterm renders on its own animation frame after write().
+ present() {},
+ dispose() { term.dispose(); },
+ };
+ };
+}
diff --git a/index.d.ts b/index.d.ts
index 9bf29a9b..d66f527b 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -54,6 +54,8 @@ export interface Logger {
}
export interface Options {
+ /** Experimental: mount an independently maintained VT and renderer. */
+ terminalBackend?: TerminalBackendFactory;
cols?: number;
rows?: number;
autoPlay?: boolean;
@@ -80,6 +82,44 @@ export interface Options {
logger?: Logger;
}
+export interface TerminalSize { cols: number; rows: number; }
+export interface TerminalTheme {
+ foreground: string;
+ background: string;
+ palette: string[];
+}
+export interface TerminalAppearance {
+ fontFamily: string;
+ fontSize: number;
+ lineHeight: number;
+ blinking: boolean;
+ cursorMode: "blinking" | "steady" | "hidden";
+ boldIsBright: boolean;
+ adaptivePalette: boolean;
+ theme: TerminalTheme;
+}
+export interface TerminalReset {
+ size: TerminalSize;
+ /** VT output restoring the recording segment's initial terminal state. */
+ init?: string;
+ theme?: TerminalTheme | null;
+}
+export interface TerminalBackend {
+ /** These mutations are synchronous and must preserve stream order. */
+ write(data: string): void;
+ resize(size: TerminalSize): void;
+ reset(state: TerminalReset): void;
+ update(appearance: TerminalAppearance): void;
+ /** Draw all preceding mutations. The player coalesces calls per animation frame. */
+ present(): void | Promise;
+ dispose(): void;
+}
+export type TerminalBackendFactory = (context: {
+ element: HTMLDivElement;
+ size: TerminalSize;
+ appearance: TerminalAppearance;
+}) => TerminalBackend | Promise;
+
export type SeekLocation =
| number
| `${number}%`
diff --git a/src/components/ExternalTerminal.js b/src/components/ExternalTerminal.js
new file mode 100644
index 00000000..8c50abba
--- /dev/null
+++ b/src/components/ExternalTerminal.js
@@ -0,0 +1,138 @@
+import { createEffect, onCleanup, onMount } from "solid-js";
+import { getCssTheme } from "./Terminal";
+
+// Experimental external terminal surface. Playback owns event order; the backend
+// owns VT state and drawing. No backend-specific objects cross the worker boundary.
+export default (props) => {
+ /* eslint-disable solid/reactivity -- factory and lifecycle callbacks are mount-time dependencies */
+ const core = props.core;
+ const factory = props.backend;
+ const ready = props.onReady;
+ /* eslint-enable solid/reactivity */
+ let element;
+ let backend;
+ let disposed = false;
+ let failed = false;
+ let frame;
+ let presenting = false;
+ let dirty = false;
+ let cssTheme;
+ let embeddedTheme;
+
+ const style = () => ({
+ width: `${props.cols}ch`,
+ height: `${(props.lineHeight ?? 1.3333333333) * props.rows}em`,
+ "font-size": `${(props.scale || 1) * 100}%`,
+ overflow: "hidden",
+ });
+
+ function fail(error) {
+ if (disposed || failed) return;
+ failed = true;
+ core.terminalFailed(error instanceof Error ? error : new Error(String(error)));
+ }
+
+ function invoke(method, value) {
+ if (disposed || failed) return;
+ try {
+ backend[method](value);
+ schedule();
+ } catch (error) {
+ fail(error);
+ }
+ }
+
+ function schedule() {
+ dirty = true;
+ if (frame === undefined && !presenting && !disposed && !failed) {
+ frame = requestAnimationFrame(present);
+ }
+ }
+
+ async function present() {
+ frame = undefined;
+ presenting = true;
+ dirty = false;
+ try {
+ await backend.present();
+ } catch (error) {
+ fail(error);
+ } finally {
+ presenting = false;
+ if (dirty) schedule();
+ }
+ }
+
+ function appearance() {
+ const computed = getComputedStyle(element);
+ return {
+ fontFamily: computed.fontFamily,
+ fontSize: parseFloat(computed.fontSize),
+ lineHeight: props.lineHeight ?? 1.3333333333,
+ blinking: props.blinking,
+ cursorMode: props.cursorMode,
+ boldIsBright: props.boldIsBright ?? false,
+ adaptivePalette: props.adaptivePalette,
+ theme: props.preferEmbeddedTheme && embeddedTheme ? embeddedTheme : cssTheme,
+ };
+ }
+
+ function output(data) {
+ for (const text of Array.isArray(data) ? data : [data]) invoke("write", text);
+ }
+
+ function reset(state) {
+ embeddedTheme = state.theme;
+ invoke("update", appearance());
+ invoke("reset", state);
+ }
+
+ function resize(size) {
+ invoke("resize", size);
+ }
+
+ createEffect(() => {
+ // Track display changes even before the asynchronous factory resolves.
+ props.scale;
+ props.blinking;
+ props.cursorMode;
+ if (backend) invoke("update", appearance());
+ });
+
+ onMount(async () => {
+ cssTheme = getCssTheme(element);
+ try {
+ const instance = await factory({
+ element,
+ size: { cols: props.cols, rows: props.rows },
+ appearance: appearance(),
+ });
+ if (disposed) {
+ instance.dispose();
+ return;
+ }
+ backend = instance;
+ for (const method of ["write", "resize", "reset", "update", "present", "dispose"]) {
+ if (typeof backend?.[method] !== "function") throw new Error(`terminal backend missing ${method}`);
+ }
+ invoke("update", appearance());
+ core.addEventListener("output", output);
+ core.addEventListener("reset", reset);
+ core.addEventListener("resize", resize);
+ ready?.();
+ } catch (error) {
+ fail(error);
+ }
+ });
+
+ onCleanup(() => {
+ disposed = true;
+ cancelAnimationFrame(frame);
+ core.removeEventListener("output", output);
+ core.removeEventListener("reset", reset);
+ core.removeEventListener("resize", resize);
+ backend?.dispose?.();
+ });
+
+ return ;
+};
diff --git a/src/components/Player.js b/src/components/Player.js
index 86e8669d..368f1331 100644
--- a/src/components/Player.js
+++ b/src/components/Player.js
@@ -2,6 +2,7 @@ import { batch, createMemo, createSignal, Match, onCleanup, onMount, Switch, Sho
import { debounce } from "../util";
import Slide from "./Slide";
import Terminal from "./Terminal";
+import ExternalTerminal from "./ExternalTerminal";
import ControlBar from "./ControlBar";
import ErrorOverlay from "./ErrorOverlay";
import LoaderOverlay from "./LoaderOverlay";
@@ -25,6 +26,7 @@ export default (props) => {
const bordersW = props.bordersW;
const bordersH = props.bordersH;
const themeOption = props.theme ?? "auto/asciinema";
+ const TerminalView = props.terminalBackend ? ExternalTerminal : Terminal;
/* eslint-enable solid/reactivity */
const preferEmbeddedTheme = themeOption.slice(0, 5) === "auto/";
const themeName = preferEmbeddedTheme ? themeOption.slice(5) : themeOption;
@@ -573,7 +575,8 @@ export default (props) => {
onMouseMove={() => onUserActive(true)}
ref={playerRef}
>
- {
+ this.startupPromise = new Promise((resolve, reject) => {
this.resolveStartup = resolve;
+ this.rejectStartup = reject;
});
this.eventHandlers = new Map([
@@ -66,6 +67,11 @@ class Core {
this.resolveStartup();
}
+ terminalFailed(error) {
+ this.rejectStartup(error);
+ this._dispatchEvent("error", error);
+ }
+
async _init() {
// Wait until Terminal has installed VT event listeners before drivers start dispatching.
await this.startupPromise;
diff --git a/src/opts.js b/src/opts.js
index 8f228d99..d772cc5b 100644
--- a/src/opts.js
+++ b/src/opts.js
@@ -26,6 +26,7 @@ const UI_OPTS = [
"keystrokeOverlay",
"rows",
"terminalFontFamily",
+ "terminalBackend",
"terminalFontSize",
"terminalLineHeight",
"theme",
@@ -83,6 +84,10 @@ function coreOpts(inputOpts, overrides = {}) {
function uiOpts(inputOpts, overrides = {}) {
const opts = pick(inputOpts, UI_OPTS);
+ if (opts.terminalBackend !== undefined && typeof opts.terminalBackend !== "function") {
+ throw new Error("terminalBackend must be a factory function");
+ }
+
opts.autoPlay ??= opts.autoplay;
opts.adaptivePalette ??= false;
opts.controls ??= "auto";
diff --git a/src/ui.js b/src/ui.js
index 5569c04a..33860319 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -1,7 +1,7 @@
import { mount } from "./view";
import { coreOpts, uiOpts } from "./opts";
import { DummyLogger } from "./logging";
-import { fromErrorPayload } from "./error";
+import { fromErrorPayload, toErrorPayload } from "./error";
function create(src, elem, workerUrl, opts = {}) {
const coreLogger = opts.logger === console ? true : undefined;
@@ -102,6 +102,10 @@ class CoreWorkerProxy {
this._sendNotification("terminalReady");
}
+ terminalFailed(error) {
+ this._sendNotification("terminalFailed", toErrorPayload(error));
+ }
+
getChanges() {
return this._sendCommand("getChanges");
}
@@ -132,6 +136,12 @@ class CoreWorkerProxy {
handlers.push(handler);
}
+ removeEventListener(eventName, handler) {
+ const handlers = this.eventHandlers.get(eventName);
+ const index = handlers?.indexOf(handler) ?? -1;
+ if (index >= 0) handlers.splice(index, 1);
+ }
+
_dispatchEvent(eventName, data = {}) {
for (const handler of [...this.eventHandlers.get(eventName)]) {
try {
diff --git a/src/view.js b/src/view.js
index 1691fe70..3249db66 100644
--- a/src/view.js
+++ b/src/view.js
@@ -18,6 +18,7 @@ function mount(core, elem, opts = {}) {
adaptivePalette: opts.adaptivePalette,
terminalFontSize: opts.terminalFontSize,
terminalFontFamily: opts.terminalFontFamily,
+ terminalBackend: opts.terminalBackend,
terminalLineHeight: opts.terminalLineHeight,
theme: opts.theme,
onTerminalReady: opts.onTerminalReady,
diff --git a/src/worker.js b/src/worker.js
index cb8e6937..8f9a818a 100644
--- a/src/worker.js
+++ b/src/worker.js
@@ -1,6 +1,6 @@
import Core from "./core";
import { DummyLogger } from "./logging";
-import { toErrorPayload } from "./error";
+import { toErrorPayload, fromErrorPayload } from "./error";
let logger = new DummyLogger();
let core;
@@ -37,6 +37,8 @@ function invoke(method, params) {
return core.init();
case "terminalReady":
return core.terminalReady();
+ case "terminalFailed":
+ return core.terminalFailed(fromErrorPayload(params));
case "play":
return core.play();
case "pause":
diff --git a/tests/terminal-backend.spec.js b/tests/terminal-backend.spec.js
new file mode 100644
index 00000000..813f608a
--- /dev/null
+++ b/tests/terminal-backend.spec.js
@@ -0,0 +1,63 @@
+import { test, expect } from "@playwright/test";
+
+test("external terminal receives reset, output and resize in replay order", async ({ page }) => {
+ await page.goto("/index.html");
+ await page.evaluate(() => {
+ window.calls = [];
+ window.player = AsciinemaPlayer.create(
+ { data: [{ version: 2, width: 12, height: 3 }, [0, "o", "e"],
+ [0.1, "o", "\u0301"], [0.2, "r", "20x4"], [0.3, "o", "👩🏽💻"], [1, "o", "end"]] },
+ document.getElementById("player"),
+ { terminalBackend: async ({ element }) => {
+ element.textContent = "External terminal";
+ return {
+ reset: (state) => calls.push(["reset", state.size]),
+ write: (text) => calls.push(["write", text]),
+ resize: (size) => calls.push(["resize", size]),
+ update: () => {},
+ present: () => calls.push(["present"]),
+ dispose: () => calls.push(["dispose"]),
+ };
+ } },
+ );
+ });
+ await expect(page.getByText("External terminal", { exact: true })).toBeVisible();
+ await page.evaluate(() => player.seek(0.5));
+ const calls = await page.evaluate(() => calls.filter(([name]) => name !== "present"));
+ expect(calls).toContainEqual(["resize", { cols: 20, rows: 4 }]);
+ expect(calls.filter(([name]) => name === "write").map(([, text]) => text).join(""))
+ .toContain("e\u0301👩🏽💻");
+ await page.evaluate(async () => { calls.length = 0; await player.seek(0); });
+ expect(await page.evaluate(() => calls)).toContainEqual(["reset", { cols: 12, rows: 3 }]);
+ await page.evaluate(() => player.dispose());
+ expect(await page.evaluate(() => calls.filter(([name]) => name === "dispose").length)).toBe(1);
+});
+
+test("disposes a backend that finishes initializing after the player is removed", async ({ page }) => {
+ await page.goto("/index.html");
+ await page.evaluate(() => {
+ window.disposed = 0;
+ window.player = AsciinemaPlayer.create("/assets/simple.cast", document.getElementById("player"), {
+ terminalBackend: () => new Promise((resolve) => { window.finish = () => resolve({
+ write() {}, reset() {}, resize() {}, update() {}, present() {},
+ dispose() { window.disposed++; },
+ }); }),
+ });
+ });
+ await expect.poll(() => page.evaluate(() => typeof window.finish)).toBe("function");
+ await page.evaluate(() => { player.dispose(); finish(); });
+ await expect.poll(() => page.evaluate(() => window.disposed)).toBe(1);
+});
+
+test("backend initialization failure rejects playback and emits an error", async ({ page }) => {
+ await page.goto("/index.html");
+ const result = await page.evaluate(async () => {
+ const player = AsciinemaPlayer.create("/assets/simple.cast", document.getElementById("player"), {
+ terminalBackend: async () => { throw new Error("backend unavailable"); },
+ });
+ let error;
+ player.addEventListener("error", (e) => { error = e.message; });
+ try { await player.play(); } catch (e) { return [error, e.message]; }
+ });
+ expect(result).toEqual(["backend unavailable", "backend unavailable"]);
+});