"""One ComfyUI loader for recovered MiniMax-H3 text encoders. This extension adds one visible node. It loads a selected text-only encoder package and returns a standard CLIP object for official MiniMax-H3 nodes. """ from __future__ import annotations import hashlib import json import logging from pathlib import Path from typing import Any import torch import torch.nn as nn from safetensors.torch import load_file import comfy.sd import comfy.sd1_clip import comfy.supported_models_base import comfy.utils import comfy.model_management import comfy.weight_adapter import folder_paths from comfy.text_encoders.hunyuan_video import llama_detect from comfy.text_encoders.minimax import MiniMaxQwen3VL from comfy.text_encoders.qwen3vl import Qwen3VLSDTokenizer MODEL_ROOT = Path(folder_paths.models_dir) / "text_encoders" RECOVERED_FORMAT = "minimax_h3_recovered_8b_release_v1" PRUNED24_FORMAT = "minimax_h3_pruned24_balanced_release_v1" ARA_TARGETS = ("self_attn.o_proj", "mlp.gate_proj", "mlp.up_proj", "mlp.down_proj") def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for block in iter(lambda: handle.read(8 << 20), b""): digest.update(block) return digest.hexdigest() def _load_manifest(directory: Path, filename: str) -> dict[str, Any]: path = directory / filename value = json.loads(path.read_text(encoding="utf-8")) if not isinstance(value, dict): raise ValueError(f"Invalid manifest: {path}") return value def _verified(directory: Path, entry: dict[str, Any]) -> Path: path = (directory / entry["file"]).resolve() if path.parent != directory.resolve(): raise ValueError(f"Model file must be inside its package: {path}") actual = _sha256(path) if actual != entry["sha256"]: raise ValueError(f"SHA-256 mismatch for {path.name}: {actual} != {entry['sha256']}") return path def _submodule(root: Any, dotted_name: str) -> Any: current = root for part in dotted_name.split("."): current = current[int(part)] if part.isdigit() else getattr(current, part) return current def _set_submodule(root: Any, dotted_name: str, value: nn.Module) -> None: parts = dotted_name.split(".") parent = root for part in parts[:-1]: parent = parent[int(part)] if part.isdigit() else getattr(parent, part) if parts[-1].isdigit(): parent[int(parts[-1])] = value else: setattr(parent, parts[-1], value) class IdentityBlock(nn.Module): def forward(self, x, *args, **kwargs): return x, None class RecoveryLinear(nn.Module): def __init__(self, base: nn.Module, rank: int, alpha: float, device: torch.device, prefix: str = "lora"): super().__init__() self.base = base self.prefix = prefix setattr(self, f"{prefix}_down", nn.Linear(base.in_features, rank, bias=False, device=device, dtype=torch.float32)) setattr(self, f"{prefix}_up", nn.Linear(rank, base.out_features, bias=False, device=device, dtype=torch.float32)) self.scaling = alpha / rank def forward(self, x: torch.Tensor) -> torch.Tensor: base_output = self.base(x) down = getattr(self, f"{self.prefix}_down") up = getattr(self, f"{self.prefix}_up") delta = up(down(x.float())) * self.scaling return base_output + delta.to(dtype=base_output.dtype) class ConditioningAdapter(nn.Module): def __init__(self, input_width: int, output_width: int, bottleneck: int, device: torch.device): super().__init__() self.norm = nn.RMSNorm(input_width, device=device, dtype=torch.float32) self.proj = nn.Linear(input_width, output_width, bias=False, device=device, dtype=torch.float32) self.down = nn.Linear(input_width, bottleneck, bias=False, device=device, dtype=torch.float32) self.up = nn.Linear(bottleneck, output_width, bias=False, device=device, dtype=torch.float32) def forward(self, hidden: torch.Tensor) -> torch.Tensor: normalized = self.norm(hidden.float()) return self.proj(normalized) + self.up(torch.nn.functional.silu(self.down(normalized))) class RecoveredMiniMaxTextModel(MiniMaxQwen3VL): model_type = "qwen3vl_8b" def __init__(self, config_dict, dtype, device, operations): super().__init__({"num_hidden_layers": 24, "final_norm": False, "lm_head": False}, dtype, device, operations) # ponytail: text-only package; tokenizer rejects image/reference inputs. del self.visual adapter = config_dict["adapter"] for layer in config_dict["ara_layers"]: for suffix in ARA_TARGETS: parent_name, leaf = f"layers.{layer}.{suffix}".rsplit(".", 1) parent = _submodule(self.model, parent_name) base = getattr(parent, leaf) setattr(parent, leaf, RecoveryLinear(base, config_dict["ara_rank"], config_dict["ara_alpha"], device)) self.adapter = ConditioningAdapter(adapter["input_width"], adapter["output_width"], adapter["bottleneck"], device) def forward(self, input_ids, attention_mask=None, embeds=None, num_tokens=None, intermediate_output=None, final_layer_norm_intermediate=True, dtype=None, embeds_info=None, **kwargs): result = super().forward( input_ids, attention_mask=attention_mask, embeds=embeds, num_tokens=num_tokens, intermediate_output=None, final_layer_norm_intermediate=False, dtype=dtype, embeds_info=embeds_info or [], **kwargs, ) return self.adapter(result[0]), None def _recovered_clip_model(recovery: dict[str, Any], quantization_metadata: dict[str, Any] | None): class RecoveredClipModel(comfy.sd1_clip.SDClipModel): def __init__(self, device="cpu", layer="last", layer_idx=None, dtype=None, model_options=None): options = dict(model_options or {}) if quantization_metadata is not None: options["quantization_metadata"] = quantization_metadata super().__init__( device=device, layer="last", layer_idx=None, textmodel_json_config={ "adapter": recovery["adapter"], "ara_layers": recovery["ara"]["layers"], "ara_rank": recovery["ara"]["rank"], "ara_alpha": recovery["ara"]["alpha"], }, dtype=dtype, special_tokens={"pad": 151643}, layer_norm_hidden_state=False, model_class=RecoveredMiniMaxTextModel, enable_attention_masks=False, return_attention_masks=False, model_options=options, ) def encode_token_weights(self, token_weight_pairs): output = super().encode_token_weights(token_weight_pairs) tags = getattr(self.transformer, "last_token_tags", None) if tags is None: tags = torch.ones(output[0].shape[1], dtype=torch.long) extra = output[2] if len(output) > 2 and isinstance(output[2], dict) else {} extra["minimax_token_tags"] = tags return output[0], output[1], extra return RecoveredClipModel def _recovered_te_model(recovery: dict[str, Any], quantization_metadata: dict[str, Any] | None): clip_model = _recovered_clip_model(recovery, quantization_metadata) class RecoveredTEModel(comfy.sd1_clip.SD1ClipModel): def __init__(self, device="cpu", dtype=None, model_options=None): super().__init__(device=device, dtype=dtype, name="qwen3vl_8b", clip_model=clip_model, model_options=model_options or {}) return RecoveredTEModel class RecoveredMiniMaxTokenizer(comfy.sd1_clip.SD1Tokenizer): """The official H3 verbatim text token contract, without vision inputs.""" def __init__(self, embedding_directory=None, tokenizer_data=None): tokenizer = lambda *args, **kwargs: Qwen3VLSDTokenizer(*args, **kwargs, embedding_size=4096, embedding_key="qwen3vl_8b") super().__init__(embedding_directory=embedding_directory, tokenizer_data=tokenizer_data or {}, name="qwen3vl_8b", tokenizer=tokenizer) def tokenize_with_weights(self, text, return_word_ids=False, images=None, minimax_ref_items=None, **kwargs): if images or minimax_ref_items: raise ValueError("Recovered MiniMax-H3 encoders support text-only T2V. Do not use image or reference inputs.") tokens = [(token, 1.0) for token in self.qwen3vl_8b.tokenizer(text, add_special_tokens=False)["input_ids"]] if not tokens: tokens = [(151643, 1.0)] if return_word_ids: tokens = [token + (0,) for token in tokens] return {"qwen3vl_8b": [tokens]} def _translate_recovered_base(state: dict[str, torch.Tensor], ara_layers: list[int]) -> dict[str, torch.Tensor]: translated: dict[str, torch.Tensor] = {} for key, value in state.items(): output = key for layer in ara_layers: for suffix in ARA_TARGETS: prefix = f"model.layers.{layer}.{suffix}." if key.startswith(prefix): output = prefix + "base." + key.removeprefix(prefix) break translated[output] = value return translated def _load_recovered(directory: Path, package: dict[str, Any], variant_id: str): recovery = { "ara": package["ara"], "adapter": package["adapter"], } variant = package["base_variants"][variant_id] base_path = _verified(directory, variant) adapter_path = _verified(directory, recovery["adapter"]) ara_path = _verified(directory, recovery["ara"]) state, metadata = comfy.utils.load_torch_file(str(base_path), safe_load=True, return_metadata=True) state, _ = comfy.utils.convert_old_quants(state, metadata=metadata) quantization_metadata = llama_detect(state)["llama_quantization_metadata"] if any( key.endswith(".comfy_quant") for key in state ) else None target = comfy.supported_models_base.ClipTarget( RecoveredMiniMaxTokenizer, _recovered_te_model(recovery, quantization_metadata) ) # The base, ARA, and adapter state is loaded below. Keep the empty model on # CPU during construction; otherwise CLIP forces GPU placement before the # custom RecoveryLinear wrappers have populated weights. clip = comfy.sd.CLIP( target, embedding_directory=folder_paths.get_folder_paths("embeddings"), parameters=6_000_000_000, model_options={"initial_device": comfy.model_management.text_encoder_offload_device()}, ) state = _translate_recovered_base(state, recovery["ara"]["layers"]) adapter = {f"adapter.{key}": value for key, value in load_file(str(adapter_path), device="cpu").items()} ara = {f"model.{key}": value for key, value in load_file(str(ara_path), device="cpu").items()} state.update(adapter) state.update(ara) missing, unexpected = clip.load_sd(state, full_model=False) real_missing = [key for key in (missing or []) if not key.endswith("lm_head.weight")] if real_missing or unexpected: raise RuntimeError(f"Recovered encoder load mismatch: missing={real_missing[:8]} unexpected={list(unexpected or [])[:8]}") return clip def _load_pruned24(directory: Path, package: dict[str, Any], variant_id: str): checkpoint = _verified(directory, package["base_variants"][variant_id]) ara_path = _verified(directory, package["ara"]) clip = comfy.sd.load_clip([str(checkpoint)], embedding_directory=folder_paths.get_folder_paths("embeddings"), clip_type=comfy.sd.CLIPType.MINIMAX) transformer = clip.cond_stage_model.qwen3vl_32b.transformer for layer in package["removed_blocks"]: transformer.model.layers[layer] = IdentityBlock() ara = load_file(str(ara_path), device="cpu") rank = package["ara"]["rank"] alpha = package["ara"]["alpha"] expected = set() manager = comfy.weight_adapter.BypassInjectionManager() for layer in package["ara"]["layers"]: for suffix in ARA_TARGETS: prefix = f"layers.{layer}.{suffix}" down_key = f"{prefix}.ara_down.weight" up_key = f"{prefix}.ara_up.weight" adapter = comfy.weight_adapter.LoRAAdapter( {down_key, up_key}, (ara[up_key], ara[down_key], alpha, None, None, None), ) manager.add_adapter(f"qwen3vl_32b.transformer.model.{prefix}", adapter, strength=1.0) expected.update({down_key, up_key}) if set(ara) != expected: raise RuntimeError("Pruned-24 ARA keys do not match manifest") injections = manager.create_injections(clip.cond_stage_model) if manager.get_hook_count() != len(expected) // 2: raise RuntimeError("Pruned-24 ARA injection count does not match manifest") clip.patcher.set_injections("balanced_ara", injections) clip.patcher.cached_patcher_init = (_reload_pruned24, (directory, package, variant_id)) return clip def _reload_pruned24(directory: Path, package: dict[str, Any], variant_id: str): return _load_pruned24(directory, package, variant_id).patcher def _choices() -> dict[str, tuple[Path, dict[str, Any], str | None]]: choices: dict[str, tuple[Path, dict[str, Any], str | None]] = {} if not MODEL_ROOT.is_dir(): return choices for directory in MODEL_ROOT.iterdir(): recovered = directory / "minimax_h3_recovered_8b_manifest.json" pruned = directory / "minimax_h3_pruned24_balanced_manifest.json" if recovered.is_file(): package = _load_manifest(directory, recovered.name) if package.get("format") == RECOVERED_FORMAT: for variant in sorted(package["base_variants"]): choices[f"{directory.name}: {variant}"] = (directory, package, variant) if pruned.is_file(): package = _load_manifest(directory, pruned.name) if package.get("format") == PRUNED24_FORMAT: for variant_id in sorted(package["base_variants"]): choices[f"{directory.name}: {variant_id}"] = (directory, package, variant_id) return choices class MiniMaxH3TextEncoderLoader: @classmethod def INPUT_TYPES(cls): choices = sorted(_choices()) return {"required": {"text_encoder": (choices,)}} RETURN_TYPES = ("CLIP",) FUNCTION = "load" CATEGORY = "advanced/loaders" DESCRIPTION = "Loads one recovered MiniMax-H3 text encoder. Text-only T2V." def load(self, text_encoder): choice = _choices().get(text_encoder) if choice is None: raise ValueError("Selected package is missing. Reopen the workflow after model files are installed.") directory, package, variant = choice if package["format"] == RECOVERED_FORMAT: clip = _load_recovered(directory, package, variant) elif package["format"] == PRUNED24_FORMAT: clip = _load_pruned24(directory, package, variant) else: raise ValueError("Unsupported MiniMax-H3 text encoder package") logging.info("MiniMaxH3TextEncoderLoader: %s", text_encoder) return (clip,) NODE_CLASS_MAPPINGS = {"MiniMaxH3TextEncoderLoader": MiniMaxH3TextEncoderLoader} NODE_DISPLAY_NAME_MAPPINGS = {"MiniMaxH3TextEncoderLoader": "Load MiniMax H3 Text Encoder"}