Cómo generar video con IA y audio nativo con Gemini Omni Flash mediante la Google Flow API

8 min de lectura • 15 de junio de 2026 (12 de noviembre de 2026)

useapi.net es una API de terceros. No está afiliada a Google ni cuenta con su respaldo.

Índice

  1. Introducción
  2. Modelo compatible
  3. Precios
  4. Genera un video con dos llamadas a la API
  5. Modos de referencia
  6. Voces y personajes
  7. Extiende un video con Omni 1.2
  8. Genera por lotes con un script
  9. Ejemplos
  10. Preguntas frecuentes
  11. Conclusión

Introducción

Gemini Omni Flash genera video con IA y audio nativo con un solo curl a través de la Google Flow API de useapi.net, con voces sincronizadas, personajes y ediciones de video a video, con tu suscripción al sitio web de Google Flow (Google AI: Omni 1.1 Flash en Plus, Pro o Ultra, Omni 1.2 Flash en todos los planes, y en cuentas gratuitas hasta 10 segundos). Un video de 10 segundos en 720p cuesta US$0,12 en Google AI Ultra US$199,99, frente a US$1,01 en la Gemini API oficial (a noviembre de 2026), sin proyecto de Google Cloud y sin clave de API. La misma suscripción también genera videos Omni en Google Vids, sin gastar créditos de Flow.

En la interfaz, Google Flow llama a este modelo Omni 1.1 Flash, y el valor en la API es model: "omni-flash". Omni 1.2 Flash, que Google lanzó el 5 de noviembre de 2026, es otro valor, model: "omni-flash-1.2": cualquier duración de 3 a 15 segundos, créditos por segundo, y es el modelo que extiende un video hasta 10 segundos cada vez. El código que envía omni-flash sigue recibiendo el 1.1. Para videos de 15 segundos, extensiones con voces y el precio por video en Google Flow y Google Vids, consulta Cómo usar la API de Gemini Omni 1.2 Flash.

Omni Flash es la misma Google Flow API que Veo, elegido con un solo campo (model: "omni-flash-1.2" o model: "omni-flash"), pero es otro tipo de modelo. Veo es un motor sólido de texto a video sencillo, y Omni Flash tiene audio nativo y acepta muchas referencias:

  • El sonido se genera junto con el video: la narración, el diálogo y el ambiente salen del mismo modelo, no se agregan después.
  • Voces: 30 voces predefinidas del sistema, más voces personalizadas que creas con tu propio diálogo y notas de interpretación, pasadas en referenceAudio_1..5.
  • Referencias de imagen y de video: hasta 10 imágenes de referencia en Omni 1.2 Flash (7 en Omni 1.1 Flash) para referencia a video (R2V), y edición de video a video (V2V) de un video subido o generado con referenceVideo_1.
  • Personajes reutilizables: junta imágenes de referencia y una voz en un personaje con nombre y úsalo en varios videos.
  • Videos más largos: Omni 1.1 Flash genera 4, 6, 8 o 10 segundos y Omni 1.2 Flash cualquier duración de 3 a 15 (Veo se detiene en 8), y Omni 1.2 extiende un video en pasos de hasta 10 segundos.

Esta guía muestra el flujo de dos llamadas con curl para copiar y pegar, los modos de referencia que hacen distinto a Omni Flash y, después, un script de Node.js listo para ejecutar que genera por lotes a partir de una lista de prompts. Para texto a video sencillo, consulta el tutorial de Veo 3.1 hermano de este.

Modelo compatible

Esta guía usa los modelos de video con audio nativo de Google, Omni 1.1 Flash (model: "omni-flash") y Omni 1.2 Flash (model: "omni-flash-1.2"). En Omni 1.1, el costo en créditos crece con la duración del video:

Duración Créditos por video
4s 7
6s 10
8s 12
10s 15
Edición V2V 20

Esos son los costos en el 720p predeterminado. Pasar resolution: "360p" cuesta más o menos la mitad (4 / 5 / 6 / 7 créditos según la duración, 10 por una edición V2V), y un video en 360p se puede llevar a 720p después con POST /videos/upscale.

Omni 1.2 Flash (model: "omni-flash-1.2") acepta cualquier número entero de segundos de 3 a 15 y cobra por segundo, igual en todos los planes:

Duración 720p 360p
3s 9 3
8s 24 8
10s 30 10
15s 60 20

Hasta 10 segundos son 3 créditos por segundo en 720p y 1 en 360p, y cada segundo por encima de 10 cuesta el doble. Una edición V2V cuesta lo mismo que un video con la duración editada, y el 1.2 edita hasta 15 segundos de un clip. Las cuentas de Google gratuitas pueden usar el 1.2 para videos de hasta 10 segundos.

