import { existsSync } from 'node:fs'; import { mkdir, readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { writeTextFileAtomic } from './atomic-write.js'; import { validateCinemaContinuity, validateCinemaTimedAudio } from './cinema-continuity.js'; import type { CinemaContinuityLedgerArtifact, CinemaGeographyMapArtifact, CinemaTimedAudioContractArtifact, } from './cinema-continuity-types.js'; import { cinemaArtifactsDirFor } from './cinema-store.js'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import type { CinemaShotManifestArtifact } from './cinema-types.js'; export interface CinemaProductionContractPaths { continuityLedgerPath: string; timedAudioContractPath: string; } export function cinemaContinuityLedgerPathFor(root: string, projectSlug: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'continuity-ledger.json'); } export function cinemaTimedAudioContractPathFor(root: string, projectSlug: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'timed-audio-contract.json'); } async function readJson(path: string, label: string): Promise { if (!existsSync(path)) return null; try { return JSON.parse(await readFile(path, 'utf8')) as T; } catch (error) { throw new Error(`${label} is not readable valid JSON at ${path}: ${error instanceof Error ? error.message : String(error)}`); } } function assertSameOrAbsent(existing: T | null, next: T, label: string, path: string): void { if (existing && stableCinemaJson(existing) !== stableCinemaJson(next)) { throw new Error(`${label} is immutable and already exists with different content: ${path}`); } } export async function writeCinemaProductionContracts( root: string, continuity: CinemaContinuityLedgerArtifact, audio: CinemaTimedAudioContractArtifact, geography: CinemaGeographyMapArtifact, manifests: CinemaShotManifestArtifact[], ): Promise { const continuityValidation = validateCinemaContinuity(continuity, geography, manifests); if (!continuityValidation.ok) throw new Error(`Cinema continuity failed validation: ${continuityValidation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`); const audioValidation = validateCinemaTimedAudio(audio, manifests); if (!audioValidation.ok) throw new Error(`Cinema timed audio failed validation: ${audioValidation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`); if (continuity.projectSlug !== audio.projectSlug) throw new Error('Cinema continuity and timed audio must belong to one project'); const continuityLedgerPath = cinemaContinuityLedgerPathFor(root, continuity.projectSlug); const timedAudioContractPath = cinemaTimedAudioContractPathFor(root, audio.projectSlug); const existingContinuity = await readJson(continuityLedgerPath, 'Cinema continuity ledger'); const existingAudio = await readJson(timedAudioContractPath, 'Cinema timed audio contract'); assertSameOrAbsent(existingContinuity, continuity, 'Cinema continuity ledger', continuityLedgerPath); assertSameOrAbsent(existingAudio, audio, 'Cinema timed audio contract', timedAudioContractPath); await mkdir(dirname(continuityLedgerPath), { recursive: true }); if (!existingContinuity) await writeTextFileAtomic(continuityLedgerPath, `${JSON.stringify(continuity, null, 2)}\n`); if (!existingAudio) await writeTextFileAtomic(timedAudioContractPath, `${JSON.stringify(audio, null, 2)}\n`); return { continuityLedgerPath, timedAudioContractPath }; } export async function readCinemaContinuityLedger( root: string, projectSlug: string, geography: CinemaGeographyMapArtifact, manifests: CinemaShotManifestArtifact[] = [], ): Promise { const artifact = await readJson(cinemaContinuityLedgerPathFor(root, projectSlug), 'Cinema continuity ledger'); if (!artifact) return null; if (artifact.projectSlug !== projectSlug) throw new Error(`Cinema continuity ledger belongs to ${artifact.projectSlug}, expected ${projectSlug}`); const validation = validateCinemaContinuity(artifact, geography, manifests); if (!validation.ok) throw new Error(`Cinema continuity ledger is invalid: ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`); return artifact; } export async function readCinemaTimedAudioContract( root: string, projectSlug: string, manifests: CinemaShotManifestArtifact[], ): Promise { const artifact = await readJson(cinemaTimedAudioContractPathFor(root, projectSlug), 'Cinema timed audio contract'); if (!artifact) return null; if (artifact.projectSlug !== projectSlug) throw new Error(`Cinema timed audio contract belongs to ${artifact.projectSlug}, expected ${projectSlug}`); const validation = validateCinemaTimedAudio(artifact, manifests); if (!validation.ok) throw new Error(`Cinema timed audio contract is invalid: ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`); return artifact; }