如何通过 Google Flow API 用 Gemini Omni Flash 生成带原生音频的 AI 视频(中文教程)

8 分钟阅读 • 2026 年 6 月 15 日(2026 年 11 月 12 日更新)

useapi.net 是第三方 API,与 Google 无关联,也未获得 Google 的认可。

目录

  1. 简介
  2. 支持的模型
  3. 价格
  4. 两次 API 调用生成一条视频
  5. 参考模式
  6. 音色与角色
  7. 用 Omni 1.2 延长视频
  8. 用脚本批量生成
  9. 示例
  10. 常见问题
  11. 结语

简介

通过 useapi.net 的 Google Flow API,一条 curl 就能用 Gemini Omni Flash 生成带原生音频的 AI 视频,支持同步音色、角色和视频到视频编辑,运行在你的 Google Flow 网站订阅上(Google AI:Omni 1.1 Flash 需要 Plus、Pro 或 Ultra,Omni 1.2 Flash 所有套餐都能用,免费账号最长 10 秒)。一条 10 秒 720p 视频在 Google AI Ultra(199.99 美元)上约 0.12 美元,官方 Gemini API 为 1.01 美元(截至 2026 年 11 月),而且不需要 Google Cloud 项目,也不需要 API Key。同一个订阅还能在 Google Vids 里生成 Omni 视频,不消耗 Flow 积分。

这个模型在 Google Flow 界面里叫 Omni 1.1 Flash,API 取值是 model: "omni-flash"。Google 于 2026 年 11 月 5 日发布的 Omni 1.2 Flash 是另一个取值 model: "omni-flash-1.2":时长可在 3 到 15 秒之间任选,按秒计算积分,也是用来延长视频的模型,每次最多延长 10 秒。传 omni-flash 的代码仍然调用 1.1。15 秒视频、带音色的延长以及在 Google Flow 和 Google Vids 中的单条价格,见 Gemini Omni 1.2 Flash API 中文教程。

Omni Flash 和 Veo 用的是同一个 Google Flow API,只靠一个字段区分:model: "omni-flash-1.2" 或 model: "omni-flash"。但它是另一类模型。Veo 擅长纯文本生成视频,而 Omni Flash 原生生成音频,并且能接收大量参考素材:

  • 声音和视频一起生成。旁白、对白和环境音都出自同一个模型,不是事后拼接上去的。
  • 音色。30 个内置系统音色,外加用你自己的对白和表演说明创建的自定义音色,通过 referenceAudio_1..5 传入。
  • 图片和视频参考。Omni 1.2 Flash 最多 10 张参考图(Omni 1.1 Flash 为 7 张),用于参考图生视频(R2V),还可以通过 referenceVideo_1 对上传或生成的视频做视频到视频(V2V)编辑。
  • 可复用角色。把参考图和一个音色打包成一个有名字的角色,在多条视频里重复使用。
  • 更长的视频。Omni 1.1 Flash 可生成 4、6、8 或 10 秒,Omni 1.2 Flash 可生成 3 到 15 秒之间的任意时长(Veo 最长 8),Omni 1.2 还能以每次最多 10 秒的步长延长视频。

本文先用可直接复制的 curl 演示两次调用的流程,再介绍 Omni Flash 独有的参考模式,最后给出一个能从提示词列表批量生成视频的 Node.js 脚本。纯文本生成视频请看同系列的 Google Flow API 中文教程。

支持的模型

本文使用 Google 的两个原生音频视频模型:Omni 1.1 Flash(model: "omni-flash")和 Omni 1.2 Flash(model: "omni-flash-1.2")。Omni 1.1 的积分消耗随视频时长变化:

时长 每条视频积分
4 秒 7
6 秒 10
8 秒 12
10 秒 15
V2V 编辑 20

以上是默认 720p 的消耗。传 resolution: "360p" 大约减半,按时长依次为 4 / 5 / 6 / 7 个积分,V2V 编辑为 10 个。360p 视频之后可以用 POST /videos/upscale 提升到 720p。

