diff --git a/README.md b/README.md index 39e11ed..862781b 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,25 @@ docker compose --profile tools run --rm assembler The tools profile is opt-in and is not started by the normal app command. +## Capturing screenshots + +Every program has a recipe in `screenshots.json`. The recipe waits for the emulator, focuses it, dismisses the splash screen, performs any program-specific actions, and captures a clean 960×720 PNG from the browser compositor. Screenshots are stored as `screenshot.png` beside each program and appear automatically in the grid view; no web-app rebuild is required. + +Capture screenshots that do not exist yet: + +```text +docker compose --profile tools run --rm screenshots +``` + +Capture one program again, or rebuild the whole gallery: + +```text +docker compose --profile tools run --rm screenshots haunted-night --force +docker compose --profile tools run --rm screenshots --force +``` + +When adding a program, add its slug to `screenshots.json`. In most cases an empty object uses the collection's standard splash-screen recipe. Add explicit timed key actions when a program needs a particular scene. Always run and inspect the new screenshot before committing the program. + ## Source format Each program directory contains a loadable `.prg` and its readable source: diff --git a/cipher-wheel/screenshot.png b/cipher-wheel/screenshot.png new file mode 100644 index 0000000..132d97d Binary files /dev/null and b/cipher-wheel/screenshot.png differ diff --git a/code-breaker/screenshot.png b/code-breaker/screenshot.png new file mode 100644 index 0000000..d69b2ec Binary files /dev/null and b/code-breaker/screenshot.png differ diff --git a/compose.yaml b/compose.yaml index 6998697..77a9a47 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,3 +18,16 @@ services: volumes: - .:/programs + screenshots: + profiles: ["tools"] + build: + context: . + dockerfile: tools/screenshots/Dockerfile + working_dir: /programs + environment: + SCREENSHOT_BASE_URL: http://c64-basic:8080 + volumes: + - .:/programs + depends_on: + - c64-basic + diff --git a/dice-poker/screenshot.png b/dice-poker/screenshot.png new file mode 100644 index 0000000..40ab41b Binary files /dev/null and b/dice-poker/screenshot.png differ diff --git a/fifteen-puzzle/screenshot.png b/fifteen-puzzle/screenshot.png new file mode 100644 index 0000000..a310504 Binary files /dev/null and b/fifteen-puzzle/screenshot.png differ diff --git a/fireworks/screenshot.png b/fireworks/screenshot.png new file mode 100644 index 0000000..f143f82 Binary files /dev/null and b/fireworks/screenshot.png differ diff --git a/happy-birthday-beca/screenshot.png b/happy-birthday-beca/screenshot.png new file mode 100644 index 0000000..03bfecd Binary files /dev/null and b/happy-birthday-beca/screenshot.png differ diff --git a/haunted-night/screenshot.png b/haunted-night/screenshot.png new file mode 100644 index 0000000..3b5830f Binary files /dev/null and b/haunted-night/screenshot.png differ diff --git a/infinite-road/screenshot.png b/infinite-road/screenshot.png new file mode 100644 index 0000000..7a75680 Binary files /dev/null and b/infinite-road/screenshot.png differ diff --git a/jiffy-clock/screenshot.png b/jiffy-clock/screenshot.png new file mode 100644 index 0000000..7390499 Binary files /dev/null and b/jiffy-clock/screenshot.png differ diff --git a/kaleidoscope/screenshot.png b/kaleidoscope/screenshot.png new file mode 100644 index 0000000..65f64df Binary files /dev/null and b/kaleidoscope/screenshot.png differ diff --git a/life-lab/screenshot.png b/life-lab/screenshot.png new file mode 100644 index 0000000..f3ce48e Binary files /dev/null and b/life-lab/screenshot.png differ diff --git a/lunar-lander/screenshot.png b/lunar-lander/screenshot.png new file mode 100644 index 0000000..a263c67 Binary files /dev/null and b/lunar-lander/screenshot.png differ diff --git a/memory-matrix/screenshot.png b/memory-matrix/screenshot.png new file mode 100644 index 0000000..8589140 Binary files /dev/null and b/memory-matrix/screenshot.png differ diff --git a/meteor-dodge/screenshot.png b/meteor-dodge/screenshot.png new file mode 100644 index 0000000..77a257a Binary files /dev/null and b/meteor-dodge/screenshot.png differ diff --git a/morse-trainer/screenshot.png b/morse-trainer/screenshot.png new file mode 100644 index 0000000..4741aba Binary files /dev/null and b/morse-trainer/screenshot.png differ diff --git a/music-box/screenshot.png b/music-box/screenshot.png new file mode 100644 index 0000000..f6f06b8 Binary files /dev/null and b/music-box/screenshot.png differ diff --git a/neon-rift/screenshot.png b/neon-rift/screenshot.png new file mode 100644 index 0000000..b004bb9 Binary files /dev/null and b/neon-rift/screenshot.png differ diff --git a/nim-challenge/screenshot.png b/nim-challenge/screenshot.png new file mode 100644 index 0000000..bba75b2 Binary files /dev/null and b/nim-challenge/screenshot.png differ diff --git a/orbit-lab/screenshot.png b/orbit-lab/screenshot.png new file mode 100644 index 0000000..e7b6a99 Binary files /dev/null and b/orbit-lab/screenshot.png differ diff --git a/paddle-ball/screenshot.png b/paddle-ball/screenshot.png new file mode 100644 index 0000000..1180404 Binary files /dev/null and b/paddle-ball/screenshot.png differ diff --git a/petscii-aquarium/screenshot.png b/petscii-aquarium/screenshot.png new file mode 100644 index 0000000..ab4180e Binary files /dev/null and b/petscii-aquarium/screenshot.png differ diff --git a/pi-drop/screenshot.png b/pi-drop/screenshot.png new file mode 100644 index 0000000..3b73217 Binary files /dev/null and b/pi-drop/screenshot.png differ diff --git a/pixel-painter/screenshot.png b/pixel-painter/screenshot.png new file mode 100644 index 0000000..53dd1d4 Binary files /dev/null and b/pixel-painter/screenshot.png differ diff --git a/plasma-grid/screenshot.png b/plasma-grid/screenshot.png new file mode 100644 index 0000000..ccbd03b Binary files /dev/null and b/plasma-grid/screenshot.png differ diff --git a/prime-workshop/screenshot.png b/prime-workshop/screenshot.png new file mode 100644 index 0000000..e75e03c Binary files /dev/null and b/prime-workshop/screenshot.png differ diff --git a/projectile-lab/screenshot.png b/projectile-lab/screenshot.png new file mode 100644 index 0000000..11ccef4 Binary files /dev/null and b/projectile-lab/screenshot.png differ diff --git a/public/app.js b/public/app.js index 9d5072f..5ff6cc3 100644 --- a/public/app.js +++ b/public/app.js @@ -100,6 +100,20 @@ function createProgramCard(program) { badges.className = 'card-badges'; badges.append(createCategoryPill(program.category), createLanguageBadge(program.language)); + const screenshot = document.createElement('div'); + screenshot.className = 'card-screenshot'; + if (program.screenshot) { + const image = document.createElement('img'); + image.src = program.screenshot; + image.alt = `${program.title} running on a Commodore 64`; + image.loading = 'lazy'; + image.decoding = 'async'; + screenshot.append(image); + } else { + screenshot.classList.add('placeholder'); + screenshot.innerHTML = 'Screenshot not yet available'; + } + const title = document.createElement('h2'); title.className = 'program-title'; title.textContent = program.title; @@ -125,7 +139,7 @@ function createProgramCard(program) { source.disabled = !program.source; source.addEventListener('click', () => showSource(program)); actions.append(run, source); - card.append(badges, title, description, releaseDate, actions); + card.append(screenshot, badges, title, description, releaseDate, actions); return card; } diff --git a/public/styles.css b/public/styles.css index 38f8ae8..eca774f 100644 --- a/public/styles.css +++ b/public/styles.css @@ -37,10 +37,13 @@ h1 { margin: 0; font-size: clamp(2rem, 7vw, 4rem); line-height: 1; } .reset-button.filters-active { color: #75d7d0; } .reset-button:focus-visible { outline: 2px solid var(--c64-light-blue); outline-offset: 2px; } .program-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1rem; } -.program-card { display: flex; min-height: 270px; flex-direction: column; align-items: flex-start; padding: 1.25rem; border: 1px solid #b8b1e733; border-radius: 14px; background: #211a59d9; color: #fff; cursor: pointer; transition: transform .15s, border-color .15s, background .15s; } +.program-card { display: flex; min-height: 270px; flex-direction: column; align-items: flex-start; padding: 1.25rem; overflow: hidden; border: 1px solid #b8b1e733; border-radius: 14px; background: #211a59d9; color: #fff; cursor: pointer; transition: transform .15s, border-color .15s, background .15s; } .program-card:hover { transform: translateY(-3px); border-color: var(--c64-light-blue); } .program-card:has(.card-action:hover), .program-card:has(.category-pill:hover) { transform: none; background: #29216bd9; } .card-badges, .program-badges { display: flex; flex-wrap: wrap; gap: .45rem; align-items: center; } +.card-screenshot { display: grid; width: calc(100% + 2.5rem); aspect-ratio: 4 / 3; margin: -1.25rem -1.25rem 1rem; place-items: center; overflow: hidden; border-bottom: 1px solid #b8b1e733; background: #080611; } +.card-screenshot img { width: 100%; height: 100%; object-fit: cover; } +.card-screenshot.placeholder { background: radial-gradient(circle, #40318d 0, #17113b 68%); color: #b8b1e755; font: 900 2.1rem/1 ui-monospace, monospace; letter-spacing: .12em; } .category-pill, .language-badge { display: inline-flex; gap: .4rem; align-items: center; min-height: 28px; padding: .3rem .65rem; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .04em; line-height: 1; text-transform: uppercase; } .category-pill { border: 1px solid #b8b1e755; background: #17113b99; color: #d8d3f4; text-decoration: none; transition: background .15s, color .15s, transform .15s; } .category-pill:hover { border-color: var(--c64-lightest-blue); background: var(--c64-light-blue); color: #fff; transform: translateY(-1px); } @@ -62,6 +65,7 @@ kbd { min-width: 1.8rem; padding: .25rem .45rem; border: 1px solid var(--c64-lig .code-icon { font: 800 .8rem ui-monospace, monospace; } .program-grid.list-view { grid-template-columns: 1fr; } .program-grid.list-view .program-card { min-height: 0; display: grid; grid-template-columns: minmax(180px, auto) minmax(150px, .8fr) minmax(220px, 1.5fr) minmax(110px, auto) minmax(180px, 1fr); align-items: center; gap: 1rem; } +.program-grid.list-view .card-screenshot { display: none; } .program-grid.list-view .program-title, .program-grid.list-view .program-description, .program-grid.list-view .release-date, .program-grid.list-view .program-actions { margin: 0; padding: 0; } .source-modal { width: min(900px, calc(100% - 2rem)); height: min(80vh, 760px); padding: 0; border: 1px solid #b8b1e744; border-radius: 14px; background: var(--c64-deep-blue); color: #fff; box-shadow: 0 30px 80px #000a; } .source-modal::backdrop { background: #090621d9; backdrop-filter: blur(5px); } diff --git a/raster-bars/screenshot.png b/raster-bars/screenshot.png new file mode 100644 index 0000000..e62e533 Binary files /dev/null and b/raster-bars/screenshot.png differ diff --git a/reaction-timer/screenshot.png b/reaction-timer/screenshot.png new file mode 100644 index 0000000..5adbad0 Binary files /dev/null and b/reaction-timer/screenshot.png differ diff --git a/robot-maze/screenshot.png b/robot-maze/screenshot.png new file mode 100644 index 0000000..6efca56 Binary files /dev/null and b/robot-maze/screenshot.png differ diff --git a/screenshots.json b/screenshots.json new file mode 100644 index 0000000..5de0751 --- /dev/null +++ b/screenshots.json @@ -0,0 +1,88 @@ +{ + "$default": { + "readyTimeoutMs": 60000, + "settleBeforeActionsMs": 5000, + "actions": [{ "key": "SPACE", "waitAfterMs": 2500 }], + "captureAfterMs": 1500 + }, + "meteor-dodge": {}, + "code-breaker": {}, + "submarine-hunt": {}, + "lunar-lander": {}, + "memory-matrix": {}, + "robot-maze": {}, + "music-box": {}, + "pixel-painter": {}, + "stock-trader": {}, + "starfield": {}, + "snake-trail": {}, + "dice-poker": {}, + "word-scramble": {}, + "nim-challenge": {}, + "reaction-timer": {}, + "morse-trainer": {}, + "kaleidoscope": {}, + "world-quiz": {}, + "life-lab": {}, + "cipher-wheel": {}, + "sprite-playground": {}, + "raster-bars": {}, + "sid-arpeggiator": {}, + "paddle-ball": {}, + "smooth-scroller": {}, + "fireworks": { + "actions": [ + { "key": "SPACE", "waitAfterMs": 2200 }, + { "key": "SPACE", "waitAfterMs": 450 } + ], + "captureAfterMs": 100 + }, + "jiffy-clock": {}, + "typing-sprint": {}, + "petscii-aquarium": { + "actions": [ + { "key": "SPACE", "waitAfterMs": 2500 }, + { "key": "F", "waitAfterMs": 700 } + ], + "captureAfterMs": 600 + }, + "fifteen-puzzle": {}, + "plasma-grid": {}, + "neon-rift": {}, + "infinite-road": {}, + "projectile-lab": { + "actions": [ + { "key": "SPACE", "waitAfterMs": 1000 }, + { "text": "20", "waitAfterMs": 150 }, + { "key": "RETURN", "waitAfterMs": 500 }, + { "text": "45", "waitAfterMs": 150 }, + { "key": "RETURN", "waitAfterMs": 5000 } + ], + "captureAfterMs": 1000 + }, + "prime-workshop": { + "actions": [ + { "key": "SPACE", "waitAfterMs": 1000 }, + { "text": "360", "waitAfterMs": 150 }, + { "key": "RETURN", "waitAfterMs": 3000 } + ], + "captureAfterMs": 500 + }, + "pi-drop": { "captureAfterMs": 15000 }, + "wave-composer": { "captureAfterMs": 12000 }, + "orbit-lab": {}, + "haunted-night": { + "actions": [ + { "key": "SPACE", "waitAfterMs": 2200 }, + { "key": "L", "waitAfterMs": 120 } + ], + "captureAfterMs": 120 + }, + "happy-birthday-beca": { + "actions": [ + { "key": "SPACE", "waitAfterMs": 2500 }, + { "key": "C", "waitAfterMs": 400 } + ], + "captureAfterMs": 250 + } +} diff --git a/server.mjs b/server.mjs index d4e1601..40cdf62 100644 --- a/server.mjs +++ b/server.mjs @@ -44,10 +44,15 @@ function fetchPrograms() { const slug = program.path.split('/')[0]; if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slugs.has(slug)) throw new Error(`Invalid or duplicate program slug: ${slug}`); slugs.add(slug); + const screenshotPath = resolve(programDirectory, slug, 'screenshot.png'); + const screenshotStat = existsSync(screenshotPath) && statSync(screenshotPath).isFile() ? statSync(screenshotPath) : null; return { ...program, slug, - size: statSync(filePath).size + size: statSync(filePath).size, + screenshot: screenshotStat + ? `/api/screenshot?slug=${encodeURIComponent(slug)}&v=${Math.trunc(screenshotStat.mtimeMs)}` + : null }; }); } @@ -86,6 +91,22 @@ function sendSource(response, programPath, headOnly = false) { else createReadStream(filePath).pipe(response); } +function sendScreenshot(response, slug, headOnly = false) { + const program = fetchPrograms().find(entry => entry.slug === slug && entry.screenshot); + if (!program) { + sendJson(response, 404, { error: 'Program screenshot not found' }, headOnly); + return; + } + const filePath = resolve(programDirectory, slug, 'screenshot.png'); + response.writeHead(200, { + 'Content-Type': 'image/png', + 'Content-Length': statSync(filePath).size, + 'Cache-Control': 'public, max-age=31536000, immutable' + }); + if (headOnly) response.end(); + else createReadStream(filePath).pipe(response); +} + function serveStatic(response, pathname, headOnly = false) { const isProgramPage = /^\/program\/[a-z0-9]+(?:-[a-z0-9]+)*\/?$/.test(pathname); const isCategoryPage = /^\/category\/[a-z0-9]+(?:-[a-z0-9]+)*\/?$/.test(pathname); @@ -118,6 +139,8 @@ createServer(async (request, response) => { sendProgram(response, url.searchParams.get('path') || '', headOnly); } else if (url.pathname === '/api/source') { sendSource(response, url.searchParams.get('path') || '', headOnly); + } else if (url.pathname === '/api/screenshot') { + sendScreenshot(response, url.searchParams.get('slug') || '', headOnly); } else if (!serveStatic(response, url.pathname, headOnly)) { sendJson(response, 404, { error: 'Not found' }, headOnly); } diff --git a/sid-arpeggiator/screenshot.png b/sid-arpeggiator/screenshot.png new file mode 100644 index 0000000..2e4128b Binary files /dev/null and b/sid-arpeggiator/screenshot.png differ diff --git a/smooth-scroller/screenshot.png b/smooth-scroller/screenshot.png new file mode 100644 index 0000000..2db332a Binary files /dev/null and b/smooth-scroller/screenshot.png differ diff --git a/snake-trail/screenshot.png b/snake-trail/screenshot.png new file mode 100644 index 0000000..f818788 Binary files /dev/null and b/snake-trail/screenshot.png differ diff --git a/sprite-playground/screenshot.png b/sprite-playground/screenshot.png new file mode 100644 index 0000000..18d0355 Binary files /dev/null and b/sprite-playground/screenshot.png differ diff --git a/starfield/screenshot.png b/starfield/screenshot.png new file mode 100644 index 0000000..4efa4cb Binary files /dev/null and b/starfield/screenshot.png differ diff --git a/stock-trader/screenshot.png b/stock-trader/screenshot.png new file mode 100644 index 0000000..37f7057 Binary files /dev/null and b/stock-trader/screenshot.png differ diff --git a/submarine-hunt/screenshot.png b/submarine-hunt/screenshot.png new file mode 100644 index 0000000..29f96ef Binary files /dev/null and b/submarine-hunt/screenshot.png differ diff --git a/tools/screenshots/Dockerfile b/tools/screenshots/Dockerfile new file mode 100644 index 0000000..f8fc8a3 --- /dev/null +++ b/tools/screenshots/Dockerfile @@ -0,0 +1,8 @@ +FROM mcr.microsoft.com/playwright:v1.56.1-noble + +WORKDIR /tool +COPY tools/screenshots/package*.json ./ +RUN npm ci --omit=dev +COPY tools/screenshots/capture.mjs ./ + +ENTRYPOINT ["node", "/tool/capture.mjs"] diff --git a/tools/screenshots/capture.mjs b/tools/screenshots/capture.mjs new file mode 100644 index 0000000..004119e --- /dev/null +++ b/tools/screenshots/capture.mjs @@ -0,0 +1,166 @@ +import { readFile, stat, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import process from 'node:process'; +import { chromium } from 'playwright'; +import { PNG } from 'pngjs'; + +const root = '/programs'; +const baseUrl = (process.env.SCREENSHOT_BASE_URL || 'http://c64-basic:8080').replace(/\/$/, ''); +const args = process.argv.slice(2); +const force = args.includes('--force'); +const requestedSlugs = args.filter(argument => !argument.startsWith('--')); +const programs = JSON.parse(await readFile(resolve(root, 'programs.json'), 'utf8')); +const recipes = JSON.parse(await readFile(resolve(root, 'screenshots.json'), 'utf8')); +const selected = programs.filter(program => { + const slug = program.path.split('/')[0]; + return requestedSlugs.length === 0 || requestedSlugs.includes(slug); +}); +const knownSlugs = new Set(programs.map(program => program.path.split('/')[0])); +const missingRecipes = [...knownSlugs].filter(slug => !Object.hasOwn(recipes, slug)); +const unknownRecipes = Object.keys(recipes).filter(slug => slug !== '$default' && !knownSlugs.has(slug)); + +if (missingRecipes.length || unknownRecipes.length) { + if (missingRecipes.length) console.error(`Missing screenshot recipes: ${missingRecipes.join(', ')}`); + if (unknownRecipes.length) console.error(`Unknown screenshot recipes: ${unknownRecipes.join(', ')}`); + process.exit(1); +} + +if (!selected.length) { + console.error('No matching programs. Pass a program slug or omit it to capture the full collection.'); + process.exit(1); +} + +const browser = await chromium.launch({ + headless: true, + args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] +}); +const context = await browser.newContext({ + viewport: { width: 1280, height: 1100 }, + deviceScaleFactor: 1, + acceptDownloads: false +}); +const page = await context.newPage(); +page.setDefaultTimeout(30_000); + +function slugOf(program) { + return program.path.split('/')[0]; +} + +function outputPath(program) { + return resolve(root, slugOf(program), 'screenshot.png'); +} + +function normalizeKey(key) { + const names = { SPACE: 'Space', RETURN: 'Enter', ENTER: 'Enter', ESC: 'Escape' }; + return names[key.toUpperCase()] || (key.length === 1 ? key.toLowerCase() : key); +} + +function imageStats(buffer) { + const png = PNG.sync.read(buffer); + const colors = new Set(); + let visiblePixels = 0; + for (let offset = 0; offset < png.data.length; offset += 16) { + const red = png.data[offset]; + const green = png.data[offset + 1]; + const blue = png.data[offset + 2]; + const alpha = png.data[offset + 3]; + if (alpha > 0 && red + green + blue > 18) visiblePixels++; + if (colors.size < 512) colors.add(`${red >> 3},${green >> 3},${blue >> 3},${alpha >> 5}`); + } + return { width: png.width, height: png.height, colors: colors.size, visiblePixels }; +} + +async function captureCanvas(canvas, path) { + const box = await canvas.boundingBox(); + if (!box) throw new Error('emulator canvas has no visible bounds'); + return page.screenshot({ + path, + type: 'png', + clip: { x: box.x, y: box.y, width: box.width, height: box.height } + }); +} + +async function waitForPicture(canvas, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let lastStats; + while (Date.now() < deadline) { + const buffer = await captureCanvas(canvas); + lastStats = imageStats(buffer); + if (lastStats.colors >= 2 && lastStats.visiblePixels >= 100) return lastStats; + await page.waitForTimeout(750); + } + throw new Error(`emulator never produced a visible frame (${JSON.stringify(lastStats)})`); +} + +async function captureVisibleFrame(canvas, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + let lastStats; + while (Date.now() < deadline) { + const buffer = await captureCanvas(canvas); + lastStats = imageStats(buffer); + if (lastStats.colors >= 2 && lastStats.visiblePixels >= 100) return { buffer, stats: lastStats }; + await page.waitForTimeout(250); + } + throw new Error(`captured frames look blank (${JSON.stringify(lastStats)})`); +} + +async function exists(path) { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +let failures = 0; +for (const program of selected) { + const slug = slugOf(program); + const destination = outputPath(program); + const recipeOverride = recipes[slug]; + const recipe = recipes.$default ? { ...recipes.$default, ...recipeOverride } : recipeOverride; + if (!recipe || !recipeOverride) { + console.error(`FAIL ${slug}: no screenshot recipe`); + failures++; + continue; + } + if (!force && await exists(destination)) { + console.log(`SKIP ${slug}: screenshot.png already exists`); + continue; + } + + try { + console.log(`OPEN ${slug}`); + await page.goto(`${baseUrl}/program/${slug}`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + const canvas = page.locator('canvas.ejs_canvas'); + await canvas.waitFor({ state: 'visible', timeout: 60_000 }); + await waitForPicture(canvas, recipe.readyTimeoutMs || 60_000); + await page.waitForTimeout(recipe.settleBeforeActionsMs || 5000); + await canvas.click({ position: { x: 320, y: 240 } }); + await page.mouse.move(10, 10); + await page.waitForTimeout(1500); + + for (const action of recipe.actions || []) { + if (action.waitBeforeMs) await page.waitForTimeout(action.waitBeforeMs); + if (action.text !== undefined) await page.keyboard.type(String(action.text), { delay: 120 }); + else if (action.key) await page.keyboard.press(normalizeKey(action.key), { delay: 120 }); + else throw new Error('screenshot action must contain key or text'); + if (action.waitAfterMs) await page.waitForTimeout(action.waitAfterMs); + } + if (recipe.captureAfterMs) await page.waitForTimeout(recipe.captureAfterMs); + + const { buffer, stats } = await captureVisibleFrame(canvas); + await writeFile(destination, buffer); + console.log(`SAVED ${slug}/screenshot.png (${stats.width}x${stats.height}, ${stats.colors} sampled colors)`); + } catch (error) { + failures++; + console.error(`FAIL ${slug}: ${error.message}`); + } +} + +await browser.close(); +if (failures) { + console.error(`${failures} screenshot${failures === 1 ? '' : 's'} failed.`); + process.exit(1); +} +console.log(`Screenshot run complete: ${selected.length} program${selected.length === 1 ? '' : 's'} selected.`); diff --git a/tools/screenshots/package-lock.json b/tools/screenshots/package-lock.json new file mode 100644 index 0000000..fff5561 --- /dev/null +++ b/tools/screenshots/package-lock.json @@ -0,0 +1,66 @@ +{ + "name": "screenshots", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "playwright": "1.56.1", + "pngjs": "7.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + } + } +} diff --git a/tools/screenshots/package.json b/tools/screenshots/package.json new file mode 100644 index 0000000..e3db387 --- /dev/null +++ b/tools/screenshots/package.json @@ -0,0 +1,8 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "playwright": "1.56.1", + "pngjs": "7.0.0" + } +} diff --git a/typing-sprint/screenshot.png b/typing-sprint/screenshot.png new file mode 100644 index 0000000..27c53b8 Binary files /dev/null and b/typing-sprint/screenshot.png differ diff --git a/wave-composer/screenshot.png b/wave-composer/screenshot.png new file mode 100644 index 0000000..31aae34 Binary files /dev/null and b/wave-composer/screenshot.png differ diff --git a/word-scramble/screenshot.png b/word-scramble/screenshot.png new file mode 100644 index 0000000..0fdb5d5 Binary files /dev/null and b/word-scramble/screenshot.png differ diff --git a/world-quiz/screenshot.png b/world-quiz/screenshot.png new file mode 100644 index 0000000..f492707 Binary files /dev/null and b/world-quiz/screenshot.png differ