166 lines
6.2 KiB
JavaScript
166 lines
6.2 KiB
JavaScript
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.`);
|