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/capture.mjs
T
karti 0cc2126e85 The capture harness uses the graphics card that was there all along
`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>
2026-08-06 19:38:14 -07:00

128 lines
5.6 KiB
JavaScript

/**
* Regenerate the share cards.
*
* node scripts/brand-assets/capture.mjs
*
* Two passes, because the cards are backed by the running app rather than by a
* drawing of it. First it shoots the city and the office out of a built `dist/`;
* then it renders `og.html` over those shots at exactly 1200x630 and writes the
* two PNGs into `public/`, from where Vite copies them verbatim.
*
* Rasterising with headless Chromium rather than a converter is the convention
* `lumbridge-v4/scripts/brand-assets/README.md` already set on this box, for the
* reason it gives: there is no ImageMagick, no `rsvg-convert` and no `sharp`
* here, and a browser renders the CSS the card was designed in anyway.
*
* ### Why the art is a screenshot and not an illustration
*
* Because the thing is worth looking at, and because an illustration of it goes
* stale silently. The card that shipped on lumbridgecorp.com was a viewport
* screenshot of a marketing page that had since been rewritten, so the preview
* advertised a positioning the site no longer used and nothing noticed for a
* month. A card regenerated from `dist/` by one command is a card that can be
* kept true by running that command.
*/
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { serve, launch, clockShim, FURNITURE, hide } from "./harness.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const PUBLIC = join(ROOT, "public");
/**
* Midday, fixed.
*
* The sun is real — `observe()` computes it from `new Date()` — so a card
* regenerated 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.
*/
const NOON = "2026-08-06T12:40:00-07:00";
async function shootApp(browser, url, file, { key = null, settle = 20000 } = {}) {
// 2x, so the art is still sharp when a timeline shows the card at 600px wide
// on a retina screen.
const page = await browser.newPage({
viewport: { width: 1400, height: 900 },
deviceScaleFactor: 2,
/**
* Without this the card was framed differently every run, and had been
* since the day it was written.
*
* `scenekit.ts` eases a chapter change over about two seconds of scene
* time and `stage.ts` clamps `dt` to 50 ms a frame — so a flight is about
* forty frames however fast they are drawn, and the four seconds this
* waited caught the camera partway across the bay at a different point
* each time. Two consecutive captures of an unchanged repo produced two
* different cards, neither of them the chapter the key press had asked
* for. It was worst under software GL, where forty frames took twenty
* seconds; on the GPU it is a shorter race and still a race.
*
* Reduced motion is the app's own answer for "somebody clicked a name in a
* list": `flyTo` sets the pose outright, so the shutter opens on the pose
* the key names.
*
* The *framing* is what this fixes, not the bytes. Aircraft are still
* crossing and cloud shadow is still drifting, so two runs differ by a few
* pixels and `git diff` will always show the PNG as changed. That is the
* app being alive, and worth far less than the framing being on purpose.
*/
reducedMotion: "reduce",
});
await page.addInitScript(clockShim(NOON));
await page.goto(url, { waitUntil: "networkidle" });
await page.waitForTimeout(settle);
if (key) {
await page.mouse.move(700, 450);
await page.keyboard.press(key);
await page.waitForTimeout(4000);
}
// The chrome comes off. The card supplies its own typography, and the app's
// panels shrunk to card size are unreadable furniture. A product screenshot
// wants the opposite — see `shots.mjs`, which keeps the instruments.
await hide(page, [...FURNITURE.transient, ...FURNITURE.BARE]);
await page.screenshot({ path: join(HERE, file), timeout: 120_000, animations: "disabled" });
console.log("art ", file);
await page.close();
}
async function renderCard(browser, which, out) {
const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 });
await page.goto(`http://127.0.0.1:8799/og.html?card=${which}`, { waitUntil: "networkidle" });
// The art is a background image, so `networkidle` is not proof it has decoded.
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(1200);
await page.screenshot({ path: join(PUBLIC, out) });
console.log("card ", out);
await page.close();
}
/**
* `--cards-only` re-renders the two PNGs from art already on disk.
*
* The app pass shoots the running city and the card pass is two seconds, and
* every iteration on a headline needs only the second. Without the flag, tuning
* a line of copy costs a whole app pass each time, which is how a card ends up
* shipped with the first wording anybody tried.
*/
const cardsOnly = process.argv.includes("--cards-only");
const app = cardsOnly ? null : await serve(join(ROOT, "dist"), 5210, { spa: true });
const assets = await serve(HERE, 8799);
const browser = await launch();
try {
if (!cardsOnly) {
await shootApp(browser, "http://office.lumbridgecorp.com:5210/", "art-office.png");
await shootApp(browser, "http://tera.lumbridgecorp.com:5210/", "art-tera.png", { key: "2" });
}
await renderCard(browser, "tera", "og-tera.png");
await renderCard(browser, "office", "og-office.png");
} finally {
await browser.close();
app?.close();
assets.close();
}