The engine gets photographed, and the camera stops moving while the shutter is open
`shots.mjs` renders the product imagery for lumbridgecorp.com/simulate: seven frames of the running `dist/`, at chapters and times chosen rather than defaulted, written as WebP into the sibling site checkout together with a generated manifest that carries each picture's caption and alt text. The pages there had described a renderer in prose for their whole life without ever showing one, which is a strange way to sell a renderer. The captions live next to the camera poses in this repo, not on the site, so re-aiming a camera cannot leave a caption behind describing the old view. The manifest emits a `ShotId` union, so a page asking for a picture that has been renamed fails the site's typecheck instead of rendering a hole. Along the way: the share cards were never reproducible. `scenekit.ts` eases a chapter change over about two seconds of scene time, `stage.ts` clamps `dt` to 50 ms a frame, and SwiftShader here draws about three frames a second — so the flight takes twenty seconds of wall clock and `capture.mjs` waited four. Every run caught the camera at a different point over the bay, and none of them at the chapter the key press asked for. Both scripts now open the page with reduced motion, which is the app's own answer to "somebody clicked a name in a list": `flyTo` sets the pose outright. `og-tera.png` is regenerated and is Hayes Valley for the first time. The bytes still differ run to run, because aircraft are crossing and cloud shadow is drifting; the framing no longer does. `harness.mjs` is the static server, the SwiftShader flags, the two-hostnames- one-dist trick and the clock shim, extracted because there are two consumers now and two copies would have drifted apart while both claimed to photograph the same app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,12 +23,9 @@
|
||||
* kept true by running that command.
|
||||
*/
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname } from "node:path";
|
||||
import { serve, launch, clockShim, FURNITURE, hide } from "./harness.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, "..", "..");
|
||||
@@ -45,64 +42,36 @@ const PUBLIC = join(ROOT, "public");
|
||||
*/
|
||||
const NOON = "2026-08-06T12:40:00-07:00";
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webmanifest": "application/manifest+json",
|
||||
};
|
||||
|
||||
/** A static server over one directory, with the SPA fallback the app expects. */
|
||||
function serve(dir, port, { spa = false } = {}) {
|
||||
const server = createServer(async (req, res) => {
|
||||
const path = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
||||
for (const candidate of [path, `${path}/index.html`, spa ? "/index.html" : null]) {
|
||||
if (!candidate) continue;
|
||||
try {
|
||||
const body = await readFile(join(dir, candidate));
|
||||
res.writeHead(200, { "content-type": MIME[extname(candidate)] ?? "application/octet-stream" });
|
||||
res.end(body);
|
||||
return;
|
||||
} catch {
|
||||
/* try the next candidate */
|
||||
}
|
||||
}
|
||||
res.writeHead(404).end("not found");
|
||||
});
|
||||
return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server)));
|
||||
}
|
||||
|
||||
const CHROME_ARGS = [
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
// Software GL, so this runs on a box with no display and no GPU.
|
||||
"--use-gl=angle",
|
||||
"--use-angle=swiftshader",
|
||||
// The app reads its own hostname to decide which door it is. Resolving both
|
||||
// names at the local server is what makes one `dist/` produce both shots.
|
||||
"--host-resolver-rules=MAP office.lumbridgecorp.com 127.0.0.1, MAP tera.lumbridgecorp.com 127.0.0.1",
|
||||
];
|
||||
|
||||
const clockShim = `{
|
||||
const target = new Date(${JSON.stringify(NOON)}).getTime();
|
||||
const skew = target - Date.now();
|
||||
const Real = Date;
|
||||
globalThis.Date = class extends Real {
|
||||
constructor(...a) { super(...(a.length ? a : [Real.now() + skew])); }
|
||||
static now() { return Real.now() + skew; }
|
||||
};
|
||||
}`;
|
||||
|
||||
async function shootApp(browser, url, file, { key = null, settle = 20000 } = {}) {
|
||||
// 2x, so the art is still sharp when a timeline shows the card at 600px wide
|
||||
// on a retina screen.
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1400, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
/**
|
||||
* Without this the card was framed differently every run, and had been
|
||||
* since the day it was written.
|
||||
*
|
||||
* `scenekit.ts` eases a chapter change over about two seconds of scene
|
||||
* time, `stage.ts` clamps `dt` to 50 ms a frame, and SwiftShader on this
|
||||
* box draws about three frames a second — so the flight below takes some
|
||||
* twenty seconds of wall clock, and the four this waited caught the camera
|
||||
* partway across the bay at a different point each time. Two consecutive
|
||||
* captures of an unchanged repo produced two different cards, neither of
|
||||
* them the chapter the key press had asked for.
|
||||
*
|
||||
* Reduced motion is the app's own answer for "somebody clicked a name in a
|
||||
* list": `flyTo` sets the pose outright, so the shutter opens on the pose
|
||||
* the key names.
|
||||
*
|
||||
* The *framing* is what this fixes, not the bytes. Aircraft are still
|
||||
* crossing and cloud shadow is still drifting, so two runs differ by a few
|
||||
* pixels and `git diff` will always show the PNG as changed. That is the
|
||||
* app being alive, and worth far less than the framing being on purpose.
|
||||
*/
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
await page.addInitScript(clockShim);
|
||||
await page.addInitScript(clockShim(NOON));
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(settle);
|
||||
if (key) {
|
||||
@@ -111,13 +80,9 @@ async function shootApp(browser, url, file, { key = null, settle = 20000 } = {})
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
// The chrome comes off. The card supplies its own typography, and the app's
|
||||
// panels shrunk to card size are unreadable furniture.
|
||||
await page.evaluate(() => {
|
||||
for (const sel of ["#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier", "#boot", "#scrim"]) {
|
||||
document.querySelectorAll(sel).forEach((el) => (el.style.display = "none"));
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
// panels shrunk to card size are unreadable furniture. A product screenshot
|
||||
// wants the opposite — see `shots.mjs`, which keeps the instruments.
|
||||
await hide(page, [...FURNITURE.transient, ...FURNITURE.BARE]);
|
||||
await page.screenshot({ path: join(HERE, file), timeout: 120_000, animations: "disabled" });
|
||||
console.log("art ", file);
|
||||
await page.close();
|
||||
@@ -146,7 +111,7 @@ const cardsOnly = process.argv.includes("--cards-only");
|
||||
|
||||
const app = cardsOnly ? null : await serve(join(ROOT, "dist"), 5210, { spa: true });
|
||||
const assets = await serve(HERE, 8799);
|
||||
const browser = await chromium.launch({ channel: "chrome", args: CHROME_ARGS });
|
||||
const browser = await launch();
|
||||
try {
|
||||
if (!cardsOnly) {
|
||||
await shootApp(browser, "http://office.lumbridgecorp.com:5210/", "art-office.png");
|
||||
|
||||
Reference in New Issue
Block a user