/** * 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); }