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?

OpenRouter の画像生成と TTS で、アニメショート動画を Python で低コストに作る

0
Posted at

なぜ作ったか

YouTube に動画を投稿したいと思っていますが、本格的な動画編集ソフトは高く、かといって動画生成AIで作るのも高くつきます。求めているのはそこまで本格的な動画ではなくてよくて、絵コンテにあたる素材(立ち絵・表情・口パク・背景)を一度作れば、それを使い回して動画を作れるのではないか、と考えました。

作ったもの

縦型(9:16)の短い会話動画を、素材の生成から書き出しまで Python スクリプトだけで作りました。

  • 立ち絵(基本 → 表情差分 → 口パク差分)と背景を、OpenRouter 経由の Gemini 画像生成で作る
  • rembg で白背景を透過する
  • 台詞を OpenRouter の TTS(Gemini) で音声にする
  • 音声の音量に合わせて口パクを切り替え、ffmpeg で mp4 にする

生成された動画

完成動画の各台詞のコマ

※ 上段・下段とも左から順に、動画内の8つの台詞の途中のコマです。

全体の構成

フォルダ構成

リポジトリと、実行すると増えるフォルダの構成です(.env、raw/、assets/、out/ は Git に入れません)。

rooftop-assets/
├── gen.py              画像生成
├── post.py             透過・左右反転・サイズ統一
├── tts.py              台詞 → wav
├── make_video.py       台詞JSON → mp4
├── scene.json          台詞・声・演出
├── .env                API キー
├── raw/                生成そのまま(白背景)
│   ├── characters/
│   │   ├── wataru/
│   │   │   ├── base.png          基本立ち絵
│   │   │   └── excited/ ...      表情ごとに closed.png / open.png
│   │   └── haruka/ ...
│   └── backgrounds/    rooftop_day.png など
├── assets/             後処理済み(動画に使う素材)
│   ├── characters/<キャラ>/<表情>/closed.png, open.png
│   └── backgrounds/
└── out/
    ├── audio/          音声のキャッシュ(wav)
    └── scene.mp4       完成した動画

構成図

技術スタック

領域 使ったもの
実行環境 Python 3 / uv(post.py・make_video.py は uv run、gen.py・tts.py は標準ライブラリのみ)
API OpenRouter
画像生成 google/gemini-3.1-flash-image(/chat/completions)
音声合成 google/gemini-3.8-flash-lite-tts(/audio/speech)
背景除去 rembg(isnet-anime)/ onnxruntime
画像処理 Pillow / NumPy
動画書き出し imageio / imageio-ffmpeg(同梱の ffmpeg 7.1)
台詞データ JSON(scene.json)
字幕フォント ヒラギノ角ゴシック W6(macOS 標準)

手順

スクリプトを順番に実行するだけで、素材から動画までできます。

1. 準備

Python 3 と uv を用意し、OpenRouter の API キーを .env に書きます(gen.py と tts.py が読み込みます)。

動作確認は macOS(Apple Silicon)です。.env の読み込みに zsh、音声の再生確認に afplay、字幕のフォントに macOS 標準のヒラギノを使っているので、他の OS では調整が必要です。

# .env
OPENROUTER_API_KEY=sk-or-xxxxxxxx

2. 基本立ち絵を作る

1人目は参照画像なしで作り、出てきた候補から使う1枚を base.png にコピーします。

python3 gen.py base wataru
# → raw/characters/wataru/candidates/base_1.png ができる
cp raw/characters/wataru/candidates/base_1.png raw/characters/wataru/base.png

2人目は、1人目の base.png を参照にして絵柄を揃えます。

python3 gen.py base haruka --ref raw/characters/wataru/base.png
cp raw/characters/haruka/candidates/base_1.png raw/characters/haruka/base.png

気に入らなければ --n 4 で候補を増やせます。ここで2人を並べて、構図と大きさを確認してから確定してください(後から変えると、差分をすべて作り直すことになります)。

3. 表情差分・口パク・背景を作る

基本立ち絵を元に、残りをまとめて作ります。すでにあるファイルは作り直さず、--force を付けたときだけ再生成します。

