Add automated program screenshots
19
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.
|
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
|
## Source format
|
||||||
|
|
||||||
Each program directory contains a loadable `.prg` and its readable source:
|
Each program directory contains a loadable `.prg` and its readable source:
|
||||||
|
|
|
||||||
BIN
cipher-wheel/screenshot.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
code-breaker/screenshot.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
13
compose.yaml
|
|
@ -18,3 +18,16 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- .:/programs
|
- .:/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
|
||||||
|
|
||||||
|
|
|
||||||
BIN
dice-poker/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
fifteen-puzzle/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
fireworks/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
happy-birthday-beca/screenshot.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
haunted-night/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
infinite-road/screenshot.png
Normal file
|
After Width: | Height: | Size: 8 KiB |
BIN
jiffy-clock/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
kaleidoscope/screenshot.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
life-lab/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
lunar-lander/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
memory-matrix/screenshot.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
meteor-dodge/screenshot.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
morse-trainer/screenshot.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
music-box/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
neon-rift/screenshot.png
Normal file
|
After Width: | Height: | Size: 7 KiB |
BIN
nim-challenge/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
orbit-lab/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
paddle-ball/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
petscii-aquarium/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
pi-drop/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
pixel-painter/screenshot.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
plasma-grid/screenshot.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
prime-workshop/screenshot.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
projectile-lab/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -100,6 +100,20 @@ function createProgramCard(program) {
|
||||||
badges.className = 'card-badges';
|
badges.className = 'card-badges';
|
||||||
badges.append(createCategoryPill(program.category), createLanguageBadge(program.language));
|
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 = '<span aria-hidden="true">C64</span><span class="sr-only">Screenshot not yet available</span>';
|
||||||
|
}
|
||||||
|
|
||||||
const title = document.createElement('h2');
|
const title = document.createElement('h2');
|
||||||
title.className = 'program-title';
|
title.className = 'program-title';
|
||||||
title.textContent = program.title;
|
title.textContent = program.title;
|
||||||
|
|
@ -125,7 +139,7 @@ function createProgramCard(program) {
|
||||||
source.disabled = !program.source;
|
source.disabled = !program.source;
|
||||||
source.addEventListener('click', () => showSource(program));
|
source.addEventListener('click', () => showSource(program));
|
||||||
actions.append(run, source);
|
actions.append(run, source);
|
||||||
card.append(badges, title, description, releaseDate, actions);
|
card.append(screenshot, badges, title, description, releaseDate, actions);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,13 @@ h1 { margin: 0; font-size: clamp(2rem, 7vw, 4rem); line-height: 1; }
|
||||||
.reset-button.filters-active { color: #75d7d0; }
|
.reset-button.filters-active { color: #75d7d0; }
|
||||||
.reset-button:focus-visible { outline: 2px solid var(--c64-light-blue); outline-offset: 2px; }
|
.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-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: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; }
|
.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-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, .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 { 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); }
|
.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; }
|
.code-icon { font: 800 .8rem ui-monospace, monospace; }
|
||||||
.program-grid.list-view { grid-template-columns: 1fr; }
|
.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 .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; }
|
.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 { 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); }
|
.source-modal::backdrop { background: #090621d9; backdrop-filter: blur(5px); }
|
||||||
|
|
|
||||||
BIN
raster-bars/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
reaction-timer/screenshot.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
robot-maze/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
88
screenshots.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
25
server.mjs
|
|
@ -44,10 +44,15 @@ function fetchPrograms() {
|
||||||
const slug = program.path.split('/')[0];
|
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}`);
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slugs.has(slug)) throw new Error(`Invalid or duplicate program slug: ${slug}`);
|
||||||
slugs.add(slug);
|
slugs.add(slug);
|
||||||
|
const screenshotPath = resolve(programDirectory, slug, 'screenshot.png');
|
||||||
|
const screenshotStat = existsSync(screenshotPath) && statSync(screenshotPath).isFile() ? statSync(screenshotPath) : null;
|
||||||
return {
|
return {
|
||||||
...program,
|
...program,
|
||||||
slug,
|
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);
|
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) {
|
function serveStatic(response, pathname, headOnly = false) {
|
||||||
const isProgramPage = /^\/program\/[a-z0-9]+(?:-[a-z0-9]+)*\/?$/.test(pathname);
|
const isProgramPage = /^\/program\/[a-z0-9]+(?:-[a-z0-9]+)*\/?$/.test(pathname);
|
||||||
const isCategoryPage = /^\/category\/[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);
|
sendProgram(response, url.searchParams.get('path') || '', headOnly);
|
||||||
} else if (url.pathname === '/api/source') {
|
} else if (url.pathname === '/api/source') {
|
||||||
sendSource(response, url.searchParams.get('path') || '', headOnly);
|
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)) {
|
} else if (!serveStatic(response, url.pathname, headOnly)) {
|
||||||
sendJson(response, 404, { error: 'Not found' }, headOnly);
|
sendJson(response, 404, { error: 'Not found' }, headOnly);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
sid-arpeggiator/screenshot.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
smooth-scroller/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
snake-trail/screenshot.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
sprite-playground/screenshot.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
starfield/screenshot.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
stock-trader/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
submarine-hunt/screenshot.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
8
tools/screenshots/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
166
tools/screenshots/capture.mjs
Normal file
|
|
@ -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.`);
|
||||||
66
tools/screenshots/package-lock.json
generated
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
tools/screenshots/package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.56.1",
|
||||||
|
"pngjs": "7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
typing-sprint/screenshot.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
wave-composer/screenshot.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
word-scramble/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
world-quiz/screenshot.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |