import { createHash } from 'node:crypto'; import { VclawError } from './errors.js'; /** Structured creative intent. Legacy storyboards remain readable without this contract. */ export interface FilmPlan { schemaVersion: 1; format: 'narrative' | 'advert' | 'explainer' | 'music-video' | 'montage' | 'technical-test'; purpose: string; audience: string; outcome: string; style?: { version: string; description: string }; sequences: Array<{ id: string; purpose: string; setting: string }>; } export interface ShotDirection { shotId: string; sequenceId: string; purpose: string; startState: string; endState: string; subjectAction: string; camera: string; audioIntent: string; performance?: Array<{ character: string; objective: string; behaviour: string; delivery?: string }>; stateBefore?: Record; stateAfter?: Record; transition?: { nextShotId: string; intent: string }; } const text = (v: unknown): v is string => typeof v === 'string' && !!v.trim(); const object = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); export function validateFilmPlan(board: unknown): string[] { if (!object(board)) return ['Storyboard must be an object']; if (board.scenes !== undefined && !Array.isArray(board.scenes)) return ['Storyboard scenes must be an array']; const rawScenes: unknown[] = (board.scenes as unknown[] | undefined) ?? []; if (rawScenes.some(scene => !object(scene))) return ['Each scene must be an object']; const scenes = rawScenes as Array>; const plan = board.filmPlan; const issues: string[] = []; for (const scene of scenes) { if (scene.characters !== undefined && (!Array.isArray(scene.characters) || scene.characters.some(character => !text(character)))) issues.push(`Scene ${scene.sceneIndex}: characters must be an array of non-empty names`); } if (plan === undefined && scenes.every(s => s.direction === undefined)) return issues; if (!object(plan)) return ['filmPlan is required when shot directions are present']; if (plan.schemaVersion !== 1) issues.push('filmPlan.schemaVersion must be 1'); if (!['narrative', 'advert', 'explainer', 'music-video', 'montage', 'technical-test'].includes(String(plan.format))) issues.push('filmPlan.format is invalid'); for (const key of ['purpose', 'audience', 'outcome'] as const) if (!text(plan[key])) issues.push(`filmPlan.${key} is required`); if (!scenes.length) issues.push('Film plan requires at least one scene'); const seqIds = new Set(); if (!Array.isArray(plan.sequences) || !plan.sequences.length) issues.push('filmPlan.sequences must not be empty'); else for (const seq of plan.sequences) { if (!object(seq) || !text(seq.id) || !text(seq.purpose) || !text(seq.setting)) { issues.push('Each sequence needs id, purpose and setting'); continue; } if (seqIds.has(seq.id)) issues.push(`Duplicate sequence ${seq.id}`); seqIds.add(seq.id); } if (plan.style !== undefined && (!object(plan.style) || !text(plan.style.version) || !text(plan.style.description))) issues.push('Style needs version and description'); const ids = new Set(); for (const scene of scenes) { const d = scene.direction; if (!object(d)) { issues.push(`Scene ${scene.sceneIndex}: direction is required`); continue; } for (const key of ['shotId', 'sequenceId', 'purpose', 'startState', 'endState', 'subjectAction', 'camera', 'audioIntent'] as const) if (!text(d[key])) issues.push(`Scene ${scene.sceneIndex}: ${key} is required`); if (text(d.shotId) && ids.has(d.shotId)) issues.push(`Duplicate shot ${d.shotId}`); if (text(d.shotId)) ids.add(d.shotId); if (!text(d.sequenceId) || !seqIds.has(d.sequenceId)) issues.push(`Unknown sequence ${d.sequenceId}`); if (d.performance !== undefined) { if (!Array.isArray(d.performance)) issues.push(`Shot ${d.shotId}: performance must be an array`); else for (const actor of d.performance) { if (!object(actor) || !text(actor.character) || !text(actor.objective) || !text(actor.behaviour)) { issues.push(`Shot ${d.shotId}: performance needs character, objective and behaviour`); continue; } if (actor.delivery !== undefined && !text(actor.delivery)) issues.push(`Shot ${d.shotId}: delivery must be non-empty text`); if (!Array.isArray(scene.characters) || !scene.characters.includes(actor.character)) issues.push(`Shot ${d.shotId}: performer ${actor.character} is not in scene characters`); } } for (const key of ['stateBefore', 'stateAfter'] as const) if (d[key] !== undefined && (!object(d[key]) || Object.values(d[key] as Record).some(v => !text(v)))) issues.push(`Shot ${d.shotId}: ${key} must contain explicit text values`); } scenes.forEach((scene, index) => { const t = object(scene.direction) ? scene.direction.transition : undefined; const next = scenes[index + 1]?.direction; if (t !== undefined && (!object(t) || !text(t.intent) || !text(t.nextShotId) || !object(next) || t.nextShotId !== next.shotId)) issues.push(`Scene ${scene.sceneIndex}: transition must name the next shot and its intent`); }); return issues; } export function assertFilmPlan(board: unknown): void { const issues = validateFilmPlan(board); if (issues.length) throw new VclawError('invalid_flag_value', `film-plan-invalid: ${issues.join('; ')}`, { code: 'film-plan-invalid', issues }); } /** Only visible/audible directions enter the provider prompt; story bookkeeping stays outside. */ export function shotDirectionPrompt(plan: FilmPlan | undefined, d: ShotDirection | undefined): string { if (!d) return ''; return [plan?.style ? `Visual style: ${plan.style.description}` : '', `Starting frame: ${d.startState}`, `Subject action: ${d.subjectAction}`, `Camera: ${d.camera}`, `Ending state: ${d.endState}`, ...(d.performance ?? []).map(p => `${p.character} performance: ${p.behaviour}${p.delivery ? ` Delivery: ${p.delivery}` : ''}`), `Audio intention: ${d.audioIntent}`].filter(Boolean).join('\n'); } /** Binds saved prompt packets to the exact planning inputs, including ordered shots. */ export function filmPlanFingerprint(board: unknown): string { return createHash('sha256').update(JSON.stringify(board)).digest('hex'); }