@reelscript/cli 0.1.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/LICENSE +21 -0
- package/README.md +179 -0
- package/assets/fonts/InterVariable.ttf +0 -0
- package/assets/fonts/JetBrainsMono-Variable.ttf +0 -0
- package/assets/fonts/LICENSE-Inter.txt +92 -0
- package/assets/fonts/LICENSE-JetBrainsMono.txt +93 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +114 -0
- package/dist/clock.d.ts +11 -0
- package/dist/clock.js +88 -0
- package/dist/cursor.d.ts +14 -0
- package/dist/cursor.js +55 -0
- package/dist/easing.d.ts +6 -0
- package/dist/easing.js +25 -0
- package/dist/encoder.d.ts +38 -0
- package/dist/encoder.js +122 -0
- package/dist/index.d.ts +187 -0
- package/dist/index.js +197 -0
- package/dist/renderer.d.ts +48 -0
- package/dist/renderer.js +687 -0
- package/dist/resolve-self.d.ts +12 -0
- package/dist/resolve-self.js +12 -0
- package/dist/terminal.d.ts +38 -0
- package/dist/terminal.js +164 -0
- package/dist/theme.d.ts +51 -0
- package/dist/theme.js +181 -0
- package/dist/timeline.d.ts +103 -0
- package/dist/timeline.js +16 -0
- package/dist/tts.d.ts +34 -0
- package/dist/tts.js +154 -0
- package/package.json +72 -0
package/dist/renderer.js
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
import sharp from "sharp";
|
|
3
|
+
import { mkdirSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { dirname, extname } from "node:path";
|
|
5
|
+
import { DEFAULTS } from "./timeline.js";
|
|
6
|
+
import { clamp, lerp, progress } from "./easing.js";
|
|
7
|
+
import { createTheme } from "./theme.js";
|
|
8
|
+
import { cursorSprite, rippleSprite } from "./cursor.js";
|
|
9
|
+
import { Encoder, muxNarration } from "./encoder.js";
|
|
10
|
+
import { CLOCK_SHIM } from "./clock.js";
|
|
11
|
+
import { applyPronunciations, kokoro, synthesizeClip } from "./tts.js";
|
|
12
|
+
import { TERMINAL_URL, loadRecording, playbackEvents, scriptedEvents, terminalPageHtml, } from "./terminal.js";
|
|
13
|
+
const RIPPLE_MS = 420;
|
|
14
|
+
const SQUISH_MS = 120;
|
|
15
|
+
const CURSOR_PX = 26;
|
|
16
|
+
const CASCADE_MARGIN = 40;
|
|
17
|
+
class Engine {
|
|
18
|
+
viewport;
|
|
19
|
+
deterministic;
|
|
20
|
+
desktop;
|
|
21
|
+
theme;
|
|
22
|
+
browser;
|
|
23
|
+
context;
|
|
24
|
+
windows = new Map();
|
|
25
|
+
zTop = 0;
|
|
26
|
+
focusedId = null;
|
|
27
|
+
// desktop-coordinate cursor state
|
|
28
|
+
cursor;
|
|
29
|
+
move = null;
|
|
30
|
+
zoom;
|
|
31
|
+
zoomAnim = null;
|
|
32
|
+
lastClick = -Infinity;
|
|
33
|
+
// terminal
|
|
34
|
+
termEvents = new Map();
|
|
35
|
+
// narration
|
|
36
|
+
clips = new Map();
|
|
37
|
+
narrationEnd = 0;
|
|
38
|
+
narration = [];
|
|
39
|
+
constructor(viewport, desktop, themeName, deterministic) {
|
|
40
|
+
this.viewport = viewport;
|
|
41
|
+
this.deterministic = deterministic;
|
|
42
|
+
this.theme = createTheme(themeName, (html, w, h, transparent) => this.rasterizeHtml(html, w, h, transparent));
|
|
43
|
+
this.desktop = desktop ?? this.theme.defaultDesktop(viewport);
|
|
44
|
+
this.cursor = { x: this.desktop[0] / 2, y: this.desktop[1] / 2 };
|
|
45
|
+
this.zoom = { scale: 1, cx: this.desktop[0] / 2, cy: this.desktop[1] / 2 };
|
|
46
|
+
}
|
|
47
|
+
setClips(clips) {
|
|
48
|
+
this.clips = clips;
|
|
49
|
+
}
|
|
50
|
+
setTerminalEvents(events) {
|
|
51
|
+
this.termEvents = events;
|
|
52
|
+
}
|
|
53
|
+
async open() {
|
|
54
|
+
this.browser = await chromium.launch({ headless: true });
|
|
55
|
+
this.context = await this.browser.newContext({
|
|
56
|
+
viewport: { width: this.viewport[0], height: this.viewport[1] },
|
|
57
|
+
deviceScaleFactor: 1,
|
|
58
|
+
colorScheme: "light",
|
|
59
|
+
});
|
|
60
|
+
if (this.deterministic)
|
|
61
|
+
await this.context.addInitScript(CLOCK_SHIM);
|
|
62
|
+
}
|
|
63
|
+
async close() {
|
|
64
|
+
await this.browser?.close();
|
|
65
|
+
}
|
|
66
|
+
/** Render static HTML (theme chrome) in a separate, un-shimmed context. */
|
|
67
|
+
async rasterizeHtml(html, width, height, transparent) {
|
|
68
|
+
const ctx = await this.browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 });
|
|
69
|
+
try {
|
|
70
|
+
const page = await ctx.newPage();
|
|
71
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
72
|
+
await page.evaluate(() => document.fonts.ready);
|
|
73
|
+
return await page.screenshot({ type: "png", omitBackground: transparent });
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await ctx.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// ------------------------------------------------------------ windows
|
|
80
|
+
get titleH() {
|
|
81
|
+
return this.theme.titleHeight;
|
|
82
|
+
}
|
|
83
|
+
clampGeometry(w) {
|
|
84
|
+
const [W, H] = this.desktop;
|
|
85
|
+
w.width = Math.max(200, Math.min(w.width, W));
|
|
86
|
+
w.height = Math.max(120, Math.min(w.height, H - this.titleH));
|
|
87
|
+
w.x = Math.round(clamp(w.x, 0, W - w.width));
|
|
88
|
+
w.y = Math.round(clamp(w.y, 0, H - w.height - this.titleH));
|
|
89
|
+
}
|
|
90
|
+
/** Get a window, creating its page if this is the first time it's used. */
|
|
91
|
+
async ensureWindow(id, kind, geometry = {}) {
|
|
92
|
+
const existing = this.windows.get(id);
|
|
93
|
+
if (existing) {
|
|
94
|
+
if (geometry.x !== undefined || geometry.y !== undefined || geometry.width !== undefined || geometry.height !== undefined) {
|
|
95
|
+
await this.place(existing, geometry);
|
|
96
|
+
}
|
|
97
|
+
return existing;
|
|
98
|
+
}
|
|
99
|
+
const first = this.windows.size === 0;
|
|
100
|
+
const [W, H] = this.desktop;
|
|
101
|
+
let width = geometry.width ?? (first ? this.viewport[0] : Math.min(900, Math.round(W * 0.6)));
|
|
102
|
+
let height = geometry.height ?? (first ? this.viewport[1] : Math.min(520, Math.round(H * 0.5)));
|
|
103
|
+
let x;
|
|
104
|
+
let y;
|
|
105
|
+
if (first) {
|
|
106
|
+
({ x, y } = this.theme.mainPlacement(this.desktop, [width, height]));
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
x = W - width - CASCADE_MARGIN;
|
|
110
|
+
y = H - height - this.titleH - CASCADE_MARGIN;
|
|
111
|
+
}
|
|
112
|
+
if (geometry.x !== undefined)
|
|
113
|
+
x = geometry.x;
|
|
114
|
+
if (geometry.y !== undefined)
|
|
115
|
+
y = geometry.y;
|
|
116
|
+
const page = await this.context.newPage();
|
|
117
|
+
const win = {
|
|
118
|
+
id,
|
|
119
|
+
kind,
|
|
120
|
+
page,
|
|
121
|
+
cdp: await this.context.newCDPSession(page),
|
|
122
|
+
x,
|
|
123
|
+
y,
|
|
124
|
+
width,
|
|
125
|
+
height,
|
|
126
|
+
z: ++this.zTop,
|
|
127
|
+
title: "",
|
|
128
|
+
url: "",
|
|
129
|
+
termPrompt: DEFAULTS.terminalPrompt,
|
|
130
|
+
termRouted: false,
|
|
131
|
+
frameOverlay: null,
|
|
132
|
+
};
|
|
133
|
+
this.clampGeometry(win);
|
|
134
|
+
await page.setViewportSize({ width: win.width, height: win.height });
|
|
135
|
+
this.windows.set(id, win);
|
|
136
|
+
this.focusedId = id;
|
|
137
|
+
return win;
|
|
138
|
+
}
|
|
139
|
+
window(id) {
|
|
140
|
+
const w = this.windows.get(id);
|
|
141
|
+
if (!w)
|
|
142
|
+
throw new Error(`reelscript: window "${id}" is not open (call browser.goto or terminal.open first)`);
|
|
143
|
+
return w;
|
|
144
|
+
}
|
|
145
|
+
focused() {
|
|
146
|
+
if (!this.focusedId)
|
|
147
|
+
throw new Error("reelscript: no window is open yet (call browser.goto or terminal.open first)");
|
|
148
|
+
return this.window(this.focusedId);
|
|
149
|
+
}
|
|
150
|
+
focus(w) {
|
|
151
|
+
if (this.focusedId !== w.id || w.z !== this.zTop)
|
|
152
|
+
w.z = ++this.zTop;
|
|
153
|
+
this.focusedId = w.id;
|
|
154
|
+
}
|
|
155
|
+
async place(w, g) {
|
|
156
|
+
const resized = (g.width !== undefined && g.width !== w.width) || (g.height !== undefined && g.height !== w.height);
|
|
157
|
+
if (g.x !== undefined)
|
|
158
|
+
w.x = g.x;
|
|
159
|
+
if (g.y !== undefined)
|
|
160
|
+
w.y = g.y;
|
|
161
|
+
if (g.width !== undefined)
|
|
162
|
+
w.width = g.width;
|
|
163
|
+
if (g.height !== undefined)
|
|
164
|
+
w.height = g.height;
|
|
165
|
+
this.clampGeometry(w);
|
|
166
|
+
if (resized) {
|
|
167
|
+
await w.page.setViewportSize({ width: w.width, height: w.height });
|
|
168
|
+
if (w.kind === "terminal")
|
|
169
|
+
await this.refitTerminal(w);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
byZ() {
|
|
173
|
+
return [...this.windows.values()].sort((a, b) => a.z - b.z);
|
|
174
|
+
}
|
|
175
|
+
/** Topmost window whose frame contains the desktop point. */
|
|
176
|
+
windowAt(p) {
|
|
177
|
+
const wins = this.byZ();
|
|
178
|
+
for (let i = wins.length - 1; i >= 0; i--) {
|
|
179
|
+
const w = wins[i];
|
|
180
|
+
if (p.x >= w.x && p.x < w.x + w.width && p.y >= w.y && p.y < w.y + w.height + this.titleH)
|
|
181
|
+
return w;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
toLocal(w, p) {
|
|
186
|
+
return { x: p.x - w.x, y: p.y - w.y - this.titleH };
|
|
187
|
+
}
|
|
188
|
+
inContent(w, local) {
|
|
189
|
+
return local.x >= 0 && local.y >= 0 && local.x < w.width && local.y < w.height;
|
|
190
|
+
}
|
|
191
|
+
// ------------------------------------------------------------ page clock
|
|
192
|
+
/** Advance every window's virtual clock by `ms`. */
|
|
193
|
+
async advanceClock(ms) {
|
|
194
|
+
if (!this.deterministic)
|
|
195
|
+
return;
|
|
196
|
+
await Promise.all([...this.windows.values()].map((w) => w.page.evaluate((ms) => {
|
|
197
|
+
const g = window;
|
|
198
|
+
g.__reelscript_advance?.(ms);
|
|
199
|
+
}, ms)));
|
|
200
|
+
}
|
|
201
|
+
// ------------------------------------------------------------ targets
|
|
202
|
+
/** Resolve a target to desktop coordinates, searching the given or focused window. */
|
|
203
|
+
async resolveRect(target, windowId) {
|
|
204
|
+
const w = windowId ? this.window(windowId) : this.focused();
|
|
205
|
+
if (typeof target !== "string")
|
|
206
|
+
return { x: w.x + target.x, y: w.y + this.titleH + target.y, w: 0, h: 0 };
|
|
207
|
+
const loc = w.page.locator(target).first();
|
|
208
|
+
const deadline = Date.now() + 3000;
|
|
209
|
+
while (!(await loc.isVisible())) {
|
|
210
|
+
if (Date.now() > deadline)
|
|
211
|
+
throw new Error(`reelscript: target "${target}" was not found or never became visible in the ${w.id} window`);
|
|
212
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
213
|
+
}
|
|
214
|
+
const box = await loc.boundingBox();
|
|
215
|
+
if (!box)
|
|
216
|
+
throw new Error(`reelscript: target "${target}" has no bounding box`);
|
|
217
|
+
return { x: w.x + box.x, y: w.y + this.titleH + box.y, w: box.width, h: box.height };
|
|
218
|
+
}
|
|
219
|
+
async resolvePoint(target, windowId) {
|
|
220
|
+
const r = await this.resolveRect(target, windowId);
|
|
221
|
+
return { x: r.x + r.w / 2, y: r.y + r.h / 2 };
|
|
222
|
+
}
|
|
223
|
+
// ------------------------------------------------------------ terminal
|
|
224
|
+
async termWrite(w, text) {
|
|
225
|
+
if (!text)
|
|
226
|
+
return;
|
|
227
|
+
await w.page.evaluate((s) => {
|
|
228
|
+
window.__rsTerm.write(s);
|
|
229
|
+
}, text);
|
|
230
|
+
}
|
|
231
|
+
async refitTerminal(w) {
|
|
232
|
+
const dims = await w.page.evaluate(() => {
|
|
233
|
+
const t = window.__rsTerm;
|
|
234
|
+
return t ? t.fit() : null;
|
|
235
|
+
});
|
|
236
|
+
if (dims)
|
|
237
|
+
w.title = w.title.replace(/\s\S+$/, ` ${dims.cols}×${dims.rows}`);
|
|
238
|
+
}
|
|
239
|
+
// ------------------------------------------------------------ steps
|
|
240
|
+
async begin(action, index, start) {
|
|
241
|
+
switch (action.kind) {
|
|
242
|
+
case "browser.goto": {
|
|
243
|
+
const w = await this.ensureWindow("browser", "browser");
|
|
244
|
+
this.focus(w);
|
|
245
|
+
await w.page.goto(action.url, { waitUntil: "load" });
|
|
246
|
+
w.url = action.url;
|
|
247
|
+
return { end: start + (action.settle ?? DEFAULTS.gotoSettle) };
|
|
248
|
+
}
|
|
249
|
+
case "browser.mockAPI": {
|
|
250
|
+
const w = await this.ensureWindow("browser", "browser");
|
|
251
|
+
const { pattern, response, status = 200 } = action;
|
|
252
|
+
await w.page.route(pattern, (route) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(response) }));
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
case "cursor.moveTo": {
|
|
256
|
+
const to = await this.resolvePoint(action.target, action.window);
|
|
257
|
+
const from = { ...this.cursor };
|
|
258
|
+
const dist = Math.hypot(to.x - from.x, to.y - from.y);
|
|
259
|
+
const dur = action.duration ?? clamp(Math.round(dist * 0.9 + 200), 250, 1400);
|
|
260
|
+
this.move = { from, to, start, dur, ease: action.ease ?? "smooth" };
|
|
261
|
+
return {
|
|
262
|
+
end: start + dur,
|
|
263
|
+
onEnd: () => {
|
|
264
|
+
this.cursor = to;
|
|
265
|
+
this.move = null;
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
case "cursor.click": {
|
|
270
|
+
const w = this.windowAt(this.cursor);
|
|
271
|
+
if (w) {
|
|
272
|
+
this.focus(w);
|
|
273
|
+
const local = this.toLocal(w, this.cursor);
|
|
274
|
+
if (this.inContent(w, local))
|
|
275
|
+
await w.page.mouse.click(local.x, local.y, { button: action.button ?? "left" });
|
|
276
|
+
}
|
|
277
|
+
this.lastClick = start;
|
|
278
|
+
return { end: start + DEFAULTS.clickDuration };
|
|
279
|
+
}
|
|
280
|
+
case "type": {
|
|
281
|
+
const w = this.focused();
|
|
282
|
+
if (action.target)
|
|
283
|
+
await w.page.locator(action.target).first().focus();
|
|
284
|
+
const msPerChar = 60000 / ((action.wpm ?? DEFAULTS.typeWpm) * 5);
|
|
285
|
+
const chars = Array.from(action.text);
|
|
286
|
+
const firstAt = start + 80;
|
|
287
|
+
let next = 0;
|
|
288
|
+
const flush = async (upTo) => {
|
|
289
|
+
let batch = "";
|
|
290
|
+
while (next < chars.length && firstAt + next * msPerChar <= upTo)
|
|
291
|
+
batch += chars[next++];
|
|
292
|
+
if (batch)
|
|
293
|
+
await w.page.keyboard.type(batch);
|
|
294
|
+
};
|
|
295
|
+
return {
|
|
296
|
+
end: firstAt + chars.length * msPerChar + 120,
|
|
297
|
+
onFrame: flush,
|
|
298
|
+
onEnd: () => flush(Infinity),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
case "press": {
|
|
302
|
+
await this.focused().page.keyboard.press(action.key);
|
|
303
|
+
return { end: start + 100 };
|
|
304
|
+
}
|
|
305
|
+
case "wait":
|
|
306
|
+
return { end: start + action.ms };
|
|
307
|
+
case "zoom.to": {
|
|
308
|
+
const r = await this.resolveRect(action.target, action.window);
|
|
309
|
+
const to = { scale: action.scale ?? DEFAULTS.zoomScale, cx: r.x + r.w / 2, cy: r.y + r.h / 2 };
|
|
310
|
+
this.zoomAnim = { from: { ...this.zoom }, to, start, dur: action.duration ?? DEFAULTS.zoomDuration, ease: action.ease ?? "smooth" };
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
case "zoom.out": {
|
|
314
|
+
this.zoomAnim = {
|
|
315
|
+
from: { ...this.zoom },
|
|
316
|
+
to: { scale: 1, cx: this.desktop[0] / 2, cy: this.desktop[1] / 2 },
|
|
317
|
+
start,
|
|
318
|
+
dur: action.duration ?? DEFAULTS.zoomDuration,
|
|
319
|
+
ease: action.ease ?? "smooth",
|
|
320
|
+
};
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
case "say": {
|
|
324
|
+
const clip = this.clips.get(index);
|
|
325
|
+
if (!clip)
|
|
326
|
+
throw new Error("reelscript: narration clip missing (internal)");
|
|
327
|
+
const at = Math.max(start, this.narrationEnd);
|
|
328
|
+
this.narration.push({ file: clip.file, atMs: at });
|
|
329
|
+
this.narrationEnd = at + clip.seconds * 1000 + DEFAULTS.narrationGapMs;
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
case "waitForNarration":
|
|
333
|
+
return { end: Math.max(start, this.narrationEnd) };
|
|
334
|
+
case "window.focus": {
|
|
335
|
+
this.focus(this.window(action.window));
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
case "window.place": {
|
|
339
|
+
await this.place(this.window(action.window), action);
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
case "terminal.open": {
|
|
343
|
+
const w = await this.ensureWindow("terminal", "terminal", action);
|
|
344
|
+
this.focus(w);
|
|
345
|
+
if (!w.termRouted) {
|
|
346
|
+
const html = terminalPageHtml(action.fontSize);
|
|
347
|
+
await w.page.route(`${TERMINAL_URL}**`, (route) => route.fulfill({ status: 200, contentType: "text/html", body: html }));
|
|
348
|
+
w.termRouted = true;
|
|
349
|
+
}
|
|
350
|
+
await w.page.goto(TERMINAL_URL, { waitUntil: "load" });
|
|
351
|
+
const deadline = Date.now() + 5000;
|
|
352
|
+
let dims = null;
|
|
353
|
+
while (!dims) {
|
|
354
|
+
dims = await w.page.evaluate(() => {
|
|
355
|
+
const t = window.__rsTerm;
|
|
356
|
+
return t?.ready ? { cols: t.cols, rows: t.rows } : null;
|
|
357
|
+
});
|
|
358
|
+
if (!dims) {
|
|
359
|
+
if (Date.now() > deadline)
|
|
360
|
+
throw new Error("reelscript: terminal page did not initialise");
|
|
361
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
w.termPrompt = action.prompt ?? DEFAULTS.terminalPrompt;
|
|
365
|
+
await this.termWrite(w, w.termPrompt);
|
|
366
|
+
w.url = TERMINAL_URL;
|
|
367
|
+
w.title = `${action.title ?? DEFAULTS.terminalTitle} — ${dims.cols}×${dims.rows}`;
|
|
368
|
+
return { end: start + 300 };
|
|
369
|
+
}
|
|
370
|
+
case "terminal.run": {
|
|
371
|
+
const w = this.window("terminal");
|
|
372
|
+
this.focus(w);
|
|
373
|
+
const events = this.termEvents.get(index);
|
|
374
|
+
if (!events)
|
|
375
|
+
throw new Error("reelscript: terminal events missing (internal)");
|
|
376
|
+
const chars = Array.from(action.command);
|
|
377
|
+
const msPerChar = 60000 / ((action.wpm ?? DEFAULTS.typeWpm) * 5);
|
|
378
|
+
const typeStart = start + 120;
|
|
379
|
+
const typeEnd = typeStart + chars.length * msPerChar;
|
|
380
|
+
const outStart = typeEnd + 60;
|
|
381
|
+
const lastOut = events.length ? outStart + events[events.length - 1][0] : outStart;
|
|
382
|
+
const endsWithNewline = !events.length || /\n$/.test(events[events.length - 1][1]);
|
|
383
|
+
const promptAt = lastOut + 150;
|
|
384
|
+
let nextChar = 0;
|
|
385
|
+
let nextEvent = 0;
|
|
386
|
+
let entered = false;
|
|
387
|
+
let prompted = false;
|
|
388
|
+
const flush = async (t) => {
|
|
389
|
+
let batch = "";
|
|
390
|
+
while (nextChar < chars.length && typeStart + nextChar * msPerChar <= t)
|
|
391
|
+
batch += chars[nextChar++];
|
|
392
|
+
await this.termWrite(w, batch);
|
|
393
|
+
if (!entered && t >= typeEnd) {
|
|
394
|
+
entered = true;
|
|
395
|
+
await this.termWrite(w, "\r\n");
|
|
396
|
+
}
|
|
397
|
+
let out = "";
|
|
398
|
+
while (nextEvent < events.length && outStart + events[nextEvent][0] <= t)
|
|
399
|
+
out += events[nextEvent++][1];
|
|
400
|
+
await this.termWrite(w, out);
|
|
401
|
+
if (!prompted && t >= promptAt) {
|
|
402
|
+
prompted = true;
|
|
403
|
+
await this.termWrite(w, (endsWithNewline ? "" : "\r\n") + w.termPrompt);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
return { end: promptAt + 100, onFrame: flush, onEnd: () => flush(Infinity) };
|
|
407
|
+
}
|
|
408
|
+
default: {
|
|
409
|
+
const never = action;
|
|
410
|
+
throw new Error(`reelscript: unknown action ${JSON.stringify(never)}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/** Time (ms) after which nothing is still animating or speaking. */
|
|
415
|
+
pendingUntil() {
|
|
416
|
+
const zoom = this.zoomAnim ? this.zoomAnim.start + this.zoomAnim.dur : 0;
|
|
417
|
+
return Math.max(zoom, this.narrationEnd);
|
|
418
|
+
}
|
|
419
|
+
/** Advance continuous state (cursor, zoom) to time t. */
|
|
420
|
+
sample(t) {
|
|
421
|
+
if (this.move) {
|
|
422
|
+
const p = progress(t, this.move.start, this.move.dur, this.move.ease);
|
|
423
|
+
this.cursor = { x: lerp(this.move.from.x, this.move.to.x, p), y: lerp(this.move.from.y, this.move.to.y, p) };
|
|
424
|
+
}
|
|
425
|
+
if (this.zoomAnim) {
|
|
426
|
+
const z = this.zoomAnim;
|
|
427
|
+
const p = progress(t, z.start, z.dur, z.ease);
|
|
428
|
+
this.zoom = { scale: lerp(z.from.scale, z.to.scale, p), cx: lerp(z.from.cx, z.to.cx, p), cy: lerp(z.from.cy, z.to.cy, p) };
|
|
429
|
+
if (t >= z.start + z.dur) {
|
|
430
|
+
this.zoom = { ...z.to };
|
|
431
|
+
this.zoomAnim = null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** Drive the real mouse in whichever window is under the cursor so hover states render. */
|
|
436
|
+
async syncMouse() {
|
|
437
|
+
const w = this.windowAt(this.cursor);
|
|
438
|
+
if (!w)
|
|
439
|
+
return;
|
|
440
|
+
const local = this.toLocal(w, this.cursor);
|
|
441
|
+
if (this.inContent(w, local))
|
|
442
|
+
await w.page.mouse.move(local.x, local.y);
|
|
443
|
+
}
|
|
444
|
+
// ------------------------------------------------------------ frames
|
|
445
|
+
/**
|
|
446
|
+
* Capture a window via CDP rather than page.screenshot(): Playwright's
|
|
447
|
+
* screenshot waits for an in-page requestAnimationFrame, which is under
|
|
448
|
+
* our control and slower; CDP grabs the current compositor frame directly.
|
|
449
|
+
*/
|
|
450
|
+
async capture(w) {
|
|
451
|
+
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("reelscript: screenshot timed out (page compositor stalled)")), 10_000));
|
|
452
|
+
const { data } = await Promise.race([w.cdp.send("Page.captureScreenshot", { format: "png" }), timeout]);
|
|
453
|
+
return Buffer.from(data, "base64");
|
|
454
|
+
}
|
|
455
|
+
/** Window content as RGBA raw, masked to the theme's rounded corners. */
|
|
456
|
+
async contentOverlay(w) {
|
|
457
|
+
const png = await this.capture(w);
|
|
458
|
+
const mask = await this.theme.contentMask(w.width, w.height);
|
|
459
|
+
let pipeline = sharp(png).ensureAlpha();
|
|
460
|
+
if (mask)
|
|
461
|
+
pipeline = pipeline.composite([{ input: mask.data, raw: { width: mask.width, height: mask.height, channels: 4 }, blend: "dest-in" }]);
|
|
462
|
+
const { data, info } = await pipeline.raw().toBuffer({ resolveWithObject: true });
|
|
463
|
+
return { input: data, raw: { width: info.width, height: info.height, channels: 4 }, left: w.x, top: w.y + this.titleH };
|
|
464
|
+
}
|
|
465
|
+
/** Window chrome as an overlay, clipped to the desktop; cached per style and position. */
|
|
466
|
+
async frameOverlay(w) {
|
|
467
|
+
const style = { kind: w.kind, width: w.width, height: w.height, title: w.title, url: w.url, focused: w.id === this.focusedId };
|
|
468
|
+
const key = `${JSON.stringify(style)}@${w.x},${w.y}`;
|
|
469
|
+
if (w.frameOverlay?.key === key)
|
|
470
|
+
return w.frameOverlay.overlay;
|
|
471
|
+
const img = await this.theme.frame(style);
|
|
472
|
+
const overlay = img ? await clipRaw(img, w.x + img.dx, w.y + img.dy, this.desktop[0], this.desktop[1]) : null;
|
|
473
|
+
w.frameOverlay = { key, overlay };
|
|
474
|
+
return overlay;
|
|
475
|
+
}
|
|
476
|
+
/** Capture every window, compose the desktop, zoom, and overlay the cursor. Returns packed RGB. */
|
|
477
|
+
async frame(t) {
|
|
478
|
+
const [W, H] = this.desktop;
|
|
479
|
+
const bg = await this.theme.background(this.desktop);
|
|
480
|
+
const layers = await Promise.all(this.byZ().map(async (w) => {
|
|
481
|
+
const [frame, content] = await Promise.all([this.frameOverlay(w), this.contentOverlay(w)]);
|
|
482
|
+
return frame ? [frame, content] : [content];
|
|
483
|
+
}));
|
|
484
|
+
const { data: sceneData } = await sharp(bg.data, { raw: { width: bg.width, height: bg.height, channels: 4 } })
|
|
485
|
+
.composite(layers.flat())
|
|
486
|
+
.raw()
|
|
487
|
+
.toBuffer({ resolveWithObject: true });
|
|
488
|
+
const s = this.zoom.scale;
|
|
489
|
+
const cw = W / s;
|
|
490
|
+
const ch = H / s;
|
|
491
|
+
// Sub-pixel crop origin. Rounding it to whole pixels makes the content
|
|
492
|
+
// jump by up to a pixel per frame during a zoom, which reads as flicker.
|
|
493
|
+
const fx = clamp(this.zoom.cx - cw / 2, 0, W - cw);
|
|
494
|
+
const fy = clamp(this.zoom.cy - ch / 2, 0, H - ch);
|
|
495
|
+
// cursor: desktop → output coordinates
|
|
496
|
+
const ox = (this.cursor.x - fx) * s;
|
|
497
|
+
const oy = (this.cursor.y - fy) * s;
|
|
498
|
+
const overlays = [];
|
|
499
|
+
const since = t - this.lastClick;
|
|
500
|
+
if (since >= 0 && since < RIPPLE_MS) {
|
|
501
|
+
const p = since / RIPPLE_MS;
|
|
502
|
+
const ripple = await rippleSprite((6 + 22 * p) * s, 0.55 * (1 - p));
|
|
503
|
+
const o = await placeSprite(ripple, ox, oy, W, H);
|
|
504
|
+
if (o)
|
|
505
|
+
overlays.push(o);
|
|
506
|
+
}
|
|
507
|
+
const squish = since >= 0 && since < SQUISH_MS ? 0.86 : 1;
|
|
508
|
+
const arrow = await cursorSprite(CURSOR_PX * s * squish);
|
|
509
|
+
const o = await placeSprite(arrow, ox, oy, W, H);
|
|
510
|
+
if (o)
|
|
511
|
+
overlays.push(o);
|
|
512
|
+
let zoomed = sceneData;
|
|
513
|
+
if (s > 1.001) {
|
|
514
|
+
// Integer crop with a margin, then scale with the fractional offset
|
|
515
|
+
// folded into the affine transform, then trim to the output size.
|
|
516
|
+
const left = Math.floor(fx);
|
|
517
|
+
const top = Math.floor(fy);
|
|
518
|
+
const width = Math.min(W - left, Math.ceil(fx + cw) - left + 2);
|
|
519
|
+
const height = Math.min(H - top, Math.ceil(fy + ch) - top + 2);
|
|
520
|
+
const { data, info } = await sharp(sceneData, { raw: { width: W, height: H, channels: 4 } })
|
|
521
|
+
.extract({ left, top, width, height })
|
|
522
|
+
.affine([[s, 0], [0, s]], { interpolator: "bicubic", idx: -(fx - left), idy: -(fy - top), background: "#000" })
|
|
523
|
+
.raw()
|
|
524
|
+
.toBuffer({ resolveWithObject: true });
|
|
525
|
+
zoomed = await sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } })
|
|
526
|
+
.extract({ left: 0, top: 0, width: Math.min(W, info.width), height: Math.min(H, info.height) })
|
|
527
|
+
.resize(W, H, { fit: "fill" })
|
|
528
|
+
.raw()
|
|
529
|
+
.toBuffer();
|
|
530
|
+
}
|
|
531
|
+
let pipeline = sharp(zoomed, { raw: { width: W, height: H, channels: 4 } });
|
|
532
|
+
if (overlays.length)
|
|
533
|
+
pipeline = pipeline.composite(overlays);
|
|
534
|
+
return pipeline.removeAlpha().raw().toBuffer();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/** Clip a raw RGBA image to the desktop bounds and return it as an overlay (sharp rejects out-of-bounds overlays). */
|
|
538
|
+
async function clipRaw(img, left, top, W, H) {
|
|
539
|
+
let { width, height } = img;
|
|
540
|
+
if (left >= W || top >= H || left + width <= 0 || top + height <= 0)
|
|
541
|
+
return null;
|
|
542
|
+
const clipL = Math.max(0, -left);
|
|
543
|
+
const clipT = Math.max(0, -top);
|
|
544
|
+
const clipR = Math.max(0, left + width - W);
|
|
545
|
+
const clipB = Math.max(0, top + height - H);
|
|
546
|
+
if (!clipL && !clipT && !clipR && !clipB) {
|
|
547
|
+
return { input: img.data, raw: { width, height, channels: 4 }, left, top };
|
|
548
|
+
}
|
|
549
|
+
width -= clipL + clipR;
|
|
550
|
+
height -= clipT + clipB;
|
|
551
|
+
if (width <= 0 || height <= 0)
|
|
552
|
+
return null;
|
|
553
|
+
const { data } = await sharp(img.data, { raw: { width: img.width, height: img.height, channels: 4 } })
|
|
554
|
+
.extract({ left: clipL, top: clipT, width, height })
|
|
555
|
+
.raw()
|
|
556
|
+
.toBuffer({ resolveWithObject: true });
|
|
557
|
+
return { input: data, raw: { width, height, channels: 4 }, left: left + clipL, top: top + clipT };
|
|
558
|
+
}
|
|
559
|
+
/** Position a sprite by its hotspot, clipping it to the frame. */
|
|
560
|
+
async function placeSprite(sp, x, y, W, H) {
|
|
561
|
+
let left = Math.round(x - sp.hx);
|
|
562
|
+
let top = Math.round(y - sp.hy);
|
|
563
|
+
let { width, height } = sp;
|
|
564
|
+
if (left >= W || top >= H || left + width <= 0 || top + height <= 0)
|
|
565
|
+
return null;
|
|
566
|
+
const clipL = Math.max(0, -left);
|
|
567
|
+
const clipT = Math.max(0, -top);
|
|
568
|
+
const clipR = Math.max(0, left + width - W);
|
|
569
|
+
const clipB = Math.max(0, top + height - H);
|
|
570
|
+
if (!clipL && !clipT && !clipR && !clipB)
|
|
571
|
+
return { input: sp.data, left, top };
|
|
572
|
+
width -= clipL + clipR;
|
|
573
|
+
height -= clipT + clipB;
|
|
574
|
+
if (width <= 0 || height <= 0)
|
|
575
|
+
return null;
|
|
576
|
+
const input = await sharp(sp.data).extract({ left: clipL, top: clipT, width, height }).png().toBuffer();
|
|
577
|
+
return { input, left: left + clipL, top: top + clipT };
|
|
578
|
+
}
|
|
579
|
+
export async function render(actions, options) {
|
|
580
|
+
const fps = options.fps ?? DEFAULTS.fps;
|
|
581
|
+
const viewport = options.viewport ?? DEFAULTS.viewport;
|
|
582
|
+
const themeName = options.theme ?? "macos";
|
|
583
|
+
const frameMs = 1000 / fps;
|
|
584
|
+
const snapshot = options.snapshotAt;
|
|
585
|
+
const engine = new Engine(viewport, options.desktop, themeName, options.deterministic ?? true);
|
|
586
|
+
const [width, height] = engine.desktop;
|
|
587
|
+
// Narration is synthesized up front so clip durations can pace the timeline.
|
|
588
|
+
const sayIndexes = actions.flatMap((a, i) => (a.kind === "say" ? [i] : []));
|
|
589
|
+
const isGif = extname(options.out).toLowerCase() === ".gif";
|
|
590
|
+
const hasAudio = sayIndexes.length > 0 && snapshot === undefined && !isGif;
|
|
591
|
+
if (sayIndexes.length) {
|
|
592
|
+
const tts = options.tts ?? kokoro();
|
|
593
|
+
options.onStatus?.(`synthesizing narration (${sayIndexes.length} clip${sayIndexes.length === 1 ? "" : "s"})`);
|
|
594
|
+
const clips = new Map();
|
|
595
|
+
for (const i of sayIndexes) {
|
|
596
|
+
const a = actions[i];
|
|
597
|
+
const text = applyPronunciations(a.text, options.pronunciations);
|
|
598
|
+
clips.set(i, await synthesizeClip(tts, text, { voice: a.voice ?? options.voice, speed: a.speed }));
|
|
599
|
+
}
|
|
600
|
+
engine.setClips(clips);
|
|
601
|
+
if (isGif && snapshot === undefined)
|
|
602
|
+
options.onStatus?.("note: GIF output has no audio; narration is used for pacing only");
|
|
603
|
+
}
|
|
604
|
+
// Terminal output: declared in the script, or replayed from a recording.
|
|
605
|
+
const termRuns = actions.flatMap((a, i) => (a.kind === "terminal.run" ? [i] : []));
|
|
606
|
+
if (termRuns.length) {
|
|
607
|
+
const events = new Map();
|
|
608
|
+
for (const i of termRuns) {
|
|
609
|
+
const a = actions[i];
|
|
610
|
+
if (a.output !== undefined) {
|
|
611
|
+
events.set(i, scriptedEvents(a.output, a.duration));
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const rec = options.recordingsDir ? loadRecording(options.recordingsDir, a.command) : null;
|
|
615
|
+
if (!rec) {
|
|
616
|
+
throw new Error(`reelscript: no recording for terminal command "${a.command}".\n` +
|
|
617
|
+
` Declare its output with terminal.run(cmd, { output }), or record it:\n` +
|
|
618
|
+
` reelscript record <script>`);
|
|
619
|
+
}
|
|
620
|
+
events.set(i, playbackEvents(rec.events, { speed: a.speed, maxGapMs: a.maxGapMs }));
|
|
621
|
+
}
|
|
622
|
+
engine.setTerminalEvents(events);
|
|
623
|
+
}
|
|
624
|
+
const videoPath = hasAudio ? `${options.out}.video.tmp.mp4` : options.out;
|
|
625
|
+
const encoder = snapshot === undefined ? new Encoder({ out: videoPath, width, height, fps, gif: options.gif }) : null;
|
|
626
|
+
await engine.open();
|
|
627
|
+
encoder?.start();
|
|
628
|
+
let t = 0;
|
|
629
|
+
let frames = 0;
|
|
630
|
+
let i = 0;
|
|
631
|
+
let step = null;
|
|
632
|
+
let nextStart = 0;
|
|
633
|
+
let endAt = null;
|
|
634
|
+
try {
|
|
635
|
+
for (;;) {
|
|
636
|
+
// Advance the sequential step machine up to time t.
|
|
637
|
+
for (;;) {
|
|
638
|
+
if (step && t >= step.end) {
|
|
639
|
+
await step.onEnd?.();
|
|
640
|
+
nextStart = step.end;
|
|
641
|
+
step = null;
|
|
642
|
+
}
|
|
643
|
+
if (step)
|
|
644
|
+
break;
|
|
645
|
+
if (i >= actions.length) {
|
|
646
|
+
endAt ??= Math.max(nextStart, engine.pendingUntil()) + DEFAULTS.tailMs;
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
step = await engine.begin(actions[i], i, nextStart);
|
|
650
|
+
i++;
|
|
651
|
+
}
|
|
652
|
+
if (endAt !== null && t >= endAt)
|
|
653
|
+
break;
|
|
654
|
+
engine.sample(t);
|
|
655
|
+
await engine.syncMouse();
|
|
656
|
+
if (step?.onFrame)
|
|
657
|
+
await step.onFrame(t);
|
|
658
|
+
await engine.advanceClock(frameMs);
|
|
659
|
+
if (snapshot !== undefined) {
|
|
660
|
+
if (t + frameMs / 2 >= snapshot || (endAt !== null && t + frameMs >= endAt)) {
|
|
661
|
+
const rgb = await engine.frame(t);
|
|
662
|
+
mkdirSync(dirname(options.out), { recursive: true });
|
|
663
|
+
await sharp(rgb, { raw: { width, height, channels: 3 } }).png().toFile(options.out);
|
|
664
|
+
frames++;
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
const rgb = await engine.frame(t);
|
|
670
|
+
await encoder.writeFrame(rgb);
|
|
671
|
+
frames++;
|
|
672
|
+
}
|
|
673
|
+
options.onProgress?.({ frame: frames, timeMs: t });
|
|
674
|
+
t += frameMs;
|
|
675
|
+
}
|
|
676
|
+
await encoder?.finish();
|
|
677
|
+
if (hasAudio && engine.narration.length) {
|
|
678
|
+
options.onStatus?.("mixing narration");
|
|
679
|
+
await muxNarration(videoPath, engine.narration, options.out);
|
|
680
|
+
unlinkSync(videoPath);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
finally {
|
|
684
|
+
await engine.close();
|
|
685
|
+
}
|
|
686
|
+
return { out: options.out, frames, durationMs: Math.round(t), width, height };
|
|
687
|
+
}
|