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.
254 lines
18 KiB
TypeScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
/** 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
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)`);
|
|
}
|