1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/scripts/brand-assets/harness.mjs
T
karti 3c4649d078 fix: make the app boot on an insecure origin, and re-aim the capture harness
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>
2026-08-22 00:03:20 -07:00

206 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}