Veo 3.1 API の使い方:Google Flow API と curl で動画を生成する

6分で読めます • 2026年6月15日(2026年11月6日更新)

useapi.net はサードパーティの API です。Google とは提携しておらず、Google の承認も受けていません。

目次

  1. はじめに
  2. 対応モデル
  3. 料金
  4. 2回の API 呼び出しで動画を生成する
  5. 画像から動画、参照画像から動画
    1. 開始・終了フレーム(I2V)
    2. 参照画像(R2V)
  6. スクリプトで一括生成する
  7. 例
  8. よくある質問
  9. おわりに

はじめに

Veo 3.1 は、useapi.net の Google Flow API を通して、curl 1回で動きます。使うのは、お使いの Google Flow の Web サイトのプラン(Google AI Pro または Ultra)です。8秒の Veo 3.1 Fast の動画は Google AI Ultra $199.99 で $0.08 で、公式の Veo API では $0.80 です(2026年11月時点)。

Google は2026年10月22日に Gemini API の Veo 3.1 Preview モデルを終了し、開発者を Cloud の Agent Platform に移しました。Agent Platform は、同じ1秒あたりの料金を Google Cloud プロジェクトに請求します。このサードパーティの API なら、Cloud プロジェクトも Google の API キーも企業向けの承認も不要です。以下では、モデル、2回の呼び出しで終わる流れ、画像の入力、そのまま動く一括生成スクリプトを紹介します。

対応モデル

モデルはリクエストごとに model フィールドで選びます(デフォルトは veo-3.1-fast)。Veo のクレジット数は Google の公式の Flow クレジット表によるもので、Omni のクレジット数は実際の API で測定した値です。

モデル ID 種類・速度 長さ 動画1本のクレジット
veo-3.1-lite 最も安い Veo 4秒、6秒、8秒 10(Ultra 以外)・5(Ultra)
veo-3.1-lite-low-priority Lite、低優先度 4秒、6秒、8秒 0、Ultra $199.99 のプランのオーナーのみ
veo-3.1-fast (デフォルト) 高速な Veo 4秒、6秒、8秒 20(Ultra 以外)・10(Ultra)
veo-3.1-quality 最高品質の Veo 8秒のみ 100
omni-flash-1.2 Omni 1.2 Flash、音声ネイティブ 3〜15秒 10秒までは1秒3、それ以降は1秒6(720p)
omni-flash Omni 1.1 Flash、音声ネイティブ 4秒、6秒、8秒、10秒 7 / 10 / 12 / 15(長さ別、720p)

Veo の4秒と6秒の動画には Ultra が必要です(概要を参照)。Google Flow では、別のエンドポイント POST /images で Nano Banana のモデルを使った静止画も作れます。

料金

動画は1本ごとに Google AI のプランの Flow クレジットを使い、useapi.net が動画ごとに料金を取ることはありません。各プランでの動画1本の料金と公式 API との比較は Google Flow の料金表に、Veo の詳しい料金は Veo 3.1 API の料金比較(英語)にあります。

2回の API 呼び出しで動画を生成する

useapi.net の API トークンと、接続済みの Google Flow アカウントが必要です。動画のジョブは非同期です。async: true を渡すと作成の呼び出しはすぐに jobid を返すので、動画ができるまでポーリングします。

  1. ジョブを送信する — POST https://api.useapi.net/v1/google-flow/videos:
curl -X POST "https://api.useapi.net/v1/google-flow/videos" \
  -H "Authorization: Bearer $USEAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "A serene mountain landscape at sunset, camera slowly panning right",
    "model": "veo-3.1-fast",
    "aspectRatio": "landscape",
    "duration": 8,
    "async": true
  }'

非同期モードでは、すぐに 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 serene mountain landscape at sunset, camera slowly panning right",
    "model": "veo-3.1-fast",
    "aspectRatio": "landscape",
    "duration": 8
  }
}
  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 に入っています(1つのプロンプトで count > 1 を指定すると、動画ごとに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
  }
}

