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 +9 -1
- package/package.json +1 -1
- package/scripts/build-themes.ts +10 -6
- package/src/browser.ts +3 -3
- package/src/cast.ts +2 -0
- package/src/cli.ts +135 -27
- package/src/config.ts +35 -2
- package/src/diff.ts +3 -13
- package/src/duration.ts +4 -8
- package/src/edit.ts +188 -0
- package/src/export/frames.ts +18 -1
- package/src/export/html.ts +6 -2
- package/src/export/svg.ts +31 -3
- package/src/index.ts +6 -2
- package/src/keylabels.ts +28 -27
- package/src/keys.ts +18 -18
- package/src/live.ts +18 -7
- package/src/loop.ts +14 -9
- package/src/presets.ts +2 -2
- package/src/publish.ts +19 -21
- package/src/recorder.ts +22 -5
- package/src/render.ts +69 -2
- package/src/renderer/encoder.ts +49 -11
- package/src/renderer/generated/page.js +1 -1
- package/src/renderer/page-entry.ts +18 -6
- package/src/renderer/page.ts +99 -3
- package/src/renderer/png.ts +189 -0
- package/src/renderer/webview.ts +37 -11
- package/src/screen.ts +9 -3
- package/src/scriptgen.ts +31 -30
- package/src/themes.ts +9 -11
- package/src/timeline.ts +21 -5
- package/src/types.ts +63 -2
- package/src/video.ts +15 -12
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
package/scripts/build-themes.ts
CHANGED
|
@@ -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
|
|
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)
|
|
56
|
+
if (cursor) entries.push(["cursor", cursor]);
|
|
53
57
|
const cursorText = hex(props["cursor-text"] ?? "");
|
|
54
|
-
if (cursorText)
|
|
58
|
+
if (cursorText) entries.push(["cursorAccent", cursorText]);
|
|
55
59
|
const selection = hex(props["selection-background"] ?? "");
|
|
56
|
-
if (selection)
|
|
57
|
-
KEYS.forEach((k, i) => (
|
|
58
|
-
return
|
|
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
|
-
|
|
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 (
|
|
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
|
-
(
|
|
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
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:
|
|
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<
|
|
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
|
|
258
|
-
basic
|
|
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
|
|
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
|
|
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
|
|
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 ?? ""
|
|
428
|
+
secretAccessKey: await ask("Secret key", values["secret-key"], existing?.secretAccessKey ?? ""),
|
|
368
429
|
region: values.region ?? existing?.region ?? "us-east-1",
|
|
369
|
-
|
|
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
|
|
396
|
-
if (!make) fail(`Unknown template "${template}". Available: ${
|
|
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((
|
|
489
|
-
const message =
|
|
490
|
-
if (json) process.stdout.write(JSON.stringify({ error: message, type:
|
|
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 }):
|
|
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
|
|
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 }, () =>
|
|
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:
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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 {
|