TL;DR
- スライドPNGと読み上げ原稿から、VRMアバターが画面の隅で口パク解説する動画を自動生成しています
- ゲームエンジン(Unity / Unreal)も、中間表現スキーマも、演出をLLMに考えさせる仕組みも、全部使っていません
- 構成は、音声合成でVisemeのタイムラインを作り、ブラウザ内でスライドの上にVRMを合成し、フレームをJPEGで撮ってffmpegで結合する、だけです
- 1本(17枚・約25分)で総処理およそ1.2時間。夜間バッチ前提の設計にしたことで、品質の作り方が変わりました
- リアルタイム配信は以前破綻していました。破綻していたのは人間のリアルタイム入力への追従で、事前レンダリングではその経路が消えます
- 長時間バッチで一番効いた設計は、機能ではなくクラッシュ耐性でした。落ちても失われないこと
- そして品質ゲートで一番危なかったのは、検査が全部失敗しても「指摘0件」=成功に見えていたことです
観測期間: 2026-07 〜 2026-08
はじめに
自社のキャラクター(VRMモデル)に解説を喋らせた動画を、記事とスライドから量産しています。
この記事は、その仕組みの設計判断の話です。「何を作ったか」より「何を作らないことにしたか」のほうが情報量があると思うので、そこを中心に書きます。
前提として、これは配信システムではありません。事前に生成してファイルにする、バッチ処理です。そこが設計の分岐点でした。
1. 作らなかったもの
先に、検討して却下したものを書きます。
ゲームエンジンを使わない
VRMを動かす、と言えばまずUnityが出てきます。VRMの仕様自体がUnity文化圏から出ているので自然です。
使いませんでした。理由は3つです。
- CIに載らない。ヘッドレスで安定して回す構成のコストが高い
- 成果物の再現性が読めない。同じ入力から同じフレームが出る保証を自分で作る必要がある
- 既存の資産が使えない。スライドはブラウザでレンダリングしているので、そこに合成できるほうが素直
代わりに three-vrm(WebGL)をヘッドレスのChromiumで回しました。スライドがすでにHTMLとPNGなので、同じ場所で合成できるのが決定的でした。
中間表現のスキーマを作らない
最初は「発話・表情・カメラの指示を持つ中間表現(IR)を定義して、そこから描画する」という設計を考えました。
やめました。IRを定義すると、IRを埋める仕事が発生します。 そして今回埋めたいのは口の開閉と瞬きだけです。表情の演出やカメラワークは要件に入っていません。
要件に無い自由度のためにスキーマを作ると、そのスキーマを維持するコストが恒久的に乗ります。
演出をLLMに考えさせない
「原稿を読んで、ここで首を傾げる、ここで笑う、という演出をLLMに出させる」も検討しました。
やめました。理由は、確認できないからです。生成された演出が原稿に対して適切かを、人が全部見て判定するなら自動化の意味がありません。見ないなら、変な動きをした動画が公開されます。
代わりに、瞬きと髪の揺れと頭の微動を決定論的なパラメータで動かしています。原稿の内容とは無関係に、常に同じルールで動きます。地味ですが、破綻しません。
2. 全体の構成
やっていることは単純です。
読み上げ原稿md(スライドごとに区切る) スライドPNG
│ │
▼ 文に分割して音声合成 ▼ 静的サーバから配信
wav + Viseme(口形)のタイムライン │
│ │
▼ ブラウザ内の合成ページ(GPU headless)
│ 背景 = スライドの img
│ 前面 = VRMを描画したcanvas(画面の隅にクリップ)
│
▼ フレームごとに口・瞬き・頭の状態を設定して、要素まるごとJPEGで撮影
▼ ffmpeg: JPEG連番 + wav → セグメントmp4
▼ concat → 完成
ポイントは「ブラウザの中で合成を完結させて、結果をまるごと撮る」ことです。
透過PNGを吐いて後段で合成する構成にすると、合成のパラメータ(位置・スケール・クリップ)が2箇所に分かれます。ブラウザ内でCSSで合成してしまえば、見えているものがそのまま出力です。プレビューと本番が同じコードになります。
アバターは透過canvasに描いて、CSSで幅を縮小し、上からクリップして頭から肩だけを出しています。
3. リアルタイムを捨てたら再現性が手に入った
以前、リアルタイムで喋らせる仕組みを試して破綻していました。
その経緯を検討したときに気づいたことがあります。破綻していたのは、人間のリアルタイム入力(タイピング)への追従でした。 入力が来てから音声と口が動くまでの遅延が詰められない、という問題です。
事前レンダリングでは、その経路が構造的に消えます。入力は原稿ファイルで、時間軸は自分で決められます。だから同じ問題は再発しません。
これは要件を疑ったら設計が単純になった例だと思っています。「アバターが喋る」の要件に、リアルタイム性は含まれていませんでした。
そして捨てた見返りが大きかった。
リアルタイムを捨てる
→ 1フレームあたりの時間予算が消える
→ 品質のためにいくらでも時間を使える
→ 「同じ入力から同じ出力」を保証できる
1本1.2時間かかりますが、夜に回せばよいので誰も待っていません。時間予算を捨てると、品質の設計が楽になります。
4. 長時間バッチで一番効いたのはクラッシュ耐性
1本1.2時間の処理を作ると、機能より先に必要になるものがあります。
落ちても失われないこと です。
1時間走って最後で落ちて、次の実行が最初からやり直しになる構成だと、実質的に開発できません。1回の試行が1時間だと、1日に数回しか試せない。
入れたものを挙げます。
LLM呼び出しのディスクキャッシュ
読みの点検にLLM(Bedrock上のHaiku)を使っています。プロンプト単位でハッシュをキーにしてディスクにキャッシュしました。
落ちても、再実行はキャッシュヒットで即再開できる。
LLM の費用も二重に払わない。
「レジューム可能」を機能として設計するのではなく、キャッシュで結果的にそうなる形にしました。処理の途中状態を管理する仕組みを作るより、単純で壊れにくいです。
一時的な失敗のリトライ
2箇所に入れました。
-
音声合成エンジンへのHTTP: 長時間ループ中の一時切断(
ECONNRESET)で指数バックオフのリトライ。ローカルのサービスでも、数千回叩けば落ちます - LLM API: スロットリングとサービス側の一時エラーで指数バックオフ。失敗レスポンスはキャッシュしない(これを間違えるとエラーが永続化します)
直接実行のガード
地味ですが、これが無いと事故ります。
オーケストレータのスクリプトは、
直接実行されたときだけ main() を走らせる。
このガードが無いと、他のスクリプトからimportした瞬間に動画の全ビルドが暴発します。実際に踏みました。1.2時間のバッチが意図せず始まると、GPUが占有されて他の作業が止まります。
エントリポイントのガードは、処理が重いほど重要になります。
5. 品質ゲートで一番危なかったもの
ここが、この記事で一番書きたいところです。
読みの点検工程は、スライド1枚ずつLLMに問い合わせます。以前の実装は、あるスライドの呼び出しが失敗しても、そのスライドを飛ばして続行していました。
クラウドの認証セッションが切れて、79枚すべての呼び出しが失敗したとき、レポートは「指摘0件」でした。
成功に見えました。
「全文を点検して問題なし」
「1文も点検できなかった」
この2つが、同じ出力になっていた。
対策は単純です。
未検査のスライドを数えて、レポートの冒頭に警告を出す。
1枚でも検査できていなければ、非ゼロの終了コードで終わる。
そして規則にしたのはこれです。
「指摘0件」を通し検査の証拠として使ってよいのは、
終了コードが0のときだけ。
品質ゲートを作るときに一般化できると思います。検査器が「問題なし」を返したとき、それが「検査して問題がなかった」なのか「検査できなかった」なのかを、出力から区別できるようにする。
区別できないと、壊れた検査器が最も静かな成功として振る舞います。エラーで落ちる検査器は気づけますが、静かに0件を返す検査器は気づけません。
6. レイアウトの検査も機械でやる
もう1つ、目視をやめた工程があります。
アバターは画面の隅に置きます。スライドのテーマはその領域を空けている想定ですが、アバターの背のほうが高いことがあります。すると本文の上に頭が重なります。
これを人がプレビューで確認するのをやめました。
スライドPNGの、アバターが乗る矩形の中に
本文の画素があるかを数える。
ぶつかる枚と、逃がし方の候補を出力する。
やっているのは画素を数えるだけです。しかし、17枚や79枚のスライドを1枚ずつプレビューで見るより確実で速い。目視は枚数に比例して漏れます。
矩形のサイズと位置、許容量はパラメータにしました。テーマやアバターのサイズが変われば数字が変わるので、判定を固定値で埋め込まないほうがよいです。
7. 企画をまたぐものと、企画ごとのものを分ける
運用が続くと、どのデータがどこに属するかが問題になります。整理したのはこの線引きです。
| 置き場所 | 何を置くか |
|---|---|
| エンジン側(コード) | スクリプト全部、企画をまたいで蓄積する読み辞書、LLMのキャッシュ |
| 企画側 | 読み上げ原稿、その企画固有の読み修正・アクセント指定、スライドPNG、生成物 |
切り替えは環境変数1つ(企画ディレクトリのパス)で、各スクリプトがそこからパスを解決します。
読み辞書をエンジン側に置いたのが効いています。 社名や一般的な専門用語の読みは、企画が変わっても同じです。企画ごとに持つと、同じ語を何度も登録し直すことになります。
逆に、文脈依存の読み修正は企画側です。文が違えば意味が違うので、使い回せません。
LLMのキャッシュもエンジン側です。キーがプロンプトのハッシュなので、企画をまたいで共有しても衝突しません。
まとめ
- 作らないものを決めるのが設計の中心だった。 ゲームエンジン、中間表現スキーマ、LLM演出家は全部却下した
- 要件に無い自由度のためにスキーマを作ると、維持コストが恒久的に乗る
- 確認できない自動化は入れない。 演出の自動生成は、生成物の妥当性を人が判定できないので採らなかった
- ブラウザ内で合成を完結させると、プレビューと本番が同じコードになる
- リアルタイム性は要件に入っていなかった。捨てたら再現性が手に入った
- 時間予算を捨てると、品質の設計が楽になる(夜間バッチ前提)
- 長時間バッチで最初に必要なのは機能ではなくクラッシュ耐性
- レジューム機構を作るより、ディスクキャッシュで結果的にレジュームできる形にする
- 一時的な失敗はリトライする。失敗レスポンスはキャッシュしない
- 重い処理のスクリプトには、直接実行のガードを必ず入れる
- 「指摘0件」が「問題なし」なのか「検査できなかった」なのかを、出力から区別できるようにする
- 壊れた検査器は、最も静かな成功として振る舞う
- 目視は枚数に比例して漏れる。レイアウトの衝突も画素を数えて機械で判定する
- 企画をまたぐ資産(辞書・キャッシュ)と企画固有のデータを、置き場所で分ける
この仕組みで作った動画は、公開するときに使用している音声ライブラリのクレジットを必ず入れています。声は他者が作った資産で、キャラクターの絵とは別の出自を持ちます。「同じ存在」に見えるものが、権利の上では別という話は技術の記事の範囲を超えるので、note 側に「誰の声で、誰の名前で語るのか」として書きました。
量産できるようになった結論は「たくさん出せる」ではなく、「出したものの責任の所在を明示する必要が増えた」だと思っています。
この記事は 七夕研究所 Qiita Organization の記事です。開発と運用で実際に踏んだものを順次書いています。