import { existsSync, realpathSync } from 'node:fs'; import { isAbsolute, relative, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { cinemaCharacterSheetHash, projectCinemaEvidenceState } from './cinema-evidence.js'; import type { CinemaEvidenceLedgerArtifact } from './cinema-evidence-types.js'; import { buildCinemaConsoleProjection } from './cinema-console-projection.js'; import type { CinemaConsoleModel } from './cinema-console-types.js'; import type { CinemaDeliveryManifestArtifact } from './cinema-delivery.js'; import type { ClipQcReport } from './clip-qc.js'; import type { CinemaCandidateReviewArtifact } from './cinema-candidate-review.js'; import type { CinemaContinuityLedgerArtifact, CinemaTimedAudioContractArtifact } from './cinema-continuity-types.js'; import type { CinemaPlanningPackage } from './cinema-project-planner.js'; import type { CinemaPreflightReportArtifact } from './cinema-preflight.js'; import type { CinemaProductionQueueArtifact } from './cinema-queue-types.js'; import type { CinemaReviewLedgerArtifact } from './cinema-review-types.js'; import type { CinemaCharacterSheetArtifact, CinemaGenerationLedgerArtifact, CinemaReferencePackArtifact, CinemaShotManifestArtifact, } from './cinema-types.js'; import { resolveProjectWorkspace } from './workspace.js'; function emptyQueue(projectSlug: string, generatedAt: string): CinemaProductionQueueArtifact { return { schemaVersion: 1, projectSlug, generatedAt, tasks: [] }; } function emptyLedger(projectSlug: string, generatedAt: string): CinemaGenerationLedgerArtifact { return { schemaVersion: 1, projectSlug, generatedAt, attempts: [], promotions: [] }; } function gateStatus(check: CinemaPreflightReportArtifact['checks'][number] | undefined): 'locked' | 'ready' | 'blocked' | 'external-approval' { if (!check || check.status === 'blocked') return 'blocked'; if (check.status === 'external-gate') return 'external-approval'; return 'locked'; } export function buildCinemaProjectConsole(input: { root: string; planning: CinemaPlanningPackage; preflight: CinemaPreflightReportArtifact; evidenceLedger: CinemaEvidenceLedgerArtifact | null; characterSheets: CinemaCharacterSheetArtifact[]; referencePacks: CinemaReferencePackArtifact[]; shotManifests: CinemaShotManifestArtifact[]; continuityLedger: CinemaContinuityLedgerArtifact | null; timedAudioContract: CinemaTimedAudioContractArtifact | null; queue?: CinemaProductionQueueArtifact | null; generationLedger?: CinemaGenerationLedgerArtifact | null; reviewLedger?: CinemaReviewLedgerArtifact | null; candidateReviews?: CinemaCandidateReviewArtifact[]; deliveryManifest?: CinemaDeliveryManifestArtifact | null; clipQcReport?: ClipQcReport | null; mode?: CinemaConsoleModel['mode']; }): CinemaConsoleModel { const { planning, preflight } = input; const projectSlug = planning.receipt.projectSlug; const generatedAt = preflight.generatedAt; const queue = input.queue ?? emptyQueue(projectSlug, generatedAt); const generationLedger = input.generationLedger ?? emptyLedger(projectSlug, generatedAt); const projectDir = resolveProjectWorkspace(projectSlug, input.root).projectDir; if (input.clipQcReport && input.clipQcReport.projectSlug !== projectSlug) { throw new Error(`Clip QC report belongs to ${input.clipQcReport.projectSlug}, expected ${projectSlug}`); } const projectBoundMedia = (path: string | undefined): { path: string; mediaExists: boolean } | null => { if (!path) return null; const localPath = isAbsolute(path) ? resolve(path) : resolve(projectDir, path); const mediaExists = existsSync(localPath); const boundaryPath = existsSync(projectDir) ? realpathSync(projectDir) : resolve(projectDir); const targetPath = mediaExists ? realpathSync(localPath) : localPath; const projectRelative = relative(boundaryPath, targetPath); if (projectRelative === '' || projectRelative.startsWith('..') || isAbsolute(projectRelative)) return null; return { path: mediaExists ? pathToFileURL(targetPath).href : targetPath, mediaExists }; }; const deliveryMediaPath = (path: string): string => isAbsolute(path) ? path : resolve(projectDir, path); const filmstripByScene = new Map((input.clipQcReport?.scenes ?? []).map((scene) => [scene.sceneIndex, projectBoundMedia(scene.filmstripPath)])); const manifestByShot = new Map(input.shotManifests.map((manifest) => [manifest.shotId, manifest])); const attemptById = new Map(generationLedger.attempts.map((attempt) => [attempt.attemptId, attempt])); const supersededPromotions = new Set(generationLedger.promotions.map((promotion) => promotion.supersedesPromotionId).filter((id): id is string => id !== null)); const activePromotionByShot = new Map(generationLedger.promotions.filter((promotion) => !supersededPromotions.has(promotion.promotionId)).map((promotion) => [promotion.shotId, promotion])); const candidateMedia = Object.fromEntries((generationLedger.outcomes ?? []).map((outcome) => { const localPath = isAbsolute(outcome.media.path) ? outcome.media.path : resolve(projectDir, outcome.media.path); const attempt = attemptById.get(outcome.attemptId); const manifest = attempt ? manifestByShot.get(attempt.shotId) : undefined; const sceneIndex = manifest ? manifest.ordinal - 1 : null; const sceneReport = sceneIndex === null ? undefined : input.clipQcReport?.scenes.find((scene) => scene.sceneIndex === sceneIndex); const reportedClip = sceneReport ? (isAbsolute(sceneReport.clipPath) ? resolve(sceneReport.clipPath) : resolve(projectDir, sceneReport.clipPath)) : null; const isReviewedClip = reportedClip === resolve(localPath) || activePromotionByShot.get(outcome.shotId)?.attemptId === outcome.attemptId; const filmstrip = isReviewedClip && sceneIndex !== null ? filmstripByScene.get(sceneIndex) : null; return [outcome.attemptId, { videoPath: existsSync(localPath) ? pathToFileURL(localPath).href : localPath, mediaExists: existsSync(localPath), filmstripPath: filmstrip?.path ?? null, filmstripExists: filmstrip?.mediaExists ?? false, }]; })); const base = buildCinemaConsoleProjection({ bundle: { characterSheets: input.characterSheets, referencePacks: input.referencePacks, shotManifests: input.shotManifests, ledger: generationLedger, }, queue, generatedAt, mode: input.mode ?? 'static-read-only', title: `${planning.story.storyBible.characters[0].name} · ${planning.story.creativeContract.genre}`, reviewLedger: input.reviewLedger ?? undefined, storyBlueprint: planning.story, coverage: planning.coverage, candidateMedia, }); const mediaByAsset = new Map(); for (const sheet of input.characterSheets) { for (const media of [sheet.identityAnchor, ...sheet.bodyPlates, sheet.compositeSheet]) { const localPath = isAbsolute(media.path) ? media.path : resolve(projectDir, media.path); mediaByAsset.set(media.assetId, { path: existsSync(localPath) ? pathToFileURL(localPath).href : localPath, mediaExists: existsSync(localPath) }); } } const continuityByShot = new Map((input.continuityLedger?.records ?? []).map((record) => [record.shotId, record])); const effective = projectCinemaEvidenceState(planning, input.evidenceLedger); const sheetByVersion = new Map(input.characterSheets.map((sheet) => [sheet.characterVersionId, sheet])); const sheetDecisions = new Map((input.evidenceLedger?.decisions ?? []).filter((decision) => decision.kind === 'character-sheet').map((decision) => [decision.characterVersionId, decision])); const checks = new Map(preflight.checks.map((check) => [check.checkId, check])); const contractsBound = input.continuityLedger !== null && input.timedAudioContract !== null && input.shotManifests.length > 0 && input.shotManifests.every((manifest) => ( manifest.providerParameters.values.continuityLedgerHash === input.continuityLedger?.contentHash && manifest.providerParameters.values.timedAudioContractHash === input.timedAudioContract?.contentHash )); const compiled = input.shotManifests.length === planning.receipt.plannedShotIds.length && contractsBound; const delivery = input.deliveryManifest ?? null; const deliveredByShot = new Map(delivery?.shots.map((shot) => [shot.shotId, shot]) ?? []); const assetEvidenceIds = effective.assetEvidence.assets.flatMap((asset) => asset.conditions.flatMap((condition) => condition.evidenceIds)); const rightsEvidenceIds = effective.rightsRegistry.records.flatMap((record) => record.evidenceIds); const voiceEvidenceIds = effective.voiceRegistry.characters.flatMap((voice) => [...voice.auditionEvidenceIds, ...voice.releaseEvidenceIds]); return { ...base, scope: 'proof-scene', summary: { readiness: delivery ? 'ready' : compiled && preflight.compileReady ? 'awaiting-approval' : 'blocked', blockers: delivery ? [] : preflight.blockers, assumptions: planning.story.creativeContract.assumptions.map((item) => `${item.field}: ${String(item.value)} — ${item.reason}`), estimatedCost: queue.tasks.reduce((sum, task) => sum + task.estimatedCost.amount, 0), actualCost: queue.tasks.reduce((sum, task) => sum + task.actualCost.amount, 0), currency: queue.tasks[0]?.estimatedCost.currency ?? 'USD', }, gates: [ { id: 'character_sheet', label: 'Single-face character sheet', status: gateStatus(checks.get('character-sheet')), evidenceIds: input.characterSheets.flatMap((sheet) => sheet.qc.humanReview.evidenceIds) }, { id: 'recognition_lock', label: 'Ten-condition recognition', status: gateStatus(checks.get('recognition-lock')), evidenceIds: assetEvidenceIds }, { id: 'rights_clearance', label: 'Rights clearance', status: gateStatus(checks.get('rights-clearance')), evidenceIds: rightsEvidenceIds }, { id: 'voice_release', label: 'Voice and release', status: gateStatus(checks.get('voice-release')), evidenceIds: voiceEvidenceIds }, { id: 'dialogue_authorship', label: 'Dialogue authorship', status: gateStatus(checks.get('dialogue-authorship')), evidenceIds: [] }, { id: 'shot_compile', label: 'Continuity-safe shot compile', status: compiled ? 'locked' : 'blocked', evidenceIds: input.shotManifests.map((manifest) => manifest.compiledPrompt.contentHash) }, { id: 'generation_batch', label: 'Exact provider batch', status: 'external-approval', evidenceIds: [] }, { id: 'picture_lock', label: 'Picture lock', status: delivery ? 'locked' : 'blocked', evidenceIds: delivery ? [delivery.contentHash, delivery.master.contentHash] : [] }, ], assets: base.assets.map((asset) => ({ ...asset, ...(mediaByAsset.get(asset.assetId) ?? {}) })), reviews: [ ...base.reviews, ...(input.candidateReviews ?? []).map((review) => ({ decisionId: review.reviewId, attemptId: review.attemptId, shotId: review.shotId, kind: review.decision, reviewerId: review.reviewerId, decidedAt: review.reviewedAt, reason: review.lanes.map((lane) => `${lane.lane}: ${lane.reason}`).join(' · '), timeSeconds: null, changedVariable: review.changedVariable, evidenceIds: [...new Set([ review.bridgeReceiptHash, ...(review.authorityEvidence ?? []).map((entry) => entry.contentHash), ...review.lanes.flatMap((lane) => lane.evidence.map((entry) => entry.contentHash)), ])], contentHash: review.contentHash, })), ], shots: base.shots.map((shot) => { const record = continuityByShot.get(shot.shotId); const delivered = deliveredByShot.get(shot.shotId); const continuity = record?.subjects.flatMap((subject) => [ ...Object.entries(subject.values).map(([dimension, value]) => `${subject.subjectId}.${dimension}=${value}`), ...record.intentionalChanges.map((change) => `${change.subjectId}.${change.dimension}: ${change.from} → ${change.to} (${change.beatId})`), ]) ?? []; const deliveredPath = delivered ? deliveryMediaPath(delivered.stagedPath) : null; return { ...shot, continuity, ...(deliveredPath ? { videoPath: pathToFileURL(deliveredPath).href, mediaExists: existsSync(deliveredPath) } : {}) }; }), qc: base.qc.map((finding) => finding.lane === 'continuity' && finding.shotId !== 'asset-room' ? { ...finding, severity: input.continuityLedger ? 'pass' : 'info', summary: input.continuityLedger ? `Validated continuity record ${finding.shotId} is bound to the compiled manifest.` : 'Not evaluated: no compiled continuity ledger exists.' } : finding), evidence: { planningReceiptHash: planning.receipt.contentHash, evidenceLedgerHash: input.evidenceLedger?.contentHash ?? null, preflightHash: preflight.contentHash, blockers: [...preflight.blockers], assumptions: planning.story.creativeContract.assumptions.map((item) => ({ field: item.field, value: String(item.value), reason: item.reason })), characterSheets: planning.assetEvidence.assets.filter((asset) => asset.kind === 'hero' || asset.kind === 'supporting').map((asset) => { const sheet = sheetByVersion.get(asset.versionId); const decision = sheetDecisions.get(asset.versionId); const sheetHash = sheet ? cinemaCharacterSheetHash(sheet) : null; const approved = Boolean(decision && decision.sheetHash === sheetHash); return { characterVersionId: asset.versionId, status: approved ? 'approved' : sheet ? 'stored' : 'missing', sheetHash, reviewerId: decision?.reviewerId ?? null, evidenceIds: decision ? decision.evidence.map((evidence) => evidence.contentHash) : [] }; }), recognition: effective.assetEvidence.assets.flatMap((asset) => asset.conditions.map((condition) => ({ assetId: asset.assetId, versionId: asset.versionId, conditionId: condition.conditionId, category: condition.category, description: condition.description, status: condition.status, reviewerId: condition.reviewerId, evidenceIds: [...condition.evidenceIds] }))), rights: effective.rightsRegistry.records.map((record) => ({ assetId: record.assetId, status: record.status, owner: record.owner, permittedScope: record.permittedScope, evidenceIds: [...record.evidenceIds] })), voices: effective.voiceRegistry.characters.map((voice) => ({ characterId: voice.characterId, speaking: voice.speaking, voiceId: voice.voiceId, lockStatus: voice.lockStatus, releaseStatus: voice.releaseStatus, auditionEvidenceIds: [...voice.auditionEvidenceIds], releaseEvidenceIds: [...voice.releaseEvidenceIds] })), productionContracts: { continuityHash: input.continuityLedger?.contentHash ?? null, continuityRecords: input.continuityLedger?.records.length ?? 0, timedAudioHash: input.timedAudioContract?.contentHash ?? null, timedAudioShots: input.timedAudioContract?.shots.length ?? 0, boundToEveryManifest: contractsBound, }, }, edit: { ...base.edit, roughCutPath: delivery ? pathToFileURL(deliveryMediaPath(delivery.master.path)).href : null, audioLayers: input.timedAudioContract ? [ `${input.timedAudioContract.shots.reduce((sum, shot) => sum + shot.dialogue.length, 0)} dialogue cues`, `${input.timedAudioContract.shots.reduce((sum, shot) => sum + shot.music.length, 0)} music cues`, `${input.timedAudioContract.shots.reduce((sum, shot) => sum + shot.sfx.length, 0)} SFX cues`, `${input.timedAudioContract.shots.reduce((sum, shot) => sum + shot.ambience.length, 0)} ambience cues`, ] : ['Timed audio contract not compiled'], }, delivery: delivery ? { status: 'ready', masterPath: pathToFileURL(deliveryMediaPath(delivery.master.path)).href, captionsPath: null, archiveHash: null, notes: [`Delivery ${delivery.deliveryId} passed local assembly and final verification.`, `Master ${delivery.master.contentHash}`, `Actual cost ${delivery.cost.currency} ${delivery.cost.actual.toFixed(2)}`], } : base.delivery, }; }