termcut 0.6.3 → 0.7.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.
package/README.md CHANGED
@@ -51,7 +51,15 @@ 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.
54
+ Polish: `shadow: true`, `watermark: "© you"`, `marginFill: "transparent"` (real alpha in PNG/WebP/GIF/WebM/SVG), `keys: true` shows key presses, `maxPause: "800ms"` cuts dead air, `t.timelapse(fn, { speed: 8 })` fast-forwards an install, `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
+
56
+ Cut and join without re-recording — on the cast, so every format works:
57
+
58
+ ```sh
59
+ tcut render demo.cast --from 2s --to 10s -o clip.gif # a window of the video
60
+ tcut render demo.cast --split-chapters -o demo.mp4 # one file per t.chapter()
61
+ tcut concat intro.cast demo.cast --gap 500ms -o launch.mp4
62
+ ```
55
63
 
56
64
  Re-render any recording without re-running it — ~600 themes ([Ghostty's collection](https://github.com/mbadolato/iTerm2-Color-Schemes)), `tcut themes` lists them:
57
65
 
@@ -66,6 +74,14 @@ tcut publish --setup # once: your S3-compatible bucket (RustFS, MinIO, R2
66
74
  tcut publish demo.gif # → https://…/3f9a1c2b7d4e/demo.gif
67
75
  ```
68
76
 
77
+ ## Agents
78
+
79
+ ```sh
80
+ npx skills add AmanVarshney01/tcut # tcut + tcut-remotion skills for Claude Code, Cursor, etc.
81
+ ```
82
+
83
+ Two skills: `tcut` (record terminal videos) and `tcut-remotion` (compose tcut footage into launch videos with [Remotion](https://remotion.dev)). Plus [llms.txt](https://tcut.amanv.dev/llms.txt) and `--json` everywhere.
84
+
69
85
  ## More
70
86
 
71
87
  - [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) — driving an interactive TUI, recording Claude Code / Codex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
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",
@@ -47,15 +47,19 @@ function parse(name: string, text: string): Record<string, string> | null {
47
47
  const foreground = hex(props.foreground ?? "");
48
48
  if (!background || !foreground) return null;
49
49
  for (let i = 0; i < 16; i++) if (!palette[i]) return null;
50
- const theme: Record<string, string> = { name, background, foreground };
50
+ const entries: [string, string][] = [
51
+ ["name", name],
52
+ ["background", background],
53
+ ["foreground", foreground],
54
+ ];
51
55
  const cursor = hex(props["cursor-color"] ?? "");
52
- if (cursor) theme.cursor = cursor;
56
+ if (cursor) entries.push(["cursor", cursor]);
53
57
  const cursorText = hex(props["cursor-text"] ?? "");
54
- if (cursorText) theme.cursorAccent = cursorText;
58
+ if (cursorText) entries.push(["cursorAccent", cursorText]);
55
59
  const selection = hex(props["selection-background"] ?? "");
56
- if (selection) theme.selectionBackground = selection;
57
- KEYS.forEach((k, i) => (theme[k] = palette[i]!));
58
- return theme;
60
+ if (selection) entries.push(["selectionBackground", selection]);
61
+ KEYS.forEach((k, i) => entries.push([k, palette[i]!]));
62
+ return Object.fromEntries(entries);
59
63
  }
60
64
 
61
65
  const tmp = await mkdtemp(path.join(tmpdir(), "tcut-themes-"));
package/src/browser.ts CHANGED
@@ -17,7 +17,7 @@ export function normalizeUrl(url: string): string {
17
17
  }
18
18
 
19
19
  const toRegExp = (pattern: RegExp | string): RegExp =>
20
- typeof pattern === "string" ? new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) : pattern;
20
+ pattern instanceof RegExp ? pattern : new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
21
21
 
22
22
  /**
23
23
  * A Bun.WebView sampled on the recording clock, shared by scripted and live recording. Only changed frames are
@@ -25,7 +25,7 @@ const toRegExp = (pattern: RegExp | string): RegExp =>
25
25
  */
26
26
  export function startBrowserCapture(config: ResolvedConfig, stamp: () => number, log: (m: string) => void = () => {}): BrowserCapture {
27
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).");
28
+ if (!Bun.WebView) throw new Error("The browser pane needs Bun.WebView (Bun >= 1.4).");
29
29
  const bcfg = config.browser;
30
30
 
31
31
  // Default pane size: match the terminal window (estimated from the font metrics) unless given.
@@ -75,7 +75,7 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
75
75
  try {
76
76
  navigation ??= view.navigate(url).then(
77
77
  () => "ok" as const,
78
- (err: unknown) => (/pending/i.test(String(err)) ? ("pending" as const) : ("failed" as const)),
78
+ (cause: unknown) => (/pending/i.test(String(cause)) ? ("pending" as const) : ("failed" as const)),
79
79
  );
80
80
  } catch {
81
81
  return; // navigate threw synchronously: the view is closed
package/src/cast.ts CHANGED
@@ -44,5 +44,7 @@ export const MARKER = {
44
44
  focus: "focus:",
45
45
  zoom: "zoom:",
46
46
  chapter: "chapter:",
47
+ /** Render-clock speed for the events that follow (`speed:8`); `speed:1` restores real time. */
48
+ speed: "speed:",
47
49
  end: "end",
48
50
  } as const;
package/src/cli.ts CHANGED
@@ -2,19 +2,21 @@
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { parseArgs } from "node:util";
5
- import { writeCast } from "./cast";
6
- import { resolveConfig } from "./config";
5
+ import { readCast, writeCast } from "./cast";
6
+ import { applyOverrides, resolveConfig } from "./config";
7
7
  import * as api from "./index";
8
8
  import { recordLive } from "./live";
9
- import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig } from "./publish";
10
- import { diffCasts } from "./diff";
9
+ import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig, type Published } from "./publish";
10
+ import { diffCasts, type DiffResult } from "./diff";
11
+ import { toMs } from "./duration";
12
+ import { concatRecordings, cutRecording, flattenedConfig, rebaseBrowserFrames, recordingDuration, selectChapters } from "./edit";
11
13
  import { presetNames, type PresetName } from "./presets";
