// 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 = { 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 = { 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 = { 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 | 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>; /** 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 | 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): 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 } }; } /** 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). */ export function defaultSizes(config: Pick): string[] { const apple = !!config.apple || !config.play, play = !!config.play || !config.apple; return Object.keys(SIZES).filter(k => (k === 'ipad13' ? apple && !!config.apple?.ipad : k === 'iphone-medium' ? apple && !!config.apple?.iphoneMedium : k.startsWith('ios') ? apple : 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): / or //. */ 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 | 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 / for `--variant ` (must be one of * shots.variants; "base" or empty = the base renders). Pure. */ export function variantRoot(renders: string, variant: string | undefined, variants: Record | 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}`; }