Veo 3.1 は、モデルとモードによって通常60〜180秒で完了します。2026年10月20日の当社の実行では、8秒の動画9本が、送信から MP4 の完成まで50〜105秒でした(測定した実行、英語)。署名付きの videoUrl は約6時間有効なので、早めにダウンロードしてください。ポーリングしたくない場合は、作成のボディに replyUrl を渡すと、ジョブの完了時に Webhook で通知を受け取れます。

画像から動画、参照画像から動画

Veo は2つの方法で画像を入力として受け取ります。まず各画像を POST /assets/email でアップロードします(生のバイト列、画像の Content-Type、20 MB までの PNG/JPEG/WebP)。アップロードの応答では、ID が mediaGenerationId.mediaGenerationId に入れ子になっています。この入れ子の文字列を次のリクエストに渡します。画像を入力に指定した場合は email を省略でき、リクエストは画像をアップロードしたアカウントに送られます。

開始・終了フレーム(I2V)

画像は、そのまま動画の1フレームになります。startImage として渡すと、動画はそのフレームから始まります。

curl -X POST "https://api.useapi.net/v1/google-flow/videos" \
  -H "Authorization: Bearer $USEAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "Brave Captain Cat chasing away the pirates!",
    "model": "veo-3.1-fast",
    "aspectRatio": "portrait",
    "startImage": "user:12345-email:6a6f...-image:ff9aa5cc-...",
    "async": true
  }'

endImage(2つ目のアップロードした参照 ID)を追加すると最後のフレームが固定され、Veo は2つのフレームの間をつなぐ動画を作ります。endImage には startImage が必要で、終了フレームだけの指定はできません。Omni のモデル(omni-flash-1.2、omni-flash)も同じ組み合わせを受け付けますが、その場合は2つのフレームが唯一の入力になり、referenceImage_*、character_*、referenceAudio_* は一緒に使えません。

参照画像(R2V)

画像は特定のフレームではなく、被写体、キャラクター、スタイルなど動画全体の方向を決めます。referenceImage_1 から referenceImage_3 までを渡します(Veo は最大3枚で、8秒の動画のみ、veo-3.1-quality では使えません)。R2V は startImage/endImage と組み合わせられません。

curl -X POST "https://api.useapi.net/v1/google-flow/videos" \
  -H "Authorization: Bearer $USEAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "A lady says to the dog, \"Say meow meow meow.\" The dog looks at the lady with a surprised, frustrated look and woofs in a human voice, \"What the heck lady?!\"",
    "model": "veo-3.1-fast",
    "aspectRatio": "portrait",
    "referenceImage_1": "user:12345-email:6a6f...-image:abcde-...",
    "async": true
  }'

プロンプトの中では、各参照をインラインの @referenceImage_N で指定します。ナレーションには referenceAudio_1(Zephyr のようなシステムの声の名前)を追加でき、再利用できるキャラクターを character_1 として渡すこともできます(同じ R2V モードで動き、3つの参照の枠に数えられます)。Omni Flash では参照をさらに広く使えます。最大7枚の参照、動画から動画への編集、同期した声に対応しています。Omni Flash のチュートリアル(英語)を参照してください。

スクリプトで一括生成する

狙いどおりのショットを得るには何度も試す必要があり、手作業で1本ずつ実行するのは面倒です。下の Node.js スクリプトは、prompts.json からプロンプトのリストを読み込み、開始・終了フレームの画像をアップロードし、各ジョブを非同期モードで送信してから、完成した MP4 をポーリングしてすべてダウンロードします。まとめてキューに入れておき、あとで良いものを選べます。

Node.js v21 以降が必要です。prompts.json と google-flow.mjs を同じフォルダーに置き、node ./google-flow.mjs API_TOKEN EMAIL を実行します。API_TOKEN は useapi.net の API トークン、EMAIL は接続済みの Google Flow アカウントのメールアドレスです。スクリプトはメールアドレスからアカウントを自動で探し、送信前に health フィールドを確認します。

