storebound/cli/shots/render.ts

345 lines
24 KiB
TypeScript

// Store screenshots + Play feature graphic at exact pixel sizes, from raw app or game frames and per-language copy.
// storebound shots render [--lang en,tr] [--size ios69,play,feature] [--scenes hero,battle] [--variant b | --variants]
// [--no-sheet] [--port 8792] [--concurrency N] [--dry]
// Out: <shots.out>/<lang>/<size>/NN.png (24-bit RGB: Play rejects alpha / palette PNGs), <lang>/feature/1024x500.png,
// <lang>/sheet.png (contact sheet of every size packed into a grid, for a quick review) and <shots.out>/report.json (text overflow flags
// per image, read by `shots check` and `shots gallery`). Variants go to <shots.out>/<variant>/<lang>/...
// Default sizes: defaultSizes() (ipad13 only with apple.ipad; tab7 / tab10 only with play.tablets, default: tablet renders
// already exist). Needs playwright + ffmpeg.
// Fonts: the per-language stacks from .storebound/fonts/fonts.json (`shots fonts`). Before rendering, every character
// of the copy is checked against the cmaps of the downloaded faces (stale fonts fail with the language and the
// characters); while rendering, the page measures which face drew every character, and report.json records it per
// image (`fonts`: faces used, automatic fallbacks, system-font glyphs) for `shots check`. Local font files (shots.fonts
// paths) are served from where they are at /fonts/local/... (local-fonts.ts); a file changed since `shots fonts` fails.
// Style: shotsStyle() = shots.style, else app for the app / subscription-app profiles (template-app.html,
// feature-app.html: calm gradient, device frame, headline + subline), else game (template.html, feature.html).
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { SHOTS_PAGES } from '../lib/embedded';
import { fontCodepoints } from './cmap';
import { readManifest } from './fonts';
import { type FontManifest, fontUsage, measureFonts, roleTexts, uncoveredChars } from './glyphs';
import { join, relative } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, warn } from '../lib/log';
import { closeBrowser, launch } from './browser';
import { defaultConcurrency, pool, progressLine } from './capture-web';
import { shotsStyle } from '../lib/config';
import { type CopyFile, type DeviceBox, type TextBox, SIZES, renderDims, sheetTimeoutMs, featureLayout, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
import { bgCss, bgTone, type ContrastSample, contrastIssues, parseColour, photoBackground, photoCss, type RGB, worstContrast } from './palette';
import { type WeightSample, weightIssues } from './weights';
import type { Palette } from '../lib/config';
import { faceFilePath, fontsRoute, resolveShotFonts, staleLocalFonts } from './local-fonts';
import { type RenderReport, mergeReport, readReport, resolvedTablets } from './report';
import * as rt from '../lib/rt';
import { cpus, loadavg } from 'node:os';
/** max(1-min, 5-min load) / cores, 1..5 (the same factor tests/helpers/load.ts uses; STOREBOUND_LOAD_FACTOR overrides). */
export function loadFactor(env = process.env, load = loadavg(), cores = cpus().length): number {
const forced = Number(env.STOREBOUND_LOAD_FACTOR);
if (Number.isFinite(forced) && forced > 0) return Math.min(5, Math.max(1, forced));
return Math.min(5, Math.max(1, Math.max(load[0] ?? 0, load[1] ?? 0) / Math.max(1, cores)));
}
const TYPES: Record<string, string> = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
webp: 'image/webp', woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', css: 'text/css', svg: 'image/svg+xml' };
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const shots = need(config.shots, 'shots');
const a = ctx.args;
const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id);
const tablets = resolvedTablets(config, resolve(shots.out));
const sizes = a.list('size').length ? a.list('size') : defaultSizes(config, tablets);
if (config.play && !tablets && !a.list('size').length)
log('shots', `Play tablet screenshots: not rendered (${config.play.tablets === false ? 'play.tablets is false' : 'no tablet renders yet; set play.tablets: true to render tab7 / tab10'}; optional on Play)`);
for (const s of sizes) if (!SIZES[s]) throw new Error(`unknown size ${s} (${Object.keys(SIZES).join(', ')})`);
const variants: (string | undefined)[] = a.has('variants') ? [undefined, ...Object.keys(shots.variants ?? {})] : [a.get('variant')];
const baseCopy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile;
const jobs = new Map<string, { scenes: typeof shots.scenes; copy: CopyFile }>();
for (const v of variants) {
const scenes = variantScenes(shots, v);
const vc = v && shots.variants?.[v]?.copy ? JSON.parse(readFileSync(resolve(shots.variants[v]!.copy!), 'utf8')) as CopyFile : undefined;
const copy = mergeCopy(baseCopy, vc);
const problems = validateCopy(copy, { ...shots, scenes }, langs);
if (problems.length) throw new Error(`shots copy${v ? ` (variant ${v})` : ''}:\n ` + problems.join('\n '));
jobs.set(v ?? '', { scenes, copy });
}
for (const [v, job] of jobs) {
const bad = a.list('scenes').filter(id => !job.scenes.some(s => s.id === id));
if (bad.length) throw new Error(`--scenes: unknown scene id${bad.length > 1 ? 's' : ''} ${bad.join(', ')}${v ? ` (variant ${v})` : ''}; scene ids: ${job.scenes.map(s => s.id).join(', ')}`);
}
const framesDir = resolve(shots.frames), outDir = resolve(shots.out);
if (a.has('dry')) {
let n = 0;
for (const [v, job] of jobs) for (const lang of langs) {
const scenes = a.list('scenes').length ? a.list('scenes') : job.scenes.map(s => s.id);
const files = sizes.reduce((t, s) => t + (SIZES[s]!.feature ? 1 : scenes.length), 0);
n += files;
log('shots', `would render ${renderDir(outDir, lang, v || undefined)}: ${files} images (${sizes.join(', ')}; scenes ${scenes.join(', ')})`);
}
return done('shots', `render planned (dry run): ${n} images`);
}
const fontsDir = join(root, '.storebound', 'fonts');
if (!existsSync(join(fontsDir, 'fonts.css'))) warn('shots', 'no .storebound/fonts/fonts.css: run `storebound shots fonts` first (falling back to system fonts; shots check will fail)');
const { phone, tablet } = frameDims(config);
const rtl = new Map(config.languages.map(l => [l.id, !!l.rtl] as const));
const style = shotsStyle(config);
const rf = resolveShotFonts(shots, style, resolve, root);
const sf = { title: rf.title.family, body: rf.body.family };
const fonts = { title: sf.title, body: sf.body };
const manifest = existsSync(join(fontsDir, 'fonts.css')) ? readManifest(fontsDir) : null;
if (existsSync(join(fontsDir, 'fonts.css')) && !manifest) warn('shots', 'fonts from an older storebound (no fonts.json): run `storebound shots fonts` again for the glyph coverage check');
if (manifest && (!manifest.faces[sf.title] || !manifest.faces[sf.body])) throw new Error(`shots render: .storebound/fonts has other fonts than shots.fonts (${sf.title} / ${sf.body}): run \`storebound shots fonts\``);
const stacks = (lang: string, copy: CopyFile): { title: string[]; body: string[]; script: string | null } => {
const m = manifest?.langs[lang];
if (m) return { title: m.title, body: m.body, script: m.title[0]?.startsWith('Store ') && !m.title[0].startsWith('Store fb ') ? m.title[0] : null };
const sc = scriptFont(lang, copyText(lang, copy[lang]));
const x = sc ? `Store ${sc.slug}` : null;
return { title: [...(x ? [x] : []), sf.title], body: [...(x ? [x] : []), sf.body], script: x };
};
const faces: FontManifest['faces'] = manifest?.faces ?? { [sf.title]: { family: sf.title, file: '', kind: 'title', chosen: true }, [sf.body]: { family: sf.body, file: '', kind: 'body', chosen: true } };
if (!manifest) for (const [, job] of jobs) for (const lang of langs) {
const x = stacks(lang, job.copy).script;
if (x) faces[x] = { family: x, file: '', kind: 'script', chosen: true };
}
if (manifest) {
const stale = staleLocalFonts(manifest, root);
if (stale.length) throw new Error(`shots render: local font files changed since \`shots fonts\`: run \`storebound shots fonts\`\n ${stale.join('\n ')}`);
}
if (manifest) { // glyph coverage of the local font files, before rendering anything
const cmaps = new Map<string, Set<number>>();
const cps = (face: string): Set<number> => {
if (!cmaps.has(face)) { const f = manifest.faces[face]; const file = f && faceFilePath(fontsDir, root, f); cmaps.set(face, file && existsSync(file) ? fontCodepoints(readFileSync(file)) : new Set()); }
return cmaps.get(face)!;
};
const gaps: string[] = [];
for (const [, job] of jobs) for (const lang of langs) {
if (!manifest.langs[lang]) { gaps.push(`${lang}: no font stack (language added after \`shots fonts\`)`); continue; }
const t = roleTexts(lang, job.copy[lang], config.app.name);
for (const role of ['title', 'body'] as const) {
const miss = uncoveredChars(t[role], manifest.langs[lang]![role].map(cps));
if (miss.length) gaps.push(`${lang} (${role}): ${miss.join(' ')}`);
}
}
if (gaps.length) throw new Error(`shots render: the downloaded fonts do not cover the copy (edited since \`shots fonts\`?): run \`storebound shots fonts\`\n ${[...new Set(gaps)].join('\n ')}`);
for (const lang of langs) for (const f of manifest.langs[lang]?.fallbacks ?? [])
(manifest.faces[f.face]?.chosen ? log : warn)('shots', `${lang} ${f.role}: ${[...f.chars].join(' ')} from ${f.family}${manifest.faces[f.face]?.chosen ? '' : ' (automatic fallback: shots check will fail)'}`);
}
// palette backgrounds as CSS (colour, gradient string or stops) and photo backgrounds, served at /bgimg/<n>
const photos: string[] = [];
const look = (pl: Palette, angle: number, mid: number, featureBg?: NonNullable<typeof shots.feature>['background']): Palette & { bgCss: string; bgTone: string; photo?: { overlay: number } } => {
const ph = photoBackground(pl, featureBg);
let css = bgCss(pl.bg, angle, mid);
if (ph) {
const abs = resolve(ph.image);
if (!existsSync(abs)) throw new Error(`shots render: background image ${ph.image} not found (${abs})`);
if (!photos.includes(abs)) photos.push(abs);
css = photoCss(`/bgimg/${photos.indexOf(abs)}`, ph.overlay) + ', ' + css;
}
return { ...pl, bgCss: css, bgTone: ph && ph.overlay >= 0.35 ? '#000000' : bgTone(pl.bg), ...(ph ? { photo: { overlay: ph.overlay } } : {}) };
};
const server = await rt.serve({
port: a.num('port', 8792), hostname: '127.0.0.1',
async fetch(req) {
const url = new URL(req.url);
const p = decodeURIComponent(url.pathname);
if (p === '/job.json') {
const lang = url.searchParams.get('lang') ?? '';
const { scenes, copy } = jobs.get(url.searchParams.get('v') ?? '') ?? jobs.values().next().value!;
const st = stacks(lang, copy);
const ls = shots.landscape?.size;
const landscape = { w: ls?.w ?? 1440, h: ls?.h ?? 900, bar: 0 };
const base = { style, phone, tablet, landscape, side: shots.landscape?.side ?? 'left', rtl: rtl.get(lang), logo: !!shots.logo, appName: config.app.name,
fonts: { ...fonts, script: st.script, titleStack: st.title, bodyStack: st.body } };
if (url.searchParams.has('feature'))
return Response.json({ ...base, frame: shots.feature?.frame ?? scenes[0]!.frames[0]!.src,
device: featureLayout(style, phone, { fit: shots.feature?.fit, scale: shots.feature?.scale }),
palette: look(palette(shots.feature?.palette ?? scenes[0]!.palette, style), style === 'app' ? 135 : 160, style === 'app' ? 55 : 50, shots.feature?.background) });
const sid = url.searchParams.get('s') ?? '';
const scene = scenes.find(s => s.id === sid)!;
const number = scenes.filter(s => !s.hero).indexOf(scene) + 1;
return Response.json({ ...base, scene, number, palette: look(palette(scene.palette, style), 180, style === 'app' ? 55 : 48), copy: copy[lang]?.[sid] ?? {} });
}
let file = '';
if (p === '/logo' && shots.logo) file = resolve(shots.logo);
else if (p.startsWith('/bgimg/')) file = photos[Number(p.slice(7))] ?? '';
else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8));
else if (p.startsWith('/fonts/')) file = fontsRoute(p.slice(7), fontsDir, root, manifest) ?? '';
else if (p.startsWith('/out/')) file = join(outDir, p.slice(5));
else if (/^\/(template|feature)(-app)?\.html$/.test(p)) return new Response(SHOTS_PAGES[p.slice(1)]!, { headers: { 'content-type': TYPES.html! } });
if (!file || file.includes('..')) return new Response('no', { status: 404 });
return rt.fileResponse(file, { 'content-type': TYPES[file.split('.').pop()!.toLowerCase()] ?? 'application/octet-stream' }) ?? new Response('missing ' + p, { status: 404 });
},
});
const base = `http://127.0.0.1:${server.port}`;
const browser = await launch(root, { headless: true });
const concurrency = a.num('concurrency', defaultConcurrency('render'));
// one page per worker, each with its own error list
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pages: { page: any; errs: string[] }[] = [];
const pageFor = async (k: number): Promise<{ page: any; errs: string[] }> => { // eslint-disable-line @typescript-eslint/no-explicit-any
if (pages[k]) return pages[k]!;
const page = await (await browser.newContext({ deviceScaleFactor: 1 })).newPage(); // own context: no background-tab throttling
const errs: string[] = [];
page.on('pageerror', (e: Error) => errs.push(e.message));
page.on('response', (r: { status(): number; url(): string }) => { if (r.status() >= 400 && !r.url().includes('/fonts/')) errs.push(`${r.status()} ${r.url()}`); });
return (pages[k] = { page, errs });
};
let warnings = 0, finished = 0, total = 0;
const started = Date.now();
const report: RenderReport = {};
async function shoot(p: { page: any; errs: string[] }, url: string, w: number, h: number, out: string, timeout = 30000, what = ''): Promise<void> { // eslint-disable-line @typescript-eslint/no-explicit-any
const { page, errs } = p;
const t0 = Date.now();
// heartbeat for long waits (the contact sheet decodes every image): one line every 15 s, the budget on timeout
const beat = timeout > 30000 ? setInterval(() => log('shots', `${what || relative(root, out)}: still loading (${Math.round((Date.now() - t0) / 1000)} s of ${Math.round(timeout / 1000)} s)`), 15000) : null;
try {
errs.length = 0;
mkdirSync(join(out, '..'), { recursive: true });
await page.setViewportSize({ width: w, height: h });
await page.goto(url, { waitUntil: 'load' });
await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout }).catch((e: Error) => {
throw new Error(`${what || relative(root, out)}: not ready after ${Math.round((Date.now() - t0) / 1000)} s (budget ${Math.round(timeout / 1000)} s; ${errs.length ? errs.join(' | ') : 'no page errors'}): ${e.message.split('\n')[0]}`);
});
} finally { if (beat) clearInterval(beat); }
await page.waitForTimeout(200);
const tmp = out + '.tmp.png';
const overflow = JSON.parse((await page.evaluate('document.body.dataset.overflow')) ?? '[]') as string[];
const devices = JSON.parse((await page.evaluate('document.body.dataset.devices')) ?? '[]') as DeviceBox[];
const bleed = (await page.evaluate('document.body.dataset.bleed')) === '1';
const boxes = JSON.parse((await page.evaluate('document.body.dataset.boxes')) ?? '[]') as TextBox[];
if (!out.endsWith('sheet.png')) {
const measured = await measureFonts(page, Object.keys(faces));
const usage = fontUsage(measured, faces);
const weights = await titleWeights(page, sf.title);
report[relative(outDir, out)] = { overflow, devices, ...(bleed ? { bleed } : {}), ...(boxes.length ? { boxes } : {}), missing: errs.filter(e => e.startsWith('404')), fonts: usage, ...(weights.length ? { weights } : {}) };
for (const i of weightIssues(weights)) warn('shots', `${relative(root, out)}: ${i.msg}`);
for (const f of usage.fallback) warn('shots', `${relative(root, out)}: ${f.el} ${[...f.chars].join(' ')} drawn with ${f.family} (automatic fallback)`);
for (const f of usage.missing) warn('shots', `${relative(root, out)}: ${f.el} ${[...f.chars].join(' ')} drawn with a system font`);
}
if (overflow.length) warn('shots', `${relative(root, out)}: ${overflow.join('; ')}`);
await page.screenshot({ path: tmp, clip: { x: 0, y: 0, width: w, height: h } });
if (style === 'app' && !out.endsWith('sheet.png')) { // contrast guard: title ink vs. the pixels behind it (text hidden)
const contrast = await titleContrast(page, w, h);
const r = report[relative(outDir, out)];
if (r && contrast.length) r.contrast = contrast;
for (const i of contrastIssues(contrast)) warn('shots', `${relative(root, out)}: ${i.msg}`);
}
const ff = rt.spawn(['ffmpeg', '-v', 'error', '-y', '-i', tmp, '-pix_fmt', 'rgb24', out], { stderr: 'pipe' });
const code = await ff.exited;
rmSync(tmp, { force: true });
if (code !== 0) throw new Error('ffmpeg: ' + await new Response(ff.stderr).text());
if (errs.length) warnings++;
finished++;
log('shots', `${progressLine(finished, total, started)} ${errs.length ? 'WARN ' : ''}${relative(root, out)}${errs.length ? ' ' + errs.join(' | ') : ''}`);
}
// every image first (in parallel), then one contact sheet per language from its finished images
const items: { url: string; w: number; h: number; out: string }[] = [];
const sheets: { dir: string; rows: { size: string; files: string[]; dims?: { w: number; h: number } }[] }[] = [];
for (const [v, job] of jobs) for (const lang of langs) {
const sceneIds = a.list('scenes').length ? a.list('scenes') : job.scenes.map(s => s.id);
const vq = v ? `&v=${encodeURIComponent(v)}` : '';
const dir = renderDir(outDir, lang, v || undefined);
const rows: { size: string; files: string[]; dims?: { w: number; h: number } }[] = [];
for (const size of sizes) {
const s = SIZES[size]!, d = renderDims(size, shots);
if (s.feature) {
const f = join(dir, 'feature', '1024x500.png');
items.push({ url: `${base}/${style === 'app' ? 'feature-app' : 'feature'}.html?lang=${encodeURIComponent(lang)}${vq}`, w: s.w, h: s.h, out: f });
rows.push({ size, files: [f] });
continue;
}
const files: string[] = [];
for (const [i, id] of sceneIds.entries()) {
const f = join(dir, size, `${String(i + 1).padStart(2, '0')}.png`);
const dev = d.landscape ? `&device=landscape&frame=${d.frame}` : s.device ? '&device=tablet' : '';
items.push({ url: `${base}/${style === 'app' ? 'template-app' : 'template'}.html?s=${encodeURIComponent(id)}&w=${d.w}&h=${d.h}&lang=${encodeURIComponent(lang)}${dev}${size === 'tab7' || size === 'tab10' ? '&style=android' : ''}${vq}`, w: d.w, h: d.h, out: f });
files.push(f);
}
rows.push({ size, files, dims: { w: d.w, h: d.h } });
}
if (!a.has('no-sheet') && rows.length) sheets.push({ dir, rows });
}
total = items.length + sheets.length;
log('shots', `rendering ${items.length} images${sheets.length ? ` + ${sheets.length} contact sheets` : ''}, ${Math.min(concurrency, items.length)} at a time (--concurrency)`);
try {
const slot = new Map<number, number>();
let nextSlot = 0;
const withPage = async (fn: (p: { page: any; errs: string[] }) => Promise<void>): Promise<void> => { // eslint-disable-line @typescript-eslint/no-explicit-any
const free = [...Array(concurrency).keys()].find(k => !slot.has(k)) ?? nextSlot++ % concurrency;
slot.set(free, 1);
try { await fn(await pageFor(free)); } finally { slot.delete(free); }
};
await pool(items, concurrency, it => withPage(p => shoot(p, it.url, it.w, it.h, it.out)));
await pool(sheets, concurrency, async ({ dir, rows }) => {
const lay = sheetLayout(rows);
const imgs = lay.tiles.map(t => `<img src="/out/${relative(outDir, t.file)}" style="position:absolute;left:${t.x}px;top:${t.y}px;width:${t.w}px;height:${t.h}px;border-radius:14px">`).join('');
const sheet = join(dir, '.sheet.html');
writeFileSync(sheet, `<!doctype html><body style="margin:0;background:#141418;width:${lay.w}px;height:${lay.h}px;position:relative">${imgs}
<script>Promise.all([...document.images].map(i=>i.decode())).then(()=>document.body.dataset.ready='1')</script></body>`);
const ms = sheetTimeoutMs(lay.tiles.length, rows.length, loadFactor());
await withPage(p => shoot(p, `${base}/out/${relative(outDir, sheet)}`, lay.w, lay.h, join(dir, 'sheet.png'), ms, `${relative(root, join(dir, 'sheet.png'))} (${lay.tiles.length} images, ${rows.length} sizes)`));
rmSync(sheet, { force: true });
});
} finally {
for (const p of pages) await p?.page.context().close().catch(() => {});
await closeBrowser(browser);
server.stop(true);
if (Object.keys(report).length) writeFileSync(join(outDir, 'report.json'), JSON.stringify(mergeReport(readReport(outDir), report), null, 1) + '\n');
}
const flagged = Object.values(report).filter(r => r.overflow.length).length;
if (flagged) warn('shots', `${flagged} images with text overflow flags: storebound shots check / shots gallery`);
done('shots', `render done${warnings ? `, ${warnings} with warnings (missing frames or fonts?)` : ''}`);
}
/** Title elements' asked weight vs. the weights the loaded faces of the title family serve (report.json `weights`). */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function titleWeights(page: any, family: string): Promise<WeightSample[]> {
return (await page.evaluate(`(() => {
const fam = ${JSON.stringify(family)};
const served = [...document.fonts].filter(f => f.family.replace(/["']/g, '') === fam && f.status === 'loaded').map(f => String(f.weight));
const seen = new Set(), out = [];
for (const e of document.querySelectorAll('h1 span, .name')) {
const want = Number(getComputedStyle(e).fontWeight), el = e.matches('.name') ? 'app name' : 'title';
if (!e.textContent.trim() || seen.has(el + want)) continue;
seen.add(el + want);
out.push({ el, family: fam, want, served: [...new Set(served)] });
}
return out;
})()`)) as WeightSample[];
}
/** Title ink vs. the background behind it: hides the title text, screenshots each title box, averages it into a
* small grid with ffmpeg and keeps the worst contrast (report.json `contrast`). Run after the real screenshot. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function titleContrast(page: any, W: number, H: number): Promise<ContrastSample[]> {
const els = (await page.evaluate(`(() => {
const out = [];
for (const e of document.querySelectorAll('h1 span, .name')) {
if (!e.textContent.trim()) continue;
const r = e.getBoundingClientRect();
out.push({ el: e.matches('.name') ? 'app name' : 'title', ink: getComputedStyle(e).color, x: r.x, y: r.y, w: r.width, h: r.height });
}
for (const e of document.querySelectorAll('h1, .name')) e.style.visibility = 'hidden';
return out;
})()`)) as { el: string; ink: string; x: number; y: number; w: number; h: number }[];
const out: ContrastSample[] = [];
for (const e of els) {
const ink = parseColour(e.ink);
const x = Math.max(0, Math.floor(e.x)), y = Math.max(0, Math.floor(e.y));
const cw = Math.min(W - x, Math.ceil(e.w)), ch = Math.min(H - y, Math.ceil(e.h));
if (!ink || cw < 2 || ch < 2) continue;
const png = await page.screenshot({ clip: { x, y, width: cw, height: ch } }) as Uint8Array;
const r = rt.spawnSync(['ffmpeg', '-v', 'error', '-i', 'pipe:0', '-vf', 'scale=8:3:flags=area', '-f', 'rawvideo', '-pix_fmt', 'rgb24', 'pipe:1'], { stdin: png, timeout: 15000 });
const raw = r.stdout;
if (r.exitCode !== 0 || raw.length < 3) continue;
const samples: RGB[] = [];
for (let i = 0; i + 2 < raw.length; i += 3) samples.push([raw[i]!, raw[i + 1]!, raw[i + 2]!]);
const wc = worstContrast(ink, samples);
out.push({ el: e.el, ink: e.ink, worst: wc.worst, ratio: Math.round(wc.ratio * 100) / 100 });
}
return out;
}