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 +1 -1
- package/src/browser.ts +18 -5
- package/src/cli.ts +3 -3
- package/src/config.ts +1 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +1 -1
- package/src/recorder.ts +4 -2
- package/src/renderer/png.ts +30 -7
- package/src/types.ts +3 -0
package/package.json
CHANGED
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
|
|
52
|
-
const STARTUP_GRACE =
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
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;
|
package/src/renderer/png.ts
CHANGED
|
@@ -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
|
-
//
|
|
4
|
-
|
|
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
|
|
20
|
-
|
|
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 =
|
|
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",
|
|
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>;
|