storebound/cli/shots/lib.ts
CRT a9261a8052 play images: tablet, TV and Wear sets are optional; play.tablets switch (default: tablet renders exist); a missing optional set never deletes Play's images without --prune
A phone-only project aborted with 'no sevenInchScreenshots'. Play requires the 512 icon, the 1024x500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151). play images uploads what exists and prints an info line for missing optional sets; play.tablets true makes missing tablet sets an error in shots render/check/gallery, validate and play images. Pitfall 19.
2026-10-12 08:21:03 +08:00

197 lines
13 KiB
TypeScript

// Pure parts of the screenshot pipeline: store sizes, palettes, per-language script fonts, copy validation, job data.
import { specFamily } from './local-fonts';
import type { Palette, ShipConfig, ShotScene, ShotsConfig } from '../lib/config';
/** Store image sizes. ios69 = 6.9" (APP_IPHONE_67), ios65 = 6.5" (APP_IPHONE_65), iphone-medium = 6.3" iPhone with
* Dynamic Island, medium display (APP_IPHONE_61, only with apple.iphoneMedium), ipad13 = 13" iPad
* (APP_IPAD_PRO_3GEN_129, only for apps that run on iPad), play = Play phone 9:16, tab7 / tab10 = Play 7" / 10"
* tablets (tablet frames), feature = Play feature graphic. Rules per size: spec.ts. */
export const SIZES: Record<string, { w: number; h: number; device?: 'tablet'; feature?: true }> = {
ios69: { w: 1320, h: 2868 },
ios65: { w: 1284, h: 2778 },
'iphone-medium': { w: 1206, h: 2622 },
ipad13: { w: 2064, h: 2752, device: 'tablet' },
play: { w: 1080, h: 1920 },
tab7: { w: 1080, h: 1920, device: 'tablet' },
tab10: { w: 1440, h: 2560, device: 'tablet' },
feature: { w: 1024, h: 500, feature: true },
};
export const PALETTES: Record<string, Palette> = {
fire: { bg: ['#ff3d2e', '#ff7a1a', '#ffc43a'], glow: 'rgba(255,240,150,.95)', ink: '#6e1300', shadow: 'rgba(120,10,0,.45)' },
violet: { bg: ['#2b1676', '#6a2fe0', '#ff5fb8'], glow: 'rgba(255,170,240,.75)', ink: '#22095e', shadow: 'rgba(30,0,80,.55)' },
blast: { bg: ['#ff2d55', '#ff6a2a', '#ffb02e'], glow: 'rgba(255,236,140,.9)', ink: '#6a0a1e', shadow: 'rgba(110,0,30,.45)' },
green: { bg: ['#0fa35c', '#3cc96a', '#ff6f91'], glow: 'rgba(230,255,170,.85)', ink: '#0a4a2a', shadow: 'rgba(0,60,30,.45)' },
night: { bg: ['#14105a', '#4a2bd6', '#11b8ff'], glow: 'rgba(140,230,255,.85)', ink: '#170d55', shadow: 'rgba(10,0,70,.55)' },
sky: { bg: ['#0a6fe0', '#21a8ff', '#7be3ff'], glow: 'rgba(255,255,255,.85)', ink: '#06336e', shadow: 'rgba(0,40,100,.45)' },
gold: { bg: ['#ff8a00', '#ffb300', '#ffe14a'], glow: 'rgba(255,255,220,.95)', ink: '#6b2c00', shadow: 'rgba(120,50,0,.4)' },
candy: { bg: ['#d01f7a', '#ff4f8b', '#ff9f43'], glow: 'rgba(255,220,180,.9)', ink: '#5e0636', shadow: 'rgba(90,0,50,.45)' },
};
/** Calm palettes for the app style: bg = gradient stops, ink = headline colour, glow = accent (eyebrow, second
* headline line), shadow = device shadow. Light ones first, then dark ones (ocean, slate, night-ink). */
export const APP_PALETTES: Record<string, Palette> = {
mist: { bg: ['#eef2ff', '#e6ebff', '#f6f4ff'], glow: '#4f46e5', ink: '#111827', shadow: 'rgba(30,41,90,.22)' },
paper: { bg: ['#ffffff', '#f7f8fa', '#eef1f5'], glow: '#0f766e', ink: '#0f172a', shadow: 'rgba(15,23,42,.18)' },
mint: { bg: ['#ecfdf5', '#dcfce7', '#f0fdfa'], glow: '#059669', ink: '#064e3b', shadow: 'rgba(6,78,59,.2)' },
sand: { bg: ['#fffbeb', '#fef3c7', '#fff7ed'], glow: '#c2410c', ink: '#3b2a0b', shadow: 'rgba(90,50,10,.2)' },
rose: { bg: ['#fff1f2', '#ffe4e6', '#fdf2f8'], glow: '#e11d48', ink: '#4c0519', shadow: 'rgba(90,10,40,.2)' },
sky: { bg: ['#f0f9ff', '#e0f2fe', '#eef6ff'], glow: '#0369a1', ink: '#0c2a43', shadow: 'rgba(10,50,90,.2)' },
ocean: { bg: ['#0b1736', '#14306e', '#1d4ed8'], glow: '#93c5fd', ink: '#ffffff', shadow: 'rgba(0,0,0,.45)' },
slate: { bg: ['#0f172a', '#1e293b', '#334155'], glow: '#a5b4fc', ink: '#f8fafc', shadow: 'rgba(0,0,0,.5)' },
};
export const palette = (p: string | Palette | undefined, style: 'game' | 'app' = 'game'): Palette => {
const own = style === 'app' ? APP_PALETTES : PALETTES;
if (!p) return style === 'app' ? APP_PALETTES.mist! : PALETTES.fire!;
if (typeof p !== 'string') return p;
const hit = own[p] ?? PALETTES[p] ?? APP_PALETTES[p];
if (!hit) throw new Error(`unknown palette "${p}" (${Object.keys(own).join(', ')}) or pass { bg, glow, ink, shadow }`);
return hit;
};
/** Screenshot fonts: shots.fonts, else per style (game: Paytone One titles + Montserrat; app: Inter for both). All
* three cover Latin Extended-A / B (Turkish, Polish, Czech, Romanian ...) and Vietnamese; Montserrat and Inter also
* Cyrillic. The app title is a variable font (bold headline weights). Per-script stack: glyphs.ts. */
export function shotFonts(shots: Pick<ShotsConfig, 'fonts'> | undefined, style: 'game' | 'app'): { title: string; body: string; titleVariable: boolean } {
const d = style === 'app' ? { title: 'Inter', body: 'Inter' } : { title: 'Paytone One', body: 'Montserrat' };
// local files: the family given in the config, else the file name (resolveShotFonts reads the real name table)
return { title: specFamily(shots?.fonts?.title ?? d.title), body: specFamily(shots?.fonts?.body ?? d.body), titleVariable: style === 'app' };
}
export interface SceneCopy { eyebrow?: string; title: [string, string]; sub?: string; badges?: [string, string?][] }
export type CopyFile = Record<string, Record<string, SceneCopy>>;
/** Noto family + slug for the first non-Latin script found in a language's text, or null for Latin-only text. */
export function scriptFont(lang: string, text: string): { family: string; slug: string } | null {
if (/\p{Script=Hangul}/u.test(text)) return { family: 'Noto Sans KR', slug: 'kr' };
if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) return { family: 'Noto Sans JP', slug: 'jp' };
if (/\p{Script=Han}/u.test(text)) return /Hant|TW|HK/.test(lang) ? { family: 'Noto Sans TC', slug: 'tc' } : { family: 'Noto Sans SC', slug: 'sc' };
if (/\p{Script=Arabic}/u.test(text)) return { family: 'Noto Sans Arabic', slug: 'arabic' };
if (/\p{Script=Hebrew}/u.test(text)) return { family: 'Noto Sans Hebrew', slug: 'hebrew' };
if (/\p{Script=Devanagari}/u.test(text)) return { family: 'Noto Sans Devanagari', slug: 'devanagari' };
if (/\p{Script=Bengali}/u.test(text)) return { family: 'Noto Sans Bengali', slug: 'bengali' };
if (/\p{Script=Thai}/u.test(text)) return { family: 'Noto Sans Thai', slug: 'thai' };
if (/[\p{Script=Cyrillic}\p{Script=Greek}]/u.test(text)) return { family: 'Noto Sans', slug: 'cyrgreek' };
return null;
}
/** Every string of a language's copy (for font subsetting and script detection), upper-cased eyebrows included. */
export function copyText(lang: string, c: Record<string, SceneCopy> | undefined): string {
if (!c) return '';
return Object.values(c).flatMap(s => [s.eyebrow ?? '', (s.eyebrow ?? '').toLocaleUpperCase(lang), ...s.title, s.sub ?? '',
...(s.badges ?? []).flat().filter((x): x is string => !!x)]).join('');
}
/** Problems with the copy file for the configured scenes and languages. */
export function validateCopy(copy: CopyFile, shots: ShotsConfig, langs: string[]): string[] {
const e: string[] = [];
for (const lang of langs) {
const c = copy[lang];
if (!c) { e.push(`${lang}: no copy`); continue; }
for (const s of shots.scenes) {
const sc = c[s.id];
if (!sc) { e.push(`${lang} scene ${s.id}: missing`); continue; }
if (!Array.isArray(sc.title) || sc.title.length !== 2) e.push(`${lang} scene ${s.id}: title must be [line1, line2]`);
const slots = s.badges?.length ?? 0;
if ((sc.badges?.length ?? 0) > slots) e.push(`${lang} scene ${s.id}: ${sc.badges!.length} badges but the scene has ${slots} slots`);
}
}
return e;
}
export interface FrameDims { w: number; h: number; dpr: number; bar: number }
/** Raw frame size in CSS px below the status bar (bar = status bar height in CSS px, drawn by the template).
* Phone: 6.9" iPhone (440 x 956 pt, 54 pt bar). Tablet: a generic Android tablet (Play 7" / 10"). */
export const DEFAULT_PHONE: FrameDims = { w: 440, h: 902, dpr: 3, bar: 54 };
export const DEFAULT_TABLET: FrameDims = { w: 800, h: 1240, dpr: 2, bar: 40 };
/** 13" iPad (iPad Pro M4 / M5, iPad Air 13" M2+): 1032 x 1376 pt @2x = 2064 x 2752 px, 24 pt status bar. The tablet
* default when apple.ipad is set, so a simulator screenshot of a 13" iPad fits the frame after cropping the bar. */
export const DEFAULT_IPAD: FrameDims = { w: 1032, h: 1352, dpr: 2, bar: 24 };
/** Phone / tablet frame dims for a project: defaults (tablet follows the 13" iPad when apple.ipad) + shots.phone / tablet. */
export function frameDims(config: Pick<ShipConfig, 'apple' | 'shots'>): Record<'phone' | 'tablet', FrameDims> {
const tablet = config.apple?.ipad ? DEFAULT_IPAD : DEFAULT_TABLET;
return { phone: { ...DEFAULT_PHONE, ...config.shots?.phone }, tablet: { ...tablet, ...config.shots?.tablet } };
}
/** Play tablet size keys (7" and 10"): optional on Play, switched by play.tablets. */
export const PLAY_TABLET_SIZES: readonly string[] = ['tab7', 'tab10'];
/** Size keys this project renders by default: Apple sizes only with an apple section (iPad only with apple.ipad, the
* 6.3" iPhone only with apple.iphoneMedium),
* Play sizes only with a play section (both when neither is configured); the Play tablet sizes only when `tablets`
* (resolved play.tablets, see resolvedTablets in report.ts; unset here = play.tablets ?? true). */
export function defaultSizes(config: Pick<ShipConfig, 'apple' | 'play'>, tablets?: boolean): string[] {
const apple = !!config.apple || !config.play, play = !!config.play || !config.apple;
const tabs = tablets ?? config.play?.tablets ?? true;
return Object.keys(SIZES).filter(k => (k === 'ipad13' ? apple && !!config.apple?.ipad : k === 'iphone-medium' ? apple && !!config.apple?.iphoneMedium
: k.startsWith('ios') ? apple : PLAY_TABLET_SIZES.includes(k) ? play && tabs : play));
}
/** Scenes of a variant (its order / subset, palette overrides), or the base scenes. */
export function variantScenes(shots: ShotsConfig, variant?: string): ShotScene[] {
if (!variant) return shots.scenes;
const v = shots.variants?.[variant];
if (!v) throw new Error(`unknown variant "${variant}" (${Object.keys(shots.variants ?? {}).join(', ') || 'none configured in shots.variants'})`);
const byId = new Map(shots.scenes.map(s => [s.id, s] as const));
return (v.scenes ?? shots.scenes.map(s => s.id)).map(id => {
const s = byId.get(id);
if (!s) throw new Error(`variant ${variant}: unknown scene ${id}`);
return v.palettes?.[id] ? { ...s, palette: v.palettes[id]! } : s;
});
}
/** Base copy with a variant's copy merged over it per language and scene (fields of the variant win). */
export function mergeCopy(base: CopyFile, over: CopyFile | undefined): CopyFile {
if (!over) return base;
const out: CopyFile = {};
for (const lang of new Set([...Object.keys(base), ...Object.keys(over)])) {
out[lang] = { ...base[lang] };
for (const [id, sc] of Object.entries(over[lang] ?? {})) out[lang]![id] = { ...base[lang]?.[id], ...sc } as SceneCopy;
}
return out;
}
/** Output folder for a language (and variant): <out>/<lang> or <out>/<variant>/<lang>. */
export const renderDir = (out: string, lang: string, variant?: string): string => (variant ? `${out}/${variant}/${lang}` : `${out}/${lang}`);
/** One caption per clip from the scene titles ("line one line two"), cycling through the scenes. */
export function captionsFor(copy: Record<string, SceneCopy> | undefined, sceneIds: string[], clips: number): string[] {
const titles = sceneIds.map(id => copy?.[id]?.title?.filter(Boolean).join(' ') ?? '').filter(Boolean);
if (!titles.length) return Array.from({ length: clips }, () => '');
return Array.from({ length: clips }, (_, i) => titles[i % titles.length]!);
}
export interface SheetTile { file: string; x: number; y: number; w: number; h: number }
/** Contact sheet layout: one block per size (its scenes side by side, all at the same height; the feature graphic at
* half height), blocks packed onto shelves up to `maxW` wide so a project with one or two scenes gets a compact grid
* instead of a tall strip. Pure. */
export function sheetLayout(rows: { size: string; files: string[] }[], o: { h?: number; gap?: number; maxW?: number } = {}): { w: number; h: number; tiles: SheetTile[] } {
const H0 = o.h ?? 900, gap = o.gap ?? 24;
const blocks = rows.filter(r => r.files.length).map(r => {
const d = SIZES[r.size]!, h = d.feature ? Math.round(H0 / 2) : H0, w = Math.round(h * d.w / d.h);
return { r, h, tw: w, w: r.files.length * w + (r.files.length - 1) * gap };
});
const maxW = Math.max(o.maxW ?? 3600, ...blocks.map(b => b.w));
const tiles: SheetTile[] = [];
let x = gap, y = gap, shelf = 0, W = 0;
for (const b of blocks) {
if (x > gap && x + b.w > maxW + gap) { x = gap; y += shelf + gap; shelf = 0; }
b.r.files.forEach((file, i) => tiles.push({ file, x: x + i * (b.tw + gap), y, w: b.tw, h: b.h }));
x += b.w + gap * 2;
shelf = Math.max(shelf, b.h);
W = Math.max(W, x - gap);
}
return { w: W, h: blocks.length ? y + shelf + gap : 0, tiles };
}
/** Renders root an upload reads from: the base folder, or <renders>/<variant> for `--variant <id>` (must be one of
* shots.variants; "base" or empty = the base renders). Pure. */
export function variantRoot(renders: string, variant: string | undefined, variants: Record<string, unknown> | undefined): string {
if (!variant || variant === 'base') return renders;
if (!variants?.[variant]) throw new Error(`--variant ${variant}: not in shots.variants (${Object.keys(variants ?? {}).join(', ') || 'none configured'})`);
return `${renders.replace(/\/$/, '')}/${variant}`;
}