import { createHash } from 'node:crypto'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import type { CinemaReferenceEntry, CinemaReferencePackArtifact, CinemaSha256 } from './cinema-types.js'; import { validateCinemaReferencePack } from './cinema-validation.js'; export interface CinemaIdentityVariantSource extends CinemaReferenceEntry { role: 'identity_face'; identityBearingFaceCount: 1; identityVariant: 'canonical' | 'profile' | 'expression' | 'injury'; } export interface CinemaProviderReferenceProjection { schemaVersion: 1; projectSlug: string; shotId: string; packId: string; routeId: string; packaging: 'multi-input' | 'composite'; semanticBindings: Array<{ assetId: string; contentHash: CinemaSha256; role: CinemaReferenceEntry['role']; visibilityIntent: CinemaReferenceEntry['visibilityIntent']; subjectId: string; state: CinemaReferenceEntry['state'] | null; controls: string; mustNotTransfer: string; }>; providerInputs: Array<{ inputId: string; sourceAssetIds: string[]; path: string | null; contentHash: CinemaSha256 }>; contentHash: CinemaSha256; } function hash(value: unknown): CinemaSha256 { return `sha256:${createHash('sha256').update(stableCinemaJson(value)).digest('hex')}`; } export function selectCinemaIdentityVariant(input: { sources: CinemaIdentityVariantSource[]; selectedAssetId: string; }): CinemaIdentityVariantSource { const selected = input.sources.filter((source) => source.assetId === input.selectedAssetId); if (selected.length !== 1) throw new Error(`Identity selection must resolve exactly one source; found ${selected.length}`); const versions = new Set(input.sources.map((source) => source.characterVersionId)); if (versions.size !== 1 || versions.has(undefined)) throw new Error('Identity variant catalog must bind one immutable character version'); return structuredClone(selected[0]); } function semantics(reference: CinemaReferenceEntry): { controls: string; mustNotTransfer: string } { if (reference.visibilityIntent === 'lighting_only') return { controls: 'lighting direction, quality, colour and exposure context', mustNotTransfer: 'subjects, wardrobe, props, geometry or camera framing' }; if (reference.visibilityIntent === 'offscreen_context') return { controls: 'offscreen spatial and narrative context', mustNotTransfer: 'visible subjects or background pixels' }; if (reference.role === 'identity_face') return { controls: 'face identity and approved identity variant only', mustNotTransfer: 'background, camera, lighting, body pose or wardrobe' }; if (reference.role.includes('body_') || reference.role === 'wardrobe' || reference.role === 'silhouette') return { controls: 'body, garment and silhouette construction', mustNotTransfer: 'face identity, environment, lighting or camera' }; if (reference.visibilityIntent === 'start_state' || reference.visibilityIntent === 'end_state') return { controls: `declared ${reference.visibilityIntent.replace('_', ' ')} continuity`, mustNotTransfer: 'unrelated identity, wardrobe, environment or camera style' }; return { controls: `${reference.role} with ${reference.visibilityIntent} intent`, mustNotTransfer: 'identity or state outside the canonical binding' }; } export function projectCinemaReferencePack(input: { pack: CinemaReferencePackArtifact; routeId: string; packaging: 'multi-input' | 'composite'; maximumReferences: number; }): CinemaProviderReferenceProjection { const validation = validateCinemaReferencePack(input.pack); if (!validation.ok) throw new Error(`Cannot project invalid Cinema reference pack: ${validation.issues.map((issue) => issue.code).join(', ')}`); if (input.pack.references.length > input.maximumReferences) throw new Error(`Route ${input.routeId} supports ${input.maximumReferences} references, pack requires ${input.pack.references.length}`); const semanticBindings = input.pack.references.map((reference) => ({ assetId: reference.assetId, contentHash: reference.contentHash, role: reference.role, visibilityIntent: reference.visibilityIntent, subjectId: reference.subjectId, state: reference.state ? structuredClone(reference.state) : null, ...semantics(reference), })); const providerInputs = input.packaging === 'multi-input' ? input.pack.references.map((reference) => ({ inputId: reference.assetId, sourceAssetIds: [reference.assetId], path: reference.path, contentHash: reference.contentHash })) : [{ inputId: `${input.pack.packId}-composite`, sourceAssetIds: input.pack.references.map((reference) => reference.assetId), path: null, contentHash: hash(semanticBindings) }]; const canonical = { schemaVersion: 1 as const, projectSlug: input.pack.projectSlug, shotId: input.pack.shotId, packId: input.pack.packId, routeId: input.routeId, packaging: input.packaging, semanticBindings, providerInputs }; return { ...canonical, contentHash: hash(canonical) }; } export function validateCinemaProviderReferenceProjection( pack: CinemaReferencePackArtifact, projection: CinemaProviderReferenceProjection, ): string[] { const issues: string[] = []; if (projection.projectSlug !== pack.projectSlug || projection.shotId !== pack.shotId || projection.packId !== pack.packId) issues.push('projection scope does not match canonical pack'); if (stableCinemaJson(projection.semanticBindings.map(({ controls: _controls, mustNotTransfer: _mustNotTransfer, ...binding }) => binding)) !== stableCinemaJson(pack.references.map((reference) => ({ assetId: reference.assetId, contentHash: reference.contentHash, role: reference.role, visibilityIntent: reference.visibilityIntent, subjectId: reference.subjectId, state: reference.state ?? null })))) issues.push('semantic bindings do not preserve the canonical pack'); const projectedIds = new Set(projection.providerInputs.flatMap((providerInput) => providerInput.sourceAssetIds)); const packIds = new Set(pack.references.map((reference) => reference.assetId)); if (projectedIds.size !== packIds.size || [...packIds].some((id) => !projectedIds.has(id))) issues.push('provider inputs do not contain every and only active reference'); const { contentHash: _contentHash, ...canonical } = projection; if (projection.contentHash !== hash(canonical)) issues.push('projection content hash is invalid'); return issues; }