diff --git a/CHANGELOG.md b/CHANGELOG.md index 6790cdc..d4f68fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ read-only and fails when one differs from the local file. A file App Review rejected, or one that failed processing twice, is not uploaded again; an archived copy of the same file is unarchived instead of uploaded. A live app without an editable version uses the live version: approved assets are selected there, new ones are uploaded and wait for a - review of their own. Pitfall 76 rewritten. + review of their own. Art without words needs no copy per locale: `/creative/header.png` / `search.*` + fill every language without its own file. Pitfall 76 rewritten. ## 0.8.13 ยท 2026-10-18 diff --git a/cli/shots/creative.ts b/cli/shots/creative.ts index d5828d6..65d36d7 100644 --- a/cli/shots/creative.ts +++ b/cli/shots/creative.ts @@ -2,8 +2,9 @@ // 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). +// Files: //creative/{header,search}.{png,jpg,jpeg,mov,m4v,mp4}, else the same names in +// /creative/ for every locale; 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. @@ -29,16 +30,20 @@ export interface CreativeFile { placement: Placement; file: string; kind: 'image 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. */ +/** The file for each placement of one language: its own header / search file first, then a shared one for every + * language (/creative/header.png: art without words needs no copy per locale), then 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 dir = join(renders, lang, 'creative'), shared = join(renders, 'creative'); const own = list(dir).filter(f => EXT.test(f)).sort(); + const common = list(shared).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; + : common.includes('universal.png') ? join(shared, 'universal.png') : undefined; const out: CreativeFile[] = []; for (const p of ['header', 'search'] as Placement[]) { const f = own.find(x => x.toLowerCase().startsWith(`${p}.`)); + const g = common.find(x => x.toLowerCase().startsWith(`${p}.`)); if (f) out.push({ placement: p, file: join(dir, f), kind: kindOf(f), universal: false }); + else if (g) out.push({ placement: p, file: join(shared, g), kind: kindOf(g), universal: false }); else if (uni) out.push({ placement: p, file: uni, kind: 'image', universal: true }); } return out; diff --git a/docs/store-art.md b/docs/store-art.md index 77ab175..93ca1b2 100644 --- a/docs/store-art.md +++ b/docs/store-art.md @@ -93,7 +93,8 @@ Since 5 Oct 2026 the App Store version page has a "Header and Search Results" ta 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 + (also `/creative/universal.png` for every locale, or `apple.creativeAssets.universal`). Art without words + needs no copy per locale: `/creative/header.png` / `search.png` fill every language without its own file. 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 diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index de05ca4..b07203c 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -202,7 +202,8 @@ Since 5 Oct 2026 the App Store version page has a "Header and Search Results" ta 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 + (also `/creative/universal.png` for every locale, or `apple.creativeAssets.universal`). Art without words + needs no copy per locale: `/creative/header.png` / `search.png` fill every language without its own file. 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 diff --git a/tests/creative-assets.test.ts b/tests/creative-assets.test.ts index 4c4da94..07b5daa 100644 --- a/tests/creative-assets.test.ts +++ b/tests/creative-assets.test.ts @@ -53,6 +53,11 @@ describe('creative asset checks', () => { 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('files: a shared /creative/header.png fills every language without its own', () => { + const list = (d: string) => (d === '/r/creative' ? ['header.png', 'search.mp4', 'universal.png'] : d === '/r/tr/creative' ? ['search.png'] : []); + expect(creativeFiles('/r', 'en', list).map(f => [f.placement, f.file, f.kind, f.universal])).toEqual([['header', '/r/creative/header.png', 'image', false], ['search', '/r/creative/search.mp4', 'video', false]]); + expect(creativeFiles('/r', 'tr', list).map(f => f.file)).toEqual(['/r/creative/header.png', '/r/tr/creative/search.png']); + }); 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);