termcut 0.5.1 → 0.6.1

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.
package/README.md CHANGED
@@ -51,6 +51,8 @@ defineVideo({ output: "demo.mp4", browser: { position: "overlay" } }, async (t)
51
51
  });
52
52
  ```
53
53
 
54
+ Polish: `keys: true` shows key presses, `maxPause: "800ms"` cuts dead air, `t.zoom({ rows: [0, 5] })` magnifies output, `t.chapter("Install")` adds mp4 chapters, `preset: "x"` sizes it for X. `tcut diff a.cast b.cast` catches output changes in CI.
55
+
54
56
  Re-render any recording without re-running it — ~600 themes ([Ghostty's collection](https://github.com/mbadolato/iTerm2-Color-Schemes)), `tcut themes` lists them:
55
57
 
56
58
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
5
5
  "license": "MIT",
6
6
  "author": "Aman Varshney",
package/src/browser.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { WINDOW_BAR_HEIGHT, estimateCell } from "./config";
2
+ import { toMs } from "./duration";
3
+ import { WaitTimeoutError } from "./errors";
4
+ import type { BrowserFrame, BrowserSession, ResolvedConfig } from "./types";
5
+
6
+ export interface BrowserCapture extends BrowserSession {
7
+ /** Frames captured so far (only when the page's pixels changed). */
8
+ frames: BrowserFrame[];
9
+ /** Stop sampling and close the WebView. */
10
+ stop(): Promise<void>;
11
+ }
12
+
13
+ /** "better-t-stack.dev" → "https://better-t-stack.dev"; localhost defaults to http. */
14
+ export function normalizeUrl(url: string): string {
15
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || /^(about|data|file|blob):/i.test(url)) return url;
16
+ return /^(localhost|127\.|0\.0\.0\.0|\[::1\])/.test(url) ? `http://${url}` : `https://${url}`;
17
+ }
18
+
19
+ const toRegExp = (pattern: RegExp | string): RegExp =>
20
+ typeof pattern === "string" ? new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) : pattern;
21
+
22
+ /**
23
+ * A Bun.WebView sampled on the recording clock, shared by scripted and live recording. Only changed frames are
24
+ * kept; every WebView call is bounded by a timeout so a stuck page can never hang a recording.
25
+ */
26
+ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number, log: (m: string) => void = () => {}): BrowserCapture {
27
+ if (!config.browser) throw new Error("startBrowserCapture needs config.browser");
28
+ if (typeof Bun.WebView !== "function") throw new Error("The browser pane needs Bun.WebView (Bun >= 1.4).");
29
+ const bcfg = config.browser;
30
+
31
+ // Default pane size: match the terminal window (estimated from the font metrics) unless given.
32
+ const est = estimateCell(config.font);
33
+ const termFrameH = Math.round(config.rows * est.h + config.padding * 2 + (config.windowBar === "none" ? 0 : WINDOW_BAR_HEIGHT));
34
+ const termFrameW = Math.round(config.cols * est.w + config.padding * 2);
35
+ const stacked = bcfg.position === "top" || bcfg.position === "bottom";
36
+ const paneW = stacked ? termFrameW : bcfg.width;
37
+ const paneH = bcfg.height || (stacked || bcfg.position === "overlay" ? 480 : termFrameH);
38
+ const view = new Bun.WebView({ width: paneW, height: paneH });
39
+
40
+ const frames: BrowserFrame[] = [];
41
+ const within = <T,>(promise: Promise<T>, ms: number, label: string): Promise<T> =>
42
+ Promise.race([promise, Bun.sleep(ms).then(() => Promise.reject(new Error(`browser.${label} did not finish within ${ms}ms`)))]);
43
+
44
+ let currentUrl = bcfg.url ?? "about:blank";
45
+ let lastHash = "";
46
+ let running = true;
47
+ const sampler = (async () => {
48
+ while (running) {
49
+ try {
50
+ const png = (await within(view.screenshot({ encoding: "buffer" }), 5000, "screenshot")) as Uint8Array;
51
+ const hash = Bun.hash(png).toString(16);
52
+ if (hash !== lastHash) {
53
+ lastHash = hash;
54
+ frames.push({ time: stamp(), png });
55
+ }
56
+ } catch {
57
+ /* view busy or closed */
58
+ }
59
+ await Bun.sleep(1000 / bcfg.fps);
60
+ }
61
+ })();
62
+
63
+ /**
64
+ * Navigate and wait for the page to be there. Dev servers may still be starting (connection refused → retry)
65
+ * or take a long first load (Vite pre-bundling, then a reload), so success is judged by the document's
66
+ * readyState at the target URL rather than by the navigate() promise alone.
67
+ */
68
+ const goto = async (rawUrl: string): Promise<void> => {
69
+ const url = normalizeUrl(rawUrl);
70
+ const deadline = performance.now() + config.waitTimeout;
71
+ const target = url.replace(/\/$/, "");
72
+ let navigation: Promise<"ok" | "pending" | "failed"> | null = null;
73
+ for (;;) {
74
+ if (!running) return; // stop() closed the view mid-retry; abort quietly
75
+ try {
76
+ navigation ??= view.navigate(url).then(
77
+ () => "ok" as const,
78
+ (err: unknown) => (/pending/i.test(String(err)) ? ("pending" as const) : ("failed" as const)),
79
+ );
80
+ } catch {
81
+ return; // navigate threw synchronously: the view is closed
82
+ }
83
+ const outcome = await Promise.race([navigation, Bun.sleep(750).then(() => "tick" as const)]);
84
+ if (outcome === "ok") {
85
+ currentUrl = url;
86
+ return;
87
+ }
88
+ if (outcome === "failed") navigation = null;
89
+ if (!running) return;
90
+ const state = await within(view.evaluate("document.readyState"), 3000, "goto").catch(() => "");
91
+ if (state === "complete" && (view.url ?? "").replace(/\/$/, "").startsWith(target)) {
92
+ currentUrl = url;
93
+ return;
94
+ }
95
+ if (performance.now() > deadline) {
96
+ throw new WaitTimeoutError(`browser.goto(${url})`, config.waitTimeout, `current url: ${view.url ?? "(none)"}, readyState: ${state || "unknown"}`);
97
+ }
98
+ await Bun.sleep(250);
99
+ }
100
+ };
101
+
102
+ const capture: BrowserCapture = {
103
+ frames,
104
+ get url() {
105
+ return currentUrl;
106
+ },
107
+ goto,
108
+ async waitFor(pattern, waitOpts = {}) {
109
+ const regex = toRegExp(pattern);
110
+ const timeout = toMs(waitOpts.timeout, config.waitTimeout);
111
+ const deadline = performance.now() + timeout;
112
+ for (;;) {
113
+ const text = String((await within(view.evaluate("document.body ? document.body.innerText : ''"), 5000, "waitFor").catch(() => "")) ?? "");
114
+ if (regex.test(text)) return;
115
+ if (performance.now() > deadline) throw new WaitTimeoutError(`${regex} in the browser page`, timeout, text.slice(0, 2000));
116
+ await Bun.sleep(150);
117
+ }
118
+ },
119
+ click: (selector) => within(view.click(selector), 10000, "click"),
120
+ reload: () => within(view.reload(), 30000, "reload").catch((err) => (/pending/i.test(String(err)) ? undefined : Promise.reject(err))),
121
+ evaluate: (js) => within(view.evaluate(js), 10000, "evaluate"),
122
+ async stop() {
123
+ running = false;
124
+ await sampler;
125
+ try {
126
+ view.close();
127
+ } catch {
128
+ /* closed */
129
+ }
130
+ },
131
+ };
132
+
133
+ if (bcfg.url) void goto(bcfg.url).catch((err) => log(String(err)));
134
+ return capture;
135
+ }
package/src/cast.ts CHANGED
@@ -42,5 +42,7 @@ export const MARKER = {
42
42
  show: "show",
43
43
  screenshot: "screenshot:",
44
44
  focus: "focus:",
45
+ zoom: "zoom:",
46
+ chapter: "chapter:",
45
47
  end: "end",
46
48
  } as const;
