From e5369090fa2afd29a85b2d6948909c33717f337d Mon Sep 17 00:00:00 2001 From: CRT Date: Sun, 18 Oct 2026 13:09:38 +0700 Subject: [PATCH] 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 --- CHANGELOG.md | 9 +- cli/api/wrapped.gen.ts | 8 +- cli/apple/creative-assets.ts | 134 ++++++++++++++++++++++++++++++ cli/check.ts | 3 + cli/commands.ts | 1 + cli/data/flags.json | 6 ++ cli/lib/config.ts | 6 ++ cli/mcp-tools.ts | 3 + cli/shots/creative.ts | 108 ++++++++++++++++++++++++ cli/shots/gallery.ts | 14 +++- cli/validate.ts | 3 + docs/api-coverage.md | 16 ++-- docs/store-art.md | 19 +++++ skills/store-pitfalls/SKILL.md | 14 ++++ skills/store-screenshots/SKILL.md | 19 +++++ tests/creative-assets.test.ts | 126 ++++++++++++++++++++++++++++ tests/helpers/fake-stores.ts | 13 ++- 17 files changed, 486 insertions(+), 16 deletions(-) create mode 100644 cli/apple/creative-assets.ts create mode 100644 cli/shots/creative.ts create mode 100644 tests/creative-assets.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 548a917..db8a60a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `//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 diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index 8668a6f..0a075f2 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -7,6 +7,10 @@ export const WRAPPED: { apple: Record; play: Record; play: Record; play: Record//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 " 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 } { + 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(); + 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 { + 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}//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>; + 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 => [ + ...(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 { + 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 = {}; + 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` : ''}`); +} diff --git a/cli/check.ts b/cli/check.ts index 3aa9f70..288b830 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -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 { 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`); diff --git a/cli/commands.ts b/cli/commands.ts index 151c217..fd850b1 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -80,6 +80,7 @@ export const COMMANDS: Record Promise, 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 )'], + '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'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 467de14..370e4cf 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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]" ], diff --git a/cli/lib/config.ts b/cli/lib/config.ts index f64bb93..152d025 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -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 + * //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`. */ diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index 67c90f4..4d6606f 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -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 //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)' } }, diff --git a/cli/shots/creative.ts b/cli/shots/creative.ts new file mode 100644 index 0000000..d5828d6 --- /dev/null +++ b/cli/shots/creative.ts @@ -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: //creative/{header,search}.{png,jpg,jpeg,mov,m4v,mp4}; universal fallback +// //creative/universal.png or /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 = { header: 'PRODUCT_PAGE_HEADER_ASSET', search: 'APP_STORE_SEARCH_RESULTS_ASSET' }; +export const PLACEMENT_LABEL: Record = { 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 { + const out = new Map extends Map ? 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; +} diff --git a/cli/shots/gallery.ts b/cli/shots/gallery.ts index b476179..d94044f 100644 --- a/cli/shots/gallery.ts +++ b/cli/shots/gallery.ts @@ -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 { 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 (/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; } diff --git a/cli/validate.ts b/cli/validate.ts index b3843c3..72aa93c 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -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 { @@ -196,6 +197,8 @@ export async function run(ctx: Ctx): Promise { 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 })); } diff --git a/docs/api-coverage.md b/docs/api-coverage.md index ca7c1b5..664f363 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -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 | | diff --git a/docs/store-art.md b/docs/store-art.md index a9b2567..bafe71d 100644 --- a/docs/store-art.md +++ b/docs/store-art.md @@ -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: `//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 `/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. diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 236b1a8..0479986 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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 "), 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). diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index b16019a..5a223c7 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -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: `//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 `/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`, diff --git a/tests/creative-assets.test.ts b/tests/creative-assets.test.ts new file mode 100644 index 0000000..1239a96 --- /dev/null +++ b/tests/creative-assets.test.ts @@ -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) => { + 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, 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); + }); +}); diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts index 5fa874b..bf45d8c 100644 --- a/tests/helpers/fake-stores.ts +++ b/tests/helpers/fake-stores.ts @@ -71,6 +71,10 @@ const LISTS: Record = { '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> = { @@ -97,7 +101,9 @@ const UNIQUE: Record 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> = { 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