termcut 0.7.2 → 0.7.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
5
5
  "license": "MIT",
6
6
  "author": "Aman Varshney",
package/src/browser.ts CHANGED
@@ -48,10 +48,18 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
48
48
  // Sampling starts once a page has loaded: headless Chrome never resolves a screenshot of the initial blank
49
49
  // view, and a hung screenshot blocks every later command on that view.
50
50
  let loaded = false;
51
- /** Extra time allowed while the browser has never answered: a cold Chrome on a CI runner can take ~20 s to come up. */
52
- const STARTUP_GRACE = 20_000;
51
+ /** Extra time allowed while the browser has never answered: a cold Chrome on a fresh Windows runner can take 20–40 s. */
52
+ const STARTUP_GRACE = 45_000;
53
53
  const startedAt = performance.now();
54
- const startupDeadline = (deadline: number): number => (loaded ? deadline : Math.max(deadline, startedAt + STARTUP_GRACE));
54
+ let startupNoted = false;
55
+ const startupDeadline = (deadline: number): number => {
56
+ if (loaded) return deadline;
57
+ if (!startupNoted && performance.now() > startedAt + 5000) {
58
+ startupNoted = true;
59
+ log("waiting for the browser to start…");
60
+ }
61
+ return Math.max(deadline, startedAt + STARTUP_GRACE);
62
+ };
55
63
  // A view runs one evaluate() at a time; goto's probes, waitFor and the script's own evaluate calls take turns.
56
64
  let chain: Promise<unknown> = Promise.resolve();
57
65
  const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
@@ -89,6 +97,11 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
89
97
  })();
90
98
  return sampling;
91
99
  };