package/src/cli.ts CHANGED
@@ -7,12 +7,14 @@ import { resolveConfig } from "./config";
7
7
  import * as api from "./index";
8
8
  import { recordLive } from "./live";
9
9
  import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig } from "./publish";
10
+ import { diffCasts } from "./diff";
11
+ import { presetNames, type PresetName } from "./presets";
10
12
  import { renderOutputs } from "./render";
11
13
  import { generateScript } from "./scriptgen";
12
14
  import { runScriptTests } from "./testing";
13
15
  import { findThemes, themeNames } from "./themes";
14
- import type { CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
15
- import { Video, isVideo, renderCast } from "./video";
16
+ import type { BrowserConfig, CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
17
+ import { Video, attachBrowserFrames, isVideo, renderCast } from "./video";
16
18
 
17
19
  // Let user scripts `import { defineVideo } from "tcut"` (or "termcut", the npm package name) regardless of
18
20
  // where they live or whether this is the compiled binary (no node_modules there): resolve the bare specifier
@@ -34,6 +36,7 @@ Usage:
34
36
  tcut record <script.ts> [options] record only (writes the .cast)
35
37
  tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
36
38
  tcut test <path...> run scripts in fast mode as tests (no video)
39
+ tcut diff <a.cast> <b.cast> compare what two recordings show on screen (exit 1 if different)
37
40
  tcut publish <files...> [--open] upload to your S3-compatible bucket and print share links
38
41
  tcut publish --setup configure the bucket (RustFS, MinIO, R2, S3 …) — once
39
42
  tcut init [name] [--template t] scaffold a new script (basic | tour | test)
@@ -51,6 +54,12 @@ Options (override the script's config):
51
54
  --cols <n> --rows <n> terminal grid (rec: defaults to your terminal's size)
52
55
  --width <px> --height <px> video size; the grid is derived and centred inside
53
56
  --loop-offset <n|N%> where GIF/WebP loops start
57
+ --max-pause <dur> idle compression: cap gaps between events (e.g. 800ms)
58
+ --keys show recent key presses as chips
59
+ --preset <name> readme | x | youtube | square
60
+ --browser <url> rec: record a browser window too (--browser-position right|left|top|bottom|overlay)
61
+ --at <seconds> diff: compare the screen at this time instead of the end
62
+ --images <dir> diff: also write a.png / b.png
54
63
  --cast <path> where to read/write the .cast
55
64
  --record-only stop after writing the cast
56
65
  --no-script rec: don't write the editable <name>.video.ts next to the cast
@@ -89,6 +98,13 @@ const { values, positionals } = parseArgs({
89
98
  width: { type: "string" },
90
99
  height: { type: "string" },
91
100
  "loop-offset": { type: "string" },
101
+ "max-pause": { type: "string" },
102
+ keys: { type: "boolean" },
103
+ preset: { type: "string" },
104
+ browser: { type: "string" },
105
+ "browser-position": { type: "string" },
106
+ at: { type: "string" },
107
+ images: { type: "string" },
92
108
  cast: { type: "string" },
93
109
  "record-only": { type: "boolean" },
94
110
  "no-script": { type: "boolean" },
@@ -169,6 +185,17 @@ function overridesFromFlags(): Partial<VideoConfig> {
169
185
  if (values.width !== undefined) o.width = num("width");
170
186
  if (values.height !== undefined) o.height = num("height");
171
187
  if (values["loop-offset"] !== undefined) o.loopOffset = values["loop-offset"];
188
+ if (values["max-pause"] !== undefined) o.maxPause = values["max-pause"];
189
+ if (values.keys) o.keys = true;
190
+ if (values.preset) {
191
+ if (!presetNames.includes(values.preset as PresetName)) fail(`--preset must be one of ${presetNames.join(", ")}`);
192
+ o.preset = values.preset as PresetName;
193
+ }
194
+ if (values.browser) {
195
+ const position = values["browser-position"] as BrowserConfig["position"] | undefined;
196
+ if (position && !["right", "left", "top", "bottom", "overlay"].includes(position)) fail("--browser-position must be right, left, top, bottom or overlay");
197
+ o.browser = { url: values.browser, ...(position && { position }) };
198
+ }
172
199
  return o;
173
200
  }
174
201
 
@@ -388,7 +415,9 @@ async function main(): Promise<void> {
388
415
  rows: overrides.rows ?? (sized ? config.rows : undefined),
389
416
  });
390
417
  await mkdir(path.dirname(path.resolve(config.cast)), { recursive: true });
418
+ await attachBrowserFrames(recording, path.resolve(config.cast));
391
419
  await writeCast(config.cast, recording);
420
+ recording.source = path.resolve(config.cast);
392
421
  log("");
393
422
  ok(`wrote ${config.cast}`, `${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s`);
394
423
  if (!values["no-script"]) {
@@ -422,6 +451,20 @@ async function main(): Promise<void> {
422
451
  emit({ cast: rest[0], outputs: files, frames: result.frames, durationSeconds: result.durationSeconds });
423
452
  return;
424
453
  }
454
+ case "diff": {
455
+ if (rest.length < 2) fail("diff needs two .cast files");
456
+ const result = await diffCasts(rest[0]!, rest[1]!, { at: values.at !== undefined ? num("at") : undefined, images: values.images });
457
+ emit(result);
458
+ if (!json) {
459
+ if (result.equal) ok("screens match");
460
+ else {
461
+ for (const line of result.lines) log(line.startsWith("- ") ? red(line) : line.startsWith("+ ") ? green(line) : dim(line));
462
+ if (result.images) log(dim(` images: ${result.images.a} ${result.images.b}`));
463
+ }
464
+ }
465
+ process.exit(result.equal ? 0 : 1);
466
+ }
467
+ // eslint-disable-next-line no-fallthrough -- process.exit above never returns
425
468
  case "test": {
426
469
  if (rest.length === 0) fail("test needs at least one script file or directory");
427
470
  const summary = await runScriptTests(rest, json ? () => {} : (line) => console.log(line));
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { toMs } from "./duration";
3
+ import { applyPreset } from "./presets";
3
4
  import { resolveTheme } from "./themes";
4
5
  import type { ResolvedConfig, VideoConfig } from "./types";
5
6
 
@@ -21,7 +22,8 @@ export function estimateCell(font: { size: number; lineHeight: number; letterSpa
21
22
 
22
23
  export const WINDOW_BAR_HEIGHT = 36;
23
24
 
24
- export function resolveConfig(config: VideoConfig): ResolvedConfig {
25
+ export function resolveConfig(input: VideoConfig): ResolvedConfig {
26
+ const config = applyPreset(input);
25
27
  const outputs = Array.isArray(config.output) ? config.output : [config.output];
26
28
  if (outputs.length === 0) throw new Error("config.output must name at least one output");
27
29
 
@@ -60,6 +62,15 @@ export function resolveConfig(config: VideoConfig): ResolvedConfig {
60
62
  ...(config.width !== undefined && { width: config.width }),
61
63
  ...(config.height !== undefined && { height: config.height }),
62
64
  ...(config.loopOffset !== undefined && { loopOffset: config.loopOffset }),
65
+ ...(config.maxPause !== undefined && { maxPause: toMs(config.maxPause) / 1000 }),
66
+ ...(config.keys && {
67
+ keys: {
68
+ position: (config.keys === true ? undefined : config.keys.position) ?? "bottom",
69
+ ttl: toMs(config.keys === true ? undefined : config.keys.ttl, 1200),
70
+ merge: toMs(config.keys === true ? undefined : config.keys.merge, 350),
71
+ limit: (config.keys === true ? undefined : config.keys.limit) ?? 1,
72
+ },
73
+ }),
63
74
  fps: config.fps ?? 60,
64
75
  typingSpeed: toMs(config.typingSpeed, 50),
65
76
  typingJitter: Math.min(1, Math.max(0, config.typingJitter ?? 0)),
package/src/diff.ts ADDED
@@ -0,0 +1,91 @@
1
+ import path from "node:path";
2
+ import { readCast } from "./cast";
3
+ import { applyOverrides, resolveConfig } from "./config";
4
+ import { replayFrames, type GridFrame } from "./export/frames";
5
+ import { renderOutputs } from "./render";
6
+ import type { Recording, ResolvedConfig } from "./types";
7
+
8
+ export interface DiffOptions {
9
+ /** Compare the screen at this time on the visible timeline (seconds). Default: the last frame. */
10
+ at?: number;
11
+ /** Write `a.png` / `b.png` of the compared frames into this directory. */
12
+ images?: string;
13
+ }
14
+
15
+ export interface DiffResult {
16
+ equal: boolean;
17
+ a: string[];
18
+ b: string[];
19
+ /** Unified-ish diff lines (" same", "- only in a", "+ only in b"). */
20
+ lines: string[];
21
+ images?: { a: string; b: string };
22
+ }
23
+
24
+ function frameAt(frames: GridFrame[], at: number | undefined): GridFrame {
25
+ if (at === undefined) return frames[frames.length - 1]!;
26
+ let chosen = frames[0]!;
27
+ for (const f of frames) if (f.time <= at + 1e-9) chosen = f;
28
+ return chosen;
29
+ }
30
+
31
+ function rowsText(frame: GridFrame): string[] {
32
+ const out: string[] = [];
33
+ for (let y = 0; y < frame.rows; y++) {
34
+ const cells = frame.rows_.get(y);
35
+ out.push(cells ? cells.map((c) => c.text).join("").replace(/\s+$/, "") : "");
36
+ }
37
+ while (out.length && out[out.length - 1] === "") out.pop();
38
+ return out;
39
+ }
40
+
41
+ /** Simple LCS-based line diff — screens are small, so O(n·m) is fine. */
42
+ function diffLines(a: string[], b: string[]): string[] {
43
+ const n = a.length;
44
+ const m = b.length;
45
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
46
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
47
+ const out: string[] = [];
48
+ let i = 0;
49
+ let j = 0;
50
+ while (i < n && j < m) {
51
+ if (a[i] === b[j]) {
52
+ out.push(` ${a[i]}`);
53
+ i++;
54
+ j++;
55
+ } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
56
+ out.push(`- ${a[i]}`);
57
+ i++;
58
+ } else {
59
+ out.push(`+ ${b[j]}`);
60
+ j++;
61
+ }
62
+ }
63
+ while (i < n) out.push(`- ${a[i++]}`);
64
+ while (j < m) out.push(`+ ${b[j++]}`);
65
+ return out;
66
+ }
67
+
68
+ async function screenOf(rec: Recording, at: number | undefined): Promise<{ text: string[]; config: ResolvedConfig }> {
69
+ const base = rec.header.bunVideo ?? resolveConfig({ output: "x.svg", cols: rec.header.width, rows: rec.header.height });
70
+ const config = applyOverrides(base, {});
71
+ const replay = await replayFrames(rec, config);
72
+ return { text: rowsText(frameAt(replay.frames, at)), config };
73
+ }
74
+
75
+ /** Compare what two recordings show on screen (text, not pixels) at the end or at a given time. */
76
+ export async function diffCasts(fileA: string, fileB: string, opts: DiffOptions = {}): Promise<DiffResult> {
77
+ const [recA, recB] = await Promise.all([readCast(fileA), readCast(fileB)]);
78
+ const [a, b] = await Promise.all([screenOf(recA, opts.at), screenOf(recB, opts.at)]);
79
+ const equal = a.text.length === b.text.length && a.text.every((line, i) => line === b.text[i]);
80
+ const result: DiffResult = { equal, a: a.text, b: b.text, lines: equal ? [] : diffLines(a.text, b.text) };
81
+ if (opts.images) {
82
+ const dir = path.resolve(opts.images);
83
+ const pa = path.join(dir, "a.png");
84
+ const pb = path.join(dir, "b.png");
85
+ const speedA = opts.at === undefined ? 1 : 1; // stills are the final frame; `at` applies to text only
86
+ await renderOutputs(recA, { ...a.config, output: [pa], playbackSpeed: speedA });
87
+ await renderOutputs(recB, { ...b.config, output: [pb], playbackSpeed: speedA });
88
+ result.images = { a: pa, b: pb };
89
+ }
90
+ return result;
91
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { formatMs } from "./duration";
2
+
3
+ export class WaitTimeoutError extends Error {
4
+ constructor(what: string, timeoutMs: number, screen: string) {
5
+ super(`Timed out after ${formatMs(timeoutMs)} waiting for ${what}.\n\n--- screen ---\n${screen}\n--------------`);
6
+ this.name = "WaitTimeoutError";
7
+ }
8
+ }
9
+
10
+ export class ExpectationError extends Error {
11
+ constructor(what: string, screen: string) {
12
+ super(`Expected ${what} to match.\n\n--- screen ---\n${screen}\n--------------`);
13
+ this.name = "ExpectationError";
14
+ }
15
+ }
package/src/index.ts CHANGED
@@ -16,6 +16,11 @@ export { publishFiles, loadPublishConfig, savePublishConfig, ensurePublicBucket,
16
16
  export type { PublishConfig, Published, PublishOptions } from "./publish";
17
17
  export { readCast, writeCast, parseCast, serializeCast } from "./cast";
18
18
  export { buildTimeline } from "./timeline";
19
+ export { presets, presetNames, applyPreset } from "./presets";
20
+ export { diffCasts } from "./diff";
21
+ export type { DiffOptions, DiffResult } from "./diff";
22
+ export { keyLabels, keyChips } from "./keylabels";
23
+ export { startBrowserCapture } from "./browser";
19
24
  export { resolveConfig } from "./config";
20
25
  export { WaitTimeoutError, ExpectationError } from "./recorder";
21
26
  export type * from "./types";
@@ -0,0 +1,81 @@
1
+ import { tokenize } from "./scriptgen";
2
+
3
+ const NAMED: Record<string, string> = {
4
+ "\r": "⏎",
5
+ "\n": "⏎",
6
+ "\t": "⇥",
7
+ "\x1b[Z": "⇧⇥",
8
+ "\x7f": "⌫",
9
+ "\x1b": "esc",
10
+ "\x1b[A": "↑",
11
+ "\x1b[B": "↓",
12
+ "\x1b[C": "→",
13
+ "\x1b[D": "←",
14
+ "\x1bOA": "↑",
15
+ "\x1bOB": "↓",
16
+ "\x1bOC": "→",
17
+ "\x1bOD": "←",
18
+ "\x1b[1;2A": "⇧↑",
19
+ "\x1b[1;2B": "⇧↓",
20
+ "\x1b[1;2C": "⇧→",
21
+ "\x1b[1;2D": "⇧←",
22
+ "\x1b[H": "home",
23
+ "\x1b[F": "end",
24
+ "\x1b[3~": "del",
25
+ "\x1b[5~": "pgup",
26
+ "\x1b[6~": "pgdn",
27
+ };
28
+
29
+ /** Human-readable labels for a raw input chunk: printable runs stay words, control sequences become symbols. */
30
+ export function keyLabels(input: string): string[] {
31
+ const labels: string[] = [];
32
+ for (const token of tokenize(input)) {
33
+ if (token === " ") { labels.push(" "); continue; }
34
+ const named = NAMED[token];
35
+ if (named) {
36
+ labels.push(named);
37
+ } else if (token.length === 1 && token.charCodeAt(0) < 32) {
38
+ labels.push(`⌃${String.fromCharCode(token.charCodeAt(0) + 64)}`);
39
+ } else if (token.length === 2 && token[0] === "\x1b") {
40
+ labels.push(`⌥${token[1]}`);
41
+ } else if (token.startsWith("\x1b[<")) {
42
+ labels.push(/M$/.test(token) && /^\x1b\[<6[45]/.test(token) ? "wheel" : "mouse");
43
+ } else if (token.startsWith("\x1b")) {
44
+ labels.push("esc…");
45
+ } else {
46
+ labels.push(token);
47
+ }
48
+ }
49
+ return labels;
50
+ }
51
+
52
+ export interface KeyChip {
53
+ /** Time the chip appeared (visible timeline, seconds). */
54
+ at: number;
55
+ label: string;
56
+ }
57
+
58
+ /**
59
+ * Turn timed input events into chips. Printable keystrokes within `mergeWithin` seconds of each other are merged
60
+ * into one chip (so typing reads as words, not a flood of letters); named keys always get their own chip.
61
+ */
62
+ export function keyChips(inputs: Array<{ vt: number; data: string }>, mergeWithin = 0.35): KeyChip[] {
63
+ const chips: KeyChip[] = [];
64
+ let lastPrintable: KeyChip | null = null;
65
+ let lastTime = -Infinity;
66
+ for (const { vt, data } of inputs) {
67
+ for (const label of keyLabels(data)) {
68
+ const printable = !/^[⏎⇥⌫␣↑↓→←⌃⌥⇧]|^(esc|home|end|del|pgup|pgdn|wheel|mouse)/.test(label);
69
+ if (printable && lastPrintable && vt - lastTime <= mergeWithin) {
70
+ lastPrintable.label += label;
71
+ lastPrintable.at = vt;
72
+ } else {
73
+ const chip = { at: vt, label };
74
+ chips.push(chip);
75
+ lastPrintable = printable ? chip : null;
76
+ }
77
+ lastTime = vt;
78
+ }
79
+ }
80
+ return chips;
81
+ }
package/src/live.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { startBrowserCapture } from "./browser";
1
2
  import { MARKER } from "./cast";
2
3
  import { shellSetup } from "./recorder";
3
4
  import type { CastEvent, Recording, ResolvedConfig } from "./types";
@@ -37,6 +38,7 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
37
38
  events.push([stamp(), type, data]);
38
39
  };
39
40
 
41
+ const browser = config.browser ? startBrowserCapture({ ...config, cols, rows }, stamp, log) : null;
40
42
  const setup = opts.command ? { cmd: opts.command, env: {} } : shellSetup(config);
41
43
  const env: Record<string, string> = {
42
44
  ...process.env,
@@ -98,6 +100,7 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
98
100
  await Promise.race([exitedPromise, proc.exited]);
99
101
  push("m", MARKER.end);
100
102
  } finally {
103
+ await browser?.stop();
101
104
  (process as unknown as { off(event: string, fn: () => void): void }).off("SIGWINCH", onResize); // newer @types/bun drop the signal overload on off()
102
105
  if (stdin) {
103
106
  stdin.off("data", onData);
@@ -126,5 +129,6 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
126
129
  bunVideo: { ...config, cols, rows },
127
130
  },
128
131
  events,
132
+ ...(browser && { browserFrames: browser.frames }),
129
133
  };
130
134
  }
package/src/presets.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { VideoConfig } from "./types";
2
+
3
+ export type PresetName = "readme" | "x" | "youtube" | "square";
4
+
5
+ /** Opinionated bundles applied *under* whatever the config sets explicitly. */
6
+ export const presets: Record<PresetName, Partial<VideoConfig>> = {
7
+ // Small, loops well, reads at README width.
8
+ readme: { cols: 80, rows: 20, fps: 30, font: { size: 18 }, padding: 20, margin: 0, borderRadius: 8, windowBar: "none", typingSpeed: "40ms" },
9
+ // 16:9 at the size X/Twitter serves without downscaling.
10
+ x: { width: 1280, height: 720, fps: 30, font: { size: 20 }, padding: 24, margin: 24, borderRadius: 12, windowBar: "colorful", typingSpeed: "35ms", typingJitter: 0.3 },
11
+ // Full HD, smooth.
12
+ youtube: { width: 1920, height: 1080, fps: 60, font: { size: 26 }, padding: 32, margin: 40, borderRadius: 14, windowBar: "colorful", typingSpeed: "35ms", typingJitter: 0.3 },
13
+ // 1:1 for feeds.
14
+ square: { width: 1080, height: 1080, fps: 30, font: { size: 22 }, padding: 24, margin: 32, borderRadius: 14, windowBar: "colorful", typingSpeed: "35ms" },
15
+ };
16
+
17
+ export const presetNames = Object.keys(presets) as PresetName[];
18
+
19
+ export function applyPreset(config: VideoConfig): VideoConfig {
20
+ if (!config.preset) return config;
21
+ const base = presets[config.preset];
22
+ if (!base) throw new Error(`Unknown preset "${config.preset}". Available: ${presetNames.join(", ")}`);
23
+ return { ...base, ...config, font: { ...base.font, ...config.font } };
24
+ }