How to Generate Audio-Native AI Video with Gemini Omni Flash via the Google Flow API

7 min read • June 15, 2026 (October 20, 2026)

Table of contents

  1. Introduction
  2. Supported model
  3. Pricing
  4. Generate a video in two API calls
  5. Reference modes
  6. Voices & characters
  7. Batch-generate with a script
  8. Examples
  9. Frequently asked questions
  10. Conclusion

Introduction

Gemini Omni Flash makes audio-native AI video from a single curl through the useapi.net Google Flow API, with synced voices, characters and video-to-video edits, on your Google Flow website subscription (Google AI Plus, Pro or Ultra). A 10-second 720p video costs $0.12 on Google AI Ultra $199.99, against $1.01 on the official Gemini API (as of October 2026), with no Google Cloud project or API key. The same subscription also makes Omni video in Google Vids, without spending Flow credits.

Google Flow labels this model Omni 1.1 Flash in its interface. The API value stays model: "omni-flash" across Google’s version renames, so nothing below changes when the label does.

Omni Flash is the same Google Flow API as Veo, picked with one field — model: "omni-flash" — but it is a different kind of model. Where Veo is a strong plain text-to-video engine, Omni Flash is audio-native and reference-rich:

  • Sound is generated as part of the video — narration, dialog, and ambience come out of the same model, not bolted on after.
  • Voices — 30 built-in system voice presets plus custom voices you create from your own dialog and performance notes, fed in via referenceAudio_1..5.
  • Image and video references — up to 7 reference images for reference-to-video (R2V), and video-to-video (V2V) editing of an uploaded or generated video via referenceVideo_1.
  • Reusable characters — bundle reference images + a voice into a named character and reuse it across videos.
  • 10-second videos — Omni Flash is the only Google Flow model that goes to 10 seconds (Veo tops out at 8).

This guide shows the two-call workflow with copy-paste curl, the reference modes that make Omni Flash distinctive, then a runnable Node.js script that batch-generates from a list of prompts. For plain text-to-video, see the sibling Veo 3.1 tutorial.

Supported model

This guide uses one model — omni-flash, Google’s audio-native video model — selected with model: "omni-flash". Credit cost scales with video length:

Duration Credits per video
4s 7
6s 10
8s 12
10s 15
V2V edit 20

Those are the costs at the default 720p. Passing resolution: "360p" costs about half — 4 / 5 / 6 / 7 credits by length, 10 for a V2V edit — and a 360p video can be promoted to 720p afterwards with POST /videos/upscale.

Omni Flash accepts five modes on POST /videos: plain text-to-video (T2V), reference-to-video (R2V) with up to 7 referenceImage_1..7, image-to-video from a start frame via startImage (I2V), image-to-video between a start and an end frame via startImage + endImage (I2V-FL), and video-to-video editing (V2V) of an uploaded MP4 via referenceVideo_1. See Reference modes below.

Pricing

Omni Flash video needs a paid Google AI subscription (Plus, Pro or Ultra), and useapi.net adds no per-video charge. Each video spends Flow credits by length (see Supported model). The price per video on every plan, against the official Gemini API, is in the Google Flow price table.

A Google AI plan also comes with a monthly allowance in Google Vids, which generates video with Gemini Omni 1.1 Flash (Google’s announcement) and does not use Flow credits. On Ultra $199 (Google calls it Ultra 20x) it is 10,000 seconds of video a month — about 1,000 more 10-second clips at 1080p on top of the ~1,666 that Flow’s 25,000 credits buy. Those seconds are shared by everyone on a Google family plan. Other plans get less (see Google’s Gemini in Google Vids page), and free accounts get none. The Google Vids API uses this allowance with no captcha. Connect the account with Setup Google Vids.

Generate a video in two API calls

You need a useapi.net API token and a connected Google Flow account on a paid Google AI subscription (Plus, Pro or Ultra). Video jobs are asynchronous: pass async: true and the create call returns a jobid immediately, then you poll until the video is ready.

  1. Submit the job — POST https://api.useapi.net/v1/google-flow/videos with model: "omni-flash":
curl -X POST "https://api.useapi.net/v1/google-flow/videos" \
  -H "Authorization: Bearer $USEAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "A lighthouse keeper narrates the coming storm, waves crashing below",
    "model": "omni-flash",
    "aspectRatio": "landscape",
    "duration": 10,
    "async": true
  }'

Omni Flash is audio-native, so it generates the soundtrack — the keeper’s narration and the crashing waves — straight from the prompt, no audio parameter required. In async mode the response returns immediately with 201 Created, a lowercase jobid, and status: "created":

{
  "jobid": "j1731859234567v-u12345-email:jo***@gmail.com-bot:google-flow",
  "type": "video",
  "status": "created",
  "created": "2026-06-15T12:34:56.789Z",
  "request": {
    "async": true,
    "prompt": "A lighthouse keeper narrates the coming storm, waves crashing below",
    "model": "omni-flash",
    "aspectRatio": "landscape",
    "duration": 10
  }
}
  1. Poll for the result — GET https://api.useapi.net/v1/google-flow/jobs/{jobId}:
curl "https://api.useapi.net/v1/google-flow/jobs/JOBID" \
  -H "Authorization: Bearer $USEAPI_TOKEN"

status moves through created → started → completed. When it reaches completed, the finished MP4 is in response.media[].videoUrl (a single prompt with count > 1 returns one entry per video):

{
  "jobid": "j1731859234567v-...",
  "type": "video",
  "status": "completed",
  "response": {
    "media": [
      {
        "mediaGenerationId": "user:12345-email:6a6f...-video:a1d95d21-...",
        "videoUrl": "https://flow-content.google/video/a1d95d21-...?Expires=...",
        "thumbnailUrl": "https://flow-content.google/image/a1d95d21-...?Expires=..."
      }
    ],
    "remainingCredits": 18760
  }
}

Omni Flash typically finishes in 60–180 seconds. The signed videoUrl is valid for about 6 hours, so download promptly. Prefer not to poll? Pass a replyUrl in the create body to receive a webhook callback when the job completes.

Reference modes

Omni Flash’s distinctive value is how much you can feed it as a reference. All references come from the POST /assets/email upload endpoint (or, for characters, from POST /characters). The upload response nests the reference id at mediaGenerationId.mediaGenerationId — that nested string is what you pass back. The modes accepted by omni-flash (from the POST /videos docs):

Mode Parameter(s) Omni Flash limit
Reference-to-video (R2V) referenceImage_1..7 up to 7 image refs
Image-to-video (I2V) startImage one start frame
Image-to-video, first + last (I2V-FL) startImage + endImage the two frames only — no other refs
Video-to-video edit (V2V) referenceVideo_1 (+ optional startFrameIndex_1 / endFrameIndex_1) one uploaded MP4
Characters character_1..7 (from POST /characters) 7 total combined with referenceImage_*
Voice narration referenceAudio_1..5 up to 5 (R2V) / up to 3 (V2V edit)

Reference-to-video (R2V) — upload one or more images, then pass them as referenceImage_1, referenceImage_2, … (slots _4.._7 are Omni Flash only). Image references cannot be combined with startImage/endImage.

# 1. Upload a reference image — the id is nested one level deep:
curl -X POST "https://api.useapi.net/v1/google-flow/assets/john%40gmail.com" \
  -H "Authorization: Bearer $USEAPI_TOKEN" \
  -H "Content-Type: image/jpeg" \
  --data-binary @subject.jpeg
# Response: { "mediaGenerationId": { "mediaGenerationId": "user:12345-email:...-image:..." }, ... }

# 2. Generate, narrated by a system voice
curl -X POST "https://api.useapi.net/v1/google-flow/videos" \
  -H "Authorization: Bearer $USEAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "omni-flash",
    "prompt": "the subject walks toward camera and introduces herself",
    "duration": 8,
    "referenceImage_1": "user:12345-email:...-image:...",
    "referenceAudio_1": "Kore"
  }'

Video-to-video edit (V2V) — upload an MP4 with Content-Type: video/mp4 to POST /assets, then pass the returned id as referenceVideo_1 to edit that video. This is Omni Flash only and switches the request into V2V mode. duration is not accepted (output length matches the input trim window, max 10 s). Optionally trim the source on a 24 fps virtual timeline with startFrameIndex_1 (0–239) and endFrameIndex_1 (1–240, where 240 = 10 s):

curl -X POST "https://api.useapi.net/v1/google-flow/videos" \
  -H "Authorization: Bearer $USEAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "omni-flash",
    "prompt": "restyle the scene as a rainy neon-lit night, add ambient city sound",
    "referenceVideo_1": "user:12345-email:...-video:...",
    "startFrameIndex_1": 0,
    "endFrameIndex_1": 240,
    "async": true
  }'

You can also place inline @-markers in the prompt that resolve to the slots you supply — e.g. @referenceImage_1, @character_1, @referenceAudio_1. Markers are opt-in and each must have a matching body param. (@referenceVideo_* is not supported inline — pass it as a body field.) See Inline @-mention markers.

Voices & characters

Two short setup calls power Omni Flash’s audio and identity features.

Custom voices — POST /voices saves a voice built on one of the 30 system presets with your own dialog and voicePerformance. The response voice field is a reference-id you then pass as referenceAudio_1..5 on POST /videos:

curl -X POST "https://api.useapi.net/v1/google-flow/voices" \
  -H "Authorization: Bearer $USEAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "voice": "Achernar",
    "dialog": "Welcome to the demo.",
    "voicePerformance": "Cheerful, energetic delivery",
    "displayName": "Cheerful Narrator"
  }'
