/** * native-flow-r2v.ts — first-class Google Flow Reference-to-Video (R2V) scene * render via saved **Flow Characters**, the proven character-ad workflow. * * Unlike the produce/execute path (which routes through the bun `vclaw-cli` * flow.ts and a full readiness/storyboard ceremony), this is a lightweight, * pure-Node fetch transport — the same shape as {@link ./native-runway} / * {@link ./native-dreamina} — for rendering ONE scene directly from character * references: * * POST {BASE}/google-flow/videos { email, prompt, model, aspectRatio, * duration, async, character_1..7 } → { jobid } * GET {BASE}/google-flow/jobs/{jobid} (jobid VERBATIM — encoding → 400) * → { status: started|completed|failed, ... videoUrl } * * R2V constraints (vclaw-cli/src/backends/types.ts, live-proven 2026-06-11): * - model MUST be `veo-3.1-fast` (veo-3.1-quality rejects character_*). * - character refs only (character_1..7) — NO startImage (can't mix R2V + I2V). * - Veo generates the character's voice + lip-sync NATIVELY from the prompt. * - Flow burst-throttles with a 403 reCAPTCHA (PUBLIC_ERROR_UNUSUAL_ACTIVITY); * this transport cools down and retries the SUBMIT for that case only. * * Transports (fetch + sleep) are injectable so the whole module is unit-testable * offline with no network and no real waiting. */ import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { resolveFlowCaptchaRetry, applyFlowCaptcha } from './flow-captcha.js'; export const FLOW_BASE = 'https://api.useapi.net/v1'; export const FLOW_R2V_MODEL = 'veo-3.1-fast'; export const FLOW_R2V_DURATIONS = new Set([4, 6, 8, 10]); const RECAPTCHA_RE = /recaptcha|unusual_activity|permission_denied/i; export interface FlowFetchResponse { ok: boolean; status: number; text(): Promise; arrayBuffer(): Promise; /** Optional so test fakes can omit it; real fetch always provides it. */ headers?: { get(name: string): string | null }; } /** * Server-stated throttle cooldown in ms, or null. Prefers the `Retry-After` * header (seconds or an HTTP/ISO date), then the response body's `retryAfter` * field (legacy seconds, or — current useapi shape since 2026-06-15 — an ISO * timestamp string). Honoring this waits the REAL window the load balancer * reports instead of a fixed guess. */ export function flowRetryAfterMs( res: { headers?: { get(name: string): string | null } }, parsedBody: Record, ): number | null { const header = res.headers?.get?.('retry-after'); if (header) { const secs = Number(header); if (Number.isFinite(secs) && secs >= 0) return secs * 1000; const ms = Date.parse(header); if (!Number.isNaN(ms)) { const d = ms - Date.now(); if (d > 0) return d; } } const field = parsedBody.retryAfter; if (typeof field === 'number' && Number.isFinite(field) && field >= 0) return field * 1000; if (typeof field === 'string') { const ms = Date.parse(field); if (!Number.isNaN(ms)) { const d = ms - Date.now(); if (d > 0) return d; } } return null; } export type FlowFetchLike = ( input: string, init?: { method?: string; headers?: Record; body?: string }, ) => Promise; export interface FlowR2vOptions { /** Scene prompt (dialogue goes inline; prompt hygiene is applied by the caller). */ prompt: string; /** Resolved character refs → character_1..7 (1–7; the person + optional product). */ characterRefs: string[]; outputPath: string; /** 4 | 6 | 8 | 10 seconds (default 8). */ durationSeconds?: number; /** 'landscape' | 'portrait' (default 'landscape'). */ aspectRatio?: 'landscape' | 'portrait'; env?: NodeJS.ProcessEnv; fetchImpl?: FlowFetchLike; sleepImpl?: (ms: number) => Promise; /** * Captcha-retry count sent on the submit (`captchaRetry`, auto-solve). Omit to * resolve from VCLAW_FLOW_CAPTCHA_RETRY (default 3); 0 opts out. This rides * through the intermittent 403 reCAPTCHA inline rather than relying solely on * the cooldown-resubmit fallback below. */ captchaRetry?: number; /** Cooldown-retries for a 403 reCAPTCHA / 429 throttle on SUBMIT (default 2). */ maxRecaptchaRetries?: number; /** Cooldown between throttle retries, ms, when the server states no window (default 5 min). */ recaptchaCooldownMs?: number; /** * Max in-process wait for a server-stated Retry-After window (default 10 min). * A longer quarantine (e.g. the ~30-min USER_QUOTA window) fails fast with the * real wait instead of blocking the process for it. */ maxThrottleWaitMs?: number; pollIntervalMs?: number; maxPolls?: number; } export interface FlowR2vResult { jobId: string; outputPath: string; videoUrl: string; characterCount: number; model: string; durationSeconds: number; aspectRatio: string; } function readDotEnvLike(raw: string): Record { const out: Record = {}; for (const line of raw.split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m) out[m[1]] = m[2].replace(/^['"]|['"]$/g, '').trim(); } return out; } async function resolveEnv(env: NodeJS.ProcessEnv, workspaceRoot?: string): Promise { if (workspaceRoot) { const envLocal = join(workspaceRoot, '.env.local'); if (existsSync(envLocal)) return { ...readDotEnvLike(await readFile(envLocal, 'utf-8')), ...env }; } return env; } const defaultSleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); /** * Render one R2V scene from saved Flow character refs and download it to * `outputPath`. Throws a clear error on a non-throttle failure; cools down and * retries the submit on a 403 reCAPTCHA throttle. */ export async function submitFlowR2vNative( options: FlowR2vOptions & { workspaceRoot?: string }, ): Promise { const refs = options.characterRefs.filter((r) => r && r.trim()).slice(0, 7); if (refs.length === 0) { throw new Error('flow-r2v requires at least one character reference (the person and/or product).'); } const duration = options.durationSeconds ?? 8; if (!FLOW_R2V_DURATIONS.has(duration)) { throw new Error(`flow-r2v duration must be one of ${[...FLOW_R2V_DURATIONS].join(', ')} (got ${duration}).`); } const aspectRatio = options.aspectRatio ?? 'landscape'; const env = await resolveEnv(options.env ?? process.env, options.workspaceRoot); const token = env.USEAPI_API_TOKEN?.trim(); const email = env.USEAPI_ACCOUNT_EMAIL?.trim(); if (!token) throw new Error('flow-r2v requires USEAPI_API_TOKEN in the environment.'); if (!email) throw new Error('flow-r2v requires USEAPI_ACCOUNT_EMAIL in the environment.'); const fetchImpl = options.fetchImpl ?? (fetch as unknown as FlowFetchLike); const sleep = options.sleepImpl ?? defaultSleep; const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; const body: Record = { email, prompt: options.prompt, model: FLOW_R2V_MODEL, aspectRatio, duration, async: true, }; refs.forEach((ref, i) => { body[`character_${i + 1}`] = ref; }); // Auto-solve the reCAPTCHA inline (cycles useapi's configured providers / free // credits) so the burst-throttle 403 is far rarer; the cooldown loop below is // the residual fallback. applyFlowCaptcha(body, options.captchaRetry ?? resolveFlowCaptchaRetry(env)); // --- submit (with bounded throttle cooldown-retry) --- const maxRetries = options.maxRecaptchaRetries ?? 2; const cooldownMs = options.recaptchaCooldownMs ?? 5 * 60 * 1000; const maxThrottleWaitMs = options.maxThrottleWaitMs ?? 10 * 60 * 1000; let jobId = ''; for (let attempt = 0; attempt <= maxRetries; attempt++) { const res = await fetchImpl(`${FLOW_BASE}/google-flow/videos`, { method: 'POST', headers, body: JSON.stringify(body) }); const text = await res.text(); if (res.ok) { const parsed = safeJson(text); jobId = String(parsed.jobid ?? parsed.jobId ?? (parsed.job as Record)?.jobid ?? parsed.id ?? ''); if (!jobId) throw new Error(`flow-r2v submit returned no jobId: ${text.slice(0, 200)}`); break; } // Throttle (403 reCAPTCHA burst, or 429 quota/traffic) → cool down + retry // the submit (NOT a content reject). Honor the server-stated Retry-After // window when present; otherwise use the fixed cooldown. const isThrottle = res.status === 429 || (res.status === 403 && RECAPTCHA_RE.test(text)); if (isThrottle && attempt < maxRetries) { const serverMs = flowRetryAfterMs(res, safeJson(text)); if (serverMs !== null && serverMs > maxThrottleWaitMs) { throw new Error( `flow-r2v submit throttled (HTTP ${res.status}): server quarantine window is ~${Math.round(serverMs / 60_000)} min — ` + `retry after that, or add accounts / wait for the quota to clear.`, ); } await sleep(serverMs ?? cooldownMs); continue; } throw new Error(`flow-r2v submit failed (HTTP ${res.status}): ${text.slice(0, 300)}`); } // --- poll (jobId VERBATIM) --- const interval = options.pollIntervalMs ?? 12000; const maxPolls = options.maxPolls ?? 90; let videoUrl = ''; for (let i = 0; i < maxPolls; i++) { const res = await fetchImpl(`${FLOW_BASE}/google-flow/jobs/${jobId}`, { method: 'GET', headers }); const text = await res.text(); const parsed = safeJson(text); const status = String(parsed.status ?? '').toLowerCase(); if (status === 'completed') { videoUrl = extractVideoUrl(parsed, text); if (!videoUrl) throw new Error(`flow-r2v completed but no videoUrl: ${text.slice(0, 200)}`); break; } if (status === 'failed') { const detail = String(parsed.error ?? '') + ' ' + JSON.stringify(parsed.errorDetails ?? parsed.raw ?? '').slice(0, 200); throw new Error(`flow-r2v generation failed: ${detail.trim()}`); } if (i < maxPolls - 1) await sleep(interval); } if (!videoUrl) throw new Error('flow-r2v poll timed out before completion.'); // --- download --- const dl = await fetchImpl(videoUrl, { method: 'GET' }); if (!dl.ok) throw new Error(`flow-r2v download failed (HTTP ${dl.status}).`); const bytes = Buffer.from(await dl.arrayBuffer()); await mkdir(dirname(options.outputPath), { recursive: true }); await writeFile(options.outputPath, bytes); return { jobId, outputPath: options.outputPath, videoUrl, characterCount: refs.length, model: FLOW_R2V_MODEL, durationSeconds: duration, aspectRatio, }; } function safeJson(text: string): Record { try { return JSON.parse(text) as Record; } catch { return {}; } } function extractVideoUrl(parsed: Record, rawText: string): string { const response = parsed.response as Record | undefined; const media = (response?.media ?? parsed.media) as Array> | undefined; const fromMedia = media?.[0]?.videoUrl; if (typeof fromMedia === 'string' && fromMedia) return fromMedia; const scan = rawText.match(/https:\/\/[^"]+\.mp4[^"]*/); return scan ? scan[0] : ''; } // ─── prompt hygiene (the dry-voice + no-baked-music standing rules) ────────── export interface R2vPromptHygieneOptions { /** Keep Veo's generated music in the clip (default false → suppress it). */ keepMusic?: boolean; /** Allow room reverb on the voice (default false → dry close-mic). */ allowReverb?: boolean; } /** * Append the standing character-ad audio directives to a scene prompt, unless * opted out. Suppressing Veo-generated music keeps the clip audio voice-only so * a post bed can sit under it without clashing; the dry close-mic directive * fixes Veo's default echoey delivery. Idempotent-ish (only appends when not * already present). Pure. */ export function applyR2vPromptHygiene(prompt: string, options: R2vPromptHygieneOptions = {}): string { const clauses: string[] = []; if (!options.allowReverb && !/no echo|no reverb|close-mic|close-microphone/i.test(prompt)) { clauses.push('Any spoken voice is clean, dry and intimate, recorded close-microphone with no echo and no reverb.'); } if (!options.keepMusic && !/no music|no instrumental|no soundtrack/i.test(prompt)) { clauses.push('No music, no instrumental, no background soundtrack in the audio — only the spoken voice and subtle natural room ambience.'); } return clauses.length > 0 ? `${prompt.trim()} ${clauses.join(' ')}` : prompt; }