記事から YouTube Shorts を量産する — 記事ID 1つで MP4 が出てくるパイプラインを組んだ

記事から YouTube Shorts を量産する

21,874 本の旅行記事を持つブログがある。読まれないわけじゃないが、動画側の導線が無い から、SNS 世代のトラフィックが取れていない。

手作業で Shorts を作れば 1 本 30 分はかかる。月 30 本で 15 時間。量産できない。

今日作ったのは、記事 ID を 1 つ渡すと MP4 が出てきて YouTube Shorts に予約投稿され、元記事にも埋め戻される パイプラインだ。1 本あたりの人間作業は「どの記事からやるか選ぶ」だけ。


全体構成

記事 ID (WP)


┌─────────────────────────────────────────────┐
│ shorts_scenario_gen.py                       │
│   WP REST → 画像サンプル DL                  │
│   → Gemini 2.5 Flash (multimodal, 画像も見る) │
│   → scenario JSON (hook-first 8 シーン構成)   │
└─────────────────────────────────────────────┘


┌─────────────────────────────────────────────┐
│ shorts_gen.py                                │
│   scenario 読み込み                          │
│   → 記事画像 cache (wp.py)                   │
│   → Irodori-TTS (local MPS, Python subprocess)│
│   → PIL で字幕 PNG 合成                      │
│   → ffmpeg で 9:16 縦動画 + Ken Burns + BGM  │
└─────────────────────────────────────────────┘


┌─────────────────────────────────────────────┐
│ shorts_upload.py                             │
│   YT Data API v3 resumable upload            │
│   → --schedule で publishAt 指定予約投稿     │
└─────────────────────────────────────────────┘


┌─────────────────────────────────────────────┐
│ shorts_embed.py                              │
│   WP REST PUT で元記事本文に iframe 埋め込み  │
│   → 冪等 (HTML コメントマーカーで位置固定)   │
└─────────────────────────────────────────────┘

全部 Python 単体スクリプト。分業ラインではなく直列パイプライン。


決断 1:hook-first 構成

YouTube Shorts は最初の 2 秒で勝負が決まる。TikTok / YT のリサーチでは 「3 秒以内に視聴者の 70% が離脱」する。

だから scenario の最初 3 シーンを必ず「hook」とし、各シーン 2.4〜2.8 秒で以下の型に押し込む:

  • Scene 0: counter-intuitive な対比数字(例:「新幹線なら 4 時間 / 鈍行で 18 時間」)
  • Scene 1: 行き先・制約・結果の stacked text(「東京 → 山口 / 往復 2,000km / 切符 ¥12,050」)
  • Scene 2: 動画の約束(promise)(「5 日間の全部 / 45 秒で見せる」)

この 3 段を LLM に「必ず生成させる」ために、scenario_gen のシステムプロンプトで is_hook: true フラグを構造化出力させている。LLM には JSON schema を渡して 出力形式を物理的に縛る


決断 2:画像は Gemini に「実際に見せる」

最初は記事本文だけを LLM に渡して scenario を書かせた。結果、こういう事故が起きた:

caption: "{hl:三ノ宮駅}を通過"  ← 記事に三ノ宮の記述なし
image: article_idx=22             ← 実際の画像は「名鉄一宮駅」

LLM はテキストだけで「東京→山口の鉄道旅だから神戸三宮を通るはず」と経路推論して、実際には別の駅(愛知・一宮)の画像に誤ったキャプションを付けた。

解決策はシンプル:Gemini に画像を実際に見せる

for idx in sampled_idx:
    path = wp.cache_image(post["images"][idx])
    image_parts.append(types.Part.from_text(text=f"画像 index={idx}:"))
    image_parts.append(types.Part.from_bytes(
        data=path.read_bytes(),
        mime_type="image/jpeg",
    ))

contents = [prompt_text, *image_parts]
resp = client.models.generate_content(model="gemini-2.5-flash", contents=contents, ...)

これで駅看板を Gemini が読んで、「一宮駅に到着」と正しくキャプションを書くようになった。

画像コスト は ~$0.005/記事(30 枚 × ~258 tokens)。誤読を消せるなら安い。


決断 3:画像生成はしない(基本的に)

当初は hook 3 シーンを Nano Banana 2 で生成する計画だった。理由:抽象的でスタイリッシュなビジュアル+キャラ導入。

しかし計算すると 1 本 $0.12(3 枚 × $0.04)。月 100 本で $12。金額は小さいが、21,874 記事分のフル量産を視野に入れると 1 本単価を削る意味がある

何より、旅行記事の場合 「本物の旅写真」に勝る視覚素材は無い。生成画像の”綺麗な誰の旅でもない風景”より、ブログ投稿者本人が撮った不完全な車窓写真の方が旅情を伝える。