# Response: { "voice": "user:12345-email:...-voice:...-mid:...", ... }

Each referenceAudio_* slot accepts either a system voice name (case-insensitive, one of the 30 presets such as Aoede, Kore, Puck, Zephyr) or a user voice id from the call above. Voice creation requires reCAPTCHA (solved automatically — see the FAQ).

Reusable characters — POST /characters bundles 1–2 reference images plus an optional voice into a named character. The response character field is what you pass as character_1..7:

curl -X POST "https://api.useapi.net/v1/google-flow/characters" \
  -H "Authorization: Bearer $USEAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "displayName": "Carol",
    "imageReference_1": "user:12345-email:...-image:abc123...",
    "voice": "user:12345-email:...-voice:...-mid:..."
  }'
# Response: { "character": "user:12345-email:...-character:...-imgs:1-voice:...", ... }

On Omni Flash, characters work in all durations and share the 7-total image-ref budget with referenceImage_* (referenceImages.length + Σ(character.imgs) ≤ 7). POST /characters does not require a captcha. Characters and voices are also usable from Veo, but with tighter budgets (3 images, 1 voice, 8 s only) — see What can use a character?.

Batch-generate with a script

Finding the right take takes many attempts, and running them by hand is tedious. The Node.js script below reads a list of prompts from prompts.json, uploads any reference images, start and end frames or source videos, passes character_* ids and resolution straight through, submits each job in async mode with model: "omni-flash", then polls and downloads every finished MP4 — so you can queue a batch and come back to the winners. It stops with a warning before submitting a combination the API would reject, such as an end frame without a start frame.

You need Node.js v21 or newer. Put prompts.json and omni-flash.mjs in the same folder and run node ./omni-flash.mjs API_TOKEN EMAIL, where API_TOKEN is your useapi.net API token and EMAIL is your connected Google Flow account email. The script looks the account up by email automatically and checks its health field before submitting.

Prefer to clone and run it locally? The complete omni-flash.mjs and prompts.json are on GitHub in useapi/google-flow-api.

Working in Claude Code or Codex? The omni-flash-video agent skill lets the agent do all of this for you: describe the video, point it at your images, and it uploads them, writes the request, waits and saves the .mp4. Install it with npx skills add useapi/google-flow-api --skill omni-flash-video.

Expand prompts.json
[
    {
        "prompt": "Audio-native text-to-video. By default model omni-flash with a landscape 8-second clip will be used."
    },
    {
        "duration": 10,
        "prompt": "A 10-second clip (omni-flash only). Omni Flash is audio-native, so narration, dialogue and sound come from the prompt. To pin a specific voice, add referenceAudio_1 together with a referenceImage_* or character_*, as in the next prompt."
    },
    {
        "duration": 8,
        "referenceImage_1": "./subject.jpeg",
        "referenceAudio_1": "Kore",
        "prompt": "Reference-to-video (R2V): the uploaded image is a reference, narrated by 'Kore'. Up to 7 referenceImage_* on omni-flash."
    },
    {
        "duration": 8,
        "startImage": "./first_image.jpeg",
        "endImage": "./last_image.jpeg",
        "prompt": "Image-to-video with a start frame (startImage) and an end frame (endImage). The two frames are the only input — no referenceImage_*, character_* or referenceAudio_* alongside them."
    },
    {
        "referenceVideo_1": "./source.mp4",
        "startFrameIndex_1": 0,
        "endFrameIndex_1": 240,
        "prompt": "Video-to-video edit (V2V): omni-flash restyles the uploaded MP4. No duration here — output matches the trim window (max 10 s). For all parameters see https://useapi.net/docs/api-google-flow-v1/post-google-flow-videos"
    }
]
Expand omni-flash.mjs script
/*

Script version 1.1, September 11, 2026

Script to batch-generate audio-native videos with Gemini Omni Flash using the Google Flow API v1 by useapi.net 🚀
Uses the POST /videos endpoint in async mode (model: omni-flash) and polls GET /jobs/{jobId}.
For more details visit https://useapi.net/docs/api-google-flow-v1/post-google-flow-videos

Installation Instructions:
==========================

You need Node.js v21 or newer installed to run this script. Download and install Node.js from:

- Windows, macOS, Linux: https://nodejs.org/

After installation, verify by running the following command in a terminal:

   node -v

Running the Script:
===================

Usage: node omni-flash.mjs <API_TOKEN> <EMAIL> [PROMPTS_FILE]

Replace API_TOKEN with your actual useapi.net API token, see https://useapi.net/docs/start-here/setup-useapi
Replace EMAIL with configured Google Flow email account, see https://useapi.net/docs/start-here/setup-google-flow
If optional PROMPTS_FILE not provided prompts.json will be used.

Example:
--------

node omni-flash.mjs user:1234-abcdefhijklmnopqrstuv [email protected]

This command executes the script using API token user:1234-abcdefhijklmnopqrstuv with [email protected] Google Flow account email.

Changelog:
==========

- June 15, 2026: Initial release.
- September 11, 2026: Adds resolution, startImage / endImage (start and end frames, local files uploaded for you) and character_1..7. Accepts .jpg and upper-case file extensions. Stops with a warning before submitting combinations the API rejects, such as endImage without startImage or referenceAudio_* on its own. A prompt whose local startImage, endImage, referenceImage_* or referenceVideo_1 file fails to upload is skipped and logged to omni-flash_errors.txt instead of being submitted without it. Saves a downloaded video under its final name only once it has been fully received, so a failed download leaves no partial MP4 for the next run to skip as "already exists".

*/

