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.
123 lines
8.1 KiB
TypeScript
123 lines
8.1 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 { PLAY_TABLET_SIZES, 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.
|
|
* 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<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, 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');
|
|
};
|