import { readFileSync } from 'fs' import path from 'path' import { devices, type DeviceModel } from 'sootsim-engine/settings' import yaml from 'yaml' import { cloneBackgroundSpec, DEFAULT_FRAME_SHADOW, DEFAULT_FRAME_STYLE, resolveBackgroundPreset, resolveCanvasPreset, toAssetKey, type BackgroundPresetName, type BackgroundSpec, type CanvasPreset, type FrameComposeSpec, type PosePresetName, type TextPresetName, } from './registry' export type CaptureMode = 'raw' | 'framed' | 'raw+framed' export type CapturePathMode = 'auto' | 'plan' | 'flow' export interface NormalizedScreenshotSlide { id: string assetKey: string screenshot: string headline: string subheadline: string eyebrow: string pose?: PosePresetName scale?: number offsetY?: number background?: BackgroundSpec } export interface NormalizedCapturePlan { flowPath: string | null fromDir: string | null outDir: string rawDir: string framedDir: string mode: CaptureMode pathMode: CapturePathMode simId: string | null openInNewSim: boolean } export interface NormalizedComposePlan { outDir: string locale: string canvases: CanvasPreset[] frame: FrameComposeSpec background: BackgroundSpec text: { preset: TextPresetName color: string subColor: string eyebrowColor: string } slides: NormalizedScreenshotSlide[] } export interface NormalizedScreenshotsPlan { planPath: string planDir: string appTarget: string | null deviceModel: DeviceModel | null capture: NormalizedCapturePlan compose: NormalizedComposePlan } function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } function expectRecord(value: unknown, label: string): Record { if (!isRecord(value)) throw new Error(`${label} must be an object`) return value } function readString(value: unknown, fallback = ''): string { return typeof value === 'string' ? value : fallback } function readOptionalString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null } function readBoolean(value: unknown, fallback: boolean): boolean { return typeof value === 'boolean' ? value : fallback } function readNumber(value: unknown, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) ? value : fallback } function readDeviceModel( value: unknown, fallback: DeviceModel | null, ): DeviceModel | null { if (typeof value !== 'string' || value.trim().length === 0) return fallback return Object.prototype.hasOwnProperty.call(devices, value) ? (value as DeviceModel) : null } function normalizeBackground(value: unknown, fallback: BackgroundSpec): BackgroundSpec { if (typeof value === 'string') { const preset = resolveBackgroundPreset(value) if (!preset) throw new Error(`unknown background preset: ${value}`) return preset } if (!isRecord(value)) return cloneBackgroundSpec(fallback) const type = value.type === 'solid' || value.type === 'gradient' ? value.type : fallback.type if (type === 'solid') { return { type, color: readString(value.color, fallback.color ?? '#000000'), glow: readOptionalString(value.glow) ?? fallback.glow, } } const stops = Array.isArray(value.stops) ? value.stops .map((stop) => { if (!isRecord(stop)) return null const offset = readNumber(stop.offset, Number.NaN) const color = readOptionalString(stop.color) if (!Number.isFinite(offset) || !color) return null return { offset, color } }) .filter((stop): stop is { offset: number; color: string } => !!stop) : (fallback.stops?.map((stop) => ({ ...stop })) ?? []) return { type, direction: readNumber(value.direction, fallback.direction ?? 180), glow: readOptionalString(value.glow) ?? fallback.glow, stops: stops.length > 0 ? stops : (fallback.stops?.map((stop) => ({ ...stop })) ?? []), } } function normalizeCanvasList(value: unknown): CanvasPreset[] { const names = Array.isArray(value) ? value : ['iphone-6-9'] const presets = names.map((entry) => { if (typeof entry !== 'string') throw new Error('compose.canvases entries must be strings') const preset = resolveCanvasPreset(entry) if (!preset) throw new Error(`unknown canvas preset: ${entry}`) return preset }) if (presets.length === 0) throw new Error('compose.canvases must include at least one preset') return presets } function normalizeTextPreset(value: unknown): TextPresetName { switch (value) { case 'editorial-left': case 'minimal-bottom': case 'none': case 'bold-top': return value default: return 'bold-top' } } function normalizeCapturePathMode(value: unknown): CapturePathMode { switch (value) { case 'plan': case 'flow': case 'auto': return value default: return 'auto' } } function normalizePose(value: unknown): PosePresetName | undefined { switch (value) { case 'straight': case 'tilted-left': case 'tilted-right': case 'cut-bottom': case 'cut-top': return value default: return undefined } } function resolvePlanPath(planDir: string, value: string): string { return path.isAbsolute(value) ? value : path.resolve(planDir, value) } function normalizeSlides( value: unknown, fallbackBackground: BackgroundSpec, ): NormalizedScreenshotSlide[] { if (!Array.isArray(value) || value.length === 0) { throw new Error('compose.slides must be a non-empty array') } return value.map((entry, index) => { const slide = expectRecord(entry, `compose.slides[${index}]`) const screenshot = readOptionalString(slide.screenshot) if (!screenshot) { throw new Error(`compose.slides[${index}].screenshot is required`) } const explicitId = readOptionalString(slide.id) const assetKey = toAssetKey(explicitId || screenshot) return { id: explicitId || assetKey, assetKey, screenshot, headline: readString(slide.headline), subheadline: readString(slide.subheadline), eyebrow: readString(slide.eyebrow), pose: normalizePose(slide.pose), scale: typeof slide.scale === 'number' && Number.isFinite(slide.scale) ? slide.scale : undefined, offsetY: typeof slide.offsetY === 'number' && Number.isFinite(slide.offsetY) ? slide.offsetY : undefined, background: slide.theme || slide.background ? normalizeBackground(slide.theme ?? slide.background, fallbackBackground) : undefined, } }) } export function loadScreenshotsPlan(planPath: string): NormalizedScreenshotsPlan { const resolvedPlanPath = path.resolve(planPath) const planDir = path.dirname(resolvedPlanPath) const raw = yaml.parse(readFileSync(resolvedPlanPath, 'utf8')) const root = expectRecord(raw, 'screenshots plan') const capture = expectRecord(root.capture ?? {}, 'capture') const compose = expectRecord(root.compose ?? {}, 'compose') const appTarget = typeof root.app === 'number' ? String(root.app) : readOptionalString(root.app) const deviceModel = readDeviceModel(root.device, null) const captureOutDir = resolvePlanPath( planDir, readString(capture.out, path.join('.rnx', 'screenshots', 'capture')), ) const captureFrom = readOptionalString(capture.from) const flowPath = readOptionalString(capture.flow) if (!captureFrom && !flowPath) { throw new Error('capture.flow or capture.from is required') } const captureMode = capture.mode === 'raw' || capture.mode === 'framed' || capture.mode === 'raw+framed' ? capture.mode : ('raw+framed' as CaptureMode) const capturePathMode = normalizeCapturePathMode(capture.pathMode) const defaultBackground = normalizeBackground( compose.background ?? ('cyan' as BackgroundPresetName), resolveBackgroundPreset('cyan')!, ) const frameConfig: FrameComposeSpec = { show: readBoolean( compose.frame && isRecord(compose.frame) ? compose.frame.show : undefined, true, ), style: readDeviceModel( compose.frame && isRecord(compose.frame) ? compose.frame.style : null, null, ) || deviceModel || DEFAULT_FRAME_STYLE, pose: normalizePose( compose.frame && isRecord(compose.frame) ? compose.frame.pose : undefined, ) || 'straight', scale: readNumber( compose.frame && isRecord(compose.frame) ? compose.frame.scale : undefined, 1, ), offsetY: readNumber( compose.frame && isRecord(compose.frame) ? compose.frame.offsetY : undefined, 0, ), shadow: { color: readString( compose.frame && isRecord(compose.frame) && isRecord(compose.frame.shadow) ? compose.frame.shadow.color : undefined, DEFAULT_FRAME_SHADOW.color, ), blur: readNumber( compose.frame && isRecord(compose.frame) && isRecord(compose.frame.shadow) ? compose.frame.shadow.blur : undefined, DEFAULT_FRAME_SHADOW.blur, ), spread: readNumber( compose.frame && isRecord(compose.frame) && isRecord(compose.frame.shadow) ? compose.frame.shadow.spread : undefined, DEFAULT_FRAME_SHADOW.spread, ), opacity: readNumber( compose.frame && isRecord(compose.frame) && isRecord(compose.frame.shadow) ? compose.frame.shadow.opacity : undefined, DEFAULT_FRAME_SHADOW.opacity, ), }, } return { planPath: resolvedPlanPath, planDir, appTarget, deviceModel, capture: { flowPath: flowPath ? resolvePlanPath(planDir, flowPath) : null, fromDir: captureFrom ? resolvePlanPath(planDir, captureFrom) : null, outDir: captureOutDir, rawDir: captureFrom ? resolvePlanPath(planDir, captureFrom) : path.join(captureOutDir, 'raw'), framedDir: path.join(captureOutDir, 'framed'), mode: captureMode, pathMode: capturePathMode, simId: readOptionalString(capture.sim), openInNewSim: readBoolean(capture.new, false), }, compose: { outDir: resolvePlanPath( planDir, readString(compose.out, path.join('.rnx', 'screenshots', 'exports')), ), locale: readString(compose.locale, 'en'), canvases: normalizeCanvasList(compose.canvases), frame: frameConfig, background: defaultBackground, text: { preset: normalizeTextPreset( compose.text && isRecord(compose.text) ? compose.text.preset : undefined, ), color: readString( compose.text && isRecord(compose.text) ? compose.text.color : undefined, '#ffffff', ), subColor: readString( compose.text && isRecord(compose.text) ? compose.text.subColor : undefined, 'rgba(232,240,247,0.86)', ), eyebrowColor: readString( compose.text && isRecord(compose.text) ? compose.text.eyebrowColor : undefined, 'rgba(229,242,255,0.72)', ), }, slides: normalizeSlides(compose.slides, defaultBackground), }, } }