termcut 0.5.0 → 0.6.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 +2 -0
- package/package.json +3 -2
- package/src/browser.ts +122 -0
- package/src/cast.ts +2 -0
- package/src/cli.ts +45 -2
- package/src/config.ts +11 -1
- package/src/diff.ts +91 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +5 -0
- package/src/keylabels.ts +81 -0
- package/src/live.ts +5 -1
- package/src/presets.ts +24 -0
- package/src/recorder.ts +41 -113
- package/src/renderer/encoder.ts +29 -4
- package/src/renderer/generated/page.js +1 -1
- package/src/renderer/page-entry.ts +28 -0
- package/src/renderer/page.ts +14 -1
- package/src/renderer/webview.ts +86 -5
- package/src/timeline.ts +29 -10
- package/src/types.ts +40 -0
package/src/recorder.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { MarkdownRenderer } from "@wterm/markdown";
|
|
2
|
+
import { startBrowserCapture, type BrowserCapture } from "./browser";
|
|
1
3
|
import { MARKER } from "./cast";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
+
import { toMs } from "./duration";
|
|
5
|
+
import { ExpectationError, WaitTimeoutError } from "./errors";
|
|
4
6
|
import { altSequence, ctrlSequence, keySequence, shiftSequence, wheelSequence } from "./keys";
|
|
5
7
|
import { Screen } from "./screen";
|
|
6
8
|
import type {
|
|
7
|
-
BrowserFrame,
|
|
8
9
|
BrowserSession,
|
|
9
10
|
CastEvent,
|
|
10
11
|
Duration,
|
|
@@ -19,19 +20,7 @@ import type {
|
|
|
19
20
|
WaitOptions,
|
|
20
21
|
} from "./types";
|
|
21
22
|
|
|
22
|
-
export
|
|
23
|
-
constructor(what: string, timeoutMs: number, screen: string) {
|
|
24
|
-
super(`Timed out after ${formatMs(timeoutMs)} waiting for ${what}.\n\n--- screen ---\n${screen}\n--------------`);
|
|
25
|
-
this.name = "WaitTimeoutError";
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export class ExpectationError extends Error {
|
|
30
|
-
constructor(what: string, screen: string) {
|
|
31
|
-
super(`Expected ${what} to match.\n\n--- screen ---\n${screen}\n--------------`);
|
|
32
|
-
this.name = "ExpectationError";
|
|
33
|
-
}
|
|
34
|
-
}
|
|
23
|
+
export { WaitTimeoutError, ExpectationError } from "./errors";
|
|
35
24
|
|
|
36
25
|
/** Deterministic PRNG (mulberry32) so typing jitter is reproducible. */
|
|
37
26
|
function mulberry32(seed: number): () => number {
|
|
@@ -153,103 +142,8 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
153
142
|
if (!exited && !terminal.closed) terminal.write(response);
|
|
154
143
|
};
|
|
155
144
|
|
|
156
|
-
// Optional browser pane: a WebView sampled on the recording clock
|
|
157
|
-
|
|
158
|
-
if (config.browser) {
|
|
159
|
-
if (typeof Bun.WebView !== "function") throw new Error("The browser pane needs Bun.WebView (Bun >= 1.4).");
|
|
160
|
-
const bcfg = config.browser;
|
|
161
|
-
// Default pane height: what the terminal window will measure (estimated from the font metrics).
|
|
162
|
-
const est = estimateCell(config.font);
|
|
163
|
-
const termFrameH = Math.round(config.rows * est.h + config.padding * 2 + (config.windowBar === "none" ? 0 : WINDOW_BAR_HEIGHT));
|
|
164
|
-
const termFrameW = Math.round(config.cols * est.w + config.padding * 2);
|
|
165
|
-
const stacked = bcfg.position === "top" || bcfg.position === "bottom";
|
|
166
|
-
const paneW = stacked ? termFrameW : bcfg.width;
|
|
167
|
-
const paneH = bcfg.height || (stacked || bcfg.position === "overlay" ? 480 : termFrameH);
|
|
168
|
-
const view = new Bun.WebView({ width: paneW, height: paneH });
|
|
169
|
-
const frames: BrowserFrame[] = [];
|
|
170
|
-
// WebView calls can stall (page never fires load, evaluate on a navigating page); never let them hang a recording.
|
|
171
|
-
const within = <T,>(promise: Promise<T>, ms: number, label: string): Promise<T> =>
|
|
172
|
-
Promise.race([promise, Bun.sleep(ms).then(() => Promise.reject(new Error(`browser.${label} did not finish within ${ms}ms`)))]);
|
|
173
|
-
let currentUrl = bcfg.url ?? "about:blank";
|
|
174
|
-
let lastHash = "";
|
|
175
|
-
let running = true;
|
|
176
|
-
const sampler = (async () => {
|
|
177
|
-
while (running) {
|
|
178
|
-
try {
|
|
179
|
-
const png = (await within(view.screenshot({ encoding: "buffer" }), 5000, "screenshot")) as Uint8Array;
|
|
180
|
-
const hash = Bun.hash(png).toString(16);
|
|
181
|
-
if (hash !== lastHash) {
|
|
182
|
-
lastHash = hash;
|
|
183
|
-
frames.push({ time: stamp(), png });
|
|
184
|
-
}
|
|
185
|
-
} catch {
|
|
186
|
-
/* view busy or closed */
|
|
187
|
-
}
|
|
188
|
-
await Bun.sleep(1000 / bcfg.fps);
|
|
189
|
-
}
|
|
190
|
-
})();
|
|
191
|
-
/**
|
|
192
|
-
* Navigate and wait for the page to be there. Dev servers may still be starting (connection refused → retry)
|
|
193
|
-
* or may take a long first load (Vite pre-bundling, then a reload), so success is judged by the document's
|
|
194
|
-
* readyState at the target URL rather than by the navigate() promise alone.
|
|
195
|
-
*/
|
|
196
|
-
const goto = async (url: string): Promise<void> => {
|
|
197
|
-
const deadline = performance.now() + config.waitTimeout;
|
|
198
|
-
const target = url.replace(/\/$/, "");
|
|
199
|
-
let navigation: Promise<"ok" | "pending" | "failed"> | null = null;
|
|
200
|
-
for (;;) {
|
|
201
|
-
navigation ??= view.navigate(url).then(
|
|
202
|
-
() => "ok" as const,
|
|
203
|
-
(err: unknown) => (/pending/i.test(String(err)) ? ("pending" as const) : ("failed" as const)),
|
|
204
|
-
);
|
|
205
|
-
const outcome = await Promise.race([navigation, Bun.sleep(750).then(() => "tick" as const)]);
|
|
206
|
-
if (outcome === "ok") {
|
|
207
|
-
currentUrl = url;
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
if (outcome === "failed") navigation = null; // e.g. connection refused: the server isn't up yet
|
|
211
|
-
const state = await within(view.evaluate("document.readyState"), 3000, "goto").catch(() => "");
|
|
212
|
-
if (state === "complete" && (view.url ?? "").replace(/\/$/, "").startsWith(target)) {
|
|
213
|
-
currentUrl = url;
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
if (performance.now() > deadline) {
|
|
217
|
-
throw new WaitTimeoutError(`browser.goto(${url})`, config.waitTimeout, `current url: ${view.url ?? "(none)"}, readyState: ${state || "unknown"}`);
|
|
218
|
-
}
|
|
219
|
-
await Bun.sleep(250);
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
browserSession = {
|
|
223
|
-
frames,
|
|
224
|
-
get url() {
|
|
225
|
-
return currentUrl;
|
|
226
|
-
},
|
|
227
|
-
goto,
|
|
228
|
-
async waitFor(pattern, waitOpts = {}) {
|
|
229
|
-
const regex = toRegExp(pattern);
|
|
230
|
-
const deadline = performance.now() + toMs(waitOpts.timeout, config.waitTimeout);
|
|
231
|
-
for (;;) {
|
|
232
|
-
const text = String((await within(view.evaluate("document.body ? document.body.innerText : ''"), 5000, "waitFor").catch(() => "")) ?? "");
|
|
233
|
-
if (regex.test(text)) return;
|
|
234
|
-
if (performance.now() > deadline) throw new WaitTimeoutError(`${regex} in the browser page`, toMs(waitOpts.timeout, config.waitTimeout), text.slice(0, 2000));
|
|
235
|
-
await Bun.sleep(150);
|
|
236
|
-
}
|
|
237
|
-
},
|
|
238
|
-
click: (selector) => within(view.click(selector), 10000, "click"),
|
|
239
|
-
reload: () => within(view.reload(), 30000, "reload").catch((err) => (/pending/i.test(String(err)) ? undefined : Promise.reject(err))),
|
|
240
|
-
evaluate: (js) => within(view.evaluate(js), 10000, "evaluate"),
|
|
241
|
-
async stop() {
|
|
242
|
-
running = false;
|
|
243
|
-
await sampler;
|
|
244
|
-
try {
|
|
245
|
-
view.close();
|
|
246
|
-
} catch {
|
|
247
|
-
/* closed */
|
|
248
|
-
}
|
|
249
|
-
},
|
|
250
|
-
};
|
|
251
|
-
if (bcfg.url) await goto(bcfg.url).catch((err) => log(String(err)));
|
|
252
|
-
}
|
|
145
|
+
// Optional browser pane (shared with live recording): a WebView sampled on the recording clock.
|
|
146
|
+
const browserSession: BrowserCapture | null = config.browser ? startBrowserCapture(config, stamp, log) : null;
|
|
253
147
|
|
|
254
148
|
const sleep = async (duration: Duration): Promise<void> => {
|
|
255
149
|
const ms = toMs(duration);
|
|
@@ -269,6 +163,30 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
269
163
|
push("i", typeof data === "string" ? data : decoder.decode(data));
|
|
270
164
|
};
|
|
271
165
|
|
|
166
|
+
/** Put text on screen as if the terminal had printed it: into the cast and the screen model, not the PTY. */
|
|
167
|
+
const inject = (data: string): void => {
|
|
168
|
+
push("o", data);
|
|
169
|
+
screen.write(data);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const renderMarkdown = (markdown: string): string => {
|
|
173
|
+
const renderer = new MarkdownRenderer({ width: Math.max(20, cols - 2) });
|
|
174
|
+
return renderer.push(markdown.endsWith("\n") ? markdown : markdown + "\n") + renderer.flush();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const print = async (markdown: string): Promise<void> => {
|
|
178
|
+
await screen.settle();
|
|
179
|
+
// Clear the prompt line, show the caption, then ask the shell for a fresh prompt on the next line.
|
|
180
|
+
inject(`\r\x1b[K${renderMarkdown(markdown)}`);
|
|
181
|
+
await raw("\r");
|
|
182
|
+
await waitFor(`prompt ${promptPattern} after print()`, promptVisible, config.waitTimeout);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const title = async (text: string, titleOpts: { pause?: Duration } = {}): Promise<void> => {
|
|
186
|
+
await print(`# ${text}\n\n---`);
|
|
187
|
+
await sleep(titleOpts.pause ?? "1.5s");
|
|
188
|
+
};
|
|
189
|
+
|
|
272
190
|
const typingDelay = (base: number): number => {
|
|
273
191
|
if (config.typingJitter === 0) return base;
|
|
274
192
|
const factor = 1 + (rand() * 2 - 1) * config.typingJitter;
|
|
@@ -435,6 +353,8 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
435
353
|
await screen.settle();
|
|
436
354
|
},
|
|
437
355
|
clear: () => run("clear"),
|
|
356
|
+
print,
|
|
357
|
+
title,
|
|
438
358
|
get browser(): BrowserSession {
|
|
439
359
|
if (!browserSession) throw new Error("t.browser needs `browser: { url }` in the video config.");
|
|
440
360
|
return browserSession;
|
|
@@ -443,6 +363,14 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
443
363
|
await screen.settle();
|
|
444
364
|
push("m", `${MARKER.focus}${target}`);
|
|
445
365
|
},
|
|
366
|
+
zoom: async (region) => {
|
|
367
|
+
await screen.settle();
|
|
368
|
+
push("m", `${MARKER.zoom}${region ? JSON.stringify({ ...region, duration: region.duration === undefined ? undefined : toMs(region.duration) }) : "null"}`);
|
|
369
|
+
},
|
|
370
|
+
chapter: async (name) => {
|
|
371
|
+
await screen.settle();
|
|
372
|
+
push("m", `${MARKER.chapter}${name}`);
|
|
373
|
+
},
|
|
446
374
|
screen: () => screen.screen(),
|
|
447
375
|
line: () => screen.line(),
|
|
448
376
|
cursor: () => screen.cursor(),
|
package/src/renderer/encoder.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FileSink, Subprocess } from "bun";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
|
|
5
6
|
export interface FrameSink {
|
|
@@ -138,8 +139,26 @@ async function requireEncoder(format: FfmpegFormat, output: string): Promise<Enc
|
|
|
138
139
|
return found;
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
|
|
142
|
+
export interface Chapter {
|
|
143
|
+
title: string;
|
|
144
|
+
/** Start time, seconds. */
|
|
145
|
+
start: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** ffmpeg metadata file with [CHAPTER] blocks (milliseconds timebase). */
|
|
149
|
+
export function chaptersMetadata(chapters: Chapter[], durationSeconds: number): string {
|
|
150
|
+
const lines = [";FFMETADATA1"];
|
|
151
|
+
chapters.forEach((ch, i) => {
|
|
152
|
+
const start = Math.round(ch.start * 1000);
|
|
153
|
+
const end = Math.round((chapters[i + 1]?.start ?? durationSeconds) * 1000);
|
|
154
|
+
lines.push("[CHAPTER]", "TIMEBASE=1/1000", `START=${start}`, `END=${Math.max(end, start + 1)}`, `title=${ch.title.replace(/[\\=;#\n]/g, " ")}`);
|
|
155
|
+
});
|
|
156
|
+
return lines.join("\n") + "\n";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function ffmpegArgs(format: Format, fps: number, output: string, encoder: string, metadataFile?: string): string[] {
|
|
142
160
|
const input = ["-y", "-loglevel", "error", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0"];
|
|
161
|
+
if (metadataFile && format === "mp4") input.push("-i", metadataFile, "-map_metadata", "1");
|
|
143
162
|
const evenSize = "scale=trunc(iw/2)*2:trunc(ih/2)*2";
|
|
144
163
|
switch (format) {
|
|
145
164
|
case "mp4":
|
|
@@ -183,9 +202,10 @@ class FfmpegSink implements FrameSink {
|
|
|
183
202
|
format: Format,
|
|
184
203
|
fps: number,
|
|
185
204
|
match: EncoderMatch,
|
|
205
|
+
metadataFile?: string,
|
|
186
206
|
) {
|
|
187
207
|
this.loops = format === "gif" || format === "webp";
|
|
188
|
-
this.proc = Bun.spawn([match.binary, ...ffmpegArgs(format, fps, target, match.encoder)], { stdin: "pipe", stdout: "ignore", stderr: "pipe" });
|
|
208
|
+
this.proc = Bun.spawn([match.binary, ...ffmpegArgs(format, fps, target, match.encoder, metadataFile)], { stdin: "pipe", stdout: "ignore", stderr: "pipe" });
|
|
189
209
|
this.stdin = this.proc.stdin;
|
|
190
210
|
this.stderr = new Response(this.proc.stderr).text();
|
|
191
211
|
}
|
|
@@ -234,8 +254,13 @@ export async function ensureFfmpeg(): Promise<void> {
|
|
|
234
254
|
);
|
|
235
255
|
}
|
|
236
256
|
|
|
237
|
-
export async function createSinks(outputs: string[], fps: number): Promise<FrameSink[]> {
|
|
257
|
+
export async function createSinks(outputs: string[], fps: number, opts: { chapters?: Chapter[]; durationSeconds?: number } = {}): Promise<FrameSink[]> {
|
|
238
258
|
const sinks: FrameSink[] = [];
|
|
259
|
+
let metadataFile: string | undefined;
|
|
260
|
+
if (opts.chapters && opts.chapters.length > 0) {
|
|
261
|
+
metadataFile = path.join(tmpdir(), `tcut-chapters-${process.pid}-${Math.random().toString(36).slice(2)}.txt`);
|
|
262
|
+
await Bun.write(metadataFile, chaptersMetadata(opts.chapters, opts.durationSeconds ?? 0));
|
|
263
|
+
}
|
|
239
264
|
for (const output of outputs) {
|
|
240
265
|
const format = detectFormat(output);
|
|
241
266
|
if (format === "png-sequence") {
|
|
@@ -251,7 +276,7 @@ export async function createSinks(outputs: string[], fps: number): Promise<Frame
|
|
|
251
276
|
await ensureFfmpeg();
|
|
252
277
|
const match = await requireEncoder(format as FfmpegFormat, output);
|
|
253
278
|
await mkdir(path.dirname(path.resolve(output)), { recursive: true });
|
|
254
|
-
sinks.push(new FfmpegSink(output, format, fps, match));
|
|
279
|
+
sinks.push(new FfmpegSink(output, format, fps, match, metadataFile));
|
|
255
280
|
}
|
|
256
281
|
return sinks;
|
|
257
282
|
}
|