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.
197 lines
13 KiB
TypeScript
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}`;
|
|
}
|