/** * flow-upscale-finish.ts — the free Google Flow 1080p finish, applied to a clip * we just generated on a Flow route. * * ## Why this exists * * `providers/google-flow.ts` has had a working `upscaleFlowVideo()` since June * 2026 with **no production caller** — every Flow render shipped at the * resolution it was generated at while a free 1080p upsample sat one call away. * Google's Veo upsampler synthesizes real detail (skin, dust, edges) rather than * sharpening, so it beats any local filter on the same source, and it costs * nothing at 720p/1080p on a paid plan. * * ## The constraint that shapes everything here * * `POST /videos/upscale` accepts **only a mediaGenerationId Flow itself * generated**. Uploading a finished clip as an asset succeeds and then upscale * returns `400 INVALID_ARGUMENT` — so there is NO API route to upscale a * hand-edited cut, a stitched master, or a clip from Seedance/Runway/Dreamina. * That is why this runs **per clip at the generation stage** and why the * file-based `finish` lane (Topaz / Real-ESRGAN / Magnific) still exists for * everything else. The two are complementary, not alternatives. * * ## Rules this module encodes * * - **Never fatal.** A failed upscale keeps the original clip and reports the * reason. The render was already CHARGED; losing it to a free finishing step * would be the worst possible trade. * - **Idempotent.** Re-upscaling the same id returns Google's cached result and * costs nothing, so the resumable drivers can re-enter a scene safely. * - **Free tiers only by default.** 720p and 1080p cost 0 credits; `4K` costs 50 * and needs Ultra, so it is never the automatic choice. * - **A 360p clip can go straight to 1080p** — it does not have to stop at 720p. * * Spec: https://useapi.net/docs/api-google-flow-v1/post-google-flow-videos-upscale */ import { mkdir, rename, rm, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { upscaleFlowVideo, normalizeFlowUpscaleResolution, FREE_FLOW_UPSCALE_RESOLUTIONS, type GoogleFlowUpscaleResolution, type GoogleFlowFetchLike, } from './providers/google-flow.js'; import { recoverFlowVideoBytes, type FlowMediaFetchLike } from './flow-media-url.js'; /** Default finish target: free on a paid plan, and reachable directly from 360p. */ export const FLOW_FINISH_DEFAULT_RESOLUTION: GoogleFlowUpscaleResolution = '1080p'; export interface FlowUpscaleFinishInput { /** Flow-GENERATED video id. An uploaded/edited clip cannot be upscaled. */ mediaGenerationId: string; /** Where the generated clip already sits; overwritten in place on success. */ outputPath: string; apiToken: string; /** Target resolution (default 1080p). '4K' spends 50 credits and needs Ultra. */ resolution?: GoogleFlowUpscaleResolution; /** * Injectable fetch. Typed as the media-fetch shape because this module both * POSTs the upscale and DOWNLOADS bytes; that interface is the superset (it * carries `arrayBuffer`), and it is cast down for the submit call. */ fetchImpl?: FlowMediaFetchLike; sleepImpl?: (ms: number) => Promise; /** Set false to skip entirely (env/flag opt-out). Default true. */ enabled?: boolean; /** * Injectable ffprobe. Used ONLY to confirm the upscale did not drop the audio * track — these are lip-sync lanes, and a silent HD clip is worse than a 720p * one with sound. Omit to use the real probe; a probe that is unavailable or * throws is treated as "cannot tell" and the swap stands. */ probeImpl?: (path: string) => Promise<{ hasAudio?: boolean; durationSec?: number }>; } export interface FlowUpscaleFinishResult { /** True only when the file on disk was actually replaced with the upscale. */ upscaled: boolean; resolution?: GoogleFlowUpscaleResolution; /** The upscaled clip's own Flow id (chainable), when we got one. */ mediaGenerationId?: string; videoUrl?: string; /** Why it did not happen — 'disabled', 'no-media-id', or the failure text. */ skippedReason?: string; } /** * Read the opt-out. `VCLAW_FLOW_UPSCALE=0|false|no|off` disables the automatic * finish; anything else (including unset) leaves it ON, because the operator's * standing instruction is that every Flow clip gets the free upscale. */ export function flowUpscaleEnabled(env: NodeJS.ProcessEnv = process.env): boolean { const raw = (env.VCLAW_FLOW_UPSCALE ?? '').trim().toLowerCase(); return !(raw === '0' || raw === 'false' || raw === 'no' || raw === 'off'); } /** Read the target from `VCLAW_FLOW_UPSCALE_RESOLUTION`, defaulting to 1080p. */ export function resolveFlowFinishResolution( env: NodeJS.ProcessEnv = process.env, ): GoogleFlowUpscaleResolution { const raw = (env.VCLAW_FLOW_UPSCALE_RESOLUTION ?? '').trim(); if (raw === '') return FLOW_FINISH_DEFAULT_RESOLUTION; return normalizeFlowUpscaleResolution(raw); } /** * The target for an AUTOMATIC post-render finish — always a free tier. * * `4K` costs 50 credits and needs Ultra, so it must never ride in on an * environment variable: one `VCLAW_FLOW_UPSCALE_RESOLUTION=4k` left in a shell * profile would otherwise spend 50 credits on every clip of every subsequent * render, unprompted and invisible in the plan. Asking for 4K is a decision, and * it belongs at the explicit `vclaw video finish --flow-resolution 4K` call * site, where `--confirm-spend` gates it. */ export function resolveAutomaticFlowFinishResolution( env: NodeJS.ProcessEnv = process.env, ): GoogleFlowUpscaleResolution { const requested = resolveFlowFinishResolution(env); return isFreeFlowUpscale(requested) ? requested : FLOW_FINISH_DEFAULT_RESOLUTION; } /** True when the target costs no credits (720p/1080p) — i.e. needs no spend gate. */ export function isFreeFlowUpscale(resolution: GoogleFlowUpscaleResolution): boolean { return FREE_FLOW_UPSCALE_RESOLUTIONS.includes(resolution); } /** * Upscale a just-generated Flow clip and overwrite it in place. * * NEVER throws for an upscale failure: the caller has a charged, valid clip on * disk and must keep it. The result says whether the swap happened, so a caller * that records per-clip dimensions can report a mixed-resolution timeline rather * than assuming every clip rose together. */ export async function finishFlowClip(input: FlowUpscaleFinishInput): Promise { if (input.enabled === false) return { upscaled: false, skippedReason: 'disabled' }; if (!input.mediaGenerationId) { // Not an error: some paths legitimately never learn the id (a withheld // response, a hand-supplied file). The clip stands as generated. return { upscaled: false, skippedReason: 'no-media-id' }; } const resolution = input.resolution ?? FLOW_FINISH_DEFAULT_RESOLUTION; try { const up = await upscaleFlowVideo({ apiToken: input.apiToken, mediaGenerationId: input.mediaGenerationId, resolution, ...(input.fetchImpl ? { fetchImpl: input.fetchImpl as unknown as GoogleFlowFetchLike } : {}), }); // Pull the bytes. The same 2026-07-27 signed-URL block that hits generation // hits upscales, so reuse the shared recovery chain rather than trusting the // URL — a withheld link here is not a failed upscale. let bytes: Buffer | undefined; if (up.videoUrl) { const fetchImpl = input.fetchImpl ?? (globalThis.fetch as unknown as FlowMediaFetchLike); const res = await fetchImpl(up.videoUrl, { method: 'GET' }); if (res.ok) bytes = Buffer.from(await res.arrayBuffer()); } if (!bytes && up.mediaGenerationId) { const recovered = await recoverFlowVideoBytes({ mediaGenerationId: up.mediaGenerationId, apiToken: input.apiToken, ...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}), ...(input.sleepImpl ? { sleepImpl: input.sleepImpl } : {}), }); bytes = recovered.bytes; } if (!bytes || bytes.length === 0) { return { upscaled: false, skippedReason: 'upscale returned no downloadable bytes' }; } // Stage beside the clip, inspect, and only then swap by ATOMIC RENAME. The // generated clip is already charged, so it must never be the thing at risk: // a direct overwrite that fails partway (ENOSPC, EIO, a killed process) // would truncate a paid render and — because this function reports failures // as a non-fatal `upscaled: false` — the driver would mark the scene // complete over a corrupt file. Writing the temp first means the only way // the original changes is a rename that succeeded. await mkdir(dirname(input.outputPath), { recursive: true }); const staged = `${input.outputPath}.upscale-tmp`; try { await writeFile(staged, bytes); const audioCheck = await assertAudioSurvived(input, staged); if (audioCheck !== undefined) { return { upscaled: false, skippedReason: audioCheck }; } await rename(staged, input.outputPath); } finally { // Leaves nothing behind on any path, including the rename having already // consumed it (rm is a no-op then). await rm(staged, { force: true }).catch(() => {}); } return { upscaled: true, resolution, ...(up.mediaGenerationId ? { mediaGenerationId: up.mediaGenerationId } : {}), ...(up.videoUrl ? { videoUrl: up.videoUrl } : {}), }; } catch (err) { return { upscaled: false, skippedReason: err instanceof Error ? err.message : String(err), }; } } /** * Compare the STAGED upscale against the clip still on disk: does the upscale * still carry the audio the original had? Returns a reason string when the swap * must be abandoned, or `undefined` when it should go ahead. * * Both files exist at this point (the original has not been touched yet), so * this is a straight read of each — no temp-file dance, nothing to restore. * * Deliberately fail-open: no probe, or an unprobeable file, means "cannot tell", * and a clip we cannot check is not a clip to throw away. It only refuses on a * POSITIVE finding — the original had audio and the upscale does not. */ async function assertAudioSurvived( input: FlowUpscaleFinishInput, stagedPath: string, ): Promise { const probe = input.probeImpl ?? (async (p: string) => { const { probeMedia } = await import('./final-media.js'); const info = await probeMedia(p); return { hasAudio: info.audioPresent, durationSec: info.durationSeconds }; }); try { const after = await probe(stagedPath); if (after.hasAudio !== false) return undefined; const before = await probe(input.outputPath); if (before.hasAudio === true) { return 'upscaled clip lost its audio track; kept the generated clip (these are lip-sync lanes)'; } return undefined; } catch { // Unprobeable (no ffprobe on this machine, odd container). Cannot tell, so // the swap stands — the alternative is discarding good HD on no evidence. return undefined; } } /** A flag-validation failure carrying the CLI error code the handler should use. */ export interface FlowFinishFlagError extends Error { code?: 'missing_required_flag' | 'invalid_flag_value'; details?: Record; } function flagError( code: FlowFinishFlagError['code'], message: string, details: Record, ): FlowFinishFlagError { const err = new Error(message) as FlowFinishFlagError; err.code = code; err.details = details; return err; } /** * Validate the `vclaw video finish` flags for (and against) the `google-flow` * backend, returning the resolved upscale target. * * The asymmetry it enforces: google-flow takes `--media-id` and NO `--input`, * because Google upscales a clip Flow itself generated, addressed by id — an * uploaded or stitched file is rejected with 400 INVALID_ARGUMENT. Every other * backend is file-based and must not accept `--media-id`. Mismatched flags are * rejected rather than ignored, matching gen-image's strict handling of * backend-incompatible flags. */ export function validateFlowFinishFlags(input: { backend: string; mediaIdBackend: boolean; input?: string | undefined; output?: string | undefined; mediaId?: string | undefined; resolutionFlag?: string | undefined; }): GoogleFlowUpscaleResolution | undefined { const missing: string[] = []; if (input.mediaIdBackend) { if (!input.mediaId) missing.push('--media-id'); if (!input.output) missing.push('--output'); if (missing.length > 0) { throw flagError( 'missing_required_flag', `video finish --backend ${input.backend} requires --media-id and --output ` + '(it upscales a Flow-GENERATED clip by id; an uploaded or stitched file is rejected by Google)', { missing }, ); } } else { if (input.mediaId !== undefined) { throw flagError( 'invalid_flag_value', `video finish: --media-id only applies to --backend google-flow (got --backend ${input.backend})`, { flag: '--media-id' }, ); } if (!input.input) missing.push('--input'); if (!input.output) missing.push('--output'); if (missing.length > 0) { throw flagError('missing_required_flag', 'video finish requires --input