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

@ -12,6 +12,14 @@
(`APP_IPHONE_61`) per locale, met by a 6.9" or 6.5" set that App Store Connect scales down (an ok row, not a
warning). Labels match the console: "iPhone with Dynamic Island (medium display)", "iPhone Duo", "iPad 13" display".
- **App Store Header and Search Results.** `storebound apple creative-assets [--dry]` puts a product page header
(3840 x 1646, 21:9) and a search results asset (3:2, 1920 x 1280 to 3840 x 2560), image or video (5-30 s, 30/60
fps), per locale from `<renders>/<lang>/creative/`, or one universal 5244 x 2950 PNG, into the App Asset Library and
selects them (`PRODUCT_PAGE_HEADER_ASSET`, `APP_STORE_SEARCH_RESULTS_ASSET`) once Apple approved them; MD5 upsert,
read back. Local checks: size, no alpha, video length / fps / container (ffprobe, else skipped with a note).
`check` and `validate` note when there is none (Play: the feature graphic and promo video are the counterparts).
MCP `apple_creative_assets`; config `apple.creativeAssets`; pitfall 76.
## 0.8.12 · 2026-10-17
- **Shareable readiness report.** `storebound report` puts `scan`, `check` and `validate` into one self-contained HTML
@ -259,7 +267,6 @@ errors are clear messages instead of raw API text.
- **One row per release across the stores.** Versions are matched without trailing .0 parts (App Store "1.0" and Play
"1.0.0" are one row and one timeline); each side still shows the store's own string.
## 0.8.1 · 2026-10-13
`apple build` exports each build into its own folder and fails loudly on any export error; `apple upload` refuses a

View file

@ -7,6 +7,10 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'analyticsReportRequests_createInstance': 'analytics',
'analyticsReportRequests_reports_getToManyRelated': 'analytics',
'analyticsReports_instances_getToManyRelated': 'analytics',
'appAssetLibraries_images_getToManyRelated': 'apple/creative-assets',
'appAssetLibraries_videos_getToManyRelated': 'apple/creative-assets',
'appAssetLibraryPlacements_createInstance': 'apple/creative-assets',
'appAssetLibraryPlacements_deleteInstance': 'apple/creative-assets',
'appAvailabilitiesV2_createInstance': 'apple/appinfo',
'appAvailabilitiesV2_territoryAvailabilities_getToManyRelated': 'apple/preorder',
'appCustomProductPageLocalizations_appScreenshotSets_getToManyRelated': 'apple/cpp',
@ -54,12 +58,13 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'appStoreVersionLocalizations_appPreviewSets_getToManyRelated': 'apple/previews art',
'appStoreVersionLocalizations_appScreenshotSets_getToManyRelated': 'apple/screenshots art validate',
'appStoreVersionLocalizations_createInstance': 'listing-store',
'appStoreVersionLocalizations_placements_getToManyRelated': 'apple/creative-assets',
'appStoreVersionLocalizations_updateInstance': 'listing-store',
'appStoreVersionPhasedReleases_createInstance': 'apple/release',
'appStoreVersionPhasedReleases_updateInstance': 'apple/release',
'appStoreVersionReleaseRequests_createInstance': 'apple/release',
'appStoreVersions_appStoreReviewDetail_getToOneRelated': 'apple/review-details onboard validate',
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots art listing-store onboard validate',
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/creative-assets apple/listing apple/previews apple/screenshots art listing-store onboard validate',
'appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated': 'apple/release apple/status',
'appStoreVersions_build_getToOneRelated': 'apple/listing apple/resubmit validate',
'appStoreVersions_build_updateToOneRelationship': 'apple/listing apple/resubmit',
@ -75,6 +80,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate',
'apps_appStoreVersionExperimentsV2_getToManyRelated': 'apple/ppo',
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/iap-submit apple/listing apple/release apple/resubmit apple/review-details apple/status art lib/store-apps listing-store onboard validate',
'apps_assetLibrary_getToOneRelated': 'apple/creative-assets',
'apps_betaAppLocalizations_getToManyRelated': 'apple/testflight',
'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight',
'apps_betaFeedbackCrashSubmissions_getToManyRelated': 'health',

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` : ''}`);
}

View file