方針:hook も本編も記事画像を使う。多モーダル Gemini が「代表的・絵力のある 3 枚」を hook に選ぶよう prompt で指示。

画像生成モジュール (image_gen.py) は残した — 将来「記事に絵が無い / キャラ立ち絵を挿したい」時に opt-in で呼べる。


決断 4:TTS はローカル(Irodori)

クラウド TTS には抗いがたい魅力がある — 起動不要、安定、高音質。

しかし 月 100 本 × 1 本 9 文 = 月 900 文の synthesis。OpenAI / ElevenLabs 系で組むと月 $10〜30。年 $120〜360。

一方 Irodori-TTS (Aratako/Irodori-TTS-500M-v2-VoiceDesign) は MIT 系 + ローカル推論 (PyTorch MPS 対応)。音質は「キャラクター TTS」レベルで充分、声質を日本語自由記述 prompt で指定できる

# shorts/tts.py
subprocess.run([
    ".venv/bin/python", "infer.py",
    "--hf-checkpoint", "Aratako/Irodori-TTS-500M-v2-VoiceDesign",
    "--text", fix_pronunciation(text),
    "--caption", "落ち着いた中年男性の声で、旅番組のナレーションのように…",
    "--no-ref",
    "--model-device", "mps", "--codec-device", "mps",
    "--model-precision", "fp32",
    "--output-wav", str(raw),
], cwd=str(IRODORI_ROOT), check=True)

M2 Mac MPS で 1 文 ~42 秒。9 文 = 6〜7 分。夜 3 本回せば翌朝には 3 本の原音声ができている。クラウド費ゼロ。


決断 5:発音辞書を挟む

Irodori は漢字を意味分解で読む癖がある。具体的には:

  • 鈍行 → 「どんゆき」(正: どんこう。鈍+行 を「鈍く行く」と分解)
  • 小倉 → 「おぐら」(正: こくら。京都・小倉山と衝突)
  • 下関 → 「しもせき」(正: しものせき)

固有名詞は音声入力テキストだけカタカナに置換し、視覚的キャプションは漢字のまま保持する二重管理にした:

PRONUNCIATION_FIXES: dict[str, str] = {
    "鈍行": "どんこう",
    "小倉": "コクラ",
    "下関": "シモノセキ",
    "米子": "ヨナゴ",
    ...
}

def fix_pronunciation(text: str) -> str:
    for kanji, kana in PRONUNCIATION_FIXES.items():
        text = text.replace(kanji, kana)
    return text

プロジェクト固有の辞書だが、日本語 TTS を本番運用する以上このレイヤーは不可避。新しい誤読パターンを見つけるたびに追加していく育成型辞書。


決断 6:Apple systemPink × マーカーハイライト

字幕のデザインで試行錯誤した。初期は「黄×黒」(Alex Hormozi 系)。高コントラストで TikTok バズ動画が全員使うやつ。

しかし読者層を考えたとき、旅ブログは 20〜40代女性メイン。黄×黒は男性攻め感が強すぎる。

