storebound/cli/shots/spec.ts

223 lines
16 KiB
TypeScript

// 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;
/** Only the listed sizes as given (Mac: no portrait). */
landscapeOnly?: true;
}
const MB = 1024 * 1024;
/** One entry per render size key (cli/shots/lib.ts SIZES). */
export const IMAGE_SPECS: Record<string, ImageSpec> = {
// 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: 'iPhone with Dynamic Island (medium display)', 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: 'iPad 13" display', sizes: [[2064, 2752], [2048, 2732]], min: 1, max: 10, maxBytes: null, alpha: false },
// iPhone Duo (foldable, Screenshot specifications, October 2026): outer and inner display, portrait or landscape,
// optional (apple.iphoneDuo). `type` is not an ASC display type: see IPHONE_DUO_DISPLAY. No app preview sizes yet.
'iphone-duo-outer': { store: 'apple', type: 'IPHONE_DUO_OUTER', label: 'iPhone Duo (outer display)', sizes: [[1398, 2034]], min: 1, max: 10, maxBytes: null, alpha: false },
'iphone-duo-inner': { store: 'apple', type: 'IPHONE_DUO_INNER', label: 'iPhone Duo (inner display)', sizes: [[2007, 2853]], min: 1, max: 10, maxBytes: null, alpha: false },
// Mac (Screenshot specifications, checked 2026-10-20): 16:10 only, one of these four, landscape, 1-10, no alpha.
mac: { store: 'apple', type: 'APP_DESKTOP', label: 'Mac App Store', sizes: [[2880, 1800], [2560, 1600], [1440, 900], [1280, 800]], min: 1, max: 10, maxBytes: null, alpha: false, landscapeOnly: true },
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.
// Chromebook (Play Console listing): 16:9 or 9:16, 1080-7680 px per side; no API image type (console only)
chromebook: { store: 'play', type: 'chromebookScreenshots', label: 'Play Chromebook', sizes: [[1920, 1080]], min: 0, max: 8, maxBytes: 8 * MB, alpha: false },
feature: { store: 'play', type: 'featureGraphic', label: 'Play feature graphic', sizes: [[1024, 500]], min: 1, max: 1, maxBytes: 15 * MB, alpha: false },
};
/** ASC screenshotDisplayType per iPhone Duo size key. null = NOT IN THE API YET: ScreenshotDisplayType in App Store
* Connect API 4.5.1 (spec re-checked 2026-10-20: the published zip is still the 2026-10-06 one, same sha256) has no Duo value (only AppAssetLibraryDisplayClass lists IPHONE_DUO).
* When Apple adds it: regenerate (bun tools/gen-api.ts) and put the exact enum names here; uploads then include them. */
export const IPHONE_DUO_DISPLAY: Record<string, string | null> = { 'iphone-duo-outer': null, 'iphone-duo-inner': null };
export const IPHONE_DUO_NOT_IN_API = 'not uploaded: the App Store Connect API has no iPhone Duo display type yet (the console takes them); the renders stay in store/renders';
/** Reportedly required from this date for apps built with the iOS 27.1 SDK or later. */
export const IPHONE_DUO_REQUIRED_FROM = '2027-04-01';
const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" / 10" tablet screenshots (tab7, tab10, play.tablets)';
/** check / validate note for an iPhone app without iPhone Duo screenshots: info before IPHONE_DUO_REQUIRED_FROM, a
* warning after (never an error: the rule depends on the SDK the build uses). null when Duo renders exist or no iPhone app. */
export function iphoneDuoNote(o: { apple: boolean; duo: boolean; play?: boolean; today?: Date }): { level: 'info' | 'warn'; msg: string } | null {
if (!o.apple || o.duo) return null;
const late = (o.today ?? new Date()).toISOString().slice(0, 10) >= IPHONE_DUO_REQUIRED_FROM;
return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds; the App Store Connect console takes Duo screenshots and previews, the API does not yet (apple screenshots skips them with the console link)${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
}
/** validate / check note for an app with a macOS version but no Mac screenshots (apple.mac off or no mac renders). */
export function macNote(o: { macVersion: boolean; mac: boolean }): string | null {
return o.macVersion && !o.mac ? 'the app has a macOS version but no Mac screenshots: set apple.mac: true, add landscape frames in <frames>/<lang>/landscape/, storebound shots render, then apple screenshots --platform macos (APP_DESKTOP, 2880x1800 / 2560x1600 / 1440x900 / 1280x800)' : null;
}
/** 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' | 'device-crop' | 'fonts' | 'margin' | 'font-weight' | 'contrast' | 'background'; 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' && !s.landscapeOnly && 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' && !s.landscapeOnly ? ' (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 <renders>/<lang>/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<string, VideoTarget> = {
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<string, unknown>[]; 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;
}