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:
parent
14ffe3545a
commit
e5369090fa
17 changed files with 486 additions and 16 deletions
108
cli/shots/creative.ts
Normal file
108
cli/shots/creative.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -6,13 +6,14 @@
|
|||
// `shots check`: size, alpha, bytes, counts, text overflow from report.json, video rules) are outlined in red (error)
|
||||
// or amber (warning) with the message under the tile. Images are linked by relative path, so the page stays small and
|
||||
// always shows the current files; move it together with the renders folder.
|
||||
import { existsSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { SIZES } from './lib';
|
||||
import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders } from './report';
|
||||
import { type Issue, IMAGE_SPECS, VIDEO_TARGETS } from './spec';
|
||||
import { allIssues, ffprobe, requiredSets, type RendersScan, resolvedTablets, scanRenders } from './report';
|
||||
import { type Issue, IMAGE_SPECS, imageInfo, VIDEO_TARGETS } from './spec';
|
||||
import { creativeRows } from './creative';
|
||||
|
||||
export interface GalleryTile {
|
||||
/** Path relative to the gallery file. */
|
||||
|
|
@ -245,6 +246,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const scan = scanRenders(dir, { langs, variants, required: lang => requiredSets(config, lang, tablets) });
|
||||
if (!scan.images.length && !scan.videos.length) warn('shots', `no renders in ${dir} (storebound shots render)`);
|
||||
const data = galleryData(scan, { at: join(out, '..'), title: `${config.app.name} · store renders`, exists: existsSync });
|
||||
// App Store Header and Search Results assets (<lang>/creative/), base variant only
|
||||
if (config.apple) for (const [lang, row] of creativeRows(dir, langs, f => relative(join(out, '..'), f).split('\\').join('/'), ffprobe, f => readFileSync(f), imageInfo)) {
|
||||
const base = data.variants[0]!;
|
||||
let l = base.langs.find(x => x.lang === lang);
|
||||
if (!l) base.langs.push(l = { lang, rows: [] });
|
||||
l.rows.push(row);
|
||||
}
|
||||
// --json: the data only (the desktop gallery), with the renders folder relative to the app folder; writes nothing
|
||||
if (a.has('json')) { printJson({ ...data, dir: relative(ctx.loaded.root, dir).split('\\').join('/') || '.' }); return; }
|
||||
if (a.has('dry')) { log('shots', `would write ${out}: ${data.totals.images} images, ${data.totals.videos} videos, ${data.totals.errors} errors`); return; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue