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';
/**
* img2mesh harness — capture a shot sheet of the player and goalie for
* equipment / animation iteration.
*
* npm run img2mesh
* npm run img2mesh -- --subject goalie --poses ready,butterfly --views front,side
* npm run img2mesh -- --list
*
* Writes PNGs + manifest.json under shots/img2mesh/. Pair each PNG with a
* reference (drop into shots/img2mesh/ref/) and re-run after code changes.
*/
const ROOT = path.resolve(import.meta.dirname, '..');
const OUT = path.join(ROOT, 'shots', 'img2mesh');
const PORT = 4182;
const BASE = process.env.TILT_URL ?? `http://127.0.0.1:${PORT}/`;
const STUDIO = new URL('character.html', BASE).href;
function parseArgs(argv) {
const out = {
subjects: ['player', 'goalie'],
poses: null,
views: null,
list: false,
settle: 50,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--list') out.list = true;
else if (a === '--subject' || a === '--subjects') {
out.subjects = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
} else if (a === '--poses') {
out.poses = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
} else if (a === '--views') {
out.views = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
} else if (a === '--settle') {
out.settle = Number(argv[++i]) || 50;
} else if (a === '--help' || a === '-h') {
console.log(`img2mesh — character shot sheet
Usage:
node tools/img2mesh.mjs [options]
Options:
--subject player|goalie|player,goalie (default: both)
--poses carry,windup,butterfly,... (default: all for subject)
--views front,side,threequarter,... (default: front,3/4,side,closeup,gear)
--settle N frames of settle before shot (via API)
--list print catalogs and exit
--help
`);
process.exit(0);
}
}
return out;
}
function findChrome() {
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
return process.env.CHROME_PATH;
}
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 || res.status === 404) return; // 404 on / is fine; studio is /character.html
} catch {
// still booting
}
if (Date.now() > deadline) throw new Error('vite did not come up at ' + url);
await new Promise((r) => setTimeout(r, 200));
}
}
const args = parseArgs(process.argv.slice(2));
let server = null;
let browser = null;
try {
fs.mkdirSync(OUT, { recursive: true });
fs.mkdirSync(path.join(OUT, 'ref'), { 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(BASE);
browser = await puppeteer.launch({
executablePath: findChrome(),
headless: true,
args: [
'--enable-unsafe-swiftshader',
'--use-gl=angle',
'--use-angle=swiftshader',
'--no-sandbox',
],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
const errors = [];
page.on('pageerror', (err) => errors.push(String(err?.stack ?? err)));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto(STUDIO, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.img2mesh?.captureShot, { timeout: 45000 });
// Boot overlay gone.
await page.waitForFunction(() => !document.getElementById('boot'), { timeout: 10000 }).catch(() => {});
if (args.list) {
const catalogs = await page.evaluate(() => ({
playerPoses: window.img2mesh.catalogs.playerPoses(),
goaliePoses: window.img2mesh.catalogs.goaliePoses(),
views: window.img2mesh.catalogs.views(),
}));
console.log(JSON.stringify(catalogs, null, 2));
process.exit(0);
}
const sheet = await page.evaluate((opts) => {
return window.img2mesh.shotSheet(opts);
}, {
subjects: args.subjects,
poses: args.poses,
views: args.views ?? ['front', 'threequarter', 'side', 'closeup', 'gear'],
});
console.log(`img2mesh: ${sheet.length} shots → ${path.relative(ROOT, OUT)}/`);
const manifest = {
createdAt: new Date().toISOString(),
subjects: args.subjects,
shots: [],
};
for (let i = 0; i < sheet.length; i++) {
const spec = sheet[i];
const meta = await page.evaluate(async (s) => {
return window.img2mesh.captureShot(s);
}, { ...spec, settleMs: args.settle });
const filePath = path.join(OUT, spec.file);
await page.screenshot({ path: filePath, type: 'png' });
const entry = {
...spec,
path: path.relative(ROOT, filePath),
measures: meta.measures,
};
manifest.shots.push(entry);
const m = meta.measures.player || meta.measures.goalie;
const foot = m ? ` feetY=${m.footLY.toFixed(2)}` : '';
console.log(
`[${String(i + 1).padStart(3)}/${sheet.length}] ${spec.file}`
+ ` anim=${m?.anim ?? '—'}${foot}`,
);
}
const manifestPath = path.join(OUT, 'manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log(`manifest → ${path.relative(ROOT, manifestPath)}`);
// Index HTML for quick visual review in a browser.
const indexPath = path.join(OUT, 'index.html');
const cards = manifest.shots.map((s) => {
const m = s.measures.player || s.measures.goalie || {};
return `
anim=${m.anim ?? '—'} feetY=${m.footLY?.toFixed?.(2) ?? '—'} hands=${m.handLY?.toFixed?.(2) ?? '—'}/${m.handRY?.toFixed?.(2) ?? '—'}