// 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 { boxOutside, type DeviceBox, PLAY_TABLET_SIZES, SIZES } from './lib'; import { fontIssues, type FontUsage } from './glyphs'; import { pulledEntry, readPulled } from '../lib/art-pull'; import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec'; /** /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; /** Device frames that leave the image. Feature graphic: an error unless shots.feature.fit is 'bleed' (Play shows the * phone cut off). Screenshots: the templates let the phone run off the bottom by design, so only top / left / right * count, as a warning. */ export function deviceIssues(entry: RenderReport[string] | undefined, size: string, W: number, H: number): Issue[] { if (!entry?.devices?.length || entry.bleed) return []; const feature = !!SIZES[size]?.feature; return entry.devices.flatMap((b, i) => { const sides = boxOutside(b, W, H).filter(s => feature || !s.startsWith('bottom')); if (!sides.length) return []; const which = entry.devices!.length > 1 ? `device ${i + 1}` : 'the device'; return [feature ? { level: 'error' as const, code: 'device-crop', msg: `${which} is cut off by the canvas (${sides.join(', ')}): render again (shots.feature.fit 'contain' fits it) or set fit 'bleed' if the crop is intended` } : { level: 'warn' as const, code: 'device-crop', msg: `${which} runs off the image (${sides.join(', ')}): check the scene's frame x / dy / scale / rotate` }]; }); } 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//*.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 | ((lang: string) => Record); sizes?: string[]; probe?: (file: string) => ProbeInfo | null }): RendersScan { const report = readReport(dir); // art pulled from the stores (art pull): an unchanged pulled file is the store's own (the store took it), and a set // the store had nothing for (Play's default-language fallback, a locale without that set) is not demanded here: // validate reports the store side const pulled = readPulled(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 bytes = readFileSync(file); const info = imageInfo(bytes); const rel = (variant ? `${variant}/` : '') + `${lang}/${size}/${f}`; const fromStore = !variant && !!pulledEntry(pulled, dir, file, bytes); const issues = fromStore ? [] : 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)); issues.push(...deviceIssues(entry, size, info.w, info.h)); 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) && !(!variant && !files.length && pulled?.storeEmpty.includes(`${lang}/${size}`)); 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. * Play tablets (tab7, tab10) only when `tablets` (resolved play.tablets; unset here = play.tablets === true). */ export function requiredSets(c: { apple?: { ipad?: boolean; iphoneMedium?: boolean } | undefined; play?: unknown; languages?: { id: string; apple?: string; play?: string | string[] }[] }, lang?: string, tablets?: boolean): Record { 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)); const tabs = play && (tablets ?? (c.play as { tablets?: boolean } | undefined)?.tablets === true); return { ios69: apple, ...(c.apple?.iphoneMedium ? { 'iphone-medium': apple } : {}), ipad13: apple && !!c.apple?.ipad, play, feature: play, ...(tabs ? { tab7: true, tab10: true } : {}) }; } /** Whether a language (or variant/language) folder under the renders dir has Play tablet renders (tab7/ or tab10/ with * NN.png|jpg), for the play.tablets default. Reads the folder tree two levels deep. */ export function tabletRendersExist(dir: string): boolean { const sub = (d: string): string[] => { try { return readdirSync(d, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => join(d, e.name)); } catch { return []; } }; const has = (d: string) => PLAY_TABLET_SIZES.some(s => existsSync(join(d, s)) && readdirSync(join(d, s)).some(f => /^\d+\.(png|jpe?g)$/i.test(f))); return sub(dir).some(l => has(l) || sub(l).some(has)); } /** play.tablets for a project, with the default (tablet renders exist under `rendersDir`) filled in. */ export function resolvedTablets(config: { play?: { tablets?: boolean } }, rendersDir: string): boolean { return config.play?.tablets ?? tabletRendersExist(rendersDir); } 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'); };