import { CINEMA_CONTINUITY_DIMENSIONS, createCinemaContinuityLedger, createCinemaTimedAudioContract, validateCinemaContinuity, validateCinemaTimedAudio, } from './cinema-continuity.js'; import type { CinemaContinuityChange, CinemaContinuityStateValues, CinemaContinuityLedgerArtifact, CinemaTimedAudioContractArtifact, } from './cinema-continuity-types.js'; import type { CinemaPlanningPackage } from './cinema-project-planner.js'; import type { CinemaShotManifestArtifact } from './cinema-types.js'; function assertManifestSet(planning: CinemaPlanningPackage, manifests: CinemaShotManifestArtifact[]): void { const expected = planning.coverage.shotAssignments.map((assignment) => assignment.shotId); const actual = [...manifests].sort((left, right) => left.ordinal - right.ordinal).map((manifest) => manifest.shotId); if (JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error(`Cinema production contracts require the planned shot order ${expected.join(', ')}, got ${actual.join(', ')}`); } } function landmarkForBeat(planning: CinemaPlanningPackage, beatId: string): string { const map = planning.geographyMap; if (beatId === 'beat-setup') return map.axis.fromLandmarkId; if (beatId === 'beat-choice') return map.movementPaths[0]?.toLandmarkId ?? map.axis.toLandmarkId; return map.axis.toLandmarkId; } function stateForShot( planning: CinemaPlanningPackage, manifest: CinemaShotManifestArtifact, ): CinemaContinuityStateValues { const hero = planning.story.storyBible.characters[0]; const version = planning.assetEvidence.assets[0].versionId; const setupDetail = manifest.shotId === 'shot-002'; const reaction = manifest.shotId === 'shot-004'; const consequence = manifest.story.beatId === 'beat-consequence'; return { wardrobe: `${version}:canonical-look`, hair: `${version}:canonical-hair`, makeup: `${version}:canonical-makeup`, damage: 'clean', injury: 'none', propHand: 'none-declared', mechanism: consequence ? 'consequence-visible' : manifest.story.beatId === 'beat-choice' ? 'choice-committed' : 'opportunity-closing', geography: landmarkForBeat(planning, manifest.story.beatId), gaze: consequence ? 'toward-new-trajectory' : reaction ? 'toward-cost' : setupDetail ? 'toward-closing-opportunity' : manifest.story.beatId === 'beat-choice' ? 'toward-decision' : 'toward-objective', time: 'continuous-proof-scene-time', light: planning.geographyMap.cameraSides[0].lightDirection, emotion: consequence ? 'changed-resolve' : reaction ? 'cost-registering' : manifest.story.beatId === 'beat-choice' ? 'committed-action' : 'guarded-focus', }; } function describeChange(change: Omit): string { const descriptions: Partial> = { geography: 'The beat moves the subject along the declared scene-map path.', gaze: 'The subject redirects attention to the story obligation of this beat.', mechanism: 'The irreversible action advances the story mechanism.', emotion: 'Performance state changes in response to the beat outcome.', }; return descriptions[change.dimension] ?? `The ${change.dimension} change is explicitly motivated by the current story beat.`; } export function compileCinemaContinuityLedger( planning: CinemaPlanningPackage, manifests: CinemaShotManifestArtifact[], generatedAt: string, ): CinemaContinuityLedgerArtifact { assertManifestSet(planning, manifests); const ordered = [...manifests].sort((left, right) => left.ordinal - right.ordinal); const hero = planning.story.storyBible.characters[0]; const records = ordered.map((manifest, index) => { const values = stateForShot(planning, manifest); const prior = index === 0 ? null : stateForShot(planning, ordered[index - 1]); const intentionalChanges: CinemaContinuityChange[] = prior ? CINEMA_CONTINUITY_DIMENSIONS.flatMap((dimension) => { if (prior[dimension] === values[dimension]) return []; const change = { subjectId: hero.characterId, dimension, from: prior[dimension], to: values[dimension], beatId: manifest.story.beatId, }; return [{ ...change, reason: describeChange(change) }]; }) : []; return { shotId: manifest.shotId, sceneMapId: planning.geographyMap.sceneMapId, cameraSideId: planning.geographyMap.cameraSides[0].sideId, carriesFromShotId: index === 0 ? null : ordered[index - 1].shotId, subjects: [{ subjectId: hero.characterId, values }], intentionalChanges, }; }); const ledger = createCinemaContinuityLedger({ projectSlug: planning.receipt.projectSlug, ledgerId: `${planning.receipt.projectSlug}-continuity-v1`, generatedAt, records, }); const validation = validateCinemaContinuity(ledger, planning.geographyMap, ordered); if (!validation.ok) throw new Error(`Compiled Cinema continuity is invalid: ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`); return ledger; } export function compileCinemaTimedAudioContract( planning: CinemaPlanningPackage, manifests: CinemaShotManifestArtifact[], generatedAt: string, ): CinemaTimedAudioContractArtifact { assertManifestSet(planning, manifests); const ordered = [...manifests].sort((left, right) => left.ordinal - right.ordinal); const requiredDialogue = ordered.filter((manifest) => manifest.audio.dialogue === 'required'); if (requiredDialogue.length > 0) { throw new Error(`Cinema dialogue-led compilation requires authored dialogue text before timing shots: ${requiredDialogue.map((manifest) => manifest.shotId).join(', ')}`); } const audio = createCinemaTimedAudioContract({ projectSlug: planning.receipt.projectSlug, audioContractId: `${planning.receipt.projectSlug}-timed-audio-v1`, generatedAt, shots: ordered.map((manifest) => ({ shotId: manifest.shotId, durationSeconds: manifest.durationSeconds, dialogue: [], music: [], sfx: [{ cueId: `${manifest.shotId}-story-action`, startSeconds: 0, endSeconds: manifest.durationSeconds, description: manifest.actionBeats.map((beat) => beat.description).join(' '), }], ambience: [{ cueId: `${manifest.shotId}-location-tone`, startSeconds: 0, endSeconds: manifest.durationSeconds, description: planning.visualSoundBible.sound.ambience, }], intentionalSilence: manifest.audio.intentionalSilence, })), }); const validation = validateCinemaTimedAudio(audio, ordered); if (!validation.ok) throw new Error(`Compiled Cinema timed audio is invalid: ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`); return audio; }