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

View file

@ -0,0 +1,134 @@
// App Store "Header and Search Results" creative assets per locale of the editable App Store version:
// storebound apple creative-assets [--src store/renders] [--only en-US,tr] [--dry]
// Files (cli/shots/creative.ts): <renders>/<lang>/creative/header.{png,jpg,mov,m4v,mp4} (3840x1646, 21:9),
// search.{...} (3:2, 1920x1280 to 3840x2560), or one universal.png (5244x2950) for both slots. apple.creativeAssets
// can turn a placement off (header: false / search: false) or point at another universal file.
// Flow, as App Store Connect works (Header and Search Results tab > Header / Search Results > Browse Assets): a file is
// uploaded once into the app's Asset Library (POST appAssetLibraryImages / Videos, category CREATIVE_ASSETS, its MD5
// in referenceName "storebound <md5>" because library assets carry no checksum), Apple reviews it, and only an
// APPROVED asset is selected for a slot (POST appAssetLibraryPlacements with placementType PRODUCT_PAGE_HEADER_ASSET or
// APP_STORE_SEARCH_RESULTS_ASSET and the version localization). Upsert: a slot that already shows the same MD5 is left
// alone; a new file replaces the slot only once it is approved (the old placement is removed, then the new one is
// created); an asset with the same MD5 already in the library is reused, never uploaded twice. Read back at the end.
// Play has no such slot: the feature graphic (play images) and the promo video URL are its closest equivalents.
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, warn } from '../lib/log';
import { ffprobe } from '../shots/report';
import { imageInfo } from '../shots/spec';
import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative';
import { ascClient, md5, type J } from './asc';
export const REF = (sum: string): string => `storebound ${sum}`;
const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'wait' | 'select' | 'replace'; state?: string }
/** Local files per App Store locale, checked; throws on any error before a network call. */
export function localCreative(ctx: Ctx): { src: string; local: Map<string, CreativeFile[]> } {
const { config, resolve } = ctx.loaded;
const a = ctx.args;
const src = resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders');
const only = a.list('only');
const ca = config.apple?.creativeAssets ?? {};
const local = new Map<string, CreativeFile[]>();
let bad = 0;
for (const l of config.languages) {
if (!l.apple || (only.length && !only.includes(l.apple))) continue;
let fs = creativeFiles(src, l.id);
if (ca.universal && !fs.length) fs = (['header', 'search'] as Placement[]).map(p => ({ placement: p, file: resolve(ca.universal!), kind: 'image' as const, universal: true }));
fs = fs.filter(f => ca[f.placement] !== false);
for (const f of fs) {
const issues = f.kind === 'image' ? checkCreativeImage(imageInfo(readFileSync(f.file)), f.placement, f.universal) : checkCreativeVideo(f.file, ffprobe(f.file), f.placement);
for (const i of issues) (i.level === 'error' ? warn : log)('apple', `${l.apple} ${f.placement} ${basename(f.file)}: ${i.msg}`);
if (issues.some(i => i.level === 'error')) bad++;
}
if (fs.length) local.set(l.apple, fs);
}
if (bad) throw new Error(`${bad} creative asset problems: fix them first (sizes in docs/store-art.md)`);
return { src, local };
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const dry = ctx.args.has('dry');
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
const { src, local } = localCreative(ctx);
if (!local.size) { warn('apple', `no creative assets in ${src}/<lang>/creative/ (header.png 3840x1646, search.png 3:2, or universal.png 5244x2950): optional; without them the App Store uses events, previews and screenshots`); return; }
log('apple', `${[...local.values()].flat().length} creative assets for ${local.size} locales pass the checks`);
let c: Awaited<ReturnType<typeof ascClient>>;
try { c = await ascClient(root, bundleId, config.apple?.appId); }
catch (e) {
if (!dry) throw e;
for (const [loc, fs] of local) for (const f of fs) log('apple', `${loc} ${PLACEMENT_TYPE[f.placement]}: would upload ${basename(f.file)} to the Asset Library and select it once approved`);
return done('apple', `creative assets planned (dry run, no App Store Connect comparison: ${(e as Error).message.split('\n')[0]})`);
}
const lib = (await c.asc('GET', `/v1/apps/${await c.app()}/assetLibrary`)).data as J;
const version = await c.editableVersion();
if (!version) throw new Error('no editable App Store version (run apple listing --version x.y.z first)');
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
const library = async (): Promise<J[]> => [
...(await c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=CREATIVE_ASSETS&limit=200`)),
...(await c.all(`/v1/appAssetLibraries/${lib.id}/videos?filter[category]=CREATIVE_ASSETS&limit=200`)),
];
let assets = await library();
const bySum = (s: string) => assets.filter(x => sumOf(x) === s && x.attributes?.state !== 'ARCHIVED' && x.attributes?.state !== 'FAILED' && x.attributes?.state !== 'REJECTED')[0];
const rows: Row[] = [];
async function upload(f: CreativeFile, sum: string): Promise<J> {
const buf = readFileSync(f.file);
const type = f.kind === 'image' ? 'appAssetLibraryImages' : 'appAssetLibraryVideos';
const res = (await c.asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: basename(f.file), fileSize: buf.length, category: 'CREATIVE_ASSETS', referenceName: REF(sum) },
relationships: { assetLibrary: { data: { type: 'appAssetLibraries', id: lib.id } } } } })).data as J;
for (const op of (res.attributes.uploadOperations ?? []) as J[]) {
const headers: Record<string, string> = {};
for (const h of op.requestHeaders ?? []) headers[h.name] = h.value;
const r = await fetch(op.url, { method: op.method, headers, body: buf.subarray(op.offset, op.offset + op.length) });
if (!r.ok) throw new Error(`upload ${basename(f.file)} part ${op.offset} -> ${r.status}`);
}
return (await c.asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true } } })).data as J;
}
for (const loc of locs) {
const locale: string = loc.attributes.locale;
for (const f of local.get(locale) ?? []) {
const ptype = PLACEMENT_TYPE[f.placement];
const tag = `${locale} ${ptype}`;
const sum = md5(readFileSync(f.file));
const placed = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/placements?filter[placementType]=${ptype}&include=image,video`);
const shownIds = placed.map(p => p.relationships?.image?.data?.id ?? p.relationships?.video?.data?.id).filter(Boolean);
if (placed.length === 1 && assets.some(x => shownIds.includes(x.id) && sumOf(x) === sum)) { log('apple', `${tag}: ${basename(f.file)} already selected`); rows.push({ locale, placement: f.placement, file: f.file, action: 'keep' }); continue; }
let asset = bySum(sum);
if (!asset) {
if (dry) { log('apple', `${tag}: would upload ${basename(f.file)} to the Asset Library (Apple approves it, then it is selected)`); rows.push({ locale, placement: f.placement, file: f.file, action: 'upload' }); continue; }
log('apple', `${tag}: uploading ${basename(f.file)} to the Asset Library`);
asset = await upload(f, sum);
assets = await library();
asset = bySum(sum) ?? asset;
}
const state = String(asset.attributes?.state ?? '');
if (state !== 'APPROVED') { log('apple', `${tag}: ${basename(f.file)} waiting for Apple's approval (${state || 'processing'}); re-run once it is approved to select it`); rows.push({ locale, placement: f.placement, file: f.file, action: 'wait', state }); continue; }
const action = placed.length ? 'replace' : 'select';
if (dry) { log('apple', `${tag}: would ${action === 'replace' ? `replace ${placed.length} selected asset(s) with` : 'select'} ${basename(f.file)}`); rows.push({ locale, placement: f.placement, file: f.file, action }); continue; }
for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`);
const media = f.kind === 'image' ? 'image' : 'video';
await c.asc('POST', '/v1/appAssetLibraryPlacements', { data: { type: 'appAssetLibraryPlacements', attributes: { placementType: ptype },
relationships: { [media]: { data: { type: asset.type, id: asset.id } }, appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: loc.id } } } } });
log('apple', `${tag}: selected ${basename(f.file)}`);
rows.push({ locale, placement: f.placement, file: f.file, action });
}
}
if (dry) return done('apple', 'creative assets checked (dry run, nothing sent)');
// read back
let off = 0;
for (const r of rows.filter(r => r.action === 'select' || r.action === 'replace')) {
const loc = locs.find(l => l.attributes.locale === r.locale)!;
const now = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/placements?filter[placementType]=${PLACEMENT_TYPE[r.placement]}`);
if (now.length !== 1) { off++; warn('apple', `${r.locale} ${PLACEMENT_TYPE[r.placement]}: read back ${now.length} selected assets, wanted 1`); }
}
const waiting = rows.filter(r => r.action === 'wait').length;
done('apple', `creative assets: ${rows.filter(r => r.action === 'keep').length} up to date, ${rows.filter(r => r.action === 'select' || r.action === 'replace').length} selected, ${waiting} waiting for approval${off ? `, ${off} did not read back` : ''}`);
}