python3 gen.py expr all    # 表情差分(基本立ち絵を元に)
python3 gen.py mouth all   # 口パク差分(各表情を元に)
python3 gen.py bg all      # 背景(人物なし)
python3 gen.py status      # 進み具合の確認

4. 透過・反転・サイズ統一

uv run post.py

白背景を透過し、2人目を左右反転して向かい合わせ、同じキャラの画像を同じサイズに揃えて assets/ に配置します。初回は rembg のモデル(約176MB)をダウンロードします。

5. 声を決める(音声)

台詞は OpenRouter の TTS(google/gemini-3.8-flash-lite-tts)で音声にします。キャラごとに、**声の名前(voice)と演出(style)**を scene.json に書きます(style は英語の文章で指定します)。

"voices": {
  "wataru": "Puck",
  "haruka": {"voice": "Leda", "style": "a young girl's voice, light and soft, deadpan"}
}

本番の動画を作る前に、1行だけ音声にして聴いて、声と演出を決めます。

# 台詞・声・演出を渡すと、out/audio/ に wav ができてパスが表示される
python3 -c "from tts import speak; print(speak('それ、お昼代を使い果たしただけでしょ', 'Leda', 'youthful girl, light and soft, a little tired and deadpan'))"

# 再生して確認(macOS)
afplay out/audio/Leda_xxxxxxxxxx.wav

聴いて違和感があれば、声の名前を変える(今回は Puck、Leda、Zephyr、Aoede、Autonoe などを試しました)か、style の文章を直して、もう一度作ります。テンポを上げたいときは、style に speaking at a brisk, quick pace のような文を足します。

同じ台詞・声・演出なら再生成されないので、聴き直しても費用は増えません(仕組みは「TTS の音声はキャッシュする」で説明します)。

6. 台詞を書く

scene.json に、背景と台詞を書きます。expr は、その台詞の間の表情です(表情の名前は assets/characters/<キャラ>/ のフォルダ名です)。

{
  "background": "rooftop_day",
  "voices": {
    "wataru": "Puck",
    "haruka": {"voice": "Leda", "style": "a young girl's voice, light and soft, deadpan"}
  },
  "fps": 24,
  "initial": {"wataru": "normal", "haruka": "normal"},
  "lines": [
    {"who": "wataru", "expr": "excited",     "text": "なあ遥、焼きそばパンって実は一番コスパがいいんだ"},
    {"who": "haruka", "expr": "unimpressed", "text": "また始まった"}
  ]
}

7. 動画を作る

uv run make_video.py scene.json
# → out/scene.mp4

処理の中身は、後ろの「動画化」の節で説明します。

画像生成

手順は次の順です。後ろの工程が前の画像を添付するので、順番が大事です。

  1. 基本立ち絵(白背景・1人・1枚)
  2. 表情差分(基本立ち絵を添付し「表情だけ変える」)
  3. 口パク差分(各表情の画像を添付し「口だけ変える」)
  4. 背景(人物なし)

パイプライン図

API の呼び出し

OpenRouter の /chat/completions に modalities: ["image", "text"] を渡すと、画像が返ります。参照画像は、同じメッセージに image_url(data URL)として添付します。縦横比は image_config.aspect_ratio で指定します(立ち絵は 2:3、背景は 9:16)。

body = {
    "model": "google/gemini-3.1-flash-image",
    "modalities": ["image", "text"],
    "messages": [{"role": "user", "content": [
        {"type": "text", "text": prompt},
        {"type": "image_url", "image_url": {"url": data_url}},  # 参照画像(1人目などは付けない)
    ]}],
    "image_config": {"aspect_ratio": "2:3"},
}
# POST https://openrouter.ai/api/v1/chat/completions

# 返ってくる画像は data URL(base64)
url = res["choices"][0]["message"]["images"][0]["image_url"]["url"]
open("out.png", "wb").write(base64.b64decode(url.split(",", 1)[1]))

プロンプト

プロンプトは gen.py に埋め込んであります。すべてのプロンプトの先頭に絵柄の指定、末尾に1人・文字なしの指定を付けます。

# 先頭(共通)
Simple anime-style illustration, clean thin line art, flat colors with soft cel shading, slightly loose and charming hand-drawn feel, warm soft color palette, not overly detailed, consistent proportions.

