288 lines
22 KiB
TypeScript
288 lines
22 KiB
TypeScript
// 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] upload + select (upsert)
|
|
// storebound apple creative-assets --add-for-review [--dry] also put unreviewed assets into the draft submission
|
|
// storebound apple creative-assets --verify read-only: does every slot show the local file?
|
|
// 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 (version page > Product Page Information > Header and Search Results > Header /
|
|
// Search Results > Browse Assets > Save; Help "Manage your App Store assets", "App Store asset statuses",
|
|
// "Submit assets from Asset Library", checked 19 Oct 2026):
|
|
// 1. 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);
|
|
// 2. after processing it is "Prepare for Submission"; App Review reviews it as a review submission item of its own
|
|
// (reviewSubmissionItems appAssetLibraryImage / appAssetLibraryVideo; console "Add for Review"): before the app's
|
|
// first approved version in the same submission as that version, afterwards alone. --add-for-review adds it to
|
|
// the draft submission; pressing Submit for Review stays the owner's;
|
|
// 3. the version page's slots offer only approved assets ("Browse approved assets", seen in a real release, 20 Oct
|
|
// 2026): an APPROVED asset is selected (POST appAssetLibraryPlacements, placementType PRODUCT_PAGE_HEADER_ASSET /
|
|
// APP_STORE_SEARCH_RESULTS_ASSET, the version localization); any other one is reported per locale and placement
|
|
// as "uploaded <date>, waiting for Apple's approval", and a later run places it (pitfall 76).
|
|
// Upsert: a slot that already shows the same MD5 is left alone; an asset with the same MD5 in the library is reused
|
|
// (an archived one is unarchived), never uploaded twice; a REJECTED file is not uploaded again (change the file); a
|
|
// changed file replaces the slot (the old placement is removed, then the new one is created). 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 { consoleLink } from '../lib/console-links';
|
|
import { done, log, warn } from '../lib/log';
|
|
import { elapsed } from '../lib/progress';
|
|
import { ffprobe } from '../shots/report';
|
|
import { imageInfo } from '../shots/spec';
|
|
import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative';
|
|
import { type Asc, ascClient, md5, type J } from './asc';
|
|
import { type AssetClass, ASSET_STATES, classify, MAX_FAILED, pickAsset, processingTiming, reason, REF, selectable, sumOf } from './asset-library';
|
|
export { type AssetClass, ASSET_STATES, classify, MAX_FAILED, pickAsset, processingTiming, REF, selectable };
|
|
import { partHeaders } from './screenshot-health';
|
|
import { AmbiguousWriteError } from '../lib/upsert';
|
|
import { addItem, ensureDraft, OtherSubmissionError } from './submission';
|
|
|
|
const TAG = 'apple';
|
|
/** "uploaded 2026-10-19, waiting for Apple's approval (IN_REVIEW); run `storebound apple creative-assets` again later".
|
|
* The version page's header / search slots offer only approved assets ("Browse approved assets"), so an asset that
|
|
* is not APPROVED waits; a later run places it. Pure. */
|
|
export function waitingText(a: J): string {
|
|
const d = String(a?.attributes?.createdDate ?? '').slice(0, 10);
|
|
return `${d ? `uploaded ${d}, ` : ''}waiting for Apple's approval (${a?.attributes?.state ?? '?'}); run \`storebound apple creative-assets\` again later`;
|
|
}
|
|
|
|
/** Local creative files whose library asset (same MD5) is processed but not APPROVED yet, per locale and placement:
|
|
* for validate and the report (one library read, no placements). */
|
|
export async function creativeWaiting(c: Asc, appId: string, local: Map<string, CreativeFile[]>): Promise<{ locale: string; placement: string; file: string; text: string }[]> {
|
|
const lib = (await c.maybe(`/v1/apps/${appId}/assetLibrary`))?.data as J | undefined;
|
|
if (!lib) return [];
|
|
const assets = [...(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`))];
|
|
const out: { locale: string; placement: string; file: string; text: string }[] = [];
|
|
for (const [locale, fs] of local) for (const f of fs) {
|
|
const a = pickAsset(assets, md5(readFileSync(f.file))).asset;
|
|
if (a && selectable(classify(a.attributes?.state)) && a.attributes?.state !== 'APPROVED') out.push({ locale, placement: PLACEMENT_TYPE[f.placement], file: basename(f.file), text: waitingText(a) });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'reuse' | 'wait' | 'select' | 'replace' | 'approve' | 'refuse'; state?: string; asset?: J }
|
|
|
|
/** 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)(TAG, `${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 verify = ctx.args.has('verify');
|
|
const forReview = ctx.args.has('add-for-review');
|
|
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
|
|
const { src, local } = localCreative(ctx);
|
|
if (!local.size) { warn(TAG, `no creative assets in ${src}/<lang>/creative/ or ${src}/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(TAG, `${[...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(TAG, `${loc} ${PLACEMENT_TYPE[f.placement]}: would upload ${basename(f.file)} to the Asset Library and select it`);
|
|
return done(TAG, `creative assets planned (dry run, no App Store Connect comparison: ${(e as Error).message.split('\n')[0]})`);
|
|
}
|
|
const appId = await c.app();
|
|
const lib = (await c.asc('GET', `/v1/apps/${appId}/assetLibrary`)).data as J;
|
|
const live = async () => (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`))
|
|
.find(v => (v.attributes.appVersionState ?? v.attributes.appStoreState) === 'READY_FOR_DISTRIBUTION' || v.attributes.appStoreState === 'READY_FOR_SALE') ?? null;
|
|
// No editable version: the live one. Apple lets an approved asset be swapped onto the live version without a new
|
|
// build, and an app with an approved version can send assets to review alone; so there only APPROVED assets are
|
|
// selected, and the others wait for their review (--add-for-review puts them into a submission of their own).
|
|
const editable = await c.editableVersion();
|
|
const version = editable ?? await live();
|
|
const onLive = !editable;
|
|
if (!version) throw new Error(`no editable or live App Store version: the header and search results slots belong to a version page (run apple listing --version x.y.z first). ${consoleLink('asc.version')}`);
|
|
if (onLive && !verify) log(TAG, `no editable version: using the live version ${version.attributes.versionString}`);
|
|
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 placedIn = async (locId: string, ptype: string) => c.all(`/v1/appStoreVersionLocalizations/${locId}/placements?filter[placementType]=${ptype}&include=image,video`);
|
|
const shownOf = (p: J) => p.relationships?.image?.data?.id ?? p.relationships?.video?.data?.id;
|
|
|
|
if (verify) return verifySlots();
|
|
|
|
const rows: Row[] = [];
|
|
let liveRefused = ''; // the first 4xx on a live-version placement: later live slots are not tried
|
|
const planned = new Set<string>(); // dry run: MD5s already planned for upload
|
|
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 r = await fetch(op.url, { method: op.method, headers: partHeaders(op), body: buf.subarray(op.offset, op.offset + op.length), signal: AbortSignal.timeout(300_000) });
|
|
if (r.status < 200 || r.status > 299) { await c.asc('DELETE', `/v1/${type}/${res.id}`).catch(() => {}); throw new Error(`upload ${basename(f.file)} part ${op.offset} -> ${r.status} (reservation deleted, nothing committed)`); }
|
|
}
|
|
return (await c.asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true } } })).data as J;
|
|
}
|
|
/** Polls one asset until it leaves processing, at most processingTiming.timeoutMs, with a heartbeat line. */
|
|
async function processed(a: J, label: string): Promise<J> {
|
|
const t0 = Date.now();
|
|
let beat = t0, cur = a;
|
|
while (classify(cur.attributes?.state) === 'processing') {
|
|
if (Date.now() - t0 >= processingTiming.timeoutMs) {
|
|
warn(TAG, `${label}: still ${cur.attributes?.state ?? 'processing'} after ${elapsed(Date.now() - t0)}${reason(cur)} (asset ${cur.id}); re-run later to select it`);
|
|
return cur;
|
|
}
|
|
await processingTiming.sleep(processingTiming.pollMs);
|
|
cur = ((await c.maybe(`/v1/${cur.type}/${cur.id}`))?.data as J) ?? cur;
|
|
if (Date.now() - beat >= processingTiming.heartbeatMs) { beat = Date.now(); log(TAG, `${label}: processing (${cur.attributes?.state}, ${elapsed(Date.now() - t0)})`); }
|
|
}
|
|
return cur;
|
|
}
|
|
|
|
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 row = (action: Row['action'], asset?: J) => rows.push({ locale, placement: f.placement, file: f.file, action, state: asset?.attributes?.state, asset });
|
|
const placed = await placedIn(loc.id, ptype);
|
|
const shownIds = placed.map(shownOf).filter(Boolean);
|
|
const shown = assets.find(x => shownIds.includes(x.id) && sumOf(x) === sum);
|
|
if (placed.length === 1 && shown) { log(TAG, `${tag}: ${basename(f.file)} already selected (${shown.attributes?.state})`); row('keep', shown); continue; }
|
|
const pick = pickAsset(assets, sum);
|
|
if (pick.refuse) { warn(TAG, `${tag}: ${basename(f.file)} ${pick.refuse}`); row('refuse'); continue; }
|
|
let asset = pick.asset;
|
|
if (!asset) {
|
|
if (dry) {
|
|
// one upload per file: a later locale with the same file reuses it (the real run finds it by MD5)
|
|
const first = !planned.has(sum); planned.add(sum);
|
|
log(TAG, `${tag}: would ${first ? 'upload' : 'reuse'} ${basename(f.file)}${first ? ' to the Asset Library' : ' (uploaded once for an earlier locale)'} and select it once Apple approved it`);
|
|
row(first ? 'upload' : 'reuse'); continue;
|
|
}
|
|
log(TAG, `${tag}: uploading ${basename(f.file)} to the Asset Library`);
|
|
asset = await upload(f, sum);
|
|
assets = await library();
|
|
asset = pickAsset(assets, sum).asset ?? asset;
|
|
} else if (pick.unarchive) {
|
|
if (dry) { log(TAG, `${tag}: would unarchive ${basename(f.file)} (same file, archived) and select it`); row(placed.length ? 'replace' : 'select', asset); continue; }
|
|
asset = (await c.asc('PATCH', `/v1/${asset.type}/${asset.id}`, { data: { type: asset.type, id: asset.id, attributes: { archived: false } } })).data as J;
|
|
log(TAG, `${tag}: unarchived ${basename(f.file)} (asset ${asset.id})`);
|
|
}
|
|
if (!dry) asset = await processed(asset, `${tag} ${basename(f.file)}`);
|
|
const cls = classify(asset.attributes?.state);
|
|
if (!selectable(cls)) {
|
|
if (cls === 'processing' && dry) { log(TAG, `${tag}: ${basename(f.file)} is processing in the Asset Library; would select it once processed`); row('wait', asset); continue; }
|
|
if (cls === 'processing') { row('wait', asset); continue; }
|
|
warn(TAG, `${tag}: ${basename(f.file)} is ${asset.attributes?.state}${reason(asset)}; not selected`); row('refuse', asset); continue;
|
|
}
|
|
// the version page's slots take only approved assets ("Browse approved assets"): the rest wait, a re-run places them
|
|
if (asset.attributes?.state !== 'APPROVED') {
|
|
log(TAG, `${tag}: ${basename(f.file)} ${waitingText(asset)}`);
|
|
row('approve', asset); continue;
|
|
}
|
|
const action = placed.length ? 'replace' : 'select';
|
|
if (dry) { log(TAG, `${tag}: would ${action === 'replace' ? `replace ${placed.length} selected asset(s) with` : 'select'} ${basename(f.file)} (${asset.attributes?.state})`); row(action, asset); continue; }
|
|
if (onLive && liveRefused) { warn(TAG, `${tag}: not tried (App Store Connect refused a placement on the live version above; no blind retries)`); row('refuse', asset); continue; }
|
|
const media = f.kind === 'image' ? 'image' : 'video';
|
|
const place = () => 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 } } } } });
|
|
if (onLive) {
|
|
// Live version: place the new asset first, remove the old one only after Apple accepted it, so a refusal
|
|
// leaves the live page as it was. Whether the API takes placements on a live version is not documented.
|
|
try { await place(); }
|
|
catch (e) {
|
|
const status = /-> (4\d\d)/.exec(String((e as Error).message))?.[1];
|
|
if (!status || e instanceof AmbiguousWriteError) throw e;
|
|
liveRefused = String(e instanceof Error ? e.message : e).split('\n')[0]!;
|
|
warn(TAG, `${tag}: App Store Connect refused to select ${basename(f.file)} on the live version ${version.attributes.versionString} (${status}): ${liveRefused}. The live page is unchanged. Select it in the console instead (Header and Search Results on the live version page): ${consoleLink('asc.version', '', { appleAppId: appId }).replace('/version/inflight', '/version/deliverable')}, or create a new version and re-run; the remaining live slots are not tried`);
|
|
row('refuse', asset); continue;
|
|
}
|
|
for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`);
|
|
} else {
|
|
for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`);
|
|
await place();
|
|
}
|
|
log(TAG, `${tag}: selected ${basename(f.file)} (${asset.attributes?.state})`);
|
|
row(action, asset);
|
|
}
|
|
}
|
|
|
|
// read back
|
|
let off = 0;
|
|
if (!dry) 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 || shownOf(now[0]!) !== r.asset?.id) { off++; warn(TAG, `${r.locale} ${PLACEMENT_TYPE[r.placement]}: read back ${now.length} selected assets, wanted ${basename(r.file)}`); }
|
|
}
|
|
if (off) process.exitCode = 1;
|
|
|
|
const n = (a: Row['action'][]) => rows.filter(r => a.includes(r.action)).length;
|
|
// review: one item per distinct unreviewed asset (in a slot, or waiting for approval to go onto the live version)
|
|
const unreviewed = new Map<string, J>();
|
|
for (const r of rows) if (r.asset && r.action !== 'refuse' && r.action !== 'wait' && classify(r.asset.attributes?.state) === 'unreviewed') unreviewed.set(r.asset.id, r.asset);
|
|
const approve = n(['approve']);
|
|
let added = 0;
|
|
if (unreviewed.size && forReview) {
|
|
const kindOf = (a: J) => (a.type === 'appAssetLibraryVideos' ? 'appAssetLibraryVideo' : 'appAssetLibraryImage');
|
|
if (dry) for (const a of unreviewed.values()) log(TAG, `would add ${a.attributes?.fileName ?? a.id} to the draft review submission (${kindOf(a)})`);
|
|
else {
|
|
const { id: sid } = await ensureDraft(c, appId, root, TAG);
|
|
for (const a of unreviewed.values()) {
|
|
try { if (await addItem(c, sid, kindOf(a), a.id, `creative asset ${a.attributes?.fileName ?? a.id}`, TAG)) added++; }
|
|
catch (e) { if (e instanceof OtherSubmissionError) warn(TAG, `${a.attributes?.fileName ?? a.id}: already in review submission ${e.otherId}; nothing added`); else throw e; }
|
|
}
|
|
log(TAG, `${added} creative asset(s) added to draft review submission ${sid}; nothing was submitted`);
|
|
}
|
|
}
|
|
const summary = `creative assets: ${n(['keep'])} up to date, ${n(['select', 'replace'])} ${dry ? 'to select' : 'selected'}, ${n(['upload'])} file(s) to upload${n(['reuse']) ? ` (used by ${n(['upload', 'reuse'])} slots)` : ''}, ${n(['wait'])} still processing, ${approve ? `${approve} waiting for Apple's approval, ` : ''}${n(['refuse'])} refused${off ? `, ${off} did not read back` : ''}`;
|
|
if (unreviewed.size) {
|
|
log(TAG, forReview
|
|
? `owner step: Submit for Review with the version (or alone once the app has an approved version): ${consoleLink('asc.app-review')}`
|
|
: `${unreviewed.size} asset(s) are not reviewed yet ("Prepare for Submission"): run with --add-for-review to put them into the draft review submission (the version's, or alone once the app has an approved version); Submit for Review stays the owner's: ${consoleLink('asc.app-review')}`);
|
|
}
|
|
if (n(['refuse'])) process.exitCode = 1;
|
|
return done(TAG, dry ? `${summary} (dry run, nothing sent)` : summary);
|
|
|
|
async function verifySlots(): Promise<void> {
|
|
let bad = 0, waiting = 0;
|
|
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 sum = md5(readFileSync(f.file));
|
|
const placed = await placedIn(loc.id, ptype);
|
|
const shown = placed.map(p => assets.find(x => x.id === shownOf(p))).filter(Boolean) as J[];
|
|
const ok = placed.length === 1 && shown[0] && sumOf(shown[0]) === sum;
|
|
const what = shown.length ? shown.map(a => `${a.attributes?.fileName ?? a.id} (${a.attributes?.state})`).join(', ') : 'nothing';
|
|
const pending = ok ? undefined : pickAsset(assets, sum).asset;
|
|
if (ok) log(TAG, `ok ${locale} ${ptype}: ${what}`);
|
|
else if (pending && selectable(classify(pending.attributes?.state)) && pending.attributes?.state !== 'APPROVED') { waiting++; log(TAG, `waiting ${locale} ${ptype}: ${basename(f.file)} ${waitingText(pending)} (shows ${what} until then)`); }
|
|
else { bad++; warn(TAG, `${locale} ${ptype}: shows ${what}, wanted ${basename(f.file)}${placed.length > 1 ? ` (${placed.length} selected)` : ''}`); }
|
|
}
|
|
}
|
|
if (bad) process.exitCode = 1;
|
|
done(TAG, `creative assets verified on version ${version!.attributes.versionString} (read-only): ${bad ? `${bad} slot(s) differ from the local files` : waiting ? 'every other slot shows the local file' : 'every slot shows the local file'}${waiting ? `, ${waiting} waiting for Apple's approval` : ''}`);
|
|
}
|
|
}
|