import { createHash } from 'node:crypto'; import type { CinemaReferencePackArtifact, CinemaSha256, CinemaShotManifestArtifact, } from './cinema-types.js'; export type CinemaShotManifestDraft = Omit< CinemaShotManifestArtifact, 'visualLaw' | 'compiledPrompt' | 'providerParameters' > & { visualLaw: Omit; providerParameters: { values: Record }; }; export function sha256Text(value: string): CinemaSha256 { return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; } function canonicalize(value: unknown): string { if (value === null || typeof value === 'string' || typeof value === 'boolean') { return JSON.stringify(value); } if (typeof value === 'number') { if (!Number.isFinite(value)) throw new Error('Cinema provider parameters must contain finite numbers'); return JSON.stringify(value); } if (Array.isArray(value)) { return `[${value.map((item) => canonicalize(item)).join(',')}]`; } if (typeof value === 'object') { const entries = Object.entries(value as Record) .filter(([, item]) => item !== undefined) .sort(([left], [right]) => left.localeCompare(right)); return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalize(item)}`).join(',')}}`; } throw new Error(`Cinema provider parameters contain unsupported ${typeof value} value`); } export function stableCinemaJson(value: unknown): string { return canonicalize(value); } function renderReferences(pack: CinemaReferencePackArtifact): string[] { return [...pack.references] .sort((left, right) => left.assetId.localeCompare(right.assetId)) .map((reference) => { const state = reference.state ? `; state ${reference.state.group}=${reference.state.value}` : ''; return `- ${reference.role}: ${reference.assetId}; intent ${reference.visibilityIntent}; subject ${reference.subjectId}${state}`; }); } function renderTransitions(pack: CinemaReferencePackArtifact): string[] { return [...pack.transitions] .sort((left, right) => left.startSeconds - right.startSeconds || left.subjectId.localeCompare(right.subjectId)) .map((transition) => `- ${transition.subjectId} ${transition.stateGroup}: ${transition.startValue} -> ${transition.endValue}; ` + `${transition.startSeconds}-${transition.endSeconds}s; trigger ${transition.trigger}; direction ${transition.direction}; ` + `steps ${transition.orderedSteps.join(' -> ')}; interaction ${transition.interaction}`, ); } export function compileCinemaShotManifest( draft: CinemaShotManifestDraft, pack: CinemaReferencePackArtifact, ): CinemaShotManifestArtifact { if (draft.projectSlug !== pack.projectSlug) { throw new Error(`Shot ${draft.shotId} and reference pack ${pack.packId} belong to different projects`); } if (draft.shotId !== pack.shotId || draft.referencePackId !== pack.packId) { throw new Error(`Shot ${draft.shotId} does not bind reference pack ${pack.packId}`); } const overrides = draft.visualLaw.overrides.length === 0 ? ['- none'] : draft.visualLaw.overrides.map((override) => `- ${override.field}: ${override.from} -> ${override.to}; reason ${override.reason}`, ); const actions = [...draft.actionBeats] .sort((left, right) => left.order - right.order) .map((beat) => `- ${beat.order}. ${beat.startSeconds}-${beat.endSeconds}s: ${beat.description}; ` + `timing ${beat.timingMode}; transition ${beat.transition}`, ); const prompt = [ '[VISUAL LAW]', draft.visualLaw.prefix, '[VISUAL OVERRIDES]', ...overrides, '[STORY PURPOSE]', `Act: ${draft.story.act}. Beat: ${draft.story.beatId}.`, `Scene purpose: ${draft.story.scenePurpose}`, `Character objective: ${draft.story.characterObjective}`, `Expected change: ${draft.story.expectedChange}`, '[REFERENCES]', ...renderReferences(pack), '[CAMERA]', `${draft.camera.shotSize}; position ${draft.camera.position}; movement ${draft.camera.movement}; stability ${draft.camera.stability}.`, '[ACTION TIMELINE]', ...actions, '[MECHANICAL TRANSITIONS]', ...(pack.transitions.length === 0 ? ['- none'] : renderTransitions(pack)), '[OCCUPANCY]', `Characters: ${draft.occupancy.characterIds.join(', ') || 'none'}.`, `Vehicles: ${draft.occupancy.vehicleIds.join(', ') || 'none'}.`, `Props: ${draft.occupancy.propIds.join(', ') || 'none'}.`, `Constraint: exactly ${draft.occupancy.characterIds.length} character(s), ${draft.occupancy.vehicleIds.length} vehicle(s), and ${draft.occupancy.propIds.length} prop(s); no duplicates, omissions, or undeclared additions.`, '[AUDIO CONTRACT]', `Music ${draft.audio.music}; dialogue ${draft.audio.dialogue}; SFX ${draft.audio.sfx}; ambience ${draft.audio.ambience}; ` + `intentional silence ${draft.audio.intentionalSilence ? 'yes' : 'no'}.`, `Notes: ${draft.audio.notes || 'none'}`, ].join('\n'); const providerJson = stableCinemaJson(draft.providerParameters.values); return { ...draft, visualLaw: { ...draft.visualLaw, prefixHash: sha256Text(draft.visualLaw.prefix), }, compiledPrompt: { text: prompt, contentHash: sha256Text(prompt), }, providerParameters: { values: draft.providerParameters.values, contentHash: sha256Text(providerJson), }, }; }