@homie-rocks/studio 0.51.4 → 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.
- package/CHANGELOG.md +29 -0
- package/README.md +10 -29
- package/apps/APPS.md +1 -1
- package/bin/homie-studio.mjs +28 -30
- package/guides/app/SKILL.md +1 -1
- package/guides/art/SKILL.md +68 -140
- package/guides/art/scripts/art.mjs +6 -79
- package/guides/models/SKILL.md +73 -177
- package/guides/models/references/RIGHTS.md +1 -1
- package/guides/models/scripts/models.mjs +7 -288
- package/guides/music/SKILL.md +74 -183
- package/guides/music/references/COMPOSITION.md +21 -26
- package/guides/music/references/GAME-AUDIO.md +3 -3
- package/guides/music/references/RIGHTS.md +12 -15
- package/guides/music/scripts/music.mjs +11 -202
- package/guides/parts/SKILL.md +1 -1
- package/guides/shop/SKILL.md +68 -77
- package/guides/sound/SKILL.md +70 -1
- package/guides/sound/scripts/player/sound.js +233 -0
- package/guides/studio-setup/SKILL.md +11 -27
- package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
- package/guides/style/SKILL.md +66 -3
- package/guides/video/SKILL.md +83 -0
- package/guides/video/references/DELIVERY.md +73 -0
- package/guides/video/references/HONESTY.md +40 -0
- package/guides/video/references/METHOD.md +144 -0
- package/guides/video/references/RECORD.md +137 -0
- package/guides/video/references/TRAILER.md +154 -0
- package/guides/video/references/examples/site-walkthrough.json +14 -0
- package/guides/video/references/examples/studio-play-phone.json +15 -0
- package/guides/video/references/examples/studio-play.json +17 -0
- package/guides/video/scripts/capture-game.mjs +175 -0
- package/guides/video/scripts/clock.js +136 -0
- package/guides/video/scripts/drawover/film.js +58 -0
- package/guides/video/scripts/drawover/index.html +9 -0
- package/guides/video/scripts/drawover/look.js +115 -0
- package/guides/video/scripts/drawover/shots.json +12 -0
- package/guides/video/scripts/lib/browser.mjs +87 -0
- package/guides/video/scripts/lib/frames.mjs +170 -0
- package/guides/video/scripts/lib/remix.mjs +159 -0
- package/guides/video/scripts/qa.mjs +127 -0
- package/guides/video/scripts/record-fixed.mjs +403 -0
- package/guides/video/scripts/record-page.mjs +477 -0
- package/guides/video/scripts/tap.js +108 -0
- package/guides/video/scripts/video.mjs +888 -0
- package/lib/build.mjs +12 -9
- package/lib/cloudflare.mjs +6 -11
- package/lib/dev.mjs +1 -2
- package/lib/doctor.mjs +10 -54
- package/lib/mcp-tools.mjs +42 -21
- package/lib/mcp.mjs +1 -1
- package/lib/media-import.mjs +41 -0
- package/lib/media-workflows.mjs +13 -0
- package/lib/official-media.mjs +15 -0
- package/lib/rules-build.mjs +13 -5
- package/lib/scaffold.mjs +2 -2
- package/lib/shop-links.mjs +54 -18
- package/lib/shop-listen.mjs +43 -0
- package/lib/shop-local.mjs +1 -1
- package/lib/shop-mcp.mjs +58 -0
- package/lib/stripe-connect.mjs +36 -15
- package/lib/template-history.json +45 -0
- package/lib/upgrade-media.mjs +13 -0
- package/lib/upgrade.mjs +10 -0
- package/package.json +1 -1
- package/parts/PARTS.md +1 -1
- package/rules/view.ts +4 -0
- package/shop/SHOP.md +66 -34
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +5 -1
- package/worker/purchase-reconciliation.mjs +16 -1
- package/worker/shop-subscriptions.mjs +2 -0
- package/worker/shop.mjs +7 -5
- package/worker/stripe-ownership.mjs +7 -0
- package/worker/version.mjs +1 -1
- package/guides/models/references/models.json +0 -175
- package/guides/music/scripts/lib/eleven.mjs +0 -260
- package/lib/projects-env.mjs +0 -68
- package/lib/projects.mjs +0 -311
- package/lib/provider-key.mjs +0 -21
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* record-fixed.mjs — a page rendered FRAME BY FRAME on a virtual clock, with its sound rebuilt offline: for
|
|
4
|
+
* a trailer of a game too heavy to draw every frame in real time, and for any film that must not hold a frame.
|
|
5
|
+
*
|
|
6
|
+
* node record-fixed.mjs --url <page> --out <folder> [--seconds 30] [--fps 30] [--width 1920] [--height 1080]
|
|
7
|
+
* [--scale 1] [--settle 2] [--wait-for "<js that turns true>"] [--steps <steps.json>] [--css "<css>"]
|
|
8
|
+
* [--frame game] [--no-pace] [--min-free-gb 10]
|
|
9
|
+
*
|
|
10
|
+
* How it differs from capture-game.mjs and record-page.mjs (which film the page in real time and HOLD a frame
|
|
11
|
+
* the page did not paint in time): here the page's clock is ours (clock.js). Each film frame, the page's
|
|
12
|
+
* requestAnimationFrame, performance.now, Date.now, setTimeout, setInterval and its CSS and Web animations all
|
|
13
|
+
* move on by exactly 1/fps, the page draws, and the frame is copied. A frame that takes half a second to draw
|
|
14
|
+
* is still one frame of film, so there are no held frames, at any size, on any computer.
|
|
15
|
+
*
|
|
16
|
+
* Sound: a page on a virtual clock has no speaker worth recording (its audio clock runs at the wall's speed).
|
|
17
|
+
* The recorder sets window.__homieSoundCapture before the page's scripts, the sound player logs every
|
|
18
|
+
* sound it schedules (sound.js says exactly what), the files it names are fetched from the page's own
|
|
19
|
+
* origin, and lib/remix.mjs mixes them again on the film's clock. A game that does not use sound.js
|
|
20
|
+
* and does not push the same events records silent, and the result says so.
|
|
21
|
+
*
|
|
22
|
+
* Steps (optional): the same file as record-page.mjs, on the virtual clock, with these steps only:
|
|
23
|
+
* wait, waitFor (selector | text | js | frame), click, tap, key, keys, type, focus, caption.
|
|
24
|
+
* No cursor is drawn, and there is no hover, drag or scroll here: use record-page.mjs for those.
|
|
25
|
+
*
|
|
26
|
+
* What stays on the wall's clock, and so is NOT covered (clock.js lists them): the audio clock, <video> and
|
|
27
|
+
* <audio> elements, Web Workers, and the network. A page fed by a live server (a room on a relay) sees that
|
|
28
|
+
* server run fast when frames take longer than 1/fps to render: the result gives the speed (film seconds per
|
|
29
|
+
* real second) and warns when a page with an open WebSocket ran well under 1. Rendering is never run faster
|
|
30
|
+
* than real time (--no-pace lifts that, for a page with no server).
|
|
31
|
+
*
|
|
32
|
+
* Out: <out>/capture.mp4 (H.264 limited-range BT.709, + AAC when the log held sound), <out>/capture.json,
|
|
33
|
+
* <out>/events.json (the sound log on the film's clock), <out>/sound.wav, sfx.wav and music.wav (the mix
|
|
34
|
+
* and its two buses), <out>/captions.vtt when steps have captions. Raw frames are deleted once encoded.
|
|
35
|
+
*/
|
|
36
|
+
import { mkdirSync, readFileSync, rmSync, statfsSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
import { launch } from './lib/browser.mjs';
|
|
40
|
+
import { encodeMp4, writeConcat } from './lib/frames.mjs';
|
|
41
|
+
import { remix } from './lib/remix.mjs';
|
|
42
|
+
import { findStudio } from '../../music/scripts/lib/studio.mjs';
|
|
43
|
+
import { wavBytes } from '../../sound/scripts/lib/synth.mjs';
|
|
44
|
+
|
|
45
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
const argv = process.argv.slice(2);
|
|
47
|
+
const has = (n) => argv.includes(`--${n}`);
|
|
48
|
+
const opt = (n, d = null) => { const i = argv.indexOf(`--${n}`); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
|
|
49
|
+
const fail = (m, code = 2) => { process.stderr.write(`record-fixed: ${m}\n`); process.exit(code); };
|
|
50
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
51
|
+
const log = (k, v = {}) => process.stderr.write(`${JSON.stringify({ t: +(process.uptime()).toFixed(1), k, ...v })}\n`);
|
|
52
|
+
|
|
53
|
+
let spec = { steps: [] };
|
|
54
|
+
if (opt('steps')) { try { spec = JSON.parse(readFileSync(resolve(String(opt('steps'))), 'utf8')); } catch (error) { fail(`--steps: ${error.message}`); } }
|
|
55
|
+
if (!Array.isArray(spec.steps)) fail('the steps file needs "steps": [ ... ]');
|
|
56
|
+
if (!opt('out')) fail('--out <folder>');
|
|
57
|
+
const OUT = resolve(String(opt('out')));
|
|
58
|
+
const base = String(opt('url', spec.base ?? '')).replace(/\/+$/, '');
|
|
59
|
+
const START = spec.url ?? (spec.path != null ? `${base}${String(spec.path).startsWith('/') ? '' : '/'}${spec.path}` : String(opt('url', '')));
|
|
60
|
+
if (!/^https?:\/\//.test(START)) fail('--url <the page to film> (or "url" / "path" in the steps file)');
|
|
61
|
+
const FPS = Number(opt('fps', spec.fps ?? 30));
|
|
62
|
+
const W = Number(opt('width', spec.width ?? 1920)); const H = Number(opt('height', spec.height ?? 1080));
|
|
63
|
+
const SCALE = Number(opt('scale', spec.scale ?? 1));
|
|
64
|
+
const SECONDS = Number(opt('seconds', spec.seconds ?? 30));
|
|
65
|
+
const SETTLE = Number(opt('settle', spec.settle ?? 2));
|
|
66
|
+
const TAIL = Number(spec.tail ?? 2);
|
|
67
|
+
const WAIT_FOR = opt('wait-for', spec.waitFor ?? null);
|
|
68
|
+
const WAIT_FRAME = opt('frame', spec.frame ?? null);
|
|
69
|
+
const CSS = opt('css', spec.css ?? null);
|
|
70
|
+
const PACE = !has('no-pace');
|
|
71
|
+
const MIN_FREE_GB = Number(opt('min-free-gb', 10));
|
|
72
|
+
if (!(Number.isInteger(FPS) && FPS >= 1 && FPS <= 60)) fail('--fps 1..60, a whole number');
|
|
73
|
+
if (!(SCALE >= 0.25 && SCALE <= 1)) fail('--scale 0.25..1');
|
|
74
|
+
if (!(SECONDS >= 1 && SECONDS <= 600)) fail('--seconds 1..600');
|
|
75
|
+
const RW = Math.max(2, Math.round((W * SCALE) / 2) * 2); const RH = Math.max(2, Math.round((H * SCALE) / 2) * 2);
|
|
76
|
+
const DT = 1000 / FPS;
|
|
77
|
+
|
|
78
|
+
mkdirSync(OUT, { recursive: true });
|
|
79
|
+
const freeGB = () => { try { const f = statfsSync(OUT); return (f.bavail * f.bsize) / 1e9; } catch { return 99; } };
|
|
80
|
+
if (freeGB() < MIN_FREE_GB + 2) fail(`only ${freeGB().toFixed(1)} GB free; a recording needs room (stopping below ${MIN_FREE_GB} GB)`);
|
|
81
|
+
|
|
82
|
+
const CLOCK = readFileSync(join(HERE, 'clock.js'), 'utf8');
|
|
83
|
+
// The sound log is asked for in every frame, before the page's own scripts: see sound.js.
|
|
84
|
+
const ASK = '(() => { if (!window.__homieSoundCapture) window.__homieSoundCapture = { events: [] }; })();';
|
|
85
|
+
|
|
86
|
+
/* ---------------------------------------------------------------- steps, on the virtual clock */
|
|
87
|
+
|
|
88
|
+
const KINDS = ['wait', 'waitFor', 'click', 'tap', 'key', 'keys', 'type', 'focus', 'caption'];
|
|
89
|
+
const NOT_HERE = ['goto', 'hover', 'move', 'drag', 'scroll'];
|
|
90
|
+
function normal(raw, i) {
|
|
91
|
+
if (!raw || typeof raw !== 'object') throw new Error(`step ${i + 1} is not an object`);
|
|
92
|
+
const other = NOT_HERE.find((k) => raw.do === k || k in raw);
|
|
93
|
+
if (other) throw new Error(`step ${i + 1}: "${other}" is not a step of the frame-by-frame recorder (it has ${KINDS.join(', ')}); record-page.mjs has it, in real time`);
|
|
94
|
+
const kind = raw.do ?? KINDS.find((k) => k in raw);
|
|
95
|
+
if (!KINDS.includes(kind)) throw new Error(`step ${i + 1}: say what to do (${KINDS.join(', ')})`);
|
|
96
|
+
const s = { ...raw, do: kind };
|
|
97
|
+
const v = raw.do ? undefined : raw[kind];
|
|
98
|
+
if (kind === 'wait' && v !== undefined) s.ms = Number(v);
|
|
99
|
+
if (kind === 'waitFor' && v !== undefined) Object.assign(s, typeof v === 'string' ? { selector: v } : v);
|
|
100
|
+
if (['click', 'tap'].includes(kind) && v !== undefined) Object.assign(s, typeof v === 'string' ? { selector: v } : Array.isArray(v) ? { at: v } : v);
|
|
101
|
+
if (kind === 'key' && v !== undefined) s.key = v;
|
|
102
|
+
if (kind === 'keys' && v !== undefined) s.keys = v;
|
|
103
|
+
if (kind === 'type' && v !== undefined) s.text = v;
|
|
104
|
+
if (kind === 'focus' && v !== undefined) Object.assign(s, typeof v === 'string' ? { frame: v } : v);
|
|
105
|
+
if (kind === 'caption' && v !== undefined) s.caption = v;
|
|
106
|
+
return s;
|
|
107
|
+
}
|
|
108
|
+
let steps;
|
|
109
|
+
try { steps = spec.steps.map(normal); } catch (error) { fail(error.message); }
|
|
110
|
+
|
|
111
|
+
const root = findStudio(OUT) ?? findStudio();
|
|
112
|
+
const report = { ran: new Date().toISOString(), start: START, fps: FPS, size: [W, H], ...(SCALE !== 1 ? { scale: SCALE, rendered: [RW, RH] } : {}), steps: [], notes: [] };
|
|
113
|
+
const { browser, close, pid } = await launch(root, { width: RW, height: RH, extra: ['--autoplay-policy=no-user-gesture-required', '--mute-audio'] });
|
|
114
|
+
log('browser', { pid });
|
|
115
|
+
const frameDir = join(OUT, 'frames');
|
|
116
|
+
rmSync(frameDir, { recursive: true, force: true });
|
|
117
|
+
mkdirSync(frameDir, { recursive: true });
|
|
118
|
+
const stop = async (why) => { log('stop', { why }); await close(); process.exit(3); };
|
|
119
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
120
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
121
|
+
|
|
122
|
+
let page;
|
|
123
|
+
let vt = 0; // the master clock, virtual ms since the page was opened
|
|
124
|
+
let vt0 = null; // the master clock when the film's first frame was copied
|
|
125
|
+
const events = []; // the sound log, t in seconds on the film's clock (negative: before the first frame)
|
|
126
|
+
const clockSeen = { timers: 0, animations: 0, frames: 0, errors: [] };
|
|
127
|
+
const captions = [];
|
|
128
|
+
const files = [];
|
|
129
|
+
let failed = null;
|
|
130
|
+
let sockets = 0;
|
|
131
|
+
|
|
132
|
+
function frameOf(name) {
|
|
133
|
+
if (!name || name === 'top' || name === 'page') return page.mainFrame();
|
|
134
|
+
const want = name === 'game' ? /\/__game\//i : null;
|
|
135
|
+
return page.frames().find((f) => !f.isDetached() && (want ? want.test(f.url()) : f.url().includes(String(name)))) ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One film frame of time in every frame of the page, together; the sound each one logged comes back with it. */
|
|
139
|
+
async function stepAll(ms) {
|
|
140
|
+
const before = vt;
|
|
141
|
+
vt += ms;
|
|
142
|
+
await Promise.all(page.frames().filter((f) => !f.isDetached()).map(async (f) => {
|
|
143
|
+
let r = null;
|
|
144
|
+
try {
|
|
145
|
+
r = await f.evaluate((d) => (window.__homieClock ? window.__homieClock.step(d).then((x) => ({ ...x, events: window.__homieSoundCapture ? window.__homieSoundCapture.events.splice(0) : [] })) : null), ms);
|
|
146
|
+
} catch { /* a frame that went away mid-step */ }
|
|
147
|
+
if (!r) return;
|
|
148
|
+
clockSeen.timers = Math.max(clockSeen.timers, r.timers); clockSeen.animations = Math.max(clockSeen.animations, r.animations); clockSeen.frames = Math.max(clockSeen.frames, r.frames);
|
|
149
|
+
for (const e of r.errors ?? []) if (clockSeen.errors.length < 20) clockSeen.errors.push(e);
|
|
150
|
+
// A frame's own clock began at 0 when its document did: its events are moved onto the master clock.
|
|
151
|
+
const offset = before + ms - r.now;
|
|
152
|
+
for (const e of r.events) events.push({ ...e, t: e.t + offset });
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ---- the steps run as one script that sleeps on the virtual clock; the frame loop wakes it between frames */
|
|
157
|
+
const sleepers = [];
|
|
158
|
+
let parkResolve = () => {};
|
|
159
|
+
let scriptDone = steps.length === 0;
|
|
160
|
+
let scriptEndedAt = steps.length === 0 ? 0 : null;
|
|
161
|
+
const vsleep = (ms) => new Promise((res) => { sleepers.push({ at: vt + Math.max(0, ms), res }); parkResolve(); });
|
|
162
|
+
async function wake() {
|
|
163
|
+
const due = sleepers.filter((s) => s.at <= vt + 1e-6);
|
|
164
|
+
if (!due.length || scriptDone) return;
|
|
165
|
+
const parked = new Promise((r) => { parkResolve = r; });
|
|
166
|
+
for (const s of due) { sleepers.splice(sleepers.indexOf(s), 1); s.res(); }
|
|
167
|
+
await parked;
|
|
168
|
+
}
|
|
169
|
+
const filmNow = () => (vt0 === null ? 0 : (vt - vt0) / 1000);
|
|
170
|
+
async function until(test, timeout, what) {
|
|
171
|
+
const end = vt + timeout;
|
|
172
|
+
for (;;) {
|
|
173
|
+
if (await test().catch(() => false)) return;
|
|
174
|
+
if (vt > end) throw new Error(`${what} did not happen within ${timeout} ms of the film`);
|
|
175
|
+
await vsleep(DT);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function pointOf(s, timeout) {
|
|
179
|
+
if (Array.isArray(s.at)) { const [x, y] = s.at.map(Number); return x <= 1 && y <= 1 && x >= 0 && y >= 0 ? { x: x * RW, y: y * RH } : { x, y }; }
|
|
180
|
+
if (!s.selector) throw new Error('give "selector" or "at": [x, y]');
|
|
181
|
+
let box = null;
|
|
182
|
+
await until(async () => { const f = frameOf(s.frame); const el = f ? await f.$(s.selector) : null; box = el ? await el.boundingBox() : null; return Boolean(box); }, timeout, `"${s.selector}"`);
|
|
183
|
+
return { x: box.x + box.width * (s.offset?.[0] ?? 0.5), y: box.y + box.height * (s.offset?.[1] ?? 0.5) };
|
|
184
|
+
}
|
|
185
|
+
async function runStep(s) {
|
|
186
|
+
const timeout = Number(s.timeout ?? 20_000);
|
|
187
|
+
switch (s.do) {
|
|
188
|
+
case 'wait': await vsleep(Number(s.ms ?? 1000)); break;
|
|
189
|
+
case 'caption': await vsleep(Number(s.ms ?? 0)); break;
|
|
190
|
+
case 'waitFor': {
|
|
191
|
+
if (s.selector) await until(async () => Boolean(await frameOf(s.frame)?.$(s.selector)), timeout, `"${s.selector}"`);
|
|
192
|
+
else if (s.text) await until(() => frameOf(s.frame).evaluate((t) => Boolean(document.body && document.body.innerText.includes(t)), String(s.text)), timeout, `the text "${s.text}"`);
|
|
193
|
+
else if (s.js) await until(() => frameOf(s.frame).evaluate(String(s.js)).then(Boolean), timeout, 'the "js" condition');
|
|
194
|
+
else if (s.frame) await until(async () => Boolean(frameOf(s.frame)), timeout, `the frame "${s.frame}"`);
|
|
195
|
+
else throw new Error('waitFor needs "selector", "text", "js" or "frame"');
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case 'click': { const p = await pointOf(s, timeout); await page.mouse.move(p.x, p.y); await page.mouse.down(); await vsleep(Number(s.hold ?? 60)); await page.mouse.up(); break; }
|
|
199
|
+
case 'tap': { const p = await pointOf(s, timeout); await page.touchscreen.touchStart(p.x, p.y); await vsleep(Number(s.hold ?? 60)); await page.touchscreen.touchEnd(); break; }
|
|
200
|
+
case 'focus': {
|
|
201
|
+
await until(async () => Boolean(frameOf(s.frame ?? 'game')), timeout, `the frame "${s.frame ?? 'game'}"`);
|
|
202
|
+
const f = frameOf(s.frame ?? 'game');
|
|
203
|
+
if (f !== page.mainFrame()) { const el = await f.frameElement(); await el?.focus(); await f.evaluate(() => window.focus()); }
|
|
204
|
+
if (s.selector) await (await f.$(s.selector))?.focus();
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case 'key': case 'keys': {
|
|
208
|
+
if (s.frame) { const f = frameOf(s.frame); if (f && f !== page.mainFrame()) { const el = await f.frameElement(); await el?.focus().catch(() => {}); } }
|
|
209
|
+
const list = s.do === 'key' ? [s.key] : (Array.isArray(s.keys) ? s.keys : [s.keys]);
|
|
210
|
+
for (let t = 0; t < Number(s.times ?? 1); t++) {
|
|
211
|
+
for (const k of list) {
|
|
212
|
+
const chord = String(k).split('+');
|
|
213
|
+
for (const c of chord) await page.keyboard.down(c);
|
|
214
|
+
await vsleep(Number(s.hold ?? 120));
|
|
215
|
+
for (const c of [...chord].reverse()) await page.keyboard.up(c);
|
|
216
|
+
await vsleep(Number(s.gap ?? 120));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case 'type': {
|
|
222
|
+
if (s.into || s.selector) { const p = await pointOf({ ...s, selector: s.into ?? s.selector }, timeout); await page.mouse.click(p.x, p.y); }
|
|
223
|
+
for (const ch of String(s.text ?? '')) { await page.keyboard.type(ch); await vsleep(Number(s.delay ?? 70)); }
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
default: throw new Error(`unknown step "${s.do}"`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function script() {
|
|
230
|
+
try {
|
|
231
|
+
for (const [i, s] of steps.entries()) {
|
|
232
|
+
const at = filmNow();
|
|
233
|
+
if (s.caption) captions.push({ from: at, to: at + Number(s.captionSeconds ?? 3), text: String(s.caption) });
|
|
234
|
+
try {
|
|
235
|
+
await runStep(s);
|
|
236
|
+
report.steps.push({ i: i + 1, do: s.do, at: +at.toFixed(3), took: +(filmNow() - at).toFixed(3), ...(s.selector ? { selector: s.selector } : {}), ...(s.key ? { key: s.key } : {}) });
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const why = String(error.message ?? error).split('\n')[0].slice(0, 300);
|
|
239
|
+
report.steps.push({ i: i + 1, do: s.do, at: +at.toFixed(3), failed: why });
|
|
240
|
+
if (s.optional) continue;
|
|
241
|
+
failed = { step: i + 1, do: s.do, why };
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} finally { scriptDone = true; scriptEndedAt = filmNow(); parkResolve(); }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ---------------------------------------------------------------- the film */
|
|
249
|
+
|
|
250
|
+
const realStart = Date.now();
|
|
251
|
+
let realRecordStart = null;
|
|
252
|
+
try {
|
|
253
|
+
page = await browser.newPage();
|
|
254
|
+
await page.setViewport({ width: RW, height: RH, deviceScaleFactor: 1 });
|
|
255
|
+
const ua0 = await browser.userAgent();
|
|
256
|
+
await page.setUserAgent(`${ua0} homie-studio-check video-record`);
|
|
257
|
+
await page.evaluateOnNewDocument(ASK);
|
|
258
|
+
await page.evaluateOnNewDocument(CLOCK);
|
|
259
|
+
const errors = [];
|
|
260
|
+
page.on('pageerror', (e) => { if (errors.length < 30) errors.push(String(e.message).slice(0, 200)); });
|
|
261
|
+
const cdp = await page.createCDPSession();
|
|
262
|
+
await cdp.send('Network.enable').catch(() => {});
|
|
263
|
+
cdp.on('Network.webSocketCreated', () => { sockets++; });
|
|
264
|
+
let loaded = false;
|
|
265
|
+
page.once('load', () => { loaded = true; });
|
|
266
|
+
// The page cannot finish loading with its clock stopped (a loader on a timer, a fade-in), so the clock is run at
|
|
267
|
+
// the wall's speed until the page is ready: loaded, settled, and whatever --wait-for asks for.
|
|
268
|
+
const nav = page.goto(START, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
269
|
+
nav.catch(() => {});
|
|
270
|
+
await nav;
|
|
271
|
+
if (CSS) await page.addStyleTag({ content: String(CSS) }).catch(() => {});
|
|
272
|
+
const ready = async () => {
|
|
273
|
+
if (!loaded || vt < SETTLE * 1000) return false;
|
|
274
|
+
if (WAIT_FRAME && !frameOf(WAIT_FRAME)) return false;
|
|
275
|
+
if (!WAIT_FOR) return true;
|
|
276
|
+
return Boolean(await (frameOf(WAIT_FRAME) ?? page.mainFrame()).evaluate(String(WAIT_FOR)).catch(() => false));
|
|
277
|
+
};
|
|
278
|
+
const giveUp = Date.now() + 90_000;
|
|
279
|
+
for (;;) {
|
|
280
|
+
const t = Date.now();
|
|
281
|
+
await stepAll(DT);
|
|
282
|
+
if (await ready()) break;
|
|
283
|
+
if (Date.now() > giveUp) throw new Error(`the page was not ready within 90 s${WAIT_FOR ? ` (--wait-for ${WAIT_FOR})` : ''}${WAIT_FRAME ? ` (--frame ${WAIT_FRAME})` : ''}`);
|
|
284
|
+
const left = DT - (Date.now() - t);
|
|
285
|
+
if (left > 0) await sleep(left);
|
|
286
|
+
}
|
|
287
|
+
await page.keyboard.press('Shift').catch(() => {}); // a first gesture, for a game that starts its sound on one
|
|
288
|
+
report.renderer = await page.evaluate(() => {
|
|
289
|
+
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'; }
|
|
290
|
+
}).catch(() => 'unknown');
|
|
291
|
+
log('ready', { settledAt: +(vt / 1000).toFixed(2), renderer: report.renderer });
|
|
292
|
+
|
|
293
|
+
vt0 = vt;
|
|
294
|
+
realRecordStart = Date.now();
|
|
295
|
+
const maxFrames = Math.round(SECONDS * FPS);
|
|
296
|
+
if (steps.length) { const parked = new Promise((r) => { parkResolve = r; }); script(); await parked; }
|
|
297
|
+
let lastLog = Date.now();
|
|
298
|
+
for (let k = 0; k < maxFrames; k++) {
|
|
299
|
+
const shot = await cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 92, optimizeForSpeed: true });
|
|
300
|
+
const file = join(frameDir, `${String(k).padStart(6, '0')}.jpg`);
|
|
301
|
+
writeFileSync(file, Buffer.from(shot.data, 'base64'));
|
|
302
|
+
files.push(file);
|
|
303
|
+
// With steps, the film ends `tail` seconds after the last one (or at --seconds); without, at --seconds.
|
|
304
|
+
if (steps.length && scriptDone && filmNow() >= scriptEndedAt + TAIL) break;
|
|
305
|
+
if (failed) break;
|
|
306
|
+
if (freeGB() < MIN_FREE_GB) { report.notes.push('stopped early: disk low'); break; }
|
|
307
|
+
await wake();
|
|
308
|
+
await stepAll(DT);
|
|
309
|
+
if (PACE) { const ahead = (vt - vt0) - (Date.now() - realRecordStart); if (ahead > 0) await sleep(ahead); }
|
|
310
|
+
if (Date.now() - lastLog > 10_000) { lastLog = Date.now(); log('rendering', { frame: k + 1, of: maxFrames, filmS: +filmNow().toFixed(1), realS: +((Date.now() - realRecordStart) / 1000).toFixed(1) }); }
|
|
311
|
+
}
|
|
312
|
+
if (steps.length && !scriptDone) report.notes.push(`stopped at --seconds ${SECONDS} before the steps finished`);
|
|
313
|
+
report.errors = errors;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
report.crashed = String(error.stack ?? error).split('\n').slice(0, 3).join(' ');
|
|
316
|
+
}
|
|
317
|
+
const realSeconds = realRecordStart ? (Date.now() - realRecordStart) / 1000 : 0;
|
|
318
|
+
|
|
319
|
+
/* ---- the game's own sound files, from the page's own origin and nowhere else */
|
|
320
|
+
const origin = new URL(START).origin;
|
|
321
|
+
const soundDir = join(OUT, 'sounds');
|
|
322
|
+
const local = new Map();
|
|
323
|
+
const notFetched = [];
|
|
324
|
+
const filmEvents = events.map((e) => ({ ...e, t: +((e.t - (vt0 ?? 0)) / 1000).toFixed(4) })).sort((a, b) => a.t - b.t);
|
|
325
|
+
if (!report.crashed && files.length) {
|
|
326
|
+
rmSync(soundDir, { recursive: true, force: true });
|
|
327
|
+
const urls = [...new Set(filmEvents.filter((e) => e.type === 'start' && e.url).map((e) => e.url))];
|
|
328
|
+
if (urls.length) mkdirSync(soundDir, { recursive: true });
|
|
329
|
+
for (const [i, u] of urls.entries()) {
|
|
330
|
+
let ok = false;
|
|
331
|
+
try {
|
|
332
|
+
if (new URL(u).origin !== origin) throw new Error('another origin');
|
|
333
|
+
const r = await fetch(u);
|
|
334
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
335
|
+
const f = join(soundDir, `${String(i).padStart(3, '0')}-${basename(new URL(u).pathname).replace(/[^A-Za-z0-9._-]/g, '_')}`);
|
|
336
|
+
writeFileSync(f, Buffer.from(await r.arrayBuffer()));
|
|
337
|
+
local.set(u, f); ok = true;
|
|
338
|
+
} catch (error) { notFetched.push({ url: u, why: String(error.message ?? error).slice(0, 120) }); }
|
|
339
|
+
if (!ok) local.set(u, null);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
await close();
|
|
343
|
+
|
|
344
|
+
if (report.crashed || files.length < 1) {
|
|
345
|
+
writeFileSync(join(OUT, 'capture.json'), `${JSON.stringify({ ...report, frames: files.length }, null, 1)}\n`);
|
|
346
|
+
rmSync(frameDir, { recursive: true, force: true });
|
|
347
|
+
fail(report.crashed ? `the recording failed: ${report.crashed}` : 'no frame was rendered', 1);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const total = files.length;
|
|
351
|
+
const seconds = total / FPS;
|
|
352
|
+
/* ---- sound: the log mixed again on the film's clock */
|
|
353
|
+
let audio = null;
|
|
354
|
+
const starts = filmEvents.filter((e) => e.type === 'start');
|
|
355
|
+
if (starts.length) {
|
|
356
|
+
const m = remix(filmEvents, { seconds, fileFor: (u) => local.get(u) ?? null });
|
|
357
|
+
for (const [name, buf] of [['sound', m.mix], ['sfx', m.sfx], ['music', m.music]]) writeFileSync(join(OUT, `${name}.wav`), wavBytes(buf, { bits: 16 }));
|
|
358
|
+
const raw = join(OUT, 'sound.raw');
|
|
359
|
+
const pcm = Buffer.alloc(m.mix.L.length * 4);
|
|
360
|
+
for (let i = 0; i < m.mix.L.length; i++) { pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, m.mix.L[i])) * 32767), i * 4); pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, m.mix.R[i])) * 32767), i * 4 + 2); }
|
|
361
|
+
writeFileSync(raw, pcm);
|
|
362
|
+
audio = { rebuilt: true, rate: 48000, seconds: +m.report.seconds.toFixed(3), peakDb: m.report.peakDb, placed: m.report.placed, dropped: m.report.dropped, files: m.report.files, limited: m.report.limited, missing: m.report.missing, stems: { mix: 'sound.wav', sfx: 'sfx.wav', music: 'music.wav' }, raw, note: m.report.note };
|
|
363
|
+
} else for (const f of ['sound.wav', 'sfx.wav', 'music.wav']) rmSync(join(OUT, f), { force: true });
|
|
364
|
+
writeFileSync(join(OUT, 'events.json'), `${JSON.stringify({ fps: FPS, frames: total, seconds: +seconds.toFixed(4), origin, events: filmEvents }, null, 1)}\n`);
|
|
365
|
+
|
|
366
|
+
const concat = writeConcat(join(OUT, 'frames.txt'), files, FPS);
|
|
367
|
+
const mp4 = join(OUT, 'capture.mp4');
|
|
368
|
+
const enc = encodeMp4({ concat, audio, fps: FPS, width: W, height: H, seconds, out: mp4, crf: 16 });
|
|
369
|
+
rmSync(frameDir, { recursive: true, force: true });
|
|
370
|
+
rmSync(concat, { force: true });
|
|
371
|
+
rmSync(soundDir, { recursive: true, force: true });
|
|
372
|
+
if (audio) delete audio.raw;
|
|
373
|
+
if (!enc.ok) fail(`encode failed: ${enc.why}`, 1);
|
|
374
|
+
|
|
375
|
+
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}`; };
|
|
376
|
+
if (captions.length) {
|
|
377
|
+
const cues = captions.map((c, i) => ({ from: c.from, to: Math.min(seconds, captions[i + 1] ? Math.min(c.to, captions[i + 1].from) : c.to), text: c.text })).filter((c) => c.to > c.from);
|
|
378
|
+
writeFileSync(join(OUT, 'captions.vtt'), `WEBVTT\n\n${cues.map((c) => `${vtt(c.from)} --> ${vtt(c.to)}\n${c.text}`).join('\n\n')}\n`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const speed = realSeconds > 0 ? +(seconds / realSeconds).toFixed(2) : null;
|
|
382
|
+
const warnings = [];
|
|
383
|
+
if (/swiftshader|llvmpipe|software|basic render/i.test(String(report.renderer))) report.notes.push(`drawn on a software renderer (${report.renderer}): slow to render, and the film is unaffected, each frame is still one frame`);
|
|
384
|
+
if (sockets > 0 && speed !== null && speed < 0.8) warnings.push(`the page holds ${sockets} live connection(s) and the film was rendered at ${speed} of real time: a server on the other end kept real time, so what it sent arrived ${(1 / speed).toFixed(1)} times too fast for the film. For a room on a relay, film it in real time (capture), or render smaller (--scale) until the speed is near 1.`);
|
|
385
|
+
if (!starts.length) warnings.push('the page logged no sound: the film is silent. The sound player made by the sound skill logs when asked (sound.js); a game with its own audio code has to push the same events (window.__homieSoundCapture.events).');
|
|
386
|
+
if (audio?.missing?.length || notFetched.length) warnings.push(`${new Set([...(audio?.missing ?? []), ...notFetched.map((x) => x.url)]).size} sound file(s) named in the log could not be read (${notFetched.slice(0, 2).map((x) => `${x.url}: ${x.why}`).join('; ') || 'not decodable'}): those sounds are missing from the mix`);
|
|
387
|
+
if (clockSeen.errors.length) report.notes.push(`callbacks threw while the clock ran them: ${clockSeen.errors.slice(0, 3).join(' | ')}`);
|
|
388
|
+
const result = {
|
|
389
|
+
...report, file: 'capture.mp4', seconds: +seconds.toFixed(3), outputFrames: total, sourceFps: FPS, heldFrames: 0, distinctFramesUsed: total,
|
|
390
|
+
realSeconds: +realSeconds.toFixed(1), speed, settled: +((vt0 ?? 0) / 1000).toFixed(2), webSockets: sockets,
|
|
391
|
+
clock: { virtual: ['requestAnimationFrame', 'performance.now', 'Date', 'setTimeout', 'setInterval', 'CSS animations', 'CSS transitions', 'Web Animations'], notVirtual: ['AudioContext.currentTime', 'video and audio elements', 'Web Workers', 'the network'], mostTimers: clockSeen.timers, mostAnimations: clockSeen.animations },
|
|
392
|
+
audio, events: 'events.json', soundEvents: filmEvents.length, ...(captions.length ? { captions: 'captions.vtt' } : {}), ...(warnings.length ? { warnings } : {}), ...(failed ? { failed } : {}),
|
|
393
|
+
honesty: `Rendered frame by frame from the page on a virtual clock at ${FPS} frames a second: every frame is one the page drew, none is held or invented, and the page's own clocks moved 1/${FPS} s a frame however long it took to draw. ${starts.length ? 'The sound is mixed again offline from the game\'s own sound files and its log of what it played; it is not a recording of a speaker.' : 'It has no sound.'}`,
|
|
394
|
+
};
|
|
395
|
+
writeFileSync(join(OUT, 'capture.json'), `${JSON.stringify(result, null, 1)}\n`);
|
|
396
|
+
const ok = !failed;
|
|
397
|
+
process.stdout.write(`${JSON.stringify({
|
|
398
|
+
ok, file: mp4, json: join(OUT, 'capture.json'), events: join(OUT, 'events.json'), ...(captions.length ? { captions: join(OUT, 'captions.vtt') } : {}),
|
|
399
|
+
seconds: result.seconds, frames: total, heldFrames: 0, realSeconds: result.realSeconds, speed, soundEvents: filmEvents.length,
|
|
400
|
+
audio: audio ? { seconds: audio.seconds, peakDb: audio.peakDb, placed: audio.placed, missing: audio.missing.length } : null, steps: report.steps.length,
|
|
401
|
+
...(failed ? { failed } : {}), ...(warnings.length ? { warnings } : {}),
|
|
402
|
+
})}\n`);
|
|
403
|
+
process.exit(ok ? 0 : 1);
|