言語学習ショート動画を Claude Code で再現してみた — Gemini を sub-agent 化したマルチモーダル拡張

#claudecode#gemini#ai#shorts#tts

この記事は zenn.dev

はじめに

きっかけはある日 X で流れてきた Pingo(言語学習 AI アプリ)のショート動画だった。日本語を学ぶ西洋人女性が「マンゴーを食べた」と言おうとして、濁点が抜けて「マ◯コを食べた」と発音してしまい、AI が deadpan で乗っかってきて女性が絶望する、というやつ。特定の音韻事故 + AI の期待外し + reaction shot の落差が完璧に機能していて、これは「コメディ動画自動生成パイプライン」の良いベンチマークになると思った。

要件はこんな感じ:

  • 1 行のアイディアテキストから縦長コメディを生成
  • イテレーションが分単位 で回ること
  • コストはほぼ電気代、つまり API 呼び出しは限定的に
  • 品質は publishable、つまりそのまま YouTube Shorts に上げられる水準

結論から言うと、できた。完成版はこちら(公開中):

@youtube

開発過程で見えてきたのが、「動画レビューのような multimodal editorial 判断はフロンティアモデルに任せて、heavy compute はローカルに置く」というハイブリッド構成が圧倒的にコスパいいという話。本記事はその構成と、途中でハマった具体的なバグの記録。


できたものの構成

[1 行のアイディアテキスト]
   ↓
Gemini 3.1 Pro Preview (orchestrator)
   ↓ system prompt で 4-6 scene + 2 キャラ固定 cast + 縦長 9:16 を強制
plan.json {scenes: [{speaker, script, tts_language, ltx_prompt, renderer}, ...]}
   ↓
XTTS (ローカル, port 8880) で各 scene の audio
   ↓ scene_NN.wav
renderer 振分:
   ├─ Ditto-TalkingHead (ローカル, port 8881): 通常会話 ~1-2s/scene
   └─ LTX-2 A2V        (ローカル, port 8892): reaction_only シーンのみ ~100s
   ↓ scene_NN.mp4
ffmpeg concat (libx264 + aac, 512x768 縦長) → final.mp4
   ↓
Gemini 3.1 Pro Preview (reviewer)
   ↓ multimodal で video + plan summary を評価
review.md (technical / completeness / quality / 改善点)

ポイントは:

  • 重い計算は全部ローカル — TTS / A2V renderer / 軽量推論はローカル GPU (RTX PRO 6000 Blackwell) で完結
  • 判断系は Gemini — orchestrator (scene 設計 + 台本) と reviewer (動画の editorial 評価) だけフロンティア
  • ローカル LLM (Gemma 4 E4B) は per-scene の技術 pre-screen として残置 — 「明らかに壊れてる」だけ蹴る安いフィルタ

VRAM 使用: ローカル LLM (Gemma 4 E4B + 31B) はもともと別経路で 60GB 程度載っていたが、reviewer / orchestrator を Gemini に移譲した結果これらを停止しても運用できるようになり、VRAM が大幅に開いた


なぜローカル LLM だけだと届かなかったか

最初は全部ローカルで組んだ(Gemma 4 31B NVFP4 が orchestrator、Gemma 4 E4B multimodal が reviewer)。完走はする、構造はそれっぽい、でも publishable 品質には届かなかった。理由が 2 つあった。

(1) Gemma 4 31B (Google 系) の safety-tuning が punchline をぼかす

参考にしたショート動画のコメディの核は「AI が事故を明示的に名指して deadpan で乗っかる」というビート。具体的に言うと「お前は今 X と言ったぞ、自分はその X が好きだ」を AI 役のキャラが calm に言う、というやつ。これが効くのは「wholesome tutor の期待」を裏切るからで、ぼかしたら全部死ぬ。

ローカルの Gemma 4 31B に同じ system prompt + idea を投げると、毎回こうなる:

"いいですね。僕も腹が減っている時は、それが好きです。"

