"""ComfyUI 节点: MiniMax H3 Adapter Loader(假 CLIP 注入)。 用法(T2V 工作流): UNETLoader(fl2va) -> model MiniMaxH3AdapterLoader(student=, adapter=) -> clip VAELoader(video_vae) -> vae MiniMaxH3ImageToVideo(clip=clip, vae=vae, prompt=..., width, height, length) -> positive + latent BasicGuider(model, positive) -> RandomNoise -> SamplerCustomAdvanced -> VAEDecode -> SaveVideo 原理: ComfyUI 的 MiniMaxH3 DiT (comfy/ldm/minimax/model.py) 在 preprocess_text_embeds / _forward 中条件式跳过投影: if text_states.shape[-1] != self.hidden_size: # 5376 text_states = token_refiner(condition_proj(text_states)) 适配器输出已是 [1, S_T, 5376](post-refiner),DiT 直接使用,零手术。 官方 MiniMaxH3ImageToVideo 只对 clip 调用 tokenize(prompt) + encode_from_tokens_scheduled(tokens),故用鸭子类型 CLIP 对象替换即可, latent 创建 / keyframe / duration 网格全部保留。 """ from __future__ import annotations import os import torch import folder_paths import comfy.utils import comfy.model_management as mm from .adapter.model import H3Adapter from .adapter_clip import MiniMaxH3AdapterCLIP, MiniMaxH3AdapterFromCLIP from .student import StudentTextEncoder, get_torch_device ADAPTER_FOLDER = "minimax_h3_adapter" # models/minimax_h3_adapter/ STUDENT_FOLDER = "minimax_h3_student" # -> models/text_encoders/ def _h3_tokenizer() -> object: from transformers import AutoTokenizer tok_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tokenizer") return AutoTokenizer.from_pretrained(tok_dir) def _student_folder_options() -> list[str]: """models/text_encoders/ 下的子目录(HF 格式)或 .gguf 文件(transformers GGUF 加载)。""" opts = [] for root in folder_paths.get_folder_paths(STUDENT_FOLDER): if os.path.isdir(root): opts += [d for d in sorted(os.listdir(root)) if os.path.isdir(os.path.join(root, d)) and os.path.exists(os.path.join(root, d, "config.json"))] opts += [f for f in sorted(os.listdir(root)) if f.endswith(".gguf")] return opts or ["<把 Qwen3.5-4B 文件夹或 .gguf 放到 models/text_encoders/>"] class MiniMaxH3AdapterLoader: @classmethod def INPUT_TYPES(cls): return {"required": { "student": (_student_folder_options(),), "adapter": (folder_paths.get_filename_list(ADAPTER_FOLDER),), }, "optional": { # 留空 = 整卡;24GB 及以下填 "16GiB" 等启用层间 offload "gpu_mem": ("STRING", {"default": ""}), }} RETURN_TYPES = ("CLIP",) RETURN_NAMES = ("clip",) FUNCTION = "load_adapter" CATEGORY = "model/conditioning/minimax" def load_adapter(self, student: str, adapter: str, gpu_mem: str = ""): dev = get_torch_device() model = _load_adapter_weights(adapter, dev) student_root = folder_paths.get_folder_paths(STUDENT_FOLDER)[0] student_dir = os.path.join(student_root, student) stu = StudentTextEncoder(student_dir, lowvram=True, gpu_mem=gpu_mem) tok = _h3_tokenizer() clip = MiniMaxH3AdapterCLIP(model, stu, tok, dev) return (clip,) def _load_adapter_weights(adapter: str, dev: torch.device) -> H3Adapter: """加载适配器 safetensors -> bf16 H3Adapter(容错 model. 前缀)。""" adapter_path = folder_paths.get_full_path(ADAPTER_FOLDER, adapter) sd = comfy.utils.load_torch_file(adapter_path) if any(k.startswith("model.") for k in sd): sd = {k[len("model."):]: v for k, v in sd.items()} model = H3Adapter().to(torch.bfloat16) missing, unexpected = model.load_state_dict(sd, strict=False) assert not missing, f"adapter 缺 key: {missing[:10]}" if unexpected: print(f" [adapter] ignored {len(unexpected)} unexpected keys", flush=True) model.to(dev).eval() for m in model.modules(): if hasattr(m, "use_sdpa"): m.use_sdpa = True print(f" [adapter] loaded {adapter} ({sum(p.numel() for p in model.parameters())/1e9:.3f}B)", flush=True) return model class MiniMaxH3AdapterFromCLIPLoader: """CLIP→CLIP 包装: 学生 CLIP(CLIPLoader GGUF 等)的 encode 输出经适配器 -> 可喂给官方 MiniMaxH3ImageToVideo。 工作流: CLIPLoader(GGUF, 学生模型) -> clip MiniMaxH3AdapterFromCLIPLoader(clip=clip, adapter=adapter_stage2.safetensors) -> clip' MiniMaxH3ImageToVideo(clip=clip', ...) # 官方节点原样 """ @classmethod def INPUT_TYPES(cls): return {"required": { "clip": ("CLIP",), "adapter": (folder_paths.get_filename_list(ADAPTER_FOLDER),), }} RETURN_TYPES = ("CLIP",) RETURN_NAMES = ("clip",) FUNCTION = "wrap_clip" CATEGORY = "model/conditioning/minimax" def wrap_clip(self, clip, adapter): dev = get_torch_device() model = _load_adapter_weights(adapter, dev) wrapped = MiniMaxH3AdapterFromCLIP(clip, model, _h3_tokenizer(), dev) return (wrapped,)