import readline from 'node:readline';
import fs from 'fs/promises';
import path from 'path';
import { writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';


// Constants
const RESULTS_FILE = 'omni-flash_results.txt';
const ERRORS_FILE = 'omni-flash_errors.txt';
const DEFAULT_PROMPTS_FILE = 'prompts.json';
const MODEL = 'omni-flash';
const SLEEP_429 = 30 * 1000; // in milliseconds
const SLEEP_POLL = 15 * 1000; // in milliseconds

const urlAccounts = 'https://api.useapi.net/v1/google-flow/accounts';
const urlVideos = 'https://api.useapi.net/v1/google-flow/videos';
const urlJobs = 'https://api.useapi.net/v1/google-flow/jobs/';
const urlUploadAsset = 'https://api.useapi.net/v1/google-flow/assets/';

// Google Flow accepts png, jpeg and webp images, and mp4 video (for omni-flash V2V edit).
const supportedImageExtensions = ['png', 'jpg', 'jpeg', 'webp'];
const supportedVideoExtensions = ['mp4'];

// { filename: mediaGenerationId }
const uploadedFiles = {};

// Utility to sleep for given milliseconds
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Function to fetch configured Google Flow API accounts
async function fetchAccounts(apiToken) {
    const response = await fetch(urlAccounts, {
        headers: {
            'Accept': 'application/json',
            'Authorization': `Bearer ${apiToken}`
        }
    });

    if (!response.ok) {
        console.error(`⛔ Error fetching accounts (HTTP ${response.status}): ${response.statusText}`);
        process.exit(1);
    }

    return response.json();
}

const elapsedTimeSec = (start) => (Date.now() - start) / 1000;

// Map a file extension to the Content-Type required by POST /assets
const contentTypeForExt = (ext) =>
    ext === 'png' ? 'image/png' :
    ext === 'webp' ? 'image/webp' :
    ext === 'mp4' ? 'video/mp4' :
    'image/jpeg';

async function uploadAsset(apiToken, email, filename) {

    // Check if already uploaded
    if (uploadedFiles.hasOwnProperty(filename))
        return uploadedFiles[filename];

    const startTime = Date.now();

    console.log(`⬆️  Account ${email} uploading file…`, filename);

    const body = new Blob([await fs.readFile(filename)]);

    const fileExt = filename.split('.').pop().toLowerCase();

    const response = await fetch(`${urlUploadAsset}${encodeURIComponent(email)}`, {
        method: 'POST',
        headers: {
            'Accept': 'application/json',
            'Authorization': `Bearer ${apiToken}`,
            'Content-Type': contentTypeForExt(fileExt)
        },
        body
    });

    if (response.ok) {
        const json = await response.json();
        // POST /assets returns the reference id nested as mediaGenerationId.mediaGenerationId
        const mediaGenerationId = json?.mediaGenerationId?.mediaGenerationId;
        console.log(`🆗 mediaGenerationId (${elapsedTimeSec(startTime)} sec)`, mediaGenerationId);
        uploadedFiles[filename] = mediaGenerationId;
    }
    else {
        console.error(`❗ Unable to upload file HTTP ${response.status} (${elapsedTimeSec(startTime)} sec)`, await response.text());
        // Do not attempt to upload failed file again
        uploadedFiles[filename] = undefined;
    }

    return uploadedFiles[filename];
}

async function submit(apiToken, url, body, index, prompt) {
    const createResponse = await fetch(url, {
        method: 'POST',
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${apiToken}`
        },
        body
    });

    const createBody = await createResponse.text();

    // Async POST /videos returns 201 Created with a jobid to poll.
    if (createResponse.status == 201) {
        const json = JSON.parse(createBody);
        // 201 async payload uses lowercase "jobid"; sync 200 uses "jobId". Accept either.
        const jobId = json.jobid ?? json.jobId;
        if (jobId) {
            await fs.appendFile(RESULTS_FILE, `${jobId},#${index}:${prompt}\n`);
            console.log(`✅ jobId`, jobId);
            return 201;
        } else {
            const error = `No jobid found in HTTP 201 response`;
            console.log(`❓ ${error}`, createBody);
            await fs.appendFile(ERRORS_FILE, `${error},#${index}:${prompt}\n`);
            return 500;
        }
    } else {
        switch (createResponse.status) {
            case 429:
                console.log(`🔄️ Retry on HTTP ${createResponse.status}`, createBody);
                break;
            case 503:
                console.log(`🔄️ Service unavailable, retry on HTTP ${createResponse.status}`, createBody);
                break;
            case 400:
                console.log(`🛑 Rejected request (validation or content policy)`, createBody);
                await fs.appendFile(ERRORS_FILE, `${createResponse.status},#${index}:${prompt}\n`);
                break;
            case 402:
                console.log(`🛑 No subscription / insufficient credits`, createBody);
                break;
            default:
                console.log(`❗ FAILED with HTTP ${createResponse.status}`, createBody);
                await fs.appendFile(ERRORS_FILE, `${createResponse.status},#${index}:${prompt}\n`);
        }
        return createResponse.status;
    }
}

// Submit a single prompt to POST /videos with model omni-flash in async mode.
// startImage = start frame (I2V), endImage = end frame (I2V-FL, requires startImage) — the frames are the only input;
// referenceImage_1..7 = R2V image refs; character_1..7 = POST /characters ids;
// referenceVideo_1 = V2V edit (no duration); referenceAudio_1..5 = system voice name or POST /voices user-voice id;
// resolution = 720p (default) or 360p (about half the credits).
async function submitVideo(apiToken, email, prompt, index) {
    const {
        prompt: text, aspectRatio, duration, resolution, count, seed,
        startImage, endImage,
        referenceImage_1, referenceImage_2, referenceImage_3, referenceImage_4,
        referenceImage_5, referenceImage_6, referenceImage_7,
        character_1, character_2, character_3, character_4,
        character_5, character_6, character_7,
        referenceVideo_1, startFrameIndex_1, endFrameIndex_1,
        referenceAudio_1, referenceAudio_2, referenceAudio_3, referenceAudio_4, referenceAudio_5
    } = prompt;

    console.log(`🚀 ${MODEL} » Prompt #${index} • account ${email} …`);

    // Upload any local image/video files and swap the paths for their mediaGenerationId.
    const resolveRef = async (ref) =>
        ref && (ref.startsWith('./') || ref.includes('.'))
            && [...supportedImageExtensions, ...supportedVideoExtensions].includes(ref.split('.').pop().toLowerCase())
            ? await uploadAsset(apiToken, email, ref)
            : ref;

    const startImageId = await resolveRef(startImage);
    const endImageId = await resolveRef(endImage);
    const refImage_1 = await resolveRef(referenceImage_1);
    const refImage_2 = await resolveRef(referenceImage_2);
    const refImage_3 = await resolveRef(referenceImage_3);
    const refImage_4 = await resolveRef(referenceImage_4);
    const refImage_5 = await resolveRef(referenceImage_5);
    const refImage_6 = await resolveRef(referenceImage_6);
    const refImage_7 = await resolveRef(referenceImage_7);
    const refVideo_1 = await resolveRef(referenceVideo_1);

    // A local file that failed to upload would be left out of the body, and the prompt would still be
    // generated (and charged) without it. Skip the prompt instead. uploadAsset remembers the failed file,
    // so a later prompt using it is skipped too, without another upload attempt.
    const failedUploads = [
        ['startImage', startImage, startImageId], ['endImage', endImage, endImageId],
        ['referenceImage_1', referenceImage_1, refImage_1], ['referenceImage_2', referenceImage_2, refImage_2],
        ['referenceImage_3', referenceImage_3, refImage_3], ['referenceImage_4', referenceImage_4, refImage_4],
        ['referenceImage_5', referenceImage_5, refImage_5], ['referenceImage_6', referenceImage_6, refImage_6],
        ['referenceImage_7', referenceImage_7, refImage_7], ['referenceVideo_1', referenceVideo_1, refVideo_1]
    ].filter(([, file, id]) => file && !id);
    for (const [param, file] of failedUploads) {
        console.error(`🛑 Prompt #${index} skipped: ${param} ${file} failed to upload`);
        await fs.appendFile(ERRORS_FILE, `Upload failed for ${param} ${file},#${index}:${text}\n`);
    }
    if (failedUploads.length)
        return;

    // V2V edit does not accept duration — output matches the trim window.
    const isV2V = !!refVideo_1;

    const body = JSON.stringify({
        model: MODEL,
        email,
        prompt: text,
        aspectRatio,
        duration: isV2V ? undefined : duration,
        resolution,
        count,
        seed,
        startImage: startImageId,
        endImage: endImageId,
        referenceImage_1: refImage_1,
        referenceImage_2: refImage_2,
        referenceImage_3: refImage_3,
        referenceImage_4: refImage_4,
        referenceImage_5: refImage_5,
        referenceImage_6: refImage_6,
        referenceImage_7: refImage_7,
        character_1,
        character_2,
        character_3,
        character_4,
        character_5,
        character_6,
        character_7,
        referenceVideo_1: refVideo_1,
        startFrameIndex_1,
        endFrameIndex_1,
        referenceAudio_1,
        referenceAudio_2,
        referenceAudio_3,
        referenceAudio_4,
        referenceAudio_5,
        async: true
    });

    return await submit(apiToken, urlVideos, body, index, text);
}

// Function to download videos
async function download(apiToken) {
    if (! await fileExists(RESULTS_FILE)) return;

    try {
        const resultsContent = await fs.readFile(RESULTS_FILE, 'utf8');
        const lines = resultsContent.trim().split('\n');

        for (const line of lines) {
            const [jobId, prompt] = line.split(',');

            console.log(`👉 ${jobId}`);

            while (true) {
                const response = await fetch(`${urlJobs}${jobId}`, {
                    headers: {
                        'Accept': 'application/json',
                        'Authorization': `Bearer ${apiToken}`
                    }
                });

                if (!response.ok) {
                    console.log(`🛑 Poll failed ${jobId} (HTTP ${response.status}):\n${prompt}\n`, await response.text());
                    break;
                }

                const jobBody = await response.json();
                const { status, error, response: jobResponse } = jobBody;

                if (status == 'failed') {
                    console.error(`🛑 FAILED ${jobId} (${error}):\n${prompt}\n`);
                    break;
                }

                if (status == 'completed') {
                    const media = jobResponse?.media ?? [];

                    if (media.length == 0)
                        console.error(`🛑 Completed but no media for ${jobId}:\n${prompt}\n`);

                    // A single prompt with count > 1 returns multiple videos.
                    for (let i = 0; i < media.length; i++) {
                        const url = media[i]?.videoUrl;
                        const videoFilename = `${jobId.replace(/[:*]/g, '_')}_${i + 1}.mp4`;

                        try {
                            await fs.access(videoFilename);
                            console.log(`⚠️ ${videoFilename} already exists. Skipping download.`);
                            continue;
                        } catch {
                            // File does not exist, proceed with downloading
                        }

                        if (url) {
                            console.log(`✅ Downloading ${url} to ${videoFilename}`);
                            // Stream into a temporary file and rename it once the whole video has arrived, so a download
                            // that drops mid-stream leaves no partial file behind for the next run to skip as "already exists".
                            const tempFilename = `${videoFilename}.part`;
                            try {
                                const videoResponse = await fetch(url);
                                if (!videoResponse.ok) {
                                    console.error(`⛔ Unable to download ${jobId} (HTTP ${videoResponse.status}):\n${prompt}\n`, url);
                                    continue;
                                }
                                const stream = Readable.fromWeb(videoResponse.body);
                                await writeFile(tempFilename, stream);
                                await fs.rename(tempFilename, videoFilename);
                            } catch (err) {
                                await fs.rm(tempFilename, { force: true });
                                console.error(`⛔ Error during download: ${err}`);
                            }
                        } else
                            console.error(`🛑 No videoUrl for ${jobId} media #${i + 1}:\n${prompt}\n`);
                    }

                    break;
                }

                console.log(`⌛ ${jobId} status (${status}) and is still in progress, waiting…`);
                await sleep(SLEEP_POLL);
            }
        }
    } catch (error) {
        console.log(`⛔ Error during download:`, error.stack || error);
    }
}

// Main function
async function main() {
    const apiToken = process.argv[2];
    const email = process.argv[3];
    const promptFile = process.argv[4] || DEFAULT_PROMPTS_FILE;

    if (!apiToken || !email) {
        console.error('Usage: node omni-flash.mjs <API_TOKEN> <EMAIL> [PROMPTS_FILE]');
        process.exit(1);
    }

    console.info('Script v1.1');

    console.info('Node version is: ' + process.version);

    try {
        if (await fileExists(RESULTS_FILE)) {
            let user_input;
            while (!['y', 'n'].includes(user_input)) {
                user_input = (await promptUser(`❔ ${RESULTS_FILE} file detected. Do you want to download the results now? (y/n): `))?.toLowerCase();
                if (user_input == 'y') {
                    await download(apiToken);
                    await fs.unlink(RESULTS_FILE);
                }
            }
        }

        const start = new Date();
        try {
            console.info('START EXECUTION', start);
            await execute(apiToken, email, promptFile); // Pass the promptFile to execute function
        }
        finally {
            console.info('COMPLETED', new Date());
            console.info('EXECUTION ELAPSED', diffInMinutesAndSeconds(start, new Date()));
        }

        try {
            console.info('START DOWNLOAD', start);
            await download(apiToken);
        }
        finally {
            console.info('TOTAL ELAPSED', diffInMinutesAndSeconds(start, new Date()));
        }
    } catch (error) {
        console.error('⛔ Error during execution:', error.stack || error);
    }
}

// Modify the execute function to accept promptFile as a parameter
async function execute(apiToken, email, promptFile) {
    const accounts = await fetchAccounts(apiToken);

    console.info(`Configured Google Flow API accounts (${Object.keys(accounts).length}):`, Object.keys(accounts).join(', '));

    if (Object.keys(accounts).length <= 0) {
        console.error(`⛔ No configured Google Flow accounts found. Please refer to https://useapi.net/docs/start-here/setup-google-flow`);
        process.exit(1);
    }

    if (!accounts[email]) {
        console.error(`⛔ Account ${email} not found. Please refer to https://useapi.net/docs/start-here/setup-google-flow`);
        process.exit(1);
    }

    if (accounts[email].health && accounts[email].health !== 'OK') {
        console.error(`⛔ Account ${email} health is '${accounts[email].health}'. Please resolve and update the account, see https://useapi.net/docs/start-here/setup-google-flow`);
        process.exit(1);
    }

    const promptData = await fs.readFile(promptFile, 'utf8');
    const prompts = JSON.parse(promptData);
    console.log(`Total number of prompts to process`, prompts.length);

    let warnings = [];

    // Parameters accepted by this script for the omni-flash POST /videos endpoint.
    // See https://useapi.net/docs/api-google-flow-v1/post-google-flow-videos for the full parameter set.
    const supportedParams = [
        'prompt', 'aspectRatio', 'duration', 'resolution', 'count', 'seed',
        'startImage', 'endImage',
        'referenceImage_1', 'referenceImage_2', 'referenceImage_3', 'referenceImage_4',
        'referenceImage_5', 'referenceImage_6', 'referenceImage_7',
        'character_1', 'character_2', 'character_3', 'character_4',
        'character_5', 'character_6', 'character_7',
        'referenceVideo_1', 'startFrameIndex_1', 'endFrameIndex_1',
        'referenceAudio_1', 'referenceAudio_2', 'referenceAudio_3', 'referenceAudio_4', 'referenceAudio_5'
    ];

    const imageRefKeys = ['referenceImage_1', 'referenceImage_2', 'referenceImage_3', 'referenceImage_4', 'referenceImage_5', 'referenceImage_6', 'referenceImage_7'];
    const characterKeys = ['character_1', 'character_2', 'character_3', 'character_4', 'character_5', 'character_6', 'character_7'];
    const audioRefKeys = ['referenceAudio_1', 'referenceAudio_2', 'referenceAudio_3', 'referenceAudio_4', 'referenceAudio_5'];

    const invalidKeys = (prompt) => Object.keys(prompt).filter(key => !key.startsWith('__') && !supportedParams.includes(key))

    // Validate a local file path reference (skip already-uploaded mediaGenerationId strings).
    const looksLikePath = (ref) => ref && (ref.startsWith('./') || (ref.includes('.') && !ref.startsWith('user:')));

    for (let i = 1; i <= prompts.length; i++) {
        const prompt = prompts[i - 1];
        const { prompt: text, referenceVideo_1, duration, startImage, endImage } = prompt;

        const validateFile = async (file, allowedExts) => {
            if (looksLikePath(file)) {
                try {
                    await fs.access(file);
                } catch {
                    warnings.push(`⚠️  File '${file}' does not exist. Prompt ${i}`);
                }

                const ext = file.split('.').pop().toLowerCase();

                if (!allowedExts.includes(ext))
                    warnings.push(`⚠️  File ${file} extension ${ext} not supported. Prompt ${i}`);
            }
        };

        const notSupported = invalidKeys(prompt);
        if (notSupported.length)
            warnings.push(`⚠️  Following params not supported: ${notSupported.join(',')}. Prompt ${i}`);

        if (!text)
            warnings.push(`⚠️  prompt is required. Prompt ${i}`);

        // V2V edit (referenceVideo_1) does not accept duration — output matches the trim window.
        if (referenceVideo_1 && duration)
            warnings.push(`⚠️  duration is not accepted with referenceVideo_1 (V2V edit). Prompt ${i}`);

        if (endImage && !startImage)
            warnings.push(`⚠️  endImage requires startImage (end-frame-only is not supported). Prompt ${i}`);

        // Start/end frames (I2V, I2V-FL) are the only input in that mode — the API rejects anything alongside them.
        if (startImage || endImage) {
            if (referenceVideo_1)
                warnings.push(`⚠️  startImage/endImage cannot be combined with referenceVideo_1 (V2V edit). Prompt ${i}`);
            if ([...imageRefKeys, ...characterKeys].some(k => prompt[k]))
                warnings.push(`⚠️  startImage/endImage cannot be combined with referenceImage_* or character_*. Prompt ${i}`);
            if (audioRefKeys.some(k => prompt[k]))
                warnings.push(`⚠️  startImage/endImage cannot be combined with referenceAudio_*. Prompt ${i}`);
        }

        // A voice needs a referenceImage_* or character_* to attach to (or a V2V edit) — the API rejects referenceAudio_* on its own.
        // With start/end frames the check above already applies.
        if (!startImage && !endImage && !referenceVideo_1 && audioRefKeys.some(k => prompt[k]) && ![...imageRefKeys, ...characterKeys].some(k => prompt[k]))
            warnings.push(`⚠️  referenceAudio_* requires a referenceImage_* or character_* (or referenceVideo_1 for a V2V edit). Prompt ${i}`);

        await Promise.all([
            validateFile(referenceVideo_1, supportedVideoExtensions),
            ...['startImage', 'endImage', ...imageRefKeys].map(k => validateFile(prompt[k], supportedImageExtensions))
        ]);
    }

    if (warnings.length > 0) {
        warnings.forEach(warning => console.warn(warning));
        console.error(`⛔ Execution stopped due to warnings.`);
        process.exit(1);
    }

    for (let i = 0; i < prompts.length; i++) {
        const prompt = prompts[i];
        while (true) {
            const responseCode = await submitVideo(apiToken, email, prompt, i + 1);
            if (responseCode == 429 || responseCode == 503)
                await sleep(SLEEP_429);
            else
                if (responseCode == 402) {
                    process.exit(1);
                } else
                    break;
        }
    }
}

// Utility function to check if a file exists
async function fileExists(path) {
    try {
        await fs.access(path);
        return true;
    } catch {
        return false;
    }
}

// Function to prompt user input
async function promptUser(query) {
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
    });

    return new Promise((resolve) => rl.question(query, answer => {
        rl.close();
        resolve(answer);
    }));
}

