単体GPUなしの780MミニPCでローカル動画生成を動かす

780mcomfyuivideo-generation

ぼやけた動画フレームの流れから、鮮明な雪山の風景を投影する小型PC

デスクの上にGMKtec NucBox K8 Plusがあります。Ryzen 8845HS、Radeon 780Mの内蔵GPU(gfx1103)、共有DDR5メモリ32GB、そして単体のグラフィックカードはありません。AIワークステーションと呼ぶ人はいないでしょう。それでも、このパソコンでローカルの動画生成をどこまで動かせるか試してみたくなりました。

ここから先はそこにたどり着くまでの記録です。途中で行き止まりになった道もありました。2つのモデルは一度あきらめたあと、本当の原因がわかってから作業を再開しました。最後には、ブール値のフラグが1つ黙って消えていただけで、まともな動画がノイズに変わっていたというデバッグの顛末があります。

環境

gfx1103はROCm本体では公式サポート対象外で、そのぶん簡単な道はほとんど塞がれています。動いたのはComfyUI-TheRockで、AMD自身のTheRock ROCm 7.15ナイトリー版とPyTorch 2.12を組み合わせたビルドでした。ZLUDAもCUDA変換層も挟まない、780Mを直接ターゲットにしたHIPベースのPyTorchです。(TheRockへの乗り換え自体は別の記事で書いています。)ComfyUIはこれを14GBの利用可能VRAMとして認識しますが、実体はiGPU用に切り出されたシステムRAMです。この数字は、このあとの話にずっと関わってきます。

最初の壁: MiniMax H3

最初に試したのはMiniMax H3でした。ほぼ即座にVRAMの上限にぶつかりました。単に、このハードウェアが持っている以上のメモリをモデルが要求しただけです。メモリが足りないこと自体にソフトウェア側の回避策はないので、もっと小さいモデルに切り替えました。

LTX-2.5: 4つのバグと間違った結論

LTX-2.5はもっと有望に見えました。サイズが小さく、Lightricksがコンシューマー向けハードウェアを明示的にターゲットにしているモデルです。動かすまでに、別々の修正が4つ必要でした。

  1. 壊れたオプショナルインポート。 ComfyUI-LTXVideoのピラミッドブレンディングのモジュールが、インストールされているkorniaのバージョンには存在しないkornia.geometry.transform.pyramidのpadをインポートしていました。これがカスタムノードパッケージ全体をロード時にクラッシュさせ、壊れたインポートとは無関係な機能まで巻き添えにしていました。importをtry/exceptで囲み、成功したときだけノードを登録するようにして直しました。
  2. VAEの取り違え。 ltx-2.5-video-vae-bf16.safetensorsはdiffusionデコーダーで、素のVAELoaderではパースできない別アーキテクチャです。従来型の畳み込みデコーダーであるltx-2.5-video-vae-conv-bf16.safetensorsに切り替えて直りました。
  3. ComfyUI本体側の実在のバグ。 Gemma4Model.process_tokens()が、ベースのforward()が4つの値を期待しているところに1つしか返していませんでした。ComfyUIコア側の実際のバグです。壊れたオーバーライドを取り除いたコミットまでgit pullして直りました。
  4. VRAM、それからアーキテクチャ。 この3つを片付けたところで、テキストエンコードがCUDAのOOM(13.81GB要求、空き0)にぶつかりました。テキストエンコーダー単体が14GBのプールに収まっていませんでした。それを回避すると、今度はモデルのロードがテンソルの形状不一致で失敗しました。scale_shift_tableが、チェックポイントでは[9, ...]なのにComfyUIが組み立てたモデルでは[6, ...]になっていました。

この時点で2.5をあきらめました。コミュニティ製のGGUF量子化モデルが「別のアーキテクチャリビジョン向けにビルドされたものだろう」という、裏を取っていない推測のままでした。この推測は間違っていて、どれくらい間違っていたかがわかったのはずっとあとのことです。

LTX-2.3に切り替えて動かす

LTX-2.3は、テキストエンコーダーをGPUから外したところで、このハードウェアにちょうど合うサイズになりました。LTXAVTextEncoderLoader(と、同じオプションを見つけにくいadvancedパラメータの中に隠している素のCLIPLoader)はdevice: "cpu"をサポートしていて、これでエンコーダーを丸ごとシステムRAM側に押し出せます。おかげで11GBのテキストエンコーダーが、diffusionモデルに必要な14GBのVRAM予算にまったく触れずに動くようになりました。

もう1つの修正は、長さが伸びたときのVAEデコード側でした。汎用のVAEDecodeTiledノードは、控えめなタイル設定にしても97フレームでネイティブプロセスごとクラッシュしていました(LTXが要求するフレーム数は1 + 8kの形で、97フレームは24fpsでおよそ4秒に当たります)。タイル分割をピクセルサイズではなく枚数単位で行うLTXネイティブのLTXVTiledVAEDecodeノードに切り替えると、きれいに解決しました。

両方の修正を入れた状態で、2.3のパイプラインは動きました。9フレームのテスト生成が数分、音声付きの4秒クリップも5分弱でした。

エラーゼロでノイズが出るバグ

反復のたびに30〜40分かかるテキストエンコードのステップを省くため、conditioningテンソルの保存・読み込みキャッシュ(LTXVSaveConditioning / LTXVLoadConditioning、コミュニティ製ノード)を足しました。エンコードを1回だけ済ませて、あとはその結果を使い回す仕組みです。

キャッシュ経由の実行はきれいに通りました。長さも解像度も正しく、status: successです。ですが、出てきたのはゴミでした。平坦で濁ったノイズのテクスチャで、ボールも、テーブルも、動きらしきものも、画面のどこにも見当たりませんでした。

ここのデバッグ過程は正直に書いておく価値があります。2回間違えてから、ようやく正解にたどり着きました。

  • 最初の推測: 量子化。 Q2_Kの量子化モデルを使っていたので、圧縮が強すぎるのかもしれないと考えました。Q4_K_Mをダウンロードしても同じ結果で、これは違いました。
  • 2つ目の推測: パイプラインのノード漏れ。 LTXのconditioningは、フレームレートを明示的に刻むノードを必要とすることがあります。追加しても何も変わりませんでした。
  • 3回目: 保存時にテンソル値のオプションが落ちている可能性。 保存ノードはハードコードされたattention_maskテンソルだけを保持していたので、テンソル値のオプションをすべて保存するように一般化し、実装して、31分の再エンコードを待ってテストしました。結果、このモデルではcond_options自体が空でした。実装した修正は、正しく動くno-opだったということです。生の値を直接調べても(形状、dtype、平均・標準偏差、NaNチェック)、どこにも破損は見つかりませんでした。

実際のバグは、もう1段上に隠れていました。ComfyUIのテキストエンコーダーはconditioningにextra = {"unprocessed_ltxav_embeds": True}を付けています。これはテンソルではなく、ただのPythonのブール値です。元の保存・読み込みの実装はテンソルしか往復させていなかったので、このフラグは保存のたびに黙って落ちていました。

このブール値1つが落ちていたことが、大きな意味を持っていました。av_model.pyのpreprocess_text_embeds()は、入ってきた埋め込みの最後の次元がすでにcross_attention_dim + audio_cross_attention_dim(4096 + 2048 = 6144)と一致しているかどうかで、すでに射影済みかを判定します。この環境の生のGemma埋め込みは、たまたま同じく6144次元でした。特定のモデル構成による偶然です。フラグがないと、モデルは射影されていない生の埋め込みを、すでに射影層を通ったものとして扱ってしまいます。形状は一致しているのでエラーも警告も出ず、意味を持たない数値がそのまま以降の全段階を素通りしていきました。

修正では、保存・読み込みノードを拡張して、テンソル以外の任意のオプションをJSONにシリアライズし、.safetensorsファイルのメタデータブロックにテンソルと一緒に書き込むようにしました。パッチが必要だったのはメタデータだけだったので、30〜40分のエンコードをやり直す代わりに、すでに保存済みのconditioningファイルをその場で修正しました。

for idx, (cond_tensor, cond_options) in enumerate(conditioning):
    tensors_to_save[f"conditioning_data_{idx}"] = cond_tensor.to(dtype=target_dtype).contiguous()
    for key, value in cond_options.items():
        if torch.is_tensor(value):
            tensors_to_save[f"opt_{key}_{idx}"] = value.contiguous()
        else:
            try:
                json.dumps(value)
            except (TypeError, ValueError):
                continue
            non_tensor_options[f"{idx}:{key}"] = value  # -> written into file metadata

status: successも正しい出力形状も、実際の正しさについては何も保証していませんでした。パイプライン自身が返す成功信号を信用せず、実際のフレームをデコードして自分の目で確認することにこだわっていたからこそ見つかったバグです。この習慣は、このあとも別の場面で役に立ちました。

LTX-2.5を復活させる

2.3が安定したところで、2.5のscale_shift_table不一致に戻ってきました。「たぶん別のアーキテクチャリビジョンだろう」という答えで済ませるのは、もうやめにしました。

バグ1: コミュニティ製のGGUF量子化モデルにアーキテクチャのメタデータが欠けていた。 ComfyUIは、すべてのアーキテクチャ上のフラグをテンソルの形状から推測しているわけではありません。cross_attention_adalnのようなものは、GGUFファイル自体のメタデータに埋め込まれたconfigのJSONブロブから読み込まれます。

# comfy/model_detection.py
if metadata is not None and "config" in metadata:
    dit_config.update(json.loads(metadata["config"]).get("transformer", {}))

動作していたLTX-2.3の量子化モデル(unsloth製)には、このブロブが入っています。私がダウンロードしたLTX-2.5の量子化モデル(Abiray製)には、些末なKVフィールドが6つだけで、config自体がありませんでした。これがないとcross_attention_adalnは黙ってFalseにデフォルトされ、BasicAVTransformerBlock.__init__はチェックポイント側の9行ではなく6行のmodulationテーブルを組み立ててしまいます。まさに最初の挑戦を終わらせた[9,...]対[6,...]の不一致そのものでした。

これを直すため、動作している2.3のチェックポイントと壊れている2.5のチェックポイントとで、4,349個のテンソル名と形状をすべて突き合わせました。アーキテクチャとしては2点を除いて同一でした。2.3にはフィードフォワードのバイアステンソルが余分にあって2.5にはなく、2.5にはComfyUIがどのみち別扱いで検出しているテンソル(keyframes_abs_pos_embedding)が1つ余分にあります。つまり、2.3のconfigブロブをそのまま2.5のファイルにコピーし、フラグを1つ(ff_bias: False)反転させれば、テンソルのデータには一切手を触れずに、チェックポイントと整合するconfigが手に入るということでした。

バグ2、その次の挑戦ですぐに見つかりました。 scale_shift_tableのエラーは消えましたが、代わりに新しい不一致が3つ現れ、どれも2倍ちょうど、あるいは次元が潰れたような、いかにも怪しい形をしていました。

size mismatch for keyframes_abs_pos_embedding: checkpoint [4096] vs model [1, 4096]
size mismatch for audio_embeddings_connector.learnable_registers: checkpoint [128,4096] vs model [128,2048]
size mismatch for video_embeddings_connector.learnable_registers: checkpoint [128,8192] vs model [128,4096]

ComfyUI-GGUFのローダーはロード時にテンソルをreshapeしますが、対象はF32とF16のdtypeだけです。BF16の扱いは1次元テンソルしかカバーしていません。

if tensor.tensor_type in {F32, F16}:
    torch_tensor = torch_tensor.view(*shape)
# 1D tensors shouldn't be quantized, this is a fix for BF16
if len(shape) <= 1 and tensor.tensor_type == BF16:
    state_dict[sd_key] = dequantize_tensor(...)

多次元のBF16テンソルはどちらの分岐にも引っかからずすり抜けて、論理的な形状ではなく生のバイト列としての形状のままになります。BF16の1要素は2バイトなので、[128, 4096]のテンソルを生のバイト列として見ると[128, 8192]と報告されます。エラーに出ていた2倍という数字そのものです。LTX-2.3はこの3つのテンソルをF32で保存していて、このバグを完全に避けています。Abiray製のLTX-2.5量子化モデルはBF16で保存しているので、ロードのたびにこのバグを踏みます。

修正では、この3つのテンソルだけをF32に変換します。ファイル内のBF16テンソルを全部変換すると+856MBかかるところ、こちらは+1.6MBで済みます。あわせてcomfy.gguf.orig_shapeのメタデータエントリを足し、潰れていたkeyframes_abs_pos_embeddingの先頭次元を元に戻します。

def bf16_to_f32(u8):
    return (u8.view(np.uint16).astype(np.uint32) << 16).view(np.float32)

両方の修正を入れると、Abiray製のLTX-2.5量子化モデルなら何にでも使い回せる1本のパッチスクリプトとして、LTX-2.5はきれいにロードしサンプリングも通りました。ロードが通っただけでなく、実際の生成でも確認しています。実プロンプトでの40分のGemma4 CPUエンコードをフルで走らせ、デコードされたフレームを確認しました。出力は破綻なく、動きも正しく出ていました。4秒の生成では、2.3で1×1タイルのまま安定していたLTXネイティブのタイル分割デコーダーが2.5ではプロセスごとクラッシュし、VAEが重い分2×2タイルにしてようやく通りました。

画質を追い込む

両方のモデルが動くようになったところで、次の疑問は自然と、どこまで画質の余地が残っているかでした。以下のテストはすべて保存済みのconditioningを使い回しているので、1回のイテレーションは数分で済み、40分のエンコードをやり直す必要はありません。

