import { mkdir, writeFile, readFile, rename, unlink } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { safeErrorBody } from './http-error-safety.js'; import { cheapestModelForOperation, getVideoModel } from './magnific/models.js'; import type { VideoExecutionPayload, VideoExecutionPollResult, VideoExecutionCancelResult, } from './types.js'; type FetchLike = typeof fetch; interface MagnificSceneState { sceneIndex: number; prompt: string; taskId: string; outputPath: string; status: 'submitted' | 'completed' | 'failed'; error?: string; } interface MagnificJobState { externalJobId: string; routeId: 'magnific-rest'; model: string; /** Live submit/poll path prefix, e.g. /v1/ai/image-to-video/ (set at submit). */ modelPath?: string; outputDir: string; createdAt: string; scenes: MagnificSceneState[]; } function apiBase(env: NodeJS.ProcessEnv): string { return (env.VCLAW_MAGNIFIC_API_URL ?? 'https://api.magnific.com').trim(); } function apiKey(env: NodeJS.ProcessEnv): string { const k = (env.MAGNIFIC_API_KEY ?? '').trim(); if (!k) throw new Error('magnific-rest native transport requires MAGNIFIC_API_KEY.'); return k; } /** * Cheap-by-default with premium opt-in. An explicit VCLAW_MAGNIFIC_MODEL always wins * (this is how Seedance Pro and other premium tiers are opted into). Otherwise pick the * cheapest model in the WHOLE catalog that supports the requested operation — NOT pinned * to the seedance family, because Magnific's only Seedance model is premium and pinning * would silently bill premium on the default path. */ function resolveModelId(env: NodeJS.ProcessEnv, operationKind: string): string { const explicit = (env.VCLAW_MAGNIFIC_MODEL ?? '').trim(); if (explicit) return getVideoModel(explicit).id; // throws on unknown id const model = cheapestModelForOperation(operationKind); if (!model) { throw new Error( `No Magnific video model supports ${operationKind}. Set VCLAW_MAGNIFIC_MODEL explicitly.`, ); } return model.id; } function jobDir(outputDir: string): string { return join(outputDir, '.vclaw-jobs'); } function jobPath(outputDir: string, id: string): string { return join(jobDir(outputDir), `${id}.json`); } async function writeJobState(s: MagnificJobState): Promise { await mkdir(jobDir(s.outputDir), { recursive: true }); await writeFile(jobPath(s.outputDir, s.externalJobId), `${JSON.stringify(s, null, 2)}\n`); } async function readJobState(outputDir: string, id: string): Promise { try { return JSON.parse(await readFile(jobPath(outputDir, id), 'utf-8')) as MagnificJobState; } catch { throw new Error(`magnific-rest job state not found for ${id}.`); } } async function downloadToFile(fetchImpl: FetchLike, url: string, outputPath: string): Promise { const res = await fetchImpl(url); if (!res.ok) { throw new Error( `magnific-rest download failed (HTTP ${res.status}): ${safeErrorBody(await res.text().catch(() => ''))}`, ); } await mkdir(dirname(outputPath), { recursive: true }); const tmp = `${outputPath}.tmp`; try { await writeFile(tmp, Buffer.from(await res.arrayBuffer())); await rename(tmp, outputPath); } catch (e) { await unlink(tmp).catch(() => {}); throw e; } } function mapStatus(raw: string): 'pending' | 'completed' | 'failed' { const s = raw.toLowerCase(); if (s === 'completed' || s === 'succeeded' || s === 'done') return 'completed'; if (s === 'failed' || s === 'error' || s === 'canceled' || s === 'cancelled') return 'failed'; return 'pending'; } // Magnific wraps every task in a `data` envelope (verified live 2026-06-16): // { "data": { "task_id": "...", "status": "CREATED|COMPLETED|FAILED", "generated": ["https://..."] } } // These helpers read the envelope but fall back to top-level for robustness. function magnificData(json: unknown): Record { if (json && typeof json === 'object') { const d = (json as { data?: unknown }).data; if (d && typeof d === 'object') return d as Record; return json as Record; } return {}; } function extractTaskId(json: unknown): unknown { const d = magnificData(json); return d.task_id ?? d.taskId ?? d.id; } function extractStatus(json: unknown): string { return String(magnificData(json).status ?? 'pending'); } function extractOutputUrl(json: unknown): string | undefined { const d = magnificData(json); const gen = d.generated; if (Array.isArray(gen) && gen.length > 0 && typeof gen[0] === 'string') return gen[0]; return (d.video_url ?? d.videoUrl ?? d.url ?? d.video ?? (d.response as { videoUrl?: string })?.videoUrl) as | string | undefined; } export async function submitMagnificRestNative( payload: VideoExecutionPayload, options: { env?: NodeJS.ProcessEnv; fetchImpl?: FetchLike; externalJobId?: string } = {}, ): Promise<{ externalJobId: string; rawResult: unknown }> { const env = options.env ?? process.env; const fetchImpl = options.fetchImpl ?? fetch; const key = apiKey(env); const model = resolveModelId(env, payload.operationKind); const base = apiBase(env); const externalJobId = options.externalJobId ?? `magnific-rest-${env.VCLAW_TEST_JOB_ID ?? Date.now()}`; const raw: unknown[] = []; // Persist job state INCREMENTALLY after each successful submit so a mid-loop failure // on task N never orphans the already-charged tasks 0..N-1 (they stay recoverable/pollable). const state: MagnificJobState = { externalJobId, routeId: 'magnific-rest', model, outputDir: payload.outputDir, createdAt: new Date().toISOString(), scenes: [], }; // Live-verified path structure: video models live under /v1/ai// // (e.g. /v1/ai/image-to-video/kling-v2-6-pro), NOT /v1/ai/. const opSegment = payload.operationKind === 'text-to-video' ? 'text-to-video' : 'image-to-video'; const modelPath = `/v1/ai/${opSegment}/${model}`; state.modelPath = modelPath; for (const task of payload.tasks) { const outputPath = join(payload.outputDir, `scene-${task.sceneIndex}.mp4`); const body: Record = { prompt: task.prompt }; const firstRef = task.referencePaths[0]; if (firstRef) { // The i2v first-frame field name varies per model (verified live: minimax-live/pixverse-v5 → // `image_url`, runway-gen4-turbo → `image`, kling-o1-pro → `first_frame`). Supply all three; // models ignore the keys they don't use (confirmed: minimax-live accepts the extras). body.image = firstRef; body.image_url = firstRef; body.first_frame = firstRef; } // duration is a STRING enum on Magnific video models (Kling: '5'|'10'); clamp the requested // seconds to the nearest supported value (ignored by models that don't take a duration). body.duration = (task.durationSeconds ?? 5) >= 8 ? '10' : '5'; // resolution is required by some models (e.g. pixverse-v5); pass the profile resolution // (ignored by models that don't take it). body.resolution = payload.executionProfile.resolution === '1080p' ? '1080p' : '720p'; const res = await fetchImpl(`${base}${modelPath}`, { method: 'POST', headers: { 'x-magnific-api-key': key, 'content-type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { throw new Error( `magnific-rest submit failed (HTTP ${res.status}): ${safeErrorBody(await res.text().catch(() => ''))}`, ); } const json = await res.json(); raw.push(json); const taskId = extractTaskId(json); if (taskId === undefined || taskId === null || String(taskId).trim() === '') { throw new Error( `magnific-rest submit did not return a task id (model ${model}, scene ${task.sceneIndex}).`, ); } state.scenes.push({ sceneIndex: task.sceneIndex, prompt: task.prompt, taskId: String(taskId), outputPath, status: 'submitted', }); await writeJobState(state); } return { externalJobId, rawResult: raw }; } export async function pollMagnificRestNative( input: { outputDir: string; externalJobId: string; workspaceRoot: string }, options: { env?: NodeJS.ProcessEnv; fetchImpl?: FetchLike } = {}, ): Promise { const env = options.env ?? process.env; const fetchImpl = options.fetchImpl ?? fetch; const key = apiKey(env); const base = apiBase(env); const state = await readJobState(input.outputDir, input.externalJobId); const outputs: VideoExecutionPollResult['outputs'] = []; const issues: string[] = []; let anyPending = false; let anyFailed = false; for (const scene of state.scenes) { if (scene.status === 'completed') { outputs.push({ id: `generated-scene-${scene.sceneIndex}`, kind: 'video', path: scene.outputPath, sceneIndex: scene.sceneIndex, backend: 'magnific-rest' }); continue; } if (scene.status === 'failed') { anyFailed = true; if (scene.error) issues.push(scene.error); continue; } const pollPrefix = state.modelPath ?? `/v1/ai/${state.model}`; const res = await fetchImpl(`${base}${pollPrefix}/${scene.taskId}`, { headers: { 'x-magnific-api-key': key }, }); if (!res.ok) { anyPending = true; continue; } const json = await res.json(); const status = mapStatus(extractStatus(json)); if (status === 'completed') { const url = extractOutputUrl(json); if (!url) { scene.status = 'failed'; scene.error = 'magnific-rest: completed with no video url'; anyFailed = true; issues.push(scene.error); } else { if (!existsSync(scene.outputPath)) await downloadToFile(fetchImpl, url, scene.outputPath); scene.status = 'completed'; outputs.push({ id: `generated-scene-${scene.sceneIndex}`, kind: 'video', path: scene.outputPath, sceneIndex: scene.sceneIndex, backend: 'magnific-rest' }); } } else if (status === 'failed') { scene.status = 'failed'; scene.error = `magnific-rest: task ${scene.taskId} failed`; anyFailed = true; issues.push(scene.error); } else { anyPending = true; } } await writeJobState(state); return { status: anyFailed ? 'failed' : anyPending ? 'pending' : 'completed', externalJobId: input.externalJobId, outputs, issues, rawResult: state, }; } export async function cancelMagnificRestNative( input: { outputDir: string; externalJobId: string; workspaceRoot: string }, ): Promise { return { status: 'unsupported', externalJobId: input.externalJobId, issues: ['magnific-rest has no server-side cancel; let the job complete or ignore it.'], rawResult: null, }; } // ─── Video Upscaler Precision (used by the `finish` magnific-precision backend) ── // Direct Magnific REST, VERIFIED LIVE 2026-06-16 against api.magnific.com: // POST /v1/ai/video-upscaler-precision body { video: , resolution, // strength(0..100), sharpen, smart_grain, fps_boost } → { task_id } // GET /v1/ai/video-upscaler-precision/{task_id} → { status, ... output url } // (The input video is the `video` field — NOT video_url; confirmed by the API's own // validation error `body.video Field required`.) export const MAGNIFIC_VIDEO_UPSCALE_PATH = '/v1/ai/video-upscaler-precision'; /** Submit a hosted video URL for Magnific upscale; returns the task id. */ export async function submitMagnificUpscale( videoUrl: string, params: Record, opts: { env?: NodeJS.ProcessEnv; fetchImpl?: FetchLike } = {}, ): Promise { const env = opts.env ?? process.env; const fetchImpl = opts.fetchImpl ?? fetch; const key = apiKey(env); const base = apiBase(env); const res = await fetchImpl(`${base}${MAGNIFIC_VIDEO_UPSCALE_PATH}`, { method: 'POST', headers: { 'x-magnific-api-key': key, 'content-type': 'application/json' }, body: JSON.stringify({ video: videoUrl, ...params }), }); if (!res.ok) { throw new Error( `magnific upscale submit failed (HTTP ${res.status}): ${safeErrorBody(await res.text().catch(() => ''))}`, ); } const json = await res.json(); const taskId = extractTaskId(json); if (taskId === undefined || taskId === null || String(taskId).trim() === '') { throw new Error('magnific upscale submit did not return a task id.'); } return String(taskId); } /** Poll a Magnific upscale task to completion and return the output video URL. */ export async function awaitMagnificUpscale( taskId: string, opts: { env?: NodeJS.ProcessEnv; fetchImpl?: FetchLike; intervalMs?: number; maxAttempts?: number; sleep?: (ms: number) => Promise; } = {}, ): Promise { const env = opts.env ?? process.env; const fetchImpl = opts.fetchImpl ?? fetch; const key = apiKey(env); const base = apiBase(env); const interval = opts.intervalMs ?? 5000; const maxAttempts = opts.maxAttempts ?? 240; const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); for (let attempt = 0; attempt < maxAttempts; attempt += 1) { const res = await fetchImpl(`${base}${MAGNIFIC_VIDEO_UPSCALE_PATH}/${taskId}`, { headers: { 'x-magnific-api-key': key }, }); if (res.ok) { const json = await res.json(); const status = mapStatus(extractStatus(json)); if (status === 'completed') { const url = extractOutputUrl(json); if (!url) throw new Error('magnific upscale completed with no video url'); return url; } if (status === 'failed') throw new Error(`magnific upscale task ${taskId} failed`); } await sleep(interval); } throw new Error(`magnific upscale task ${taskId} did not complete after ${maxAttempts} attempts`); } // ─── Image operations (used by the `image-ops` command) ────────────────────── // Verified live 2026-06-16: POST /v1/ai/image-upscaler-precision-v2 { image: , ... } // → { data:{ task_id } }; poll the same path + /{task_id} → data.generated[0] (output image URL). // Unlike the VIDEO upscaler, the image endpoint accepts a base64 `image` (no host needed for small // files). This endpoint WORKS (the video upscaler 502s — different service). export const MAGNIFIC_IMAGE_UPSCALE_PATH = '/v1/ai/image-upscaler-precision-v2'; /** Submit an image (base64 or HTTPS URL) for Magnific upscale; returns the task id. */ export async function submitMagnificImageUpscale( image: string, params: Record, opts: { env?: NodeJS.ProcessEnv; fetchImpl?: FetchLike } = {}, ): Promise { const env = opts.env ?? process.env; const fetchImpl = opts.fetchImpl ?? fetch; const key = apiKey(env); const base = apiBase(env); const res = await fetchImpl(`${base}${MAGNIFIC_IMAGE_UPSCALE_PATH}`, { method: 'POST', headers: { 'x-magnific-api-key': key, 'content-type': 'application/json' }, body: JSON.stringify({ image, ...params }), }); if (!res.ok) { throw new Error( `magnific image upscale submit failed (HTTP ${res.status}): ${safeErrorBody(await res.text().catch(() => ''))}`, ); } const json = await res.json(); const taskId = extractTaskId(json); if (taskId === undefined || taskId === null || String(taskId).trim() === '') { throw new Error('magnific image upscale submit did not return a task id.'); } return String(taskId); } /** Poll a Magnific image task to completion and return the output image URL. */ export async function awaitMagnificImage( taskId: string, opts: { env?: NodeJS.ProcessEnv; fetchImpl?: FetchLike; intervalMs?: number; maxAttempts?: number; sleep?: (ms: number) => Promise } = {}, ): Promise { const env = opts.env ?? process.env; const fetchImpl = opts.fetchImpl ?? fetch; const key = apiKey(env); const base = apiBase(env); const interval = opts.intervalMs ?? 3000; const maxAttempts = opts.maxAttempts ?? 120; const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); for (let attempt = 0; attempt < maxAttempts; attempt += 1) { const res = await fetchImpl(`${base}${MAGNIFIC_IMAGE_UPSCALE_PATH}/${taskId}`, { headers: { 'x-magnific-api-key': key }, }); if (res.ok) { const json = await res.json(); const status = mapStatus(extractStatus(json)); if (status === 'completed') { const url = extractOutputUrl(json); if (!url) throw new Error('magnific image upscale completed with no output url'); return url; } if (status === 'failed') throw new Error(`magnific image upscale task ${taskId} failed`); } await sleep(interval); } throw new Error(`magnific image upscale task ${taskId} did not complete after ${maxAttempts} attempts`); } /** Download an upscaled output URL to disk (atomic tmp+rename). Returns dest. */ export async function downloadMagnificFile( url: string, dest: string, opts: { fetchImpl?: FetchLike } = {}, ): Promise { await downloadToFile(opts.fetchImpl ?? fetch, url, dest); return dest; }