Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion modules/modelSaver/hunyuanVideo/HunyuanVideoLoRASaver.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from modules.model.HunyuanVideoModel import HunyuanVideoModel
from modules.modelSaver.mixin.LoRASaverMixin import LoRASaverMixin
from modules.util.convert.lora.convert_hunyuan_video_lora import convert_hunyuan_video_lora_key_sets
from modules.util.convert.lora.convert_hunyuan_video_lora import (
convert_hunyuan_video_lora_key_sets,
convert_hunyuan_video_lora_to_comfyui,
)
from modules.util.convert.lora.convert_lora_util import LoraConversionKeySet
from modules.util.enum.ModelFormat import ModelFormat

Expand All @@ -17,6 +20,13 @@ def __init__(self):
def _get_convert_key_sets(self, model: HunyuanVideoModel) -> list[LoraConversionKeySet] | None:
return convert_hunyuan_video_lora_key_sets()

def _get_comfy_state_dict(
self,
state_dict: dict[str, Tensor],
model: HunyuanVideoModel,
) -> dict[str, Tensor]:
return convert_hunyuan_video_lora_to_comfyui(state_dict)

def _get_state_dict(
self,
model: HunyuanVideoModel,
Expand Down
24 changes: 24 additions & 0 deletions modules/modelSaver/mixin/LoRASaverMixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ def _get_state_dict(
) -> dict[str, Tensor]:
pass

def _get_comfy_state_dict(
self,
state_dict: dict[str, Tensor],
model: BaseModel,
) -> dict[str, Tensor]:
# Models that support ComfyUI LoRA export override this to return the
# converted state dict. Default: unsupported.
raise NotImplementedError("ComfyUI LoRA export is not supported for this model type")

def __save_safetensors(
self,
model: BaseModel,
Expand Down Expand Up @@ -67,6 +76,19 @@ def __save_legacy_safetensors(
os.makedirs(Path(destination).parent.absolute(), exist_ok=True)
save_file(save_state_dict, destination, self._create_safetensors_header(model, save_state_dict))

def __save_comfy(
self,
model: BaseModel,
destination: str,
dtype: torch.dtype | None,
):
state_dict = self._get_state_dict(model)
save_state_dict = self._convert_state_dict_dtype(state_dict, dtype)
save_state_dict = self._get_comfy_state_dict(save_state_dict, model)

os.makedirs(Path(destination).parent.absolute(), exist_ok=True)
save_file(save_state_dict, destination, self._create_safetensors_header(model, save_state_dict))

def __save_internal(
self,
model: BaseModel,
Expand Down Expand Up @@ -95,5 +117,7 @@ def _save(
self.__save_legacy_safetensors(model, output_model_destination, dtype)
case ModelFormat.LEGACY_SAFETENSORS:
self.__save_legacy_safetensors(model, output_model_destination, dtype)
case ModelFormat.COMFY_LORA:
self.__save_comfy(model, output_model_destination, dtype)
case ModelFormat.INTERNAL:
self.__save_internal(model, output_model_destination)
1 change: 1 addition & 0 deletions modules/ui/ConvertModelUI.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def main_frame(self, master):
components.options_kv(master, 4, 1, [
("Safetensors", ModelFormat.SAFETENSORS),
("Diffusers", ModelFormat.DIFFUSERS),
("Comfy LoRA", ModelFormat.COMFY_LORA),
], self.ui_state, "output_model_format")

# output model destination
Expand Down
61 changes: 60 additions & 1 deletion modules/util/convert/lora/convert_hunyuan_video_lora.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from modules.util.convert.lora.convert_clip import map_clip
from modules.util.convert.lora.convert_llama import map_llama
from modules.util.convert.lora.convert_lora_util import LoraConversionKeySet, map_prefix_range
from modules.util.convert.lora.convert_lora_util import LoraConversionKeySet, convert_to_omi, map_prefix_range
from modules.util.convert_util import convert, lora_qkv_fusion, lora_qkv_mlp_fusion

from torch import Tensor


def __map_token_refiner_block(key_prefix: LoraConversionKeySet) -> list[LoraConversionKeySet]:
Expand Down Expand Up @@ -97,3 +100,59 @@ def convert_hunyuan_video_lora_key_sets() -> list[LoraConversionKeySet]:
keys += map_clip(LoraConversionKeySet("clip_l", "lora_te2"))

return keys


# Conversion patterns from OT's OMI format to ComfyUI's native HunyuanVideo LoRA
# format ("Form 1": diffusion_model. prefix with the raw checkpoint attribute paths,
# e.g. double_blocks.0.img_attn.qkv). This is the format used by community/diffusion-pipe
# LoRAs and is accepted both by ComfyUI's generic loader and its HunyuanVideo-specific
# remapper (comfy/lora.py). Mirrors the approach of Flux2's diffusers_to_original, using
# the LoRA-aware fusion helpers to merge OT's split Q/K/V (and single-block Q/K/V/MLP)
# adapters into the single fused projections ComfyUI expects.
def _omi_to_comfyui_patterns() -> list:
return [
("transformer.double_blocks.{i}", "diffusion_model.double_blocks.{i}",
lora_qkv_fusion("img_attn_qkv.0", "img_attn_qkv.1", "img_attn_qkv.2", "img_attn.qkv") +
lora_qkv_fusion("txt_attn_qkv.0", "txt_attn_qkv.1", "txt_attn_qkv.2", "txt_attn.qkv") + [
("img_attn_proj", "img_attn.proj"),
("img_mlp.fc0", "img_mlp.0"),
("img_mlp.fc2", "img_mlp.2"),
("img_mod.linear", "img_mod.lin"),
("txt_attn_proj", "txt_attn.proj"),
("txt_mlp.fc0", "txt_mlp.0"),
("txt_mlp.fc2", "txt_mlp.2"),
("txt_mod.linear", "txt_mod.lin"),
]),
("transformer.single_blocks.{i}", "diffusion_model.single_blocks.{i}",
lora_qkv_mlp_fusion("linear1.0", "linear1.1", "linear1.2", "linear1.3", "linear1") + [
("linear2", "linear2"),
("modulation.linear", "modulation.lin"),
]),
]


def convert_hunyuan_video_lora_to_comfyui(
state_dict: dict[str, Tensor],
) -> dict[str, Tensor]:
"""Convert an OT HunyuanVideo LoRA state dict to ComfyUI-compatible format.

Only double_blocks and single_blocks are exported: conditioning/embedder layers
are inference-setting dependent and are absent from reference ComfyUI HYV LoRAs.
Split Q/K/V (and single-block Q/K/V/MLP) adapters are fused into a single
block-diagonal adapter per layer via convert_util's lora_qkv(_mlp)_fusion.
"""
# normalize whatever source format (legacy/diffusers/omi) to OMI native paths
omi = convert_to_omi(state_dict, convert_hunyuan_video_lora_key_sets())

dora_scales = [k for k in omi if k.endswith(".dora_scale")]
if dora_scales:
raise NotImplementedError(
"ComfyUI HunyuanVideo LoRA export does not support DoRA "
f"(found {len(dora_scales)} .dora_scale tensors)"
)

# keep only the transformer blocks ComfyUI loads; drop embedders/conditioning/text encoders
blocks = {k: v for k, v in omi.items()
if ".double_blocks." in k or ".single_blocks." in k}

return convert(blocks, _omi_to_comfyui_patterns(), strict=True)