Initial commit
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import puppeteer from 'puppeteer-core';
|
||||
|
||||
/**
|
||||
* Boot the app in a headless browser, let it skate for a while, and report
|
||||
* back what happened: console errors, frame rate, and where everyone ended up.
|
||||
*
|
||||
* The point is not the screenshots — it is that a spike whose whole success
|
||||
* criterion is "does this look and run right" needs an answer that does not
|
||||
* depend on someone having the tab open.
|
||||
*
|
||||
* node tools/capture.mjs [seconds]
|
||||
*/
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..');
|
||||
const OUT = path.join(ROOT, 'shots');
|
||||
const PORT = 4181;
|
||||
const URL = process.env.TILT_URL ?? `http://127.0.0.1:${PORT}/`;
|
||||
const SECONDS = Number(process.argv[2] ?? 12);
|
||||
|
||||
function findChrome() {
|
||||
const cache = path.join(process.env.HOME, '.cache/puppeteer/chrome');
|
||||
if (fs.existsSync(cache)) {
|
||||
const builds = fs.readdirSync(cache).sort().reverse();
|
||||
for (const b of builds) {
|
||||
const exe = path.join(cache, b, 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing');
|
||||
if (fs.existsSync(exe)) return exe;
|
||||
}
|
||||
}
|
||||
const system = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
if (fs.existsSync(system)) return system;
|
||||
throw new Error('no Chrome found — set CHROME_PATH');
|
||||
}
|
||||
|
||||
async function waitForServer(url, timeoutMs = 30000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return;
|
||||
} catch {
|
||||
// Vite is still starting.
|
||||
}
|
||||
if (Date.now() > deadline) throw new Error('vite did not come up at ' + url);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
}
|
||||
|
||||
let server = null;
|
||||
let browser = null;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
if (!process.env.TILT_URL) {
|
||||
server = spawn(
|
||||
path.join(ROOT, 'node_modules/.bin/vite'),
|
||||
['--host', '127.0.0.1', '--port', String(PORT), '--strictPort'],
|
||||
{ cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
server.stderr.on('data', (d) => process.stderr.write('[vite] ' + d));
|
||||
}
|
||||
await waitForServer(URL);
|
||||
|
||||
browser = await puppeteer.launch({
|
||||
executablePath: process.env.CHROME_PATH ?? findChrome(),
|
||||
headless: true,
|
||||
args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader', '--no-sandbox'],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
// deviceScaleFactor 2, not 1: the target is a retina Mac, and running this at
|
||||
// 1 hid a canvas-sizing bug that made the element twice the window on the
|
||||
// machine anyone actually looks at it on.
|
||||
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
|
||||
|
||||
const errors = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => errors.push(String(err?.stack ?? err)));
|
||||
|
||||
await page.goto(URL, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// The boot overlay is removed once physics is up and the first frame ran.
|
||||
await page.waitForFunction(() => !document.getElementById('boot'), { timeout: 45000 });
|
||||
|
||||
// The canvas must fill the window exactly, at whatever pixel ratio. Checked
|
||||
// at two window sizes so a resize path that only works on first load fails
|
||||
// here rather than in someone's browser.
|
||||
for (const [w, h] of [[1280, 720], [900, 1000]]) {
|
||||
await page.setViewport({ width: w, height: h, deviceScaleFactor: 2 });
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const fit = await page.evaluate(() => {
|
||||
const c = document.getElementById('stage');
|
||||
const r = c.getBoundingClientRect();
|
||||
return {
|
||||
css: [Math.round(r.width), Math.round(r.height)],
|
||||
win: [window.innerWidth, window.innerHeight],
|
||||
buffer: [c.width, c.height],
|
||||
dpr: window.devicePixelRatio,
|
||||
};
|
||||
});
|
||||
const fits = fit.css[0] === fit.win[0] && fit.css[1] === fit.win[1];
|
||||
console.log(`viewport ${w}x${h} @${fit.dpr}x: canvas ${fit.css.join('x')} css, `
|
||||
+ `${fit.buffer.join('x')} buffer — ${fits ? 'fills the window' : 'DOES NOT FIT'}`);
|
||||
if (!fits) {
|
||||
errors.push(`canvas ${fit.css.join('x')} does not fill window ${fit.win.join('x')}`);
|
||||
}
|
||||
}
|
||||
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
// The starting lineup, before anyone has moved: both teams in their own half.
|
||||
await page.evaluate(() => {
|
||||
window.tilt.match.reset();
|
||||
Object.assign(window.tilt.cam.state, { mode: 'broadcast', distance: 46, pitch: 0.85 });
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
await page.screenshot({ path: path.join(OUT, 'lineup.png') });
|
||||
await page.evaluate(() => {
|
||||
window.tilt.match.reset();
|
||||
Object.assign(window.tilt.cam.state, { distance: 34, pitch: 0.62 });
|
||||
});
|
||||
|
||||
// Let them skate. Software rasterisation is slow, so this is wall-clock time
|
||||
// rather than a frame count — the sim is dt-driven and does not care.
|
||||
await new Promise((r) => setTimeout(r, SECONDS * 1000));
|
||||
|
||||
const hud = await page.$eval('#hud', (el) => el.textContent);
|
||||
await page.screenshot({ path: path.join(OUT, 'broadcast.png') });
|
||||
|
||||
/** Frame a shot through the debug handle and wait for the camera to settle. */
|
||||
async function shot(name, camState, settleMs = 2500) {
|
||||
await page.evaluate((s) => Object.assign(window.tilt.cam.state, s), camState);
|
||||
await new Promise((r) => setTimeout(r, settleMs));
|
||||
await page.screenshot({ path: path.join(OUT, name + '.png') });
|
||||
}
|
||||
|
||||
// Follow-cam: the only view that shows whether the stride and the direction
|
||||
// of travel actually agree.
|
||||
await shot('follow', { mode: 'follow', followIndex: 0, distance: 9, pitch: 0.28 });
|
||||
// Close enough to judge the stance, the arm carry and the blade angle.
|
||||
await shot('closeup', { mode: 'follow', followIndex: 0, distance: 3.6, pitch: 0.16 });
|
||||
// From the side, where a lean into a turn actually reads.
|
||||
await shot('side', { mode: 'follow', followIndex: 1, distance: 5.5, pitch: 0.1 });
|
||||
|
||||
// Fastest skater's numbers, so the shot can be read against real motion.
|
||||
const detail = await page.evaluate(() => window.tilt.match.states.map((s) => ({
|
||||
speed: +Math.hypot(s.vx, s.vz).toFixed(2),
|
||||
effort: +s.effort.toFixed(2),
|
||||
gait: +window.tilt.match.skaters[s.id].animator.gait.toFixed(2),
|
||||
bank: +window.tilt.match.skaters[s.id].animator.bank.toFixed(2),
|
||||
state: window.tilt.match.skaters[s.id].animator.state,
|
||||
})));
|
||||
|
||||
console.log('HUD: ' + hud.replace(/\n/g, ' | '));
|
||||
console.log('skaters: ' + JSON.stringify(detail));
|
||||
console.log(`shots → ${path.relative(ROOT, OUT)}/`);
|
||||
if (errors.length) {
|
||||
console.error('\nbrowser errors:\n' + errors.join('\n'));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('no console errors');
|
||||
}
|
||||
} finally {
|
||||
await browser?.close();
|
||||
server?.kill('SIGTERM');
|
||||
}
|
||||
Reference in New Issue
Block a user