tower / interior-flight / files / tools / screenshots.mjs
Branch interior-flight · default branch · all branches
screenshots.mjs
6364748a 2026-09-08 Pavel Safronov | 1 | /** |
| 2 | * Screenshot tour. `npm run screenshots` | |
| 3 | * | |
| 4 | * Drives the real application in a headless browser and photographs it: | |
| 5 | * docs/screenshots/<profile>-<shot>.png one file per view, desktop and mobile | |
| 6 | * docs/screenshots/overview.png all of them on one contact sheet | |
| 7 | * | |
| 8 | * Every shot is taken by *using* the map — clicking the view buttons, picking | |
| 9 | * places out of the list, dragging to orbit and to look up — never by posing a | |
| 10 | * renderer directly. That is the whole point: a screenshot taken through the UI | |
| 11 | * fails when the UI fails, so a black canvas, or a sidebar that has eaten the | |
| 12 | * window, ends up in the picture instead of hiding behind a passing unit test. | |
| 13 | * | |
| 14 | * The contact sheet exists to be looked at, once, before committing: one image that | |
| 15 | * answers "is the application still there?". `--check` puts a number on that (see | |
| 16 | * `divergence`), but the number is a smoke alarm, not a verdict. Look at the sheet. | |
| 17 | * | |
| 18 | * Determinism is deliberate and shallow — a fixed viewport, a seeded Math.random | |
e27aaf20 2026-09-08 Pavel Safronov | 19 | * (nothing in Tower is stochastic since D-049) and the exterior spin switched |
6364748a 2026-09-08 Pavel Safronov | 20 | * off. Anything else that moves between runs is a real change worth seeing. |
| 21 | * | |
| 22 | * Flags: | |
| 23 | * --check exit non-zero if any shot diverged past the threshold | |
| 24 | * --threshold <pct> how far a shot may move first (default 3, and calibrated) | |
| 25 | * --url <origin> photograph a server already running instead of starting one | |
| 26 | */ | |
| 27 | import { mkdir, readFile } from 'node:fs/promises' | |
| 28 | import path from 'node:path' | |
| 29 | import { fileURLToPath } from 'node:url' | |
| 30 | import { createServer } from 'vite' | |
| 31 | import { chromium } from 'playwright' | |
| 32 | ||
| 33 | const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') | |
| 34 | const OUT = path.join(ROOT, 'docs', 'screenshots') | |
| 35 | const OVERVIEW = path.join(OUT, 'overview.png') | |
| 36 | ||
| 37 | /** | |
| 38 | * Above this per-shot mean pixel difference, `--check` stops and asks for human eyes. | |
| 39 | * | |
| 40 | * Measured, not guessed, at both ends: | |
| 41 | * | |
| 42 | * noise Seven of the ten shots are byte-identical run to run. The two | |
| 43 | * exterior shots are not: OrbitControls damps toward its target | |
| 44 | * asymptotically, so the camera lands a hair short of the same place | |
| 45 | * each time and the labels and markers shift by a pixel. Observed up | |
| 46 | * to 0.98% on mobile-exterior, where the canvas is narrow enough for | |
| 47 | * a pixel to count for more. | |
| 48 | * failure Hiding both 3D canvases scored 7% (desktop-exterior, mostly black | |
| 49 | * sky already) to 31% (desktop-interior). A shot going entirely black | |
| 50 | * scores 12% to 39%. | |
| 51 | * | |
| 52 | * 3% is three times the worst observed noise and less than half the mildest real | |
| 53 | * failure. Re-measure both ends before moving it — and note that a view already | |
| 54 | * broken scores 0% when it breaks further, which is how mobile-exterior behaved | |
| 55 | * before D-043: this catches regressions, not things that never worked. | |
| 56 | */ | |
| 57 | const DEFAULT_THRESHOLD = 3 | |
| 58 | ||
18958d3d 2026-09-08 Pavel Safronov | 59 | /** How many shots to photograph at once. See the pool in main() for why four. */ |
| 60 | const CONCURRENCY = 4 | |
| 61 | ||
6364748a 2026-09-08 Pavel Safronov | 62 | /** |
| 63 | * Two shapes, because the second one is currently the interesting one: at 390 px | |
| 64 | * the fixed 340 px sidebar leaves the world about fifty pixels of window. The | |
| 65 | * mobile shots are in here to keep that visible until it is fixed. | |
| 66 | */ | |
| 67 | const PROFILES = [ | |
| 68 | { id: 'desktop', label: 'Desktop — 1440×900', viewport: { width: 1440, height: 900 }, scale: 1, mobile: false }, | |
| 69 | { id: 'mobile', label: 'Mobile — 390×844', viewport: { width: 390, height: 844 }, scale: 2, mobile: true }, | |
| 70 | ] | |
| 71 | ||
| 72 | // --------------------------------------------------------------------- helpers | |
| 73 | ||
| 74 | /** Flush a few animation frames, then hold still, so a settling view has settled. */ | |
| 75 | async function settle(page, ms = 900) { | |
| 76 | await page.evaluate(async () => { | |
| 77 | for (let i = 0; i < 20; i++) await new Promise((r) => requestAnimationFrame(() => r(null))) | |
| 78 | }) | |
| 79 | await page.waitForTimeout(ms) | |
| 80 | } | |
| 81 | ||
| 82 | /** The canvas box, for aiming clicks and drags at the world rather than the panel. */ | |
| 83 | async function stage(page) { | |
| 84 | const box = await page.locator('main').boundingBox() | |
| 85 | if (!box) throw new Error('no <main> to aim at') | |
| 86 | return { x: box.x, y: box.y, w: box.width, h: box.height } | |
| 87 | } | |
| 88 | ||
| 89 | /** A slow drag, so pointermove handlers read it as a gesture and not as a jump. */ | |
| 90 | async function drag(page, [fx, fy], [tx, ty]) { | |
| 91 | await page.mouse.move(fx, fy) | |
| 92 | await page.mouse.down() | |
| 93 | const steps = 24 | |
| 94 | for (let i = 1; i <= steps; i++) { | |
| 95 | await page.mouse.move(fx + ((tx - fx) * i) / steps, fy + ((ty - fy) * i) / steps) | |
| 96 | await page.waitForTimeout(8) | |
| 97 | } | |
| 98 | await page.mouse.up() | |
| 99 | } | |
| 100 | ||
4699e6cb 2026-09-08 Pavel Safronov | 101 | /** |
| 102 | * Open one of the three views by clicking its button, as a reader would. | |
| 103 | * | |
| 104 | * Waits for the view to exist rather than for a guess at how long it takes. Three | |
| 105 | * is a deferred import, so both 3D views appear some hundreds of milliseconds | |
| 106 | * after the click — and a drag begun before the renderer has attached its pointer | |
| 107 | * handlers is silently half-swallowed. That is not hypothetical: it is how | |
| 108 | * interior-overhead came out pitched +13° when its drag is sized for +45°, and it | |
| 109 | * had been doing so quietly for several runs behind a 2.5 s timeout that was | |
| 110 | * usually but not always long enough. | |
| 111 | * | |
| 112 | * Both readouts are rendered only once their view is live, so a non-empty one is | |
| 113 | * the signal — no app change needed to expose it. | |
| 114 | */ | |
6364748a 2026-09-08 Pavel Safronov | 115 | async function openView(page, view) { |
| 116 | await page.click(`#views button[data-view="${view}"]`) | |
4699e6cb 2026-09-08 Pavel Safronov | 117 | if (view !== 'map') { |
| 118 | await page.waitForFunction( | |
| 119 | (sel) => (document.querySelector(sel)?.textContent ?? '').length > 0, | |
| 120 | view === 'exterior' ? '#spin' : '#standing', | |
| 121 | { timeout: 30_000 }) | |
| 122 | } | |
| 123 | await page.waitForTimeout(view === 'map' ? 400 : 700) | |
6364748a 2026-09-08 Pavel Safronov | 124 | await settle(page) |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * Reach the sidebar, whatever shape it is in. | |
| 129 | * | |
| 130 | * On a wide screen it is a column and always there. Below 720 px it is a drawer | |
| 131 | * that starts closed, so anything in it has to be opened for first — and closed | |
| 132 | * again afterwards, or every mobile shot would be a picture of the panel. | |
| 133 | */ | |
| 134 | async function withPanel(page, fn) { | |
| 135 | /** | |
| 136 | * Ask the same media query the CSS and the app ask, rather than looking at the | |
| 137 | * buttons. A closed drawer is only translated off-screen, so `isVisible()` on | |
| 138 | * anything inside it still answers true and then the click times out against | |
| 139 | * "element is outside of the viewport". | |
| 140 | */ | |
| 141 | const drawer = await page.evaluate(() => matchMedia('(max-width: 720px)').matches) | |
| 142 | const isOpen = () => | |
| 143 | page.evaluate(() => document.getElementById('app').classList.contains('panel-open')) | |
| 144 | ||
| 145 | if (drawer && !(await isOpen())) { | |
| 146 | await page.click('#panel-toggle') | |
| 147 | await page.waitForTimeout(300) | |
| 148 | } | |
| 149 | await fn() | |
| 150 | // The app closes the drawer itself when a place is picked, so this is only for | |
| 151 | // the cases where it did not — flipping a checkbox, say. | |
| 152 | if (drawer && (await isOpen())) { | |
| 153 | await page.click('#panel-close') | |
| 154 | await page.waitForTimeout(300) | |
| 155 | } | |
| 156 | await settle(page) | |
| 157 | } | |
| 158 | ||
| 159 | /** Click a place in the World list. In every view this is also how you travel. */ | |
| 160 | async function pick(page, name) { | |
| 161 | await withPanel(page, () => page.locator('#list li', { hasText: name }).first().click()) | |
| 162 | } | |
| 163 | ||
| 164 | // ----------------------------------------------------------------------- shots | |
| 165 | ||
| 166 | /** | |
| 167 | * The tour. `on` names the profiles a shot is taken in — the sightline probe and | |
| 168 | * the overhead look are desktop-only because they are gestures, and the reader | |
| 169 | * card is desktop-only because it is the same layout with fewer controls. | |
| 170 | */ | |
| 171 | const SHOTS = [ | |
| 172 | { | |
| 173 | id: 'map', | |
| 174 | caption: 'Map — the unrolled inner surface, with a place selected', | |
| 175 | on: ['desktop', 'mobile'], | |
| 176 | run: async (page) => { | |
| 177 | await openView(page, 'map') | |
| 178 | await pick(page, 'The Capital') | |
| 179 | }, | |
| 180 | }, | |
| 181 | { | |
| 182 | id: 'map-sightline', | |
| 183 | caption: 'Map — shift-click two points to measure a sightline through the air', | |
| 184 | on: ['desktop'], | |
| 185 | run: async (page) => { | |
| 186 | await openView(page, 'map') | |
| 187 | const s = await stage(page) | |
| 188 | // Two points well apart in z, so the readout has a long line of sight and a | |
| 189 | // transmission worth reading. Shift is held down across both clicks, because | |
| 190 | // the probe is a two-click gesture and the map reads the modifier on each. | |
| 191 | await page.keyboard.down('Shift') | |
| 192 | await page.mouse.click(s.x + s.w * 0.22, s.y + s.h * 0.45) | |
| 193 | await page.mouse.click(s.x + s.w * 0.68, s.y + s.h * 0.58) | |
| 194 | await page.keyboard.up('Shift') | |
| 195 | await settle(page) | |
| 196 | }, | |
| 197 | }, | |
| 198 | { | |
| 199 | id: 'exterior', | |
4699e6cb 2026-09-08 Pavel Safronov | 200 | caption: 'Exterior, x-ray hull — the inner surface projected out, with the caps and the collector', |
6364748a 2026-09-08 Pavel Safronov | 201 | on: ['desktop', 'mobile'], |
| 202 | run: async (page) => { | |
| 203 | await openView(page, 'exterior') | |
| 204 | // Spin off: a turning Tower photographs differently every run, and the value | |
| 205 | // of these images is that a difference means something. | |
| 206 | await withPanel(page, () => page.uncheck('#f-spin')) | |
4699e6cb 2026-09-08 Pavel Safronov | 207 | await withPanel(page, () => page.selectOption('#f-ext-view', 'xray')) |
6364748a 2026-09-08 Pavel Safronov | 208 | const s = await stage(page) |
| 209 | await drag(page, [s.x + s.w * 0.5, s.y + s.h * 0.5], [s.x + s.w * 0.5 - 150, s.y + s.h * 0.5 + 70]) | |
| 210 | // The default view sits about 1,200 km out, where Tower is a sliver. Close in | |
| 211 | // until the caps and the collector are worth looking at. | |
| 212 | for (let i = 0; i < 6; i++) { | |
| 213 | await page.mouse.wheel(0, -400) | |
| 214 | await page.waitForTimeout(120) | |
| 215 | } | |
| 216 | // OrbitControls damps at 0.08 per frame, so the camera coasts after a drag. | |
| 217 | // Wait it out: a shot taken mid-coast is a different image every run. | |
| 218 | await settle(page, 3000) | |
| 219 | }, | |
| 220 | }, | |
4699e6cb 2026-09-08 Pavel Safronov | 221 | { |
| 222 | id: 'exterior-true', | |
| 223 | caption: 'Exterior, true hull — bare skin, and the land visible in through the far cap window', | |
| 224 | on: ['desktop'], | |
| 225 | run: async (page) => { | |
| 226 | await openView(page, 'exterior') | |
| 227 | await withPanel(page, () => page.uncheck('#f-spin')) | |
| 228 | await withPanel(page, () => page.selectOption('#f-ext-view', 'true')) | |
| 229 | // Swing round to the far cap: the window is the whole point of this mode and | |
| 230 | // it is at one specific end of a 540 km object. | |
| 231 | await pick(page, 'The Far Cap') | |
| 232 | const s = await stage(page) | |
| 233 | await page.mouse.move(s.x + s.w * 0.5, s.y + s.h * 0.5) | |
| 234 | for (let i = 0; i < 3; i++) { | |
| 235 | await page.mouse.wheel(0, -400) | |
| 236 | await page.waitForTimeout(120) | |
| 237 | } | |
| 238 | await settle(page, 3000) | |
| 239 | }, | |
| 240 | }, | |
6364748a 2026-09-08 Pavel Safronov | 241 | { |
| 242 | id: 'interior', | |
| 243 | caption: 'Interior — standing at The Narrows, the ground curving up on both sides', | |
| 244 | on: ['desktop', 'mobile'], | |
| 245 | run: async (page) => { | |
| 246 | await openView(page, 'interior') | |
| 247 | await pick(page, 'The Narrows') | |
| 248 | await settle(page, 1400) | |
| 249 | }, | |
| 250 | }, | |
| 251 | { | |
| 252 | id: 'interior-overhead', | |
| 253 | caption: 'Interior — looking up: the far side 100 km overhead is clearer than ground 100 km away', | |
| 254 | on: ['desktop'], | |
| 255 | run: async (page) => { | |
| 256 | await openView(page, 'interior') | |
| 257 | await pick(page, 'Greatwood') | |
| 258 | const s = await stage(page) | |
| 259 | /** | |
| 260 | * Dragging down pitches the view up. The interior turns at 0.0032 rad per | |
4699e6cb 2026-09-08 Pavel Safronov | 261 | * pixel at the default 60° field, so 164 px is 30°. |
| 262 | * | |
| 263 | * It was 245 px / 45° until the far cap became a window. At 45° the frame is | |
| 264 | * almost all haze — which is honest, and a poor picture of the claim in the | |
| 265 | * caption. 30° keeps the ground's edge curving up on one side and the cap on | |
| 266 | * the other, which is what the shot is actually for. | |
6364748a 2026-09-08 Pavel Safronov | 267 | */ |
4699e6cb 2026-09-08 Pavel Safronov | 268 | await drag(page, [s.x + s.w * 0.5, s.y + s.h * 0.32], [s.x + s.w * 0.5, s.y + s.h * 0.32 + 164]) |
6364748a 2026-09-08 Pavel Safronov | 269 | await settle(page, 1600) |
| 270 | }, | |
| 271 | }, | |
| 272 | { | |
| 273 | id: 'panel', | |
| 274 | caption: 'Mobile — the sidebar as a drawer, opened over the world and one tap from gone', | |
| 275 | on: ['mobile'], | |
| 276 | run: async (page) => { | |
| 277 | await openView(page, 'interior') | |
| 278 | await pick(page, 'The Narrows') | |
| 279 | await page.click('#panel-toggle') | |
| 280 | await page.waitForTimeout(400) | |
| 281 | await settle(page) | |
| 282 | }, | |
| 283 | }, | |
4699e6cb 2026-09-08 Pavel Safronov | 284 | { |
| 285 | id: 'interior-night', | |
| 286 | caption: 'Interior — the far cap window at night: stars, and the beam banked to a thousandth', | |
| 287 | on: ['desktop'], | |
| 288 | run: async (page) => { | |
| 289 | await openView(page, 'interior') | |
| 290 | // The Capital is 270 km from the far cap, which is far enough to see the | |
| 291 | // whole 100 km window and near enough that some of the sky survives the air. | |
| 292 | await pick(page, 'The Capital') | |
17ccee99 2026-09-08 Pavel Safronov | 293 | /** |
| 294 | * Driven from the clock rather than the Light menu, so the picture agrees | |
| 295 | * with itself: 01:00 gives night lighting *and* puts the collector's hand | |
| 296 | * 15° off its fixed mark. Setting the menu to night instead left the world | |
| 297 | * dark under a clock reading noon. | |
| 298 | */ | |
| 299 | await withPanel(page, async () => { | |
| 300 | await page.locator('#f-time').fill('60') | |
| 301 | await page.locator('#f-time').dispatchEvent('input') | |
| 302 | }) | |
4699e6cb 2026-09-08 Pavel Safronov | 303 | await settle(page, 1600) |
| 304 | }, | |
| 305 | }, | |
567fe8ad 2026-09-08 Pavel Safronov | 306 | { |
| 307 | id: 'interior-suncap', | |
17ccee99 2026-09-08 Pavel Safronov | 308 | caption: 'Interior — the sun cap window: the collector, its sunlit rim, and Tower\'s clock on its face', |
567fe8ad 2026-09-08 Pavel Safronov | 309 | on: ['desktop'], |
| 310 | run: async (page) => { | |
| 311 | await openView(page, 'interior') | |
| 312 | await pick(page, 'The Capital') | |
17ccee99 2026-09-08 Pavel Safronov | 313 | /** |
| 314 | * Driven from the clock rather than the Light menu, so the picture agrees | |
| 315 | * with itself: 01:00 gives night lighting *and* puts the collector's hand | |
| 316 | * 15° off its fixed mark. Setting the menu to night instead left the world | |
| 317 | * dark under a clock reading noon. | |
| 318 | */ | |
| 319 | await withPanel(page, async () => { | |
| 320 | await page.locator('#f-time').fill('60') | |
| 321 | await page.locator('#f-time').dispatchEvent('input') | |
| 322 | }) | |
567fe8ad 2026-09-08 Pavel Safronov | 323 | // Turn to face sunward. 246 px is 45° at the default field, and the drag has |
| 324 | // to stay inside the viewport, so it goes round in four passes rather than | |
| 325 | // one 982 px sweep that would run off the edge and drop the pointer capture. | |
| 326 | const s = await stage(page) | |
| 327 | for (let i = 0; i < 4; i++) { | |
| 328 | await drag(page, [s.x + s.w * 0.8, s.y + s.h * 0.5], [s.x + s.w * 0.8 - 246, s.y + s.h * 0.5]) | |
| 329 | await page.waitForTimeout(150) | |
| 330 | } | |
| 331 | await settle(page, 1600) | |
| 332 | }, | |
| 333 | }, | |
2bb3e0de 2026-09-08 Pavel Safronov | 334 | { |
| 335 | id: 'interior-dawn', | |
| 336 | caption: 'Interior — dawn: a lit sky over dark ground, and the light still 27 km away', | |
| 337 | on: ['desktop'], | |
| 338 | run: async (page) => { | |
| 339 | await openView(page, 'interior') | |
| 340 | await pick(page, 'The Capital') | |
| 341 | await withPanel(page, async () => { | |
| 342 | // 06:17. The dawn front leaves the sun cap at 06:00 and takes 40 minutes to | |
| 343 | // cross the world, so at The Capital's z 270 km it is 27 km short and still | |
| 344 | // coming — which is the whole point of the shot. | |
| 345 | await page.locator('#f-time').fill('378') | |
| 346 | await page.locator('#f-time').dispatchEvent('input') | |
| 347 | }) | |
| 348 | const s = await stage(page) | |
| 349 | for (let i = 0; i < 4; i++) { | |
| 350 | await drag(page, [s.x + s.w * 0.8, s.y + s.h * 0.5], [s.x + s.w * 0.8 - 246, s.y + s.h * 0.5]) | |
| 351 | await page.waitForTimeout(150) | |
| 352 | } | |
| 353 | await settle(page, 1600) | |
| 354 | }, | |
| 355 | }, | |
1fcef8e6 2026-09-08 Pavel Safronov | 356 | { |
| 357 | id: 'interior-flying', | |
41516065 2026-09-08 Pavel Safronov | 358 | caption: 'Interior — flying on autopilot at 4 km, looking off the course rather than along it', |
1fcef8e6 2026-09-08 Pavel Safronov | 359 | on: ['desktop'], |
| 360 | run: async (page) => { | |
| 361 | await openView(page, 'interior') | |
| 362 | await pick(page, 'The Capital') | |
| 363 | await withPanel(page, async () => { | |
| 364 | /** | |
| 365 | * Take off from a known height rather than climbing to one. A flying camera | |
| 366 | * never stops, so the only way to photograph it twice the same is to fix | |
| 367 | * where the flight *starts* and keep the shutter short — 809 on the | |
| 368 | * logarithmic eye-height slider is 4 km, and the slowest cruise covers | |
| 369 | * 150 m a second, so the drift between runs is metres out of kilometres. | |
| 370 | */ | |
| 371 | await page.locator('#f-eye').fill('809') | |
| 372 | await page.locator('#f-eye').dispatchEvent('input') | |
| 373 | await page.locator('#f-fly-speed').fill('0') | |
| 374 | await page.locator('#f-fly-speed').dispatchEvent('input') | |
| 375 | await page.check('#f-fly') | |
| 376 | }) | |
41516065 2026-09-08 Pavel Safronov | 377 | // Look away from the course, which is the whole point of the mode: takeoff |
| 378 | // aims you along it, and the drag only turns your head. | |
| 379 | const s = await stage(page) | |
| 380 | await drag(page, [s.x + s.w * 0.5, s.y + s.h * 0.38], | |
| 381 | [s.x + s.w * 0.5 - 230, s.y + s.h * 0.38 + 70]) | |
1fcef8e6 2026-09-08 Pavel Safronov | 382 | await settle(page, 900) |
| 383 | }, | |
| 384 | }, | |
6364748a 2026-09-08 Pavel Safronov | 385 | { |
| 386 | id: 'reader', | |
| 387 | caption: 'Reader mode (?reader=1) — terrain, places, a detail card and the probe, no editing', | |
| 388 | on: ['desktop'], | |
| 389 | run: async (page) => { | |
| 390 | await openView(page, 'map') | |
| 391 | await pick(page, 'The Spindle') | |
| 392 | await settle(page) | |
| 393 | }, | |
| 394 | }, | |
| 395 | ] | |
| 396 | ||
| 397 | /** Reader mode is a query parameter, so it has to be chosen before the page loads. */ | |
| 398 | const searchFor = (shot) => (shot.id === 'reader' ? '?reader=1' : '') | |
| 399 | ||
| 400 | const fileFor = (profile, shot) => path.join(OUT, `${profile}-${shot}.png`) | |
| 401 | ||
| 402 | // ------------------------------------------------------------------- the tour | |
| 403 | ||
18958d3d 2026-09-08 Pavel Safronov | 404 | /** |
| 405 | * Launch the browser that will do the drawing. | |
| 406 | * | |
| 407 | * `channel: 'chromium'` is the full Chrome for Testing build, which reaches the | |
| 408 | * real GPU; the default headless shell falls back to SwiftShader and renders the | |
| 409 | * interior's haze integral on the CPU. That is not a small difference — one | |
| 410 | * interior shot measured **87.3 s on SwiftShader against 6.4 s on the GPU**, and | |
| 411 | * the 180° turn inside it went from 77.7 s to 2.6 s, because every mouse move | |
| 412 | * during a drag waits on a frame. | |
| 413 | * | |
| 414 | * The cost is that the baseline is now tied to this machine's GPU rather than to a | |
| 415 | * software renderer that would give the same pixels anywhere. Nothing runs this in | |
| 416 | * CI, so that is a fair trade — but it is why `--check` compares against images a | |
| 417 | * particular machine produced, and why a different one will show a large one-off | |
| 418 | * divergence rather than a regression. | |
| 419 | */ | |
| 420 | async function launch() { | |
| 421 | try { | |
| 422 | return await chromium.launch({ channel: 'chromium' }) | |
| 423 | } catch { | |
| 424 | console.log(' (no Chrome for Testing build; falling back to the software renderer)') | |
| 425 | return await chromium.launch() | |
| 426 | } | |
| 427 | } | |
| 428 | ||
6364748a 2026-09-08 Pavel Safronov | 429 | async function capture(browser, base, profile, shot) { |
18958d3d 2026-09-08 Pavel Safronov | 430 | const t0 = Date.now() |
6364748a 2026-09-08 Pavel Safronov | 431 | /** |
| 432 | * A fresh context per shot. The app autosaves the view, the eye height and the | |
| 433 | * heading to localStorage, so a shared context would let each shot inherit the | |
| 434 | * previous one's camera and quietly stop being reproducible. | |
| 435 | */ | |
| 436 | const context = await browser.newContext({ | |
| 437 | viewport: profile.viewport, | |
| 438 | deviceScaleFactor: profile.scale, | |
| 439 | isMobile: profile.mobile, | |
| 440 | hasTouch: profile.mobile, | |
| 441 | }) | |
| 442 | /** | |
e27aaf20 2026-09-08 Pavel Safronov | 443 | * Insurance rather than a live need: the rain that used to be the world's one |
| 444 | * randomised thing is gone (D-049), and the star field is hashed in GLSL rather | |
| 445 | * than sampled. If anything stochastic comes back, the contact sheet stays | |
| 446 | * comparable between runs without anyone having to remember why. | |
6364748a 2026-09-08 Pavel Safronov | 447 | */ |
| 448 | await context.addInitScript(() => { | |
| 449 | let seed = 0x2f6e2b1 | |
| 450 | Math.random = () => { | |
| 451 | seed = (seed * 1664525 + 1013904223) >>> 0 | |
| 452 | return seed / 0x100000000 | |
| 453 | } | |
| 454 | }) | |
| 455 | const page = await context.newPage() | |
| 456 | const problems = [] | |
| 457 | page.on('pageerror', (e) => problems.push(String(e))) | |
| 458 | page.on('console', (m) => { if (m.type() === 'error') problems.push(m.text()) }) | |
| 459 | ||
| 460 | await page.goto(`${base}/${searchFor(shot)}`, { waitUntil: 'networkidle' }) | |
| 461 | await settle(page) | |
| 462 | await shot.run(page) | |
| 463 | ||
| 464 | const file = fileFor(profile.id, shot.id) | |
| 465 | await page.screenshot({ path: file, scale: 'css' }) | |
| 466 | await context.close() | |
| 467 | ||
| 468 | const note = problems.length ? ` ⚠ ${problems.length} console error(s): ${problems[0]}` : '' | |
18958d3d 2026-09-08 Pavel Safronov | 469 | console.log(` ${path.relative(ROOT, file)} ${((Date.now() - t0) / 1000).toFixed(1)}s${note}`) |
6364748a 2026-09-08 Pavel Safronov | 470 | } |
| 471 | ||
| 472 | // -------------------------------------------------------------- contact sheet | |
| 473 | ||
| 474 | const dataUri = async (file) => `data:image/png;base64,${(await readFile(file)).toString('base64')}` | |
| 475 | ||
| 476 | /** | |
| 477 | * Build the overview: every shot on one page, photographed as one image. | |
| 478 | * | |
| 479 | * Deliberately undated. Eight of the ten files this tool writes come out | |
| 480 | * byte-identical run to run, so regenerating usually costs git nothing at all; | |
| 481 | * the exterior shot is the exception, carrying a pixel or two of renderer noise | |
| 482 | * that measures 0.00% and still changes the bytes, and the sheet inherits it. | |
| 483 | * A date stamp would guarantee a fresh 600 kB blob every day regardless, which | |
| 484 | * is a bad trade for a fact git already records. | |
| 485 | * | |
| 486 | * The browser is already running and already knows how to lay images out, so the | |
| 487 | * contact sheet is an HTML page rather than a compositing routine and a PNG decoder | |
| 488 | * this project would otherwise have no use for (tools/png.mjs only encodes). | |
| 489 | */ | |
| 490 | async function buildOverview(browser, cells) { | |
| 491 | const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } }) | |
| 492 | const page = await context.newPage() | |
| 493 | // The doctype is load-bearing: without it the sheet lays out in quirks mode, the | |
| 494 | // frames stop clamping their images, and the page grows tall enough that | |
| 495 | // Page.captureScreenshot gives up with "Unable to capture screenshot". | |
| 496 | await page.setContent(`<!doctype html> | |
| 497 | <style> | |
| 498 | :root { color-scheme: dark; } | |
| 499 | body { margin: 0; padding: 28px; background: #0b0d12; color: #dde3ee; | |
| 500 | font: 13px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; } | |
| 501 | h1 { font-size: 16px; letter-spacing: 0.08em; text-transform: uppercase; margin: 0 0 4px; } | |
| 502 | p.sub { color: #8892a6; margin: 0 0 22px; font-size: 12px; } | |
| 503 | .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } | |
| 504 | figure { margin: 0; background: #131722; border: 1px solid #232a3a; border-radius: 8px; | |
| 505 | padding: 10px; display: flex; flex-direction: column; } | |
| 506 | .frame { height: 300px; display: flex; align-items: center; justify-content: center; | |
| 507 | background: #05070d; border-radius: 4px; overflow: hidden; } | |
| 508 | img { max-width: 100%; max-height: 100%; display: block; } | |
| 509 | figcaption { margin-top: 8px; font-size: 11px; color: #8892a6; } | |
| 510 | figcaption b { display: block; color: #7fd4ff; font-weight: 500; letter-spacing: 0.04em; | |
| 511 | text-transform: uppercase; font-size: 10px; margin-bottom: 2px; } | |
| 512 | </style> | |
| 513 | <h1>Tower — screenshot overview</h1> | |
| 514 | <p class="sub">${cells.length} views · one glance before committing</p> | |
| 515 | <div class="grid"> | |
| 516 | ${cells.map((c) => ` <figure><div class="frame"><img src="${c.src}" /></div> | |
| 517 | <figcaption><b>${c.label.replace('-', ' · ')}</b>${c.caption}</figcaption></figure>`).join('\n')} | |
| 518 | </div>`) | |
| 519 | await page.waitForTimeout(400) | |
| 520 | /** | |
| 521 | * Size the window to the sheet and take an ordinary screenshot. `fullPage` is the | |
| 522 | * obvious call and it fails outright here: the page carries every shot inline as | |
| 523 | * a data URI, and capturing beyond the viewport on that much content returns | |
| 524 | * "Unable to capture screenshot" from the protocol rather than an image. | |
| 525 | */ | |
| 526 | const height = await page.evaluate(() => document.documentElement.scrollHeight) | |
| 527 | await page.setViewportSize({ width: 1600, height }) | |
| 528 | await page.waitForTimeout(200) | |
| 529 | await page.screenshot({ path: OVERVIEW, scale: 'css' }) | |
| 530 | await context.close() | |
| 531 | console.log(` ${path.relative(ROOT, OVERVIEW)} ${1600}×${height}`) | |
| 532 | } | |
| 533 | ||
| 534 | /** | |
| 535 | * Mean absolute difference between two PNGs, as a percentage of full scale. | |
| 536 | * | |
| 537 | * A crude metric on purpose. It is not trying to be a visual-diff tool; it is | |
| 538 | * trying to notice that the interior went black, or that the sidebar swallowed the | |
| 539 | * window. Returns null when the two are different sizes — the viewport changed, or | |
| 540 | * a shot is new, which is a change the author already knows about. | |
| 541 | * | |
| 542 | * It is measured per shot rather than on the contact sheet, because the sheet | |
| 543 | * dilutes: nine panels on a dark page, and rewriting two of them outright moved | |
| 544 | * the whole-sheet mean by 0.6%. Per shot the same change reads in tens of percent, | |
| 545 | * which is a number a threshold can actually be set against. | |
| 546 | */ | |
| 547 | async function divergence(browser, before, after) { | |
| 548 | const context = await browser.newContext({ viewport: { width: 400, height: 300 } }) | |
| 549 | const page = await context.newPage() | |
| 550 | const result = await page.evaluate(async ([a, b]) => { | |
| 551 | const load = (src) => new Promise((res, rej) => { | |
| 552 | const img = new Image() | |
| 553 | img.onload = () => res(img) | |
| 554 | img.onerror = () => rej(new Error('bad image')) | |
| 555 | img.src = src | |
| 556 | }) | |
| 557 | const [ia, ib] = await Promise.all([load(a), load(b)]) | |
| 558 | if (ia.width !== ib.width || ia.height !== ib.height) return null | |
| 559 | const pixels = (img) => { | |
| 560 | const c = document.createElement('canvas') | |
| 561 | c.width = img.width | |
| 562 | c.height = img.height | |
| 563 | const ctx = c.getContext('2d') | |
| 564 | ctx.drawImage(img, 0, 0) | |
| 565 | return ctx.getImageData(0, 0, img.width, img.height).data | |
| 566 | } | |
| 567 | const pa = pixels(ia) | |
| 568 | const pb = pixels(ib) | |
| 569 | let sum = 0 | |
| 570 | // RGB only: both sheets are fully opaque, and alpha would only dilute the mean. | |
| 571 | for (let i = 0; i < pa.length; i += 4) { | |
| 572 | sum += Math.abs(pa[i] - pb[i]) + Math.abs(pa[i + 1] - pb[i + 1]) + Math.abs(pa[i + 2] - pb[i + 2]) | |
| 573 | } | |
| 574 | return ((sum / ((pa.length / 4) * 3)) / 255) * 100 | |
| 575 | }, [before, after]) | |
| 576 | await context.close() | |
| 577 | return result | |
| 578 | } | |
| 579 | ||
| 580 | // ------------------------------------------------------------------------ main | |
| 581 | ||
| 582 | const argv = process.argv.slice(2) | |
| 583 | const check = argv.includes('--check') | |
| 584 | const flag = (name) => { | |
| 585 | const i = argv.indexOf(name) | |
| 586 | return i >= 0 ? argv[i + 1] : undefined | |
| 587 | } | |
| 588 | const threshold = Number(flag('--threshold') ?? DEFAULT_THRESHOLD) | |
| 589 | const givenUrl = flag('--url') | |
| 590 | ||
| 591 | await mkdir(OUT, { recursive: true }) | |
| 592 | /** | |
| 593 | * Read every existing shot before anything overwrites it. These are the only | |
| 594 | * baseline there is — the run is about to replace them in place. | |
| 595 | */ | |
| 596 | const previous = new Map() | |
| 597 | for (const profile of PROFILES) { | |
| 598 | for (const shot of SHOTS) { | |
| 599 | if (!shot.on.includes(profile.id)) continue | |
| 600 | const file = fileFor(profile.id, shot.id) | |
| 601 | const buf = await readFile(file).catch(() => null) | |
| 602 | if (buf) previous.set(`${profile.id}-${shot.id}`, `data:image/png;base64,${buf.toString('base64')}`) | |
| 603 | } | |
| 604 | } | |
| 605 | ||
| 606 | // Serve the app ourselves unless pointed at one already running. Vite's own API | |
| 607 | // means no port guessing and no orphaned child process to reap. | |
| 608 | const server = givenUrl ? null : await createServer({ root: ROOT, server: { port: 0 } }) | |
| 609 | if (server) await server.listen() | |
| 610 | const base = givenUrl ?? server.resolvedUrls.local[0].replace(/\/$/, '') | |
| 611 | console.log(`\nTower screenshots — ${base}\n`) | |
| 612 | ||
| 613 | const cells = [] | |
| 614 | try { | |
18958d3d 2026-09-08 Pavel Safronov | 615 | const tour = await launch() |
6364748a 2026-09-08 Pavel Safronov | 616 | try { |
18958d3d 2026-09-08 Pavel Safronov | 617 | /** |
| 618 | * Every shot is independent — its own context, its own page, its own copy of | |
| 619 | * the world — so they run several at a time. On the GPU a shot is mostly | |
| 620 | * waiting: for the module graph, for a shader compile, for a view to settle. | |
| 621 | * Serial, that idle time is the whole run; four at once fills it. | |
| 622 | * | |
| 623 | * Four rather than more because each shot holds a WebGL context and its own | |
| 624 | * copy of the terrain rasters, and because past that the machine starts | |
| 625 | * competing with itself for the GPU and the settle timings get noisier — which | |
| 626 | * matters here more than speed does, since a shot that settles differently | |
| 627 | * between runs shows up as divergence rather than as a slow run. | |
| 628 | */ | |
| 629 | const jobs = [] | |
6364748a 2026-09-08 Pavel Safronov | 630 | for (const profile of PROFILES) { |
| 631 | for (const shot of SHOTS) { | |
18958d3d 2026-09-08 Pavel Safronov | 632 | if (shot.on.includes(profile.id)) jobs.push({ profile, shot }) |
| 633 | } | |
| 634 | } | |
| 635 | const results = new Array(jobs.length) | |
| 636 | let next = 0 | |
| 637 | const worker = async () => { | |
| 638 | for (let i = next++; i < jobs.length; i = next++) { | |
| 639 | const { profile, shot } = jobs[i] | |
6364748a 2026-09-08 Pavel Safronov | 640 | await capture(tour, base, profile, shot) |
18958d3d 2026-09-08 Pavel Safronov | 641 | results[i] = { |
6364748a 2026-09-08 Pavel Safronov | 642 | src: await dataUri(fileFor(profile.id, shot.id)), |
| 643 | caption: shot.caption, | |
| 644 | label: `${profile.id}-${shot.id}`, | |
18958d3d 2026-09-08 Pavel Safronov | 645 | } |
6364748a 2026-09-08 Pavel Safronov | 646 | } |
| 647 | } | |
18958d3d 2026-09-08 Pavel Safronov | 648 | await Promise.all(Array.from({ length: CONCURRENCY }, worker)) |
| 649 | // The sheet reads in profile order, not in whatever order the pool finished. | |
| 650 | cells.push(...results) | |
6364748a 2026-09-08 Pavel Safronov | 651 | } finally { |
| 652 | await tour.close() | |
| 653 | } | |
| 654 | ||
| 655 | /** | |
| 656 | * A second browser for the paperwork. The tour leaves nine WebGL contexts and | |
| 657 | * two Three.js scenes behind it, and in that browser Page.captureScreenshot on | |
| 658 | * the sheet fails outright with "Unable to capture screenshot" — the sheet is | |
| 659 | * plain HTML and images, so the only thing wrong with it is its neighbours. | |
| 660 | */ | |
18958d3d 2026-09-08 Pavel Safronov | 661 | const sheet = await launch() |
6364748a 2026-09-08 Pavel Safronov | 662 | try { |
| 663 | console.log('Overview') | |
| 664 | await buildOverview(sheet, cells) | |
| 665 | ||
| 666 | if (!previous.size) { | |
| 667 | console.log('\nNothing to compare against — this run is the baseline.') | |
| 668 | } else { | |
| 669 | console.log('\nDivergence from the committed shots') | |
| 670 | let worst = 0 | |
| 671 | let worstName = '' | |
| 672 | for (const cell of cells) { | |
| 673 | const before = previous.get(cell.label) | |
| 674 | if (!before) { | |
| 675 | console.log(` ${cell.label.padEnd(26)} new`) | |
| 676 | continue | |
| 677 | } | |
| 678 | const moved = await divergence(sheet, before, cell.src) | |
| 679 | if (moved === null) { | |
| 680 | console.log(` ${cell.label.padEnd(26)} resized`) | |
| 681 | continue | |
| 682 | } | |
| 683 | if (moved > worst) { worst = moved; worstName = cell.label } | |
| 684 | console.log(` ${cell.label.padEnd(26)} ${moved.toFixed(2)}%${moved > threshold ? ' ←' : ''}`) | |
| 685 | } | |
| 686 | if (worst > threshold) { | |
| 687 | console.log(`\n${worstName} moved ${worst.toFixed(2)}%, over the ${threshold}% threshold.`) | |
| 688 | console.log('Open docs/screenshots/overview.png and make sure the UI is still there.') | |
| 689 | if (check) process.exitCode = 1 | |
| 690 | } else { | |
| 691 | console.log(`\nWorst: ${worstName} at ${worst.toFixed(2)}%, under the ${threshold}% threshold.`) | |
| 692 | } | |
| 693 | } | |
| 694 | } finally { | |
| 695 | await sheet.close() | |
| 696 | } | |
| 697 | } finally { | |
| 698 | await server?.close() | |
| 699 | } | |
| 700 | console.log('\nDone. Look at docs/screenshots/overview.png.\n') |