feat(detailing): load MiniMax H3's latent upscaler natively

ComfyUI has no branch for this architecture, so the only published 24-channel
upsampler needed its own node pack — and second_pass_op on H3 needs a 24-channel
one to run. Shape and statistics come off the checkpoint; it owns its normalisation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-21 00:03:43 +03:00
co-authored by Claude Opus 5
parent d4a69eb873
commit a9d2c0d9f0
4 changed files with 341 additions and 2 deletions
+10
View File
@@ -15,6 +15,16 @@
backend installed on its own. Kernel and block map vendored from LightX2V (Apache-2.0) via
ComfyUI-H3-SLA-Attention (MIT).
- **MiniMax H3's latent upscaler loads without its custom node pack**, so `second_pass_op`
and segmented detailing work on H3 — the operation needs an upsampler whose latents are
the model's width, and the only published 24-channel one shipped behind its own node.
Architecture and normalisation statistics are read off the checkpoint; verified
bit-identical against the reference implementation.
- **H3 scenes continue from the previous one.** The previous scene's last latent frame
becomes the next scene's frame-0 keyframe pin — the only continuity conditioning H3
accepts. A carried latent tail is not conditioning on this model, so the seam matched
while the rest of the shot knew nothing about the scene before it.
### Fixed
- **Multi-scene chains work with a second pass again.** `second_pass_op="upscale_2x"` hands
back a scene at twice the latent size, but every later scene is still built from the latent
+16 -2
View File
@@ -204,6 +204,16 @@ def load_latent_upsampler(model_name):
model_path = folder_paths.get_full_path_or_raise("latent_upscale_models", model_name)
sd, metadata = comfy.utils.load_torch_file(model_path, safe_load=True, return_metadata=True)
try:
from . import h3_latent_upscaler as _h3up
except ImportError:
import h3_latent_upscaler as _h3up
if _h3up.is_h3_latent_upscaler(sd):
# MiniMax H3's own upscaler. ComfyUI has no branch for this architecture, so
# without this the only published 24-channel upsampler needs a custom node pack —
# and second_pass_op on H3 needs a 24-channel one to run at all.
return _h3up.from_state_dict(sd, dtype=comfy.model_management.vae_dtype(
allowed_dtypes=[torch.bfloat16, torch.float32]))
if "post_upsample_res_blocks.0.conv2.bias" not in sd:
# Not Lightricks' architecture. ComfyUI's own loader knows the others (and will know
# the next one), so hand it over rather than declaring the file unusable here.
@@ -472,14 +482,18 @@ def _run_upsampler(upsampler, video_crop, vae, debug=False):
device = comfy.model_management.get_torch_device()
model_dtype = next(module.parameters()).dtype
in_dtype, in_device = video_crop.dtype, video_crop.device
stats = _per_channel_stats(vae)
# An upscaler that owns its normalisation (H3's) says so, because its statistics are the
# model's own — running it through the VAE's per-channel stats instead would hand it a
# distribution it never saw.
own_norm = getattr(module, "funpack_latent_upscale", None)
stats = None if callable(own_norm) else _per_channel_stats(vae)
try:
module.to(device)
x = video_crop.to(device=device, dtype=model_dtype)
if stats is not None:
x = stats.un_normalize(x)
with torch.no_grad():
x = module(x)
x = own_norm(x, scale=2.0) if callable(own_norm) else module(x)
if stats is not None:
x = stats.normalize(x)
finally:
+222
View File
@@ -0,0 +1,222 @@
"""MiniMax H3's latent upscaler: the 3D-conv resizer, loadable without a node pack.
ComfyUI's own Load Latent Upscale Model knows three architectures (Lightricks'
LatentUpsampler and two Hunyuan SR variants) and this is a fourth, so an H3 upsampler
file could not be loaded at all — which is what blocked `second_pass_op` on H3: the
operation needs an upsampler whose latents are the same width as the model's, and the only
published 24-channel one lives behind its own custom node.
The architecture is dictated by the checkpoint, not chosen here: every module name below
exists to match a key in the published state dict, and the shape of each is read back off
the weights (`detect_config`) rather than configured. Latent normalisation uses the model's
own published per-channel statistics — H3's VAE statistics are a different thing and would
be wrong here.
Reference implementation: LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler (the companion node
for LBH-123-AI/Minimax_h3_latent_Upscaler). Reconstructed rather than vendored — that
repository ships no licence.
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
# Per-channel statistics the upscaler was trained against, from the model's own card. The
# net sees normalised latents and returns normalised latents; skipping this does not
# degrade the output so much as produce a different distribution entirely.
LATENTS_MEAN = [
0.858090341091156, -0.9606591463088989, 1.0661640167236328, -0.5090325474739075,
-0.2727581858634949, -1.3675414323806763, -0.2553254961967468, -0.26907554268836975,
-0.5376840829849243, -0.0464097298681736, 0.6657370328903198, 0.19690127670764923,
-0.5460608005523682, -0.4035342037677765, -0.23683024942874908, 0.25928452610969543,
-0.30133944749832153, 0.211341992020607, -1.1206848621368408, 0.3581933379173279,
-0.04225143790245056, 0.2604829967021942, 0.22864092886447906, 0.7056031823158264,
]
LATENTS_STD = [
1.2223774194717407, 1.2767263650894165, 1.6831774711608887, 1.7549455165863037,
1.5636216402053833, 2.194143533706665, 0.9653137922286987, 1.0569885969161987,
0.841948926448822, 0.7729952931404114, 1.8955937623977661, 0.946841835975647,
0.7996809482574463, 0.44988900423049927, 0.7197399735450745, 0.6936293244361877,
2.961095094680786, 2.7694199085235596, 3.0496184825897217, 2.1088054180145264,
3.276226282119751, 3.1627357006073, 2.2816812992095947, 2.6127843856811523,
]
_EMBED_DIM = 64
_GROUPS = 32
def _norm(channels):
return nn.GroupNorm(_GROUPS, channels)
class _ResBlockEmb3D(nn.Module):
"""Residual block conditioned on the scale factor (FiLM-style shift/scale)."""
def __init__(self, channels, emb_channels, out_channels=None):
super().__init__()
self.out_channels = out_channels or channels
self.in_layers = nn.Sequential(
_norm(channels), nn.SiLU(), nn.Conv3d(channels, self.out_channels, 3, padding=1))
self.emb_layers = nn.Sequential(
nn.SiLU(), nn.Linear(emb_channels, 2 * self.out_channels))
self.out_norm = _norm(self.out_channels)
self.out_layers = nn.Sequential(
nn.SiLU(), nn.Dropout(p=0.0),
nn.Conv3d(self.out_channels, self.out_channels, 3, padding=1))
self.skip = (nn.Conv3d(channels, self.out_channels, 1)
if self.out_channels != channels else nn.Identity())
def forward(self, x, emb):
h = self.in_layers(x)
emb_out = self.emb_layers(emb).type(h.dtype)
while emb_out.dim() < h.dim():
emb_out = emb_out[..., None]
scale, shift = torch.chunk(emb_out, 2, dim=1)
h = self.out_norm(h) * (1 + scale) + shift
return self.skip(x) + self.out_layers(h)
class _TemporalConv(nn.Module):
"""Depthwise conv along time only — what makes this a video upscaler rather than a
per-frame image one, and why an upscaled clip does not shimmer frame to frame."""
def __init__(self, channels, kernel_size=5):
super().__init__()
self.norm = _norm(channels)
self.dwconv = nn.Conv3d(channels, channels, kernel_size=(kernel_size, 1, 1),
padding=(kernel_size // 2, 0, 0), groups=channels)
self.pwconv = nn.Conv3d(channels, channels, kernel_size=1)
def forward(self, x):
return x + self.pwconv(self.dwconv(F.silu(self.norm(x))))
class H3LatentResizer3D(nn.Module):
"""Refine, resample, refine. The resample is a plain trilinear interpolation; the
learned part is what the blocks do on either side of it."""
def __init__(self, in_channels=24, in_blocks=12, out_blocks=12, channels=512,
temporal_every=2, temporal_kernel=5):
super().__init__()
self.in_channels = in_channels
self.conv_in = nn.Conv3d(in_channels, channels, 3, padding=1)
self.embed = nn.Sequential(
nn.Linear(1, _EMBED_DIM), nn.SiLU(), nn.Linear(_EMBED_DIM, _EMBED_DIM))
def stack(count):
blocks = nn.ModuleList()
for index in range(count):
blocks.append(_ResBlockEmb3D(channels, _EMBED_DIM))
if temporal_every > 0 and index % temporal_every == 0:
blocks.append(_TemporalConv(channels, temporal_kernel))
return blocks
# Interleaved in one flat list, so the index a key names depends on this exact
# construction order — it is part of the format, not a style choice.
self.in_blocks = stack(in_blocks)
self.out_blocks = stack(out_blocks)
self.norm_out = _norm(channels)
self.conv_out = nn.Conv3d(channels, in_channels, 3, padding=1)
def _run(self, blocks, x, emb):
for block in blocks:
if isinstance(block, _ResBlockEmb3D):
x = block(x, emb.expand(x.shape[0], -1))
else:
x = block(x)
return x
def forward(self, x, scale=None, target_size=None):
if target_size is not None:
size = tuple(int(v) for v in target_size)
elif scale is not None:
size = tuple(int(round(dim * scale)) for dim in x.shape[-3:])
else:
return x
if size == tuple(x.shape[-3:]):
return x
# The block conditioning is scale-1, so an unchanged size embeds as zero.
embed_in = torch.tensor([[0.0 if scale is None else float(scale) - 1.0]],
dtype=x.dtype, device=x.device)
emb = self.embed(embed_in)
x = self._run(self.in_blocks, self.conv_in(x), emb)
x = F.interpolate(x, size=size, mode="trilinear", align_corners=False)
x = self._run(self.out_blocks, x, emb)
return self.conv_out(F.silu(self.norm_out(x)))
# Marks this as an upscaler that owns its normalisation and takes a scale factor,
# so the caller does not have to know which architecture it loaded.
def funpack_latent_upscale(self, x, scale=2.0):
mean = torch.tensor(LATENTS_MEAN, dtype=x.dtype, device=x.device).view(1, -1, 1, 1, 1)
std = torch.tensor(LATENTS_STD, dtype=x.dtype, device=x.device).view(1, -1, 1, 1, 1)
if mean.shape[1] != x.shape[1]:
raise ValueError(
f"this upscaler's statistics are {mean.shape[1]}-channel and the latent is "
f"{x.shape[1]}-channel")
# Spatial only: H3's video latent is on a 5k+2 time grid and resampling it would
# leave a frame count the VAE has no defined decode for.
target = (int(x.shape[2]), int(round(x.shape[3] * scale)), int(round(x.shape[4] * scale)))
out = self((x - mean) / std, scale=scale, target_size=target)
return out * std + mean
def is_h3_latent_upscaler(sd):
"""Whether a state dict is this architecture. Checked before ComfyUI's own loader,
which has no branch for it and fails with a Python error rather than a diagnosis."""
sd = strip_prefix(sd)
return "conv_in.weight" in sd and any(k.startswith("in_blocks.") for k in sd) \
and "post_upsample_res_blocks.0.conv2.bias" not in sd
def strip_prefix(sd):
"""Some checkpoints wrap the net under `upscaler.` (and a `model` top-level key)."""
if isinstance(sd, dict) and "model" in sd and isinstance(sd["model"], dict):
sd = sd["model"]
if any(str(k).startswith("upscaler.") for k in sd):
return {k[len("upscaler."):]: v for k, v in sd.items() if str(k).startswith("upscaler.")}
return sd
def detect_config(sd):
"""Read the architecture back off the weights — width, depth and temporal layout are
all recoverable, so there is no config to carry and no version to get wrong."""
import re
config = {"in_channels": 24, "in_blocks": 12, "out_blocks": 12, "channels": 512,
"temporal_every": 2, "temporal_kernel": 5}
conv_in = sd.get("conv_in.weight")
if conv_in is not None:
config["channels"] = int(conv_in.shape[0])
config["in_channels"] = int(conv_in.shape[1])
res, temporal = {"in": set(), "out": set()}, False
for key in sd:
match = re.match(r"(in|out)_blocks\.(\d+)\.in_layers\.", str(key))
if match:
res[match.group(1)].add(int(match.group(2)))
if str(key).endswith("dwconv.weight"):
temporal = True
config["temporal_kernel"] = int(sd[key].shape[2])
if res["in"]:
config["in_blocks"] = len(res["in"])
if res["out"]:
config["out_blocks"] = len(res["out"])
if not temporal:
config["temporal_every"] = 0
return config
def from_state_dict(sd, dtype=None):
"""Build the resizer this state dict describes and load it."""
sd = strip_prefix(sd)
if any(".q.weight" in str(k) or ".proj_out.weight" in str(k) for k in sd):
raise ValueError(
"this H3 latent upscaler has attention blocks, which this loader does not build "
"— use the companion custom node for that checkpoint")
model = H3LatentResizer3D(**detect_config(sd))
model.load_state_dict(sd, strict=True)
model.eval().requires_grad_(False)
if dtype is not None:
model = model.to(dtype=dtype)
return model
+93
View File
@@ -0,0 +1,93 @@
"""MiniMax H3's latent upscaler, loaded without its custom node pack.
The architecture is dictated by the checkpoint, so the tests that matter are the ones that
prove a state dict round-trips: build a small net, save its keys, rebuild from them alone,
and load strictly. If the module names or the block interleaving drift, that fails.
"""
import sys
from pathlib import Path
import pytest
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import h3_latent_upscaler as h3up # noqa: E402
def _small(**kw):
cfg = dict(in_channels=24, in_blocks=2, out_blocks=2, channels=32,
temporal_every=2, temporal_kernel=5)
cfg.update(kw)
return h3up.H3LatentResizer3D(**cfg)
def test_the_architecture_is_read_back_off_the_weights():
"""No config travels with these files, so every dimension has to be recoverable."""
sd = _small(in_blocks=3, out_blocks=2, channels=64, temporal_kernel=3).state_dict()
cfg = h3up.detect_config(sd)
assert cfg == {"in_channels": 24, "in_blocks": 3, "out_blocks": 2, "channels": 64,
"temporal_every": 2, "temporal_kernel": 3}
def test_a_checkpoint_without_temporal_convs_is_detected_as_such():
sd = _small(temporal_every=0).state_dict()
assert h3up.detect_config(sd)["temporal_every"] == 0
def test_a_state_dict_rebuilds_into_the_same_net_strictly():
"""strict=True is the point: a silently-renamed module would load as random weights."""
original = _small(in_blocks=3, channels=64)
rebuilt = h3up.from_state_dict(original.state_dict())
assert isinstance(rebuilt, h3up.H3LatentResizer3D)
for key, tensor in original.state_dict().items():
assert torch.equal(rebuilt.state_dict()[key], tensor)
def test_the_wrapper_prefix_some_checkpoints_use_is_stripped():
sd = {"upscaler." + k: v for k, v in _small().state_dict().items()}
assert h3up.is_h3_latent_upscaler(sd)
assert h3up.from_state_dict(sd) is not None
def test_it_is_told_apart_from_the_architectures_comfyui_already_knows():
assert not h3up.is_h3_latent_upscaler({"post_upsample_res_blocks.0.conv2.bias": None,
"conv_in.weight": None, "in_blocks.0.x": None})
assert not h3up.is_h3_latent_upscaler({"blocks.0.block.0.conv.weight": None})
assert h3up.is_h3_latent_upscaler(_small().state_dict())
def test_upscaling_doubles_the_spatial_axes_and_leaves_time_alone():
"""H3's video latent is on a 5k+2 time grid — resampling time would leave a frame count
the VAE has no defined decode for."""
model = _small()
latent = torch.randn(1, 24, 3, 8, 12)
out = model.funpack_latent_upscale(latent, scale=2.0)
assert out.shape == (1, 24, 3, 16, 24)
def test_a_latent_of_the_wrong_width_is_refused_by_name():
"""LTX latents are 128-channel; this model's statistics are 24."""
model = _small()
with pytest.raises(ValueError) as exc:
model.funpack_latent_upscale(torch.randn(1, 128, 2, 8, 8), scale=2.0)
assert "24-channel" in str(exc.value) and "128-channel" in str(exc.value)
def test_the_published_statistics_cover_every_channel():
assert len(h3up.LATENTS_MEAN) == len(h3up.LATENTS_STD) == 24
def test_the_upscaler_reports_the_latent_width_it_takes():
"""detailing refuses a mismatched upsampler by naming both numbers; that check reads
the width off this model."""
import detailing
assert detailing.upsampler_in_channels(_small()) == 24
def test_an_attention_checkpoint_says_what_to_do_instead_of_loading_wrong():
sd = _small().state_dict()
sd["in_blocks.0.q.weight"] = torch.zeros(1)
with pytest.raises(ValueError) as exc:
h3up.from_state_dict(sd)
assert "custom node" in str(exc.value)