Lilita One lacks most of Latin Extended-A (Turkish s-cedilla, g-breve, dotted I ...), so Turkish titles were drawn with a silent browser fallback while shots check passed. shots fonts now reads the cmap of every face (pure TS, TTF/OTF/WOFF/WOFF2), adds a Noto fallback per missing character or fails with language + characters; render measures which face drew each character (CDP probes) and records it in report.json; check errors on fallbacks the config did not choose (shots.fonts.fallback) and on system-font glyphs. Default game title font: Paytone One.
107 lines
6.9 KiB
TypeScript
107 lines
6.9 KiB
TypeScript
// Render report + the local checks over a renders folder: exact sizes, alpha, bytes, counts per set, text overflow
|
|
// flags from the last render, app preview videos. Shared by `shots check`, `shots gallery` and `validate`.
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { SIZES } from './lib';
|
|
import { fontIssues, type FontUsage } from './glyphs';
|
|
import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec';
|
|
|
|
/** <renders>/report.json: path relative to the renders dir -> flags found while rendering. missing = 404s (frames);
|
|
* fonts = which face drew the text (glyphs.ts FontUsage): automatic fallbacks and system-font glyphs are errors. */
|
|
export type RenderReport = Record<string, { overflow: string[]; missing?: string[]; fonts?: FontUsage }>;
|
|
|
|
export function readReport(dir: string): RenderReport {
|
|
try { return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf8')) as RenderReport; } catch { return {}; }
|
|
}
|
|
|
|
/** Newer entries replace older ones per file; keys sorted so the file diffs well. */
|
|
export function mergeReport(old: RenderReport, next: RenderReport): RenderReport {
|
|
const all = { ...old, ...next };
|
|
return Object.fromEntries(Object.keys(all).sort().map(k => [k, all[k]!]));
|
|
}
|
|
|
|
export interface ImageEntry { rel: string; lang: string; variant?: string; size: string; file: string; info: ImageInfo; issues: Issue[] }
|
|
export interface VideoEntry { rel: string; lang: string; variant?: string; target: string; file: string; probe: ProbeInfo | null; issues: Issue[] }
|
|
export interface SetEntry { lang: string; variant?: string; size: string; count: number; issues: Issue[] }
|
|
export interface RendersScan { images: ImageEntry[]; videos: VideoEntry[]; sets: SetEntry[]; variants: string[]; langs: string[] }
|
|
|
|
/** Every NN.png|jpg of every size, plus preview/<target>/*.mp4|mov, for the given languages (and variants). Reads files;
|
|
* `probe` is injectable so tests do not need ffprobe. JPEG copies next to PNGs are checked as their own files but
|
|
* counted once per number. */
|
|
export function scanRenders(dir: string, o: { langs: string[]; variants?: string[];
|
|
required?: Record<string, boolean> | ((lang: string) => Record<string, boolean>); sizes?: string[];
|
|
probe?: (file: string) => ProbeInfo | null }): RendersScan {
|
|
const report = readReport(dir);
|
|
const out: RendersScan = { images: [], videos: [], sets: [], variants: [], langs: o.langs };
|
|
const probe = o.probe ?? ffprobe;
|
|
for (const variant of [undefined, ...(o.variants ?? [])]) {
|
|
const vdir = variant ? join(dir, variant) : dir;
|
|
if (variant && !existsSync(vdir)) continue;
|
|
if (variant) out.variants.push(variant);
|
|
for (const lang of o.langs) {
|
|
const ldir = join(vdir, lang);
|
|
const req = typeof o.required === 'function' ? o.required(lang) : o.required ?? {};
|
|
for (const size of o.sizes ?? Object.keys(SIZES)) {
|
|
const sdir = join(ldir, size);
|
|
const files = existsSync(sdir) ? readdirSync(sdir).filter(f => (size === 'feature' ? /^1024x500\.(png|jpe?g)$/i : /^\d+\.(png|jpe?g)$/i).test(f)).sort() : [];
|
|
const numbers = new Set(files.map(f => f.split('.')[0]));
|
|
for (const f of files) {
|
|
const file = join(sdir, f);
|
|
const info = imageInfo(readFileSync(file));
|
|
const rel = (variant ? `${variant}/` : '') + `${lang}/${size}/${f}`;
|
|
const issues = checkImage(info, size);
|
|
const entry = report[rel.replace(/\.jpe?g$/i, '.png')];
|
|
for (const msg of entry?.overflow ?? []) issues.push({ level: 'warn', code: 'overflow', msg });
|
|
issues.push(...fontIssues(entry?.fonts));
|
|
if (entry && !entry.fonts) issues.push({ level: 'warn', code: 'fonts', msg: 'font usage not recorded (rendered by an older storebound): run shots fonts + shots render again so missing glyphs are caught' });
|
|
out.images.push({ rel, lang, variant, size, file, info, issues });
|
|
}
|
|
const required = req[size] ?? false;
|
|
if (!files.length && !required) continue;
|
|
out.sets.push({ lang, variant, size, count: numbers.size, issues: checkCount(numbers.size, size, required) });
|
|
}
|
|
const pdir = join(ldir, 'preview');
|
|
if (!existsSync(pdir)) continue;
|
|
for (const target of readdirSync(pdir).filter(t => VIDEO_TARGETS[t])) {
|
|
const vids = readdirSync(join(pdir, target)).filter(f => /\.(mp4|mov|m4v)$/i.test(f)).sort();
|
|
if (VIDEO_TARGETS[target]!.store === 'apple' && vids.length > PREVIEW_RULES.maxPerSet)
|
|
out.sets.push({ lang, variant, size: `preview/${target}`, count: vids.length, issues: [{ level: 'error', code: 'count', msg: `${vids.length} app previews, at most ${PREVIEW_RULES.maxPerSet} per device size and language` }] });
|
|
for (const f of vids) {
|
|
const file = join(pdir, target, f);
|
|
const p = probe(file);
|
|
out.videos.push({ rel: (variant ? `${variant}/` : '') + `${lang}/preview/${target}/${f}`, lang, variant, target, file, probe: p,
|
|
issues: p ? checkVideo(p, target) : [{ level: 'warn', code: 'video', msg: 'not checked (ffprobe missing)' }] });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function ffprobe(file: string): ProbeInfo | null {
|
|
const r = spawnSync('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_streams', '-show_format', file]);
|
|
if (r.status !== 0) return null;
|
|
return parseProbe(r.stdout.toString(), statSync(file).size);
|
|
}
|
|
|
|
/** Which sets the stores require for this config (missing ones are errors, others only checked when present). With a
|
|
* language id: Apple sets only when that language has an App Store locale, Play sets only when it has a Play code. */
|
|
export function requiredSets(c: { apple?: { ipad?: boolean; iphoneMedium?: boolean } | undefined; play?: unknown; languages?: { id: string; apple?: string; play?: string | string[] }[] },
|
|
lang?: string): Record<string, boolean> {
|
|
const l = lang === undefined ? undefined : c.languages?.find(x => x.id === lang);
|
|
const apple = !!c.apple && (!l || !!l.apple);
|
|
const play = !!c.play && (!l || (Array.isArray(l.play) ? l.play.length > 0 : !!l.play));
|
|
return { ios69: apple, ...(c.apple?.iphoneMedium ? { 'iphone-medium': apple } : {}), ipad13: apple && !!c.apple?.ipad, play, feature: play };
|
|
}
|
|
|
|
export const allIssues = (s: RendersScan): { where: string; issue: Issue }[] => [
|
|
...s.sets.flatMap(x => x.issues.map(issue => ({ where: `${x.variant ? x.variant + '/' : ''}${x.lang}/${x.size}`, issue }))),
|
|
...s.images.flatMap(x => x.issues.map(issue => ({ where: x.rel, issue }))),
|
|
...s.videos.flatMap(x => x.issues.map(issue => ({ where: x.rel, issue }))),
|
|
];
|
|
|
|
export const storeOf = (where: string): 'apple' | 'play' => {
|
|
const key = where.split('/').find(p => IMAGE_SPECS[p] || VIDEO_TARGETS[p]) ?? '';
|
|
return (IMAGE_SPECS[key]?.store ?? VIDEO_TARGETS[key]?.store ?? 'apple');
|
|
};
|