// Store art pulled from the live stores (`storebound art pull`): where each live image goes in the project's store art // layout, the upsert plan against the local files, and the manifest (/pulled.json) that lets the checks and // the uploads treat pulled files like rendered art. Pure except readPulled / pulledChecksum (they read files). // // Layout (the same as `shots render` writes, so check / validate / play images / apple screenshots read it unchanged): // //ios69|ios65|iphone-medium|ipad13/NN.png App Store screenshot sets (APP_IPHONE_67, _65, _61, // APP_IPAD_PRO_3GEN_129); other display types go to // apple-/ (kept, not checked or uploaded) // //preview/iphone|ipad/NN.mp4 App Store app previews (IPHONE_67, IPAD_PRO_3GEN_129) // //play|tab7|tab10|tv|wear/NN.png|jpg Play screenshots per type // //feature/1024x500.png|jpg Play feature graphic // /app-store-1024.png, play.icon (or /play-512.png) the icons // pulled.json: per file (path relative to the renders folder) the store, type, store locale, the original URL's host // (never the URL), the local file's sha256, the store's own checksum of the uploaded file (App Store: MD5 // sourceFileChecksum; Play: sha256) and when it was pulled; plus `storeEmpty`: the / sets the store had // nothing for (a Play language using the default language's graphics, an App Store locale without that set), which // the local checks then do not demand. import { createHash } from 'node:crypto'; import { existsSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, relative, sep } from 'node:path'; export type ArtStore = 'apple' | 'play'; export type ArtKind = 'screenshot' | 'preview' | 'icon' | 'featureGraphic'; /** One live image (or preview) on a store. */ export interface RemoteArt { store: ArtStore; kind: ArtKind; /** ASC screenshotDisplayType / previewType, or the Play image type. */ type: string; /** Store locale (App Store locale, Play language code); '' for the App Store icon. */ locale: string; /** Position in its set, from 0. */ index: number; /** Download URL of the original (App Store: the template at the original size; Play: =s0). */ url: string; /** A small version for thumbnails. */ thumb: string; w?: number; h?: number; /** The store's checksum of the uploaded file (ASC sourceFileChecksum = MD5, Play sha256); null when it has none. */ checksum: string | null; /** Stable id of the store asset (ASC id, Play image id, icon token). */ id: string; } /** App Store display type -> renders size folder. */ export const APPLE_SHOT_DIR: Record = { APP_IPHONE_67: 'ios69', APP_IPHONE_65: 'ios65', APP_IPHONE_61: 'iphone-medium', APP_IPAD_PRO_3GEN_129: 'ipad13' }; /** App Store preview type -> preview folder. */ export const APPLE_PREVIEW_DIR: Record = { IPHONE_67: 'iphone', IPAD_PRO_3GEN_129: 'ipad' }; /** Play image type -> renders size folder (screenshots; the feature graphic and icon are files of their own). */ export const PLAY_SHOT_DIR: Record = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10', tvScreenshots: 'tv', wearScreenshots: 'wear' }; export const PLAY_TYPES = ['icon', 'featureGraphic', ...Object.keys(PLAY_SHOT_DIR)] as const; export const nn = (i: number): string => String(i + 1).padStart(2, '0'); export const posix = (p: string): string => p.split(sep).join('/'); /** The size folder of an App Store type (unknown display types keep their own folder, outside the checks). */ export function appleDir(kind: ArtKind, type: string): string { if (kind === 'preview') return `preview/${APPLE_PREVIEW_DIR[type] ?? `apple-${type.toLowerCase()}`}`; return APPLE_SHOT_DIR[type] ?? `apple-${type.toLowerCase()}`; } /** App Store template URL (`.../{w}x{h}{c}.{f}`) filled in. */ export function fillTemplate(t: string, w: number, h: number, f: 'png' | 'jpg'): string { return t.replace('{w}', String(w)).replace('{h}', String(h)).replace('{c}', 'bb').replace('{f}', f); } /** Thumbnail size: the long side at most `max`, the aspect kept. */ export function thumbSize(w: number, h: number, max = 300): [number, number] { const s = Math.min(1, max / Math.max(w, h || 1)); return [Math.max(1, Math.round(w * s)), Math.max(1, Math.round(h * s))]; } /** Play image URL: the original bytes (=s0) or a thumbnail (=w). Any size suffix already there is replaced. */ export const playUrl = (url: string, size: 'original' | number): string => url.replace(/=[a-z0-9-]+$/i, '') + (size === 'original' ? '=s0' : `=w${size}`); /** Where a live image goes, as a path relative to the project root WITHOUT its extension for images whose format is * known only after the download (Play), with `ext` the default. null: no config language for this locale. */ export interface Slot { rel: string; ext: string; lang: string; size: string } export interface SlotCtx { renders: string; appleIcon: string; playIcon: string; /** Store locale -> config language id. */ langOf: (store: ArtStore, locale: string) => string | null; } export function slotOf(r: RemoteArt, c: SlotCtx): Slot | null { if (r.kind === 'icon') { const f = r.store === 'apple' ? c.appleIcon : c.playIcon; const m = /\.(png|jpe?g)$/i.exec(f); return { rel: m ? f.slice(0, -m[0].length) : f, ext: m ? m[1]!.toLowerCase() : 'png', lang: '', size: 'icon' }; } const lang = c.langOf(r.store, r.locale); if (!lang) return null; if (r.store === 'apple') { const size = appleDir(r.kind, r.type); return { rel: `${c.renders}/${lang}/${size}/${nn(r.index)}`, ext: r.kind === 'preview' ? 'mp4' : 'png', lang, size }; } if (r.kind === 'featureGraphic') return { rel: `${c.renders}/${lang}/feature/1024x500`, ext: 'png', lang, size: 'feature' }; const size = PLAY_SHOT_DIR[r.type]; if (!size) return null; return { rel: `${c.renders}/${lang}/${size}/${nn(r.index)}`, ext: 'png', lang, size }; } // --------------------------------------------------------------------------------------------------------------------- // The manifest export interface PulledEntry { store: ArtStore; kind: ArtKind; type: string; locale: string; /** Host of the original URL (is1-ssl.mzstatic.com, lh3.googleusercontent.com); never the URL itself. */ host: string; /** sha256 of the local file as pulled. */ sha256: string; /** The store's checksum of the uploaded original (App Store MD5, Play sha256), when it gives one. */ storeChecksum: string | null; /** The store asset id (App Store screenshot id, Play image id, icon token hash). */ storeId: string; pulledAt: string; } export interface Pulled { version: 1; note: string; /** Path relative to the renders folder -> entry. */ files: Record; /** / sets the store had nothing for when pulled. */ storeEmpty: string[]; pulledAt: string; } export const PULLED_FILE = 'pulled.json'; export const PULLED_NOTE = 'Store art pulled from the live stores by `storebound art pull` (sha256 of each file, the store and its checksum). Files still matching their sha256 are the store\'s own: uploads skip them and the checks do not ask for render sources. Edit or re-render a file and it counts as your own again.'; export const emptyPulled = (): Pulled => ({ version: 1, note: PULLED_NOTE, files: {}, storeEmpty: [], pulledAt: '' }); const cache = new Map(); /** /pulled.json, or null when there is none (or it does not parse). */ export function readPulled(rendersDir: string): Pulled | null { const f = join(rendersDir, PULLED_FILE); let mtime = -1; try { mtime = statSync(f).mtimeMs; } catch { return null; } const hit = cache.get(f); if (hit && hit.mtime === mtime) return hit.data; let data: Pulled | null = null; try { const j = JSON.parse(readFileSync(f, 'utf8')) as Partial; if (j && typeof j === 'object' && j.files && typeof j.files === 'object') data = { ...emptyPulled(), ...j, files: j.files, storeEmpty: Array.isArray(j.storeEmpty) ? j.storeEmpty : [] } as Pulled; } catch { data = null; } cache.set(f, { mtime, data }); return data; } export const sha256Of = (b: Uint8Array): string => createHash('sha256').update(b).digest('hex'); export const md5Of = (b: Uint8Array): string => createHash('md5').update(b).digest('hex'); /** The pulled entry of a file that is still exactly as pulled (sha256 equal), else null. */ export function pulledEntry(p: Pulled | null, rendersDir: string, file: string, bytes?: Uint8Array): PulledEntry | null { if (!p) return null; const e = p.files[posix(relative(rendersDir, file))]; if (!e) return null; try { return sha256Of(bytes ?? readFileSync(file)) === e.sha256 ? e : null; } catch { return null; } } /** For uploads: the store's checksum of a file that is still exactly as pulled from that store (App Store MD5 / Play * sha256 of the original upload), so the upload compares the store with itself and sends nothing. null otherwise. * The manifest is looked up in the file's parent folders (renders///NN.png: three up). */ export function pulledChecksum(file: string, store: ArtStore): string | null { let d = dirname(file); for (let i = 0; i < 5; i++) { if (existsSync(join(d, PULLED_FILE))) { const e = pulledEntry(readPulled(d), d, file); return e && e.store === store && e.storeChecksum ? e.storeChecksum : null; } const up = dirname(d); if (up === d) break; d = up; } return null; } // --------------------------------------------------------------------------------------------------------------------- // The plan export type ArtAction = 'add' | 'same' | 'update' | 'conflict' | 'remove'; export interface LocalFile { rel: string; sha256: string; md5: string } export interface ArtStep { action: ArtAction; /** Target path relative to the project root (with the extension of the local file, or the default one). */ rel: string; remote: RemoteArt | null; why: string; /** Overwrite / remove only with --force. */ needsForce?: boolean; /** `same` because the local file is the store's (checksum equal) but not yet in the manifest: record it. */ adopt?: boolean; } /** What to do with one live image given the local file in its slot (any extension) and the manifest entry of that * file. Pure. `force`: a local file that differs and is not an unchanged pull is overwritten. */ export function decide(r: RemoteArt, slotRel: string, local: LocalFile | null, entry: PulledEntry | null, force: boolean, defaultExt: string): ArtStep { if (!local) return { action: 'add', rel: `${slotRel}.${defaultExt}`, remote: r, why: 'not in the project yet' }; const ours = entry && entry.sha256 === local.sha256; if (ours) { if (entry.storeId === r.id && (entry.storeChecksum ?? null) === (r.checksum ?? null)) return { action: 'same', rel: local.rel, remote: r, why: 'pulled before, unchanged on the store' }; return { action: 'update', rel: local.rel, remote: r, why: 'changed on the store since the last pull (the local file is the earlier pull, unedited)' }; } const sameAsStore = !!r.checksum && (r.store === 'play' ? local.sha256 === r.checksum : local.md5 === r.checksum); if (sameAsStore) return { action: 'same', rel: local.rel, remote: r, why: 'the local file is the one on the store', adopt: true }; return force ? { action: 'update', rel: local.rel, remote: r, why: 'the local file differs from the store (--force: replaced)', needsForce: true } : { action: 'conflict', rel: local.rel, remote: r, why: entry ? 'edited after the pull: differs from the store' : 'your own file differs from the store', needsForce: true }; } /** Local NN files of a set beyond what the store has: removed when they are unchanged pulls (or with --force), else a * conflict (the set would not match the store). Pure. */ export function extraStep(local: LocalFile, entry: PulledEntry | null, force: boolean): ArtStep { const ours = !!entry && entry.sha256 === local.sha256; if (ours) return { action: 'remove', rel: local.rel, remote: null, why: 'no longer on the store (an earlier pull)' }; return force ? { action: 'remove', rel: local.rel, remote: null, why: 'not on the store (--force: removed)', needsForce: true } : { action: 'conflict', rel: local.rel, remote: null, why: 'your own file, not on the store: the set would differ from the store', needsForce: true }; } export interface ArtSummary { add: number; same: number; update: number; conflict: number; remove: number } export const summarize = (steps: ArtStep[]): ArtSummary => { const s: ArtSummary = { add: 0, same: 0, update: 0, conflict: 0, remove: 0 }; for (const x of steps) s[x.action]++; return s; }; /** File extension from the first bytes (PNG / JPEG / MP4 / QuickTime). */ export function sniffExt(b: Uint8Array): 'png' | 'jpg' | 'mp4' | 'mov' | null { if (b.length > 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'png'; if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpg'; if (b.length > 12 && String.fromCharCode(b[4]!, b[5]!, b[6]!, b[7]!) === 'ftyp') return String.fromCharCode(b[8]!, b[9]!) === 'qt' ? 'mov' : 'mp4'; return null; } /** Hosts storebound downloads store art from (and the desktop's thumbnails): Apple's and Google's image CDNs only. */ export const ART_HOST_RE = /^https:\/\/([a-z0-9-]+\.)*(mzstatic\.com|googleusercontent\.com|ggpht\.com)\//i; export const hostOf = (url: string): string => { try { return new URL(url).host; } catch { return ''; } }; /** Every / of the required store sets that got no file from the store. Pure. */ export function storeEmptySets(langs: { id: string; apple: boolean; play: boolean }[], filled: Set, sizes: { apple: string[]; play: string[] }): string[] { const out: string[] = []; for (const l of langs) { for (const s of l.apple ? sizes.apple : []) if (!filled.has(`${l.id}/${s}`)) out.push(`${l.id}/${s}`); for (const s of l.play ? sizes.play : []) if (!filled.has(`${l.id}/${s}`)) out.push(`${l.id}/${s}`); } return out.sort(); }