import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { assertCinemaProjectionQuoteParity, type CinemaProviderProjection, } from './cinema-provider-projection.js'; import { sealCinemaCapabilitySnapshot, sealCinemaGenerationQuote, validateCinemaGenerationAuthorization, } from './cinema-provider-contracts.js'; import type { CinemaGenerationQuoteArtifact, CinemaProviderCapabilitySnapshotArtifact, CinemaProviderContractBundle, } from './cinema-provider-types.js'; import type { CinemaReferenceMediaKind } from './cinema-types.js'; /** * The official Higgsfield CLI as the Cinema `higgsfield-cli` route. * * Every verb, flag and response shape here was verified against the installed * binary, `higgsfield 1.1.23 (dd8d00a8) built 2026-08-08`, on 2026-09-05 — the * previous adapter spawned five verbs that binary does not have (#417) and only * its injectable-runner tests kept it green. HIGGSFIELD_CLI_CONTRACT below is * the pinned surface; the adapter test asserts the argv it produces against it, * so the next upstream change fails loudly instead of reporting the route * unavailable at step one. * * What the CLI is and is not: * - `version` prints TEXT (`higgsfield () built `), never JSON. * - There is no `auth status`. `account status --json` is the auth probe: it * answers `{credits, email, subscription_plan_type}` or exits non-zero. * - Models and workflows are `model list --video --json` / `workflow list --json`, * arrays of `{display_name, job_type, type}`. Per-model parameter schemas are * `model get --json` → `{params:[{name,type,default,required,enum?}], * rules:[{cel,message}]}`. There is no global schema, operation list or * concurrency figure — see the discovery notes. * - `generate cost|create -- ... --json` takes the * model's params as flags, verbatim names (underscores). `cost` validates * unknown params, enum values, types AND the model's CEL rules (exit 4, * diagnostic on stderr) without creating a job — it is the preflight. * Reference media flags take a local path and the CLI uploads it, INCLUDING * on `cost` (verified: a new `upload list` row appears). A quote therefore * uploads each reference once; the handler reports that as providerMutations. * - `generate get --json` → `{id, status, job_type, result_url, ...}`; * missing → exit 3, stderr `Error: Job not found` / `Error: invalid id: `. * Observed statuses: waiting, completed, nsfw. No cost field on a job. * - `generate create --json` prints a bare ARRAY OF JOB-ID STRINGS — * `["ea301e2d-…"]` — not the job object (verified 2026-09-05 01:45 with a * 5 s / 480p t2v job: 12.5 credits, status `in_progress` at once, while the * web/cloak render slot was held by another job — so CLI jobs run on the * API pool, not the app's single unlimited slot). A one-element array is the * job id; an object with `id`/`job_id`/`jobId` is tolerated; anything else is * an unknown submission, never retried. * - Credits: `account transactions` rows are `{action:'spend', credits}` where a * DEBIT IS NEGATIVE (`-12.5`); the app's unlimited-plan renders appear as * `credits: 0`. CLI jobs are billed (the `cost` estimate is the real price). */ export const HIGGSFIELD_CLI_CONTRACT = { verifiedAgainst: 'higgsfield 1.1.23 (dd8d00a83d46aa443eaa605a75c246e1226010c1) built 2026-08-08', version: ['version'], account: ['account', 'status', '--json'], models: ['model', 'list', '--video', '--json'], modelSchema: (jobType: string): string[] => ['model', 'get', jobType, '--json'], workflows: ['workflow', 'list', '--json'], transactions: ['account', 'transactions', '--size', '50', '--json'], job: (jobId: string): string[] => ['generate', 'get', jobId, '--json'], generation: (operation: 'cost' | 'create', jobType: string, params: string[]): string[] => ['generate', operation, jobType, ...params, '--json'], } as const; /** * The neutral Cinema operations this adapter knows how to express as CLI * params. Adapter-side, not provider-discovered: the CLI has no operation * list. `image-to-video` = `mode omni_reference` with ≥1 `--image-references`; * `text-to-video` = `mode t2v` with none. */ export const HIGGSFIELD_CLI_OPERATIONS = ['image-to-video', 'text-to-video'] as const; /** Credits are the CLI's only unit: `account status`, `generate cost` and the transaction log all speak credits. */ export const HIGGSFIELD_CLI_CURRENCY = 'credits'; export interface CinemaCliCommandResult { exitCode: number; stdout: string; stderr: string; } export interface CinemaHiggsfieldCliRunner { run(args: string[]): CinemaCliCommandResult; } export interface CinemaHiggsfieldDiscoveryResult { available: boolean; authenticated: boolean; blockers: string[]; snapshot: CinemaProviderCapabilitySnapshotArtifact | null; account: { accountId: string; balance: number; currency: string } | null; } export interface CinemaHiggsfieldAccountStatus { authenticated: true; accountId: string; balance: number; currency: string; } export interface CinemaHiggsfieldJobObservation { state: 'not-found' | 'accepted' | 'processing' | 'completed' | 'failed' | 'unknown'; authoritative: boolean; providerJobId: string | null; providerStatus: string; envelope: Record; resultUrl: string | null; actualCost: { currency: string; amount: number } | null; failure?: { code: string; message: string; retryable: boolean }; } /** * One reference the projection names by content hash, resolved to the file the * CLI will upload. `mediaKind` picks the flag (`--image-references`, * `--video-references`, `--audio-references`); absent means image. */ export interface CinemaHiggsfieldReferenceFile { contentHash: string; path: string; mediaKind?: CinemaReferenceMediaKind; } /** `model get` as the snapshot stores it, plus the reference caps read from its rules. */ export interface CinemaHiggsfieldModelSchema { jobType: string; displayName: string; params: Array<{ name: string; type: string; required: boolean; default: unknown; enum?: unknown[] }>; rules: Array<{ cel: string; message: string }>; maximumImageReferences: number; /** 0 when the model declares no `video_references` param (older snapshots may lack the field: same meaning). */ maximumVideoReferences?: number; /** 0 when the model declares no `audio_references` param. */ maximumAudioReferences?: number; /** The `at most N reference media items` rule; null when the model states none. */ maximumReferenceMedia?: number | null; } /** The per-kind reference caps a model schema states. */ export interface CinemaHiggsfieldMediaCaps { images: number; videos: number; audio: number; /** null = the model states no total-media rule */ total: number | null; } function parseJson(result: CinemaCliCommandResult, label: string): unknown { if (result.exitCode !== 0) throw new Error(`${label} failed with exit ${result.exitCode}: ${result.stderr.trim() || result.stdout.trim() || 'no diagnostic'}`); try { return JSON.parse(result.stdout); } catch { throw new Error(`${label} returned malformed JSON`); } } function parseObject(result: CinemaCliCommandResult, label: string): Record { const value = parseJson(result, label); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must return a JSON object`); return value as Record; } function parseArray(result: CinemaCliCommandResult, label: string): Record[] { const value = parseJson(result, label); if (!Array.isArray(value) || value.some((entry) => !entry || typeof entry !== 'object' || Array.isArray(entry))) { throw new Error(`${label} must return a JSON array of objects`); } return value as Record[]; } function finite(value: unknown, path: string): number { if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new Error(`${path} must be a finite non-negative number`); return value; } function text(value: unknown, path: string): string { if (typeof value !== 'string' || !value.trim()) throw new Error(`${path} must be a non-empty string`); return value; } /** `higgsfield 1.1.23 (dd8d…) built 2026-08-08T19:50:15Z` → `1.1.23`. */ export function parseHiggsfieldCliVersion(stdout: string): string | null { const match = /^higgsfield\s+(\S+)/m.exec(stdout); return match ? match[1] : null; } /** * The account identity artifacts carry. The CLI reports the login e-mail; a * hash of it is stable per account and keeps the address out of every * snapshot, quote and evidence id. */ export function higgsfieldAccountId(email: string): string { return `acct-${createHash('sha256').update(email.trim().toLowerCase()).digest('hex').slice(0, 12)}`; } function readAccount(runner: CinemaHiggsfieldCliRunner): { accountId: string; balance: number; plan: string } { const status = parseObject(runner.run([...HIGGSFIELD_CLI_CONTRACT.account]), 'Higgsfield CLI account status'); return { accountId: higgsfieldAccountId(text(status.email, 'account status email')), balance: finite(status.credits, 'account status credits'), plan: typeof status.subscription_plan_type === 'string' ? status.subscription_plan_type : 'unknown', }; } function jobTypes(entries: Record[], label: string): string[] { const ids = entries.map((entry, index) => text(entry.job_type, `${label}[${index}].job_type`)); return [...new Set(ids)].sort(); } /** * The reference caps a model's CEL rules state, per kind. Higgsfield phrases the * image cap as `at most N images are allowed…`; when a model has an * `image_references` param but no such rule, the total-media rule (`at most N * reference media items`) bounds it. Video and audio have no rule of their own * (verified on seedance_2_5, 1.1.23): a model that declares `video_references` / * `audio_references` is bounded by the total-media rule, one that does not * declare the param takes none. A kind with a param but no total rule is 0 — * an unknown cap is not a licence. */ export function higgsfieldModelMediaCaps(schema: { params: Array<{ name: string }>; rules: Array<{ message: string }> }): CinemaHiggsfieldMediaCaps { const has = (name: string): boolean => schema.params.some((param) => param.name === name); const media = schema.rules.map((rule) => /at most (\d+) reference media/i.exec(rule.message)).find(Boolean); const total = media ? Number(media[1]) : null; const images = schema.rules.map((rule) => /at most (\d+) images/i.exec(rule.message)).find(Boolean); return { images: images ? Number(images[1]) : (has('image_references') ? (total ?? 0) : 0), videos: has('video_references') ? (total ?? 0) : 0, audio: has('audio_references') ? (total ?? 0) : 0, total, }; } /** The image cap alone — see `higgsfieldModelMediaCaps`. */ export function higgsfieldModelImageCap(schema: { params: Array<{ name: string }>; rules: Array<{ message: string }> }): number { return higgsfieldModelMediaCaps(schema).images; } function readModelSchema(runner: CinemaHiggsfieldCliRunner, jobType: string): CinemaHiggsfieldModelSchema { const label = `Higgsfield CLI model schema ${jobType}`; const raw = parseObject(runner.run(HIGGSFIELD_CLI_CONTRACT.modelSchema(jobType)), label); if (!Array.isArray(raw.params)) throw new Error(`${label} must list params`); const params = raw.params.map((param, index) => { if (!param || typeof param !== 'object') throw new Error(`${label} params[${index}] must be an object`); const record = param as Record; return { name: text(record.name, `${label} params[${index}].name`), type: typeof record.type === 'string' ? record.type : 'unknown', required: record.required === true, default: record.default ?? null, ...(Array.isArray(record.enum) ? { enum: record.enum } : {}), }; }); const rules = Array.isArray(raw.rules) ? raw.rules.map((rule) => { const record = (rule && typeof rule === 'object' ? rule : {}) as Record; return { cel: typeof record.cel === 'string' ? record.cel : '', message: typeof record.message === 'string' ? record.message : '' }; }) : []; const caps = higgsfieldModelMediaCaps({ params, rules }); return { jobType, displayName: typeof raw.display_name === 'string' ? raw.display_name : jobType, params, rules, maximumImageReferences: caps.images, maximumVideoReferences: caps.videos, maximumAudioReferences: caps.audio, maximumReferenceMedia: caps.total, }; } /** * Discovery, in the order the real CLI allows it: version (text) → account * status (the auth probe) → video models → workflows → one `model get` per * video model. The snapshot's `parameterSchema` is keyed by job_type and is * what `higgsfieldCliGenerationArgs` builds flags from, so a schema change * upstream changes the snapshot hash and invalidates every projection on it — * which is the point of content-addressing the snapshot. * * Two capability fields the CLI cannot answer are set honestly rather than * guessed: `maximumConcurrentJobs` is null (the account's slot count is not * exposed), and `operations` is this adapter's own translation contract. * `maximumReferenceImages` is the widest per-model cap; the per-model cap is * enforced again when the flags are built. */ export function discoverCinemaHiggsfieldCli(input: { runner: CinemaHiggsfieldCliRunner; projectSlug: string; discoveredAt: string; expiresAt: string; }): CinemaHiggsfieldDiscoveryResult { const versionResult = input.runner.run([...HIGGSFIELD_CLI_CONTRACT.version]); if (versionResult.exitCode !== 0) { return { available: false, authenticated: false, blockers: [`Higgsfield CLI is not runnable (exit ${versionResult.exitCode}): ${versionResult.stderr.trim() || 'no diagnostic'}`], snapshot: null, account: null }; } const version = parseHiggsfieldCliVersion(versionResult.stdout); if (!version) { return { available: false, authenticated: false, blockers: [`Higgsfield CLI version output was not recognised: ${versionResult.stdout.trim().slice(0, 120) || '(empty)'}`], snapshot: null, account: null }; } let account: { accountId: string; balance: number; plan: string }; try { account = readAccount(input.runner); } catch (error) { return { available: true, authenticated: false, blockers: [`Higgsfield CLI is installed but not authenticated: ${error instanceof Error ? error.message : String(error)}`], snapshot: null, account: null }; } const models = jobTypes(parseArray(input.runner.run([...HIGGSFIELD_CLI_CONTRACT.models]), 'Higgsfield CLI model discovery'), 'model list'); if (models.length === 0) throw new Error('Higgsfield CLI model discovery listed no video models'); const workflows = jobTypes(parseArray(input.runner.run([...HIGGSFIELD_CLI_CONTRACT.workflows]), 'Higgsfield CLI workflow discovery'), 'workflow list'); const parameterSchema: Record = {}; for (const jobType of models) parameterSchema[jobType] = readModelSchema(input.runner, jobType); const snapshot = sealCinemaCapabilitySnapshot({ schemaVersion: 1, snapshotId: `higgsfield-cli-${version.replace(/[^A-Za-z0-9._-]/g, '-')}-${Date.parse(input.discoveredAt)}`, projectSlug: input.projectSlug, routeId: 'higgsfield-cli', transportId: 'official-higgsfield-cli', accountClass: 'provider-credits', discoveredAt: input.discoveredAt, expiresAt: input.expiresAt, discovery: { kind: 'official-cli', version, authenticated: true, sourceEvidenceIds: [`higgsfield-account:${account.accountId}`, `higgsfield-plan:${account.plan}`], }, capabilities: { mediaKinds: ['video'], operations: [...HIGGSFIELD_CLI_OPERATIONS], models, workflows, maximumReferenceImages: Math.max(0, ...models.map((jobType) => parameterSchema[jobType].maximumImageReferences)), maximumReferenceVideos: Math.max(0, ...models.map((jobType) => parameterSchema[jobType].maximumVideoReferences ?? 0)), maximumReferenceAudio: Math.max(0, ...models.map((jobType) => parameterSchema[jobType].maximumAudioReferences ?? 0)), maximumConcurrentJobs: null, parameterSchema, }, }); return { available: true, authenticated: true, blockers: [], snapshot, account: { accountId: account.accountId, balance: account.balance, currency: HIGGSFIELD_CLI_CURRENCY }, }; } export function readCinemaHiggsfieldCliAccount(runner: CinemaHiggsfieldCliRunner): CinemaHiggsfieldAccountStatus { let account: { accountId: string; balance: number }; try { account = readAccount(runner); } catch (error) { throw new Error(`Higgsfield CLI is not authenticated: ${error instanceof Error ? error.message : String(error)}`); } return { authenticated: true, accountId: account.accountId, balance: account.balance, currency: HIGGSFIELD_CLI_CURRENCY }; } function redactUrls(value: unknown): unknown { if (Array.isArray(value)) return value.map(redactUrls); if (!value || typeof value !== 'object') return typeof value === 'string' && /^https?:\/\//i.test(value) ? '[redacted-url]' : value; return Object.fromEntries(Object.entries(value as Record).map(([key, item]) => [key, /url/i.test(key) && typeof item === 'string' ? '[redacted-url]' : redactUrls(item)])); } function firstResultUrl(value: Record): string | null { const direct = [value.result_url, value.resultUrl, value.outputUrl, value.output_url, value.url]; for (const item of direct) if (typeof item === 'string' && /^(https?:|data:)/i.test(item)) return item; for (const key of ['output', 'result']) { const nested = value[key]; if (nested && typeof nested === 'object' && !Array.isArray(nested)) { const found = firstResultUrl(nested as Record); if (found) return found; } } for (const key of ['outputs', 'results', 'media', 'videos']) { const items = value[key]; if (Array.isArray(items)) { for (const item of items) if (item && typeof item === 'object' && !Array.isArray(item)) { const found = firstResultUrl(item as Record); if (found) return found; } } } return null; } /** * A job carries no cost. The account's transaction log does — as * `{action:'spend', created_at, credits, display_name}` rows with no job id — * and a spend row is stamped within milliseconds of its job (measured 52 ms). * So the actual cost is the ONE spend row for the job's model within the * window around its creation (measured 102 ms on a CLI job). A debit is a * NEGATIVE `credits` value (`-12.5`); the amount recorded is its magnitude, and * a `0` row (an unlimited-plan render) is a real cost of 0. Two candidates, or * none, is not evidence: the observation carries `actualCost: null` and the * sync handler blocks with `provider-actual-cost-unavailable` rather than * guessing. */ export const HIGGSFIELD_COST_MATCH_WINDOW_MS = 10_000; export function matchHiggsfieldSpend( job: { created_at: string; display_name: string }, transactions: Array>, ): { actualCost: { currency: string; amount: number } | null; candidates: number } { const createdAt = Date.parse(job.created_at); if (!Number.isFinite(createdAt)) return { actualCost: null, candidates: 0 }; const candidates = transactions.filter((row) => row.action === 'spend' && row.display_name === job.display_name && typeof row.created_at === 'string' && Math.abs(Date.parse(row.created_at) - createdAt) <= HIGGSFIELD_COST_MATCH_WINDOW_MS && typeof row.credits === 'number' && Number.isFinite(row.credits)); if (candidates.length !== 1) return { actualCost: null, candidates: candidates.length }; return { actualCost: { currency: HIGGSFIELD_CLI_CURRENCY, amount: Math.abs(candidates[0].credits as number) }, candidates: 1 }; } function normalizeJobState(rawStatus: string): CinemaHiggsfieldJobObservation['state'] { const normalized = rawStatus.toLowerCase().replace(/[\s-]+/g, '_'); if (['completed', 'complete', 'succeeded', 'success', 'done'].includes(normalized)) return 'completed'; if (['failed', 'error', 'cancelled', 'canceled', 'nsfw', 'moderated', 'rejected'].includes(normalized)) return 'failed'; if (['processing', 'running', 'in_progress', 'generating'].includes(normalized)) return 'processing'; if (['waiting', 'accepted', 'submitted', 'queued', 'pending', 'created'].includes(normalized)) return 'accepted'; return 'unknown'; } export function inspectCinemaHiggsfieldCliJob(runner: CinemaHiggsfieldCliRunner, jobId: string): CinemaHiggsfieldJobObservation { if (!jobId.trim()) throw new Error('Higgsfield CLI job inspection requires a provider job id'); const response = runner.run(HIGGSFIELD_CLI_CONTRACT.job(jobId)); if (response.exitCode !== 0) { const rawDiagnostic = response.stderr.trim() || response.stdout.trim() || `exit ${response.exitCode}`; const diagnostic = rawDiagnostic.replace(/https?:\/\/\S+/gi, '[redacted-url]'); // `Error: Job not found` (exit 3) and `Error: invalid id: ` (exit 3) are the // CLI's two authoritative "no such job" answers; anything else is a lookup failure. const notFound = /(?:not[ -]?found|404|unknown job|invalid id)/i.test(rawDiagnostic); return { state: notFound ? 'not-found' : 'unknown', authoritative: notFound, providerJobId: notFound ? null : jobId, providerStatus: notFound ? 'not-found' : 'lookup-failed', envelope: { exitCode: response.exitCode, diagnostic }, resultUrl: null, actualCost: null, }; } let value: Record; try { value = JSON.parse(response.stdout) as Record; } catch { return { state: 'unknown', authoritative: false, providerJobId: jobId, providerStatus: 'malformed-json', envelope: { diagnostic: 'Higgsfield CLI get returned malformed JSON' }, resultUrl: null, actualCost: null }; } if (!value || typeof value !== 'object' || Array.isArray(value)) { return { state: 'unknown', authoritative: false, providerJobId: jobId, providerStatus: 'invalid-envelope', envelope: { diagnostic: 'Higgsfield CLI get did not return an object' }, resultUrl: null, actualCost: null }; } const rawStatus = typeof value.status === 'string' ? value.status : typeof value.state === 'string' ? value.state : 'unknown'; const normalized = rawStatus.toLowerCase().replace(/[\s-]+/g, '_'); const state = normalizeJobState(rawStatus); const message = typeof value.error === 'string' ? value.error : typeof value.message === 'string' ? value.message : rawStatus; let actualCost: CinemaHiggsfieldJobObservation['actualCost'] = null; let costEvidence: Record = {}; if (state === 'completed' && typeof value.created_at === 'string' && typeof value.display_name === 'string') { const transactions = runner.run([...HIGGSFIELD_CLI_CONTRACT.transactions]); try { const log = parseObject(transactions, 'Higgsfield CLI account transactions'); const rows = Array.isArray(log.items) ? log.items.filter((row): row is Record => !!row && typeof row === 'object') : []; const matched = matchHiggsfieldSpend({ created_at: value.created_at, display_name: value.display_name }, rows); actualCost = matched.actualCost; costEvidence = { costSource: 'account-transactions', costCandidates: matched.candidates, costMatchWindowMs: HIGGSFIELD_COST_MATCH_WINDOW_MS }; } catch (error) { costEvidence = { costSource: 'account-transactions', costLookupFailed: error instanceof Error ? error.message : String(error) }; } } return { state, authoritative: state !== 'unknown', providerJobId: jobId, providerStatus: rawStatus, envelope: { ...(redactUrls(value) as Record), ...costEvidence }, resultUrl: state === 'completed' ? firstResultUrl(value) : null, actualCost, ...(state === 'failed' ? { failure: normalized === 'nsfw' || normalized === 'moderated' || normalized === 'rejected' // Higgsfield moderation is a per-draw verdict, not a property of the prompt; // the same request clears on a later draw. Flagged retryable for the operator // to decide — the durable queue never resubmits on its own. ? { code: 'provider-moderated', message: `Higgsfield moderation refused this draw (${rawStatus})`, retryable: true } : { code: normalized === 'cancelled' || normalized === 'canceled' ? 'provider-cancelled' : 'provider-failed', message, retryable: false }, } : {}), }; } function sha256File(path: string): string { return `sha256:${createHash('sha256').update(readFileSync(path)).digest('hex')}`; } /** * The files the CLI will upload must be the bytes the immutable reference pack * hashed — in the projection's order. Called before cost and again before * create, because the pack is immutable but the disk is not. */ export function verifyHiggsfieldReferenceFiles(projection: CinemaProviderProjection, references: CinemaHiggsfieldReferenceFile[]): void { const expected = projection.sourceHashes; if (references.length !== expected.length) { throw new Error(`Projection ${projection.taskId} names ${expected.length} reference(s) but ${references.length} file(s) were resolved`); } references.forEach((reference, index) => { if (reference.contentHash !== expected[index]) { throw new Error(`Projection ${projection.taskId} reference ${index + 1} resolves to ${reference.contentHash}, expected ${expected[index]}`); } let actual: string; try { actual = sha256File(reference.path); } catch (error) { throw new Error(`Projection ${projection.taskId} reference ${index + 1} is not readable at ${reference.path}: ${error instanceof Error ? error.message : String(error)}`); } if (actual !== reference.contentHash) { throw new Error(`Projection ${projection.taskId} reference ${index + 1} at ${reference.path} has drifted from the immutable pack (${actual} on disk, ${reference.contentHash} sealed)`); } }); } function modelSchemaFor(snapshot: CinemaProviderCapabilitySnapshotArtifact, jobType: string): CinemaHiggsfieldModelSchema { const schema = (snapshot.capabilities.parameterSchema as Record)[jobType]; if (!schema || typeof schema !== 'object' || !Array.isArray((schema as CinemaHiggsfieldModelSchema).params)) { throw new Error(`Capability snapshot ${snapshot.snapshotId} carries no parameter schema for model ${jobType}`); } return schema as CinemaHiggsfieldModelSchema; } function enumOf(schema: CinemaHiggsfieldModelSchema, name: string): unknown[] | null { const param = schema.params.find((entry) => entry.name === name); return param?.enum ?? null; } function hasParam(schema: CinemaHiggsfieldModelSchema, name: string): boolean { return schema.params.some((entry) => entry.name === name); } /** * The CLI's integer `duration` for a Cinema shot's fractional seconds: never * shorter than the shot (the clip must cover it; assemble trims), so round UP, * and when the model enumerates durations take the smallest allowed one that * still covers. A shot no allowed duration covers is refused, not clamped. */ export function higgsfieldDurationFor(schema: CinemaHiggsfieldModelSchema, durationSeconds: number): number { if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) throw new Error(`durationSeconds must be a positive number, got ${durationSeconds}`); const param = schema.params.find((entry) => entry.name === 'duration'); if (!param) throw new Error(`model ${schema.jobType} has no duration param`); const needed = param.type.includes('integer') ? Math.ceil(durationSeconds - 1e-9) : durationSeconds; if (!param.enum) return needed; const allowed = param.enum.filter((entry): entry is number => typeof entry === 'number').sort((left, right) => left - right); const chosen = allowed.find((entry) => entry >= needed); if (chosen === undefined) throw new Error(`model ${schema.jobType} allows durations ${allowed.join('/')}s; a ${durationSeconds}s shot is not covered`); return chosen; } /** * The exact `--param value` list for one projection, built from the model's * own schema so only params the model declares are emitted, in a fixed order. * `cost` and `create` receive byte-identical params: what was priced is what is * submitted. Deterministic and pure — file verification is the caller's step. */ export function higgsfieldCliGenerationArgs( operation: 'cost' | 'create', projection: CinemaProviderProjection, snapshot: CinemaProviderCapabilitySnapshotArtifact, references: CinemaHiggsfieldReferenceFile[], ): string[] { if (projection.routeId !== 'higgsfield-cli' || projection.transportId !== 'official-higgsfield-cli') { throw new Error('Official Higgsfield CLI adapter accepts the explicit higgsfield-cli route only'); } const args = projection.providerArguments as Record; const jobType = text(args.model, 'projection providerArguments.model'); const schema = modelSchemaFor(snapshot, jobType); const parameters = (args.parameters && typeof args.parameters === 'object' ? args.parameters : {}) as Record; const neutralOperation = text(args.operation, 'projection providerArguments.operation'); if (!(HIGGSFIELD_CLI_OPERATIONS as readonly string[]).includes(neutralOperation)) { throw new Error(`Higgsfield CLI adapter cannot express operation ${neutralOperation}; it knows ${HIGGSFIELD_CLI_OPERATIONS.join(', ')}`); } if (references.length !== projection.sourceHashes.length) { throw new Error(`Projection ${projection.taskId} names ${projection.sourceHashes.length} reference(s) but ${references.length} file(s) were given`); } // The projection sealed each reference's kind beside its hash; the files the // caller resolved must agree, or what is uploaded is not what was quoted. const sealed = Array.isArray(args.references) ? args.references as Array<{ mediaKind?: string }> : []; references.forEach((reference, index) => { const fileKind = reference.mediaKind ?? 'image'; const sealedKind = sealed[index]?.mediaKind ?? 'image'; if (fileKind !== sealedKind) throw new Error(`Projection ${projection.taskId} reference ${index + 1} was sealed as ${sealedKind} but resolved as ${fileKind}`); }); const images = references.filter((reference) => (reference.mediaKind ?? 'image') === 'image'); const videos = references.filter((reference) => reference.mediaKind === 'video'); const audio = references.filter((reference) => reference.mediaKind === 'audio'); const params: string[] = ['--prompt', text(parameters.prompt, 'projection parameters.prompt')]; if (hasParam(schema, 'duration')) { params.push('--duration', String(higgsfieldDurationFor(schema, finite(parameters.durationSeconds, 'projection parameters.durationSeconds')))); } const enumerated = (name: string, value: unknown, label: string): void => { if (!hasParam(schema, name) || value === null || value === undefined) return; const allowed = enumOf(schema, name); if (allowed && !allowed.includes(value)) throw new Error(`model ${jobType} does not accept ${label} ${String(value)} (allowed: ${allowed.join(', ')})`); params.push(`--${name}`, String(value)); }; enumerated('resolution', args.targetResolution, 'resolution'); enumerated('aspect_ratio', args.aspectRatio, 'aspect ratio'); if (neutralOperation === 'image-to-video') { if (images.length === 0) throw new Error(`image-to-video on ${jobType} needs at least one image reference; projection ${projection.taskId} has none`); if (images.length > schema.maximumImageReferences) { throw new Error(`model ${jobType} accepts at most ${schema.maximumImageReferences} image reference(s); projection ${projection.taskId} carries ${images.length}`); } // Video and audio ride only on models that declare the param, each within // its cap, and every kind together within the model's total-media rule. const videoCap = hasParam(schema, 'video_references') ? (schema.maximumVideoReferences ?? 0) : 0; const audioCap = hasParam(schema, 'audio_references') ? (schema.maximumAudioReferences ?? 0) : 0; if (videos.length > videoCap) { throw new Error(videoCap === 0 ? `model ${jobType} takes no video references; projection ${projection.taskId} carries ${videos.length}` : `model ${jobType} accepts at most ${videoCap} video reference(s); projection ${projection.taskId} carries ${videos.length}`); } if (audio.length > audioCap) { throw new Error(audioCap === 0 ? `model ${jobType} takes no audio references; projection ${projection.taskId} carries ${audio.length}` : `model ${jobType} accepts at most ${audioCap} audio reference(s); projection ${projection.taskId} carries ${audio.length}`); } const total = schema.maximumReferenceMedia ?? null; if (total !== null && references.length > total) { throw new Error(`model ${jobType} accepts at most ${total} reference media item(s) in total; projection ${projection.taskId} carries ${references.length}`); } enumerated('mode', 'omni_reference', 'mode'); } else { if (references.length !== 0) throw new Error(`text-to-video takes no references; projection ${projection.taskId} carries ${references.length}`); enumerated('mode', 't2v', 'mode'); } if (hasParam(schema, 'generate_audio') && (parameters.audio === 'required' || parameters.audio === 'forbidden')) { params.push('--generate_audio', parameters.audio === 'required' ? 'true' : 'false'); } // Fixed order: images, then videos, then audio — pack order within each kind. // An image-only projection emits exactly what it did before video/audio existed. for (const reference of images) params.push('--image-references', reference.path); for (const reference of videos) params.push('--video-references', reference.path); for (const reference of audio) params.push('--audio-references', reference.path); return HIGGSFIELD_CLI_CONTRACT.generation(operation, jobType, params); } function priceOne(runner: CinemaHiggsfieldCliRunner, projection: CinemaProviderProjection, snapshot: CinemaProviderCapabilitySnapshotArtifact, references: CinemaHiggsfieldReferenceFile[], label: string): number { const response = parseObject(runner.run(higgsfieldCliGenerationArgs('cost', projection, snapshot, references)), label); return finite(response.credits, `${label} credits`); } export interface CinemaHiggsfieldQuoteInput { runner: CinemaHiggsfieldCliRunner; projections: CinemaProviderProjection[]; /** Resolved reference files per projection, keyed by taskId, in the projection's reference order. */ references: Record; snapshot: CinemaProviderCapabilitySnapshotArtifact; projectSlug: string; quoteId: string; createdAt: string; expiresAt: string; balanceEnvelopeTolerance: number; } /** * One exact quote for a batch: the balance is read before and after the cost * calls and must not have moved (a quote is a statement about THIS balance); * every reference file is verified against the pack before its cost call, * because that call uploads the file. */ export function quoteCinemaHiggsfieldCliBatch(input: CinemaHiggsfieldQuoteInput): CinemaGenerationQuoteArtifact { if (input.projections.length === 0) throw new Error('Higgsfield CLI batch quote requires at least one projection'); if (!Number.isFinite(input.balanceEnvelopeTolerance) || input.balanceEnvelopeTolerance < 0) { throw new Error('Higgsfield CLI balance envelope tolerance must be finite and non-negative'); } for (const projection of input.projections) { if (projection.capabilitySnapshotHash !== input.snapshot.contentHash || projection.capabilitySnapshotId !== input.snapshot.snapshotId) { throw new Error(`Projection ${projection.taskId} capability snapshot changed before quote`); } verifyHiggsfieldReferenceFiles(projection, input.references[projection.taskId] ?? []); } const balance = readCinemaHiggsfieldCliAccount(input.runner).balance; const priced = input.projections.map((projection) => ({ projection, amount: priceOne(input.runner, projection, input.snapshot, input.references[projection.taskId] ?? [], `Higgsfield CLI generation quote ${projection.taskId}`), })); const balanceAfter = readCinemaHiggsfieldCliAccount(input.runner).balance; if (balanceAfter !== balance) throw new Error(`Higgsfield CLI account balance changed during batch quotation (${balance} → ${balanceAfter} ${HIGGSFIELD_CLI_CURRENCY})`); const currency = HIGGSFIELD_CLI_CURRENCY; const jobs = priced.map(({ projection, amount }) => ({ jobId: projection.taskId, taskKind: projection.taskKind, payloadHash: projection.payloadHash, sourceHashes: [...projection.sourceHashes], providerArgumentsHash: projection.providerArgumentsHash, quantity: 1, unitCost: { currency, amount }, maximumCost: { currency, amount }, })); const total = Number(priced.reduce((sum, item) => sum + item.amount, 0).toFixed(8)); return sealCinemaGenerationQuote({ schemaVersion: 1, quoteId: input.quoteId, projectSlug: input.projectSlug, routeId: input.projections[0].routeId, accountClass: input.projections[0].accountClass, capabilitySnapshotId: input.snapshot.snapshotId, capabilitySnapshotHash: input.snapshot.contentHash, createdAt: input.createdAt, expiresAt: input.expiresAt, jobs, totalCost: { currency, amount: total }, maximumBalanceDebit: { currency, amount: total }, balanceEnvelope: { observedBefore: { currency, amount: balance }, minimumBefore: { currency, amount: Math.max(0, balance - input.balanceEnvelopeTolerance) }, maximumBefore: { currency, amount: balance + input.balanceEnvelopeTolerance }, }, }); } export function quoteCinemaHiggsfieldCli(input: Omit & { projection: CinemaProviderProjection; references: CinemaHiggsfieldReferenceFile[]; }): CinemaGenerationQuoteArtifact { const { projection, references, ...rest } = input; return quoteCinemaHiggsfieldCliBatch({ ...rest, projections: [projection], references: { [projection.taskId]: references } }); } export function assertCinemaHiggsfieldCliSubmitReady(input: { projection: CinemaProviderProjection; bundle: CinemaProviderContractBundle; now: string; currentBalance: number; confirmationQuoteHash: string; allowPaidSubmit: boolean; }): void { if (!input.allowPaidSubmit) throw new Error('Higgsfield CLI paid submission requires a separate explicit execution approval'); if (input.confirmationQuoteHash !== input.bundle.quote.contentHash) throw new Error('Higgsfield CLI confirmation quote hash does not match'); const validation = validateCinemaGenerationAuthorization(input.bundle.authorization, input.bundle.quote, input.bundle.snapshot, input.now); if (!validation.ok) throw new Error(`Higgsfield CLI authorization invalid: ${validation.issues.map((entry) => entry.code).join(', ')}`); const job = input.bundle.quote.jobs.find((entry) => entry.payloadHash === input.projection.payloadHash); if (!job) throw new Error('Higgsfield CLI projection is absent from the authorized quote'); assertCinemaProjectionQuoteParity(input.projection, { routeId: input.bundle.quote.routeId, payloadHash: job.payloadHash, providerArgumentsHash: job.providerArgumentsHash, sourceHashes: job.sourceHashes, }); const envelope = input.bundle.quote.balanceEnvelope; if (input.currentBalance < envelope.minimumBefore.amount || input.currentBalance > envelope.maximumBefore.amount) { throw new Error('Higgsfield CLI account balance drifted outside the authorized envelope'); } } /** * The job id in a create response. The real shape (1.1.23) is `[""]` — a * bare array of id strings, one per job created. An object carrying * `id`/`job_id`/`jobId` is tolerated. Two or more ids means the CLI created * more jobs than the one that was quoted, which is not a shape to pick from. */ export function higgsfieldCreateJobId(response: unknown): string | null { if (Array.isArray(response)) { if (response.length !== 1) return null; const [only] = response; if (typeof only === 'string') return only.trim() || null; return only && typeof only === 'object' ? higgsfieldCreateJobId(only) : null; } if (!response || typeof response !== 'object') return null; for (const key of ['id', 'job_id', 'jobId']) { const value = (response as Record)[key]; if (typeof value === 'string' && value.trim()) return value; } return null; } /** * Submit exactly what was quoted. The CLI answers `[""]`; the returned * record carries that id as `jobId` (the handler's durable id) beside the raw * output. When no single id can be read the record has no `jobId` and the * handler records an UNKNOWN submission — a create may have happened, so it is * never retried. */ export function submitCinemaHiggsfieldCli(input: { runner: CinemaHiggsfieldCliRunner; projection: CinemaProviderProjection; references: CinemaHiggsfieldReferenceFile[]; bundle: CinemaProviderContractBundle; now: string; currentBalance: number; confirmationQuoteHash: string; allowPaidSubmit: boolean; }): Record { assertCinemaHiggsfieldCliSubmitReady(input); verifyHiggsfieldReferenceFiles(input.projection, input.references); const argv = higgsfieldCliGenerationArgs('create', input.projection, input.bundle.snapshot, input.references); const value = parseJson(input.runner.run(argv), 'Higgsfield CLI generation create'); // `[""]` is the shape; the envelope keeps the raw output beside the // normalised id so an unknown submission still records what the CLI said. const jobId = higgsfieldCreateJobId(value); const raw = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : { created: value }; return jobId ? { ...raw, jobId } : raw; }