Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions examples/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
*/dist/
unicode.cast
68 changes: 68 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -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: <http://localhost:8077/examples/>

Pick backends with the checkboxes, or via the URL:
<http://localhost:8077/examples/?backends=default,ghostty-web,wterm-ghostty>

## 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, `<name>/<name>-backend.js`, plus that
engine's npm package and its WASM/CSS, copied into `<name>/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 <name>/dist/
gen-cast.mjs generates unicode.cast (the recording played)
<name>/
<name>-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 `<name>/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.
38 changes: 38 additions & 0 deletions examples/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Builds every terminal-backend example in one go. Each adapter is bundled into
// its own <name>/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");
149 changes: 149 additions & 0 deletions examples/gen-cast.mjs
Original file line number Diff line number Diff line change
@@ -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 <outfile> # 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);
}
82 changes: 82 additions & 0 deletions examples/ghostty-web/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading