/** * Project Blueprint — the AI Animation Director "director layer" artifact. * * A project-level visual blueprint that locks the film language ABOVE the * existing execution layer ({@link ./cinematography}, {@link ./filmmaking-prompts}). * Distinct from {@link ./story-bible} (a CONTINUITY bible: cast/props/timeline) — * this is the VISUAL DIRECTION bible: visual identity, master color system, * lighting grammar, per-character blueprint (silhouette + palette + voice + * power/vulnerability/signature camera framing), environment blueprint (with the * 5-sensory-words rule), a project camera bible (dominant shots, forbidden * movements, the one rule the camera must never break), and voice/performance * world rules. * * Generation is a creative LLM task (the `ai-director` skill runs the staged * director master-prompt and emits a JSON). This module is the deterministic * half: validate the skill-authored JSON, normalize it to the canonical artifact * shape, and persist it as a managed, history-tracked artifact. The * {@link ./filmmaking-prompts} composer reads it back (graceful when absent) to * enrich every scene packet. */ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { VclawError } from './errors.js'; import { artifactPathFor, writeArtifact } from './artifact-store.js'; import { resolveProjectWorkspace } from './workspace.js'; import type { VideoProjectWorkspace } from './workspace.js'; import type { SensorySignature } from './shot-grammar.js'; export interface BlueprintInfluence { name: string; /** The specific visual element borrowed (not a vibe — a concrete element). */ borrowed: string; } export interface VisualIdentity { aesthetic: string; influences: BlueprintInfluence[]; texture: string; scale: string; timeOfDay: string; /** One-line "looks like X, feels like Y, lit like Z" thesis. */ thesis: string; } export interface MasterColor { name: string; where: string; emotion: string; hex?: string; } export interface ColorSystem { colors: MasterColor[]; kelvinRange: string; warmShiftMeaning: string; coolShiftMeaning: string; contrast: string; saturation: string; /** Optional grade-register id resolved by {@link gradeSpec} (e.g. teal-orange). */ gradeId?: string; } export interface SignatureLightingSetups { intimate: string; tension: string; hero: string; } export interface LightingGrammar { keyDirection: string; quality: string; shadowStrategy: string; practicals: string; signatureSetups: SignatureLightingSetups; } export interface CharacterVoice { ageGender: string; timbre: string; rhythm: string; accent: string; emotionalLeak: string; /** Two performance anchors. */ anchors: string[]; } export interface CharacterCameraLanguage { power: string; vulnerability: string; signature: string; } export interface BlueprintCharacter { name: string; role: string; visualType: string; silhouette: string; signatureDetail: string; /** Three character colors. */ palette: string[]; costume: string; communicates: string; voice: CharacterVoice; cameraLanguage: CharacterCameraLanguage; } export interface BlueprintEnvironment { name: string; type: string; emotionalFunction: string; anchors: string[]; scale: string; lightColor: string; /** The 5-sensory-words rule: 1 smell + 1 texture + 1 sound + 2 feelings. */ sensory: SensorySignature; } export interface RareShot { shot: string; when: string; } export interface ProjectCameraBible { relationship: string; whenMoves: string; whenLocked: string; /** Three dominant shot ids/labels. */ dominantShots: string[]; rareShots: RareShot[]; primaryAngles: string[]; primaryMovements: string[]; /** Movement ids/labels banned for this project — validated by the composer. */ forbiddenMovements: string[]; focalFeel: string; depthStrategy: string; /** The single rule the camera must never break. */ oneRule: string; } export interface PerformanceRules { actingStyle: string; dialogueEnergy: string; silenceUsage: string; intensityScale: string; castingPattern: string; avoid: string; } export interface OutputNotes { runtimeSeconds: number; aspectRatio: string; motionStyle: string; editingRhythm: string; /** 8–12 vibe keywords. */ vibeKeywords: string[]; } export interface ProjectBlueprintArtifact { schemaVersion: 1; projectSlug: string; generatedAt: string; source: 'ai-director'; title: string; visualIdentity: VisualIdentity; colorSystem: ColorSystem; lightingGrammar: LightingGrammar; characters: BlueprintCharacter[]; environments: BlueprintEnvironment[]; cameraBible: ProjectCameraBible; performanceRules: PerformanceRules; output: OutputNotes; } // --- coercion helpers (lenient on sub-fields, strict on required sections) --- function asObject(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function asString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map((entry) => asString(entry)).filter(Boolean); } function asNumber(value: unknown, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } function asSensory(value: unknown): SensorySignature { const o = asObject(value) ?? {}; const feelings = Array.isArray(o.feelings) ? o.feelings.map(asString) : []; return { smell: asString(o.smell), texture: asString(o.texture), sound: asString(o.sound), feelings: [feelings[0] ?? '', feelings[1] ?? ''], }; } function normalizeVisualIdentity(o: Record): VisualIdentity { const influences = Array.isArray(o.influences) ? o.influences.flatMap((entry) => { const inf = asObject(entry); if (!inf) return []; return [{ name: asString(inf.name), borrowed: asString(inf.borrowed) }]; }) : []; return { aesthetic: asString(o.aesthetic), influences, texture: asString(o.texture), scale: asString(o.scale), timeOfDay: asString(o.timeOfDay), thesis: asString(o.thesis), }; } function normalizeColorSystem(o: Record): ColorSystem { const colors = Array.isArray(o.colors) ? o.colors.flatMap((entry) => { const c = asObject(entry); if (!c) return []; const hex = asString(c.hex); return [{ name: asString(c.name), where: asString(c.where), emotion: asString(c.emotion), ...(hex ? { hex } : {}) }]; }) : []; const gradeId = asString(o.gradeId); return { colors, kelvinRange: asString(o.kelvinRange), warmShiftMeaning: asString(o.warmShiftMeaning), coolShiftMeaning: asString(o.coolShiftMeaning), contrast: asString(o.contrast), saturation: asString(o.saturation), ...(gradeId ? { gradeId } : {}), }; } function normalizeLightingGrammar(o: Record): LightingGrammar { const setups = asObject(o.signatureSetups) ?? {}; return { keyDirection: asString(o.keyDirection), quality: asString(o.quality), shadowStrategy: asString(o.shadowStrategy), practicals: asString(o.practicals), signatureSetups: { intimate: asString(setups.intimate), tension: asString(setups.tension), hero: asString(setups.hero), }, }; } function normalizeCharacter(entry: unknown): BlueprintCharacter | null { const o = asObject(entry); if (!o) return null; const voice = asObject(o.voice) ?? {}; const cam = asObject(o.cameraLanguage) ?? {}; return { name: asString(o.name), role: asString(o.role), visualType: asString(o.visualType), silhouette: asString(o.silhouette), signatureDetail: asString(o.signatureDetail), palette: asStringArray(o.palette).slice(0, 3), costume: asString(o.costume), communicates: asString(o.communicates), voice: { ageGender: asString(voice.ageGender), timbre: asString(voice.timbre), rhythm: asString(voice.rhythm), accent: asString(voice.accent), emotionalLeak: asString(voice.emotionalLeak), anchors: asStringArray(voice.anchors).slice(0, 2), }, cameraLanguage: { power: asString(cam.power), vulnerability: asString(cam.vulnerability), signature: asString(cam.signature), }, }; } function normalizeEnvironment(entry: unknown): BlueprintEnvironment | null { const o = asObject(entry); if (!o) return null; return { name: asString(o.name), type: asString(o.type), emotionalFunction: asString(o.emotionalFunction), anchors: asStringArray(o.anchors), scale: asString(o.scale), lightColor: asString(o.lightColor), sensory: asSensory(o.sensory), }; } function normalizeCameraBible(o: Record): ProjectCameraBible { const rareShots = Array.isArray(o.rareShots) ? o.rareShots.flatMap((entry) => { const r = asObject(entry); if (!r) return []; return [{ shot: asString(r.shot), when: asString(r.when) }]; }) : []; return { relationship: asString(o.relationship), whenMoves: asString(o.whenMoves), whenLocked: asString(o.whenLocked), dominantShots: asStringArray(o.dominantShots), rareShots, primaryAngles: asStringArray(o.primaryAngles), primaryMovements: asStringArray(o.primaryMovements), forbiddenMovements: asStringArray(o.forbiddenMovements), focalFeel: asString(o.focalFeel), depthStrategy: asString(o.depthStrategy), oneRule: asString(o.oneRule), }; } function normalizePerformanceRules(o: Record): PerformanceRules { return { actingStyle: asString(o.actingStyle), dialogueEnergy: asString(o.dialogueEnergy), silenceUsage: asString(o.silenceUsage), intensityScale: asString(o.intensityScale), castingPattern: asString(o.castingPattern), avoid: asString(o.avoid), }; } function normalizeOutput(o: Record): OutputNotes { return { runtimeSeconds: asNumber(o.runtimeSeconds, 0), aspectRatio: asString(o.aspectRatio) || '16:9', motionStyle: asString(o.motionStyle), editingRhythm: asString(o.editingRhythm), vibeKeywords: asStringArray(o.vibeKeywords), }; } export interface ValidateBlueprintOptions { projectSlug: string; generatedAt?: string; } /** * Validate + normalize a skill-authored blueprint JSON into the canonical * {@link ProjectBlueprintArtifact}. Lenient on sub-fields (missing strings → '') * but strict on the required top-level sections: throws a single * `invalid_flag_value` VclawError listing every missing/invalid section so the * operator can fix them all at once. Pure (apart from the injected generatedAt). */ export function validateProjectBlueprint( input: unknown, options: ValidateBlueprintOptions, ): ProjectBlueprintArtifact { const root = asObject(input); if (!root) { throw new VclawError('invalid_flag_value', 'project blueprint must be a JSON object', {}); } const title = asString(root.title); const visualIdentity = asObject(root.visualIdentity); const colorSystem = asObject(root.colorSystem); const lightingGrammar = asObject(root.lightingGrammar); const cameraBible = asObject(root.cameraBible); const performanceRules = asObject(root.performanceRules); const output = asObject(root.output); const missing: string[] = []; if (!title) missing.push('title'); if (!visualIdentity) missing.push('visualIdentity'); if (!colorSystem) missing.push('colorSystem'); if (!lightingGrammar) missing.push('lightingGrammar'); if (!cameraBible) missing.push('cameraBible'); if (!performanceRules) missing.push('performanceRules'); if (!output) missing.push('output'); if (missing.length > 0) { throw new VclawError( 'invalid_flag_value', `project blueprint is missing required section(s): ${missing.join(', ')}`, { missing }, ); } const characters = Array.isArray(root.characters) ? root.characters.map(normalizeCharacter).filter((c): c is BlueprintCharacter => c !== null) : []; const environments = Array.isArray(root.environments) ? root.environments.map(normalizeEnvironment).filter((e): e is BlueprintEnvironment => e !== null) : []; return { schemaVersion: 1, projectSlug: options.projectSlug, generatedAt: options.generatedAt ?? new Date().toISOString(), source: 'ai-director', title, visualIdentity: normalizeVisualIdentity(visualIdentity!), colorSystem: normalizeColorSystem(colorSystem!), lightingGrammar: normalizeLightingGrammar(lightingGrammar!), characters, environments, cameraBible: normalizeCameraBible(cameraBible!), performanceRules: normalizePerformanceRules(performanceRules!), output: normalizeOutput(output!), }; } /** * Read + JSON-parse a blueprint file. Throws `invalid_flag_value` on a missing * file or malformed JSON so the CLI surfaces a clear exit-1 error. */ export async function loadBlueprintJson(path: string): Promise { if (!existsSync(path)) { throw new VclawError('invalid_flag_value', `project blueprint file not found: ${path}`, { path }); } const raw = await readFile(path, 'utf-8'); try { return JSON.parse(raw); } catch (error) { throw new VclawError('invalid_flag_value', `project blueprint is not valid JSON: ${path}`, { path, cause: error instanceof Error ? error.message : String(error), }); } } /** Persist a validated blueprint as the managed, history-tracked artifact. */ export async function writeProjectBlueprint( workspace: VideoProjectWorkspace, artifact: ProjectBlueprintArtifact, ): Promise { return writeArtifact(workspace, 'project-blueprint', artifact); } /** * Read `artifacts/project-blueprint.json`. Returns null when absent (graceful — * the composer then behaves byte-identically to today). Mirrors * {@link readEnvironmentAssets} / {@link readSeedanceAssets}. */ export async function readProjectBlueprint( root: string, slug: string, ): Promise { const workspace = resolveProjectWorkspace(slug, root); const path = artifactPathFor(workspace, 'project-blueprint'); if (!existsSync(path)) return null; return JSON.parse(await readFile(path, 'utf-8')) as ProjectBlueprintArtifact; }