import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { PAID_ACCOUNT_CLASSES } from './cinema-compatibility-quote.js'; import { buildCinemaImageSubmitPayload, type CinemaImageSubmitPayload } from './cinema-image-payload.js'; import { sealCinemaCapabilitySnapshot, sealCinemaGenerationQuote } from './cinema-provider-contracts.js'; import { writeCinemaCapabilitySnapshot, writeCinemaGenerationQuote, writeCinemaProviderProjectionBundle } from './cinema-provider-store.js'; import { stableCinemaJson, sha256Text } from './cinema-shot-compiler.js'; import type { CinemaProviderProjection } from './cinema-provider-projection.js'; import type { CinemaProviderAccountClass, CinemaProviderCapabilitySnapshotArtifact } from './cinema-provider-types.js'; import type { CinemaQueueMoney, CinemaQueueTask } from './cinema-queue-types.js'; import type { CinemaSha256 } from './cinema-types.js'; /** * What a route must tell us before it may be paid. Identical in spirit to the * video observation, minus the submit-adapter command hash: that binds a CORE * `ProviderRouteId` adapter command, and image routes have no entry in that * space at all (ADR 0001). */ export interface CinemaImageQuoteObservation { authoritative: boolean; routeId: string; transportId: string; accountClass: CinemaProviderAccountClass; capabilityVersion: string; observedAt: string; expiresAt: string; models: string[]; maximumReferenceImages: number; maximumConcurrentJobs: number | null; parameterSchema: Record; unitCost: CinemaQueueMoney; maximumCost: CinemaQueueMoney; balance: { observed: CinemaQueueMoney; minimum: CinemaQueueMoney; maximum: CinemaQueueMoney }; } export interface CinemaImageQuotePort { quote(input: { taskId: string; routeId: string; payload: CinemaImageSubmitPayload; providerArgumentsHash: CinemaSha256; sourceHashes: CinemaSha256[]; }): Promise; } export interface CinemaImageQuoteResult { snapshot: CinemaProviderCapabilitySnapshotArtifact; snapshotPath: string; quote: ReturnType; quotePath: string; projection: CinemaProviderProjection; projectionBundlePath: string; providerCalls: 1; providerMutations: 0; generationCalls: 0; spendAuthorized: false; executable: false; } function money(value: CinemaQueueMoney, label: string): void { if (!value.currency.trim() || !Number.isFinite(value.amount) || value.amount < 0) { throw new Error(`${label} must be a finite non-negative amount in a named currency`); } } function instant(value: string, label: string): number { const parsed = Date.parse(value); if (!Number.isFinite(parsed)) throw new Error(`${label} must be an ISO timestamp`); return parsed; } function validate(task: CinemaQueueTask, payload: CinemaImageSubmitPayload, observation: CinemaImageQuoteObservation): void { if (observation.authoritative !== true) throw new Error('Cinema image quote must be authoritative'); if (observation.routeId !== task.routeId) throw new Error(`Cinema image quote route changed from ${task.routeId} to ${observation.routeId}`); if (!observation.transportId.trim() || !observation.capabilityVersion.trim()) throw new Error('Cinema image quote lacks transport or capability version'); // The same paid-account rule the video quote enforces, from the same exported // set — so the compile-time refusal and this check cannot drift apart. if (!PAID_ACCOUNT_CLASSES.has(observation.accountClass)) throw new Error('Cinema image quote must identify a paid credit account class'); const observedAt = instant(observation.observedAt, 'Cinema image quote observedAt'); if (instant(observation.expiresAt, 'Cinema image quote expiresAt') <= observedAt) { throw new Error('Cinema image quote expiry must be after observation'); } if (!Number.isInteger(observation.maximumReferenceImages) || observation.maximumReferenceImages < 0) { throw new Error('Cinema image quote maximumReferenceImages is invalid'); } if (payload.referencePaths.length > observation.maximumReferenceImages) { throw new Error(`Cinema image route accepts ${observation.maximumReferenceImages} references, job requires ${payload.referencePaths.length}`); } [observation.unitCost, observation.maximumCost, observation.balance.observed, observation.balance.minimum, observation.balance.maximum] .forEach((value, index) => money(value, `Cinema image quote money[${index}]`)); const currency = observation.unitCost.currency; if ([observation.maximumCost, observation.balance.observed, observation.balance.minimum, observation.balance.maximum] .some((value) => value.currency !== currency)) throw new Error('Cinema image quote currencies must match'); if (observation.maximumCost.amount < observation.unitCost.amount) throw new Error('Cinema image maximum cost cannot be below unit cost'); if (observation.balance.minimum.amount < observation.maximumCost.amount) { throw new Error('Cinema image quote balance envelope cannot cover maximum cost'); } } function safeId(value: string): string { return value.replace(/[^A-Za-z0-9._-]/g, '-').replace(/^-+/, '') || 'image'; } /** * Take an exact quote for ONE `image-generate` task and seal it. * * A sibling of `quoteCinemaCompatibilityTask` rather than a widening of it. That * function's port and binding types are declared as * `ReturnType` — a * `VideoExecutionPayload` — so reusing it would push an image payload through the * shipped external quote-adapter contract and every adapter binary written * against it. It also hashes a core-route submit-adapter command, which does not * exist for an image route. * * The quote it seals is ordinary: `bindCinemaQuotedAuthorization` matches jobs on * `taskKind` + `payloadHash` and is already kind-agnostic, so the EXISTING * `cinema-authorize` binds this with no change. */ export async function quoteCinemaImageTask(options: { root: string; projectSlug: string; task: CinemaQueueTask; port: CinemaImageQuotePort; }): Promise { const { task } = options; if (task.kind !== 'image-generate') throw new Error(`Cinema image quote cannot price a ${task.kind} task`); if (task.authorizationRequirement !== 'exact-quote' || task.spendAuthorizationId) { throw new Error(`Cinema image task ${task.taskId} is not awaiting an exact quote`); } if (task.status !== 'awaiting-quote') throw new Error(`Cinema image task ${task.taskId} is ${task.status}, not awaiting-quote`); // Built from the immutable plan, so the quote prices exactly what will be sent. const payload = await buildCinemaImageSubmitPayload(options.root, options.projectSlug, task); const providerArguments = structuredClone(payload) as unknown as Record; const providerArgumentsHash = sha256Text(stableCinemaJson(providerArguments)); const sourceHashes = await Promise.all(payload.referencePaths.map(async (path) => { const bytes = await readFile(path); return `sha256:${createHash('sha256').update(bytes).digest('hex')}` as CinemaSha256; })); const observation = await options.port.quote({ taskId: task.taskId, routeId: task.routeId, payload, providerArgumentsHash, sourceHashes, }); validate(task, payload, observation); const suffix = `${safeId(task.routeId)}-${safeId(task.taskId)}-${Date.parse(observation.observedAt)}`; const snapshot = sealCinemaCapabilitySnapshot({ schemaVersion: 1, snapshotId: `capability-${suffix}`, projectSlug: options.projectSlug, routeId: task.routeId, transportId: observation.transportId, accountClass: observation.accountClass, discoveredAt: observation.observedAt, expiresAt: observation.expiresAt, discovery: { kind: 'transport-probe', version: observation.capabilityVersion, authenticated: true, sourceEvidenceIds: [providerArgumentsHash] }, capabilities: { mediaKinds: ['image'], operations: ['text-to-image'], models: [...observation.models], workflows: [], maximumReferenceImages: observation.maximumReferenceImages, maximumConcurrentJobs: observation.maximumConcurrentJobs, parameterSchema: structuredClone(observation.parameterSchema), }, }); const projection: CinemaProviderProjection = { taskId: task.taskId, taskKind: task.kind, routeId: task.routeId, transportId: observation.transportId, accountClass: observation.accountClass, capabilitySnapshotId: snapshot.snapshotId, capabilitySnapshotHash: snapshot.contentHash, payloadHash: task.payloadHash, sourceHashes, providerArguments, providerArgumentsHash, fallbackPolicy: 'forbidden', }; const quote = sealCinemaGenerationQuote({ schemaVersion: 1, quoteId: `quote-${suffix}`, projectSlug: options.projectSlug, routeId: task.routeId, accountClass: observation.accountClass, capabilitySnapshotId: snapshot.snapshotId, capabilitySnapshotHash: snapshot.contentHash, createdAt: observation.observedAt, expiresAt: observation.expiresAt, jobs: [{ jobId: task.taskId, taskKind: task.kind, payloadHash: task.payloadHash, sourceHashes, providerArgumentsHash, quantity: 1, unitCost: structuredClone(observation.unitCost), maximumCost: structuredClone(observation.maximumCost), }], totalCost: structuredClone(observation.unitCost), maximumBalanceDebit: structuredClone(observation.maximumCost), balanceEnvelope: { observedBefore: structuredClone(observation.balance.observed), minimumBefore: structuredClone(observation.balance.minimum), maximumBefore: structuredClone(observation.balance.maximum), }, }); const snapshotPath = await writeCinemaCapabilitySnapshot(options.root, snapshot); const projectionBundle = await writeCinemaProviderProjectionBundle(options.root, { quote, snapshot, projections: [projection] }); const quotePath = await writeCinemaGenerationQuote(options.root, quote, snapshot); return { snapshot, snapshotPath, quote, quotePath, projection, projectionBundlePath: projectionBundle.path, providerCalls: 1, providerMutations: 0, generationCalls: 0, spendAuthorized: false, executable: false, }; } /** * An image quote taken from a command shim, mirroring the video quote adapter: * JSON in on stdin, one observation out on stdout. The same shape every other * `..._ADAPTER` in this repo speaks, so a route's pricing lives outside the CLI * and the suite can drive it with a fake binary. */ export function createCinemaImageQuoteCommandPort(executable: string, env: NodeJS.ProcessEnv = process.env): CinemaImageQuotePort { if (!executable.trim()) throw new Error('Cinema image quote adapter executable is required'); return { async quote(input) { const result = await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { const child = spawn(executable, [], { env, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += String(chunk); if (stdout.length > 1_000_000) child.kill(); }); child.stderr.on('data', (chunk) => { stderr += String(chunk); if (stderr.length > 1_000_000) child.kill(); }); child.on('error', reject); child.on('close', (code) => resolve({ code, stdout, stderr })); child.stdin.end(JSON.stringify({ action: 'quote', mediaKind: 'image', ...input })); }); if (result.code !== 0) throw new Error(`Cinema image quote adapter failed: ${result.stderr.trim() || `exit ${result.code}`}`); try { return JSON.parse(result.stdout) as CinemaImageQuoteObservation; } catch (error) { throw new Error(`Cinema image quote adapter returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`); } }, }; }