/** * finish.ts — the "finish" pass: upscale a rendered video to a clean HD/QHD * master. PURE planning core here (backend selection, the anti-plastic Topaz * param recipe, and the frame-exact chunk plan); execution backends (hosted * Topaz via the apiz client, local Real-ESRGAN) layer on top. * * WHY a dedicated finish (vs the existing `assemble/upscale.ts` planner): that * one only builds args for a LOCAL Topaz CLI. This adds (a) hosted Topaz so a * finish works without a desktop Topaz install, (b) the LESSONS from a real run: * - The anime Real-ESRGAN model PLASTICIZES skin; use a photoreal model and, * for hosted Topaz, the "detail-not-sharp" recipe — DISABLE denoise/sharpen * (noise=0, halo=0) and KEEP film grain — so the result is detailed, not waxy. * - Topaz `grain` caps at 0.1 (the published schema lies, says 0..1) → clamp. * - Diffusion upscalers (Topaz Starlight) are slow + flaky → split into * frame-exact chunks, upscale in parallel, retry, concat. {@link planFinishChunks} * produces the frame-aligned chunk windows so the concat preserves timing * (lip-sync stays locked). NOTE: runFinish does NOT consume it yet — it is * exported for callers orchestrating chunked Starlight runs themselves * until the executor grows that wiring. * * {@link runFinish} is the executor: for a HOSTED Topaz backend it uploads the * input (uguu) → submits the apiz/xskill `fal-ai/topaz/upscale/video` task → * awaits → downloads; for `topaz-local` it shells the local Topaz CLI via the * existing {@link topazUpscalePlan}. Every transport is injectable so the executor * is unit-testable offline; `--dry-run` returns the plan without spending. */ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { hostPublicUrl } from './providers/public-host.js'; import { xskillSubmit, xskillAwait, xskillDownload, type XskillAwaitOptions } from './providers/xskill.js'; import { topazUpscalePlan } from './assemble/upscale.js'; import { submitMagnificUpscale, awaitMagnificUpscale, downloadMagnificFile } from './native-magnific.js'; import { uploadToGoBananasR2 } from './magnific/media-host.js'; import type { VideoMediaProbe } from './final-media.js'; import { RUNWAY_UPSCALE_MAX_SECONDS, awaitRunwayUpscale, downloadRunwayUpscale, submitRunwayUpscale, uploadRunwayUpscaleAsset, } from './native-runway-upscale.js'; const execFileAsync = promisify(execFile); export type FinishBackend = | 'topaz-starlight' // hosted diffusion — most detail, slowest/flakiest | 'topaz-proteus' // hosted CNN — natural detail, fast + reliable (default) | 'topaz-gaia' // hosted CNN — gentlest, most natural skin | 'magnific-precision' // hosted Magnific Video Upscaler Precision (1K/2K/4K) | 'runway-topaz-free' // hosted Runway/Topaz 4K via useapi exploreMode — $0 on Runway Unlimited | 'realesrgan-x4plus' // local, free — photoreal general model (NOT the anime one) | 'topaz-local'; // local Topaz CLI (delegates to assemble/upscale.ts) export const FINISH_BACKEND_IDS: FinishBackend[] = [ 'topaz-starlight', 'topaz-proteus', 'topaz-gaia', 'magnific-precision', 'runway-topaz-free', 'realesrgan-x4plus', 'topaz-local', ]; /** Hosted backends fetch their input by URL and run on the aggregator/fal/Magnific. */ export function isHostedBackend(backend: FinishBackend): boolean { // runway-topaz-free is included so the handler's --confirm-spend gate covers // it: exploreMode is $0 only on a Runway Unlimited plan — on any other // account the same call bills 2 credits/second, so it must not run ungated. return ( backend === 'topaz-starlight' || backend === 'topaz-proteus' || backend === 'topaz-gaia' || backend === 'magnific-precision' || backend === 'runway-topaz-free' ); } export interface FinishOptions { backend: FinishBackend; /** Anti-plastic preset: denoise/sharpen off, film grain kept, detail recovered. Default true. */ detailNotSharp?: boolean; /** Upscale factor (default 2). 1.5 = 720p->1080p exactly. */ scale?: number; /** Override film grain (0..0.1; clamped). */ grain?: number; /** Override noise reduction (0..1). detailNotSharp forces 0. */ noise?: number; /** Override detail recovery (0..1). detailNotSharp defaults 0.85. */ recoverDetail?: number; } /** Topaz request params (the subset the aggregator's Topaz endpoint accepts). */ export interface TopazParams { model: string; upscale_factor: number; grain: number; noise: number; halo: number; recover_detail: number; H264_output: boolean; } /** The published max for Topaz `grain` — the docs incorrectly say 0..1. */ export const TOPAZ_GRAIN_MAX = 0.1; const TOPAZ_MODEL_NAME: Partial> = { 'topaz-starlight': 'Starlight Precise 2.5', 'topaz-proteus': 'Proteus', 'topaz-gaia': 'Gaia HQ', }; function clamp(v: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, v)); } /** * Map finish options to Topaz request params for a HOSTED backend. PURE. Applies * the anti-plastic recipe by default and clamps `grain` to the real 0.1 ceiling. * Throws for a non-Topaz backend. */ export function topazParamsFor(opts: FinishOptions): TopazParams { const model = TOPAZ_MODEL_NAME[opts.backend]; if (!model) { throw new Error(`topazParamsFor: ${opts.backend} is not a hosted Topaz backend.`); } const detailNotSharp = opts.detailNotSharp ?? true; const grain = clamp(opts.grain ?? TOPAZ_GRAIN_MAX, 0, TOPAZ_GRAIN_MAX); const noise = detailNotSharp ? 0 : clamp(opts.noise ?? 0, 0, 1); const halo = detailNotSharp ? 0 : 0.3; const recoverDetail = clamp(opts.recoverDetail ?? (detailNotSharp ? 0.85 : 0.6), 0, 1); return { model, upscale_factor: opts.scale ?? 2, grain, noise, halo, recover_detail: recoverDetail, H264_output: true, }; } // ─── Magnific Video Upscaler Precision ────────────────────────────────────── /** * Magnific Video Upscaler Precision request params. Field names + ranges are the * LIVE Magnific contract (verified 2026-06-16 against api.magnific.com): the input * video is the separate `video` field on the request body; `resolution` is an enum, * `strength` is a 0..100 blend, `sharpen`/`smart_grain`/`fps_boost` are booleans. */ export interface MagnificUpscaleParams { resolution: '720p' | '1k' | '2k' | '4k'; strength: number; // 0..100 blend sharpen: boolean; smart_grain: boolean; fps_boost: boolean; } /** Magnific Video Upscaler input limits (Precision). */ export const MAGNIFIC_MAX_SECONDS = 15; export const MAGNIFIC_MAX_FRAMES = 450; export const MAGNIFIC_MAX_BYTES = 150 * 1024 * 1024; export const MAGNIFIC_MAX_DIM = 3840; /** * ffmpeg args that re-encode an over-limit clip to fit Magnific's caps: trim to * MAGNIFIC_MAX_SECONDS, cap fps at 30 (30×15s = 450 frames, the frame cap), and * downscale (never upscale) to fit a 3840-px box preserving aspect with even dims. */ export function magnificNormalizeCommand(input: string, output: string): string[] { return [ 'ffmpeg', '-y', '-i', input, '-t', String(MAGNIFIC_MAX_SECONDS), '-r', '30', '-vf', `scale='min(${MAGNIFIC_MAX_DIM},iw)':'min(${MAGNIFIC_MAX_DIM},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`, '-c:v', 'libx264', '-crf', '23', '-preset', 'medium', '-c:a', 'aac', output, ]; } /** * Map finish options to Magnific upscale params. PURE. Reuses the anti-plastic * recipe knobs (detailNotSharp keeps grain, drops sharpen) and clamps to real ranges. */ export function magnificUpscaleParamsFor( opts: FinishOptions & { targetResolution?: '720p' | '1k' | '2k' | '4k' }, ): MagnificUpscaleParams { const detailNotSharp = opts.detailNotSharp ?? true; // recoverDetail (0..1, the videoclaw knob) maps onto Magnific's 0..100 blend strength. const strength = Math.round(clamp(opts.recoverDetail ?? (detailNotSharp ? 0.85 : 0.6), 0, 1) * 100); return { resolution: opts.targetResolution ?? '2k', strength, sharpen: !detailNotSharp, // anti-plastic recipe: sharpen OFF by default, ON with --sharpen smart_grain: (opts.grain ?? 0.05) > 0, // keep film grain unless explicitly --grain 0 fps_boost: false, }; } function parseFps(r?: string): number { if (!r) return 0; const parts = r.split('/').map(Number); const n = parts[0] ?? 0; const d = parts[1]; return d ? n / d : n; } /** * Validate a video against Magnific's Video Upscaler limits. PURE. `nb_frames` is * not exposed by probeMedia, so frame count is derived from duration × fps. With * `normalize:true` the caller re-encodes to fit, so violations are non-fatal. */ export function assertMagnificVideoInput( probe: Pick, opts: { normalize: boolean }, ): { ok: boolean; violations: string[] } { const violations: string[] = []; const dur = probe.durationSeconds ?? 0; const fps = parseFps(probe.frameRate); const frames = Math.round(dur * fps); if (dur > MAGNIFIC_MAX_SECONDS) violations.push(`duration ${dur}s exceeds Magnific cap ${MAGNIFIC_MAX_SECONDS}s`); if (frames > MAGNIFIC_MAX_FRAMES) violations.push(`~${frames} frames exceeds cap ${MAGNIFIC_MAX_FRAMES}`); if ((probe.sizeBytes ?? 0) > MAGNIFIC_MAX_BYTES) violations.push('file size exceeds 150MB'); if (Math.max(probe.width ?? 0, probe.height ?? 0) > MAGNIFIC_MAX_DIM) violations.push('input larger than 4K (3840px)'); return { ok: violations.length === 0 || opts.normalize, violations }; } /** A frame-aligned window of the source to upscale as an independent chunk. */ export interface ChunkWindow { index: number; startSec: number; durationSec: number; } /** * Split `totalDurationSec` into frame-aligned chunk windows of ~`chunkSeconds` * each. PURE. Boundaries land on exact frames so concatenating the upscaled * chunks preserves the source's frame count (and therefore its lip-sync). The * last chunk takes the remainder. Returns a single full-length window when the * source is at or under one chunk. */ export function planFinishChunks( totalDurationSec: number, chunkSeconds = 54, fps = 24, ): ChunkWindow[] { if (totalDurationSec <= 0) return []; const totalFrames = Math.round(totalDurationSec * fps); const chunkFrames = Math.max(1, Math.round(chunkSeconds * fps)); if (totalFrames <= chunkFrames) { return [{ index: 0, startSec: 0, durationSec: totalFrames / fps }]; } const windows: ChunkWindow[] = []; let startFrame = 0; let index = 0; while (startFrame < totalFrames) { const frames = Math.min(chunkFrames, totalFrames - startFrame); windows.push({ index, startSec: startFrame / fps, durationSec: frames / fps }); startFrame += frames; index += 1; } return windows; } // ─── Executor ───────────────────────────────────────────────────────────────── /** The apiz/xskill model id for hosted Topaz video upscale. */ export const TOPAZ_MODEL_ID = 'fal-ai/topaz/upscale/video'; export interface RunFinishOptions extends FinishOptions { input: string; output: string; /** Plan only — no upload, submit, or spawn. */ dryRun?: boolean; /** apiz key override (else APIZ_API_KEY / XSKILL_API_KEY). */ apiKey?: string; env?: NodeJS.ProcessEnv; /** Await tuning for the hosted task (poll interval / timeout / onTick). */ awaitOptions?: XskillAwaitOptions; /** Injectable hosted transports (default the real public-host + xskill client). */ upload?: (localPath: string) => Promise; submit?: (modelId: string, params: Record) => Promise; awaitTask?: (taskId: string) => Promise; download?: (url: string, dest: string) => Promise; /** Magnific Video Upscaler target output resolution (default 2k). */ targetResolution?: '720p' | '1k' | '2k' | '4k'; /** Auto-re-encode an out-of-limit input to fit Magnific's caps instead of failing. */ normalize?: boolean; /** Injectable ffprobe (default final-media probeMedia) — magnific-precision preflight. */ probe?: (path: string) => Promise; /** Injectable Magnific upscale submit (default native-magnific submitMagnificUpscale). */ submitUpscale?: (videoUrl: string, params: MagnificUpscaleParams) => Promise; /** Injectable Magnific upscale await (default native-magnific awaitMagnificUpscale). */ awaitUpscale?: (taskId: string) => Promise; /** Injectable normalizer used when --normalize re-encodes an over-limit input (default ffmpeg). */ normalizeInput?: (input: string, output: string) => Promise; /** Local Topaz CLI path (else VCLAW_TOPAZ_CLI); used by topaz-local. */ topazCliPath?: string; /** Injectable local runner (default execFile) — for offline tests. */ runLocal?: (command: string[]) => Promise; /** Injectable runway-topaz-free asset upload (default uploadRunwayUpscaleAsset). */ runwayUpload?: (input: { filePath: string; name: string }) => Promise<{ assetId: string }>; /** Injectable runway-topaz-free submit (default submitRunwayUpscale, exploreMode on). */ runwaySubmit?: (input: { videoAssetId: string; exploreMode?: boolean }) => Promise<{ taskUuid: string; status: string }>; /** Injectable runway-topaz-free await (default awaitRunwayUpscale — polls the NAMESPACED id). */ runwayAwait?: (input: { assetId: string; taskUuid: string }) => Promise<{ outputUrl: string }>; } export interface RunFinishResult { backend: FinishBackend; hosted: boolean; output: string; dryRun: boolean; /** The hosted request params (Topaz or Magnific) for logging/repro. */ params?: (TopazParams | MagnificUpscaleParams) & { video_url?: string }; /** The local CLI command (topaz-local). */ command?: string[]; /** The hosted task id (real run). */ taskId?: string; /** The public input URL the backend fetched (real hosted run). */ sourceUrl?: string; /** The remote output URL before download (real hosted run). */ outputUrl?: string; /** runway-topaz-free: the ≤40s chunk windows the source was split into. */ chunks?: ChunkWindow[]; } /** * Execute a finish pass. HOSTED Topaz: upload → submit `fal-ai/topaz/upscale/video` * → await → download. LOCAL `topaz-local`: shell the Topaz CLI via * {@link topazUpscalePlan}. `realesrgan-x4plus` is plan-only here (needs an * external frame pipeline) and throws an actionable error on a real run. On * `dryRun`, returns the resolved plan without spending. */ export async function runFinish(opts: RunFinishOptions): Promise { const { backend, input, output } = opts; if (backend === 'magnific-precision') { const params = magnificUpscaleParamsFor(opts); if (opts.dryRun) { return { backend, hosted: true, output, dryRun: true, params }; } const probe = opts.probe ?? ((p: string) => import('./final-media.js').then((m) => m.probeMedia(p))); const info = await probe(input); const violations = assertMagnificVideoInput(info, { normalize: false }).violations; const normalize = opts.normalize ?? false; if (violations.length > 0 && !normalize) { throw new Error( `finish (magnific-precision): input violates Magnific limits — ${violations.join('; ')}. ` + `Re-run with --normalize to auto-fit, or trim the clip.`, ); } // --normalize: actually re-encode the over-limit input to fit (not just skip the check). let uploadPath = input; if (violations.length > 0 && normalize) { const normalizeInput = opts.normalizeInput ?? (async (inp: string, out: string): Promise => { const dest = `${out}.magnific-src.mp4`; const runLocal = opts.runLocal ?? (async (command: string[]) => { await execFileAsync(command[0], command.slice(1)); }); await runLocal(magnificNormalizeCommand(inp, dest)); return dest; }); uploadPath = await normalizeInput(input, output); } // Magnific needs a direct-HTTPS, no-redirect host; uguu (finish's default elsewhere) makes its // video endpoints error, so the magnific-precision lane hosts the input on GoBananas R2. const upload = opts.upload ?? ((p: string) => uploadToGoBananasR2(p, { ...(opts.env ? { env: opts.env } : {}) })); const submitUpscale = opts.submitUpscale ?? ((videoUrl, p) => submitMagnificUpscale(videoUrl, { ...p }, { ...(opts.env ? { env: opts.env } : {}) })); const awaitUpscale = opts.awaitUpscale ?? ((taskId: string) => awaitMagnificUpscale(taskId, { ...(opts.env ? { env: opts.env } : {}) })); const download = opts.download ?? ((url, dest) => downloadMagnificFile(url, dest)); const sourceUrl = await upload(uploadPath); const taskId = await submitUpscale(sourceUrl, params); const outputUrl = await awaitUpscale(taskId); await download(outputUrl, output); return { backend, hosted: true, output, dryRun: false, params: { ...params, video_url: sourceUrl }, taskId, sourceUrl, outputUrl }; } if (backend === 'runway-topaz-free') { // FREE Runway/Topaz 4K via useapi exploreMode (hermes-do-launch recipe, // 2026-07-04). The three field traps live in native-runway-upscale.ts: // ≤40s per task (chunked here), NAMESPACED poll id (derived from the // assetId), and audio stripped by the upscale (original re-muxed below). const probe = opts.probe ?? ((p: string) => import('./final-media.js').then((m) => m.probeMedia(p))); const info = await probe(input); const durationSec = info.durationSeconds ?? 0; if (durationSec <= 0) { throw new Error('finish (runway-topaz-free): could not probe the input duration.'); } const fpsMatch = /^(\d+)(?:\/(\d+))?$/.exec(info.frameRate ?? ''); const fps = fpsMatch ? Number(fpsMatch[1]) / Number(fpsMatch[2] ?? 1) : 24; const chunks = planFinishChunks(durationSec, RUNWAY_UPSCALE_MAX_SECONDS, fps > 0 ? fps : 24); if (opts.dryRun) { return { backend, hosted: true, output, dryRun: true, chunks }; } const runLocal = opts.runLocal ?? (async (command: string[]) => { await execFileAsync(command[0], command.slice(1)); }); const upload = opts.runwayUpload ?? ((i: { filePath: string; name: string }) => uploadRunwayUpscaleAsset(i, { ...(opts.env ? { env: opts.env } : {}) })); const submit = opts.runwaySubmit ?? ((i: { videoAssetId: string; exploreMode?: boolean }) => submitRunwayUpscale(i, { ...(opts.env ? { env: opts.env } : {}) })); const awaitUpscale = opts.runwayAwait ?? ((i: { assetId: string; taskUuid: string }) => awaitRunwayUpscale(i, { ...(opts.env ? { env: opts.env } : {}) })); const download = opts.download ?? ((url: string, dest: string) => downloadRunwayUpscale(url, dest, { ...(opts.env ? { env: opts.env } : {}) })); const upscaledPaths: string[] = []; let lastTaskId = ''; for (const window of chunks) { // Video-only, frame-exact chunk extraction (the upscale drops audio // anyway; uploading it just wastes bytes). crf 16 keeps the upscaler fed // with near-source quality. const chunkSrc = `${output}.runway-chunk-${window.index}.mp4`; await runLocal([ 'ffmpeg', '-nostdin', '-y', '-v', 'error', '-ss', String(window.startSec), '-t', String(window.durationSec), '-i', input, '-an', '-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-pix_fmt', 'yuv420p', chunkSrc, ]); const { assetId } = await upload({ filePath: chunkSrc, name: `vclaw-finish-chunk-${window.index}` }); const { taskUuid } = await submit({ videoAssetId: assetId, exploreMode: true }); lastTaskId = taskUuid; const { outputUrl } = await awaitUpscale({ assetId, taskUuid }); const upscaledDest = `${output}.runway-up-${window.index}.mp4`; await download(outputUrl, upscaledDest); upscaledPaths.push(upscaledDest); } // Reassemble: concat the upscaled chunks (filter — re-encode, drift-free) // and re-mux the ORIGINAL input's audio (the upscale strips it). `a?` // keeps a silent source working. if (upscaledPaths.length === 1) { await runLocal([ 'ffmpeg', '-nostdin', '-y', '-v', 'error', '-i', upscaledPaths[0], '-i', input, '-map', '0:v', '-c:v', 'copy', '-map', '1:a?', '-c:a', 'copy', '-movflags', '+faststart', output, ]); } else { const inputArgs = upscaledPaths.flatMap((p) => ['-i', p]); const filter = `${upscaledPaths.map((_, i) => `[${i}:v]`).join('')}concat=n=${upscaledPaths.length}:v=1:a=0[v]`; await runLocal([ 'ffmpeg', '-nostdin', '-y', '-v', 'error', ...inputArgs, '-i', input, '-filter_complex', filter, '-map', '[v]', '-c:v', 'libx264', '-preset', 'slow', '-crf', '17', '-pix_fmt', 'yuv420p', '-map', `${upscaledPaths.length}:a?`, '-c:a', 'copy', '-movflags', '+faststart', output, ]); } return { backend, hosted: true, output, dryRun: false, chunks, taskId: lastTaskId }; } if (isHostedBackend(backend)) { const params = { ...topazParamsFor(opts) }; if (opts.dryRun) { return { backend, hosted: true, output, dryRun: true, params }; } const upload = opts.upload ?? (async (p) => (await hostPublicUrl(p, { ...(opts.env ? { env: opts.env } : {}) })).url); const submit = opts.submit ?? ((modelId, p) => xskillSubmit(modelId, p, { ...(opts.apiKey ? { apiKey: opts.apiKey } : {}), ...(opts.env ? { env: opts.env } : {}) })); const awaitTask = opts.awaitTask ?? (async (taskId: string): Promise => { const done = await xskillAwait(taskId, { ...(opts.apiKey ? { apiKey: opts.apiKey } : {}), ...(opts.env ? { env: opts.env } : {}), ...opts.awaitOptions }); return done.videoUrl; }); const download = opts.download ?? ((url, dest) => xskillDownload(url, dest)); const sourceUrl = await upload(input); const submitParams: Record = { ...params, video_url: sourceUrl }; const taskId = await submit(TOPAZ_MODEL_ID, submitParams); const outputUrl = await awaitTask(taskId); await download(outputUrl, output); return { backend, hosted: true, output, dryRun: false, params: { ...params, video_url: sourceUrl }, taskId, sourceUrl, outputUrl }; } if (backend === 'topaz-local') { const plan = topazUpscalePlan(input, output, { enabled: true, ...(opts.topazCliPath ? { cliPath: opts.topazCliPath } : {}), ...(opts.scale !== undefined ? { scale: opts.scale } : {}), }); if (!plan.run) { throw new Error(`finish (topaz-local): ${plan.reason}. Set --topaz-cli or VCLAW_TOPAZ_CLI.`); } if (opts.dryRun) { return { backend, hosted: false, output, dryRun: true, command: plan.command }; } const runLocal = opts.runLocal ?? (async (command) => { await execFileAsync(command[0], command.slice(1)); }); await runLocal(plan.command); return { backend, hosted: false, output, dryRun: false, command: plan.command }; } // realesrgan-x4plus: the executing frame pipeline is not wired here. A real // run throws an actionable error — but --dry-run must still PLAN (the whole // point of the plan-only contract is previewing a backend choice for free). if (opts.dryRun) { return { backend, hosted: false, output, dryRun: true }; } throw new Error( `finish: backend "${backend}" has no executor yet. Use a hosted Topaz backend ` + `(topaz-proteus / topaz-gaia / topaz-starlight) or topaz-local.`, ); }