0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

無料モデルでプロンプトを作り、Nano Banana 2.1 で最初のフレームを固めてから Veo 3.1 Lite で動画にする

0
Last updated at Posted at 2026-11-10

W45-Qiita-02.png

前回までは、動画タスクの「送信 → 状態確認 → 保存」と、無料のテキストモデルでプロンプトを作る方法をまとめました。

今回はその間に1段はさみます。動画を作る前に、最初のフレームを画像で決める手順です。

動画で構図が外れると、8秒分をまるごと作り直すことになります。構図だけなら、画像1枚で先に確認できます。ここで見られるのは構図と色までで、動きの良し悪しは動画を作るまで分かりません。その前提で読んでください。

流れとコスト

  1. 商品の説明文を無料のテキストモデル deepseek-v4.1-flash-free に渡し、最初のフレーム用のプロンプトを3案つくる
  2. 3案を nano-banana-2.1(1K)で画像にする
  3. 1枚を選び、veo-3.1(Lite・720p)の開始フレームとして渡して動画にする
工程 モデル 金額
プロンプト3案 deepseek-v4.1-flash-free $0
最初のフレーム3枚(1K) nano-banana-2.1 USD 0.012 × 3 = USD 0.036
動画1本(8秒・720p・音声つき) veo-3.1(quality: "Lite") $0.15

料金は 2026年11月5日時点の beatapi.io の表示価格です。無料モデルの提供内容は変わることがあるので、使う前に料金ページで確認し、本番の処理には有料モデルの代わりも用意しておくと安心です。

前提

  • Node.js 18 以上(fetch が標準で使えます)
  • 環境変数 BEATAPI_API_KEY
  • テキストは OpenAI 互換の https://api.beatapi.io/v1/chat/completions、画像は POST /v1/images/tasks、動画は POST /v1/videos/tasks。状態確認はどちらも GET /v1/tasks/{task_id} です

1. 無料モデルでフレーム用プロンプトを3案つくる

動画プロンプトではなく、「1枚の静止画」として書かせます。動きは次の工程で足すので、ここでは被写体・構図・光・変えてはいけないものに絞ります。

// firstframe.ts — 実行例: npx tsx firstframe.ts
const base = "https://api.beatapi.io/v1";
const headers = {
  Authorization: `Bearer ${process.env.BEATAPI_API_KEY}`,
  "Content-Type": "application/json",
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function makeFramePrompts(product: string): Promise<string[]> {
  const res = await fetch(`${base}/chat/completions`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      model: "deepseek-v4.1-flash-free",
      messages: [
        {
          role: "system",
          content:
            "You write prompts for ONE still image that will be the first frame of an 8-second 16:9 product video. Each prompt: subject and look, composition and camera angle, ONE light source, empty space where motion can happen, and what must NOT change (logo, color). English, under 60 words, no text overlay. Return only a JSON array of 3 strings.",
        },
        { role: "user", content: product },
      ],
    }),
  });
  if (!res.ok) throw new Error(`chat failed: ${res.status}`);
  const json = await res.json();
  const text: string = json.choices[0].message.content;
  // モデルが前後に文章を付けても拾えるように、最初の [ から最後の ] までを読む
  const arr = JSON.parse(text.slice(text.indexOf("["), text.lastIndexOf("]") + 1));
  if (!Array.isArray(arr) || arr.length === 0) throw new Error("no prompts");
  return arr.slice(0, 3);
}

2. 送信と待機を共通にする

画像も動画もタスクAPIなので、送信と待機は同じ関数で扱えます。1案ごとに Idempotency-Key を分け、通信が切れて再送するときは同じキー・同じ本文で送ります。

