store art from the live stores: storebound art pull (App Store screenshot sets and previews per locale and display type from imageAsset.templateUrl at the original size, the App Store icon from the newest build or the public artwork; per Play language icon, feature graphic, phone / 7" / 10" / TV / Wear screenshots as the =s0 originals checked against Play's sha256) into store/renders with pulled.json (sha256, store, URL host, store checksum); upsert (a re-run changes nothing, differing local files kept unless --force, the upload's file of a slot compared: Play .jpg, App Store .png, the App Store icon as a picture); onboard runs it; apple screenshots / previews and play images see unchanged pulled files as the store's own; check: render sources are notes with pulled art; local renders check: pulled files pass, store-empty sets not demanded; art live / thumb / replace; desktop Store art tab: live thumbnails per store, language and set (same / differs / not pulled), Pull from stores, store-only apps read-only, Replace with a file (image_pick, art.replace) in the lightbox and the art editor; boot check store art case; tests/art-pull.test.ts; console parity 76 rows (57 complete, 19 with the reason); store-screenshots skill (pull first for live apps), pitfall 59

This commit is contained in:
CRTkafa 2026-10-14 02:18:38 +08:00
parent 49a73c90e4
commit 00be838b76
40 changed files with 1669 additions and 58 deletions

256
cli/lib/art-pull.ts Normal file
View file

@ -0,0 +1,256 @@
// 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 (<renders>/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):
// <renders>/<lang id>/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-<type>/ (kept, not checked or uploaded)
// <renders>/<lang id>/preview/iphone|ipad/NN.mp4 App Store app previews (IPHONE_67, IPAD_PRO_3GEN_129)
// <renders>/<lang id>/play|tab7|tab10|tv|wear/NN.png|jpg Play screenshots per type
// <renders>/<lang id>/feature/1024x500.png|jpg Play feature graphic
// <icons.out>/app-store-1024.png, play.icon (or <icons.out>/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 <lang>/<size> 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<string, string> = { 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<string, string> = { 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<string, string> = { 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<n>). 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<string, PulledEntry>;
/** <lang id>/<size> 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<string, { mtime: number; data: Pulled | null }>();
/** <renders>/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<Pulled>;
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/<lang>/<size>/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 <lang>/<size> 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<string>, 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();
}