/** * 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'; import { resolveFlowMediaUrl, flowRawAssetUrl, type FlowMediaFetchLike } from '../flow-media-url.js'; export type GoogleFlowFetchLike = ( url: string, init: { method: string; headers: Record; body?: string }, ) => Promise<{ ok: boolean; status: number; text(): Promise; /** * Optional so test fakes can omit it; real fetch always provides it. Without * it a 503's stated `Retry-After` is invisible and the caller falls back to a * fixed 10 s — which is exactly the tight retry loop that lengthens a Google * link block rather than shortening it. */ headers?: { get(name: string): string | null }; }>; export const GOOGLE_FLOW_BASE = 'https://api.useapi.net/v1/google-flow'; /** * Upscale targets. `720p` arrived with Omni 1.1 Flash (2026-08-26) to PROMOTE a * clip generated at `resolution: 360p`; `720p` and `1080p` are both FREE on a * paid Google AI plan, and a 360p clip can go straight to `1080p` without * stopping at 720p. `4K` costs 50 credits and needs Ultra. * * Case: the API spells it `4K`. We accept `4k` from operators/CLI (the sidecar * and our own cli-schema have long documented lowercase) and normalize on the * wire — see {@link normalizeFlowUpscaleResolution}. */ export type GoogleFlowUpscaleResolution = '720p' | '1080p' | '4K'; /** Free on any paid plan: no credits, so it needs no spend gate. */ export const FREE_FLOW_UPSCALE_RESOLUTIONS: readonly GoogleFlowUpscaleResolution[] = ['720p', '1080p']; /** * Accept either casing of 4K and either free tier, and return the spelling the * API expects. Throws on anything else rather than defaulting — a silent fall * back to 1080p would hide an operator asking for 4K and quietly not get it. */ export function normalizeFlowUpscaleResolution(value: string): GoogleFlowUpscaleResolution { const v = value.trim().toLowerCase(); if (v === '720p') return '720p'; if (v === '1080p') return '1080p'; if (v === '4k') return '4K'; throw new Error(`google-flow upscale: resolution must be 720p|1080p|4K, got ${JSON.stringify(value)}`); } 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. '720p'/'1080p' (default) are 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 5); 0 opts out (no field sent). */ captchaRetry?: number; /** * Ceiling on re-asking for a withheld download link after a successful upscale * (default 3 min). Its own budget — a Google link block can outlast any sane * in-process wait. */ urlRecoveryMaxWaitMs?: 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]; let videoUrl = media?.videoUrl ?? (text.match(/https:\/\/[^"\\]+\.mp4[^"\\]*/) ?? [])[0]; // Since 2026-07-27 useapi omits `videoUrl` (rather than returning a dead link) // while Google rate-limits its signed-URL calls. The upscale itself succeeded, // so re-ask for the link by the upscaled clip's own id before giving up. if (!videoUrl && media?.mediaGenerationId) { videoUrl = (await resolveFlowMediaUrl({ mediaGenerationId: media.mediaGenerationId, apiToken: input.apiToken, fetchImpl: fetchImpl as unknown as FlowMediaFetchLike, ...(input.urlRecoveryMaxWaitMs !== undefined ? { maxWaitMs: input.urlRecoveryMaxWaitMs } : {}), })) ?? undefined; } if (!videoUrl) { const id = media?.mediaGenerationId; throw new Error( id ? `google-flow upscale succeeded but its download link is unavailable (Google is rate-limiting signed URLs). ` + `The clip exists — retry later, or stream it with GET ${flowRawAssetUrl(id)}.` : `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, }; }