import { realpath } from 'node:fs/promises'; import { join, relative, resolve, sep } from 'node:path'; import { cinemaArtifactsDirFor } from './cinema-store.js'; import { readApprovedCinemaImageReference } from './cinema-image-jobs.js'; import type { CinemaPlanningPackage } from './cinema-project-planner.js'; import type { CinemaCharacterSheetArtifact, CinemaReferenceRole } from './cinema-types.js'; import { resolveProjectWorkspace } from './workspace.js'; import { VclawError } from './errors.js'; /** Preserve image-job approval when its output enters an active character sheet. */ export async function validateCinemaImageSheetBindings( root: string, planning: CinemaPlanningPackage, sheet: CinemaCharacterSheetArtifact, ): Promise { const project = planning.receipt.projectSlug; const projectDir = resolveProjectWorkspace(project, root).projectDir; const jobsDir = join(cinemaArtifactsDirFor(root, project), 'image-jobs'); const activeCharacter = planning.assetEvidence.assets.find(asset => asset.versionId === sheet.characterVersionId && (asset.kind === 'hero' || asset.kind === 'supporting')); const components: Array<{media: {assetId:string;path:string;contentHash:string};role:CinemaReferenceRole}> = [ {media:sheet.identityAnchor,role:'identity_face'}, ...sheet.bodyPlates.map(plate => ({media:plate,role:(plate.view === 'front' ? 'body_front_headless' : 'body_back_headless') as CinemaReferenceRole})), ]; for (const {media,role} of components) { const path = resolve(projectDir, media.path); // realpath recognises a symlink to a job output as the same managed image. let canonicalPath: string; try { canonicalPath = await realpath(path); } catch { canonicalPath = path; } let canonicalJobsDir = jobsDir; try { canonicalJobsDir = await realpath(jobsDir); } catch { /* No image jobs in legacy projects. */ } const within = relative(canonicalJobsDir, canonicalPath); if (within.startsWith(`..${sep}`) || within === '..' || resolve(canonicalJobsDir, within) !== canonicalPath) continue; const parts = within.split(sep); if (parts.length !== 2) throw new VclawError('invalid_flag_value', 'Character sheet references an invalid image-job result path'); try { const reference = await readApprovedCinemaImageReference(root, project, parts[0]); if (!activeCharacter || reference.subjectId !== activeCharacter.assetId || reference.characterVersionId !== sheet.characterVersionId || reference.role !== role || reference.contentHash !== media.contentHash || reference.assetId !== media.assetId || await realpath(reference.path) !== canonicalPath) { throw new VclawError('invalid_flag_value', 'Reviewed image does not match the active character sheet role, subject, version or hash'); } } catch (error) { if (error instanceof VclawError) throw error; throw new VclawError('invalid_flag_value', `Image job ${parts[0]} needs a valid approved review before use in a character sheet`); } } }