storebound/cli/shots/report.ts

162 lines
11 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 { boxOutside, type DeviceBox, marginViolations, PLAY_TABLET_SIZES, SIZES, type TextBox } from './lib';
import { fontIssues, type FontUsage } from './glyphs';
import { type ContrastSample, contrastIssues } from './palette';
import { type WeightSample, weightIssues } from './weights';
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';
/** <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;
/** Device frames' bounding boxes (the rotated outline, from the page's layout) in image px; bleed = cropped on purpose. */
devices?: DeviceBox[]; bleed?: boolean;
/** Text and logo boxes in image px (from the page's layout), for the safe-margin rule (lib.ts marginViolations). */
boxes?: TextBox[];
/** Title weight asked vs. served (weights.ts), and title ink contrast over the pixels behind it (palette.ts). */
weights?: WeightSample[]; contrast?: ContrastSample[] }>;
/** Text or logo closer to an edge than the safe margin: an error (stores crop and round corners; it reads cramped). */
export function marginIssues(entry: RenderReport[string] | undefined, W: number, H: number): Issue[] {
return marginViolations(entry?.boxes ?? [], W, H).map(msg => ({ level: 'error' as const, code: 'margin' as const, msg: `${msg}: render again with the current templates, or shorten the copy / shrink the logo` }));
}
/** 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/<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);
// 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));
issues.push(...marginIssues(entry, info.w, info.h));
issues.push(...weightIssues(entry?.weights), ...contrastIssues(entry?.contrast));
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; mac?: boolean } | undefined; play?: unknown; languages?: { id: string; apple?: string; play?: string | string[] }[] },
lang?: string, tablets?: boolean): 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));
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, ...(c.apple?.mac ? { mac: apple } : {}), 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');
};