shots: Mac App Store screenshots (mac size, APP_DESKTOP to the macOS version), a landscape layout (Mac window or bare; Mac, Play Chromebook, Play tablets), safe margins in every template checked by shots check from the render's layout boxes, a contact sheet timeout that scales with images, sizes and load; pitfall 80

This commit is contained in:
CRTkafa 2026-10-20 03:29:19 +07:00
parent 30488c512d
commit e59539fec8
26 changed files with 532 additions and 67 deletions

View file

@ -29,6 +29,8 @@ export interface ImageSpec {
/** 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;
@ -48,10 +50,14 @@ export const IMAGE_SPECS: Record<string, ImageSpec> = {
// 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 },
};
@ -59,7 +65,7 @@ export const IMAGE_SPECS: Record<string, ImageSpec> = {
* 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 = 'the App Store Connect console takes iPhone Duo screenshots, the API does not yet; storebound keeps them in store/renders';
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)';
@ -72,6 +78,11 @@ export function iphoneDuoNote(o: { apple: boolean; duo: boolean; play?: boolean;
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);
@ -111,7 +122,7 @@ export function imageInfo(b: Uint8Array): ImageInfo {
}
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'; msg: string }
export interface Issue { level: IssueLevel; code: 'size' | 'alpha' | 'format' | 'bytes' | 'count' | 'overflow' | 'video' | 'font-fallback' | 'glyph-missing' | 'device-crop' | 'fonts' | 'margin'; msg: string }
/** Problems with one image for a size key. */
export function checkImage(info: ImageInfo, sizeKey: string): Issue[] {
@ -121,11 +132,11 @@ export function checkImage(info: ImageInfo, sizeKey: string): 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 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' ? ' (or landscape)' : '');
: 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' });