動画完成、ついに"待ち"ゼロへ ─ 20時間50分→6時間40分、14時間短縮の記録
はじめに
競走馬の予想スコアを算出する自作Python予測システム(V5py / V58py)を個人開発しつつ、その週次の成果をYouTubeチャンネル「71才隠居ジジィの競馬新聞」で動画にして公開しています。
前回の記事「スライド制作、ついに手修正ゼロへ ─ 3時間→1.5時間→5秒の記録」では、xlsxの予想結果表をSVGスライドへ自動合成する仕組みを仕上げ、動画1本分のスライド制作から「手で直す」工程をゼロにしました。
今回はその続きです。スライドが完成した「その先」──PNGへの変換、ナレーション音声との対応付け、mp4への書き出しという、動画の「出口」を自動化した記録です。ここが片付いたことで、動画1本が完成するまでの時間が 20時間50分から6時間40分へ、約14時間短縮 されました。
今回作ったツール
| ツール | 役割 |
|---|---|
| svg_to_png_batch.py | 生成済みSVGを対象フォルダから検出し、PNGへ一括変換するバッチツール |
| build_mp4_real.py | スライドPNGとナレーションwavをJSON定義に従って結合し、mp4を書き出す |
メインの スライド画像加工.py 自体もこの期間にNo.53〜67まで、レイアウト安定化を含めた改修を重ねています。
14時間はどこで消えていたのか
自動化に着手する前、動画1本の完成までの流れはこうでした。
| 時刻 | 工程 |
|---|---|
| 10:10 | 正規出馬表公開、予想アプリbuild開始 |
| 11:00〜15:30 | build結果を元にスライド用資料作成(推しレース選定・馬柱データ取得・分析・台本作成・ナレーションwav作成・スライド34枚自動生成) |
| 15:30 | 完成したスライド34枚(svg)・ナレーションwav・台本などをAIに送付し、動画mp4作成を依頼 |
| 15:35〜21:35 | AIによる動画作成開始 → 数分後にChat容量制限に到達し6時間停止 |
| 21:35〜翌5:30 | 動画作成を再開するも数分後に再び制限に到達、再び6時間停止。そのまま就寝 |
| 翌5:30〜6:00 | 動画作成再開、mp4完成 |
| 6:00〜7:00 | 試聴・YouTube投稿準備・アップロード |
10:10のbuild開始から翌7:00の投稿完了まで、合計20時間50分。このうち大部分は、AIチャットに動画作成を依頼してから完成を待つ「拘束時間」で、しかも2度のChat容量制限で合計12時間が待機に消えていました。作業内容そのものより、「待つしかない」ことが一番のロスだったわけです。
これをsvg_to_png_batch.pyとbuild_mp4_real.pyによるローカル完結の自動処理に置き換えた結果が、こうなりました。
| 時刻 | 工程 |
|---|---|
| 10:10〜15:30 | (同じ) build〜スライド34枚自動生成まで |
| 15:30 | スライド34枚(svg)をpng変換 |
| 15:35 | 変換済みpng34枚・ナレーションwav・台本などをAIに送付 |
| 15:40〜15:45 | AIがslides_real.json(構成の設計図)を作成 |
| 15:50 | png34枚・wav・slides_real.jsonを作業フォルダにセットし、build_mp4_real.pyを実行 → 5分後にmp4完成 |
| 15:55 | 試聴 |
| 16:25〜16:50 | YouTube投稿準備・アップロード |
10:10から16:50まで、合計6時間40分。AIに依頼するのは「動画そのものの生成」ではなく「json設計図の作成」だけになり、実際の書き出しは手元のスクリプトが5分で終わらせます。Chat容量制限による待機はもう発生しません。
20時間50分 → 6時間40分(約14時間短縮)
この短縮を実現するまでに、実は地道な試行錯誤が何度もありました。以下、その中身です。
① SVG→PNG変換で「豆腐」が見えた日 ─ 誤診断とその訂正
png変換の自動化そのものは、長らく手作業でした。理由は単純で、Inkscapeで一括変換するとフォントが崩れる不具合があったからです。これをKritaのコマンドライン機能で自動化し、無事に動くようになった──はずでした。
ところが、ある朝こんな報告が届きます。
新たな問題が発生した。添付pyでsvg→pngに自動変換しているが例の結果検証スライドのテキストがおかしい。調べてくれ
最初に下した診断はこうでした。
「区分」列の絵文字(🟢BUY / 🟡SHOW / 🚫SKIP)が、Krita変換後のPNGでは黒枠の四角(いわゆる「豆腐」文字)に化けています。
指定フォントに絵文字のグリフが無いことが原因で、Kritaは独自のテキストエンジンを使っているためOS標準のフォント代替が効かない ── 理屈は通っていました。ところが座標を精密計算して該当セルを拡大してみると、そこは普通に円マークが描画されているだけでした。拡大しすぎた画像を粗い補間で見て、ノイズを「豆腐」だと誤読していたのです。誤診断だと認めて撤回しました。
本当の不具合は別の場所にありました。結果検証スライドの右側にある集計テキストが、本来5行に分かれるべきところ、1行に潰れて重なっていたのです。
SVGを覗くと、原因はテキスト要素に残っていたshape-insideというCSSプロパティでした。
<text style="shape-inside:url(#rect625829)">
<tspan x="1329" y="190">推しレース 5件中...</tspan>
<tspan x="1329" y="224"/>
<tspan x="1329" y="257">1着 1件 複勝的中...</tspan>
...
</text>
各tspanには本来ちゃんと個別のy座標が入っているのに、Kritaはshape-inside(SVG2の「シェイプ内フロー配置」)を解釈しようとして失敗し、全行を同じ位置に重ねて描画してしまっていました。
過去に同じような文字崩れでGIMPに助けられた記憶があったので、変換エンジンをGIMPのバッチモードに切り替えることにしました。
cmd = [
"gimp", "-i", "-d",
"-b", (
'(let* ((image (car (file-svg-load RUN-NONINTERACTIVE '
f'"{svg_path}" "{svg_path}" 90 1920 1080 0))) '
'(drawable (car (gimp-image-flatten image)))) '
f'(file-png-save RUN-NONINTERACTIVE image drawable "{png_path}" "{png_path}" 0 9 1 1 1 1 1) '
'(gimp-image-delete image))'
),
"-b", "(gimp-quit 0)",
]
GIMPはSVG読み込みにlibrsvgを使っていて、テキスト描画はPango+fontconfigというOS標準の仕組みに乗っています。shape-insideのような一部のSVG2機能に対応しておらず単に無視するため、結果的にtspan側の正しい座標がそのまま活きて、5行が正しく分離されました。
実行結果(GIMP版):
推しレース 5件中 4件 複勝的中 的中率 80%
1着 1件 複勝的中 的中率 20%
2着 2件 複勝的中 的中率 40%
3着 1件 複勝的中 的中率 20%
全レース 35件 22件 複勝 的中率 62.9%
教訓: 目に見える不具合ほど、見た目のインパクトが強い場所に引っ張られて誤診断しやすい。座標を計算で追い、独立したレンダラーで裏取りする地道な作業が、結局いちばん早い近道でした。
おまけ: WorksheetとWorkbookを取り違えるバグ
同じ時期、png変換までは通っても一括バッチ処理だけが失敗するという不具合もありました。
# 修正前(バッチ処理側)
stats_lines = M.build_kekka_stats_lines(wb["照合結果一覧"]) # ← シート1枚だけ渡している
Traceback (most recent call last):
File "batch_slide_auto.py", line 419, in main
stats_lines = M.build_kekka_stats_lines(wb["照合結果一覧"])
File "スライド画像加工.py", line 2873, in build_kekka_stats_lines
if "推しレースTOP5集計" not in wb.sheetnames:
AttributeError: 'Worksheet' object has no attribute 'sheetnames'
呼び出し先の関数はwb.sheetnames(ブック内の全シート名一覧)を見にいく実装に変わっていたのに、呼び出し元は相変わらず1枚のシート(Worksheet)だけを渡していました。単体メニューでは問題なく動いていたのは、そちらの呼び出し元だけは正しくブック全体を渡していたからです。
# 修正後
stats_lines = M.build_kekka_stats_lines(wb) # ブック全体を渡す
直すこと自体は1行ですが、「片方の呼び出し元だけ新しい仕様に追従できていない」という、複数の入口を持つコードにありがちな不整合でした。png変換が正しくなっても、その先のバッチ連携でつまずいては「出口の自動化」は完成しません。
② PNGとwavの対応付け ─「神経を使う」作業の正体
スライド(PNG)とナレーション音声(wav)を組み合わせてmp4を書き出す工程を自動化するにあたり、まずPNGとwavの対応にズレが見つかりました。「PNGが用意されていない区間」が3箇所、「2枚のPNGが同じ音声を取り合っている区間」が3箇所。ここは推測で埋めず、wavファイルに自動で付くプレビューテキストの中身を1つずつ確認して裏取りし、152個のクリップ全てを41枚のPNGに過不足なく割り当てました。
作業を終えた後、率直な感想が届きます。
ナレーション整理Noとスライドのする合わせに2時間半掛かった。しかも整理No抜け発生。これは何とかしないといけない。制限で止まるよりは良いけど私が疲れる(神経的に)
最初に提案したのは「PNGファイルの手動リネーム自体を無くす」案でした。しかしこれは的外れでした。
手動リネイムはあまり苦にならん。ナレーションwavを1個づつ再生しながらどのスライドが当てはまるかの確認するのに神経を使う
本当に負担だったのは「リネーム作業」ではなく「音声を聞き直してどのスライドか判断する確認作業」だったのです。負担の正体が分かれば解決策も変わります。台本を書く時点で、話者は「ここからこの馬の解説」「ここから予想表の再掲」という切れ目を自分で把握しているはず。そこにマーカー行を1つ入れておくだけで、後から聞き直す作業自体をなくせます。
本命は、クリスレジーナ、鮫島駿騎手、Bランクです。
(...本命の解説...)
◆スライド切替:対抗馬(フリーガー)◆
対抗は、フリーガー、西村淳騎手、Aランクです。
これは前回記事で紹介したマーカー方式(スライド背景SVGに専用の目印文字列を仕込み、それを検索して置き換える設計)と同じ発想を、ナレーション台本側にも展開したものです。「聞いて確認する」という神経を使う作業を、「台本を書くときにマーカーを1行足す」という一瞬の作業に置き換えました。
導入にあたっては、想像で進めることを許してもらえませんでした。
そんな不確実なことしないで、pyを確認しろ
実際にコードを確認すると、build_mp4_real.pyはslides_real.jsonという固定ファイル名を実行時のカレントディレクトリから相対パスで読む仕様で、JSON内の画像・音声パスもフォルダ無しの裸のファイル名でした。つまりPNG・wavとも実行フォルダ直下にフラットに置く必要がある、という運用上の制約が分かりました。これは後に、素材(mp4_task_folder/)と成果物(mp4_output/)を分離し、号の入れ替え時の消し忘れ事故を防ぐ設計につながっています。
マーカー行の考え方を単純化して表すと、こういう処理です。台本テキストを1行ずつ読み、マーカー(◆スライド切替:〜◆)が出た時点を「区切り」として記録し、その区切りごとにナレーションwavとスライドPNGを1対1で対応させます。
MARKER_PATTERN = re.compile(r"◆スライド切替:(.+?)◆")
def split_script_by_marker(script_lines):
"""台本をマーカー行で区切り、区切りごとのブロックに分割する"""
blocks = []
current = {"label": "オープニング", "lines": []}
for line in script_lines:
m = MARKER_PATTERN.match(line.strip())
if m:
blocks.append(current)
current = {"label": m.group(1), "lines": []}
else:
current["lines"].append(line)
blocks.append(current)
return blocks
この分割結果をそのままslides_real.jsonの「どのPNGとどのwavクリップを組にするか」の元情報として使えるようにしたことで、「wavを聞いてスライドを判断する」という神経を使う作業が、台本執筆時点のマーカー1行に置き換わりました。人間が後から音声を聞き直して判断していた情報を、台本を書く本人がいちばんよく知っている「執筆の瞬間」に前倒しした、という設計転換です。
教訓: 「大変そうに見える作業」を無くす提案と、「本人が本当に負担に感じている作業」を無くす提案は、別物になり得る。解決策を出す前に、負担の正体を聞くべきだった。負担の発生源(音声を聞き直す工程)そのものを潰さない限り、周辺をどう自動化しても本質的な軽減にはならない。
まとめ: 14時間の内訳と、数字にならない教訓
今回の短縮の内訳を整理すると、こうなります。
- AIによる動画生成の待ち時間(Chat容量制限による2回×6時間の停止含む)がほぼ丸ごと消えた ── これが14時間短縮の大部分を占める本丸でした。
- AIに依頼する内容を「動画そのものの生成」から「slides_real.json(設計図)の作成」に絞り込んだことで、AIの作業自体も15分程度に短縮。
- 実際の書き出しはローカルのbuild_mp4_real.pyが10秒で完了。待機による停止が原理的に発生しません。
数字に出ない収穫もいくつかありました。
- 見た目のインパクトに引っ張られない。派手に崩れて見える場所が、必ずしも本当の原因とは限らない。座標やログといった検証可能な材料で裏取りする。
-
複数の入口を持つコードは、片方だけ仕様追従漏れが起きる。
wb.sheetnamesのバグのように、単体メニューでは動いていても一括バッチ経由だと壊れるケースは、呼び出し元を横断して確認しないと見つからない。 - 解決策を出す前に、負担の正体を聞く。「大変そうに見える作業」と「本人が本当に負担な作業」はズレることがある。負担の発生源そのものを特定してから設計する。
- 推測で先に進まない。「たぶん合っているはず」でコードを書くと、必ず後で高くつく。これはpng変換の仕様確認でも同じでした。
前回記事の「20時間→3時間→5秒」でスライド制作からは手直しが一切なくなり、今回は動画の出口までを含めて「20時間50分→6時間40分」まで縮みました。個人開発は課題を1つ潰すと次の課題が見える、という循環がここでも続いています。次回は、チャットログ管理ツールの開発で足止めを食らった認証まわりの話を書く予定です。