/** * Show-bible-driven, route-aware reference auto-attach. * * The cartoon-show method (Jack-Vs-AI workflow) is a per-route ruleset: on a * Seedance route identity rides on REFERENCES (each cast member's character * sheet + the scene's location plate as image refs, plus each speaking * character's bound voice clip as a video ref) PLUS a specific visual * descriptor; on Flow (veo-useapi) identity rides on a registered Flow * Character and the sheets/plates are NOT attached. This module turns a * persisted `artifacts/show-bible.json` into the additive, LOW-PRIORITY * back-fill that `buildExecutionPayload` merges into the SAME name-keyed maps * its existing per-route resolvers already consume — never a parallel * reference path. * * Composition contract (load-bearing): every map is seeded with * `if (!map.has(key)) map.set(key, bibleValue)` semantics so the per-project * artifacts (seedance-assets.json / flow-characters.json / voice-clones.json / * asset-manifest) always win. A fully-provisioned project — or any project with * NO show-bible — therefore produces a byte-identical payload. * * PURE: every function here is deterministic and does no I/O. The caller * (`buildExecutionPayload`) loads the bible and passes it in. */ import { existsSync } from 'node:fs'; import { isAbsolute, resolve } from 'node:path'; import type { ShowBibleArtifact } from './show-bible.js'; import type { AssetTagEntry, AssetTagLookup } from './prompt-rules.js'; import type { ProviderRouteId } from './provider-platform/types.js'; /** Seedance family — identity rides on references + a specific descriptor. */ export const SEEDANCE_ROUTE_FAMILY = new Set([ 'seedance-direct', 'runway-useapi', 'dreamina-useapi', ]); /** Flow family — identity rides on a registered Flow Character. */ export const FLOW_ROUTE_FAMILY = new Set(['veo-useapi']); export type ShowRouteFamily = 'seedance' | 'flow' | 'other'; export function routeFamilyFor(routeId: ProviderRouteId): ShowRouteFamily { if (SEEDANCE_ROUTE_FAMILY.has(routeId)) return 'seedance'; if (FLOW_ROUTE_FAMILY.has(routeId)) return 'flow'; return 'other'; } /** * Is this sheetRef/plateRef a reference the provider can consume WITHOUT a local * filesystem upload — i.e. an `Asset://` URI, an http(s) URL, or a `gobananas://` * URI? These pass through unchanged on every route. */ export function isHostedReference(ref: string): boolean { return /^Asset:\/\//i.test(ref) || /^https?:\/\//i.test(ref) || /^gobananas:\/\//i.test(ref); } /** * Resolve a bible-stored sheetRef/plateRef that may be a hosted URI, an absolute * path, or a repo/project-relative path. Hosted refs pass through unchanged. * Absolute paths pass through unchanged. A relative path is resolved against the * PROJECT root first, then the workspace root (mirrors environment-assets * preferring the hosted plateUrl). Returns null when a relative path resolves to * nothing on disk (so the caller can drop it / flag it). PURE except the * existsSync probe used to pick the directory. */ export function resolveBibleRef( ref: string | undefined, dirs: { projectDir: string; workspaceRoot: string }, ): string | null { const value = ref?.trim(); if (!value) return null; if (isHostedReference(value)) return value; if (isAbsolute(value)) return value; const projectCandidate = resolve(dirs.projectDir, value); if (existsSync(projectCandidate)) return projectCandidate; const workspaceCandidate = resolve(dirs.workspaceRoot, value); if (existsSync(workspaceCandidate)) return workspaceCandidate; return null; } /** * The bible-derived sources `buildExecutionPayload` folds into its existing * name-keyed maps. Keys are lowercased to match the lookups' conventions * (voiceByCharacter / characterRefByName lowercase; seedance assetUriByName is * exact-case, so the seedance back-fill below seeds with the bible's exact name * too). Every value is a LOW-PRIORITY back-fill — only used for a name the * per-project artifacts did not already resolve. */ export interface ShowBibleSourcesForRoute { /** * On seedance-direct only: cast sheetRefs that ARE `Asset://` URIs, keyed by * the bible's exact cast name (matches seedance `assetUriByName` casing). A raw * local portrait is NOT included — it fails submit + trips the real-person * filter (same rule as the asset-tag `assetUriOnly` path). */ assetUriByName: Map; /** * Cast + location refs the @Name/@location lookup should resolve to (descriptor * + an image referencePath when the ref is submittable on this route). Lowercased. */ assetTagEntries: AssetTagLookup; /** * Per-scene image references to attach for cast sheets + location plates on the * SEEDANCE family. Empty on Flow (the Flow Character bundles identity). Keyed * lowercase by cast name; the location plate is keyed by lowercase location name. */ imageRefByName: Map; /** * character (lowercased) -> voice clip reference (hosted URL preferred). Seeds * the same `voiceByCharacter` map the voice auto-inject path reads. */ voiceRefByCharacter: Map; } /** * Build the route-specific bible sources. Deterministic / no I/O beyond the * existsSync probe inside resolveBibleRef. * * Per-route: * • seedance family: cast sheetRefs become image refs (Asset:// preferred on * seedance-direct; hosted/local allowed on runway/dreamina), location * plateRefs become image refs, and bound voices become voice refs. * • flow: NOTHING is attached as a reference (the Flow Character carries * identity); the bible only supplies descriptor text via assetTagEntries. * • other: empty (no live route). */ export function buildShowBibleSourcesForRoute(input: { bible: ShowBibleArtifact; routeId: ProviderRouteId; projectDir: string; workspaceRoot: string; }): ShowBibleSourcesForRoute { const { bible, routeId, projectDir, workspaceRoot } = input; const family = routeFamilyFor(routeId); const dirs = { projectDir, workspaceRoot }; const assetUriByName = new Map(); const assetTagEntries: AssetTagLookup = new Map(); const imageRefByName = new Map(); const voiceRefByCharacter = new Map(); // Voice clipPath by voice name → so cast[].voice resolves to a clip. const voiceClipByName = new Map(); for (const voice of bible.voices ?? []) { if (!voice.name) continue; if (voice.clipPath) voiceClipByName.set(voice.name.toLowerCase(), voice.clipPath); } for (const member of bible.cast ?? []) { if (!member.name) continue; const key = member.name.toLowerCase(); const descriptor = member.description ?? member.name; const resolvedSheet = resolveBibleRef(member.sheetRef, dirs); if (family === 'seedance') { // On seedance-direct, only an Asset:// sheet is submittable; a hosted URL // (runway/dreamina) or any resolvable ref rides as a plain image ref. const submittable = routeId === 'seedance-direct' ? resolvedSheet && /^Asset:\/\//i.test(resolvedSheet) ? resolvedSheet : null : resolvedSheet; if (routeId === 'seedance-direct' && submittable) { assetUriByName.set(member.name, submittable); // exact-case, matches seedance map } if (submittable) imageRefByName.set(key, submittable); assetTagEntries.set(key, { descriptor, ...(submittable ? { referencePath: submittable } : {}), } satisfies AssetTagEntry); } else { // Flow / other: descriptor only, NEVER a sheet reference. assetTagEntries.set(key, { descriptor } satisfies AssetTagEntry); } // Bound voice → voiceRefByCharacter (seedance family only consumes it). if (family === 'seedance' && member.voice) { const clip = voiceClipByName.get(member.voice.toLowerCase()); if (clip) voiceRefByCharacter.set(key, clip); } } // Location plates → @location descriptor + image ref (seedance family only). for (const location of bible.locations ?? []) { if (!location.name) continue; const key = location.name.toLowerCase(); const descriptor = location.description ?? location.name; if (family === 'seedance') { const resolvedPlate = resolveBibleRef(location.plateRef, dirs); const submittable = routeId === 'seedance-direct' ? resolvedPlate && (/^Asset:\/\//i.test(resolvedPlate) || /^https?:\/\//i.test(resolvedPlate)) ? resolvedPlate : null : resolvedPlate; if (submittable) imageRefByName.set(key, submittable); assetTagEntries.set(key, { descriptor, ...(submittable ? { referencePath: submittable } : {}), } satisfies AssetTagEntry); } else { assetTagEntries.set(key, { descriptor } satisfies AssetTagEntry); } } return { assetUriByName, assetTagEntries, imageRefByName, voiceRefByCharacter }; } /** * Match a scene's text (description + animation prompt) against the bible's * location names. Returns the lowercased name of the FIRST location whose name * appears as a whole word in the scene text (word-boundary, case-insensitive), * or null. Mirrors the substring style stripProperNames uses. PURE. */ export function matchSceneLocation( sceneText: string, locationNames: string[], ): string | null { const names = locationNames.map((n) => n.trim()).filter(Boolean); // Single-location show (the common cartoon-show case — one sofa/room): the lone // location is the default for EVERY scene, so an operator never has to write the // literal location name into each scene's text. Multi-location shows fall back to // a whole-word name match against the scene text. if (names.length === 1) return names[0].toLowerCase(); const haystack = sceneText.toLowerCase(); for (const name of names) { const escaped = name.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(`(^|\\b)${escaped}(\\b|$)`, 'i'); if (re.test(haystack)) return name.toLowerCase(); } return null; }