# 末尾(共通)
Single character only, one image, high resolution, no text.

基本立ち絵(航) です。1人目は参照画像がないので、先頭の「Match the art style of the attached image exactly.」は外して送ります。2人目以降は、この1文を含めて、1人目の画像を添付します。

Match the art style of the attached image exactly. A Japanese high school boy, second-year student. Messy black hair with a noticeable cowlick, curious eyes. Wearing a black gakuran school uniform with the top button clearly undone. Holding a yakisoba bread roll in his right hand. Knee-up standing pose, body turned slightly to the right (three-quarter view). Neutral relaxed expression, mouth closed. Neutral even lighting. Plain pure white background.

表情差分 です。基本立ち絵を添付して、{e} に表情の文を入れます(例: excitedly explaining a theory, eyebrows raised high, sparkling eyes)。

Use the attached image as the reference. Keep the exact same character, hairstyle, outfit, colors, art style, pose, framing, and size. Change only the facial expression to: {e}. Mouth closed. Plain pure white background.

口パク差分 です。各表情の画像を添付します。

Use the attached image as the reference. Do not change anything except the mouth: make the mouth slightly open as if in the middle of speaking. Keep the exact same expression, pose, framing, and size. Plain pure white background.

flash と flash-lite の比較

同じプロンプトで1枚ずつ作って比べました(各1枚なので、ばらつきの範囲かもしれません)。

flash-image flash-lite-image
画像出力の単価 $60 / 100万 token $30 / 100万 token
構図 指定どおり膝上、顔が大きい 全身になり顔が小さくなった
表情の読み取り しやすい 口の周りが不自然

表情差分・口パクは顔の大きさが決め手になるので、flash を採用しました。1枚あたりの差は約 $0.03 で、撮り直しが1回増えれば逆転します。

音声生成

Gemini TTSを使って生成しました。

演出

声の名前だけを指定した最初の音声は、トーンが低く、女子高生らしい若さが出ませんでした。声の名前を変えても、音程を後から上げても、若さは出ませんでした。そこで、話し方を指定する「演出」を試しました。

最初は、台詞の前に「女子高生の声で、淡々と」のような指示を付けて input に入れました。すると、指示の文章まで読み上げられてしまい、約3秒の台詞が11〜13秒になりました。Gemini TTS は input をそのまま読み上げるためです。

演出は input には入れず、provider.options.google-ai-studio.speech_metadata.style に、自然言語(英語)で別に渡します。感情、トーン、声のスタイルを指定するための設定です。

body = {
    "model": "google/gemini-3.8-flash-lite-tts",
    "input": "それ、お昼代を使い果たしただけでしょ",   # 台詞のみ
    "voice": "Leda",
    "response_format": "pcm",
    "provider": {"options": {"google-ai-studio": {
        "speech_metadata": {"style": "a young girl's voice, light and soft, a little tired and deadpan"}
    }}},
}
  • response_format は pcm のみ(mp3 だと 400)。24kHz / 16bit / モノラルなので、wav のヘッダを付けて保存します。
  • speed は Gemini では効きません(1.2 を指定しても 4.0秒 → 4.4秒)。
  • テンポを上げたいときは、style に speaking at a brisk, quick pace with snappy timing のような文を足します。同じ台詞で、約22%短くなりました(1行での測定です)。

TTS の音声はキャッシュする

ファイル名を「モデル・声・演出・台詞」のハッシュにして、同じ入力なら再生成しません。動画の見た目だけを調整するときに、音声の費用がかかりません。

動画化

make_video.py は、台詞ごとの音声をもとに、1コマ(24fps、1080×1920)ずつ画像を合成して、mp4 に書き出します。

全体の流れ

1. 台詞の長さは音声で決める

字幕を出す時間は「音声の長さ + 0.15秒」、台詞の後は0.5秒の間を置きます。台詞の文字数から長さを決めると、実際の声とずれるので、音声の長さを使います。

2. 口パクは音量で決める