12
14
  import { renderOutputs } from "./render";
13
15
  import { generateScript } from "./scriptgen";
14
- import { runScriptTests } from "./testing";
16
+ import { runScriptTests, type TestSummary } from "./testing";
15
17
  import { findThemes, themeNames } from "./themes";
16
- import type { BrowserConfig, CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
17
- import { Video, attachBrowserFrames, isVideo, renderCast } from "./video";
18
+ import type { BrowserConfig, ClipSelection, CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
19
+ import { Video, attachBrowserFrames, castConfig, isVideo, renderCast } from "./video";
18
20
 
19
21
  // Let user scripts `import { defineVideo } from "tcut"` (or "termcut", the npm package name) regardless of
20
22
  // where they live or whether this is the compiled binary (no node_modules there): resolve the bare specifier
@@ -37,6 +39,8 @@ Usage:
37
39
  tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
38
40
  tcut test <path...> run scripts in fast mode as tests (no video)
39
41
  tcut diff <a.cast> <b.cast> compare what two recordings show on screen (exit 1 if different)
42
+ tcut cut <file.cast> --from 2s --to 10s [--cast out.cast] [-o …] keep part of a recording (by time or --chapters)
43
+ tcut concat <a.cast> <b.cast…> [--gap 500ms] [--cast out.cast] [-o …] join recordings end to end
40
44
  tcut publish <files...> [--open] upload to your S3-compatible bucket and print share links
41
45
  tcut publish --setup configure the bucket (RustFS, MinIO, R2, S3 …) — once
42
46
  tcut init [name] [--template t] scaffold a new script (basic | tour | test)
@@ -56,6 +60,12 @@ Options (override the script's config):
56
60
  --loop-offset <n|N%> where GIF/WebP loops start
57
61
  --max-pause <dur> idle compression: cap gaps between events (e.g. 800ms)
58
62
  --keys show recent key presses as chips
63
+ --shadow drop shadow under the window (margin defaults to 40)
64
+ --watermark <text> text in the bottom-right corner; --watermark-image <file> for a logo
65
+ --from <t> --to <t> render/cut only this part of the visible timeline (seconds, or "1.5s", "2m")
66
+ --chapters <a,b> render/cut only these chapters (titles or numbers), joined in that order
67
+ --split-chapters one output per chapter: demo.mp4 → demo-01-install.mp4 …
68
+ --gap <dur> concat: still time between parts
59
69
  --preset <name> readme | x | youtube | square
60
70
  --browser <url> rec: record a browser window too (--browser-position right|left|top|bottom|overlay)
61
71
  --at <seconds> diff: compare the screen at this time instead of the end
@@ -71,6 +81,8 @@ Options (override the script's config):
71
81
  --json machine-readable result (or { "error" }) on stdout, nothing else
72
82
  -q, --quiet
73
83
  -h, --help
84
+
85
+ Agents: npx skills add AmanVarshney01/tcut · docs: https://tcut.amanv.dev/llms.txt
74
86
  `;
75
87
 
76
88
  const { values, positionals } = parseArgs({
@@ -103,6 +115,14 @@ const { values, positionals } = parseArgs({
103
115
  preset: { type: "string" },
104
116
  browser: { type: "string" },
105
117
  "browser-position": { type: "string" },
118
+ shadow: { type: "boolean" },
119
+ watermark: { type: "string" },
120
+ "watermark-image": { type: "string" },
121
+ from: { type: "string" },
122
+ to: { type: "string" },
123
+ chapters: { type: "string" },
124
+ "split-chapters": { type: "boolean" },
125
+ gap: { type: "string" },
106
126
  at: { type: "string" },
107
127
  images: { type: "string" },
108
128
  cast: { type: "string" },
@@ -127,8 +147,25 @@ const { values, positionals } = parseArgs({
127
147
 
128
148
  const json = values.json === true;
129
149
  const quiet = values.quiet === true || json;
150
+ interface OutputFile {
151
+ path: string;
152
+ bytes: number;
153
+ }
154
+
155
+ /** Every shape `--json` can print (one document on stdout; failures print `{ error, type }` instead). */
156
+ type CliReport =
157
+ | { published: Published[] }
158
+ | { cast: string; script: string | null; events: number; durationSeconds: number }
159
+ | { cast: string; script: string | null; outputs: OutputFile[]; frames: number; durationSeconds: number }
160
+ | { cast: string; cached: boolean; events: number; durationSeconds: number }
161
+ | { cast: string; outputs: OutputFile[]; frames: number; durationSeconds: number }
162
+ | { cast: string; cached: boolean; outputs: OutputFile[]; frames: number; durationSeconds: number }
163
+ | { cast: string; events: number; durationSeconds: number; outputs: OutputFile[] }
164
+ | DiffResult
165
+ | TestSummary;
166
+
130
167
  /** With --json, the only thing on stdout is one JSON document (results or { error }). */
131
- const emit = (data: unknown) => {
168
+ const emit = (data: CliReport) => {
132
169
  if (json) process.stdout.write(JSON.stringify(data, null, 2) + "\n");
133
170
  };
134
171
  const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
@@ -187,6 +224,10 @@ function overridesFromFlags(): Partial<VideoConfig> {
187
224
  if (values["loop-offset"] !== undefined) o.loopOffset = values["loop-offset"];
188
225
  if (values["max-pause"] !== undefined) o.maxPause = values["max-pause"];
189
226
  if (values.keys) o.keys = true;
227
+ if (values.shadow) o.shadow = true;
228
+ if (values.watermark || values["watermark-image"]) {
229
+ o.watermark = { ...(values.watermark && { text: values.watermark }), ...(values["watermark-image"] && { image: values["watermark-image"] }) };
230
+ }
190
231
  if (values.preset) {
191
232
  if (!presetNames.includes(values.preset as PresetName)) fail(`--preset must be one of ${presetNames.join(", ")}`);
192
233
  o.preset = values.preset as PresetName;
@@ -199,6 +240,28 @@ function overridesFromFlags(): Partial<VideoConfig> {
199
240
  return o;
200
241
  }
201
242
 
243
+ /** `--from 2` and `--at 2` are seconds; `--from 1.5s` / `"2m"` go through the duration parser. */
244
+ function seconds(flag: "from" | "to" | "gap"): number | undefined {
245
+ const raw = values[flag];
246
+ if (raw === undefined) return undefined;
247
+ return /^\s*\d+(\.\d+)?\s*$/.test(raw) ? Number(raw) : toMs(raw) / 1000;
248
+ }
249
+
250
+ function clipFromFlags(): ClipSelection | undefined {
251
+ const clip: ClipSelection = {};
252
+ const from = seconds("from");
253
+ const to = seconds("to");
254
+ if (from !== undefined) clip.from = from;
255
+ if (to !== undefined) clip.to = to;
256
+ if (values.chapters) clip.chapters = values.chapters.split(",").map((s) => s.trim()).filter(Boolean);
257
+ if (values["split-chapters"]) clip.splitChapters = true;
258
+ return Object.keys(clip).length ? clip : undefined;
259
+ }
260
+
261
+ function reportNotes(notes: string[] | undefined): void {
262
+ for (const note of notes ?? []) log(dim(` note: ${note}`));
263
+ }
264
+
202
265
  async function loadVideo(file: string): Promise<Video> {
203
266
  const abs = path.resolve(file);
204
267
  if (!(await Bun.file(abs).exists())) fail(`Script not found: ${file}`);
@@ -246,14 +309,14 @@ async function fileSize(file: string): Promise<string> {
246
309
 
247
310
  const ok = (what: string, detail = "") => log(`${green("✔")} ${what}${detail ? ` ${dim(detail)}` : ""}`);
248
311
 
249
- async function reportOutputs(outputs: string[], screenshots: string[]): Promise<Array<{ path: string; bytes: number }>> {
312
+ async function reportOutputs(outputs: string[], screenshots: string[]): Promise<OutputFile[]> {
250
313
  for (const out of outputs) ok(`wrote ${out}`, await fileSize(out));
251
314
  for (const shot of screenshots) ok(`screenshot ${shot}`);
252
315
  return Promise.all([...outputs, ...screenshots].map(async (p) => ({ path: p, bytes: (await Bun.file(p).exists()) ? Bun.file(p).size : 0 })));
253
316
  }
254
317
 
255
- const TEMPLATES: Record<string, (name: string) => string> = {
256
- basic: (name) => `import { defineVideo } from "tcut";
318
+ const TEMPLATES = new Map<string, (name: string) => string>([
319
+ ["basic", (name) => `import { defineVideo } from "tcut";
257
320
 
258
321
  export default defineVideo(
259
322
  {
@@ -273,8 +336,8 @@ export default defineVideo(
273
336
  await t.sleep("2s");
274
337
  },
275
338
  );
276
- `,
277
- tour: (name) => `import { defineVideo } from "tcut";
339
+ `],
340
+ ["tour", (name) => `import { defineVideo } from "tcut";
278
341
 
279
342
  export default defineVideo(
280
343
  {
@@ -308,8 +371,8 @@ export default defineVideo(
308
371
  await t.sleep("1.5s");
309
372
  },
310
373
  );
311
- `,
312
- test: (name) => `import { defineVideo } from "tcut";
374
+ `],
375
+ ["test", (name) => `import { defineVideo } from "tcut";
313
376
 
314
377
  // Run with: tcut test ${name}.tcut.ts (fast mode: no sleeps, no typing delay)
315
378
  export default defineVideo(
@@ -328,8 +391,8 @@ export default defineVideo(
328
391
  await t.expect(/ok/);
329
392
  },
330
393
  );
331
- `,
332
- };
394
+ `],
395
+ ]);
333
396
 
334
397
  async function main(): Promise<void> {
335
398
  if (values.help || positionals.length === 0) {
@@ -351,7 +414,7 @@ async function main(): Promise<void> {
351
414
  }
352
415
  case "publish": {
353
416
  if (values.setup) {
354
- const ask = async (label: string, flag: string | undefined, fallback: string, secret = false): Promise<string> => {
417
+ const ask = async (label: string, flag: string | undefined, fallback: string): Promise<string> => {
355
418
  if (flag) return flag;
356
419
  if (!process.stdin.isTTY) return fallback;
357
420
  const answer = prompt(`${label}${fallback ? ` [${fallback}]` : ""}:`) ?? "";
@@ -362,9 +425,9 @@ async function main(): Promise<void> {
362
425
  endpoint: await ask("S3 endpoint", values.endpoint, existing?.endpoint ?? "https://s3.amanv.cloud"),
363
426
  bucket: await ask("Bucket", values.bucket, existing?.bucket ?? "tcut"),
364
427
  accessKeyId: await ask("Access key", values["access-key"], existing?.accessKeyId ?? ""),
365
- secretAccessKey: await ask("Secret key", values["secret-key"], existing?.secretAccessKey ?? "", true),
428
+ secretAccessKey: await ask("Secret key", values["secret-key"], existing?.secretAccessKey ?? ""),
366
429
  region: values.region ?? existing?.region ?? "us-east-1",
367
- ...(values["public-url"] || existing?.publicUrl ? { publicUrl: values["public-url"] ?? existing?.publicUrl } : {}),
430
+ publicUrl: values["public-url"] || existing?.publicUrl || undefined,
368
431
  };
369
432
  if (!cfg.accessKeyId || !cfg.secretAccessKey) fail("publish --setup needs --access-key and --secret-key (or run it in a terminal to be prompted)");
370
433
  const result = await ensurePublicBucket(cfg, log);
@@ -390,8 +453,8 @@ async function main(): Promise<void> {
390
453
  case "init": {
391
454
  const name = rest[0] ?? "demo";
392
455
  const template = values.template ?? "basic";
393
- const make = TEMPLATES[template];
394
- if (!make) fail(`Unknown template "${template}". Available: ${Object.keys(TEMPLATES).join(", ")}`);
456
+ const make = TEMPLATES.get(template);
457
+ if (!make) fail(`Unknown template "${template}". Available: ${[...TEMPLATES.keys()].join(", ")}`);
395
458
  const base = name.replace(/\.(video|tcut)\.ts$|\.ts$/, "");
396
459
  const file = name.endsWith(".ts") ? name : template === "test" ? `${base}.tcut.ts` : `${base}.video.ts`;
397
460
  if (await Bun.file(file).exists()) fail(`${file} already exists`);
@@ -445,12 +508,58 @@ async function main(): Promise<void> {
445
508
  }
446
509
  case "render": {
447
510
  if (!rest[0]) fail("render needs a .cast file");
448
- const result = await renderCast(rest[0], overridesFromFlags(), progressReporter());
511
+ const result = await renderCast(rest[0], overridesFromFlags(), progressReporter(), clipFromFlags());
449
512
  const files = await reportOutputs(result.outputs, result.screenshots);
513
+ reportNotes(result.notes);
450
514
  log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
451
515
  emit({ cast: rest[0], outputs: files, frames: result.frames, durationSeconds: result.durationSeconds });
452
516
  return;
453
517
  }
518
+ case "cut":
519
+ case "concat": {
520
+ const joining = first === "concat";
521
+ if (joining ? rest.length < 2 : !rest[0]) fail(joining ? "concat needs two or more .cast files" : "cut needs a .cast file");
522
+ const clip = clipFromFlags();
523
+ if (!joining && !clip) fail("cut needs --from/--to and/or --chapters");
524
+ const castOut = values.cast ?? (joining ? path.join(path.dirname(rest[0]!), "concat.cast") : rest[0]!.replace(/\.cast$/, "") + "-cut.cast");
525
+ const overrides = overridesFromFlags();
526
+ delete overrides.cast;
527
+ const parts: Array<{ rec: Awaited<ReturnType<typeof readCast>>; config: ReturnType<typeof castConfig> }> = [];
528
+ for (const [i, file] of rest.entries()) {
529
+ const rec = await readCast(file);
530
+ const config = applyOverrides(castConfig(rec, file, values.output), overrides);
531
+ parts.push({ rec: await rebaseBrowserFrames(rec, file, castOut, joining ? `${i}-` : ""), config });
532
+ }
533
+ let out: Awaited<ReturnType<typeof readCast>>;
534
+ if (joining) {
535
+ out = concatRecordings(parts, { gap: seconds("gap") ?? 0 });
536
+ } else {
537
+ const { rec, config } = parts[0]!;
538
+ let part = rec;
539
+ let partConfig = config;
540
+ if (clip?.chapters) {
541
+ part = selectChapters(rec, config, clip.chapters);
542
+ partConfig = flattenedConfig(config);
543
+ }
544
+ out = clip && (clip.from !== undefined || clip.to !== undefined) ? cutRecording(part, partConfig, clip) : part;
545
+ }
546
+ const renderConfig = { ...flattenedConfig(parts[0]!.config), cast: castOut, output: values.output?.length ? values.output : parts[0]!.config.output };
547
+ out.header.bunVideo = renderConfig;
548
+ await mkdir(path.dirname(path.resolve(castOut)), { recursive: true });
549
+ await writeCast(castOut, out);
550
+ out.source = path.resolve(castOut);
551
+ const durationSeconds = recordingDuration(out);
552
+ ok(`wrote ${castOut}`, `${out.events.length} events, ${durationSeconds.toFixed(1)}s`);
553
+ let files: OutputFile[] = [];
554
+ if (values.output?.length) {
555
+ const result = await renderOutputs(out, renderConfig, progressReporter());
556
+ files = await reportOutputs(result.outputs, result.screenshots);
557
+ reportNotes(result.notes);
558
+ log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
559
+ }
560
+ emit({ cast: castOut, events: out.events.length, durationSeconds, outputs: files });
561
+ return;
562
+ }
454
563
  case "diff": {
455
564
  if (rest.length < 2) fail("diff needs two .cast files");
456
565
  const result = await diffCasts(rest[0]!, rest[1]!, { at: values.at !== undefined ? num("at") : undefined, images: values.images });
@@ -474,7 +583,8 @@ async function main(): Promise<void> {
474
583
  // eslint-disable-next-line no-fallthrough -- process.exit above never returns
475
584
  default: {
476
585
  const video = await loadVideo(first!);
477
- const result = await video.run({ log, force: values.force, recordOnly: values["record-only"], onProgress: progressReporter() });
586
+ const result = await video.run({ log, force: values.force, recordOnly: values["record-only"], onProgress: progressReporter(), clip: clipFromFlags() });
587
+ reportNotes(result.notes);
478
588
  ok(`${result.cached ? "reused" : "wrote"} ${result.cast}`);
479
589
  const files = await reportOutputs(result.outputs, result.screenshots);
480
590
  if (!values["record-only"]) log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
@@ -483,9 +593,9 @@ async function main(): Promise<void> {
483
593
  }
484
594
  }
485
595
 
486
- main().catch((err: unknown) => {
487
- const message = err instanceof Error ? err.message : String(err);
488
- if (json) process.stdout.write(JSON.stringify({ error: message, type: err instanceof Error ? err.name : "Error" }) + "\n");
596
+ main().catch((cause: unknown) => {
597
+ const message = cause instanceof Error ? cause.message : String(cause);
598
+ if (json) process.stdout.write(JSON.stringify({ error: message, type: cause instanceof Error ? cause.name : "Error" }) + "\n");
489
599
  else console.error(`\n${red("error:")} ${message}`);
490
600
  process.exit(1);
491
601
  });
package/src/config.ts CHANGED
@@ -15,8 +15,14 @@ export function defaultPromptPattern(prompt: string): RegExp {
15
15
  return new RegExp(`${escapeRegExp(prompt.trimEnd())}\\s*$`);
16
16
  }
17
17
 
18
+ /** Size of one terminal cell in CSS pixels. */
19
+ export interface CellSize {
20
+ w: number;
21
+ h: number;
22
+ }
23
+
18
24
  /** Approximate cell size before anything is measured (JetBrains Mono / Menlo are ~0.6em wide). */
19
- export function estimateCell(font: { size: number; lineHeight: number; letterSpacing: number }): { w: number; h: number } {
25
+ export function estimateCell(font: { size: number; lineHeight: number; letterSpacing: number }): CellSize {
20
26
  return { w: Math.round(font.size * 0.6 * 100) / 100 + font.letterSpacing, h: Math.ceil(font.size * font.lineHeight) };
21
27
  }
22
28
 
@@ -37,7 +43,30 @@ export function resolveConfig(input: VideoConfig): ResolvedConfig {
37
43
  letterSpacing: config.font?.letterSpacing ?? 0,
38
44
  };
39
45
  const padding = config.padding ?? 24;
40
- const margin = config.margin ?? 0;
46
+ const shadow = config.shadow
47
+ ? {
48
+ x: (config.shadow === true ? undefined : config.shadow.x) ?? 0,
49
+ y: (config.shadow === true ? undefined : config.shadow.y) ?? 18,
50
+ blur: (config.shadow === true ? undefined : config.shadow.blur) ?? 50,
51
+ color: (config.shadow === true ? undefined : config.shadow.color) ?? "#000000",
52
+ opacity: (config.shadow === true ? undefined : config.shadow.opacity) ?? 0.45,
53
+ }
54
+ : undefined;
55
+ // A shadow needs room around the window; give it some unless the margin was set explicitly.
56
+ const margin = config.margin ?? (shadow ? 40 : 0);
57
+ const wm = config.watermark === undefined ? undefined : config.watermark instanceof Object ? config.watermark : { text: config.watermark };
58
+ const watermark = wm
59
+ ? {
60
+ ...(wm.text !== undefined && { text: wm.text }),
61
+ ...(wm.image !== undefined && { image: wm.image }),
62
+ position: wm.position ?? "bottom-right",
63
+ opacity: wm.opacity ?? 0.6,
64
+ size: wm.size ?? (wm.image ? 28 : 14),
65
+ color: wm.color ?? theme.foreground,
66
+ margin: wm.margin ?? 16,
67
+ }
68
+ : undefined;
69
+ if (watermark && !watermark.text && !watermark.image) throw new Error("watermark needs `text` or `image`");
41
70
  const bar = (config.windowBar ?? "none") === "none" ? 0 : WINDOW_BAR_HEIGHT;
42
71
  const cell = estimateCell(font);
43
72
  let cols = config.cols;
@@ -105,6 +134,8 @@ export function resolveConfig(input: VideoConfig): ResolvedConfig {
105
134
  padding,
106
135
  margin,
107
136
  marginFill: config.marginFill ?? theme.background,
137
+ ...(shadow && { shadow }),
138
+ ...(watermark && { watermark }),
108
139
  borderRadius: config.borderRadius ?? 0,
109
140
  windowBar: config.windowBar ?? "none",
110
141
  title: config.title ?? "",
@@ -117,6 +148,8 @@ export function applyOverrides(base: ResolvedConfig, overrides: Partial<VideoCon
117
148
  const merged: VideoConfig = {
118
149
  ...base,
119
150
  promptPattern: new RegExp(base.promptPattern),
151
+ // ResolvedConfig keeps maxPause in seconds; VideoConfig reads bare numbers as milliseconds.
152
+ maxPause: base.maxPause === undefined ? undefined : base.maxPause * 1000,
120
153
  ...overrides,
121
154
  font: { ...base.font, ...overrides.font },
122
155
  cursor: { ...base.cursor, ...overrides.cursor },
package/src/diff.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { readCast } from "./cast";
3
3
  import { applyOverrides, resolveConfig } from "./config";
4
- import { replayFrames, type GridFrame } from "./export/frames";
4
+ import { frameText, replayFrames, type GridFrame } from "./export/frames";
5
5
  import { renderOutputs } from "./render";
6
6
  import type { Recording, ResolvedConfig } from "./types";
7
7
 
@@ -28,21 +28,11 @@ function frameAt(frames: GridFrame[], at: number | undefined): GridFrame {
28
28
  return chosen;
29
29
  }
30
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
31
  /** Simple LCS-based line diff — screens are small, so O(n·m) is fine. */
42
32
  function diffLines(a: string[], b: string[]): string[] {
43
33
  const n = a.length;
44
34
  const m = b.length;
45
- const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
35
+ const dp: number[][] = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }, () => 0));
46
36
  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
37
  const out: string[] = [];
48
38
  let i = 0;
@@ -69,7 +59,7 @@ async function screenOf(rec: Recording, at: number | undefined): Promise<{ text:
69
59
  const base = rec.header.bunVideo ?? resolveConfig({ output: "x.svg", cols: rec.header.width, rows: rec.header.height });
70
60
  const config = applyOverrides(base, {});
71
61
  const replay = await replayFrames(rec, config);
72
- return { text: rowsText(frameAt(replay.frames, at)), config };
62
+ return { text: frameText(frameAt(replay.frames, at)), config };
73
63
  }
74
64
 
75
65
  /** Compare what two recordings show on screen (text, not pixels) at the end or at a given time. */
package/src/duration.ts CHANGED
@@ -1,18 +1,14 @@
1
1
  import type { Duration } from "./types";
2
2
 
3
- const units: Record<string, number> = { ms: 1, s: 1000, m: 60_000 };
3
+ const unitMs = (unit: string | undefined): number => (unit === "s" ? 1000 : unit === "m" ? 60_000 : 1);
4
4
 
5
5
  /** "500ms" → 500, "1.5s" → 1500, "2m" → 120000, 250 → 250. Bare numbers are milliseconds. */
6
6
  export function toMs(value: Duration | undefined, fallback = 0): number {
7
7
  if (value === undefined) return fallback;
8
- if (typeof value === "number") {
9
- if (!Number.isFinite(value) || value < 0) throw new Error(`Invalid duration ${value}`);
10
- return value;
11
- }
12
- const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m)?\s*$/.exec(value);
8
+ // Numbers and strings share one grammar: a non-negative decimal with an optional unit.
9
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m)?\s*$/.exec(String(value));
13
10
  if (!match) throw new Error(`Invalid duration "${value}" (use e.g. 500, "500ms", "1.5s", "2m")`);
14
- const unit = match[2] ?? "ms";
15
- return Number(match[1]) * units[unit]!;
11
+ return Number(match[1]) * unitMs(match[2]);
16
12
  }
17
13
 
18
14
  export function formatMs(ms: number): string {