@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/config.ts ADDED
@@ -0,0 +1,222 @@
1
+ import { promises as fs, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ /**
6
+ * Engine install root = parent of src/. When the engine is run from its own
7
+ * repo this is the repo root; when it's fetched via `npx github:.../aidemo`
8
+ * it's the temp package dir. Either way, package.json / bin / .claude live here.
9
+ */
10
+ export const ENGINE_ROOT = resolve(new URL("..", import.meta.url).pathname);
11
+ /** Back-compat alias — historically the engine only ran from its own checkout. */
12
+ export const REPO_ROOT = ENGINE_ROOT;
13
+
14
+ /** The engine's own version, read from its bundled package.json (single source). */
15
+ export function engineVersion(): string {
16
+ try {
17
+ const pkg = JSON.parse(readFileSync(resolve(ENGINE_ROOT, "package.json"), "utf8"));
18
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
19
+ } catch {
20
+ return "0.0.0";
21
+ }
22
+ }
23
+
24
+ /** Parse one KEY=VALUE .env file into a plain object. No process.env side effects here —
25
+ * see loadEnv() below for why the two files are read fully before anything is assigned. */
26
+ async function readEnvFile(envPath: string): Promise<Record<string, string>> {
27
+ const values: Record<string, string> = {};
28
+ let raw: string;
29
+ try {
30
+ raw = await fs.readFile(envPath, "utf8");
31
+ } catch {
32
+ return values; // no file — nothing to load
33
+ }
34
+ for (const line of raw.split("\n")) {
35
+ const trimmed = line.trim();
36
+ if (!trimmed || trimmed.startsWith("#")) continue;
37
+ const eq = trimmed.indexOf("=");
38
+ if (eq === -1) continue;
39
+ const key = trimmed.slice(0, eq).trim();
40
+ let val = trimmed.slice(eq + 1).trim();
41
+ if (
42
+ (val.startsWith('"') && val.endsWith('"')) ||
43
+ (val.startsWith("'") && val.endsWith("'"))
44
+ ) {
45
+ val = val.slice(1, -1);
46
+ }
47
+ values[key] = val;
48
+ }
49
+ return values;
50
+ }
51
+
52
+ /**
53
+ * Load .env for the CLI. Reads the *consumer's* cwd/.env (so a repo that installed
54
+ * the skill can keep its own OpenAI key next to its demos) and the engine's own
55
+ * .env, then merges with cwd winning over the engine dir for any key both define.
56
+ *
57
+ * CRITICAL: a value found in EITHER .env file always OVERRIDES whatever is already
58
+ * in process.env — e.g. an OPENAI_API_KEY exported globally in a shell profile
59
+ * (~/.zshrc), possibly shared across several unrelated repos/projects. This is
60
+ * deliberate. The previous version of this function only assigned a var when it
61
+ * was not yet present in process.env at all, which is the exact "ambient wins"
62
+ * default that Node's `dotenv.config()` and Python's `load_dotenv()` also ship
63
+ * with — it LOOKS like a safe merge but actually means a repo's own .env is
64
+ * silently ignored whenever the key already happens to be set in the process
65
+ * environment, and the spend goes to whatever key that ambient value belongs to
66
+ * instead. See scripts/guard-openai-key.ts, which fails the build if this
67
+ * skip-if-already-set shape ever comes back. Ambient only wins here when
68
+ * NEITHER .env file defines the key at all (e.g. a deliberate one-off invocation
69
+ * like `OPENAI_API_KEY=sk-... aidemo record ...`). Called once at CLI startup.
70
+ */
71
+ export async function loadEnv(): Promise<void> {
72
+ const cwdEnv = resolve(process.cwd(), ".env");
73
+ const engineEnv = resolve(ENGINE_ROOT, ".env");
74
+ const engineValues = await readEnvFile(engineEnv);
75
+ const cwdValues = cwdEnv === engineEnv ? {} : await readEnvFile(cwdEnv);
76
+ const merged = { ...engineValues, ...cwdValues }; // cwd wins per-key over the engine dir
77
+ for (const [key, val] of Object.entries(merged)) {
78
+ process.env[key] = val;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Custom endpoint for any OpenAI-compatible server (LocalAI, speaches,
84
+ * Kokoro-FastAPI, …). AIDEMO_OPENAI_BASE_URL wins over the SDK-standard
85
+ * OPENAI_BASE_URL; unset → api.openai.com.
86
+ */
87
+ export const openAiBaseUrl = () =>
88
+ process.env.AIDEMO_OPENAI_BASE_URL || process.env.OPENAI_BASE_URL || undefined;
89
+
90
+ export function requireOpenAiKey(): string {
91
+ const key = process.env.OPENAI_API_KEY;
92
+ if (key) return key;
93
+ // Local OpenAI-compatible servers rarely check auth; a placeholder key keeps
94
+ // the SDK happy without demanding a real one.
95
+ if (openAiBaseUrl()) return "sk-aidemo-local";
96
+ throw new Error(
97
+ "OPENAI_API_KEY is not set. Copy .env.example to .env and add your key, " +
98
+ "or point OPENAI_BASE_URL at a local OpenAI-compatible server."
99
+ );
100
+ }
101
+
102
+ export const TTS_MODEL = () => process.env.AIDEMO_TTS_MODEL || "gpt-4o-mini-tts";
103
+ export const STT_MODEL = () => process.env.AIDEMO_STT_MODEL || "whisper-1";
104
+
105
+ /**
106
+ * TTS backend for `aidemo voice`. Unset → OpenAI (or whatever OPENAI_BASE_URL
107
+ * points at), exactly as before this knob existed. Captions/STT are unaffected —
108
+ * they always use the OpenAI-compatible endpoint (but see captionsAutoOffline).
109
+ */
110
+ export const ttsProvider = (): "openai" | "elevenlabs" | "local" => {
111
+ const p = process.env.AIDEMO_TTS_PROVIDER || "openai";
112
+ if (p !== "openai" && p !== "elevenlabs" && p !== "local") {
113
+ throw new Error(
114
+ `AIDEMO_TTS_PROVIDER="${p}" is not a known provider — use "openai" (default), "elevenlabs" or "local".`
115
+ );
116
+ }
117
+ return p;
118
+ };
119
+
120
+ /** The one model the in-process local provider runs — same weights speaches serves. */
121
+ export const KOKORO_MODEL_ID = "onnx-community/Kokoro-82M-v1.0-ONNX";
122
+
123
+ const KOKORO_DTYPES = ["fp32", "fp16", "q8", "q4", "q4f16"] as const;
124
+ export type KokoroDtype = (typeof KOKORO_DTYPES)[number];
125
+ /** Quantization for the local model: q8 ≈ 90 MB (default), fp32 ≈ 330 MB (best). */
126
+ export const kokoroDtype = (): KokoroDtype => {
127
+ const d = process.env.AIDEMO_TTS_MODEL_DTYPE || "q8";
128
+ if (!KOKORO_DTYPES.includes(d as KokoroDtype)) {
129
+ throw new Error(
130
+ `AIDEMO_TTS_MODEL_DTYPE="${d}" is not a Kokoro quantization — use one of: ${KOKORO_DTYPES.join(", ")}.`
131
+ );
132
+ }
133
+ return d as KokoroDtype;
134
+ };
135
+
136
+ /**
137
+ * Voice used when the storyboard keeps the schema default ("marin", an OpenAI
138
+ * name Kokoro doesn't know). Any explicitly authored voiceId is passed to the
139
+ * local model verbatim. Same convention as ELEVENLABS_VOICE.
140
+ */
141
+ export const KOKORO_VOICE = () => process.env.AIDEMO_KOKORO_VOICE || "af_heart";
142
+
143
+ /**
144
+ * Where the local provider caches the downloaded model. Defaults inside the
145
+ * HuggingFace home (HF_HOME or ~/.cache/huggingface) so it survives
146
+ * node_modules reinstalls and a pre-seeded copy makes installs air-gapped.
147
+ */
148
+ export const ttsModelCacheDir = () =>
149
+ process.env.AIDEMO_TTS_MODEL_CACHE ||
150
+ resolve(
151
+ process.env.HF_HOME || resolve(homedir(), ".cache", "huggingface"),
152
+ "transformersjs"
153
+ );
154
+
155
+ /**
156
+ * `render` auto-routes captions through the offline (script-timed) derivation
157
+ * when the voice is generated locally and the STT call would fail anyway —
158
+ * local voice shouldn't demand a cloud key just for caption timing.
159
+ */
160
+ export const captionsAutoOffline = () =>
161
+ ttsProvider() === "local" && !openAiBaseUrl() && !process.env.OPENAI_API_KEY;
162
+
163
+ export function requireElevenLabsKey(): string {
164
+ const key = process.env.ELEVENLABS_API_KEY;
165
+ if (key) return key;
166
+ throw new Error(
167
+ "ELEVENLABS_API_KEY is not set but AIDEMO_TTS_PROVIDER=elevenlabs. Add the key " +
168
+ "to .env, or unset AIDEMO_TTS_PROVIDER to use the default OpenAI TTS."
169
+ );
170
+ }
171
+
172
+ export const ELEVENLABS_MODEL = () =>
173
+ process.env.AIDEMO_ELEVENLABS_MODEL || "eleven_multilingual_v2";
174
+ /**
175
+ * Voice used when the storyboard keeps the schema default ("marin", an OpenAI
176
+ * name that means nothing to ElevenLabs). Any explicitly authored voiceId is
177
+ * passed to ElevenLabs verbatim instead. Default: Rachel.
178
+ */
179
+ export const ELEVENLABS_VOICE = () =>
180
+ process.env.AIDEMO_ELEVENLABS_VOICE || "21m00Tcm4TlvDq8ikWAM";
181
+
182
+ /**
183
+ * LLM-only servers (Ollama, plain llama.cpp/vLLM) speak the OpenAI chat
184
+ * protocol but 404 the audio routes — the only routes this engine calls.
185
+ * Rethrow that specific failure with what's wrong and what to run instead.
186
+ */
187
+ export function explainAudioEndpointError(err: unknown, kind: "tts" | "stt"): never {
188
+ const base = openAiBaseUrl();
189
+ const status = (err as { status?: number } | null)?.status;
190
+ if (base && (status === 404 || status === 405)) {
191
+ const endpoint =
192
+ kind === "tts" ? "POST /v1/audio/speech (TTS)" : "POST /v1/audio/transcriptions (STT)";
193
+ throw new Error(
194
+ `${base} doesn't implement ${endpoint}. LLM-only servers like Ollama or plain ` +
195
+ `llama.cpp/vLLM have no OpenAI audio endpoints — point OPENAI_BASE_URL at a ` +
196
+ `speech server such as speaches instead (README → "Local models & offline")` +
197
+ (kind === "stt" ? `, or skip transcription: aidemo captions <dir> --offline` : "") +
198
+ `. Original error: ${(err as Error).message}`
199
+ );
200
+ }
201
+ throw err;
202
+ }
203
+
204
+ /** Dedicated Chrome profile handed to Playwright (logged into ChatGPT). */
205
+ export const chromeProfileDir = () =>
206
+ process.env.AIDEMO_CHROME_PROFILE || resolve(REPO_ROOT, "chrome-profile");
207
+
208
+ /** Default capture path: playwright | native | obs (see src/capture.ts). */
209
+ export const defaultCaptureMode = () => process.env.AIDEMO_CAPTURE || "playwright";
210
+ /** avfoundation input name for native capture. List devices with:
211
+ * ffmpeg -f avfoundation -list_devices true -i "" */
212
+ export const captureDevice = () =>
213
+ process.env.AIDEMO_CAPTURE_DEVICE || "Capture screen 0";
214
+ export const obsUrl = () => process.env.AIDEMO_OBS_URL || "ws://127.0.0.1:4455";
215
+ export const obsPassword = () => process.env.AIDEMO_OBS_PASSWORD;
216
+ /**
217
+ * Escape hatch for the native/OBS capture crop sanity gate: "1" downgrades
218
+ * the abort-on-geometry-mismatch to a loud warning. A wrong crop can ship the
219
+ * whole desktop into the video — frame-review any take recorded with this set.
220
+ */
221
+ export const nativeCropUnsafe = () =>
222
+ process.env.AIDEMO_NATIVE_CROP_UNSAFE === "1";
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Compose-time cursor overlay. The default pipeline BAKES the cursor into the
3
+ * recording (src/cursor.ts). When a storyboard opts into `cursor` control, the
4
+ * take is recorded cursor-free and the player logs the cursor path
5
+ * (timeline `cursorSamples`); compose then draws the cursor as an overlay along
6
+ * that path so hide/resize is a recompose, never a re-record.
7
+ *
8
+ * The path is replayed with the same piecewise-expression trick as the auto-zoom
9
+ * (src/zoom.ts): overlay the cursor PNG at x='X(t)':y='Y(t)', where X/Y are
10
+ * piecewise-LINEAR over the recorded samples (the samples are already eased, so
11
+ * linear interpolation between them preserves the glide). Portable — `overlay`
12
+ * is the same baseline filter the caption strip uses. Applied to the content
13
+ * BEFORE the zoom pass, so the cursor zooms and pans with the frame, exactly
14
+ * like the baked one.
15
+ */
16
+
17
+ /** A cursor sample already mapped into final content time + output pixels. */
18
+ export interface CursorPoint {
19
+ /** Content-time seconds. */
20
+ t: number;
21
+ /** Output pixels (viewport CSS px * pxScale). */
22
+ x: number;
23
+ y: number;
24
+ }
25
+
26
+ /** A content-time span (seconds) during which the cursor is hidden. */
27
+ export interface HideWindow {
28
+ a: number;
29
+ b: number;
30
+ }
31
+
32
+ /**
33
+ * Per-axis key budget. ffmpeg's expression evaluator allocates an AST and blows
34
+ * up (ENOMEM) somewhere past ~100 flat terms — measured ~80 OK / 120 fail on a
35
+ * stock build. X(t) and Y(t) are INDEPENDENT overlay expressions, so this budget
36
+ * is per axis; keeping it well under the cliff (and small) also protects weaker
37
+ * ffmpeg builds. The path is simplified to fit (simplifyAxis) — cheap, because
38
+ * the player's glides are eased S-curves that RDP collapses to a few keys each.
39
+ */
40
+ const KEY_BUDGET = 48;
41
+
42
+ interface Key {
43
+ t: number;
44
+ v: number;
45
+ }
46
+
47
+ function num(n: number): string {
48
+ const s = n.toFixed(3).replace(/\.?0+$/, "");
49
+ return s === "" || s === "-" ? "0" : s;
50
+ }
51
+
52
+ /** Linear-interpolation (vertical) error at `p` against the a→b chord. */
53
+ function chordError(a: Key, b: Key, p: Key): number {
54
+ const dt = b.t - a.t;
55
+ const vAt = dt < 1e-9 ? a.v : a.v + ((b.v - a.v) * (p.t - a.t)) / dt;
56
+ return Math.abs(p.v - vAt);
57
+ }
58
+
59
+ /**
60
+ * Ramer–Douglas–Peucker on a value-over-time polyline, using vertical (linear-
61
+ * interpolation) error in the value's own units (pixels) — so `eps` is exactly
62
+ * "max pixels the drawn cursor may drift from the recorded path". Keeps the
63
+ * points where the curve actually bends; a straight or held stretch collapses to
64
+ * its endpoints. Iterative to avoid deep recursion on long inputs.
65
+ */
66
+ function rdp(keys: Key[], eps: number): Key[] {
67
+ const n = keys.length;
68
+ if (n <= 2) return keys.slice();
69
+ const keep = new Uint8Array(n);
70
+ keep[0] = 1;
71
+ keep[n - 1] = 1;
72
+ const stack: Array<[number, number]> = [[0, n - 1]];
73
+ while (stack.length) {
74
+ const [lo, hi] = stack.pop()!;
75
+ if (hi - lo < 2) continue;
76
+ let maxErr = -1;
77
+ let idx = -1;
78
+ for (let i = lo + 1; i < hi; i++) {
79
+ const e = chordError(keys[lo], keys[hi], keys[i]);
80
+ if (e > maxErr) {
81
+ maxErr = e;
82
+ idx = i;
83
+ }
84
+ }
85
+ if (maxErr > eps && idx >= 0) {
86
+ keep[idx] = 1;
87
+ stack.push([lo, idx], [idx, hi]);
88
+ }
89
+ }
90
+ const out: Key[] = [];
91
+ for (let i = 0; i < n; i++) if (keep[i]) out.push(keys[i]);
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Simplify one axis to at most KEY_BUDGET keys: start at a sub-pixel epsilon and
97
+ * grow it until the point count fits, so a busy path just drifts a pixel or two
98
+ * more rather than blowing the expression budget.
99
+ */
100
+ function simplifyAxis(keys: Key[]): Key[] {
101
+ if (keys.length <= KEY_BUDGET) {
102
+ const r = rdp(keys, 1.2);
103
+ if (r.length <= KEY_BUDGET) return r;
104
+ }
105
+ let eps = 1.2;
106
+ for (let i = 0; i < 24; i++) {
107
+ const r = rdp(keys, eps);
108
+ if (r.length <= KEY_BUDGET) return r;
109
+ eps *= 1.6;
110
+ }
111
+ // Backstop: uniform thin (should never be reached for a real cursor path).
112
+ const step = Math.ceil(keys.length / KEY_BUDGET);
113
+ const out = keys.filter((_, i) => i % step === 0);
114
+ if (out[out.length - 1] !== keys[keys.length - 1]) out.push(keys[keys.length - 1]);
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * Piecewise-linear expression over overlay's `t` (seconds): holds the first
120
+ * value before the first key, linearly interpolates between keys, holds the last
121
+ * value after the last.
122
+ *
123
+ * Emitted as a FLAT sum of half-open gated segments — NOT nested `if`s. A path
124
+ * has dozens–hundreds of keys, and ffmpeg's expression parser blows its nesting
125
+ * limit ("Missing ')' or too many args") a hundred `if`s deep (zoom.ts gets away
126
+ * with nested `if` only because it has a handful of keys). Each segment
127
+ * [t_i, t_{i+1}) contributes `gte(t,t_i)*lt(t,t_{i+1})*(v_i + slope*(t-t_i))`;
128
+ * the gates are disjoint half-open intervals so exactly one term is live at a
129
+ * time. Depth stays ~3 regardless of key count.
130
+ */
131
+ function piecewiseLinear(keys: Key[]): string {
132
+ if (keys.length === 1) return num(keys[0].v);
133
+ const first = keys[0];
134
+ const last = keys[keys.length - 1];
135
+ const terms: string[] = [`lt(t,${num(first.t)})*${num(first.v)}`];
136
+ for (let i = 0; i < keys.length - 1; i++) {
137
+ const a = keys[i];
138
+ const b = keys[i + 1];
139
+ const dt = b.t - a.t;
140
+ const gate = `gte(t,${num(a.t)})*lt(t,${num(b.t)})`;
141
+ if (a.v === b.v || dt < 1e-6) {
142
+ terms.push(`${gate}*${num(a.v)}`);
143
+ } else {
144
+ const slope = (b.v - a.v) / dt;
145
+ terms.push(`${gate}*(${num(a.v)}+(${num(slope)})*(t-${num(a.t)}))`);
146
+ }
147
+ }
148
+ terms.push(`gte(t,${num(last.t)})*${num(last.v)}`);
149
+ return terms.join("+");
150
+ }
151
+
152
+ /**
153
+ * Build the `-filter_complex` string overlaying the cursor PNG (input 1) onto
154
+ * the content (input 0) along `rawPts`, hidden during `hideWindows`. Returns
155
+ * null when there's nothing to draw (no samples). The PNG's top-left tracks the
156
+ * point — the same anchor the baked cursor used (translate(x,y)), so the arrow
157
+ * tip lands identically.
158
+ */
159
+ export function buildCursorFilter(
160
+ rawPts: CursorPoint[],
161
+ hideWindows: HideWindow[] = []
162
+ ): string | null {
163
+ if (rawPts.length === 0) return null;
164
+ const sorted = [...rawPts].sort((p, q) => p.t - q.t);
165
+ // Strictly-increasing time so each segment has a positive span.
166
+ const mono: CursorPoint[] = [];
167
+ for (const p of sorted) {
168
+ const last = mono[mono.length - 1];
169
+ mono.push(last && p.t <= last.t ? { ...p, t: last.t + 0.001 } : p);
170
+ }
171
+ // Simplify each axis independently (X and Y are separate expressions) so each
172
+ // stays under the ffmpeg expression budget.
173
+ const X = piecewiseLinear(simplifyAxis(mono.map((p) => ({ t: p.t, v: p.x }))));
174
+ const Y = piecewiseLinear(simplifyAxis(mono.map((p) => ({ t: p.t, v: p.y }))));
175
+
176
+ // enable is single-quoted so the commas inside between()/not() are literal,
177
+ // not filtergraph separators (same reason zoom single-quotes its expressions).
178
+ let enable = "";
179
+ if (hideWindows.length) {
180
+ const terms = hideWindows
181
+ .map((w) => `between(t,${num(w.a)},${num(w.b)})`)
182
+ .join("+");
183
+ enable = `:enable='not(${terms})'`;
184
+ }
185
+ return `[0:v][1:v]overlay=x='${X}':y='${Y}':eval=frame${enable}[vout]`;
186
+ }
package/src/cursor.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { chromium } from "playwright";
2
+
3
+ /**
4
+ * The classic arrow pointer, shared by both cursor render paths so they look
5
+ * identical: the record-time BAKED cursor (cursorInitScript, injected into the
6
+ * page) and the compose-time OVERLAY cursor (renderCursorPng, rasterized and
7
+ * overlaid by compose when a storyboard opts into `cursor` control). 24x24
8
+ * viewBox; the visual tip sits near (5,2). Fill white, dark stroke, soft shadow.
9
+ */
10
+ export const CURSOR_ARROW_PATH = "M5 2 L5 20 L10 15 L13 22 L16 21 L13 14 L20 14 Z";
11
+ const CURSOR_FILL = "#ffffff";
12
+ const CURSOR_STROKE = "#111111";
13
+
14
+ /**
15
+ * Rasterize the arrow pointer to a transparent PNG for the compose-time cursor
16
+ * overlay (see src/cursor-overlay.ts). Same trick as the card/caption renderers:
17
+ * headless Chrome screenshots an SVG so the overlay pointer matches the baked
18
+ * one. The arrow is drawn in the top-left of a padded canvas (room for the drop
19
+ * shadow) with its tip at ~(5,2) — the same anchor as the baked cursor, so the
20
+ * overlay lands where the baked cursor would have. `sizePx` is the rendered
21
+ * width/height in output pixels (24 logical units * pxScale * cursor scale).
22
+ */
23
+ export async function renderCursorPng(
24
+ outPath: string,
25
+ sizePx: number
26
+ ): Promise<void> {
27
+ // Logical canvas is 32 units (24 arrow + shadow headroom); scale to sizePx.
28
+ const LOGICAL = 32;
29
+ const dsf = Math.max(1, sizePx / LOGICAL);
30
+ const browser = await chromium.launch({ channel: "chrome", headless: true });
31
+ const context = await browser.newContext({
32
+ viewport: { width: LOGICAL, height: LOGICAL },
33
+ deviceScaleFactor: dsf,
34
+ });
35
+ const page = await context.newPage();
36
+ try {
37
+ await page.setContent(
38
+ `<!doctype html><html><head><meta charset="utf-8"><style>
39
+ html,body{margin:0;padding:0;background:transparent;}
40
+ svg{position:absolute;left:0;top:0;
41
+ filter:drop-shadow(0 1px 2px rgba(0,0,0,.45));}
42
+ </style></head><body>
43
+ <svg width="${LOGICAL}" height="${LOGICAL}" viewBox="0 0 ${LOGICAL} ${LOGICAL}"
44
+ xmlns="http://www.w3.org/2000/svg">
45
+ <path d="${CURSOR_ARROW_PATH}" fill="${CURSOR_FILL}"
46
+ stroke="${CURSOR_STROKE}" stroke-width="1.3" stroke-linejoin="round"/>
47
+ </svg>
48
+ </body></html>`,
49
+ { waitUntil: "load" }
50
+ );
51
+ await page.screenshot({ path: outPath, omitBackground: true });
52
+ } finally {
53
+ await browser.close();
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Injected into every frame (main page + widget iframes) via addInitScript.
59
+ * Playwright's built-in video does not capture the OS cursor and its mouse moves
60
+ * teleport, so we render our own cursor: an SVG pointer that follows mousemove
61
+ * events (which our eased player emits as many small steps) and pulses a ripple
62
+ * on mousedown. Each frame renders its own cursor; only the one under the
63
+ * pointer is visible at a time, so there is no double-cursor.
64
+ *
65
+ * This is the DEFAULT (baked) path. A storyboard with a `cursor` block skips
66
+ * this injection and draws the cursor at compose time instead (renderCursorPng
67
+ * + src/cursor-overlay.ts), so hide/resize is a recompose, not a re-record.
68
+ */
69
+ export function cursorInitScript(): string {
70
+ // Serialized as a string so it runs in the page context with no closure deps.
71
+ return `(() => {
72
+ if (window.__aidemoCursor) return;
73
+ window.__aidemoCursor = true;
74
+
75
+ const NS = 'http://www.w3.org/2000/svg';
76
+ const style = document.createElement('style');
77
+ style.textContent = \`
78
+ .__aidemo-cursor{position:fixed;left:0;top:0;width:24px;height:24px;
79
+ z-index:2147483647;pointer-events:none;opacity:0;
80
+ transition:opacity .15s ease;will-change:transform;
81
+ filter:drop-shadow(0 1px 2px rgba(0,0,0,.45));}
82
+ .__aidemo-ripple{position:fixed;left:0;top:0;width:14px;height:14px;
83
+ margin:-7px 0 0 -7px;border-radius:50%;
84
+ background:rgba(56,132,255,.45);z-index:2147483646;pointer-events:none;
85
+ transform:scale(.2);opacity:.9;}
86
+ @keyframes __aidemo-ripple-anim{
87
+ from{transform:scale(.2);opacity:.9}
88
+ to{transform:scale(2.6);opacity:0}}
89
+ \`;
90
+ const mount = () => {
91
+ if (!document.documentElement) return;
92
+ document.documentElement.appendChild(style);
93
+
94
+ const svg = document.createElementNS(NS, 'svg');
95
+ svg.setAttribute('class', '__aidemo-cursor');
96
+ svg.setAttribute('viewBox', '0 0 24 24');
97
+ const path = document.createElementNS(NS, 'path');
98
+ // Classic arrow pointer (shared with the compose-time overlay).
99
+ path.setAttribute('d', '${CURSOR_ARROW_PATH}');
100
+ path.setAttribute('fill', '${CURSOR_FILL}');
101
+ path.setAttribute('stroke', '${CURSOR_STROKE}');
102
+ path.setAttribute('stroke-width', '1.3');
103
+ path.setAttribute('stroke-linejoin', 'round');
104
+ svg.appendChild(path);
105
+ document.documentElement.appendChild(svg);
106
+
107
+ let x = window.innerWidth / 2, y = window.innerHeight / 2;
108
+ const place = () => { svg.style.transform = 'translate(' + x + 'px,' + y + 'px)'; };
109
+ place();
110
+
111
+ window.addEventListener('mousemove', (e) => {
112
+ x = e.clientX; y = e.clientY;
113
+ svg.style.opacity = '1';
114
+ place();
115
+ }, true);
116
+
117
+ window.addEventListener('mousedown', (e) => {
118
+ const r = document.createElement('div');
119
+ r.className = '__aidemo-ripple';
120
+ r.style.transform = 'translate(' + e.clientX + 'px,' + e.clientY + 'px) scale(.2)';
121
+ r.style.animation = '__aidemo-ripple-anim .5s ease-out forwards';
122
+ // account for the -7px margin by translating to the point
123
+ r.style.left = e.clientX + 'px';
124
+ r.style.top = e.clientY + 'px';
125
+ r.style.transform = 'scale(.2)';
126
+ document.documentElement.appendChild(r);
127
+ setTimeout(() => r.remove(), 550);
128
+ }, true);
129
+ };
130
+
131
+ if (document.documentElement) mount();
132
+ else document.addEventListener('DOMContentLoaded', mount);
133
+ })();`;
134
+ }