113 lines
8.3 KiB
TypeScript
113 lines
8.3 KiB
TypeScript
// 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: <renders>/<language id>/creative/{header,search}.{png,jpg,jpeg,mov,m4v,mp4}, else the same names in
|
|
// <renders>/creative/ for every locale; universal fallback <renders>/<language id>/creative/universal.png or
|
|
// <renders>/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<Placement, string> = { header: 'PRODUCT_PAGE_HEADER_ASSET', search: 'APP_STORE_SEARCH_RESULTS_ASSET' };
|
|
export const PLACEMENT_LABEL: Record<Placement, string> = { 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 (<renders>/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<string, { size: string; label: string; store: 'apple'; tiles: { src: string; kind: 'image' | 'video'; name: string; w: number; h: number; bytes: number; issues: Issue[] }[]; issues: Issue[] }> {
|
|
const out = new Map<string, ReturnType<typeof creativeRows> extends Map<string, infer V> ? 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;
|
|
}
|