// 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' }, // iPhone Duo (foldable), only with apple.iphoneDuo: near-tablet aspect, so the tablet frames fill it 'iphone-duo-outer': { w: 1398, h: 2034, device: 'tablet' }, 'iphone-duo-inner': { w: 2007, h: 2853, device: 'tablet' }, // Mac App Store (APP_DESKTOP, only with apple.mac): 16:10, landscape layout from //landscape/ mac: { w: 2880, h: 1800, device: 'landscape' }, play: { w: 1080, h: 1920 }, tab7: { w: 1080, h: 1920, device: 'tablet' }, tab10: { w: 1440, h: 2560, device: 'tablet' }, // Play Chromebook screenshots (only with play.chromebook): 16:9 landscape; the Play API has no Chromebook image type, // so they stay in the renders folder for the Play Console (play images says so) chromebook: { w: 1920, h: 1080, device: 'landscape' }, feature: { w: 1024, h: 500, feature: true }, }; /** Landscape renders of the Play tablet slots (shots.landscape.playTablets): Play takes any 320-3840 px size up to 2:1. */ export const PLAY_TABLET_LANDSCAPE: Record = { tab7: { w: 1920, h: 1080 }, tab10: { w: 2560, h: 1440 } }; /** Pixel size and layout of one size key for a project: the landscape layout for mac / chromebook, and for the Play * tablet slots with shots.landscape.playTablets. frame = the device drawn around a landscape frame ('window' = a Mac * window, 'none' = the bare frame with rounded corners); default window on Mac, none on Play. Pure. */ export function renderDims(size: string, shots: Pick | undefined): { w: number; h: number; landscape: boolean; frame: 'window' | 'none' } { const s = SIZES[size]!; const tabLand = !!shots?.landscape?.playTablets && !!PLAY_TABLET_LANDSCAPE[size]; const landscape = s.device === 'landscape' || tabLand; const d = tabLand ? PLAY_TABLET_LANDSCAPE[size]! : s; const frame = shots?.landscape?.frame ?? (size === 'mac' ? 'window' : 'none'); return { w: d.w, h: d.h, landscape, frame }; } /** Safe margins every template keeps around its text and logo (fractions of the image), and the minimum `shots check` * accepts (a little under the target, so rounding never fails a render). */ export const SAFE_MARGIN = { x: 0.07, top: 0.05 } as const; export const SAFE_MARGIN_MIN = { x: 0.06, top: 0.04 } as const; export interface TextBox { el: string; x: number; y: number; w: number; h: number } /** Text / logo boxes (from the page's own layout, report.json `boxes`) closer to the left, right or top edge than * SAFE_MARGIN_MIN. Pure. */ export function marginViolations(boxes: TextBox[], W: number, H: number): string[] { const mx = W * SAFE_MARGIN_MIN.x, mt = H * SAFE_MARGIN_MIN.top, out: string[] = []; for (const b of boxes) { const sides: string[] = []; if (b.x < mx - 0.5) sides.push(`left ${Math.round(b.x)} px`); if (W - (b.x + b.w) < mx - 0.5) sides.push(`right ${Math.round(W - b.x - b.w)} px`); if (b.y < mt - 0.5) sides.push(`top ${Math.round(b.y)} px`); if (sides.length) out.push(`${b.el} is too close to the edge (${sides.join(', ')}; minimum ${Math.round(mx)} px left / right, ${Math.round(mt)} px top)`); } return out; } /** Timeout for the contact sheet page: it decodes every image of the language, so it scales with their count and the * sizes (big Mac / iPad tiles), times the machine's load factor (max(1-min, 5-min load) / cores, 1..5). Never a * fixed 30 s. Pure. */ export function sheetTimeoutMs(images: number, sizes: number, load = 1): number { return Math.round((30_000 + images * 2_000 + sizes * 5_000) * Math.min(5, Math.max(1, load))); } 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 } }; } /** 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, the iPhone Duo sizes only with apple.iphoneDuo, mac only with apple.mac, * chromebook only with play.chromebook), * 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, 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('iphone-duo') ? apple && !!config.apple?.iphoneDuo : k === 'mac' ? apple && !!config.apple?.mac : k === 'chromebook' ? play && !!config.play?.chromebook : 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): / 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[]; dims?: { w: number; h: number } }[], 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 = r.dims ?? SIZES[r.size]!, h = SIZES[r.size]!.feature || d.w > d.h ? 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}`; } /** Axis-aligned bounding box of a w x h rectangle rotated by `deg` around its centre. */ export function rotatedBox(w: number, h: number, deg: number): { w: number; h: number } { const r = (deg * Math.PI) / 180, c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r)); return { w: w * c + h * s, h: w * s + h * c }; } /** Feature graphic canvas (Play: 1024 x 500) and the device's place per template: centre x, design width, rotation, * and the top the old fixed layout used (kept by fit 'bleed'). */ export const FEATURE_CANVAS = { w: 1024, h: 500, margin: 24 } as const; export const FEATURE_DEVICE: Record<'game' | 'app', { cx: number; width: number; angle: number; bleedTop: number }> = { game: { cx: 864, width: 232, angle: 7, bleedTop: 36 }, app: { cx: 805, width: 230, angle: 0, bleedTop: 40 }, }; export interface FeatureLayout { left: number; top: number; width: number; height: number; angle: number; /** Rotated bounding box in canvas px. */ box: { x: number; y: number; w: number; h: number }; fit: 'contain' | 'bleed' } /** Where the feature graphic's device goes. fit 'contain' (default): the device (frame aspect dev.w : dev.h + dev.bar, * rotated by the template's angle) is sized so its rotated bounding box stays inside the canvas with `margin` on every * side, centred vertically; `scale` (default 1) multiplies the design width before that clamp. fit 'bleed': the design * width x scale at the old fixed top, cropped by the canvas edge on purpose. */ export function featureLayout(style: 'game' | 'app', dev: { w: number; h: number; bar: number }, o: { fit?: 'contain' | 'bleed'; scale?: number } = {}): FeatureLayout { const d = FEATURE_DEVICE[style], C = FEATURE_CANVAS, fit = o.fit ?? 'contain'; const k = (dev.h + dev.bar) / dev.w; let width = d.width * (o.scale ?? 1); if (fit === 'contain') { const unit = rotatedBox(1, k, d.angle); width = Math.min(width, (C.h - 2 * C.margin) / unit.h, (2 * Math.min(d.cx - C.margin, C.w - C.margin - d.cx)) / unit.w); } const height = width * k; const top = fit === 'contain' ? (C.h - height) / 2 : d.bleedTop; const bb = rotatedBox(width, height, d.angle); const cy = top + height / 2; return { left: d.cx - width / 2, top, width, height, angle: d.angle, fit, box: { x: d.cx - bb.w / 2, y: cy - bb.h / 2, w: bb.w, h: bb.h } }; } export interface DeviceBox { x: number; y: number; w: number; h: number } /** Sides of a device box that leave a W x H canvas (more than `tol` px). */ export function boxOutside(b: DeviceBox, W: number, H: number, tol = 1): string[] { const out: string[] = []; if (b.y < -tol) out.push(`top ${Math.round(-b.y)} px`); if (b.y + b.h > H + tol) out.push(`bottom ${Math.round(b.y + b.h - H)} px`); if (b.x < -tol) out.push(`left ${Math.round(-b.x)} px`); if (b.x + b.w > W + tol) out.push(`right ${Math.round(b.x + b.w - W)} px`); return out; }