クローンしてローカルで実行したい場合は、google-flow.mjs と prompts.json の完全版が GitHub の useapi/google-flow-api にあります。

prompts.json を表示
[
    {
        "prompt": "By default the veo-3.1-fast model with a landscape 8-second clip will be used."
    },
    {
        "model": "veo-3.1-quality",
        "aspectRatio": "landscape",
        "duration": 8,
        "count": 2,
        "seed": 123456,
        "prompt": "Highest-quality Veo 3.1, two variations. For all parameters see https://useapi.net/docs/api-google-flow-v1/post-google-flow-videos"
    },
    {
        "model": "veo-3.1-fast",
        "aspectRatio": "portrait",
        "duration": 8,
        "startImage": "./first_image.jpeg",
        "endImage": "./last_image.jpeg",
        "prompt": "Image-to-video with a start frame (startImage) and an end frame (endImage). Supported by Veo and by both Omni models."
    },
    {
        "model": "omni-flash",
        "duration": 10,
        "prompt": "Gemini Omni Flash is audio-native and supports 10-second clips. Other models (veo-3.1-lite, veo-3.1-lite-low-priority, …) are selectable via the model field — see the POST /videos docs."
    }
]
google-flow.mjs スクリプトを表示
/*

Script version 1.1, September 11, 2026

Script to batch-generate videos using prompts with the Google Flow API v1 by useapi.net 🚀
Uses the POST /videos endpoint in async mode (default model: veo-3.1-fast) 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 google-flow.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 google-flow.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: Accepts .jpg and upper-case image extensions for startImage and endImage. A prompt whose startImage or endImage fails to upload is skipped and logged to google-flow_errors.txt instead of being submitted without that frame. 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 = 'google-flow_results.txt';
const ERRORS_FILE = 'google-flow_errors.txt';
const DEFAULT_PROMPTS_FILE = 'prompts.json';
const DEFAULT_MODEL = 'veo-3.1-fast';
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/';

// To upload .webp keep its .webp extension — Google Flow accepts png, jpeg and webp.
const supportedFileExtensions = ['png', 'jpg', 'jpeg', 'webp'];

// { 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' : '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 in async mode.
// startImage = start frame (I2V), endImage = end frame (I2V-FL, requires startImage).
async function submitVideo(apiToken, email, prompt, index) {
    const { model, prompt: text, startImage, endImage, aspectRatio, duration, count, seed } = prompt;

    const useModel = model ?? DEFAULT_MODEL;

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

    const startImageId = startImage ? await uploadAsset(apiToken, email, startImage) : undefined;
    const endImageId = endImage ? await uploadAsset(apiToken, email, endImage) : undefined;

    // A frame 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]]
        .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;

    const body = JSON.stringify({
        model: useModel,
        email,
        prompt: text,
        aspectRatio,
        duration,
        count,
        seed,
        startImage: startImageId,
        endImage: endImageId,
        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 google-flow.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 POST /videos endpoint.
    // See https://useapi.net/docs/api-google-flow-v1/post-google-flow-videos for every model's full parameter set.
    const supportedParams = ['model', 'prompt', 'startImage', 'endImage', 'aspectRatio', 'duration', 'count', 'seed'];

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

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

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

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

                if (!supportedFileExtensions.includes(ext))
                    warnings.push(`⚠️  Image ${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}`);

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

        await Promise.all([validateImage(startImage), validateImage(endImage)]);
    }

    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 で作った実際の Veo 3.1 の動画です。当社のリリースノートからそのまま転載しています。

veo-3.1-fast » 海賊を追い払う勇敢なキャプテン・キャット(画像から動画)

出典は Google Flow API v1: Initial Release(英語)です。Nano Banana の静止画を startImage として渡し、動かしました。

veo-3.1-fast » 女性と犬、AI 音声のナレーション付き

出典は Veo 3.1 Lite and Voice Narration(英語)です。参照画像から動画を作り、referenceAudio_1: "Zephyr" のプリセットでセリフを加えました。

veo-3.1-fast » チャチャを踊る(縦長、参照画像から動画)

出典は Veo 3.1 Portrait R2V(英語)です。参照画像1枚から縦長の R2V で作りました。

よくある質問

  • Veo 3.1 の API はありますか? useapi.net を使えば、あります。Google Flow API は、お使いの Google Flow の Web サイトのプラン(Google AI Pro または Ultra)で、Veo 3.1 Quality、Fast、Lite を REST で動かします。8秒の Veo 3.1 Quality の動画は Google AI Ultra $199.99 で $0.80 で、公式の Veo API では $3.20 です(2026年11月時点)。
  • Veo 3.1 の Quality、Fast、Lite の違いは? useapi.net を使えば、リクエストごとに model フィールドで1つを選べます。veo-3.1-quality は最も高い品質、veo-3.1-fast は速度と品質のバランスを取ったデフォルト、veo-3.1-lite は最も安く、Google AI Ultra $199.99 で1本 $0.04、公式の Veo API では $0.40 です。このプランでは veo-3.1-lite-low-priority が $0 で、1日約1,000本以上作れます(2026年11月時点)。
  • Veo で画像から動画を作れますか? useapi.net を使えば、作れます。POST /assets/email で開始フレームをアップロードし、返ってきた参照 ID(mediaGenerationId.mediaGenerationId に入れ子)を startImage として渡します。最後のフレームを固定するなら endImage も渡します(startImage が必要です)。フレームではなく被写体やスタイルを指定したい場合は、referenceImage_N を最大3枚渡します(2026年11月時点)。上の画像から動画を参照してください。
  • CAPTCHA を解く必要はありますか? useapi.net を使えば、ご自身で解く必要はありません。Google Flow は動画ごとに reCAPTCHA を求めますが、useapi.net のワーカーが自動で解決します。最初の Google Flow アカウントには、当社の AntiCaptcha、CapSolver、EzCaptcha のキーで300回の無料解決が付き、その後は POST /accounts/captcha-providers でご自身のプロバイダーのキーを追加します(2026年11月時点)。このガイドのスクリプトは captchaToken を送りません。
  • Veo 3.1 の API キーは必要ですか? useapi.net を使えば、Google の API キーも Google Cloud プロジェクトも不要です。useapi.net の API トークン1つで Veo 3.1 を呼び出せ、Google アカウントは自動セットアップで一度接続するだけです。Google Flow 自体には公開 API がなく、2026年10月22日に Gemini API の Veo 3.1 Preview モデルが終了したため、Google 自身の Veo API には課金を有効にした Cloud プロジェクトが必要です。

おわりに

ご質問は Discord サーバー または Telegram チャンネルへどうぞ。

コード例は GitHub リポジトリにあります。

ほかの掲載先

この記事(英語版)は dev.to にも掲載しています。

API を使い始める

月額15ドルのサブスクリプション1つで、useapi.net のすべての API を使えます。トークン1つで、すべてのサービスに対応します。各サービスにご自身のアカウントを最大3つまで接続でき、REST API、負荷分散、ポーリングは useapi.net が担当します。アカウントを増やしたい場合は、サブスクリプションを1つ追加するごとに月額10ドルで、各サービスのアカウントが3つずつ増えます。

登録する — 月額15ドル

海外で使えるクレジットカードがない場合は、暗号資産で登録することもできます(暗号資産での支払いは返金できません)。料金とボリュームプランはサブスクリプションのページをご覧ください。Stripe でお支払いの場合、初回購入から14日以内で、成功した生成が50回未満であれば全額返金されます。すでに登録済みの場合は、新たに購入せず Stripe カスタマーポータルで数量を変更してください。