前回の記事で、Claude Codeの解説ガイドを3つの形式(Webサイト・スライド動画・PDF)で作った話を書きました。
今回はその裏側 — 「どうやって作ったか」の話です。
やりたかったのは、1つのMarkdownソースから複数の形式に展開するコンテンツパイプラインの構築。書くのは1回、出力は複数。これが実用レベルで動くのか、3日間かけて検証しました。
最終的に出来上がったパイプライン
Markdown(VSCode で執筆)
├→ MkDocs Material → Webサイト ✅
│ └→ edge-tts → ページ単位の音声MP3 ✅
├→ Marp → スライドPNG ✅
│ ├→ edge-tts + FFmpeg → ナレーション付き動画MP4 ✅
│ └→ Pillow → スライドPDF ✅
└→ md-to-pdf → 書籍風PDF ❌(Puppeteerタイムアウトで断念)
5つのパイプラインのうち4つが確立。1つは断念。それぞれの検証過程を書いていきます。
Phase 1: MkDocs Material → Webサイト
最初に着手したのがWebサイトです。MkDocs Materialは Markdownをそのまま美しいドキュメントサイトに変換してくれるツールで、全6部・25ページのガイドサイトを構築しました。
ここは比較的スムーズに進みましたが、2つハマりポイントがありました。
Mermaid.jsのテキスト色が制御できない。block-beta ダイアグラムで color:#fff を指定してもテキストに反映されない。さらにMkDocs MaterialはMermaidをShadow DOM内で描画するため、外部CSSも届かない。結局、Mermaidを諦めてHTML+インラインCSSで直接描画しました。
デプロイ先を間違えていたのに気づかなかった。scp で更新をアップロードしても、サイトに反映されない。ブラウザキャッシュか?ハードリロードしてもダメ。Xserverの「Xアクセラレータ」を無効にしてもダメ。
「やはりハードリロードで更新されませんね」「うーん、?v=1でもダメでした…」— シークレットウィンドウ、.htaccess設置、サーバーキャッシュ削除、Xアクセラレータ OFF。思いつく対策を全部試して、5回連続で「ダメでした」。Claude Codeも「Xアクセラレータが原因です」と確信を持って誤診し、僕にサーバー設定を変えさせた。
13分後にようやく判明した原因は、デプロイパスが根本的に間違っていたこと。xs896849.xsrv.jp と kaleidofuture.com は同じサーバー上の別ディレクトリで、前者にアップロードし続けていたんですよね。パスを直したら「あー、更新されました」。キャッシュだと思い込んで、関係ないところを直し続けていた。
AIも人間も「直前に変えたもの」を原因だと思い込む。インフラの問題がコンテンツ制作を中断させる典型例です。
Phase 2: edge-tts → 音声読み上げ
全25ページにMP3音声を付けました。ここが最も「人間の耳」に依存したフェーズです。
ただ、そもそもの出発点で設計ミスがありました。音声を先に作り始めたものの、設置場所を決めていなかった。「作成してもらった音声なんですが、それぞれ公開ドキュメントのどこに設置する予定ですか?」と確認したら、「設置場所は決まっていません」。Web版はページ単位に分割すれば各ページ上部に置ける。でも動画版は「スライドに書いてない内容をナレーションで話しているので、このままでは使えない」— 結局、両方の構成を見直すことになりました。作る前に「どこに置くか」を決めるべきだった。
「Florianの声質が好きなんだけどな…」
音声エンジンはedge-tts。音声モデルの選択で、最初の分岐がありました。
ja-JP-KeitaNeural(日本語ネイティブの男性音声)に切り替えれば、漢字の発音問題はほぼ解決します。技術的には正しい選択です。でも僕は de-DE-FlorianMultilingualNeural(ドイツ語ベースの多言語対応音声)の声質の方が好きだった。
この「好き」の一言が、その後の70語超のREADING_MAP辞書と、edge-ttsのSSMLモンキーパッチという大量のワークアラウンドを生みました。技術的に「正しい」選択より、主観的な「好き」を優先した結果です。
中国語読み問題 → 4回の失敗 → 解決
Florianで日本語を読ませると、漢字だけの箇所で中国語のような発音になる。「背景」「課題」「提案」がことごとく日本語に聞こえない。
ここからのデバッグが長かった。
1. SSMLの言語指定タグで囲む → タグがそのまま読み上げられた(ファイルサイズが2倍に膨らんで気づいた)
2. <lang> タグに変更 → Azure側で完全拒否(NoAudioReceived)
3. SSMLの構造を変えて再挑戦 → また拒否
4. edge-ttsのソースコードを読んで、内部で英語の言語設定がハードコードされていると判明 → 日本語にモンキーパッチ
ここでようやく日本語として読まれるようになった…と思ったら、READING_MAPに登録した ひらがなが今度はおかしい。「背景」を「はいけい」に変換したら、「ワイケイ」と読まれた。
「おぉ、はいけいと読まれましたね」— カタカナ「ハイケイ」に変えたら、ようやく正しく発音された。ひらがなよりカタカナの方がFlorianとの相性が良い、という発見は完全に予想外でした。
Florianの珍読みコレクション
中国語読みだけではありません。Florianは日本語を独創的に解釈してくれました。
– 「上位互換的」→「ゴカンマト」(正しくは「ゴカンテキ」)
– 「3ヶ月後」→「ミコゼゴ」
– 「第1世代」→「ダイイッセイダイ」(正しくは「ダイイチセダイ」)
さらに数字の読みも厄介でした。見出しの「2.4」を「ツーポイントフォー」と英語読みする。日本語の番号読み辞書を作ったら、今度は「3.1」が「サンテンイッ」になる。大番号の「1」は「イッ(テン)」、小番号の「1」は「(テン)イチ」— 日本語話者なら無意識に使い分けているルールですが、これをmajor_numsとminor_numsに分離して辞書化する必要がありました。
READING_MAPは最終的に70語を超えましたが、それでもなお新しいページを聴くたびに珍読みが見つかる。辞書の完成はありません。
READING_MAPの落とし穴: 置換順序
辞書が育っていく過程で、別の問題が発生しました。Git のエントリが GitHub より先にあると、GitHub → ギットHub に壊れる。Claude が CLAUDE.md を壊す。
単純な文字列置換なので、長い文字列を先に置く必要がある。Pythonのdictは挿入順を保持するため、CLAUDE.md → Claude Code → claude.ai → Claude のように、長い順に並べることで解決しました。シンプルだけど気づかないとハマる。
「/」をどう読ませるか
「Claude Code / Cowork」を「クロードコード コワーク」と続けて読んでしまう問題。
Claude Codeの提案: / を と に置換。つまり「クロードコードとコワーク」。
僕の反応: 「うーん…すべての / が『と』になるわけではないので、間で表現するほうが良いかな」
最終的に、/ の位置に300msのサイレンスを挿入しました。「と」ではなく「間」。この判断はAIには出せない。日本語の自然さを知っている人間の感覚です。
同じように、em-dash — には500ms、矢印 → には200ms、連続するカギ括弧の間には200msのポーズを設定。全部聴きながら決めました。
テキスト前処理: 「何を消して何を残すか」
Markdownを音声用テキストに変換する前処理パイプラインも、想像以上に複雑になりました。
正規表現の実行順序がまず問題になった。リンク構文 text → text の変換が先に走ると、その後のパターンがマッチしなくなる。処理順序を入れ替えて1083字から615字まで大幅に削れました。
コードブロックの除去も一筋縄ではいかない。言語指定のあるコードブロック(python等)は実コードなので消す。でも言語指定なしのブロックは使い方の例文だったりするので中身を残す。この「何を消して何を残すか」の判断が繰り返し発生しました。
絵文字やナビゲーション要素も音声には不要です。ロボットアイコンが読み上げられたり、「今すぐ始める →」「基礎から読む →」のようなナビリンクが音声に入ったり。admonition記法(!!! warning)やMkDocsの画像キャプションも除外対象。
「視覚的には意味があるが、聴覚的には不要」な要素が大量にある。こうして前処理パイプラインは24段階まで育っていきました。
25ページ分の聴き返し
1ページずつ音声を生成して、聴いて、問題を指摘して、修正して、再生成。25ページでこれを繰り返すと、1ページあたり3〜8往復のやり取りが発生しました。
途中でAIが生成したデータの捏造を発見する場面もありました。Session 1で書いたガイド本文に「日本のAI導入率が29%から55%に上昇」というNRIのデータが含まれていたんですが、音声レビュー中にソースリンクを確認したところ、このデータは存在しなかった。AIがもっともらしく作り上げた数字でした。
一方で、「利用量のリセットについて、ちょっとファクトチェックしておきたいですね」と確認した別の箇所は正確でした。音声レビューを通じてファクトチェックの習慣が自然に生まれ、ソースリンク付きの信頼性の高いドキュメントになった。音声化のプロセスが、意図せず品質管理の機会になったのは面白い副産物でした。
Phase 3: Marp → スライド + FFmpeg → 動画
25ページのWebガイドを24枚のスライドに要約し、各スライドにナレーションを付け、トランジションとBGMを加えて1本の動画にする。
初めて動画が完成したときは「正直驚きました!今でも十分な説明動画ですね!」と声が出ました。Markdownから始めて、スライド画像化、音声生成、FFmpeg結合 — 全部Claude Codeが組み上げたパイプラインが一発で動いて、9分の解説動画が出てきた。
ただ、ここからの微調整がPhase 2以上に大変でした。
トランジション: 22種類を目で見て選ぶ
FFmpegのxfadeフィルタは22種類のトランジションをサポートしています。Claude Codeに全種類を各スライドに適用したプレビュー動画を作ってもらい、1本ずつ確認しました。
結果、2種類に絞り込み。通常スライド間はfadeblack、パートタイトル(セクション区切り)はwipeleft。派手なものより落ち着いたものの方が、解説動画には合います。
「トランジション開始と読み上げが同じ感じがします」
ここで一番苦労しました。意図を伝えるのに3回かかった。
最初の実装は、トランジション開始と同時にナレーションが始まるもの。僕の感想: 「やはり、トランジション開始と読み上げが同じ感じがしますね。一旦1.5秒待ちに指定してもらえますか?」
Claude Codeの修正: ナレーション終了後に1.5秒の無音を挿入 → その後トランジション+ナレーション同時開始。つまり「ナレーション → 1.5秒沈黙 → トランジション中にもう次の読み上げが始まる」という流れで、待ちの位置が違った。意図通りになっていなかった。
2回目の説明: 「スライド1の音声読み終わり→トランジション開始→1.5秒の待ち→音声読み上げ開始。こうなるべきです。」
Claude Codeの修正: トランジション時間を1.5秒に変更。
3回目の説明: 「これ、トランジションの時間を1.5秒にしていますね。ではなく、各スライドの音声読み上げを1.5秒待ってもらうという意図でした。」
ようやく意図が伝わりました。最終的には0.5秒のトランジション + 0.5秒の待ち時間に落ち着いた。「意図通りになりましたね!1.5秒は長かったんで、0.5秒にしましょうか」
この「0.5秒の待ち」の有無で、動画の印象は劇的に変わります。数値だけ見ると些細な差ですが、聴いてみると1.5秒は確実に長い。こういう微調整は反復試聴でしか決められません。
yuv444p: 「エラーで開けないようです…」
生成した動画がWindows標準プレイヤーで再生できない。スクリーンショットを送ったらエラーコード0x80004005。
原因は、FFmpegのxfadeフィルタがピクセルフォーマットをyuv444pに静かに変更してしまうこと。format=yuv420p フィルタを最終段に追加して解決。知らないとハマるFFmpegの罠です。
グリッドレイアウト: 「順番が1,5,2,6…って」
ベストプラクティスのスライドで、CSSグリッドのgrid-auto-flowがデフォルトで列優先のため、2列レイアウトで番号が上下に蛇行していました。さらに1枚に詰め込みすぎて下が切れ、2枚に分割が必要に。
一見小さなデザイン修正ですが、スライドが23から24枚に増えたことで、ナレーションのインデックス、トランジションの設定、動画全体の再構成が連鎖的に発生しました。パイプラインの上流を変えると下流すべてに波及する — これがパイプライン構築の難しさです。
BGM: 「ちょっとだけBの方がイメージに合うかな」
3候補の曲を動画に載せてA/B/C比較しました。音量はW3C WCAG 2.0推奨基準に従い、ナレーションより20dB低く設定。
選定理由は「ちょっとだけBの方がイメージに合うかな、ぐらいの差ですね」。技術的な根拠はなく、完全に主観。でもこの「ぐらいの差」が、最終的な視聴体験を決めるんですよね。
Phase 4: PDF
当初は md-to-pdf(Puppeteer経由のPDF生成)を試みましたが、ProtocolErrorでタイムアウト。
「それって、動画のスライドと同じですよね?作成した画像をつなげて」— この一言で方針が変わりました。MarpのスライドPNGをPillowで結合してPDFにする。3行のPythonコードで3.6MBのPDFが生成されました。
複雑な仕組みを作ろうとしていたところを、シンプルな発想で解決。これも「人間の判断」でした。
パイプライン全体から見えたこと
「ソースは1つ」は半分だけ本当
起点はMarkdown1つです。スライド原稿もこのMarkdownから要約して作っている。ただし、25ページの詳細ガイドを24枚のスライドに凝縮する工程は自動変換ではなく、人間が判断する「要約」のステップが必要でした。
制作中に気づいた根本的な問題もありました。「スライドに書いてない内容をナレーションで話しているので、このままでは使えない」。スライドの内容とナレーションが一致しないと、視聴者は混乱する。
つまり「1つのMarkdownソースから全形式に展開」という理想は、Webサイト+音声の範囲では自動パイプラインが回る。一方でスライド+動画は、Markdownから派生するものの要約・再構成という手動ステップが入る。完全な自動展開にはならなかった。
音声チェックがコンテンツを改善する
音声レビュー中に、ガイド本文の改善アイデアが浮かぶことがありました。「このコスト実践として、『他のAIツールを活用する』もあったらいいかなと思いました」— 聴いているうちに「あれが足りない」と気づく。
Claude Codeにリサーチさせて、Gemini CLI無料枠やChatGPTとの併用パターンを追加しました。パイプラインの下流(音声チェック)が上流(ガイド本文)にフィードバックする双方向プロセスになっていた。
仕上げの段階では、ジャーナルで実装済みのダウンロード保護(controlsList="nodownload noplaybackrate")を25ファイルのドキュメントに横展開する作業もありました。過去のプロジェクトの知見が別のプロジェクトで再利用できる — これもパイプラインを「仕組み」として作る利点です。
自動化できる部分とできない部分
| 自動化できた | 人間の判断が必要だった |
|---|---|
| Markdown → HTML変換 | 情報設計(6部構成の決定) |
| テキスト → 音声変換 | 発音の聴き返し・辞書チューニング |
| スライド → 動画結合 | トランジション・BGMの選定 |
| フィルタチェーン構築 | タイミングの微調整(0.5秒 vs 1.5秒) |
| デプロイスクリプト | デプロイパスの誤り検出 |
パターン: コード生成・変換・結合はAIに任せられるが、「品質の最終判断」は人間。特に「耳」と「目」を使う判断は自動化が難しい。そしてもう1つ — 「意図を正確に伝えること」自体がスキル。トランジションのタイミング問題で3回説明が必要だったように、「こうしてほしい」を曖昧さなく伝えるのは簡単ではありません。
再利用性
このパイプラインは、テーマを変えても使い回せます。次に別のテーマで初学者向け解説を作るときは:
1. Markdownを書く
2. mkdocs build + scp でWebサイト公開
3. generate_audio.py で音声生成(READING_MAPだけ更新)
4. スライド要約を書いて generate_video.py で動画化
2回目以降は、仕組みを作る時間がゼロになる分、コンテンツ制作に集中できます。今回は「パイプラインを作ること自体」が目的だったので3日かかりましたが、次回からは大幅に短縮できるはずです。
まとめ
Markdownから動画まで、コンテンツパイプラインを構築・検証しました。
– 4つのパイプラインが確立: Web + 音声 + スライド動画 + スライドPDF
– 1つは断念: md-to-pdf(書籍風PDF)は Puppeteerの問題で未解決
– 「Single Source」は半分だけ実現: Web+音声はMarkdown1本で自動展開できるが、スライドはMarkdownからの要約・再構成が必要
– 自動化の限界: 変換・結合はAI、品質判断は人間。「声の好み」がアーキテクチャを決め、「0.5秒の間」が体験品質を決め、「ちょっとだけB」が選曲を決めた
使用したツール: MkDocs Material / Marp / edge-tts / FFmpeg (xfade) / Pillow / pydub
次回は、このプロジェクト全体を通して「何割が人間の仕事だったか」を横断的に分析します。
この記事が参考になったら
Share