termcut 0.1.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/LICENSE +21 -0
- package/README.md +186 -0
- package/package.json +65 -0
- package/scripts/build-assets.ts +45 -0
- package/scripts/build-binaries.ts +41 -0
- package/src/cast.ts +40 -0
- package/src/cli.ts +312 -0
- package/src/config.ts +84 -0
- package/src/duration.ts +21 -0
- package/src/export/frames.ts +142 -0
- package/src/export/html.ts +88 -0
- package/src/export/svg.ts +171 -0
- package/src/index.ts +15 -0
- package/src/keys.ts +58 -0
- package/src/recorder.ts +362 -0
- package/src/render.ts +52 -0
- package/src/renderer/bundle.ts +78 -0
- package/src/renderer/embedded.ts +17 -0
- package/src/renderer/encoder.ts +253 -0
- package/src/renderer/generated/ghostty-vt.wasm +0 -0
- package/src/renderer/generated/page.js +1 -0
- package/src/renderer/generated/player.js +1 -0
- package/src/renderer/generated/terminal.css +195 -0
- package/src/renderer/generated.d.ts +17 -0
- package/src/renderer/page-entry.ts +109 -0
- package/src/renderer/page.ts +114 -0
- package/src/renderer/player-entry.ts +105 -0
- package/src/renderer/webview.ts +140 -0
- package/src/screen.ts +130 -0
- package/src/testing.ts +69 -0
- package/src/themes.ts +138 -0
- package/src/timeline.ts +58 -0
- package/src/types.ts +282 -0
- package/src/video.ts +127 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import * as api from "./index";
|
|
5
|
+
import { runScriptTests } from "./testing";
|
|
6
|
+
import { themeNames } from "./themes";
|
|
7
|
+
import type { CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
|
|
8
|
+
import { Video, isVideo, renderCast } from "./video";
|
|
9
|
+
|
|
10
|
+
// Let user scripts `import { defineVideo } from "tcut"` (or "termcut", the npm package name) regardless of
|
|
11
|
+
// where they live or whether this is the compiled binary (no node_modules there): resolve the bare specifier
|
|
12
|
+
// to this very module graph.
|
|
13
|
+
Bun.plugin({
|
|
14
|
+
name: "tcut-self",
|
|
15
|
+
setup(build) {
|
|
16
|
+
for (const specifier of ["tcut", "termcut"]) {
|
|
17
|
+
build.module(specifier, () => ({ exports: { ...api }, loader: "object" }));
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const HELP = `tcut — script terminal sessions in TypeScript, render them to video.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
tcut <script.ts> [options] record + render
|
|
26
|
+
tcut record <script.ts> [options] record only (writes the .cast)
|
|
27
|
+
tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
|
|
28
|
+
tcut test <path...> run scripts in fast mode as tests (no video)
|
|
29
|
+
tcut init [name] [--template t] scaffold a new script (basic | tour | test)
|
|
30
|
+
tcut themes list built-in themes
|
|
31
|
+
|
|
32
|
+
Options (override the script's config):
|
|
33
|
+
-o, --output <path> .mp4 .webm .gif .webp .svg .html .png .jpg or dir/ for PNG frames — repeatable
|
|
34
|
+
--theme <name> ${themeNames.join(" | ")}
|
|
35
|
+
--font <family> --font-size <px> --line-height <x> --letter-spacing <px>
|
|
36
|
+
--fps <n> --speed <x> playback speed multiplier
|
|
37
|
+
--padding <px> --margin <px> --margin-fill <css-color> --radius <px>
|
|
38
|
+
--window-bar <type> none | colorful | colorfulRight | rings | ringsRight
|
|
39
|
+
--title <text> --no-blink
|
|
40
|
+
--core <name> ghostty | lite
|
|
41
|
+
--cast <path> where to read/write the .cast
|
|
42
|
+
--record-only stop after writing the cast
|
|
43
|
+
--force ignore the cast cache and re-record
|
|
44
|
+
--template <name> for init: basic | tour | test
|
|
45
|
+
-q, --quiet
|
|
46
|
+
-h, --help
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
const { values, positionals } = parseArgs({
|
|
50
|
+
args: Bun.argv.slice(2),
|
|
51
|
+
allowPositionals: true,
|
|
52
|
+
options: {
|
|
53
|
+
output: { type: "string", short: "o", multiple: true },
|
|
54
|
+
theme: { type: "string" },
|
|
55
|
+
font: { type: "string" },
|
|
56
|
+
"font-size": { type: "string" },
|
|
57
|
+
"line-height": { type: "string" },
|
|
58
|
+
"letter-spacing": { type: "string" },
|
|
59
|
+
fps: { type: "string" },
|
|
60
|
+
speed: { type: "string" },
|
|
61
|
+
padding: { type: "string" },
|
|
62
|
+
margin: { type: "string" },
|
|
63
|
+
"margin-fill": { type: "string" },
|
|
64
|
+
radius: { type: "string" },
|
|
65
|
+
"window-bar": { type: "string" },
|
|
66
|
+
title: { type: "string" },
|
|
67
|
+
"no-blink": { type: "boolean" },
|
|
68
|
+
core: { type: "string" },
|
|
69
|
+
cast: { type: "string" },
|
|
70
|
+
"record-only": { type: "boolean" },
|
|
71
|
+
force: { type: "boolean" },
|
|
72
|
+
template: { type: "string" },
|
|
73
|
+
quiet: { type: "boolean", short: "q" },
|
|
74
|
+
help: { type: "boolean", short: "h" },
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const quiet = values.quiet === true;
|
|
79
|
+
const log = (msg: string) => {
|
|
80
|
+
if (!quiet) console.error(msg);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function fail(message: string): never {
|
|
84
|
+
console.error(`error: ${message}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function num(name: keyof typeof values): number | undefined {
|
|
89
|
+
const raw = values[name];
|
|
90
|
+
if (raw === undefined) return undefined;
|
|
91
|
+
const n = Number(raw);
|
|
92
|
+
if (!Number.isFinite(n)) fail(`--${String(name)} expects a number, got "${String(raw)}"`);
|
|
93
|
+
return n;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function overridesFromFlags(): Partial<VideoConfig> {
|
|
97
|
+
const o: Partial<VideoConfig> = {};
|
|
98
|
+
if (values.output?.length) o.output = values.output;
|
|
99
|
+
if (values.theme) o.theme = values.theme as ThemeName;
|
|
100
|
+
const font: NonNullable<VideoConfig["font"]> = {};
|
|
101
|
+
if (values.font) font.family = values.font;
|
|
102
|
+
if (values["font-size"] !== undefined) font.size = num("font-size");
|
|
103
|
+
if (values["line-height"] !== undefined) font.lineHeight = num("line-height");
|
|
104
|
+
if (values["letter-spacing"] !== undefined) font.letterSpacing = num("letter-spacing");
|
|
105
|
+
if (Object.keys(font).length) o.font = font;
|
|
106
|
+
if (values.fps !== undefined) o.fps = num("fps");
|
|
107
|
+
if (values.speed !== undefined) o.playbackSpeed = num("speed");
|
|
108
|
+
if (values.padding !== undefined) o.padding = num("padding");
|
|
109
|
+
if (values.margin !== undefined) o.margin = num("margin");
|
|
110
|
+
if (values["margin-fill"]) o.marginFill = values["margin-fill"];
|
|
111
|
+
if (values.radius !== undefined) o.borderRadius = num("radius");
|
|
112
|
+
if (values["window-bar"]) o.windowBar = values["window-bar"] as WindowBar;
|
|
113
|
+
if (values.title !== undefined) o.title = values.title;
|
|
114
|
+
if (values["no-blink"]) o.cursor = { blink: false };
|
|
115
|
+
if (values.core) {
|
|
116
|
+
if (values.core !== "ghostty" && values.core !== "lite") fail("--core must be ghostty or lite");
|
|
117
|
+
o.core = values.core as CoreName;
|
|
118
|
+
}
|
|
119
|
+
if (values.cast) o.cast = values.cast;
|
|
120
|
+
return o;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function loadVideo(file: string): Promise<Video> {
|
|
124
|
+
const abs = path.resolve(file);
|
|
125
|
+
if (!(await Bun.file(abs).exists())) fail(`Script not found: ${file}`);
|
|
126
|
+
const mod = (await import(abs)) as { default?: unknown };
|
|
127
|
+
if (!isVideo(mod.default)) {
|
|
128
|
+
fail(`${file} must \`export default defineVideo({...}, async (t) => {...})\``);
|
|
129
|
+
}
|
|
130
|
+
const video = mod.default;
|
|
131
|
+
video.source = abs;
|
|
132
|
+
const overrides = overridesFromFlags();
|
|
133
|
+
if (Object.keys(overrides).length === 0) return video;
|
|
134
|
+
const merged = new Video(
|
|
135
|
+
{
|
|
136
|
+
...video.config,
|
|
137
|
+
promptPattern: new RegExp(video.config.promptPattern),
|
|
138
|
+
...overrides,
|
|
139
|
+
font: { ...video.config.font, ...overrides.font },
|
|
140
|
+
cursor: { ...video.config.cursor, ...overrides.cursor },
|
|
141
|
+
},
|
|
142
|
+
video.script,
|
|
143
|
+
);
|
|
144
|
+
merged.source = abs;
|
|
145
|
+
return merged;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function progressReporter(): (p: { frame: number; total: number }) => void {
|
|
149
|
+
if (quiet || !process.stderr.isTTY) return () => {};
|
|
150
|
+
let last = -1;
|
|
151
|
+
return ({ frame, total }) => {
|
|
152
|
+
const pct = Math.floor((frame / total) * 100);
|
|
153
|
+
if (pct === last && frame !== total) return;
|
|
154
|
+
last = pct;
|
|
155
|
+
process.stderr.write(`\r rendering ${frame}/${total} frames (${pct}%)`);
|
|
156
|
+
if (frame === total) process.stderr.write("\n");
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function fileSize(file: string): Promise<string> {
|
|
161
|
+
const f = Bun.file(file);
|
|
162
|
+
if (!(await f.exists())) return "";
|
|
163
|
+
const bytes = f.size;
|
|
164
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
165
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function reportOutputs(outputs: string[], screenshots: string[]): Promise<void> {
|
|
169
|
+
for (const out of outputs) log(`✔ wrote ${out} ${await fileSize(out)}`);
|
|
170
|
+
for (const shot of screenshots) log(`✔ screenshot ${shot}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const TEMPLATES: Record<string, (name: string) => string> = {
|
|
174
|
+
basic: (name) => `import { defineVideo } from "tcut";
|
|
175
|
+
|
|
176
|
+
export default defineVideo(
|
|
177
|
+
{
|
|
178
|
+
output: "${name}.mp4",
|
|
179
|
+
theme: "catppuccin-mocha",
|
|
180
|
+
cols: 80,
|
|
181
|
+
rows: 24,
|
|
182
|
+
typingSpeed: "40ms",
|
|
183
|
+
},
|
|
184
|
+
async (t) => {
|
|
185
|
+
await t.run("echo 'Hello from tcut!'");
|
|
186
|
+
await t.sleep("1s");
|
|
187
|
+
await t.type("ls -la");
|
|
188
|
+
await t.sleep("500ms");
|
|
189
|
+
await t.enter();
|
|
190
|
+
await t.wait();
|
|
191
|
+
await t.sleep("2s");
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
`,
|
|
195
|
+
tour: (name) => `import { defineVideo } from "tcut";
|
|
196
|
+
|
|
197
|
+
export default defineVideo(
|
|
198
|
+
{
|
|
199
|
+
output: ["${name}.mp4", "${name}.gif", "${name}.svg"],
|
|
200
|
+
theme: "tokyo-night",
|
|
201
|
+
cols: 90,
|
|
202
|
+
rows: 24,
|
|
203
|
+
typingSpeed: "35ms",
|
|
204
|
+
typingJitter: 0.3,
|
|
205
|
+
windowBar: "colorful",
|
|
206
|
+
title: "${name}",
|
|
207
|
+
margin: 32,
|
|
208
|
+
borderRadius: 12,
|
|
209
|
+
},
|
|
210
|
+
async (t) => {
|
|
211
|
+
// Setup that happens but is cut from the video.
|
|
212
|
+
await t.hide(async () => {
|
|
213
|
+
await t.run("cd $(mktemp -d) && printf 'alpha\\\\nbeta\\\\n' > notes.txt");
|
|
214
|
+
await t.clear();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await t.run("ls -la");
|
|
218
|
+
await t.expect(/notes\\.txt/); // assertion: the script is also a test
|
|
219
|
+
await t.sleep("800ms");
|
|
220
|
+
|
|
221
|
+
await t.type("cat notes.txt");
|
|
222
|
+
await t.sleep("400ms");
|
|
223
|
+
await t.enter();
|
|
224
|
+
await t.wait(); // prompt is back
|
|
225
|
+
await t.screenshot("${name}-notes.png");
|
|
226
|
+
await t.sleep("1.5s");
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
`,
|
|
230
|
+
test: (name) => `import { defineVideo } from "tcut";
|
|
231
|
+
|
|
232
|
+
// Run with: tcut test ${name}.tcut.ts (fast mode: no sleeps, no typing delay)
|
|
233
|
+
export default defineVideo(
|
|
234
|
+
{
|
|
235
|
+
output: "${name}.mp4",
|
|
236
|
+
shell: "bash",
|
|
237
|
+
},
|
|
238
|
+
async (t) => {
|
|
239
|
+
await t.run("echo $((6 * 7))");
|
|
240
|
+
await t.expect(/^42$/m);
|
|
241
|
+
|
|
242
|
+
await t.run("printf 'a\\\\nb\\\\n' | wc -l");
|
|
243
|
+
await t.expect(/2/);
|
|
244
|
+
|
|
245
|
+
await t.run("true && echo ok");
|
|
246
|
+
await t.expect(/ok/);
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
`,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
async function main(): Promise<void> {
|
|
253
|
+
if (values.help || positionals.length === 0) {
|
|
254
|
+
console.log(HELP);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const [first, ...rest] = positionals;
|
|
259
|
+
const started = performance.now();
|
|
260
|
+
const elapsed = () => `${((performance.now() - started) / 1000).toFixed(1)}s`;
|
|
261
|
+
|
|
262
|
+
switch (first) {
|
|
263
|
+
case "themes": {
|
|
264
|
+
for (const name of themeNames) console.log(name);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
case "init": {
|
|
268
|
+
const name = rest[0] ?? "demo";
|
|
269
|
+
const template = values.template ?? "basic";
|
|
270
|
+
const make = TEMPLATES[template];
|
|
271
|
+
if (!make) fail(`Unknown template "${template}". Available: ${Object.keys(TEMPLATES).join(", ")}`);
|
|
272
|
+
const base = name.replace(/\.(video|tcut)\.ts$|\.ts$/, "");
|
|
273
|
+
const file = name.endsWith(".ts") ? name : template === "test" ? `${base}.tcut.ts` : `${base}.video.ts`;
|
|
274
|
+
if (await Bun.file(file).exists()) fail(`${file} already exists`);
|
|
275
|
+
await Bun.write(file, make(path.basename(base)));
|
|
276
|
+
console.log(`created ${file}\n\nrun it with:\n ${template === "test" ? `tcut test ${file}` : `tcut ${file}`}`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
case "record": {
|
|
280
|
+
if (!rest[0]) fail("record needs a script file");
|
|
281
|
+
const video = await loadVideo(rest[0]);
|
|
282
|
+
const rec = await video.record({ log, force: values.force });
|
|
283
|
+
log(`✔ ${rec.cached ? "reused" : "wrote"} ${video.config.cast} (${rec.events.length} events, ${(rec.header.duration ?? 0).toFixed(1)}s) in ${elapsed()}`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
case "render": {
|
|
287
|
+
if (!rest[0]) fail("render needs a .cast file");
|
|
288
|
+
const result = await renderCast(rest[0], overridesFromFlags(), progressReporter());
|
|
289
|
+
await reportOutputs(result.outputs, result.screenshots);
|
|
290
|
+
log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
case "test": {
|
|
294
|
+
if (rest.length === 0) fail("test needs at least one script file or directory");
|
|
295
|
+
const summary = await runScriptTests(rest, (line) => console.log(line));
|
|
296
|
+
process.exit(summary.failed > 0 ? 1 : 0);
|
|
297
|
+
}
|
|
298
|
+
// eslint-disable-next-line no-fallthrough -- process.exit above never returns
|
|
299
|
+
default: {
|
|
300
|
+
const video = await loadVideo(first!);
|
|
301
|
+
const result = await video.run({ log, force: values.force, recordOnly: values["record-only"], onProgress: progressReporter() });
|
|
302
|
+
log(`✔ ${result.cached ? "reused" : "wrote"} ${result.cast}`);
|
|
303
|
+
await reportOutputs(result.outputs, result.screenshots);
|
|
304
|
+
if (!values["record-only"]) log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
main().catch((err: unknown) => {
|
|
310
|
+
console.error(`\nerror: ${err instanceof Error ? err.message : String(err)}`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { toMs } from "./duration";
|
|
3
|
+
import { resolveTheme } from "./themes";
|
|
4
|
+
import type { ResolvedConfig, VideoConfig } from "./types";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_FONT_FAMILY =
|
|
7
|
+
'"JetBrains Mono", "JetBrainsMono Nerd Font Mono", "Fira Code", Menlo, Consolas, "DejaVu Sans Mono", monospace';
|
|
8
|
+
|
|
9
|
+
function escapeRegExp(s: string): string {
|
|
10
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function defaultPromptPattern(prompt: string): RegExp {
|
|
14
|
+
return new RegExp(`${escapeRegExp(prompt.trimEnd())}\\s*$`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveConfig(config: VideoConfig): ResolvedConfig {
|
|
18
|
+
const outputs = Array.isArray(config.output) ? config.output : [config.output];
|
|
19
|
+
if (outputs.length === 0) throw new Error("config.output must name at least one output");
|
|
20
|
+
|
|
21
|
+
const prompt = config.prompt ?? "> ";
|
|
22
|
+
const theme = resolveTheme(config.theme);
|
|
23
|
+
const first = outputs[0]!;
|
|
24
|
+
const castDefault = first.endsWith("/")
|
|
25
|
+
? path.join(first, "session.cast")
|
|
26
|
+
: first.replace(/\.[^./]+$/, "") + ".cast";
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
output: outputs,
|
|
30
|
+
cast: config.cast ?? castDefault,
|
|
31
|
+
shell: config.shell ?? "bash",
|
|
32
|
+
prompt,
|
|
33
|
+
promptPattern: (config.promptPattern ?? defaultPromptPattern(prompt)).source,
|
|
34
|
+
cwd: config.cwd ?? process.cwd(),
|
|
35
|
+
env: config.env ?? {},
|
|
36
|
+
cols: config.cols ?? 80,
|
|
37
|
+
rows: config.rows ?? 24,
|
|
38
|
+
fps: config.fps ?? 60,
|
|
39
|
+
typingSpeed: toMs(config.typingSpeed, 50),
|
|
40
|
+
typingJitter: Math.min(1, Math.max(0, config.typingJitter ?? 0)),
|
|
41
|
+
seed: config.seed ?? 1,
|
|
42
|
+
playbackSpeed: config.playbackSpeed ?? 1,
|
|
43
|
+
waitTimeout: toMs(config.waitTimeout, 15_000),
|
|
44
|
+
endPause: toMs(config.endPause, 1000),
|
|
45
|
+
quantize: config.quantize ?? false,
|
|
46
|
+
core: config.core ?? "ghostty",
|
|
47
|
+
cache: config.cache ?? true,
|
|
48
|
+
font: {
|
|
49
|
+
family: config.font?.family ?? DEFAULT_FONT_FAMILY,
|
|
50
|
+
size: config.font?.size ?? 20,
|
|
51
|
+
lineHeight: config.font?.lineHeight ?? 1.2,
|
|
52
|
+
letterSpacing: config.font?.letterSpacing ?? 0,
|
|
53
|
+
},
|
|
54
|
+
theme,
|
|
55
|
+
cursor: {
|
|
56
|
+
blink: config.cursor?.blink ?? true,
|
|
57
|
+
period: config.cursor?.period ?? 1000,
|
|
58
|
+
},
|
|
59
|
+
padding: config.padding ?? 24,
|
|
60
|
+
margin: config.margin ?? 0,
|
|
61
|
+
marginFill: config.marginFill ?? theme.background,
|
|
62
|
+
borderRadius: config.borderRadius ?? 0,
|
|
63
|
+
windowBar: config.windowBar ?? "none",
|
|
64
|
+
title: config.title ?? "",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Apply partial user overrides on top of an already-resolved config (used by `render --theme …`). */
|
|
69
|
+
export function applyOverrides(base: ResolvedConfig, overrides: Partial<VideoConfig> | undefined): ResolvedConfig {
|
|
70
|
+
if (!overrides) return base;
|
|
71
|
+
const merged: VideoConfig = {
|
|
72
|
+
...base,
|
|
73
|
+
promptPattern: new RegExp(base.promptPattern),
|
|
74
|
+
...overrides,
|
|
75
|
+
font: { ...base.font, ...overrides.font },
|
|
76
|
+
cursor: { ...base.cursor, ...overrides.cursor },
|
|
77
|
+
output: overrides.output ?? base.output,
|
|
78
|
+
};
|
|
79
|
+
// marginFill should follow a new theme unless explicitly set
|
|
80
|
+
if (overrides.theme && !overrides.marginFill) delete (merged as Partial<VideoConfig>).marginFill;
|
|
81
|
+
const resolved = resolveConfig(merged);
|
|
82
|
+
if (!overrides.cast) resolved.cast = base.cast;
|
|
83
|
+
return resolved;
|
|
84
|
+
}
|
package/src/duration.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Duration } from "./types";
|
|
2
|
+
|
|
3
|
+
const units: Record<string, number> = { ms: 1, s: 1000, m: 60_000 };
|
|
4
|
+
|
|
5
|
+
/** "500ms" → 500, "1.5s" → 1500, "2m" → 120000, 250 → 250. Bare numbers are milliseconds. */
|
|
6
|
+
export function toMs(value: Duration | undefined, fallback = 0): number {
|
|
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);
|
|
13
|
+
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]!;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatMs(ms: number): string {
|
|
19
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
20
|
+
return `${(ms / 1000).toFixed(ms % 1000 === 0 ? 0 : 1)}s`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { CellData, TerminalCore } from "@wterm/core";
|
|
2
|
+
import { themeOsc } from "../renderer/page";
|
|
3
|
+
import { loadCore } from "../screen";
|
|
4
|
+
import { buildTimeline, withReinjection } from "../timeline";
|
|
5
|
+
import type { Recording, ResolvedConfig, Theme } from "../types";
|
|
6
|
+
|
|
7
|
+
export const FLAG = {
|
|
8
|
+
bold: 0x01,
|
|
9
|
+
dim: 0x02,
|
|
10
|
+
italic: 0x04,
|
|
11
|
+
underline: 0x08,
|
|
12
|
+
reverse: 0x20,
|
|
13
|
+
invisible: 0x40,
|
|
14
|
+
strike: 0x80,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export interface GridCell {
|
|
18
|
+
text: string;
|
|
19
|
+
/** 1 = normal, 2 = wide (next cell is a continuation and is omitted). */
|
|
20
|
+
width: 1 | 2;
|
|
21
|
+
/** Resolved CSS hex colour, or null for the theme default. */
|
|
22
|
+
fg: string | null;
|
|
23
|
+
bg: string | null;
|
|
24
|
+
flags: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GridFrame {
|
|
28
|
+
/** Start time on the visible timeline, seconds. */
|
|
29
|
+
time: number;
|
|
30
|
+
/** How long this frame is shown, seconds. */
|
|
31
|
+
hold: number;
|
|
32
|
+
cols: number;
|
|
33
|
+
rows: number;
|
|
34
|
+
/** Sparse rows: index → cells (rows that are entirely blank/default are omitted). */
|
|
35
|
+
rows_: Map<number, GridCell[]>;
|
|
36
|
+
cursor: { row: number; col: number; visible: boolean };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GridReplay {
|
|
40
|
+
frames: GridFrame[];
|
|
41
|
+
duration: number;
|
|
42
|
+
cols: number;
|
|
43
|
+
rows: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ANSI: (keyof Theme)[] = [
|
|
47
|
+
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
|
|
48
|
+
"brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const hex = (n: number) => `#${(n & 0xffffff).toString(16).padStart(6, "0")}`;
|
|
52
|
+
|
|
53
|
+
/** Resolve a wterm colour (palette index 0–255, 256 = default, or packed RGB) to CSS. */
|
|
54
|
+
export function resolveColor(index: number, rgb: number | undefined, theme: Theme): string | null {
|
|
55
|
+
if (rgb !== undefined) return hex(rgb);
|
|
56
|
+
if (index === 256) return null;
|
|
57
|
+
if (index < 16) return theme[ANSI[index]!] as string;
|
|
58
|
+
if (index < 232) {
|
|
59
|
+
const n = index - 16;
|
|
60
|
+
const r = Math.floor(n / 36) * 51;
|
|
61
|
+
const g = (Math.floor(n / 6) % 6) * 51;
|
|
62
|
+
const b = (n % 6) * 51;
|
|
63
|
+
return hex((r << 16) | (g << 8) | b);
|
|
64
|
+
}
|
|
65
|
+
const level = (index - 232) * 10 + 8;
|
|
66
|
+
return hex((level << 16) | (level << 8) | level);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toGridCell(cell: CellData, theme: Theme): GridCell {
|
|
70
|
+
let fg = resolveColor(cell.fg, cell.fgRgb, theme);
|
|
71
|
+
let bg = resolveColor(cell.bg, cell.bgRgb, theme);
|
|
72
|
+
if (cell.flags & FLAG.reverse) {
|
|
73
|
+
[fg, bg] = [bg ?? theme.background, fg ?? theme.foreground];
|
|
74
|
+
}
|
|
75
|
+
const text = cell.chars ?? (cell.char === 0 ? " " : String.fromCodePoint(cell.char));
|
|
76
|
+
return { text, width: cell.width === 2 ? 2 : 1, fg, bg, flags: cell.flags & ~FLAG.reverse };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function snapshot(core: TerminalCore, theme: Theme): { rows: Map<number, GridCell[]>; key: string } {
|
|
80
|
+
const rows = new Map<number, GridCell[]>();
|
|
81
|
+
const keyParts: string[] = [];
|
|
82
|
+
const cols = core.getCols();
|
|
83
|
+
for (let y = 0; y < core.getRows(); y++) {
|
|
84
|
+
const cells: GridCell[] = [];
|
|
85
|
+
let meaningful = false;
|
|
86
|
+
for (let x = 0; x < cols; x++) {
|
|
87
|
+
const raw = core.getCell(y, x);
|
|
88
|
+
if (raw.width === 0) continue;
|
|
89
|
+
const cell = toGridCell(raw, theme);
|
|
90
|
+
if (cell.text !== " " || cell.bg !== null || cell.flags & (FLAG.underline | FLAG.strike)) meaningful = true;
|
|
91
|
+
cells.push(cell);
|
|
92
|
+
}
|
|
93
|
+
if (meaningful) {
|
|
94
|
+
rows.set(y, cells);
|
|
95
|
+
keyParts.push(`${y}:${cells.map((c) => `${c.text}${c.fg ?? ""}${c.bg ?? ""}${c.flags}`).join("")}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { rows, key: keyParts.join("\n") };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Replay the visible timeline into a headless core and return de-duplicated grid snapshots with hold durations.
|
|
103
|
+
* This is the shared source for vector/text exporters (SVG, and anything else that doesn't need pixels).
|
|
104
|
+
*/
|
|
105
|
+
export async function replayFrames(rec: Recording, config: ResolvedConfig): Promise<GridReplay> {
|
|
106
|
+
const core = await loadCore(config.core);
|
|
107
|
+
core.init(rec.header.width, rec.header.height);
|
|
108
|
+
|
|
109
|
+
const timeline = buildTimeline(rec.events, config.playbackSpeed);
|
|
110
|
+
const osc = themeOsc(config.theme);
|
|
111
|
+
const events = config.core === "lite" ? timeline.events : withReinjection(timeline.events, osc);
|
|
112
|
+
if (config.core !== "lite") core.writeString(osc);
|
|
113
|
+
|
|
114
|
+
const fps = config.fps;
|
|
115
|
+
const totalFrames = Math.max(1, Math.ceil(timeline.duration * fps) + 1);
|
|
116
|
+
const frames: GridFrame[] = [];
|
|
117
|
+
let pointer = 0;
|
|
118
|
+
let lastKey: string | null = null;
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
121
|
+
const time = i / fps;
|
|
122
|
+
while (pointer < events.length && events[pointer]!.vt <= time + 1e-9) {
|
|
123
|
+
const e = events[pointer++]!;
|
|
124
|
+
if (e.type === "o") core.writeString(e.data);
|
|
125
|
+
else if (e.type === "r") {
|
|
126
|
+
const [c, r] = e.data.split("x").map(Number);
|
|
127
|
+
if (c! > 0 && r! > 0) core.resize(c!, r!);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const cursor = core.getCursor();
|
|
131
|
+
const { rows, key } = snapshot(core, config.theme);
|
|
132
|
+
const fullKey = `${key}|${cursor.row},${cursor.col},${cursor.visible}|${core.getCols()}x${core.getRows()}`;
|
|
133
|
+
if (fullKey === lastKey && frames.length > 0) {
|
|
134
|
+
frames[frames.length - 1]!.hold += 1 / fps;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
lastKey = fullKey;
|
|
138
|
+
frames.push({ time, hold: 1 / fps, cols: core.getCols(), rows: core.getRows(), rows_: rows, cursor });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { frames, duration: totalFrames / fps, cols: rec.header.width, rows: rec.header.height };
|
|
142
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { barHeight } from "../renderer/page";
|
|
4
|
+
import { pageAssets } from "../renderer/bundle";
|
|
5
|
+
import { buildTimeline } from "../timeline";
|
|
6
|
+
import type { Recording, ResolvedConfig, Theme } from "../types";
|
|
7
|
+
|
|
8
|
+
const ANSI_ORDER: (keyof Theme)[] = [
|
|
9
|
+
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
|
|
10
|
+
"brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function escapeHtml(s: string): string {
|
|
14
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function windowBar(config: ResolvedConfig): string {
|
|
18
|
+
if (config.windowBar === "none") return "";
|
|
19
|
+
const rings = config.windowBar.startsWith("rings");
|
|
20
|
+
const right = config.windowBar.endsWith("Right");
|
|
21
|
+
const dot = (c: string) => `<span class="dot" style="${rings ? `border:2px solid ${c}` : `background:${c}`}"></span>`;
|
|
22
|
+
const dots = `<div class="dots">${dot("#ff5f57")}${dot("#febc2e")}${dot("#28c840")}</div>`;
|
|
23
|
+
const title = `<div class="title">${escapeHtml(config.title)}</div>`;
|
|
24
|
+
return `<div id="bar" class="${right ? "right" : ""}">${right ? title + dots : dots + title}</div>`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Single-file HTML player: cast + theme + lite core + controls. Works from file://. */
|
|
28
|
+
export async function buildHtml(rec: Recording, config: ResolvedConfig): Promise<string> {
|
|
29
|
+
const assets = await pageAssets();
|
|
30
|
+
const { events, duration } = buildTimeline(rec.events, config.playbackSpeed);
|
|
31
|
+
const { theme, font } = config;
|
|
32
|
+
const data = {
|
|
33
|
+
cols: rec.header.width,
|
|
34
|
+
rows: rec.header.height,
|
|
35
|
+
duration,
|
|
36
|
+
speed: 1,
|
|
37
|
+
events: events.filter((e) => e.type === "o" || e.type === "r").map(({ vt, type, data }) => ({ vt, type, data })),
|
|
38
|
+
};
|
|
39
|
+
// "</script>" inside the JSON would terminate the data block; escape it.
|
|
40
|
+
const json = JSON.stringify(data).replace(/<\//g, "<\\/");
|
|
41
|
+
const vars = [
|
|
42
|
+
`--term-fg:${theme.foreground}`,
|
|
43
|
+
`--term-bg:${theme.background}`,
|
|
44
|
+
`--term-cursor:${theme.cursor ?? theme.foreground}`,
|
|
45
|
+
...ANSI_ORDER.map((k, i) => `--term-color-${i}:${theme[k]}`),
|
|
46
|
+
].join(";");
|
|
47
|
+
|
|
48
|
+
return `<!doctype html>
|
|
49
|
+
<html lang="en">
|
|
50
|
+
<head>
|
|
51
|
+
<meta charset="utf-8">
|
|
52
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
53
|
+
<title>${escapeHtml(config.title || "tcut recording")}</title>
|
|
54
|
+
<style>
|
|
55
|
+
${assets.css}
|
|
56
|
+
html, body { margin: 0; background: ${config.marginFill}; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
57
|
+
#frame { display: inline-block; background: ${theme.background}; border-radius: ${config.borderRadius}px; padding: ${config.padding}px; margin: ${config.margin}px; box-shadow: 0 12px 40px rgba(0,0,0,.35); }
|
|
58
|
+
#bar { height: ${barHeight(config)}px; margin-top: -${Math.min(config.padding, 12)}px; display: flex; align-items: center; justify-content: space-between; font: 13px -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: ${theme.foreground}; }
|
|
59
|
+
#bar .dots { display: flex; gap: 8px; } #bar .dot { width: 12px; height: 12px; border-radius: 50%; box-sizing: border-box; display: inline-block; }
|
|
60
|
+
#bar .title { flex: 1; text-align: center; opacity: .7; } #bar.right .title { text-align: left; }
|
|
61
|
+
#term.wterm { ${vars}; --term-font-family: ${font.family}; --term-font-size: ${font.size}px; --term-line-height: ${font.lineHeight}; --term-row-height: ${Math.ceil(font.size * font.lineHeight)}px; letter-spacing: ${font.letterSpacing}px; padding: 0; border-radius: 0; box-shadow: none; background: transparent; cursor: pointer; }
|
|
62
|
+
#controls { display: flex; gap: 12px; align-items: center; margin-top: 12px; font: 12px -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: ${theme.foreground}; opacity: .85; }
|
|
63
|
+
#controls button { background: transparent; color: inherit; border: 1px solid currentColor; border-radius: 6px; width: 34px; height: 26px; cursor: pointer; }
|
|
64
|
+
#controls input[type=range] { flex: 1; accent-color: ${theme.cursor ?? theme.foreground}; }
|
|
65
|
+
#controls label { display: flex; gap: 4px; align-items: center; }
|
|
66
|
+
</style>
|
|
67
|
+
</head>
|
|
68
|
+
<body>
|
|
69
|
+
<div id="frame">
|
|
70
|
+
${windowBar(config)}
|
|
71
|
+
<div id="term"></div>
|
|
72
|
+
<div id="controls">
|
|
73
|
+
<button id="play" title="Play / pause">▶</button>
|
|
74
|
+
<input id="progress" type="range" min="0" max="1000" value="0">
|
|
75
|
+
<span id="time">0:00</span>
|
|
76
|
+
<label><input id="loop" type="checkbox" checked> loop</label>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
<script type="application/json" id="tcut-cast">${json}</script>
|
|
80
|
+
<script type="module">${assets.playerJs}</script>
|
|
81
|
+
</body>
|
|
82
|
+
</html>`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function writeHtml(rec: Recording, config: ResolvedConfig, file: string): Promise<void> {
|
|
86
|
+
await mkdir(path.dirname(path.resolve(file)), { recursive: true });
|
|
87
|
+
await Bun.write(file, await buildHtml(rec, config));
|
|
88
|
+
}
|