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:
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* The bits of "photograph the running app" that both `capture.mjs` and
|
||||
* `shots.mjs` need.
|
||||
*
|
||||
* This file exists because the second consumer arrived. `capture.mjs` shot the
|
||||
* app to make two share cards; `shots.mjs` shoots it to make the product
|
||||
* imagery on lumbridgecorp.com. Everything below — the static server, the
|
||||
* SwiftShader flags, the two-hostnames-one-dist trick, the clock shim — was
|
||||
* already load-bearing for the first one, and copying it would have meant two
|
||||
* capture pipelines drifting apart while both claimed to photograph the same
|
||||
* app. There is one pipeline. The two scripts differ only in what they frame.
|
||||
*/
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import { createServer } from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, extname } from "node:path";
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".json": "application/json",
|
||||
".webmanifest": "application/manifest+json",
|
||||
};
|
||||
|
||||
/** A static server over one directory, with the SPA fallback the app expects. */
|
||||
export 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)));
|
||||
}
|
||||
|
||||
export 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",
|
||||
];
|
||||
|
||||
export function launch() {
|
||||
return chromium.launch({ channel: "chrome", args: CHROME_ARGS });
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the page's clock to a fixed instant.
|
||||
*
|
||||
* The sun is real — `solar.ts` computes it from `new Date()` — so a shot taken
|
||||
* at two in the morning is an honest photograph of a black rectangle. The clock
|
||||
* is *shifted* rather than frozen because the app drives everything else off
|
||||
* `requestAnimationFrame`, and a stopped clock stalls the frame loop the
|
||||
* screenshot is waiting on.
|
||||
*
|
||||
* This is also the only time control available here. Scrubbing the date from
|
||||
* the godmode panel needs `can.debug`, which needs a signed-in admin; shifting
|
||||
* `Date` needs nothing, and produces the same sun.
|
||||
*/
|
||||
export function clockShim(iso) {
|
||||
return `{
|
||||
const target = new Date(${JSON.stringify(iso)}).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; }
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a shot leaves out, by kind of shot.
|
||||
*
|
||||
* `BARE` is the share card's answer: the card supplies its own typography, and
|
||||
* the app's panels shrunk to 1200×630 are unreadable furniture.
|
||||
*
|
||||
* `CLUTTER` is the product screenshot's, and it is deliberately much shorter.
|
||||
* The left panel and the plan view are not chrome over the product, they *are*
|
||||
* the product; a picture of the engine with its instruments cropped off is a
|
||||
* stock photo of a landscape. What goes is only what is untrue outside the
|
||||
* capture, or unreadable inside it:
|
||||
*
|
||||
* - `#tier` reads "Full view" here because the capture runs against a local
|
||||
* `dist/` with no API to say otherwise. On the deployed site an anonymous
|
||||
* visitor gets "Public view". Shipping the local answer as marketing art
|
||||
* would be a picture of a permission nobody browsing the site actually has.
|
||||
* - `#rail` is the keyboard hint strip, which is advice for a person holding a
|
||||
* keyboard, not information about the city.
|
||||
*/
|
||||
export const FURNITURE = {
|
||||
/** Never wanted in a photograph: the boot curtain and the phone's panel scrim. */
|
||||
transient: ["#boot", "#scrim"],
|
||||
BARE: ["#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier"],
|
||||
CLUTTER: ["#rail", "#tier", "#source", "#panel-toggle"],
|
||||
};
|
||||
|
||||
/** Hide selectors, and wait a beat for the layout to settle before shooting. */
|
||||
export async function hide(page, selectors) {
|
||||
await page.evaluate((sels) => {
|
||||
for (const sel of sels) {
|
||||
document.querySelectorAll(sel).forEach((el) => (el.style.display = "none"));
|
||||
}
|
||||
}, selectors);
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
Reference in New Issue
Block a user