Omni 1.2 Flash(model: "omni-flash-1.2")接受 3 到 15 之间的任意整数秒,按秒计价,所有套餐相同:

时长 720p 360p
3 秒 9 3
8 秒 24 8
10 秒 30 10
15 秒 60 20

10 秒以内,720p 每秒 3 个积分,360p 每秒 1 个,超过 10 秒的部分每秒加倍。V2V 编辑的价格与同等时长的视频相同,1.2 最多可编辑片段中的 15 秒。免费 Google 账号可以用 1.2 生成最长 10 秒的视频。

Omni Flash 在 POST /videos 上支持五种模式:纯文本生成视频(T2V),参考图生视频(R2V,Omni 1.2 Flash 最多 10 张 referenceImage_1..10,Omni 1.1 Flash 最多 7 张),通过 startImage 从首帧生成视频(I2V),通过 startImage + endImage 在首尾帧之间生成视频(I2V-FL),以及通过 referenceVideo_1 编辑上传的 MP4(V2V)。详见下文的参考模式。

价格

Omni 1.1 Flash 需要付费 Google AI 订阅(Plus、Pro 或 Ultra),Omni 1.2 Flash 免费账号也能用(最长 10 秒),useapi.net 不按视频收费。每条视频按时长消耗 Flow 积分(见支持的模型)。各套餐上每条视频的价格以及与官方 Gemini API 的对比,见 Google Flow 价格表。

Google AI 套餐每月还附带一份 Google Vids 额度,可用 Gemini Omni 1.2 Flash 生成最长 15 秒的视频(Google 公告),不消耗 Flow 积分。在 Ultra(199.99 美元,Google 称为 Ultra 20x)上,每月有 10,000 秒视频额度,约等于再多 1,000 条 1080p 10 秒视频,而 Flow 的 25,000 积分本身可生成约 1,666 条 10 秒 Omni 1.1 Flash 视频(或约 833 条 720p Omni 1.2 Flash 视频)。这些秒数由 Google 家庭方案的所有成员共享。其他套餐的额度更少(见 Google 的 Gemini in Google Vids 页面),我们测试的免费账号则没有额度。Google Vids API 使用这份额度,不需要验证码。用 Setup Google Vids 绑定账号。

两次 API 调用生成一条视频

你需要一个 useapi.net API Token,以及一个已绑定的 Google Flow 账号。下面的 Omni 1.1 Flash 示例要求该账号有付费 Google AI 订阅(Plus、Pro 或 Ultra),Omni 1.2 Flash 用免费账号也能运行(最长 10 秒)。视频任务是异步的:传 async: true,创建请求会立即返回一个 jobid,然后轮询直到视频生成完毕。

  1. 提交任务:POST https://api.useapi.net/v1/google-flow/videos,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 原生生成音频,所以守塔人的旁白和海浪拍岸声都直接来自提示词,不需要任何音频参数。异步模式下请求会立即返回 201 Created,带一个小写的 jobid 和 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. 轮询结果: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 依次经过 created → started → completed。到 completed 时,成品 MP4 在 response.media[].videoUrl 里(单个提示词传 count > 1 时,每条视频各一项):

{
  "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 通常在 60–180 秒内完成。带签名的 videoUrl 约 6 小时内有效,请尽快下载。不想轮询?在创建请求里传 replyUrl,任务完成时会回调你的 webhook。

参考模式

Omni Flash 的独特之处在于它能接收大量参考素材。所有参考都来自 POST /assets/email 上传接口(角色则来自 POST /characters)。上传响应把参考 id 嵌套在 mediaGenerationId.mediaGenerationId 里,要回传的就是这个嵌套的字符串。omni-flash 支持的模式(来自 POST /videos 文档):

模式 参数 Omni Flash 限制
参考图生视频(R2V) referenceImage_1..10 omni-flash-1.2 最多 10 张参考图,omni-flash 最多 7 张
图生视频(I2V) startImage 一张首帧
首尾帧图生视频(I2V-FL) startImage + endImage 只能用这两帧,不能加其他参考
视频到视频编辑(V2V) referenceVideo_1(可选 startFrameIndex_1 / endFrameIndex_1) 一个上传的 MP4
角色 character_1..10(来自 POST /characters) 与 referenceImage_* 合计:omni-flash-1.2 最多 10 个,omni-flash 最多 7 个
音色旁白 referenceAudio_1..5 最多 5 个(R2V)/ 最多 3 个(V2V 编辑)

参考图生视频(R2V):上传一张或多张图片,然后作为 referenceImage_1、referenceImage_2 … 传入(_4.._7 槽位仅 Omni Flash 可用)。参考图不能与 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"
  }'