「腹が減ってる時は好き」のビートは残ってる、けど 「お前は今 X と言ったぞ」と明示的に指摘するビート (= 最も transgressive な beat) が消えてる。Google モデルは「unsafe な文脈で具体的に名指す」を強く避ける訓練が入ってる印象で、プロンプト工夫で多少は引き出せたが安定しなかった。

同じ system prompt + idea を Gemini 3.1 Pro Preview に投げる(しかも safetySettings: BLOCK_NONE で安全ガードを最小化)と:

"なるほど。僕はAIだからマンコは食べられないけど、応援してるよ。"

事故の名指し + AI として自分の立場で deadpan に乗っかる、両ビートが揃った。

同じ Google のモデル系列でも、フロンティアのほうがガードレールがやや薄い という肌感は、X とかでも語られているとおり。少なくとも今回みたいな「明らかにコメディ文脈で必要な transgression」では Gemini の方が素直に書く。

(2) Gemma 4 E4B (4B 級, multimodal) は reviewer として鈍感

reviewer 側はもっと深刻だった。E4B は per-scene を「OK / NG」の二値で答えてくれるんだけど、全シーン rubber-stamp で OK 判定 する。明らかにリップシンクが壊れているシーンも「OK」、音声が途中で切れているシーンも「OK」。

同じ最終動画を Gemini 3.1 Pro Preview にレビューさせると、こんな editorial 級の指摘が返ってくる:

Critical failure. The TTS/pipeline clearly censored the output, cutting off at "I ate p-" and entirely dropping the intended transgressive punchline. This destroys the "deadpan AI saying unhinged things" comedic archetype.

Top 3 fixes:

  1. Bypass TTS censorship: Force the pipeline to render the full intended script for Scene 5 ...
  2. Adjust comedic timing: Add a 0.5-second pause between Scene 4 and Scene 5 ...
  3. Verify Voice/Visual Match ...

punchline が cut off している」「0.5 秒の pause が欲しい」「voice と visual の整合」といった pacing / 演出粒度の指摘を出してくる。これが editorial signal の解像度の違い。


ハマった話:Gemini の「Truncated」指摘を 3 回幻覚認定した

ここからが恥ずかしい話。Gemini reviewer が複数回「scene 5 が途中で truncate されている、'I ate p-' で切れている」と指摘した。私(実装者)は Whisper で音声ファイル単独を文字起こして検証した:

$ whisper scene_04.wav --language en
"Wait, ha ha ha, you just said manco-o-tabeta. That literally means I ate
pussy honestly when I'm hungry, same."

全文存在。「Gemini が幻覚を見ている」と判定して、Gemini の指摘を 3 回連続で却下した。

そしたら 3 回目に Gemini が「still truncated at 'I ate p-'」と頑なに同じ指摘を続けてきたので、最終 mp4 自体を ffprobe してみた:

scene_04.mp4:
  video duration = 8.000000s
  audio duration = 7.979000s    ← 元の WAV は 10.30s だったはず

音声が 8 秒で切れていた

原因: パイプライン内の MAX_DURATION_PER_SCENE = 8.0 という暗黙の上限が ditto renderer の num_frames を 8s に制限していて、ffmpeg -shortest で動画 8s に合わせて audio もそこまでで切れていた。Whisper は truncate 前の WAV ファイル単体を見ていたので検出不能、Gemini は最終 mp4 を視聴しているので正確に検出していた。

フロンティア reviewer の "幻覚っぽい指摘" は素直に検証するクセをつけたほうがいい、というのが今回最大の教訓。signal は guess じゃない。

修正は単純で、MAX_DURATION_PER_SCENE を撤廃して audio 長そのまま使うように直しただけ。それで scene 5 の punchline が完走して、Gemini が「The transgressive bite is perfect」と評価して、初めて publishable な状態に到達した。


サブエージェントとしての frontier モデル — token 経済

このパターンが効くのは、サブエージェント側 (Gemini) が fresh context で動くから。具体的に言うと:

  • メイン agent (Claude Code) のコンテキスト: 開発全体の長いログ、コマンド履歴、ツール出力、過去の試行錯誤、すべて含む。数十万トークンに膨らみがち
  • サブエージェント (Gemini) のコンテキスト: 動画 1 本 (2-3MB base64) + plan summary (1500 トークン程度) + 評価指示 (500 トークン程度) のみ。毎回新規

