import { captureFilmReferenceEvidence, type FilmReferenceEvidence } from './film-reference-evidence.js'; import { VclawError } from './errors.js'; import { assertFilmPlan, shotDirectionPrompt, filmPlanFingerprint } from './film-plan.js'; import { mergePerformanceCanon, adaptPerformanceContext, type FilmmakingCharacterContext } from './character-performance.js'; import { characterSheetThreePanelPrompt, type ReferenceProfile } from './character-reference-prompts.js'; import { characterSheetDescriptionPrompt, characterSheetReferencePrompt, characterSheetSixPanelPrompt } from './character-reference-prompts.js'; import { resolveWorkspaceRootFromEnv } from './workspace-root.js'; import { writeArtifact } from './artifact-store.js'; import { backgroundPlate, flatGradeClose, type CaptureRealismOpts, type PlateKind, type DetailLevel } from './cinematography.js'; import type { AssetManifestArtifact, BriefArtifact, StoryboardArtifact } from './artifacts.js'; import { referenceBuildOrder, resolveCategory, type ReferenceBuildStep } from './category-registry.js'; import { listCharacterProfiles, type CharacterProfile } from './characters.js'; import { readReferenceSheetsArtifact } from './reference-sheet-store.js'; import type { ReferenceSheetsArtifact } from './types.js'; import { ensureProjectWorkspace, readProjectManifest } from './workspace.js'; import { type DialogueLine } from './multi-shot-prompt.js'; import { readProjectBlueprint, type ProjectBlueprintArtifact } from './project-blueprint.js'; import { renderDirectorLine, forbiddenMovementHits } from './blueprint-prompt.js'; import { readBrandDefinition, type BrandDefinitionArtifact } from './brand-definition.js'; import { renderBrandLine } from './brand-prompt.js'; import { resolveCinemaProfile, type ResolvedCinemaProfile, type CinemaProfileOverrides } from './cinema-profile.js'; export { characterSheetThreePanelPrompt } from './character-reference-prompts.js'; export { characterSheetDescriptionPrompt, characterSheetReferencePrompt, characterSheetSixPanelPrompt } from './character-reference-prompts.js'; export { invisibleMannequinPrompt, outfitReplacementPrompt } from './outfit-prompts.js'; // Phase 3b: the product branch, the Seedance composer, the panel vocab and the shared helpers // live in their own modules; every public name stays importable from here. import { richCinematographySuffix, type GenreStyle, resolveGenreStyle, readOptionalArtifact, nextSlot, cleanSentence } from './filmmaking-shared.js'; import { generateProductPrompts } from './filmmaking-product-prompts.js'; import { checkSeedanceBlockOrder, seedancePromptText, threeBeatFrameMap, weaveDialogueIntoFrameMap } from './seedance-prompt.js'; import { buildPanels, characterLine, findIdentitySheet, firstCharacterReference } from './filmmaking-vocab.js'; import { anchorManifestAssetPath } from './asset-spec.js'; export { richCinematographySuffix, resolveGenreStyle } from './filmmaking-shared.js'; export type { GenreStyle } from './filmmaking-shared.js'; export { checkSeedanceBlockOrder, seedancePromptText } from './seedance-prompt.js'; /** * Two-phase gate for the generation function (E5). The intended workflow is * (1) lock the storyboard/panel layout + camera language, then (2) generate the * heavy video-generation packets. `phase` selects which slice to return: * - omitted (default) → full result, byte-identical to today (no behavior change). * - `'storyboard'` → storyboard-only: `seedancePackets` is gated to `[]`; the * storyboard/camera-language portion (referenceMap, * characterSheetPrompts, storyboardGridPrompt) is kept. * - `'video'` → full video packets, equivalent to the default. */ export type FilmmakingPhase = 'storyboard' | 'video'; export type FilmmakingPromptVariant = | 'character-sheet' | 'storyboard-grid' | 'text-driven' | 'storyboard-grid-reference' | 'character-sheets-plus-storyboard-grid'; export interface FilmmakingReferenceSlot { slot: string; role: 'character-sheet' | 'storyboard-grid' | 'start-frame' | 'end-frame' | 'reference-image' | 'background-plate'; label: string; path?: string; characterName?: string; sceneIndex?: number; status: 'ready' | 'pending'; } export interface FilmmakingCharacterSheetPrompt { identityDescription?: string; characterName: string; mode: 'reference-image' | 'description-only'; referenceSlots: string[]; promptText: string; } export interface FilmmakingStoryboardPanel { panel: number; position: string; timecode: string; beat: string; cam: string; move: string; mood: string; } export interface FilmmakingStoryboardGridPrompt { variant: 'storyboard-grid'; panelCount: number; rows: number; cols: number; promptText: string; panels: FilmmakingStoryboardPanel[]; } export interface FilmmakingTimelineBeat { /** Timecode span for this beat, e.g. `0:00-0:05`. */ t: string; /** Shot/action direction for this beat. */ beat: string; } export interface FilmmakingSeedancePacket { sceneIndex: number; /** * How `buildExecutionPayload` treats `promptText`. Omitted / `'composed'` is * today's behaviour: `@Name` tags are resolved (text AND references), Flow * markers injected or stripped, costume/scale clauses and the standing render * rules appended. `'exact'` submits `promptText` BYTE FOR BYTE and collects no * tag references — for a prompt a human approved as written. It is the tool's * answer to working-discipline rule 5: an `@tag` silently rewrote an approved * prompt and hijacked its references once; an exact packet cannot be rewritten. * The cost is the operator's: nothing is appended, so the no-speech / natural- * motion rules and any costume lock must already be IN the text * (`prompt-lint` says so on every exact packet). Set it by editing the * artifact; re-running `filmmaking-prompts` regenerates packets without it. * Scope — what it covers (the prompt, through every later stage) and what it * does not (registry-attached references) — is spelled out in `prompt-policy.ts`. */ promptPolicy?: 'composed' | 'exact'; variant: Extract< FilmmakingPromptVariant, 'text-driven' | 'storyboard-grid-reference' | 'character-sheets-plus-storyboard-grid' >; durationSeconds: number; /** * OUTPUT-DEPENDENT render resolution (e.g. `720p`, `1080p`). Omitted by * default — only populated when the caller threads a resolution through * {@link GenerateFilmmakingPromptsOptions.resolution}, so the default packet * shape is byte-identical to before. */ resolution?: string; /** * OUTPUT-DEPENDENT multi-beat scene timeline. A single kinetic shot needs NO * timeline; a multi-beat render does. Omitted by default and whenever * {@link GenerateFilmmakingPromptsOptions.singleShot} is set; populated only * when {@link GenerateFilmmakingPromptsOptions.timeline} is requested AND the * render is not single-shot. */ timeline?: FilmmakingTimelineBeat[]; references: FilmmakingReferenceSlot[]; promptText: string; warnings: string[]; } export interface FilmmakingPromptIssue { code: | 'character-description-missing' | 'character-description-long' | 'storyboard-missing' | 'storyboard-grid-pending' | 'reference-slot-pending' | 'seedance-music-default' | 'seedance-block-order' | 'forbidden-camera-movement'; severity: 'warning' | 'error'; message: string; path?: string; } export interface FilmmakingPromptsArtifact { filmPlanFingerprint?: string; referenceEvidence?: FilmReferenceEvidence[]; validationProfile?: 'cinematic-v1'; promptRegister?: 'prose' | 'numeric'; schemaVersion: 1; projectSlug: string; generatedAt: string; sourceSkill: 'ai-filmmaking'; durationDefaultSeconds: number; referenceMap: FilmmakingReferenceSlot[]; characterSheetPrompts: FilmmakingCharacterSheetPrompt[]; storyboardGridPrompt: FilmmakingStoryboardGridPrompt | null; seedancePackets: FilmmakingSeedancePacket[]; issues: FilmmakingPromptIssue[]; } export interface GenerateFilmmakingPromptsOptions { root?: string; projectSlug: string; durationSeconds?: number; storyboardGridPath?: string; /** * Render the storyboard grid prompt in a silhouette / no-face register so the * grid stays usable as a provider `reference_image` (real-person content * filters reject photoreal faces). See the multi-shot-framework Anti-patterns. */ noFaces?: boolean; /** * Visual style/genre — the ai-filmmaking skill treats this as a swappable * parameter, not a hardcoded assumption. Known: live-action (default), pixar, * anime, noir, influencer/vlog, action/martial-arts, music-video. An unknown * value is passed through as a free-form style descriptor. */ genre?: string; /** * Category id (see `category-registry.ts`). Resolves to a `CategoryDescriptor` * that supplies a default genre/style. Default (undefined) → the `cinematic` * character descriptor, whose genre is `'live-action'` — i.e. today's default, * so this is a no-op for the existing character/cinematic path. An explicit * `genre` still wins over the descriptor's genre. Unknown ids throw. */ category?: string; /** Aspect ratio stated in every template (default 16:9; 9:16 for vertical/social). */ aspectRatio?: string; sheetAspectRatio?: string; referenceProfile?: ReferenceProfile; /** * Storyboard panel count — 9, 12, 15 (default), or 20. Drives the grid layout * (3×3 / 3×4 / 3×5 / 4×5, transposed for vertical aspect ratios) and the * per-panel timecode breakdown. Mirrors the storyboard-prompt-builder skill. */ panelCount?: number; /** * Cinematography language density (default `standard`). At `rich`, a quantified * cinematography suffix (lens mm, Kelvin, key angle, color-grade hue/sat, audio * dB hierarchy, move velocity in ft/s) is appended to the storyboard-grid Style * line and the text-driven Seedance STYLE/AUDIO lines. `terse`/`standard` emit * exactly today's output (no behavior change). */ detail?: DetailLevel; /** Storyboard phase omits video packets; video/default returns the full result. */ phase?: FilmmakingPhase; write?: boolean; /** * Character sheet layout variant (default `'8-shot'` — unchanged behavior). * Set to `'6-panel'` to opt into the compact 3-column × 2-row mid-gray sheet * built by {@link characterSheetSixPanelPrompt}, or `'3-panel'` for the Joey * 3.0 identity-anchor sheet (headless front / rear / tight chest-up face * lock, flat shadowless grade) built by * {@link characterSheetThreePanelPrompt}. */ sheetLayout?: '8-shot' | '6-panel' | '3-panel'; /** * Joey opt-in anti-plastic realism block (`captureRealismBlock`). When set, the * `rich`-detail storyboard-grid + text-driven CAMERA CAPTURE Style lines append * the keystone capture-realism clause. Omitted (default) → byte-identical to * today (the suffix is the legacy no-arg form). `wet`/`haze` only apply when * `realism` is enabled. */ realism?: CaptureRealismOpts | false; /** * Cinematography register for the `rich`-detail suffix and the CAMERA CAPTURE * block: `'prose'` (Joey 2.0 behaviour wording, no colour-math numerals) or * `'numeric'` (Kelvin / key-angle / ratio / hue°). When omitted the resolved * {@link resolveCinemaProfile} register applies (HARD DEFAULT `'prose'`). */ register?: 'prose' | 'numeric'; /** * Energy dial — binds cant range, camera physicality, and frame stillness * into one register so they cannot be set against each other. Omitted leaves * the camera-body clause off entirely (byte-identical). */ dynamicRegister?: 'composed' | 'elevated' | 'kinetic' | 'violent'; /** * Strobe pulse in BPM. Emits THE STROBE block AND forces the cadence * quarantine — coupled here rather than left to the operator, because a * strobe without the quarantine returns genuinely broken footage. */ strobeBpm?: number; /** * Lighting register id for the `rich`-detail cinematography suffix (default * `'neutral-studio'`). Omitted → legacy default. See `lightingSpec`. */ lightingId?: string; /** * Color-grade register id for the `rich`-detail cinematography suffix (default * `'teal-orange'`). Omitted → legacy default. See `gradeSpec`. */ gradeId?: string; /** * Backdrop plate kind. When set, appends a `backgroundPlate` clause to the * storyboard-grid Style line (opt-in, additive). Omitted (default) → no plate * clause, output unchanged. The character-sheet prompts keep their own * mid-gray default verbatim. */ plateKind?: PlateKind; /** Apply shadowless neutral lighting to reference sheets only. Scene lighting is independent. */ flatGrade?: boolean; /** * Opt-in (WS-C) canonical multi-reference text discipline proven by the user's * ARK payloads. When set, EVERY multi-reference Seedance packet additionally * emits: (a) a per-character POSITIONAL visual-descriptor line (Center/Left/ * Right/…, visual descriptors, never proper names), (b) an explicit * identity-lock "no face morphing" line, (c) the single-full-frame guard on * ALL packets (not only grid-bearing ones), and (d) a diegetic soundscape line * when {@link generateAudio} is set (else the existing no-music line). Omitted * (default) → output is byte-identical to today. */ textDiscipline?: boolean; /** * Whether the scene generates audio. Only consulted when {@link textDiscipline} * is set: `true` swaps the no-music line for a diegetic soundscape line; `false` * (default) keeps the existing no-music line. Mirrors the execution profile's * `generateAudio` flag. */ generateAudio?: boolean; /** * OUTPUT-DEPENDENT render resolution (e.g. `720p`, `1080p`). When set, each * generated Seedance packet carries it on `resolution`, so a downstream * `buildExecutionPayload` task can submit a per-render resolution instead of a * fixed one. Omitted (default) → no `resolution` field, output unchanged. */ resolution?: string; /** * Emit a multi-beat scene timeline on each Seedance packet. The timeline/ * scene-timing block is OUTPUT-DEPENDENT: a multi-beat render wants it, a * single kinetic shot does not. Default (omitted/false) → no `timeline` field, * output byte-identical to today. Ignored when {@link singleShot} is set. */ timeline?: boolean; /** * Render as a single kinetic shot — no scene-timing/timeline block. When set, * packets NEVER carry a `timeline` field (it wins over {@link timeline}). * Default (omitted/false) leaves timeline behaviour to {@link timeline}. */ singleShot?: boolean; /** * The AI-Director Project Blueprint. When provided (or auto-read from * `artifacts/project-blueprint.json` when omitted), every scene packet gains a * compact prose `DIRECTOR — …` addendum (master palette, look + vibe, the * scene's signature lighting setup, present-character silhouette/signature * framing, the environment's 5-sensory-words line, and the camera bible's one * rule) AND the camera bible's forbidden movements are validated against each * packet (a `forbidden-camera-movement` issue per hit). Absent → byte-identical * to today. */ blueprint?: ProjectBlueprintArtifact; /** * The locked brand system (brand-agency skill). When provided (or auto-read * from `artifacts/brand-definition.json` when omitted), every scene packet * gains a compact prose `BRAND — …` addendum (wordmark, named palette, * voice tone) appended after the canonical body and any DIRECTOR addendum. * Absent → byte-identical to today. */ brandDefinition?: BrandDefinitionArtifact; /** * Spoken dialogue woven into every scene packet (the ai-filmmaking "Dialog * scenes" discipline: `"X says: …"` / `"Y replies: …"` signals consecutive- * order speech so speakers don't collapse into each other). Text-driven * packets carry it on the opening FRAME MAP beat; the grid-reference and * character-sheets-plus-storyboard-grid variants carry it on the Storyline * line. A speaker whose name matches a stored character is rendered by that * character's visual descriptor, never the proper name. Character path only. * Applies to every scene packet that lacks a {@link dialogueByScene} entry. * Omitted (default) → output byte-identical to today. */ dialogue?: DialogueLine; /** * Per-scene dialogue keyed by `sceneIndex`. A scene with an entry here uses * it instead of the blanket {@link dialogue} (so a four-scene piece can put * the exchange only on the scene where it happens). A scene without an entry * falls back to {@link dialogue} if set, else stays dialogue-free. Empty/ * omitted → no effect. */ dialogueByScene?: Map; /** * Rewrite named `[emotion]` dialogue tags as physical-cue descriptors * (`rewriteEmotionAsPhysical`). Consulted for both {@link dialogue} and * {@link dialogueByScene}. Default off. */ emotionCues?: boolean; } export const SUPPORTED_PANEL_COUNTS = [9, 12, 15, 20] as const; // Grid layout per panel count (storyboard-prompt-builder skill). Horizontal // orientation keeps cols ≥ rows; a vertical (taller-than-wide) aspect ratio // transposes so the sheet reads top-to-bottom. function gridLayout(panelCount: number, aspectRatio: string): { rows: number; cols: number } { const base: Record = { 9: { rows: 3, cols: 3 }, 12: { rows: 3, cols: 4 }, 15: { rows: 3, cols: 5 }, 20: { rows: 4, cols: 5 }, }; const layout = base[panelCount] ?? { rows: 3, cols: Math.ceil(panelCount / 3) }; const [w, h] = aspectRatio.split(':').map((n) => Number(n)); const vertical = Number.isFinite(w) && Number.isFinite(h) && h > w; return vertical ? { rows: layout.cols, cols: layout.rows } : layout; } export function resolvePanelCount(panelCount?: number): number { if (panelCount === undefined) return 15; if (!(SUPPORTED_PANEL_COUNTS as readonly number[]).includes(panelCount)) { throw new Error(`filmmaking-prompts: --panels must be one of ${SUPPORTED_PANEL_COUNTS.join(', ')} (got ${panelCount})`); } return panelCount; } export interface GenerateFilmmakingPromptsResult { artifact: FilmmakingPromptsArtifact; artifactPath?: string; } /** * Build the per-call CLI override layer for {@link resolveCinemaProfile} from the * generate options. Only fields the caller actually set are forwarded so the * project manifest + genre + HARD DEFAULT layers fill in the rest. The legacy * `realism` option (a {@link CaptureRealismOpts} | false) is mapped onto the * profile's boolean `realism` plus its `haze`/`wet` knobs. */ function cinemaOverridesFromOptions( options: GenerateFilmmakingPromptsOptions, ): CinemaProfileOverrides { const overrides: CinemaProfileOverrides = {}; if (options.detail !== undefined) overrides.detail = options.detail; if (options.register !== undefined) overrides.register = options.register; if (options.lightingId !== undefined) overrides.lightingId = options.lightingId; if (options.gradeId !== undefined) overrides.gradeId = options.gradeId; if (options.plateKind !== undefined) overrides.plateKind = options.plateKind; if (options.dynamicRegister !== undefined) overrides.dynamicRegister = options.dynamicRegister; if (options.strobeBpm !== undefined) overrides.strobeBpm = options.strobeBpm; if (options.realism !== undefined) { if (options.realism === false) { overrides.realism = false; } else { overrides.realism = true; if (options.realism.haze !== undefined) overrides.haze = options.realism.haze; if (options.realism.wet !== undefined) overrides.wet = options.realism.wet; } } return overrides; } /** * Project the resolved profile into the cinematography-override bag accepted by * {@link buildStoryboardGridPrompt} / {@link seedancePromptText}. `realism:false` * disables the capture-realism block; otherwise the resolved haze/wet flow in. */ function cinematicsFromProfile(profile: ResolvedCinemaProfile): { realism: CaptureRealismOpts | false; register: 'prose' | 'numeric'; captureRegister: 'cinema' | 'phone'; lightingId?: string; gradeId?: string; plateKind: PlateKind; } { return { realism: profile.realism ? { haze: profile.haze, ...(profile.wet ? { wet: true } : {}) } : false, register: profile.register, captureRegister: profile.captureRegister, ...(profile.lightingId !== undefined ? { lightingId: profile.lightingId } : {}), ...(profile.gradeId !== undefined ? { gradeId: profile.gradeId } : {}), plateKind: profile.plateKind, }; } export async function generateFilmmakingPrompts( options: GenerateFilmmakingPromptsOptions, ): Promise { const root = options.root ?? resolveWorkspaceRootFromEnv(); const durationSeconds = options.durationSeconds ?? 15; const noFaces = options.noFaces ?? false; // Resolve the category descriptor (default → cinematic character descriptor). // The descriptor supplies a default genre; an explicit `--genre` still wins. // For the cinematic default this yields `'live-action'`, identical to today's // behavior — purely internal plumbing, no output change on the character path. // `subjectType` selects the branch: `'character'` (default) keeps today's // cinematic/character path verbatim; `'product'` takes the additive // product-subject branch below. const descriptor = resolveCategory(options.category); const effectiveGenre = options.genre ?? descriptor.genre; const genreStyle = resolveGenreStyle(effectiveGenre); const panelCount = resolvePanelCount(options.panelCount); const workspace = await ensureProjectWorkspace(options.projectSlug, root); // Resolve the cinema profile ONCE: project manifest < CLI overrides, with the // genre + HARD DEFAULT below them (Joey 2.0: rich + realism + prose by default). // With ZERO flags and no project block this yields the full photoreal treatment. const manifest = await readProjectManifest(workspace); options = { ...options, referenceProfile: options.referenceProfile ?? manifest?.cinemaProfile?.referenceProfile }; const profile = resolveCinemaProfile( manifest?.cinemaProfile, cinemaOverridesFromOptions(options), effectiveGenre, ); const detail = profile.detail; const brief = await readOptionalArtifact(workspace, 'brief'); const aspectRatio = options.aspectRatio?.trim() || (brief?.metadata?.executionProfile as { aspectRatio?: string } | undefined)?.aspectRatio || '16:9'; const sheetAspectRatio = options.sheetAspectRatio?.trim() || '16:9'; const generatedAt = new Date().toISOString(); const storyboard = await readOptionalArtifact(workspace, 'storyboard'); if (storyboard) assertFilmPlan(storyboard); if (descriptor.subjectType === 'product') { if (storyboard?.filmPlan) throw new VclawError('invalid_flag_value', 'Structured film plans are not yet supported by product-category prompt compilation. Use the cinematic category with the advert/explainer film format to retain the approved shot directions.', { code: 'film-plan-product-unsupported' }); return generateProductPrompts({ options, workspace, brief, descriptor, genreStyle, aspectRatio, durationSeconds, detail, profile, generatedAt, }); } const assetManifest = await readOptionalArtifact(workspace, 'asset-manifest'); const referenceSheets = await readReferenceSheetsArtifact(root, options.projectSlug); const characters = await listCharacterProfiles(workspace); // Director layer: the explicit option wins; otherwise auto-read the persisted // blueprint (graceful null when absent → byte-identical legacy output). const blueprint = options.blueprint ?? (await readProjectBlueprint(root, options.projectSlug)) ?? undefined; // Brand layer: the explicit option wins; otherwise auto-read the persisted // brand definition (graceful null when absent → byte-identical legacy output). const brandDefinition = options.brandDefinition ?? (await readBrandDefinition(root, options.projectSlug)) ?? undefined; const issues: FilmmakingPromptIssue[] = []; const sheetLayout = options.sheetLayout ?? (options.referenceProfile === 'cinematic-face-first' ? '3-panel' : '8-shot'); const referenceMap = buildReferenceMap(referenceSheets, characters, assetManifest, workspace.projectDir); const characterSheetPrompts = buildCharacterSheetPrompts(characters, referenceMap, issues, genreStyle, sheetAspectRatio, sheetLayout, options.flatGrade ?? false); // Character lock: carry each character's identity description + its @imageN // slot forward into the Seedance Variant A SUBJECT lines (verbatim reuse is // the skill's most important rule). const storyBible = await readOptionalArtifact<{ characters?: unknown[] }>(workspace, 'story-bible'); const canon = mergePerformanceCanon(brief?.metadata?.characters, characters, storyBible?.characters); const characterContext = new Map(); for (const character of characters) { const slot = referenceMap.find((s) => s.role === 'character-sheet' && s.characterName === character.name && s.status === 'ready'); characterContext.set(character.name, { ...canon.get(character.name.toLowerCase()), ...(slot ? { slot: slot.slot } : {}), description: cleanSentence(character.description ?? `${character.name}, visually distinctive lead character`), }); } const storyboardGridPrompt = storyboard ? buildStoryboardGridPrompt(storyboard, brief, characterSheetPrompts, noFaces, genreStyle, aspectRatio, panelCount, durationSeconds, detail, { ...cinematicsFromProfile(profile), }) : null; if (!storyboard) { issues.push({ code: 'storyboard-missing', severity: 'warning', message: 'No storyboard artifact exists; Seedance packets fall back to text-driven prompts.', path: 'artifacts/storyboard.json', }); } if (storyboardGridPrompt) { const storyboardGridPath = options.storyboardGridPath?.trim(); referenceMap.push({ slot: nextSlot(referenceMap.length), role: 'storyboard-grid', label: '9-panel storyboard grid', ...(storyboardGridPath ? { path: storyboardGridPath } : {}), status: storyboardGridPath ? 'ready' : 'pending', }); if (!storyboardGridPath) { issues.push({ code: 'storyboard-grid-pending', severity: 'warning', message: 'Storyboard grid prompt was generated, but no rendered grid image is attached yet.', }); } } // Two-phase gate (E5): in storyboard phase, omit the heavy video-generation // packets (and their packet-only issues) — return the storyboard/camera- // language portion only. Default/video phase builds the full packets. const seedancePackets = options.phase === 'storyboard' ? [] : buildSeedancePackets({ storyboard, brief, referenceMap, durationSeconds, noFaces, genreStyle, aspectRatio, characterContext, detail, profile, issues, textDiscipline: options.textDiscipline ?? (options.referenceProfile === 'cinematic-face-first'), generateAudio: options.generateAudio ?? false, ...(options.resolution !== undefined ? { resolution: options.resolution } : {}), // A single kinetic shot carries no timeline; otherwise honour the opt-in. emitTimeline: !(options.singleShot ?? false) && (options.timeline ?? false), ...(blueprint ? { blueprint } : {}), ...(brandDefinition ? { brandDefinition } : {}), ...(options.dialogue ? { dialogue: options.dialogue } : {}), ...(options.dialogueByScene && options.dialogueByScene.size > 0 ? { dialogueByScene: options.dialogueByScene } : {}), ...(options.dialogue || (options.dialogueByScene && options.dialogueByScene.size > 0) ? { emotionCues: options.emotionCues ?? false } : {}), }); const artifact: FilmmakingPromptsArtifact = { schemaVersion: 1, projectSlug: options.projectSlug, generatedAt, sourceSkill: 'ai-filmmaking', ...(storyboard?.filmPlan ? { filmPlanFingerprint: filmPlanFingerprint(storyboard), referenceEvidence: await captureFilmReferenceEvidence(workspace.projectDir, seedancePackets) } : {}), ...(options.referenceProfile === 'cinematic-face-first' ? { validationProfile: 'cinematic-v1' as const, promptRegister: profile.register } : {}), durationDefaultSeconds: durationSeconds, referenceMap, characterSheetPrompts, storyboardGridPrompt, seedancePackets, issues, }; if (!options.write) return { artifact }; return { artifact, artifactPath: await writeArtifact(workspace, 'filmmaking-prompts', artifact), }; } /** * Map a reference slot's role onto its canonical build step (the banana-pro- * director discipline). `background-plate` is the scene-context-free `base-ref`; * the character sheet is the `sheet`; every in-context frame is a `scene-plate`. */ function buildStepForRole(role: FilmmakingReferenceSlot['role']): ReferenceBuildStep { switch (role) { case 'background-plate': return 'base-ref'; case 'character-sheet': return 'sheet'; case 'storyboard-grid': case 'start-frame': case 'end-frame': case 'reference-image': return 'scene-plate'; } } function buildReferenceMap( referenceSheets: ReferenceSheetsArtifact, characters: CharacterProfile[], assetManifest: AssetManifestArtifact | undefined, projectDir: string, ): FilmmakingReferenceSlot[] { // Collect every slot first (the `slot` field is assigned last, once the final // build-order is known, so `@imageN == array order` per WS0). const collected: Omit[] = []; for (const character of characters) { const identitySheet = findIdentitySheet(referenceSheets, character.name); const path = firstCharacterReference(character, identitySheet); collected.push({ role: 'character-sheet', label: `${character.name} character sheet`, characterName: character.name, ...(path ? { path } : {}), status: path && identitySheet?.reviewStatus !== 'pending' ? 'ready' : 'pending', }); } for (const entry of assetManifest?.assets ?? []) { if (entry.kind !== 'image') continue; // A project-relative manifest path (a stock import) is written into the // packet as the project file, so the packet names one file to every reader. const asset = { ...entry, path: anchorManifestAssetPath(projectDir, entry.path) }; if (asset.role === 'background-plate') { // A scene-context-free background/base plate (no sceneIndex required). if (collected.some((slot) => slot.role === 'background-plate' && slot.path === asset.path)) continue; collected.push({ role: 'background-plate', label: 'Background plate', path: asset.path, status: 'ready', }); continue; } if (!Number.isInteger(asset.sceneIndex)) continue; if (collected.some((slot) => slot.path === asset.path && slot.sceneIndex === asset.sceneIndex)) continue; collected.push({ role: 'start-frame', label: `Scene ${asset.sceneIndex} start frame`, path: asset.path, sceneIndex: asset.sceneIndex, status: 'ready', }); } // Consult the canonical reference build order (base-ref -> sheet -> scene-plate) // and group the collected slots by build step. The sort is STABLE, so within a // step the original collection order is preserved — and the canonical character // sheet (a `sheet` step) is never displaced/replaced by a scene plate. A default // single-character project (sheet only) keeps its original order unchanged. const order = referenceBuildOrder('character'); const rank = new Map(order.map((step, index) => [step, index] as const)); const ordered = collected .map((slot, index) => ({ slot, index })) .sort((left, right) => { const leftRank = rank.get(buildStepForRole(left.slot.role)) ?? order.length; const rightRank = rank.get(buildStepForRole(right.slot.role)) ?? order.length; if (leftRank !== rightRank) return leftRank - rightRank; return left.index - right.index; }) .map((entry) => entry.slot); return ordered.map((slot, index) => ({ slot: nextSlot(index), ...slot })); } function buildCharacterSheetPrompts( characters: CharacterProfile[], referenceMap: FilmmakingReferenceSlot[], issues: FilmmakingPromptIssue[], genreStyle: GenreStyle, aspectRatio: string, sheetLayout: '8-shot' | '6-panel' | '3-panel' = '8-shot', flatGrade = false, ): FilmmakingCharacterSheetPrompt[] { return characters.map((character) => { const slots = referenceMap.filter((slot) => slot.role === 'character-sheet' && slot.characterName === character.name); const readySlots = slots.filter((slot) => slot.status === 'ready').map((slot) => slot.slot); const mode = readySlots.length > 0 ? 'reference-image' : 'description-only'; const description = cleanSentence(character.description ?? `${character.name}, visually distinctive lead character`); const wordCount = description.split(/\s+/).filter(Boolean).length; if (!character.description) { issues.push({ code: 'character-description-missing', severity: 'warning', message: `${character.name} has no stored identity description; generated prompt uses a fallback.`, }); } else if (wordCount > 100) { // The skill calls >100 words an outright failure: bloat dilutes the // identity signal and bakes scene contamination into the reference. issues.push({ code: 'character-description-long', severity: 'error', message: `${character.name} identity description is ${wordCount} words; ai-filmmaking treats >100 as a failure (target 30-60). Trim scene effects/atmosphere down to identity-locking traits.`, }); } else if (wordCount > 60) { issues.push({ code: 'character-description-long', severity: 'warning', message: `${character.name} identity description is ${wordCount} words; ai-filmmaking target is 30-60 words.`, }); } return { characterName: character.name, identityDescription: description, mode, referenceSlots: readySlots, promptText: (sheetLayout === '6-panel' ? characterSheetSixPanelPrompt(description, genreStyle.charSheetStyle, aspectRatio) : sheetLayout === '3-panel' ? characterSheetThreePanelPrompt(description, genreStyle.charSheetStyle, aspectRatio) : mode === 'reference-image' ? characterSheetReferencePrompt(readySlots, genreStyle.charSheetStyle, aspectRatio) : characterSheetDescriptionPrompt(description, genreStyle.charSheetStyle, aspectRatio)) + (readySlots.length && sheetLayout !== '8-shot' ? ` Use ${readySlots.join(', ')} as the identity reference; preserve the exact face and proportions.` : '') + (flatGrade && sheetLayout !== '3-panel' ? ` Reference lighting: ${flatGradeClose('mid-gray', 'standard')}.` : ''), }; }); } function buildStoryboardGridPrompt( storyboard: StoryboardArtifact, brief: BriefArtifact | undefined, characterPrompts: FilmmakingCharacterSheetPrompt[], noFaces = false, genreStyle: GenreStyle = resolveGenreStyle(), aspectRatio = '16:9', panelCount = 15, durationSeconds = 15, detail: DetailLevel = 'standard', // Resolved cinema-profile overrides (default {} → byte-identical legacy output). cinematics: { realism?: CaptureRealismOpts | false; register?: 'prose' | 'numeric'; captureRegister?: 'cinema' | 'phone'; lightingId?: string; gradeId?: string; plateKind?: PlateKind; } = {}, ): FilmmakingStoryboardGridPrompt { const { rows, cols } = gridLayout(panelCount, aspectRatio); const panels = buildPanels(storyboard, panelCount, durationSeconds, cols); const characters = characterPrompts .map((prompt) => `${prompt.characterName.toUpperCase()}: ${characterLine(prompt)}`) .join('\n'); const sceneType = brief?.productionMode === 'director' ? 'cinematic director scene' : 'cinematic sequence'; const location = brief?.title ?? storyboard.projectSlug; const third = genreStyle.annotationThirdLine; // MOOD | VOICE | STYLE const noFaceClause = noFaces ? ' Render ALL figures as backlit silhouettes, shot from behind, or at distance — faces obscured, in shadow, or turned away, with NO clear frontal facial features (this keeps the sheet usable as a provider reference image despite real-person content filters).' : ''; // At `rich`, append a cinematography suffix to the Style line. `terse`/`standard` // keep the line byte-identical to today. When any cinema-profile override is set // (realism / register / lighting / grade), pass them through; otherwise the // no-arg call reproduces today's legacy suffix exactly. const hasCinematicsOverride = cinematics.realism !== undefined || cinematics.register !== undefined || cinematics.lightingId !== undefined || cinematics.gradeId !== undefined; const richStyleSuffix = detail === 'rich' ? ` ${hasCinematicsOverride ? richCinematographySuffix({ ...(cinematics.realism !== undefined ? { realism: cinematics.realism } : {}), ...(cinematics.register !== undefined ? { register: cinematics.register } : {}), ...(cinematics.lightingId !== undefined ? { lightingId: cinematics.lightingId } : {}), ...(cinematics.gradeId !== undefined ? { gradeId: cinematics.gradeId } : {}), }) : richCinematographySuffix()}` : ''; // Scene plates retain scene lighting; flat-grade applies only to reference sheets. const plateClause = cinematics.plateKind !== undefined ? ` Backdrop: ${backgroundPlate(cinematics.plateKind, detail)}.` : ''; const promptText = [ // A) Title & format header `Create a professional ${durationSeconds}-second ${genreStyle.formatTone} storyboard sheet for "${location}" — a complete production presentation page of ${panelCount} sequential cinematic panels arranged in a clean ${rows}×${cols} grid layout, depicting ONE CONTINUOUS ${sceneType}.`, '', // B) Style declaration `Style: Cinematic, production-grade, ${genreStyle.gridStyleDescriptors}.${noFaceClause} Aspect ratio = ${aspectRatio} page layout.${richStyleSuffix}${plateClause}`, '', // C) Character descriptions + lock 'CHARACTER LOCK - all recurring characters must appear IDENTICAL across every panel (same face, same build, same clothing, same props). Use the descriptions below as the source of truth. If reference images are attached, treat them as additional identity anchors and match them precisely.', characters || 'NO NAMED CHARACTER: preserve the same subject, setting, palette, and camera language across all panels.', '', // D) Visual tone `Visual tone: consistent colour grade, lighting logic, and lens language across all ${panelCount} panels — establish it once and hold it. This is one continuous moment in ${location}: same geography, same lighting, same wardrobe, same props.`, '', // E) Storyboard layout details `Storyboard sheet layout: a clean ${rows}×${cols} grid on a neutral production board, thin clean separators between panels, each panel numbered with its timecode label, a short shot description beneath. UNDER EACH panel a thin off-white annotation strip with three short lines in a clean, high-contrast sans-serif font legible at rendered size: CAM, MOVE, and ${third}. Notes must read as short uppercase slug lines, not sentences. No readable UI text, no logos unless already part of the brief. Camera moves naturally around the action as if shot in a single continuous take broken into ${panelCount} sequential beats.`, '', // F) Scene breakdown (read left-to-right, top-to-bottom) 'Narrative - read left-to-right, top-to-bottom:', // Aspect ratio is part of the prompt — state it on every shot, not just the // header (ai-filmmaking pitfall: "Forgetting aspect ratio on shots 2-9"). ...panels.map((panel) => ( `Panel ${panel.panel} ${panel.timecode} (${panel.position}): ${panel.beat}. CAM: ${panel.cam}. MOVE: ${panel.move}. ${third}: ${panel.mood}. ASPECT: ${aspectRatio}.` )), '', // G/H) Art-direction + rendering footer `Art direction: vary the framing every panel (wide -> medium -> close-up -> over-the-shoulder), build intensity through the middle, peak near the end, then resolve. Distribute character detail across panels — faces in close-ups, full wardrobe in wides. Render quality: masterpiece, production-ready, ${aspectRatio} professional storyboard sheet.`, ].join('\n'); return { variant: 'storyboard-grid', panelCount, rows, cols, promptText, panels, }; } function buildSeedancePackets(input: { storyboard: StoryboardArtifact | undefined; brief: BriefArtifact | undefined; referenceMap: FilmmakingReferenceSlot[]; durationSeconds: number; noFaces?: boolean; genreStyle: GenreStyle; aspectRatio: string; characterContext: Map; detail: DetailLevel; /** Resolved cinema profile driving register / realism / capture register. */ profile: ResolvedCinemaProfile; issues: FilmmakingPromptIssue[]; textDiscipline?: boolean; generateAudio?: boolean; /** OUTPUT-DEPENDENT render resolution; omitted → no `resolution` field. */ resolution?: string; /** * Emit the multi-beat scene timeline on each packet. Already gated by the * caller for the single-shot case, so here it is a plain populate flag. */ emitTimeline?: boolean; /** Director blueprint; when present each packet gains the prose addendum and * forbidden-movement validation. Absent → byte-identical legacy. */ blueprint?: ProjectBlueprintArtifact; /** Locked brand system; when present each packet gains the prose-only BRAND * addendum after any DIRECTOR line. Absent → byte-identical legacy. */ brandDefinition?: BrandDefinitionArtifact; /** Blanket dialogue for every scene lacking a `dialogueByScene` entry. */ dialogue?: DialogueLine; /** Per-scene dialogue keyed by sceneIndex; wins over `dialogue` for that scene. */ dialogueByScene?: Map; /** Rewrite named dialogue emotions as physical cues; with either dialogue source. */ emotionCues?: boolean; }): FilmmakingSeedancePacket[] { const scenes = input.storyboard?.scenes ?? []; const characterSlots = input.referenceMap.filter((slot) => slot.role === 'character-sheet'); const gridSlot = input.referenceMap.find((slot) => slot.role === 'storyboard-grid'); // Does the blueprint forbid handheld? Reuses the forbidden-move matcher so the // grid-reference packet can drop its handheld boilerplate (see seedancePromptText). const forbidHandheld = input.blueprint ? forbiddenMovementHits(input.blueprint, 'handheld').length > 0 : false; if (gridSlot?.status === 'pending') { input.issues.push({ code: 'reference-slot-pending', severity: 'warning', message: `${gridSlot.slot} is reserved for the storyboard grid, but the rendered grid image is not attached yet.`, }); } if (input.genreStyle?.genre !== 'music-video') { input.issues.push({ code: 'seedance-music-default', severity: 'warning', message: 'Seedance packets default to NO MUSIC unless a scene prompt explicitly asks for music.', }); } return scenes.map((scene) => { const startFrame = input.referenceMap.find((slot) => slot.role === 'start-frame' && slot.sceneIndex === scene.sceneIndex); const references = [ ...characterSlots.filter((slot) => input.storyboard?.filmPlan ? (scene.characters ?? []).includes(slot.characterName ?? '') : !scene.characters?.length || scene.characters.includes(slot.characterName ?? '')), ...(gridSlot ? [gridSlot] : []), ...(startFrame ? [startFrame] : []), ]; const variant = gridSlot && characterSlots.length > 0 ? 'character-sheets-plus-storyboard-grid' : gridSlot ? 'storyboard-grid-reference' : 'text-driven'; // Per-scene dialogue wins over the blanket dialogue; either is optional. const sceneDialogue = input.dialogueByScene?.get(scene.sceneIndex) ?? input.dialogue; const promptText = seedancePromptText({ scene, brief: input.brief, references, variant, durationSeconds: scene.durationSeconds ?? input.durationSeconds, noFaces: input.noFaces ?? false, genreStyle: input.genreStyle, aspectRatio: input.aspectRatio, characterContext: adaptPerformanceContext(input.characterContext, scene.direction?.performance), detail: input.detail, profile: input.profile, textDiscipline: input.textDiscipline ?? false, generateAudio: input.generateAudio ?? false, forbidHandheld, ...(sceneDialogue ? { dialogue: sceneDialogue, emotionCues: input.emotionCues ?? false } : {}), }); // QA gate (WS6): only the text-driven packet follows the 13-block contract; // grid variants intentionally use the grid-reference body shape. // Block-order QA runs on the canonical 13-block body (BEFORE the director // addendum, which appends after the last block and never reorders). if (variant === 'text-driven') { const blockIssue = checkSeedanceBlockOrder(promptText); if (blockIssue) input.issues.push(blockIssue); } // Director layer: append the prose-only DIRECTOR addendum and validate the // scene's camera direction against the camera bible's forbidden movements. let finalPromptText = promptText; if (input.blueprint) { const directorLine = renderDirectorLine(input.blueprint, { characterNames: scene.characters ?? [], sceneText: `${scene.description ?? ''} ${scene.scenePrompt?.animationPrompt ?? ''}`, }); if (directorLine) finalPromptText = `${promptText}\n\n${directorLine}`; for (const hit of forbiddenMovementHits(input.blueprint, promptText)) { input.issues.push({ code: 'forbidden-camera-movement', severity: 'warning', message: `scene ${scene.sceneIndex}: camera movement "${hit}" is forbidden by the project blueprint camera bible.`, }); } } // Brand layer: append the prose-only BRAND addendum after any DIRECTOR line. if (input.brandDefinition) { const brandLine = renderBrandLine(input.brandDefinition); if (brandLine) finalPromptText = `${finalPromptText}\n\n${brandLine}`; } const directionText = shotDirectionPrompt(input.storyboard?.filmPlan, scene.direction); if (directionText) finalPromptText += `\n\n${directionText}`; const packetDurationSeconds = scene.durationSeconds ?? input.durationSeconds; const action = cleanSentence(scene.scenePrompt?.animationPrompt ?? scene.description); return { sceneIndex: scene.sceneIndex, variant, durationSeconds: packetDurationSeconds, // OUTPUT-DEPENDENT params (omitted unless requested → default byte-stable). ...(input.resolution ? { resolution: input.resolution } : {}), ...(input.emitTimeline ? { timeline: weaveDialogueIntoFrameMap( threeBeatFrameMap(packetDurationSeconds, action, input.aspectRatio), sceneDialogue, input.characterContext, input.emotionCues ?? false, ) } : {}), references, promptText: finalPromptText, warnings: references.filter((reference) => reference.status === 'pending') .map((reference) => `${reference.slot} ${reference.label} is pending.`), }; }); }