// App Store creative assets for "Header and Search Results" (App Store Connect, since 5 Oct 2026, shown on iOS / iPadOS // 27+): the product page header (21:9) and the search results asset (3:2), image or video, per locale, with one // universal 16:9 image (5244 x 2950) that can fill either slot. Apple's Creative assets specifications: // https://developer.apple.com/help/app-store-connect/reference/app-information/creative-assets-specifications/ // Files: //creative/{header,search}.{png,jpg,jpeg,mov,m4v,mp4}, else the same names in // /creative/ for every locale; universal fallback //creative/universal.png or // /creative/universal.png (every locale). // API: App Asset Library (placement types PRODUCT_PAGE_HEADER_ASSET and APP_STORE_SEARCH_RESULTS_ASSET); see // cli/apple/creative-assets.ts. Play has no such slot: the feature graphic (1024 x 500) and the promo video are its // closest equivalents. import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import type { ImageInfo, Issue, ProbeInfo } from './spec'; export type Placement = 'header' | 'search'; export const PLACEMENT_TYPE: Record = { header: 'PRODUCT_PAGE_HEADER_ASSET', search: 'APP_STORE_SEARCH_RESULTS_ASSET' }; export const PLACEMENT_LABEL: Record = { header: 'product page header (21:9)', search: 'search results asset (3:2)' }; export const CREATIVE_RULES = { header: { w: 3840, h: 1646, ratio: 21 / 9 }, search: { minW: 1920, minH: 1280, maxW: 3840, maxH: 2560, ratio: 3 / 2 }, universal: { w: 5244, h: 2950 }, video: { minSec: 5, maxSec: 30, fps: [30, 60], ext: /\.(mov|m4v|mp4)$/i }, } as const; export const CREATIVE_NOTE = 'App Store "Header and Search Results" (iOS / iPadOS 27+) has no header or search results asset: optional; without it the App Store uses in-app events, app previews and screenshots (storebound apple creative-assets; Play: the feature graphic 1024x500 and the promo video are the closest equivalents)'; export interface CreativeFile { placement: Placement; file: string; kind: 'image' | 'video'; universal: boolean } const EXT = /\.(png|jpe?g|mov|m4v|mp4)$/i; const kindOf = (f: string): 'image' | 'video' => (CREATIVE_RULES.video.ext.test(f) ? 'video' : 'image'); /** The file for each placement of one language: its own header / search file first, then a shared one for every * language (/creative/header.png: art without words needs no copy per locale), then the universal image. */ export function creativeFiles(renders: string, lang: string, list: (d: string) => string[] = d => (existsSync(d) ? readdirSync(d) : [])): CreativeFile[] { const dir = join(renders, lang, 'creative'), shared = join(renders, 'creative'); const own = list(dir).filter(f => EXT.test(f)).sort(); const common = list(shared).filter(f => EXT.test(f)).sort(); const uni = own.find(f => /^universal\.png$/i.test(f)) ? join(dir, own.find(f => /^universal\.png$/i.test(f))!) : common.includes('universal.png') ? join(shared, 'universal.png') : undefined; const out: CreativeFile[] = []; for (const p of ['header', 'search'] as Placement[]) { const f = own.find(x => x.toLowerCase().startsWith(`${p}.`)); const g = common.find(x => x.toLowerCase().startsWith(`${p}.`)); if (f) out.push({ placement: p, file: join(dir, f), kind: kindOf(f), universal: false }); else if (g) out.push({ placement: p, file: join(shared, g), kind: kindOf(g), universal: false }); else if (uni) out.push({ placement: p, file: uni, kind: 'image', universal: true }); } return out; } const close = (a: number, b: number) => Math.abs(a - b) / b < 0.01; /** Problems with a creative image (JPEG / PNG, no alpha, size per placement or the universal 5244 x 2950). */ export function checkCreativeImage(info: ImageInfo, placement: Placement, universal = false): Issue[] { const out: Issue[] = []; if (info.format === 'unknown') return [{ level: 'error', code: 'format', msg: 'not a PNG or JPEG' }]; if (info.alpha) out.push({ level: 'error', code: 'alpha', msg: 'has an alpha channel / transparency (Apple rejects it): flatten to RGB' }); const { w, h } = info; if (universal) { if (info.format !== 'png') out.push({ level: 'error', code: 'format', msg: 'the universal asset is a PNG' }); if (w !== CREATIVE_RULES.universal.w || h !== CREATIVE_RULES.universal.h) out.push({ level: 'error', code: 'size', msg: `${w}x${h}, the universal asset is 5244x2950 (16:9)` }); } else if (placement === 'header') { if (w !== CREATIVE_RULES.header.w || h !== CREATIVE_RULES.header.h) out.push({ level: 'error', code: 'size', msg: `${w}x${h}, the product page header is 3840x1646 (21:9)` }); } else { const s = CREATIVE_RULES.search; if (w < s.minW || h < s.minH || w > s.maxW || h > s.maxH || w * 2 !== h * 3) out.push({ level: 'error', code: 'size', msg: `${w}x${h}, the search results asset is 3:2 from 1920x1280 to 3840x2560` }); } return out; } /** Problems with a creative video (5-30 s, 30 or 60 fps, .mov / .m4v / .mp4, the placement's aspect ratio). */ export function checkCreativeVideo(file: string, p: ProbeInfo | null, placement: Placement): Issue[] { const out: Issue[] = []; const err = (msg: string) => out.push({ level: 'error', code: 'video', msg }); if (!CREATIVE_RULES.video.ext.test(file)) err('container: .mov, .m4v or .mp4'); if (!p) return [...out, { level: 'warn', code: 'video', msg: 'length, frame rate and size not checked (ffprobe missing: brew install ffmpeg)' }]; if (!p.video) { err('no video stream'); return out; } const ratio = placement === 'header' ? CREATIVE_RULES.header.ratio : CREATIVE_RULES.search.ratio; if (!close(p.video.w / p.video.h, ratio)) err(`${p.video.w}x${p.video.h}, the ${PLACEMENT_LABEL[placement]} video has the same aspect ratio as the image`); if (p.duration < CREATIVE_RULES.video.minSec - 0.05 || p.duration > CREATIVE_RULES.video.maxSec + 0.05) err(`${p.duration.toFixed(1)} s, creative videos are 5-30 s`); if (!CREATIVE_RULES.video.fps.some(f => Math.abs(p.video!.fps - f) < 0.05)) err(`${p.video.fps.toFixed(2)} fps, creative videos are 30 or 60 fps`); if (placement === 'search' && p.audio) out.push({ level: 'warn', code: 'video', msg: 'has an audio track: search results videos play muted' }); return out; } /** check / validate: the info note when an App Store app has no header or search results asset in any locale. */ export function creativeNote(config: { apple?: { creativeAssets?: { header?: boolean; search?: boolean; universal?: string } }; languages: { id: string; apple?: string }[] }, renders: string, list?: (d: string) => string[]): string | null { if (!config.apple) return null; const ca = config.apple.creativeAssets ?? {}; if (ca.header === false && ca.search === false) return null; if (ca.universal) return null; const has = config.languages.filter(l => l.apple).some(l => creativeFiles(renders, l.id, list).length); return has ? null : CREATIVE_NOTE; } /** Gallery / desktop Art rows: one "creative" row per language with its header / search files and their issues. */ export function creativeRows(renders: string, langs: string[], rel: (f: string) => string, probe: (f: string) => ProbeInfo | null, read: (f: string) => Uint8Array, info: (b: Uint8Array) => ImageInfo): Map { const out = new Map extends Map ? V : never>(); for (const lang of langs) { const fs = creativeFiles(renders, lang); if (!fs.length) continue; const tiles = fs.map(f => { if (f.kind === 'image') { const i = info(read(f.file)); return { src: rel(f.file), kind: 'image' as const, name: `${f.placement}: ${f.file.split(/[\\/]/).pop()}`, w: i.w, h: i.h, bytes: i.bytes, issues: checkCreativeImage(i, f.placement, f.universal) }; } const p = probe(f.file); return { src: rel(f.file), kind: 'video' as const, name: `${f.placement}: ${f.file.split(/[\\/]/).pop()}`, w: p?.video?.w ?? 0, h: p?.video?.h ?? 0, bytes: p?.bytes ?? 0, issues: checkCreativeVideo(f.file, p, f.placement) }; }); out.set(lang, { size: 'creative', label: 'App Store header and search results', store: 'apple', tiles, issues: [] }); } return out; }