Initial commit
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
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 `<figure>
|
||||
<a href="${s.file}"><img src="${s.file}" alt="${s.file}" loading="lazy"></a>
|
||||
<figcaption><strong>${s.subject}</strong> · ${s.pose} · ${s.view}<br>
|
||||
anim=${m.anim ?? '—'} feetY=${m.footLY?.toFixed?.(2) ?? '—'} hands=${m.handLY?.toFixed?.(2) ?? '—'}/${m.handRY?.toFixed?.(2) ?? '—'}
|
||||
</figcaption>
|
||||
</figure>`;
|
||||
}).join('\n');
|
||||
fs.writeFileSync(indexPath, `<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>img2mesh sheet</title>
|
||||
<style>
|
||||
body { margin:0; background:#0a0e14; color:#9ec0dc; font:12px/1.4 ui-monospace, Menlo, monospace; }
|
||||
h1 { margin:16px; font-size:14px; letter-spacing:2px; color:#6ea8dc; }
|
||||
main { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:12px; padding:0 16px 24px; }
|
||||
figure { margin:0; background:#121a24; border:1px solid #1e3348; border-radius:8px; overflow:hidden; }
|
||||
img { display:block; width:100%; height:auto; background:#0c1018; }
|
||||
figcaption { padding:8px 10px; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>IMG2MESH · ${manifest.shots.length} shots · ${manifest.createdAt}</h1>
|
||||
<main>
|
||||
${cards}
|
||||
</main>
|
||||
</body></html>`);
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user