termcut 0.6.4 → 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.6.4",
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
@@ -105,6 +115,14 @@ const { values, positionals } = parseArgs({
105
115
  preset: { type: "string" },
106
116
  browser: { type: "string" },
107
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" },
108
126
  at: { type: "string" },
109
127
  images: { type: "string" },
110
128
  cast: { type: "string" },
@@ -129,8 +147,25 @@ const { values, positionals } = parseArgs({
129
147
 
130
148
  const json = values.json === true;
131
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
+
132
167
  /** With --json, the only thing on stdout is one JSON document (results or { error }). */
133
- const emit = (data: unknown) => {
168
+ const emit = (data: CliReport) => {
134
169
  if (json) process.stdout.write(JSON.stringify(data, null, 2) + "\n");
135
170
  };
136
171
  const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
@@ -189,6 +224,10 @@ function overridesFromFlags(): Partial<VideoConfig> {
189
224
  if (values["loop-offset"] !== undefined) o.loopOffset = values["loop-offset"];
190
225
  if (values["max-pause"] !== undefined) o.maxPause = values["max-pause"];
191
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
+ }
192
231
  if (values.preset) {
193
232
  if (!presetNames.includes(values.preset as PresetName)) fail(`--preset must be one of ${presetNames.join(", ")}`);
194
233
  o.preset = values.preset as PresetName;
@@ -201,6 +240,28 @@ function overridesFromFlags(): Partial<VideoConfig> {
201
240
  return o;
202
241
  }
203
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
+
204
265
  async function loadVideo(file: string): Promise<Video> {
205
266
  const abs = path.resolve(file);
206
267
  if (!(await Bun.file(abs).exists())) fail(`Script not found: ${file}`);
@@ -248,14 +309,14 @@ async function fileSize(file: string): Promise<string> {
248
309
 
249
310
  const ok = (what: string, detail = "") => log(`${green("✔")} ${what}${detail ? ` ${dim(detail)}` : ""}`);
250
311
 
251
- async function reportOutputs(outputs: string[], screenshots: string[]): Promise<Array<{ path: string; bytes: number }>> {
312
+ async function reportOutputs(outputs: string[], screenshots: string[]): Promise<OutputFile[]> {
252
313
  for (const out of outputs) ok(`wrote ${out}`, await fileSize(out));
253
314
  for (const shot of screenshots) ok(`screenshot ${shot}`);
254
315
  return Promise.all([...outputs, ...screenshots].map(async (p) => ({ path: p, bytes: (await Bun.file(p).exists()) ? Bun.file(p).size : 0 })));
255
316
  }
256
317
 
257
- const TEMPLATES: Record<string, (name: string) => string> = {
258
- basic: (name) => `import { defineVideo } from "tcut";
318
+ const TEMPLATES = new Map<string, (name: string) => string>([
319
+ ["basic", (name) => `import { defineVideo } from "tcut";
259
320
 
260
321
  export default defineVideo(
261
322
  {
@@ -275,8 +336,8 @@ export default defineVideo(
275
336
  await t.sleep("2s");
276
337
  },
277
338
  );
278
- `,
279
- tour: (name) => `import { defineVideo } from "tcut";
339
+ `],
340
+ ["tour", (name) => `import { defineVideo } from "tcut";
280
341
 
281
342
  export default defineVideo(
282
343
  {
@@ -310,8 +371,8 @@ export default defineVideo(
310
371
  await t.sleep("1.5s");
311
372
  },
312
373
  );
313
- `,
314
- test: (name) => `import { defineVideo } from "tcut";
374
+ `],
375
+ ["test", (name) => `import { defineVideo } from "tcut";
315
376
 
316
377
  // Run with: tcut test ${name}.tcut.ts (fast mode: no sleeps, no typing delay)
317
378
  export default defineVideo(
@@ -330,8 +391,8 @@ export default defineVideo(
330
391
  await t.expect(/ok/);
331
392
  },
332
393
  );
333
- `,
334
- };
394
+ `],
395
+ ]);
335
396
 
336
397
  async function main(): Promise<void> {
337
398
  if (values.help || positionals.length === 0) {
@@ -353,7 +414,7 @@ async function main(): Promise<void> {
353
414
  }
354
415
  case "publish": {
355
416
  if (values.setup) {
356
- 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> => {
357
418
  if (flag) return flag;
358
419
  if (!process.stdin.isTTY) return fallback;
359
420
  const answer = prompt(`${label}${fallback ? ` [${fallback}]` : ""}:`) ?? "";
@@ -364,9 +425,9 @@ async function main(): Promise<void> {
364
425
  endpoint: await ask("S3 endpoint", values.endpoint, existing?.endpoint ?? "https://s3.amanv.cloud"),
365
426
  bucket: await ask("Bucket", values.bucket, existing?.bucket ?? "tcut"),
366
427
  accessKeyId: await ask("Access key", values["access-key"], existing?.accessKeyId ?? ""),
367
- secretAccessKey: await ask("Secret key", values["secret-key"], existing?.secretAccessKey ?? "", true),
428
+ secretAccessKey: await ask("Secret key", values["secret-key"], existing?.secretAccessKey ?? ""),
368
429
  region: values.region ?? existing?.region ?? "us-east-1",
369
- ...(values["public-url"] || existing?.publicUrl ? { publicUrl: values["public-url"] ?? existing?.publicUrl } : {}),
430
+ publicUrl: values["public-url"] || existing?.publicUrl || undefined,
370
431
  };
371
432
  if (!cfg.accessKeyId || !cfg.secretAccessKey) fail("publish --setup needs --access-key and --secret-key (or run it in a terminal to be prompted)");
372
433
  const result = await ensurePublicBucket(cfg, log);
@@ -392,8 +453,8 @@ async function main(): Promise<void> {
392
453
  case "init": {
393
454
  const name = rest[0] ?? "demo";
394
455
  const template = values.template ?? "basic";
395
- const make = TEMPLATES[template];
396
- 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(", ")}`);
397
458
  const base = name.replace(/\.(video|tcut)\.ts$|\.ts$/, "");
398
459
  const file = name.endsWith(".ts") ? name : template === "test" ? `${base}.tcut.ts` : `${base}.video.ts`;
399
460
  if (await Bun.file(file).exists()) fail(`${file} already exists`);
@@ -447,12 +508,58 @@ async function main(): Promise<void> {
447
508
  }
448
509
  case "render": {
449
510
  if (!rest[0]) fail("render needs a .cast file");
450
- const result = await renderCast(rest[0], overridesFromFlags(), progressReporter());
511
+ const result = await renderCast(rest[0], overridesFromFlags(), progressReporter(), clipFromFlags());
451
512
  const files = await reportOutputs(result.outputs, result.screenshots);
513
+ reportNotes(result.notes);
452
514
  log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
453
515
  emit({ cast: rest[0], outputs: files, frames: result.frames, durationSeconds: result.durationSeconds });
454
516
  return;
455
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
+ }
456
563
  case "diff": {
457
564
  if (rest.length < 2) fail("diff needs two .cast files");
458
565
  const result = await diffCasts(rest[0]!, rest[1]!, { at: values.at !== undefined ? num("at") : undefined, images: values.images });
@@ -476,7 +583,8 @@ async function main(): Promise<void> {
476
583
  // eslint-disable-next-line no-fallthrough -- process.exit above never returns
477
584
  default: {
478
585
  const video = await loadVideo(first!);
479
- 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);
480
588
  ok(`${result.cached ? "reused" : "wrote"} ${result.cast}`);
481
589
  const files = await reportOutputs(result.outputs, result.screenshots);
482
590
  if (!values["record-only"]) log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
@@ -485,9 +593,9 @@ async function main(): Promise<void> {
485
593
  }
486
594
  }
487
595
 
488
- main().catch((err: unknown) => {
489
- const message = err instanceof Error ? err.message : String(err);
490
- 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");
491
599
  else console.error(`\n${red("error:")} ${message}`);
492
600
  process.exit(1);
493
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 {