/** * useapi.net Backend for veo-cli * Uses useapi.net REST API for video generation */ import { join } from "path"; import { existsSync } from "fs"; import type { Operation, VideoAspectRatio } from "../../types"; import type { BackendInitOptions, DirectResources, HealthResult, VideoRequest, VideoGenerationResult, ImageUploadResult, AccountTier, CostEstimate, VideoBackend, UseApiVideoParams, UseApiVideoResponse, } from "../types"; import { UseApiClient, mapModelToUseApi, mapAspectRatioToUseApi, calculateCost, } from "./client"; import { log } from "../../config"; /** * Is the free Flow 1080p finish on? ON unless explicitly disabled, because the * standing instruction is that every Flow-generated clip gets it. Mirrors * `flowUpscaleEnabled` in the main repo's `src/video/flow-upscale-finish.ts` — * same env var, same accepted off-switches, so the two clients behave alike. */ export function flowUpscaleEnabled(noUpscaleFlag: boolean, env: Record = process.env): boolean { if (noUpscaleFlag) return false; const raw = (env.VCLAW_FLOW_UPSCALE ?? "").trim().toLowerCase(); return !(raw === "0" || raw === "false" || raw === "no" || raw === "off"); } /** * Target tier for the AUTOMATIC finish, from `VCLAW_FLOW_UPSCALE_RESOLUTION`. * * Free tiers only: `4K` costs 50 credits and needs Ultra, so it must never ride * in on an environment variable — one stale value in a shell profile would * otherwise bill every clip of every later run. Asking for 4K is a decision and * belongs at an explicit call site with a confirmation. * * Mirrors `resolveAutomaticFlowFinishResolution` in the main repo's * `src/video/flow-upscale-finish.ts`; the two clients must agree, because the * operator sets ONE variable and expects both to honour it. */ export function flowUpscaleTarget(env: Record = process.env): "720p" | "1080p" { return (env.VCLAW_FLOW_UPSCALE_RESOLUTION ?? "").trim().toLowerCase() === "720p" ? "720p" : "1080p"; } import { download, setUseApiAuthHeader } from "../../download"; /** * Normalize a Google Flow video response into a flat media list. * Prefers the current `media[]` (200 sync) shape; falls back to the legacy * `operations[].operation.metadata.video.fifeUrl` shape used by async + upscale. */ /** * Pull Google's own failure reason(s) out of a video response, for an error * message the operator can act on. Falls back to the generic phrasing when the * payload carries nothing useful. * * Mirrors `extractFailureReason` in the main repo's * `src/video/motion-overlay/v2v-transport.ts`, which already did this for the * V2V path; the sidecar's generate path was the one still discarding it. */ export function describeFlowFailures(response: unknown): string { const reasons = new Set(); const walk = (node: unknown): void => { if (Array.isArray(node)) { node.forEach(walk); return; } if (!node || typeof node !== "object") return; const o = node as Record; for (const r of (Array.isArray(o.failureReasons) ? o.failureReasons : [])) { if (typeof r === "string" && r) reasons.add(r); } const msg = (o.error as Record | undefined)?.message; if (typeof msg === "string" && /^PUBLIC_ERROR_/.test(msg)) reasons.add(msg); Object.values(o).forEach(walk); }; walk(response); if (reasons.size === 0) return "no video URLs returned"; const list = [...reasons].slice(0, 3).join(", "); // IP is checked FIRST and wins. The spec (2026-09-01) says a job can carry // both a terminal code and a classifier label, so an IP block can arrive // alongside a *_BLOCKED string — and then the probabilistic advice below is // exactly wrong: it tells the operator to resubmit something that can never // clear, because the flagged image is identical on every draw. const ipBlocked = [...reasons].some((r) => /IP_PROHIBITED|PUBLIC_ERROR_IP_INPUT_IMAGE/i.test(r)); if (ipBlocked) { return `${list}. Google's INTELLECTUAL-PROPERTY classifier flagged an input image ` + `(nothing to do with network addresses). This is the one refusal that never clears on ` + `retry — the image is unchanged, so every resubmit is flagged again. REPLACE the reference ` + `image: original photos of non-famous subjects pass where celebrity photos, film stills, ` + `product shots and copyrighted characters do not.`; } const moderation = [...reasons].some((r) => /BLOCKED|SAFETY|UNSAFE/i.test(r)); return moderation ? `${list}. Google's content filter refused this input; moderation is probabilistic, so RESUBMIT rather than rewriting the prompt.` : list; } export function transformVideoResponse( resp: UseApiVideoResponse ): Array<{ mediaGenerationId?: string; videoUrl?: string; thumbnailUrl?: string; seed?: number; status?: string; }> { if (resp.media && resp.media.length > 0) { return resp.media.map((m) => ({ mediaGenerationId: m.mediaGenerationId, videoUrl: m.videoUrl, thumbnailUrl: m.thumbnailUrl, seed: m.video?.generatedVideo?.seed, // Carry the per-item generation status: on the 200-sync shape it is the // ONLY thing separating "the link could not be minted yet" (recoverable) // from a genuinely failed generation (which never gets a URL). Without it // a real failure would be sent down the recovery path and burn its budget. status: m.mediaMetadata?.mediaStatus?.mediaGenerationStatus, })); } return (resp.operations ?? []).map((op) => ({ mediaGenerationId: op.mediaGenerationId, videoUrl: op.operation?.metadata?.video?.fifeUrl, thumbnailUrl: op.operation?.metadata?.video?.servingBaseUri, seed: op.operation?.metadata?.video?.seed, status: op.status, })); } /** * useapi.net Backend implementation * Uses REST API for video generation */ export class UseApiBackend implements VideoBackend { readonly name = "useapi" as const; readonly requiresBrowser = false; private options: BackendInitOptions; private client: UseApiClient | null = null; private accountEmail: string = ""; private accountTier: AccountTier = "unknown"; /** From the account's own model table — see getAccountHealth. undefined = not fetched. */ private freeModelAvailable: boolean | undefined = undefined; private initialized = false; private webhookUrl?: string; private skipConfirmation: boolean = false; constructor(options: BackendInitOptions) { this.options = options; this.webhookUrl = options.webhookUrl; this.skipConfirmation = options.skipConfirmation ?? false; } async initialize(): Promise { if (this.initialized) return; // Get configuration from environment or options const apiToken = this.options.config.useapi?.apiToken || process.env.USEAPI_API_TOKEN; const accountEmail = this.options.config.useapi?.accountEmail || process.env.USEAPI_ACCOUNT_EMAIL; const baseUrl = this.options.config.useapi?.baseUrl || process.env.USEAPI_BASE_URL; if (!apiToken) { throw new Error( "USEAPI_API_TOKEN environment variable is required.\n" + "Get your API token from https://useapi.net/dashboard" ); } if (!accountEmail) { throw new Error( "USEAPI_ACCOUNT_EMAIL environment variable is required.\n" + "This should be the Google account email registered with useapi.net" ); } this.accountEmail = accountEmail; // Create client this.client = new UseApiClient({ apiToken, accountEmail, baseUrl, }); // Let the downloader authenticate against useapi-hosted URLs. Only the raw // media stream needs this, and only when Google withholds a signed link; // the downloader scopes the header to api.useapi.net so it never reaches // Google's CDN. setUseApiAuthHeader(this.client.authHeader()); // Validate credentials by checking account health try { const health = await this.client.getAccountHealth(accountEmail); this.accountTier = health.tier; this.freeModelAvailable = health.freeModelAvailable; const tierLabel = health.paygateTier ?? health.tier; log(`useapi.net account: ${accountEmail} (${tierLabel}${health.freeModelAvailable ? ", free model available" : ""})`); if (health.status !== "active" && health.status !== "ok") { console.warn(`Warning: Account status is "${health.status}". ${health.message || ""}`); } } catch (error) { throw new Error( `Failed to validate useapi.net credentials: ${error instanceof Error ? error.message : String(error)}` ); } this.initialized = true; } async shutdown(): Promise { // No-op for REST API client this.initialized = false; } async checkHealth(): Promise { if (!this.initialized || !this.client) { return { healthy: false, message: "Backend not initialized", }; } try { const health = await this.client.getAccountHealth(this.accountEmail); return { healthy: health.status === "active" || health.status === "ok", message: health.message || health.status, accountTier: health.tier, ...(health.freeModelAvailable !== undefined ? { freeModelAvailable: health.freeModelAvailable } : {}), accountEmail: this.accountEmail, captchaCredits: health.captchaCredits, }; } catch (error) { return { healthy: false, message: error instanceof Error ? error.message : String(error), accountEmail: this.accountEmail, }; } } async getAccountTier(): Promise { if (!this.initialized || !this.client) { throw new Error("Backend not initialized"); } return this.accountTier; } async uploadImage(path: string, _mode: "frames" | "ingredients"): Promise { if (!this.initialized || !this.client) { throw new Error("Backend not initialized"); } // Resolve path const fullPath = path.startsWith("/") ? path : join(process.cwd(), path); if (!existsSync(fullPath)) { throw new Error(`Image file not found: ${path}`); } log(`Uploading image: ${path}`); const result = await this.client.uploadImage(fullPath); // Extract mediaGenerationId - API returns nested object { mediaGenerationId: { mediaGenerationId: "..." } } const mediaId = typeof result.mediaGenerationId === 'object' ? result.mediaGenerationId.mediaGenerationId : result.mediaGenerationId; return { mediaId, url: undefined, }; } async generateVideo(request: VideoRequest): Promise { if (!this.initialized || !this.client) { throw new Error("Backend not initialized"); } // Map parameters const model = mapModelToUseApi(request.model); const aspectRatio = mapAspectRatioToUseApi(request.aspectRatio); // The free model is gated on CAPABILITY, read from the account's own model // table, not on a tier enum. `accountTier` is hardcoded "unknown" (the API // never told us a tier we could map), so the old `!== "ultra"` check refused // this zero-credit model on every account, including the ones that list it. if (model === "veo-3.1-lite-low-priority" && this.freeModelAvailable !== true) { throw new Error( `Free model (veo-3.1-lite-low-priority) is not listed on this account's Flow model table` + (this.freeModelAvailable === undefined ? " (could not read the table — check useapi:health)" : "") + `.\nUse --model fast instead.` ); } // Show cost estimate and ask for confirmation (unless --yes flag) const outputCount = request.outputsPerPrompt ?? 1; const costEstimate = this.estimateCost(request); if (costEstimate && !this.skipConfirmation) { console.log(`\nEstimated cost: ${costEstimate.totalCredits} credits`); console.log(` ${outputCount} video(s) × ${model} = ${costEstimate.videoGenerationCredits} credits`); // Ask for confirmation const confirmed = await this.askConfirmation("Proceed? [Y/n] "); if (!confirmed) { throw new Error("Generation cancelled by user"); } } // Build request parameters const params: UseApiVideoParams = { email: this.accountEmail, prompt: request.prompt, model, aspectRatio: aspectRatio, }; // Add output count if specified if (request.outputsPerPrompt && request.outputsPerPrompt > 1) { params.count = request.outputsPerPrompt; } // Add seed if specified if (request.seed !== undefined) { params.seed = request.seed; } // Flow v1 extensions: thread duration, resolution, voice (referenceAudio_1), // refVideo (referenceVideo_1) through to the wire so validateFlowVideoRequest // sees them and the API receives them. Each stays an ABSENT key when unset, // so a request that does not use the flag posts a byte-identical body. if (request.duration !== undefined) { params.duration = request.duration; } if (request.resolution !== undefined) { params.resolution = request.resolution; } if (request.voice) { params.referenceAudio_1 = request.voice; } if (request.refVideo) { params.referenceVideo_1 = request.refVideo; } // Saved-character refs → character_1..7 (reusable identity + bundled voice). // Orthogonal to request.type, so applied for every mode (typically type:"text" // when characters supply the only visual reference). if (request.characterRefs && request.characterRefs.length > 0) { const slots = ["character_1", "character_2", "character_3", "character_4", "character_5", "character_6", "character_7"] as const; const bag = params as unknown as Record; request.characterRefs.slice(0, 7).forEach((ref, i) => { if (ref) bag[slots[i]] = ref; }); } // Add webhook if configured if (this.webhookUrl) { params.replyUrl = this.webhookUrl; params.replyRef = `veo-cli-${Date.now()}`; } // Handle image references based on request type if (request.type === "image") { // Upload image if needed let mediaId: string; if (request.startImageMediaId) { mediaId = request.startImageMediaId; } else if (request.startImagePath) { const result = await this.uploadImage(request.startImagePath, "frames"); mediaId = result.mediaId; } else { throw new Error("I2V request requires startImagePath or startImageMediaId"); } params.startImage = mediaId; } else if (request.type === "frames") { // Upload start and end frames let startMediaId: string; let endMediaId: string; if (request.startImageMediaId) { startMediaId = request.startImageMediaId; } else if (request.startImagePath) { const result = await this.uploadImage(request.startImagePath, "frames"); startMediaId = result.mediaId; } else { throw new Error("Frames request requires startImagePath or startImageMediaId"); } if (request.endImageMediaId) { endMediaId = request.endImageMediaId; } else if (request.endImagePath) { const result = await this.uploadImage(request.endImagePath, "frames"); endMediaId = result.mediaId; } else { throw new Error("Frames request requires endImagePath or endImageMediaId"); } params.startImage = startMediaId; params.endImage = endMediaId; } else if (request.type === "ingredients") { // Upload reference images (Veo ≤3, omni-flash ≤7 — the per-model budget is // enforced by validateFlowVideoRequest before submit). The API has exactly // 7 slots (referenceImage_1..7), so >7 fails fast BEFORE any upload spend. // NOTE: this count MUST mirror the truthy-length source selection below — // an empty referenceImageMediaIds array falls through to the paths, so // nullish coalescing alone would let 8+ paths bypass the guard, upload // them all (spend), and then silently drop the extras at slice(0, 7). const refSourceCount = request.referenceImageMediaIds && request.referenceImageMediaIds.length > 0 ? request.referenceImageMediaIds.length : request.referenceImagePaths?.length ?? 0; if (refSourceCount > 7) { throw new Error( `Ingredients mode supports at most 7 reference images (referenceImage_1..7, omni-flash only beyond 3); got ${refSourceCount}.` ); } const mediaIds: string[] = []; if (request.referenceImageMediaIds && request.referenceImageMediaIds.length > 0) { mediaIds.push(...request.referenceImageMediaIds); } else if (request.referenceImagePaths && request.referenceImagePaths.length > 0) { for (const imgPath of request.referenceImagePaths) { const result = await this.uploadImage(imgPath, "ingredients"); mediaIds.push(result.mediaId); } } else { throw new Error("Ingredients request requires referenceImagePaths or referenceImageMediaIds"); } // R2V on Veo is supported by all variants except veo-3.1-quality; // omni-flash supports R2V via _1..7 with its own validator path. if (model === "veo-3.1-quality") { throw new Error("R2V (Ingredients) mode is not supported on veo-3.1-quality; use fast, lite, lite-low-priority, or omni-flash."); } // Assign to individual referenceImage_N fields (API format). All 7 slots // are wired; validateFlowVideoRequest enforces the per-model budget // (referenceImage_4..7 are omni-flash only, Veo caps at 3), so a 4th ref // on a Veo model now FAILS FAST instead of being silently dropped. const referenceImageSlots = [ "referenceImage_1", "referenceImage_2", "referenceImage_3", "referenceImage_4", "referenceImage_5", "referenceImage_6", "referenceImage_7", ] as const; const refBag = params as unknown as Record; mediaIds.slice(0, 7).forEach((id, i) => { if (id) refBag[referenceImageSlots[i]] = id; }); } // Submit generation request log("Submitting video generation to useapi.net..."); const response = await this.client.generateVideo(params); // Check for API error if (response.error) { throw new Error(`useapi.net error: ${response.error}`); } // useapi.net returns synchronous response - video is already complete log(`Job ${response.jobId} completed.`); // Normalize the response — prefers media[] (200 sync), falls back to operations[] (async/upscale) let mediaItems = transformVideoResponse(response); // Free 1080p finish. Google's own upsampler costs 0 credits at 720p/1080p on // a paid plan and synthesizes real detail rather than sharpening, so every // generated clip should go through it. It has to happen HERE: the endpoint // takes a mediaGenerationId that Flow itself GENERATED, and this is the only // point in the pipeline holding one — everything downstream sees a file. mediaItems = await this.upscaleGeneratedVideos(mediaItems, request.noUpscale === true, params); // Build operations array compatible with existing download code const operations: Operation[] = []; for (const item of mediaItems) { let videoUrl = item.videoUrl; const status = item.status; const generationFailed = !!status && status.includes("FAILED"); // Since 2026-07-27 useapi omits `videoUrl` (rather than returning a dead // link) whenever Google is rate-limiting its signed-URL calls. The render // SUCCEEDED and was charged, so recover the link by media id instead of // dropping the item — which used to surface as the badly misleading // "All operations failed - no video URLs returned". if (!videoUrl && !generationFailed && item.mediaGenerationId) { log("Video generated but its download link was withheld; re-asking by media id..."); videoUrl = (await this.client.resolveMediaUrl(item.mediaGenerationId)) ?? undefined; if (!videoUrl) { // The link route is blocked too, so stream the bytes through useapi. // This URL needs the bearer token — download.ts attaches it for // api.useapi.net hosts only (never to a Google-signed CDN URL). log("Link still unavailable; downloading the video through useapi (raw stream)."); videoUrl = this.client.rawAssetUrl(item.mediaGenerationId); } } if (videoUrl && !generationFailed) { operations.push({ name: item.mediaGenerationId || `useapi-${response.jobId}`, done: true, metadata: { video: { state: "SUCCEEDED", }, }, operation: { metadata: { video: { fifeUrl: videoUrl, state: "SUCCEEDED", seed: item.seed, aspectRatio: params.aspectRatio, }, }, }, } as unknown as Operation); } else if (generationFailed) { log(`Operation failed: ${status}`); } else { log( `Skipping media ${item.mediaGenerationId ?? "(no id)"}: generation reported success ` + `but no download link could be produced or recovered.` ); } } if (operations.length === 0) { const unresolved = mediaItems .filter((m) => !m.status?.includes("FAILED") && !m.videoUrl) .map((m) => m.mediaGenerationId) .filter(Boolean); if (unresolved.length > 0) { // Naming the ids matters: the clips exist on Google's side and can be // pulled by hand once the block clears — cheaper than re-rendering. throw new Error( `Video generation succeeded but no download link could be produced ` + `(Google is rate-limiting signed URLs; this clears on its own). ` + `Recover later with GET /google-flow/assets/{id}: ${unresolved.join(", ")}` ); } // Surface Google's OWN reason instead of a generic sentence. Live-found // 2026-09-01: an omni-flash V2V edit came back with // failureReasons ["VIDEO_EDIT_BLOCKED"] / "PUBLIC_ERROR_VIDEO_EDIT", and // the operator saw only "All operations failed" — which says nothing about // what to DO. VIDEO_EDIT_BLOCKED is moderation (resubmit, do not // prompt-bisect); a quota or model error needs the opposite response. // Getting that wrong wastes either credits or an afternoon. throw new Error(`All operations failed - ${describeFlowFailures(response)}`); } return { operations, jobId: response.jobId, estimatedCredits: costEstimate?.totalCredits, }; } estimateCost(request: VideoRequest): CostEstimate | null { const model = mapModelToUseApi(request.model); const videoCount = request.outputsPerPrompt ?? 1; const duration = request.duration ?? 8; const cost = calculateCost(model, videoCount, duration, request.resolution); return { videoGenerationCredits: cost.credits, captchaCredits: 0, totalCredits: cost.credits, model, videoCount, }; } getDirectResources(): DirectResources | null { // useapi backend doesn't have direct resources return null; } /** * Upscale each freshly generated clip to 1080p through Google's own free * upsampler, swapping the item's URL + id for the upscaled one so the existing * download path writes the HD file. * * Rules, in order of importance: * 1. **Never fatal.** The generation is already CHARGED. Any failure leaves * the item exactly as it was and logs why — trading a paid clip for * nothing would be the worst outcome in this pipeline. * 2. Default ON; `--no-upscale` or `VCLAW_FLOW_UPSCALE=0|false|no|off` skips. * 3. 1080p only. 4K costs 50 credits and needs Ultra — never automatic. * 4. Idempotent: re-upscaling an id returns Google's cached result for free, * so a resumed run can re-enter a clip safely. * * SCOPE — the useapi backend ONLY. A clip generated on `--backend direct` * ships at the resolution it was generated at. * * CORRECTED 2026-09-02 — an earlier version of this note claimed the direct * backend "holds neither an API token nor a mediaGenerationId". Only the first * half is true. `generation.ts` passes Google's raw `operations[]` through * untouched from aisandbox-pa.googleapis.com, and those carry * `operation.metadata.video.mediaGenerationId` — which is why the Operation * type in src/types.ts declares it at both levels. The direct backend HAS the * id. What it lacks is the useapi transport that reaches * `POST /videos/upscale`. * * So the gap is a transport gap, and cross-wiring useapi is NOT the fix: that * endpoint only accepts an id from a Google account registered with useapi, * which is precisely the account whose owner would be running `--backend * useapi` in the first place. On the direct backend's actual audience it would * 400 on every clip — a per-clip warning stream, not a feature. * * The correct wiring is Google's OWN upsample on aisandbox-pa, called through * the same browser session that generated the clip: same account, same * cookies, no token. That endpoint's shape is not documented here and MUST NOT * be guessed — see docs/design/flow-native-upsample-discovery.md for the * one-pass capture recipe that would settle it. */ private async upscaleGeneratedVideos( mediaItems: ReturnType, noUpscale: boolean, params: { captchaRetry?: number } = {} ): Promise> { if (!flowUpscaleEnabled(noUpscale)) return mediaItems; const client = this.client; if (!client) return mediaItems; const target = flowUpscaleTarget(); const out: ReturnType = []; for (const item of mediaItems) { const failed = !!item.status && item.status.includes("FAILED"); if (failed || !item.mediaGenerationId) { out.push(item); continue; } try { const up = await client.upscaleVideo({ mediaGenerationId: item.mediaGenerationId, resolution: target, // Same auto-solve the generation submit uses. Without it the 403 // reCAPTCHA lands disproportionately on the finishing step, and // "every Flow clip gets 1080p" quietly degrades to "most do". ...(params.captchaRetry !== undefined ? { captchaRetry: params.captchaRetry } : {}), }); if (up.error) { throw new Error(typeof up.error === "string" ? up.error : JSON.stringify(up.error)); } const firstOperation = up.operations?.[0]; const firstMedia = up.media?.[0]; const videoMetadata = firstOperation?.operation?.metadata?.video; const upscaledId = firstOperation?.mediaGenerationId || videoMetadata?.mediaGenerationId || firstMedia?.mediaGenerationId; const upscaledUrl = firstMedia?.videoUrl || videoMetadata?.fifeUrl; // Require the URL, not just an id. An upscale that returns an id with // the link withheld (the 2026-07-27 signed-URL block hits upscales too) // used to overwrite the generation's own WORKING url with undefined — // turning a charged success into an unrecoverable one, because the // recovery below would then chase the _upsampled id instead. if (!upscaledUrl) { throw new Error("upscale returned no download URL"); } // Deliberately keep the ORIGINAL mediaGenerationId. Only the bytes we // download change; the id is what downstream hands back to Flow for // extend / chain-from-prev / referenceVideo_1, and an `_upsampled` id is // not documented as a valid INPUT to any of them. log(`Upscaled to ${target} (free)${up.cached ? " [cached]" : ""}: ${item.mediaGenerationId}`); out.push({ ...item, videoUrl: upscaledUrl }); } catch (err) { // Keep the generated clip, and SAY SO. A silent fallback here is how a // run quietly ships 720p while the operator believes it is HD. log( `1080p upscale failed for ${item.mediaGenerationId}; keeping the generated clip as-is. ` + `Reason: ${err instanceof Error ? err.message : String(err)}` ); out.push(item); } } return out; } /** * Ask user for confirmation (returns true if confirmed) */ private async askConfirmation(prompt: string): Promise { process.stdout.write(prompt); // Read from stdin const response = await new Promise((resolve) => { const stdin = process.stdin; stdin.setRawMode?.(false); stdin.resume(); stdin.setEncoding("utf8"); let data = ""; const onData = (chunk: string) => { data += chunk; if (data.includes("\n")) { stdin.removeListener("data", onData); resolve(data.trim().toLowerCase()); } }; stdin.on("data", onData); // Timeout after 30 seconds setTimeout(() => { stdin.removeListener("data", onData); resolve("n"); }, 30000); }); return response === "" || response === "y" || response === "yes"; } }