3c4649d078
Three things, all found by trying to re-shoot the product imagery and failing.
**`crypto.randomUUID` is secure-context only.** `main.ts` called it at module top
level for its three wire identities, so on any origin that is not HTTPS and not
`localhost` the call threw before the scene was built and the app stopped at
"Starting up" with one TypeError and no other symptom. Every way a developer
normally opens this app is a secure context — `vite dev` and `vite preview` serve
localhost, the deployed site is HTTPS — which is why this survived since 3326d2e.
It breaks the brand-capture harness, which serves `dist/` over
`http://tera.lumbridgecorp.com:5210` so the app can read its own hostname and
decide which door it is; it breaks the plain `http://` static host STATIC.md
explicitly invites; and it breaks opening the dev server by LAN IP to try it on a
phone. `src/ids.ts` prefers the platform's `randomUUID` and falls back to
`getRandomValues`, which carries no such restriction. The tests exercise the
fallback specifically, because the happy path was never the broken one.
**`waitForFunction` was ignoring its own timeout.** Playwright's signature is
`(pageFunction, arg, options)` and all four call sites in `shots.mjs` and
`films.mjs` passed `{ timeout: 180_000 }` second, binding it as the predicate's
argument. The wait silently used the 30 s default, which was invisible for as
long as the app booted inside thirty seconds and started failing the moment the
California board grew its relief — with "Timeout 30000ms exceeded" reported
against a line that plainly reads 180_000.
**The office shot list photographed a building that no longer exists.** The
`expect` guard caught it and refused to shoot, which is exactly what it is for:
chapter 0 is "Front Door" now, not "The Floor". But the captions were staler than
the labels — they described forty-eight metres by eighteen, thirty-six seats in
four benches and a fourteen-metre interstitial commons, and Lumbridge HQ is a
live/work studio now. Re-aimed at the buildings that exist: `office-floor` and
`office-desks` at the SF studio (the second specifically at the bench, because the
desk mic and the machine speaker are the new thing there), `office-commons` at the
LA courtyard, `office-hangar` still at Frontier Valley and now honest about being
in development. The four ids are deliberately unchanged: the manifest emits a
`ShotId` union that v4 imports, so renaming one fails v4's typecheck at push.
Also `#onboarding-host` joins the harness's clutter list. The first-run card is
correct behaviour for a real visitor and wrong in a product photograph, and it had
quietly placed itself in the middle of every frame — which is the general hazard
that list exists for, because a shot with a stray card still renders and still
looks deliberate.
`bay-relief`'s daylight frame moves from 07:40 to 09:10. At 07:40 the marine layer
buried the heightfield the shot exists to demonstrate; the version currently on the
site is almost entirely white. Its `note` moves with it, since the note names the
hour and a stale one describes light that is not in the picture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
206 lines
9.0 KiB
JavaScript
206 lines
9.0 KiB
JavaScript
/**
|
||
* 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 GL
|
||
* 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)));
|
||
}
|
||
|
||
const COMMON_ARGS = [
|
||
"--no-sandbox",
|
||
"--disable-dev-shm-usage",
|
||
// 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",
|
||
];
|
||
|
||
/**
|
||
* The GPU is real, and headless Chrome will use it if you name the backend
|
||
* precisely.
|
||
*
|
||
* This box has a Radeon RX 6700 XT with the amdgpu driver and a working RADV
|
||
* ICD, and Chrome reaches it with **no display server at all** — no `DISPLAY`,
|
||
* no `XDG_RUNTIME_DIR`, nothing but read access to `/dev/dri/renderD128`, which
|
||
* membership of the `render` group already grants. So this still runs under
|
||
* cron and over ssh, which is what the software-GL flag was protecting.
|
||
*
|
||
* It is worth naming the backend exactly, because most of the plausible spellings
|
||
* silently give you SwiftShader instead and report success: `--use-angle=gl`,
|
||
* `--use-gl=egl`, `--use-gl=desktop`, `--enable-features=Vulkan` and passing no
|
||
* GL flags at all were all measured on this box, and all four land on
|
||
* `SwiftShader Device (Subzero)`. Only `--use-angle=vulkan` and
|
||
* `--use-angle=gl-egl` reach the card. Nothing else is required — the
|
||
* `--ozone-platform`, `--enable-features=Vulkan` and `--disable-software-rasterizer`
|
||
* flags that usually accompany this change measured as no-ops.
|
||
*
|
||
* What it buys, on the real film loop at 1440x900: 7.56 s/frame to 0.47, and
|
||
* about twelve cores pegged to less than one. A 180-frame film goes from
|
||
* twenty-two minutes to eighty seconds. The pictures are the same pictures —
|
||
* mean absolute difference half a level out of 255, confined to MSAA edges and
|
||
* to cloud shadow that drifts between any two runs anyway.
|
||
*/
|
||
export const GPU_ARGS = [...COMMON_ARGS, "--use-gl=angle", "--use-angle=vulkan"];
|
||
|
||
/** Where `launch()` goes when there is no usable card. See `launch()`. */
|
||
export const SOFTWARE_ARGS = [...COMMON_ARGS, "--use-gl=angle", "--use-angle=swiftshader"];
|
||
|
||
/** The renderer string a real WebGL context reports, or `null` if it has none. */
|
||
async function rendererOf(browser) {
|
||
const page = await browser.newPage();
|
||
try {
|
||
await page.goto("about:blank");
|
||
return await page.evaluate(() => {
|
||
const gl = document.createElement("canvas").getContext("webgl2");
|
||
const ext = gl && gl.getExtension("WEBGL_debug_renderer_info");
|
||
return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : null;
|
||
});
|
||
} catch {
|
||
return null;
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Chrome with the GPU, having checked that it actually got one.
|
||
*
|
||
* The check is not ceremony. `--use-angle=vulkan` is a demand, not a
|
||
* preference: on a box where the driver is missing it does not quietly fall
|
||
* back to software, it produces **no WebGL context at all**. The app's boot
|
||
* curtain then never lifts, and the caller sits in `waitForFunction` until its
|
||
* three-minute timeout before failing with something that looks nothing like
|
||
* "there is no GPU here". Asking the context what it is costs half a second
|
||
* once per run and turns that into a line of output and a slow, correct render.
|
||
*
|
||
* `SwiftShader` and `llvmpipe` both count as failure — they are what a silent
|
||
* fallback looks like, and a run that thinks it is on the GPU while taking
|
||
* twenty minutes a film is the confusion this whole check exists to prevent.
|
||
*/
|
||
export async function launch({ gpu = true } = {}) {
|
||
if (gpu) {
|
||
const browser = await chromium.launch({ channel: "chrome", args: GPU_ARGS });
|
||
const renderer = await rendererOf(browser);
|
||
if (renderer && !/SwiftShader|llvmpipe/i.test(renderer)) {
|
||
console.log(` GPU: ${renderer}`);
|
||
return browser;
|
||
}
|
||
console.log(` no GPU (${renderer ?? "no WebGL context"}) — falling back to SwiftShader, which is slow.`);
|
||
await browser.close();
|
||
}
|
||
const browser = await chromium.launch({ channel: "chrome", args: SOFTWARE_ARGS });
|
||
console.log(` GPU: ${await rendererOf(browser)}`);
|
||
return browser;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
* - `#onboarding-host` is the first-run card. It is *correct* — a real visitor
|
||
* on a first visit does see it — but it is teaching the reader how to fly a
|
||
* board they are looking at a photograph of, and it covers the bottom-left
|
||
* quarter of the frame. It is also the newest thing here, which is the
|
||
* general hazard this list exists for: every card added to the interface is
|
||
* in the product imagery by default, and silently, because the shot still
|
||
* renders and still looks deliberate. Anything mounted over the canvas that
|
||
* is not the world or an instrument reading it belongs in this list.
|
||
*/
|
||
export const FURNITURE = {
|
||
/** Never wanted in a photograph: the boot curtain and the phone's panel scrim. */
|
||
transient: ["#boot", "#scrim", "#onboarding-host"],
|
||
BARE: ["#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier", "#onboarding-host"],
|
||
CLUTTER: ["#rail", "#tier", "#source", "#panel-toggle", "#onboarding-host"],
|
||
};
|
||
|
||
/** 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);
|
||
}
|