import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { artifactPathFor, writeArtifact } from './artifact-store.js'; import type { BriefArtifact, StoryboardArtifact } from './artifacts.js'; import { listCharacterProfiles, type CharacterProfile } from './characters.js'; import type { VideoProductionMode } from './types.js'; import type { VideoProjectWorkspace } from './workspace.js'; export interface StoryBibleCharacter { name: string; description?: string; voice?: string; referenceAssets: string[]; } export interface StoryBibleSetting { name: string; description: string; } export interface StoryBibleProp { name: string; description: string; } export interface StoryBibleScene { sceneIndex: number; startSeconds: number; endSeconds: number; durationSeconds: number; description: string; narration: string; charactersPresent: string[]; visualPrompt?: string; motionPrompt?: string; diegeticAudio?: string; continuityNotes: string[]; } export interface StoryBibleTimeline { totalDurationSeconds: number; scenes: Array<{ sceneIndex: number; startSeconds: number; endSeconds: number; }>; } export interface StoryBibleArtifact { schemaVersion: 1; projectSlug: string; generatedAt: string; title: string; intent: string; productionMode: VideoProductionMode; style?: string; genre?: string; mood?: string; music?: string; aspectRatio?: string; defaultSceneDurationSeconds?: number; characters: StoryBibleCharacter[]; settings: StoryBibleSetting[]; props: StoryBibleProp[]; scenes: StoryBibleScene[]; timeline: StoryBibleTimeline; } interface StoryBibleMetadata { style?: unknown; genre?: unknown; mood?: unknown; music?: unknown; targetRuntimeSeconds?: unknown; clipDurationSeconds?: unknown; executionProfile?: { aspectRatio?: unknown; }; storyBible?: { settings?: unknown; props?: unknown; }; settings?: unknown; props?: unknown; characters?: unknown; } interface StoryBibleScenePrompt { imagePrompt?: string; animationPrompt?: string; styleFooter?: string; diegeticAudio?: string; ambianceAudio?: string; } function asNonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } function asPositiveNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; } function extractNamedDescriptions(value: unknown): Array<{ name: string; description: string }> { if (!Array.isArray(value)) return []; return value.flatMap((entry) => { if (!entry || typeof entry !== 'object') return []; const record = entry as Record; const name = asNonEmptyString(record.name); const description = asNonEmptyString(record.description); return name && description ? [{ name, description }] : []; }); } function uniqueStrings(values: Array): string[] { return [...new Set(values.map((value) => value?.trim()).filter((value): value is string => !!value))]; } function characterProfilesByName(profiles: CharacterProfile[]): Map { const map = new Map(); for (const profile of profiles) { map.set(profile.name.toLowerCase(), profile); map.set(profile.id.toLowerCase(), profile); } return map; } function collectCharacterNames( storyboard: StoryboardArtifact, metadata: StoryBibleMetadata, profiles: CharacterProfile[], ): string[] { const fromScenes = storyboard.scenes.flatMap((scene) => scene.characters ?? []); const fromMetadata = Array.isArray(metadata.characters) ? metadata.characters.flatMap((entry) => ( entry && typeof entry === 'object' ? [asNonEmptyString((entry as Record).name)] : [] )) : []; return uniqueStrings([...fromScenes, ...fromMetadata, ...profiles.map((profile) => profile.name)]) .sort((left, right) => left.localeCompare(right)); } function buildCharacters( names: string[], metadata: StoryBibleMetadata, profiles: CharacterProfile[], ): StoryBibleCharacter[] { const byName = characterProfilesByName(profiles); const metadataDescriptions = new Map>(); if (Array.isArray(metadata.characters)) { for (const entry of metadata.characters) { if (!entry || typeof entry !== 'object') continue; const record = entry as Record; const name = asNonEmptyString(record.name); if (name) metadataDescriptions.set(name.toLowerCase(), record); } } return names.map((name) => { const profile = byName.get(name.toLowerCase()); const metadataEntry = metadataDescriptions.get(name.toLowerCase()); const description = profile?.description ?? asNonEmptyString(metadataEntry?.description); const voice = asNonEmptyString(metadataEntry?.voice); return { name, ...(description ? { description } : {}), ...(voice ? { voice } : {}), referenceAssets: profile?.referenceAssets ?? [], }; }); } function buildContinuityNotes(input: { sceneIndex: number; characterNames: string[]; style?: string; previousSceneExists: boolean; }): string[] { const notes: string[] = []; if (input.characterNames.length > 0) { notes.push(`Keep ${input.characterNames.join(', ')} visually consistent with the character bible.`); } if (input.previousSceneExists) { notes.push(`Continue cleanly from scene ${input.sceneIndex}; avoid replaying the prior beat unless the storyboard asks for it.`); } if (input.style) { notes.push(`Maintain the project visual style: ${input.style}.`); } return notes; } export function buildStoryBibleArtifact(input: { projectSlug: string; brief: BriefArtifact; storyboard: StoryboardArtifact; characterProfiles?: CharacterProfile[]; generatedAt?: string; }): StoryBibleArtifact { const metadata = (input.brief.metadata ?? {}) as StoryBibleMetadata; const style = asNonEmptyString(metadata.style); const genre = asNonEmptyString(metadata.genre); const mood = asNonEmptyString(metadata.mood); const music = asNonEmptyString(metadata.music); const aspectRatio = asNonEmptyString(metadata.executionProfile?.aspectRatio); const targetRuntimeSeconds = asPositiveNumber(metadata.targetRuntimeSeconds); const sortedStoryboardScenes = [...input.storyboard.scenes].sort((left, right) => left.sceneIndex - right.sceneIndex); const defaultSceneDurationSeconds = asPositiveNumber(metadata.clipDurationSeconds) ?? (targetRuntimeSeconds && sortedStoryboardScenes.length > 0 ? targetRuntimeSeconds / sortedStoryboardScenes.length : undefined); const profiles = input.characterProfiles ?? []; const characterNames = collectCharacterNames(input.storyboard, metadata, profiles); const characters = buildCharacters(characterNames, metadata, profiles); const settings = [ ...extractNamedDescriptions(metadata.storyBible?.settings), ...extractNamedDescriptions(metadata.settings), ]; const props = [ ...extractNamedDescriptions(metadata.storyBible?.props), ...extractNamedDescriptions(metadata.props), ]; let cursorSeconds = 0; const scenes = sortedStoryboardScenes .map((scene, sortedIndex): StoryBibleScene => { const durationSeconds = scene.durationSeconds ?? defaultSceneDurationSeconds ?? 0; const startSeconds = cursorSeconds; const endSeconds = cursorSeconds + durationSeconds; cursorSeconds = endSeconds; const scenePrompt = (scene.scenePrompt ?? {}) as StoryBibleScenePrompt; const sceneCharacters = uniqueStrings(scene.characters ?? []); return { sceneIndex: scene.sceneIndex, startSeconds, endSeconds, durationSeconds, description: scene.description, narration: (scene.dialogue ?? scene.description).trim(), charactersPresent: sceneCharacters, ...(scenePrompt.imagePrompt ? { visualPrompt: scenePrompt.imagePrompt } : {}), ...(scenePrompt.animationPrompt ? { motionPrompt: scenePrompt.animationPrompt } : {}), ...(scenePrompt.diegeticAudio ?? scenePrompt.ambianceAudio ? { diegeticAudio: (scenePrompt.diegeticAudio ?? scenePrompt.ambianceAudio)! } : {}), continuityNotes: buildContinuityNotes({ sceneIndex: scene.sceneIndex, characterNames: sceneCharacters, style, previousSceneExists: sortedIndex > 0, }), }; }); return { schemaVersion: 1, projectSlug: input.projectSlug, generatedAt: input.generatedAt ?? new Date().toISOString(), title: input.brief.title, intent: input.brief.intent, productionMode: input.brief.productionMode, ...(style ? { style } : {}), ...(genre ? { genre } : {}), ...(mood ? { mood } : {}), ...(music ? { music } : {}), ...(aspectRatio ? { aspectRatio } : {}), ...(defaultSceneDurationSeconds ? { defaultSceneDurationSeconds } : {}), characters, settings, props, scenes, timeline: { totalDurationSeconds: cursorSeconds, scenes: scenes.map((scene) => ({ sceneIndex: scene.sceneIndex, startSeconds: scene.startSeconds, endSeconds: scene.endSeconds, })), }, }; } export async function writeStoryBibleForProject( workspace: VideoProjectWorkspace, ): Promise<{ artifactPath: string; artifact: StoryBibleArtifact } | null> { const briefPath = artifactPathFor(workspace, 'brief'); const storyboardPath = artifactPathFor(workspace, 'storyboard'); if (!existsSync(briefPath) || !existsSync(storyboardPath)) return null; const brief = JSON.parse(await readFile(briefPath, 'utf-8')) as BriefArtifact; const storyboard = JSON.parse(await readFile(storyboardPath, 'utf-8')) as StoryboardArtifact; const characterProfiles = await listCharacterProfiles(workspace); const artifact = buildStoryBibleArtifact({ projectSlug: workspace.slug, brief, storyboard, characterProfiles, }); const artifactPath = await writeArtifact(workspace, 'story-bible', artifact); return { artifactPath, artifact }; }