解像度が単独で一番効果が大きく、しかもほぼ無料でした。 パイプラインがそもそもロードできるかを確かめるための、テスト用の512×288の設定をそのまま引きずっていて、これが達成できるはずの画質の大半を捨てていました。864×480(ピクセル数で2.8倍)に上げると9秒余分にかかるだけで、鮮明さは大きく跳ね上がりました。LTXのネイティブ解像度である1344×768(ピクセル数で7倍)まで上げても、合計の時間は6割ほど増えるだけです。木目は輪郭のぼやけた塊ではなく1本1本の筋として出るようになり、反射も反射として見えるようになりました。被写界深度も本物のレンズのように、手前がくっきりして奥が自然にぼけていきます。このハードウェアでは生成時間の大半をモデルのロードとCPU-GPU間のオフロードのオーバーヘッドが占めていて、ピクセル計算そのものではないので、解像度を上げてもこれだけ安く済みます。

ステップ数は思っていた以上に効きました。 2.3のdistilledモデルから引き継いだ8ステップのスケジュールは、2.5には明らかに足りていませんでした。LTXVSchedulerで25ステップに増やすと、時間はおよそ3倍になりましたが、細部のテクスチャは目に見えて良くなりました。Abirayの量子化モデルがdistilledモデルではなくdevモデルだという、有力な根拠です。

「best quality」のdiffusionデコーダーは、ネイティブ解像度でも標準の畳み込みデコーダーと見た目の差がなく、画質を上げる手段としては完全に外れでした。

量子化のレベルこそが、私を一番だましたポイントでした。 木のテーブルの上に赤いボールという単純なテストプロンプトでは、Q3_K_SとQ6_K(17.8GB、上と同じ2つの修正を当てたもの)はほぼ見分けがつきませんでした。この時点では、量子化はそこまで大きな影響がないと考えていました。この結論は、もっと難しいプロンプトの前では持ちこたえませんでした。

量子化モデルの差が出たテスト

木のテーブルの上の赤いボールは、低ビットの量子化モデルにとってほぼ理想的な条件です。物体は1つだけ、被写界深度は浅くて背景も隠れ、細かいテクスチャもテキストもほとんどありません。量子化による劣化は、高周波の細部や、複雑な照明の下での多物体構成のような、このシーンがほとんど含んでいない要素に集中して出ます。

そこで、あえてもっと難しいプロンプトを組みました。白髪のアニメ風の魔女が、日差しの入る温室でひざまずき、光る生物発光の植物を世話しながら鼻歌を歌っている場面です。髪の毛の1本1本、葉のテクスチャ、手と物体の接触、複雑な光、そして音声ブランチを働かせるための音の要素まで詰め込み、ボールのプロンプトが含んでいなかったものを意図的に狙いました。

シード、conditioning、設定(1344×768、25ステップ)を両方とも同じにして、Q3_K_SとQ6_Kを走らせました。

Q3_K_S単体で見れば、十分に使える出力でした。Q6_Kと並べると、違いはすぐにわかりました。Q6_Kは光を拾うハイライトまで含めて髪の毛が1本ずつ描き分けられていたのに対し、Q3_K_Sの髪はのっぺりした塊として出ていました。帽子でも同じことが起きていました。Q6_Kはバックルとタッセルまで見て取れる帯を造形していましたが、Q3_K_Sの帯は輪郭のあいまいな暗い染みでした。ボールのプロンプトでは完全に隠れていた差が、ここでははっきり出ていました。

修正した結論: デフォルトはQ6_K、1344×768、25ステップ。Q3_K_Sは細部の粗い簡易ドラフトには今でも使えますが、細部や顔が絡む用途には向きません。

現状

LTX-2.3LTX-2.5
テキストエンコード(プロンプトごとに1回、CPU)約32分約40分
9フレーム生成約2.4分約3〜5分(解像度・ステップ数依存)
4秒(97フレーム)+音声約4.5分約5.4分
見つかった最良設定Q2_K/Q4_K_M、8ステップ(distilled)Q6_K、1344×768、25ステップ

このハードウェアで問題なく動くはずのモデルを止めていたのは、2つの地味なバグでした。欠けていたメタデータのブロブと、GGUFローダーの中で処理されずに素通りしていたdtypeの分岐です。どちらもハードウェアの限界ではありませんでした。ComfyUIの読み込みコードがチェックポイントのバイト列に対して正確に何をしているかを理解し、すでに動いているモデルと突き合わせて差分を取ることで、どちらも直せるものでした。