storebound/cli/shots/render.ts
CRT a9261a8052 play images: tablet, TV and Wear sets are optional; play.tablets switch (default: tablet renders exist); a missing optional set never deletes Play's images without --prune
A phone-only project aborted with 'no sevenInchScreenshots'. Play requires the 512 icon, the 1024x500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151). play images uploads what exists and prints an info line for missing optional sets; play.tablets true makes missing tablet sets an error in shots render/check/gallery, validate and play images. Pitfall 19.
2026-10-12 08:21:03 +08:00

249 lines
17 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 } from 'node:fs';
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 { launch } from './browser';
import { defaultConcurrency, pool, progressLine } from './capture-web';
import { shotsStyle } from '../lib/config';
import { type CopyFile, SIZES, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
import { faceFilePath, fontsRoute, resolveShotFonts, staleLocalFonts } from './local-fonts';
import { type RenderReport, mergeReport, readReport, resolvedTablets } from './report';
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)'}`);
}
const tpl = import.meta.dir;
const server = Bun.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 base = { style, phone, tablet, 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, palette: palette(shots.feature?.palette ?? scenes[0]!.palette, style) });
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: palette(scene.palette, style), copy: copy[lang]?.[sid] ?? {} });
}
let file = '';
if (p === '/logo' && shots.logo) file = resolve(shots.logo);
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)) file = join(tpl, p.slice(1));
if (!file || file.includes('..')) return new Response('no', { status: 404 });
const f = Bun.file(file);
if (!(await f.exists())) return new Response('missing ' + p, { status: 404 });
return new Response(f, { headers: { 'content-type': TYPES[file.split('.').pop()!.toLowerCase()] ?? 'application/octet-stream' } });
},
});
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): Promise<void> { // eslint-disable-line @typescript-eslint/no-explicit-any
const { page, errs } = p;
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: 30000 });
await page.waitForTimeout(200);
const tmp = out + '.tmp.png';
const overflow = JSON.parse((await page.evaluate('document.body.dataset.overflow')) ?? '[]') as string[];
if (!out.endsWith('sheet.png')) {
const measured = await measureFonts(page, Object.keys(faces));
const usage = fontUsage(measured, faces);
report[relative(outDir, out)] = { overflow, missing: errs.filter(e => e.startsWith('404')), fonts: usage };
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 } });
const ff = Bun.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[] }[] }[] = [];
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[] }[] = [];
for (const size of sizes) {
const s = SIZES[size]!;
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`);
items.push({ url: `${base}/${style === 'app' ? 'template-app' : 'template'}.html?s=${encodeURIComponent(id)}&w=${s.w}&h=${s.h}&lang=${encodeURIComponent(lang)}${s.device ? '&device=tablet' : ''}${size === 'tab7' || size === 'tab10' ? '&style=android' : ''}${vq}`, w: s.w, h: s.h, out: f });
files.push(f);
}
rows.push({ size, files });
}
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');
await Bun.write(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>`);
await withPage(p => shoot(p, `${base}/out/${relative(outDir, sheet)}`, lay.w, lay.h, join(dir, 'sheet.png')));
rmSync(sheet, { force: true });
});
} finally {
for (const p of pages) await p?.page.context().close().catch(() => {});
await browser.close();
server.stop(true);
if (Object.keys(report).length) await Bun.write(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?)` : ''}`);
}