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 2aa4049258 feat(brand): re-shoot everything at the new engine, and make it one command
**Seven new shots**, because the world grew the most photogenic things in it
after the last pass: `sfo`, `lax`, `golden-gate`, `bay-bridge`, `freeway`,
`california-relief` and `pacific-sea`. All nine existing ids are unchanged — the
manifest emits a `ShotId` union that lumbridge-v4 imports, so ids are added and
never renamed.

**A shot can now aim itself.** The chapter list has no camera for SFO, LAX, the
bridges, the freeway or the open sea, and the engine has no `?pose=` back door,
so a shot points itself by driving the app's own inputs: a click on the plan view
slides the orbit target to a lat/lng while keeping the chapter's stance, wheel
notches set the standoff, and a drag sets azimuth and elevation. The plan view's
pixel-to-coordinate map is solved at runtime from three hovers of
`#minimap-readout` rather than hard-coded, so it survives a board resize or a
restyle of the widget.

**The tera share card was a picture of the wrong thing, and had been.** Its art
came from `keyboard.press("2")`, which had landed on the California board's drive
mode once the default board changed — so the card under the headline "Cities from
above." was a chase camera on US-101, showing metre-scale cars driving between
kilometre-wide buildings, with the DRIVE readout and the mode pill baked into the
art. It renders, it looks deliberate, and it is why an unguarded key press has no
place in a capture script. `capture.mjs` now clicks an indexed chapter and
asserts its `shortLabel` the way `shots.mjs` does, waits on `#boot` and
`#chapters` instead of sleeping twenty seconds, and gives each card its own hour.

**`npm run refresh` is the durable half.** One command: build, stills, cards,
films, both manifests, and a hashed before/after diff of every deliverable. It
fails loudly and specifically on the two conditions that otherwise produce
confident wrong output — the renderer coming up as SwiftShader, and a chapter
`expect` guard firing. `--stills-only` / `--cards-only` / `--films-only` compose,
`--dry-run` lists the plan without opening a browser, and
`shots.mjs --list` prints the whole shot plan — board, chapter, expect, aim, both
hours — which is what to run first when a guard does fire.

It also re-stamps `PROVENANCE.json`, narrowly: only entries whose origin is
`repository-generated` and whose `generator` names a script the run actually
executed, by literal hash substitution rather than re-serialising the file.
Without that, every legitimate card re-shoot leaves `npm run provenance` red.

**Every film re-shot.** They were at `9c9e78f`, captured 2026-08-07, and predated
the tone mapping, the reflective sea, the sky dome, terrain shadows, the rebuilt
California board, SFO, LAX, both bridges and the moving aircraft.

Tests 1137, typecheck, build, eight budget cells and every provenance and licence
check pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:49:06 -07:00

217 lines
9.5 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"],
/*
* `#mode-dock` and `#play-hud` joined this list after they turned up in the
* shipped share card. The dock is the VIEW / DRIVE / EXPLORE / FLY pill and the
* HUD is the drive readout, and both are exactly the app furniture a card
* replaces with its own typography — but because they are new, they arrived in
* the art silently, the way `#onboarding-host` did. Anything mounted over the
* canvas belongs in one of these two lists on the day it is added.
*/
BARE: [
"#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier",
"#onboarding-host", "#mode-dock", "#play-hud",
],
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);
}