370 lines
24 KiB
TypeScript
370 lines
24 KiB
TypeScript
// Pure parts of the screenshot pipeline: store sizes, palettes, per-language script fonts, copy validation, job data.
|
|
import { readdirSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { specFamily } from './local-fonts';
|
|
import { bgIssue } from './palette';
|
|
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' | 'landscape'; 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' },
|
|
// 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 <frames>/<lang>/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<string, { w: number; h: number }> = { 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<ShotsConfig, 'landscape' | 'orientation'> | 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;
|
|
// shots.orientation 'landscape' (bleed layout): every other portrait screenshot size swaps width and height
|
|
const swap = shots?.orientation === 'landscape' && !s.feature && !landscape;
|
|
const d = tabLand ? PLAY_TABLET_LANDSCAPE[size]! : swap ? { w: s.h, h: s.w } : 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<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') {
|
|
const bad = bgIssue(p.bg);
|
|
if (bad) throw new Error(`${bad} (got ${JSON.stringify(p.bg)})`);
|
|
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 (storebound shots copy translate --to ${lang})`); 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 } };
|
|
}
|
|
|
|
/** Landscape capture viewport (shots.landscape.size): a desktop window, 1440 x 900 CSS px @2x = 2880 x 1800 (the Mac size). No status bar. */
|
|
export const DEFAULT_LANDSCAPE: FrameDims = { w: 1440, h: 900, dpr: 2, bar: 0 };
|
|
|
|
/** Viewport `shots capture` uses for landscape frames (<lang>/landscape/<name>.png): shots.landscape.size over the default. */
|
|
export function landscapeDims(config: Pick<ShipConfig, 'shots'>): FrameDims {
|
|
const s = config.shots?.landscape?.size;
|
|
return { ...DEFAULT_LANDSCAPE, ...(s ? { w: s.w, h: s.h, ...(s.dpr ? { dpr: s.dpr } : {}) } : {}), bar: 0 };
|
|
}
|
|
|
|
/** 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<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('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));
|
|
}
|
|
|
|
/** True when `shots render` draws a landscape size for this project (apple.mac, play.chromebook, shots.landscape.playTablets):
|
|
* it then needs <lang>/landscape/<name>.png frames, which `shots capture` makes with the landscape device. Same rules as render. */
|
|
export function rendersLandscape(config: Pick<ShipConfig, 'apple' | 'play' | 'shots'>): boolean {
|
|
return defaultSizes(config).some(s => renderDims(s, config.shots).landscape);
|
|
}
|
|
|
|
/** 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[]; 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 <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}`;
|
|
}
|
|
|
|
/** 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; rotate?: number } = {}): FeatureLayout {
|
|
const base = FEATURE_DEVICE[style], C = FEATURE_CANVAS, fit = o.fit ?? 'contain';
|
|
const d = { ...base, angle: base.angle + (o.rotate ?? 0) }; // shots.feature.rotate adds to the template's tilt, like a scene frame's rotate
|
|
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;
|
|
}
|
|
|
|
/** Frame files (relative to shots.frames: <lang>/<phone|tablet|landscape>/<src>) a render of these scenes and sizes
|
|
* loads. A missing one renders an image with no app or game content (only a 404 in the page), so `shots render`
|
|
* refuses to start without them. Landscape layouts use each scene's first frame; the feature graphic a phone frame. */
|
|
export function framesForRender(scenes: { frames: { src: string }[] }[], sizes: string[], langs: string[],
|
|
shots: Pick<ShotsConfig, 'landscape' | 'layout'> & { feature?: { frame?: string } }): string[] {
|
|
const out = new Set<string>();
|
|
for (const lang of langs) for (const size of sizes) {
|
|
const s = SIZES[size];
|
|
if (!s) continue;
|
|
if (shots.layout === 'bleed') { // one frame per scene at each size's own pixels: <lang>/<size>/<src>, the feature one in <lang>/feature/
|
|
if (s.feature) { const f = shots.feature?.frame ?? scenes[0]?.frames[0]?.src; if (f) out.add(`${lang}/feature/${f}`); }
|
|
else for (const sc of scenes) if (sc.frames[0]) out.add(`${lang}/${size}/${sc.frames[0].src}`);
|
|
continue;
|
|
}
|
|
if (s.feature) { const f = shots.feature?.frame ?? scenes[0]?.frames[0]?.src; if (f) out.add(`${lang}/phone/${f}`); continue; }
|
|
const d = renderDims(size, shots);
|
|
const kind = d.landscape ? 'landscape' : s.device ? 'tablet' : 'phone';
|
|
for (const sc of scenes) for (const f of d.landscape ? sc.frames.slice(0, 1) : sc.frames) out.add(`${lang}/${kind}/${f.src}`);
|
|
}
|
|
return [...out].sort();
|
|
}
|
|
|
|
/** Which language's frames each language loads (shots.perLanguageFrames false, the default): its own when
|
|
* <frames>/<lang> holds any file, else the default language's. `hasFrames` is injected so tests need no disk. */
|
|
export function frameLangs(langs: string[], hasFrames: (lang: string) => boolean, def: string, perLanguage = false): { map: Record<string, string>; borrowed: string[] } {
|
|
const map: Record<string, string> = {}, borrowed: string[] = [];
|
|
for (const l of langs) {
|
|
if (perLanguage || l === def || hasFrames(l)) map[l] = l;
|
|
else if (hasFrames(def)) { map[l] = def; borrowed.push(l); }
|
|
else map[l] = l; // nothing anywhere: the normal missing-frames error names it
|
|
}
|
|
return { map, borrowed };
|
|
}
|
|
|
|
/** true when <dir>/<lang> holds at least one file (any depth up to 3). */
|
|
export function langHasFrames(dir: string, lang: string): boolean {
|
|
const walk = (d: string, depth: number): boolean => {
|
|
let ents; try { ents = readdirSync(d, { withFileTypes: true }); } catch { return false; }
|
|
return ents.some(e => e.isFile() || (depth < 3 && e.isDirectory() && walk(join(d, e.name), depth + 1)));
|
|
};
|
|
return walk(join(dir, lang), 0);
|
|
}
|