nemocity / tools /shot.mjs
AndresCarreon's picture
NEMOCITY v0 — mock backend, gradio 6.16.0 (pre-SSR)
d72231c verified
Raw
History Blame Contribute Delete
6.03 kB
// NEMOCITY — headless screenshot harness (the screenshot-and-LOOK loop).
// Serves web/ itself on a free port (8200-8299), drives the mock city through
// deterministic seeks (?mock=1&t=<simTime> fixes the shared clock), and saves
// shots to tools/shots/.
//
// node tools/shot.mjs # full suite
// node tools/shot.mjs --god-night --street # subset
// node tools/shot.mjs --base http://localhost:8123
//
// Playwright resolves from this repo's node_modules if installed, else from
// the glassblower checkout next door (shares its browser cache).
import { mkdirSync } from "node:fs";
import { createServer } from "node:net";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const WEB = path.join(HERE, "..", "web");
const OUT = path.join(HERE, "shots");
mkdirSync(OUT, { recursive: true });
const FLAGS = ["--god-day", "--god-night", "--street", "--heatmap", "--construction"];
const argv = process.argv.slice(2);
const baseIdx = argv.indexOf("--base");
let BASE = baseIdx > -1 ? argv[baseIdx + 1] : null;
const chosen = new Set(argv.filter((a) => FLAGS.includes(a)));
const want = (k) => chosen.size === 0 || chosen.has(k);
async function loadPlaywright() {
try {
return await import("playwright");
} catch {
const fallback = path.resolve(HERE, "../../glassblower/node_modules/playwright/index.mjs");
return import(`file://${fallback}`);
}
}
function freePort(start = 8200, end = 8299) {
return new Promise((resolve, reject) => {
const tryPort = (p) => {
if (p > end) return reject(new Error("no free port in 8200-8299"));
const srv = createServer();
srv.once("error", () => tryPort(p + 1));
srv.once("listening", () => srv.close(() => resolve(p)));
srv.listen(p, "127.0.0.1");
};
tryPort(start);
});
}
let server = null;
if (!BASE) {
const port = await freePort();
server = spawn("python3", ["-m", "http.server", String(port), "--directory", WEB], {
stdio: "ignore",
});
BASE = `http://127.0.0.1:${port}`;
await new Promise((r) => setTimeout(r, 900)); // let it bind
}
const cleanup = () => { if (server) { server.kill(); server = null; } };
process.on("exit", cleanup);
process.on("SIGINT", () => { cleanup(); process.exit(130); });
const { chromium } = await loadPlaywright();
const browser = await chromium.launch({
headless: true,
args: [
"--enable-unsafe-swiftshader",
"--use-gl=angle",
"--use-angle=swiftshader",
"--ignore-gpu-blocklist",
"--enable-webgl",
],
});
const page = await browser.newPage({ viewport: { width: 1512, height: 945 }, deviceScaleFactor: 2 });
const errors = [];
page.on("console", (m) => { if (m.type() === "error") errors.push("console: " + m.text()); });
page.on("pageerror", (e) => errors.push("pageerror: " + e.message));
async function open(url) {
console.log("->", url);
await page.goto(`${BASE}/${url}`, { waitUntil: "networkidle", timeout: 45000 });
await page.waitForFunction(() => window.__app && window.__app.ready, { timeout: 30000 });
await page.evaluate(() => window.__app.pause(true));
await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready; });
}
async function shot(name) {
await page.screenshot({ path: path.join(OUT, name) });
const s = await page.evaluate(() => window.__app.stats());
console.log(` shot ${name} simTime=${s.simTime} calls=${s.calls ?? "?"} tris=${s.triangles ?? "?"} epoch=${s.epoch}`);
}
// ------------------------------------------------------------------ suite
// Day page: t=78 -> 07:48, morning rush + low golden sun. The welcome build
// (Cafe Luna) starts at t0+4 and its delta lands ~t0+7.7 — tick past it so a
// construction site is alive in the god view.
const needDayPage = want("--god-day") || want("--heatmap") || want("--construction") || want("--street");
if (needDayPage) {
await open("index.html?mock=1&t=78");
await page.evaluate(() => window.__app.tick(14));
if (want("--god-day")) await shot("god-day.png");
if (want("--heatmap")) {
await page.evaluate(() => { window.__app.setHeatmap(true); window.__app.tick(3); });
await shot("heatmap.png");
await page.evaluate(() => { window.__app.setHeatmap(false); window.__app.tick(0.5); });
}
if (want("--construction")) {
// apartments script (5 floors -> scaffold + crane), framed close mid-rise
await page.evaluate(() => {
window.__app.runScript(1); // Elm Court, anchor (1,-8) 2x2
window.__app.tick(6); // through plan + world_delta
window.__app.gazeCell(2, -7, 30);
window.__app.tick(9); // floors rising (45 s build)
});
await shot("construction.png");
}
if (want("--street")) {
// low camera down Main St (x=2). Manual override LAST on this page — it
// bypasses the rig, so no further rig-driven shots afterwards.
await page.evaluate(() => {
const a = window.__app.app;
window.__app.pause(true);
if (a.rig?.streetView) {
a.rig.streetView();
window.__app.tick(0.5);
} else if (a.camera) {
a.camera.position.set(2, 5, 60);
a.camera.lookAt(2, 2, -20);
a.renderOnce?.();
} else {
console.warn("street shot: no rig.streetView or app.camera exposed");
}
});
await shot("street.png");
}
}
// Night page: t=206 + tick(8) -> 21:24. Lamps, window glow, and the welcome
// construction site lit in orange.
if (want("--god-night")) {
await open("index.html?mock=1&t=206");
await page.evaluate(() => window.__app.tick(8));
await shot("god-night.png");
}
// ------------------------------------------------------------------ wrap
if (errors.length) {
console.log("\npage errors:");
for (const e of errors.slice(0, 25)) console.log(" " + e);
process.exitCode = 1;
} else {
console.log("\nno page errors");
}
await browser.close();
cleanup();
console.log("done ->", OUT);