記事から 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: true や image_index: int のように型を固定しておくと、後段のパイプラインが LLM 出力に 100% 依存できるようになる。
予約投稿は”運用の義務”を”設定の選択”に変換する
「毎日 21:00 投稿」は人間の義務として見ると重い。
--schedule として CLI option で渡せると、単なる設定の選択になる。
量産パイプラインの生死を分けるのは、この義務 → 設定 の変換を徹底することかもしれない。
再利用可能な部分
この構成は tabinolog に固有ではない。以下のパーツは他のブログでも流用可能:
shorts/scenario_gen.py— WP 記事 → Gemini multimodal → scenario JSONshorts/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 本できている。