/** * 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 { applyFlowResolution, resolveFlowVideoResolution, type FlowVideoResolution } from '../flow-resolution.js'; import { finishFlowClip, flowUpscaleEnabled } from '../flow-upscale-finish.js'; import { recoverFlowVideoBytes, isFlowMediaSuccessful, type FlowMediaFetchLike } from '../flow-media-url.js'; // Canonical IP-classifier predicate lives with classifyVeoFailure — one copy, // so the two retry loops can never disagree about what is retryable. import { isIpProhibitedFailure } from '../native-veo.js'; import type { MotionOverlayV2VStep } from './execute.js'; const execFileP = promisify(execFile); const FLOW_BASE = 'https://api.useapi.net/v1/google-flow'; /** * Default per-take attempts. At the measured ~1-in-8 clear rate, 3 attempts * finish a take about one run in three; 10 finish it about seven in ten, and * the seven blocked draws in between cost nothing. */ export const FLOW_V2V_DEFAULT_ATTEMPTS = 10; export interface FlowV2VConfig { token: string; email: string; base?: string; /** * Per-take submit attempts before giving up. Flow's V2V moderation on person * footage is probabilistic — measured 3 clears in 24 identical draws of a * talking-head clip (2026-09-02) — and a blocked draw is free and returns in * ~20 s, so the ceiling costs wall-clock, never credits. Default * `FLOW_V2V_DEFAULT_ATTEMPTS`. */ 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 5), 0 opts out. */ captchaRetry?: number; /** * Output resolution for the edit; omit → VCLAW_FLOW_RESOLUTION, and unset * there means the field is not sent at all (API default 720p). `360p` halves * a V2V edit to 10 credits — the tier to iterate a prompt in. */ resolution?: FlowVideoResolution; /** * Apply the free Google Flow 1080p finish to each edited clip. Omit to resolve * from VCLAW_FLOW_UPSCALE (ON unless explicitly disabled). */ upscale?: boolean; } /** * 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; /** * Output resolution (omni-flash only, which this transport always is). A V2V * edit costs 20 credits at 720p and **10 at 360p**, so the draft loop can run * at half price. Omitted when undefined — a body without it is byte-identical * to what this transport sent before the parameter existed. */ resolution?: FlowVideoResolution; }): Record { return applyFlowResolution( 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, ), args.resolution, ); } /** 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: pull the media id out of a poll response. Always present on a completed * job, and the only handle on the clip when `videoUrl` was withheld. */ export function extractMediaGenerationId(payload: unknown): string | undefined { const media = (payload as { response?: { media?: Array<{ mediaGenerationId?: unknown }> } })?.response?.media; // Skip items whose own status reports a FAILED generation — they never carry // a URL and never will, so recovery would only burn its budget. const id = media ?.filter((m) => isFlowMediaSuccessful(m)) .find((m) => typeof m.mediaGenerationId === 'string' && m.mediaGenerationId)?.mediaGenerationId; return typeof id === 'string' ? id : undefined; } /** * Pure: extract a compact moderation/failure reason from a failed poll payload. * * Matches BOTH string kinds useapi documents in `response.failureReasons` * (spec 2026-09-01): terminal `PUBLIC_ERROR_*` / `FINISH_REASON_*` codes, and * bare classifier labels naming which filter fired — `IP_PROHIBITED` is one, * and it carries no `PUBLIC_ERROR_` prefix, so the old code-only pattern * reported the most important failure we can get as `unknown`. * * The label list is Google's and open-ended, so this matches the SHAPE of a * screaming-snake label rather than an exhaustive set. */ export function extractFailureReason(payload: unknown): string { const json = JSON.stringify((payload as { response?: unknown })?.response ?? payload); const codes = json.match(/PUBLIC_ERROR_\w+|FINISH_REASON_\w+/g) ?? []; // Bare classifier labels: 2+ SCREAMING_SNAKE segments, not already captured. const labels = (json.match(/"([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)"/g) ?? []) .map((s) => s.replace(/"/g, '')) .filter((s) => !/^(PUBLIC_ERROR_|FINISH_REASON_)/.test(s)); const all = [...new Set([...codes, ...labels])]; return all.length ? all.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 resolution = cfg.resolution ?? resolveFlowVideoResolution(); const maxAttempts = cfg.maxAttempts ?? FLOW_V2V_DEFAULT_ATTEMPTS; const pollIntervalMs = cfg.pollIntervalMs ?? 20000; const pollTimeoutMs = cfg.pollTimeoutMs ?? 8 * 60 * 1000; async function poll(jobId: string): Promise<{ url?: string; mediaGenerationId?: string }> { 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') { // A completed job may carry no videoUrl: since 2026-07-27 useapi omits // the field while Google rate-limits its signed-URL calls. The edit // succeeded, so hand back the media id and recover the bytes by it. const url = extractVideoUrl(pj); if (url) return { url }; const mediaGenerationId = extractMediaGenerationId(pj); if (mediaGenerationId) return { mediaGenerationId }; throw new Error('motion-overlay V2V completed but carried neither a videoUrl nor a mediaGenerationId.'); } 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, resolution })), }); const jobId = extractJobId(await gen.json().catch(() => ({}))); if (!jobId) { lastReason = `submit returned HTTP ${gen.status} with no job id`; continue; } try { const done = await poll(jobId); let bytes: Buffer; if (done.url) { const dl = await doFetch(done.url); bytes = Buffer.from(await dl.arrayBuffer()); } else { bytes = (await recoverFlowVideoBytes({ mediaGenerationId: done.mediaGenerationId!, apiToken: cfg.token, fetchImpl: doFetch as unknown as FlowMediaFetchLike, })).bytes; } await mkdir(dirname(step.outputPath), { recursive: true }); await writeFile(step.outputPath, bytes); // Free 1080p finish while we still hold the mediaGenerationId — Google // upscales only a clip Flow generated, and this is the last point that // has the id. Never fatal: a failure keeps the edited clip as-is. if (done.mediaGenerationId) { const up = await finishFlowClip({ mediaGenerationId: done.mediaGenerationId, outputPath: step.outputPath, apiToken: cfg.token, enabled: cfg.upscale ?? flowUpscaleEnabled(), fetchImpl: doFetch as unknown as FlowMediaFetchLike, }); if (!up.upscaled && up.skippedReason && up.skippedReason !== 'disabled') { process.stderr.write( `[motion-overlay] take ${step.index}: kept the edited clip — 1080p upscale skipped (${up.skippedReason})\n`, ); } } return; } catch (err) { lastReason = (err as Error).message; // An IP-classifier block is the exception to "moderation is // probabilistic": the flagged input image does not change between // draws, so every remaining attempt is guaranteed to fail the same way. // Stop now and say what actually fixes it. if (isIpProhibitedFailure(lastReason)) { throw new Error( `motion-overlay V2V take ${step.index} was refused by Google's intellectual-property ` + `classifier (${lastReason}) — NOT a network/IP-address problem. This is the one Flow ` + 'refusal that never clears on retry: the input image is unchanged, so every further ' + 'draw is flagged the same way. Replace the reference image — original photos of ' + 'non-famous subjects pass where celebrity photos, film stills, product shots and ' + 'copyrighted characters do not. Raising --v2v-retries cannot help here.', ); } 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 person footage for editing (FINISH_REASON_INPUT_VIDEO_EDIT / VIDEO_EDIT_BLOCKED) ' + 'at random — about 1 draw in 8 clears the same clip, and a blocked draw is free — so raise --v2v-retries, ' + 'or try a different, more stylized source clip.', ); }; }