@ -29,6 +29,7 @@ import { reviewShotProblem } from './apple/iap-review';
import { allSubscriptions } from './lib/subscriptions';
import { type CopyFile, validateCopy } from './shots/lib';
import { posix, readPulled } from './lib/art-pull';
import { creativeNote } from './shots/creative';
import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets';
import { iphoneDuoNote } from './shots/spec';
@ -149,6 +150,8 @@ export async function run(ctx: Ctx): Promise<void> {
if (config.apple && !config.app.copyright?.trim()) problems.push(COPYRIGHT_MISSING);
const duo = iphoneDuoNote({ apple: !!config.apple, duo: !!config.apple?.iphoneDuo, play: !!config.play });
if (duo?.level === 'info') { hints.push(duo.msg); log('check', `hint: ${duo.msg}`); } else if (duo) note(duo.msg);
const creative = creativeNote(config, ctx.loaded.resolve(config.renders ?? config.shots?.out ?? 'store/renders'));
if (creative) { hints.push(creative); log('check', `hint: ${creative}`); }
// the scaffolder's own placeholders would reach the stores as the app's copyright, contact and legal pages
const tph = templatePlaceholders(config as never);
if (tph.length) problems.push(`template placeholders still in the config: ${tph.join(', ')} ("Example Studio", example.com): the stores would show them as your copyright, contact address and legal pages; put the real values in`);

View file

@ -80,6 +80,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'apple appinfo': [() => import('./apple/appinfo'), 'categories, age rating, content rights, Free price, territories'],
'apple review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes'],
'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission <id>)'],
'apple creative-assets': [() => import('./apple/creative-assets'), 'Header and Search Results: product page header (21:9) and search results asset (3:2) per locale, or one universal 5244x2950 PNG; uploads to the Asset Library, selects once Apple approved it (MD5 upsert); --dry'],
'apple previews': [() => import('./apple/previews'), 'app preview videos per locale (MD5-idempotent, poster frame time code); --dry'],
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first)'],
'apple signing': [() => import('./apple/signing'), 'status | ensure: bundle id capabilities, distribution certificate (key kept in .storebound/signing), App Store profiles installed for Xcode; --dry, asks first'],

View file

@ -87,6 +87,9 @@
"variant",
"write"
],
"apple creative-assets": [
"src"
],
"apple encryption": [
"build"
],
@ -1175,6 +1178,9 @@
"storebound apple cpp submit --name \"...\" [--dry] [--yes] into a review submission (the owner's step; asks)",
"storebound apple cpp visible --name \"...\" [--off] [--dry] [--yes] show / hide an approved page (asks)"
],
"apple creative-assets": [
"storebound apple creative-assets [--src store/renders] [--only en-US,tr] [--dry]"
],
"apple encryption": [
"storebound apple encryption [--build 42] [--dry]"
],

View file

@ -626,6 +626,12 @@ export interface ShipConfig {
* Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. Not uploaded while the API has no
* display type for them (cli/shots/spec.ts IPHONE_DUO_DISPLAY). */
iphoneDuo?: boolean;
/** "Header and Search Results" creative assets (`storebound apple creative-assets`, iOS / iPadOS 27+). Files under
* <renders>/<lang>/creative/: header.{png,jpg,mov,m4v,mp4} (3840x1646, 21:9), search.{...} (3:2, 1920x1280 to
* 3840x2560) or universal.png (5244x2950) for both. `header` / `search` false skip that slot; `universal` is a
* universal PNG for every locale without own files. Optional: without them the App Store uses events, previews and
* screenshots. Play: the feature graphic and the promo video are the closest equivalents. */
creativeAssets?: { header?: boolean; search?: boolean; universal?: string };
/** TestFlight beta groups, the tester CSV (in .storebound/) and Test Information (`storebound apple testflight`). */
testflight?: TfConfig;
/** Certificates, profiles and capabilities through the API (`storebound apple signing`), signing style of `apple build`. */

View file

@ -245,6 +245,9 @@ export const TOOLS: ToolDef[] = [
props: { lang, target: { type: 'string', description: 'Comma-separated: iphone, ipad, youtube, youtube-landscape' },
seconds: { type: 'integer', minimum: 15, maximum: 30 }, captions: { type: 'boolean', default: true } },
args: i => [...opt('lang', i.lang), ...opt('target', i.target), ...opt('seconds', i.seconds), ...(i.captions === false ? ['--no-captions'] : [])] },
{ name: 'apple_creative_assets', title: 'App Store header and search results assets', command: ['apple', 'creative-assets'], write: true, destructive: true, network: true, idempotent: true,
description: 'Header and Search Results (iOS / iPadOS 27+): checks <renders>/<lang>/creative/ (header 3840x1646, search 3:2, universal 5244x2950 PNG; no alpha; videos 5-30 s, 30/60 fps), uploads new files to the App Asset Library, and selects an asset for PRODUCT_PAGE_HEADER_ASSET / APP_STORE_SEARCH_RESULTS_ASSET only once Apple approved it (MD5 upsert, same file = nothing sent). Play counterpart: feature graphic + promo video.',
props: { only }, args: i => opt('only', i.only) },
{ name: 'apple_previews', title: 'Upload App Store app previews', command: ['apple', 'previews'], write: true, destructive: true, network: true, idempotent: true,
description: 'Sync app preview videos per locale to the editable App Store version (checked locally first; MD5-idempotent, changed sets are replaced; poster frame time code). Not public until the version is submitted and released. Slow: prefer the terminal with a log.',
props: { only, poster: { type: 'integer', minimum: 0, maximum: 29, description: 'Poster frame second (default shots.video.poster or 5)' } },

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;
}

View file

@ -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; }