100
+ /** A sample taken after this moment: waits out any in-flight screenshot (seconds on a cold Chrome), then takes a fresh one. */
101
+ const sampleNow = async (): Promise<void> => {
102
+ if (sampling) await sampling;
103
+ await sampleOnce();
104
+ };
92
105
  const sampler = (async () => {
93
106
  while (running) {
94
107
  await sampleOnce();
@@ -153,7 +166,7 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
153
166
  const text = String((await evaluate("document.body ? document.body.innerText : ''", 5000, "waitFor").catch(() => "")) ?? "");
154
167
  if (regex.test(text)) {
155
168
  loaded = true; // a page that answers is a page worth sampling, even if navigate() has not settled yet
156
- await sampleOnce(); // the frame the script waited for, captured the moment it appeared
169
+ await sampleNow(); // the frame the script waited for, captured the moment it appeared
157
170
  return;
158
171
  }
159
172
  if (performance.now() > startupDeadline(deadline)) throw new WaitTimeoutError(`${regex} in the browser page`, timeout, text.slice(0, 2000));
@@ -178,7 +191,7 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
178
191
  if (!loaded && bcfg.url) await evaluate("document.readyState", 2000, "probe").catch(() => undefined); // marks loaded if the page answers
179
192
  running = false;
180
193
  await sampler;
181
- await sampleOnce(); // final state of the page
194
+ await sampleNow(); // final state of the page
182
195
  try {
183
196
  view.close();
184
197
  } catch {
package/src/cli.ts CHANGED
@@ -15,7 +15,7 @@ import { renderOutputs } from "./render";
15
15
  import { generateScript } from "./scriptgen";
16
16
  import { runScriptTests, type TestSummary } from "./testing";
17
17
  import { findThemes, themeNames } from "./themes";
18
- import type { BrowserConfig, ClipSelection, CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
18
+ import type { BrowserConfig, ClipSelection, CoreName, Recording, ResolvedConfig, ThemeName, VideoConfig, WindowBar } from "./types";
19
19
  import { Video, attachBrowserFrames, castConfig, isVideo, renderCast } from "./video";
20
20
 
21
21
  // Let user scripts `import { defineVideo } from "tcut"` (or "termcut", the npm package name) regardless of
@@ -524,13 +524,13 @@ async function main(): Promise<void> {
524
524
  const castOut = values.cast ?? (joining ? path.join(path.dirname(rest[0]!), "concat.cast") : rest[0]!.replace(/\.cast$/, "") + "-cut.cast");
525
525
  const overrides = overridesFromFlags();
526
526
  delete overrides.cast;
527
- const parts: Array<{ rec: Awaited<ReturnType<typeof readCast>>; config: ReturnType<typeof castConfig> }> = [];
527
+ const parts: Array<{ rec: Recording; config: ResolvedConfig }> = [];
528
528
  for (const [i, file] of rest.entries()) {
529
529
  const rec = await readCast(file);
530
530
  const config = applyOverrides(castConfig(rec, file, values.output), overrides);
531
531
  parts.push({ rec: await rebaseBrowserFrames(rec, file, castOut, joining ? `${i}-` : ""), config });
532
532
  }
533
- let out: Awaited<ReturnType<typeof readCast>>;
533
+ let out: Recording;
534
534
  if (joining) {
535
535
  out = concatRecordings(parts, { gap: seconds("gap") ?? 0 });
536
536
  } else {
package/src/config.ts CHANGED
@@ -114,6 +114,7 @@ export function resolveConfig(input: VideoConfig): ResolvedConfig {
114
114
  quantize: config.quantize ?? false,
115
115
  core: config.core ?? "ghostty",
116
116
  cache: config.cache ?? true,
117
+ requires: config.requires ?? [],
117
118
  font,
118
119
  theme,
119
120
  cursor: {
package/src/errors.ts CHANGED
@@ -7,6 +7,16 @@ export class WaitTimeoutError extends Error {
7
7
  }
8
8
  }
9
9
 
10
+ /** A program named in `requires` is not on the PATH — raised before the shell is even started. */
11
+ export class MissingRequirementError extends Error {
12
+ constructor(readonly missing: string[]) {
13
+ super(
14
+ `${missing.length === 1 ? `\`${missing[0]}\` is` : `${missing.map((m) => `\`${m}\``).join(", ")} are`} not on the PATH. Install ${missing.length === 1 ? "it" : "them"}, or remove ${missing.length === 1 ? "it" : "them"} from \`requires\`.`,
15
+ );
16
+ this.name = "MissingRequirementError";
17
+ }
18
+ }
19
+
10
20
  export class ExpectationError extends Error {
11
21
  constructor(what: string, screen: string) {
12
22
  super(`Expected ${what} to match.\n\n--- screen ---\n${screen}\n--------------`);
package/src/index.ts CHANGED
@@ -26,5 +26,5 @@ export type { DiffOptions, DiffResult } from "./diff";
26
26
  export { keyLabels, keyChips } from "./keylabels";
27
27
  export { startBrowserCapture } from "./browser";
28
28
  export { resolveConfig } from "./config";
29
- export { WaitTimeoutError, ExpectationError } from "./recorder";
29
+ export { WaitTimeoutError, ExpectationError, MissingRequirementError } from "./recorder";
30
30
  export type * from "./types";
package/src/recorder.ts CHANGED
@@ -2,7 +2,7 @@ import { MarkdownRenderer } from "@wterm/markdown";
2
2
  import { startBrowserCapture, type BrowserCapture } from "./browser";
3
3
  import { MARKER } from "./cast";
4
4
  import { toMs } from "./duration";
5
- import { ExpectationError, WaitTimeoutError } from "./errors";
5
+ import { ExpectationError, MissingRequirementError, WaitTimeoutError } from "./errors";
6
6
  import { altSequence, ctrlSequence, keySequence, shiftSequence, wheelSequence } from "./keys";
7
7
  import { Screen } from "./screen";
8
8
  import type {
@@ -20,7 +20,7 @@ import type {
20
20
  WaitOptions,
21
21
  } from "./types";
22
22
 
23
- export { WaitTimeoutError, ExpectationError } from "./errors";
23
+ export { WaitTimeoutError, ExpectationError, MissingRequirementError } from "./errors";
24
24
 
25
25
  /** Deterministic PRNG (mulberry32) so typing jitter is reproducible. */
26
26
  function mulberry32(seed: number): () => number {
@@ -103,6 +103,8 @@ export function defaultLang(): string {
103
103
  /** Drives a Bun.Terminal PTY according to a script and produces an asciicast recording. */
104
104
  export async function record(config: ResolvedConfig, script: Script, opts: RecordOptions = {}): Promise<Recording> {
105
105
  const log = opts.log ?? (() => {});
106
+ const missing = config.requires.filter((name) => Bun.which(name) === null);
107
+ if (missing.length > 0) throw new MissingRequirementError(missing);
106
108
  const events: CastEvent[] = [];
107
109
  const screen = await Screen.create(config.cols, config.rows, { core: config.core });
108
110
  const fast = opts.fast === true;
@@ -1,7 +1,7 @@
1
1
  // A minimal PNG codec for the renderer. Bun.WebView screenshots come in as opaque PNGs and Bun.Image has no raw
2
2
  // pixel access, so transparent output needs its own decode → matte → encode step. 8-bit RGB/RGBA, non-interlaced.
3
- // node:zlib rather than Bun.inflateSync/deflateSync: PNG needs zlib-framed streams and Bun's defaults are raw deflate.
4
- import { deflateSync, inflateSync } from "node:zlib";
3
+ // PNG streams are zlib-framed; Bun's inflateSync reads that with windowBits 15, and deflateSync's raw output gets the
4
+ // two-byte header and Adler-32 trailer added here (Bun only — no node:zlib).
5
5
 
6
6
  /** Straight (non-premultiplied) RGBA pixels, row-major. */
7
7
  export interface RgbaImage {
@@ -16,8 +16,31 @@ const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
16
16
 
17
17
  const readU32 = (b: Uint8Array, o: number): number => ((b[o]! << 24) | (b[o + 1]! << 16) | (b[o + 2]! << 8) | b[o + 3]!) >>> 0;
18
18
 
19
- function concat(parts: Uint8Array[]): Uint8Array {
20
- const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
19
+ function adler32(bytes: Uint8Array): number {
20
+ let a = 1;
21
+ let b = 0;
22
+ for (let i = 0; i < bytes.length; i++) {
23
+ a = (a + bytes[i]!) % 65521;
24
+ b = (b + a) % 65521;
25
+ }
26
+ return ((b << 16) | a) >>> 0;
27
+ }
28
+
29
+ /** Raw DEFLATE from Bun.deflateSync wrapped as a zlib stream (RFC 1950): CMF/FLG header + data + Adler-32. */
30
+ function zlibDeflate(raw: Uint8Array<ArrayBuffer>, level: 1 | 6 = 1): Uint8Array {
31
+ const body = Bun.deflateSync(raw, { level });
32
+ const out = new Uint8Array(2 + body.length + 4);
33
+ out[0] = 0x78;
34
+ out[1] = level === 1 ? 0x01 : 0x9c;
35
+ out.set(body, 2);
36
+ new DataView(out.buffer).setUint32(2 + body.length, adler32(raw));
37
+ return out;
38
+ }
39
+
40
+ const zlibInflate = (data: Uint8Array<ArrayBuffer>): Uint8Array => Bun.inflateSync(data, { windowBits: 15 });
41
+
42
+ function concat(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
43
+ const out = new Uint8Array(new ArrayBuffer(parts.reduce((n, p) => n + p.length, 0)));
21
44
  let o = 0;
22
45
  for (const p of parts) {
23
46
  out.set(p, o);
@@ -83,7 +106,7 @@ export function decodePng(png: Uint8Array): RgbaImage {
83
106
  }
84
107
  const bpp = colorType === 6 ? 4 : 3;
85
108
  const stride = width * bpp;
86
- const raw = inflateSync(concat(idat));
109
+ const raw = zlibInflate(concat(idat));
87
110
  const out = new Uint8Array(width * height * 4);
88
111
  let prev = new Uint8Array(stride);
89
112
  let row = new Uint8Array(stride);
@@ -133,7 +156,7 @@ function chunk(type: string, data: Uint8Array): Uint8Array {
133
156
  /** Encode straight RGBA as an 8-bit PNG (filter 0; speed matters more than size — ffmpeg re-encodes anyway). */
134
157
  export function encodePng(img: RgbaImage): Uint8Array {
135
158
  const stride = img.width * 4;
136
- const raw = new Uint8Array((stride + 1) * img.height);
159
+ const raw = new Uint8Array(new ArrayBuffer((stride + 1) * img.height));
137
160
  for (let y = 0; y < img.height; y++) {
138
161
  raw[y * (stride + 1)] = 0;
139
162
  raw.set(img.data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
@@ -144,7 +167,7 @@ export function encodePng(img: RgbaImage): Uint8Array {
144
167
  v.setUint32(4, img.height);
145
168
  ihdr[8] = 8; // bit depth
146
169
  ihdr[9] = 6; // RGBA
147
- return concat([new Uint8Array(SIGNATURE), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 1 })), chunk("IEND", new Uint8Array(0))]);
170
+ return concat([new Uint8Array(SIGNATURE), chunk("IHDR", ihdr), chunk("IDAT", zlibDeflate(raw)), chunk("IEND", new Uint8Array(0))]);
148
171
  }
149
172
 
150
173
  export function parseHex(color: string): Rgb {
package/src/types.ts CHANGED
@@ -205,6 +205,8 @@ export interface VideoConfig {
205
205
  core?: CoreName;
206
206
  /** Reuse the existing cast when the script and record config are unchanged. Default true. */
207
207
  cache?: boolean;
208
+ /** Programs the script needs on the PATH (`["bun", "eza"]`). Checked before the shell starts; missing ones fail fast with a clear message. */
209
+ requires?: string[];
208
210
 
209
211
  font?: FontConfig;
210
212
  theme?: ThemeName | Theme;
@@ -257,6 +259,7 @@ export interface ResolvedConfig {
257
259
  quantize: boolean;
258
260
  core: CoreName;
259
261
  cache: boolean;
262
+ requires: string[];
260
263
  font: Required<FontConfig>;
261
264
  theme: Theme;
262
265
  cursor: Required<CursorConfig>;