termcut 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ import { barHeight } from "../renderer/page";
2
+ import type { Recording, ResolvedConfig } from "../types";
3
+ import { FLAG, replayFrames, type GridCell, type GridFrame } from "./frames";
4
+
5
+ const esc = (s: string) =>
6
+ s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
7
+
8
+ const num = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, ""));
9
+
10
+ interface Geometry {
11
+ cellW: number;
12
+ cellH: number;
13
+ termW: number;
14
+ termH: number;
15
+ frameX: number;
16
+ frameY: number;
17
+ frameW: number;
18
+ frameH: number;
19
+ termX: number;
20
+ termY: number;
21
+ width: number;
22
+ height: number;
23
+ }
24
+
25
+ export function svgGeometry(config: ResolvedConfig, cols: number, rows: number): Geometry {
26
+ const cellW = Math.round(config.font.size * 0.6 * 100) / 100 + config.font.letterSpacing;
27
+ const cellH = Math.ceil(config.font.size * config.font.lineHeight);
28
+ const termW = cols * cellW;
29
+ const termH = rows * cellH;
30
+ const bar = barHeight(config);
31
+ const frameW = termW + config.padding * 2;
32
+ const frameH = termH + config.padding * 2 + bar;
33
+ return {
34
+ cellW,
35
+ cellH,
36
+ termW,
37
+ termH,
38
+ frameX: config.margin,
39
+ frameY: config.margin,
40
+ frameW,
41
+ frameH,
42
+ termX: config.margin + config.padding,
43
+ termY: config.margin + config.padding + bar,
44
+ width: Math.ceil(frameW + config.margin * 2),
45
+ height: Math.ceil(frameH + config.margin * 2),
46
+ };
47
+ }
48
+
49
+ function windowBar(config: ResolvedConfig, g: Geometry): string {
50
+ if (config.windowBar === "none") return "";
51
+ const rings = config.windowBar.startsWith("rings");
52
+ const right = config.windowBar.endsWith("Right");
53
+ const y = g.frameY + Math.max(8, config.padding - 12) + 12;
54
+ const colors = ["#ff5f57", "#febc2e", "#28c840"];
55
+ const startX = right ? g.frameX + g.frameW - config.padding - 6 - 40 : g.frameX + config.padding + 6;
56
+ const dots = colors
57
+ .map((c, i) => `<circle cx="${num(startX + i * 20)}" cy="${num(y)}" r="6" ${rings ? `fill="none" stroke="${c}" stroke-width="2"` : `fill="${c}"`}/>`)
58
+ .join("");
59
+ const title = config.title
60
+ ? `<text x="${num(g.frameX + g.frameW / 2)}" y="${num(y + 4)}" text-anchor="middle" font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="${config.theme.foreground}" opacity="0.7">${esc(config.title)}</text>`
61
+ : "";
62
+ return dots + title;
63
+ }
64
+
65
+ function styleAttrs(cell: GridCell, defaultFg: string): string {
66
+ let a = ` fill="${cell.fg ?? defaultFg}"`;
67
+ if (cell.flags & FLAG.bold) a += ' font-weight="bold"';
68
+ if (cell.flags & FLAG.italic) a += ' font-style="italic"';
69
+ if (cell.flags & FLAG.dim) a += ' opacity="0.6"';
70
+ const deco = [cell.flags & FLAG.underline ? "underline" : "", cell.flags & FLAG.strike ? "line-through" : ""].filter(Boolean).join(" ");
71
+ if (deco) a += ` text-decoration="${deco}"`;
72
+ return a;
73
+ }
74
+
75
+ function frameMarkup(frame: GridFrame, config: ResolvedConfig, g: Geometry): string {
76
+ const parts: string[] = [];
77
+ const { theme } = config;
78
+ const baseline = Math.round(g.cellH * 0.78 * 100) / 100;
79
+
80
+ for (const [y, cells] of frame.rows_) {
81
+ // Background runs
82
+ let x = 0;
83
+ let runStart = 0;
84
+ let runBg: string | null = null;
85
+ const flushBg = (end: number) => {
86
+ if (runBg) parts.push(`<rect x="${num(runStart * g.cellW)}" y="${num(y * g.cellH)}" width="${num((end - runStart) * g.cellW)}" height="${num(g.cellH)}" fill="${runBg}"/>`);
87
+ };
88
+ for (const cell of cells) {
89
+ if (cell.bg !== runBg) {
90
+ flushBg(x);
91
+ runStart = x;
92
+ runBg = cell.bg;
93
+ }
94
+ x += cell.width;
95
+ }
96
+ flushBg(x);
97
+
98
+ // Text runs with identical style
99
+ const spans: string[] = [];
100
+ x = 0;
101
+ let run: { x: number; text: string; style: string } | null = null;
102
+ for (const cell of cells) {
103
+ const blank = cell.text === " " && !(cell.flags & (FLAG.underline | FLAG.strike));
104
+ const style = blank ? "" : styleAttrs(cell, theme.foreground);
105
+ if (run && (run.style === style || (blank && run.text.length > 0))) {
106
+ run.text += cell.text;
107
+ } else {
108
+ if (run && run.text.trim()) spans.push(`<tspan x="${num(run.x * g.cellW)}"${run.style}>${esc(run.text.replace(/\s+$/, ""))}</tspan>`);
109
+ run = blank ? null : { x, text: cell.text, style };
110
+ }
111
+ x += cell.width;
112
+ }
113
+ if (run && run.text.trim()) spans.push(`<tspan x="${num(run.x * g.cellW)}"${run.style}>${esc(run.text.replace(/\s+$/, ""))}</tspan>`);
114
+ if (spans.length) parts.push(`<text y="${num(y * g.cellH + baseline)}">${spans.join("")}</text>`);
115
+ }
116
+
117
+ if (frame.cursor.visible && frame.cursor.row < frame.rows && frame.cursor.col < frame.cols) {
118
+ parts.push(`<rect x="${num(frame.cursor.col * g.cellW)}" y="${num(frame.cursor.row * g.cellH)}" width="${num(g.cellW)}" height="${num(g.cellH)}" fill="${theme.cursor ?? theme.foreground}" opacity="0.85"/>`);
119
+ }
120
+ return parts.join("");
121
+ }
122
+
123
+ export interface SvgResult {
124
+ svg: string;
125
+ frames: number;
126
+ duration: number;
127
+ }
128
+
129
+ /** Animated SVG: a horizontal strip of unique frames moved by a stepped CSS animation. No JS, no fonts embedded. */
130
+ export async function buildSvg(rec: Recording, config: ResolvedConfig): Promise<SvgResult> {
131
+ const replay = await replayFrames(rec, config);
132
+ const g = svgGeometry(config, replay.cols, replay.rows);
133
+ const { theme, font } = config;
134
+ const n = replay.frames.length;
135
+ const total = replay.duration;
136
+
137
+ const keyframes: string[] = [];
138
+ for (let i = 0; i < n; i++) {
139
+ const pct = (replay.frames[i]!.time / total) * 100;
140
+ keyframes.push(`${num(pct)}%{transform:translateX(${num(-i * g.termW)}px)}`);
141
+ }
142
+ keyframes.push(`100%{transform:translateX(${num(-(n - 1) * g.termW)}px)}`);
143
+
144
+ const frames = replay.frames
145
+ .map((f, i) => `<g transform="translate(${num(i * g.termW)} 0)">${frameMarkup(f, config, g)}</g>`)
146
+ .join("\n");
147
+
148
+ const svg = `<?xml version="1.0" encoding="UTF-8"?>
149
+ <svg xmlns="http://www.w3.org/2000/svg" width="${g.width}" height="${g.height}" viewBox="0 0 ${g.width} ${g.height}" font-family="${esc(font.family)}" font-size="${font.size}">
150
+ <style>
151
+ .strip{animation:tcut ${num(total)}s steps(1,end) infinite}
152
+ @keyframes tcut{${keyframes.join("")}}
153
+ text{white-space:pre;dominant-baseline:auto}
154
+ </style>
155
+ <rect width="100%" height="100%" fill="${config.marginFill}"/>
156
+ <rect x="${num(g.frameX)}" y="${num(g.frameY)}" width="${num(g.frameW)}" height="${num(g.frameH)}" rx="${config.borderRadius}" fill="${theme.background}"/>
157
+ ${windowBar(config, g)}
158
+ <clipPath id="term"><rect x="${num(g.termX)}" y="${num(g.termY)}" width="${num(g.termW)}" height="${num(g.termH)}"/></clipPath>
159
+ <g clip-path="url(#term)"><g transform="translate(${num(g.termX)} ${num(g.termY)})"><g class="strip" xml:space="preserve">
160
+ ${frames}
161
+ </g></g></g>
162
+ </svg>
163
+ `;
164
+ return { svg, frames: n, duration: total };
165
+ }
166
+
167
+ export async function writeSvg(rec: Recording, config: ResolvedConfig, file: string): Promise<SvgResult> {
168
+ const result = await buildSvg(rec, config);
169
+ await Bun.write(file, result.svg);
170
+ return result;
171
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export { defineVideo, Video, renderCast, isVideo } from "./video";
2
+ export type { RunOptions as VideoRunOptions, RunResult, VideoRecordOptions } from "./video";
3
+ export { renderOutputs } from "./render";
4
+ export { buildSvg } from "./export/svg";
5
+ export { buildHtml } from "./export/html";
6
+ export { replayFrames } from "./export/frames";
7
+ export type { GridFrame, GridCell, GridReplay } from "./export/frames";
8
+ export { runScriptTests, discoverScripts } from "./testing";
9
+ export type { TestResult, TestSummary } from "./testing";
10
+ export { themes, themeNames, resolveTheme } from "./themes";
11
+ export { readCast, writeCast, parseCast, serializeCast } from "./cast";
12
+ export { buildTimeline } from "./timeline";
13
+ export { resolveConfig } from "./config";
14
+ export { WaitTimeoutError, ExpectationError } from "./recorder";
15
+ export type * from "./types";
package/src/keys.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { KeyName } from "./types";
2
+
3
+ const ESC = "\x1b";
4
+
5
+ const keySequences: Record<KeyName, string> = {
6
+ enter: "\r",
7
+ tab: "\t",
8
+ backspace: "\x7f",
9
+ delete: `${ESC}[3~`,
10
+ escape: ESC,
11
+ space: " ",
12
+ up: `${ESC}[A`,
13
+ down: `${ESC}[B`,
14
+ right: `${ESC}[C`,
15
+ left: `${ESC}[D`,
16
+ home: `${ESC}[H`,
17
+ end: `${ESC}[F`,
18
+ pageUp: `${ESC}[5~`,
19
+ pageDown: `${ESC}[6~`,
20
+ insert: `${ESC}[2~`,
21
+ f1: `${ESC}OP`,
22
+ f2: `${ESC}OQ`,
23
+ f3: `${ESC}OR`,
24
+ f4: `${ESC}OS`,
25
+ f5: `${ESC}[15~`,
26
+ f6: `${ESC}[17~`,
27
+ f7: `${ESC}[18~`,
28
+ f8: `${ESC}[19~`,
29
+ f9: `${ESC}[20~`,
30
+ f10: `${ESC}[21~`,
31
+ f11: `${ESC}[23~`,
32
+ f12: `${ESC}[24~`,
33
+ };
34
+
35
+ export function keySequence(name: KeyName): string {
36
+ const seq = keySequences[name];
37
+ if (seq === undefined) {
38
+ throw new Error(`Unknown key "${name}". Known keys: ${Object.keys(keySequences).join(", ")}`);
39
+ }
40
+ return seq;
41
+ }
42
+
43
+ /** Ctrl+<letter> → control character (Ctrl+C = 0x03). Also accepts "[", "]", "\\", "^", "_", "@". */
44
+ export function ctrlSequence(key: string): string {
45
+ if (key.length !== 1) throw new Error(`ctrl() expects a single character, got "${key}"`);
46
+ const upper = key.toUpperCase();
47
+ const code = upper.charCodeAt(0);
48
+ if (code >= 0x40 && code <= 0x5f) return String.fromCharCode(code & 0x1f);
49
+ if (key === "?") return "\x7f";
50
+ if (key === " ") return "\x00";
51
+ throw new Error(`Cannot send Ctrl+${key}`);
52
+ }
53
+
54
+ /** Alt/Meta+<key> → ESC-prefixed key. */
55
+ export function altSequence(key: string): string {
56
+ const isNamed = key in keySequences;
57
+ return ESC + (isNamed ? keySequence(key as KeyName) : key);
58
+ }
@@ -0,0 +1,362 @@
1
+ import { MARKER } from "./cast";
2
+ import { formatMs, toMs } from "./duration";
3
+ import { altSequence, ctrlSequence, keySequence } from "./keys";
4
+ import { Screen } from "./screen";
5
+ import type {
6
+ CastEvent,
7
+ Duration,
8
+ KeyName,
9
+ RecordOptions,
10
+ Recording,
11
+ ResolvedConfig,
12
+ RunOptions,
13
+ Script,
14
+ TerminalSession,
15
+ TypeOptions,
16
+ WaitOptions,
17
+ } from "./types";
18
+
19
+ export class WaitTimeoutError extends Error {
20
+ constructor(what: string, timeoutMs: number, screen: string) {
21
+ super(`Timed out after ${formatMs(timeoutMs)} waiting for ${what}.\n\n--- screen ---\n${screen}\n--------------`);
22
+ this.name = "WaitTimeoutError";
23
+ }
24
+ }
25
+
26
+ export class ExpectationError extends Error {
27
+ constructor(what: string, screen: string) {
28
+ super(`Expected ${what} to match.\n\n--- screen ---\n${screen}\n--------------`);
29
+ this.name = "ExpectationError";
30
+ }
31
+ }
32
+
33
+ /** Deterministic PRNG (mulberry32) so typing jitter is reproducible. */
34
+ function mulberry32(seed: number): () => number {
35
+ let a = seed >>> 0;
36
+ return () => {
37
+ a = (a + 0x6d2b79f5) >>> 0;
38
+ let t = a;
39
+ t = Math.imul(t ^ (t >>> 15), t | 1);
40
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
41
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
42
+ };
43
+ }
44
+
45
+ interface ShellSetup {
46
+ cmd: string[];
47
+ env: Record<string, string>;
48
+ }
49
+
50
+ function shellSetup(config: ResolvedConfig): ShellSetup {
51
+ const { shell, prompt } = config;
52
+ if (Array.isArray(shell)) return { cmd: shell, env: {} };
53
+ switch (shell) {
54
+ case "bash":
55
+ return {
56
+ cmd: ["bash", "--norc", "--noprofile"],
57
+ env: {
58
+ PS1: prompt,
59
+ PS2: "",
60
+ HISTFILE: "/dev/null",
61
+ BASH_SILENCE_DEPRECATION_WARNING: "1",
62
+ },
63
+ };
64
+ case "zsh":
65
+ return {
66
+ cmd: ["zsh", "-f"],
67
+ env: { PS1: prompt, PROMPT: prompt, PS2: "", HISTFILE: "/dev/null", PROMPT_EOL_MARK: "" },
68
+ };
69
+ case "fish": {
70
+ const escaped = prompt.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
71
+ return {
72
+ cmd: [
73
+ "fish",
74
+ "--no-config",
75
+ "--init-command",
76
+ `function fish_prompt; printf '%s' '${escaped}'; end; function fish_greeting; end; set -g fish_autosuggestion_enabled 0`,
77
+ ],
78
+ env: {},
79
+ };
80
+ }
81
+ case "sh":
82
+ return { cmd: ["sh"], env: { PS1: prompt, PS2: "" } };
83
+ default:
84
+ return { cmd: [shell], env: { PS1: prompt } };
85
+ }
86
+ }
87
+
88
+ /** Drives a Bun.Terminal PTY according to a script and produces an asciicast recording. */
89
+ export async function record(config: ResolvedConfig, script: Script, opts: RecordOptions = {}): Promise<Recording> {
90
+ const log = opts.log ?? (() => {});
91
+ const events: CastEvent[] = [];
92
+ const screen = await Screen.create(config.cols, config.rows, { core: config.core });
93
+ const fast = opts.fast === true;
94
+ const decoder = new TextDecoder("utf-8");
95
+ const rand = mulberry32(config.seed);
96
+ const promptPattern = new RegExp(config.promptPattern);
97
+
98
+ let startedAt: number | null = null;
99
+ let hiddenDepth = 0;
100
+ let cols = config.cols;
101
+ let rows = config.rows;
102
+
103
+ const now = (): number => (startedAt === null ? 0 : (performance.now() - startedAt) / 1000);
104
+ const stamp = (): number => {
105
+ const t = now();
106
+ if (!config.quantize) return Number(t.toFixed(6));
107
+ return Math.ceil(t * config.fps - 1e-6) / config.fps;
108
+ };
109
+ const push = (type: CastEvent[1], data: string): void => {
110
+ const event: CastEvent = [stamp(), type, data];
111
+ events.push(event);
112
+ opts.onEvent?.(event);
113
+ };
114
+
115
+ const setup = shellSetup(config);
116
+ const env: Record<string, string> = {
117
+ ...process.env,
118
+ TERM: "xterm-256color",
119
+ COLORTERM: "truecolor",
120
+ LANG: process.env.LANG ?? "en_US.UTF-8",
121
+ ...setup.env,
122
+ ...config.env,
123
+ };
124
+ delete env.PROMPT_COMMAND;
125
+
126
+ let exited = false;
127
+ const proc = Bun.spawn(setup.cmd, {
128
+ cwd: config.cwd,
129
+ env,
130
+ terminal: {
131
+ cols,
132
+ rows,
133
+ name: "xterm-256color",
134
+ data(_terminal, chunk) {
135
+ const text = decoder.decode(chunk, { stream: true });
136
+ if (!text) return;
137
+ push("o", text);
138
+ screen.write(text);
139
+ },
140
+ exit() {
141
+ exited = true;
142
+ },
143
+ },
144
+ });
145
+ const terminal = proc.terminal;
146
+ if (!terminal) throw new Error("Bun.spawn did not return a terminal. Is this Bun >= 1.4?");
147
+ // Programs that query the terminal (DA, cursor position, …) need the emulator's answer written back.
148
+ screen.onResponse = (response) => {
149
+ if (!exited && !terminal.closed) terminal.write(response);
150
+ };
151
+
152
+ const sleep = async (duration: Duration): Promise<void> => {
153
+ const ms = toMs(duration);
154
+ if (fast) return;
155
+ await Bun.sleep(ms);
156
+ };
157
+
158
+ const ensureAlive = (): void => {
159
+ if (exited || terminal.closed) {
160
+ throw new Error(`The shell exited before the script finished.\n\n--- screen ---\n${screen.screen()}\n--------------`);
161
+ }
162
+ };
163
+
164
+ const raw = async (data: string | Uint8Array): Promise<void> => {
165
+ ensureAlive();
166
+ terminal.write(data);
167
+ push("i", typeof data === "string" ? data : decoder.decode(data));
168
+ };
169
+
170
+ const typingDelay = (base: number): number => {
171
+ if (config.typingJitter === 0) return base;
172
+ const factor = 1 + (rand() * 2 - 1) * config.typingJitter;
173
+ return Math.max(0, base * factor);
174
+ };
175
+
176
+ const type = async (text: string, typeOpts: TypeOptions = {}): Promise<void> => {
177
+ const speed = fast ? 0 : toMs(typeOpts.speed, config.typingSpeed);
178
+ for (const char of text) {
179
+ await raw(char === "\n" ? "\r" : char);
180
+ if (speed > 0) await Bun.sleep(typingDelay(speed));
181
+ }
182
+ };
183
+
184
+ const pressKey = async (sequence: string, times = 1): Promise<void> => {
185
+ for (let i = 0; i < times; i++) {
186
+ await raw(sequence);
187
+ if (!fast && config.typingSpeed > 0 && times > 1) await Bun.sleep(typingDelay(config.typingSpeed));
188
+ }
189
+ };
190
+
191
+ const key = (name: KeyName, times = 1): Promise<void> => pressKey(keySequence(name), times);
192
+
193
+ const matches = (pattern: RegExp, scope: "line" | "screen"): boolean =>
194
+ pattern.test(scope === "screen" ? screen.screen() : screen.line());
195
+
196
+ const toRegExp = (pattern: RegExp | string): RegExp =>
197
+ typeof pattern === "string" ? new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) : pattern;
198
+
199
+ const waitFor = async (
200
+ description: string,
201
+ test: () => boolean,
202
+ timeoutMs: number,
203
+ ): Promise<void> => {
204
+ const deadline = performance.now() + timeoutMs;
205
+ for (;;) {
206
+ await screen.settle();
207
+ if (test()) return;
208
+ if (exited) {
209
+ await screen.settle();
210
+ if (test()) return;
211
+ throw new Error(`Shell exited while waiting for ${description}.\n\n--- screen ---\n${screen.screen()}\n--------------`);
212
+ }
213
+ const remaining = deadline - performance.now();
214
+ if (remaining <= 0) throw new WaitTimeoutError(description, timeoutMs, screen.screen());
215
+ await Promise.race([screen.nextFlush(), Bun.sleep(Math.min(remaining, 50))]);
216
+ }
217
+ };
218
+
219
+ const wait = async (pattern?: RegExp | string, waitOpts: WaitOptions = {}): Promise<void> => {
220
+ const regex = pattern === undefined ? promptPattern : toRegExp(pattern);
221
+ const scope = waitOpts.scope ?? "line";
222
+ await waitFor(`${regex} on ${scope}`, () => matches(regex, scope), toMs(waitOpts.timeout, config.waitTimeout));
223
+ };
224
+
225
+ const waitForPrompt = async (afterLine: number, echoLine: string, timeoutMs: number): Promise<void> => {
226
+ await waitFor(
227
+ `prompt ${promptPattern}`,
228
+ () =>
229
+ promptPattern.test(screen.line()) &&
230
+ (screen.absoluteCursorLine() !== afterLine || screen.line() !== echoLine),
231
+ timeoutMs,
232
+ );
233
+ };
234
+
235
+ const run = async (command: string, runOpts: RunOptions = {}): Promise<void> => {
236
+ await type(command, runOpts);
237
+ await screen.settle();
238
+ const beforeLine = screen.absoluteCursorLine();
239
+ const echoLine = screen.line();
240
+ await raw("\r");
241
+ const timeout = toMs(runOpts.timeout, config.waitTimeout);
242
+ if (runOpts.wait === false) return;
243
+ if (runOpts.wait instanceof RegExp) {
244
+ await wait(runOpts.wait, { timeout });
245
+ return;
246
+ }
247
+ await waitForPrompt(beforeLine, echoLine, timeout);
248
+ };
249
+
250
+ const expect = async (pattern: RegExp | string, expectOpts: Pick<WaitOptions, "scope"> = {}): Promise<void> => {
251
+ await screen.settle();
252
+ const regex = toRegExp(pattern);
253
+ const scope = expectOpts.scope ?? "screen";
254
+ if (!matches(regex, scope)) throw new ExpectationError(`${regex} on ${scope}`, screen.screen());
255
+ };
256
+
257
+ const hide = async <T>(fn: () => Promise<T>): Promise<T> => {
258
+ if (hiddenDepth === 0) push("m", MARKER.hide);
259
+ hiddenDepth++;
260
+ try {
261
+ return await fn();
262
+ } finally {
263
+ hiddenDepth--;
264
+ if (hiddenDepth === 0) {
265
+ await screen.settle();
266
+ push("m", MARKER.show);
267
+ }
268
+ }
269
+ };
270
+
271
+ const session: TerminalSession = {
272
+ type,
273
+ run,
274
+ paste: (text) => raw(text),
275
+ key,
276
+ enter: (n) => key("enter", n),
277
+ tab: (n) => key("tab", n),
278
+ backspace: (n) => key("backspace", n),
279
+ delete: (n) => key("delete", n),
280
+ escape: (n) => key("escape", n),
281
+ space: (n) => key("space", n),
282
+ up: (n) => key("up", n),
283
+ down: (n) => key("down", n),
284
+ left: (n) => key("left", n),
285
+ right: (n) => key("right", n),
286
+ home: () => key("home"),
287
+ end: () => key("end"),
288
+ pageUp: (n) => key("pageUp", n),
289
+ pageDown: (n) => key("pageDown", n),
290
+ ctrl: (letter, n) => pressKey(ctrlSequence(letter), n),
291
+ alt: (k, n) => pressKey(altSequence(k), n),
292
+ raw,
293
+ sleep,
294
+ wait,
295
+ expect,
296
+ hide,
297
+ screenshot: async (file) => {
298
+ await screen.settle();
299
+ push("m", MARKER.screenshot + file);
300
+ },
301
+ marker: async (name) => {
302
+ push("m", name);
303
+ },
304
+ resize: async (newCols, newRows) => {
305
+ ensureAlive();
306
+ cols = newCols;
307
+ rows = newRows;
308
+ terminal.resize(newCols, newRows);
309
+ screen.resize(newCols, newRows);
310
+ push("r", `${newCols}x${newRows}`);
311
+ await screen.settle();
312
+ },
313
+ clear: () => run("clear"),
314
+ screen: () => screen.screen(),
315
+ line: () => screen.line(),
316
+ cursor: () => screen.cursor(),
317
+ get cols() {
318
+ return cols;
319
+ },
320
+ get rows() {
321
+ return rows;
322
+ },
323
+ config,
324
+ };
325
+
326
+ try {
327
+ // Everything that happens before the first prompt is stamped at t=0.
328
+ log(`starting ${Array.isArray(config.shell) ? config.shell.join(" ") : config.shell}`);
329
+ await waitFor(`initial prompt ${promptPattern}`, () => promptPattern.test(screen.line()), config.waitTimeout);
330
+ startedAt = performance.now();
331
+ log("recording");
332
+ await script(session);
333
+ if (hiddenDepth > 0) throw new Error("Script finished inside hide() — this is a bug in the recorder");
334
+ await screen.settle();
335
+ if (config.endPause > 0 && !fast) await Bun.sleep(config.endPause);
336
+ push("m", MARKER.end);
337
+ } finally {
338
+ try {
339
+ terminal.close();
340
+ } catch {
341
+ /* already closed */
342
+ }
343
+ if (!exited) proc.kill();
344
+ await proc.exited.catch(() => undefined);
345
+ screen.dispose();
346
+ }
347
+
348
+ const duration = events.length > 0 ? events[events.length - 1]![0] : 0;
349
+ return {
350
+ header: {
351
+ version: 2,
352
+ width: config.cols,
353
+ height: config.rows,
354
+ timestamp: Math.floor(Date.now() / 1000),
355
+ duration,
356
+ title: config.title || undefined,
357
+ env: { TERM: "xterm-256color", SHELL: Array.isArray(config.shell) ? config.shell[0]! : config.shell },
358
+ bunVideo: config,
359
+ },
360
+ events,
361
+ };
362
+ }
package/src/render.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeHtml } from "./export/html";
4
+ import { writeSvg } from "./export/svg";
5
+ import { render as renderRaster, type RenderResult } from "./renderer/webview";
6
+ import type { Recording, RenderProgress, ResolvedConfig } from "./types";
7
+
8
+ export type { RenderResult };
9
+
10
+ const kind = (output: string): "svg" | "html" | "raster" => {
11
+ if (output.endsWith("/")) return "raster";
12
+ const ext = path.extname(output).toLowerCase();
13
+ if (ext === ".svg") return "svg";
14
+ if (ext === ".html" || ext === ".htm") return "html";
15
+ return "raster";
16
+ };
17
+
18
+ /**
19
+ * Fan the configured outputs out to the right backend: `.svg` and `.html` are produced from the headless core
20
+ * (no WebView, no ffmpeg); everything else goes through the WebView + ffmpeg renderer in one pass.
21
+ */
22
+ export async function renderOutputs(
23
+ rec: Recording,
24
+ config: ResolvedConfig,
25
+ onProgress?: (p: RenderProgress) => void,
26
+ ): Promise<RenderResult> {
27
+ const svg = config.output.filter((o) => kind(o) === "svg");
28
+ const html = config.output.filter((o) => kind(o) === "html");
29
+ const raster = config.output.filter((o) => kind(o) === "raster");
30
+
31
+ const result: RenderResult = { outputs: [], frames: 0, screenshots: [], durationSeconds: 0 };
32
+
33
+ for (const file of svg) {
34
+ await mkdir(path.dirname(path.resolve(file)), { recursive: true });
35
+ const r = await writeSvg(rec, config, file);
36
+ result.outputs.push(file);
37
+ result.frames = Math.max(result.frames, r.frames);
38
+ result.durationSeconds = r.duration;
39
+ }
40
+ for (const file of html) {
41
+ await writeHtml(rec, config, file);
42
+ result.outputs.push(file);
43
+ }
44
+ if (raster.length > 0) {
45
+ const r = await renderRaster(rec, { ...config, output: raster }, onProgress);
46
+ result.outputs.push(...r.outputs);
47
+ result.frames = r.frames;
48
+ result.screenshots.push(...r.screenshots);
49
+ result.durationSeconds = r.durationSeconds;
50
+ }
51
+ return result;
52
+ }