やりたかったこと
架空の人物が話している動画を、顔写真1枚と音声から作りたかった。 1人が話すだけでなく、2人が交互に話し、話している人に画面が切り替わる動画も作る。
音声はElevenLabsの日本語ボイスで作り、顔はOpenAIの画像生成で作った。残る課題は「顔写真と音声から、口が音声に合って動く動画を作る」部分で、これをリップシンク動画生成と呼ぶ。 もう1つ条件があった。素材を外部の動画生成サービスに送らず、手元のMac(M3 Pro・メモリ36GB)で完結させることだ。 本記事はその実装記録で、どこまでできて、どこで詰まったかを数字付きで残す。
リップシンク動画の2つの方式
写真から話す動画を作るOSSは多いが、仕組みで見ると大きく2つに分かれる。どちらを選ぶかで、処理時間が数十倍変わる。
| 変形する方式 | 描き直す方式 | |
|---|---|---|
| 代表例 | SadTalker、Wav2Lip | InfiniteTalk、FLOAT |
| 仕組み | 元の写真の口元や顔を、音声に合わせて少しずつ変形させる | ノイズから始めて、写真と音声を手がかりに全コマを描き直す(拡散モデル) |
| たとえ | 紙人形の口をパクパク動かす | 画家が1コマずつ描く |
| M3 Proでの速さ | 動画1秒あたり約20秒 | 動画1秒あたり約15分 |
| 見た目 | 頭や体がほとんど動かず、口元だけ浮いて見えやすい | 頭の揺れ・まばたき・表情まで自然 |
最初はSadTalkerで作った。数分で仕上がるが、顔の輪郭から外が静止画のままで、一目で合成だと分かる。 そこで、描き直す方式のInfiniteTalkに切り替えた。
Macで動かす構成
InfiniteTalkの公式実装はNVIDIA製GPUを前提にしている。Macで動かすには、ComfyUIとkijai氏のComfyUI-WanVideoWrapperを使い、MPS(MacのGPUをPyTorchから使う仕組み)で実行する。 使ったモデルは次のとおりで、すべてHugging Faceから取得した。
| 役割 | ファイル | サイズ |
|---|---|---|
| 本体(画像から動画) | Wan2.1-I2V-14B-480P のGGUF Q4_K_M(city96) | 11.3GB |
| 口の動きの制御 | InfiniteTalk Single のGGUF Q4_K_M(Kijai) | 1.4GB |
| 音声の特徴抽出 | chinese-wav2vec2-base fp16 | 0.2GB |
| ステップ数の削減 | lightx2v の蒸留LoRA(rank64) | 約0.7GB |
| 文章・画像の特徴抽出、映像への変換 | umt5-xxl(テキスト)、CLIP Vision H、Wan2.1 VAE | 合計約13GB |
140億パラメータのモデルは、そのままだと16ビットで約33GBある。36GBのMacでは載らないので、4ビット相当に量子化したGGUF版(11.3GB)を使う。 蒸留LoRAは、本来なら数十回くり返すノイズ除去を4〜6回で済ませるためのものだ。これがないと、Macでは現実的な時間で終わらない。
処理全体の流れは次のようになる。
flowchart LR
T["台本"] --> V["ElevenLabsで<br/>発話ごとに音声化"]
V --> S["話者ごとに<br/>区間を切り出す"]
F["顔画像"] --> I
S --> I["InfiniteTalkで<br/>区間ごとに動画化"]
I --> C["区間をつなぎ<br/>名前ラベルを重ねる"]
C --> M["全員の音声を<br/>重ねて書き出し"]
2人の会話は、話者が切り替わるたびに区間を分けて作る。 ComfyUIはHTTP APIで操作できるので、ノード構成をJSONで組み立てて投げるスクリプトを書いた。主な設定は次のとおりだ。
# WanVideoSampler の主な設定(蒸留LoRAを使う前提)
sampler = {
"steps": 4, # 6 だと品質がやや上がり、時間は1.5倍
"cfg": 1.0, # 蒸留LoRAはCFGなしで使う
"shift": 11.0,
"scheduler": "dpm++_sde",
"seed": 2,
}
# WanVideoImageToVideoMultiTalk
i2v = {
"width": 832, "height": 480,
"frame_window_size": 81, # 1回に作るのは81コマ(25fpsで3.24秒)
"motion_frame": 9, # 前の区間の末尾9コマを引き継いで、つなぎ目をなめらかにする
"tiled_vae": True, # 映像への変換を分割して、メモリ不足で落ちるのを防ぐ
"force_offload": True,
"mode": "infinitetalk",
}セットアップでつまずいた点
- transformers 5系では音声の特徴が取れない:wav2vec2の
hidden_statesがNoneになり、'NoneType' object is not subscriptableで止まる。transformers<5に固定して解決した - 映像への変換(VAEデコード)で落ちる:最初の試行は、70分かけたノイズ除去の後に、エラーを残さずプロセスが消えた。
tiled_vaeとforce_offloadを有効にしたら通った - 実行中はAPIが応答しなくなる:生成中のComfyUIは
/historyへの問い合わせに応答しないことがある。クライアント側はタイムアウトを例外として握りつぶし、ポーリングを続ける作りにした
実測:動画1秒に約15分
動くことは確認できた。品質も十分で、頭の揺れ・まばたき・口の形が音声に合い、顔も途中で別人にならない。問題は時間だった。
| 条件 | 生成した長さ | かかった時間 |
|---|---|---|
| 832×480・6ステップ | 3.24秒(81コマ) | 約70分 |
| 832×480・4ステップ | 3.24秒(1区間) | 約43分(ノイズ除去39分+映像への変換4分) |
| 832×480・4ステップ | 13.2秒 | 3時間31分 |
| 832×480・4ステップ | 19.5秒 | 約4.5〜5時間 |
4ステップで、動画1秒あたり約15分かかる。30秒の動画なら約8時間、2分半の動画なら約37時間になる。 日中は作業にならないので、launchdで21時に起動して朝7時に止まるバッチにした。区間ごとに結果を保存するので、途中で止まっても翌晩に続きから再開できる。
なぜここまで遅いのか
重さの原因は3つ重なっている。
- 計算量そのものが大きい:1回のノイズ除去ごとに、81コマ分の画面全体を140億パラメータで計算する。 さらに、顔や動きがコマ間でずれないよう、各コマをほかのすべてのコマと見比べる(アテンション)。比べる組み合わせはコマ数と解像度の2乗で増える。
- MacのGPUは、この種の計算でNVIDIAに大きく劣る:AI向けの計算速度はH100の数十分の1程度だ。FlashAttentionやSageAttentionのような高速化もほとんどがCUDA専用で、Macでは使えない。
- メモリが足りない:量子化しても動作中は20GBを超え、ほかのアプリと36GBを分け合う。あふれた9〜16GBはSSDへのスワップになり、さらに遅くなる。GGUFは計算のたびに重みを元の形に戻すので、その手間も上乗せされる。
ハマりどころ:映像が真っ黒になる
一晩かけて完成した30秒の動画を見ると、20秒あたりから先が真っ黒だった。 ComfyUIのログに残っていたのは、書き出し時の次の警告1行だけだった。
videohelpersuite/nodes.py:101: RuntimeWarning: invalid value encountered in cast
return tensor_to_int(tensor, 8).astype(np.uint8)これは、計算の途中で値がNaN(非数)になったことを示している。NaNを8ビットの画素値に変換すると0付近になり、映像は真っ黒になる。 Wan2.1はbf16(指数部が広く、大きな値でもあふれにくい16ビット形式)で学習されたモデルだ。fp16で動かすと値の範囲が狭いため、途中の値があふれやすい。
対策として精度をbf16に変えると、真っ黒だった区間の1つは正常に出るようになった。ところが別の区間は、bf16でもfp16でも真っ黒になった。 しかも2回の出力はバイト単位で同じファイルだった。精度の問題だけではなく、入力の音声と乱数シードの組み合わせで毎回同じようにNaNになっていると考えられる。 シードを変えて試す途中で作業を止めたため、根本原因はまだ確かめられていない。
原因が分からなくても、検出はできる。ffmpegで一定間隔のコマの平均輝度を測り、真っ黒なコマ(YUVで輝度16)が多ければ失敗とみなす。 生成直後にこれを実行し、黒ければ精度を切り替えて自動で作り直すようにした。
# 25コマごとに平均輝度(YAVG)を出し、先頭を除いて20%超が16付近なら真っ黒と判定
ffmpeg -i out.mp4 \
-vf "select='not(mod(n,25))',signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-" \
-f null - 2>/dev/null |
grep YAVG | tail -n +2 |
awk -F= '{n++; if ($2 < 20) b++} END {exit !(n > 0 && b / n > 0.2)}' && echo "black"長時間ジョブを回すときの注意
処理時間が数時間単位になると、品質とは別の問題が出てくる。
- バッテリー切れによるスリープなら続きから動く:
caffeinate -i -sでスリープを防いでいても、-sはAC電源につないでいないと効かない。 バッテリーでスリープしたときはプロセスが生き残り、充電後に続きから再開した。電源が完全に落ちたときは、その区間を最初からやり直すことになった - ロックの残骸:二重起動を防ぐロックディレクトリが、電源断でそのまま残り、次の起動が「実行中」と判断して何もせず終わった。ロックにPIDを書き、そのプロセスが生きていなければ取り直すようにした
- 途中結果を区間単位で保存する:音声と顔画像のハッシュをキーに区間ごとの動画を保存しておくと、失敗した区間だけを作り直せる。 最終的に、ある区間がどうしても作れなかった動画は、別の動画で同じ人物が話している部分を切り出してつなぎ、完成させた
クラウドGPUならどうなるか
本数を増やすなら、GPUを時間単位で借りるほうが現実的だ。以下は執筆時点の目安で、価格は変わるので各社のページで確認してほしい。
| M3 Pro 36GB | クラウドのL40S | クラウドのH100 | |
|---|---|---|---|
| 30秒の動画1本 | 約8時間 | 1時間前後(推定) | 30分前後(推定) |
| 料金の目安 | 電気代のみ | 1時間あたり約1〜3ドル | 1時間あたり約2〜3.5ドル |
| NVIDIA向けの高速化 | 使えない | 使える | 使える |
| 生成中のMac | 重くて実質使えない | 普段どおり使える | 普段どおり使える |
クラウドの推定値は、公式が示すNVIDIA環境での速度から筆者が見積もったもので、実測ではない。 モデルのダウンロード(約26GB)と環境構築にも30分〜1時間かかり、その時間も課金される。それでも30秒の動画2本で数百円〜1,000円程度に収まる見込みだ。 インスタンスの止め忘れには注意したい。H100を1日つけっぱなしにすると、それだけで数十ドルかかる。
学び
- 「写真から話す動画」は、変形させる方式と描き直す方式でコストが数十倍違う。自然さを求めると後者になり、GPU次第で処理時間が決まる
- Macでも動かすことはできる。量子化、蒸留LoRA、タイル分割の3つがそろえば、36GBで14Bの動画モデルが動く。ただし動画1秒に15分かかるので、試しに数本作る用途までと考えたほうがよい
- MPSでの生成は、映像が真っ黒になってもエラーにならない。生成物を機械的に検査する工程を最初から組み込んでおく
- 長時間ジョブは、途中結果を細かく保存し、電源・スリープ・ロックの残骸まで想定して組む
理解度チェック
SadTalkerからInfiniteTalkに切り替えたとき、処理時間が数十倍に増えた主な理由はどれか。
キーボード: 1〜4 で選択、Enter で回答