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"]); +});