Omni Flash acepta cinco modos en POST /videos: texto a video sencillo (T2V), referencia a video (R2V) con hasta 10 referenceImage_1..10 en Omni 1.2 Flash (7 en Omni 1.1 Flash), imagen a video a partir de un fotograma inicial con startImage (I2V), imagen a video entre un fotograma inicial y uno final con startImage + endImage (I2V-FL), y edición de video a video (V2V) de un MP4 subido con referenceVideo_1. Consulta Modos de referencia más abajo.

Precios

Omni 1.1 Flash requiere una suscripción de pago a Google AI (Plus, Pro o Ultra), Omni 1.2 Flash funciona en cuentas gratuitas hasta 10 segundos, y useapi.net no cobra nada por video. Cada video gasta créditos de Flow según la duración (consulta Modelo compatible). El precio por video en cada plan, frente a la Gemini API oficial, está en la tabla de precios de Google Flow.

Un plan de Google AI también incluye una cuota mensual en Google Vids, que genera videos de hasta 15 segundos con Gemini Omni 1.2 Flash (anuncio de Google) y no usa créditos de Flow. En Ultra US$199,99 (que Google llama Ultra 20x) son 10.000 segundos de video al mes, unos 1.000 clips de 10 segundos en 1080p más, además de los ~1.666 clips de diez segundos de Omni 1.1 Flash (o ~833 clips de Omni 1.2 Flash en 720p) que compran los 25.000 créditos de Flow. Esos segundos se comparten entre todos los miembros de un plan familiar de Google. Los demás planes reciben menos (consulta la página Gemini en Google Vids de Google), y las cuentas gratuitas que probamos no tenían nada. La Google Vids API usa esa cuota sin captcha. Conecta la cuenta con Setup Google Vids.

Genera un video con dos llamadas a la API

Necesitas un token de la API de useapi.net y una cuenta de Google Flow conectada, con una suscripción de pago a Google AI (Plus, Pro o Ultra) para el ejemplo con Omni 1.1 Flash de abajo (Omni 1.2 Flash también funciona en una cuenta gratuita, hasta 10 segundos). Los jobs de video son asíncronos: pasa async: true y la llamada de creación devuelve un jobid al instante, después consultas hasta que el video esté listo.

  1. Envía el job: POST https://api.useapi.net/v1/google-flow/videos con 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
  }'

Como Omni Flash tiene audio nativo, genera la banda sonora (la narración del farero y las olas que rompen) directamente del prompt, sin ningún parámetro de audio. En modo asíncrono, la respuesta vuelve al instante con 201 Created, un jobid en minúsculas y 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. Consulta el resultado: 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"

El status pasa por created → started → completed. Cuando llega a completed, el MP4 terminado está en response.media[].videoUrl (un solo prompt con count > 1 devuelve una entrada por 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 suele terminar en 60 a 180 segundos. El videoUrl firmado es válido durante unas 6 horas, así que descárgalo pronto. ¿Prefieres no consultar? Pasa un replyUrl en el cuerpo de la creación para recibir un webhook cuando el job termine.

Modos de referencia

La gran diferencia de Omni Flash es cuánto puedes pasarle como referencia. Todas las referencias vienen del endpoint de subida POST /assets/email (o, en el caso de los personajes, de POST /characters). La respuesta de la subida trae el id de referencia anidado en mediaGenerationId.mediaGenerationId, y es esa cadena anidada la que pasas de vuelta. Los modos que acepta omni-flash (de la documentación de POST /videos):

Modo Parámetro(s) Límite en Omni Flash
Referencia a video (R2V) referenceImage_1..10 hasta 10 imágenes en omni-flash-1.2, 7 en omni-flash
Imagen a video (I2V) startImage un fotograma inicial
Imagen a video, inicial + final (I2V-FL) startImage + endImage solo los dos fotogramas, sin otras referencias
Edición de video a video (V2V) referenceVideo_1 (+ startFrameIndex_1 / endFrameIndex_1 opcionales) un MP4 subido
Personajes character_1..10 (de POST /characters) 10 en total con referenceImage_* en omni-flash-1.2, 7 en omni-flash
Narración con voz referenceAudio_1..5 hasta 5 (R2V) / hasta 3 (edición V2V)

Referencia a video (R2V): sube una o más imágenes y pásalas como referenceImage_1, referenceImage_2, … (los espacios _4.._7 son solo de Omni Flash). Las imágenes de referencia no se pueden combinar con 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"
  }'

Edición de video a video (V2V): sube un MP4 con Content-Type: video/mp4 a POST /assets y pasa el id devuelto como referenceVideo_1 para editar ese video. Esto es solo de Omni Flash y pone la solicitud en modo V2V. duration no se acepta (la duración de salida sigue la ventana de recorte de la entrada, como máximo 10 s en omni-flash y 15 s en omni-flash-1.2). Si quieres, recorta el origen en una línea de tiempo virtual de 24 fps con startFrameIndex_1 (0–239) y endFrameIndex_1 (1–240, donde 240 = 10 s), o hasta 359 y 360 (15 s) en omni-flash-1.2:

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

