Skip to content

Commit d3eaf6a

Browse files
Minimax h3 controlnet as a model patch instead of a controlnet. (#15975)
1 parent a8b4fa6 commit d3eaf6a

4 files changed

Lines changed: 331 additions & 1 deletion

File tree

comfy/ldm/minimax/controlnet.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""MiniMax H3 Fun ControlNet-Union model patch."""
2+
3+
import torch
4+
import torch.nn as nn
5+
6+
import comfy.ldm.common_dit
7+
from .model import DiTBlock, patchify_video
8+
9+
10+
class ControlDiTBlock(DiTBlock):
11+
def __init__(self, hidden, heads, head_dim, ffn, t_dim, eps, qk_eps, first_block=False,
12+
apply_silu=True, adaln_dtype=None, dtype=None, device=None, operations=None):
13+
super().__init__(hidden, heads, head_dim, ffn, t_dim, eps, qk_eps, apply_silu=apply_silu,
14+
adaln_dtype=adaln_dtype, dtype=dtype, device=device, operations=operations)
15+
if first_block:
16+
self.before_proj = operations.Linear(hidden, hidden, bias=True, dtype=dtype, device=device)
17+
self.after_proj = operations.Linear(hidden, hidden, bias=True, dtype=dtype, device=device)
18+
19+
20+
class MiniMaxH3FunControl(torch.nn.Module):
21+
def __init__(self, control_in_dim=49, injection_layers=(0, 10, 20, 30, 40), hidden_size=5376,
22+
num_attention_heads=56, attention_head_dim=128, ffn_hidden_size=14336,
23+
time_embed_dim=2688, patch_size=(1, 2, 2), norm_eps=1e-5, qk_norm_eps=1e-5,
24+
use_adaln_curves=False, dtype=None, device=None, operations=None):
25+
super().__init__()
26+
self.dtype = dtype
27+
self.patch_size = tuple(patch_size)
28+
self.injection_layers = tuple(injection_layers)
29+
if not self.injection_layers or self.injection_layers[0] != 0:
30+
raise ValueError("MiniMax H3 Fun control injection layers must start at layer 0")
31+
if self.injection_layers != tuple(sorted(set(self.injection_layers))):
32+
raise ValueError("MiniMax H3 Fun control injection layers must be unique and increasing")
33+
self.control_in_dim = control_in_dim
34+
patch_dim = control_in_dim * self.patch_size[0] * self.patch_size[1] * self.patch_size[2]
35+
self.control_proj_in = operations.Linear(patch_dim, hidden_size, bias=True, dtype=torch.float32, device=device)
36+
self.control_blocks = nn.ModuleList([
37+
ControlDiTBlock(hidden_size, num_attention_heads, attention_head_dim, ffn_hidden_size,
38+
time_embed_dim, norm_eps, qk_norm_eps, first_block=(i == 0),
39+
apply_silu=not use_adaln_curves,
40+
adaln_dtype=torch.float32 if use_adaln_curves else dtype,
41+
dtype=dtype, device=device, operations=operations)
42+
for i in range(len(self.injection_layers))])
43+
44+
def init_stream(self, h, control_latent, layout, t_emb):
45+
if any(kind not in ("text", "audio", "video") for _, _, kind in layout.segments):
46+
raise ValueError("MiniMax H3 Fun ControlNet does not support keyframe or reference conditioning")
47+
adaln_in = self.control_blocks[0].adaln_proj.linear.in_features
48+
if t_emb.shape[-1] != adaln_in:
49+
raise RuntimeError(
50+
"MiniMax H3 controlnet adaln width {} does not match the base model's timestep embedding width {}: "
51+
"the controlnet and base checkpoint use different adaln forms (curve basis vs full), "
52+
"convert the controlnet to match the base model.".format(adaln_in, t_emb.shape[-1]))
53+
54+
patch_dim = self.control_in_dim * self.patch_size[0] * self.patch_size[1] * self.patch_size[2]
55+
control_latent = comfy.ldm.common_dit.pad_to_patch_size(control_latent.to(torch.float32), self.patch_size)
56+
target_rows = patchify_video(control_latent, self.patch_size)
57+
if target_rows.shape[1] < patch_dim:
58+
target_rows = torch.nn.functional.pad(target_rows, (0, patch_dim - target_rows.shape[1]))
59+
elif target_rows.shape[1] > patch_dim:
60+
raise ValueError("MiniMax H3 control input has {} columns but the model patch expects {}".format(target_rows.shape[1], patch_dim))
61+
62+
c = h.clone()
63+
c[layout.img_pos.to(h.device)] = self.control_proj_in(target_rows).to(h.dtype)
64+
return self.control_blocks[0].before_proj(c).add_(h)
65+
66+
def step(self, index, c, t_emb, mod_segments, rope_freqs, transformer_options):
67+
block = self.control_blocks[index]
68+
c = DiTBlock.forward(block, c, t_emb, mod_segments, rope_freqs, transformer_options=transformer_options)
69+
return c, block.after_proj(c)
70+
71+
72+
def is_minimax_h3_fun_state_dict(state_dict):
73+
required = (
74+
"control_proj_in.weight",
75+
"control_blocks.0.adaln_proj.linear.weight",
76+
"control_blocks.0.after_proj.weight",
77+
"control_blocks.0.before_proj.weight",
78+
"control_blocks.0.attn.qkv_proj.weight",
79+
"control_blocks.0.attn.q_norm.weight",
80+
"control_blocks.0.mlp.fc1.weight",
81+
)
82+
return all(key in state_dict for key in required)

comfy/ldm/minimax/model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,7 @@ def block_wrap(args):
731731
transformer_options=args["transformer_options"])}
732732
h = blocks_replace[("double_block", i)](
733733
{"img": h, "t_emb": t_emb, "mod_segments": mod_segments, "rope_freqs": rope_freqs,
734-
"transformer_options": transformer_options},
734+
"layout": layout, "transformer_options": transformer_options},
735735
{"original_block": block_wrap})["img"]
736736
else:
737737
h = block(h, t_emb, mod_segments, rope_freqs, transformer_options=transformer_options)

comfy_extras/nodes_minimax_h3.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
import math
1313

1414
import torch
15+
import torch.nn.functional as F
1516
import torchaudio
1617

1718
import nodes
1819
import comfy.model_management
1920
import comfy.model_sampling
2021
import comfy.nested_tensor
22+
import comfy.patcher_extension
2123
import comfy.utils
2224
import node_helpers
2325
from comfy.ldm.minimax.model import FRAME_PER_TOKEN, FRAME_RESCALE
@@ -399,6 +401,206 @@ class ModelSamplingAdvanced(comfy.model_sampling.ModelSamplingAV, comfy.model_sa
399401
return io.NodeOutput(m)
400402

401403

404+
class MiniMaxH3FunControlPatch:
405+
def __init__(self, model_patch, vae, control_video, mask, source_video, strength, sigma_start, sigma_end):
406+
self.model_patch = model_patch
407+
self.vae = vae
408+
self.control_video = control_video
409+
self.mask = mask
410+
self.source_video = source_video
411+
self.strength = strength
412+
self.sigma_start = sigma_start
413+
self.sigma_end = sigma_end
414+
self.control_latent = None
415+
self.control_latent_shape = None
416+
self.control_stream = None
417+
self.active = False
418+
419+
def _fit_frames(self, frames, frame_count, width, height):
420+
indices = torch.arange(frame_count, device=frames.device).clamp(max=frames.shape[0] - 1)
421+
return comfy.utils.common_upscale(frames[indices], width, height, "bilinear", "center")
422+
423+
def _encode(self, frames, target_shape):
424+
latent = self.vae.encode(frames.movedim(1, -1)).to(torch.float32)
425+
if tuple(latent.shape) != target_shape:
426+
raise ValueError("MiniMax H3 Fun VAE output shape {} does not match the target {}".format(tuple(latent.shape), target_shape))
427+
return latent
428+
429+
def prepare_control_latent(self, target_shape):
430+
target_shape = tuple(target_shape)
431+
if self.control_latent is not None and self.control_latent_shape == target_shape:
432+
return
433+
434+
latent_frames, latent_height, latent_width = target_shape[2:]
435+
frame_count = max((latent_frames - 2) // 5, 0) * 17 + 5
436+
spatial_compression = self.vae.spacial_compression_encode()
437+
width = latent_width * spatial_compression
438+
height = latent_height * spatial_compression
439+
loaded_models = comfy.model_management.loaded_models(only_currently_used=True)
440+
441+
try:
442+
hint = None
443+
if self.control_video is not None:
444+
frames = self._fit_frames(self.control_video, frame_count, width, height)
445+
hint = self._encode(frames, target_shape)
446+
447+
if self.mask is not None:
448+
mask = (self.mask.reshape(-1, 1, self.mask.shape[-2], self.mask.shape[-1]) > 0.5).to(torch.float32)
449+
indices = torch.arange(frame_count, device=mask.device).clamp(max=mask.shape[0] - 1)
450+
mask = comfy.utils.common_upscale(mask[indices], width, height, "bilinear", "center")
451+
visibility = 1.0 - (mask > 0.5).to(torch.float32)
452+
if self.source_video is None:
453+
source = torch.zeros(frame_count, 3, height, width, dtype=visibility.dtype, device=visibility.device)
454+
else:
455+
source = self._fit_frames(self.source_video, frame_count, width, height)
456+
masked_latent = self._encode(source * visibility.to(source.device), target_shape)
457+
if hint is None:
458+
hint = torch.zeros_like(masked_latent)
459+
visibility_latent = F.interpolate(
460+
visibility.squeeze(1)[None, None], size=(latent_frames, latent_height, latent_width),
461+
mode="trilinear", align_corners=False)
462+
hint = torch.cat([hint, visibility_latent.to(hint.device), masked_latent.to(hint.device)], dim=1)
463+
finally:
464+
comfy.model_management.load_models_gpu(loaded_models)
465+
466+
self.control_latent = hint
467+
self.control_latent_shape = target_shape
468+
469+
def diffusion_model_wrapper(self, executor, x, timestep, context, transformer_options={}, **kwargs):
470+
sigmas = transformer_options.get("sigmas")
471+
sigma = float(sigmas[0]) if sigmas is not None else float(timestep.flatten()[0]) / 1000.0
472+
self.active = self.sigma_end <= sigma <= self.sigma_start
473+
self.control_stream = None
474+
if self.active:
475+
payload = kwargs.get("minimax_payload") or {}
476+
if payload.get("keyframes") or payload.get("refs"):
477+
raise ValueError("MiniMax H3 Fun ControlNet does not support keyframe or reference conditioning")
478+
self.prepare_control_latent(x[0].shape)
479+
try:
480+
return executor(x, timestep, context, transformer_options, **kwargs)
481+
finally:
482+
self.control_stream = None
483+
484+
def before_block(self, block_index, args):
485+
if not self.active or block_index != self.model_patch.model.injection_layers[0]:
486+
return
487+
self.control_latent = self.control_latent.to(args["img"].device)
488+
self.control_stream = self.model_patch.model.init_stream(
489+
args["img"], self.control_latent, args["layout"], args["t_emb"])
490+
491+
def after_block(self, block_index, args, out):
492+
if not self.active:
493+
return out
494+
control_index = self.model_patch.model.injection_layers.index(block_index)
495+
self.control_stream, skip = self.model_patch.model.step(
496+
control_index, self.control_stream, args["t_emb"], args["mod_segments"], args["rope_freqs"],
497+
transformer_options=args["transformer_options"])
498+
skip[args["layout"].audio_pos.to(skip.device)] = 0
499+
out["img"].add_(skip, alpha=self.strength)
500+
return out
501+
502+
def to(self, device_or_dtype):
503+
if isinstance(device_or_dtype, torch.device):
504+
if self.control_latent is not None:
505+
self.control_latent = self.control_latent.to(device_or_dtype)
506+
self.control_stream = None
507+
return self
508+
509+
def cleanup(self):
510+
self.control_latent = None
511+
self.control_latent_shape = None
512+
self.control_stream = None
513+
self.active = False
514+
515+
def models(self):
516+
return [self.model_patch]
517+
518+
def register(self, model):
519+
model.add_wrapper(comfy.patcher_extension.WrappersMP.DIFFUSION_MODEL, self.diffusion_model_wrapper)
520+
for block_index in self.model_patch.model.injection_layers:
521+
blocks_replace = model.model_options.get("transformer_options", {}).get("patches_replace", {}).get("dit", {})
522+
previous = blocks_replace.get(("double_block", block_index))
523+
model.set_model_patch_replace(
524+
MiniMaxH3FunControlBlockPatch(self, block_index, previous), "dit", "double_block", block_index)
525+
526+
527+
class MiniMaxH3FunControlBlockPatch:
528+
def __init__(self, control_patch, block_index, previous):
529+
self.control_patch = control_patch
530+
self.block_index = block_index
531+
self.previous = previous
532+
533+
def __call__(self, args, extra_args):
534+
self.control_patch.before_block(self.block_index, args)
535+
if self.previous is None:
536+
out = extra_args["original_block"](args)
537+
else:
538+
out = self.previous(args, extra_args)
539+
return self.control_patch.after_block(self.block_index, args, out)
540+
541+
def to(self, device_or_dtype):
542+
self.control_patch.to(device_or_dtype)
543+
if hasattr(self.previous, "to"):
544+
self.previous = self.previous.to(device_or_dtype)
545+
return self
546+
547+
def cleanup(self):
548+
self.control_patch.cleanup()
549+
if hasattr(self.previous, "cleanup"):
550+
self.previous.cleanup()
551+
552+
def models(self):
553+
models = self.control_patch.models()
554+
if hasattr(self.previous, "models"):
555+
models += self.previous.models()
556+
return models
557+
558+
559+
class MiniMaxH3FunControlNetApply(io.ComfyNode):
560+
@classmethod
561+
def define_schema(cls):
562+
return io.Schema(
563+
node_id="MiniMaxH3FunControlNetApply",
564+
description="Apply a MiniMax H3 Fun ControlNet to a text-to-video model as a model patch.",
565+
display_name="Apply MiniMax H3 Fun ControlNet",
566+
search_aliases=["minimax controlnet", "h3 controlnet", "video inpaint controlnet"],
567+
category="model/patch/minimax",
568+
inputs=[
569+
io.Model.Input("model"),
570+
io.ModelPatch.Input("model_patch"),
571+
io.Vae.Input("vae"),
572+
io.Float.Input("strength", default=1.0, min=0.0, max=10.0, step=0.01),
573+
io.Float.Input("start_percent", default=0.0, min=0.0, max=1.0, step=0.001, advanced=True),
574+
io.Float.Input("end_percent", default=1.0, min=0.0, max=1.0, step=0.001, advanced=True),
575+
io.Image.Input("control_video", optional=True),
576+
io.Mask.Input("mask", optional=True, tooltip="1 marks the regions to regenerate."),
577+
io.Image.Input("source_video", optional=True, tooltip="Video behind the mask; only read when a mask is given."),
578+
],
579+
outputs=[io.Model.Output()],
580+
)
581+
582+
@classmethod
583+
def execute(cls, model, model_patch, vae, strength, start_percent, end_percent,
584+
control_video=None, mask=None, source_video=None) -> io.NodeOutput:
585+
if strength == 0 or (control_video is None and mask is None):
586+
return io.NodeOutput(model)
587+
588+
model_patched = model.clone()
589+
model_sampling = model.get_model_object("model_sampling")
590+
patch = MiniMaxH3FunControlPatch(
591+
model_patch,
592+
vae,
593+
control_video[..., :3].movedim(-1, 1) if control_video is not None else None,
594+
mask,
595+
source_video[..., :3].movedim(-1, 1) if mask is not None and source_video is not None else None,
596+
strength,
597+
float(model_sampling.percent_to_sigma(start_percent)),
598+
float(model_sampling.percent_to_sigma(end_percent)),
599+
)
600+
patch.register(model_patched)
601+
return io.NodeOutput(model_patched)
602+
603+
402604
class MiniMaxH3Extension(ComfyExtension):
403605
async def get_node_list(self):
404606
return [
@@ -407,6 +609,7 @@ async def get_node_list(self):
407609
MiniMaxH3AddGuide,
408610
MiniMaxH3ReferenceToVideo,
409611
MiniMaxH3SigmaShift,
612+
MiniMaxH3FunControlNetApply,
410613
]
411614

412615

comfy_extras/nodes_model_patch.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
13
import torch
24
from torch import nn
35
import folder_paths
@@ -9,6 +11,7 @@
911
import comfy.ldm.lumina.controlnet
1012
import comfy.ldm.supir.supir_modules
1113
import comfy.ldm.anima.lllite
14+
import comfy.ldm.minimax.controlnet
1215
import comfy.ldm.wan.uni3c
1316
import comfy.ldm.lightricks.duration_head
1417
from comfy.ldm.wan.model_multitalk import WanMultiTalkAttentionBlock, MultiTalkAudioProjModel
@@ -266,6 +269,48 @@ def load_model_patch(self, name):
266269
if torch.count_nonzero(ref_weight) == 0:
267270
config['broken'] = True
268271
model = comfy.ldm.lumina.controlnet.ZImage_Control(device=comfy.model_management.unet_offload_device(), dtype=dtype, operations=comfy.ops.manual_cast, **config)
272+
elif comfy.ldm.minimax.controlnet.is_minimax_h3_fun_state_dict(sd):
273+
load_device = comfy.model_management.get_torch_device()
274+
quant = comfy.utils.detect_layer_quantization(sd, "")
275+
if quant is not None:
276+
dtype = torch.bfloat16
277+
operations = comfy.ops.mixed_precision_ops(quant, dtype)
278+
else:
279+
dtype = comfy.model_management.unet_dtype(
280+
model_params=-1,
281+
supported_dtypes=[torch.bfloat16, torch.float32],
282+
weight_dtype=comfy.utils.weight_dtype(sd),
283+
)
284+
manual_cast_dtype = comfy.model_management.unet_manual_cast(
285+
dtype, load_device, supported_dtypes=[torch.bfloat16, torch.float32])
286+
operations = comfy.ops.pick_operations(dtype, manual_cast_dtype)
287+
288+
num_blocks = 0
289+
while "control_blocks.{}.after_proj.weight".format(num_blocks) in sd:
290+
num_blocks += 1
291+
injection_layers = tuple(range(0, num_blocks * 10, 10))
292+
if metadata is not None and "control_blocks_places" in metadata:
293+
injection_layers = tuple(json.loads(metadata["control_blocks_places"]))
294+
if len(injection_layers) != num_blocks:
295+
raise ValueError("MiniMax H3 Fun control_blocks_places metadata does not match the checkpoint")
296+
qkv = sd["control_blocks.0.attn.qkv_proj.weight"]
297+
head_dim = sd["control_blocks.0.attn.q_norm.weight"].shape[0]
298+
use_adaln_curves = metadata is not None and metadata.get("minimax_h3_fun_controlnet") == "adaln_basis"
299+
time_embed_dim = 8 if use_adaln_curves else 2688
300+
model = comfy.ldm.minimax.controlnet.MiniMaxH3FunControl(
301+
control_in_dim=49,
302+
injection_layers=injection_layers,
303+
hidden_size=sd["control_proj_in.weight"].shape[0],
304+
num_attention_heads=qkv.shape[0] // (3 * head_dim),
305+
attention_head_dim=head_dim,
306+
ffn_hidden_size=sd["control_blocks.0.mlp.fc1.weight"].shape[0] // 2,
307+
time_embed_dim=time_embed_dim,
308+
use_adaln_curves=use_adaln_curves,
309+
operations=operations,
310+
device=comfy.model_management.unet_offload_device(),
311+
dtype=dtype,
312+
)
313+
model.requires_grad_(False)
269314
elif 'controlnet_patch_embedding.weight' in sd: # Uni3C controlnet for Wan
270315
attn_key_replace = {".self_attn.to_q.": ".self_attn.q.",
271316
".self_attn.to_k.": ".self_attn.k.",

0 commit comments

Comments
 (0)