import { createHash } from 'node:crypto'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import type { CinemaSha256 } from './cinema-types.js'; import type { CinemaCompactIntake, CinemaCreativeAssumption, CinemaCreativeContractArtifact, CinemaStoryBibleArtifact, CinemaStoryBlueprint, CinemaStorySeed, } from './cinema-story-types.js'; function hash(value: unknown): CinemaSha256 { return `sha256:${createHash('sha256').update(stableCinemaJson(value)).digest('hex')}`; } function safeId(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'protagonist'; } function assumption(assumptions: CinemaCreativeAssumption[], field: string, value: string | number, reason: string): void { assumptions.push({ field, value, reason }); } function valueOrAssumption( value: T | undefined, fallback: T, assumptions: CinemaCreativeAssumption[], field: string, reason: string, ): T { if (value !== undefined) return value; assumption(assumptions, field, fallback, reason); return fallback; } export function compileCinemaStoryBlueprint( intake: CinemaCompactIntake, seed: CinemaStorySeed = {}, generatedAt = new Date().toISOString(), ): CinemaStoryBlueprint { if (!intake.projectSlug.trim()) throw new Error('Cinema compact intake requires projectSlug'); if (!intake.logline.trim()) throw new Error('Cinema compact intake requires premise/logline'); const assumptions: CinemaCreativeAssumption[] = []; const runtimeSeconds = valueOrAssumption(intake.runtimeSeconds, 45, assumptions, 'runtimeSeconds', 'Cinema proof-scene default'); const genre = valueOrAssumption(intake.genre, 'speculative drama', assumptions, 'genre', 'Neutral narrative default'); const tone = valueOrAssumption(intake.tone, 'grounded tension', assumptions, 'tone', 'Supports a legible proof scene'); const audience = valueOrAssumption(intake.audience, 'adult general audience', assumptions, 'audience', 'No audience supplied'); const format = valueOrAssumption(intake.format, '16:9 narrative short', assumptions, 'format', 'Cinema proof delivery default'); const dialoguePosture = valueOrAssumption(intake.dialoguePosture, 'sparse' as const, assumptions, 'dialoguePosture', 'Prioritizes visual storytelling'); const budgetPosture = valueOrAssumption(intake.budgetPosture, 'controlled' as const, assumptions, 'budgetPosture', 'Requires explicit spend discipline'); const boundaries = intake.boundaries?.length ? [...intake.boundaries] : ['No fabricated rights, releases, approvals, or provider capabilities']; if (!intake.boundaries?.length) assumption(assumptions, 'boundaries', boundaries[0], 'Safety and truthfulness default'); const creativeInput = { schemaVersion: 1 as const, projectSlug: intake.projectSlug, contractId: `${intake.projectSlug}-creative-v1`, generatedAt, premise: intake.logline.trim(), runtimeSeconds, genre, tone, audience, format, dialoguePosture, boundaries, budgetPosture, assumptions, }; const creativeContract: CinemaCreativeContractArtifact = { ...creativeInput, contentHash: hash(creativeInput) }; const heroName = valueOrAssumption(intake.heroName, 'The protagonist', assumptions, 'heroName', 'No named hero supplied'); const heroId = safeId(heroName); const theme = seed.theme ?? 'Identity is proven through a difficult choice.'; if (!seed.theme) assumption(assumptions, 'story.theme', theme, 'Derived thematic default'); const dramaticQuestion = seed.dramaticQuestion ?? `Can ${heroName} act before the opportunity in the premise is lost?`; if (!seed.dramaticQuestion) assumption(assumptions, 'story.dramaticQuestion', dramaticQuestion, 'Derived from the premise and hero'); const opposition = seed.opposition ?? 'Time pressure and a force that makes the necessary choice costly.'; if (!seed.opposition) assumption(assumptions, 'story.opposition', opposition, 'Producible narrative opposition default'); const stakes = seed.stakes ?? 'Failure closes the immediate opportunity and confirms the hero’s limiting belief.'; if (!seed.stakes) assumption(assumptions, 'story.stakes', stakes, 'Explicit consequence required'); const beatIds = ['beat-setup', 'beat-choice', 'beat-consequence']; const setupDuration = Math.floor(runtimeSeconds * 0.3 * 1000) / 1000; const confrontationDuration = Math.floor(runtimeSeconds * 0.45 * 1000) / 1000; const resolutionDuration = Math.round((runtimeSeconds - setupDuration - confrontationDuration) * 1000) / 1000; const look = seed.majorLookChange; const storyInput: Omit = { schemaVersion: 1, projectSlug: intake.projectSlug, storyId: `${intake.projectSlug}-story-v1`, generatedAt, creativeContractHash: creativeContract.contentHash, theme, dramaticQuestion, opposition, stakes, characters: [{ characterId: heroId, name: heroName, objective: seed.hero?.objective ?? 'Complete the concrete action implied by the premise.', want: seed.hero?.want ?? 'Control the outcome without exposing vulnerability.', need: seed.hero?.need ?? 'Accept the cost of decisive action.', contradiction: seed.hero?.contradiction ?? 'Appears controlled while urgency leaks through physical behavior.', movement: seed.hero?.movement ?? 'Economical movement that accelerates only at the choice point.', stillness: seed.hero?.stillness ?? 'Holds still while assessing risk; the eyes remain active.', stressBehavior: seed.hero?.stressBehavior ?? 'Shortens breath, fixes gaze, and over-controls the hands.', currentArcState: seed.hero?.currentArcState ?? 'Avoiding the choice that would reveal true priorities.', }], beats: [ { beatId: beatIds[0], act: 'setup', purpose: 'Establish the objective, opposition and a visible setup.', change: 'The hero recognizes the opportunity is closing.', characterIds: [heroId], setupIds: ['setup-core-choice'], payoffIds: [] }, { beatId: beatIds[1], act: 'confrontation', purpose: 'Force the costly choice through physical action and reaction.', change: 'The hero commits and cannot return to the prior state.', characterIds: [heroId], setupIds: [], payoffIds: [] }, { beatId: beatIds[2], act: 'resolution', purpose: 'Pay off the setup and make the consequence legible.', change: 'The result changes the hero’s immediate trajectory.', characterIds: [heroId], setupIds: [], payoffIds: ['setup-core-choice'] }, ], scenes: [ { sceneId: 'scene-setup', act: 'setup', beatIds: [beatIds[0]], purpose: 'Orient the audience and establish the dramatic problem.', expectedChange: 'Objective and opposition become visible.', durationSeconds: setupDuration }, { sceneId: 'scene-choice', act: 'confrontation', beatIds: [beatIds[1]], purpose: 'Stage the irreversible choice.', expectedChange: 'The hero crosses the decision threshold.', durationSeconds: confrontationDuration }, { sceneId: 'scene-consequence', act: 'resolution', beatIds: [beatIds[2]], purpose: 'Deliver reaction, payoff and consequence.', expectedChange: 'The audience understands what the choice cost.', durationSeconds: resolutionDuration }, ], recognitionMilestones: look ? [{ milestoneId: 'recognition-look-change-001', characterId: heroId, fromLookId: look.fromLookId, toLookId: look.toLookId, earnedByBeatId: beatIds[1], rationale: look.rationale }] : [], ending: seed.ending ?? 'The consequence answers the dramatic question while leaving the larger life problem open.', }; const storyBible: CinemaStoryBibleArtifact = { ...storyInput, contentHash: hash(storyInput) }; creativeContract.assumptions = assumptions; const { contentHash: _oldHash, ...updatedCreativeInput } = creativeContract; creativeContract.contentHash = hash(updatedCreativeInput); storyBible.creativeContractHash = creativeContract.contentHash; const { contentHash: _storyHash, ...updatedStoryInput } = storyBible; storyBible.contentHash = hash(updatedStoryInput); return { creativeContract, storyBible }; }