fuzzyworld

tower / files / tools / photos.mjs

photos.mjs

5.1 KB · history · blame

1/**
2 * Photographs. `npm run photos`
3 *
4 * Renders every shot in content/photos.json to photos/<id>.png: the view alone — no panel,
5 * no readout, no labels — at the shape it was composed in, 3840 px along its long edge.
6 *
7 * The list is the thing kept; the renders are made from it and are not committed. A shot is
8 * saved from the running app (P, or the button, at `npm run dev`) or typed by hand, and its
9 * file name never changes, so rendering again replaces the picture with the latest version
10 * of the same picture: the world as it is now, drawn as the renderer draws it now.
11 *
12 * Unlike the screenshot tour (screenshots.mjs), a photo is posed directly rather than
13 * through the UI. That tour is a test of the application; this is a camera.
14 *
15 * Flags:
16 * --only <id,id> render just these shots (id prefixes)
17 * --long-edge <px> the long edge, in pixels (default 3840)
18 * --url <origin> photograph a server already running instead of starting one
19 */
20import { mkdir, readFile } from 'node:fs/promises'
21import path from 'node:path'
22import { fileURLToPath } from 'node:url'
23import { createServer } from 'vite'
24import { launch, seedRandom, settle, warmWorld } from './browser.mjs'
25
26const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
27const LIST = path.join(ROOT, 'content', 'photos.json')
28const OUT = path.join(ROOT, 'photos')
29
30/** As the screenshot tour asks: two terrain workers, and the compiled world from the cache. */
31const PARAMS = 'workers=2&cache=1'
32
33/**
34 * The view and nothing else. The panel's column is given back to the world, and everything
35 * laid over the canvas — labels, readouts, the gauge, the view buttons — is taken away.
36 */
37const BARE = `
38 #app { grid-template-columns: 1fr !important; }
39 aside, #panel-toggle, #scrim, main > :not(canvas) { display: none !important; }
40`
41
42const argv = process.argv.slice(2)
43const flag = (name) => {
44 const i = argv.indexOf(name)
45 return i >= 0 ? argv[i + 1] : undefined
46}
47const only = (flag('--only') ?? '').split(',').map((s) => s.trim()).filter(Boolean)
48const longEdge = Number(flag('--long-edge') ?? 3840)
49const givenUrl = flag('--url')
50
51const list = JSON.parse(await readFile(LIST, 'utf8').catch(() => '[]'))
52const photos = list.filter((p) => only.length === 0 || only.some((o) => String(p.id).startsWith(o)))
53if (!photos.length) {
54 console.log(only.length
55 ? `No shot in content/photos.json matches ${only.join(', ')}.`
56 : 'content/photos.json has no shots yet: run `npm run dev`, frame one, and press P.')
57 process.exit(only.length ? 1 : 0)
58}
59
60async function take(browser, base, storage, photo) {
61 const t0 = Date.now()
62 /**
63 * Two device pixels to the CSS pixel, which is the most the views will draw at, and what
64 * the author's own screen does: the stars and the lines come out the weight they were
65 * when the shot was framed, rather than half of it across a 3840 px window.
66 */
67 const long = Math.round(longEdge / 2)
68 const short = Math.round(long / Math.max(photo.aspect, 1 / photo.aspect))
69 const viewport = photo.aspect >= 1 ? { width: long, height: short } : { width: short, height: long }
70 const context = await browser.newContext({ storageState: storage, viewport, deviceScaleFactor: 2 })
71 await seedRandom(context)
72 const page = await context.newPage()
73 const problems = []
74 page.on('pageerror', (e) => problems.push(String(e)))
75 page.on('console', (m) => { if (m.type() === 'error') problems.push(m.text()) })
76
77 // The clock is named in the address so the page never opens at the time now, running.
78 await page.goto(`${base}/?${PARAMS}&clock=${photo.hours}#view=${photo.view}`, { waitUntil: 'networkidle' })
79 await page.addStyleTag({ content: BARE })
80 await settle(page)
81 // The app checks every field, and says which is wrong: the list is edited by hand too.
82 await page.evaluate((p) => window.towerPhotos.show(p), photo)
83 await settle(page, 1500)
84
85 const file = path.join(OUT, `${photo.id}.png`)
86 await page.screenshot({ path: file })
87 await context.close()
88 const note = problems.length ? ` ⚠ ${problems.length} console error(s): ${problems[0]}` : ''
89 console.log(` ${path.relative(ROOT, file)} ${viewport.width * 2}×${viewport.height * 2} ${((Date.now() - t0) / 1000).toFixed(1)}s${note}`)
90}
91
92await mkdir(OUT, { recursive: true })
93const server = givenUrl ? null : await createServer({ root: ROOT, server: { port: 0 } })
94if (server) await server.listen()
95const base = givenUrl ?? server.resolvedUrls.local[0].replace(/\/$/, '')
96console.log(`\nTower photographs — ${base}\n`)
97
98let failed = 0
99try {
100 const browser = await launch()
101 try {
102 const storage = await warmWorld(browser, base, `${PARAMS}&clock=12`)
103 // One at a time: each is a 4K frame on the GPU, and there are only ever a handful.
104 for (const photo of photos) {
105 try {
106 await take(browser, base, storage, photo)
107 } catch (e) {
108 failed++
109 console.log(` ${photo.id} ✗ ${String(e).split('\n')[0]}`)
110 }
111 }
112 } finally {
113 await browser.close()
114 }
115} finally {
116 await server?.close()
117}
118if (failed) process.exitCode = 1
119console.log(`\nDone: ${photos.length - failed} of ${photos.length} in photos/.\n`)