1コマ分の音声の大きさ(RMS)を測って、一定以上なら口を開けた画像(open.png)、小さければ閉じた画像(closed.png)にします。しきい値は、その台詞でいちばん大きい音の2割です。話している側だけが動き、もう片方は閉じたままです。

win = SR // fps  # 1フレーム分のサンプル数
rms = [np.sqrt(np.mean(seg[i * win:(i + 1) * win] ** 2)) for i in range(n)]
thr = 0.2 * max(rms)
mouth_open = rms[i] > thr   # 音が出ている間だけ open.png

表情は、台詞ごとに scene.json の expr に切り替わり、次の台詞まで保たれます。

3. コマを合成する

Pillow で、背景、立ち絵(2人)、字幕の順に重ねます。立ち絵は下端をそろえ、字幕の枠は不透明にして、立ち絵の足元を隠します。

4. 音声と合わせて書き出す

映像だけを先に mp4 に書き出し、台詞ごとの音声を並べて1本の wav にして、最後に ffmpeg で結合します。映像は再エンコードせず(-c:v copy)、音声だけ aac にします。

subprocess.run([ffmpeg, "-y", "-i", video, "-i", wav,
                "-c:v", "copy", "-c:a", "aac", "-shortest", out])

ffmpeg は imageio-ffmpeg に同梱されたものを使うので、別途インストールは不要です。

かかった費用

OpenRouter のダッシュボードで確認した実績です(2026年10月時点)。完成品を作るまでの作り直しを含んだ合計です。金額は OpenRouter の分だけで、Claude Code など他の利用料は含みません。

用途 モデル 費用
画像生成 Nano Banana 2 (Gemini 3.1 Flash Image) $3.80
画像生成(flash との比較で1回だけ) Nano Banana 2 Lite (Gemini 3.1 Flash Lite Image) $0.03
音声合成 Gemini 3.8 Flash Lite TTS $0.03

画像の費用の内訳

枚数は、ダッシュボードのリクエスト数(56回)です。1枚あたり約 $0.068 で計算しています。

枚数 費用
完成品に使った素材(立ち絵24枚 + 背景5枚) 29枚 約 $2.0
作り直し・やり直しで捨てた分 27枚 約 $1.8
合計 56枚 $3.80

完成品1セットだけなら約 $2 のところ、実際は作り直しで約2倍かかりました。

作り直しの大半は、航の基本立ち絵です。表情差分と口パクは基本立ち絵を元に作るので、基本立ち絵を後から変えると、航の差分11枚をまとめて作り直すことになります。これを2回やりました(構図が遥と揃っていないことに、差分まで作ってから気づいたため)。

音声の費用

声の聴き比べや速さの調整など、42回のリクエストで $0.03 でした(1回あたり約 $0.0007)。声の比較で試した他社の TTS(MiniMax、Microsoft、Kokoro)は、合計しても $0.004 未満でした。最終の動画の8行分だけなら、1円にも届きません。

うまくいかなかったこと

  • 全身の構図: 基本立ち絵が腰までで切れたキャラと、膝下まで入ったキャラが混ざり、並べると高さが揃いませんでした。再生成して揃えましたが、毎回ばらつきます。
  • 表情の差: 「ドヤ顔」や「微笑み」は、通常顔との差が小さくなりがちです。
  • 一部の差分で目の色がずれることがありました。

まとめ

  • 画像生成: flash で、2人のキャラと背景5種を、基本立ち絵 → 差分の順に作れました。顔の大きさが決め手で、flash-lite は見送りました。
  • 音声: 演出の指定で、声の若さとテンポを調整できました。
  • 動画化: 台詞の音声に合わせて、口パクと字幕を切り替えた縦型の動画を、Python だけで書き出せました。
  • 費用: 完成品1セットで約 $2、作り直しを含めて $3.80 でした。音声は $0.03 です。
  • 一番の課題: キャラ同士の構図と大きさを揃えることでした。基本立ち絵の段階で並べて確認していれば、減らせた分です。

今後やりたいこと

  • 仮の台詞を、オチのある本番の台本に差し替える
  • 天気ごとの背景を、同じ場所の構図で揃える
  • 通常顔との差が小さい表情(「ドヤ顔」「微笑み」)を作り直す
  • BGM と効果音を足す
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?