import { createHash } from 'node:crypto'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import type { CinemaSha256 } from './cinema-types.js'; export interface CinemaVisualSoundBibleArtifact { schemaVersion: 1; projectSlug: string; bibleId: string; generatedAt: string; visualLawVersion: string; visual: { palette: string[]; contrast: string; lighting: { sources: string[]; direction: string; quality: string }; opticsFamilies: string[]; cameraGrammar: string; motionCharacter: string; texture: string; aspectRatio: string; cadenceFps: number; }; sound: { ambience: string; dialoguePerspective: string; musicStrategy: string; forbiddenDrift: string[]; }; contentHash: CinemaSha256; } export type CinemaRecognitionCategory = | 'canonical' | 'profile' | 'lighting' | 'scale' | 'action' | 'ensemble' | 'material' | 'interaction'; export interface CinemaRecognitionCondition { conditionId: string; category: CinemaRecognitionCategory; description: string; status: 'pending' | 'pass' | 'fail'; reviewerId: string | null; reviewedAt: string | null; evidenceIds: string[]; } export interface CinemaAssetEvidenceArtifact { schemaVersion: 1; projectSlug: string; evidencePlanId: string; generatedAt: string; assets: Array<{ assetId: string; versionId: string; kind: 'hero' | 'supporting' | 'location' | 'prop' | 'vehicle'; requiredConditionCount: number; lockStatus: 'pending' | 'locked' | 'rejected'; conditions: CinemaRecognitionCondition[]; }>; contentHash: CinemaSha256; } export interface CinemaRightsRegistryArtifact { schemaVersion: 1; projectSlug: string; registryId: string; generatedAt: string; records: Array<{ assetId: string; sourceType: 'original' | 'generated' | 'licensed' | 'third-party'; owner: string | null; permittedScope: string | null; status: 'pending' | 'cleared' | 'rejected'; evidenceIds: string[]; }>; contentHash: CinemaSha256; } export interface CinemaVoiceRegistryArtifact { schemaVersion: 1; projectSlug: string; registryId: string; generatedAt: string; characters: Array<{ characterId: string; speaking: boolean; voiceId: string | null; auditionEvidenceIds: string[]; releaseStatus: 'not-required' | 'pending' | 'cleared' | 'rejected'; releaseEvidenceIds: string[]; lockStatus: 'not-required' | 'pending' | 'locked' | 'rejected'; }>; contentHash: CinemaSha256; } export interface CinemaAssuranceIssue { code: string; path: string; message: string; } export interface CinemaAssuranceValidation { ok: boolean; issues: CinemaAssuranceIssue[]; } function hash(value: unknown): CinemaSha256 { return `sha256:${createHash('sha256').update(stableCinemaJson(value)).digest('hex')}`; } function artifact(value: T): T & { contentHash: CinemaSha256 } { return { ...structuredClone(value), contentHash: hash(value) }; } function add(issues: CinemaAssuranceIssue[], code: string, path: string, message: string): void { issues.push({ code, path, message }); } function required(issues: CinemaAssuranceIssue[], value: string | null, path: string): void { if (!value?.trim()) add(issues, 'required', path, 'is required'); } function unique(issues: CinemaAssuranceIssue[], values: string[], path: string): void { if (values.length !== new Set(values).size) add(issues, 'duplicate', path, 'must contain unique values'); } function timestamp(issues: CinemaAssuranceIssue[], value: string | null, path: string): void { if (!value || Number.isNaN(Date.parse(value))) add(issues, 'timestamp', path, 'must be an ISO timestamp'); } function contentHash(issues: CinemaAssuranceIssue[], value: { contentHash: CinemaSha256 }, path: string): void { const { contentHash: _contentHash, ...canonical } = value; if (value.contentHash !== hash(canonical)) add(issues, 'content-hash', path, 'does not match canonical artifact bytes'); } function result(issues: CinemaAssuranceIssue[]): CinemaAssuranceValidation { return { ok: issues.length === 0, issues }; } export function createCinemaVisualSoundBible( input: Omit, ): CinemaVisualSoundBibleArtifact { return artifact({ schemaVersion: 1 as const, ...input }); } export function validateCinemaVisualSoundBible(value: CinemaVisualSoundBibleArtifact): CinemaAssuranceValidation { const issues: CinemaAssuranceIssue[] = []; required(issues, value.projectSlug, 'projectSlug'); required(issues, value.bibleId, 'bibleId'); required(issues, value.visualLawVersion, 'visualLawVersion'); timestamp(issues, value.generatedAt, 'generatedAt'); unique(issues, value.visual.palette, 'visual.palette'); unique(issues, value.visual.lighting.sources, 'visual.lighting.sources'); unique(issues, value.visual.opticsFamilies, 'visual.opticsFamilies'); unique(issues, value.sound.forbiddenDrift, 'sound.forbiddenDrift'); if (value.visual.palette.length === 0 || value.visual.lighting.sources.length === 0 || value.visual.opticsFamilies.length === 0) { add(issues, 'visual-law', 'visual', 'palette, light sources and optics families cannot be empty'); } for (const [path, text] of [ ['visual.contrast', value.visual.contrast], ['visual.lighting.direction', value.visual.lighting.direction], ['visual.lighting.quality', value.visual.lighting.quality], ['visual.cameraGrammar', value.visual.cameraGrammar], ['visual.motionCharacter', value.visual.motionCharacter], ['visual.texture', value.visual.texture], ['visual.aspectRatio', value.visual.aspectRatio], ['sound.ambience', value.sound.ambience], ['sound.dialoguePerspective', value.sound.dialoguePerspective], ['sound.musicStrategy', value.sound.musicStrategy], ] as const) required(issues, text, path); if (!Number.isFinite(value.visual.cadenceFps) || value.visual.cadenceFps <= 0) add(issues, 'cadence', 'visual.cadenceFps', 'must be positive'); contentHash(issues, value, 'contentHash'); return result(issues); } export function createCinemaAssetEvidence( input: Omit, ): CinemaAssetEvidenceArtifact { return artifact({ schemaVersion: 1 as const, ...input }); } export function validateCinemaAssetEvidence(value: CinemaAssetEvidenceArtifact): CinemaAssuranceValidation { const issues: CinemaAssuranceIssue[] = []; required(issues, value.projectSlug, 'projectSlug'); required(issues, value.evidencePlanId, 'evidencePlanId'); timestamp(issues, value.generatedAt, 'generatedAt'); unique(issues, value.assets.map((asset) => asset.assetId), 'assets.assetId'); unique(issues, value.assets.map((asset) => asset.versionId), 'assets.versionId'); value.assets.forEach((asset, assetIndex) => { const base = `assets[${assetIndex}]`; required(issues, asset.assetId, `${base}.assetId`); required(issues, asset.versionId, `${base}.versionId`); if (!Number.isInteger(asset.requiredConditionCount) || asset.requiredConditionCount < 1) add(issues, 'condition-count', `${base}.requiredConditionCount`, 'must be a positive integer'); if (asset.kind === 'hero' && asset.requiredConditionCount < 10) add(issues, 'hero-condition-count', `${base}.requiredConditionCount`, 'Cinema hero assets require at least ten recognition conditions'); if (asset.kind === 'hero') { const requiredCategories: CinemaRecognitionCategory[] = ['canonical', 'profile', 'lighting', 'scale', 'action', 'ensemble', 'material', 'interaction']; const presentCategories = new Set(asset.conditions.map((condition) => condition.category)); const missingCategories = requiredCategories.filter((category) => !presentCategories.has(category)); if (missingCategories.length > 0) add(issues, 'hero-condition-coverage', `${base}.conditions`, `Cinema hero recognition plan is missing: ${missingCategories.join(', ')}`); } unique(issues, asset.conditions.map((condition) => condition.conditionId), `${base}.conditions.conditionId`); if (asset.conditions.length !== asset.requiredConditionCount) add(issues, 'condition-plan-count', `${base}.conditions`, 'must equal requiredConditionCount'); asset.conditions.forEach((condition, conditionIndex) => { const path = `${base}.conditions[${conditionIndex}]`; required(issues, condition.conditionId, `${path}.conditionId`); required(issues, condition.description, `${path}.description`); unique(issues, condition.evidenceIds, `${path}.evidenceIds`); if (condition.status === 'pending' && (condition.reviewerId !== null || condition.reviewedAt !== null || condition.evidenceIds.length > 0)) add(issues, 'fabricated-pending-evidence', path, 'pending conditions cannot carry review evidence'); if (condition.status !== 'pending') { required(issues, condition.reviewerId, `${path}.reviewerId`); timestamp(issues, condition.reviewedAt, `${path}.reviewedAt`); if (condition.evidenceIds.length === 0) add(issues, 'recognition-evidence', `${path}.evidenceIds`, 'reviewed conditions require evidence'); } }); const reviewedEvidenceIds = asset.conditions.flatMap((condition) => condition.status === 'pending' ? [] : condition.evidenceIds); unique(issues, reviewedEvidenceIds, `${base}.conditions.evidenceIds`); if (asset.lockStatus === 'locked' && asset.conditions.some((condition) => condition.status !== 'pass')) add(issues, 'unproven-lock', `${base}.lockStatus`, 'locked assets require every planned recognition condition to pass'); if (asset.lockStatus === 'rejected' && !asset.conditions.some((condition) => condition.status === 'fail')) add(issues, 'unexplained-rejection', `${base}.lockStatus`, 'rejected assets require a failed condition'); }); contentHash(issues, value, 'contentHash'); return result(issues); } export function createCinemaRightsRegistry( input: Omit, ): CinemaRightsRegistryArtifact { return artifact({ schemaVersion: 1 as const, ...input }); } export function validateCinemaRightsRegistry(value: CinemaRightsRegistryArtifact): CinemaAssuranceValidation { const issues: CinemaAssuranceIssue[] = []; required(issues, value.projectSlug, 'projectSlug'); required(issues, value.registryId, 'registryId'); timestamp(issues, value.generatedAt, 'generatedAt'); unique(issues, value.records.map((record) => record.assetId), 'records.assetId'); value.records.forEach((record, index) => { const path = `records[${index}]`; required(issues, record.assetId, `${path}.assetId`); unique(issues, record.evidenceIds, `${path}.evidenceIds`); if (record.status === 'pending' && (record.evidenceIds.length > 0 || record.permittedScope !== null)) add(issues, 'fabricated-clearance', path, 'pending rights cannot carry clearance scope or evidence'); if (record.status === 'cleared') { required(issues, record.owner, `${path}.owner`); required(issues, record.permittedScope, `${path}.permittedScope`); if (record.evidenceIds.length === 0) add(issues, 'rights-evidence', `${path}.evidenceIds`, 'cleared rights require evidence'); } }); contentHash(issues, value, 'contentHash'); return result(issues); } export function createCinemaVoiceRegistry( input: Omit, ): CinemaVoiceRegistryArtifact { return artifact({ schemaVersion: 1 as const, ...input }); } export function validateCinemaVoiceRegistry(value: CinemaVoiceRegistryArtifact): CinemaAssuranceValidation { const issues: CinemaAssuranceIssue[] = []; required(issues, value.projectSlug, 'projectSlug'); required(issues, value.registryId, 'registryId'); timestamp(issues, value.generatedAt, 'generatedAt'); unique(issues, value.characters.map((character) => character.characterId), 'characters.characterId'); value.characters.forEach((character, index) => { const path = `characters[${index}]`; required(issues, character.characterId, `${path}.characterId`); unique(issues, character.auditionEvidenceIds, `${path}.auditionEvidenceIds`); unique(issues, character.releaseEvidenceIds, `${path}.releaseEvidenceIds`); if (!character.speaking) { if (character.lockStatus !== 'not-required' || character.releaseStatus !== 'not-required') add(issues, 'silent-voice-state', path, 'non-speaking characters require not-required voice/release states'); if (character.voiceId !== null || character.auditionEvidenceIds.length > 0 || character.releaseEvidenceIds.length > 0) add(issues, 'silent-voice-evidence', path, 'non-speaking characters cannot carry voice evidence'); } if (character.speaking && character.lockStatus === 'locked') { required(issues, character.voiceId, `${path}.voiceId`); if (character.auditionEvidenceIds.length === 0) add(issues, 'audition-evidence', `${path}.auditionEvidenceIds`, 'locked speaking voices require audition evidence'); if (character.releaseStatus !== 'cleared' || character.releaseEvidenceIds.length === 0) add(issues, 'voice-release', path, 'locked speaking voices require cleared release evidence'); } if (character.speaking && character.lockStatus === 'pending' && (character.releaseStatus === 'cleared' || character.releaseEvidenceIds.length > 0)) add(issues, 'premature-release-evidence', path, 'pending voice lock cannot claim cleared release evidence'); }); contentHash(issues, value, 'contentHash'); return result(issues); }