/** * Render a {@link ProjectBlueprintArtifact} into prompt fragments the * filmmaking-prompts composer layers onto each scene packet, plus the * forbidden-movement validator. * * Pure, deterministic. The director addendum is intentionally PROSE-ONLY (no * Kelvin / hue numerals) so it passes the prompt-lint prose-register check, and * it is appended AFTER the canonical 10-block body so it never disturbs the * Seedance block-order contract. */ import type { ProjectBlueprintArtifact } from './project-blueprint.js'; import { sensoryClause, movementGrammar } from './shot-grammar.js'; const TENSION_WORDS = /\b(tense|tension|danger|dangerous|fear|afraid|conflict|chase|fight|threat|panic|struggle|peril)\b/; const HERO_WORDS = /\b(reveal|hero|heroic|triumph|epic|launch|climax|victory|rise|soar|payoff)\b/; /** * Pick the signature lighting setup for a scene from its text: tension words → * `tension`, hero/reveal words → `hero`, otherwise the `intimate` default. */ export function selectSignatureSetup(blueprint: ProjectBlueprintArtifact, sceneText: string): string { const text = sceneText.toLowerCase(); const setups = blueprint.lightingGrammar.signatureSetups; if (TENSION_WORDS.test(text) && setups.tension) return setups.tension; if (HERO_WORDS.test(text) && setups.hero) return setups.hero; return setups.intimate || setups.hero || setups.tension || ''; } /** Word-boundary substring test (escapes regex metachars). Used so a forbidden * movement token like `tracking`/`orbit` matches the word but never a longer * word that merely contains it (`backtracking`, `orbital`). */ function wordBoundaryMatch(haystack: string, token: string): boolean { const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(`\\b${escaped}\\b`).test(haystack); } function clause(label: string, value: string): string { return value.trim() ? `${label}: ${value.trim()}.` : ''; } /** * Render the compact, prose-only `DIRECTOR — …` addendum for one scene. Pulls * the master palette, look (contrast/saturation + vibe keywords), the scene's * signature lighting setup, the present characters' silhouette + signature * detail + signature framing, the matching environment's 5-sensory-words line, * and the camera bible's one rule. Returns '' when the blueprint carries no * usable direction (so callers can skip appending). */ export function renderDirectorLine( blueprint: ProjectBlueprintArtifact, opts: { characterNames?: string[]; sceneText?: string }, ): string { const sceneText = opts.sceneText ?? ''; const palette = blueprint.colorSystem.colors.map((c) => c.name).filter(Boolean).slice(0, 4).join(', '); const lookBits = [ blueprint.colorSystem.contrast ? `${blueprint.colorSystem.contrast} contrast` : '', blueprint.colorSystem.saturation, ...blueprint.output.vibeKeywords.slice(0, 6), ].filter(Boolean); const lighting = selectSignatureSetup(blueprint, sceneText); // Subject framing: only characters PRESENT in this scene, matched by name. const present = new Set((opts.characterNames ?? []).map((n) => n.toLowerCase())); const subjects = blueprint.characters .filter((c) => present.size === 0 ? false : present.has(c.name.toLowerCase())) .map((c) => { const look = [c.silhouette, c.signatureDetail].filter(Boolean).join(', '); const framing = c.cameraLanguage.signature || c.cameraLanguage.power || c.cameraLanguage.vulnerability; return [c.name, look].filter(Boolean).join(' — ') + (framing ? `; ${framing}` : ''); }); // Atmosphere: the environment whose name appears in the scene text, else the // first environment. const lowerScene = sceneText.toLowerCase(); const env = blueprint.environments.find((e) => e.name && lowerScene.includes(e.name.toLowerCase())) ?? blueprint.environments[0]; const atmosphere = env ? sensoryClause(env.sensory) : ''; const parts = [ clause('Palette', palette), clause('Look', lookBits.join(', ')), clause('Lighting', lighting), clause('Subject', subjects.join(' | ')), clause('Atmosphere', atmosphere), clause('Rule', blueprint.cameraBible.oneRule), ].filter(Boolean); return parts.length > 0 ? `DIRECTOR — ${parts.join(' ')}` : ''; } /** * Detect forbidden camera movements in a scene's prompt text. For each entry in * the camera bible's `forbiddenMovements`, checks the entry's spaced form (e.g. * `whip-pan` → `whip pan`) and its movement-grammar Seedance syntax token(s) * against the (lowercased) text. Returns the human-readable labels found * (deduped), so the composer can raise a `forbidden-camera-movement` issue. */ export function forbiddenMovementHits(blueprint: ProjectBlueprintArtifact, text: string): string[] { const haystack = text.toLowerCase(); const hits = new Set(); for (const raw of blueprint.cameraBible.forbiddenMovements) { const id = raw.trim().toLowerCase(); if (!id) continue; const spaced = id.replace(/-/g, ' '); const grammar = movementGrammar(id); const syntaxTokens = grammar.seedanceSyntax .toLowerCase() .split('/') .map((t) => t.trim()) .filter(Boolean); const candidates = [spaced, ...syntaxTokens].filter((c) => c.length >= 4); if (candidates.some((c) => wordBoundaryMatch(haystack, c))) { hits.add(spaced); } } return [...hits]; }