@homie-rocks/studio 0.19.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +927 -0
- package/README.md +49 -3
- package/bin/homie-studio.mjs +57 -2
- package/lab/harness.js +371 -0
- package/lab/lab.ts +180 -0
- package/lab/page/index.html +83 -0
- package/lab/page/lab.css +174 -0
- package/lab/page/page.js +802 -0
- package/lab/take.js +189 -0
- package/lib/build.mjs +61 -44
- package/lib/changelog.mjs +204 -0
- package/lib/jobs.mjs +3 -2
- package/lib/lab-check.mjs +212 -0
- package/lib/lab.mjs +546 -0
- package/lib/mcp-tools.mjs +72 -6
- package/lib/mcp.mjs +4 -2
- package/lib/scaffold.mjs +7 -1
- package/lib/studio.mjs +14 -0
- package/lib/template-history.json +74 -0
- package/lib/upgrade.mjs +9 -1
- package/mcp/ui/card.css +2 -0
- package/mcp/ui/lab.js +67 -0
- package/mcp/ui/studio.js +9 -1
- package/package.json +7 -1
- package/port/index.ts +2 -0
- package/starters/ember-vale/lab.json +22 -0
- package/starters/ember-vale/src/main.ts +271 -19
- package/starters/ember-vale/tunables.json +13 -0
- package/starters/gem-rush/lab.json +19 -0
- package/starters/gem-rush/src/main.ts +213 -38
- package/starters/gem-rush/tunables.json +13 -0
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio lab check <game>`: the Game Lab, headless. The same lab page a person opens (lab/page) plays the take
|
|
3
|
+
* in New and Today in a headless Chrome on this computer's GPU, then plays it again from a fresh load and compares
|
|
4
|
+
* every frame, and writes what it saw:
|
|
5
|
+
*
|
|
6
|
+
* .studio/lab/<game>/check-<time>/
|
|
7
|
+
* summary.json per build: each phase (frames, ms), each tracked value (peak, when, where it ends), the game's
|
|
8
|
+
* JavaScript per frame (median, p95), errors; whether a replay landed on the same frames
|
|
9
|
+
* REPORT.md the same, New against Today, in a few lines a person reads
|
|
10
|
+
* sheet.png the lab at the start of each phase, New beside Today
|
|
11
|
+
* still.jpg New beside Today at the take's busiest moment (what the Homie card shows)
|
|
12
|
+
* page.jpg the whole lab page at that moment
|
|
13
|
+
* .studio/lab/<game>/latest.json the last check, in a few fields (where its files are, its phases and numbers): what a
|
|
14
|
+
* Studio pane or a card shows without running anything (.studio/lab/server.json says where the
|
|
15
|
+
* lab itself is running)
|
|
16
|
+
*
|
|
17
|
+
* homie-studio lab check <game> [--take <name>] [--today HEAD|<ref>] [--device desk|phone] [--fps 60] [--frames 6]
|
|
18
|
+
* [--out <dir>] [--still <file.jpg>]
|
|
19
|
+
*
|
|
20
|
+
* It prints paths and a few numbers, never the data. The cost numbers are the game's JavaScript per frame on this
|
|
21
|
+
* computer, both builds on the same frames: a hint that a feel change costs something, never a frame-rate claim (the
|
|
22
|
+
* perf skill measures that, in two browsers in a room).
|
|
23
|
+
*/
|
|
24
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { join, relative } from 'node:path';
|
|
26
|
+
import { chromeArgs, findChrome, noChrome, SOFTWARE_GL } from './chrome.mjs';
|
|
27
|
+
import { labDir, startLabServer } from './lab.mjs';
|
|
28
|
+
import { listGames } from './studio.mjs';
|
|
29
|
+
|
|
30
|
+
const round = (v, d = 2) => (Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null);
|
|
31
|
+
const pct = (xs, p) => { const s = xs.filter(Number.isFinite).sort((a, b) => a - b); return s.length ? s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] : null; };
|
|
32
|
+
|
|
33
|
+
/** One build's take, in numbers. */
|
|
34
|
+
export function summarise(pane, { fps, units }) {
|
|
35
|
+
if (!pane?.available) return { available: false, why: pane?.why ?? null };
|
|
36
|
+
const frames = pane.frames ?? [];
|
|
37
|
+
const phases = (pane.phases ?? []).map((s) => ({ name: s.name, note: s.note ?? '', from: s.from + 1, to: s.to, frames: s.to - s.from, ms: round(((s.to - s.from) * 1000) / fps, 0) }));
|
|
38
|
+
const names = new Set();
|
|
39
|
+
for (const r of frames) for (const k of Object.keys(r?.tr ?? {})) names.add(k);
|
|
40
|
+
const tracks = {};
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const xs = frames.map((r) => (r?.tr && Object.hasOwn(r.tr, name) ? r.tr[name] : null));
|
|
43
|
+
let peak = null; let peakAt = null; let last = null;
|
|
44
|
+
xs.forEach((v, i) => { if (v === null) return; if (peak === null || Math.abs(v) > Math.abs(peak)) { peak = v; peakAt = i + 1; } last = v; });
|
|
45
|
+
const vals = xs.filter((v) => v !== null);
|
|
46
|
+
tracks[name] = { unit: units?.[name] ?? '', peak: round(peak), peakAt, final: round(last), mean: round(vals.reduce((a, b) => a + b, 0) / Math.max(1, vals.length)) };
|
|
47
|
+
}
|
|
48
|
+
const ms = frames.map((r) => r?.ms).filter(Number.isFinite);
|
|
49
|
+
return {
|
|
50
|
+
available: true,
|
|
51
|
+
instrumented: Boolean(pane.meta?.instrumented || pane.meta?.views?.length || pane.meta?.overlays?.length),
|
|
52
|
+
frames: frames.filter(Boolean).length,
|
|
53
|
+
phases, tracks,
|
|
54
|
+
// Chrome's clock in a page is coarse (0.1 ms steps): the mean over every frame is the finer number.
|
|
55
|
+
cost: { mean: round(ms.reduce((a, b) => a + b, 0) / Math.max(1, ms.length), 3), median: round(pct(ms, 0.5), 3), p95: round(pct(ms, 0.95), 3), max: round(Math.max(0, ...ms), 3) },
|
|
56
|
+
views: pane.meta?.views ?? [], overlays: pane.meta?.overlays ?? [],
|
|
57
|
+
errors: pane.meta?.errors ?? [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Where two runs of one build first differ (frame number), or null when every frame matches. */
|
|
62
|
+
export function firstDifference(a, b) {
|
|
63
|
+
const n = Math.max(a?.length ?? 0, b?.length ?? 0);
|
|
64
|
+
for (let i = 0; i < n; i += 1) if ((a?.[i]?.sig ?? null) !== (b?.[i]?.sig ?? null)) return i + 1;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Frames worth a picture: where each phase starts (New's, then Today's), filled in evenly, at most `n`. */
|
|
69
|
+
export function keyFrames(newPhases, todayPhases, total, n = 6) {
|
|
70
|
+
const want = [];
|
|
71
|
+
const add = (f) => { if (f >= 1 && f <= total && !want.some((x) => Math.abs(x - f) < 3)) want.push(f); };
|
|
72
|
+
for (const s of newPhases ?? []) add(Math.min(s.to, s.from + 3));
|
|
73
|
+
for (const s of todayPhases ?? []) add(Math.min(s.to, s.from + 3));
|
|
74
|
+
for (let i = 1; want.length < n && i <= n * 2; i += 1) add(Math.round((i / (n * 2 + 1)) * total));
|
|
75
|
+
return want.sort((a, b) => a - b).slice(0, n);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reportOf(r) {
|
|
79
|
+
const n = r.new; const t = r.today;
|
|
80
|
+
const lines = [`# ${r.game}: take "${r.take ?? '(none)'}"`, '', `${r.note ?? ''}`.trim(), '',
|
|
81
|
+
`- ${r.frames} frames at ${r.fps} fps (${round(r.frames / r.fps, 2)} s), ${r.device}, seed ${r.seed}, ${r.inputs} input events.`,
|
|
82
|
+
`- New: the working tree${r.newBuild?.dirty ? ` (${r.newBuild.dirty} changed in games/${r.game})` : ''}. Today: ${r.todayBuild?.commit ? `${r.todayBuild.commit.short} "${r.todayBuild.commit.subject}"` : r.todayBuild?.none ?? 'none'}.`,
|
|
83
|
+
`- Replays: ${r.deterministic.new === null ? 'New replayed on the same frames' : `New DIFFERED from frame ${r.deterministic.new}`}${t.available ? `; ${r.deterministic.today === null ? 'Today replayed on the same frames' : `Today DIFFERED from frame ${r.deterministic.today}`}` : ''}.`,
|
|
84
|
+
...(Object.keys(r.overrides ?? {}).length ? [`- Sliders not kept yet: ${Object.entries(r.overrides).map(([k, v]) => `${k}=${v}`).join(', ')}`] : []),
|
|
85
|
+
'', '## Phases', '', '| | New | Today |', '| --- | --- | --- |'];
|
|
86
|
+
const names = [...new Set([...(n.phases ?? []).map((p) => p.name), ...(t.phases ?? []).map((p) => p.name)])];
|
|
87
|
+
const of = (side, name) => (side.phases ?? []).filter((p) => p.name === name).map((p) => `F${p.from}-${p.to} (${p.frames} f, ${p.ms} ms)`).join(', ') || '-';
|
|
88
|
+
for (const name of names) lines.push(`| ${name} | ${of(n, name)} | ${t.available ? of(t, name) : 'n/a'} |`);
|
|
89
|
+
lines.push('', '## Tracked values (peak, at frame; where it ends)', '', '| | New | Today |', '| --- | --- | --- |');
|
|
90
|
+
for (const name of [...new Set([...Object.keys(n.tracks ?? {}), ...Object.keys(t.tracks ?? {})])]) {
|
|
91
|
+
const cell = (side) => { const x = side.tracks?.[name]; return x ? `${x.peak}${x.unit ? ` ${x.unit}` : ''} at F${x.peakAt}; ends ${x.final}` : '-'; };
|
|
92
|
+
lines.push(`| ${name} | ${cell(n)} | ${t.available ? cell(t) : 'n/a'} |`);
|
|
93
|
+
}
|
|
94
|
+
lines.push('', `## The game's JavaScript per frame (this computer, the same frames)`, '',
|
|
95
|
+
`New mean ${n.cost?.mean} ms (median ${n.cost?.median}, p95 ${n.cost?.p95})${t.available ? `; Today mean ${t.cost?.mean} ms (median ${t.cost?.median}, p95 ${t.cost?.p95})` : ''}. Chrome times a page in steps of about 0.1 ms, so the mean over every frame is the finer number. A hint, not a frame rate: the perf skill measures that.`);
|
|
96
|
+
const errors = [...(n.errors ?? []).map((e) => `New: ${e}`), ...(t.errors ?? []).map((e) => `Today: ${e}`)];
|
|
97
|
+
if (errors.length) lines.push('', '## Errors', '', ...errors.map((e) => `- ${e}`));
|
|
98
|
+
lines.push('', `Pictures: sheet.png (the lab at ${r.keyFrames.map((f) => `F${f}`).join(', ')}), still.jpg (F${r.stillFrame}).`, '');
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function labCheck(root, id, { take = null, today = 'HEAD', device = null, fps = null, out = null, still = null, frames: nKey = 6, log = () => {} } = {}) {
|
|
103
|
+
const games = listGames(root);
|
|
104
|
+
const game = id ?? (games.length === 1 ? games[0].id : null);
|
|
105
|
+
if (!game || !games.some((g) => g.id === game)) return { ok: false, command: 'lab check', why: `name the game: homie-studio lab check <${games.map((g) => g.id).join('|') || 'id'}>` };
|
|
106
|
+
const chrome = findChrome();
|
|
107
|
+
if (!chrome) return { ok: false, command: 'lab check', why: noChrome() };
|
|
108
|
+
let puppeteer;
|
|
109
|
+
try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'lab check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
|
|
110
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
111
|
+
const dir = out ?? join(labDir(root, game), `check-${stamp}`);
|
|
112
|
+
mkdirSync(dir, { recursive: true });
|
|
113
|
+
const server = await startLabServer(root, { port: 0, today, watchFiles: false, log: () => {} });
|
|
114
|
+
const browser = await puppeteer.launch({
|
|
115
|
+
executablePath: chrome, headless: true, timeout: 150_000, protocolTimeout: 240_000,
|
|
116
|
+
args: [...chromeArgs(), '--mute-audio', '--window-size=1600,1000', '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--force-color-profile=srgb'],
|
|
117
|
+
});
|
|
118
|
+
const t0 = Date.now();
|
|
119
|
+
try {
|
|
120
|
+
const page = await browser.newPage();
|
|
121
|
+
await page.setViewport({ width: 1600, height: 1000, deviceScaleFactor: 1 });
|
|
122
|
+
await page.setUserAgent(`${await browser.userAgent()} homie-studio-check homie-studio-lab`);
|
|
123
|
+
const pageErrors = [];
|
|
124
|
+
page.on('pageerror', (e) => { if (pageErrors.length < 10) pageErrors.push(String(e?.message ?? e).slice(0, 240)); });
|
|
125
|
+
const q = new URLSearchParams({ check: '1', ...(take ? { take } : {}), ...(device ? { device } : {}), ...(fps ? { fps: String(fps) } : {}), ...(today !== 'HEAD' ? { today } : {}) });
|
|
126
|
+
log(`lab: ${server.url}/${game}/ (headless)`);
|
|
127
|
+
await page.goto(`${server.url}/${game}/?${q}`, { waitUntil: 'load', timeout: 90_000 });
|
|
128
|
+
await page.waitForFunction(() => Boolean(window.labApp), { timeout: 30_000 });
|
|
129
|
+
await page.evaluate(() => window.labApp.ready);
|
|
130
|
+
const renderer = await page.evaluate(() => { try { const g = document.createElement('canvas').getContext('webgl'); const i = g?.getExtension('WEBGL_debug_renderer_info'); return i ? g.getParameter(i.UNMASKED_RENDERER_WEBGL) : null; } catch { return null; } });
|
|
131
|
+
const first = await page.evaluate(() => window.labApp.data());
|
|
132
|
+
if (!first.take && !Object.keys(first.tunables?.spec ?? {}).length) log('lab: this game has no take yet (lab.json): the check played a still take with no presses');
|
|
133
|
+
log(`lab: played ${first.frames} frames in both builds; playing them again from a fresh load`);
|
|
134
|
+
const second = await page.evaluate(() => window.labApp.rerun());
|
|
135
|
+
const deterministic = {
|
|
136
|
+
new: first.new.available ? firstDifference(first.new.frames, second.new.frames) : null,
|
|
137
|
+
today: first.today.available ? firstDifference(first.today.frames, second.today.frames) : null,
|
|
138
|
+
};
|
|
139
|
+
const summary = {
|
|
140
|
+
ok: true, game, take: first.take, note: first.note, fps: first.fps, frames: first.frames, device: first.device, seed: first.seed, inputs: first.inputs,
|
|
141
|
+
newBuild: first.newBuild, todayBuild: first.todayBuild, overrides: first.overrides, renderer, software: SOFTWARE_GL.test(String(renderer ?? '')),
|
|
142
|
+
deterministic, new: summarise(first.new, first), today: summarise(first.today, first), pageErrors,
|
|
143
|
+
};
|
|
144
|
+
// Pictures: the lab at the start of each phase, New beside Today.
|
|
145
|
+
const keys = keyFrames(first.new.phases, first.today.phases, first.frames, Math.max(1, Math.min(12, Number(nKey) || 6)));
|
|
146
|
+
const shots = [];
|
|
147
|
+
// Just the panes (their names above, their phases below), not the stage's empty margin.
|
|
148
|
+
const panes = () => page.evaluate(() => {
|
|
149
|
+
const rs = [...document.querySelectorAll('.slot')].filter((e) => !e.hidden).map((e) => e.getBoundingClientRect());
|
|
150
|
+
const x = Math.min(...rs.map((r) => r.left)); const y = Math.min(...rs.map((r) => r.top));
|
|
151
|
+
return { x: Math.max(0, x - 6), y: Math.max(0, y - 6), width: Math.max(...rs.map((r) => r.right)) - x + 12, height: Math.max(...rs.map((r) => r.bottom)) - y + 12 };
|
|
152
|
+
});
|
|
153
|
+
for (const f of keys) {
|
|
154
|
+
await page.evaluate((x) => window.labApp.seek(x), f);
|
|
155
|
+
const png = await page.screenshot({ type: 'png', clip: await panes() });
|
|
156
|
+
const at = (side) => side.phases?.find((p) => f >= p.from && f <= p.to)?.name ?? '';
|
|
157
|
+
shots.push({ f, png: `data:image/png;base64,${Buffer.from(png).toString('base64')}`, label: `F${f} · ${round(f / first.fps, 2)} s`, newPhase: at(summary.new), todayPhase: summary.today.available ? at(summary.today) : '' });
|
|
158
|
+
}
|
|
159
|
+
// The still: the take's busiest moment (the first phase of New, a few frames in), the whole lab page.
|
|
160
|
+
const stillFrame = summary.new.phases?.[0] ? Math.min(summary.new.phases[0].to, summary.new.phases[0].from + 6) : Math.round(first.frames / 2);
|
|
161
|
+
await page.evaluate((x) => window.labApp.seek(x), stillFrame);
|
|
162
|
+
const stillPath = still ?? join(dir, 'still.jpg');
|
|
163
|
+
await page.screenshot({ path: stillPath, type: 'jpeg', quality: 84, clip: await panes() });
|
|
164
|
+
await page.screenshot({ path: join(dir, 'page.jpg'), type: 'jpeg', quality: 82 });
|
|
165
|
+
const sheet = await browser.newPage();
|
|
166
|
+
await sheet.setViewport({ width: 1600, height: 900, deviceScaleFactor: 1 });
|
|
167
|
+
await sheet.setContent(sheetHtml(summary, shots), { waitUntil: 'load' });
|
|
168
|
+
await sheet.screenshot({ path: join(dir, 'sheet.png'), fullPage: true });
|
|
169
|
+
summary.keyFrames = keys;
|
|
170
|
+
summary.stillFrame = stillFrame;
|
|
171
|
+
summary.ms = Date.now() - t0;
|
|
172
|
+
writeFileSync(join(dir, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`);
|
|
173
|
+
writeFileSync(join(dir, 'REPORT.md'), reportOf(summary));
|
|
174
|
+
const rel = (p) => relative(root, p) || p;
|
|
175
|
+
const latest = {
|
|
176
|
+
v: 1, at: new Date().toISOString(), game, take: summary.take, frames: summary.frames, fps: summary.fps, device: summary.device,
|
|
177
|
+
out: rel(dir), report: rel(join(dir, 'REPORT.md')), sheet: rel(join(dir, 'sheet.png')), still: rel(stillPath), page: rel(join(dir, 'page.jpg')),
|
|
178
|
+
today: summary.todayBuild?.commit?.short ?? null, deterministic,
|
|
179
|
+
timeline: { new: (summary.new.phases ?? []).map(({ name, from, to }) => ({ name, from, to })), today: summary.today.available ? summary.today.phases.map(({ name, from, to }) => ({ name, from, to })) : null },
|
|
180
|
+
cost: { new: summary.new.cost ?? null, today: summary.today.available ? summary.today.cost : null },
|
|
181
|
+
};
|
|
182
|
+
if (!out) writeFileSync(join(labDir(root, game), 'latest.json'), `${JSON.stringify(latest, null, 2)}\n`);
|
|
183
|
+
return {
|
|
184
|
+
ok: true, command: 'lab check', game, take: summary.take, out: rel(dir), report: rel(join(dir, 'REPORT.md')), sheet: rel(join(dir, 'sheet.png')), still: rel(stillPath),
|
|
185
|
+
frames: summary.frames, fps: summary.fps, deterministic, renderer, software: summary.software,
|
|
186
|
+
phases: { new: summary.new.phases?.map((p) => `${p.name} ${p.frames}f`) ?? [], today: summary.today.available ? summary.today.phases.map((p) => `${p.name} ${p.frames}f`) : null },
|
|
187
|
+
timeline: { new: (summary.new.phases ?? []).map(({ name, from, to }) => ({ name, from, to })), today: summary.today.available ? summary.today.phases.map(({ name, from, to }) => ({ name, from, to })) : null },
|
|
188
|
+
page: rel(join(dir, 'page.jpg')),
|
|
189
|
+
cost: { new: summary.new.cost ?? null, today: summary.today.available ? summary.today.cost : null },
|
|
190
|
+
errors: [...(summary.new.errors ?? []), ...(summary.today.errors ?? []), ...pageErrors].slice(0, 6),
|
|
191
|
+
today: summary.todayBuild?.commit?.short ?? null, todayNote: summary.todayBuild?.none ?? summary.todayBuild?.error ?? null,
|
|
192
|
+
};
|
|
193
|
+
} finally {
|
|
194
|
+
await browser.close().catch(() => {});
|
|
195
|
+
await server.close().catch(() => {});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function sheetHtml(summary, shots) {
|
|
200
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
201
|
+
const cells = shots.map((s) => `<figure><img src="${s.png}" alt=""><figcaption><b>${esc(s.label)}</b><span class="n">${esc(s.newPhase || '·')}</span><span class="t">${esc(s.todayPhase || '·')}</span></figcaption></figure>`).join('');
|
|
202
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
203
|
+
body { margin: 0; padding: 24px; background: #080b12; color: #e7eaf3; font: 600 13px/1.3 ui-monospace, Menlo, monospace; }
|
|
204
|
+
h1 { margin: 0 0 4px; font-size: 16px; letter-spacing: .2em; } p { margin: 0 0 18px; color: #8d96b0; }
|
|
205
|
+
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
|
206
|
+
figure { margin: 0; } img { width: 100%; display: block; border: 1px solid #232c42; }
|
|
207
|
+
figcaption { display: flex; gap: 12px; margin-top: 6px; } .n { color: #ffad3b; } .t { color: #7cc4ff; }
|
|
208
|
+
</style></head><body><h1>GAME LAB · ${esc(summary.game)} · ${esc(summary.take ?? 'no take')}</h1>
|
|
209
|
+
<p>New (amber) beside Today (blue)${summary.todayBuild?.commit ? `, Today ${esc(summary.todayBuild.commit.short)}` : ''} · ${summary.frames} frames at ${summary.fps} fps · seed ${summary.seed}</p>
|
|
210
|
+
<div class="grid">${cells}</div></body></html>`;
|
|
211
|
+
}
|
|
212
|
+
|