视频到视频编辑(V2V):以 Content-Type: video/mp4 把 MP4 上传到 POST /assets,再把返回的 id 作为 referenceVideo_1 传入,即可编辑这段视频。此模式仅限 Omni Flash,会把请求切换到 V2V 模式。不接受 duration(输出时长等于输入的裁剪窗口,omni-flash 最长 10 秒,omni-flash-1.2 最长 15 秒)。可以在 24 fps 的虚拟时间轴上用 startFrameIndex_1(0–239)和 endFrameIndex_1(1–240,240 = 10 秒)裁剪源视频,omni-flash-1.2 上最大可到 359 和 360(15 秒):

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
  }'

你还可以在提示词里放入 @ 行内标记,它们会对应到你提供的槽位,例如 @referenceImage_1、@character_1、@referenceAudio_1。标记是可选的,每个标记都必须有对应的请求体参数。(@referenceVideo_* 不支持行内写法,请作为请求体字段传入。)见 Inline @-mention markers。

音色与角色

Omni Flash 的音频和人物一致性功能靠两个简短的准备调用实现。

自定义音色:POST /voices 在 30 个系统音色之一的基础上,用你自己的 dialog 和 voicePerformance 保存一个音色。响应中的 voice 字段是一个参考 id,之后在 POST /videos 上作为 referenceAudio_1..5 传入:

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:...", ... }

每个 referenceAudio_* 槽位既可以填系统音色名(不区分大小写,30 个预设之一,如 Aoede、Kore、Puck、Zephyr),也可以填上面调用返回的用户音色 id。创建音色需要 reCAPTCHA(自动求解,见常见问题)。

可复用角色:POST /characters 把 1–2 张参考图和一个可选音色打包成一个有名字的角色。响应中的 character 字段就是作为 character_1..10 传入的值:

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:...", ... }

在 Omni Flash 上,角色适用于所有时长,并与 referenceImage_* 共用 10 张参考图的总额度(referenceImages.length + Σ(character.imgs) ≤ 10, Omni 1.1: 7)。POST /characters 不需要验证码。Veo 也能使用角色和音色,但额度更紧(3 张图、1 个音色、仅 8 秒),见 What can use a character?。

用 Omni 1.2 延长视频

POST /videos/extend 用 Omni 1.2 Flash 给任意短于 30 秒的视频每次续写 3 到 10 秒,可带最多 3 个音色、参考图或角色,返回一段连续的完整片段(仅限付费套餐)。Gemini Omni 1.2 Flash API 中文教程里的完整流程把一段 10 秒视频用两种音色延长两次到 30 秒,成片见下方示例。

用脚本批量生成

找到满意的一条往往要试很多次,手动一条条跑很繁琐。下面的 Node.js 脚本从 prompts.json 读取提示词列表,上传参考图、首尾帧或源视频,把 character_* id 和 resolution 原样传过去,以异步模式和 model: "omni-flash" 提交每个任务,然后轮询并下载每一个完成的 MP4。你可以一次排好一批,回头再挑最好的。如果某个组合会被 API 拒绝,比如只有尾帧没有首帧,脚本会在提交前给出警告并停止。

需要 Node.js v21 或更高版本。把 prompts.json 和 omni-flash.mjs 放在同一个文件夹,运行 node ./omni-flash.mjs API_TOKEN EMAIL,其中 API_TOKEN 是你的 useapi.net API Token,EMAIL 是已绑定的 Google Flow 账号邮箱。脚本会按邮箱自动查找账号,并在提交前检查它的 health 字段。

