import { createHash } from 'node:crypto'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import type { CinemaShotManifestArtifact, CinemaSha256 } from './cinema-types.js'; import type { CinemaContinuityDimension, CinemaContinuityLedgerArtifact, CinemaCoverageArtifact, CinemaGeographyMapArtifact, CinemaProductionContractIssue, CinemaProductionContractValidation, CinemaTimedAudioContractArtifact, CinemaTimedCue, } from './cinema-continuity-types.js'; export const CINEMA_CONTINUITY_DIMENSIONS: CinemaContinuityDimension[] = ['wardrobe', 'hair', 'makeup', 'damage', 'injury', 'propHand', 'mechanism', 'geography', 'gaze', 'time', 'light', 'emotion']; function hash(value: unknown): CinemaSha256 { return `sha256:${createHash('sha256').update(stableCinemaJson(value)).digest('hex')}`; } function result(issues: CinemaProductionContractIssue[]): CinemaProductionContractValidation { return { ok: issues.length === 0, issues }; } export function createCinemaContinuityLedger(input: Omit): CinemaContinuityLedgerArtifact { const canonical = { schemaVersion: 1 as const, ...structuredClone(input) }; return { ...canonical, contentHash: hash(canonical) }; } export function createCinemaGeographyMap(input: Omit): CinemaGeographyMapArtifact { const canonical = { schemaVersion: 1 as const, ...structuredClone(input) }; return { ...canonical, contentHash: hash(canonical) }; } export function createCinemaCoverage(input: Omit): CinemaCoverageArtifact { const canonical = { schemaVersion: 1 as const, ...structuredClone(input) }; return { ...canonical, contentHash: hash(canonical) }; } export function createCinemaTimedAudioContract(input: Omit): CinemaTimedAudioContractArtifact { const canonical = { schemaVersion: 1 as const, ...structuredClone(input) }; return { ...canonical, contentHash: hash(canonical) }; } function checkHash(issues: CinemaProductionContractIssue[], artifact: { contentHash: CinemaSha256 }, path: string): void { const { contentHash: _contentHash, ...canonical } = artifact; if (artifact.contentHash !== hash(canonical)) issues.push({ code: 'content-hash', path, message: 'does not match canonical bytes' }); } export function validateCinemaContinuity( ledger: CinemaContinuityLedgerArtifact, geography: CinemaGeographyMapArtifact, manifests: CinemaShotManifestArtifact[] = [], ): CinemaProductionContractValidation { const issues: CinemaProductionContractIssue[] = []; const add = (code: string, path: string, message: string): void => { issues.push({ code, path, message }); }; if (ledger.projectSlug !== geography.projectSlug) add('project-mismatch', 'projectSlug', 'continuity and geography projects differ'); const landmarks = new Set(geography.landmarks.map((landmark) => landmark.landmarkId)); if (!landmarks.has(geography.axis.fromLandmarkId) || !landmarks.has(geography.axis.toLandmarkId) || geography.axis.fromLandmarkId === geography.axis.toLandmarkId) add('scene-axis', 'geography.axis', 'must bind two distinct known landmarks'); const sides = new Map(geography.cameraSides.map((side) => [side.sideId, side])); geography.cameraSides.forEach((side, index) => { if (!side.lightDirection.trim()) add('light-direction', `geography.cameraSides[${index}].lightDirection`, 'is required'); for (const id of side.allowedLandmarkIds) if (!landmarks.has(id)) add('unknown-landmark', `geography.cameraSides[${index}].allowedLandmarkIds`, `unknown ${id}`); }); geography.movementPaths.forEach((path, index) => { if (!landmarks.has(path.fromLandmarkId) || !landmarks.has(path.toLandmarkId) || !path.direction.trim()) add('movement-path', `geography.movementPaths[${index}]`, 'must bind known landmarks and direction'); }); const records = new Map(); ledger.records.forEach((record, index) => { if (records.has(record.shotId)) add('duplicate-shot', `records[${index}].shotId`, 'must be unique in the continuity ledger'); records.set(record.shotId, record); }); if (manifests.length > 0) { const ordered = [...manifests].sort((left, right) => left.ordinal - right.ordinal); if (ordered.some((manifest) => manifest.projectSlug !== ledger.projectSlug)) add('project-mismatch', 'records', 'continuity and shot-manifest projects differ'); const manifestIds = new Set(ordered.map((manifest) => manifest.shotId)); for (const manifest of ordered) if (!records.has(manifest.shotId)) add('missing-continuity-record', manifest.shotId, 'every shot manifest needs a continuity record'); for (const record of ledger.records) if (!manifestIds.has(record.shotId)) add('extra-continuity-record', record.shotId, 'continuity cannot name an uncompiled shot'); ordered.forEach((manifest, index) => { const record = records.get(manifest.shotId); const expectedCarry = index === 0 ? null : ordered[index - 1].shotId; if (record && record.carriesFromShotId !== expectedCarry) add('carry-order', `${manifest.shotId}.carriesFromShotId`, `must carry from ${expectedCarry ?? 'null'} in manifest order`); if (record) { const continuitySubjects = new Set(record.subjects.map((subject) => subject.subjectId)); for (const characterId of manifest.occupancy.characterIds) if (!continuitySubjects.has(characterId)) add('missing-continuity-subject', `${manifest.shotId}.subjects`, `missing occupied character ${characterId}`); for (const subjectId of continuitySubjects) if (!manifest.occupancy.characterIds.includes(subjectId)) add('extra-continuity-subject', `${manifest.shotId}.subjects`, `subject ${subjectId} is not occupied in the shot`); } }); } ledger.records.forEach((record, recordIndex) => { const base = `records[${recordIndex}]`; if (record.sceneMapId !== geography.sceneMapId) add('scene-map', `${base}.sceneMapId`, 'must resolve to the supplied map'); const side = sides.get(record.cameraSideId); if (!side) add('camera-side', `${base}.cameraSideId`, 'is not defined by the scene map'); const subjectIds = new Set(); record.subjects.forEach((subject, subjectIndex) => { const path = `${base}.subjects[${subjectIndex}]`; if (subjectIds.has(subject.subjectId)) add('duplicate-subject', `${path}.subjectId`, 'must be unique per shot'); subjectIds.add(subject.subjectId); for (const dimension of CINEMA_CONTINUITY_DIMENSIONS) if (!subject.values[dimension]?.trim()) add('missing-state', `${path}.values.${dimension}`, 'is required'); if (!landmarks.has(subject.values.geography)) add('state-landmark', `${path}.values.geography`, 'must name a scene-map landmark'); if (side && !side.allowedLandmarkIds.includes(subject.values.geography)) add('side-landmark', `${path}.values.geography`, 'is not visible/compatible from this camera side'); if (side && subject.values.light !== side.lightDirection) add('state-light', `${path}.values.light`, `must equal map light direction ${side.lightDirection}`); }); const declared = new Set(); record.intentionalChanges.forEach((change, changeIndex) => { const key = `${change.subjectId}:${change.dimension}`; if (declared.has(key)) add('duplicate-state-change', `${base}.intentionalChanges[${changeIndex}]`, 'only one change per subject and dimension is allowed in a shot'); declared.add(key); const subject = record.subjects.find((item) => item.subjectId === change.subjectId); if (!subject) add('unknown-change-subject', `${base}.intentionalChanges[${changeIndex}].subjectId`, 'must name a subject in this shot'); if (!change.beatId.trim() || !change.reason.trim() || change.from === change.to) add('invalid-state-change', `${base}.intentionalChanges[${changeIndex}]`, 'must declare distinct states, a beat and a reason'); }); if (!record.carriesFromShotId) { if (record.intentionalChanges.length > 0) add('change-without-carry', `${base}.intentionalChanges`, 'the first state cannot declare changes without a carry source'); return; } const prior = records.get(record.carriesFromShotId); if (!prior) { add('missing-carry-source', `${base}.carriesFromShotId`, 'must reference an existing shot'); return; } for (const subject of record.subjects) { const previous = prior.subjects.find((item) => item.subjectId === subject.subjectId); if (!previous) continue; for (const dimension of CINEMA_CONTINUITY_DIMENSIONS) { if (previous.values[dimension] === subject.values[dimension]) continue; const changes = record.intentionalChanges.filter((change) => change.subjectId === subject.subjectId && change.dimension === dimension && change.from === previous.values[dimension] && change.to === subject.values[dimension]); if (changes.length !== 1 || !changes[0].beatId.trim() || !changes[0].reason.trim()) add('undeclared-state-change', `${base}.${subject.subjectId}.${dimension}`, 'requires exactly one beat-linked intentional change'); } } for (const change of record.intentionalChanges) { const previous = prior.subjects.find((item) => item.subjectId === change.subjectId); const current = record.subjects.find((item) => item.subjectId === change.subjectId); if (!previous || !current || previous.values[change.dimension] !== change.from || current.values[change.dimension] !== change.to) { add('false-state-change', `${base}.${change.subjectId}.${change.dimension}`, 'declared change must exactly match carried and current state'); } } }); checkHash(issues, geography, 'geography.contentHash'); checkHash(issues, ledger, 'continuity.contentHash'); return result(issues); } export function validateCinemaCoverage(coverage: CinemaCoverageArtifact): CinemaProductionContractValidation { const issues: CinemaProductionContractIssue[] = []; const add = (code: string, path: string, message: string): void => { issues.push({ code, path, message }); }; const requirements = new Set(coverage.requirements.map((item) => item.requirementId)); const shots = new Set(coverage.shotAssignments.map((item) => item.shotId)); const assigned = new Set(); coverage.requirements.forEach((item, index) => { if (!item.beatId.trim() || !item.purpose.trim()) add('coverage-requirement', `requirements[${index}]`, 'requires beat and purpose'); }); coverage.shotAssignments.forEach((shot, index) => { if (!shot.editorialPurpose.trim() || shot.requirementIds.length === 0) add('orphan-shot', `shotAssignments[${index}]`, 'every shot needs editorial purpose and coverage'); for (const id of shot.requirementIds) { if (!requirements.has(id)) add('unknown-coverage', `shotAssignments[${index}].requirementIds`, `unknown ${id}`); assigned.add(id); } }); for (const id of requirements) if (!assigned.has(id)) add('missing-coverage', 'requirements', `${id} is not assigned to a shot`); coverage.reversePairs.forEach((pair, index) => { if (!shots.has(pair.firstShotId) || !shots.has(pair.reverseShotId) || pair.firstShotId === pair.reverseShotId) add('reverse-pair', `reversePairs[${index}]`, 'must bind two distinct assigned shots'); if (!pair.axisCompliant) add('reverse-axis', `reversePairs[${index}].axisCompliant`, 'reverse coverage must preserve the declared axis'); }); checkHash(issues, coverage, 'coverage.contentHash'); return result(issues); } function validateCues(issues: CinemaProductionContractIssue[], cues: CinemaTimedCue[], duration: number, path: string): void { const ids = new Set(); cues.forEach((cue, index) => { if (!cue.cueId.trim() || ids.has(cue.cueId)) issues.push({ code: 'audio-cue-id', path: `${path}[${index}].cueId`, message: 'must be present and unique in its lane' }); ids.add(cue.cueId); if (cue.startSeconds < 0 || cue.endSeconds <= cue.startSeconds || cue.endSeconds > duration || !cue.description.trim()) issues.push({ code: 'audio-timing', path: `${path}[${index}]`, message: 'must be described and fit inside shot duration' }); }); } export function validateCinemaTimedAudio( audio: CinemaTimedAudioContractArtifact, manifests: CinemaShotManifestArtifact[], ): CinemaProductionContractValidation { const issues: CinemaProductionContractIssue[] = []; const add = (code: string, path: string, message: string): void => { issues.push({ code, path, message }); }; const audioByShot = new Map(); audio.shots.forEach((shot, index) => { if (audioByShot.has(shot.shotId)) add('duplicate-audio-shot', `shots[${index}].shotId`, 'must be unique'); audioByShot.set(shot.shotId, shot); if (shot.durationSeconds <= 0) add('audio-duration', `shots[${index}].durationSeconds`, 'must be positive'); shot.dialogue.forEach((cue, cueIndex) => { if (!cue.speakerId.trim() || !cue.text.trim()) add('dialogue-authorship', `shots[${index}].dialogue[${cueIndex}]`, 'requires an authored speaker and line'); }); if (shot.intentionalSilence && [...shot.dialogue, ...shot.music, ...shot.sfx, ...shot.ambience].length > 0) add('silence-cues', `shots[${index}]`, 'an intentionally silent shot cannot contain audio cues'); }); const manifestIds = new Set(manifests.map((manifest) => manifest.shotId)); if (manifests.some((manifest) => manifest.projectSlug !== audio.projectSlug)) add('project-mismatch', 'projectSlug', 'audio and shot-manifest projects differ'); for (const shot of audio.shots) if (!manifestIds.has(shot.shotId)) add('extra-audio-contract', shot.shotId, 'audio cannot name an uncompiled shot'); for (const manifest of manifests) { const shot = audioByShot.get(manifest.shotId); if (!shot) { add('missing-audio-contract', manifest.shotId, 'audio timing must exist before shot execution'); continue; } if (Math.abs(shot.durationSeconds - manifest.durationSeconds) > 0.001) issues.push({ code: 'audio-duration', path: `${manifest.shotId}.durationSeconds`, message: 'must match shot duration' }); validateCues(issues, shot.dialogue, shot.durationSeconds, `${manifest.shotId}.dialogue`); validateCues(issues, shot.music, shot.durationSeconds, `${manifest.shotId}.music`); validateCues(issues, shot.sfx, shot.durationSeconds, `${manifest.shotId}.sfx`); validateCues(issues, shot.ambience, shot.durationSeconds, `${manifest.shotId}.ambience`); const lanes = { dialogue: shot.dialogue, music: shot.music, sfx: shot.sfx, ambience: shot.ambience }; for (const lane of Object.keys(lanes) as Array) { if (manifest.audio[lane] === 'required' && lanes[lane].length === 0) issues.push({ code: 'required-audio-missing', path: `${manifest.shotId}.${lane}`, message: 'required lane needs a timed cue before video generation' }); if (manifest.audio[lane] === 'forbidden' && lanes[lane].length > 0) issues.push({ code: 'forbidden-audio-present', path: `${manifest.shotId}.${lane}`, message: 'forbidden lane cannot contain cues' }); } if (shot.intentionalSilence !== manifest.audio.intentionalSilence) issues.push({ code: 'silence-mismatch', path: `${manifest.shotId}.intentionalSilence`, message: 'must match shot manifest' }); } checkHash(issues, audio, 'audio.contentHash'); return result(issues); }