/** * HTTP Client for useapi.net REST API * Handles authentication, requests, and response parsing * Includes caching layer for image uploads */ import { readFile } from "fs/promises"; import { existsSync, statSync } from "fs"; import { isQuietMode, log } from "../../config"; import { getImageCache, setImageCache, hashFileContents } from "../../db-unified"; import { FLOW_IMAGE_MODELS, FLOW_IMAGE_MODEL_ALIASES, FLOW_IMAGE_DEFAULT_MODEL, FLOW_IMAGE_MAX_REFERENCES, } from "../types"; import type { FlowVideoModel, FlowDuration, FlowAspectRatio, FlowImageModel, FlowImageModelAlias, UseApiVideoParams, UseApiVideoResponse, UseApiJobResponse, UseApiAccountsResponse, UseApiImageUploadResponse, UseApiVideoUploadResponse, UseApiCaptchaConfig, UseApiCaptchaProvidersResponse, AccountTier, // Extended features types UseApiImageParams, UseApiImageResponse, UseApiImageResponseRaw, UseApiImageUpscaleParams, UseApiImageUpscaleResponse, UseApiVideoGifParams, UseApiVideoGifResponse, UseApiVideoUpscaleParams, UseApiVideoUpscaleResponse, UseApiVideoExtendParams, UseApiVideoConcatParams, UseApiVideoConcatResponse, UseApiCharacterParams, UseApiCharacterResponse, UseApiVoiceParams, UseApiVoiceResponse, UseApiAccountDetail, } from "../types"; // Timeout configuration const TIMEOUT = { generation: 600_000, // 10 minutes for video generation (I2V takes longer) polling: 600_000, // 10 minutes max polling time upload: 60_000, // 1 minute for image upload account: 180_000, // 3 minutes for account operations (registration requires Google OAuth validation) imageGen: 120_000, // 2 minutes for image generation upscale: 300_000, // 5 minutes for live upscaling operations gif: 60_000, // 1 minute for GIF conversion (no CAPTCHA, faster) other: 15_000, // 15 seconds for other calls }; // Polling configuration const POLL_INTERVAL = 3_000; // 3 seconds between polls // A completed job re-resolves a withheld media URL at most once a minute, and // the Google block clears only once traffic stops — so a tight retry loop // lengthens the wait. Applies ONLY after completion, never to in-progress polls. const JOB_REPOLL_MIN_MS = 60_000; // Ceiling on that post-completion wait. A link block can last hours; the raw // media route works throughout, so there is no reason to sit on this. const JOB_URL_REPOLL_MAX_MS = 3 * 60_000; // Retry configuration for rate limiting (429) and service unavailable (503) // Based on useapi.net docs: "wait 5-10 seconds before retrying" const RETRY = { maxAttempts: 5, // Maximum retry attempts initialDelayMs: 5_000, // Initial delay: 5 seconds (as recommended by useapi.net) maxDelayMs: 120_000, // Maximum delay: 2 minutes (increased for heavy rate limiting) backoffMultiplier: 1.5, // Exponential backoff multiplier jitterMs: 1_000, // Random jitter to avoid thundering herd }; // Sliding window configuration for adaptive rate limiting const SLIDING_WINDOW = { windowMs: 5 * 60 * 1000, // 5 minute window thresholds: { light: 3, // 3+ rate limits = light throttling moderate: 6, // 6+ rate limits = moderate throttling heavy: 10, // 10+ rate limits = heavy throttling }, multipliers: { light: 1.5, // 1.5x delay for light throttling moderate: 2.5, // 2.5x delay for moderate throttling heavy: 4.0, // 4x delay for heavy throttling }, }; /** * Retryable HTTP status codes * 429 = Rate Limited (temporary capacity) * 503 = Service Unavailable (temporary capacity) */ const RETRYABLE_STATUS_CODES = [429, 503]; /** * Sliding window rate limit tracker * Tracks rate limit events to adaptively adjust backoff delays */ class RateLimitTracker { private events: number[] = []; /** * Record a rate limit event */ recordEvent(): void { this.events.push(Date.now()); this.cleanup(); } /** * Remove events outside the sliding window */ private cleanup(): void { const cutoff = Date.now() - SLIDING_WINDOW.windowMs; this.events = this.events.filter(t => t > cutoff); } /** * Get count of rate limit events in the window */ getEventCount(): number { this.cleanup(); return this.events.length; } /** * Get delay multiplier based on recent rate limit frequency */ getDelayMultiplier(): number { const count = this.getEventCount(); if (count >= SLIDING_WINDOW.thresholds.heavy) { return SLIDING_WINDOW.multipliers.heavy; } else if (count >= SLIDING_WINDOW.thresholds.moderate) { return SLIDING_WINDOW.multipliers.moderate; } else if (count >= SLIDING_WINDOW.thresholds.light) { return SLIDING_WINDOW.multipliers.light; } return 1.0; // No throttling } /** * Get throttling level name for logging */ getThrottleLevel(): string { const count = this.getEventCount(); if (count >= SLIDING_WINDOW.thresholds.heavy) { return "heavy"; } else if (count >= SLIDING_WINDOW.thresholds.moderate) { return "moderate"; } else if (count >= SLIDING_WINDOW.thresholds.light) { return "light"; } return "none"; } } // Global rate limit tracker (shared across all requests) const rateLimitTracker = new RateLimitTracker(); /** * Sleep for a specified number of milliseconds */ async function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Calculate delay for retry attempt with exponential backoff, jitter, and adaptive throttling */ function calculateRetryDelay(attempt: number): number { const exponentialDelay = RETRY.initialDelayMs * Math.pow(RETRY.backoffMultiplier, attempt - 1); const jitter = Math.random() * RETRY.jitterMs; // Apply sliding window multiplier for adaptive throttling const multiplier = rateLimitTracker.getDelayMultiplier(); const adaptiveDelay = (exponentialDelay + jitter) * multiplier; return Math.min(adaptiveDelay, RETRY.maxDelayMs); } /** * Extract an explicit server-requested cooldown (in milliseconds) from a * 429/503 response, preferring the standard `Retry-After` header, then UseAPI's * Google Flow load-balancer `retryAfter` field (seconds) returned alongside * `no_eligible_account` / quota quarantine. Returns null when the server gave no * explicit window, so the caller falls back to exponential backoff. * * Honoring this makes us wait the REAL quarantine — UseAPI shortened the * PUBLIC_ERROR_USER_QUOTA_REACHED window to ~30 min on 2026-06-12 — instead of * guessing with blind backoff or (worse) hammering a long quarantine with early * retries that just re-trip it. */ export function parseRetryAfterMs( response: { headers: { get(name: string): string | null } }, parsedError: any, ): number | null { const header = response.headers.get("retry-after"); if (header) { const secs = Number(header); if (Number.isFinite(secs) && secs >= 0) return secs * 1000; const dateMs = Date.parse(header); if (!Number.isNaN(dateMs)) { const delta = dateMs - Date.now(); if (delta > 0) return delta; } } const field = parsedError?.retryAfter; if (typeof field === "number" && Number.isFinite(field) && field >= 0) { // Legacy form: seconds (small integers like 1800), never ms. return field * 1000; } if (typeof field === "string") { // Current form (useapi 2026-06-15): an ISO timestamp body field. const ms = Date.parse(field); if (!Number.isNaN(ms)) { const delta = ms - Date.now(); if (delta > 0) return delta; } } return null; } /** * useapi.net HTTP Client */ export class UseApiClient { private baseUrl: string; private apiToken: string; private accountEmail: string; constructor(config: { apiToken: string; accountEmail: string; baseUrl?: string }) { this.apiToken = config.apiToken; this.accountEmail = config.accountEmail; this.baseUrl = config.baseUrl || "https://api.useapi.net/v1"; } /** * Make an authenticated request to useapi.net with automatic retry for rate limiting * * Retry behavior (based on useapi.net docs): * - 429 (Rate Limited) and 503 (Service Unavailable) are automatically retried * - Initial wait: 5 seconds, exponential backoff up to 60 seconds * - Maximum 5 retry attempts before failing */ private async request( method: "GET" | "POST" | "PUT" | "DELETE", path: string, body?: any, timeoutMs: number = TIMEOUT.other ): Promise { let lastError: Error | null = null; let fatalError: Error | null = null; let consecutiveRateLimits = 0; for (let attempt = 1; attempt <= RETRY.maxAttempts; attempt++) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const url = `${this.baseUrl}${path}`; const headers: Record = { "Authorization": `Bearer ${this.apiToken}`, "Content-Type": "application/json", "Accept": "application/json", }; const response = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); const responseText = await response.text(); if (!response.ok) { // Try to parse error message from response let errorMessage = `HTTP ${response.status}: ${response.statusText}`; let parsedError: any = null; try { parsedError = JSON.parse(responseText); if (parsedError.error) { // Handle both string and object error formats errorMessage = typeof parsedError.error === "string" ? parsedError.error : JSON.stringify(parsedError.error); } else if (parsedError.message) { errorMessage = typeof parsedError.message === "string" ? parsedError.message : JSON.stringify(parsedError.message); } } catch { if (responseText) { errorMessage = responseText.substring(0, 200); } } // Check for retryable status codes (429, 503) if (RETRYABLE_STATUS_CODES.includes(response.status)) { consecutiveRateLimits++; rateLimitTracker.recordEvent(); // Track for adaptive throttling // Honor an explicit server cooldown (Retry-After header or UseAPI's // load-balancer `retryAfter` field on no_eligible_account / quota // quarantine) instead of blind backoff. A window longer than our max // in-process wait (e.g. the ~30-min USER_QUOTA quarantine, per // useapi's 2026-06-12 update) is failed fast with the real wait // rather than hammered with early retries that just re-trip it. const serverRetryAfterMs = parseRetryAfterMs(response, parsedError); if (serverRetryAfterMs !== null && serverRetryAfterMs > RETRY.maxDelayMs) { // Stop now rather than throw here: a throw is caught below, where // the `lastError && attempt < maxAttempts` guard would `continue` // and swallow the fail-fast on attempt >= 2. `break` runs the // finally (clearTimeout) and skips the catch; we throw it after // the loop. fatalError = new Error( `Rate limited by Flow load balancer: ${errorMessage}. ` + `Server quarantine window is ~${Math.round(serverRetryAfterMs / 60_000)} min — ` + `retry after that, or omit --email / add more accounts to widen the pool.` ); break; } const delay = serverRetryAfterMs ?? calculateRetryDelay(attempt); const throttleLevel = rateLimitTracker.getThrottleLevel(); // Log retry attempt (respects quiet mode) if (!isQuietMode()) { const throttleInfo = throttleLevel !== "none" ? ` [${throttleLevel} throttling: ${rateLimitTracker.getEventCount()} events in 5min]` : ""; console.log( `⏳ ${response.status === 429 ? "Rate limited" : "Service unavailable"} ` + `(attempt ${attempt}/${RETRY.maxAttempts}). Retrying in ${(delay / 1000).toFixed(1)}s...${throttleInfo}` ); // If heavy throttling, warn about cool-off if (throttleLevel === "heavy") { console.log( `⚠️ Heavy rate limiting detected. Delays automatically increased.` ); } } lastError = new Error( `Rate limited: ${errorMessage}. Attempt ${attempt}/${RETRY.maxAttempts}.` ); // Wait before retry (only if not the last attempt) if (attempt < RETRY.maxAttempts) { await sleep(delay); continue; } } // Non-retryable errors - fail immediately if (response.status === 401) { throw new Error(`Authentication failed: Invalid API token. Check USEAPI_API_TOKEN.`); } if (response.status === 403) { throw new Error(`Access denied: ${errorMessage}. Check your useapi.net subscription.`); } if (response.status === 402) { throw new Error(`Payment required: ${errorMessage}. Check your useapi.net balance.`); } throw new Error(errorMessage); } // Success - reset rate limit counter consecutiveRateLimits = 0; // Parse response try { return JSON.parse(responseText) as T; } catch { throw new Error(`Invalid JSON response from useapi.net: ${responseText.substring(0, 200)}`); } } catch (error) { // Handle abort/timeout separately if (error instanceof Error && error.name === "AbortError") { throw new Error(`Request timed out after ${timeoutMs / 1000}s`); } // If it's a retryable error we just set, continue to next iteration if (lastError && attempt < RETRY.maxAttempts) { continue; } throw error; } finally { clearTimeout(timeout); } } // Server told us to wait longer than we retry in-process — surface the real // quarantine window instead of the generic exhausted-retries message. if (fatalError) throw fatalError; // All retries exhausted throw lastError || new Error( `All ${RETRY.maxAttempts} retry attempts failed. ` + `The API may be experiencing high load. Try again later or use a longer cool-off period.` ); } /** * List all accounts registered with useapi.net * GET /google-flow/accounts * * Returns a map keyed by email address with account summaries. * Returns empty object {} if no accounts configured. * * Response includes for each account: * - health: "OK" or error description * - error: Error message if health check failed * - created: ISO 8601 creation timestamp * - sessionData.expires: Session expiration * - project.projectId/projectTitle: Auto-created project info * - nextRefresh.scheduledFor: Next session refresh time * * @throws Error 401 if API token is invalid */ async getAccounts(): Promise { return this.request("GET", "/google-flow/accounts"); } /** * ONE account, in detail. Unlike the collection endpoint above, this returns * the `credits` block — the REAL Google Flow balance and the account's paygate * tier — plus `models.videoModels[]`, where every entry carries Google's own * `creditCost` for that exact model/duration/resolution key. * * Nothing used to call this, which is why `getAccountHealth` reported a * hardcoded tier and printed the CAPTCHA allowance where an operator reads a * render budget. Live-verified 2026-09-01. */ async getAccount(email: string): Promise { return this.request( "GET", `/google-flow/accounts/${encodeURIComponent(email)}`, ); } /** * Get health status of a specific account * Uses the accounts endpoint and extracts health for the specified email */ async getAccountHealth(email: string): Promise<{ status: string; tier: AccountTier; captchaCredits?: number; message?: string; /** REAL Google Flow generation credits. Distinct from captchaCredits. */ flowCredits?: number; flowSubscriptionCredits?: number; flowTopUpCredits?: number; /** * Google's own tier strings, surfaced VERBATIM. Deliberately not mapped onto * AccountTier: no mapping table exists in this repo, and the one place that * touches paygate strings fabricates a value on failure (see api.ts). Report * what the API said; map it only once a live probe per tier confirms the enum. */ paygateTier?: string; sku?: string; serviceTier?: string; /** * Whether this account can run `veo-3.1-lite-low-priority`, the zero-credit * Veo model. Read straight from the account's own model table (a * `*_lite_low_priority` key being listed), NOT inferred from a tier enum — * the enum is what kept this model unreachable for months. */ freeModelAvailable?: boolean; /** The model keys the account lists — Google's own capability table. */ modelKeys?: string[]; }> { // Get all accounts and find the one matching the email const accounts = await this.getAccounts(); const accountInfo = accounts[email]; if (!accountInfo) { return { status: "not_found", tier: "unknown", message: `Account ${email} not registered with useapi.net`, }; } // Get CAPTCHA credits from captcha-providers endpoint let captchaCredits: number | undefined; try { const captchaResponse = await this.getCaptchaProviders(); if (typeof captchaResponse.freeCaptchaCredits === "number") { captchaCredits = captchaResponse.freeCaptchaCredits; } } catch { // Ignore captcha credits errors } // Determine status from health field const healthStatus = accountInfo.health === "OK" ? "active" : "error"; // The per-account endpoint carries the real balance and tier. Best-effort: // health must still report if this call fails. let detail: UseApiAccountDetail | undefined; try { detail = await this.getAccount(email); } catch { // fall through — the collection-derived health below is still valid } const credits = detail?.credits; const modelKeys = (detail?.models?.videoModels ?? []).map((m) => m.key).filter(Boolean); // Capability, stated by the API: if the account lists the low-priority model // at all, it can use it. This is what unlocks the free lane without ever // guessing what PAYGATE_TIER_TWO "means". const freeModelAvailable = modelKeys.some((k) => /lite_low_priority/.test(k)); return { status: healthStatus, // Still "unknown" as an AccountTier: see the note on paygateTier above. // The truthful strings are reported separately rather than guessed at. tier: "unknown", captchaCredits, ...(credits?.credits !== undefined ? { flowCredits: credits.credits } : {}), ...(credits?.subscriptionCredits !== undefined ? { flowSubscriptionCredits: credits.subscriptionCredits } : {}), ...(credits?.topUpCredits !== undefined ? { flowTopUpCredits: credits.topUpCredits } : {}), ...(credits?.userPaygateTier ? { paygateTier: credits.userPaygateTier } : {}), ...(credits?.sku ? { sku: credits.sku } : {}), ...(credits?.serviceTier ? { serviceTier: credits.serviceTier } : {}), ...(detail ? { freeModelAvailable, modelKeys } : {}), message: accountInfo.health !== "OK" ? accountInfo.health : undefined, }; } /** * Add a new Google account using cookies * POST /google-flow/accounts * * Registers a Google account for use with useapi.net video generation. * Each account auto-creates an associated Google Flow project. * Session tokens are automatically refreshed 1 hour prior to expiration. * * @param cookiesPath - Path to cookies file (JSON from Puppeteer or tab-separated from Chrome DevTools) * @param dryRun - If true, validate cookies without actually registering * * @returns Success indicator and message * * @throws Error with specific message for common failures: * - 400: Invalid or missing cookies * - 401: Invalid API token * - 402: Subscription expired or insufficient credits */ async addAccount(cookiesPath: string, dryRun: boolean = false): Promise<{ success: boolean; message: string }> { if (!existsSync(cookiesPath)) { throw new Error(`Cookie file not found: ${cookiesPath}`); } const cookiesContent = await readFile(cookiesPath, "utf-8"); let cookiesTable: string; // Check if it's JSON format (Puppeteer) or tab-separated (Chrome DevTools) const trimmed = cookiesContent.trim(); if (trimmed.startsWith("[") || trimmed.startsWith("{")) { // JSON format - convert to tab-separated const cookiesJson = JSON.parse(cookiesContent); cookiesTable = cookiesJson .map((c: { name: string; value: string; domain: string; path: string; expires?: number; size?: number; httpOnly?: boolean; secure?: boolean; sameSite?: string; priority?: string; }) => { const expires = c.expires && c.expires > 0 ? new Date(c.expires * 1000).toISOString() : "Session"; return [ c.name, c.value, c.domain, c.path, expires, c.size || c.value.length, c.httpOnly ? "✓" : "", c.secure ? "✓" : "", c.sameSite || "None", "", // Partition Key c.priority || "Medium", ].join("\t"); }) .join("\n"); } else { // Already tab-separated format from Chrome DevTools - use as-is cookiesTable = cookiesContent; } try { const response = await this.request>( "POST", "/google-flow/accounts", { cookies: cookiesTable, dryRun }, TIMEOUT.account ); // API returns { accountCookies, sessionCookies } on success (200 OK) // Normalize to { success, message } for callers if (response.accountCookies || response.sessionCookies) { const numAccount = Array.isArray(response.accountCookies) ? response.accountCookies.length : 0; const numSession = Array.isArray(response.sessionCookies) ? response.sessionCookies.length : 0; return { success: true, message: `Parsed ${numAccount} account cookies and ${numSession} session cookies`, }; } // Fallback: check for explicit success/message fields if ('success' in response) { return { success: Boolean(response.success), message: response.message || response.error || "", }; } // Unknown response format - throw with details for debugging throw new Error( `Unexpected API response format. Keys: ${Object.keys(response).join(', ')}. ` + `Response: ${JSON.stringify(response).substring(0, 300)}` ); } catch (error) { // Enhance error message for common OAuth issues const errMsg = error instanceof Error ? error.message : String(error); if (errMsg.includes("OAuth stuck") || errMsg.includes("login page") || errMsg.includes("signin/identifier")) { throw new Error( `Cookie registration failed: OAuth flow stuck on login page. This usually means the cookies weren't exported correctly. Follow these steps: 1. Clear ALL browser cookies first 2. Login to https://labs.google/fx/tools/flow FIRST 3. During 2FA, CHECK "Don't ask again on this device" 4. Then navigate to https://myaccount.google.com 5. Export cookies from accounts.google.com domain: - Open DevTools (F12) > Application > Cookies > accounts.google.com - Select all cookies (Ctrl/Cmd+A) and copy (Ctrl/Cmd+C) - Save as tab-separated text file 6. Run this command again with the new cookies file Original error: ${errMsg}` ); } if (errMsg.includes("Session") || errMsg.includes("expired") || errMsg.includes("invalid")) { throw new Error( `Cookie registration failed: Session appears to be invalid or expired. Your cookies may be stale. Please: 1. Clear browser cookies 2. Log in to Google Flow again 3. Export fresh cookies 4. Try registration again Original error: ${errMsg}` ); } throw error; } } /** * Configure CAPTCHA provider for all accounts * POST /google-flow/accounts/captcha-providers * * Providers are specified by their exact API names: * - "EzCaptcha" - Best success rate, ~$2.50/1000 * - "CapSolver" - Good alternative, ~$3.00/1000 * - "YesCaptcha" - Also supported * * Set apiKey to empty string "" to remove a provider. * * @param config.provider - Provider name (EzCaptcha, CapSolver, YesCaptcha) * @param config.apiKey - API key for the provider, or "" to remove * * @returns Response with configured/masked keys * * @throws Error with specific message for common failures: * - 400: Invalid provider name * - 401: Invalid API token */ async configureCaptcha( config: UseApiCaptchaConfig ): Promise { try { const body = { [config.provider]: config.apiKey, }; return await this.request( "POST", `/google-flow/accounts/captcha-providers`, body ); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); // 400 means invalid provider name if (errMsg.includes("400") || errMsg.includes("Bad Request")) { throw new Error( `CAPTCHA configuration failed: Invalid provider "${config.provider}".\n` + ` Reason: ${errMsg}\n` + ` Valid providers are: EzCaptcha, CapSolver, YesCaptcha.` ); } throw error; } } /** * List configured CAPTCHA providers * GET /google-flow/accounts/captcha-providers * * Returns currently configured providers with masked API keys, * and/or remaining free CAPTCHA credits. * * Response variants: * - If providers configured: { "EzCaptcha": "xxxx...xxxx", "CapSolver": "xxxx...xxxx" } * - If no providers (new account): { "freeCaptchaCredits": 100 } * - Mixed (some credits + provider): { "freeCaptchaCredits": 50, "EzCaptcha": "xxxx...xxxx" } * * @returns Object with masked provider keys and/or freeCaptchaCredits * * @throws Error 401 if API token is invalid */ async getCaptchaProviders(): Promise { return this.request( "GET", `/google-flow/accounts/captcha-providers` ); } /** * Upload an image for use in video generation * POST /google-flow/assets/{email} * * Uploads an image for use in I2V, frames, or R2V video generation modes. * Returns mediaGenerationId for use in startImage/endImage/referenceImage params. * * Constraints: * - Supported formats: PNG, JPEG only (NOT webp) * - Maximum file size: 20MB * - Email is optional in path - auto-selects via load balancing if omitted * * @param imagePath - Local file path to upload * * @returns Response with mediaGenerationId (may be nested), width, height, email * * @throws Error with specific message for common failures: * - 400: Invalid request (empty, unsupported type, oversized, or content policy) * - 401: Invalid API token * - 404: Account not configured * - 429: Rate limited (retry after 5-10 seconds) * - 596: Session error (reconfigure account) */ async uploadImage( imagePath: string ): Promise { if (!existsSync(imagePath)) { throw new Error(`Image file not found: ${imagePath}`); } // Read image as raw binary const imageBuffer = await readFile(imagePath); // Check file size (max 20MB) const maxSize = 20 * 1024 * 1024; if (imageBuffer.length > maxSize) { throw new Error( `Image file too large: ${(imageBuffer.length / 1024 / 1024).toFixed(1)}MB. ` + `Maximum allowed: 20MB.` ); } // Determine mime type from extension (only PNG and JPEG supported) const ext = imagePath.toLowerCase().split(".").pop(); let mimeType: "image/png" | "image/jpeg"; if (ext === "png") { mimeType = "image/png"; } else if (ext === "jpg" || ext === "jpeg") { mimeType = "image/jpeg"; } else { throw new Error( `Unsupported image format: .${ext}. ` + `Only PNG and JPEG are supported.` ); } // Retry loop for rate limiting (429/503) let lastError: Error | null = null; for (let attempt = 1; attempt <= RETRY.maxAttempts; attempt++) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TIMEOUT.upload); try { const url = `${this.baseUrl}/google-flow/assets/${encodeURIComponent(this.accountEmail)}`; const response = await fetch(url, { method: "POST", headers: { "Authorization": `Bearer ${this.apiToken}`, "Content-Type": mimeType, }, body: imageBuffer, signal: controller.signal, }); const responseText = await response.text(); if (!response.ok) { let errorMessage = `HTTP ${response.status}: ${response.statusText}`; try { const errorData = JSON.parse(responseText); // v2.11: Handle both string and object error formats // This fixes the "[object Object]" error message issue if (errorData.error) { errorMessage = typeof errorData.error === "string" ? errorData.error : JSON.stringify(errorData.error); } else if (errorData.message) { errorMessage = typeof errorData.message === "string" ? errorData.message : JSON.stringify(errorData.message); } } catch { if (responseText) errorMessage = responseText.substring(0, 200); } // Check for retryable status codes (429, 503) if (RETRYABLE_STATUS_CODES.includes(response.status)) { rateLimitTracker.recordEvent(); // Track for adaptive throttling const delay = calculateRetryDelay(attempt); const throttleLevel = rateLimitTracker.getThrottleLevel(); if (!isQuietMode()) { const throttleInfo = throttleLevel !== "none" ? ` [${throttleLevel} throttling]` : ""; console.log( `⏳ Image upload ${response.status === 429 ? "rate limited" : "service unavailable"} ` + `(attempt ${attempt}/${RETRY.maxAttempts}). Retrying in ${(delay / 1000).toFixed(1)}s...${throttleInfo}` ); } lastError = new Error( `Image upload rate limited: ${errorMessage}. Attempt ${attempt}/${RETRY.maxAttempts}.` ); if (attempt < RETRY.maxAttempts) { clearTimeout(timeout); await sleep(delay); continue; } } // Non-retryable errors if (response.status === 404) { throw new Error( `Image upload failed: Account "${this.accountEmail}" not configured.\n` + ` Reason: ${errorMessage}` ); } if (response.status === 596) { throw new Error( `Image upload failed: Session error.\n` + ` Reason: ${errorMessage}\n` + ` Reconfigure account - see useapi.net setup docs.` ); } throw new Error(`Image upload failed: ${errorMessage}`); } // Success return JSON.parse(responseText) as UseApiImageUploadResponse; } finally { clearTimeout(timeout); } } // All retries exhausted throw lastError || new Error( `Image upload failed after ${RETRY.maxAttempts} attempts. ` + `The API may be experiencing high load.` ); } /** * Upload a local MP4 video file to the Google Flow asset library. * * The returned mediaGenerationId can be passed as `referenceVideo_1` on a * POST /videos request with `model: "omni-flash"` to perform a V2V edit. * * Endpoint: POST /google-flow/assets/{email} * Content-Type: video/mp4 (max 100 MB) * * Response shape (spec §3.4): * { mediaGenerationId: { mediaGenerationId: "user:NNN-email:HEX-video:UUID" }, * durationSeconds: 11.94, width: 1280, height: 720, email: "jo***@gmail.com" } * * Timeout note: we use 5 minutes (300 s) instead of the 1-minute TIMEOUT.upload * used for images, because a 100 MB MP4 over a slow connection can take several * minutes to transfer to the useapi.net edge node before the response arrives. * * @param videoPath - Local filesystem path to the MP4 file * @returns Parsed UseApiVideoUploadResponse * * @throws If the file is missing, exceeds 100 MB, or the API returns an error: * - 400: Invalid request (empty, unsupported type, oversized, content policy) * - 401: Invalid API token * - 404: Account not configured * - 413: Payload too large (exceeds server's 100 MB limit) * - 429: Rate limited (retry after 5-10 seconds) * - 596: Session error (reconfigure account) */ async uploadVideo( videoPath: string ): Promise { if (!existsSync(videoPath)) { throw new Error(`Video file not found: ${videoPath}`); } // Read video as raw binary const videoBuffer = await readFile(videoPath); // Check file size (max 100 MB per spec §3.4) const maxSize = 100 * 1024 * 1024; if (videoBuffer.length > maxSize) { throw new Error( `Video file too large: ${(videoBuffer.length / 1024 / 1024).toFixed(1)}MB. ` + `Maximum allowed: 100MB.` ); } // Only MP4 is accepted by the Google Flow asset endpoint const ext = videoPath.toLowerCase().split(".").pop(); if (ext !== "mp4") { throw new Error( `Unsupported video format: .${ext}. ` + `Only MP4 is supported for V2V asset upload.` ); } // 5-minute timeout for large video uploads (vs 1-minute for images) const VIDEO_UPLOAD_TIMEOUT = 300_000; // Retry loop for rate limiting (429/503) let lastError: Error | null = null; for (let attempt = 1; attempt <= RETRY.maxAttempts; attempt++) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), VIDEO_UPLOAD_TIMEOUT); try { const url = `${this.baseUrl}/google-flow/assets/${encodeURIComponent(this.accountEmail)}`; const response = await fetch(url, { method: "POST", headers: { "Authorization": `Bearer ${this.apiToken}`, "Content-Type": "video/mp4", }, body: videoBuffer, signal: controller.signal, }); const responseText = await response.text(); if (!response.ok) { let errorMessage = `HTTP ${response.status}: ${response.statusText}`; try { const errorData = JSON.parse(responseText); if (errorData.error) { errorMessage = typeof errorData.error === "string" ? errorData.error : JSON.stringify(errorData.error); } else if (errorData.message) { errorMessage = typeof errorData.message === "string" ? errorData.message : JSON.stringify(errorData.message); } } catch { if (responseText) errorMessage = responseText.substring(0, 200); } // Check for retryable status codes (429, 503) if (RETRYABLE_STATUS_CODES.includes(response.status)) { rateLimitTracker.recordEvent(); const delay = calculateRetryDelay(attempt); const throttleLevel = rateLimitTracker.getThrottleLevel(); if (!isQuietMode()) { const throttleInfo = throttleLevel !== "none" ? ` [${throttleLevel} throttling]` : ""; console.log( `⏳ Video upload ${response.status === 429 ? "rate limited" : "service unavailable"} ` + `(attempt ${attempt}/${RETRY.maxAttempts}). Retrying in ${(delay / 1000).toFixed(1)}s...${throttleInfo}` ); } lastError = new Error( `Video upload rate limited: ${errorMessage}. Attempt ${attempt}/${RETRY.maxAttempts}.` ); if (attempt < RETRY.maxAttempts) { clearTimeout(timeout); await sleep(delay); continue; } } // Non-retryable errors if (response.status === 404) { throw new Error( `Video upload failed: Account "${this.accountEmail}" not configured.\n` + ` Reason: ${errorMessage}` ); } if (response.status === 413) { throw new Error( `Video upload failed: File exceeds server's 100 MB limit.\n` + ` Reason: ${errorMessage}` ); } if (response.status === 596) { throw new Error( `Video upload failed: Session error.\n` + ` Reason: ${errorMessage}\n` + ` Reconfigure account - see useapi.net setup docs.` ); } throw new Error(`Video upload failed: ${errorMessage}`); } // Success return JSON.parse(responseText) as UseApiVideoUploadResponse; } finally { clearTimeout(timeout); } } // All retries exhausted throw lastError || new Error( `Video upload failed after ${RETRY.maxAttempts} attempts. ` + `The API may be experiencing high load.` ); } /** * Upload image with caching - checks cache before uploading * Returns cached mediaId if available, otherwise uploads and caches result * * @param imagePath - Local file path to upload * @param aspectRatio - Target aspect ratio (for cache key) * @returns Upload response with fromCache indicator */ async uploadImageWithCache( imagePath: string, aspectRatio: "landscape" | "portrait" = "landscape" ): Promise { if (!existsSync(imagePath)) { throw new Error(`Image file not found: ${imagePath}`); } // Read file and compute hash const fileContent = await readFile(imagePath); const fileHash = hashFileContents(fileContent.buffer as ArrayBuffer); const fileSize = statSync(imagePath).size; // Check cache const cached = getImageCache(fileHash, aspectRatio, "useapi"); if (cached) { if (!isQuietMode()) { log(` 📦 Using cached mediaId for ${imagePath.split("/").pop()} (hash: ${fileHash.substring(0, 8)}...)`); } return { mediaGenerationId: cached.media_id, fromCache: true, } as UseApiImageUploadResponse & { fromCache: boolean }; } // Upload image if (!isQuietMode()) { log(` ⬆️ Uploading ${imagePath.split("/").pop()} (hash: ${fileHash.substring(0, 8)}...)`); } const response = await this.uploadImage(imagePath); // Extract mediaId (may be nested) const mediaId = extractMediaId(response); // Store in cache setImageCache({ file_hash: fileHash, media_id: mediaId, file_path: imagePath, file_size: fileSize, aspect_ratio: aspectRatio, backend: "useapi", }); if (!isQuietMode()) { log(` ✅ Cached mediaId for future use`); } return { ...response, fromCache: false }; } /** * Generate a video using useapi.net * POST /google-flow/videos * * Generation modes: * - T2V: Just prompt (text-to-video) * - I2V: startImage only (image-to-video) * - I2V-FL: startImage + endImage (frames) * - R2V: referenceImage_1..3 on Veo (not veo-3.1-quality); _1..7 on omni-flash * - V2V: referenceVideo_1 + frame indices (omni-flash only) * * CONSTRAINTS: * - Cannot mix R2V (referenceImage_*) and I2V (startImage/endImage) * - endImage requires startImage * - I2V/I2V-FL only available on Veo models * - veo-3.1-quality does not support R2V * * Timing: Generation typically takes 60-180 seconds * * @param params.prompt - Required text description * @param params.model - Default: veo-3.1-fast * @param params.aspectRatio - "landscape" (default) or "portrait" * @param params.count - 1-4 variations, default: 1 * @param params.seed - For reproducibility * @param params.startImage - mediaGenerationId for I2V mode * @param params.endImage - mediaGenerationId for I2V-FL mode * @param params.referenceImage_1 to _3 - For R2V mode * @param params.async - Fire-and-forget mode (returns 201) * @param params.captchaRetry - 1-10, default: 3 * * @returns Video with signed URLs (fifeUrl ~24h valid) * * @throws Error with specific message for common failures: * - 400: Mode conflict or content policy * - 402: Insufficient credits * - 403: Google rejection - increase captchaRetry * - 408: Generation timeout (10 min) * - 429: Rate limit - wait 5-10 seconds * - 596: Session error - reconfigure account */ async generateVideo(params: UseApiVideoParams): Promise { const validation = validateFlowVideoRequest(params); if (!validation.ok) { throw new Error( `Video generation request is invalid:\n - ${validation.errors.join("\n - ")}` ); } for (const w of validation.warnings) { console.warn(`[useapi] warning: ${w}`); } try { return await this.request( "POST", `/google-flow/videos`, params, TIMEOUT.generation ); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); // 400 often means mode conflict or content policy violation if (errMsg.includes("400") || errMsg.includes("Bad Request")) { throw new Error( `Video generation failed: Validation error or content policy violation. ${errMsg}` ); } // 408 means timeout if (errMsg.includes("408") || errMsg.includes("timeout")) { throw new Error( `Video generation timed out after 10 minutes.\n` + ` Reason: ${errMsg}\n` + ` Try again or use async mode with polling.` ); } // 429 means rate limit if (errMsg.includes("429") || errMsg.includes("Rate limited")) { throw new Error( `Video generation rate limited.\n` + ` Reason: ${errMsg}\n` + ` Wait 5-10 seconds and retry.` ); } // 403 means Google rejected if (errMsg.includes("403") || errMsg.includes("Access denied")) { throw new Error( `Video generation failed: Google rejected the request.\n` + ` Reason: ${errMsg}\n` + ` Try increasing captchaRetry (current: ${params.captchaRetry ?? 5}, max: 10) or re-registering cookies.` ); } // 596 means session error if (errMsg.includes("596") || errMsg.includes("Session")) { throw new Error( `Video generation failed: Session error.\n` + ` Reason: ${errMsg}\n` + ` Reconfigure account - see useapi.net setup docs.` ); } throw error; } } /** * `GET /google-flow/assets/{mediaGenerationId}?raw=true` — streams the video * bytes through useapi over a Google route the signed-URL block does not * touch. Requires the bearer token (unlike a signed CDN URL), so anything * fetching it must send {@link authHeader}. Video ids only. */ rawAssetUrl(mediaGenerationId: string): string { return `${this.baseUrl}/google-flow/assets/${encodeURIComponent(mediaGenerationId)}?raw=true`; } /** Bearer header for useapi-hosted URLs such as {@link rawAssetUrl}. */ authHeader(): string { return `Bearer ${this.apiToken}`; } /** * Re-ask for one media item's signed download URL — * `GET /google-flow/assets/{mediaGenerationId}`. * * Since 2026-07-27 useapi OMITS `videoUrl` rather than returning a link that * does not work, because Google rate-limits the endpoint that mints those * links by network address. The video itself generated fine and was charged, * so a missing URL is a retry condition, not a failure. * * Returns the URL, or null when it is still unavailable after `maxWaitMs` * (the caller should then fall back to {@link rawAssetUrl}). Throws on 404 — * Google holds no such media and no amount of retrying will change that. */ async resolveMediaUrl( mediaGenerationId: string, maxWaitMs = 3 * 60_000 ): Promise { const url = `${this.baseUrl}/google-flow/assets/${encodeURIComponent(mediaGenerationId)}`; let waited = 0; for (;;) { const response = await fetch(url, { headers: { Authorization: this.authHeader(), Accept: "application/json" }, }); if (response.ok) { const parsed = (await response.json().catch(() => ({}))) as { url?: unknown }; return typeof parsed.url === "string" && parsed.url ? parsed.url : null; } if (response.status === 404) { throw new Error( `Google holds no media under mediaGenerationId ${mediaGenerationId} — retrying will not help.` ); } // Only 503 is temporary here ("not ready yet" ~10s, or the signed-URL // block ~60s). Both state their own window via Retry-After. if (response.status !== 503) return null; // Floored: a legal `Retry-After: 0` would otherwise spin forever without // advancing `waited`. const wait = Math.max(parseRetryAfterMs(response, null) ?? 10_000, 1_000); if (waited + wait > maxWaitMs) return null; await Bun.sleep(wait); waited += wait; } } /** * Get job status * GET /google-flow/jobs/{jobId} * * Retrieves status and details of image or video generation jobs. * Video jobs return signed URLs valid for ~24 hours. * Jobs are retained for 7 days. * * Status values: * - "created": Job created, not yet started * - "started": Job in progress * - "completed": Job finished successfully * - "failed": Job failed * * @param jobId - Unique job identifier from POST requests * * @throws Error with specific message for common failures: * - 400: Invalid job ID format * - 401: Invalid API token * - 403: Access denied (different user's job) * - 404: Job not found * - 410: Job expired (7-day retention) */ async getJobStatus(jobId: string): Promise { try { return await this.request( "GET", `/google-flow/jobs/${jobId}` ); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); // 403 means access denied (different user's job) if (errMsg.includes("403") || errMsg.includes("Access denied")) { throw new Error( `Job status failed: Access denied.\n` + ` Reason: ${errMsg}\n` + ` Job "${jobId}" may belong to a different user.` ); } // 404 means job not found if (errMsg.includes("404") || errMsg.includes("Not Found")) { throw new Error( `Job status failed: Job "${jobId}" not found.\n` + ` Reason: ${errMsg}` ); } // 410 means job expired if (errMsg.includes("410") || errMsg.includes("Gone")) { throw new Error( `Job status failed: Job "${jobId}" has expired (7-day retention).\n` + ` Reason: ${errMsg}` ); } throw error; } } /** * Poll job until completion or timeout * Uses GET /google-flow/jobs/{jobId} repeatedly * * A completed video job whose media carries no `videoUrl` is re-polled: since * 2026-07-27 the API re-resolves withheld links on a later poll and saves the * result. That re-resolve runs at most once a minute and the underlying Google * block only clears once traffic stops, so those extra polls are spaced by * {@link JOB_REPOLL_MIN_MS} — polling faster lengthens the wait. Normal * in-progress polling is unaffected and stays at POLL_INTERVAL. * * @param jobId - Job ID to poll * @param onProgress - Optional callback for progress updates */ async pollJob( jobId: string, onProgress?: (status: string, progress?: number) => void ): Promise { const startTime = Date.now(); let urlWaitStarted = 0; while (Date.now() - startTime < TIMEOUT.polling) { const job = await this.getJobStatus(jobId); if (onProgress) { onProgress(job.status, job.progress); } // Check for completion (API may use different casing) const status = job.status.toLowerCase(); if (status === "completed") { // Video jobs only: an image job's media never carries videoUrl (a // withheld image URL arrives as inline `encodedImage` instead), so // treating it as "awaiting a link" would poll forever for nothing. const media = (job.response?.media ?? []) as Array<{ videoUrl?: string }>; const awaitingUrl = job.type !== "image" && media.length > 0 && media.every((m) => !m.videoUrl); if (!awaitingUrl) return job; // Give the self-repair a bounded chance, then hand the job back so the // caller can fall back to resolveMediaUrl/rawAssetUrl by media id. if (urlWaitStarted === 0) urlWaitStarted = Date.now(); if (Date.now() - urlWaitStarted >= JOB_URL_REPOLL_MAX_MS) return job; await Bun.sleep(JOB_REPOLL_MIN_MS); continue; } if (status === "failed") { const errorMsg = job.error || job.result?.error || "Job failed"; throw new Error(errorMsg); } // Wait before next poll await Bun.sleep(POLL_INTERVAL); } throw new Error(`Job polling timed out after ${TIMEOUT.polling / 1000}s`); } /** * Get session status for a specific account * Uses GET /google-flow/accounts and extracts session expiry info * * @param email - Account email to check session for * @returns Session status with expiry details */ async getSessionStatus(email: string): Promise<{ status: "active" | "expiring_soon" | "expired" | "unknown"; expiresAt: string | null; hoursRemaining: number | null; nextRefresh: string | null; }> { const accounts = await this.getAccounts(); const accountInfo = accounts[email]; if (!accountInfo) { return { status: "unknown", expiresAt: null, hoursRemaining: null, nextRefresh: null }; } const nextRefresh = accountInfo.nextRefresh?.scheduledFor ?? null; if (!accountInfo.sessionData?.expires) { return { status: "unknown", expiresAt: null, hoursRemaining: null, nextRefresh }; } const expiresAt = accountInfo.sessionData.expires; const expiresDate = new Date(expiresAt); const now = new Date(); const msRemaining = expiresDate.getTime() - now.getTime(); const hoursRemaining = Math.round((msRemaining / (1000 * 60 * 60)) * 10) / 10; if (msRemaining <= 0) { return { status: "expired", expiresAt, hoursRemaining: 0, nextRefresh }; } // Less than 6 hours remaining = expiring soon const SIX_HOURS_MS = 6 * 60 * 60 * 1000; if (msRemaining < SIX_HOURS_MS) { return { status: "expiring_soon", expiresAt, hoursRemaining, nextRefresh }; } return { status: "active", expiresAt, hoursRemaining, nextRefresh }; } /** * Get account email configured for this client */ getAccountEmail(): string { return this.accountEmail; } // ============================================================================ // Extended Features - Image Generation // ============================================================================ /** * Generate images using the Nano Banana 2 family * POST /google-flow/images * * Model selection (Google removed Imagen from Flow in July 2026): * - nano-banana-2-lite: Flow's default since July 2026, up to 10 references * - nano-banana-2: Character consistency, up to 10 references * - nano-banana-pro: Up to 10 references, upscale-able * Deprecated aliases still accepted: nano-banana → nano-banana-2, * imagen-4 → nano-banana-2-lite. * * Timing: Generation typically completes within 10-20 seconds * Concurrency: 3-20 parallel generations depending on capacity * * @param params.prompt - Required text description * @param params.model - Default: nano-banana-2-lite * @param params.aspectRatio - 16:9 (default) | 4:3 | 1:1 | 3:4 | 9:16 | auto * (auto needs ≥1 reference_*); landscape/portrait * are legacy aliases * @param params.count - 1-4 images, default: 4 * @param params.seed - For reproducible results * @param params.reference_1 to reference_10 - mediaGenerationId values * @param params.character_1 to character_7 - saved Flow character refs (blogs 260605/260609) * @param params.replyUrl - Webhook URL for callbacks * @param params.captchaRetry - 1-10, default: 3 * * @throws Error with specific message for common failures: * - 400: Content policy violation * - 402: Insufficient credits * - 403: Google rejection - increase captchaRetry * - 429: Rate limit - wait 5-10 seconds * - 500: Content moderation - retry or modify prompt * - 596: Session error - cookie refresh required */ async generateImage(params: UseApiImageParams): Promise { const validation = validateFlowImageRequest(params); if (!validation.ok) { throw new Error( `Image generation request is invalid:\n - ${validation.errors.join("\n - ")}` ); } for (const w of validation.warnings) { console.warn(`[useapi] warning: ${w}`); } try { const rawResponse = await this.request( "POST", `/google-flow/images`, params, TIMEOUT.imageGen ); // Transform raw response to normalized format return transformImageResponse(rawResponse); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); // 500 often means content moderation - suggest retry if (errMsg.includes("500") || errMsg.includes("Internal Server")) { throw new Error( `Image generation failed: Content may have been moderated.\n` + ` Reason: ${errMsg}\n` + ` Try retrying with the same prompt (moderation decisions vary) or modify the prompt.` ); } // 429 means rate limit - suggest waiting if (errMsg.includes("429") || errMsg.includes("Rate limited")) { throw new Error( `Image generation rate limited.\n` + ` Reason: ${errMsg}\n` + ` Wait 5-10 seconds and retry. Dynamic concurrency is 3-20 parallel generations depending on capacity.` ); } // 403 means Google rejected - suggest captchaRetry if (errMsg.includes("403") || errMsg.includes("Access denied")) { throw new Error( `Image generation failed: Google rejected the request.\n` + ` Reason: ${errMsg}\n` + ` Try increasing captchaRetry (current: ${params.captchaRetry ?? 5}, max: 10) or re-registering cookies.` ); } // 596 means session error if (errMsg.includes("596") || errMsg.includes("Session")) { throw new Error( `Image generation failed: Session error.\n` + ` Reason: ${errMsg}\n` + ` Cookie refresh required - see useapi.net setup docs.` ); } throw error; } } /** * Upscale an image generated with nano-banana-pro * POST /google-flow/images/upscale * * IMPORTANT: Only images generated with nano-banana-pro model support upscaling! * * @param params.mediaGenerationId - Must be from nano-banana-pro model * @param params.resolution - "2k" (default) or "4k" (requires paid Google account) * @param params.captchaRetry - Retry attempts 1-10 (default: 3) * @param params.captchaOrder - Comma-separated captcha provider sequence * * @returns Base64-encoded upscaled image in encodedImage field * * @throws Error with specific message for common failures: * - 429: "Only nano-banana-pro images can be upscaled" * - 403: "Google rejected request - try increasing captchaRetry" * - 404: "Image not found" */ async upscaleImage(params: UseApiImageUpscaleParams): Promise { try { return await this.request( "POST", `/google-flow/images/upscale`, params, TIMEOUT.upscale ); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); // 429 for upscaling means unsupported image model, not rate limiting if (errMsg.includes("429") || errMsg.includes("Rate limited")) { throw new Error( `Image upscaling failed: Only images generated with nano-banana-pro model can be upscaled.\n` + ` Reason: ${errMsg}\n` + ` The image with mediaGenerationId "${params.mediaGenerationId}" was not created with nano-banana-pro.` ); } // 403 means Google rejected - suggest increasing captchaRetry if (errMsg.includes("403") || errMsg.includes("Access denied")) { throw new Error( `Image upscaling failed: Google rejected the request.\n` + ` Reason: ${errMsg}\n` + ` Try increasing captchaRetry (current: ${params.captchaRetry ?? 5}, max: 10) or re-registering cookies.` ); } throw error; } } // ============================================================================ // Extended Features - Video Processing // ============================================================================ /** * Convert a video to GIF format * POST /google-flow/videos/gif * * IMPORTANT: This endpoint does NOT require CAPTCHA! (Free to use) * * Processing time: Up to 90 seconds * Operation: Synchronous (returns immediately upon completion) * * @param params.mediaGenerationId - Video ID from POST /videos * * @returns Base64-encoded GIF in encodedGif field * * @throws Error with specific message for common failures: * - 400: Invalid mediaGenerationId or incorrect reference type * - 404: Video not found */ async videoToGif(params: UseApiVideoGifParams): Promise { try { return await this.request( "POST", `/google-flow/videos/gif`, params, TIMEOUT.gif ); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); // 400 means invalid mediaGenerationId if (errMsg.includes("400") || errMsg.includes("Bad Request")) { throw new Error( `GIF conversion failed: Invalid mediaGenerationId or incorrect reference type.\n` + ` Reason: ${errMsg}\n` + ` The mediaGenerationId "${params.mediaGenerationId}" may be invalid.` ); } // 404 means video not found if (errMsg.includes("404") || errMsg.includes("Not Found")) { throw new Error( `GIF conversion failed: Video not found.\n` + ` Reason: ${errMsg}\n` + ` The mediaGenerationId "${params.mediaGenerationId}" may be invalid or expired.` ); } throw error; } } /** * Upscale a video to higher resolution * POST /google-flow/videos/upscale * * Timing: * - 1080p: 30-60 seconds * - 4K: a few minutes * * Cost: * - 1080p: Free for all accounts * - 4K: 50 credits (~$0.25), Ultra tier only * * Caching: Re-upscaling identical video returns cached result (free) * * @param params.mediaGenerationId - Video media ID from POST /videos * @param params.resolution - "1080p" (default) or "4k" * @param params.async - Fire-and-forget mode (returns 201) * @param params.replyUrl - Webhook URL for callbacks * @param params.captchaRetry - 1-10, default: 3 * * @returns Upscaled video with signed URLs (fifeUrl ~24h valid) * * @throws Error with specific message for common failures: * - 403: Google rejected - increase captchaRetry * - 404: Video not found * - 408: Polling timeout (10 min) */ async upscaleVideo(params: UseApiVideoUpscaleParams): Promise { try { return await this.request( "POST", `/google-flow/videos/upscale`, params, TIMEOUT.upscale ); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); // 408 means timeout if (errMsg.includes("408") || errMsg.includes("timeout")) { throw new Error( `Video upscaling timed out after 10 minutes.\n` + ` Reason: ${errMsg}\n` + ` Try again or use async mode with polling.` ); } // 404 means video not found if (errMsg.includes("404") || errMsg.includes("Not Found")) { throw new Error( `Video upscaling failed: Video not found.\n` + ` Reason: ${errMsg}\n` + ` The mediaGenerationId "${params.mediaGenerationId}" may be invalid or expired.` ); } // 403 means Google rejected if (errMsg.includes("403") || errMsg.includes("Access denied")) { throw new Error( `Video upscaling failed: Google rejected the request (reCAPTCHA failed).\n` + ` Reason: ${errMsg}\n` + ` Try increasing captchaRetry (current: ${params.captchaRetry ?? 5}, max: 10) or re-registering cookies.` ); } throw error; } } /** * Extend a previously generated video with a new prompt. * POST /google-flow/videos/extend — CAPTCHA required. Response uses media[]. */ async extendVideo(params: UseApiVideoExtendParams): Promise { return this.request( "POST", `/google-flow/videos/extend`, params, TIMEOUT.generation ); } /** * Concatenate 2-10 previously generated videos into one. * POST /google-flow/videos/concatenate — no CAPTCHA. Returns base64 MP4. */ async concatenateVideos(params: UseApiVideoConcatParams): Promise { if (params.media.length < 2 || params.media.length > 10) { throw new Error("concatenateVideos requires 2-10 videos."); } try { return await this.request( "POST", `/google-flow/videos/concatenate`, params, TIMEOUT.generation ); } catch (error) { // The API returns a generic "Concatenation failed" on most rejections. // Add an actionable hint: omni-flash videos cannot be concatenated — // only Veo-lineage videos can. Confirmed by live-API testing 2026-05-24. const msg = error instanceof Error ? error.message : String(error); if (msg.includes("Concatenation failed") || msg.includes("MEDIA_GENERATION_STATUS_FAILED")) { throw new Error( `Video concatenation failed. The API rejected the request.\n` + ` Common cause: one or more inputs are omni-flash outputs. ` + `/videos/concatenate only accepts Veo-lineage videos ` + `(veo-3.1-quality / -fast / -lite / -lite-low-priority) and their extensions.\n` + ` Original: ${msg}` ); } throw error; } } // ============================================================ // Characters & Voices (reusable identity + voice) — Google Flow v1 // ============================================================ /** * POST /google-flow/characters — create a reusable named character from 1-2 * reference images + an optional voice. Returns a `character` ref usable as * `character_1..7` in POST /videos. * * IMPORTANT (live-verified): this endpoint does NOT accept `email` in the body * (the account comes from the bearer token). We strip it defensively. */ async createCharacter(params: UseApiCharacterParams): Promise { const { ...body } = params as UseApiCharacterParams & { email?: string }; delete (body as { email?: string }).email; // /characters rejects email return this.request("POST", "/google-flow/characters", body, TIMEOUT.account); } /** * GET /google-flow/characters?email=… — list saved characters for the account. * (Live-verified: the LIST endpoint REQUIRES email as a query param, unlike the * POST which rejects it.) */ async listCharacters(): Promise<{ characters?: UseApiCharacterResponse[]; error?: string }> { return this.request("GET", `/google-flow/characters?email=${encodeURIComponent(this.accountEmail)}`, undefined, TIMEOUT.account); } /** GET /google-flow/characters/{ref} — fetch one character by its ref string. */ async getCharacter(characterRef: string): Promise { return this.request( "GET", `/google-flow/characters/${encodeURIComponent(characterRef)}`, undefined, TIMEOUT.account ); } /** DELETE /google-flow/characters/{ref} — remove a saved character. */ async deleteCharacter(characterRef: string): Promise<{ success?: boolean; error?: string }> { return this.request("DELETE", `/google-flow/characters/${encodeURIComponent(characterRef)}`, undefined, TIMEOUT.account); } /** * POST /google-flow/voices — create a reusable custom voice from a base preset * + a short dialog sample + a performance description. Returns a `voice` ref * usable as `referenceAudio_1..5` or as a character's `voice`. * * IMPORTANT (live-verified): this endpoint REQUIRES `email` in the body — the * opposite of POST /characters. We default it to the client's account email. */ async createVoice(params: UseApiVoiceParams): Promise { const body: UseApiVoiceParams = { ...params, email: params.email || this.accountEmail }; return this.request("POST", "/google-flow/voices", body, TIMEOUT.account); } /** * GET /google-flow/voices?email=… — list voices for the account (system presets * + user voices; filter source==='user' for custom ones). LIST requires email. */ async listVoices(): Promise<{ voices?: UseApiVoiceResponse[]; error?: string }> { return this.request("GET", `/google-flow/voices?email=${encodeURIComponent(this.accountEmail)}`, undefined, TIMEOUT.account); } /** GET /google-flow/voices/{ref} — fetch one voice by its ref string. */ async getVoice(voiceRef: string): Promise { return this.request( "GET", `/google-flow/voices/${encodeURIComponent(voiceRef)}`, undefined, TIMEOUT.account ); } /** DELETE /google-flow/voices/{ref} — remove a saved custom voice. */ async deleteVoice(voiceRef: string): Promise<{ success?: boolean; error?: string }> { return this.request("DELETE", `/google-flow/voices/${encodeURIComponent(voiceRef)}`, undefined, TIMEOUT.account); } } /** * Validate a Google Flow video request against the model x mode x duration * matrix BEFORE it is sent — failing fast avoids spending a CAPTCHA credit on a * request Google will reject. Returns blocking `errors` and non-blocking * `warnings` (e.g. tier requirements that cannot be checked client-side). */ export function validateFlowVideoRequest( params: UseApiVideoParams ): { ok: boolean; errors: string[]; warnings: string[] } { const errors: string[] = []; const warnings: string[] = []; const model: FlowVideoModel = (params.model as FlowVideoModel) ?? "veo-3.1-fast"; const VALID_MODELS: FlowVideoModel[] = [ "veo-3.1-quality", "veo-3.1-fast", "veo-3.1-lite", "veo-3.1-lite-low-priority", "omni-flash", ]; if (params.model !== undefined && !VALID_MODELS.includes(model)) { errors.push(`Unknown model "${params.model}"; valid values are ${VALID_MODELS.join(", ")}.`); } const isOmni = model === "omni-flash"; const duration = params.duration ?? 8; // Resolution (spec: POST /videos, `resolution`, default 720p). omni-flash ONLY: // Veo publishes no 360p variant, so the API rejects `360p` on a Veo model // rather than silently generating 720p at full price. We reject the FIELD on // any non-omni model — a caller that set it there meant something the route // cannot honour. 360p costs ~half of 720p and can be promoted afterwards via // POST /videos/upscale with resolution "720p". if (params.resolution !== undefined) { const VALID_RESOLUTIONS = ["360p", "720p"]; if (!VALID_RESOLUTIONS.includes(params.resolution)) { errors.push( `Invalid resolution "${params.resolution}"; supported values are ${VALID_RESOLUTIONS.join(", ")}.` ); } if (!isOmni) { errors.push( `resolution is omni-flash only; ${model} publishes no 360p variant and the API rejects the pairing. Omit resolution on Veo models (they always render at their native resolution).` ); } } // Aspect ratio (spec: POST /videos, "Veo also accepts 1:1, 4:3, 3:4; Omni 1.1 // Flash does not"). omni-flash takes only landscape/portrait. if (isOmni && params.aspectRatio !== undefined) { const OMNI_ASPECT_RATIOS = ["landscape", "portrait"]; if (!OMNI_ASPECT_RATIOS.includes(params.aspectRatio)) { errors.push( `aspectRatio "${params.aspectRatio}" is not supported on omni-flash; it accepts ${OMNI_ASPECT_RATIOS.join(" or ")} only (1:1 / 4:3 / 3:4 are Veo-only).` ); } } const hasStart = !!params.startImage; const hasEnd = !!params.endImage; const refImages = [ params.referenceImage_1, params.referenceImage_2, params.referenceImage_3, params.referenceImage_4, params.referenceImage_5, params.referenceImage_6, params.referenceImage_7, ]; const hasRefImg = refImages.some(Boolean); const hasHighRefImg = [ params.referenceImage_4, params.referenceImage_5, params.referenceImage_6, params.referenceImage_7, ].some(Boolean); const hasRefVideo = !!params.referenceVideo_1; const hasVoice1 = !!params.referenceAudio_1; const hasHighVoice = [ params.referenceAudio_2, params.referenceAudio_3, params.referenceAudio_4, params.referenceAudio_5, ].some(Boolean); // Characters (saved-entity R2V). Each character ref bundles 1-2 images, encoded // in the ref as "-imgs:N-"; they count toward the same image budget as // referenceImage_* (Veo total ≤3, omni ≤7). character_4..7 are omni-only. const charRefs = [ params.character_1, params.character_2, params.character_3, params.character_4, params.character_5, params.character_6, params.character_7, ]; const hasChar = charRefs.some(Boolean); const hasHighChar = [ params.character_4, params.character_5, params.character_6, params.character_7, ].some(Boolean); const charImgCount = charRefs.reduce((sum, ref) => { if (!ref) return sum; const m = /-imgs:(\d+)-/.exec(ref); return sum + (m ? Number(m[1]) : 1); // default 1 if the ref omits the imgs hint }, 0); const refImgCount = refImages.filter(Boolean).length; const totalImageRefs = refImgCount + charImgCount; // Mode conflicts if (hasEnd && !hasStart) { errors.push("endImage requires startImage (end-frame-only is not supported)."); } if ((hasStart || hasEnd) && hasRefImg) { errors.push("Cannot combine I2V (startImage/endImage) with R2V (referenceImage_*)."); } if ((hasStart || hasEnd) && hasRefVideo) { errors.push("Cannot combine startImage/endImage with referenceVideo_1 (V2V edit)."); } if (hasRefImg && hasRefVideo) { errors.push("Cannot combine referenceImage_* with referenceVideo_1 (V2V edit)."); } if (hasChar && (hasStart || hasEnd)) { errors.push("Cannot combine character_* with I2V (startImage/endImage)."); } if (hasChar && hasRefVideo) { errors.push("Cannot combine character_* with referenceVideo_1 (V2V edit)."); } // omni-flash First-Frame (startImage) is LIVE (verified live 2026-06-06: the // request routes to abra_i2v_8s / IMAGE_TO_VIDEO). It is enabled by DEFAULT; // VCLAW_OMNI_FIRST_FRAME is now a kill-switch — only 0/false/no/off disables it // (e.g. if the provider regresses). Since Google's 2026-08-26 update the // kill-switch covers BOTH frames: endImage requires startImage, so disabling // first-frame I2V transitively blocks first+last on omni-flash too. const omniStartImageDisabled = (() => { const v = (process.env.VCLAW_OMNI_FIRST_FRAME ?? "").trim().toLowerCase(); return v === "0" || v === "false" || v === "no" || v === "off"; })(); // Model x feature rules // // NOTE (spec 2026-08-26): omni-flash startImage + endImage (I2V-FL) is now // VALID at every duration and both resolutions. The rule that used to reject // endImage on omni-flash was deleted here — do not reinstate it. The generic // "endImage requires startImage" rule above still applies to every model. if (isOmni && hasStart && omniStartImageDisabled) { errors.push("omni-flash startImage is disabled via VCLAW_OMNI_FIRST_FRAME (kill-switch); unset it (or set to 1) to use first-frame I2V on omni-flash."); } if (!isOmni && hasRefVideo) { errors.push("referenceVideo_1 (V2V edit) is omni-flash only."); } if (!isOmni && hasHighRefImg) { errors.push("referenceImage_4..7 are omni-flash only; Veo supports referenceImage_1..3."); } if (!isOmni && hasHighChar) { errors.push("character_4..7 are omni-flash only; Veo supports character_1..3."); } if (!isOmni && hasHighVoice) { errors.push("referenceAudio_2..5 are omni-flash only; Veo supports referenceAudio_1 only."); } if (model === "veo-3.1-quality" && hasRefImg) { errors.push("veo-3.1-quality does not support R2V (referenceImage_*)."); } if (model === "veo-3.1-quality" && hasChar) { errors.push("veo-3.1-quality does not support character_* (saved-entity R2V)."); } // Combined image-reference budget (referenceImage_* + Σ character imgs). const maxImageRefs = isOmni ? 7 : 3; if (totalImageRefs > maxImageRefs) { errors.push(`Too many image references: ${totalImageRefs} (referenceImage + character images) exceeds the ${maxImageRefs}-image budget for ${model}.`); } // Voice narration (any slot) requires either an image reference (R2V) or a // video reference (omni-flash V2V edit). The real API enforces this for both // Veo R2V and omni-flash — confirmed by live-API testing on 2026-05-24. // A first-frame startImage satisfies the visual-anchor requirement too, but // ONLY for omni-flash with the gate on (so Veo I2V + voice and gate-off paths // stay byte-identical and still require an R2V/V2V reference). if ((hasVoice1 || hasHighVoice) && !hasRefImg && !hasChar && !hasRefVideo) { errors.push("referenceAudio_* requires at least one referenceImage_* (R2V), character_* (saved-entity R2V), or referenceVideo_1 (omni-flash V2V edit)."); } // On omni-flash the frames are the ONLY accepted input: the spec's Reference // table marks both the I2V and I2V-FL rows "the frame only — no // referenceImage_*, no character_*, no referenceAudio_*", and POST /videos // says they "are rejected alongside them". // // LIVE-VERIFIED 2026-09-02: omni-flash + startImage + referenceAudio_1 returns // HTTP 400, "accepts at most 0 referenceAudio entries". That settles it against // the older 2026-06 reading (which had this whitelisted as a voice anchor) — do // NOT reopen it on the strength of the 2026-05-24 note above, which predates // the frames-only rule. Re-verify live before changing it. // // This validator previously did the OPPOSITE — it whitelisted // `isOmni && hasStart` as satisfying the voice anchor, so omni + startImage + // referenceAudio_1 passed locally and was then refused by the API. Unlocking // endImage on omni makes I2V-FL a live mode, which makes "first frame + last // frame + a voice" an easy thing to compose, so the rule has to bite here. if (isOmni && hasStart && (hasRefImg || hasChar || hasVoice1 || hasHighVoice)) { errors.push( "omni-flash I2V/I2V-FL takes the frames only — referenceImage_*, character_* and referenceAudio_* are rejected alongside startImage/endImage." ); } // Duration rules if (![4, 6, 8, 10].includes(duration)) { errors.push(`Invalid duration ${duration}; supported values are 4, 6, 8, 10.`); } if (duration === 10 && !isOmni) { errors.push("duration 10 is omni-flash only."); } if (model === "veo-3.1-quality" && duration !== 8) { errors.push("veo-3.1-quality supports 8-second output only."); } if (!isOmni && (hasRefImg || hasChar) && duration !== 8) { errors.push("Veo R2V / character mode supports 8-second output only."); } if (isOmni && hasRefVideo && params.duration !== undefined) { errors.push("duration is not accepted for omni-flash V2V edit; omit it and control output length via endFrameIndex_1 - startFrameIndex_1 (24fps timeline)."); } if (!isOmni && (duration === 4 || duration === 6)) { warnings.push("duration 4/6 on Veo requires a Google AI Ultra subscription."); } // V2V frame indices if ((params.startFrameIndex_1 !== undefined || params.endFrameIndex_1 !== undefined) && !hasRefVideo) { errors.push("startFrameIndex_1/endFrameIndex_1 require referenceVideo_1."); } if (params.startFrameIndex_1 !== undefined && (params.startFrameIndex_1 < 0 || params.startFrameIndex_1 > 239)) { errors.push("startFrameIndex_1 must be between 0 and 239."); } if (params.endFrameIndex_1 !== undefined && (params.endFrameIndex_1 < 1 || params.endFrameIndex_1 > 240)) { errors.push("endFrameIndex_1 must be between 1 and 240."); } if (params.startFrameIndex_1 !== undefined && params.endFrameIndex_1 !== undefined && params.endFrameIndex_1 <= params.startFrameIndex_1) { errors.push("endFrameIndex_1 must be greater than startFrameIndex_1."); } // Tier hint if (model === "veo-3.1-lite-low-priority") { warnings.push("veo-3.1-lite-low-priority is available only to Google AI Ultra $200 subscribers."); } // Inline @-mention prompt markers (useapi blog 260609). Markers are opt-in, // case-insensitive, and anchor a supplied reference at a position in the // prompt text: @character_1..7, @referenceImage_1..7, @referenceAudio_1..5 // on POST /videos. Each marker MUST have its matching body slot set or the // API returns 400 — caught here so a bad request never spends a CAPTCHA // credit. Slots without markers are always fine; duplicate markers are fine. // NOTE: the longer alternatives (referenceimage|referenceaudio) must precede // "reference" in the alternation, otherwise "@referenceImage_1" mis-parses // as the images-only "@reference" family. const videoMarkerRe = /@(referenceimage|referenceaudio|reference|character)_(\d+)\b/gi; const videoSlotBag = params as unknown as Record; for (const match of (params.prompt ?? "").matchAll(videoMarkerRe)) { const marker = match[0]; const family = match[1].toLowerCase(); const index = Number(match[2]); if (family === "reference") { errors.push( `${marker} is an images-only marker (POST /images); use @referenceImage_${match[2]} in video prompts.` ); continue; } const canonical = family === "character" ? "character" : family === "referenceimage" ? "referenceImage" : "referenceAudio"; const maxIndex = family === "referenceaudio" ? 5 : 7; if (index < 1 || index > maxIndex) { errors.push( `${marker} is out of range; valid markers are @${canonical}_1..@${canonical}_${maxIndex}.` ); continue; } const slot = `${canonical}_${index}`; if (!videoSlotBag[slot]) { errors.push( `Prompt marker ${marker} has no matching ${slot} parameter; supply ${slot} or remove the marker (unmatched markers make the API return 400).` ); } } // Numeric bounds if (params.count !== undefined && (params.count < 1 || params.count > 4)) { errors.push("count must be between 1 and 4."); } if (params.captchaRetry !== undefined && (params.captchaRetry < 1 || params.captchaRetry > 10)) { errors.push("captchaRetry must be between 1 and 10."); } return { ok: errors.length === 0, errors, warnings }; } /** * Validate a Google Flow image request (POST /google-flow/images) BEFORE it is * sent — failing fast avoids spending a CAPTCHA credit on a request Google * will reject. Mirrors validateFlowVideoRequest: returns blocking `errors` and * non-blocking `warnings`; generateImage refuses to POST when !ok. */ export function validateFlowImageRequest( params: UseApiImageParams ): { ok: boolean; errors: string[]; warnings: string[] } { const errors: string[] = []; const warnings: string[] = []; const model = normalizeFlowImageModel(params.model); if (params.model !== undefined && !FLOW_IMAGE_MODELS.includes(model)) { errors.push( `Unknown image model "${params.model}"; valid values are ${FLOW_IMAGE_MODELS.join(", ")}` + ` (deprecated aliases still accepted: ${Object.keys(FLOW_IMAGE_MODEL_ALIASES).join(", ")}).` ); } // count bounds (1-4, default 4 server-side) if (params.count !== undefined && (params.count < 1 || params.count > 4)) { errors.push("count must be between 1 and 4."); } // Image-reference budget (spec dump 2026-08-28, POST /google-flow/images): // ALL THREE models now support up to 10 references — including the imagen-4 // alias, which resolves to nano-banana-2-lite and inherits its 10-reference // budget (it used to be capped at 3, back when Imagen was a real Flow model). // Characters mix freely with reference_* and share the same per-model image // budget on Google's side — each character ref's bundled image count (the // "-imgs:N-" hint, default 1 when absent) rolls into the total, mirroring // validateFlowVideoRequest. const imageSlotBag = params as unknown as Record; let refCount = 0; for (let i = 1; i <= 10; i++) { if (imageSlotBag[`reference_${i}`]) refCount++; } let charImgCount = 0; for (let i = 1; i <= 7; i++) { const ref = imageSlotBag[`character_${i}`]; if (typeof ref !== "string" || ref.length === 0) continue; const m = /-imgs:(\d+)-/.exec(ref); charImgCount += m ? Number(m[1]) : 1; // default 1 if the ref omits the imgs hint } const totalImageRefs = refCount + charImgCount; const maxRefs = FLOW_IMAGE_MAX_REFERENCES; if (FLOW_IMAGE_MODELS.includes(model) && totalImageRefs > maxRefs) { errors.push( `Too many image references: ${totalImageRefs} (reference_* + character images) exceeds the ${maxRefs}-reference budget for ${model}.` ); } // Inline @-mention prompt markers (useapi blog 260609). POST /images accepts // @character_1..7 and @reference_1..10 (case-insensitive, opt-in); each // marker MUST have its matching body slot or the API returns 400. The // video-only families (@referenceImage_*, @referenceAudio_*) are rejected // outright. Alternation order matters — see validateFlowVideoRequest. const imageMarkerRe = /@(referenceimage|referenceaudio|reference|character)_(\d+)\b/gi; for (const match of (params.prompt ?? "").matchAll(imageMarkerRe)) { const marker = match[0]; const family = match[1].toLowerCase(); const index = Number(match[2]); if (family === "referenceimage" || family === "referenceaudio") { errors.push( `${marker} is a video-only marker (POST /videos); image prompts use @character_1..7 and @reference_1..10.` ); continue; } const canonical = family === "character" ? "character" : "reference"; const maxIndex = family === "character" ? 7 : 10; if (index < 1 || index > maxIndex) { errors.push( `${marker} is out of range; valid markers are @${canonical}_1..@${canonical}_${maxIndex}.` ); continue; } const slot = `${canonical}_${index}`; if (!imageSlotBag[slot]) { errors.push( `Prompt marker ${marker} has no matching ${slot} parameter; supply ${slot} or remove the marker (unmatched markers make the API return 400).` ); } } return { ok: errors.length === 0, errors, warnings }; } /** Map a friendly or qualified model name to a Google Flow v1 model identifier. */ export function mapModelToUseApi(model: string): FlowVideoModel { switch (model.toLowerCase()) { case "quality": case "veo-3.1-quality": return "veo-3.1-quality"; case "lite": case "veo-3.1-lite": return "veo-3.1-lite"; case "free": case "relaxed": case "lite-low-priority": case "veo-3.1-lite-low-priority": return "veo-3.1-lite-low-priority"; case "omni": case "omni-flash": return "omni-flash"; case "fast": case "veo-3.1-fast": default: return "veo-3.1-fast"; } } /** * Resolve a Flow IMAGE model name, mapping the deprecated aliases the API still * accepts (`nano-banana` → `nano-banana-2`, `imagen-4` → `nano-banana-2-lite`) * onto their canonical models. An unset model resolves to the API default * (`nano-banana-2-lite`); an unknown name passes through unchanged so the * caller (validateFlowImageRequest) can report it. */ export function normalizeFlowImageModel( model: FlowImageModel | FlowImageModelAlias | string | undefined ): FlowImageModel { if (model === undefined) return FLOW_IMAGE_DEFAULT_MODEL; return (FLOW_IMAGE_MODEL_ALIASES[model] ?? model) as FlowImageModel; } /** * Map a friendly aspect ratio to a `POST /videos` `aspectRatio` value. * * The VIDEO endpoint takes only `landscape` / `portrait` (plus `1:1` / `4:3` / * `3:4` on Veo — omni-flash rejects those three). It has no "let the backend * pick" mode, so `16:9`, `auto` and any unrecognised input collapse to * `landscape`. Images are a DIFFERENT vocabulary — use * {@link mapImageAspectRatioToUseApi} for POST /images. */ export function mapAspectRatioToUseApi(aspectRatio: string): FlowAspectRatio { const a = aspectRatio.toLowerCase(); if (a === "1:1") return "1:1"; if (a === "4:3") return "4:3"; if (a === "3:4") return "3:4"; if (a.includes("portrait") || a === "9:16") return "portrait"; return "landscape"; } /** * Map a friendly aspect ratio to a `POST /images` `aspectRatio` value. * * The IMAGE endpoint takes the ratios directly — `16:9 | 4:3 | 1:1 | 3:4 | * 9:16 | auto` — so nothing is collapsed to landscape/portrait here (that is * the video vocabulary and would have thrown away 16:9-vs-4:3 and dropped * `auto` entirely). `landscape` / `portrait` are the legacy aliases the spec * still accepts, normalized to `16:9` / `9:16` to match the main repo's * `normalizeFlowImageAspect`. Unrecognised input falls back to the endpoint's * own text-to-image default, `16:9` — a DELIBERATE divergence from the main * repo, which validates against FLOW_IMAGE_ASPECTS and rejects. So `21:9` * errors through `gen-image --backend flow` and silently renders 16:9 here, * because a batch mid-run is the wrong place to fail on a typo'd ratio. * * `auto` is only valid in image-to-image mode (at least one `reference_*`) — * the backend derives the ratio from the first reference image. */ export function mapImageAspectRatioToUseApi( aspectRatio: string ): NonNullable { const a = aspectRatio.trim().toLowerCase(); switch (a) { case "16:9": case "4:3": case "1:1": case "3:4": case "9:16": case "auto": return a; case "landscape": return "16:9"; case "portrait": return "9:16"; default: return "16:9"; } } /** * Estimate the credit cost of a video generation request, using Google's * official Flow credit table. omni-flash cost depends on duration. * Credits are the real billing unit; USD varies by subscription tier. */ export function calculateCost( model: FlowVideoModel, videoCount: number, durationSeconds: FlowDuration = 8, resolution?: "360p" | "720p" ): { credits: number; perVideoCredits: number; videoCount: number } { let perVideoCredits: number; switch (model) { case "veo-3.1-quality": perVideoCredits = 100; break; // Veo rates DO vary by plan: fast is 20 non-Ultra / 10 Ultra, lite 10 / 5. // We cannot read the tier (the API does not expose it — see the hardcoded // `tier: "unknown"` in getAccountInfo), so these quote the NON-Ultra rate: // an over-estimate is the safe direction for a pre-spend preview. case "veo-3.1-fast": perVideoCredits = 20; break; case "veo-3.1-lite": perVideoCredits = 10; break; case "veo-3.1-lite-low-priority": perVideoCredits = 0; break; case "omni-flash": // Live-verified 2026-08-31 against GET /accounts/{email}, which returns // Google's own per-key creditCost, and confirmed by a measured render // (a 4s 360p generation moved the balance by exactly 4). // // These were previously {4:15, 6:20, 8:25, 10:30} — roughly double the // real 720p rate — and `resolution` was ignored entirely, so the estimate // shown before a 360p render quoted 15 credits for a 4-credit job. That // is not a rounding error: it hides the whole point of the 360p tier. // // Omni rates do NOT vary by plan (the spec lists one row for // Plus/Pro/Ultra), so unlike the Veo rows below these are unambiguous. perVideoCredits = resolution === "360p" ? ({ 4: 4, 6: 5, 8: 6, 10: 7 }[durationSeconds] ?? 6) : ({ 4: 7, 6: 10, 8: 12, 10: 15 }[durationSeconds] ?? 12); break; default: { // Compile-time exhaustiveness check: if FlowVideoModel gains a member, // this line will fail to typecheck until calculateCost handles it. const _exhaustive: never = model; void _exhaustive; perVideoCredits = 20; break; } } return { credits: perVideoCredits * videoCount, perVideoCredits, videoCount }; } // ============================================================================ // Extended Features - Cost Calculation // ============================================================================ /** * Calculate estimated cost for image generation * * @param model - Image model to use * @param imageCount - Number of images (1-4) * @returns Cost breakdown in USD */ export function calculateImageCost( model: FlowImageModel | FlowImageModelAlias, imageCount: number ): { imageCost: number; captchaCost: number; total: number } { // Deprecated aliases keep their historical figures so existing cost // comparisons stay stable; imagen-4 now aliases nano-banana-2-lite. const modelCosts: Record = { "nano-banana-2-lite": 0.02, "imagen-4": 0.02, "nano-banana": 0.03, "nano-banana-2": 0.03, "nano-banana-pro": 0.05, }; const captchaCostPerRequest = 0.0025; const imageCost = (modelCosts[model] ?? 0.02) * imageCount; return { imageCost, captchaCost: captchaCostPerRequest, total: imageCost + captchaCostPerRequest }; } /** * Calculate cost for video upscaling * * 720p and 1080p are free on any PAID Google AI plan (free accounts cannot * upscale at all); 4K costs 50 credits and requires Ultra. 720p exists to * promote a clip generated at `resolution: "360p"` on omni-flash. * * @param resolution - Target resolution * @returns Cost in USD (0 for 720p/1080p, ~$0.25 for 4K) */ export function calculateUpscaleCost( resolution: string ): { cost: number; credits: number; notes: string } { // Case-insensitive on purpose: the API spells this `4K`, older callers pass // `4k`, and a strict lowercase comparison here once reported a paid 4K // upscale as FREE and skipped its confirmation. if (resolution.trim().toLowerCase() === "4k") { return { cost: 0.25, credits: 50, notes: "4K upscaling requires Ultra tier. Results are cached - re-upscaling is free.", }; } return { cost: 0, credits: 0, notes: `${resolution} upscaling is free on a paid plan. Results are cached.`, }; } /** * The spelling the API expects for a video-upscale target, from any casing a * caller might have typed. Returns undefined for an unrecognized value so the * caller can reject it rather than silently defaulting. */ export function normalizeUpscaleResolution( value: string | undefined ): "720p" | "1080p" | "4K" | undefined { const v = (value ?? "").trim().toLowerCase(); if (v === "") return undefined; if (v === "720p") return "720p"; if (v === "1080p") return "1080p"; if (v === "4k") return "4K"; return undefined; } /** * Calculate cost for image upscaling * POST /google-flow/images/upscale * * Note: Upscaling requires CAPTCHA (~$0.0025 per request) * Note: Only nano-banana-pro images can be upscaled * * @param resolution - Target resolution ("2k" default, "4k" requires paid Google account) * @returns Cost info including CAPTCHA cost */ export function calculateImageUpscaleCost( resolution: "2k" | "4k" ): { cost: number; captchaCost: number; total: number; notes: string } { const captchaCost = 0.0025; // CAPTCHA cost per upscale request if (resolution === "4k") { return { cost: 0, captchaCost, total: captchaCost, notes: "4K upscaling requires paid Google account subscription. Only works with nano-banana-pro images.", }; } return { cost: 0, captchaCost, total: captchaCost, notes: "2K upscaling available for free accounts. Only works with nano-banana-pro images.", }; } /** * Auto-select the Flow image model from the reference-image count. * * 0 refs → nano-banana-2-lite (Flow's default since July 2026, best pure T2I) * 1-3 → nano-banana-2 (character consistency) * 4+ → nano-banana-pro (max references) * * Kept in lockstep with the main repo's `resolveFlowImageModel` * (src/video/gen-image-flow.ts) — the two must agree or the same request picks * a different model depending on which surface submitted it. * * @param refCount - Number of reference images (0-10) * @returns Recommended model */ export function autoSelectImageModel(refCount: number): FlowImageModel { if (refCount <= 0) return "nano-banana-2-lite"; if (refCount <= 3) return "nano-banana-2"; return "nano-banana-pro"; } // ============================================================================ // Response Transformers // ============================================================================ /** * Transform raw image generation API response to normalized format * * Raw API format: * { * "jobId": "...", * "media": [{ * "name": "...", * "workflowId": "...", * "image": { * "generatedImage": { * "seed": 182811941, * "mediaGenerationId": "user:2305-..." * } * } * }] * } * * Normalized format: * { * "jobId": "...", * "images": [{ * "mediaGenerationId": "user:2305-...", * "seed": 182811941 * }] * } */ export function transformImageResponse( raw: UseApiImageResponseRaw ): UseApiImageResponse { const images = (raw.media || []) .map((mediaItem) => { const generatedImage = mediaItem.image?.generatedImage; if (!generatedImage?.mediaGenerationId) { return null; } return { mediaGenerationId: generatedImage.mediaGenerationId, seed: generatedImage.seed, name: mediaItem.name, workflowId: generatedImage.workflowId || mediaItem.workflowId, fifeUrl: generatedImage.fifeUrl, }; }) .filter((item): item is NonNullable => item !== null); return { jobId: raw.jobId, images, model: raw.model, captcha: raw.captcha, error: raw.error, }; } /** * Extract mediaId from upload response (may be nested) * Handles both formats: * - { mediaGenerationId: "..." } * - { generatedImage: { mediaGenerationId: "..." } } */ export function extractMediaId(response: UseApiImageUploadResponse): string { // Direct format if (response.mediaGenerationId) { return typeof response.mediaGenerationId === "string" ? response.mediaGenerationId : response.mediaGenerationId.mediaGenerationId; } // Nested format (from some API versions) const nested = (response as any).generatedImage?.mediaGenerationId; if (nested) { return nested; } throw new Error( `Failed to extract mediaGenerationId from upload response: ${JSON.stringify(response)}` ); }