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 `
${s.file}
${s.subject} · ${s.pose} · ${s.view}
anim=${m.anim ?? '—'} feetY=${m.footLY?.toFixed?.(2) ?? '—'} hands=${m.handLY?.toFixed?.(2) ?? '—'}/${m.handRY?.toFixed?.(2) ?? '—'}
`; }).join('\n'); fs.writeFileSync(indexPath, ` img2mesh sheet

IMG2MESH · ${manifest.shots.length} shots · ${manifest.createdAt}

${cards}
`); console.log(`gallery → ${path.relative(ROOT, indexPath)}`); 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'); }