async function submit(path: string, body: object, key: string): Promise<string> {
  const res = await fetch(`${base}${path}`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": key },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`submit failed: ${res.status} ${await res.text()}`);
  const { data } = await res.json();
  return data.id; // まず保存する
}

async function waitForTask(id: string, timeoutMs = 12 * 60_000) {
  const deadline = Date.now() + timeoutMs;
  let delay = 5_000;
  while (Date.now() < deadline) {
    const res = await fetch(`${base}/tasks/${id}`, { headers });
    if (res.status === 429) {
      const ra = Number(res.headers.get("retry-after"));
      await sleep((Number.isFinite(ra) && ra > 0 ? ra : 10) * 1_000);
      continue;
    }
    if (res.ok) {
      const { data } = await res.json();
      if (data.status === "succeeded" || data.status === "failed") return data;
    } else if (res.status < 500) {
      throw new Error(`task query failed: ${res.status}`);
    }
    await sleep(delay + Math.floor(Math.random() * 1_000));
    delay = Math.min(Math.round(delay * 1.35), 15_000);
  }
  // 待つのをやめるだけ。タスクIDは残して、あとで照合する
  throw new Error(`client deadline reached; keep the task id: ${id}`);
}

const mediaUrl = (task: any, type: string): string | undefined =>
  task.output?.media?.find((m: any) => m.type === type)?.url;

3. 最初のフレームを3枚つくる

比率は動画と同じ 16:9 にそろえます。ここがずれると、動画側で開始フレームが切り抜かれたり、構図が変わったりします。

async function makeFrames(prompts: string[], tag: string) {
  const ids: string[] = [];
  for (const [i, prompt] of prompts.entries()) {
    ids.push(
      await submit(
        "/images/tasks",
        { model: "nano-banana-2.1", prompt, aspect_ratio: "16:9", resolution: "1K", output_format: "png" },
        `${tag}-frame-${i}-v1`,
      ),
    );
  }
  const tasks = await Promise.all(ids.map((id) => waitForTask(id)));
  return tasks.map((t, i) => ({ prompt: prompts[i], status: t.status, url: mediaUrl(t, "image") }));
}

4. 選んだ1枚を開始フレームにして動画にする

Veo 3.1 の定義に duration はなく、料金は8秒の規格なので、duration は送りません。開始フレームは images に公開 HTTPS の URL で渡します。

async function makeVideo(frameUrl: string, motion: string, key: string) {
  const id = await submit(
    "/videos/tasks",
    {
      model: "veo-3.1",
      quality: "Lite",
      prompt: motion,
      images: [frameUrl],
      aspect_ratio: "16:9",
      resolution: "720p",
    },
    key,
  );
  console.log("video task id:", id);
  return waitForTask(id);
}

async function main() {
  const product =
    "Amber perfume bottle with a gold cap, label 'NOIR'. Premium, warm, for an online store hero video.";
  const prompts = await makeFramePrompts(product);
  const frames = await makeFrames(prompts, "noir");
  frames.forEach((f, i) => console.log(`#${i + 1} ${f.status}\n  prompt: ${f.prompt}\n  image: ${f.url}`));

  // ここで人が1枚を選ぶ。例として環境変数 PICK(1〜3)で受け取る
  const pick = frames[Number(process.env.PICK ?? "1") - 1];
  if (!pick?.url) throw new Error("no frame to use");

  const video = await makeVideo(
    pick.url,
    "Slow push-in toward the bottle. Warm light slowly sweeps across the glass. The logo and cap color stay unchanged.",
    "noir-video-v1",
  );
  console.log(video.status, mediaUrl(video, "video") ?? video.error_code);
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

まず PICK なしで実行して3枚を見比べ、気に入った番号で PICK=2 npx tsx firstframe.ts のように実行し直す、という使い方を想定しています。実運用では、プロンプトと画像 URL をファイルに保存し、2回目はそこから読み込むようにしてください。そうしないと、2回目の実行でプロンプトが作り直されます。

ハマりどころ

  • JSON で返ってこない:テキストモデルが説明文を付けることがあります。配列部分だけを切り出すか、失敗したらもう一度だけ聞き直します。
  • 動画の duration:Veo 3.1 の定義には duration がなく、料金は8秒の規格です。
  • 画像と動画で比率が違う:開始フレームと動画の aspect_ratio をそろえます。Veo 3.1 で使える比率は 16:9 / 9:16 / auto です。
  • 画像 URL が切れている:出力 URL は永久保存ではありません。時間を空けて動画にするなら、画像を自社のストレージに保存し、そこから公開 URL を渡します。
  • 同じ画像・動画が2つできる:タイムアウト後に新しいキーで送り直すと起きます。キーは「1案につき1つ」で固定します。
  • 無料モデルが使えない日:提供内容が変わることがあります。model を有料のテキストモデルに差し替えられるようにしておきます。

まとめ

プロンプトは無料モデル、構図は1枚 USD 0.012 の画像で先に決め、動画は1本だけ作る。この例の生成費は USD 0.186(画像3枚 USD 0.036 + 動画1本 USD 0.15)です。

ただし、画像で確認できるのは構図と色までです。動きが期待どおりになるかは、自分の用途で何本か作って記録してから判断してください。

出典・確認日

  • テキスト:OpenAI 互換 POST /v1/chat/completions(BeatAPI 公開 API ドキュメント)
  • 画像:openapi.json の nano-banana-2.1(aspect_ratio、resolution は 1K / 2K / 4K):2026年11月5日確認
  • 動画:openapi.json の veo-3.1(8秒固定、quality は Quality / Fast / Lite、開始フレームは images):2026年11月5日確認
  • 料金:api.beatapi.io/v1/pricing と beatapi.io 料金ページ:2026年11月5日確認

モデルの仕様と料金:https://beatapi.io/veo-3.1-api

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?