/** * Show-preflight — the cartoon-show method, ENFORCED per route. * * Given a persisted show-bible + storyboard, this fail-fast gate confirms every * cast/speaking subject in every scene has the references the CHOSEN provider * route actually needs, so an operator just prompts and the tool refuses to * render with a piece missing. * * Per route (the live-proven ruleset): * • Seedance family (seedance-direct / runway-useapi / dreamina-useapi): * identity rides on REFERENCES + a specific descriptor — each cast member * needs a resolvable character sheet, the scene's location needs a plate, and * each speaking character needs a bound, resolvable voice clip. On * seedance-direct a sheet must additionally be a registered Asset Library * avatar (a raw portrait trips the real-person filter, exactly the proven * Davendra-as-"the man" drift). The prompt must describe subjects with a full * visual descriptor, never a bare generic noun. * • Flow (veo-useapi): identity rides on a REGISTERED Flow Character. The bible * cannot fabricate one (registration is out-of-band via flow-characters.json), * so the bible's role here is enforcement only — every scene cast member must * resolve to a Flow Character. Sheets/plates/descriptor checks are skipped. * * Read-only: emits a report, persists nothing. The route is resolved via the * SAME `buildExecutionPlan` resolver execute uses, so the route the preflight * checks is the route execute will pick. Gated on the bible's presence: a project * with no show-bible.json yields `ready: true` with no blockers (the method is * opt-in by adopting a show-bible), so legacy pipelines never start refusing. */ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { isAbsolute, resolve } from 'node:path'; import { artifactPathFor } from './artifact-store.js'; import { buildExecutionPlan } from './execution-plan.js'; import { readShowBible, type ShowBibleArtifact } from './show-bible.js'; import { readSeedanceAssets } from './seedance-asset-library.js'; import { readFlowCharacters } from './flow-character-library.js'; import { readVoiceClones } from './voice-clone.js'; import { resolveProjectWorkspace } from './workspace.js'; import { isHostedReference, matchSceneLocation, routeFamilyFor, type ShowRouteFamily, } from './show-bible-attach.js'; import type { ProviderRouteId } from './provider-platform/types.js'; import type { VideoProductionMode } from './types.js'; export interface ShowPreflightScene { sceneIndex: number; characters: string[]; location: string | null; issues: string[]; } export interface ShowPreflightReport { slug: string; root: string; productionMode: VideoProductionMode; routeId: ProviderRouteId | null; routeFamily: ShowRouteFamily; /** True when the bible is absent (gate inert) or no blockers were found. */ ready: boolean; /** When false, no show-bible.json exists — the gate is opt-in and inert. */ bibleAdopted: boolean; blockers: string[]; warnings: string[]; checkedScenes: ShowPreflightScene[]; nextAction: string; } // Bare generic subjects that don't lock identity (the "the man" drift class). const GENERIC_SUBJECT_RE = /\bthe\s+(man|woman|men|women|guy|girl|boy|lady|person|people|figure|character|cat|dog|animal)\b/i; // A subject qualified by an adjective ("grey tabby cat", "middle-aged man", // "grey-haired man") IS specifically described — the prompt only risks identity // drift when a generic noun appears with NO descriptor anywhere. The negative // lookahead excludes bare articles so "the cat"/"a man" alone do not count as // "described", while "grumpy cat"/"middle-aged man" do. const DESCRIBED_SUBJECT_RE = /\b(?!(?:the|a|an)\b)[a-z]+(?:-[a-z]+)?\s+(man|woman|men|women|guy|girl|boy|lady|person|people|figure|character|cat|dog|animal)\b/i; function resolveLocalRefExists( ref: string, dirs: { projectDir: string; workspaceRoot: string }, ): boolean { if (isHostedReference(ref)) return true; // hosted refs are assumed reachable if (isAbsolute(ref)) return existsSync(ref); return existsSync(resolve(dirs.projectDir, ref)) || existsSync(resolve(dirs.workspaceRoot, ref)); } interface StoryboardScene { sceneIndex?: number; description?: string; scenePrompt?: { animationPrompt?: string }; characters?: string[]; } /** * Build the show-preflight report. PURE-ish: reads the bible, storyboard, and the * per-route registries via the existing readers; resolves the route via * buildExecutionPlan. Never throws on a missing piece — it reports a blocker. */ export async function buildShowPreflight( slug: string, root = process.cwd(), fallbackMode: VideoProductionMode = 'storyboard', options: { env?: NodeJS.ProcessEnv } = {}, ): Promise { const workspace = resolveProjectWorkspace(slug, root); const dirs = { projectDir: workspace.projectDir, workspaceRoot: workspace.root }; const plan = await buildExecutionPlan(slug, root, fallbackMode, { env: options.env }); const routeId = plan.recommendedRouteId; const routeFamily: ShowRouteFamily = routeId ? routeFamilyFor(routeId) : 'other'; const bible = await readShowBible(workspace.root, slug); // Gate is opt-in: no bible → inert, ready, no blockers (byte-identical to a // project that never adopted the cartoon-show method). if (!bible) { return { slug, root: workspace.root, productionMode: plan.productionMode, routeId, routeFamily, ready: true, bibleAdopted: false, blockers: [], warnings: [], checkedScenes: [], nextAction: 'No show-bible.json — show-preflight is inert. Adopt a show-bible to enforce the cartoon-show method.', }; } const blockers: string[] = []; const warnings: string[] = []; const checkedScenes: ShowPreflightScene[] = []; // route-unavailable mirrors execution-plan's "No available provider route". if (!routeId || routeFamily === 'other') { blockers.push( routeId ? `route-unsupported: ${routeId} is not a Seedance or Flow route the cartoon-show method supports.` : 'route-unavailable: no available provider route was resolved (matches execution-plan).', ); } const storyboardPath = artifactPathFor(workspace, 'storyboard'); if (!existsSync(storyboardPath)) { blockers.push('storyboard-missing: run `vclaw video storyboard ...` (or import a storyboard) first.'); return finalize(slug, workspace.root, plan.productionMode, routeId, routeFamily, true, blockers, warnings, checkedScenes); } let storyboard: { scenes?: StoryboardScene[] }; try { storyboard = JSON.parse(await readFile(storyboardPath, 'utf-8')) as { scenes?: StoryboardScene[] }; } catch { blockers.push('storyboard-unreadable: storyboard artifact could not be parsed.'); return finalize(slug, workspace.root, plan.productionMode, routeId, routeFamily, true, blockers, warnings, checkedScenes); } // Name-keyed registries the per-route resolvers consume (lowercased lookups). const seedanceAssets = await readSeedanceAssets(workspace.root, slug); const seedanceNames = new Set([...seedanceAssets.assetUriByName.keys()].map((n) => n.toLowerCase())); const flow = await readFlowCharacters(workspace.root, slug); const flowNames = new Set([...flow.characterRefByName.keys()].map((n) => n.toLowerCase())); const voiceClones = await readVoiceClones(workspace.root, slug); // Bible name → cast member (lowercased join key, matching the runtime). const castByName = new Map(); for (const member of bible.cast ?? []) { if (member.name) castByName.set(member.name.toLowerCase(), member); } const locationByName = new Map(); for (const location of bible.locations ?? []) { if (location.name) locationByName.set(location.name.toLowerCase(), location); } const voiceByName = new Map(); for (const voice of bible.voices ?? []) { if (voice.name) voiceByName.set(voice.name.toLowerCase(), voice); } const scenes = storyboard.scenes ?? []; for (const [i, scene] of scenes.entries()) { const sceneIndex = typeof scene.sceneIndex === 'number' ? scene.sceneIndex : i; const characters = (scene.characters ?? []).filter((n) => typeof n === 'string' && n.trim()); const sceneText = `${scene.description ?? ''} ${scene.scenePrompt?.animationPrompt ?? ''}`.trim(); const matchedLocation = matchSceneLocation( sceneText, [...locationByName.values()].map((l) => l.name), ); const sceneIssues: string[] = []; // GLOBAL: every scene cast member must exist in the bible cast. for (const name of characters) { if (!castByName.has(name.toLowerCase())) { sceneIssues.push( `cast-not-in-bible: "${name}" is on scene ${sceneIndex} but not in show-bible cast — add it via \`vclaw video show-bible\`.`, ); } } if (routeFamily === 'seedance') { // Cast sheet must resolve; on seedance-direct it must also be registered. for (const name of characters) { const member = castByName.get(name.toLowerCase()); if (!member) continue; // already flagged cast-not-in-bible const sheet = member.sheetRef?.trim(); if (!sheet) { sceneIssues.push( `cast-sheetRef-missing: "${name}" (scene ${sceneIndex}) has no sheetRef in the show-bible.`, ); } else if (!resolveLocalRefExists(sheet, dirs)) { sceneIssues.push( `referenced-file-missing: "${name}" sheetRef "${sheet}" (scene ${sceneIndex}) does not resolve on disk.`, ); } if (routeId === 'seedance-direct' && !seedanceNames.has(name.toLowerCase())) { sceneIssues.push( `seedance-asset-not-registered: "${name}" (scene ${sceneIndex}) is not in the Asset Library — run \`vclaw video seedance-register-assets\` (a raw portrait trips the real-person filter).`, ); } } // Location plate. if (characters.length > 0) { if (!matchedLocation) { sceneIssues.push( `location-plate-missing: scene ${sceneIndex} text matches no show-bible location — the location plate is a proven Seedance reference.`, ); } else { const location = locationByName.get(matchedLocation); const plate = location?.plateRef?.trim(); if (!plate) { sceneIssues.push( `location-plate-missing: location "${matchedLocation}" (scene ${sceneIndex}) has no plateRef in the show-bible.`, ); } else if (!resolveLocalRefExists(plate, dirs)) { sceneIssues.push( `referenced-file-missing: location "${matchedLocation}" plateRef "${plate}" (scene ${sceneIndex}) does not resolve on disk.`, ); } } } // Speaking-character voices. "Speaking" is approximated as a cast member // BOUND to a voice in the bible (no per-scene dialogue model exists). for (const name of characters) { const member = castByName.get(name.toLowerCase()); if (!member?.voice) continue; // silent / unvoiced cast — not a blocker const voice = voiceByName.get(member.voice.toLowerCase()); const clip = voice?.clipPath?.trim(); const cloneEntry = voiceClones.voiceByName.get(member.voice.toLowerCase()); if (!clip && !cloneEntry) { sceneIssues.push( `voice-clip-missing: "${name}" (scene ${sceneIndex}) is bound to voice "${member.voice}" but no voice clip resolves — run \`vclaw video voice-clone\`.`, ); } else { const resolvable = clip ?? cloneEntry?.hostedUrl ?? cloneEntry?.clipPath; if (resolvable && !resolveLocalRefExists(resolvable, dirs)) { sceneIssues.push( `voice-clip-missing: "${name}" voice clip "${resolvable}" (scene ${sceneIndex}) does not resolve on disk.`, ); } } } // Prompt must use a full visual descriptor, never a bare generic noun. // Only a flag when a bare generic ("the man"/"the cat") appears AND the // prompt provides NO adjective-qualified descriptor for any subject — a // prompt that describes its subjects (and merely back-references them as // "the cat") is not the drift risk and must not block. if (sceneText && GENERIC_SUBJECT_RE.test(sceneText) && !DESCRIBED_SUBJECT_RE.test(sceneText)) { sceneIssues.push( `prompt-generic-subject: scene ${sceneIndex} prompt uses a generic subject (e.g. "the man") with no visual descriptor — describe each subject fully (this is the axis that locks identity).`, ); } } else if (routeFamily === 'flow') { // Flow: only the Flow Character registration is enforced. for (const name of characters) { if (!flowNames.has(name.toLowerCase())) { sceneIssues.push( `flow-character-missing: "${name}" (scene ${sceneIndex}) has no registered Flow Character — run \`vclaw video flow-register-characters\` (the bible cannot fabricate one).`, ); } } } checkedScenes.push({ sceneIndex, characters, location: matchedLocation, issues: sceneIssues }); blockers.push(...sceneIssues); } return finalize(slug, workspace.root, plan.productionMode, routeId, routeFamily, true, blockers, warnings, checkedScenes); } function finalize( slug: string, root: string, productionMode: VideoProductionMode, routeId: ProviderRouteId | null, routeFamily: ShowRouteFamily, bibleAdopted: boolean, blockers: string[], warnings: string[], checkedScenes: ShowPreflightScene[], ): ShowPreflightReport { const ready = blockers.length === 0; const nextAction = ready ? 'Show-preflight passed — every cast/speaking subject has the references this route needs.' : `Resolve ${blockers.length} blocker(s) before rendering: ${blockers[0]}`; return { slug, root, productionMode, routeId, routeFamily, ready, bibleAdopted, blockers, warnings, checkedScenes, nextAction, }; }