demobite 0.0.1

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.
@@ -0,0 +1,706 @@
1
+ #!/usr/bin/env node
2
+ // Agentic Recorder — the take.
3
+ // Storyboard-driven Playwright filming: synthetic cursor drawn into the page,
4
+ // click pulses, smooth scroll, native 1920x1080 video recording, an internal
5
+ // manifest.json with real timings for every step, and a CAMERA PATH of focus
6
+ // rectangles measured off the live page.
7
+ //
8
+ // Usage: node record.mjs <takeDir> <storyboard.json>
9
+ //
10
+ // Outputs into <takeDir>: raw.webm + manifest.json (internal schema).
11
+ // The founder-proven laws live in the comments below. Do not simplify them away.
12
+ import { chromium } from "playwright";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ const [, , outArg, storyArg] = process.argv;
17
+ if (!outArg || !storyArg) {
18
+ console.error("Usage: node record.mjs <takeDir> <storyboard.json>");
19
+ process.exit(2);
20
+ }
21
+
22
+ const STORYBOARD = JSON.parse(fs.readFileSync(storyArg, "utf8"));
23
+ const ACTIONS = new Set(["goto", "settle", "scroll", "click", "hover"]);
24
+ if (!Array.isArray(STORYBOARD.steps) || STORYBOARD.steps.length === 0) {
25
+ console.error("Storyboard has no steps.");
26
+ process.exit(2);
27
+ }
28
+ for (const [i, s] of STORYBOARD.steps.entries()) {
29
+ const at = `step ${i + 1}`;
30
+ if (!ACTIONS.has(s.action)) { console.error(`${at}: unknown action "${s.action}"`); process.exit(2); }
31
+ if (s.action === "goto" && !s.url) { console.error(`${at}: goto needs url`); process.exit(2); }
32
+ if (s.action === "scroll" && typeof s.dy !== "number") { console.error(`${at}: scroll needs numeric dy`); process.exit(2); }
33
+ if ((s.action === "click" || s.action === "hover") && !s.selector) { console.error(`${at}: ${s.action} needs selector`); process.exit(2); }
34
+ }
35
+
36
+ const DIR = path.resolve(outArg);
37
+ fs.mkdirSync(DIR, { recursive: true });
38
+
39
+ // LAW (supersampled capture, measured 2026-08-09): deviceScaleFactor is a
40
+ // dead end — Chromium's screencast delivers CSS-pixel viewport resolution
41
+ // regardless of DSF, and Playwright PADS a larger recordVideo request (a 4K
42
+ // ask yields 1080p content in the corner of a gray canvas). The trick that
43
+ // works: make the CSS viewport itself 3840x2160 and zoom the DOCUMENT 2x, so
44
+ // the page lays out as the 1920 design but paints real 4K pixels. Measured:
45
+ // full-bleed 4K, layout identical to the 1080p baseline (MAD 5.4), same
46
+ // effective fps (24.7), and 3.47x sharper text inside a 3x zoom.
47
+ //
48
+ // COORDINATE LAW: under CSS zoom, gBCR / mouse / scrollTo / elementFromPoint
49
+ // all live in the SAME zoomed space as the video pixels. So: drive the
50
+ // browser in zoomed coordinates untouched, and divide by SUPERSAMPLE exactly
51
+ // once — at the recording boundary (pushMouse / pushShot / target stamps) —
52
+ // so the manifest stays in the 1920x1080 design space the wire contract,
53
+ // the studio and the pipeline expect.
54
+ //
55
+ // Caveat (accepted): devicePixelRatio stays 1, so srcset raster photos paint
56
+ // 2x-upscaled. Text, CSS UI and SVG — the substance of app demos — are
57
+ // genuinely 4K.
58
+ // SUPERSAMPLE = 1 for now (2026-08-09): the CSS-zoom trick measured 3.47x
59
+ // sharper zooms on Wikipedia and then broke LinkedIn's Connect modal — vw
60
+ // units and JS innerWidth measurements bypass CSS zoom, so real apps lay out
61
+ // for the unzoomed viewport (content shoved half off-frame, dialog lost).
62
+ // Chromium offers no other door: deviceScaleFactor is ignored by the
63
+ // screencast, and even Page.startScreencast with maxWidth 3840 at DSF2
64
+ // delivers 1920x1080 — capture is architecturally clamped to CSS pixels.
65
+ // True 4K capture needs a headful browser on a virtual 4K display with
66
+ // display-level capture: a separate chapter. The coordinate plumbing below is
67
+ // kept so flipping this constant is the only change when it lands.
68
+ const SUPERSAMPLE = 1;
69
+ const DESIGN = { width: 1920, height: 1080 };
70
+ const VIEW = { width: DESIGN.width * SUPERSAMPLE, height: DESIGN.height * SUPERSAMPLE };
71
+ // Persistent camera-browser profile: the human's signed-in sessions live here.
72
+ // The auth checkpoint (SKILL.md) fills it; record only ever reads it.
73
+ const profileDir = path.resolve(".recorder", "profile");
74
+ fs.mkdirSync(profileDir, { recursive: true });
75
+
76
+ // LAW (the video is the metronome, founder 2026-08-08): shots are as long as
77
+ // the ACTION needs, never as long as a sentence. Narration is INTENT — the
78
+ // ingestion rescripts it and fits it to these anchors, exactly as it does for
79
+ // a customer's own uploaded voice. Holding a shot to cover an estimated line
80
+ // is what produced a 60-second take with the cursor parked for 12 seconds.
81
+ const DEFAULT_SETTLE_MS = 2200;
82
+ const DEFAULT_HOVER_DWELL = 3200;
83
+ const DEFAULT_CLICK_DWELL = 700;
84
+ const DEFAULT_CLICK_AFTER = 2000;
85
+ const CLOSING_BEAT_MS = 2200;
86
+
87
+ const narrationOf = (s) => {
88
+ if (s.narration == null) return null;
89
+ const text = typeof s.narration === "string" ? s.narration : s.narration.text;
90
+ return text ? { text } : null;
91
+ };
92
+
93
+ // LAW (cursor, founder 2026-08-09): the DemoBites ending does NOT burn a
94
+ // cursor into the pixels. It ships mouseEvents with real cursor TYPES and the
95
+ // studio renders the same macOS cursor the native recorder gets — crisp at any
96
+ // zoom (a burned-in 26px arrow becomes a 78px blur at 3x), restylable via the
97
+ // cursor_size / cursor_color preferences, and switching to a pointing hand
98
+ // over anything clickable.
99
+ //
100
+ // The STANDALONE ending has no studio to render it, so those storyboards must
101
+ // set "burnCursor": true.
102
+ const BURN_CURSOR = STORYBOARD.burnCursor === true;
103
+ /** How often the cursor position is sampled into mouseEvents, milliseconds. */
104
+ const CURSOR_SAMPLE_MS = 100;
105
+
106
+ /** Map a CSS cursor value onto the native recorder's cursor vocabulary
107
+ * (cursorSvgs.ts: arrow | pointingHand | iBeam | openHand | closedHand).
108
+ * This is the recorder's structural advantage: a native recorder SAMPLES
109
+ * whatever the OS cursor happened to be, while we ask the element itself. */
110
+ const cssCursorToNative = (css) => {
111
+ const v = String(css || "").trim().toLowerCase();
112
+ if (v === "pointer") return "pointingHand";
113
+ if (v === "text" || v === "vertical-text") return "iBeam";
114
+ if (v === "grab") return "openHand";
115
+ if (v === "grabbing") return "closedHand";
116
+ return "arrow";
117
+ };
118
+
119
+ // LAW (real browser, founder 2026-08-09): film with the REAL Google Chrome
120
+ // binary by default (channel 'chrome'), not bundled Chromium — real codecs,
121
+ // real update channel, and bot walls score the genuine product more kindly.
122
+ // This is NOT disguise: automation still declares itself and the profile is
123
+ // still the recorder's own. Storyboard "channel": "chromium" opts out;
124
+ // missing Chrome falls back to Chromium automatically.
125
+ const launchOpts = {
126
+ headless: STORYBOARD.headless !== false,
127
+ viewport: VIEW,
128
+ recordVideo: { dir: DIR, size: VIEW },
129
+ deviceScaleFactor: 1,
130
+ args: ["--force-color-profile=srgb", "--disable-blink-features=AutomationControlled"],
131
+ };
132
+ let ctx;
133
+ const wantChannel = STORYBOARD.channel ?? "chrome";
134
+ try {
135
+ ctx = await chromium.launchPersistentContext(
136
+ profileDir,
137
+ wantChannel === "chromium" ? launchOpts : { ...launchOpts, channel: wantChannel },
138
+ );
139
+ } catch (launchErr) {
140
+ if (wantChannel === "chromium") throw launchErr;
141
+ console.error(`Real Chrome (channel '${wantChannel}') failed to launch (${launchErr.message.split("\n")[0]}); falling back to bundled Chromium.`);
142
+ ctx = await chromium.launchPersistentContext(profileDir, launchOpts);
143
+ }
144
+ const page = ctx.pages()[0] || (await ctx.newPage());
145
+
146
+ // ── Presenter layer: synthetic cursor + click pulse, injected per document ──
147
+ const CURSOR_SVG = encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24"><path d="M5 2 L5 19 L9.5 15.5 L12.5 21.5 L15 20.2 L12.2 14.5 L18 14 Z" fill="#fff" stroke="#111" stroke-width="1.4" stroke-linejoin="round"/></svg>');
148
+ // LAW (Trusted Types, LinkedIn lesson): no innerHTML anywhere — strict CSP
149
+ // sites with Trusted Types reject string HTML. The arrow is a CSS background
150
+ // data-URI on a bare div, the pulse is a bare styled div.
151
+ const CURSOR_JS = `
152
+ (() => {
153
+ if (window.__recCursor && document.documentElement.contains(window.__recCursor)) return;
154
+ const c = document.createElement("div");
155
+ c.id = "__rec_cursor";
156
+ // LAW (top layer): native dropdowns/menus/dialogs live in the browser's
157
+ // top layer and beat ANY z-index. The cursor rides the top layer too via
158
+ // the Popover API, and re-shows itself on EVERY move so it stays above
159
+ // later top-layer arrivals.
160
+ c.setAttribute("popover", "manual");
161
+ c.style.cssText = "position:fixed;left:0;top:0;margin:0;padding:0;border:0;width:26px;height:26px;pointer-events:none;overflow:visible;background:url(\\"data:image/svg+xml,${CURSOR_SVG}\\") no-repeat center/contain;inset:auto";
162
+ document.documentElement.appendChild(c);
163
+ try { c.showPopover(); } catch {}
164
+ window.__recCursor = c;
165
+ window.__recSetCursor = (x, y) => {
166
+ c.style.left = x + "px"; c.style.top = y + "px";
167
+ try { c.hidePopover(); c.showPopover(); } catch {}
168
+ };
169
+ window.__recPulse = (x, y) => {
170
+ const p = document.createElement("div");
171
+ p.style.cssText = "position:fixed;z-index:2147483646;pointer-events:none;width:14px;height:14px;border-radius:999px;border:2.5px solid rgba(17,17,17,.85);background:rgba(255,255,255,.35);transform:translate(-50%,-50%) scale(.5);opacity:1;transition:transform .55s ease-out,opacity .55s ease-out;left:"+x+"px;top:"+y+"px";
172
+ document.documentElement.appendChild(p);
173
+ requestAnimationFrame(() => { p.style.transform = "translate(-50%,-50%) scale(3.4)"; p.style.opacity = "0"; });
174
+ setTimeout(() => p.remove(), 700);
175
+ };
176
+ return true;
177
+ })();`;
178
+ // The zoom style dies with each document — inject it as early as possible on
179
+ // every navigation (init script polls for documentElement), and re-assert it
180
+ // after load in case a framework rewrites the root style attribute.
181
+ const ZOOM_JS = `(() => {
182
+ const apply = () => {
183
+ if (document.documentElement) document.documentElement.style.zoom = "${SUPERSAMPLE}";
184
+ else requestAnimationFrame(apply);
185
+ };
186
+ apply();
187
+ })();`;
188
+ await ctx.addInitScript(ZOOM_JS);
189
+ const ensureZoom = async () => {
190
+ await page.evaluate(ZOOM_JS).catch(() => {});
191
+ };
192
+
193
+ if (BURN_CURSOR) await ctx.addInitScript(CURSOR_JS);
194
+ const ensureCursor = async () => {
195
+ if (!BURN_CURSOR) return;
196
+ const ok = await page
197
+ .evaluate(CURSOR_JS)
198
+ .then(() => page.evaluate(() => !!document.getElementById("__rec_cursor")))
199
+ .catch((e) => { console.error("cursor inject FAILED:", e.message); return false; });
200
+ if (!ok) console.error("WARNING: cursor not present after injection");
201
+ };
202
+ const applyHideCss = async () => {
203
+ await ensureZoom();
204
+ if (STORYBOARD.hideCss) await page.addStyleTag({ content: STORYBOARD.hideCss }).catch(() => {});
205
+ };
206
+
207
+ let cx = VIEW.width / 2, cy = VIEW.height / 3;
208
+ const T0 = Date.now();
209
+ const manifest = {
210
+ app: STORYBOARD.app ?? "App",
211
+ title: STORYBOARD.title ?? null,
212
+ url: STORYBOARD.url ?? null,
213
+ frame: DESIGN,
214
+ supersample: SUPERSAMPLE,
215
+ started_at: new Date(T0).toISOString(),
216
+ steps: [],
217
+ // LAW (the camera follows the subject): every shot is a rectangle MEASURED
218
+ // off the live page, never a click coordinate. The backend derives the zoom
219
+ // factor from the rectangle's size, so a degree badge reads near 3x and a
220
+ // dialog near 1.6x, and it chains the shots so the camera travels instead of
221
+ // pulling out between them.
222
+ shots: [],
223
+ // Native-recorder-shaped interaction data. The DemoBites hook writes this to
224
+ // interactions.json beside the bite video and flips cursor_enabled, which is
225
+ // exactly what makes an uploaded bite behave like a native recording.
226
+ interactions: { viewport: { width: VIEW.width, height: VIEW.height }, mouseEvents: [] },
227
+ };
228
+ const t = () => (Date.now() - T0) / 1000;
229
+
230
+ // ── Cursor track ───────────────────────────────────────────────────────────
231
+ // currentCursor is set from the ELEMENT we are about to touch, so the arrow
232
+ // becomes a pointing hand the moment it lands on a button and reverts while
233
+ // travelling over empty page.
234
+ let currentCursor = "arrow";
235
+ const mouseEvents = manifest.interactions.mouseEvents;
236
+ const pushMouse = (type, x, y, button = null, extra = null) => {
237
+ // x/y arrive in ZOOMED capture space; the manifest speaks 1920x1080 design.
238
+ mouseEvents.push({
239
+ type,
240
+ x: Math.round((x / SUPERSAMPLE) * 10) / 10,
241
+ y: Math.round((y / SUPERSAMPLE) * 10) / 10,
242
+ time: Math.round(t() * 1000) / 1000,
243
+ button,
244
+ cursor: currentCursor,
245
+ ...(extra ?? {}),
246
+ });
247
+ };
248
+ // A real recorder samples continuously; holds must keep emitting or the cursor
249
+ // has nothing to sit on between moves.
250
+ const sampler = setInterval(() => pushMouse("move", cx, cy), CURSOR_SAMPLE_MS);
251
+
252
+ /** Ask the element what cursor it shows. Takes the locator visibleBox already
253
+ * resolved — re-scanning cost ~1s of dead video per step, which inflated a
254
+ * 29s take to 36s purely from instrumentation. Playwright locators pierce
255
+ * shadow DOM; getComputedStyle via document queries does not. */
256
+ async function cursorForElement(el) {
257
+ if (!el) return "arrow";
258
+ const css = await el.evaluate((node) => getComputedStyle(node).cursor).catch(() => null);
259
+ return cssCursorToNative(css);
260
+ }
261
+
262
+ /** What cursor does the page show at the CURRENT point? A click swaps the
263
+ * content under a stationary cursor (a dialog closes, another opens, a page
264
+ * navigates) and the recorded type went stale — the founder caught a
265
+ * pointing hand floating over plain dialog text (2026-08-09). Ask the page
266
+ * again after anything that can change what is under the cursor.
267
+ * elementFromPoint does not descend into shadow roots on its own. */
268
+ async function cursorUnderPoint() {
269
+ try {
270
+ const css = await page.evaluate(([px, py]) => {
271
+ let el = document.elementFromPoint(px, py);
272
+ let guard = 0;
273
+ while (el && el.shadowRoot && guard++ < 5) {
274
+ const inner = el.shadowRoot.elementFromPoint(px, py);
275
+ if (!inner || inner === el) break;
276
+ el = inner;
277
+ }
278
+ return el ? getComputedStyle(el).cursor : null;
279
+ }, [Math.round(cx), Math.round(cy)]);
280
+ return cssCursorToNative(css);
281
+ } catch { return "arrow"; }
282
+ }
283
+
284
+ const pushShot = (box, tStart, tEnd, label, extra) => {
285
+ if (!box || !(box.width > 0) || !(box.height > 0)) return;
286
+ if (!(tEnd > tStart)) return;
287
+ manifest.shots.push({
288
+ t_start: Math.round(tStart * 100) / 100,
289
+ t_end: Math.round(tEnd * 100) / 100,
290
+ x: Math.round(box.x / SUPERSAMPLE),
291
+ y: Math.round(box.y / SUPERSAMPLE),
292
+ w: Math.round(box.width / SUPERSAMPLE),
293
+ h: Math.round(box.height / SUPERSAMPLE),
294
+ label: label ?? null,
295
+ // `extra` carries n (step linkage) and glide {t_start,t_end} (wall) so the
296
+ // server camera planner works from MEASURED departure/arrival, never
297
+ // inference — the camera-regime ladder (merge/pan/trombone) needs to know
298
+ // exactly when the cursor is in flight.
299
+ ...(extra ?? {}),
300
+ });
301
+ };
302
+
303
+ /** LAW (visible-instance pick, dry-run lesson): sticky-header twins and
304
+ * offscreen duplicates shadow the real control — take the first VISIBLE match
305
+ * whose top clears minY, retrying while the page settles. */
306
+ async function visibleTarget(selector, minY = 0, timeout = 15000) {
307
+ visibleTarget.lastWaitMs = 0;
308
+ const waitT0 = Date.now();
309
+ const all = page.locator(selector);
310
+ await all.first().waitFor({ state: "attached", timeout }).catch(() => {});
311
+ for (let tries = 0; tries < 20; tries++) {
312
+ const n = await all.count().catch(() => 0);
313
+ for (let i = 0; i < n; i++) {
314
+ const el = all.nth(i);
315
+ if (!(await el.isVisible().catch(() => false))) continue;
316
+ const b = await el.boundingBox().catch(() => null);
317
+ if (!b) continue;
318
+ // LAW (in-frame targets, Reddit lesson 2026-08-09): "visible" per
319
+ // Playwright includes elements parked outside the viewport in a
320
+ // horizontally scrollable strip — a topic chip at x=2281 sent the
321
+ // cursor 360px off the 1920 frame and the click landed off-camera.
322
+ // A target's CENTER must be inside the recorded frame, with margin.
323
+ const cx = b.x + b.width / 2, cy = b.y + b.height / 2;
324
+ if (cx < 8 || cx > VIEW.width - 8 || cy < 8 || cy > VIEW.height - 8) continue;
325
+ // Return the ELEMENT alongside its box so callers never re-scan.
326
+ if (b.y >= minY * SUPERSAMPLE) { visibleTarget.lastWaitMs = Date.now() - waitT0; return { box: b, el }; }
327
+ }
328
+ await page.waitForTimeout(500);
329
+ }
330
+ return { box: null, el: null };
331
+ }
332
+ const visibleBox = async (selector, minY = 0, timeout = 15000) =>
333
+ (await visibleTarget(selector, minY, timeout)).box;
334
+
335
+ /** What appeared after a click. A click that opens something moves the subject
336
+ * somewhere else on screen — the menu below the button, the dialog in the
337
+ * middle. Keeping the camera on the button is how a take ends up showing a
338
+ * dimmed backdrop while the thing you just opened sits off frame.
339
+ *
340
+ * An explicit `reveals` selector wins. Otherwise look for a top-layer arrival:
341
+ * native menus, dialogs and popovers are exactly what a click tends to open. */
342
+ async function revealedBox(step) {
343
+ if (step.reveals) {
344
+ return await visibleBox(step.reveals, 0, 4000);
345
+ }
346
+ const box = await page
347
+ .evaluate(() => {
348
+ // LAW (shadow DOM, LinkedIn lesson 2026-08-08): modern apps render
349
+ // overlays inside shadow roots. `document.querySelectorAll` does not
350
+ // cross a shadow boundary, so a plain query reports NOTHING while the
351
+ // dialog is plainly on screen — and the camera silently stays on the
352
+ // button. Playwright's own locators pierce shadow DOM, which is why an
353
+ // explicit `reveals` selector kept working while this fallback did not.
354
+ const els = [];
355
+ const walk = (root, depth) => {
356
+ if (depth > 8) return;
357
+ let kids;
358
+ try { kids = root.querySelectorAll("*"); } catch { return; }
359
+ for (const el of kids) {
360
+ els.push(el);
361
+ if (el.shadowRoot) walk(el.shadowRoot, depth + 1);
362
+ }
363
+ };
364
+ walk(document, 0);
365
+
366
+ const vw = window.innerWidth, vh = window.innerHeight;
367
+ const cands = [];
368
+ for (const el of els) {
369
+ if (el.id === "__rec_cursor") continue;
370
+ const r = el.getBoundingClientRect();
371
+ if (r.width < 140 * SUPERSAMPLE || r.height < 70 * SUPERSAMPLE) continue;
372
+ if (r.width > vw * 0.97 && r.height > vh * 0.97) continue;
373
+ // Offscreen carousels and preloaded media are not what just opened.
374
+ if (r.right < 0 || r.bottom < 0 || r.left > vw || r.top > vh) continue;
375
+ let cs;
376
+ try { cs = getComputedStyle(el); } catch { continue; }
377
+ if (cs.visibility === "hidden" || cs.display === "none" || cs.opacity === "0") continue;
378
+ const role = el.getAttribute && el.getAttribute("role");
379
+ const explicit =
380
+ role === "dialog" || role === "menu" || role === "alertdialog" ||
381
+ role === "listbox" || el.tagName === "DIALOG" || el.hasAttribute("popover");
382
+ const floating = cs.position === "fixed" || cs.position === "absolute";
383
+ if (!explicit && !floating) continue;
384
+ cands.push({
385
+ explicit,
386
+ x: r.x, y: r.y, width: r.width, height: r.height,
387
+ area: r.width * r.height,
388
+ });
389
+ }
390
+ // An explicit overlay role wins. Among equals take the SMALLEST, which
391
+ // is the panel itself rather than its backdrop or layout wrapper.
392
+ cands.sort((a, b) => (b.explicit ? 1 : 0) - (a.explicit ? 1 : 0) || a.area - b.area);
393
+ return cands[0] ?? null;
394
+ })
395
+ .catch(() => null);
396
+ return box;
397
+ }
398
+
399
+ // LAW (human pace, founder 2026-08-09): the old curve was
400
+ // min(1300, max(420, dist * 1.1)), so a 234px move down a menu hit the 420ms
401
+ // floor and crossed four items in under half a second. A hand does not do
402
+ // that — it takes closer to a second and each item has time to light up. These
403
+ // numbers are deliberately unhurried; the demo reads calmer for it.
404
+ // Pace matched to the reference: a 350px move lands in ~0.75s INCLUDING its
405
+ // deceleration tail. The ballistic shape covers most distance early, so the
406
+ // same wall duration reads far snappier than min-jerk did.
407
+ const GLIDE_MIN_MS = 650;
408
+ const GLIDE_MAX_MS = 1250;
409
+ const GLIDE_PER_PX = 1.3;
410
+
411
+ // LAW (ballistic motion, measured off the founder's reference hero-demo,
412
+ // 2026-08-09): the life is in the VELOCITY PROFILE, not the path. Frame-by-
413
+ // frame tracking of the reference showed peak speed at 16-28% of each move
414
+ // with a long deceleration tail (pk/mean 2-6), along NEARLY STRAIGHT paths
415
+ // (path/chord 1.02). Two earlier models both failed the eye: symmetric
416
+ // min-jerk (peak at 50%, reads floaty) and a big 14% spatial arc (the
417
+ // reference does not swoop). This bezier timing hits peak@21%, pk/mean 3.1,
418
+ // 84% of the distance covered by half-time, with a soft landing.
419
+ // Endpoints, durations and click moments stay EXACT — sync is untouched.
420
+ const ballistic = (() => {
421
+ const [p1x, p1y, p2x, p2y] = [0.3, 0.0, 0.1, 1.0];
422
+ const cxb = 3 * p1x, bxb = 3 * (p2x - p1x) - cxb, axb = 1 - cxb - bxb;
423
+ const cyb = 3 * p1y, byb = 3 * (p2y - p1y) - cyb, ayb = 1 - cyb - byb;
424
+ const sampleX = (t) => ((axb * t + bxb) * t + cxb) * t;
425
+ const sampleY = (t) => ((ayb * t + byb) * t + cyb) * t;
426
+ const derivX = (t) => (3 * axb * t + 2 * bxb) * t + cxb;
427
+ return (x) => {
428
+ let t = x;
429
+ for (let i = 0; i < 8; i++) {
430
+ const d = derivX(t);
431
+ if (Math.abs(d) < 1e-6) break;
432
+ t -= (sampleX(t) - x) / d;
433
+ }
434
+ return sampleY(Math.max(0, Math.min(1, t)));
435
+ };
436
+ })();
437
+
438
+ async function glide(x, y) {
439
+ const x0 = cx, y0 = cy;
440
+ const dist = Math.hypot(x - x0, y - y0);
441
+ if (dist < 1) return;
442
+ // Pace and arc are perceptual quantities — compute them in DESIGN pixels,
443
+ // not the 2x capture space, or every move doubles in duration and bow.
444
+ const designDist = dist / SUPERSAMPLE;
445
+ const dur = Math.min(GLIDE_MAX_MS, Math.max(GLIDE_MIN_MS, designDist * GLIDE_PER_PX));
446
+ const steps = Math.max(12, Math.round(dur / 16));
447
+ // Near-straight trace: the reference hero-demo's moves are straight to
448
+ // within 2% (path/chord 1.02) — a big swoop reads as fake, a trace of
449
+ // curvature reads as a hand. 4.5% capped 20px, DETERMINISTIC.
450
+ const arcMag = Math.min(20 * SUPERSAMPLE, dist * 0.045);
451
+ const perpX = -(y - y0) / dist, perpY = (x - x0) / dist;
452
+ const sign = x - x0 >= 0 ? 1 : -1;
453
+ const c1x = x0 + (x - x0) * 0.3 + perpX * arcMag * sign;
454
+ const c1y = y0 + (y - y0) * 0.3 + perpY * arcMag * sign;
455
+ const c2x = x0 + (x - x0) * 0.75 + perpX * arcMag * 0.35 * sign;
456
+ const c2y = y0 + (y - y0) * 0.75 + perpY * arcMag * 0.35 * sign;
457
+ for (let i = 1; i <= steps; i++) {
458
+ const e = ballistic(i / steps);
459
+ const u = 1 - e;
460
+ const nx = u * u * u * x0 + 3 * u * u * e * c1x + 3 * u * e * e * c2x + e * e * e * x;
461
+ const ny = u * u * u * y0 + 3 * u * u * e * c1y + 3 * u * e * e * c2y + e * e * e * y;
462
+ // LAW (record what you animate, founder 2026-08-09): cx/cy used to be
463
+ // assigned only AFTER this loop, so the background sampler recorded the
464
+ // OLD position for the entire glide and then teleported. 98% of a real
465
+ // take's events were duplicates and the cursor jumped 458px in 0ms. Every
466
+ // reported symptom (rigid, jumping, too fast, missing where the action is,
467
+ // appearing and reappearing) came from that one omission — at a 3x zoom
468
+ // the visible frame is 640x360, so a cursor parked at the previous target
469
+ // is off-frame entirely. Advance the live position EVERY frame and record
470
+ // it, so the event track is the motion the viewer actually sees.
471
+ cx = nx; cy = ny;
472
+ pushMouse("move", nx, ny);
473
+ // The burned-in cursor only exists in the STANDALONE ending. In the
474
+ // DemoBites lane __recSetCursor is undefined, and awaiting a no-op
475
+ // evaluate cost a full protocol round trip PER STEP — it doubled the
476
+ // step cadence to ~33ms and made the motion chunkier than designed.
477
+ if (BURN_CURSOR) {
478
+ await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [nx, ny]);
479
+ }
480
+ // LAW (mouse-coordinate clicks): the real mouse tracks the drawn cursor,
481
+ // so hover states fire naturally and the click lands where the pulse is.
482
+ await page.mouse.move(nx, ny);
483
+ await page.waitForTimeout(dur / steps);
484
+ }
485
+ cx = x; cy = y;
486
+ pushMouse("move", x, y);
487
+ }
488
+
489
+ async function smoothScroll(dy, ms = 1400) {
490
+ await page.evaluate(([d, m]) => new Promise((res) => {
491
+ const y0 = window.scrollY, t0 = performance.now();
492
+ const step = (now) => {
493
+ const e = Math.min(1, (now - t0) / m);
494
+ const ease = e < 0.5 ? 2 * e * e : 1 - Math.pow(-2 * e + 2, 2) / 2;
495
+ window.scrollTo(0, y0 + d * ease);
496
+ e < 1 ? requestAnimationFrame(step) : res();
497
+ };
498
+ requestAnimationFrame(step);
499
+ }), [dy, ms]);
500
+ }
501
+
502
+ let failure = null;
503
+ try {
504
+ for (const step of STORYBOARD.steps) {
505
+ const rec = {
506
+ n: manifest.steps.length + 1,
507
+ action: step.action,
508
+ label: step.label ?? null,
509
+ // What the viewer is looking at, handed to the ingestion's rescripting
510
+ // stage. A microphone can never know this; the recorder always does.
511
+ on_screen: step.on_screen ?? null,
512
+ narration: narrationOf(step),
513
+ t_start: t(),
514
+ };
515
+ process.stdout.write(`step ${rec.n} ${step.action} ${step.label ?? ""}\n`);
516
+ if (step.action === "goto") {
517
+ await page.goto(step.url, { waitUntil: "load" });
518
+ await ensureCursor();
519
+ await applyHideCss();
520
+ if (manifest.record_from === undefined) {
521
+ // LAW (first-frame, founder 2026-08-08): the published cut opens on a
522
+ // FULLY loaded page — wait for network quiet (best effort, LinkedIn
523
+ // style long-pollers never go fully idle) plus a short beat, THEN
524
+ // stamp record_from: where the final video begins.
525
+ await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {});
526
+ await page.waitForTimeout(600);
527
+ manifest.record_from = t();
528
+ }
529
+ await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [cx, cy]);
530
+ } else if (step.action === "settle") {
531
+ // A settle can still carry the camera: `focus` names what to look at.
532
+ const ms = step.ms ?? DEFAULT_SETTLE_MS;
533
+ const shotStart = t();
534
+ await page.waitForTimeout(ms);
535
+ if (step.focus) {
536
+ const box = await visibleBox(step.focus, step.minY ?? 0, 4000);
537
+ pushShot(box, shotStart, t(), step.label, { n: rec.n });
538
+ }
539
+ } else if (step.action === "scroll") {
540
+ // scrollTo distances are ALSO zoomed-space under CSS zoom (measured:
541
+ // scrollTo(0,600) moves 300 design px) — storyboards speak design px.
542
+ await smoothScroll(step.dy * SUPERSAMPLE, step.ms ?? 1400);
543
+ } else if (step.action === "click" || step.action === "hover") {
544
+ const { box, el } = await visibleTarget(step.selector, step.minY ?? 0);
545
+ if (!box) throw new Error("no visible target for " + step.selector);
546
+ // SLOW-CONTENT STAMP (Reddit lesson 2026-08-09): a long target wait
547
+ // means the app was loading ON CAMERA — the viewer watched skeletons.
548
+ // The take still completes; the stamp makes the dead air visible at
549
+ // judgment time instead of at the founder's desk.
550
+ if (visibleTarget.lastWaitMs > 1500) {
551
+ rec.slow_content_ms = visibleTarget.lastWaitMs;
552
+ console.log(`SLOW CONTENT: step ${rec.n} waited ${(visibleTarget.lastWaitMs / 1000).toFixed(1)}s for its target — content was loading on camera. Judge the take; prefer a retake.`);
553
+ }
554
+ const x = box.x + box.width / 2, y = box.y + box.height / 2;
555
+ // Resolved BEFORE the glide so the answer costs no video time.
556
+ const targetCursor = await cursorForElement(el);
557
+ const shotStart = t();
558
+ // Travel as an arrow, then adopt whatever cursor the target actually
559
+ // shows on arrival.
560
+ currentCursor = "arrow";
561
+ await glide(x, y);
562
+ const arrivalT = t();
563
+ currentCursor = targetCursor;
564
+ rec.cursor = currentCursor;
565
+ pushMouse("move", x, y);
566
+ // bbox rides along for calibrate.mjs's hover-anchor pass: the CSS
567
+ // hover style flips in the SAME video frame the real cursor crosses
568
+ // the element's edge, so track-crossing-into-bbox vs pixel-change-in-
569
+ // bbox is a frame-exact clock anchor (measured 2026-08-09: 4 anchors,
570
+ // 23ms spread). Center arrival is NOT the anchor — entry edge is.
571
+ rec.target = {
572
+ selector: step.selector,
573
+ x: Math.round(x / SUPERSAMPLE),
574
+ y: Math.round(y / SUPERSAMPLE),
575
+ bbox: {
576
+ x: Math.round(box.x / SUPERSAMPLE),
577
+ y: Math.round(box.y / SUPERSAMPLE),
578
+ w: Math.round(box.width / SUPERSAMPLE),
579
+ h: Math.round(box.height / SUPERSAMPLE),
580
+ },
581
+ };
582
+
583
+ if (step.action === "hover") {
584
+ await page.waitForTimeout(step.dwell ?? DEFAULT_HOVER_DWELL);
585
+ // LAW (camera choreography, measured off the reference 2026-08-09):
586
+ // the camera must NOT travel in lockstep with the cursor. When it
587
+ // does, the cursor sits pinned near frame center while the page
588
+ // slides underneath — the forensics tracked our export doing exactly
589
+ // that, and the same ballistic motion read stiffer for it. The shot
590
+ // begins just before ARRIVAL, so the previous frame holds still
591
+ // while the cursor sweeps across it, then the camera reframes.
592
+ pushShot(step.focus ? await visibleBox(step.focus, 0, 3000) : box, Math.max(shotStart, arrivalT - 0.3), t(), step.label, { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
593
+ } else {
594
+ await page.waitForTimeout(step.dwell ?? DEFAULT_CLICK_DWELL);
595
+ await page.evaluate(([a, b]) => window.__recPulse?.(a, b), [x, y]);
596
+ await page.waitForTimeout(220);
597
+ rec.click_at = t();
598
+ const clickEventIndex = mouseEvents.length;
599
+ pushMouse("click", x, y, "left");
600
+ await page.mouse.click(x, y);
601
+ if (step.waitLoad) {
602
+ await page.waitForLoadState("load");
603
+ await ensureCursor();
604
+ await applyHideCss();
605
+ await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [cx, cy]);
606
+ }
607
+ // Shot one: the control — beginning near ARRIVAL (see the camera
608
+ // choreography law above), never spanning the approach glide.
609
+ pushShot(box, Math.max(shotStart, arrivalT - 0.3), t() + 0.3, step.label, { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
610
+ // LAW (press physics, founder 2026-08-09): a press has a down and an
611
+ // up — but the up only exists if the clicked surface is still there.
612
+ // A menu item or modal button that DESTROYS itself on click gets a
613
+ // press-down with no spring-back; the glyph simply becomes an arrow.
614
+ // A link/button that survives gets the full down-and-up and stays a
615
+ // pointer. The recorder does not guess: it ASKS the page whether the
616
+ // element it just clicked is still connected, visible, and under the
617
+ // point. The verdict rides the click event as press:'full'|'down'.
618
+ const afterMs = step.after ?? DEFAULT_CLICK_AFTER;
619
+ const early = Math.min(450, afterMs);
620
+ await page.waitForTimeout(Math.min(200, early));
621
+ // TIMING LAW (parity forensics 2026-08-09): this check must not eat
622
+ // wall time — an inline 1.2s race pushed the arrow flip to +1.7s in
623
+ // the track. The check starts at +200ms (late enough for a closing
624
+ // menu to be gone), races 250ms (healthy evaluates return in <50ms; a
625
+ // destroyed context HANGS, and the hang IS the vanished verdict), and
626
+ // the verdict lands before the +450ms probe.
627
+ let surfaceSurvived = false;
628
+ try {
629
+ surfaceSurvived = await Promise.race([
630
+ new Promise((res) => setTimeout(() => res("__timeout__"), 250)),
631
+ el.evaluate((node, pt) => {
632
+ if (!node.isConnected) return false;
633
+ const r = node.getBoundingClientRect();
634
+ if (r.width === 0 || r.height === 0) return false;
635
+ const st = getComputedStyle(node);
636
+ if (st.visibility === "hidden" || st.display === "none") return false;
637
+ // Still under the click point? (something may cover it now)
638
+ let hit = document.elementFromPoint(pt[0], pt[1]);
639
+ let guard = 0;
640
+ while (hit && hit.shadowRoot && guard++ < 5) {
641
+ const inner = hit.shadowRoot.elementFromPoint(pt[0], pt[1]);
642
+ if (!inner || inner === hit) break;
643
+ hit = inner;
644
+ }
645
+ return !!hit && (hit === node || node.contains(hit) || hit.contains(node));
646
+ }, [Math.round(x), Math.round(y)]),
647
+ ]);
648
+ if (surfaceSurvived === "__timeout__") surfaceSurvived = false;
649
+ } catch {
650
+ surfaceSurvived = false; // detached handle throws — the surface is gone
651
+ }
652
+ mouseEvents[clickEventIndex].press = surfaceSurvived ? "full" : "down";
653
+ rec.press = mouseEvents[clickEventIndex].press;
654
+ await page.waitForTimeout(Math.max(0, early - 200 - 250));
655
+ // ONE probe only. A second post-settle probe used to re-find a
656
+ // clickable under the parked point and push hand AFTER the arrow —
657
+ // a hand→arrow→hand flash with no mouse movement (founder). The next
658
+ // glide re-reads the true cursor from its target anyway.
659
+ currentCursor = await cursorUnderPoint();
660
+ pushMouse("move", cx, cy);
661
+ await page.waitForTimeout(Math.max(0, afterMs - early));
662
+ // Shot two: whatever the click opened. This is the shot that was
663
+ // missing on 2026-08-08, when the camera stayed on the button while
664
+ // the dialog opened in the middle of the screen.
665
+ if (step.reveals !== false) {
666
+ const opened = await revealedBox(step);
667
+ if (opened) {
668
+ rec.revealed = {
669
+ x: Math.round(opened.x / SUPERSAMPLE), y: Math.round(opened.y / SUPERSAMPLE),
670
+ w: Math.round(opened.width / SUPERSAMPLE), h: Math.round(opened.height / SUPERSAMPLE),
671
+ };
672
+ pushShot(opened, rec.click_at + 0.35, t(), `${step.label ?? "click"}, result`, { n: rec.n, revealed: true });
673
+ }
674
+ }
675
+ }
676
+ }
677
+ rec.t_end = t();
678
+ manifest.steps.push(rec);
679
+ }
680
+ // Closing beat so the last action breathes before the cut ends.
681
+ await page.waitForTimeout(CLOSING_BEAT_MS);
682
+ } catch (e) {
683
+ failure = e;
684
+ }
685
+
686
+ clearInterval(sampler);
687
+ manifest.duration = t();
688
+ const video = page.video();
689
+ await ctx.close();
690
+ if (video) {
691
+ const vpath = await video.path();
692
+ fs.renameSync(vpath, path.join(DIR, "raw.webm"));
693
+ }
694
+ fs.writeFileSync(path.join(DIR, "manifest.json"), JSON.stringify(manifest, null, 2));
695
+ if (failure) {
696
+ console.error(`TAKE FAILED at step ${manifest.steps.length + 1}: ${failure.message}`);
697
+ console.error("Partial raw.webm + manifest.json saved in", DIR);
698
+ process.exit(1);
699
+ }
700
+ const cursorKinds = [...new Set(mouseEvents.map((e) => e.cursor))].join(", ");
701
+ console.log(
702
+ `DONE raw.webm + manifest.json in ${DIR} — ${manifest.duration.toFixed(1)}s, ` +
703
+ `${manifest.steps.length} steps, ${manifest.shots.length} camera shots, ` +
704
+ `${mouseEvents.length} mouse events (${cursorKinds})` +
705
+ `${BURN_CURSOR ? ", cursor BURNED IN" : ", cursor rendered by the studio"}`,
706
+ );