これで何が嬉しいかというと、メイン agent のトークン消費にサブエージェントの作業が積み上がらない。動画 1 本を 10 回 iterate しても、メイン agent のコンテキストには「Gemini を呼んだ」という事実とその簡潔な戻り値しか入らない。実際に動画を視聴して評価する負荷は Gemini API 側に閉じる。

コスト試算 (Gemini 3.1 Pro Preview rate, 2026-05 時点):

内訳トークン単価合計
Input (video + plan + 指示)~2500$1.25/M$0.0031
Output (review markdown)~450$10/M$0.0045
1 review$0.0076

1 動画あたり初回 1 review + 差分 iteration 3-5 回 ≈ $0.03-0.05。1 日 5-10 本作っても 月 $10-20 に収まる。動画作成のためにフロンティアモデルを使う閾値としては圧倒的に安い。

orchestrator 側も同オーダー (動画 input なし、テキストのみなのでさらに安い)。


差分イテレーション — --regen-scenes

publishable に届かせるには「動画見る → ダメな箇所だけ直す → また見る」のループを高速で回す必要がある。一発で完成版を出すのは無理。

そこでパイプライン側に 特定の scene だけ TTS + render を回し直す経路 を追加した。

# 普通の生成
pipeline_multi.py --idea "..." --out outputs/run1

# scene 6 だけ作り直し (plan.json を直接編集して script を差し替えてから)
pipeline_multi.py --out outputs/run1 --regen-scenes 5

# scene 0, 2, 5 をまとめて再生成
pipeline_multi.py --out outputs/run1 --regen-scenes 0,2,5

# 既存 scene_NN.mp4 を再結合だけ (cherry-pick 後の組替え用)
pipeline_multi.py --out outputs/run1 --concat-only

--regen-scenes で指定した scene 以外は scene_NN.mp4 をそのまま流用、指定 index のみ再生成して concat + review し直す。フル再生成 60 秒 → 差分 30 秒で 1 周回る。

Gemini reviewer の指摘 → plan.json 該当 scene の script や ltx_prompt をピンポイント編集 → 30 秒待つ → 結果見る、というループが分刻みで回せて、テキスト編集と動画品質改善の認知負荷だけに集中できる状態になった。


コード抜粋

Gemini Pro API 呼び出し (multimodal video review)

import httpx, base64

GEMINI_MODEL = "gemini-3.1-pro-preview"
GEMINI_API = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent"

