import { join } from 'node:path'; import { VclawError } from './errors.js'; import { readCinemaImageJob, type ImageJobRole } from './cinema-image-jobs.js'; import { cinemaImageTransportFor } from './cinema-image-transport.js'; import { resolveProjectWorkspace } from './workspace.js'; import type { CinemaProviderRouteId } from './cinema-provider-projection.js'; import type { CinemaQueueTask } from './cinema-queue-types.js'; import type { CinemaSha256 } from './cinema-types.js'; /** * The exact thing an image transport submits. This is the image lane's answer to * `VideoExecutionPayload`, and it is deliberately a SEPARATE type rather than a * widening of it: the video payload is keyed on a core `ProviderRouteId`, and * Cinema image route ids are a different id space that ADR 0001 forbids merging. */ export interface CinemaImageSubmitPayload { taskId: string; routeId: CinemaProviderRouteId; jobId: string; /** The plan hash the queue task was compiled against. */ jobHash: CinemaSha256; prompt: string; role: ImageJobRole; subjectId: string; characterVersionId?: string; /** Absolute paths to the plan's verified reference images, in plan order. */ referencePaths: string[]; /** * Where the bytes land. Derived from the task id alone, so a worker that * crashed after writing can find them again and report the job complete * instead of asking a human — or worse, paying twice. */ outputPath: string; } /** * Build the submit payload for an `image-generate` task by READING the immutable * plan the task references, not by trusting anything copied into the queue. * * `readCinemaImageJob` re-verifies the plan hash, the project binding, the active * planning receipt and every source file's bytes, and then this function asserts * the plan is the same one the task was compiled against. A reference that * changed between compile and work therefore fails HERE, before any spend, * rather than rendering against an input nobody approved. */ export async function buildCinemaImageSubmitPayload( root: string, projectSlug: string, task: CinemaQueueTask, ): Promise { if (task.kind !== 'image-generate') { throw new VclawError('invalid_flag_value', `Cinema image worker cannot execute ${task.kind} tasks`, { taskId: task.taskId, kind: task.kind }); } // Assert the route HERE, beside the kind: this one function feeds the worker, // the quote and the dry run, so a task compiled for a route nothing can render // is refused in all three rather than silently substituted for another // provider's transport (ADR 0001). if (!cinemaImageTransportFor(task.routeId)) { throw new VclawError('execution_blocked_by_readiness', `Cinema image task ${task.taskId} names route ${task.routeId}, which has no image transport; refusing rather than rendering it somewhere else`, { taskId: task.taskId, routeId: task.routeId, }); } const jobId = task.payload.artifactIds[0]; if (!jobId) { throw new VclawError('workspace_corrupt', `Cinema image task ${task.taskId} names no image job`, { taskId: task.taskId }); } const compiledHash = task.payload.parameters.jobHash; if (typeof compiledHash !== 'string') { throw new VclawError('workspace_corrupt', `Cinema image task ${task.taskId} carries no plan hash to bind against`, { taskId: task.taskId }); } const job = await readCinemaImageJob(root, projectSlug, jobId); if (job.contentHash !== compiledHash) { throw new VclawError('execution_blocked_by_readiness', `Image job ${jobId} has been re-planned since this task was compiled; compile a fresh task rather than rendering the old one`, { taskId: task.taskId, compiledAgainst: compiledHash, current: job.contentHash, }); } // The compile gate refuses a reference-bearing plan on a reference-blind route, // but it only ever runs at compile. A task already on disk — enqueued before // this gate existed, restored from an archive, or written by any other door — // would otherwise render here with its references silently dropped, and the dry // run would name a reference the submission does not carry. Assert it at the // chokepoint the worker, the quote and the dry run all share. const transport = cinemaImageTransportFor(task.routeId)!; if (job.sources.length > 0 && !transport.transmitsReferences) { throw new VclawError('execution_blocked_by_readiness', `Image job ${jobId} carries ${job.sources.length} reference image(s) and ${task.routeId} renders from the prompt alone; refusing rather than rendering an image bound to nothing`, { taskId: task.taskId, routeId: task.routeId, sourceCount: job.sources.length, }); } return { taskId: task.taskId, routeId: task.routeId as CinemaProviderRouteId, jobId, jobHash: job.contentHash, prompt: job.prompt, role: job.role, subjectId: job.subjectId, ...(job.characterVersionId ? { characterVersionId: job.characterVersionId } : {}), referencePaths: job.sources.map((source) => source.path), outputPath: cinemaImageOutputPath(root, projectSlug, task.taskId), }; } /** * Deterministic from the task id, so it is the same on every attempt. The * extension is fixed rather than discovered: every image route here returns PNG, * and a path that depends on the response cannot be recomputed by a worker that * is trying to find out whether a previous attempt already wrote the bytes. */ export function cinemaImageOutputPath(root: string, projectSlug: string, taskId: string): string { const safe = taskId.replace(/[^A-Za-z0-9._-]/g, '-'); return join(resolveProjectWorkspace(projectSlug, root).projectDir, 'outputs', 'cinema-images', safe, 'image.png'); }