También puedes poner marcadores @ en línea dentro del prompt que apuntan a los espacios que pasas, por ejemplo @referenceImage_1, @character_1, @referenceAudio_1. Los marcadores son opcionales y cada uno necesita un parámetro correspondiente en el cuerpo. (@referenceVideo_* no funciona en línea: pásalo como campo del cuerpo.) Consulta Inline @-mention markers.

Voces y personajes

Dos llamadas cortas de configuración alimentan las funciones de audio e identidad de Omni Flash.

Voces personalizadas: POST /voices guarda una voz basada en una de las 30 voces predefinidas del sistema, con tu propio dialog y voicePerformance. El campo voice de la respuesta es un id de referencia que después pasas como referenceAudio_1..5 en 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:...", ... }

Cada espacio referenceAudio_* acepta el nombre de una voz del sistema (sin distinguir mayúsculas, una de las 30 predefinidas, como Aoede, Kore, Puck, Zephyr) o el id de una voz de usuario de la llamada anterior. Crear una voz requiere reCAPTCHA (se resuelve automáticamente, consulta las preguntas frecuentes).

Personajes reutilizables: POST /characters junta 1 o 2 imágenes de referencia y una voz opcional en un personaje con nombre. El campo character de la respuesta es lo que pasas como 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:...", ... }

En Omni Flash, los personajes funcionan en todas las duraciones y comparten el límite total de 10 imágenes de referencia con referenceImage_* (referenceImages.length + Σ(character.imgs) ≤ 10, Omni 1.1: 7). POST /characters no requiere captcha. Los personajes y las voces también funcionan en Veo, pero con límites más estrictos (3 imágenes, 1 voz, solo 8 s): consulta What can use a character?.

Extiende un video con Omni 1.2

POST /videos/extend agrega de 3 a 10 segundos cada vez con Omni 1.2 Flash a cualquier video de menos de 30 segundos, con hasta 3 voces, imágenes de referencia o personajes, y devuelve un solo clip continuo (solo en planes de pago). El recorrido de Cómo usar la API de Gemini Omni 1.2 Flash extiende un video de 10 segundos dos veces hasta 30 segundos, con dos voces: es el ejemplo de abajo.

Genera por lotes con un script

Encontrar la toma correcta lleva muchos intentos, y hacerlo todo a mano cansa. El script de Node.js de abajo lee una lista de prompts de prompts.json, sube las imágenes de referencia, los fotogramas inicial y final o los videos de origen, pasa tal cual los ids character_* y la resolution, envía cada job en modo asíncrono con model: "omni-flash" y después consulta y descarga cada MP4 terminado. Así pones un lote en cola y vuelves más tarde a elegir los mejores. Se detiene con un aviso antes de enviar una combinación que la API rechazaría, como un fotograma final sin fotograma inicial.

Necesitas Node.js v21 o posterior. Pon prompts.json y omni-flash.mjs en la misma carpeta y ejecuta node ./omni-flash.mjs API_TOKEN EMAIL, donde API_TOKEN es tu token de la API de useapi.net y EMAIL es el correo de tu cuenta de Google Flow conectada. El script encuentra la cuenta por el correo automáticamente y revisa su campo health antes de enviar.

¿Prefieres clonarlo y ejecutarlo en tu equipo? Los archivos completos omni-flash.mjs y prompts.json están en GitHub, en useapi/google-flow-api.

¿Trabajas en Claude Code o Codex? La agent skill omni-flash-video deja que el agente haga todo esto por ti: describe el video, señala tus imágenes, y el agente las sube, escribe la solicitud, espera y guarda el .mp4. Instálala con npx skills add useapi/google-flow-api --skill omni-flash-video.

Abrir 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"
    }
]
Abrir el script 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();

Ejemplos

Los videos de abajo son resultados reales de Gemini Omni Flash hechos con esta Google Flow API: model: "omni-flash-1.2" y model: "omni-flash", extensión, voces, referencias, personajes y edición V2V.

Extensión en Omni 1.2: un video de 10 segundos extendido dos veces hasta 30 segundos, con dos voces

De Google Flow: Omni 1.2 Flash, 15-Second Videos, and Extend with Voices (en inglés): omni-flash-1.2 en los primeros 10 segundos, después dos llamadas a POST /videos/extend con referenceAudio_1.

Edición V2V: convierte un MP4 subido en caricatura, con el mismo movimiento y encuadre

De Google Flow: Gemini Omni Flash (en inglés): edición con referenceVideo_1.

