@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/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, "&amp;")
35
+ .replace(/</g, "&lt;")
36
+ .replace(/>/g, "&gt;");
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
+ }