storebound/cli/shots/gallery.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

254 lines
18 KiB
TypeScript

// 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 <file>] [--no-variants] [--json]
// Out: <renders>/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<string, GalleryRow>();
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<string, typeof imgs>();
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** JSON safe inside a <script> element. */
export const scriptJson = (o: unknown): string => JSON.stringify(o).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
/** The whole page. No external URLs: inline CSS + JS, images by relative path. Pure. */
export function galleryHtml(d: GalleryData): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="storebound shots gallery">
<title>${esc(d.title)}</title>
<style>
:root { --bg: #f4f4f6; --panel: #ffffff; --ink: #18181c; --mute: #6b6b76; --line: #dedee4; --err: #d92d20; --warn: #c27a00; --ok: #138a4c; --acc: #4b3bd6; --tile: #e9e9ee; }
:root[data-theme="dark"] { --bg: #111114; --panel: #1b1b20; --ink: #ececf1; --mute: #9a9aa6; --line: #2e2e36; --err: #ff6b5e; --warn: #ffb547; --ok: #3ccf7f; --acc: #9d8cff; --tile: #24242b; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #111114; --panel: #1b1b20; --ink: #ececf1; --mute: #9a9aa6; --line: #2e2e36; --err: #ff6b5e; --warn: #ffb547; --ok: #3ccf7f; --acc: #9d8cff; --tile: #24242b; } }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.4 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
header { position: sticky; top: 0; z-index: 5; background: var(--panel); border-bottom: 1px solid var(--line); padding: 12px 16px; display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; }
h1 { font-size: 17px; margin: 0 8px 0 0; }
.stat { color: var(--mute); }
.stat b { color: var(--ink); }
.stat .e { color: var(--err); } .stat .w { color: var(--warn); } .stat .k { color: var(--ok); }
.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
.tabs button, .ctl select, .ctl input[type=search], #theme { font: inherit; color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 5px 10px; cursor: pointer; }
.tabs button[aria-selected="true"] { background: var(--acc); border-color: var(--acc); color: #fff; }
.ctl { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-left: auto; }
.ctl label { display: flex; gap: 4px; align-items: center; color: var(--mute); }
main { padding: 8px 16px 40px; }
section.lang { margin-top: 18px; }
section.lang > h2 { font-size: 16px; margin: 0 0 8px; }
.row { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px 12px; margin-bottom: 10px; }
.row h3 { font-size: 13px; margin: 0 0 8px; font-weight: 600; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
.row h3 code { color: var(--mute); font-weight: 400; }
.row.bad { border-color: var(--err); }
.tiles { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; }
figure { margin: 0; flex: none; width: var(--tw); }
figure .pic { display: block; width: 100%; aspect-ratio: var(--ar); background: var(--tile); border-radius: 8px; overflow: hidden; border: 2px solid transparent; cursor: zoom-in; padding: 0; }
figure .pic img, figure .pic video { width: 100%; height: 100%; object-fit: contain; display: block; }
figure.err .pic { border-color: var(--err); } figure.warn .pic { border-color: var(--warn); }
figcaption { font-size: 12px; color: var(--mute); margin-top: 4px; overflow-wrap: anywhere; }
ul.issues { list-style: none; margin: 4px 0 0; padding: 0; font-size: 12px; }
ul.issues li { padding-left: 14px; position: relative; overflow-wrap: anywhere; }
ul.issues li::before { content: ""; position: absolute; left: 2px; top: .5em; width: 7px; height: 7px; border-radius: 50%; background: var(--warn); }
ul.issues li.error { color: var(--err); } ul.issues li.error::before { background: var(--err); }
.empty { color: var(--mute); padding: 30px 0; }
dialog { border: 0; padding: 0; background: transparent; max-width: 96vw; max-height: 96vh; }
dialog::backdrop { background: rgba(0,0,0,.82); }
dialog img, dialog video { max-width: 94vw; max-height: 88vh; display: block; margin: auto; border-radius: 10px; }
dialog p { color: #fff; text-align: center; margin: 8px 0 0; font-size: 13px; }
@media (max-width: 600px) { .ctl { margin-left: 0; } }
</style>
</head>
<body>
<header>
<h1>${esc(d.title)}</h1>
<span class="stat"><b>${d.totals.images}</b> images · <b>${d.totals.videos}</b> videos · ${d.totals.errors ? `<b class="e">${d.totals.errors} errors</b>` : '<b class="k">0 errors</b>'} · <b class="${d.totals.warnings ? 'w' : 'k'}">${d.totals.warnings} warnings</b> · ${esc(d.generated)}</span>
<nav class="tabs" role="tablist" id="tabs"></nav>
<div class="ctl">
<label>language <select id="lang"><option value="">all</option></select></label>
<label>size <select id="size"><option value="">all</option></select></label>
<label><input type="checkbox" id="only"> only issues</label>
<input type="search" id="q" placeholder="filter by name or message" aria-label="filter">
<button id="theme" type="button" title="light / dark">theme</button>
</div>
</header>
<main id="main"></main>
<dialog id="big"><div id="bigBody"></div><p id="bigCap"></p></dialog>
<script id="data" type="application/json">${scriptJson(d)}</script>
<script>
(function () {
var D = JSON.parse(document.getElementById('data').textContent);
var root = document.documentElement, state = { v: 0, lang: '', size: '', only: false, q: '' };
try { var t = localStorage.getItem('sb-gallery-theme'); if (t) root.dataset.theme = t; } catch (e) {}
try { var s = JSON.parse(sessionStorage.getItem('sb-gallery') || 'null'); if (s) state = Object.assign(state, s); } catch (e) {}
if (!D.variants[state.v]) state.v = 0;
function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function lvl(list) { return list.some(function (i) { return i.level === 'error'; }) ? 'err' : list.length ? 'warn' : ''; }
function issuesHtml(list) { return list.length ? '<ul class="issues">' + list.map(function (i) { return '<li class="' + i.level + '">' + esc(i.msg) + '</li>'; }).join('') + '</ul>' : ''; }
function mb(b) { return b ? (b / 1048576).toFixed(b < 1048576 ? 2 : 1) + ' MB' : ''; }
var tabs = document.getElementById('tabs');
tabs.innerHTML = D.variants.map(function (v, i) { return '<button role="tab" data-v="' + i + '">' + esc(v.label) + '</button>'; }).join('');
var langs = {}, sizes = {};
D.variants.forEach(function (v) { v.langs.forEach(function (l) { langs[l.lang] = 1; l.rows.forEach(function (r) { sizes[r.size] = r.label; }); }); });
document.getElementById('lang').insertAdjacentHTML('beforeend', Object.keys(langs).map(function (l) { return '<option>' + esc(l) + '</option>'; }).join(''));
document.getElementById('size').insertAdjacentHTML('beforeend', Object.keys(sizes).map(function (s) { return '<option value="' + esc(s) + '">' + esc(s) + '</option>'; }).join(''));
function render() {
try { sessionStorage.setItem('sb-gallery', JSON.stringify(state)); } catch (e) {}
[].forEach.call(tabs.children, function (b, i) { b.setAttribute('aria-selected', String(i === state.v)); });
document.getElementById('lang').value = state.lang; document.getElementById('size').value = state.size;
document.getElementById('only').checked = state.only; document.getElementById('q').value = state.q;
var q = state.q.toLowerCase(), out = '';
D.variants[state.v].langs.forEach(function (l) {
if (state.lang && l.lang !== state.lang) return;
var rows = '';
l.rows.forEach(function (r) {
if (state.size && r.size !== state.size) return;
var tiles = r.tiles.filter(function (t) {
if (state.only && !t.issues.length && !r.issues.length) return false;
if (!q) return true;
return (l.lang + '/' + r.size + '/' + t.name + ' ' + t.issues.map(function (i) { return i.msg; }).join(' ') + ' ' + r.issues.map(function (i) { return i.msg; }).join(' ')).toLowerCase().indexOf(q) >= 0;
});
if (!tiles.length && !(r.issues.length && (!q || r.issues.some(function (i) { return i.msg.toLowerCase().indexOf(q) >= 0; })))) return;
var tw = r.size === 'feature' || /landscape/.test(r.size) ? 270 : Math.max(110, Math.min(200, Math.round(140 * (r.tiles[0] ? r.tiles[0].w / r.tiles[0].h : .5) / .5)));
rows += '<div class="row' + (lvl(r.issues) === 'err' ? ' bad' : '') + '"><h3>' + esc(r.label) + ' <code>' + esc(l.lang + '/' + r.size) + ' · ' + r.tiles.length + '</code></h3>' + issuesHtml(r.issues) +
'<div class="tiles">' + tiles.map(function (t) {
var media = t.kind === 'video'
? '<video src="' + esc(t.src) + '"' + (t.poster ? ' poster="' + esc(t.poster) + '"' : '') + ' preload="none" muted playsinline></video>'
: '<img src="' + esc(t.src) + '" loading="lazy" alt="' + esc(l.lang + ' ' + r.size + ' ' + t.name) + '">';
return '<figure class="' + lvl(t.issues) + '" style="--tw:' + tw + 'px;--ar:' + t.w + '/' + t.h + '"><button class="pic" type="button" data-src="' + esc(t.src) + '" data-kind="' + t.kind + '" data-cap="' + esc(l.lang + '/' + r.size + '/' + t.name + ' · ' + t.w + 'x' + t.h + (t.bytes ? ' · ' + mb(t.bytes) : '')) + '">' + media + '</button>' +
'<figcaption>' + esc(t.name) + ' · ' + t.w + 'x' + t.h + (t.bytes ? ' · ' + mb(t.bytes) : '') + '</figcaption>' + issuesHtml(t.issues) + '</figure>';
}).join('') + '</div></div>';
});
if (rows) out += '<section class="lang"><h2>' + esc(l.lang) + '</h2>' + rows + '</section>';
});
document.getElementById('main').innerHTML = out || '<p class="empty">Nothing matches. Render first (storebound shots render) or clear the filters.</p>';
}
tabs.addEventListener('click', function (e) { var b = e.target.closest('button'); if (b) { state.v = +b.dataset.v; render(); } });
document.getElementById('lang').onchange = function (e) { state.lang = e.target.value; render(); };
document.getElementById('size').onchange = function (e) { state.size = e.target.value; render(); };
document.getElementById('only').onchange = function (e) { state.only = e.target.checked; render(); };
document.getElementById('q').oninput = function (e) { state.q = e.target.value; render(); };
document.getElementById('theme').onclick = function () {
var dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
root.dataset.theme = dark ? 'light' : 'dark';
try { localStorage.setItem('sb-gallery-theme', root.dataset.theme); } catch (e) {}
};
var dlg = document.getElementById('big');
document.getElementById('main').addEventListener('click', function (e) {
var b = e.target.closest('.pic'); if (!b) return;
document.getElementById('bigBody').innerHTML = b.dataset.kind === 'video' ? '<video src="' + esc(b.dataset.src) + '" controls autoplay playsinline></video>' : '<img src="' + esc(b.dataset.src) + '" alt="">';
document.getElementById('bigCap').textContent = b.dataset.cap;
dlg.showModal();
});
dlg.addEventListener('click', function () { dlg.close(); document.getElementById('bigBody').innerHTML = ''; });
render();
})();
</script>
</body>
</html>
`;
}
export async function run(ctx: Ctx): Promise<void> {
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)`);
}