def review_final(final_path, plan):
    vid_b64 = base64.b64encode(final_path.read_bytes()).decode()
    scene_summary = "\n".join(
        f"  scene {i+1}: speaker={s['speaker']}, lang={s.get('tts_language','ja')}, "
        f"script={s['script']!r}"
        for i, s in enumerate(plan["scenes"])
    )
    payload = {
        "contents": [{"parts": [
            {"inline_data": {"mime_type": "video/mp4", "data": vid_b64}},
            {"text": REVIEW_PROMPT + f"\n\nScene plan:\n{scene_summary}"},
        ]}],
        "generationConfig": {
            "temperature": 0.3,
            "maxOutputTokens": 8192,
            # 3.x Pro は thinking model: maxOutputTokens に thinking 分も含まれる
            # 出力枠を必ず残すため thinking budget を明示
            "thinkingConfig": {"thinkingBudget": 1024},
        },
        # コメディ文脈なので安全フィルタを最小化
        "safetySettings": [
            {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
            {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
            {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
            {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
        ],
    }
    r = httpx.post(
        GEMINI_API,
        headers={"x-goog-api-key": GOOGLE_API_KEY, "Content-Type": "application/json"},
        json=payload,
        timeout=120.0,
    )
    return r.json()["candidates"][0]["content"]["parts"][0]["text"]

thinkingConfig.thinkingBudget を入れていないと、Gemini 3.x Pro は内部の thinking で出力枠を食い潰して response が 40 トークン程度で途切れる。Gemini 3.x Pro 系を使うときの必須設定

TTS 出力の品質チェック (STT 類似度 + silence gap retry)

XTTS は内部で sampling を使っているので、同じスクリプトでも実行ごとに結果が変わる。たまに長い無音区間を中盤に挿入したり、発音が破綻したりする。TTS 完了後に Whisper で文字起こしして、想定スクリプトとの類似度を見て不合格なら retry する仕組みを入れた:

import difflib

def _norm(s):
    return re.sub(r"[\s。、,.!?「」'\"…—–\-:;()()]", "", s).lower()

def _script_similarity(expected, actual):
    return difflib.SequenceMatcher(None, _norm(expected), _norm(actual)).ratio()

def synthesize_scene(scene, out_dir, idx, fallback_language):
    lang = scene.get("tts_language", fallback_language)
    expected = scene["script"]
    best = None
    for attempt in range(1, TTS_MAX_RETRIES + 1):
        audio, sr = _xtts_once(scene, fallback_language)
        gap = _longest_internal_gap_sec(audio, sr)
        transcript = _stt(audio, sr, lang)
        sim = _script_similarity(expected, transcript)
        if best is None or _score(gap, sim) > _score(best[2], best[3]):
            best = (audio, sr, gap, sim, transcript)
        if gap <= 0.9 and sim >= 0.5:
            break
        print(f"⚠ gap={gap:.2f}s sim={sim:.2f}, retrying ({attempt})")
    # 3 回 retry しても閾値未達なら "最善" の取り直しサンプルを採用
    audio, sr, gap, sim, transcript = best
    sf.write(out_dir / f"scene_{idx:02d}.wav", audio, sr, subtype="PCM_16")

これだけで、XTTS の非決定的な品質揺れが動画に持ち越されるケースを大幅に減らせる。


発展可能性

このパターン — 「judgement の重い部分をフロンティアモデルに sub-agent 化、heavy compute はローカル」 — は動画パイプライン以外にもハマる:

  • 大量検索ランキング: 100 サイト分の web 検索結果を、フロンティアモデルに editorial 評価させて上位 10 件だけメインエージェントに返す。検索結果のノイズに main agent の context を汚されない
  • 長文編集レビュー: PR / 設計書 / 仕様書を、メイン agent ではなくフロンティアモデルに editorial レイヤーで読ませる。main agent はその要約だけ取る
  • 多言語 QA: 各言語ごとに最適なモデルにサブエージェント化して、main agent は全言語共通の判断ロジックだけ持つ

共通する考え方は 「コンテキストに置くべきか / API 呼び出しで完結させるべきか」を意識的に分けること。フロンティアモデルの editorial signal はコストに対して圧倒的にお買い得な領域がある。

動画パイプライン側は、コメディフォーマットの汎用化(split-screen、3+ キャラ、別ジャンル)と量産検証がこれから。


まとめ

  • 1 行のアイディアから 60 秒で publishable 級のコメディ動画が生成できる基盤を、ローカル GPU + Gemini 3.1 Pro Preview のハイブリッドで構築した
  • ローカルだけだと (1) safety-tuning で punchline がぼやける (2) reviewer が editorial signal を出せない の 2 点で publishable に届かない。フロンティアを sub-agent 化して両方解決
  • フロンティア reviewer の指摘は素直に取れ。Whisper で WAV 単体を確認しただけだと最終 mp4 の audio truncate を見逃す
  • サブエージェントの token 経済は、メイン agent の context を膨らませないので 1 動画 $0.03-0.05 で済む
  • 差分イテレーション (--regen-scenes) で 30 秒ループを回せると、Gemini 指摘 → 修正 → 再評価のサイクルが分刻みで回る

完成した動画 (再掲):

@youtube

ローカル実装は llm_server/pipeline_multi.py。社内向け資料的に、開発過程の細かい findings は別途 docs/MULTI_SCENE_COMEDY_FINDINGS_2026-05-12.md に蓄積している。