import { createHash, randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { copyFile, mkdir, readFile, rename, rm } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { artifactPathFor, writeArtifact } from './artifact-store.js'; import { createPublishReportArtifact, createStoryboardArtifact, type StoryboardArtifact } from './artifacts.js'; import { assembleProject, writeAssembleReport } from './assemble/assemble.js'; import { writeTextFileAtomic } from './atomic-write.js'; import { cinemaArtifactContentHash } from './cinema-provider-contracts.js'; import { readCinemaPlanningPackage } from './cinema-planning-store.js'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import { cinemaArtifactsDirFor, readCinemaGenerationLedger, readCinemaShotManifest } from './cinema-store.js'; import type { CinemaAttemptOutcome, CinemaPromotionEvent, CinemaSha256, CinemaShotManifestArtifact } from './cinema-types.js'; import { appendProjectEvent } from './events.js'; import { probeMedia, type VideoMediaProbe } from './final-media.js'; import { readSceneCandidatesArtifact } from './scene-candidate-store.js'; import { readSceneSelectionArtifact } from './scene-selection-store.js'; import type { SceneCandidate } from './types.js'; import { ensureProjectWorkspace, resolveProjectWorkspace } from './workspace.js'; export interface CinemaDeliveryShot { shotId: string; editorialIndex: number; storySceneIndex: number; promotionId: string; attemptId: string; candidateId: string; sourcePath: string; sourceContentHash: CinemaSha256; sourceByteLength: number; stagedPath: string; durationSeconds: number; probe: VideoMediaProbe; } export interface CinemaDeliveryManifestArtifact { schemaVersion: 1; deliveryId: string; projectSlug: string; createdAt: string; planningReceiptHash: CinemaSha256; inputHash: CinemaSha256; shots: CinemaDeliveryShot[]; storyboard: { path: string; contentHash: CinemaSha256 }; assembly: { reportPath: string; reportContentHash: CinemaSha256; status: 'complete' | 'partial' }; master: { path: string; contentHash: CinemaSha256; byteLength: number; probe: VideoMediaProbe; verificationFramePath: string; verificationFrameHash: CinemaSha256; }; cost: { currency: string; actual: number }; archiveReady: true; deliveryVerified: true; providerCalls: 0; generationCalls: 0; spendAuthorized: false; contentHash: CinemaSha256; } export interface CinemaDeliveryResult { created: boolean; manifestPath: string; pointerPath: string; manifest: CinemaDeliveryManifestArtifact; providerCalls: 0; generationCalls: 0; spendAuthorized: false; } interface ResolvedShot { manifest: CinemaShotManifestArtifact; promotion: CinemaPromotionEvent; outcome: CinemaAttemptOutcome; candidate: SceneCandidate; sourcePath: string; contentHash: CinemaSha256; byteLength: number; editorialIndex: number; } function hashBytes(value: Uint8Array | string): CinemaSha256 { return `sha256:${createHash('sha256').update(value).digest('hex')}`; } async function hashFile(path: string): Promise<{ contentHash: CinemaSha256; byteLength: number }> { const bytes = await readFile(path); return { contentHash: hashBytes(bytes), byteLength: bytes.byteLength }; } function portableProjectPath(projectDir: string, path: string): string { const portable = relative(projectDir, path).split(sep).join('/'); if (!portable || portable === '..' || portable.startsWith('../') || isAbsolute(portable)) { throw new Error(`Cinema delivery path is outside the project archive boundary: ${path}`); } return portable; } function resolveProjectPath(projectDir: string, path: string): string { if (isAbsolute(path)) throw new Error(`Cinema delivery manifest path must be project-relative: ${path}`); const resolved = resolve(projectDir, path); const portable = relative(projectDir, resolved).split(sep).join('/'); if (!portable || portable === '..' || portable.startsWith('../') || isAbsolute(portable)) { throw new Error(`Cinema delivery manifest path escapes the project: ${path}`); } return resolved; } async function assertFileHash(path: string, expectedHash: CinemaSha256, expectedBytes?: number): Promise { const actual = await hashFile(path); if (actual.contentHash !== expectedHash || (expectedBytes !== undefined && actual.byteLength !== expectedBytes)) { throw new Error(`Cinema delivery referenced bytes changed: ${path}`); } } async function writeImmutableJson(path: string, value: unknown, label: string): Promise { const content = `${JSON.stringify(value, null, 2)}\n`; if (existsSync(path)) { const existing = await readFile(path, 'utf8'); if (existing === content) return; throw new Error(`${label} is immutable and already exists with different content: ${path}`); } await mkdir(dirname(path), { recursive: true }); await writeTextFileAtomic(path, content); } async function copyImmutable(source: string, target: string, expected: CinemaSha256): Promise { if (resolve(source) === resolve(target)) { const current = await hashFile(target); if (current.contentHash !== expected) throw new Error(`Cinema delivery source changed: ${source}`); return; } if (existsSync(target)) { const current = await hashFile(target); if (current.contentHash === expected) return; throw new Error(`Cinema delivery refuses to overwrite different clip bytes: ${target}`); } await mkdir(dirname(target), { recursive: true }); const temporary = `${target}.tmp-${randomUUID()}`; try { await copyFile(source, temporary); const copied = await hashFile(temporary); if (copied.contentHash !== expected) throw new Error(`Cinema delivery staging copy changed bytes: ${source}`); await rename(temporary, target); } catch (error) { await rm(temporary, { force: true }); throw error; } } function activePromotions(promotions: CinemaPromotionEvent[]): CinemaPromotionEvent[] { const superseded = new Set(promotions.map((promotion) => promotion.supersedesPromotionId).filter((id): id is string => id !== null)); return promotions.filter((promotion) => !superseded.has(promotion.promotionId)); } async function resolveDeliveryShots(root: string, projectSlug: string): Promise<{ planningReceiptHash: CinemaSha256; shots: ResolvedShot[]; currency: string; actualCost: number; }> { const planning = await readCinemaPlanningPackage(root, projectSlug); if (!planning) throw new Error(`Cinema delivery requires a canonical planning package for ${projectSlug}`); const ledger = await readCinemaGenerationLedger(root, projectSlug); if (!ledger) throw new Error('Cinema delivery requires a generation ledger'); const promotions = activePromotions(ledger.promotions); const candidates = await readSceneCandidatesArtifact(root, projectSlug); const selection = await readSceneSelectionArtifact(root, projectSlug); const projectDir = ensureProjectWorkspace(projectSlug, root).then((workspace) => workspace.projectDir); const shots: ResolvedShot[] = []; for (const shotId of planning.receipt.plannedShotIds) { const manifest = await readCinemaShotManifest(root, projectSlug, shotId); if (!manifest) throw new Error(`Cinema delivery is blocked: compiled manifest missing for ${shotId}`); const editorialIndex = manifest.ordinal - 1; if (editorialIndex < 0 || !Number.isInteger(editorialIndex)) throw new Error(`Cinema delivery is blocked: invalid ordinal for ${shotId}`); const promotion = promotions.find((entry) => entry.shotId === shotId); if (!promotion) throw new Error(`Cinema delivery is blocked: ${shotId} has no active evidence-bearing promotion`); const outcome = ledger.outcomes?.find((entry) => entry.attemptId === promotion.attemptId && entry.shotId === shotId); if (!outcome) throw new Error(`Cinema delivery is blocked: promoted attempt ${promotion.attemptId} has no completed outcome`); const selectedId = selection.scenes.find((entry) => entry.sceneIndex === editorialIndex)?.selectedCandidateId; if (!selectedId) throw new Error(`Cinema delivery is blocked: editorial slot ${editorialIndex} has no selected candidate`); const scene = candidates.scenes.find((entry) => entry.sceneIndex === editorialIndex); const candidate = scene?.candidates.find((entry) => entry.id === selectedId); if (!candidate || candidate.status !== 'completed') throw new Error(`Cinema delivery is blocked: selected candidate ${selectedId} is missing or incomplete`); const video = candidate.outputs.find((entry) => entry.kind === 'video'); if (!video) throw new Error(`Cinema delivery is blocked: selected candidate ${selectedId} has no video output`); if (candidate.source.externalJobId !== outcome.providerJobId) throw new Error(`Cinema delivery lineage mismatch for ${shotId}: candidate and outcome provider jobs differ`); const sourcePath = isAbsolute(video.path) ? video.path : resolve(await projectDir, video.path); const media = await hashFile(sourcePath); if (media.contentHash !== outcome.media.contentHash || media.byteLength !== outcome.media.byteLength) throw new Error(`Cinema delivery source bytes changed after promotion for ${shotId}`); shots.push({ manifest, promotion, outcome, candidate, sourcePath, ...media, editorialIndex }); } const expectedIndices = shots.map((shot) => shot.editorialIndex).sort((a, b) => a - b); if (new Set(expectedIndices).size !== shots.length || expectedIndices.some((index, position) => index !== position)) { throw new Error(`Cinema delivery requires one contiguous editorial slot per planned shot; got ${expectedIndices.join(', ')}`); } const currencies = new Set(shots.map((shot) => shot.outcome.actualCost.currency)); if (currencies.size !== 1) throw new Error('Cinema delivery cannot aggregate outcomes with mixed currencies'); return { planningReceiptHash: planning.receipt.contentHash, shots: shots.sort((left, right) => left.editorialIndex - right.editorialIndex), currency: [...currencies][0] ?? 'USD', actualCost: shots.reduce((sum, shot) => sum + shot.outcome.actualCost.amount, 0), }; } function deliveryStoryboard(projectSlug: string, shots: ResolvedShot[]): StoryboardArtifact { return createStoryboardArtifact({ projectSlug, productionMode: 'director', scenes: shots.map(({ manifest, editorialIndex }) => ({ sceneIndex: editorialIndex, description: manifest.story.scenePurpose, scenePrompt: { animationPrompt: manifest.compiledPrompt.text }, characters: [...manifest.occupancy.characterIds], durationSeconds: manifest.durationSeconds, silent: manifest.audio.dialogue === 'forbidden', })), }); } function inputHash(projectSlug: string, planningReceiptHash: CinemaSha256, shots: ResolvedShot[]): CinemaSha256 { return hashBytes(stableCinemaJson({ projectSlug, planningReceiptHash, shots: shots.map((shot) => ({ shotId: shot.manifest.shotId, manifestHash: hashBytes(stableCinemaJson(shot.manifest)), promotionId: shot.promotion.promotionId, attemptId: shot.outcome.attemptId, candidateId: shot.candidate.id, sourceContentHash: shot.contentHash, })), })); } function deliveryPath(root: string, projectSlug: string, deliveryId: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'deliveries', `${deliveryId}.json`); } export function cinemaDeliveryPointerPath(root: string, projectSlug: string): string { return join(cinemaArtifactsDirFor(root, projectSlug), 'delivery-current.json'); } export async function readCinemaDeliveryManifest(root: string, projectSlug: string): Promise { const pointerPath = cinemaDeliveryPointerPath(root, projectSlug); if (!existsSync(pointerPath)) return null; const pointer = JSON.parse(await readFile(pointerPath, 'utf8')) as { schemaVersion: 1; deliveryId: string; contentHash: CinemaSha256 }; const path = deliveryPath(root, projectSlug, pointer.deliveryId); const manifest = JSON.parse(await readFile(path, 'utf8')) as CinemaDeliveryManifestArtifact; if (manifest.projectSlug !== projectSlug || manifest.deliveryId !== pointer.deliveryId || manifest.contentHash !== pointer.contentHash || manifest.contentHash !== cinemaArtifactContentHash(manifest)) throw new Error(`Cinema delivery pointer is stale or corrupt for ${projectSlug}`); const projectDir = resolveProjectWorkspace(projectSlug, root).projectDir; await assertFileHash(resolveProjectPath(projectDir, manifest.storyboard.path), manifest.storyboard.contentHash); await assertFileHash(resolveProjectPath(projectDir, manifest.assembly.reportPath), manifest.assembly.reportContentHash); await assertFileHash(resolveProjectPath(projectDir, manifest.master.path), manifest.master.contentHash, manifest.master.byteLength); await assertFileHash(resolveProjectPath(projectDir, manifest.master.verificationFramePath), manifest.master.verificationFrameHash); for (const shot of manifest.shots) { await assertFileHash(resolveProjectPath(projectDir, shot.stagedPath), shot.sourceContentHash, shot.sourceByteLength); } return manifest; } export async function deliverCinemaProject(input: { root: string; projectSlug: string; createdAt?: string; ffmpegBin?: string; }): Promise { const resolved = await resolveDeliveryShots(input.root, input.projectSlug); const inputContentHash = inputHash(input.projectSlug, resolved.planningReceiptHash, resolved.shots); const deliveryId = `cinema-delivery-${inputContentHash.slice(7, 23)}`; const manifestPath = deliveryPath(input.root, input.projectSlug, deliveryId); const pointerPath = cinemaDeliveryPointerPath(input.root, input.projectSlug); const existing = await readCinemaDeliveryManifest(input.root, input.projectSlug); if (existing) { if (existing.inputHash !== inputContentHash || existing.deliveryId !== deliveryId) throw new Error('Cinema delivery inputs changed; a new delivery version is required'); return { created: false, manifestPath, pointerPath, manifest: existing, providerCalls: 0, generationCalls: 0, spendAuthorized: false }; } const workspace = await ensureProjectWorkspace(input.projectSlug, input.root); const storyboard = deliveryStoryboard(input.projectSlug, resolved.shots); const storyboardPath = artifactPathFor(workspace, 'storyboard'); if (existsSync(storyboardPath)) { const current = JSON.parse(await readFile(storyboardPath, 'utf8')) as StoryboardArtifact; if (stableCinemaJson(current) !== stableCinemaJson(storyboard)) throw new Error('Existing VideoClaw storyboard conflicts with the Cinema editorial bridge'); } else { await writeArtifact(workspace, 'storyboard', storyboard); } const storyboardBytes = await hashFile(storyboardPath); const shotRecords: CinemaDeliveryShot[] = []; for (const shot of resolved.shots) { const stagedPath = join(workspace.projectDir, 'outputs', `scene-${shot.editorialIndex}.mp4`); await copyImmutable(shot.sourcePath, stagedPath, shot.contentHash); const probe = await probeMedia(stagedPath); const durationDelta = Math.abs((probe.durationSeconds ?? -1) - shot.manifest.durationSeconds); if (!probe.videoCodec || durationDelta > 0.25) throw new Error(`Cinema delivery clip ${shot.manifest.shotId} failed duration/video probe`); shotRecords.push({ shotId: shot.manifest.shotId, editorialIndex: shot.editorialIndex, storySceneIndex: shot.manifest.sceneIndex, promotionId: shot.promotion.promotionId, attemptId: shot.outcome.attemptId, candidateId: shot.candidate.id, sourcePath: shot.sourcePath, sourceContentHash: shot.contentHash, sourceByteLength: shot.byteLength, stagedPath: portableProjectPath(workspace.projectDir, stagedPath), durationSeconds: shot.manifest.durationSeconds, probe, }); } const assembled = await assembleProject({ workspace, fromRenderedClips: true, ...(input.ffmpegBin ? { ffmpegBin: input.ffmpegBin } : {}) }); if (assembled.status === 'dry-run') throw new Error('Cinema delivery requires a real local assembly result'); if (!assembled.qc?.master || assembled.qc.master.status === 'fail') throw new Error('Cinema delivery assembly failed final media QC'); const { artifactPath: reportPath } = await writeAssembleReport(workspace, assembled); const reportHash = await hashFile(reportPath); const masterPath = join(workspace.projectDir, 'final', 'videos', 'cinema-master.mp4'); const assembledHash = await hashFile(assembled.outputPath); await copyImmutable(assembled.outputPath, masterPath, assembledHash.contentHash); const master = await hashFile(masterPath); const masterProbe = await probeMedia(masterPath); const expectedDuration = resolved.shots.reduce((sum, shot) => sum + shot.manifest.durationSeconds, 0); if (!masterProbe.videoCodec || !masterProbe.audioPresent || Math.abs((masterProbe.durationSeconds ?? -1) - expectedDuration) > 0.5) { throw new Error('Cinema delivery master failed codec, audio or total-duration verification'); } const verification = await import('./verify-final.js').then(({ verifyFinalOutput }) => verifyFinalOutput({ filePath: masterPath, outputDir: join(workspace.projectDir, 'final', 'verification') })); const frame = await hashFile(verification.framePath); const createdAt = input.createdAt ?? new Date().toISOString(); const manifestInput = { schemaVersion: 1 as const, deliveryId, projectSlug: input.projectSlug, createdAt, planningReceiptHash: resolved.planningReceiptHash, inputHash: inputContentHash, shots: shotRecords, storyboard: { path: portableProjectPath(workspace.projectDir, storyboardPath), contentHash: storyboardBytes.contentHash }, assembly: { reportPath: portableProjectPath(workspace.projectDir, reportPath), reportContentHash: reportHash.contentHash, status: assembled.status }, master: { path: portableProjectPath(workspace.projectDir, masterPath), contentHash: master.contentHash, byteLength: master.byteLength, probe: masterProbe, verificationFramePath: portableProjectPath(workspace.projectDir, verification.framePath), verificationFrameHash: frame.contentHash, }, cost: { currency: resolved.currency, actual: resolved.actualCost }, archiveReady: true as const, deliveryVerified: true as const, providerCalls: 0 as const, generationCalls: 0 as const, spendAuthorized: false as const, }; const manifest: CinemaDeliveryManifestArtifact = { ...manifestInput, contentHash: hashBytes('placeholder') }; manifest.contentHash = cinemaArtifactContentHash(manifest); await writeImmutableJson(manifestPath, manifest, `Cinema delivery ${deliveryId}`); await writeTextFileAtomic(pointerPath, `${JSON.stringify({ schemaVersion: 1, deliveryId, contentHash: manifest.contentHash }, null, 2)}\n`); await writeArtifact(workspace, 'publish-report', createPublishReportArtifact({ projectSlug: input.projectSlug, status: 'ready', generatedAt: createdAt, finalOutputPath: masterPath, notes: [`Cinema delivery ${deliveryId} verified locally.`] })); await appendProjectEvent(workspace, { type: 'cinema.delivery.verified', recordedAt: createdAt, payload: { deliveryId, manifestPath, masterPath, masterHash: master.contentHash, inputHash: inputContentHash } }); return { created: true, manifestPath, pointerPath, manifest, providerCalls: 0, generationCalls: 0, spendAuthorized: false }; }