@homie-rocks/studio 0.51.3 → 0.51.5

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.
Files changed (91) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +10 -29
  3. package/apps/APPS.md +1 -1
  4. package/bin/homie-studio.mjs +28 -30
  5. package/docs/mcp-events.md +68 -0
  6. package/guides/app/SKILL.md +25 -1
  7. package/guides/art/SKILL.md +68 -140
  8. package/guides/art/scripts/art.mjs +6 -79
  9. package/guides/models/SKILL.md +73 -177
  10. package/guides/models/references/RIGHTS.md +1 -1
  11. package/guides/models/scripts/models.mjs +7 -288
  12. package/guides/music/SKILL.md +74 -183
  13. package/guides/music/references/COMPOSITION.md +21 -26
  14. package/guides/music/references/GAME-AUDIO.md +3 -3
  15. package/guides/music/references/RIGHTS.md +12 -15
  16. package/guides/music/scripts/music.mjs +11 -202
  17. package/guides/office/SKILL.md +24 -0
  18. package/guides/parts/SKILL.md +1 -1
  19. package/guides/servers/SKILL.md +24 -0
  20. package/guides/shop/SKILL.md +68 -77
  21. package/guides/sound/SKILL.md +70 -1
  22. package/guides/sound/scripts/player/sound.js +233 -0
  23. package/guides/studio-setup/SKILL.md +11 -27
  24. package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
  25. package/guides/style/SKILL.md +66 -3
  26. package/guides/video/SKILL.md +83 -0
  27. package/guides/video/references/DELIVERY.md +73 -0
  28. package/guides/video/references/HONESTY.md +40 -0
  29. package/guides/video/references/METHOD.md +144 -0
  30. package/guides/video/references/RECORD.md +137 -0
  31. package/guides/video/references/TRAILER.md +154 -0
  32. package/guides/video/references/examples/site-walkthrough.json +14 -0
  33. package/guides/video/references/examples/studio-play-phone.json +15 -0
  34. package/guides/video/references/examples/studio-play.json +17 -0
  35. package/guides/video/scripts/capture-game.mjs +175 -0
  36. package/guides/video/scripts/clock.js +136 -0
  37. package/guides/video/scripts/drawover/film.js +58 -0
  38. package/guides/video/scripts/drawover/index.html +9 -0
  39. package/guides/video/scripts/drawover/look.js +115 -0
  40. package/guides/video/scripts/drawover/shots.json +12 -0
  41. package/guides/video/scripts/lib/browser.mjs +87 -0
  42. package/guides/video/scripts/lib/frames.mjs +170 -0
  43. package/guides/video/scripts/lib/remix.mjs +159 -0
  44. package/guides/video/scripts/qa.mjs +127 -0
  45. package/guides/video/scripts/record-fixed.mjs +403 -0
  46. package/guides/video/scripts/record-page.mjs +477 -0
  47. package/guides/video/scripts/tap.js +108 -0
  48. package/guides/video/scripts/video.mjs +888 -0
  49. package/lib/build.mjs +12 -9
  50. package/lib/cloudflare.mjs +6 -11
  51. package/lib/dev.mjs +1 -2
  52. package/lib/doctor.mjs +10 -54
  53. package/lib/functions-build.mjs +8 -1
  54. package/lib/mcp-tools.mjs +42 -21
  55. package/lib/mcp.mjs +1 -1
  56. package/lib/media-import.mjs +41 -0
  57. package/lib/media-workflows.mjs +13 -0
  58. package/lib/official-media.mjs +15 -0
  59. package/lib/rules-build.mjs +13 -5
  60. package/lib/scaffold.mjs +2 -2
  61. package/lib/shop-links.mjs +54 -18
  62. package/lib/shop-listen.mjs +43 -0
  63. package/lib/shop-local.mjs +1 -1
  64. package/lib/shop-mcp.mjs +58 -0
  65. package/lib/stripe-connect.mjs +36 -15
  66. package/lib/template-history.json +87 -0
  67. package/lib/upgrade-media.mjs +13 -0
  68. package/lib/upgrade.mjs +10 -0
  69. package/package.json +1 -1
  70. package/parts/PARTS.md +1 -1
  71. package/rules/view.ts +4 -0
  72. package/shop/SHOP.md +66 -34
  73. package/worker/build-id.mjs +1 -1
  74. package/worker/event-resources.mjs +12 -3
  75. package/worker/event-webhooks.mjs +14 -9
  76. package/worker/functions.mjs +11 -6
  77. package/worker/index.mjs +5 -1
  78. package/worker/mcp-event-streams.mjs +58 -0
  79. package/worker/mcp-events-wire.mjs +41 -0
  80. package/worker/mcp-events.mjs +132 -0
  81. package/worker/mcp.mjs +4 -1
  82. package/worker/purchase-reconciliation.mjs +16 -1
  83. package/worker/shop-subscriptions.mjs +2 -0
  84. package/worker/shop.mjs +7 -5
  85. package/worker/stripe-ownership.mjs +7 -0
  86. package/worker/version.mjs +1 -1
  87. package/guides/models/references/models.json +0 -175
  88. package/guides/music/scripts/lib/eleven.mjs +0 -260
  89. package/lib/projects-env.mjs +0 -68
  90. package/lib/projects.mjs +0 -311
  91. package/lib/provider-key.mjs +0 -21