Menciones @ en línea: video noir R2V con @character_1, @referenceImage_1, @referenceImage_2 y una voz personalizada

De Google Flow: Inline @-mentions (en inglés): marcadores de mención @ en línea que anclan cada referencia.

Personajes + edición V2V: integra dos personajes reutilizables en el video noir

De Google Flow: Inline @-mentions (en inglés): edición V2V con referenceVideo_1 y @character_* en línea. Consulta también Reusable Voices and Characters (en inglés) para el flujo de creación de voces y personajes.

Preguntas frecuentes

  • ¿Qué es Gemini Omni Flash? Con useapi.net ejecutas Gemini Omni Flash, el modelo de video con audio nativo de Google en Google Flow, definiendo model: "omni-flash-1.2" (Omni 1.2 Flash) o model: "omni-flash" (Omni 1.1 Flash) en la Google Flow API. A diferencia de un modelo de video mudo, genera la banda sonora, la narración, el diálogo y el ambiente como parte del mismo video, y acepta referencias de imagen, video y audio, además de personajes reutilizables.
  • ¿Qué diferencia hay entre Omni Flash y Veo 3.1? Con useapi.net los dos se ejecutan en la misma Google Flow API y se eligen con el campo model. Veo es el motor de texto a video e imagen a video sencillo con más fidelidad, y Omni Flash tiene audio nativo y acepta muchas referencias, con edición de video a video (referenceVideo_1), varias voces por video, personajes reutilizables en cualquier duración y videos más largos que los de Veo. Consulta los límites exactos en Modos de referencia.
  • ¿Puedo usar mi propia voz, un video de referencia o un audio de referencia? Con useapi.net, sí. Narra con las voces listas del sistema o con voces personalizadas de POST /voices, pasadas como referenceAudio_1..5, y sube un MP4 con POST /assets para editarlo como referenceVideo_1 (V2V). Consulta los límites de cada modo en Modos de referencia y Voces y personajes.
  • ¿Existe una API oficial de Omni Flash? Con useapi.net, Omni 1.1 Flash cuesta mucho menos que en la API del propio Google: un video de 8 segundos en 720p cuesta US$0,10 en Google AI Ultra US$199,99, frente a US$0,81 en la Gemini API oficial, que vende el modelo por segundo desde el 30 de junio de 2026 (a noviembre de 2026). Se ejecuta mediante un endpoint REST estándar con tu suscripción al sitio web de Google Flow.
  • ¿Omni Flash hace videos de más de 10 segundos? Con useapi.net, sí: Omni 1.2 Flash (model: "omni-flash-1.2") genera hasta 15 segundos en una llamada, y POST /videos/extend agrega hasta 10 segundos cada vez con voces, a US$0,19 por un video de 8 segundos en 720p en Google AI Ultra US$199,99, frente a US$0,56 en la API del propio Google (a noviembre de 2026). Todos los límites de duración y los precios en Google Flow y Google Vids están en Cómo usar la API de Gemini Omni 1.2 Flash.
  • ¿Tengo que resolver captchas? Con useapi.net nunca resuelves un captcha. Google Flow pide un reCAPTCHA en cada video y en cada voz nueva, y el worker de useapi.net lo resuelve automáticamente: tu primera cuenta de Google Flow incluye 300 resoluciones gratis con nuestras propias claves de AntiCaptcha, CapSolver y EzCaptcha, y después agregas las claves de tu propio proveedor con POST /accounts/captcha-providers. POST /characters no necesita captcha.

Conclusión

Visita nuestro servidor de Discord o el canal de Telegram para cualquier pregunta de soporte.

Consulta nuestro repositorio de GitHub con ejemplos de código.

Un asistente de IA también puede trabajar con Google Flow por ti: el servidor MCP de Google Flow alojado permite que Claude Code, Cursor y VS Code hablen con el Google Flow Agent, que genera imágenes y videos en tu cuenta.

Empieza a usar la API

Una sola suscripción de US$15 al mes cubre todas las API de useapi.net: un token para todos los servicios. Conectas hasta 3 cuentas propias por servicio, cada una con la suscripción de su sitio web, y useapi.net se encarga de la API REST, el balanceo de carga y el polling. ¿Necesitas más cuentas? Cada suscripción adicional cuesta US$10 al mes y suma 3 cuentas más por servicio.

Suscríbete — US$15/mes Pagar con cripto

¿No tienes una tarjeta de crédito internacional? También puedes suscribirte con criptomonedas (los pagos con cripto no se reembolsan). Los precios y los planes por volumen están en la página de suscripción. Si pagas con Stripe, recibes el reembolso completo dentro de los 14 días posteriores a la primera compra, si tienes menos de 50 generaciones exitosas. ¿Ya tienes una suscripción? Cambia la cantidad en el portal de clientes de Stripe en lugar de volver a comprar.