/** * native-runway-upscale.ts — the FREE Runway/Topaz 4K video upscale on * useapi.net, as a `finish` backend transport (`runway-topaz-free`). * * Field-proven on the hermes-do-launch film (2026-07-04): true 4096×2304 * output in <40s per chunk, at $0 with `exploreMode` on a Runway Unlimited * plan. Three production traps this module ENCODES so no operator re-learns * them by hand (they cost the film run an hour of silent polling): * * 1. **40-second cap per task** — the endpoint rejects longer inputs. The * finish executor chunks the source via {@link planFinishChunks} (frame * aligned) and concats the upscaled chunks. * 2. **The poll id is NAMESPACED** — the submit response's `task.id` is a * BARE uuid and `GET /runwayml/tasks/` 400s ("incorrect format"). * The pollable id is `user:-runwayml:-task:`, and the * only reliable source of that namespace is the upload response's * `assetId` — {@link runwayTaskIdFromAssetId} derives it. A poller using * the bare uuid sits silent forever while the task is already SUCCEEDED. * 3. **The output has NO audio** — the upscale strips it; the finish * executor re-muxes the ORIGINAL input's audio track. * * Conventions match the other native transports: pure Node fetch + fs, * injectable `fetcher`/`sleep` for offline tests, `USEAPI_API_TOKEN` bearer * auth (the same token as runway-useapi / google-flow). */ import { readFile, writeFile } from 'node:fs/promises'; /** Hard per-task input cap of the useapi Runway upscale endpoint (seconds). */ export const RUNWAY_UPSCALE_MAX_SECONDS = 40; const USEAPI_BASE_URL = 'https://api.useapi.net/v1'; export interface RunwayUpscaleEnvOptions { env?: NodeJS.ProcessEnv; fetcher?: typeof fetch; } function resolveToken(env: NodeJS.ProcessEnv | undefined): string { const token = (env ?? process.env).USEAPI_API_TOKEN; if (!token || !token.trim()) { throw new Error( 'runway-topaz-free requires USEAPI_API_TOKEN (the same useapi.net token as the runway-useapi route).', ); } return token.trim(); } /** * Derive the NAMESPACED, pollable task id from the asset upload's `assetId` * and the submit response's bare `task.id`. PURE. * * `assetId` shape: `user:-runwayml:-asset:` — the same * `user:…-runwayml:…` prefix namespaces every task id on the account. * Polling the bare uuid instead 400s with "Parameter taskId has incorrect * format" (curl -sf then swallows the body, which is how the film run's * monitor watched a finished task in silence for an hour). */ export function runwayTaskIdFromAssetId(assetId: string, taskUuid: string): string { const match = /^(user:.+-runwayml:.+?)-asset:[0-9a-f-]+$/i.exec(assetId.trim()); if (!match) { throw new Error( `runwayTaskIdFromAssetId: cannot parse the account namespace from assetId "${assetId}" ` + '(expected "user:-runwayml:-asset:").', ); } const uuid = taskUuid.trim(); if (!uuid) { throw new Error('runwayTaskIdFromAssetId: empty task uuid.'); } return `${match[1]}-task:${uuid}`; } /** * Upload a local video as a Runway asset (raw binary body, NOT multipart). * Returns the namespaced `assetId`. */ export async function uploadRunwayUpscaleAsset( input: { filePath: string; name: string }, options: RunwayUpscaleEnvOptions = {}, ): Promise<{ assetId: string }> { const token = resolveToken(options.env); const doFetch = options.fetcher ?? fetch; const body = await readFile(input.filePath); const response = await doFetch( `${USEAPI_BASE_URL}/runwayml/assets/?name=${encodeURIComponent(input.name)}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'video/mp4' }, body, }, ); if (!response.ok) { throw new Error(`runway upscale asset upload failed with HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`); } const parsed = (await response.json()) as { assetId?: string; id?: string }; const assetId = parsed.assetId ?? parsed.id; if (!assetId) { throw new Error('runway upscale asset upload returned no assetId.'); } return { assetId }; } /** * Submit the upscale task for an uploaded asset. `exploreMode: true` is the * free lane (Runway Unlimited); without Unlimited the endpoint bills * 2 credits/second — which is why the finish handler keeps this backend * behind `--confirm-spend`. Returns the BARE task uuid (see * {@link runwayTaskIdFromAssetId} for why it is not directly pollable). */ export async function submitRunwayUpscale( input: { videoAssetId: string; exploreMode?: boolean }, options: RunwayUpscaleEnvOptions = {}, ): Promise<{ taskUuid: string; status: string }> { const token = resolveToken(options.env); const doFetch = options.fetcher ?? fetch; const response = await doFetch(`${USEAPI_BASE_URL}/runwayml/videos/upscale`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ videoAssetId: input.videoAssetId, exploreMode: input.exploreMode ?? true }), }); if (!response.ok) { throw new Error(`runway upscale submit failed with HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`); } const parsed = (await response.json()) as { task?: { id?: string; status?: string } }; const taskUuid = parsed.task?.id; if (!taskUuid) { throw new Error('runway upscale submit returned no task.id.'); } return { taskUuid, status: parsed.task?.status ?? 'PENDING' }; } /** One poll of a NAMESPACED task id. */ export async function pollRunwayUpscale( input: { namespacedTaskId: string }, options: RunwayUpscaleEnvOptions = {}, ): Promise<{ status: string; outputUrl: string | null }> { const token = resolveToken(options.env); const doFetch = options.fetcher ?? fetch; const response = await doFetch( `${USEAPI_BASE_URL}/runwayml/tasks/${encodeURIComponent(input.namespacedTaskId)}`, { headers: { Authorization: `Bearer ${token}` } }, ); if (!response.ok) { throw new Error(`runway upscale poll failed with HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`); } const parsed = (await response.json()) as { task?: { status?: string; artifacts?: Array<{ url?: string }> }; status?: string; artifacts?: Array<{ url?: string }>; }; const task = parsed.task ?? parsed; const status = task.status ?? 'UNKNOWN'; const outputUrl = task.artifacts?.[0]?.url ?? null; return { status, outputUrl }; } export interface AwaitRunwayUpscaleOptions extends RunwayUpscaleEnvOptions { sleep?: (ms: number) => Promise; intervalMs?: number; timeoutMs?: number; } /** * Poll a submitted upscale to a terminal state. `THROTTLED` is the explore * queue's normal "waiting" state (it can hold for many minutes — the free * lane queues), so it continues; SUCCEEDED resolves with the artifact URL; * FAILED / CANCELED throw. */ export async function awaitRunwayUpscale( input: { assetId: string; taskUuid: string }, options: AwaitRunwayUpscaleOptions = {}, ): Promise<{ outputUrl: string }> { const namespacedTaskId = runwayTaskIdFromAssetId(input.assetId, input.taskUuid); const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); const intervalMs = options.intervalMs ?? 15_000; const timeoutMs = options.timeoutMs ?? 3_600_000; const startedAt = Date.now(); for (;;) { const { status, outputUrl } = await pollRunwayUpscale({ namespacedTaskId }, options); if (status === 'SUCCEEDED') { if (!outputUrl) { throw new Error('runway upscale SUCCEEDED but returned no artifact url.'); } return { outputUrl }; } if (status === 'FAILED' || status === 'CANCELED' || status === 'CANCELLED') { throw new Error(`runway upscale ended ${status} for ${namespacedTaskId}.`); } if (Date.now() - startedAt > timeoutMs) { throw new Error( `runway upscale timed out after ${Math.round(timeoutMs / 1000)}s (last status: ${status}) — ` + 'the free explore queue can hold for hours; retry later or raise timeoutMs.', ); } await sleep(intervalMs); } } /** Download the upscaled artifact to `dest`. */ export async function downloadRunwayUpscale( url: string, dest: string, options: RunwayUpscaleEnvOptions = {}, ): Promise { const doFetch = options.fetcher ?? fetch; const response = await doFetch(url); if (!response.ok) { throw new Error(`runway upscale download failed with HTTP ${response.status}.`); } await writeFile(dest, Buffer.from(await response.arrayBuffer())); return dest; }