// Local store checks of the renders folder, before any upload: // storebound shots check [--lang en,tr] [--variants] [--json] // Per image: pixel size for its store type (Apple: a listed size, portrait or landscape; Play screenshots: 320-3840 px, // at most 2:1; feature graphic exactly 1024x500), no alpha (both stores), Play: JPEG or 24-bit PNG, 8 MB per screenshot. // --variants also checks the A/B variant folders (///...). // Per set: Play 2-8 phone screenshots, Apple 1-10 per display type, 13" iPad set when apple.ipad, feature graphic, // Play 7" / 10" tablet sets only when play.tablets (default: tablet renders exist; optional on Play). // Per app preview: 886x1920 / 1200x1600, H.264, <= 30 fps, 15-30 s, stereo AAC track, <= 500 MB, <= 3 per set. // Text overflow flags come from the last `shots render` (report.json). Frames: with capture.source "folder" every frame // the scenes use must exist with the right aspect. Local font files (shots.fonts paths) without a license note: warn. // Exit 1 on any error (warnings do not fail). import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { ShipConfig } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { log, printJson } from '../lib/log'; import { checkFrames, type FrameIssue } from './devices'; import { defaultSizes, frameDims, renderDims, SIZES } from './lib'; import type { LocalIssue, RendersCheck } from '../lib/validate-rules'; import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders, storeOf } from './report'; import { imageInfo } from './spec'; import { fontsDirOf, readManifest } from './fonts'; import { localFontIssues } from './local-fonts'; import { photoBackground, photoIssues } from './palette'; import { palette } from './lib'; import { shotsStyle } from '../lib/config'; import type { Issue } from './spec'; /** Photo backgrounds (palette.image, shots.feature.background): the file exists and is at least as big as every * output it covers (the feature graphic 1024 x 500; screenshots at the sizes this project renders). */ export function backgroundIssues(config: ShipConfig, resolve: (p: string) => string, tablets?: boolean): { where: string; issue: Issue }[] { const shots = config.shots; if (!shots) return []; const style = shotsStyle(config), sizes = defaultSizes(config, tablets); const out: { where: string; issue: Issue }[] = []; const one = (file: string, where: string, targets: string[]): void => { const abs = resolve(file); let info: { w: number; h: number } | null = null; try { const i = imageInfo(readFileSync(abs)); info = i.format === 'unknown' ? null : i; } catch { /* missing */ } for (const t of targets) for (const x of photoIssues(info, file, SIZES[t]!.w, SIZES[t]!.h)) out.push({ where, issue: { level: x.level, code: 'background', msg: x.msg } }); }; const screens = sizes.filter(s => !SIZES[s]!.feature); // the biggest screenshot size decides; one message per file const biggest = screens.sort((a, b) => SIZES[b]!.w * SIZES[b]!.h - SIZES[a]!.w * SIZES[a]!.h).slice(0, 1); for (const sc of shots.scenes) { let p; try { p = palette(sc.palette, style); } catch (e) { out.push({ where: `shots.scenes[${sc.id}].palette`, issue: { level: 'error', code: 'background', msg: (e as Error).message } }); continue; } const ph = photoBackground(p); if (ph) one(ph.image, `shots.scenes[${sc.id}].palette.image`, biggest); } if (sizes.includes('feature')) { let p; try { p = palette(shots.feature?.palette ?? shots.scenes[0]?.palette, style); } catch { p = null; } const ph = p && photoBackground(p, shots.feature?.background); if (ph) one(ph.image, shots.feature?.background ? 'shots.feature.background' : 'shots.feature palette.image', ['feature']); } return out; } /** Frame files the scenes (and the feature graphic) use, per language and device class. */ export function framesNeeded(config: ShipConfig, langs: string[], tablets?: boolean): { lang: string; device: 'phone' | 'tablet'; name: string }[] { const shots = config.shots!; const sizes = defaultSizes(config, tablets); const devices: ('phone' | 'tablet')[] = ['phone', ...(sizes.some(s => SIZES[s]!.device === 'tablet') ? ['tablet' as const] : [])]; const scene = [...new Set(shots.scenes.flatMap(s => s.frames.map(f => f.src)))]; const phone = [...new Set([...scene, ...(shots.feature?.frame && sizes.includes('feature') ? [shots.feature.frame] : [])])]; return langs.flatMap(lang => devices.flatMap(device => (device === 'phone' ? phone : scene).map(name => ({ lang, device, name })))); } /** shots.layout 'bleed': one frame per scene for every size, at that size's pixels (//; the mac and * chromebook sizes too), and the feature frame in /feature/. Each must have the size's aspect (1 %) and at least * its width (no upscaling). */ export function checkBleedFrames(config: ShipConfig, dir: string, langs: string[], tablets?: boolean): FrameIssue[] { const shots = config.shots!; const out: FrameIssue[] = []; const names = [...new Set(shots.scenes.map(s => s.frames[0]!.src))]; for (const lang of langs) for (const size of defaultSizes(config, tablets)) { const feature = !!SIZES[size]!.feature; const want = renderDims(size, shots); for (const name of feature ? (shots.feature?.frame ? [shots.feature.frame] : []) : names) { const rel = `${lang}/${feature ? 'feature' : size}/${name}`, f = join(dir, rel); if (!existsSync(f)) { out.push({ file: rel, msg: 'missing' }); continue; } const i = imageInfo(readFileSync(f)); if (Math.abs((i.w / i.h) / (want.w / want.h) - 1) > 0.01) out.push({ file: rel, msg: `${i.w}x${i.h} has aspect ${(i.w / i.h).toFixed(3)}, ${size} wants ${(want.w / want.h).toFixed(3)} (${want.w}x${want.h}): capture at that size` }); else if (i.w < want.w * 0.99) out.push({ file: rel, msg: `${i.w}x${i.h} is smaller than ${want.w}x${want.h}: capture at native resolution (no upscaling)` }); } } return out; } export function checkFrameFolder(config: ShipConfig, dir: string, langs: string[], tablets?: boolean): FrameIssue[] { if (config.shots?.layout === 'bleed') return checkBleedFrames(config, dir, langs, tablets); const found = new Map(); for (const lang of langs) for (const device of ['phone', 'tablet']) { const d = join(dir, lang, device); if (!existsSync(d)) continue; for (const f of readdirSync(d).filter(x => /\.png$/i.test(x))) { const i = imageInfo(readFileSync(join(d, f))); found.set(`${lang}/${device}/${f}`, i); } } return checkFrames(found, framesNeeded(config, langs, tablets), frameDims(config)); } export function scanProject(ctx: Ctx): RendersScan { const { config, resolve } = ctx.loaded; const langs = ctx.args.list('lang').length ? ctx.args.list('lang') : config.languages.map(l => l.id); const dir = resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'); const tablets = resolvedTablets(config, dir); return scanRenders(dir, { langs, variants: ctx.args.has('variants') ? Object.keys(config.shots?.variants ?? {}) : [], required: lang => requiredSets(config, lang, tablets) }); } /** Local renders check split per store, for `validate` (base renders only, no variants). null = no renders folder. */ export function rendersCheck(config: ShipConfig, dir: string): Record<'apple' | 'play', RendersCheck> | null { if (!existsSync(dir)) return null; const langs = config.languages.map(l => l.id); const tablets = resolvedTablets(config, dir); const scan = scanRenders(dir, { langs, required: lang => requiredSets(config, lang, tablets) }); const out = { apple: { dir, images: 0, issues: [] as LocalIssue[] }, play: { dir, images: 0, issues: [] as LocalIssue[] } }; for (const img of scan.images) out[storeOf(img.rel)].images++; for (const v of scan.videos) out[storeOf(v.rel)].images++; for (const { where, issue } of allIssues(scan)) out[storeOf(where)].issues.push({ where, level: issue.level, code: issue.code, msg: issue.msg }); return out; } export async function run(ctx: Ctx): Promise { const { config, resolve, root } = ctx.loaded; const scan = scanProject(ctx); const cap = config.shots?.capture; const tablets = resolvedTablets(config, resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders')); const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue })), ...backgroundIssues(config, resolve, tablets)]; const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs, tablets) : []; if (ctx.args.has('json')) { printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues }); } else { for (const { where, issue } of issues) console.log(`${issue.level === 'error' ? 'ERROR' : 'warn '} ${where}: ${issue.msg}`); for (const f of frameIssues) console.log(`ERROR frames/${f.file}: ${f.msg}`); } const errors = issues.filter(i => i.issue.level === 'error').length + frameIssues.length; log('shots', `checked ${scan.images.length} images, ${scan.videos.length} videos, ${scan.sets.length} sets: ${errors} errors, ${issues.length - issues.filter(i => i.issue.level === 'error').length} warnings`); if (errors) process.exitCode = 1; }