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/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 { WINDOW_BAR_HEIGHT, estimateCell } from "./config";
3
- import { formatMs, toMs } from "./duration";
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 class WaitTimeoutError extends Error {
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; only changed frames are kept.
157
- let browserSession: (BrowserSession & { frames: BrowserFrame[]; stop(): Promise<void> }) | null = null;
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(),
@@ -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
- function ffmpegArgs(format: Format, fps: number, output: string, encoder: string): string[] {
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
  }