function diffInMinutesAndSeconds(date1, date2) {
    const diffInSeconds = Math.floor((date2 - date1) / 1000);
    return `${Math.floor(diffInSeconds / 60)} minutes ${diffInSeconds % 60} seconds`;
};

main();

Examples

The videos below are real Gemini Omni Flash results made through this Google Flow API — model: "omni-flash", voices, references, characters, and V2V editing.

V2V edit — restyle an uploaded MP4 into a cartoon, same motion and framing

From Google Flow: Gemini Omni Flash — referenceVideo_1 editing.

Inline @-mentions — R2V noir video with @character_1, @referenceImage_1, @referenceImage_2 and a custom voice

From Google Flow: Inline @-mentions — inline @-mention markers grounding each reference.

Characters + V2V edit — composites two reusable characters into the noir video

From Google Flow: Inline @-mentions — referenceVideo_1 V2V edit with inline @character_*. See also Reusable Voices and Characters for the voice/character setup pipeline.

Frequently asked questions

  • What is Gemini Omni Flash? Through useapi.net you run Gemini Omni Flash, Google’s audio-native video model in Google Flow, by setting model: "omni-flash" on the Google Flow API. Unlike a silent video model, it makes the soundtrack, narration, dialog and ambience, as part of the same video, and it accepts image, video and audio references plus reusable characters.
  • How is Omni Flash different from Veo 3.1? Through useapi.net both run on the same Google Flow API, picked with the model field. Veo is the higher-fidelity plain text-to-video and image-to-video engine, while Omni Flash is audio-native and reference-rich, with video-to-video editing (referenceVideo_1), several voices per video, reusable characters at any length, and longer videos than Veo. See Reference modes for the exact limits.
  • Can it use my own voice, a reference video, or a reference audio? Through useapi.net, yes. Narrate with the built-in system voices or with custom voices from POST /voices, passed as referenceAudio_1..5, and upload an MP4 with POST /assets to edit it as referenceVideo_1 (V2V). See Reference modes and Voices & characters for the limits of each mode.
  • Is there an official Omni Flash API? Through useapi.net, Omni 1.1 Flash costs far less than on Google’s own API: an 8-second 720p video costs $0.10 on Google AI Ultra $199.99, against $0.81 on the official Gemini API, which has sold the model per second since June 30, 2026 (as of October 2026). It runs through a standard REST endpoint on your Google Flow website subscription.
  • Do I need to solve a captcha? Through useapi.net you never solve one yourself. Google Flow asks for a reCAPTCHA on every video and every new voice, and the useapi.net worker solves it automatically: your first Google Flow account comes with 300 free solves on our own AntiCaptcha, CapSolver and EzCaptcha keys, and after that you add your own provider keys with POST /accounts/captcha-providers. POST /characters needs no captcha.

Conclusion

Visit our Discord Server or Telegram Channel for any support questions and concerns.

Check our GitHub repo with code examples.

An AI assistant can also work with Google Flow for you: the hosted Google Flow MCP server lets Claude Code, Cursor and VS Code chat with Google Flow Agent, which generates images and videos on your account.