@@ -0,0 +1,477 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * record-page.mjs — record any web page while a script drives it: clicks, taps, keys, typing, scrolls and waits for
4
+ * a selector, a text or a state, in REAL TIME, off a headless GPU Chrome, into an mp4 whose every frame is one the
5
+ * page drew. For product demos, site walkthroughs, tutorials, and a studio's own game page: land, press Play, play.
6
+ *
7
+ * node record-page.mjs --steps <steps.json> --out <folder> [--url <site>] [--device computer|phone]
8
+ * [--fps 30] [--width 1920] [--height 1080] [--scale 1] [--seconds 120] [--no-cursor] [--min-fps 0]
9
+ * [--min-free-gb 10]
10
+ *
11
+ * The steps file (references/RECORD.md has every step; references/examples/ has tested ones):
12
+ *
13
+ * { "path": "/gem-rush/", "device": "computer", "tail": 2,
14
+ * "steps": [
15
+ * { "wait": 1500, "caption": "Gem Rush, on the studio's own site" },
16
+ * { "click": "a[data-play]", "caption": "Press Play" },
17
+ * { "waitFor": { "frame": "game", "selector": "canvas" } },
18
+ * { "key": "ArrowRight", "hold": 900, "frame": "game" },
19
+ * { "keys": ["ArrowUp", "ArrowLeft"], "hold": 400, "gap": 150, "frame": "game" }
20
+ * ] }
21
+ *
22
+ * "url" (absolute) or "path" (joined to --url, the local `dev` site or the live one) is where it starts. The page is
23
+ * opened and settled first, then the recording starts and the steps run one after another at the speed they take:
24
+ * nothing is sped up, nothing is cut. The recording ends `tail` seconds after the last step (or at --seconds).
25
+ *
26
+ * Picture: the compositor's own frames (CDP screencast) with the time each was presented, laid onto --fps by
27
+ * holding the newest frame (lib/frames.mjs): a frame the page did not paint is held and counted, never
28
+ * interpolated. The page's real frame rate (requestAnimationFrame, in the page and in a game's frame) and
29
+ * the GPU renderer are measured and reported; under 20 frames a second, or on a software renderer, the
30
+ * result says so, because the recording then shows a slow computer, not the page.
31
+ * Cursor: a small arrow and a press ring drawn over the page (in the page, so it is in the frames) to show where the
32
+ * script pointed; a tap shows a ring. --no-cursor leaves them out.
33
+ * Sound: what a studio game's frame (/<id>/__game/) sends to its speaker through WebAudio (tap.js), on the same
34
+ * clock; the browser is muted. Other pages record silent: add a bed or a voice-over in the edit.
35
+ * Out: <out>/recording.mp4, <out>/recording.json (every step with the second it ran, frame rates, held
36
+ * frames, renderer, notes), <out>/captions.vtt when steps have captions. Raw frames are deleted once
37
+ * encoded. Only small JSON with paths goes to stdout.
38
+ */
39
+ import { mkdirSync, readFileSync, rmSync, statfsSync, writeFileSync } from 'node:fs';
40
+ import { dirname, join, resolve } from 'node:path';
41
+ import { fileURLToPath } from 'node:url';
42
+ import { launch } from './lib/browser.mjs';
43
+ import { TapReader, constantRate, encodeMp4, fitAudio, startScreencast, writeConcat } from './lib/frames.mjs';
44
+ import { findStudio } from '../../music/scripts/lib/studio.mjs';
45
+
46
+ const HERE = dirname(fileURLToPath(import.meta.url));
47
+ const argv = process.argv.slice(2);
48
+ const has = (n) => argv.includes(`--${n}`);
49
+ const opt = (n, d = null) => { const i = argv.indexOf(`--${n}`); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
50
+ const fail = (m, code = 2) => { process.stderr.write(`record-page: ${m}\n`); process.exit(code); };
51
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
52
+ const log = (k, v = {}) => process.stderr.write(`${JSON.stringify({ t: +(process.uptime()).toFixed(1), k, ...v })}\n`);
53
+
54
+ if (!opt('steps')) fail('--steps <steps.json> (references/RECORD.md)');
55
+ if (!opt('out')) fail('--out <folder>');
56
+ let spec;
57
+ try { spec = JSON.parse(readFileSync(resolve(String(opt('steps'))), 'utf8')); } catch (error) { fail(`--steps: ${error.message}`); }
58
+ if (!spec || !Array.isArray(spec.steps)) fail('the steps file needs "steps": [ ... ]');
59
+ const OUT = resolve(String(opt('out')));
60
+ const DEVICE = String(opt('device', spec.device ?? 'computer'));
61
+ if (!['computer', 'phone'].includes(DEVICE)) fail('--device computer|phone');
62
+ const PHONE = DEVICE === 'phone';
63
+ const FPS = Number(opt('fps', spec.fps ?? 30));
64
+ // The film's size: 16:9 for a computer, 9:16 for a phone (a 360x640 page at 3x).
65
+ const W = Number(opt('width', spec.width ?? (PHONE ? 1080 : 1920)));
66
+ const H = Number(opt('height', spec.height ?? (PHONE ? 1920 : 1080)));
67
+ const SCALE = Number(opt('scale', spec.scale ?? 1));
68
+ const SECONDS = Number(opt('seconds', spec.seconds ?? 120));
69
+ const TAIL = Number(spec.tail ?? 2);
70
+ const CURSOR = !has('no-cursor') && spec.cursor !== false;
71
+ const MIN_FPS = Number(opt('min-fps', spec.minFps ?? 0));
72
+ const MIN_FREE_GB = Number(opt('min-free-gb', 10));
73
+ if (!(FPS >= 1 && FPS <= 60)) fail('--fps 1..60');
74
+ if (!(SCALE >= 0.25 && SCALE <= 1)) fail('--scale 0.25..1');
75
+ if (!(SECONDS >= 2 && SECONDS <= 600)) fail('--seconds 2..600');
76
+ const base = String(opt('url', spec.base ?? '')).replace(/\/+$/, '');
77
+ const START = spec.url ?? (spec.path != null ? `${base}${String(spec.path).startsWith('/') ? '' : '/'}${spec.path}` : base);
78
+ if (!/^https?:\/\//.test(START)) fail('where to start: "url" in the steps file, or "path" with --url <site> (http://127.0.0.1:8787 from npm run dev, or the live site)');
79
+ // The page's own size (CSS pixels) and its pixel ratio: the frames come out at W x H (x SCALE), even sizes.
80
+ const DSF = PHONE ? 3 * SCALE : SCALE;
81
+ const VW = PHONE ? Math.round(W / 3) : W; const VH = PHONE ? Math.round(H / 3) : H;
82
+ const RW = Math.max(2, Math.round((VW * DSF) / 2) * 2); const RH = Math.max(2, Math.round((VH * DSF) / 2) * 2);
83
+
84
+ mkdirSync(OUT, { recursive: true });
85
+ const freeGB = () => { try { const f = statfsSync(OUT); return (f.bavail * f.bsize) / 1e9; } catch { return 99; } };
86
+ if (freeGB() < MIN_FREE_GB + 2) fail(`only ${freeGB().toFixed(1)} GB free; a recording needs room (stopping below ${MIN_FREE_GB} GB)`);
87
+
88
+ /* ---------------------------------------------------------------- what is injected into every page */
89
+
90
+ const TAP = readFileSync(join(HERE, 'tap.js'), 'utf8');
91
+ // A requestAnimationFrame counter in every frame: the page's real frame rate, measured where it runs. It draws nothing.
92
+ // And what input each frame actually received (passive listeners that change nothing): delivered, not judged.
93
+ const RAF = `(() => { if (window.__homieRaf) return; const s = { id: Math.random(), n: 0, t0: performance.now(), keys: 0, pointers: 0, touches: 0 }; window.__homieRaf = s;
94
+ const tick = () => { s.n++; requestAnimationFrame(tick); }; requestAnimationFrame(tick);
95
+ addEventListener('keydown', (e) => { if (!e.repeat) s.keys++; }, { capture: true, passive: true });
96
+ addEventListener('pointerdown', () => { s.pointers++; }, { capture: true, passive: true });
97
+ addEventListener('touchstart', () => { s.touches++; }, { capture: true, passive: true }); })();`;
98
+ // The cursor and the press ring, top document only, above everything and never in the way of a click.
99
+ const CURSOR_JS = `(() => { if (window.top !== window || window.__homieCursor) return;
100
+ const make = () => {
101
+ if (document.getElementById('__homie-cursor')) return;
102
+ const c = document.createElement('div'); c.id = '__homie-cursor';
103
+ c.style.cssText = 'position:fixed;left:0;top:0;width:26px;height:26px;z-index:2147483647;pointer-events:none;transform:translate(-200px,-200px);will-change:transform';
104
+ c.innerHTML = '<svg width="26" height="26" viewBox="0 0 26 26"><path d="M3 2 L3 21 L8.2 16.4 L11.6 24 L15 22.5 L11.7 15 L19 15 Z" fill="#fff" stroke="#111" stroke-width="1.6" stroke-linejoin="round"/></svg>';
105
+ const r = document.createElement('div'); r.id = '__homie-ring';
106
+ r.style.cssText = 'position:fixed;left:0;top:0;width:44px;height:44px;margin:-22px 0 0 -22px;border-radius:50%;border:3px solid rgba(255,255,255,.95);box-shadow:0 0 0 2px rgba(0,0,0,.35);z-index:2147483646;pointer-events:none;opacity:0';
107
+ (document.body || document.documentElement).append(r, c);
108
+ };
109
+ const S = { x: -200, y: -200, anim: null };
110
+ const place = () => { make(); const c = document.getElementById('__homie-cursor'); if (c) c.style.transform = 'translate(' + (S.x - 3) + 'px,' + (S.y - 2) + 'px)'; };
111
+ window.__homieCursor = {
112
+ at(x, y) { S.x = x; S.y = y; place(); },
113
+ glide(x, y, ms) { return new Promise((done) => { const x0 = S.x < -100 ? x - 140 : S.x; const y0 = S.y < -100 ? y + 90 : S.y; const t0 = performance.now();
114
+ const step = (t) => { const k = Math.min(1, (t - t0) / Math.max(1, ms)); const e = k < .5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
115
+ S.x = x0 + (x - x0) * e; S.y = y0 + (y - y0) * e; place(); if (k < 1) requestAnimationFrame(step); else done(); };
116
+ requestAnimationFrame(step); }); },
117
+ ring(x, y) { make(); const r = document.getElementById('__homie-ring'); if (!r) return;
118
+ r.style.left = x + 'px'; r.style.top = y + 'px';
119
+ r.animate([{ opacity: 1, transform: 'scale(.45)' }, { opacity: 0, transform: 'scale(1.25)' }], { duration: 420, easing: 'ease-out' }); },
120
+ hide() { S.x = -200; S.y = -200; place(); },
121
+ };
122
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', place); else place();
123
+ })();`;
124
+
125
+ /* ---------------------------------------------------------------- steps */
126
+
127
+ const KINDS = ['goto', 'wait', 'waitFor', 'click', 'tap', 'hover', 'move', 'drag', 'key', 'keys', 'type', 'scroll', 'focus', 'caption'];
128
+ /** `{ "click": "a" }` is `{ "do": "click", "selector": "a" }`; every shorthand to one shape. */
129
+ function normal(raw, i) {
130
+ if (!raw || typeof raw !== 'object') throw new Error(`step ${i + 1} is not an object`);
131
+ const kind = raw.do ?? KINDS.find((k) => k in raw);
132
+ if (!KINDS.includes(kind)) throw new Error(`step ${i + 1}: say what to do (${KINDS.join(', ')})`);
133
+ const s = { ...raw, do: kind };
134
+ const v = raw.do ? undefined : raw[kind];
135
+ if (kind === 'wait' && v !== undefined) s.ms = Number(v);
136
+ if (kind === 'waitFor' && v !== undefined) Object.assign(s, typeof v === 'string' ? { selector: v } : v);
137
+ if (['click', 'tap', 'hover', 'move'].includes(kind) && v !== undefined) Object.assign(s, typeof v === 'string' ? { selector: v } : Array.isArray(v) ? { at: v } : v);
138
+ if (kind === 'goto' && v !== undefined) s.url = v;
139
+ if (kind === 'key' && v !== undefined) s.key = v;
140
+ if (kind === 'keys' && v !== undefined) s.keys = v;
141
+ if (kind === 'type' && v !== undefined) s.text = v;
142
+ if (kind === 'scroll' && v !== undefined) Object.assign(s, typeof v === 'number' ? { by: v } : typeof v === 'string' ? { to: v } : v);
143
+ if (kind === 'focus' && v !== undefined) Object.assign(s, typeof v === 'string' ? { frame: v } : v);
144
+ if (kind === 'drag' && v !== undefined) Object.assign(s, v);
145
+ if (kind === 'caption' && v !== undefined) s.caption = v;
146
+ return s;
147
+ }
148
+ let steps;
149
+ try { steps = spec.steps.map(normal); } catch (error) { fail(error.message); }
150
+
151
+ const root = findStudio(OUT) ?? findStudio();
152
+ const report = {
153
+ ran: new Date().toISOString(), start: START, device: DEVICE, fps: FPS, size: [W, H], page: [VW, VH], ...(SCALE !== 1 ? { scale: SCALE, rendered: [RW, RH] } : {}),
154
+ steps: [], notes: [],
155
+ };
156
+
157
+ const { browser, close, pid } = await launch(root, { width: VW, height: VH, extra: ['--autoplay-policy=no-user-gesture-required', '--mute-audio'] });
158
+ log('browser', { pid });
159
+ const frameDir = join(OUT, 'frames');
160
+ rmSync(frameDir, { recursive: true, force: true });
161
+ mkdirSync(frameDir, { recursive: true });
162
+ const pcmFile = join(OUT, 'page-audio.s16le');
163
+ const tap = new TapReader(pcmFile);
164
+ let frames = [];
165
+ let videoT0 = null; // epoch seconds of the recording's start (the first frame's presentation time)
166
+ let stopAt = null;
167
+ let tapFrame = null;
168
+ const stop = async (why) => { log('stop', { why }); tap.close(); await close(); process.exit(3); };
169
+ process.on('SIGINT', () => stop('SIGINT'));
170
+ process.on('SIGTERM', () => stop('SIGTERM'));
171
+
172
+ let page;
173
+ /** A frame by name: "game" (a studio game's /<id>/__game/ frame), a piece of its address, or the page itself. */
174
+ function frameOf(name) {
175
+ if (!name || name === 'top' || name === 'page') return page.mainFrame();
176
+ const want = name === 'game' ? /\/__game\//i : null;
177
+ return page.frames().find((f) => !f.isDetached() && (want ? want.test(f.url()) : f.url().includes(String(name)))) ?? null;
178
+ }
179
+ async function frameNamed(name, timeout) {
180
+ const until = Date.now() + timeout;
181
+ for (;;) {
182
+ const f = frameOf(name);
183
+ if (f) return f;
184
+ if (Date.now() > until) throw new Error(`no frame "${name}" within ${timeout} ms`);
185
+ await sleep(100);
186
+ }
187
+ }
188
+ /** A point in the page: a selector's centre (in any frame), or [x, y] in CSS pixels, or fractions of the page (0..1). */
189
+ async function pointOf(s, timeout) {
190
+ if (Array.isArray(s.at)) {
191
+ const [x, y] = s.at.map(Number);
192
+ return x <= 1 && y <= 1 && x >= 0 && y >= 0 ? { x: x * VW, y: y * VH } : { x, y };
193
+ }
194
+ if (!s.selector) throw new Error('give "selector" or "at": [x, y]');
195
+ const frame = await frameNamed(s.frame, timeout);
196
+ const el = await frame.waitForSelector(s.selector, { visible: true, timeout });
197
+ // Scrolled to only when it is out of view, and smoothly, as a person would; never a jump.
198
+ if (!(await el.isIntersectingViewport({ threshold: 1 }).catch(() => true))) {
199
+ await el.evaluate((e) => e.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }));
200
+ await sleep(700);
201
+ }
202
+ const box = await el.boundingBox();
203
+ if (!box) throw new Error(`"${s.selector}" has no box on the page`);
204
+ return { x: box.x + box.width * (s.offset?.[0] ?? 0.5), y: box.y + box.height * (s.offset?.[1] ?? 0.5), el };
205
+ }
206
+ const cursor = (fn, ...args) => (CURSOR ? page.evaluate(fn, ...args).catch(() => {}) : Promise.resolve());
207
+ let pointer = null;
208
+ async function glide(p, ms = 450) {
209
+ // The visible cursor glides in the page; the real mouse follows in steps, so hover styles answer as for a person.
210
+ const moves = PHONE ? Promise.resolve() : page.mouse.move(p.x, p.y, { steps: Math.max(2, Math.round(ms / 25)) });
211
+ await Promise.all([cursor((x, y, t) => window.__homieCursor?.glide(x, y, t), p.x, p.y, ms), moves, sleep(ms)]);
212
+ pointer = p;
213
+ }
214
+ const secondsNow = () => (videoT0 === null ? 0 : Math.max(0, Date.now() / 1000 - videoT0));
215
+ /** The input a frame's document has received so far (keys, pointer presses, touches), with which document it is. */
216
+ const inputsOf = (f) => (f ? f.evaluate(() => (window.__homieRaf ? { id: window.__homieRaf.id, keys: window.__homieRaf.keys, pointers: window.__homieRaf.pointers, touches: window.__homieRaf.touches } : null)).catch(() => null) : Promise.resolve(null));
217
+ const INPUT_STEPS = new Set(['click', 'tap', 'drag', 'key', 'keys', 'type']);
218
+ const captions = [];
219
+
220
+ async function runStep(s) {
221
+ const timeout = Number(s.timeout ?? 20_000);
222
+ switch (s.do) {
223
+ case 'goto': await page.goto(/^https?:/.test(s.url) ? s.url : `${base}${s.url}`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); if (pointer) await cursor((x, y) => window.__homieCursor?.at(x, y), pointer.x, pointer.y); break;
224
+ case 'wait': await sleep(Number(s.ms ?? 1000)); break;
225
+ case 'caption': await sleep(Number(s.ms ?? 0)); break;
226
+ case 'waitFor': {
227
+ if (s.selector) { const f = await frameNamed(s.frame, timeout); await f.waitForSelector(s.selector, { visible: s.visible !== false, timeout }); }
228
+ else if (s.text) { const f = await frameNamed(s.frame, timeout); await f.waitForFunction((t) => document.body && document.body.innerText.includes(t), { timeout, polling: 100 }, String(s.text)); }
229
+ else if (s.js) { const f = await frameNamed(s.frame, timeout); await f.waitForFunction(String(s.js), { timeout, polling: 100 }); }
230
+ else if (s.frame) await frameNamed(s.frame, timeout);
231
+ else throw new Error('waitFor needs "selector", "text", "js" or "frame"');
232
+ break;
233
+ }
234
+ case 'hover': case 'move': { const p = await pointOf(s, timeout); await glide(p, Number(s.ms ?? 450)); break; }
235
+ case 'click': {
236
+ const p = await pointOf(s, timeout);
237
+ if (!PHONE) await glide(p, Number(s.ms ?? 450));
238
+ if (PHONE) { await cursor((x, y) => window.__homieCursor?.ring(x, y), p.x, p.y); await page.touchscreen.tap(p.x, p.y); }
239
+ else { await cursor((x, y) => window.__homieCursor?.ring(x, y), p.x, p.y); await page.mouse.click(p.x, p.y, { delay: 60 }); }
240
+ break;
241
+ }
242
+ case 'tap': {
243
+ const p = await pointOf(s, timeout);
244
+ await cursor((x, y) => window.__homieCursor?.ring(x, y), p.x, p.y);
245
+ await page.touchscreen.tap(p.x, p.y);
246
+ break;
247
+ }
248
+ case 'drag': {
249
+ // A held press that moves: a virtual stick on a phone, a drag on a computer. "to" is [dx, dy] in CSS pixels.
250
+ const from = await pointOf({ ...s, at: s.from ?? s.at }, timeout);
251
+ const [dx, dy] = (s.to ?? s.by ?? [0, 0]).map(Number);
252
+ const ms = Number(s.ms ?? 800); const n = Math.max(4, Math.round(ms / 30));
253
+ if (PHONE) {
254
+ await cursor((x, y) => window.__homieCursor?.ring(x, y), from.x, from.y);
255
+ await page.touchscreen.touchStart(from.x, from.y);
256
+ for (let k = 1; k <= n; k++) { await page.touchscreen.touchMove(from.x + (dx * k) / n, from.y + (dy * k) / n); await sleep(ms / n); }
257
+ await sleep(Number(s.hold ?? 0));
258
+ await page.touchscreen.touchEnd();
259
+ } else {
260
+ await glide(from, 350);
261
+ await page.mouse.down();
262
+ for (let k = 1; k <= n; k++) { const q = { x: from.x + (dx * k) / n, y: from.y + (dy * k) / n }; await Promise.all([page.mouse.move(q.x, q.y), cursor((x, y) => window.__homieCursor?.at(x, y), q.x, q.y)]); await sleep(ms / n); pointer = q; }
263
+ await sleep(Number(s.hold ?? 0));
264
+ await page.mouse.up();
265
+ }
266
+ break;
267
+ }
268
+ case 'focus': {
269
+ const f = await frameNamed(s.frame ?? 'game', timeout);
270
+ if (f !== page.mainFrame()) { const el = await f.frameElement(); await el?.focus(); await f.evaluate(() => window.focus()); }
271
+ if (s.selector) await (await f.waitForSelector(s.selector, { timeout })).focus();
272
+ break;
273
+ }
274
+ case 'key': case 'keys': {
275
+ if (s.frame) { const f = await frameNamed(s.frame, timeout); if (f !== page.mainFrame()) { const el = await f.frameElement(); await el?.focus().catch(() => {}); } }
276
+ const list = s.do === 'key' ? [s.key] : (Array.isArray(s.keys) ? s.keys : [s.keys]);
277
+ const times = Number(s.times ?? 1);
278
+ for (let t = 0; t < times; t++) {
279
+ for (const k of list) {
280
+ // A chord: "Shift+ArrowUp" holds every key together.
281
+ const chord = String(k).split('+');
282
+ for (const c of chord) await page.keyboard.down(c);
283
+ await sleep(Number(s.hold ?? 120));
284
+ for (const c of [...chord].reverse()) await page.keyboard.up(c);
285
+ await sleep(Number(s.gap ?? 120));
286
+ }
287
+ }
288
+ break;
289
+ }
290
+ case 'type': {
291
+ if (s.into || s.selector) {
292
+ const p = await pointOf({ ...s, selector: s.into ?? s.selector }, timeout);
293
+ if (!PHONE) await glide(p, 400);
294
+ await cursor((x, y) => window.__homieCursor?.ring(x, y), p.x, p.y);
295
+ if (PHONE) await page.touchscreen.tap(p.x, p.y); else await page.mouse.click(p.x, p.y);
296
+ }
297
+ await page.keyboard.type(String(s.text ?? ''), { delay: Number(s.delay ?? 70) });
298
+ break;
299
+ }
300
+ case 'scroll': {
301
+ // Real wheel (or touch-free) scrolling over time: never a jump.
302
+ let by = Number(s.by ?? 0);
303
+ if (s.to) {
304
+ const f = await frameNamed(s.frame, timeout);
305
+ const el = await f.waitForSelector(s.to, { timeout });
306
+ const box = await el.boundingBox();
307
+ by = box ? box.y - VH * Number(s.align ?? 0.25) : 0;
308
+ }
309
+ const ms = Number(s.ms ?? Math.min(2400, Math.max(500, Math.abs(by) * 1.6)));
310
+ const n = Math.max(4, Math.round(ms / 33));
311
+ if (pointer === null && !PHONE) await page.mouse.move(VW / 2, VH / 2);
312
+ for (let k = 0; k < n; k++) {
313
+ if (PHONE) await page.evaluate((d) => window.scrollBy(0, d), by / n);
314
+ else await page.mouse.wheel({ deltaY: by / n });
315
+ await sleep(ms / n);
316
+ }
317
+ break;
318
+ }
319
+ default: throw new Error(`unknown step "${s.do}"`);
320
+ }
321
+ }
322
+
323
+ /** Every frame's rAF count (frames drawn per second, where it was measured) and the input it received. */
324
+ async function rafRates(inputs = null) {
325
+ const out = {};
326
+ for (const [name, f] of [['page', page.mainFrame()], ['game', frameOf('game')]]) {
327
+ if (!f) continue;
328
+ try {
329
+ const r = await f.evaluate(() => (window.__homieRaf ? { n: window.__homieRaf.n, ms: performance.now() - window.__homieRaf.t0, keys: window.__homieRaf.keys, pointers: window.__homieRaf.pointers, touches: window.__homieRaf.touches } : null));
330
+ if (r && r.ms > 500) out[name] = +((r.n * 1000) / r.ms).toFixed(1);
331
+ if (r && inputs) inputs[name] = { keys: r.keys, pointers: r.pointers, touches: r.touches };
332
+ } catch { /* gone */ }
333
+ }
334
+ return out;
335
+ }
336
+
337
+ let failed = null;
338
+ try {
339
+ page = await browser.newPage();
340
+ await page.setViewport({ width: VW, height: VH, deviceScaleFactor: DSF, isMobile: PHONE, hasTouch: PHONE });
341
+ // A phone reads as one; and a studio's own site counts the recorder as its QA, never as a visitor or a player.
342
+ const ua0 = await browser.userAgent();
343
+ const ua = PHONE ? ua0.replace(/\([^)]*\)/, '(Linux; Android 14; Pixel 8)').replace('HeadlessChrome', 'Chrome').replace(/Safari\/([\d.]+)$/, 'Mobile Safari/$1') : ua0;
344
+ await page.setUserAgent(`${ua} homie-studio-check video-record`);
345
+ await page.evaluateOnNewDocument(RAF);
346
+ await page.evaluateOnNewDocument(TAP);
347
+ if (CURSOR) await page.evaluateOnNewDocument(CURSOR_JS);
348
+ const errors = [];
349
+ page.on('pageerror', (e) => { if (errors.length < 30) errors.push(String(e.message).slice(0, 200)); });
350
+ // A click that opens another page (Play): the cursor is drawn again where it was.
351
+ page.on('domcontentloaded', () => { if (pointer) cursor((x, y) => window.__homieCursor?.at(x, y), pointer.x, pointer.y); });
352
+ await page.goto(START, { waitUntil: 'load', timeout: 60_000 });
353
+ await sleep(Number(spec.settle ?? 1.5) * 1000);
354
+ // The renderer and the frame rate before anything is recorded: a software renderer makes a game look stuck.
355
+ report.renderer = await page.evaluate(() => {
356
+ try { const gl = document.createElement('canvas').getContext('webgl'); const x = gl && gl.getExtension('WEBGL_debug_renderer_info'); return gl ? String(x ? gl.getParameter(x.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL'; } catch { return 'unknown'; }
357
+ });
358
+ report.software = /swiftshader|llvmpipe|software|basic render/i.test(report.renderer);
359
+ const before = await rafRates();
360
+ report.pageFpsBefore = before.page ?? null;
361
+ if (CURSOR && !PHONE) { pointer = { x: VW * 0.55, y: VH * 0.62 }; await cursor((x, y) => window.__homieCursor?.at(x, y), pointer.x, pointer.y); await page.mouse.move(pointer.x, pointer.y); }
362
+
363
+ const cast = await startScreencast(page, { dir: frameDir, width: RW, height: RH, quality: 90 });
364
+ frames = cast.frames;
365
+ for (let i = 0; i < 100 && !frames.length; i++) await sleep(20);
366
+ videoT0 = frames[0]?.t ?? Date.now() / 1000;
367
+ const deadline = Date.now() + SECONDS * 1000;
368
+ // The game's sound, once a game frame exists: polled beside the steps, on the same clock.
369
+ let polling = true;
370
+ const poller = (async () => {
371
+ while (polling) {
372
+ try {
373
+ if (!tapFrame || tapFrame.isDetached()) {
374
+ tapFrame = frameOf('game');
375
+ if (tapFrame) await tapFrame.evaluate(() => window.__homieTap?.start?.() ?? null).catch(() => null);
376
+ }
377
+ if (tapFrame) await tap.poll(tapFrame);
378
+ } catch (error) { if (report.notes.length < 20) report.notes.push(`sound: ${String(error.message).slice(0, 120)}`); }
379
+ if (freeGB() < MIN_FREE_GB) { report.notes.push('stopped early: disk low'); break; }
380
+ await sleep(250);
381
+ }
382
+ })();
383
+
384
+ for (const [i, s] of steps.entries()) {
385
+ if (Date.now() > deadline) { report.notes.push(`stopped at --seconds ${SECONDS} before step ${i + 1}`); break; }
386
+ const at = secondsNow();
387
+ if (s.caption) captions.push({ from: at, to: at + Number(s.captionSeconds ?? 3), text: String(s.caption) });
388
+ const t = Date.now();
389
+ try {
390
+ // What the step's own frame received while it ran: delivered, not judged (a click that opens a page says so).
391
+ const target = INPUT_STEPS.has(s.do) ? (frameOf(s.frame) ?? null) : null;
392
+ const before = target ? await inputsOf(target) : null;
393
+ await runStep(s);
394
+ let received;
395
+ if (target) {
396
+ await sleep(30);
397
+ const after = await inputsOf(target);
398
+ received = !after || !before || after.id !== before.id ? 'another page opened' : { keys: after.keys - before.keys, pointers: after.pointers - before.pointers, touches: after.touches - before.touches };
399
+ }
400
+ report.steps.push({ i: i + 1, do: s.do, at: +at.toFixed(3), took: +((Date.now() - t) / 1000).toFixed(3), ...(s.selector ? { selector: s.selector } : {}), ...(s.key ? { key: s.key } : {}), ...(received ? { received } : {}) });
401
+ log('step', { i: i + 1, do: s.do, at: +at.toFixed(2) });
402
+ } catch (error) {
403
+ const why = String(error.message ?? error).split('\n')[0].slice(0, 300);
404
+ report.steps.push({ i: i + 1, do: s.do, at: +at.toFixed(3), failed: why });
405
+ if (s.optional) continue;
406
+ failed = { step: i + 1, do: s.do, why };
407
+ break;
408
+ }
409
+ }
410
+ const tailUntil = Math.min(deadline, Date.now() + TAIL * 1000);
411
+ while (Date.now() < tailUntil) await sleep(50);
412
+ report.inputs = {};
413
+ report.pageFps = await rafRates(report.inputs);
414
+ stopAt = Date.now() / 1000;
415
+ polling = false;
416
+ await poller;
417
+ await cast.stop();
418
+ await sleep(200);
419
+ report.errors = errors;
420
+ } catch (error) {
421
+ report.crashed = String(error.stack ?? error).split('\n').slice(0, 3).join(' ');
422
+ } finally {
423
+ tap.close();
424
+ await close();
425
+ }
426
+
427
+ // A still page sends one frame and nothing more until it changes: one is enough (it is held for as long as it showed).
428
+ if (report.crashed || frames.length < 1) {
429
+ writeFileSync(join(OUT, 'recording.json'), `${JSON.stringify({ ...report, frames: frames.length }, null, 1)}\n`);
430
+ rmSync(frameDir, { recursive: true, force: true });
431
+ rmSync(pcmFile, { force: true });
432
+ fail(report.crashed ? `the recording failed: ${report.crashed}` : 'the page sent no frame at all', 1);
433
+ }
434
+
435
+ /* ---- picture: held, never interpolated, to the moment the recording stopped (a still page lasts as long as it did) */
436
+ const cfr = constantRate(frames, FPS, { until: stopAt });
437
+ const concat = writeConcat(join(OUT, 'frames.txt'), cfr.list, FPS);
438
+ const audio = fitAudio(tap, { t0: cfr.t0, total: cfr.total, fps: FPS, out: join(OUT, 'page-audio.raw') });
439
+ rmSync(pcmFile, { force: true });
440
+ const mp4 = join(OUT, 'recording.mp4');
441
+ const seconds = cfr.total / FPS;
442
+ const enc = encodeMp4({ concat, audio, fps: FPS, width: W, height: H, seconds, out: mp4, crf: 18 });
443
+ rmSync(frameDir, { recursive: true, force: true });
444
+ rmSync(concat, { force: true });
445
+ if (audio) delete audio.raw;
446
+ if (!enc.ok) fail(`encode failed: ${enc.why}`, 1);
447
+
448
+ // The steps' seconds are against the first frame, which is the video's first frame.
449
+ const shift = videoT0 !== null ? videoT0 - cfr.t0 : 0;
450
+ for (const s of report.steps) s.at = +(s.at + shift).toFixed(3);
451
+ const vtt = (x) => { const h = Math.floor(x / 3600); const m = Math.floor((x % 3600) / 60); const sec = (x % 60).toFixed(3).padStart(6, '0'); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${sec}`; };
452
+ if (captions.length) {
453
+ const cues = captions.map((c, i) => ({ from: c.from + shift, to: Math.min(seconds, captions[i + 1] ? Math.min(c.to, captions[i + 1].from) : c.to) + shift, text: c.text })).filter((c) => c.to > c.from);
454
+ writeFileSync(join(OUT, 'captions.vtt'), `WEBVTT\n\n${cues.map((c) => `${vtt(c.from)} --> ${vtt(c.to)}\n${c.text}`).join('\n\n')}\n`);
455
+ }
456
+
457
+ const span = cfr.frames.at(-1).t - cfr.t0;
458
+ const painted = +(cfr.frames.length / Math.max(0.001, span)).toFixed(1);
459
+ const slow = Object.entries(report.pageFps ?? {}).filter(([, v]) => v < 20);
460
+ const warnings = [];
461
+ if (report.software) warnings.push(`Chrome drew this page on a software renderer (${report.renderer}): a WebGL page runs far slower here than on a computer with a GPU, and the recording shows that slowness. Record on a computer with a GPU.`);
462
+ if (slow.length) warnings.push(`the ${slow.map(([k, v]) => `${k} ran at ${v}`).join(' and the ')} frames a second here (under 20): the recording shows a slow page, not a stuck one. Try --scale 0.67, fewer programs running, or a computer with a GPU.`);
463
+ const result = {
464
+ ...report, file: 'recording.mp4', seconds: +seconds.toFixed(3), outputFrames: cfr.total, paintedFrames: cfr.frames.length, paintedFps: painted,
465
+ distinctFramesUsed: cfr.distinct, heldFrames: cfr.held, audio, ...(captions.length ? { captions: 'captions.vtt' } : {}), ...(warnings.length ? { warnings } : {}),
466
+ ...(failed ? { failed } : {}),
467
+ honesty: `Recorded in real time from Chrome (${report.renderer}) as the steps ran; every frame is one the page drew, and a moment it did not repaint is held (${cfr.held} of ${cfr.total} frames), never invented.${CURSOR ? ' The cursor and the press rings are drawn by the recorder to show where the script pointed and pressed.' : ''}`,
468
+ };
469
+ writeFileSync(join(OUT, 'recording.json'), `${JSON.stringify(result, null, 1)}\n`);
470
+ const minFpsMiss = MIN_FPS > 0 && Object.values(report.pageFps ?? {}).some((v) => v < MIN_FPS);
471
+ const ok = !failed && !minFpsMiss;
472
+ process.stdout.write(`${JSON.stringify({
473
+ ok, file: mp4, json: join(OUT, 'recording.json'), ...(captions.length ? { captions: join(OUT, 'captions.vtt') } : {}), seconds: result.seconds,
474
+ pageFps: report.pageFps, inputs: report.inputs, paintedFps: painted, heldFrames: cfr.held, steps: report.steps.length, ...(failed ? { failed } : {}), ...(warnings.length ? { warnings } : {}),
475
+ ...(minFpsMiss ? { why: `the page ran under --min-fps ${MIN_FPS}` } : {}),
476
+ })}\n`);
477
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,108 @@
1
+ /*
2
+ * tap.js: a copy of what a game's own WebAudio mix sends to the speaker, for a
3
+ * trailer's soundtrack. Injected ahead of the game's modules (puppeteer
4
+ * evaluateOnNewDocument) and active only in the studio's game frame (/<id>/__game/).
5
+ *
6
+ * - every connect(node -> ctx.destination) of a live AudioContext goes through one
7
+ * unity-gain bus per context instead, so what the speaker gets is unchanged;
8
+ * - the bus also feeds an AudioWorklet that copies each render quantum, stamped with
9
+ * its context frame (currentFrame), as 16-bit stereo;
10
+ * - window.__homieTap.take() drains those copies (base64) with the context frame of
11
+ * each run's first sample; window.__homieTap.clock() samples (ctx.currentTime, page
12
+ * epoch ms) so the audio clock can be fitted to the picture's clock.
13
+ *
14
+ * Nothing here changes a gain, a node or a schedule of the game's own.
15
+ */
16
+ (function () {
17
+ 'use strict';
18
+ if (!/\/__game\//.test(location.pathname)) return;
19
+ const AC = window.AudioContext;
20
+ if (!AC || window.__homieTap) return;
21
+ const origConnect = AudioNode.prototype.connect;
22
+ const origDisconnect = AudioNode.prototype.disconnect;
23
+ const WORKLET = `
24
+ class HomieTap extends AudioWorkletProcessor {
25
+ constructor() { super(); this.on = false; this.cap = 128 * 24; this.buf = new Int16Array(this.cap * 2); this.n = 0; this.f0 = 0;
26
+ this.port.onmessage = (e) => { if (e.data && 'on' in e.data) { this.flush(); this.on = !!e.data.on; } }; }
27
+ flush() { if (this.n) { this.port.postMessage({ f: this.f0, n: this.n, d: this.buf.slice(0, this.n * 2) }); this.n = 0; } }
28
+ process(inputs) {
29
+ if (!this.on) return true;
30
+ const inp = inputs[0] || []; const L = inp[0]; const R = inp[1] || inp[0];
31
+ const n = L ? L.length : 128;
32
+ if (this.n === 0) this.f0 = currentFrame;
33
+ else if (this.f0 + this.n !== currentFrame) { this.flush(); this.f0 = currentFrame; }
34
+ const b = this.buf; let o = this.n * 2;
35
+ for (let i = 0; i < n; i += 1) {
36
+ let l = L ? L[i] : 0; let r = R ? R[i] : 0;
37
+ l = l > 1 ? 1 : l < -1 ? -1 : l; r = r > 1 ? 1 : r < -1 ? -1 : r;
38
+ b[o++] = Math.round(l * 32767); b[o++] = Math.round(r * 32767);
39
+ }
40
+ this.n += n;
41
+ if (this.n + 128 > this.cap) this.flush();
42
+ return true;
43
+ }
44
+ }
45
+ registerProcessor('homie-tap', HomieTap);`;
46
+ const S = { ctxs: [], buses: new Map(), chunks: [], on: false, errors: [] };
47
+ function busFor(ctx) {
48
+ let b = S.buses.get(ctx);
49
+ if (b) return b;
50
+ const bus = ctx.createGain();
51
+ origConnect.call(bus, ctx.destination);
52
+ b = { bus, node: null, ready: false, idx: S.ctxs.length };
53
+ S.buses.set(ctx, b);
54
+ S.ctxs.push(ctx);
55
+ const url = URL.createObjectURL(new Blob([WORKLET], { type: 'text/javascript' }));
56
+ ctx.audioWorklet.addModule(url).then(() => {
57
+ const node = new AudioWorkletNode(ctx, 'homie-tap', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2], channelCount: 2, channelCountMode: 'explicit', channelInterpretation: 'speakers' });
58
+ origConnect.call(bus, node);
59
+ const z = ctx.createGain(); z.gain.value = 0;
60
+ origConnect.call(node, z); origConnect.call(z, ctx.destination);
61
+ node.port.onmessage = (e) => { if (b.idx === 0) S.chunks.push(e.data); };
62
+ b.node = node; b.ready = true;
63
+ if (S.on && b.idx === 0) node.port.postMessage({ on: true });
64
+ }).catch((e) => { S.errors.push(`addModule: ${String((e && e.message) || e)}`); });
65
+ return b;
66
+ }
67
+ AudioNode.prototype.connect = function (target, ...rest) {
68
+ try {
69
+ if (target instanceof AudioDestinationNode && this.context instanceof AC) {
70
+ const b = busFor(this.context);
71
+ origConnect.call(this, b.bus, ...rest);
72
+ return target;
73
+ }
74
+ } catch (e) { S.errors.push(`connect: ${String((e && e.message) || e)}`); }
75
+ return origConnect.call(this, target, ...rest);
76
+ };
77
+ AudioNode.prototype.disconnect = function (...a) {
78
+ if (a[0] instanceof AudioDestinationNode && S.buses.has(this.context)) a[0] = S.buses.get(this.context).bus;
79
+ return origDisconnect.apply(this, a);
80
+ };
81
+ const b64 = (i16) => {
82
+ const u8 = new Uint8Array(i16.buffer, i16.byteOffset, i16.byteLength); let s = '';
83
+ for (let k = 0; k < u8.length; k += 0x8000) s += String.fromCharCode.apply(null, u8.subarray(k, k + 0x8000));
84
+ return btoa(s);
85
+ };
86
+ window.__homieTap = {
87
+ start() { S.on = true; const b = S.ctxs[0] && S.buses.get(S.ctxs[0]); if (b && b.ready) b.node.port.postMessage({ on: true }); return { ctxs: S.ctxs.length, ready: !!(b && b.ready), rate: S.ctxs[0] ? S.ctxs[0].sampleRate : null }; },
88
+ stop() { S.on = false; const b = S.ctxs[0] && S.buses.get(S.ctxs[0]); if (b && b.ready) b.node.port.postMessage({ on: false }); },
89
+ /** The copies since the last take, as runs of contiguous context frames. */
90
+ take() {
91
+ if (!S.chunks.length) return [];
92
+ const cs = S.chunks.splice(0);
93
+ const runs = []; let cur = null;
94
+ for (const c of cs) {
95
+ if (cur && c.f === cur.f + cur.n) { cur.parts.push(c.d); cur.n += c.n; }
96
+ else { cur = { f: c.f, n: c.n, parts: [c.d] }; runs.push(cur); }
97
+ }
98
+ return runs.map((r) => { const a = new Int16Array(r.n * 2); let o = 0; for (const p of r.parts) { a.set(p, o); o += p.length; } return { f: r.f, frames: r.n, b64: b64(a) }; });
99
+ },
100
+ clock() {
101
+ const c = S.ctxs[0];
102
+ const wall = performance.timeOrigin + performance.now();
103
+ if (!c) return { ctxs: 0, wall };
104
+ let ot = null; try { const o = c.getOutputTimestamp(); ot = [o.contextTime, performance.timeOrigin + o.performanceTime]; } catch (e) { /* */ }
105
+ return { ctxs: S.ctxs.length, now: c.currentTime, wall, ot, state: c.state, rate: c.sampleRate, ready: !!(S.buses.get(c) || {}).ready, errors: S.errors.slice(0, 5) };
106
+ },
107
+ };
108
+ }());