import { createHash } from 'node:crypto'; import { accessSync, constants, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { VclawError } from './errors.js'; import { ROUTE_PREREQUISITES } from './provider-platform/route-prerequisites.js'; import type { ProviderRouteId } from './provider-platform/types.js'; import type { ActiveTransport, VideoExecutionCancelResult, VideoExecutionPayload, VideoExecutionPollResult, } from './types.js'; function adapterEnvVarForRoute(routeId: ProviderRouteId): string { switch (routeId) { case 'veo-useapi': return 'VCLAW_VEO_USEAPI_ADAPTER'; case 'seedance-direct': return 'VCLAW_SEEDANCE_DIRECT_ADAPTER'; case 'runway-useapi': return 'VCLAW_RUNWAY_USEAPI_ADAPTER'; case 'dreamina-useapi': return 'VCLAW_DREAMINA_USEAPI_ADAPTER'; case 'magnific-rest': return 'VCLAW_MAGNIFIC_REST_ADAPTER'; } } function builtinAdapterCommandForRoute(routeId: ProviderRouteId): string | null { if (!['seedance-direct', 'veo-useapi', 'runway-useapi', 'dreamina-useapi', 'magnific-rest'].includes(routeId)) { return null; } const scriptPath = fileURLToPath(new URL('../cli/provider-adapter.js', import.meta.url)); return `${JSON.stringify(process.execPath)} ${JSON.stringify(scriptPath)} --route ${routeId}`; } /** * Filename of the readiness marker `bootstrap/import_cookies.py` writes INSIDE * the Higgsfield browser session directory after a successful, signed-in cookie * import. Only the cookie import can write it, because only the cookie import * proves a sign-in. */ export const FREE_SEEDANCE_ENGINE_MARKER = '.vclaw-engine-ready.json'; /** Why the vendored free Seedance engine is (or is not) usable on this machine. */ export interface FreeSeedanceEngineDescription { ready: boolean; /** One line naming the failing check and the command that fixes it. Absent when ready. */ reason?: string; venvPath: string; profilePath: string; markerPath: string; } export interface FreeSeedanceEngineProbeOptions { /** * Engine directory to probe. Tests point this at a temp layout: the real * `engines/seedance-direct/` is set up on real machines and its profile * directory holds a live, secret Higgsfield browser session, so a test that * read it would pass or fail on who ran it. */ engineDir?: string; } /** * The readiness of the vendored free Seedance engine, as a verdict PLUS the * reason — one function so a probe and an operator-facing hint cannot disagree. * * Presence alone is not readiness. This used to check only that * `.venv/bin/python` and the profile directory EXIST, which an empty directory * and an empty file satisfy: the CI setup action creates exactly those stubs, * so any machine that mimicked it (or any half-finished bootstrap) reported * `seedance-direct: available` on `in-tree-engine` with nothing configured, and * a `--confirm-spend` render would have launched a broken engine instead of * refusing. The marker is the one artifact that cannot be faked by `mkdir`. */ export function describeFreeInTreeSeedanceEngine( env: NodeJS.ProcessEnv, options: FreeSeedanceEngineProbeOptions = {}, ): FreeSeedanceEngineDescription { const engineDir = options.engineDir ?? fileURLToPath(new URL('../../engines/seedance-direct/', import.meta.url)); const venvPath = join(engineDir, '.venv', 'bin', 'python'); const profilePath = env.HIGGS_VCLAW_PROFILE ?? join(engineDir, '.cloak-profile'); const markerPath = join(profilePath, FREE_SEEDANCE_ENGINE_MARKER); // Reasons carry no trailing period: callers embed them mid-sentence. const verdict = (reason?: string): FreeSeedanceEngineDescription => ({ ready: !reason, reason, venvPath, profilePath, markerPath }); if (env.VCLAW_SEEDANCE_DIRECT_NATIVE) { return verdict('VCLAW_SEEDANCE_DIRECT_NATIVE is set, which selects the paid API'); } if (!existsSync(venvPath)) { return verdict(`the engine is not installed (no Python environment at ${venvPath})`); } try { accessSync(venvPath, constants.X_OK); } catch { return verdict(`the engine's Python at ${venvPath} is not executable, so the install is incomplete`); } if (!existsSync(profilePath)) { return verdict(`the Higgsfield browser session has not been imported (nothing at ${profilePath})`); } let raw: string; try { raw = readFileSync(markerPath, 'utf-8'); } catch { return verdict( `the Higgsfield browser session has not been imported (${profilePath} has no ${FREE_SEEDANCE_ENGINE_MARKER};` + ' a session already signed in can have that file written by hand)', ); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return verdict(`the Higgsfield browser session record at ${markerPath} is not valid JSON`); } const loggedIn = (parsed as { loggedIn?: unknown } | null)?.loggedIn; if (loggedIn !== true) { return verdict( `the last Higgsfield sign-in check failed (${markerPath} records loggedIn=${JSON.stringify(loggedIn)}),` + ' so the imported session is not signed in', ); } return verdict(); } /** Whether the vendored free Seedance engine is fully bootstrapped and enabled. */ export function hasFreeInTreeSeedanceEngine( env: NodeJS.ProcessEnv, options: FreeSeedanceEngineProbeOptions = {}, ): boolean { return describeFreeInTreeSeedanceEngine(env, options).ready; } /** * Which transport actually owns the next submission on a route. Reported by * `vclaw video providers` as `activeTransport`, and derived from the SAME * precedence `resolveAdapterCommand` (and, for the `command-shim` step, the * built-in adapter binary) walks — so the report cannot drift from the code * that runs: * 1. `..._ADAPTER` -> custom-adapter (an operator-supplied binary) * 2. free in-tree engine -> in-tree-engine (seedance-direct only, $0, browser) * 3. `..._SUBMIT_CMD` -> command-shim (built-in adapter runs the shim) * 4. otherwise -> the route's native in-process transport * A route reading `native-*` is the one whose declared `requiredEnvVars` must * actually be present; the other three own their own credentials. */ const NATIVE_TRANSPORTS: Record = { 'veo-useapi': 'native-veo', 'seedance-direct': 'native-seedance', 'runway-useapi': 'native-runway', 'dreamina-useapi': 'native-dreamina', 'magnific-rest': 'native-magnific', }; export function resolveActiveTransport( routeId: ProviderRouteId, env: NodeJS.ProcessEnv, /** * Injectable engine probe. The real one stats the vendored venv + browser * profile, so a caller that has already probed passes its own answer in and * the two cannot disagree; tests pass a fixed one. Default = the real probe. */ probeFreeSeedanceEngine: (env: NodeJS.ProcessEnv) => boolean = hasFreeInTreeSeedanceEngine, ): ActiveTransport { if (env[adapterEnvVarForRoute(routeId)]?.trim()) return 'custom-adapter'; if (routeId === 'seedance-direct' && probeFreeSeedanceEngine(env)) return 'in-tree-engine'; // The shim env var names are DECLARED once in route-prerequisites.ts (#463); // a second table here is exactly the drift that declaration removed. if (ROUTE_PREREQUISITES[routeId].commandEnvVars.some((name) => env[name]?.trim())) return 'command-shim'; return NATIVE_TRANSPORTS[routeId]; } function resolveAdapterCommand(routeId: ProviderRouteId, env: NodeJS.ProcessEnv): string { const override = env[adapterEnvVarForRoute(routeId)]; if (override?.trim()) return override; if (routeId === 'seedance-direct' && hasFreeInTreeSeedanceEngine(env)) { return JSON.stringify(fileURLToPath(new URL('../../engines/seedance-direct/run.sh', import.meta.url))); } const builtin = builtinAdapterCommandForRoute(routeId); if (builtin) return builtin; throw new VclawError( 'env_var_missing', `Live execution for ${routeId} requires ${adapterEnvVarForRoute(routeId)} to point at an adapter command.`, { routeId, envVar: adapterEnvVarForRoute(routeId) }, ); } export function executionAdapterCommandHash(routeId: ProviderRouteId, env: NodeJS.ProcessEnv = process.env): string { return `sha256:${createHash('sha256').update(resolveAdapterCommand(routeId, env)).digest('hex')}`; } async function runAdapter( routeId: ProviderRouteId, input: unknown, env: NodeJS.ProcessEnv, action: 'submit' | 'poll' | 'cancel' | 'lookup', ): Promise<{ adapterCommand: string; stdout: string; rawResult: unknown }> { const adapterCommand = resolveAdapterCommand(routeId, env); const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => { const child = spawn('sh', ['-lc', adapterCommand], { env, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += String(chunk); }); child.stderr.on('data', (chunk) => { stderr += String(chunk); }); child.on('error', reject); child.on('close', (code) => resolve({ stdout, stderr, code })); child.stdin.write(JSON.stringify(input)); child.stdin.end(); }); if (result.code !== 0) { throw new VclawError( 'adapter_command_failed', `Adapter ${action} failed for ${routeId}: ${result.stderr.trim() || `exit ${result.code}`}`, { routeId, action, exitCode: result.code }, ); } const stdout = result.stdout.trim(); let rawResult: unknown = stdout; if (stdout) { try { rawResult = JSON.parse(stdout) as unknown; } catch { rawResult = stdout; } } return { adapterCommand, stdout, rawResult }; } function objectResult(value: unknown): Record | null { return value && typeof value === 'object' ? value as Record : null; } function externalJobIdFrom(value: unknown, fallback: string | null = null): string | null { const candidate = objectResult(value)?.externalJobId; return candidate === undefined ? fallback : String(candidate ?? '') || fallback; } function executionOutputsFrom(value: unknown): VideoExecutionPollResult['outputs'] { const record = objectResult(value); const rawOutputs = Array.isArray(record?.outputs) ? record.outputs : []; return rawOutputs .filter((asset): asset is Record => !!asset && typeof asset === 'object' && typeof (asset as Record).id === 'string' && typeof (asset as Record).kind === 'string' && typeof (asset as Record).path === 'string') .map((asset) => ({ id: String(asset.id), kind: ['image', 'video', 'audio', 'subtitle', 'other'].includes(String(asset.kind)) ? String(asset.kind) as 'image' | 'video' | 'audio' | 'subtitle' | 'other' : 'other' as const, path: String(asset.path), ...(Number.isInteger(asset.sceneIndex) ? { sceneIndex: asset.sceneIndex as number } : {}), ...(typeof asset.backend === 'string' && asset.backend.trim() ? { backend: asset.backend } : {}), })); } export async function submitExecutionPayload( payload: VideoExecutionPayload, options: { env?: NodeJS.ProcessEnv } = {}, ): Promise<{ adapterCommand: string; externalJobId: string | null; rawResult: unknown }> { const result = await runAdapter(payload.routeId, payload, options.env ?? process.env, 'submit'); const externalJobId = externalJobIdFrom(result.rawResult); if (!externalJobId) { throw new VclawError( 'adapter_command_failed', `Adapter submit for ${payload.routeId} returned no externalJobId ` + `(stdout: ${result.stdout ? result.stdout.slice(0, 200) : ''}). ` + 'Adapters must print JSON {"externalJobId":"..."} on submit.', { routeId: payload.routeId, action: 'submit' }, ); } return { adapterCommand: result.adapterCommand, externalJobId, rawResult: result.rawResult }; } export async function pollExecutionPayload( input: { projectSlug: string; routeId: ProviderRouteId; externalJobId: string; outputDir: string; workspaceRoot: string; }, options: { env?: NodeJS.ProcessEnv } = {}, ): Promise { const result = await runAdapter(input.routeId, { action: 'poll', ...input }, options.env ?? process.env, 'poll'); const record = objectResult(result.rawResult); const status = String(record?.status ?? ''); if (!(status === 'pending' || status === 'completed' || status === 'failed')) { throw new VclawError('adapter_command_failed', `Adapter poll for ${input.routeId} returned invalid status: ${status || 'missing'}`, { routeId: input.routeId, action: 'poll', status: status || null, }); } const outputs = executionOutputsFrom(result.rawResult); const issues = Array.isArray(record?.issues) ? record.issues.map(String) : []; return { status, externalJobId: externalJobIdFrom(result.rawResult, input.externalJobId) ?? input.externalJobId, outputs, issues, rawResult: result.rawResult, }; } export async function lookupExecutionSubmission( input: { payload: VideoExecutionPayload; submissionKey: string; }, options: { env?: NodeJS.ProcessEnv } = {}, ): Promise<{ state: 'not-found' | 'accepted' | 'processing' | 'completed' | 'failed' | 'unknown'; authoritative: boolean; externalJobId: string | null; providerStatus: string; outputs: VideoExecutionPollResult['outputs']; issues: string[]; rawResult: unknown; }> { if (!input.submissionKey.trim()) throw new Error('Adapter submission lookup requires a submission key'); const payload = input.payload; const result = await runAdapter(payload.routeId, { action: 'lookup', submissionKey: input.submissionKey, projectSlug: payload.projectSlug, routeId: payload.routeId, outputDir: payload.outputDir, workspaceRoot: payload.workspaceRoot, }, options.env ?? process.env, 'lookup'); const record = objectResult(result.rawResult); const state = String(record?.state ?? ''); if (!['not-found', 'accepted', 'processing', 'completed', 'failed', 'unknown'].includes(state)) { throw new VclawError('adapter_command_failed', `Adapter lookup for ${payload.routeId} returned invalid state: ${state || 'missing'}`, { routeId: payload.routeId, action: 'lookup', state: state || null, }); } const authoritative = record?.authoritative === true; const externalJobId = externalJobIdFrom(result.rawResult); if (['accepted', 'processing', 'completed', 'failed'].includes(state) && !externalJobId) { throw new VclawError('adapter_command_failed', `Adapter lookup for ${payload.routeId} returned ${state} without externalJobId`, { routeId: payload.routeId, action: 'lookup', state, }); } return { state: state as 'not-found' | 'accepted' | 'processing' | 'completed' | 'failed' | 'unknown', authoritative, externalJobId, providerStatus: String(record?.providerStatus ?? state), outputs: executionOutputsFrom(result.rawResult), issues: Array.isArray(record?.issues) ? record.issues.map(String) : [], rawResult: result.rawResult, }; } export async function cancelExecutionPayload( input: { projectSlug: string; routeId: ProviderRouteId; externalJobId: string; outputDir: string; workspaceRoot: string; }, options: { env?: NodeJS.ProcessEnv } = {}, ): Promise { const result = await runAdapter(input.routeId, { action: 'cancel', ...input }, options.env ?? process.env, 'cancel'); const record = objectResult(result.rawResult); const status = String(record?.status ?? ''); if (!(status === 'cancelled' || status === 'unsupported')) { throw new VclawError('adapter_command_failed', `Adapter cancel for ${input.routeId} returned invalid status: ${status || 'missing'}`, { routeId: input.routeId, action: 'cancel', status: status || null, }); } return { status, externalJobId: externalJobIdFrom(result.rawResult, input.externalJobId) ?? input.externalJobId, issues: Array.isArray(record?.issues) ? record.issues.map(String) : [], rawResult: result.rawResult, }; }