import { createHash } from 'node:crypto'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import type { CinemaSha256 } from './cinema-types.js'; export type CinemaNarrativeAssetType = 'character' | 'costume' | 'location' | 'vehicle' | 'weapon' | 'lore-prop'; export interface CinemaAssetSemanticLink { targetAssetId: string; relationship: string; storyRationale: string; } export interface CinemaNarrativeAsset { assetId: string; versionId: string; type: CinemaNarrativeAssetType; name: string; descriptor: string; descriptorHash: CinemaSha256; storyFunctionRationale: string; semanticLinks: CinemaAssetSemanticLink[]; rationaleApproval: { status: 'approved' | 'rejected' | 'pending'; reviewerId: string | null; decidedAt: string | null; evidenceIds: string[]; }; } export interface CinemaAssetRegistryArtifact { schemaVersion: 1; projectSlug: string; registryId: string; generatedAt: string; assets: CinemaNarrativeAsset[]; contentHash: CinemaSha256; } export interface CinemaAssetRegistryIssue { code: string; path: string; message: string; } function hash(value: unknown): CinemaSha256 { return `sha256:${createHash('sha256').update(stableCinemaJson(value)).digest('hex')}`; } export function cinemaAssetDescriptorHash(descriptor: string): CinemaSha256 { return hash({ descriptor }); } export function createCinemaAssetRegistry(input: { projectSlug: string; registryId: string; generatedAt: string; assets: CinemaNarrativeAsset[]; }): CinemaAssetRegistryArtifact { const canonical = { schemaVersion: 1 as const, ...structuredClone(input) }; return { ...canonical, contentHash: hash(canonical) }; } export function validateCinemaAssetRegistry(registry: CinemaAssetRegistryArtifact): { ok: boolean; issues: CinemaAssetRegistryIssue[] } { const issues: CinemaAssetRegistryIssue[] = []; const add = (code: string, path: string, message: string): void => { issues.push({ code, path, message }); }; const ids = new Set(); const versions = new Set(); registry.assets.forEach((asset, index) => { const path = `assets[${index}]`; if (!asset.assetId.trim() || ids.has(asset.assetId)) add('asset-id', `${path}.assetId`, 'must be present and unique'); if (!asset.versionId.trim() || versions.has(asset.versionId)) add('version-id', `${path}.versionId`, 'must be present and unique'); ids.add(asset.assetId); versions.add(asset.versionId); if (!asset.name.trim() || !asset.descriptor.trim()) add('asset-description', path, 'name and descriptor are required'); if (asset.descriptorHash !== cinemaAssetDescriptorHash(asset.descriptor)) add('descriptor-hash', `${path}.descriptorHash`, 'does not match immutable descriptor'); if (!asset.storyFunctionRationale.trim()) add('story-rationale', `${path}.storyFunctionRationale`, 'approved Cinema assets require story function'); if (asset.rationaleApproval.status !== 'approved') add('rationale-approval', `${path}.rationaleApproval.status`, 'must be approved before asset lock'); if (!asset.rationaleApproval.reviewerId?.trim() || !asset.rationaleApproval.decidedAt || asset.rationaleApproval.evidenceIds.length === 0) add('rationale-evidence', `${path}.rationaleApproval`, 'approval requires reviewer, timestamp and evidence'); if (new Set(asset.rationaleApproval.evidenceIds).size !== asset.rationaleApproval.evidenceIds.length) add('duplicate-evidence', `${path}.rationaleApproval.evidenceIds`, 'must be unique'); asset.semanticLinks.forEach((link, linkIndex) => { if (!link.targetAssetId.trim() || !link.relationship.trim() || !link.storyRationale.trim()) add('semantic-link', `${path}.semanticLinks[${linkIndex}]`, 'requires target, relationship and story rationale'); }); }); registry.assets.forEach((asset, index) => asset.semanticLinks.forEach((link, linkIndex) => { if (!ids.has(link.targetAssetId)) add('unknown-semantic-target', `assets[${index}].semanticLinks[${linkIndex}].targetAssetId`, `unknown ${link.targetAssetId}`); if (link.targetAssetId === asset.assetId) add('self-semantic-link', `assets[${index}].semanticLinks[${linkIndex}].targetAssetId`, 'cannot link an asset to itself'); })); const { contentHash: _contentHash, ...canonical } = registry; if (registry.contentHash !== hash(canonical)) add('content-hash', 'contentHash', 'does not match canonical registry bytes'); return { ok: issues.length === 0, issues }; }