View file

@ -33,6 +33,7 @@ import { runScan } from './lib/scan';
import type { ScanFinding } from './lib/scan/rules';
import { playClient } from './play/client';
import { listOneTimeProducts } from './play/products';
import { creativeNote } from './shots/creative';
/** Profile facts for the checks: config + local credentials + public URL checks (no store API). */
export async function collectProfile(loaded: Loaded): Promise<ProfileFacts> {
@ -196,6 +197,8 @@ export async function run(ctx: Ctx): Promise<void> {
if ((both || a.has('apple')) && config.apple) {
try { findings.push(...appleRules({ ...(await collectApple(ctx)), renders: local?.apple })); }
catch (e) { findings.push({ store: 'apple', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('apple', local?.apple)); }
const creative = creativeNote(config, dir);
if (creative) findings.push({ store: 'apple', level: 'ok', code: 'creative-assets', msg: creative, fix: 'storebound apple creative-assets --dry' });
} else if (a.has('apple')) warn('validate', 'no apple section in the config');
if ((both || a.has('play')) && config.play) {
try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); }

View file

@ -12,7 +12,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| API | operations | GET | other | wrapped by a command | owner only |
|---|---:|---:|---:|---:|---:|
| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 204 | 166 |
| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 210 | 166 |
| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 35 | 49 |
| Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 |
@ -80,9 +80,9 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `androidToIosAppMappingDetails_updateInstance` | PATCH | `/v1/androidToIosAppMappingDetails/{id}` | write (dry by default) | |
| **appAssetLibraries** (5) | | | | |
| `appAssetLibraries_getInstance` | GET | `/v1/appAssetLibraries/{id}` | read | |
| `appAssetLibraries_images_getToManyRelated` | GET | `/v1/appAssetLibraries/{id}/images` | read | |
| `appAssetLibraries_images_getToManyRelated` | GET | `/v1/appAssetLibraries/{id}/images` | read | apple/creative-assets |
| `appAssetLibraries_images_getToManyRelationship` | GET | `/v1/appAssetLibraries/{id}/relationships/images` | read | |
| `appAssetLibraries_videos_getToManyRelated` | GET | `/v1/appAssetLibraries/{id}/videos` | read | |
| `appAssetLibraries_videos_getToManyRelated` | GET | `/v1/appAssetLibraries/{id}/videos` | read | apple/creative-assets |
| `appAssetLibraries_videos_getToManyRelationship` | GET | `/v1/appAssetLibraries/{id}/relationships/videos` | read | |
| **appAssetLibraryImages** (6) | | | | |
| `appAssetLibraryImages_createInstance` | POST | `/v1/appAssetLibraryImages` | write (dry by default) | |
@ -94,8 +94,8 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| **appAssetLibraryPlacementOrderingRequests** (1) | | | | |
| `appAssetLibraryPlacementOrderingRequests_createInstance` | POST | `/v1/appAssetLibraryPlacementOrderingRequests` | write (dry by default) | |
| **appAssetLibraryPlacements** (3) | | | | |
| `appAssetLibraryPlacements_createInstance` | POST | `/v1/appAssetLibraryPlacements` | write (dry by default) | |
| `appAssetLibraryPlacements_deleteInstance` | DELETE | `/v1/appAssetLibraryPlacements/{id}` | owner only (typed confirm) | |
| `appAssetLibraryPlacements_createInstance` | POST | `/v1/appAssetLibraryPlacements` | write (dry by default) | apple/creative-assets |
| `appAssetLibraryPlacements_deleteInstance` | DELETE | `/v1/appAssetLibraryPlacements/{id}` | owner only (typed confirm) | apple/creative-assets |
| `appAssetLibraryPlacements_getInstance` | GET | `/v1/appAssetLibraryPlacements/{id}` | read | |
| **appAssetLibraryRefData** (2) | | | | |
| `appAssetLibraryRefData_getCollection` | GET | `/v1/appAssetLibraryRefData` | read | |
@ -342,7 +342,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `appStoreVersionLocalizations_createInstance` | POST | `/v1/appStoreVersionLocalizations` | write (dry by default) | listing-store |
| `appStoreVersionLocalizations_deleteInstance` | DELETE | `/v1/appStoreVersionLocalizations/{id}` | owner only (typed confirm) | |
| `appStoreVersionLocalizations_getInstance` | GET | `/v1/appStoreVersionLocalizations/{id}` | read | |
| `appStoreVersionLocalizations_placements_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/placements` | read | |
| `appStoreVersionLocalizations_placements_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/placements` | read | apple/creative-assets |
| `appStoreVersionLocalizations_placements_getToManyRelationship` | GET | `/v1/appStoreVersionLocalizations/{id}/relationships/placements` | read | |
| `appStoreVersionLocalizations_searchKeywords_createToManyRelationship` | POST | `/v1/appStoreVersionLocalizations/{id}/relationships/searchKeywords` | write (dry by default) | |
| `appStoreVersionLocalizations_searchKeywords_deleteToManyRelationship` | DELETE | `/v1/appStoreVersionLocalizations/{id}/relationships/searchKeywords` | owner only (typed confirm) | |
@ -371,7 +371,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `appStoreVersions_appStoreVersionExperimentsV2_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionExperimentsV2` | read | |
| `appStoreVersions_appStoreVersionExperiments_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionExperiments` | read | |
| `appStoreVersions_appStoreVersionExperiments_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionExperiments` | read | |
| `appStoreVersions_appStoreVersionLocalizations_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionLocalizations` | read | apple/listing, apple/previews, apple/screenshots, art, listing-store, onboard, validate |
| `appStoreVersions_appStoreVersionLocalizations_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionLocalizations` | read | apple/creative-assets, apple/listing, apple/previews, apple/screenshots, art, listing-store, onboard, validate |
| `appStoreVersions_appStoreVersionLocalizations_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionLocalizations` | read | |
| `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionPhasedRelease` | read | apple/release, apple/status |
| `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionPhasedRelease` | read | |
@ -425,7 +425,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `apps_appStoreVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersions` | read | |
| `apps_appTags_getToManyRelated` | GET | `/v1/apps/{id}/appTags` | read | |
| `apps_appTags_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appTags` | read | |
| `apps_assetLibrary_getToOneRelated` | GET | `/v1/apps/{id}/assetLibrary` | read | |
| `apps_assetLibrary_getToOneRelated` | GET | `/v1/apps/{id}/assetLibrary` | read | apple/creative-assets |
| `apps_assetLibrary_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/assetLibrary` | read | |
| `apps_backgroundAssets_getToManyRelated` | GET | `/v1/apps/{id}/backgroundAssets` | read | |
| `apps_backgroundAssets_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/backgroundAssets` | read | |

View file

@ -87,3 +87,22 @@ skills.
---
[All docs](README.md) · [README](../README.md)
## Header and Search Results (App Store creative assets, iOS / iPadOS 27+)
Since 5 Oct 2026 the App Store version page has a "Header and Search Results" tab next to "App Previews and
Screenshots". Optional: without these assets the App Store uses in-app events, app previews and screenshots.
- **Files** under the renders folder, per language: `<renders>/<lang>/creative/header.png` (3840 x 1646, 21:9),
`search.png` (3:2, 1920 x 1280 up to 3840 x 2560), or one `universal.png` (5244 x 2950, 16:9) that fills either slot
(also `<renders>/creative/universal.png` for every locale, or `apple.creativeAssets.universal`). JPEG / PNG without
alpha. Videos instead: `header.mov|m4v|mp4` / `search.*`, 5-30 s, 30 or 60 fps, the same aspect ratio; search videos
play muted. `apple.creativeAssets.header: false` / `search: false` skips a slot.
- **Not rendered by `shots render`**: the scene templates are portrait device shots; make the wide art yourself (or from
the feature graphic artwork at the larger size) and drop it in.
- **Upload:** `storebound apple creative-assets --dry`, then without `--dry`. Files go into the app's Asset Library
(API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`); Apple reviews each asset, and
only an approved one is selected for the slot (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` /
`APP_STORE_SEARCH_RESULTS_ASSET`). Until then the run says "waiting for Apple's approval": re-run later. Same file
(MD5) = nothing sent. MCP: `apple_creative_assets` (dry by default).
- **Checks:** `storebound check` and `validate` print an info note when no locale has one.
- **Play:** no such slot. The feature graphic (1024 x 500, `play images`) and the promo video (YouTube URL) are the
closest equivalents.

View file

@ -911,3 +911,17 @@ that fails without the fix (see CONTRIBUTING.md).
- **Guard:** `IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts` (null = not in the API; regenerate with
`bun tools/gen-api.ts` and fill in the exact names), `skippedDuoSets` in `cli/apple/screenshots.ts`, the
`screenshots-duo` note in check / validate. `tests/ppo.test.ts`.
## 76. Header and Search Results assets are not uploaded like screenshots
- **Symptom:** a header or search results image was "uploaded" but the version page still says "Browse approved
assets to select a header asset", or a re-run uploaded the same file again.
- **Cause:** these slots take assets from the app's Asset Library (`GET /v1/apps/{id}/assetLibrary`), not a set of
their own. Each asset is reviewed by Apple first; only an APPROVED asset can be selected (an
`appAssetLibraryPlacements` row with `PRODUCT_PAGE_HEADER_ASSET` or `APP_STORE_SEARCH_RESULTS_ASSET` and the version
localization). Library assets carry no `sourceFileChecksum`, and the write-ups' name for the search slot is the
API's `APP_STORE_SEARCH_RESULTS_ASSET` (`SEARCH_RESULTS_ADS_ASSET` is the Apple Ads one).
- **Do:** `storebound apple creative-assets`: it keeps the file's MD5 in `referenceName` ("storebound <md5>"), reuses
a library asset with the same MD5, selects only approved ones and says "waiting for Apple's approval" otherwise.
Uploading and selecting are normal steps, not owner buttons; the version's Submit for Review stays the owner's.
- **Guard:** `cli/apple/creative-assets.ts`, `tests/creative-assets.test.ts` (fake App Store Connect: dry run, upload,
waiting, select, replace on a changed file, nothing on the same file).

View file

@ -197,6 +197,25 @@ images there), so only do that after a test has a winner. What the stores offer,
- Test one thing per experiment (usually the first screenshot or the order), give it real traffic, and do not read
results early.
## Header and Search Results (App Store creative assets, iOS / iPadOS 27+)
Since 5 Oct 2026 the App Store version page has a "Header and Search Results" tab next to "App Previews and
Screenshots". Optional: without these assets the App Store uses in-app events, app previews and screenshots.
- **Files** under the renders folder, per language: `<renders>/<lang>/creative/header.png` (3840 x 1646, 21:9),
`search.png` (3:2, 1920 x 1280 up to 3840 x 2560), or one `universal.png` (5244 x 2950, 16:9) that fills either slot
(also `<renders>/creative/universal.png` for every locale, or `apple.creativeAssets.universal`). JPEG / PNG without
alpha. Videos instead: `header.mov|m4v|mp4` / `search.*`, 5-30 s, 30 or 60 fps, the same aspect ratio; search videos
play muted. `apple.creativeAssets.header: false` / `search: false` skips a slot.
- **Not rendered by `shots render`**: the scene templates are portrait device shots; make the wide art yourself (or from
the feature graphic artwork at the larger size) and drop it in.
- **Upload:** `storebound apple creative-assets --dry`, then without `--dry`. Files go into the app's Asset Library
(API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`); Apple reviews each asset, and
only an approved one is selected for the slot (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` /
`APP_STORE_SEARCH_RESULTS_ASSET`). Until then the run says "waiting for Apple's approval": re-run later. Same file
(MD5) = nothing sent. MCP: `apple_creative_assets` (dry by default).
- **Checks:** `storebound check` and `validate` print an info note when no locale has one.
- **Play:** no such slot. The feature graphic (1024 x 500, `play images`) and the promo video (YouTube URL) are the
closest equivalents.
## From an AI tool (MCP)
`storebound mcp` exposes `shots_check` (read-only) and `shots_capture`, `shots_render`, `shots_gallery` (local files,
`dry_run: true` by default: run it once dry, show the plan, then with `dry_run: false`). Uploads (`apple_screenshots`,

View file

@ -0,0 +1,126 @@
// App Store "Header and Search Results" creative assets: local size / alpha / video checks, the check / validate note,
// and `apple creative-assets` against the fake App Store Connect (Asset Library upload, approval wait, select, replace
// on a changed file, nothing on the same file).
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { md5 } from '../cli/apple/asc';
import { REF, run } from '../cli/apple/creative-assets';
import { checkCreativeImage, checkCreativeVideo, CREATIVE_NOTE, creativeFiles, creativeNote } from '../cli/shots/creative';
import { imageInfo } from '../cli/shots/spec';
import { upsertTiming } from '../cli/lib/upsert';
import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock';
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
/** A PNG header (IHDR only: imageInfo reads no more), RGB or RGBA, plus `salt` bytes so files differ. */
function png(w: number, h: number, alpha = false, salt = 0): Uint8Array {
const b = new Uint8Array(33 + 1);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
const dv = new DataView(b.buffer);
dv.setUint32(16, w); dv.setUint32(20, h); b[24] = 8; b[25] = alpha ? 6 : 2;
b[33] = salt;
return b;
}
describe('creative asset checks', () => {
test('header: exactly 3840x1646, no alpha', () => {
expect(checkCreativeImage(imageInfo(png(3840, 1646)), 'header')).toEqual([]);
expect(checkCreativeImage(imageInfo(png(3840, 1645)), 'header')[0]!.code).toBe('size');
expect(checkCreativeImage(imageInfo(png(3840, 1646, true)), 'header').map(i => i.code)).toEqual(['alpha']);
});
test('search: 3:2 from 1920x1280 to 3840x2560', () => {
for (const [w, h] of [[1920, 1280], [3000, 2000], [3840, 2560]]) expect(checkCreativeImage(imageInfo(png(w!, h!)), 'search')).toEqual([]);
for (const [w, h] of [[1800, 1200], [3900, 2600], [2000, 1280]]) expect(checkCreativeImage(imageInfo(png(w!, h!)), 'search')[0]!.code).toBe('size');
});
test('universal: one 5244x2950 PNG for either slot', () => {
expect(checkCreativeImage(imageInfo(png(5244, 2950)), 'header', true)).toEqual([]);
expect(checkCreativeImage(imageInfo(png(5244, 2950)), 'search', true)).toEqual([]);
expect(checkCreativeImage(imageInfo(png(3840, 2160)), 'search', true)[0]!.code).toBe('size');
});
test('video: length, fps, container, aspect; ffprobe missing is a skipped-check warning', () => {
const v = (w: number, h: number, fps: number, duration: number, audio = false) => ({ bytes: 1, duration, video: { codec: 'h264', w, h, fps }, ...(audio ? { audio: { codec: 'aac', channels: 2, sampleRate: 48000 } } : {}) });
expect(checkCreativeVideo('header.mov', v(3840, 1646, 30, 10), 'header')).toEqual([]);
expect(checkCreativeVideo('search.mp4', v(1920, 1280, 60, 30), 'search')).toEqual([]);
expect(checkCreativeVideo('search.mp4', v(1920, 1280, 25, 4), 'search').map(i => i.msg).join()).toMatch(/5-30 s.*30 or 60 fps/);
expect(checkCreativeVideo('header.webm', v(3840, 1646, 30, 10), 'header')[0]!.msg).toContain('.mov');
expect(checkCreativeVideo('header.mov', v(1920, 1080, 30, 10), 'header')[0]!.msg).toContain('aspect');
expect(checkCreativeVideo('search.mp4', v(1920, 1280, 30, 10, true), 'search')[0]!.level).toBe('warn');
const skipped = checkCreativeVideo('header.mov', null, 'header');
expect(skipped).toHaveLength(1);
expect(skipped[0]!.msg).toContain('not checked (ffprobe missing');
});
test('files: own header / search first, the universal image fills the rest', () => {
const list = (d: string) => (d.endsWith('en/creative') ? ['header.png', 'notes.txt'] : d.endsWith('creative') ? ['universal.png'] : []);
const fs = creativeFiles('/r', 'en', list);
expect(fs.map(f => [f.placement, f.file, f.universal])).toEqual([['header', '/r/en/creative/header.png', false], ['search', '/r/creative/universal.png', true]]);
});
test('info note: an App Store app without any asset; none for Play-only or with files', () => {
const langs = [{ id: 'en', apple: 'en-US' }];
expect(creativeNote({ apple: {}, languages: langs }, '/r', () => [])).toBe(CREATIVE_NOTE);
expect(CREATIVE_NOTE).toContain('optional; without it the App Store uses in-app events, app previews and screenshots');
expect(CREATIVE_NOTE).toContain('feature graphic');
expect(creativeNote({ languages: langs }, '/r', () => [])).toBeNull();
expect(creativeNote({ apple: {}, languages: langs }, '/r', d => (d.endsWith('creative') ? ['search.png'] : []))).toBeNull();
});
});
describe('apple creative-assets against the fake App Store Connect', () => {
let s: Stores;
beforeAll(ascEnv);
afterAll(restore);
beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); });
afterEach(uninstallStores);
const APP = '6400000101';
const CFG = { ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }] };
const setup = (files: Record<string, Uint8Array>) => {
s.asc.put({ type: 'appAssetLibraries', id: 'lib-1', attributes: {}, rels: { app: APP } });
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
s.asc.put({ type: 'appStoreVersionLocalizations', id: 'loc-en', attributes: { locale: 'en-US' }, rels: { appStoreVersion: 'ver-1' } });
return project(CFG, files);
};
const go = async (p: ReturnType<typeof project>, argv: string[] = []) => {
const a = s.calls.length;
const r = await capture(() => run(p.ctx(argv)));
if (r.exit) throw new Error(`exit ${r.exit}: ${r.out}\n${r.err}`);
return { out: r.out, w: writes(s.calls.slice(a)).map(c => `${c.method} ${decodeURIComponent(c.path).replace(/\?.*/, '').replace(/\/v1\/(\w+)\/[^/]+$/, '/v1/$1/{id}')}`).filter(x => x.includes(' /v1/')) };
};
const approve = () => { for (const t of ['appAssetLibraryImages', 'appAssetLibraryVideos']) for (const x of s.asc.all(t)) x.attributes.state = 'APPROVED'; };
test('dry run, upload, wait for approval, select, same file = nothing, changed file = replace', async () => {
const header = png(3840, 1646, false, 1);
const p = setup({ 'store/renders/en/creative/header.png': header, 'store/renders/en/creative/search.png': png(1920, 1280, false, 2) });
const dry = await go(p, ['--dry']);
expect(dry.w).toEqual([]);
expect(dry.out).toContain('would upload header.png');
const first = await go(p);
expect(first.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}']);
expect(first.out).toContain("waiting for Apple's approval");
expect(s.asc.all('appAssetLibraryImages').map(x => x.attributes.referenceName)).toContain(REF(md5(header)));
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(0);
const again = await go(p);
expect(again.w).toEqual([]); // still waiting: no second upload
approve();
const sel = await go(p);
expect(sel.w).toEqual(['POST /v1/appAssetLibraryPlacements', 'POST /v1/appAssetLibraryPlacements']);
expect(s.asc.all('appAssetLibraryPlacements').map(x => x.attributes.placementType).sort()).toEqual(['APP_STORE_SEARCH_RESULTS_ASSET', 'PRODUCT_PAGE_HEADER_ASSET']);
expect((await go(p)).w).toEqual([]);
// a changed header: uploaded, selected only after approval, then the old placement is replaced
const q = project(CFG, { 'store/renders/en/creative/header.png': png(3840, 1646, false, 9), 'store/renders/en/creative/search.png': png(1920, 1280, false, 2) });
expect((await go(q)).w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}']);
approve();
const rep = await go(q);
expect(rep.w).toEqual(['DELETE /v1/appAssetLibraryPlacements/{id}', 'POST /v1/appAssetLibraryPlacements']);
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(2);
});
test('a bad file stops the run before any call', async () => {
const p = setup({ 'store/renders/en/creative/header.png': png(3840, 1600, true) });
const a = s.calls.length;
await expect(capture(() => run(p.ctx([])))).rejects.toThrow('creative asset problems');
expect(s.calls.length).toBe(a);
});
});