想克隆到本地运行?完整的 omni-flash.mjs 和 prompts.json 在 GitHub 的 useapi/google-flow-api 仓库里。

在用 Claude Code 或 Codex?omni-flash-video agent skill 能让智能体替你完成这一切:描述你想要的视频,告诉它图片在哪里,它会上传图片、编写请求、等待生成并保存 .mp4。安装命令:npx skills add useapi/google-flow-api --skill omni-flash-video。

展开 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, 10 on omni-flash-1.2."
    },
    {
        "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"
    }
]
展开 omni-flash.mjs 脚本
/*

Script version 1.3, November 11, 2026

Script to batch-generate audio-native videos with Gemini Omni Flash using the Google Flow API by useapi.net
Uses the POST /videos endpoint in async mode (model: omni-flash by default, or omni-flash-1.2 per prompt) 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".
- November 11, 2026: referenceImage_8..10 and character_8..10: omni-flash-1.2 takes up to 10 references (omni-flash 7).

*/

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';
// Reference slots: up to 10 on omni-flash-1.2, 7 on omni-flash, 3 on Veo (referenceImage_* and character_* together).
const imageRefKeys = Array.from({ length: 10 }, (_, i) => `referenceImage_${i + 1}`);
const characterKeys = Array.from({ length: 10 }, (_, i) => `character_${i + 1}`);
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 (or the prompt's own model) in async mode.
// startImage = start frame (I2V), endImage = end frame (I2V-FL, requires startImage) — the frames are the only input;
// referenceImage_1..10 = R2V image refs; character_1..10 = POST /characters ids (10 on omni-flash-1.2, 7 on omni-flash);
// referenceVideo_1 = V2V edit (no duration); referenceAudio_1..5 = system voice name or POST /voices user-voice id;
// resolution = 720p (default) or 360p; model = 'omni-flash' (Omni 1.1, the default) or 'omni-flash-1.2' (3–15 s, per-second credits).
async function submitVideo(apiToken, email, prompt, index) {
    const {
        prompt: text, model = MODEL, aspectRatio, duration, resolution, count,
        startImage, endImage,
        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 refImages = {};
    for (const key of imageRefKeys)
        refImages[key] = await resolveRef(prompt[key]);
    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],
        ...imageRefKeys.map(key => [key, prompt[key], refImages[key]]),
        ['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,
        email,
        prompt: text,
        aspectRatio,
        duration: isV2V ? undefined : duration,
        resolution,
        count,
        startImage: startImageId,
        endImage: endImageId,
        ...refImages,
        ...Object.fromEntries(characterKeys.map(key => [key, prompt[key]])),
        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.3');

    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', 'model', 'aspectRatio', 'duration', 'resolution', 'count',
        'startImage', 'endImage',
        ...imageRefKeys, ...characterKeys,
        'referenceVideo_1', 'startFrameIndex_1', 'endFrameIndex_1',
        'referenceAudio_1', 'referenceAudio_2', 'referenceAudio_3', 'referenceAudio_4', 'referenceAudio_5'
    ];

    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();

示例

下面的视频都是通过这个 Google Flow API 真实生成的 Gemini Omni Flash 结果,涵盖 model: "omni-flash-1.2" 和 model: "omni-flash"、延长、音色、参考、角色和 V2V 编辑。

Omni 1.2 延长:10 秒视频延长两次到 30 秒,两种音色

来自 Google Flow: Omni 1.2 Flash, 15-Second Videos, and Extend with Voices(英文):前 10 秒用 omni-flash-1.2 生成,然后用 referenceAudio_1 调用两次 POST /videos/extend。

V2V 编辑:把上传的 MP4 改成卡通风格,动作和构图不变

来自 Google Flow: Gemini Omni Flash(英文):用 referenceVideo_1 编辑。

行内 @ 标记:带 @character_1、@referenceImage_1、@referenceImage_2 和自定义音色的 R2V 黑色电影风格视频

来自 Google Flow: Inline @-mentions(英文):用行内 @ 标记逐一指定每个参考。

角色 + V2V 编辑:把两个可复用角色合成进黑色电影风格视频

来自 Google Flow: Inline @-mentions(英文):referenceVideo_1 V2V 编辑,配合行内 @character_*。音色和角色的准备流程另见 Reusable Voices and Characters(英文)。

常见问题

  • Gemini Omni Flash 是什么? 通过 useapi.net,在 Google Flow API 上设置 model: "omni-flash-1.2"(Omni 1.2 Flash)或 model: "omni-flash"(Omni 1.1 Flash),就能调用 Gemini Omni Flash,即 Google Flow 里 Google 的原生音频视频模型。与无声视频模型不同,它在同一条视频里同时生成配乐、旁白、对白和环境音,并支持图片、视频和音频参考以及可复用角色。
  • Omni Flash 和 Veo 3.1 有什么区别? 通过 useapi.net,两者都运行在同一个 Google Flow API 上,用 model 字段选择。Veo 是画质更高的纯文本生成视频和图生视频模型,而 Omni Flash 原生生成音频、支持丰富的参考素材,可以做视频到视频编辑(referenceVideo_1),一条视频可用多个音色,任意时长都能用可复用角色,视频也比 Veo 更长。具体限制见参考模式。
  • 能用我自己的音色、参考视频或参考音频吗? 通过 useapi.net 可以。用内置系统音色或 POST /voices 创建的自定义音色配旁白,作为 referenceAudio_1..5 传入,也可以用 POST /assets 上传 MP4,作为 referenceVideo_1 编辑(V2V)。各模式的限制见参考模式和音色与角色。
  • Omni Flash 有官方 API 吗? 通过 useapi.net 调用 Omni 1.1 Flash 远比 Google 自己的 API 便宜:一条 8 秒 720p 视频在 Google AI Ultra(199.99 美元)上约 0.10 美元,而官方 Gemini API 为 0.81 美元,官方自 2026 年 6 月 30 日起按秒出售该模型(截至 2026 年 11 月)。它通过标准 REST 接口运行在你的 Google Flow 网站订阅上。
  • Omni Flash 能生成超过 10 秒的视频吗? 通过 useapi.net 可以:Omni 1.2 Flash(model: "omni-flash-1.2")一次调用最长 15 秒,POST /videos/extend 每次还能带音色续写最多 10 秒。一条 8 秒 720p 视频在 Google AI Ultra(199.99 美元)上约 0.19 美元,而 Google 自己的 API 为 0.56 美元(截至 2026 年 11 月)。Google Flow 和 Google Vids 中的所有时长限制和价格见 Gemini Omni 1.2 Flash API 中文教程。
  • 需要自己解验证码吗? 通过 useapi.net 你永远不用自己解。Google Flow 每生成一条视频、每创建一个新音色都要求 reCAPTCHA,useapi.net 的 worker 会自动求解:你的第一个 Google Flow 账号赠送 300 次免费求解,使用我们自己的 AntiCaptcha、CapSolver 和 EzCaptcha 密钥,之后用 POST /accounts/captcha-providers 添加你自己的打码服务密钥。POST /characters 不需要验证码。

结语

有问题欢迎加入我们的 Discord 服务器 或 Telegram 频道。

示例代码见 GitHub 仓库。

AI 助手也可以替你操作 Google Flow:托管的 Google Flow MCP 服务器让 Claude Code、Cursor 和 VS Code 与 Google Flow Agent 对话,由它在你的账号上生成图片和视频。

开始使用

每月 15 美元的订阅即可使用 useapi.net 上的所有 API:一个 Token,所有服务。每个服务最多绑定 3 个你自己的账号,useapi.net 负责 REST 接口、负载均衡和轮询。需要更多账号?每增加一个订阅每月 10 美元,每个服务再多 3 个账号。

订阅 — 每月 15 美元 用加密货币支付

没有国际信用卡?可以用加密货币订阅(加密货币付款不退款)。价格与批量方案见订阅页面。用 Stripe 付款,首次购买 14 天内、且成功生成少于 50 次可全额退款。已经订阅?请在 Stripe 客户门户 修改数量,无需重新购买。