// Store image / video rules as data, plus the pure checks `shots check`, `shots gallery` and `validate` share. // Sources (checked October 2026): // Apple screenshots: App Store Connect Help > Reference > Screenshot specifications // https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications // (13" iPad: 2064 x 2752 or 2048 x 2732 portrait, landscape too, "Required if app runs on iPad"; 1-10 per size; // .jpeg / .jpg / .png, no alpha channels or transparency. iPhone: the page marks "Dynamic Island (medium display)" // 1206 x 2622 as required, filled by scaling from Face ID large (6.5") <- Dynamic Island large (6.9") when absent, // so the 6.9" set stays the default here) // ASC API enum ScreenshotDisplayType (no 13" value; the 13" set is APP_IPAD_PRO_3GEN_129): // https://developer.apple.com/documentation/appstoreconnectapi/screenshotdisplaytype // Apple app previews: App Store Connect Help > Reference > App preview specifications // https://developer.apple.com/help/app-store-connect/reference/app-information/app-preview-specifications // Google Play graphics: Play Console Help "Add preview assets to showcase your app" (answer 9866151) // https://support.google.com/googleplay/android-developer/answer/9866151 // No IO here: callers read the bytes (or ffprobe output) and pass them in. export type Store = 'apple' | 'play'; export interface ImageSpec { store: Store; /** What the store calls it (ASC display type or Play image type). */ type: string; label: string; /** Accepted pixel sizes (portrait first). The renderer writes the first one. */ sizes: [number, number][]; /** Images per set / per type. */ min: number; max: number; /** Max bytes per file (null = the store does not publish one). */ maxBytes: number | null; alpha: false; } const MB = 1024 * 1024; /** One entry per render size key (cli/shots/lib.ts SIZES). */ export const IMAGE_SPECS: Record = { // Apple accepts 1260x2736, 1290x2796 and 1320x2868 for "iPhone with Dynamic Island (large display)" = 6.9". ios69: { store: 'apple', type: 'APP_IPHONE_67', label: 'App Store iPhone 6.9"', sizes: [[1320, 2868], [1290, 2796], [1260, 2736]], min: 1, max: 10, maxBytes: null, alpha: false }, // 6.3" iPhone with Dynamic Island, medium display (iPhone 14 Pro .. 18 Pro): optional here (apple.iphoneMedium). Apple // scales the 6.5" (Face ID large) set down for it when it is missing. APP_IPHONE_61 is inferred: the enum has no // descriptions and 61 is the only medium iPhone bucket (NOT VERIFIED against an upload). 'iphone-medium': { store: 'apple', type: 'APP_IPHONE_61', label: 'App Store iPhone 6.3"', sizes: [[1206, 2622], [1179, 2556]], min: 1, max: 10, maxBytes: null, alpha: false }, ios65: { store: 'apple', type: 'APP_IPHONE_65', label: 'App Store iPhone 6.5"', sizes: [[1284, 2778], [1242, 2688]], min: 1, max: 10, maxBytes: null, alpha: false }, // 13" iPad display (iPad Pro M4/M5, iPad Air M2+): required when the app runs on iPad. API display type keeps the old name. ipad13: { store: 'apple', type: 'APP_IPAD_PRO_3GEN_129', label: 'App Store iPad 13"', sizes: [[2064, 2752], [2048, 2732]], min: 1, max: 10, maxBytes: null, alpha: false }, play: { store: 'play', type: 'phoneScreenshots', label: 'Play phone', sizes: [[1080, 1920]], min: 2, max: 8, maxBytes: 8 * MB, alpha: false }, tab7: { store: 'play', type: 'sevenInchScreenshots', label: 'Play 7" tablet', sizes: [[1080, 1920]], min: 0, max: 8, maxBytes: 8 * MB, alpha: false }, tab10: { store: 'play', type: 'tenInchScreenshots', label: 'Play 10" tablet', sizes: [[1440, 2560]], min: 0, max: 8, maxBytes: 8 * MB, alpha: false }, // Play Console states 1024x500, JPEG or 24-bit PNG (no alpha); the upload dialog caps it at 15 MB. feature: { store: 'play', type: 'featureGraphic', label: 'Play feature graphic', sizes: [[1024, 500]], min: 1, max: 1, maxBytes: 15 * MB, alpha: false }, }; /** Play screenshots in general (any size the store accepts): 320..3840 px per side, long side at most 2x the short. */ export function playScreenshotOk(w: number, h: number): boolean { const lo = Math.min(w, h), hi = Math.max(w, h); return lo >= 320 && hi <= 3840 && hi <= 2 * lo; } /** depth = bits per channel (PNG IHDR bit depth; 8 for JPEG). */ export interface ImageInfo { format: 'png' | 'jpeg' | 'unknown'; w: number; h: number; alpha: boolean; palette: boolean; bytes: number; depth: number } /** Width / height / alpha from the PNG IHDR (+ tRNS) or the JPEG SOF marker. Pure, reads only the buffer. */ export function imageInfo(b: Uint8Array): ImageInfo { const bytes = b.length; const dv = new DataView(b.buffer, b.byteOffset, b.byteLength); if (bytes > 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) { const w = dv.getUint32(16), h = dv.getUint32(20), depth = b[24]!, colorType = b[25]!; let trns = false; for (let p = 8; p + 8 <= bytes;) { const len = dv.getUint32(p), type = String.fromCharCode(b[p + 4]!, b[p + 5]!, b[p + 6]!, b[p + 7]!); if (type === 'tRNS') { trns = true; break; } if (type === 'IDAT' || type === 'IEND') break; p += 12 + len; } return { format: 'png', w, h, alpha: colorType === 4 || colorType === 6 || trns, palette: colorType === 3, bytes, depth }; } if (bytes > 4 && b[0] === 0xff && b[1] === 0xd8) { for (let p = 2; p + 9 < bytes;) { if (b[p] !== 0xff) { p++; continue; } const m = b[p + 1]!; if (m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7)) { p += 2; continue; } const len = dv.getUint16(p + 2); if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { format: 'jpeg', w: dv.getUint16(p + 7), h: dv.getUint16(p + 5), alpha: false, palette: false, bytes, depth: 8 }; p += 2 + len; } } return { format: 'unknown', w: 0, h: 0, alpha: false, palette: false, bytes, depth: 0 }; } export type IssueLevel = 'error' | 'warn'; export interface Issue { level: IssueLevel; code: 'size' | 'alpha' | 'format' | 'bytes' | 'count' | 'overflow' | 'video' | 'font-fallback' | 'glyph-missing' | 'fonts'; msg: string } /** Problems with one image for a size key. */ export function checkImage(info: ImageInfo, sizeKey: string): Issue[] { const s = IMAGE_SPECS[sizeKey]; if (!s) return [{ level: 'error', code: 'size', msg: `unknown size key ${sizeKey}` }]; const out: Issue[] = []; if (info.format === 'unknown') return [{ level: 'error', code: 'format', msg: 'not a PNG or JPEG' }]; // Apple: one of the listed sizes, portrait or landscape. Play screenshots: any size Play accepts (the listed one is // what `shots render` writes). Feature graphic: exactly 1024 x 500. const exact = s.sizes.some(([w, h]) => (w === info.w && h === info.h) || (s.store === 'apple' && w === info.h && h === info.w)); const playShot = s.store === 'play' && s.type !== 'featureGraphic'; if (!exact && !(playShot && playScreenshotOk(info.w, info.h))) { const want = playShot ? `${s.sizes.map(([w, h]) => `${w}x${h}`).join(' or ')} (any 320-3840 px per side, long side at most 2x the short)` : s.sizes.map(([w, h]) => `${w}x${h}`).join(' or ') + (s.store === 'apple' ? ' (or landscape)' : ''); out.push({ level: 'error', code: 'size', msg: `${info.w}x${info.h}, ${s.label} wants ${want}` }); } if (info.alpha) out.push({ level: 'error', code: 'alpha', msg: 'has an alpha channel / transparency (both stores reject it): re-render or flatten to RGB' }); if (info.palette && s.store === 'play') out.push({ level: 'error', code: 'format', msg: 'palette PNG: Play wants JPEG or 24-bit PNG' }); else if (info.format === 'png' && info.depth !== 8 && s.store === 'play') out.push({ level: 'error', code: 'format', msg: `${info.depth}-bit per channel PNG: Play wants JPEG or 24-bit PNG (8 bits per channel)` }); if (s.maxBytes !== null && info.bytes > s.maxBytes) out.push({ level: 'error', code: 'bytes', msg: `${(info.bytes / 1024 / 1024).toFixed(1)} MB > ${s.maxBytes / 1024 / 1024} MB` }); return out; } /** Problems with how many images a set has. `required` = the store needs this set at all. */ export function checkCount(n: number, sizeKey: string, required: boolean): Issue[] { const s = IMAGE_SPECS[sizeKey]!; if (n === 0) return required ? [{ level: 'error', code: 'count', msg: `no ${s.label} images` }] : []; if (n < s.min) return [{ level: 'error', code: 'count', msg: `${n} ${s.label} images, at least ${s.min}` }]; if (n > s.max) return [{ level: s.store === 'apple' ? 'error' : 'warn', code: 'count', msg: `${n} ${s.label} images, at most ${s.max} (${s.store === 'play' ? 'play images uploads the first 8' : 'apple screenshots uploads the first 10'})` }]; return []; } // ---- app previews (Apple) and promo video (Play takes a YouTube URL, no upload) ---- export interface VideoTarget { /** Folder name under //preview/. */ key: string; store: Store; /** ASC preview type for Apple targets. */ previewType?: string; w: number; h: number; label: string; } /** 886 x 1920 is accepted for every Dynamic Island / Face ID iPhone class (6.9", 6.5", 6.1"); 1200 x 1600 for 13" iPad. */ export const VIDEO_TARGETS: Record = { iphone: { key: 'iphone', store: 'apple', previewType: 'IPHONE_67', w: 886, h: 1920, label: 'App Store iPhone app preview (6.9" / 6.5")' }, ipad: { key: 'ipad', store: 'apple', previewType: 'IPAD_PRO_3GEN_129', w: 1200, h: 1600, label: 'App Store iPad 13" app preview' }, youtube: { key: 'youtube', store: 'play', w: 1080, h: 1920, label: 'Play promo video (upload to YouTube, paste the URL)' }, 'youtube-landscape': { key: 'youtube-landscape', store: 'play', w: 1920, h: 1080, label: 'Play promo video, landscape (YouTube)' }, }; export const PREVIEW_RULES = { minSec: 15, maxSec: 30, maxFps: 30, maxBytes: 500 * 1000 * 1000, maxPerSet: 3 } as const; export interface ProbeInfo { bytes: number; duration: number; video?: { codec: string; w: number; h: number; fps: number; profile?: string }; audio?: { codec: string; channels: number; sampleRate: number }; } /** ffprobe -show_streams -show_format JSON -> ProbeInfo. */ export function parseProbe(json: string, bytes: number): ProbeInfo { const j = JSON.parse(json) as { streams?: Record[]; format?: { duration?: string } }; const v = j.streams?.find(s => s.codec_type === 'video'); const a = j.streams?.find(s => s.codec_type === 'audio'); const rate = (r: unknown) => { const [n, d] = String(r ?? '0/1').split('/').map(Number); return d ? n! / d : n!; }; return { bytes, duration: Number(j.format?.duration ?? 0), video: v ? { codec: String(v.codec_name), w: Number(v.width), h: Number(v.height), fps: rate(v.avg_frame_rate ?? v.r_frame_rate), profile: v.profile as string | undefined } : undefined, audio: a ? { codec: String(a.codec_name), channels: Number(a.channels), sampleRate: Number(a.sample_rate) } : undefined, }; } /** Problems with an app preview / promo video file for a target. Apple rules apply to Apple targets only. */ export function checkVideo(p: ProbeInfo, targetKey: string): Issue[] { const t = VIDEO_TARGETS[targetKey]; if (!t) return [{ level: 'error', code: 'video', msg: `unknown video target ${targetKey}` }]; const e: Issue[] = []; const err = (msg: string) => e.push({ level: 'error', code: 'video', msg }); if (!p.video) { err('no video stream'); return e; } if (p.video.w !== t.w || p.video.h !== t.h) err(`${p.video.w}x${p.video.h}, ${t.label} wants ${t.w}x${t.h}`); if (t.store === 'apple') { if (p.video.codec !== 'h264') err(`video codec ${p.video.codec}, Apple wants H.264 (or ProRes 422 HQ)`); if (p.video.fps > PREVIEW_RULES.maxFps + 0.01) err(`${p.video.fps.toFixed(2)} fps > 30`); if (p.duration < PREVIEW_RULES.minSec - 0.05 || p.duration > PREVIEW_RULES.maxSec + 0.05) err(`${p.duration.toFixed(1)} s, app previews are 15-30 s`); if (p.bytes > PREVIEW_RULES.maxBytes) err(`${(p.bytes / 1e6).toFixed(0)} MB > 500 MB`); if (!p.audio) err('no audio track: Apple wants a stereo audio track (a silent one is fine)'); else { if (p.audio.channels !== 2) err(`audio has ${p.audio.channels} channel(s), Apple wants stereo`); if (p.audio.codec !== 'aac' && !p.audio.codec.startsWith('pcm')) err(`audio codec ${p.audio.codec}, Apple wants AAC (or PCM with ProRes)`); if (![44100, 48000].includes(p.audio.sampleRate)) err(`audio ${p.audio.sampleRate} Hz, Apple wants 44.1 or 48 kHz`); } } return e; }