/** * google-flow.ts — UseAPI Google Flow (Veo) REST client. Reachable with the same * `USEAPI_API_TOKEN` as the other UseAPI routes (no cookie.json, no new key). * * Scope (for now): the FREE 1080p video UPSCALE. The Veo upsampler synthesizes * real detail (skin/dust/edges) rather than just sharpening, so it beats a local * lanczos finish — but it ONLY accepts a video Flow itself GENERATED, addressed * by `mediaGenerationId` (it rejects an externally uploaded/edited clip). So this * is the per-clip upscale at the GENERATION stage (right after a Flow i2v render, * when the mediaGenerationId is in hand), complementary to the file-based Topaz / * Real-ESRGAN `finish` of a stitched master. Re-upscaling the same id is cached * (no extra credits). 1080p is free on any plan; 4K costs 50 credits (Ultra only). */ import { resolveFlowCaptchaRetry, applyFlowCaptcha } from '../flow-captcha.js'; export type GoogleFlowFetchLike = ( url: string, init: { method: string; headers: Record; body?: string }, ) => Promise<{ ok: boolean; status: number; text(): Promise }>; export const GOOGLE_FLOW_BASE = 'https://api.useapi.net/v1/google-flow'; export type GoogleFlowUpscaleResolution = '1080p' | '4K'; export interface UpscaleFlowVideoInput { /** Bearer token (USEAPI_API_TOKEN). */ apiToken: string; /** A Flow-GENERATED video id (`user:..-email:..-video:..`). NOT an uploaded/edited file. */ mediaGenerationId: string; /** Target resolution. '1080p' (default) is free; '4K' costs 50 credits + Ultra. */ resolution?: GoogleFlowUpscaleResolution; fetchImpl?: GoogleFlowFetchLike; /** * Transient-retry budget for 429/5xx/network errors (default 2). Retrying is * SAFE here: useapi Flow endpoints throttle routinely, and re-upscaling the * same mediaGenerationId is cached server-side (no extra credits). */ maxRetries?: number; /** Base backoff between retries, ms (default 1000; grows linearly). */ retryBackoffMs?: number; /** * captcha-retry auto-solve count (`captchaRetry`). Omit → VCLAW_FLOW_CAPTCHA_RETRY * (default 3); 0 opts out (no field sent). */ captchaRetry?: number; } const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); export interface UpscaleFlowVideoResult { /** Signed MP4 URL of the upscaled video (download promptly — it expires). */ videoUrl: string; /** The upscaled clip's own Flow media id (re-upscale/chain target). */ mediaGenerationId: string | null; /** Credits left after the call (1080p leaves it unchanged — it's free). */ remainingCredits: number | null; raw: unknown; } /** * Upscale a Flow-generated video (synchronous; 30–60s). Throws on a non-2xx * response or a body without a video URL. The `mediaGenerationId` MUST be a Flow * generation — an external/edited clip is rejected by the API. */ export async function upscaleFlowVideo(input: UpscaleFlowVideoInput): Promise { const fetchImpl = input.fetchImpl ?? (globalThis.fetch as unknown as GoogleFlowFetchLike); const maxRetries = input.maxRetries ?? 2; const backoffMs = input.retryBackoffMs ?? 1000; const body = JSON.stringify(applyFlowCaptcha( { mediaGenerationId: input.mediaGenerationId, resolution: input.resolution ?? '1080p', } as Record, input.captchaRetry ?? resolveFlowCaptchaRetry(process.env), )); // 429/503 are routine operational statuses on useapi Flow endpoints; a // re-upscale of the same id is cached (free), so transient retry is safe. let status = 0; let text = ''; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const response = await fetchImpl(`${GOOGLE_FLOW_BASE}/videos/upscale`, { method: 'POST', headers: { Authorization: `Bearer ${input.apiToken}`, 'Content-Type': 'application/json' }, body, }); status = response.status; text = await response.text(); if (response.ok) break; } catch (err) { status = 0; text = (err as Error).message ?? 'network error'; } const transient = status === 0 || status === 429 || status >= 500; if (!transient || attempt === maxRetries) { throw new Error(`google-flow upscale failed (${status || '?'}): ${text.slice(0, 300)}`); } await sleep(backoffMs * (attempt + 1)); } // Flow embeds literal newlines inside prompt fields, so parse the WHOLE body // (never line-split). Fall back to a regex for the signed URL if JSON is odd. let parsed: { media?: Array<{ videoUrl?: string; mediaGenerationId?: string }>; remainingCredits?: number } = {}; try { parsed = JSON.parse(text); } catch { /* fall through to regex */ } const media = parsed.media?.[0]; const videoUrl = media?.videoUrl ?? (text.match(/https:\/\/[^"\\]+\.mp4[^"\\]*/) ?? [])[0]; if (!videoUrl) { throw new Error(`google-flow upscale returned no videoUrl: ${text.slice(0, 300)}`); } return { videoUrl, mediaGenerationId: media?.mediaGenerationId ?? null, remainingCredits: typeof parsed.remainingCredits === 'number' ? parsed.remainingCredits : null, raw: parsed, }; }