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 +17 -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 +137 -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
|
|
|
@@ -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
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
|
|
@@ -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:
|
|
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<
|
|
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
|
|
256
|
-
basic
|
|
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
|
|
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
|
|
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
|
|
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 ?? ""
|
|
428
|
+
secretAccessKey: await ask("Secret key", values["secret-key"], existing?.secretAccessKey ?? ""),
|
|
366
429
|
region: values.region ?? existing?.region ?? "us-east-1",
|
|
367
|
-
|
|
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
|
|
394
|
-
if (!make) fail(`Unknown template "${template}". Available: ${
|
|
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((
|
|
487
|
-
const message =
|
|
488
|
-
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");
|
|
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 }):
|
|
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 {
|