apple creative-assets: App Store Header and Search Results (PRODUCT_PAGE_HEADER_ASSET, APP_STORE_SEARCH_RESULTS_ASSET) through the App Asset Library: local size / alpha / video checks, upload once by MD5, select only approved assets, replace on a changed file; check / validate note, MCP tool, gallery and desktop Art row, docs, pitfall 75

This commit is contained in:
CRTkafa 2026-10-18 13:09:38 +07:00
parent 14ffe3545a
commit e5369090fa
17 changed files with 486 additions and 16 deletions

108
cli/shots/creative.ts Normal file
View file

@ -0,0 +1,108 @@
// 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}; 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: own header / search file first, else 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');
const own = list(dir).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))!)
: list(join(renders, 'creative')).some(f => f === 'universal.png') ? join(renders, 'creative', 'universal.png') : undefined;
const out: CreativeFile[] = [];
for (const p of ['header', 'search'] as Placement[]) {
const f = own.find(x => x.toLowerCase().startsWith(`${p}.`));
if (f) out.push({ placement: p, file: join(dir, f), kind: kindOf(f), 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;
}