termcut 0.6.3 → 0.7.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 +17 -1
- package/package.json +1 -1
- package/scripts/build-themes.ts +10 -6
- package/src/browser.ts +3 -3
- package/src/cast.ts +2 -0
- package/src/cli.ts +137 -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 +22 -5
- 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/webview.ts +37 -11
- 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
|
+
}
|
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,8 @@ 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";
|
|
11
13
|
|
|
12
14
|
export interface RenderResult {
|
|
13
15
|
outputs: string[];
|
|
@@ -15,6 +17,8 @@ export interface RenderResult {
|
|
|
15
17
|
screenshots: string[];
|
|
16
18
|
durationSeconds: number;
|
|
17
19
|
chapters?: Chapter[];
|
|
20
|
+
/** Things worth telling the user (e.g. a format that cannot carry alpha). */
|
|
21
|
+
notes?: string[];
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
interface ZoomRect {
|
|
@@ -25,7 +29,7 @@ interface ZoomRect {
|
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
/** 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:
|
|
32
|
+
function zoomRect(spec: { rows?: [number, number]; cols?: [number, number]; padding?: number }, cols: number, rows: number, cell: CellSize): ZoomRect {
|
|
29
33
|
const pad = spec.padding ?? 1;
|
|
30
34
|
const r0 = Math.max(0, (spec.rows?.[0] ?? 0) - pad);
|
|
31
35
|
const r1 = Math.min(rows - 1, (spec.rows?.[1] ?? rows - 1) + pad);
|
|
@@ -56,7 +60,7 @@ export async function render(
|
|
|
56
60
|
config: ResolvedConfig,
|
|
57
61
|
onProgress?: (p: RenderProgress) => void,
|
|
58
62
|
): Promise<RenderResult> {
|
|
59
|
-
if (
|
|
63
|
+
if (!Bun.WebView) {
|
|
60
64
|
throw new Error("Bun.WebView is not available in this Bun version. tcut needs Bun >= 1.4.");
|
|
61
65
|
}
|
|
62
66
|
|
|
@@ -77,6 +81,7 @@ export async function render(
|
|
|
77
81
|
if (pathname === "/wterm.css") return new Response(assets.css, { headers: { "content-type": "text/css" } });
|
|
78
82
|
if (pathname === "/ghostty-vt.wasm") return new Response(Bun.file(assets.wasmPath), { headers: { "content-type": "application/wasm" } });
|
|
79
83
|
if (pathname === "/theme") return new Response(osc, { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
84
|
+
if (pathname === "/watermark" && config.watermark?.image) return new Response(Bun.file(path.resolve(config.watermark.image)));
|
|
80
85
|
if (pathname.startsWith("/bframe/")) {
|
|
81
86
|
const rel = decodeURIComponent(pathname.slice("/bframe/".length));
|
|
82
87
|
if (rel.includes("..")) return new Response("forbidden", { status: 403 });
|
|
@@ -107,6 +112,14 @@ export async function render(
|
|
|
107
112
|
const totalFrames = Math.max(1, Math.ceil(timeline.duration * fps) + 1);
|
|
108
113
|
const blinkPeriod = config.cursor.period / 1000;
|
|
109
114
|
const screenshots: string[] = [];
|
|
115
|
+
const notes: string[] = [];
|
|
116
|
+
// Transparent output: every dirty frame is shot twice, over the theme background and over a contrasting one,
|
|
117
|
+
// and the pair is matted into real RGBA (the WebView itself always composites onto an opaque page).
|
|
118
|
+
const transparent = config.marginFill === "transparent";
|
|
119
|
+
const fillA = opaqueFill(config);
|
|
120
|
+
const fillB = luminance(fillA) > 0.5 ? "#000000" : "#ffffff";
|
|
121
|
+
const rgbA = parseHex(fillA);
|
|
122
|
+
const rgbB = parseHex(fillB);
|
|
110
123
|
|
|
111
124
|
const view = new Bun.WebView({ width: 800, height: 600 });
|
|
112
125
|
try {
|
|
@@ -126,7 +139,7 @@ export async function render(
|
|
|
126
139
|
await view.evaluate(`window.__vt.boot(${JSON.stringify(boot)})`);
|
|
127
140
|
if (!lite) await view.evaluate("window.__vt.writeUrl('/theme')");
|
|
128
141
|
|
|
129
|
-
const cell = (await view.evaluate("window.__vt.measure()")) as
|
|
142
|
+
const cell = (await view.evaluate("window.__vt.measure()")) as CellSize;
|
|
130
143
|
if (!cell || !(cell.w > 0) || !(cell.h > 0)) throw new Error("Could not measure terminal cell size");
|
|
131
144
|
|
|
132
145
|
const termW = Math.ceil(rec.header.width * cell.w);
|
|
@@ -167,7 +180,11 @@ export async function render(
|
|
|
167
180
|
await view.resize(width, height);
|
|
168
181
|
await view.evaluate("new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))");
|
|
169
182
|
|
|
170
|
-
const sinks = await createSinks(config.output, fps, { chapters, durationSeconds: timeline.duration });
|
|
183
|
+
const sinks = await createSinks(config.output, fps, { chapters, durationSeconds: timeline.duration, alpha: transparent });
|
|
184
|
+
if (transparent) {
|
|
185
|
+
const opaque = sinks.filter((s) => !s.alpha).map((s) => path.basename(s.target));
|
|
186
|
+
if (opaque.length) notes.push(`${opaque.join(", ")}: this format has no alpha channel, so the theme background was used instead of transparency`);
|
|
187
|
+
}
|
|
171
188
|
const cellPx = cell;
|
|
172
189
|
const fullRect: ZoomRect = { x: 0, y: 0, w: termW, h: termH };
|
|
173
190
|
let zoomFrom: ZoomRect | null = null;
|
|
@@ -179,10 +196,12 @@ export async function render(
|
|
|
179
196
|
// loopOffset rotates the frame order for looping outputs; those frames are buffered and flushed at the end.
|
|
180
197
|
const loopSinks = config.loopOffset ? sinks.filter((s) => s.loops) : [];
|
|
181
198
|
const streamSinks = sinks.filter((s) => !loopSinks.includes(s));
|
|
182
|
-
const buffered: Uint8Array
|
|
199
|
+
const buffered: Array<{ opaque: Uint8Array; alpha: Uint8Array }> = [];
|
|
183
200
|
let pointer = 0;
|
|
184
201
|
let lastPng: Uint8Array | null = null;
|
|
202
|
+
let lastAlphaPng: Uint8Array | null = null;
|
|
185
203
|
let lastBlink: boolean | null = null;
|
|
204
|
+
const setFill = (color: string) => view.evaluate(`window.__vt.background(${JSON.stringify(color)})`);
|
|
186
205
|
|
|
187
206
|
for (let frame = 0; frame < totalFrames; frame++) {
|
|
188
207
|
const time = frame / fps;
|
|
@@ -253,26 +272,33 @@ export async function render(
|
|
|
253
272
|
if (zoomChanged || chipsChanged) dirty = true;
|
|
254
273
|
if (dirty) {
|
|
255
274
|
lastPng = (await view.screenshot({ encoding: "buffer" })) as Uint8Array;
|
|
275
|
+
if (transparent) {
|
|
276
|
+
await setFill(fillB);
|
|
277
|
+
const onB = (await view.screenshot({ encoding: "buffer" })) as Uint8Array;
|
|
278
|
+
await setFill(fillA);
|
|
279
|
+
lastAlphaPng = encodePng(matte(decodePng(lastPng), decodePng(onB), rgbA, rgbB));
|
|
280
|
+
}
|
|
256
281
|
}
|
|
282
|
+
const frameFor = (sink: { alpha?: boolean }): Uint8Array => (sink.alpha && lastAlphaPng ? lastAlphaPng : lastPng!);
|
|
257
283
|
|
|
258
284
|
for (const shot of shots) {
|
|
259
285
|
const file = shot.data.slice(MARKER.screenshot.length);
|
|
260
286
|
await mkdir(path.dirname(path.resolve(file)), { recursive: true });
|
|
261
|
-
await Bun.write(file, lastPng!);
|
|
287
|
+
await Bun.write(file, lastAlphaPng ?? lastPng!);
|
|
262
288
|
screenshots.push(file);
|
|
263
289
|
}
|
|
264
290
|
|
|
265
|
-
for (const sink of streamSinks) await sink.frame(
|
|
266
|
-
if (loopSinks.length) buffered.push(lastPng!);
|
|
291
|
+
for (const sink of streamSinks) await sink.frame(frameFor(sink));
|
|
292
|
+
if (loopSinks.length) buffered.push({ opaque: lastPng!, alpha: lastAlphaPng ?? lastPng! });
|
|
267
293
|
onProgress?.({ frame: frame + 1, total: totalFrames });
|
|
268
294
|
}
|
|
269
295
|
|
|
270
296
|
if (loopSinks.length) {
|
|
271
297
|
const rotated = rotateFrames(buffered, loopOffsetFrames(buffered.length, config.loopOffset));
|
|
272
|
-
for (const
|
|
298
|
+
for (const pair of rotated) for (const sink of loopSinks) await sink.frame(sink.alpha ? pair.alpha : pair.opaque);
|
|
273
299
|
}
|
|
274
300
|
for (const sink of sinks) await sink.finish();
|
|
275
|
-
return { outputs: sinks.map((s) => s.target), frames: totalFrames, screenshots, durationSeconds: timeline.duration, chapters };
|
|
301
|
+
return { outputs: sinks.map((s) => s.target), frames: totalFrames, screenshots, durationSeconds: timeline.duration, chapters, ...(notes.length && { notes }) };
|
|
276
302
|
} finally {
|
|
277
303
|
view.close();
|
|
278
304
|
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) {
|
package/src/types.ts
CHANGED
|
@@ -109,6 +109,39 @@ export interface BrowserSession {
|
|
|
109
109
|
readonly url: string;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/** Drop shadow under the window(s). Drawn by the compositor, so it is in every output that has pixels — and in SVG. */
|
|
113
|
+
export interface ShadowConfig {
|
|
114
|
+
/** Horizontal offset, px. Default 0. */
|
|
115
|
+
x?: number;
|
|
116
|
+
/** Vertical offset, px. Default 18. */
|
|
117
|
+
y?: number;
|
|
118
|
+
/** Blur radius, px. Default 50. */
|
|
119
|
+
blur?: number;
|
|
120
|
+
/** Shadow colour. Default "#000000". */
|
|
121
|
+
color?: string;
|
|
122
|
+
/** 0–1. Default 0.45. */
|
|
123
|
+
opacity?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type WatermarkPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right" | "center";
|
|
127
|
+
|
|
128
|
+
/** A watermark drawn over the whole picture (outside the terminal grid): a line of text or an image file. */
|
|
129
|
+
export interface WatermarkConfig {
|
|
130
|
+
text?: string;
|
|
131
|
+
/** PNG/JPEG/SVG/WebP file. */
|
|
132
|
+
image?: string;
|
|
133
|
+
/** Default "bottom-right". */
|
|
134
|
+
position?: WatermarkPosition;
|
|
135
|
+
/** 0–1. Default 0.6. */
|
|
136
|
+
opacity?: number;
|
|
137
|
+
/** Text size in px (default 14) or image height in px (default 28). */
|
|
138
|
+
size?: number;
|
|
139
|
+
/** Text colour. Default: the theme foreground. */
|
|
140
|
+
color?: string;
|
|
141
|
+
/** Distance from the picture's edge, px. Default 16. */
|
|
142
|
+
margin?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
112
145
|
export interface CursorConfig {
|
|
113
146
|
/** Default true. Blink is driven by the render clock, so it is deterministic. */
|
|
114
147
|
blink?: boolean;
|
|
@@ -182,8 +215,15 @@ export interface VideoConfig {
|
|
|
182
215
|
padding?: number;
|
|
183
216
|
/** Space around the window, px. Default 0. */
|
|
184
217
|
margin?: number;
|
|
185
|
-
/**
|
|
218
|
+
/**
|
|
219
|
+
* Colour behind the window (visible when margin > 0). Default: theme background. `"transparent"` gives real
|
|
220
|
+
* alpha in PNG, WebP, GIF, WebM, SVG and HTML output (MP4 and JPEG fall back to the theme background).
|
|
221
|
+
*/
|
|
186
222
|
marginFill?: string;
|
|
223
|
+
/** Drop shadow under the window; `true` uses soft defaults. Needs margin — `margin` defaults to 40 when unset. */
|
|
224
|
+
shadow?: boolean | ShadowConfig;
|
|
225
|
+
/** Watermark over the picture: a string is text in the bottom-right corner; an object picks image/position/size. */
|
|
226
|
+
watermark?: string | WatermarkConfig;
|
|
187
227
|
/** Rounded corner radius of the window, px. Default 0 (12 is nice with a margin). */
|
|
188
228
|
borderRadius?: number;
|
|
189
229
|
windowBar?: WindowBar;
|
|
@@ -224,6 +264,8 @@ export interface ResolvedConfig {
|
|
|
224
264
|
padding: number;
|
|
225
265
|
margin: number;
|
|
226
266
|
marginFill: string;
|
|
267
|
+
shadow?: Required<ShadowConfig>;
|
|
268
|
+
watermark?: Required<Pick<WatermarkConfig, "position" | "opacity" | "size" | "color" | "margin">> & Pick<WatermarkConfig, "text" | "image">;
|
|
227
269
|
borderRadius: number;
|
|
228
270
|
windowBar: WindowBar;
|
|
229
271
|
title: string;
|
|
@@ -326,8 +368,13 @@ export interface TerminalSession {
|
|
|
326
368
|
title(text: string, opts?: { pause?: Duration }): Promise<void>;
|
|
327
369
|
/** Magnify a region of the terminal (animated at render time); `zoom(null)` resets. */
|
|
328
370
|
zoom(region: ZoomRegion | null): Promise<void>;
|
|
329
|
-
/** Named chapter: becomes mp4 chapter metadata
|
|
371
|
+
/** Named chapter: becomes mp4 chapter metadata, shows up in `--json` output, and is a cut point for `--chapters` / `--split-chapters`. */
|
|
330
372
|
chapter(name: string): Promise<void>;
|
|
373
|
+
/**
|
|
374
|
+
* Everything inside `fn` plays back `speed`× faster (default 8). Unlike `maxPause`, which only squeezes silence,
|
|
375
|
+
* this squeezes active output too — installs, builds, test runs.
|
|
376
|
+
*/
|
|
377
|
+
timelapse<T>(fn: () => Promise<T>, opts?: { speed?: number }): Promise<T>;
|
|
331
378
|
/** The recorded browser window; throws if `browser` is not configured. */
|
|
332
379
|
readonly browser: BrowserSession;
|
|
333
380
|
/** Overlay layout: bring the terminal or the browser window to the front (recorded as a marker). */
|
|
@@ -381,9 +428,23 @@ export interface RenderProgress {
|
|
|
381
428
|
total: number;
|
|
382
429
|
}
|
|
383
430
|
|
|
431
|
+
/** Which part of the visible timeline to render. */
|
|
432
|
+
export interface ClipSelection {
|
|
433
|
+
/** Start, seconds. */
|
|
434
|
+
from?: number;
|
|
435
|
+
/** End, seconds. */
|
|
436
|
+
to?: number;
|
|
437
|
+
/** Keep only these chapters (titles or 1-based numbers), joined in the order given. */
|
|
438
|
+
chapters?: string[];
|
|
439
|
+
/** Render every chapter to its own file: `demo.mp4` → `demo-01-install.mp4`, … */
|
|
440
|
+
splitChapters?: boolean;
|
|
441
|
+
}
|
|
442
|
+
|
|
384
443
|
export interface RenderOptions {
|
|
385
444
|
/** Override resolved config values (theme, font, outputs, …) without re-recording. */
|
|
386
445
|
overrides?: Partial<VideoConfig>;
|
|
446
|
+
/** Render only part of the recording (by time or by chapter). */
|
|
447
|
+
clip?: ClipSelection;
|
|
387
448
|
onProgress?: (p: RenderProgress) => void;
|
|
388
449
|
}
|
|
389
450
|
|