// Review page for every render: one self-contained HTML file next to the images (no external requests, no server). // storebound shots gallery [--lang en,tr] [--src store/renders] [--out ] [--no-variants] [--json] // Out: /index.html. Open it in any browser (file://). Grid per language: one row per size, one tile per scene, // app previews with their poster; variant tabs (base + every folder from shots.variants), filters (language, size, only // issues, search), light / dark theme, click a tile for the full-size image. Store check issues (the same as // `shots check`: size, alpha, bytes, counts, text overflow from report.json, video rules) are outlined in red (error) // or amber (warning) with the message under the tile. Images are linked by relative path, so the page stays small and // always shows the current files; move it together with the renders folder. import { existsSync, writeFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import type { Ctx } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { SIZES } from './lib'; import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders } from './report'; import { type Issue, IMAGE_SPECS, VIDEO_TARGETS } from './spec'; export interface GalleryTile { /** Path relative to the gallery file. */ src: string; kind: 'image' | 'video'; /** Poster for videos (relative), when it exists. */ poster?: string; name: string; w: number; h: number; bytes: number; issues: Issue[]; } export interface GalleryRow { size: string; label: string; store: 'apple' | 'play'; tiles: GalleryTile[]; issues: Issue[] } export interface GalleryLang { lang: string; rows: GalleryRow[] } export interface GalleryVariant { id: string; label: string; langs: GalleryLang[] } export interface GalleryData { title: string; generated: string; variants: GalleryVariant[]; totals: { images: number; videos: number; errors: number; warnings: number }; } const SIZE_ORDER = [...Object.keys(SIZES), ...Object.keys(VIDEO_TARGETS).map(t => `preview/${t}`)]; /** Gallery data from a renders scan. `at` = where the gallery file goes (for relative paths). * JPEG copies next to a PNG of the same number are shown once (the PNG; the JPEG's issues are merged in). Pure. */ export function galleryData(scan: RendersScan, o: { at: string; title?: string; now?: Date; exists?: (f: string) => boolean }): GalleryData { const rel = (f: string) => relative(o.at, f).split('\\').join('/'); const variants: GalleryVariant[] = []; for (const v of [undefined, ...scan.variants]) { const langs: GalleryLang[] = []; for (const lang of scan.langs) { const rows = new Map(); const row = (size: string): GalleryRow => { let r = rows.get(size); if (!r) { const key = size.replace(/^preview\//, ''); const spec = IMAGE_SPECS[size], vt = VIDEO_TARGETS[key]; r = { size, label: spec?.label ?? vt?.label ?? size, store: spec?.store ?? vt?.store ?? 'apple', tiles: [], issues: [] }; rows.set(size, r); } return r; }; const imgs = scan.images.filter(i => i.variant === v && i.lang === lang); const byNum = new Map(); for (const i of imgs) { const num = `${i.size}/${i.rel.split('/').pop()!.split('.')[0]}`; byNum.set(num, [...(byNum.get(num) ?? []), i]); } for (const group of byNum.values()) { const main = group.find(i => /\.png$/i.test(i.rel)) ?? group[0]!; const issues = [...main.issues]; for (const other of group.filter(i => i !== main)) issues.push(...other.issues.filter(x => x.code !== 'overflow').map(x => ({ ...x, msg: `${/\.png$/i.test(other.rel) ? 'PNG' : 'JPEG'} copy: ${x.msg}` }))); row(main.size).tiles.push({ src: rel(main.file), kind: 'image', name: main.rel.split('/').pop()!, w: main.info.w, h: main.info.h, bytes: main.info.bytes, issues }); } for (const vd of scan.videos.filter(x => x.variant === v && x.lang === lang)) { const poster = vd.file.replace(/\.[^.]+$/, '-poster.png'); row(`preview/${vd.target}`).tiles.push({ src: rel(vd.file), kind: 'video', poster: (o.exists?.(poster) ?? false) ? rel(poster) : undefined, name: vd.rel.split('/').pop()!, w: vd.probe?.video?.w ?? VIDEO_TARGETS[vd.target]!.w, h: vd.probe?.video?.h ?? VIDEO_TARGETS[vd.target]!.h, bytes: vd.probe?.bytes ?? 0, issues: vd.issues }); } for (const s of scan.sets.filter(x => x.variant === v && x.lang === lang)) row(s.size).issues.push(...s.issues); const sorted = [...rows.values()].sort((a, b) => SIZE_ORDER.indexOf(a.size) - SIZE_ORDER.indexOf(b.size)); for (const r of sorted) r.tiles.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); if (sorted.length) langs.push({ lang, rows: sorted }); } if (langs.length || !v) variants.push({ id: v ?? '', label: v ? `variant ${v}` : 'base', langs }); } const issues = allIssues(scan); return { title: o.title ?? 'Store renders', generated: (o.now ?? new Date()).toISOString().slice(0, 16).replace('T', ' '), variants, totals: { images: scan.images.length, videos: scan.videos.length, errors: issues.filter(i => i.issue.level === 'error').length, warnings: issues.filter(i => i.issue.level === 'warn').length }, }; } const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); /** JSON safe inside a `; } export async function run(ctx: Ctx): Promise { const { config, resolve } = ctx.loaded; const a = ctx.args; const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id); const dir = resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'); const out = a.get('out') ? resolve(a.get('out')!) : join(dir, 'index.html'); const variants = a.has('no-variants') ? [] : Object.keys(config.shots?.variants ?? {}); const tablets = resolvedTablets(config, dir); const scan = scanRenders(dir, { langs, variants, required: lang => requiredSets(config, lang, tablets) }); if (!scan.images.length && !scan.videos.length) warn('shots', `no renders in ${dir} (storebound shots render)`); const data = galleryData(scan, { at: join(out, '..'), title: `${config.app.name} · store renders`, exists: existsSync }); // --json: the data only (the desktop gallery), with the renders folder relative to the app folder; writes nothing if (a.has('json')) { printJson({ ...data, dir: relative(ctx.loaded.root, dir).split('\\').join('/') || '.' }); return; } if (a.has('dry')) { log('shots', `would write ${out}: ${data.totals.images} images, ${data.totals.videos} videos, ${data.totals.errors} errors`); return; } writeFileSync(out, galleryHtml(data)); log('shots', `${data.totals.images} images, ${data.totals.videos} videos, ${data.variants.length} variant tab(s): ${data.totals.errors} errors, ${data.totals.warnings} warnings`); done('shots', `gallery: ${out} (open it in a browser)`); }