fuzzyworld

tower / interior-flight / files / tools / screenshots.mjs

Branch interior-flight · default branch · all branches

screenshots.mjs

29.2 KB · history · blame

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
19 * (nothing in Tower is stochastic since D-049) and the exterior spin switched
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 */
27import { mkdir, readFile } from 'node:fs/promises'
28import path from 'node:path'
29import { fileURLToPath } from 'node:url'
30import { createServer } from 'vite'
31import { chromium } from 'playwright'
32
33const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
34const OUT = path.join(ROOT, 'docs', 'screenshots')
35const 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 */
57const DEFAULT_THRESHOLD = 3
58
59/** How many shots to photograph at once. See the pool in main() for why four. */
60const CONCURRENCY = 4
61
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 */
67const 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. */
75async 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. */
83async 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. */
90async 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
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 */
115async function openView(page, view) {
116 await page.click(`#views button[data-view="${view}"]`)
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)
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 */
134async 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. */
160async 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 */
171const 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',
200 caption: 'Exterior, x-ray hull — the inner surface projected out, with the caps and the collector',
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'))
207 await withPanel(page, () => page.selectOption('#f-ext-view', 'xray'))
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 },
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 },
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
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.
267 */
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])
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 },
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')
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 })
303 await settle(page, 1600)
304 },
305 },
306 {
307 id: 'interior-suncap',
308 caption: 'Interior — the sun cap window: the collector, its sunlit rim, and Tower\'s clock on its face',
309 on: ['desktop'],
310 run: async (page) => {
311 await openView(page, 'interior')
312 await pick(page, 'The Capital')
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 })
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 },
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 },
356 {
357 id: 'interior-flying',
358 caption: 'Interior — flying on autopilot at 4 km, looking off the course rather than along it',
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 })
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])
382 await settle(page, 900)
383 },
384 },
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. */
398const searchFor = (shot) => (shot.id === 'reader' ? '?reader=1' : '')
399
400const fileFor = (profile, shot) => path.join(OUT, `${profile}-${shot}.png`)
401
402// ------------------------------------------------------------------- the tour
403
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 */
420async 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
429async function capture(browser, base, profile, shot) {
430 const t0 = Date.now()
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 /**
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.
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]}` : ''
469 console.log(` ${path.relative(ROOT, file)} ${((Date.now() - t0) / 1000).toFixed(1)}s${note}`)
470}
471
472// -------------------------------------------------------------- contact sheet
473
474const 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 */
490async 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 */
547async 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
582const argv = process.argv.slice(2)
583const check = argv.includes('--check')
584const flag = (name) => {
585 const i = argv.indexOf(name)
586 return i >= 0 ? argv[i + 1] : undefined
587}
588const threshold = Number(flag('--threshold') ?? DEFAULT_THRESHOLD)
589const givenUrl = flag('--url')
590
591await 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 */
596const previous = new Map()
597for (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.
608const server = givenUrl ? null : await createServer({ root: ROOT, server: { port: 0 } })
609if (server) await server.listen()
610const base = givenUrl ?? server.resolvedUrls.local[0].replace(/\/$/, '')
611console.log(`\nTower screenshots — ${base}\n`)
612
613const cells = []
614try {
615 const tour = await launch()
616 try {
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 = []
630 for (const profile of PROFILES) {
631 for (const shot of SHOTS) {
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]
640 await capture(tour, base, profile, shot)
641 results[i] = {
642 src: await dataUri(fileFor(profile.id, shot.id)),
643 caption: shot.caption,
644 label: `${profile.id}-${shot.id}`,
645 }
646 }
647 }
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)
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 */
661 const sheet = await launch()
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}
700console.log('\nDone. Look at docs/screenshots/overview.png.\n')