// 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: ///NN.png (24-bit RGB: Play rejects alpha / palette PNGs), /feature/1024x500.png, // /sheet.png (contact sheet of every size packed into a grid, for a quick review) and /report.json (text overflow flags // per image, read by `shots check` and `shots gallery`). Variants go to ///... // 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 = { 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 { 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(); 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>(); const cps = (face: string): Set => { 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/ const photos: string[] = []; const look = (pl: Palette, angle: number, mid: number, featureBg?: NonNullable['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 { // 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(); let nextSlot = 0; const withPage = async (fn: (p: { page: any; errs: string[] }) => Promise): Promise => { // 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 => ``).join(''); const sheet = join(dir, '.sheet.html'); writeFileSync(sheet, `${imgs} `); 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 { 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 { 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; }