View file

@ -71,6 +71,10 @@ const LISTS: Record<string, { type: string; one?: boolean }> = {
'subscriptions/winBackOffers': { type: 'winBackOffers' },
'betaGroups/betaTesters': { type: 'betaTesters' },
'apps/analyticsReportRequests': { type: 'analyticsReportRequests' },
'apps/assetLibrary': { type: 'appAssetLibraries', one: true },
'appAssetLibraries/images': { type: 'appAssetLibraryImages' },
'appAssetLibraries/videos': { type: 'appAssetLibraryVideos' },
'appStoreVersionLocalizations/placements': { type: 'appAssetLibraryPlacements' },
};
/** Stores refuse a second resource with the same key (409 ENTITY_ERROR ... already exists). */
const UNIQUE: Record<string, (r: R) => string> = {
@ -97,7 +101,9 @@ const UNIQUE: Record<string, (r: R) => string> = {
winBackOffers: r => `${r.rels.subscription}|${r.attributes.offerId}`,
betaTesters: r => String(r.attributes.email).toLowerCase(),
};
const ASSETS = /Screenshots$|^appPreviews$|^appScreenshots$/;
const ASSETS = /Screenshots$|^appPreviews$|^appScreenshots$|^appAssetLibrary(Images|Videos)$/;
/** App Asset Library assets: a state of their own (AWAITING_UPLOAD -> WAITING_FOR_REVIEW after the commit; a test sets APPROVED). */
const LIBRARY = /^appAssetLibrary(Images|Videos)$/;
/** Attributes a new resource of a type starts with. */
const DEFAULTS: Record<string, Record<string, unknown>> = {
inAppPurchases: { state: 'MISSING_METADATA' }, subscriptions: { state: 'MISSING_METADATA' }, reviewSubmissions: { state: 'READY_FOR_REVIEW' },
@ -178,7 +184,7 @@ export class FakeAsc {
return json({ errors: [{ status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE', title: 'The provided entity includes an attribute with a value that has already been used', detail: 'already exists' }] }, 409);
if (ASSETS.test(d.type) && d.attributes?.fileName) {
main.attributes.uploadOperations = [{ method: 'PUT', url: `${UPLOAD_HOST}${main.id}`, offset: 0, length: Number(d.attributes.fileSize ?? 0), requestHeaders: [{ name: 'Content-Type', value: 'image/png' }] }];
main.attributes.assetDeliveryState = { state: 'AWAITING_UPLOAD' };
if (LIBRARY.test(d.type)) main.attributes.state = 'AWAITING_UPLOAD'; else main.attributes.assetDeliveryState = { state: 'AWAITING_UPLOAD' };
}
// a tester belongs to the app of its groups (GET /v1/betaTesters?filter[apps]=)
if (d.type === 'betaTesters') { const g = [main.rels.betaGroups].flat()[0]; const app = g ? this.res.get(String(g))?.rels.app : undefined; if (app) main.rels.app = app; }
@ -304,7 +310,8 @@ export class FakeAsc {
rels: { [singular(self.type)]: self.id, ...Object.fromEntries(Object.entries(inc.relationships ?? {}).map(([k, v]) => [k, v.data.id])) } });
if (attrs.uploaded === true && ASSETS.test(self.type)) {
const { uploaded: _u, ...rest } = attrs;
Object.assign(self.attributes, rest, { assetDeliveryState: { state: this.parts.includes(self.id) ? 'COMPLETE' : 'FAILED' } });
if (LIBRARY.test(self.type)) Object.assign(self.attributes, rest, { state: this.parts.includes(self.id) ? 'WAITING_FOR_REVIEW' : 'FAILED' });
else Object.assign(self.attributes, rest, { assetDeliveryState: { state: this.parts.includes(self.id) ? 'COMPLETE' : 'FAILED' } });
delete self.attributes.uploadOperations;
} else if (self.type === 'reviewSubmissions' && (attrs.submitted === true || attrs.canceled === true)) {
// seen live (2026-10-12): a REJECTED item that is not marked resolved blocks every submit with this 409, and an