nordic-skating / files / tools / screenshots.mjs
screenshots.mjs
e5499331 2026-10-09 Pavel Safronov | 1 | /** |
| 2 | * Photograph the built app at phone size into docs/screenshots/, for the README and for | |
| 3 | * looking a change over before it is committed. | |
| 4 | * | |
| 5 | * npm run screenshots | |
| 6 | */ | |
| 7 | import { mkdirSync } from 'node:fs' | |
| 8 | import { chromium } from 'playwright' | |
| 9 | import { preview } from 'vite' | |
| 10 | ||
| 11 | const OUT = 'docs/screenshots' | |
| 12 | const PHONE = { width: 390, height: 844 } | |
| 13 | ||
c6a1d87d 2026-10-10 Pavel Safronov | 14 | /** |
| 15 | * What to photograph: where, and what to do there first. Always lake number 1, and a | |
| 16 | * time of day by name, so that the pictures are of the same thing from one run to the next. | |
| 17 | */ | |
e5499331 2026-10-09 Pavel Safronov | 18 | const SHOTS = [ |
0045befd 2026-10-10 Pavel Safronov | 19 | { name: 'autopilot', query: '?seed=1&weather=clear&time=day&s=400', act: async () => {} }, |
e5499331 2026-10-09 Pavel Safronov | 20 | { |
| 21 | name: 'steering', | |
0045befd 2026-10-10 Pavel Safronov | 22 | query: '?seed=1&weather=clear&time=day&s=2600', |
e5499331 2026-10-09 Pavel Safronov | 23 | act: async (page) => { |
| 24 | await page.keyboard.down('ArrowRight') | |
| 25 | await page.waitForTimeout(1500) | |
| 26 | }, | |
| 27 | }, | |
1cb1fefc 2026-10-09 Pavel Safronov | 28 | // The first fork: the island between its two channels is a little way ahead. |
0045befd 2026-10-10 Pavel Safronov | 29 | { name: 'fork', query: '?seed=1&weather=clear&time=day&s=790', viewport: { width: 844, height: 390 }, act: async () => {} }, |
| 30 | { name: 'pass', query: '?seed=1&weather=clear&time=day&s=6400', act: async () => {} }, | |
| 31 | { name: 'sea', query: '?seed=1&weather=clear&time=day&s=3400', viewport: { width: 844, height: 390 }, act: async () => {} }, | |
c6a1d87d 2026-10-10 Pavel Safronov | 32 | // Impossibly thin ice lies to the left of the middle here, faintly lit from within. |
0045befd 2026-10-10 Pavel Safronov | 33 | { name: 'thin-ice', query: '?seed=1&weather=clear&time=day&s=2795', viewport: { width: 844, height: 390 }, act: async () => {} }, |
| 34 | { name: 'about', query: '?seed=1&weather=clear&time=day&s=400', act: async (page) => { await page.click('#about') } }, | |
df2b8215 2026-10-10 Pavel Safronov | 35 | // A bed of reed islets, just down the lake from where it starts. |
0045befd 2026-10-10 Pavel Safronov | 36 | { name: 'islets', query: '?seed=1&weather=clear&time=day&s=150', viewport: { width: 844, height: 390 }, act: async () => {} }, |
| 37 | { name: 'islets-dusk', query: '?seed=1&weather=clear&time=dusk&s=7040', act: async () => {} }, | |
b2c4db9f 2026-10-10 Pavel Safronov | 38 | // A long island of reeds in the middle of the lake: coming up to it, and alongside it. |
| 39 | { name: 'island', query: '?seed=1&weather=clear&time=day&s=2880', viewport: { width: 844, height: 390 }, act: async () => {} }, | |
| 40 | { name: 'island-alongside', query: '?seed=1&weather=clear&time=dusk&s=3010', act: async () => {} }, | |
| 41 | // Out at sea, held hard over for a whole circle: the skater comes round onto their own tracks. | |
| 42 | { | |
| 43 | name: 'tracks', | |
| 44 | query: '?seed=1&weather=clear&time=day&s=7880', | |
| 45 | viewport: { width: 844, height: 390 }, | |
| 46 | act: async (page) => { | |
| 47 | await page.keyboard.down('ArrowRight') | |
| 48 | await page.waitForTimeout(12_500) | |
| 49 | await page.keyboard.up('ArrowRight') | |
| 50 | }, | |
| 51 | }, | |
3f3d6bce 2026-10-11 Pavel Safronov | 52 | // At an Olympic pace, a few seconds in: down in a crouch, with a wider view. |
| 53 | { name: 'olympic', query: '?seed=1&weather=clear&time=day&pace=olympic&s=5200', viewport: { width: 844, height: 390 }, act: async (page) => { await page.waitForTimeout(9000) } }, | |
c6a1d87d 2026-10-10 Pavel Safronov | 54 | // The same water at the other times of day. |
0045befd 2026-10-10 Pavel Safronov | 55 | { name: 'dusk', query: '?seed=1&weather=clear&time=dusk&s=5200', viewport: { width: 844, height: 390 }, act: async () => {} }, |
| 56 | { name: 'night', query: '?seed=1&weather=clear&time=night&s=5200', viewport: { width: 844, height: 390 }, act: async () => {} }, | |
| 57 | { name: 'aurora', query: '?seed=1&weather=clear&time=aurora&s=5200', viewport: { width: 844, height: 390 }, act: async () => {} }, | |
| 58 | { name: 'aurora-phone', query: '?seed=1&weather=clear&time=aurora&s=400', act: async () => {} }, | |
| 59 | { name: 'settings', query: '?seed=1&weather=clear&time=night&s=400', act: async (page) => { await page.click('#settings') } }, | |
| 60 | // Weather, and what is in the sky besides stars. Lake 13 has a comet and lake 2 an aeroplane. | |
| 61 | { name: 'snow', query: '?seed=1&weather=snow&time=day&s=5200', viewport: { width: 844, height: 390 }, act: async () => {} }, | |
| 62 | { name: 'blizzard', query: '?seed=1&weather=blizzard&time=dusk&s=5200', act: async () => {} }, | |
| 63 | { name: 'comet', query: '?seed=13&weather=clear&time=night&s=3400', viewport: { width: 844, height: 390 }, act: async () => {} }, | |
| 64 | { name: 'aeroplane', query: '?seed=2&weather=clear&time=day&s=3400', viewport: { width: 844, height: 390 }, act: async () => {} }, | |
| 65 | { name: 'landscape', query: '?seed=1&weather=clear&time=day&s=5200', viewport: { width: 844, height: 390 }, act: async () => {} }, | |
e5499331 2026-10-09 Pavel Safronov | 66 | // Ten thousand kilometres down the lake: the picture must be as sharp as at the start. |
0045befd 2026-10-10 Pavel Safronov | 67 | { name: 'far', query: '?seed=1&weather=clear&time=day&s=10000000', act: async () => {} }, |
e5499331 2026-10-09 Pavel Safronov | 68 | ] |
| 69 | ||
| 70 | mkdirSync(OUT, { recursive: true }) | |
| 71 | const server = await preview({ preview: { port: 4317, strictPort: true } }) | |
| 72 | const base = server.resolvedUrls.local[0] | |
| 73 | // The full Chrome for Testing build reaches the GPU; the headless shell draws in software. | |
| 74 | const browser = await chromium.launch({ channel: 'chromium' }).catch(() => chromium.launch()) | |
| 75 | try { | |
| 76 | for (const shot of SHOTS) { | |
| 77 | const page = await browser.newPage({ viewport: shot.viewport ?? PHONE, deviceScaleFactor: 2 }) | |
| 78 | const errors = [] | |
| 79 | page.on('pageerror', (e) => errors.push(e.message)) | |
| 80 | page.on('console', (m) => m.type() === 'error' && errors.push(m.text())) | |
| 81 | await page.goto(base + shot.query, { waitUntil: 'networkidle' }) | |
| 82 | await page.waitForTimeout(1500) | |
| 83 | await shot.act(page) | |
| 84 | await page.screenshot({ path: `${OUT}/${shot.name}.png` }) | |
| 85 | await page.close() | |
| 86 | if (errors.length) throw new Error(`${shot.name}: ${errors.join('; ')}`) | |
| 87 | console.log(` ${OUT}/${shot.name}.png`) | |
| 88 | } | |
| 89 | } finally { | |
| 90 | await browser.close() | |
| 91 | await server.close() | |
| 92 | } |