0cc2126e85
`launch()` asks for `--use-angle=vulkan` and gets the Radeon RX 6700 XT. On the real film loop at 1440x900 that is 7.56 s a frame to 0.47, and about twelve cores pegged down to less than one. A 180-frame film goes from twenty-two minutes to ninety seconds; the three shipped reels go from over an hour to four minutes. The SwiftShader flag was inherited from the share-card script and its comment said "a box with no display and no GPU". Half of that was never true. The card has been in this machine the whole time, 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 group `render` already grants. So the portability the flag was protecting is intact: this still runs over ssh and under cron. Naming the backend is the whole trick, and most of the plausible spellings are traps. `--use-angle=gl`, `--use-gl=egl`, `--use-gl=desktop`, `--enable-features=Vulkan` and passing no GL flags at all were each measured on this box, and all five land on SwiftShader while reporting success. Only `--use-angle=vulkan` and `--use-angle=gl-egl` reach the card. So `launch()` verifies instead of assuming. `--use-angle=vulkan` is a demand, not a preference: with the driver hidden it yields no WebGL context at all rather than falling back, the boot curtain never lifts, and the caller sits in `waitForFunction` until a three-minute timeout before failing with something that looks nothing like "there is no GPU here". Reading `UNMASKED_RENDERER_WEBGL` once per run costs about half a second and turns that into a printed line and a slow, correct render. Both branches are tested; the sabotaged one was verified with `VK_LOADER_DRIVERS_DISABLE`. The pictures are the same pictures: mean absolute difference of half a level out of 255, confined to MSAA edges and to cloud shadow that already drifts between any two runs of the same renderer. Also corrects every comment across the four scripts that asserted three frames a second or twenty-two minutes a reel. Those were true when written and are not now, and the `reducedMotion` rationale in two of them was resting on the number: the real reason a chapter flight cannot be waited out is that it is ~40 frames however fast they are drawn, which was twenty seconds under software GL and is a shorter race on the GPU — still a race. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
198 lines
8.3 KiB
JavaScript
198 lines
8.3 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.
|
||
*/
|
||
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);
|
||
}
|