@tandryukha/aidemo 0.8.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/.claude/skills/record-demo/SKILL.md +66 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/bin/aidemo.mjs +53 -0
- package/docs/AUTHORING.md +728 -0
- package/package.json +47 -0
- package/src/caption-render.ts +69 -0
- package/src/captions.ts +441 -0
- package/src/capture.ts +574 -0
- package/src/cards.ts +67 -0
- package/src/cli.ts +646 -0
- package/src/compose.ts +937 -0
- package/src/config.ts +222 -0
- package/src/cursor-overlay.ts +186 -0
- package/src/cursor.ts +134 -0
- package/src/distribute.ts +713 -0
- package/src/embed.ts +202 -0
- package/src/ffmpeg.ts +83 -0
- package/src/gif.ts +65 -0
- package/src/golden.ts +109 -0
- package/src/i18n.ts +86 -0
- package/src/mcp/jobs.ts +285 -0
- package/src/mcp/server.ts +1016 -0
- package/src/music.ts +153 -0
- package/src/params.ts +134 -0
- package/src/player.ts +1173 -0
- package/src/project.ts +287 -0
- package/src/recorder.ts +320 -0
- package/src/starter.ts +141 -0
- package/src/stills.ts +121 -0
- package/src/types.ts +660 -0
- package/src/util.ts +171 -0
- package/src/variants.ts +117 -0
- package/src/voice-local.ts +230 -0
- package/src/voice.ts +405 -0
- package/src/zoom.ts +183 -0
package/src/capture.ts
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import type { Page } from "playwright";
|
|
5
|
+
import { runFfmpeg, probeVideoDims } from "./ffmpeg.js";
|
|
6
|
+
import { nativeCropUnsafe } from "./config.js";
|
|
7
|
+
import { log, sleep } from "./util.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Higher-fidelity capture paths. Playwright's built-in recordVideo is a CDP
|
|
11
|
+
* screencast — variable frame pacing and softer rendering. These providers
|
|
12
|
+
* record the REAL screen instead (retina pixels, GPU-composited animation):
|
|
13
|
+
*
|
|
14
|
+
* native — ffmpeg's avfoundation screen grab (macOS, zero extra software)
|
|
15
|
+
* obs — OBS Studio via obs-websocket v5 (any OS OBS supports)
|
|
16
|
+
*
|
|
17
|
+
* Both record the full screen; the recorder then crops the capture to the
|
|
18
|
+
* Chrome viewport. Getting that crop right is a PRIVACY property, not just a
|
|
19
|
+
* correctness one — a wrong rectangle ships whatever else is on screen (the
|
|
20
|
+
* desktop, notifications, permission dialogs) into the rendered video. The
|
|
21
|
+
* geometry is therefore measured via CDP `Browser.getWindowBounds` plus real
|
|
22
|
+
* (un-emulated) in-page metrics, re-measured until stable, and sanity-gated
|
|
23
|
+
* against the storyboard viewport before any cropping happens.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type CaptureMode = "playwright" | "native" | "obs";
|
|
27
|
+
|
|
28
|
+
export interface CaptureProvider {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
/** Resolves once frames are actually being recorded. */
|
|
31
|
+
start(): Promise<void>;
|
|
32
|
+
/** Stops recording and resolves to the captured file path. */
|
|
33
|
+
stop(): Promise<string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The browser viewport's position on screen, in CSS points (DIP). */
|
|
37
|
+
export interface ViewportGeometry {
|
|
38
|
+
/** Real screen size in points — the capture-pixels-per-point scale anchor. */
|
|
39
|
+
screenW: number;
|
|
40
|
+
screenH: number;
|
|
41
|
+
/** Page content area (the viewport) on screen, in points. */
|
|
42
|
+
x: number;
|
|
43
|
+
y: number;
|
|
44
|
+
w: number;
|
|
45
|
+
h: number;
|
|
46
|
+
/** Real devicePixelRatio of the display the window is on. */
|
|
47
|
+
dpr: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface WindowMetrics {
|
|
51
|
+
screenW: number;
|
|
52
|
+
screenH: number;
|
|
53
|
+
winX: number;
|
|
54
|
+
winY: number;
|
|
55
|
+
outerW: number;
|
|
56
|
+
outerH: number;
|
|
57
|
+
innerW: number;
|
|
58
|
+
innerH: number;
|
|
59
|
+
dpr: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Measure where the page's viewport sits on the screen, in points.
|
|
64
|
+
*
|
|
65
|
+
* The context MUST run without viewport emulation (`viewport: null`): under
|
|
66
|
+
* Playwright's emulation, `window.innerWidth/innerHeight`, `screen.width/
|
|
67
|
+
* height` and `devicePixelRatio` all report the EMULATED values (viewport-
|
|
68
|
+
* sized screen, DPR 1) while `outerWidth`/`screenX` stay real — mixing them
|
|
69
|
+
* produced a wildly wrong physical-per-point scale on Retina displays and
|
|
70
|
+
* cropped the take to the whole screen (issue #13).
|
|
71
|
+
*
|
|
72
|
+
* Approach:
|
|
73
|
+
* 1. CDP `Browser.getWindowBounds` — real window bounds in DIP. Force
|
|
74
|
+
* `windowState: "normal"` first (a maximized restore from the Chrome
|
|
75
|
+
* profile ignores --window-position and breaks the inset math).
|
|
76
|
+
* 2. Resize the window (CDP `Browser.setWindowBounds`) until the REAL
|
|
77
|
+
* content area (innerWidth/innerHeight, un-emulated) equals the
|
|
78
|
+
* storyboard viewport — normally one iteration.
|
|
79
|
+
* 3. Re-measure after a settle delay until two consecutive measurements
|
|
80
|
+
* agree (window placement/animation may still be in flight).
|
|
81
|
+
* 4. Content-area origin: side border is (outerWidth-innerWidth)/2, the
|
|
82
|
+
* top bar (tab strip + toolbar + infobars) is the rest.
|
|
83
|
+
* 5. Sanity-gate the result against the storyboard viewport — on mismatch,
|
|
84
|
+
* abort BEFORE any screen pixels are recorded (AIDEMO_NATIVE_CROP_UNSAFE=1
|
|
85
|
+
* downgrades to a loud warning).
|
|
86
|
+
*/
|
|
87
|
+
export async function measureViewportGeometry(
|
|
88
|
+
page: Page,
|
|
89
|
+
viewport: { width: number; height: number },
|
|
90
|
+
opts: { settleMs?: number; timeoutMs?: number } = {}
|
|
91
|
+
): Promise<ViewportGeometry> {
|
|
92
|
+
const settleMs = opts.settleMs ?? 400;
|
|
93
|
+
const timeoutMs = opts.timeoutMs ?? 10000;
|
|
94
|
+
const cdp = await page.context().newCDPSession(page);
|
|
95
|
+
try {
|
|
96
|
+
const { windowId } = await cdp.send("Browser.getWindowForTarget");
|
|
97
|
+
const getBounds = async () =>
|
|
98
|
+
(await cdp.send("Browser.getWindowBounds", { windowId })).bounds;
|
|
99
|
+
const metrics = (): Promise<WindowMetrics> =>
|
|
100
|
+
page.evaluate(() => ({
|
|
101
|
+
screenW: screen.width,
|
|
102
|
+
screenH: screen.height,
|
|
103
|
+
winX: window.screenX,
|
|
104
|
+
winY: window.screenY,
|
|
105
|
+
outerW: window.outerWidth,
|
|
106
|
+
outerH: window.outerHeight,
|
|
107
|
+
innerW: window.innerWidth,
|
|
108
|
+
innerH: window.innerHeight,
|
|
109
|
+
dpr: window.devicePixelRatio,
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
if ((await getBounds()).windowState !== "normal") {
|
|
113
|
+
log("window opened maximized/fullscreen — restoring to normal for capture");
|
|
114
|
+
await cdp.send("Browser.setWindowBounds", {
|
|
115
|
+
windowId,
|
|
116
|
+
bounds: { windowState: "normal" },
|
|
117
|
+
});
|
|
118
|
+
await sleep(settleMs);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Converge the real content area onto the storyboard viewport: grow or
|
|
122
|
+
// shrink the outer window by the inner-size delta, keeping it on-screen.
|
|
123
|
+
for (let i = 0; i < 4; i++) {
|
|
124
|
+
const m = await metrics();
|
|
125
|
+
const dw = viewport.width - m.innerW;
|
|
126
|
+
const dh = viewport.height - m.innerH;
|
|
127
|
+
if (dw === 0 && dh === 0) break;
|
|
128
|
+
const b = await getBounds();
|
|
129
|
+
const width = (b.width ?? m.outerW) + dw;
|
|
130
|
+
const height = (b.height ?? m.outerH) + dh;
|
|
131
|
+
const left = Math.max(0, Math.min(b.left ?? m.winX, m.screenW - width));
|
|
132
|
+
const top = Math.max(0, Math.min(b.top ?? m.winY, m.screenH - height));
|
|
133
|
+
await cdp.send("Browser.setWindowBounds", {
|
|
134
|
+
windowId,
|
|
135
|
+
bounds: { left, top, width, height },
|
|
136
|
+
});
|
|
137
|
+
await sleep(settleMs);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Measure twice until stable — accept only two identical consecutive reads.
|
|
141
|
+
let prev = { bounds: await getBounds(), m: await metrics() };
|
|
142
|
+
let stable = false;
|
|
143
|
+
const deadline = Date.now() + timeoutMs;
|
|
144
|
+
while (Date.now() < deadline) {
|
|
145
|
+
await sleep(settleMs);
|
|
146
|
+
const cur = { bounds: await getBounds(), m: await metrics() };
|
|
147
|
+
if (
|
|
148
|
+
cur.bounds.left === prev.bounds.left &&
|
|
149
|
+
cur.bounds.top === prev.bounds.top &&
|
|
150
|
+
cur.bounds.width === prev.bounds.width &&
|
|
151
|
+
cur.bounds.height === prev.bounds.height &&
|
|
152
|
+
cur.bounds.windowState === prev.bounds.windowState &&
|
|
153
|
+
JSON.stringify(cur.m) === JSON.stringify(prev.m)
|
|
154
|
+
) {
|
|
155
|
+
prev = cur;
|
|
156
|
+
stable = true;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
prev = cur;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const { bounds, m } = prev;
|
|
163
|
+
const side = Math.max(0, (m.outerW - m.innerW) / 2);
|
|
164
|
+
const topBar = Math.max(0, m.outerH - m.innerH - side);
|
|
165
|
+
const geo: ViewportGeometry = {
|
|
166
|
+
screenW: m.screenW,
|
|
167
|
+
screenH: m.screenH,
|
|
168
|
+
x: (bounds.left ?? m.winX) + side,
|
|
169
|
+
y: (bounds.top ?? m.winY) + topBar,
|
|
170
|
+
w: m.innerW,
|
|
171
|
+
h: m.innerH,
|
|
172
|
+
dpr: m.dpr,
|
|
173
|
+
};
|
|
174
|
+
log(
|
|
175
|
+
`window geometry: screen ${geo.screenW}x${geo.screenH}pt dpr ${geo.dpr}, ` +
|
|
176
|
+
`window ${bounds.width}x${bounds.height}+${bounds.left}+${bounds.top} ` +
|
|
177
|
+
`(${bounds.windowState}), viewport ${geo.w}x${geo.h}+${Math.round(geo.x)}+${Math.round(geo.y)}pt`
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const problems: string[] = [];
|
|
181
|
+
if (!stable) {
|
|
182
|
+
problems.push(
|
|
183
|
+
`window geometry did not stabilize within ${timeoutMs}ms (placement animation or another process moving the window?)`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const tol = 4; // points of window-chrome rounding slack
|
|
187
|
+
if (
|
|
188
|
+
Math.abs(geo.w - viewport.width) > tol ||
|
|
189
|
+
Math.abs(geo.h - viewport.height) > tol
|
|
190
|
+
) {
|
|
191
|
+
problems.push(
|
|
192
|
+
`measured content area ${geo.w}x${geo.h}pt does not match the storyboard viewport ` +
|
|
193
|
+
`${viewport.width}x${viewport.height} (is the screen large enough for the window?)`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (
|
|
197
|
+
geo.x < 0 ||
|
|
198
|
+
geo.y < 0 ||
|
|
199
|
+
geo.x + geo.w > geo.screenW + tol ||
|
|
200
|
+
geo.y + geo.h > geo.screenH + tol
|
|
201
|
+
) {
|
|
202
|
+
problems.push(
|
|
203
|
+
`viewport rect ${geo.w}x${geo.h}+${Math.round(geo.x)}+${Math.round(geo.y)}pt extends beyond ` +
|
|
204
|
+
`the ${geo.screenW}x${geo.screenH}pt screen`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
gateGeometryProblems(
|
|
208
|
+
problems,
|
|
209
|
+
"Refusing to start the screen capture: the browser-window geometry is not safe to crop."
|
|
210
|
+
);
|
|
211
|
+
return geo;
|
|
212
|
+
} finally {
|
|
213
|
+
await cdp.detach().catch(() => {});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Enforce the crop-geometry sanity gate. A wrong crop silently ships whatever
|
|
219
|
+
* else is on screen into a rendered video, so mismatches ABORT by default;
|
|
220
|
+
* AIDEMO_NATIVE_CROP_UNSAFE=1 downgrades the abort to a loud warning.
|
|
221
|
+
*/
|
|
222
|
+
function gateGeometryProblems(problems: string[], headline: string): void {
|
|
223
|
+
if (problems.length === 0) return;
|
|
224
|
+
const detail = problems.map((p) => ` - ${p}`).join("\n");
|
|
225
|
+
if (nativeCropUnsafe()) {
|
|
226
|
+
log(`⚠⚠⚠ AIDEMO_NATIVE_CROP_UNSAFE=1 — continuing despite unsafe capture geometry:`);
|
|
227
|
+
for (const p of problems) log(`⚠ ${p}`);
|
|
228
|
+
log(`⚠ the resulting video may contain your desktop — frame-review it before publishing`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
throw new Error(
|
|
232
|
+
`${headline}\n${detail}\n` +
|
|
233
|
+
`A wrong crop ships whatever else is on screen — your desktop, notifications, ` +
|
|
234
|
+
`other windows — into the rendered video (privacy risk, see issue #13). ` +
|
|
235
|
+
`Make sure the screen is large enough for the storyboard viewport and the ` +
|
|
236
|
+
`browser window sits fully on the captured display, then re-record. ` +
|
|
237
|
+
`Set AIDEMO_NATIVE_CROP_UNSAFE=1 to downgrade this error to a warning and ` +
|
|
238
|
+
`record anyway — if you do, frame-review the take before publishing.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// native: ffmpeg avfoundation (macOS)
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
export class AvfoundationCapture implements CaptureProvider {
|
|
247
|
+
readonly name: string;
|
|
248
|
+
private proc: ChildProcess | null = null;
|
|
249
|
+
private stderr = "";
|
|
250
|
+
private exited: number | null = null;
|
|
251
|
+
|
|
252
|
+
constructor(
|
|
253
|
+
private outFile: string,
|
|
254
|
+
private device: string,
|
|
255
|
+
private fps = 30
|
|
256
|
+
) {
|
|
257
|
+
if (process.platform !== "darwin") {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`--capture native uses ffmpeg's avfoundation input, which only exists on ` +
|
|
260
|
+
`macOS (this is ${process.platform}). Use --capture obs (OBS Studio + ` +
|
|
261
|
+
`obs-websocket, any OS) for high-fidelity capture, or the default ` +
|
|
262
|
+
`Playwright capture.`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
this.name = `native screen capture (avfoundation "${device}")`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async start(): Promise<void> {
|
|
269
|
+
await fs.rm(this.outFile, { force: true });
|
|
270
|
+
// Matroska container: stays playable even if the process dies uncleanly.
|
|
271
|
+
// capture_cursor=0 — the demo cursor is the injected in-page one; the real
|
|
272
|
+
// (motionless) macOS cursor would just sit in the frame.
|
|
273
|
+
this.proc = spawn(
|
|
274
|
+
"ffmpeg",
|
|
275
|
+
[
|
|
276
|
+
"-hide_banner",
|
|
277
|
+
"-loglevel",
|
|
278
|
+
"info",
|
|
279
|
+
"-y",
|
|
280
|
+
"-f",
|
|
281
|
+
"avfoundation",
|
|
282
|
+
"-framerate",
|
|
283
|
+
String(this.fps),
|
|
284
|
+
"-capture_cursor",
|
|
285
|
+
"0",
|
|
286
|
+
"-i",
|
|
287
|
+
`${this.device}:none`,
|
|
288
|
+
"-c:v",
|
|
289
|
+
"libx264",
|
|
290
|
+
"-preset",
|
|
291
|
+
"ultrafast",
|
|
292
|
+
"-crf",
|
|
293
|
+
"18",
|
|
294
|
+
"-pix_fmt",
|
|
295
|
+
"yuv420p",
|
|
296
|
+
this.outFile,
|
|
297
|
+
],
|
|
298
|
+
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
299
|
+
);
|
|
300
|
+
this.proc.stderr!.on("data", (d) => (this.stderr += d.toString()));
|
|
301
|
+
this.proc.on("close", (code) => (this.exited = code ?? -1));
|
|
302
|
+
|
|
303
|
+
// Consider capture "rolling" once the output file grows between polls.
|
|
304
|
+
const deadline = Date.now() + 15000;
|
|
305
|
+
let lastSize = -1;
|
|
306
|
+
for (;;) {
|
|
307
|
+
if (this.exited !== null) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`Screen capture failed to start (ffmpeg exited ${this.exited}).\n` +
|
|
310
|
+
`On macOS, grant your terminal Screen Recording permission ` +
|
|
311
|
+
`(System Settings → Privacy & Security), and check the device name ` +
|
|
312
|
+
`(AIDEMO_CAPTURE_DEVICE; list with: ffmpeg -f avfoundation -list_devices true -i "").\n` +
|
|
313
|
+
this.stderr.split("\n").slice(-8).join("\n")
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const size = await fs.stat(this.outFile).then((s) => s.size).catch(() => -1);
|
|
317
|
+
if (size > 0 && lastSize > 0 && size > lastSize) break;
|
|
318
|
+
lastSize = size;
|
|
319
|
+
if (Date.now() > deadline) {
|
|
320
|
+
this.proc.kill("SIGKILL");
|
|
321
|
+
throw new Error("Screen capture did not start within 15s.");
|
|
322
|
+
}
|
|
323
|
+
await sleep(200);
|
|
324
|
+
}
|
|
325
|
+
log(`screen capture rolling → ${this.outFile}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async stop(): Promise<string> {
|
|
329
|
+
const proc = this.proc;
|
|
330
|
+
if (!proc) throw new Error("Capture was never started.");
|
|
331
|
+
if (this.exited === null) {
|
|
332
|
+
proc.kill("SIGINT"); // graceful: ffmpeg flushes and finalizes
|
|
333
|
+
const deadline = Date.now() + 10000;
|
|
334
|
+
while (this.exited === null && Date.now() < deadline) await sleep(100);
|
|
335
|
+
if (this.exited === null) proc.kill("SIGKILL");
|
|
336
|
+
}
|
|
337
|
+
return this.outFile;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// obs: OBS Studio via obs-websocket v5
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
interface ObsPending {
|
|
346
|
+
resolve: (data: unknown) => void;
|
|
347
|
+
reject: (err: Error) => void;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Minimal obs-websocket v5 client on Node's global WebSocket (Node 22+).
|
|
352
|
+
* Assumes the current OBS scene captures the FULL primary display (Display
|
|
353
|
+
* Capture) — the recorder crops to the viewport afterwards, same as native.
|
|
354
|
+
*/
|
|
355
|
+
export class ObsCapture implements CaptureProvider {
|
|
356
|
+
readonly name: string;
|
|
357
|
+
private ws: WebSocket | null = null;
|
|
358
|
+
private pending = new Map<string, ObsPending>();
|
|
359
|
+
private nextId = 1;
|
|
360
|
+
|
|
361
|
+
constructor(
|
|
362
|
+
private url: string,
|
|
363
|
+
private password?: string
|
|
364
|
+
) {
|
|
365
|
+
this.name = `OBS (obs-websocket at ${url})`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async start(): Promise<void> {
|
|
369
|
+
if (typeof WebSocket === "undefined") {
|
|
370
|
+
throw new Error(
|
|
371
|
+
"OBS capture needs Node 22+ (built-in WebSocket). Use --capture native instead."
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
await this.connect();
|
|
375
|
+
await this.request("StartRecord", {});
|
|
376
|
+
await sleep(500); // let OBS write its first frames
|
|
377
|
+
log("OBS recording started");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async stop(): Promise<string> {
|
|
381
|
+
const data = (await this.request("StopRecord", {})) as {
|
|
382
|
+
outputPath?: string;
|
|
383
|
+
};
|
|
384
|
+
this.ws?.close();
|
|
385
|
+
const path = data?.outputPath;
|
|
386
|
+
if (!path) throw new Error("OBS StopRecord returned no outputPath.");
|
|
387
|
+
// Wait for OBS to finish writing the file (size stable across polls).
|
|
388
|
+
let lastSize = -1;
|
|
389
|
+
for (let i = 0; i < 50; i++) {
|
|
390
|
+
const size = await fs.stat(path).then((s) => s.size).catch(() => -1);
|
|
391
|
+
if (size > 0 && size === lastSize) break;
|
|
392
|
+
lastSize = size;
|
|
393
|
+
await sleep(200);
|
|
394
|
+
}
|
|
395
|
+
return path;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private connect(): Promise<void> {
|
|
399
|
+
return new Promise((resolve, reject) => {
|
|
400
|
+
const ws = new WebSocket(this.url);
|
|
401
|
+
this.ws = ws;
|
|
402
|
+
ws.onerror = () =>
|
|
403
|
+
reject(
|
|
404
|
+
new Error(
|
|
405
|
+
`Could not reach obs-websocket at ${this.url}. Is OBS running with ` +
|
|
406
|
+
`the WebSocket server enabled (Tools → WebSocket Server Settings)?`
|
|
407
|
+
)
|
|
408
|
+
);
|
|
409
|
+
ws.onmessage = (ev: MessageEvent) => {
|
|
410
|
+
const msg = JSON.parse(String(ev.data));
|
|
411
|
+
if (msg.op === 0) {
|
|
412
|
+
// Hello → Identify (with auth when a challenge is present)
|
|
413
|
+
const auth = msg.d.authentication
|
|
414
|
+
? obsAuth(this.password ?? "", msg.d.authentication)
|
|
415
|
+
: undefined;
|
|
416
|
+
ws.send(
|
|
417
|
+
JSON.stringify({
|
|
418
|
+
op: 1,
|
|
419
|
+
d: { rpcVersion: 1, ...(auth ? { authentication: auth } : {}) },
|
|
420
|
+
})
|
|
421
|
+
);
|
|
422
|
+
} else if (msg.op === 2) {
|
|
423
|
+
resolve(); // Identified
|
|
424
|
+
} else if (msg.op === 7) {
|
|
425
|
+
const p = this.pending.get(msg.d.requestId);
|
|
426
|
+
if (!p) return;
|
|
427
|
+
this.pending.delete(msg.d.requestId);
|
|
428
|
+
if (msg.d.requestStatus?.result) p.resolve(msg.d.responseData ?? {});
|
|
429
|
+
else
|
|
430
|
+
p.reject(
|
|
431
|
+
new Error(
|
|
432
|
+
`OBS ${msg.d.requestType} failed: ${msg.d.requestStatus?.comment ?? "unknown"}`
|
|
433
|
+
)
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
ws.onclose = () => {
|
|
438
|
+
const err = new Error(
|
|
439
|
+
"obs-websocket connection closed (wrong AIDEMO_OBS_PASSWORD?)"
|
|
440
|
+
);
|
|
441
|
+
reject(err);
|
|
442
|
+
for (const p of this.pending.values()) p.reject(err);
|
|
443
|
+
this.pending.clear();
|
|
444
|
+
};
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private request(requestType: string, requestData: unknown): Promise<unknown> {
|
|
449
|
+
const ws = this.ws;
|
|
450
|
+
if (!ws) return Promise.reject(new Error("OBS not connected"));
|
|
451
|
+
const requestId = `r${this.nextId++}`;
|
|
452
|
+
return new Promise((resolve, reject) => {
|
|
453
|
+
this.pending.set(requestId, { resolve, reject });
|
|
454
|
+
ws.send(JSON.stringify({ op: 6, d: { requestType, requestId, requestData } }));
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** obs-websocket v5 auth: b64(sha256(b64(sha256(pw+salt)) + challenge)). */
|
|
460
|
+
function obsAuth(
|
|
461
|
+
password: string,
|
|
462
|
+
{ challenge, salt }: { challenge: string; salt: string }
|
|
463
|
+
): string {
|
|
464
|
+
const secret = createHash("sha256").update(password + salt).digest("base64");
|
|
465
|
+
return createHash("sha256").update(secret + challenge).digest("base64");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// shared post-processing
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Crop a full-screen capture down to the browser viewport and normalize it
|
|
474
|
+
* for compose. Retina screens yield ~2x the storyboard's logical size — that
|
|
475
|
+
* density is kept (output at 2x) so the extra fidelity survives; compose is
|
|
476
|
+
* resolution-aware and scales captions/cards/zoom to match.
|
|
477
|
+
*
|
|
478
|
+
* Before cropping, the computed rectangle is sanity-gated against the
|
|
479
|
+
* storyboard viewport (DPR-aware): if the crop doesn't match, the whole run
|
|
480
|
+
* ABORTS instead of shipping a mis-cropped — potentially desktop-leaking —
|
|
481
|
+
* video (AIDEMO_NATIVE_CROP_UNSAFE=1 downgrades to a loud warning). On abort
|
|
482
|
+
* the un-cropped full-screen capture is left at `src` for inspection.
|
|
483
|
+
*/
|
|
484
|
+
export async function cropCaptureToViewport(
|
|
485
|
+
src: string,
|
|
486
|
+
dst: string,
|
|
487
|
+
geo: ViewportGeometry,
|
|
488
|
+
logicalW: number,
|
|
489
|
+
logicalH: number,
|
|
490
|
+
fps: number
|
|
491
|
+
): Promise<void> {
|
|
492
|
+
const { width: capW, height: capH } = await probeVideoDims(src);
|
|
493
|
+
const ratio = capW / geo.screenW; // physical px per CSS point
|
|
494
|
+
const ratioH = capH / geo.screenH;
|
|
495
|
+
const even = (n: number) => Math.max(2, 2 * Math.round(n / 2));
|
|
496
|
+
const rawCw = even(geo.w * ratio);
|
|
497
|
+
const rawCh = even(geo.h * ratio);
|
|
498
|
+
const rawCx = Math.round(geo.x * ratio);
|
|
499
|
+
const rawCy = Math.round(geo.y * ratio);
|
|
500
|
+
|
|
501
|
+
// Sanity gate: the crop must be exactly the storyboard viewport, scaled by
|
|
502
|
+
// the physical-per-point ratio, and must fall fully inside the capture.
|
|
503
|
+
const problems: string[] = [];
|
|
504
|
+
const tolX = Math.max(8, Math.round(logicalW * ratio * 0.02));
|
|
505
|
+
const tolY = Math.max(8, Math.round(logicalH * ratio * 0.02));
|
|
506
|
+
if (Math.abs(ratio - ratioH) > ratio * 0.02) {
|
|
507
|
+
problems.push(
|
|
508
|
+
`capture ${capW}x${capH} is not a uniform scale of the ${geo.screenW}x${geo.screenH}pt ` +
|
|
509
|
+
`screen (x${ratio.toFixed(3)} vs x${ratioH.toFixed(3)}) — is the capture device a different display?`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
if (Math.abs(ratio - geo.dpr) > geo.dpr * 0.05) {
|
|
513
|
+
problems.push(
|
|
514
|
+
`capture scale x${ratio.toFixed(3)} does not match the window's devicePixelRatio ` +
|
|
515
|
+
`${geo.dpr} — the browser window may sit on a different display than the one being captured`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
if (
|
|
519
|
+
Math.abs(rawCw - logicalW * ratio) > tolX ||
|
|
520
|
+
Math.abs(rawCh - logicalH * ratio) > tolY
|
|
521
|
+
) {
|
|
522
|
+
problems.push(
|
|
523
|
+
`crop ${rawCw}x${rawCh} does not match the storyboard viewport ${logicalW}x${logicalH} ` +
|
|
524
|
+
`at capture scale x${ratio.toFixed(3)} (expected ~${Math.round(logicalW * ratio)}x${Math.round(
|
|
525
|
+
logicalH * ratio
|
|
526
|
+
)})`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
if (
|
|
530
|
+
rawCx < -tolX ||
|
|
531
|
+
rawCy < -tolY ||
|
|
532
|
+
rawCx + rawCw > capW + tolX ||
|
|
533
|
+
rawCy + rawCh > capH + tolY
|
|
534
|
+
) {
|
|
535
|
+
problems.push(
|
|
536
|
+
`crop ${rawCw}x${rawCh}+${rawCx}+${rawCy} falls outside the ${capW}x${capH} capture`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
gateGeometryProblems(
|
|
540
|
+
problems,
|
|
541
|
+
`Refusing to crop the screen capture: the crop rectangle does not match the ` +
|
|
542
|
+
`storyboard viewport. The un-cropped full-screen capture was kept at ${src} — ` +
|
|
543
|
+
`review or delete it (it may show your whole desktop).`
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
const cw = Math.min(rawCw, capW);
|
|
547
|
+
const ch = Math.min(rawCh, capH);
|
|
548
|
+
const cx = Math.min(Math.max(0, rawCx), capW - cw);
|
|
549
|
+
const cy = Math.min(Math.max(0, rawCy), capH - ch);
|
|
550
|
+
const outScale = cw >= logicalW * 1.9 ? 2 : 1;
|
|
551
|
+
log(
|
|
552
|
+
`capture ${capW}x${capH} → crop ${cw}x${ch}+${cx}+${cy} → ` +
|
|
553
|
+
`${logicalW * outScale}x${logicalH * outScale}`
|
|
554
|
+
);
|
|
555
|
+
await runFfmpeg([
|
|
556
|
+
"-i",
|
|
557
|
+
src,
|
|
558
|
+
"-vf",
|
|
559
|
+
`crop=${cw}:${ch}:${cx}:${cy},` +
|
|
560
|
+
`scale=${logicalW * outScale}:${logicalH * outScale}:flags=lanczos,setsar=1`,
|
|
561
|
+
"-an",
|
|
562
|
+
"-r",
|
|
563
|
+
String(fps),
|
|
564
|
+
"-c:v",
|
|
565
|
+
"libx264",
|
|
566
|
+
"-preset",
|
|
567
|
+
"veryfast",
|
|
568
|
+
"-crf",
|
|
569
|
+
"18",
|
|
570
|
+
"-pix_fmt",
|
|
571
|
+
"yuv420p",
|
|
572
|
+
dst,
|
|
573
|
+
]);
|
|
574
|
+
}
|
package/src/cards.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
import type { Card } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rasterizes an intro/outro title card to a PNG with headless Chrome — same
|
|
6
|
+
* trick as the caption renderer (this ffmpeg build has no drawtext, and CSS
|
|
7
|
+
* typography beats it anyway). The page is laid out at the storyboard's
|
|
8
|
+
* logical size and screenshotted at `scale` (the raw video's pixel density),
|
|
9
|
+
* so the PNG always matches the video frame 1:1.
|
|
10
|
+
*/
|
|
11
|
+
export async function renderCardPng(
|
|
12
|
+
card: Card,
|
|
13
|
+
outPath: string,
|
|
14
|
+
logicalW: number,
|
|
15
|
+
logicalH: number,
|
|
16
|
+
scale: number
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
|
19
|
+
const context = await browser.newContext({
|
|
20
|
+
viewport: { width: logicalW, height: logicalH },
|
|
21
|
+
deviceScaleFactor: scale,
|
|
22
|
+
});
|
|
23
|
+
const page = await context.newPage();
|
|
24
|
+
try {
|
|
25
|
+
await page.setContent(html(card, logicalW), { waitUntil: "load" });
|
|
26
|
+
await page.screenshot({ path: outPath });
|
|
27
|
+
} finally {
|
|
28
|
+
await browser.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function esc(text: string): string {
|
|
33
|
+
return text
|
|
34
|
+
.replace(/&/g, "&")
|
|
35
|
+
.replace(/</g, "<")
|
|
36
|
+
.replace(/>/g, ">");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function html(card: Card, logicalW: number): string {
|
|
40
|
+
const background =
|
|
41
|
+
card.background ??
|
|
42
|
+
"radial-gradient(120% 140% at 20% 0%, #182036 0%, #0b0d16 55%, #07080f 100%)";
|
|
43
|
+
const accent = card.accent ?? "#6c8cff";
|
|
44
|
+
const titleSize = Math.round(logicalW / 17);
|
|
45
|
+
const subSize = Math.round(logicalW / 40);
|
|
46
|
+
const subtitle = card.subtitle
|
|
47
|
+
? `<div class="sub">${esc(card.subtitle)}</div>`
|
|
48
|
+
: "";
|
|
49
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
50
|
+
html,body{margin:0;padding:0;width:100%;height:100%;}
|
|
51
|
+
body{background:${background};display:flex;align-items:center;justify-content:center;}
|
|
52
|
+
.box{max-width:78%;text-align:center;
|
|
53
|
+
font-family:-apple-system,"Helvetica Neue",Arial,sans-serif;
|
|
54
|
+
-webkit-font-smoothing:antialiased;}
|
|
55
|
+
.rule{width:${Math.round(logicalW / 22)}px;height:5px;border-radius:3px;
|
|
56
|
+
background:${accent};margin:0 auto ${Math.round(subSize * 1.2)}px;}
|
|
57
|
+
.title{font-size:${titleSize}px;line-height:1.12;font-weight:750;
|
|
58
|
+
letter-spacing:-0.015em;color:#f4f6fb;
|
|
59
|
+
text-shadow:0 2px 24px rgba(0,0,0,.45);}
|
|
60
|
+
.sub{margin-top:${Math.round(subSize * 0.9)}px;font-size:${subSize}px;
|
|
61
|
+
line-height:1.4;font-weight:450;color:rgba(226,231,244,.72);}
|
|
62
|
+
</style></head><body><div class="box">
|
|
63
|
+
<div class="rule"></div>
|
|
64
|
+
<div class="title">${esc(card.title)}</div>
|
|
65
|
+
${subtitle}
|
|
66
|
+
</div></body></html>`;
|
|
67
|
+
}
|