/** * Small readers, coercions and validators the review-ui family shares: the * ledger / artifact readers, the decision record coercion, the storyboard * scene readers and the value helpers. A LEAF of the family. Extracted * verbatim from `review-ui.ts` (roadmap Phase 3e). */ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { buildStoryboardScenesFromTemplate } from './storyboard-templates.js'; import type { StoryboardArtifact } from './artifacts.js'; import type { SceneCandidate, SceneCandidatesArtifact } from './types.js'; export function reviewVerdict(value: unknown): 'pass' | 'retry' | 'fail' { return value === 'pass' || value === 'retry' || value === 'fail' ? value : 'retry'; } export function projectRelativeAssetPath(projectSlug: string, path: string): string { const prefix = `projects/${projectSlug}/`; return path.startsWith(prefix) ? path.slice(prefix.length) : path; } export function storyboardScenesForReview(input: { existingStoryboard: Record | null; templateId: string; characterName: string; selectedCharacterName?: string; }): StoryboardArtifact['scenes'] { const existing = existingStoryboardScenes(input.existingStoryboard); if (existing.length > 0) return existing; return buildStoryboardScenesFromTemplate({ templateId: input.templateId, environment: 'a premium cinematic product world with clean composition', characterA: input.characterName, }).map((scene) => ({ sceneIndex: scene.sceneIndex, description: scene.description, ...(input.selectedCharacterName ? { characters: [input.selectedCharacterName] } : {}), scenePrompt: { imagePrompt: scene.description, animationPrompt: 'Animate only after the storyboard still is locked and upscaled.', cameraMove: 'static', styleFooter: 'Seedance reference workflow: still first, edit narrowly, lock, upscale, then motion.', }, })); } function isStoryboardCameraMove( value: string | null, ): value is Exclude['cameraMove'], undefined> { return value === 'push-in' || value === 'pull-out' || value === 'tracking' || value === 'crane' || value === 'orbit' || value === 'static'; } export function existingStoryboardScenes(storyboard: Record | null): StoryboardArtifact['scenes'] { const rawScenes = Array.isArray(storyboard?.scenes) ? storyboard.scenes : []; return rawScenes .filter(isRecord) .map((scene, fallbackIndex): StoryboardArtifact['scenes'][number] | null => { const description = stringValue(scene.description); if (!description) return null; const sceneIndex = numberValue(scene.sceneIndex) ?? fallbackIndex; const characters = Array.isArray(scene.characters) ? scene.characters.map(stringValue).filter((value): value is string => Boolean(value)) : []; const rawPrompt = isRecord(scene.scenePrompt) ? scene.scenePrompt : null; const imagePrompt = stringValue(rawPrompt?.imagePrompt); const animationPrompt = stringValue(rawPrompt?.animationPrompt); const cameraMove = stringValue(rawPrompt?.cameraMove); const styleFooter = stringValue(rawPrompt?.styleFooter); return { sceneIndex, description, ...(characters.length ? { characters } : {}), ...(rawPrompt ? { scenePrompt: { ...(imagePrompt ? { imagePrompt } : {}), ...(animationPrompt ? { animationPrompt } : {}), ...(isStoryboardCameraMove(cameraMove) ? { cameraMove } : {}), ...(styleFooter ? { styleFooter } : {}), }, } : {}), }; }) .filter((scene): scene is StoryboardArtifact['scenes'][number] => scene !== null) .sort((left, right) => left.sceneIndex - right.sceneIndex); } export interface ReviewDecisionRecord { activeGate?: string; recommendedNextAction?: string; seedanceWorkflow?: Record; qualityScore?: string; qualityChecks?: Array>; existingInventory?: Record; savedArtifact?: Record; selections: Record; notes: Record; } export function asReviewDecision(value: unknown): ReviewDecisionRecord { const record = isRecord(value) ? value : {}; return { ...(typeof record.activeGate === 'string' ? { activeGate: record.activeGate } : {}), ...(typeof record.recommendedNextAction === 'string' ? { recommendedNextAction: record.recommendedNextAction } : {}), ...(isRecord(record.seedanceWorkflow) ? { seedanceWorkflow: record.seedanceWorkflow } : {}), ...(typeof record.qualityScore === 'string' ? { qualityScore: record.qualityScore } : {}), ...(Array.isArray(record.qualityChecks) ? { qualityChecks: record.qualityChecks.filter(isRecord) } : {}), ...(isRecord(record.existingInventory) ? { existingInventory: record.existingInventory } : {}), ...(isRecord(record.savedArtifact) ? { savedArtifact: record.savedArtifact } : {}), selections: isRecord(record.selections) ? record.selections : {}, notes: isRecord(record.notes) ? record.notes : {}, }; } export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } export function findSceneCandidate( artifact: SceneCandidatesArtifact, sceneIndex: number, candidateId: string, ): SceneCandidate | null { return artifact.scenes .find((scene) => scene.sceneIndex === sceneIndex) ?.candidates.find((candidate) => candidate.id === candidateId) ?? null; } export function stringValue(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } export function numberValue(value: unknown): number | null { if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value; if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value); return null; } export async function readReviewLedger(artifactsDir: string): Promise | null> { return readJsonArtifact(artifactsDir, 'review-ui-ledger.json'); } export async function readJsonArtifact(artifactsDir: string, name: string): Promise | null> { const path = join(artifactsDir, name); if (!existsSync(path)) return null; const raw = await readFile(path, 'utf-8'); const parsed = JSON.parse(raw) as unknown; return isRecord(parsed) ? parsed : null; }