/** * Real Google Flow omni-flash V2V transport for the standalone motion-overlay * default runner (the per-take seam injected into {@link createDefaultMotionOverlayRunner}). * * Per take it: uploads the base footage as a Flow asset (referenceVideo_1), * submits an omni-flash video-to-video edit with the composed prompt + a * frame-window, polls the job to completion, and downloads the annotated take to * `outputPath`. The audio-restore mux and the clip-stitch are handled by the * surrounding default runner — this module is ONLY the V2V edit. * * Proven recipe (validated live against useapi.net 2026-06-05): * - upload: POST {base}/assets/{email} (Content-Type: video/mp4, raw bytes) * → { mediaGenerationId: { mediaGenerationId } } * - submit: POST {base}/videos { model:'omni-flash', prompt, referenceVideo_1, * startFrameIndex_1:0, endFrameIndex_1:, aspectRatio, email, * async:true } → { jobid } (NOTE: the field is `jobid`, lowercase i) * - poll: GET {base}/jobs/{jobId} (jobId passed RAW — do NOT URL-encode it) * → status created → completed | failed * - result: response.media[].videoUrl * * The Flow safety filter (PUBLIC_ERROR_UNSAFE_GENERATION / FINISH_REASON_INPUT_VIDEO_EDIT) * is probabilistic AND input-video-specific (it rejects some source clips for * editing regardless of how benign the prompt is), so a `failed` verdict is * retried a few times before giving up with an actionable message. * * The network path is intentionally an injectable seam (`fetcher`); the pure * request-shaping helpers below are unit-tested. */ import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { dirname } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { resolveFlowCaptchaRetry, applyFlowCaptcha } from '../flow-captcha.js'; import type { MotionOverlayV2VStep } from './execute.js'; const execFileP = promisify(execFile); const FLOW_BASE = 'https://api.useapi.net/v1/google-flow'; export interface FlowV2VConfig { token: string; email: string; base?: string; maxAttempts?: number; pollIntervalMs?: number; pollTimeoutMs?: number; /** Injectable fetch (tests). Defaults to the global `fetch`. */ fetcher?: typeof fetch; /** captcha-retry auto-solve count; omit → VCLAW_FLOW_CAPTCHA_RETRY (default 3), 0 opts out. */ captchaRetry?: number; } /** * Read the useapi.net credentials from the environment. Throws a clear, * actionable error when they are absent (so `--execute` fails fast rather than * silently producing nothing). */ export function flowV2VConfigFromEnv(env: NodeJS.ProcessEnv = process.env): FlowV2VConfig { const token = env.USEAPI_API_TOKEN; const email = env.USEAPI_ACCOUNT_EMAIL; if (!token || !email) { throw new Error( 'motion-overlay --execute requires USEAPI_API_TOKEN and USEAPI_ACCOUNT_EMAIL in the environment — ' + 'the omni-flash V2V transport uploads each take to Google Flow via useapi.net. Export them (e.g. from .env) and retry.', ); } return { token, email }; } /** Pure: build the POST /videos request body for an omni-flash V2V edit. */ export function buildV2VRequestBody(args: { prompt: string; referenceVideoId: string; frames: number; aspect: string; email: string; /** captcha-retry auto-solve count (added when > 0). */ captchaRetry?: number; }): Record { return applyFlowCaptcha( { model: 'omni-flash', prompt: args.prompt, referenceVideo_1: args.referenceVideoId, startFrameIndex_1: 0, endFrameIndex_1: args.frames, aspectRatio: args.aspect, email: args.email, async: true, } as Record, args.captchaRetry ?? 0, ); } /** Pure: pull the job id from a submit response — the API returns `jobid`, some paths `jobId`. */ export function extractJobId(payload: unknown): string | undefined { const p = payload as { jobId?: unknown; jobid?: unknown }; const id = p?.jobId ?? p?.jobid; return typeof id === 'string' && id.length > 0 ? id : undefined; } /** Pure: pull the first completed videoUrl out of a poll response. */ export function extractVideoUrl(payload: unknown): string | undefined { const media = (payload as { response?: { media?: Array<{ videoUrl?: unknown }> } })?.response?.media; const url = media?.find((m) => typeof m.videoUrl === 'string' && m.videoUrl)?.videoUrl; return typeof url === 'string' ? url : undefined; } /** Pure: extract a compact moderation/failure reason from a failed poll payload. */ export function extractFailureReason(payload: unknown): string { return ( JSON.stringify((payload as { response?: unknown })?.response ?? payload) .match(/PUBLIC_ERROR_\w+|FINISH_REASON_\w+/g) ?.slice(0, 3) ?.join(', ') ?? 'unknown' ); } /** A `failed` verdict from the Flow safety filter (retryable — it is probabilistic). */ class V2VModerationError extends Error {} function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** ffprobe the clip for an integer frame count + aspect (24fps + landscape fallback). */ async function probeVideo(path: string): Promise<{ frames: number; aspect: 'landscape' | 'portrait' }> { try { const [{ stdout: wh }, { stdout: dur }] = await Promise.all([ execFileP('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', path]), execFileP('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nk=1:nw=1', path]), ]); const [w, h] = wh.trim().split(',').map(Number); const d = Number(dur.trim()); return { frames: Math.max(1, Math.round((Number.isFinite(d) ? d : 5) * 24)), aspect: w >= h ? 'landscape' : 'portrait', }; } catch { return { frames: 120, aspect: 'landscape' }; } } /** * Build the live omni-flash V2V transport. The returned function uploads the * take, submits the edit, polls, and writes the annotated take to `outputPath`. */ export function createFlowV2VTransport(cfg: FlowV2VConfig): (step: MotionOverlayV2VStep) => Promise { const base = cfg.base ?? FLOW_BASE; const doFetch = cfg.fetcher ?? fetch; const auth = { Authorization: `Bearer ${cfg.token}` }; const captchaRetry = cfg.captchaRetry ?? resolveFlowCaptchaRetry(); const maxAttempts = cfg.maxAttempts ?? 3; const pollIntervalMs = cfg.pollIntervalMs ?? 20000; const pollTimeoutMs = cfg.pollTimeoutMs ?? 8 * 60 * 1000; async function poll(jobId: string): Promise { const deadline = Date.now() + pollTimeoutMs; while (Date.now() < deadline) { await delay(pollIntervalMs); const res = await doFetch(`${base}/jobs/${jobId}`, { headers: auth }); // RAW jobId — do NOT encode const pj = (await res.json().catch(() => ({}))) as { status?: string }; if (pj.status === 'completed') { const url = extractVideoUrl(pj); if (url) return url; throw new Error('motion-overlay V2V completed but the response carried no videoUrl.'); } if (pj.status === 'failed') { throw new V2VModerationError(extractFailureReason(pj)); } } throw new Error('motion-overlay V2V poll timed out.'); } return async (step: MotionOverlayV2VStep): Promise => { const bytes = await readFile(step.baseFootage); const up = await doFetch(`${base}/assets/${encodeURIComponent(cfg.email)}`, { method: 'POST', headers: { ...auth, 'Content-Type': 'video/mp4' }, body: bytes, }); const upJson = (await up.json().catch(() => ({}))) as { mediaGenerationId?: { mediaGenerationId?: string } }; const referenceVideoId = upJson?.mediaGenerationId?.mediaGenerationId; if (!referenceVideoId) { throw new Error(`motion-overlay V2V upload failed (HTTP ${up.status}) for take ${step.index}.`); } const { frames, aspect } = await probeVideo(step.baseFootage); let lastReason = ''; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const gen = await doFetch(`${base}/videos`, { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify(buildV2VRequestBody({ prompt: step.prompt, referenceVideoId, frames, aspect, email: cfg.email, captchaRetry })), }); const jobId = extractJobId(await gen.json().catch(() => ({}))); if (!jobId) { lastReason = `submit returned HTTP ${gen.status} with no job id`; continue; } try { const url = await poll(jobId); const dl = await doFetch(url); await mkdir(dirname(step.outputPath), { recursive: true }); await writeFile(step.outputPath, Buffer.from(await dl.arrayBuffer())); return; } catch (err) { lastReason = (err as Error).message; if (err instanceof V2VModerationError) continue; // probabilistic — retry throw err; // timeout / transport / no-url — fail fast } } throw new Error( `motion-overlay V2V failed for take ${step.index} after ${maxAttempts} attempt(s): ${lastReason}. ` + 'The Google Flow safety filter rejects some input videos for editing (FINISH_REASON_INPUT_VIDEO_EDIT) ' + 'regardless of prompt — try a different, more stylized source clip.', ); }; }