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

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