/** * Show bible — the cartoon-SHOW asset library index. * * Source of truth: the Jack-Vs-AI "cartoon show" workflow (the whole video). Its * thesis is a REPEATABLE production system: a single reusable world of characters + * locations + voices that you recombine across MANY episodes, all consistent. The * existing per-project artifacts (characters.json, environment-assets.json, * voice-clones.json, brand-definition) each cover one asset kind; nothing ties them * into a SHOW that spans episodes. This module is that index. * * It is distinct from: * - story-bible (per-project narrative continuity: cast/settings/props/timeline of * ONE production), and * - director-blueprint (visual direction: color/lighting/forbidden moves). * The show bible is the multi-episode ASSET LIBRARY: which cast, which locations, * which voices, which style, and the EPISODE list of the world. * * Deterministic/pure: `buildShowBible()` assembles the bible from the project's * existing artifacts (passed in by the handler, which does the I/O) plus optional * operator overrides. `validateShowBible()` normalizes an operator-authored bible. * No provider calls. */ import { existsSync } from 'node:fs'; import { mkdir, readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { resolveProjectWorkspace } from './workspace.js'; import { writeTextFileAtomic } from './atomic-write.js'; // ── Artifact ────────────────────────────────────────────────────────────────── export interface ShowCastMember { name: string; /** Visual descriptor (from the character profile). */ description?: string; /** Character-sheet reference (image path / Asset URI), when known. */ sheetRef?: string; /** Bound voice clone name, when the character has one. */ voice?: string; } export interface ShowLocation { name: string; description?: string; /** Key-visual / plate reference, when known. */ plateRef?: string; /** * Multi-angle view labels for this location. Author-supplied via `--from-json` * sources — the auto-derive path reads single-view environment plates * (`environment-assets.json`), which carry no view labels, so deriving leaves * this empty. */ views?: string[]; } export interface ShowVoice { name: string; /** Character this voice belongs to, when bound. */ character?: string; /** The blank-video-with-audio clip path. */ clipPath?: string; description?: string; } export interface ShowEpisode { /** Stable episode id, e.g. "ep01". */ id: string; title: string; logline?: string; /** Optional scene briefs for the episode. */ scenes?: string[]; } export interface ShowBibleArtifact { schemaVersion: 1; projectSlug: string; generatedAt: string; showTitle: string; premise?: string; /** The locked art style of the world (one line). */ style?: string; cast: ShowCastMember[]; locations: ShowLocation[]; voices: ShowVoice[]; episodes: ShowEpisode[]; } export function showBiblePathFor(root: string, slug: string): string { return join(resolveProjectWorkspace(slug, root).artifactsDir, 'show-bible.json'); } export async function writeShowBible( root: string, slug: string, artifact: ShowBibleArtifact, ): Promise { const path = showBiblePathFor(root, slug); await mkdir(dirname(path), { recursive: true }); await writeTextFileAtomic(path, `${JSON.stringify(artifact, null, 2)}\n`); } /** Read `artifacts/show-bible.json` (null when absent). */ export async function readShowBible(root: string, slug: string): Promise { const path = showBiblePathFor(root, slug); if (!existsSync(path)) return null; return JSON.parse(await readFile(path, 'utf-8')) as ShowBibleArtifact; } // ── Assembly ──────────────────────────────────────────────────────────────────── /** The already-loaded project artifacts the assembler derives the bible from. */ export interface ShowBibleSources { characters?: Array<{ name: string; description?: string; referenceAssets?: string[] }>; environments?: Array<{ name: string; description?: string; plateRef?: string; plateUrl?: string; views?: Array<{ view: string }> }>; voices?: Array<{ name: string; character?: string; clipPath?: string; description?: string }>; /** A pre-existing bible to merge episodes/overrides onto (e.g. the persisted one). */ existing?: ShowBibleArtifact | null; } export interface BuildShowBibleInput { projectSlug: string; generatedAt: string; showTitle?: string; premise?: string; style?: string; sources: ShowBibleSources; /** Episodes to append/replace (matched by id). */ episodes?: ShowEpisode[]; } /** * Assemble the show bible deterministically. Cast is derived from character * profiles; voices are matched onto cast by their `character` binding (so a cast * member surfaces its bound voice). Locations come from environment assets. * Episodes merge by id (new replaces old). All overrides (title/premise/style) fall * back to the existing bible, then to sensible defaults. PURE. */ export function buildShowBible(input: BuildShowBibleInput): ShowBibleArtifact { const existing = input.sources.existing ?? null; const voiceByCharacter = new Map(); const voices: ShowVoice[] = (input.sources.voices ?? []).map((v) => { const voice: ShowVoice = { name: v.name, ...(v.character ? { character: v.character } : {}), ...(v.clipPath ? { clipPath: v.clipPath } : {}), ...(v.description ? { description: v.description } : {}), }; if (v.character) voiceByCharacter.set(v.character.toLowerCase(), voice); return voice; }); const cast: ShowCastMember[] = (input.sources.characters ?? []).map((c) => { const boundVoice = voiceByCharacter.get(c.name.toLowerCase()); return { name: c.name, ...(c.description ? { description: c.description } : {}), ...(c.referenceAssets?.[0] ? { sheetRef: c.referenceAssets[0] } : {}), ...(boundVoice ? { voice: boundVoice.name } : {}), }; }); const locations: ShowLocation[] = (input.sources.environments ?? []).map((e) => ({ name: e.name, ...(e.description ? { description: e.description } : {}), ...(e.plateUrl || e.plateRef ? { plateRef: e.plateUrl || e.plateRef } : {}), ...(Array.isArray(e.views) && e.views.length > 0 ? { views: e.views.map((v) => v.view) } : {}), })); // Merge episodes: start from existing, replace/append by id. const episodeById = new Map(); for (const ep of existing?.episodes ?? []) episodeById.set(ep.id, ep); for (const ep of input.episodes ?? []) episodeById.set(ep.id, ep); const episodes = [...episodeById.values()]; return { schemaVersion: 1, projectSlug: input.projectSlug, generatedAt: input.generatedAt, showTitle: input.showTitle ?? existing?.showTitle ?? input.projectSlug, ...(input.premise ?? existing?.premise ? { premise: input.premise ?? existing?.premise } : {}), ...(input.style ?? existing?.style ? { style: input.style ?? existing?.style } : {}), cast, locations, voices, episodes, }; } /** * Parse an `--add-episode "id|title|logline"` spec into a ShowEpisode. The id and * title are required; logline is optional. Throws on a malformed spec (code 1). */ export function parseEpisodeSpec(spec: string): ShowEpisode { const parts = spec.split('|').map((p) => p.trim()); const [id, title, logline] = parts; if (!id || !title) { throw new Error( `--add-episode expects "id|title[|logline]"; got ${JSON.stringify(spec)}.`, ); } return { id, title, ...(logline ? { logline } : {}) }; } /** * Validate + normalize an operator-authored show bible (from --from-json). Strict on * the required envelope; coerces arrays to empty when missing. Throws (code 1) on a * structurally invalid bible. */ export function validateShowBible( raw: unknown, ctx: { projectSlug: string; generatedAt: string }, ): ShowBibleArtifact { if (!raw || typeof raw !== 'object') { throw new Error('show-bible --from-json must be a JSON object.'); } const obj = raw as Partial; if (!obj.showTitle || typeof obj.showTitle !== 'string') { throw new Error('show-bible requires a string "showTitle".'); } const asArray = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []); return { schemaVersion: 1, projectSlug: ctx.projectSlug, generatedAt: ctx.generatedAt, showTitle: obj.showTitle, ...(obj.premise ? { premise: obj.premise } : {}), ...(obj.style ? { style: obj.style } : {}), cast: asArray(obj.cast), locations: asArray(obj.locations), voices: asArray(obj.voices), episodes: asArray(obj.episodes), }; }