Apple iOS の systemColors を試し、systemPink (#FF2D55) × charcoal outline (#1C1C1E) × off-white fill (#F5F5F7) に落ちた。

もう一段、ハイライト方法も変えた。従来の「文字色を変える」から、「下半分に蛍光ペン風の帯を敷く」 方式へ:

# shorts/video.py
if is_hl and seg_text.strip():
    # 蛍光ペンマーカー矩形を文字の背面に描画
    marker_y0 = y + int(line_h * marker_coverage)
    draw.rectangle(
        [seg_x - pad, marker_y0, seg_x + seg_w + pad, y + line_h + 4],
        fill=highlight,
    )
# その後に文字を重ねる
draw.text((x, y), seg_text, font=pil_font, fill=fill,
          stroke_width=outline_w, stroke_fill=outline)

これは既存の兄弟サイト (tameteko.com) の CSS em { background: linear-gradient(transparent 60%, accent 60%); } と同じ発想。サイト全体で視覚言語を統一する意図。


決断 7:予約投稿を第一級機能にする

YT のアルゴリズムは 投稿頻度の一貫性 をチャンネル健全度シグナルとして見ている。「気まぐれに 3 本投げて止まる」より「毎日 21:00 に 1 本」のほうが好まれる。

でも人間は 21:00 に毎日 PC の前に座れない。だから upload CLI に --schedule を第一級で組み込んだ:

python shorts_upload.py video.mp4 --scenario ... --schedule "tomorrow 21:00"

shorthand で tomorrow 21:00 / today 22:30 / +3h / RFC3339 いずれも受け付ける。内部では status.publishAt + privacyStatus=private を組み合わせて YT に渡す。YT 側で指定時刻になると自動で public に切り替わる。

これで月曜の昼に週 5 本分をバッチ投入して、毎日 21:00 に順次公開されていく、という運用が可能になった。


決断 8:投稿と同時に元記事へ埋め戻す

Shorts だけ作っても、元のブログ記事に導線が無ければ片道通行になる。

shorts_embed.py は YT 投稿後に元記事の WP 本文冒頭に iframe を挿入する:

ANCHOR_BEGIN = "<!-- BEGIN: tabinolog-shorts-embed -->"
ANCHOR_END = "<!-- END: tabinolog-shorts-embed -->"

def _inject_or_replace(content, block):
    if anchor_re.search(content):
        return anchor_re.sub(block, content), "replace"  # 冪等
    return block + "\n\n" + content, "inject"

HTML コメントマーカーで位置を固定しておくので、再実行しても重複しない(動画 ID の差し替えだけ可能)。

これで YouTube → 記事 の往復導線が完成する:

  • YouTube から記事へ:動画 outro の ”→ tabinolog.com” + 説明欄の URL
  • 記事から YouTube へ:本文冒頭の縦長プレーヤー

相互のトラフィックが回遊すると、YT の視聴時間指標 × ブログの滞在時間指標の両方が底上げされる。


数字

初期バッチ(青春 18 きっぷシリーズ 5 本)を実測:

項目1 本あたり
Gemini multimodal call (scenario gen)~$0.005
Nano Banana 2 (hook 3 枚 — 今回は OFF)~$0.12
Irodori-TTS ローカル$0
ffmpeg 動画合成$0
YT Data API upload$0 (無料枠)
実費合計$0.005
レンダー所要時間 (M2 Mac MPS)~7 分
人間作業時間~30 秒 (記事 ID 指定)

月 30 本で $0.15。年 $1.80


学び

音声・画像・字幕は “別モジュール” に割る

最初は全部 shorts_gen.py に書いていたが、途中で tts.py / video.py / wp.py / image_gen.py に割った。 理由は、それぞれに独立したバグと進化の方向があるから。音声は誤読辞書の育成、字幕はスタイル調整、動画合成は ffmpeg の深淵。割っておくと、1 つに集中するときに他が巻き添え食わない。

LLM には schema を固く渡す

scenario 生成の品質は、最終的に**「Gemini が守る JSON schema」に全部落ちる**。曖昧な prompt で「よろしく」と書くと、日によってシーン数がブレる / captionに絵文字が混ざる / outroが毎回違う文言になる、などの”性格のブレ” が出る。

スキーマを構造化出力として渡し、is_hook: trueimage_index: int のように型を固定しておくと、後段のパイプラインが LLM 出力に 100% 依存できるようになる。

予約投稿は”運用の義務”を”設定の選択”に変換する

「毎日 21:00 投稿」は人間の義務として見ると重い。 --schedule として CLI option で渡せると、単なる設定の選択になる。 量産パイプラインの生死を分けるのは、この義務 → 設定 の変換を徹底することかもしれない。


再利用可能な部分

この構成は tabinolog に固有ではない。以下のパーツは他のブログでも流用可能:

  • shorts/scenario_gen.py — WP 記事 → Gemini multimodal → scenario JSON
  • shorts/video.py — 字幕合成+ ffmpeg 9:16 コンポーザ
  • shorts/tts.py — Irodori subprocess ラッパー+発音辞書
  • shorts_upload.py — YT Data API v3 投稿(予約対応)
  • shorts_embed.py — WP に iframe 埋め戻し(冪等)

WP サイトを持っていて、YT Shorts に出したい人には横展開できる。


次の課題

  • Retention 実測:投稿した Day1 の 24 時間後データを見て、hook-first 構成が機能しているか検証
  • 関連 Shorts 自動埋め込み:タグマッチで「同じシリーズ」の Shorts を他記事にも展開
  • キャラクター活用:今回は NT Media のキャラ (aiko/sa-tan) をあえて使わず「男性ナレ+記事写真」のベース形に絞った。登録者が育ってから段階投入する予定
  • サムネ自動生成:Shorts ではサムネ CTR の影響は小さいが、動画の最初のフレームをサムネ化する機能は入れておきたい
  • GCS API 連携:低 PV × 画像豊富な記事を自動スコアリングして「量産候補リスト」を吐くバッチ

量産の力学は、「作る速度」より「人間の関与を減らす速度」 で決まる。7 分でレンダーできても、指示出しに 15 分かかれば量産できない。

パイプラインが組み上がった今、人間はもう「どの記事を次に映像化するか」選ぶだけでいい。残りの 99% は機械が組み立てる。

次の飯時間に、もう 1 本できている