termcut 0.6.4 → 0.7.1
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 +10 -2
- package/package.json +1 -1
- package/scripts/build-themes.ts +10 -6
- package/src/browser.ts +57 -13
- package/src/cast.ts +2 -0
- package/src/cli.ts +135 -27
- package/src/config.ts +35 -2
- package/src/diff.ts +3 -13
- package/src/duration.ts +4 -8
- package/src/edit.ts +188 -0
- package/src/export/frames.ts +18 -1
- package/src/export/html.ts +6 -2
- package/src/export/svg.ts +31 -3
- package/src/index.ts +6 -2
- package/src/keylabels.ts +28 -27
- package/src/keys.ts +18 -18
- package/src/live.ts +18 -7
- package/src/loop.ts +14 -9
- package/src/presets.ts +2 -2
- package/src/publish.ts +19 -21
- package/src/recorder.ts +45 -6
- package/src/render.ts +69 -2
- package/src/renderer/encoder.ts +49 -11
- package/src/renderer/generated/page.js +1 -1
- package/src/renderer/page-entry.ts +18 -6
- package/src/renderer/page.ts +99 -3
- package/src/renderer/png.ts +189 -0
- package/src/renderer/view.ts +41 -0
- package/src/renderer/webview.ts +38 -15
- package/src/screen.ts +9 -3
- package/src/scriptgen.ts +31 -30
- package/src/themes.ts +9 -11
- package/src/timeline.ts +21 -5
- package/src/types.ts +63 -2
- package/src/video.ts +15 -12
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// A minimal PNG codec for the renderer. Bun.WebView screenshots come in as opaque PNGs and Bun.Image has no raw
|
|
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";
|
|
5
|
+
|
|
6
|
+
/** Straight (non-premultiplied) RGBA pixels, row-major. */
|
|
7
|
+
export interface RgbaImage {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
data: Uint8Array;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Rgb = [number, number, number];
|
|
14
|
+
|
|
15
|
+
const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
16
|
+
|
|
17
|
+
const readU32 = (b: Uint8Array, o: number): number => ((b[o]! << 24) | (b[o + 1]! << 16) | (b[o + 2]! << 8) | b[o + 3]!) >>> 0;
|
|
18
|
+
|
|
19
|
+
function concat(parts: Uint8Array[]): Uint8Array {
|
|
20
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
21
|
+
let o = 0;
|
|
22
|
+
for (const p of parts) {
|
|
23
|
+
out.set(p, o);
|
|
24
|
+
o += p.length;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const paeth = (a: number, b: number, c: number): number => {
|
|
30
|
+
const p = a + b - c;
|
|
31
|
+
const pa = Math.abs(p - a);
|
|
32
|
+
const pb = Math.abs(p - b);
|
|
33
|
+
const pc = Math.abs(p - c);
|
|
34
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Undo a PNG row filter in place; `prev` is the previous (already unfiltered) row. */
|
|
38
|
+
function unfilter(filter: number, row: Uint8Array, prev: Uint8Array, bpp: number): void {
|
|
39
|
+
switch (filter) {
|
|
40
|
+
case 0:
|
|
41
|
+
return;
|
|
42
|
+
case 1:
|
|
43
|
+
for (let i = bpp; i < row.length; i++) row[i] = (row[i]! + row[i - bpp]!) & 0xff;
|
|
44
|
+
return;
|
|
45
|
+
case 2:
|
|
46
|
+
for (let i = 0; i < row.length; i++) row[i] = (row[i]! + prev[i]!) & 0xff;
|
|
47
|
+
return;
|
|
48
|
+
case 3:
|
|
49
|
+
for (let i = 0; i < row.length; i++) row[i] = (row[i]! + (((i >= bpp ? row[i - bpp]! : 0) + prev[i]!) >> 1)) & 0xff;
|
|
50
|
+
return;
|
|
51
|
+
case 4:
|
|
52
|
+
for (let i = 0; i < row.length; i++) row[i] = (row[i]! + paeth(i >= bpp ? row[i - bpp]! : 0, prev[i]!, i >= bpp ? prev[i - bpp]! : 0)) & 0xff;
|
|
53
|
+
return;
|
|
54
|
+
default:
|
|
55
|
+
throw new Error(`Unsupported PNG filter ${filter}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function decodePng(png: Uint8Array): RgbaImage {
|
|
60
|
+
for (let i = 0; i < SIGNATURE.length; i++) if (png[i] !== SIGNATURE[i]) throw new Error("Not a PNG file");
|
|
61
|
+
let width = 0;
|
|
62
|
+
let height = 0;
|
|
63
|
+
let depth = 0;
|
|
64
|
+
let colorType = 0;
|
|
65
|
+
let interlace = 0;
|
|
66
|
+
const idat: Uint8Array[] = [];
|
|
67
|
+
for (let pos = 8; pos + 8 <= png.length; ) {
|
|
68
|
+
const length = readU32(png, pos);
|
|
69
|
+
const type = String.fromCharCode(png[pos + 4]!, png[pos + 5]!, png[pos + 6]!, png[pos + 7]!);
|
|
70
|
+
const data = png.subarray(pos + 8, pos + 8 + length);
|
|
71
|
+
if (type === "IHDR") {
|
|
72
|
+
width = readU32(data, 0);
|
|
73
|
+
height = readU32(data, 4);
|
|
74
|
+
depth = data[8]!;
|
|
75
|
+
colorType = data[9]!;
|
|
76
|
+
interlace = data[12]!;
|
|
77
|
+
} else if (type === "IDAT") idat.push(data);
|
|
78
|
+
else if (type === "IEND") break;
|
|
79
|
+
pos += 12 + length;
|
|
80
|
+
}
|
|
81
|
+
if (depth !== 8 || (colorType !== 6 && colorType !== 2) || interlace !== 0) {
|
|
82
|
+
throw new Error(`Unsupported PNG layout (bit depth ${depth}, colour type ${colorType}, interlace ${interlace})`);
|
|
83
|
+
}
|
|
84
|
+
const bpp = colorType === 6 ? 4 : 3;
|
|
85
|
+
const stride = width * bpp;
|
|
86
|
+
const raw = inflateSync(concat(idat));
|
|
87
|
+
const out = new Uint8Array(width * height * 4);
|
|
88
|
+
let prev = new Uint8Array(stride);
|
|
89
|
+
let row = new Uint8Array(stride);
|
|
90
|
+
let p = 0;
|
|
91
|
+
for (let y = 0; y < height; y++) {
|
|
92
|
+
const filter = raw[p++]!;
|
|
93
|
+
row.set(raw.subarray(p, p + stride));
|
|
94
|
+
p += stride;
|
|
95
|
+
unfilter(filter, row, prev, bpp);
|
|
96
|
+
const o = y * width * 4;
|
|
97
|
+
if (bpp === 4) out.set(row, o);
|
|
98
|
+
else {
|
|
99
|
+
for (let x = 0; x < width; x++) {
|
|
100
|
+
out[o + x * 4] = row[x * 3]!;
|
|
101
|
+
out[o + x * 4 + 1] = row[x * 3 + 1]!;
|
|
102
|
+
out[o + x * 4 + 2] = row[x * 3 + 2]!;
|
|
103
|
+
out[o + x * 4 + 3] = 255;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
[prev, row] = [row, prev];
|
|
107
|
+
}
|
|
108
|
+
return { width, height, data: out };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const CRC_TABLE = new Uint32Array(256).map((_, n) => {
|
|
112
|
+
let c = n;
|
|
113
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
114
|
+
return c >>> 0;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
function crc32(bytes: Uint8Array): number {
|
|
118
|
+
let c = 0xffffffff;
|
|
119
|
+
for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
|
|
120
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function chunk(type: string, data: Uint8Array): Uint8Array {
|
|
124
|
+
const out = new Uint8Array(12 + data.length);
|
|
125
|
+
const view = new DataView(out.buffer);
|
|
126
|
+
view.setUint32(0, data.length);
|
|
127
|
+
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
|
|
128
|
+
out.set(data, 8);
|
|
129
|
+
view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Encode straight RGBA as an 8-bit PNG (filter 0; speed matters more than size — ffmpeg re-encodes anyway). */
|
|
134
|
+
export function encodePng(img: RgbaImage): Uint8Array {
|
|
135
|
+
const stride = img.width * 4;
|
|
136
|
+
const raw = new Uint8Array((stride + 1) * img.height);
|
|
137
|
+
for (let y = 0; y < img.height; y++) {
|
|
138
|
+
raw[y * (stride + 1)] = 0;
|
|
139
|
+
raw.set(img.data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
|
140
|
+
}
|
|
141
|
+
const ihdr = new Uint8Array(13);
|
|
142
|
+
const v = new DataView(ihdr.buffer);
|
|
143
|
+
v.setUint32(0, img.width);
|
|
144
|
+
v.setUint32(4, img.height);
|
|
145
|
+
ihdr[8] = 8; // bit depth
|
|
146
|
+
ihdr[9] = 6; // RGBA
|
|
147
|
+
return concat([new Uint8Array(SIGNATURE), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 1 })), chunk("IEND", new Uint8Array(0))]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parseHex(color: string): Rgb {
|
|
151
|
+
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(color.trim());
|
|
152
|
+
if (!m) throw new Error(`Expected a #RRGGBB colour, got "${color}"`);
|
|
153
|
+
return [parseInt(m[1]!, 16), parseInt(m[2]!, 16), parseInt(m[3]!, 16)];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Relative luminance (0–1) of an #RRGGBB colour — picks a contrasting second matte background. */
|
|
157
|
+
export function luminance(color: string): number {
|
|
158
|
+
const [r, g, b] = parseHex(color);
|
|
159
|
+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Two-background matting: the same frame rendered over backgrounds `a` and `b` gives, per pixel,
|
|
164
|
+
* alpha = 1 − (Pb − Pa) / (b − a) and colour = (Pa − (1 − alpha)·a) / alpha. Exact for anti-aliased edges and
|
|
165
|
+
* soft shadows alike, which is why the renderer prefers this over a colour key.
|
|
166
|
+
*/
|
|
167
|
+
export function matte(onA: RgbaImage, onB: RgbaImage, a: Rgb, b: Rgb): RgbaImage {
|
|
168
|
+
if (onA.width !== onB.width || onA.height !== onB.height) throw new Error("matte: frame sizes differ");
|
|
169
|
+
const n = onA.width * onA.height;
|
|
170
|
+
const out = new Uint8Array(n * 4);
|
|
171
|
+
const usable = [0, 1, 2].filter((c) => a[c] !== b[c]);
|
|
172
|
+
if (usable.length === 0) throw new Error("matte: the two backgrounds must differ");
|
|
173
|
+
for (let i = 0; i < n; i++) {
|
|
174
|
+
const o = i * 4;
|
|
175
|
+
let alpha = 0;
|
|
176
|
+
for (const c of usable) alpha += 1 - (onB.data[o + c]! - onA.data[o + c]!) / (b[c]! - a[c]!);
|
|
177
|
+
alpha = Math.min(1, Math.max(0, alpha / usable.length));
|
|
178
|
+
if (alpha <= 0.002) {
|
|
179
|
+
out[o + 3] = 0;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
for (let c = 0; c < 3; c++) {
|
|
183
|
+
const v = (onA.data[o + c]! - (1 - alpha) * a[c]!) / alpha;
|
|
184
|
+
out[o + c] = Math.min(255, Math.max(0, Math.round(v)));
|
|
185
|
+
}
|
|
186
|
+
out[o + 3] = Math.round(alpha * 255);
|
|
187
|
+
}
|
|
188
|
+
return { width: onA.width, height: onA.height, data: out };
|
|
189
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// One place that knows how to open a headless Bun.WebView on every platform.
|
|
2
|
+
// macOS uses the system WebKit; everywhere else Bun drives Chrome/Chromium over the DevTools protocol.
|
|
3
|
+
|
|
4
|
+
export interface ViewSize {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Chrome flags that make headless rendering work in containers and CI without changing what is drawn. */
|
|
10
|
+
export function chromeArgs(): string[] {
|
|
11
|
+
const args = ["--force-device-scale-factor=1", "--hide-scrollbars", "--disable-dev-shm-usage"];
|
|
12
|
+
// Chrome refuses to start its sandbox as root (Docker, most CI runners); unprivileged users keep it.
|
|
13
|
+
if (process.getuid?.() === 0) args.push("--no-sandbox");
|
|
14
|
+
return args;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Which backend to use: WebKit on macOS unless TCUT_WEBVIEW=chrome, Chrome elsewhere. */
|
|
18
|
+
export function webViewBackend(): Bun.WebView.Backend {
|
|
19
|
+
const forced = process.env.TCUT_WEBVIEW;
|
|
20
|
+
if (process.platform === "darwin" && forced !== "chrome") return "webkit";
|
|
21
|
+
return {
|
|
22
|
+
type: "chrome",
|
|
23
|
+
url: false, // always spawn our own headless Chrome; never attach to a user's running browser
|
|
24
|
+
argv: chromeArgs(),
|
|
25
|
+
stderr: process.env.TCUT_DEBUG_CHROME ? "inherit" : "ignore",
|
|
26
|
+
...(process.env.BUN_CHROME_PATH && { path: process.env.BUN_CHROME_PATH }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createWebView(size: ViewSize): Bun.WebView {
|
|
31
|
+
if (!Bun.WebView) throw new Error("Bun.WebView is not available in this Bun version. tcut needs Bun >= 1.4.");
|
|
32
|
+
try {
|
|
33
|
+
return new Bun.WebView({ ...size, backend: webViewBackend() });
|
|
34
|
+
} catch (cause) {
|
|
35
|
+
const hint =
|
|
36
|
+
process.platform === "darwin"
|
|
37
|
+
? ""
|
|
38
|
+
: " Rendering pixels on Linux/Windows needs Chrome or Chromium on the PATH (or BUN_CHROME_PATH=/path/to/chrome); SVG, HTML and TXT output need no browser.";
|
|
39
|
+
throw new Error(`Could not start the headless browser: ${cause instanceof Error ? cause.message : String(cause)}.${hint}`, { cause });
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/renderer/webview.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CellSize } from "../config";
|
|
1
2
|
import { mkdir } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { MARKER } from "../cast";
|
|
@@ -7,7 +8,9 @@ import { buildTimeline, withReinjection, type TimedEvent } from "../timeline";
|
|
|
7
8
|
import { pageAssets } from "./bundle";
|
|
8
9
|
import { createSinks, type Chapter } from "./encoder";
|
|
9
10
|
import { chipBuilder } from "../keylabels";
|
|
10
|
-
import { BROWSER_GAP, barHeight, renderHtml, themeOsc } from "./page";
|
|
11
|
+
import { BROWSER_GAP, barHeight, opaqueFill, renderHtml, themeOsc } from "./page";
|
|
12
|
+
import { decodePng, encodePng, luminance, matte, parseHex } from "./png";
|
|
13
|
+
import { createWebView } from "./view";
|
|
11
14
|
|
|
12
15
|
export interface RenderResult {
|
|
13
16
|
outputs: string[];
|
|
@@ -15,6 +18,8 @@ export interface RenderResult {
|
|
|
15
18
|
screenshots: string[];
|
|
16
19
|
durationSeconds: number;
|
|
17
20
|
chapters?: Chapter[];
|
|
21
|
+
/** Things worth telling the user (e.g. a format that cannot carry alpha). */
|
|
22
|
+
notes?: string[];
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
interface ZoomRect {
|
|
@@ -25,7 +30,7 @@ interface ZoomRect {
|
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/** Grid region (inclusive rows/cols, padded by `padding` cells) → px rect relative to the terminal grid. */
|
|
28
|
-
function zoomRect(spec: { rows?: [number, number]; cols?: [number, number]; padding?: number }, cols: number, rows: number, cell:
|
|
33
|
+
function zoomRect(spec: { rows?: [number, number]; cols?: [number, number]; padding?: number }, cols: number, rows: number, cell: CellSize): ZoomRect {
|
|
29
34
|
const pad = spec.padding ?? 1;
|
|
30
35
|
const r0 = Math.max(0, (spec.rows?.[0] ?? 0) - pad);
|
|
31
36
|
const r1 = Math.min(rows - 1, (spec.rows?.[1] ?? rows - 1) + pad);
|
|
@@ -56,10 +61,6 @@ export async function render(
|
|
|
56
61
|
config: ResolvedConfig,
|
|
57
62
|
onProgress?: (p: RenderProgress) => void,
|
|
58
63
|
): Promise<RenderResult> {
|
|
59
|
-
if (typeof Bun.WebView !== "function") {
|
|
60
|
-
throw new Error("Bun.WebView is not available in this Bun version. tcut needs Bun >= 1.4.");
|
|
61
|
-
}
|
|
62
|
-
|
|
63
64
|
const assets = await pageAssets();
|
|
64
65
|
const html = renderHtml(config);
|
|
65
66
|
const osc = themeOsc(config.theme);
|
|
@@ -77,6 +78,7 @@ export async function render(
|
|
|
77
78
|
if (pathname === "/wterm.css") return new Response(assets.css, { headers: { "content-type": "text/css" } });
|
|
78
79
|
if (pathname === "/ghostty-vt.wasm") return new Response(Bun.file(assets.wasmPath), { headers: { "content-type": "application/wasm" } });
|
|
79
80
|
if (pathname === "/theme") return new Response(osc, { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
81
|
+
if (pathname === "/watermark" && config.watermark?.image) return new Response(Bun.file(path.resolve(config.watermark.image)));
|
|
80
82
|
if (pathname.startsWith("/bframe/")) {
|
|
81
83
|
const rel = decodeURIComponent(pathname.slice("/bframe/".length));
|
|
82
84
|
if (rel.includes("..")) return new Response("forbidden", { status: 403 });
|
|
@@ -107,8 +109,16 @@ export async function render(
|
|
|
107
109
|
const totalFrames = Math.max(1, Math.ceil(timeline.duration * fps) + 1);
|
|
108
110
|
const blinkPeriod = config.cursor.period / 1000;
|
|
109
111
|
const screenshots: string[] = [];
|
|
112
|
+
const notes: string[] = [];
|
|
113
|
+
// Transparent output: every dirty frame is shot twice, over the theme background and over a contrasting one,
|
|
114
|
+
// and the pair is matted into real RGBA (the WebView itself always composites onto an opaque page).
|
|
115
|
+
const transparent = config.marginFill === "transparent";
|
|
116
|
+
const fillA = opaqueFill(config);
|
|
117
|
+
const fillB = luminance(fillA) > 0.5 ? "#000000" : "#ffffff";
|
|
118
|
+
const rgbA = parseHex(fillA);
|
|
119
|
+
const rgbB = parseHex(fillB);
|
|
110
120
|
|
|
111
|
-
const view =
|
|
121
|
+
const view = createWebView({ width: 800, height: 600 });
|
|
112
122
|
try {
|
|
113
123
|
await view.navigate(`http://127.0.0.1:${server.port}/`);
|
|
114
124
|
for (let i = 0; i < 100; i++) {
|
|
@@ -126,7 +136,7 @@ export async function render(
|
|
|
126
136
|
await view.evaluate(`window.__vt.boot(${JSON.stringify(boot)})`);
|
|
127
137
|
if (!lite) await view.evaluate("window.__vt.writeUrl('/theme')");
|
|
128
138
|
|
|
129
|
-
const cell = (await view.evaluate("window.__vt.measure()")) as
|
|
139
|
+
const cell = (await view.evaluate("window.__vt.measure()")) as CellSize;
|
|
130
140
|
if (!cell || !(cell.w > 0) || !(cell.h > 0)) throw new Error("Could not measure terminal cell size");
|
|
131
141
|
|
|
132
142
|
const termW = Math.ceil(rec.header.width * cell.w);
|
|
@@ -167,7 +177,11 @@ export async function render(
|
|
|
167
177
|
await view.resize(width, height);
|
|
168
178
|
await view.evaluate("new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))");
|
|
169
179
|
|
|
170
|
-
const sinks = await createSinks(config.output, fps, { chapters, durationSeconds: timeline.duration });
|
|
180
|
+
const sinks = await createSinks(config.output, fps, { chapters, durationSeconds: timeline.duration, alpha: transparent });
|
|
181
|
+
if (transparent) {
|
|
182
|
+
const opaque = sinks.filter((s) => !s.alpha).map((s) => path.basename(s.target));
|
|
183
|
+
if (opaque.length) notes.push(`${opaque.join(", ")}: this format has no alpha channel, so the theme background was used instead of transparency`);
|
|
184
|
+
}
|
|
171
185
|
const cellPx = cell;
|
|
172
186
|
const fullRect: ZoomRect = { x: 0, y: 0, w: termW, h: termH };
|
|
173
187
|
let zoomFrom: ZoomRect | null = null;
|
|
@@ -179,10 +193,12 @@ export async function render(
|
|
|
179
193
|
// loopOffset rotates the frame order for looping outputs; those frames are buffered and flushed at the end.
|
|
180
194
|
const loopSinks = config.loopOffset ? sinks.filter((s) => s.loops) : [];
|
|
181
195
|
const streamSinks = sinks.filter((s) => !loopSinks.includes(s));
|
|
182
|
-
const buffered: Uint8Array
|
|
196
|
+
const buffered: Array<{ opaque: Uint8Array; alpha: Uint8Array }> = [];
|
|
183
197
|
let pointer = 0;
|
|
184
198
|
let lastPng: Uint8Array | null = null;
|
|
199
|
+
let lastAlphaPng: Uint8Array | null = null;
|
|
185
200
|
let lastBlink: boolean | null = null;
|
|
201
|
+
const setFill = (color: string) => view.evaluate(`window.__vt.background(${JSON.stringify(color)})`);
|
|
186
202
|
|
|
187
203
|
for (let frame = 0; frame < totalFrames; frame++) {
|
|
188
204
|
const time = frame / fps;
|
|
@@ -253,26 +269,33 @@ export async function render(
|
|
|
253
269
|
if (zoomChanged || chipsChanged) dirty = true;
|
|
254
270
|
if (dirty) {
|
|
255
271
|
lastPng = (await view.screenshot({ encoding: "buffer" })) as Uint8Array;
|
|
272
|
+
if (transparent) {
|
|
273
|
+
await setFill(fillB);
|
|
274
|
+
const onB = (await view.screenshot({ encoding: "buffer" })) as Uint8Array;
|
|
275
|
+
await setFill(fillA);
|
|
276
|
+
lastAlphaPng = encodePng(matte(decodePng(lastPng), decodePng(onB), rgbA, rgbB));
|
|
277
|
+
}
|
|
256
278
|
}
|
|
279
|
+
const frameFor = (sink: { alpha?: boolean }): Uint8Array => (sink.alpha && lastAlphaPng ? lastAlphaPng : lastPng!);
|
|
257
280
|
|
|
258
281
|
for (const shot of shots) {
|
|
259
282
|
const file = shot.data.slice(MARKER.screenshot.length);
|
|
260
283
|
await mkdir(path.dirname(path.resolve(file)), { recursive: true });
|
|
261
|
-
await Bun.write(file, lastPng!);
|
|
284
|
+
await Bun.write(file, lastAlphaPng ?? lastPng!);
|
|
262
285
|
screenshots.push(file);
|
|
263
286
|
}
|
|
264
287
|
|
|
265
|
-
for (const sink of streamSinks) await sink.frame(
|
|
266
|
-
if (loopSinks.length) buffered.push(lastPng!);
|
|
288
|
+
for (const sink of streamSinks) await sink.frame(frameFor(sink));
|
|
289
|
+
if (loopSinks.length) buffered.push({ opaque: lastPng!, alpha: lastAlphaPng ?? lastPng! });
|
|
267
290
|
onProgress?.({ frame: frame + 1, total: totalFrames });
|
|
268
291
|
}
|
|
269
292
|
|
|
270
293
|
if (loopSinks.length) {
|
|
271
294
|
const rotated = rotateFrames(buffered, loopOffsetFrames(buffered.length, config.loopOffset));
|
|
272
|
-
for (const
|
|
295
|
+
for (const pair of rotated) for (const sink of loopSinks) await sink.frame(sink.alpha ? pair.alpha : pair.opaque);
|
|
273
296
|
}
|
|
274
297
|
for (const sink of sinks) await sink.finish();
|
|
275
|
-
return { outputs: sinks.map((s) => s.target), frames: totalFrames, screenshots, durationSeconds: timeline.duration, chapters };
|
|
298
|
+
return { outputs: sinks.map((s) => s.target), frames: totalFrames, screenshots, durationSeconds: timeline.duration, chapters, ...(notes.length && { notes }) };
|
|
276
299
|
} finally {
|
|
277
300
|
view.close();
|
|
278
301
|
server.stop(true);
|
package/src/screen.ts
CHANGED
|
@@ -32,6 +32,12 @@ export async function loadCore(core: CoreName = "ghostty"): Promise<TerminalCore
|
|
|
32
32
|
* Headless terminal model backed by libghostty (WASM). The recorder feeds every PTY chunk through it so
|
|
33
33
|
* `wait()` / `expect()` / `run()` look at the actual screen instead of a raw byte stream.
|
|
34
34
|
*/
|
|
35
|
+
/** Zero-based cursor cell. */
|
|
36
|
+
export interface CursorPosition {
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
export class Screen {
|
|
36
42
|
private listeners = new Set<() => void>();
|
|
37
43
|
onResponse: ((data: string) => void) | undefined;
|
|
@@ -47,8 +53,8 @@ export class Screen {
|
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
write(data: string | Uint8Array): void {
|
|
50
|
-
if (
|
|
51
|
-
else this.core.
|
|
56
|
+
if (data instanceof Uint8Array) this.core.writeRaw(data);
|
|
57
|
+
else this.core.writeString(data);
|
|
52
58
|
this.drainResponses();
|
|
53
59
|
for (const listener of this.listeners) listener();
|
|
54
60
|
}
|
|
@@ -87,7 +93,7 @@ export class Screen {
|
|
|
87
93
|
return this.core.getRows();
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
cursor():
|
|
96
|
+
cursor(): CursorPosition {
|
|
91
97
|
const c = this.core.getCursor();
|
|
92
98
|
return { x: c.col, y: c.row };
|
|
93
99
|
}
|
package/src/scriptgen.ts
CHANGED
|
@@ -23,28 +23,28 @@ type Op =
|
|
|
23
23
|
| { kind: "raw"; data: string }
|
|
24
24
|
| { kind: "sleep"; ms: number };
|
|
25
25
|
|
|
26
|
-
const NAMED
|
|
27
|
-
"\r"
|
|
28
|
-
"\n"
|
|
29
|
-
"\t"
|
|
30
|
-
"\x7f"
|
|
31
|
-
"\x1b"
|
|
32
|
-
"\x1b[A"
|
|
33
|
-
"\x1b[B"
|
|
34
|
-
"\x1b[C"
|
|
35
|
-
"\x1b[D"
|
|
36
|
-
"\x1bOA"
|
|
37
|
-
"\x1bOB"
|
|
38
|
-
"\x1bOC"
|
|
39
|
-
"\x1bOD"
|
|
40
|
-
"\x1b[H"
|
|
41
|
-
"\x1b[F"
|
|
42
|
-
"\x1b[1~"
|
|
43
|
-
"\x1b[4~"
|
|
44
|
-
"\x1b[3~"
|
|
45
|
-
"\x1b[5~"
|
|
46
|
-
"\x1b[6~"
|
|
47
|
-
|
|
26
|
+
const NAMED = new Map<string, string>([
|
|
27
|
+
["\r", "enter"],
|
|
28
|
+
["\n", "enter"],
|
|
29
|
+
["\t", "tab"],
|
|
30
|
+
["\x7f", "backspace"],
|
|
31
|
+
["\x1b", "escape"],
|
|
32
|
+
["\x1b[A", "up"],
|
|
33
|
+
["\x1b[B", "down"],
|
|
34
|
+
["\x1b[C", "right"],
|
|
35
|
+
["\x1b[D", "left"],
|
|
36
|
+
["\x1bOA", "up"],
|
|
37
|
+
["\x1bOB", "down"],
|
|
38
|
+
["\x1bOC", "right"],
|
|
39
|
+
["\x1bOD", "left"],
|
|
40
|
+
["\x1b[H", "home"],
|
|
41
|
+
["\x1b[F", "end"],
|
|
42
|
+
["\x1b[1~", "home"],
|
|
43
|
+
["\x1b[4~", "end"],
|
|
44
|
+
["\x1b[3~", "delete"],
|
|
45
|
+
["\x1b[5~", "pageUp"],
|
|
46
|
+
["\x1b[6~", "pageDown"],
|
|
47
|
+
]);
|
|
48
48
|
|
|
49
49
|
/** Split a raw input chunk into individual key tokens (escape sequences, control chars, printable runs). */
|
|
50
50
|
export function tokenize(input: string): string[] {
|
|
@@ -53,15 +53,16 @@ export function tokenize(input: string): string[] {
|
|
|
53
53
|
while (i < input.length) {
|
|
54
54
|
const ch = input[i]!;
|
|
55
55
|
if (ch === "\x1b") {
|
|
56
|
-
// CSI: ESC [ params final | SS3: ESC O x | Alt+key: ESC x
|
|
57
|
-
const
|
|
58
|
-
const
|
|
56
|
+
// CSI: ESC [ params final | SS3: ESC O x | Alt+key: ESC x (matched after the ESC we already hold)
|
|
57
|
+
const rest = input.slice(i + 1);
|
|
58
|
+
const csi = /^\[[0-9;?]*[A-Za-z~]/.exec(rest);
|
|
59
|
+
const ss3 = /^O[A-Za-z]/.exec(rest);
|
|
59
60
|
if (csi) {
|
|
60
|
-
tokens.push(csi[0]);
|
|
61
|
-
i += csi[0].length;
|
|
61
|
+
tokens.push(ch + csi[0]);
|
|
62
|
+
i += csi[0].length + 1;
|
|
62
63
|
} else if (ss3) {
|
|
63
|
-
tokens.push(ss3[0]);
|
|
64
|
-
i += ss3[0].length;
|
|
64
|
+
tokens.push(ch + ss3[0]);
|
|
65
|
+
i += ss3[0].length + 1;
|
|
65
66
|
} else if (i + 1 < input.length) {
|
|
66
67
|
tokens.push(input.slice(i, i + 2));
|
|
67
68
|
i += 2;
|
|
@@ -139,7 +140,7 @@ export function eventsToOps(rec: Recording, opts: ScriptGenOptions): Op[] {
|
|
|
139
140
|
pendingText += token;
|
|
140
141
|
continue;
|
|
141
142
|
}
|
|
142
|
-
const named = NAMED
|
|
143
|
+
const named = NAMED.get(token);
|
|
143
144
|
if (named === "enter") {
|
|
144
145
|
if (opts.cleanShell && pendingText.trim()) {
|
|
145
146
|
const command = pendingText;
|
package/src/themes.ts
CHANGED
|
@@ -134,7 +134,7 @@ export type BuiltinThemeName = keyof typeof builtinThemes;
|
|
|
134
134
|
const generated = generatedJson as Record<string, Theme>;
|
|
135
135
|
|
|
136
136
|
/** All themes by slug: the generated bundle, with the built-ins overriding colliding names. */
|
|
137
|
-
export const themes
|
|
137
|
+
export const themes = Object.assign({}, generated, builtinThemes);
|
|
138
138
|
|
|
139
139
|
export const themeNames: string[] = Object.keys(themes).sort();
|
|
140
140
|
|
|
@@ -153,15 +153,13 @@ export function findThemes(query: string): string[] {
|
|
|
153
153
|
|
|
154
154
|
export function resolveTheme(theme: ThemeName | Theme | undefined): Theme {
|
|
155
155
|
if (!theme) return builtinThemes["catppuccin-mocha"];
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
return found;
|
|
156
|
+
if (theme instanceof Object) return theme; // an inline Theme object
|
|
157
|
+
const found = themes[themeSlug(theme)];
|
|
158
|
+
if (!found) {
|
|
159
|
+
const near = findThemes(theme.split(/[\s-]+/)[0] ?? theme).slice(0, 5);
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Unknown theme "${theme}". ${near.length ? `Did you mean: ${near.join(", ")}?` : ""} Run \`tcut themes [query]\` to list all ${themeNames.length}.`.trim(),
|
|
162
|
+
);
|
|
165
163
|
}
|
|
166
|
-
return
|
|
164
|
+
return found;
|
|
167
165
|
}
|
package/src/timeline.ts
CHANGED
|
@@ -21,14 +21,25 @@ export interface TimelineOptions {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Collapse hidden intervals, apply playback speed, optionally cap idle gaps.
|
|
25
|
-
* order but all land on the instant the hide started, so the first visible
|
|
26
|
-
* combined effect. Input (`i`) events are dropped unless `keepInput`: the PTY
|
|
24
|
+
* Collapse hidden intervals, apply playback speed (global and per `speed:` segment), optionally cap idle gaps.
|
|
25
|
+
* Hidden events keep their relative order but all land on the instant the hide started, so the first visible
|
|
26
|
+
* frame after `show` reflects their combined effect. Input (`i`) events are dropped unless `keepInput`: the PTY
|
|
27
|
+
* already echoed them.
|
|
27
28
|
*/
|
|
28
29
|
export function buildTimeline(events: CastEvent[], playbackSpeed: number, opts: TimelineOptions = {}): Timeline {
|
|
29
30
|
const out: TimedEvent[] = [];
|
|
30
31
|
let hiddenSince: number | null = null;
|
|
31
32
|
let removed = 0;
|
|
33
|
+
// Visible time accumulates per segment: (collapsed time since the last event) / (global × segment speed).
|
|
34
|
+
let segmentSpeed = 1;
|
|
35
|
+
let lastCollapsed = 0;
|
|
36
|
+
let vt = 0;
|
|
37
|
+
const advance = (t: number): number => {
|
|
38
|
+
const collapsed = hiddenSince === null ? t - removed : hiddenSince - removed;
|
|
39
|
+
vt += Math.max(0, collapsed - lastCollapsed) / (playbackSpeed * segmentSpeed);
|
|
40
|
+
lastCollapsed = collapsed;
|
|
41
|
+
return vt;
|
|
42
|
+
};
|
|
32
43
|
|
|
33
44
|
for (const [t, type, data] of events) {
|
|
34
45
|
if (type === "m" && data === MARKER.hide) {
|
|
@@ -42,9 +53,14 @@ export function buildTimeline(events: CastEvent[], playbackSpeed: number, opts:
|
|
|
42
53
|
}
|
|
43
54
|
continue;
|
|
44
55
|
}
|
|
56
|
+
if (type === "m" && data.startsWith(MARKER.speed)) {
|
|
57
|
+
advance(t);
|
|
58
|
+
const speed = Number(data.slice(MARKER.speed.length));
|
|
59
|
+
segmentSpeed = Number.isFinite(speed) && speed > 0 ? speed : 1;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
45
62
|
if (type === "i" && !opts.keepInput) continue;
|
|
46
|
-
|
|
47
|
-
out.push({ vt: visible / playbackSpeed, type, data });
|
|
63
|
+
out.push({ vt: advance(t), type, data });
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
if (opts.maxPause !== undefined && opts.maxPause >= 0) {
|