やりたかったこと

架空の人物が話している動画を、顔写真1枚と音声から作りたかった。 1人が話すだけでなく、2人が交互に話し、話している人に画面が切り替わる動画も作る。

音声はElevenLabsの日本語ボイスで作り、顔はOpenAIの画像生成で作った。残る課題は「顔写真と音声から、口が音声に合って動く動画を作る」部分で、これをリップシンク動画生成と呼ぶ。 もう1つ条件があった。素材を外部の動画生成サービスに送らず、手元のMac(M3 Pro・メモリ36GB)で完結させることだ。 本記事はその実装記録で、どこまでできて、どこで詰まったかを数字付きで残す。

リップシンク動画の2つの方式

写真から話す動画を作るOSSは多いが、仕組みで見ると大きく2つに分かれる。どちらを選ぶかで、処理時間が数十倍変わる。

変形する方式描き直す方式
代表例SadTalker、Wav2LipInfiniteTalk、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 fp160.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. 計算量そのものが大きい:1回のノイズ除去ごとに、81コマ分の画面全体を140億パラメータで計算する。 さらに、顔や動きがコマ間でずれないよう、各コマをほかのすべてのコマと見比べる(アテンション)。比べる組み合わせはコマ数と解像度の2乗で増える。
  2. MacのGPUは、この種の計算でNVIDIAに大きく劣る:AI向けの計算速度はH100の数十分の1程度だ。FlashAttentionやSageAttentionのような高速化もほとんどがCUDA専用で、Macでは使えない。
  3. メモリが足りない:量子化しても動作中は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での生成は、映像が真っ黒になってもエラーにならない。生成物を機械的に検査する工程を最初から組み込んでおく
  • 長時間ジョブは、途中結果を細かく保存し、電源・スリープ・ロックの残骸まで想定して組む

理解度チェック

問題 0 / 50%
Q1

SadTalkerからInfiniteTalkに切り替えたとき、処理時間が数十倍に増えた主な理由はどれか。

キーボード: 1〜4 で選択、Enter で回答