storebound/cli/lib/art-pull.ts

256 lines
14 KiB
TypeScript

// 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();
}