デッキから動画へ(スライド + ナレーション → 自動再生mp4)
概要
N枚のスライド画像(またはPDF)とN個のスライドごとの音声クリップを1つのmp4に変換します。各スライドは、そのナレーションの長さに短いポーズを加えた時間だけ表示され、その後次のスライドにハードカットします。オーバーレイ:埋まっていくプログレスバー+残り秒数のカウントダウン(右下)と「N / M」形式のスライドカウンター(右上)。
1つのコマンド(スクリプトはこのスキルのscripts/ディレクトリにあります。フルパスで呼び出してください):
python3 <このスキルのディレクトリ>/scripts/build_video.py SLIDES AUDIO_DIR out.mp4
# SLIDES = slides.pdf(200dpiでラスタライズ)またはスライドごとの画像ディレクトリ
# (数字を考慮した順序付け:s2はs10の前。ゼロ埋めの名前を推奨)
# AUDIO_DIR = slide01.mp3, slide02.mp3, … — 数はスライド数と一致する必要があります
# オプション: --size 1920x1080 · --pad 0.7 · --no-bar · --no-counter · --bar-color HEX · --crf 20
# --highlights hl.json · --anim-fps 30 (「要素のハイライト」を参照;
# scripts/make_highlights.py が hl.json を生成します)
成功すると、スライドごとに1行(そのスライドの長さ。すでにポーズを含んでいます)が出力され、最後に duration matches segment sum と表示されます(そうでなければゼロ以外の終了コードで終了します)。
入力の準備
- スライド:pptxから始める場合は、LibreOfficeではなく実際のPowerPointからラスタライズしてください。 pptxを実際のPowerPointでPDFにエクスポートし(office-renderスキルがmacOSでこれを行います)、そのPDFをスクリプトに渡します。LibreOfficeはフォントを置き換え、レイアウトを再フローするため、動画がデッキ作成者の見ているものと一致しなくなります。200 dpiで、1080pでも密度の高いスライドテキストを鮮明に保てます。(すでにスライドごとの画像がある場合は、それらを直接使用し、この手順はスキップしてください。)
- 音声:スライドごとに1つのクリップを
slideNN.mp3という名前で用意します。 クリップが elevenlabs-tts のgenerate_tts.py(narrating-pptx が使用)から来ている場合、それらはすでにこの正確な形式になっています。それを再利用することで、pptxと動画のサウンドが同一になります。 - ナレーションの速度が調整されている場合(例:
ffmpeg -filter:a atempo=1.1)、調整済みのファイルから動画を構築します。
オーバーレイがffmpegではなくPILでベイクされる理由(血の代償)
drawboxでtベースの幅式を使用すると、いくつかのffmpegビルドでフレーム0から完全なバーが描画されます — 式はフレームごとではなく、初期化時に一度だけ評価されます。この失敗は沈黙しています。動画は正常にエンコードされ、バーがまったく動かないだけです。同じビルドではdrawtext式はフレームごとに更新されるため、動作するカウントダウンの隣で壊れたバーを見逃しやすくなります。- Homebrewのffmpegボトル(2026年以降)はlibfreetypeなしでビルドされています —
No such filter: 'drawtext'— そしてkegバージョン(ffmpeg@7、ffmpeg@8)も同様です。インストールサイクルを費やしてこれを発見しないでください。もし他の理由でフルビルドが必要な場合、uv tool install static-ffmpegが1つを提供します(スクリプトは自動的にフォールバックします)。
そのため、スクリプトはPILでフレームをベイク(バーの塗りつぶし、カウントダウン、カウンター)し、-r 30 でエンコードされた画像シーケンスをffmpegに供給します。決定論的、フォント制御可能、そして30fpsのストリームをフィルタリングするよりもエンコードコストが低くなります。1秒あたり1フレームがデフォルトで、スライド上で何も動いていない限りは適切です — 秒単位の更新はカウントダウンにとって自然に読めます。--highlights を伴うスライドは、次のセクションの理由により、代わりに30fpsでベイクされます。
スクリプトがすでに行うエンコードの選択(元に戻さないでください)
| 選択 | 理由 |
|---|---|
apad=pad_dur=PAD + -t seg_dur | 正確なテールパディング。ナイーブな -shortest は発話後のポーズをカットします |
yuv420p + 偶数寸法 | そうしないとQuickTime/WhatsApp/ブラウザで再生できません |
-tune stillimage, CRF 20 | 静的スライドはテキストのチラつきなくうまく圧縮されます |
concat demuxer + -c copy | 同一エンコードされたセグメントのロスレス結合 |
-movflags +faststart | ダウンロードが完了する前に動画がストリーミングされます |
検証 — フレームが証明するまでオーバーレイは壊れていると想定する
スクリプトは最終的な長さ ≈ セグメントの合計をアサートします。オーバーレイは目視での確認が必要です:
ffmpeg -y -ss T1 -i out.mp4 -frames:v 1 a.jpg # あるスライドの早い時間
ffmpeg -y -ss T2 -i out.mp4 -frames:v 1 b.jpg # 同じスライドの遅い時間
スクリプトが出力したスライドごとの長さからT1/T2を選択します(スライド境界を累積合計します。これらにはポーズが含まれていることに注意してください)。両方の画像を確認します。b.jpg ではバーが目に見えて埋まっており、カウントダウンが低くなっている必要があります。そして、新しいスライドごとにリセットされる必要があります。早いタイムスタンプでバーが完全に見える = 静的バグ — この正確なチェックがそもそもそれを捕らえたものです。音声をヘッドレスで検証することはできません。その旨を伝え、聞いたと主張しないでください。
ナレーションに合わせて要素をハイライトする
「左側に…中央に…右側に」と言うナレーションは、音声解説トラックのように読めます(セルフプレゼンテーションデッキを参照)。修正方法は、口頭でのポインターを削除し、代わりに要素をグローさせることです。ただし、タイミングが音声から得られる場合に限ります。
音声がマスターであり、アニメーションはそれに従います。 マークに合わせてナレーションを書いたり、フレーズがいつ発話されるかを推定したりしないでください。通常どおりクリップを生成し、ElevenLabsの強制アライメント(POST /v1/forced-alignment、音声+トランスクリプト → 文字ごとの start/end。elevenlabs-ttsの scripts/align_narration.py で実行)を使用して、各要素が実際に発話されたタイミングを読み取り、そこからウィンドウを導出します。後でクリップを再録音し、再アライメントすると自動的に修正されます。
- スクリプト内の位置にアンカーし、キーワードにはアンカーしないでください。 単一のキーワードは、それが2回出現するとすぐに壊れます(「タグ」は1つのスライドに4回出現します)。ナレーションが各要素に移る場所を、スクリプトからコピーした短いフレーズで、話された順序でマークし、前のマッチの後でのみ各フレーズを検索します。これが
make_highlights.pyの行うことです。スクリプトの前半に繰り返される単語があると、それをキャプチャできません。(結合されたスクリプトへの要素ごとのセグメントオフセットを保持することも機能します。) - アライメント前に音声タグをすべて削除してください — すべて。
[confident]は実行され、発話されません。これをアライナーに送信すると、音声内の単語を探し、後続のすべてのオフセットを引きずります。Eleven v4タグは自由形式([Pause, dry amusement])なので、括弧内のものをすべて削除します(elevenlabs-ttsのアライナーが行うように)。同じ削除された文字列に対してオフセットを計算します。 - 返された文字数がテキスト長と等しいことを確認し、
lossを監視します(正常な英語17クリップデッキでは0.04〜0.10、正常なヘブライ語26クリップデッキでは0.59〜0.77。デッキ内の明確な外れ値がシグナルであり、絶対的なしきい値ではありません)。不一致は、そのスライドのすべてのウィンドウが間違っていることを意味します。
scripts/make_highlights.py を使用して --highlights JSONを構築します。3つの入力から:マーカー(スライドごと、ナレーションが各領域に移るフレーズを含む各領域)、ボックス(スライドごと、領域 → [x, y, w, h])、およびalignment/ファイル:
python3 <このスキルのディレクトリ>/scripts/make_highlights.py markers.json boxes.json narration/alignment/ hl.json \
--size 1920x1080 [--inches 13.333] # --inches: ボックスはスライドインチ単位、この幅のページ
# markers.json: {"09": [["chart", "Last quarter"], ["callout", "The real problem"], ["_end", "Now, to"]],
# "12": {"marks": [["a", "First"], ["b", "Second"]], "dim": "black", "colour": "#E07A5F"}}
領域のウィンドウは、そのフレーズから次のマーカーのフレーズまで実行されます(_end は別のウィンドウを開かずに閉じます。そうでなければ、最後のウィンドウは発話の終わりまで実行されます)。開始は0.12秒早くなります。1.4秒未満のウィンドウは警告とともにスキップされます。0.5秒のフェードインとフェードアウトでは何も表示されないからです。フレーズがスクリプトにない場合、領域にボックスがない場合、ボックスがフレームからはみ出る場合、またはアライメントファイルがスクリプトと一致しない場合、スクリプトは何も書き込まずに停止します。ボックスが端から19ピクセル以内にある場合、グローがクリップされる可能性があるため警告します。
結果のJSONは、ゼロ埋めされたスライド番号をキーとし、ボックスは出力ピクセル単位です:
{"09": [{"card": "chart", "box": [87, 468, 549, 374], "colour": "#B45309",
"dim": "white", "start": 13.6, "end": 22.9}]}
box はデッキの独自のレイアウトコード(インチ→ピクセル)から取得し、レンダリングを目視で確認して取得しないでください。カードの形状は、それを描いたビルダーですでに正確です。dim は明るいスライドでは white、暗いスライドでは black です。暗いスライドでもグローの色を確認してください(ダークプラムのグローが、ほぼ黒のクロージングスライドで消えて見えなくなりました)。領域は1つのスライドで複数回ハイライトされる場合があります。ファイルに存在しないスライドは、通常どおり1fpsでベイクされます。
アライメントされれば、字幕は自動的に得られます。 同じ単語のタイミングで、2回目の文字起こしパスなしで .srt が作成されます:
python3 <このスキルのディレクトリ>/scripts/make_srt.py narration/alignment/ narration/audio/ out.srt --pad 0.7
--pad は動画が構築されたパッドと一致する必要があります。一致しない場合、キューのタイミングがスライドごとにさらにずれます。
字幕を提供する3つの方法。宛先がどれを選択するかを決定します。
| 形式 | 方法 | 視聴者が得るもの |
|---|---|---|
サイドカー .srt | mp4の隣にファイルを配置 | 本物のプレイヤーで両方を開いた場合のみ字幕表示 |
| ソフトトラック | -c copy -c:s mov_text -disposition:s:0 default — ロスレスmux、約12KB、数秒 | プレイヤー依存。CCコントロールが存在する必要がある |
| 焼き付け | libassで再エンコード | 常に表示、どこでも、オフにできない |
デフォルトはサイドカーまたはソフトトラック — どちらも可逆的で、画像に影響を与えません。字幕コントロールがない場所で動画が再生される場合のみ焼き付けます。 SlackやTeamsスレッドのインラインプレイヤーがそれを強制するケースです。そこですぐに再生ボタンを押した視聴者は、他の2つの形式からは何も得られません。
字幕の焼き付け
ffmpeg -i deck.srt /tmp/subs.ass # 最初に変換。force_style を渡さないでください
# … /tmp/subs.ass の Style: 行を編集(下記参照)。その後:
<static-ffmpeg> -i master.mp4 -vf "ass=/tmp/subs.ass" \
-c:v libx264 -crf 20 -preset medium -tune stillimage -pix_fmt yuv420p \
-c:a copy -sn -movflags +faststart out.mp4
それぞれがサイクルを消費する4つのこと:
- Homebrewのffmpegにはlibassがありません —
drawtext/libfreetypeと同じギャップで、その兆候は誤解を招きます。No option name near '<filename>'という、フィルターグラフ解析エラー(No such filterではない)が発生するため、引用バグのように見え、すでに正しかったエスケープの書き直しに時間を費やすことになります。ffmpeg -filters | grep -E ' (ass|subtitles) 'で確認し、static-ffmpegビルドを使用してください。 .assファイルにスタイルを設定します。force_styleをフィルター文字列に決して詰め込まないでください。 そのカンマはフィルターセパレーターとして読み取られ、バックスラッシュエスケープは、機能する前に2回失敗するほど脆弱です。- ASSスタイルフィールドは、
Format:ヘッダーでマッピングし、決して位置を数えてマッピングしないでください。 1つずれると、Alignment=0(無効)が書き込まれ、MarginVが設定されず、libassは文句を言わずにレンダリングします。 - 焼き付け前のマスターから焼き付け、そのマスターを保持してください。 すでに焼き付けられたファイルからスタイルを変更すると、2世代目の劣化が重なり、かつ古いテキストが永久に焼き付けられます。
配置 — 帯またはオーバーレイ。デッキが決定します。好みではありません。
| 方法 | コスト | |
|---|---|---|
| スライド下の帯 | pad=1920:1200:0:0:black 次に ass=、帯内のMarginV | スライド上の何も覆われない。出力が16:9でなくなる |
| オーバーレイ(おなじみのVLCルック) | ass= のみ。下中央揃え、1080pで太字約48px、太い黒の輪郭、MarginV〜40 | クラシックで自己完結型。ただし、デッキが下部ストリップに配置するものは何でも覆う |
オーバーレイは、ほとんどの人が「字幕」と言うときに意味するものです。テキスト密度の高いデッキでは、コミットする前にフレームを抽出して確認してください。フッターのキャプション、凡例、プログレスバーの上に配置されます。
1fpsはスムーズさだけでなく、同期の限界でもあります。 デフォルトのベイクは1秒あたり1フレームであるため、ハイライトは整数秒でのみ変更できます。指示する単語から最大1秒ずれる可能性があり、壊れているように見えます。アニメーション化されたスライドには約30fpsが必要です。
30fpsは、フレームをキャッシュすれば低コストです。 60秒のスライドは1,800フレームですが、そのほぼすべてがピクセル単位で同一です。グローはフェード中にのみ移動します。各フレームをその視覚状態にキー付けし、重複をハードリンクします:
key = (active_element, round(fade_alpha, 2), int(t)) # int(t) = バーのステップ
if key in cache: os.link(cache[key], path); continue
スライドあたり約1,800フレームではなく、120〜200の実際の合成処理で済み、余分なディスクも必要ありません。ただし、最初にプログレスバーを整数秒に量子化します — 継続的に動くバーはすべてのフレームを一意にし、キャッシュを完全に無効にします。(1fpsではすでにそのように動作していたため、何も失われません。)
暗いスライドでは「dim」を反転します。 非アクティブな要素を半透明の白で洗い流すと、明るいスライドではそれらを後退させ、暗いスライドでは明るくします。dimカラーはデッキごとではなく、スライドごとに指定します。
速すぎる発話はアニメーション化しないでください。 コミットする前に発話時間を測定します。4つのリスト項目がそれぞれ約1.2秒でまくしたてられている場合、0.5秒のフェードインとフェードアウトの後、グローは約0.2秒しか残らず、ストロボ効果になります。各項目にナレーション内で独自のビートを与えるか(通常は改善。一息で言うリストはあらゆるデッキで最も密度の高いものです)、スライドを静的なままにします。
よくある間違い
| 間違い | 結果 | 修正 |
|---|---|---|
ffmpeg drawbox を t でアニメーション化する | 静的バー。バーは決して埋まらない | PILベイクを使用する。drawtext でも可能だがバーは不可 |
| スライドごとに別々のffmpeg呼び出しを使用する | 各パス間の不整合、結合時の再エンコード、不正確な末尾 | スクリプトに任せる |
非yuv420pピクセルフォーマット | 多くのプレイヤーで再生されない | 明示的に yuv420p を強制 |
-shortest で終了する(apadの代わり) | 発話後のポーズがカットされる | apad=pad_dur=PAD + -t seg_dur を使用 |
drawtext でフォントパスを推測する | フォントが見つからない、バグのあるエンコード | PILがシステムフォントを管理する |
同期に -itsoffset を使用する | ストリーム間でffmpegの内部タイムスタンプが競合するリスク | ベイクの前に音声ファイルをトリミングする |
トラブルシューティング
| 症状 | 原因と修正 |
|---|---|
duration mismatch(ゼロ以外の終了コード、スクリプトの最後で報告) | セグメントの合計が最終的な長さと一致しません。どのセグメントが原因かをデバッグするには、ffprobe を各ファイルで個別に実行します。ファイルが切り詰められていないか確認します。AUDIO_DIR にスライドよりも多くのファイルがないか確認します。 |
| 音声が途切れる、またはクリックノイズが入る | 元の音声ファイルが破損している、またはサンプルレートが一貫していません。ffmpeg -i で各ファイルを確認します。必要に応じて、ffmpeg -i input.wav -ar 44100 -ac 1 output.mp3 で再サンプリングします。 |
| 字幕が表示されない(サイドカーまたはソフトトラック) | プレイヤーが字幕をサポートし、有効にする必要があります。VLCで確認します。SlackやTeamsのインラインプレイヤーはサイドカーもソフトトラックもサポートしません。焼き付けが必要な場合です。 |
| 字幕がずれている | --pad が動画の構築時に使用されたものと一致していません。正しいパッドで .srt を再生成します。 |
ffmpeg: command not found | ffmpegがインストールされていないか、PATHにありません。python3 <scripts>/build_video.py を再度実行するか、uv tool install static-ffmpeg を試してください。 |
| 動画が再生されない、またはコーデックエラーが表示される | -pix_fmt yuv420p が欠落しているか、寸法が奇数です。すべての出力が偶数であることを確認してください。 |