Files
olivv-cs-ComfyUI-FunPack/minimax_h3.py
T
digital-garbage 54fa8e4b43 feat(h3): region-locked anchors, and the taste direction lands after the refiner
Region lock: a keyframe pin can cover part of a frame. Transparent areas of an
i2v anchor are dropped from the packed sequence entirely — not blanked, not
noised, absent — so the model invents them. Row deletion re-derives the layout
from upstream's own output; two patches share one keep-mask so they cannot
disagree. Surfaced through the anchor's alpha, which needs no new wire and works
in the Editor. Excluded pixels are filled with the kept area's mean before the
VAE so no hard edge bleeds in. EXPERIMENTAL: fl2va saw whole condition frames.

Taste bias: the liked direction Studio already learns is now also added AFTER
H3's token refiner, where it lands as sent instead of being mixed across tokens
and normalized away. Relative to the span's own row norm, so one strength means
the same thing on every prompt. How far to push is a fifth learned gain,
centred on 0 and signed; h3_taste_bias is the manual override. Needs 3 liked
runs.

Gain state generalized to per-key centres, bounds and exploration width.

Tests: 1608 passed (+46). Untested on a rental.
2026-08-25 21:48:28 +03:00

1502 lines
67 KiB
Python

"""MiniMax-H3 adapter — the one place that knows how H3 differs from LTXAV.
FunPack was built against LTX2.3/LTXAV, and supports BOTH families side by side:
nothing here changes an LTX run, and every entry point answers "not H3" when the
model in hand is an LTX one. H3 (merged as ComfyUI 57500fc5, PR #15224, shipped
in ComfyUI v0.30.0) is close enough that most of the stack works untouched, and
different in four ways that break silently if nobody accounts for them:
1. **Audio latent axis.** LTXAV's audio latent is ``[B, C, T, freq]`` — time on
dim 2, the same axis as the video latent's frames, which is why the Chain
Sampler could slice every stream at ``[:, :, a:b]``. H3's audio latent is
``[B, 32, stereo=2, T]`` — dim 2 is the STEREO CHANNEL. Slicing it as time
silently crops one speaker instead of trimming the tail. Both are 4-D, so the
tensor alone cannot tell you which; it has to come from the model.
2. **Frame grid.** LTXAV is ``8k+1`` pixel frames -> ``k+1`` latent frames. H3 is
``17k+5`` -> ``5k+2`` (and the VAE reports ``downscale_index_formula = (4,16,16)``,
which is the *index* map, not the count map — reading a frame count off it is
off by ~20%). The count always lives in ``vae.downscale_ratio[0]``.
3. **No cross-attention.** H3 is a single packed self-attention stream
``[text | cond/ref | audio | video]``. There is no ``attn2``, no ``audio_attn2``,
and no audio text-channel half — so the LTXAV conditioning channel split is a
no-op here (correctly: the whole tensor conditions both streams).
4. **Batch size 1.** ``MiniMaxH3Model._forward`` raises on ``x.shape[0] != 1``,
and comfy batches cond+uncond into one forward whenever ``cfg != 1.0``. Left
alone that makes every CFG generation crash; :func:`install_batch_split`
splits the batch back apart around the model call.
Nothing here imports the H3 core modules at module scope: an older ComfyUI (or an
LTX-only install) has none of them, and every entry point has to degrade to "not
H3" rather than raise on import.
WEIGHTS COME IN TWO CHECKPOINTS, and they are not interchangeable:
``minimax_h3_fl2va_*`` is the t2v / first-last-frame model (keyframe pins) and
``minimax_h3_ref2va_*`` is the reference model (reference blocks). Both load
through the same detection and produce a structurally valid graph either way, so
nothing crashes when the wrong one is loaded — it just conditions badly. The DiT
state dict carries no variant marker, so :func:`checkpoint_mode_note` reports
which mode a run is actually using and lets the user match the file.
"""
from __future__ import annotations
import math
import torch
# ── the model's own constants (comfy/ldm/minimax/model.py, nodes_minimax_h3.py) ──
IMAGE_MODEL = "minimax_h3"
FPS = 24 # H3 generates at a fixed 24 fps
FRAME_GRID = 17 # pixel frames per grid step ...
FRAME_BASE = 5 # ... on top of this base (frame_count % 17 == 5)
LATENT_GRID = 5 # latent frames per grid step ...
LATENT_BASE = 2 # ... on top of this base
AUDIO_LATENT_FPS = 40 # audio latent frames per second
SPATIAL_DOWNSCALE = 16
VIDEO_LATENT_CHANNELS = 24
AUDIO_LATENT_CHANNELS = 32
CANVAS_MULTIPLE = 32 # width/height must be a multiple of this
REF_IMAGE_SHORT_EDGE = 2048 # the reference pipeline's own "max" sizing
DEFAULT_SHIFT_VIDEO = 12.0
DEFAULT_SHIFT_AUDIO = 3.0
# transformer_options keys MiniMaxH3SigmaShift sets. The DiT inverts the VIDEO shift to
# its base grid to derive the audio timestep AND the audio velocity slope, so a schedule
# built on a different shift has to say so here or the audio stream is integrated against
# the wrong clock. Absent, the DiT falls back to its own 12.0 / 3.0.
SHIFT_VIDEO_OPTION = "minimax_h3_sigma_shift_video"
SHIFT_AUDIO_OPTION = "minimax_h3_sigma_shift_audio"
# Audio rows are [B, 32, stereo, T]: time is the LAST axis, not dim 2.
AUDIO_TIME_DIM = -1
VIDEO_TIME_DIM = 2
# ── the audio clock ──────────────────────────────────────────────────────────
# H3 denoises video and audio on two different flow schedules (shift 12 / shift 3).
# Only ONE sigma grid reaches a sampler, so comfy reconciles them inside the DiT: it
# returns the audio velocity pre-multiplied by d(sigma_a)/d(sigma_v), which makes the
# flat ODE a sampler integrates on the video grid equal to the audio stream's true ODE
# on its own grid (comfy/ldm/minimax/model.py, final lines of _forward).
#
# That identity holds in the CONTINUOUS limit. The derivative is the tangent at the
# start of the step, and the two schedules diverge hard, so on a big step the tangent
# badly overshoots the chord it is standing in for. On a 4-step shift-12 schedule the
# video grid ~[1, .973, .923, .8, 0] maps to audio ~[1, .9, .75, .5, 0]; the final step's
# tangent is 1.56 where the true chord is 0.63 — the audio is driven ~2.5x past its
# target and comes out distorted. At 20+ steps the two agree to within a percent, which
# is why this only shows up on distilled / turbo few-step schedules.
#
# The fix is to integrate the audio on its own grid: swap the tangent for the chord that
# actually spans the step. One scalar per step, no extra model evaluation.
_AUDIO_CLOCK_MAX_FACTOR = 16.0
def time_shift_sigma(sigma, from_shift, to_shift):
"""Re-express a sigma from one flow schedule on another. Mirrors the DiT's own."""
sigma = float(sigma)
from_shift = float(from_shift)
to_shift = float(to_shift)
denom = from_shift + sigma * (1.0 - from_shift)
if abs(denom) < 1e-12:
return sigma
base = sigma / denom
return to_shift * base / (1.0 + (to_shift - 1.0) * base)
def time_shift_slope(sigma, from_shift, to_shift):
"""d(sigma_to)/d(sigma_from) at the same base-grid point. Mirrors the DiT's own."""
sigma = float(sigma)
from_shift = float(from_shift)
to_shift = float(to_shift)
denom = from_shift + sigma * (1.0 - from_shift)
if abs(denom) < 1e-12 or abs(from_shift) < 1e-12:
return 1.0
base = sigma / denom
lower = from_shift * (1.0 + (to_shift - 1.0) * base) ** 2
if abs(lower) < 1e-12:
return 1.0
return (to_shift * (1.0 + (from_shift - 1.0) * base) ** 2) / lower
def audio_clock_factors(sigmas, shift_video=DEFAULT_SHIFT_VIDEO, shift_audio=DEFAULT_SHIFT_AUDIO):
"""Per-step correction for the audio stream's displacement, one float per step.
The sampler moves audio by ``slope(sigma_v) * v_a * (sigma_v_next - sigma_v)``.
The audio's own schedule wants ``v_a * (sigma_a_next - sigma_a)``. Multiplying the
audio part of the step by
(sigma_a_next - sigma_a) / (slope(sigma_v) * (sigma_v_next - sigma_v))
turns the first into the second exactly, for any sampler that moves the stream by a
velocity times the step. Returns ``len(sigmas) - 1`` factors; 1.0 wherever the
correction is undefined (zero-length step, degenerate slope), so it no-ops rather
than dividing by ~0.
Every factor is 1.0 when the two shifts are equal — the schedules coincide and
there is nothing to correct.
"""
out = []
values = [float(s) for s in sigmas]
for i in range(max(0, len(values) - 1)):
sv, sv_next = values[i], values[i + 1]
dv = sv_next - sv
slope = time_shift_slope(sv, shift_video, shift_audio)
if abs(dv) < 1e-9 or abs(slope) < 1e-9:
out.append(1.0)
continue
da = (time_shift_sigma(sv_next, shift_video, shift_audio)
- time_shift_sigma(sv, shift_video, shift_audio))
factor = da / (slope * dv)
# With the stock shifts (video 12 > audio 3) the map is convex, the tangent is
# taken at the step's high-sigma end, and the factor lands in (0, 1] — it always
# SHORTENS the audio step. Shifts the other way round invert that and the factor
# exceeds 1, which is correct and must not be clamped away. The bound here is a
# numerical sanity rail only, wide enough never to bind on a real schedule.
out.append(float(min(_AUDIO_CLOCK_MAX_FACTOR, max(0.0, factor))))
return out
def resolve_sigma_shifts(model_options):
"""The (video, audio) flow shifts in force, from a model_options dict.
MiniMaxH3SigmaShift writes both into transformer_options; without that node the DiT
falls back to its own defaults, and so do we.
"""
to = {}
if isinstance(model_options, dict):
candidate = model_options.get("transformer_options")
if isinstance(candidate, dict):
to = candidate
try:
shift_v = float(to.get(SHIFT_VIDEO_OPTION, DEFAULT_SHIFT_VIDEO))
except (TypeError, ValueError):
shift_v = DEFAULT_SHIFT_VIDEO
try:
shift_a = float(to.get(SHIFT_AUDIO_OPTION, DEFAULT_SHIFT_AUDIO))
except (TypeError, ValueError):
shift_a = DEFAULT_SHIFT_AUDIO
return shift_v, shift_a
# ── detection ────────────────────────────────────────────────────────────────
# All three of these have to work when the H3 PR is not installed at all, so they
# only ever read attributes that any comfy object has.
def _unet_config(model):
inner = getattr(model, "model", None)
cfg = getattr(inner, "model_config", None)
return getattr(cfg, "unet_config", {}) or {}
def is_h3_model(model) -> bool:
"""True for a ModelPatcher (or BaseModel) wrapping the MiniMax H3 DiT."""
if model is None:
return False
try:
inner = getattr(model, "model", None)
if type(inner).__name__ == "MiniMaxH3":
return True
if type(model).__name__ == "MiniMaxH3":
return True
if str(_unet_config(model).get("image_model", "")).lower() == IMAGE_MODEL:
return True
diff = getattr(inner, "diffusion_model", None)
if type(diff).__name__ == "MiniMaxH3Model":
return True
except Exception:
pass
return False
def is_h3_clip(clip) -> bool:
"""True for the Qwen3-VL-32B conditioning encoder H3 ships with."""
if clip is None:
return False
try:
tok = getattr(clip, "tokenizer", None)
if type(tok).__name__ == "MiniMaxH3Tokenizer":
return True
# SD1Tokenizer names its sub-tokenizer after the TE; H3's is unique.
if tok is not None and hasattr(tok, "qwen3vl_32b"):
return True
cond_stage = getattr(clip, "cond_stage_model", None)
if type(cond_stage).__name__ in ("MiniMaxH3TEModel", "MiniMaxH3TEModel_"):
return True
except Exception:
pass
return False
def is_h3_video_vae(vae) -> bool:
if vae is None:
return False
try:
return type(getattr(vae, "first_stage_model", None)).__name__ == "MiniMaxH3VideoVAE"
except Exception:
return False
def is_h3_audio_vae(vae) -> bool:
if vae is None:
return False
try:
return type(getattr(vae, "first_stage_model", None)).__name__ == "MiniMaxH3AudioVAE"
except Exception:
return False
# ── frame-grid geometry ──────────────────────────────────────────────────────
def align_frame_count(length: int) -> int:
"""Snap a pixel frame count UP to the model's 17k+5 grid (the node's rule)."""
n = max(FRAME_BASE, int(length))
while n % FRAME_GRID != FRAME_BASE:
n += 1
return n
def video_latent_frames(frame_count: int) -> int:
"""Pixel frames -> video latent frames, for an already-aligned count."""
n = int(frame_count)
if n <= FRAME_BASE:
return LATENT_BASE
return ((n - FRAME_BASE) // FRAME_GRID) * LATENT_GRID + LATENT_BASE
def audio_latent_frames(frame_count: int) -> int:
"""Pixel frames -> audio latent frames (40 Hz against a 24 fps video clock)."""
return int(round(int(frame_count) / float(FPS) * AUDIO_LATENT_FPS))
def temporal_shape(length: int):
"""(aligned pixel frames, video latent frames, audio latent frames)."""
frames = align_frame_count(length)
return frames, video_latent_frames(frames), audio_latent_frames(frames)
def latent_frames_from_vae(vae, pixel_frames: int):
"""Video latent frame count for `pixel_frames`, asked of the VAE itself.
``vae.downscale_ratio[0]`` is the count map every video VAE in comfy carries
(LTX: ``ceil(n/8)``; H3: ``(n-5)//17*5+2``). Reading it beats hardcoding a
per-family formula and beats ``downscale_index_formula``, which is the index
map and gives the wrong count on H3. Returns None when the VAE doesn't
expose one, so callers can keep their old fallback.
"""
try:
ratio = getattr(vae, "downscale_ratio", None)
if isinstance(ratio, (list, tuple)) and ratio and callable(ratio[0]):
return max(1, int(ratio[0](max(1, int(pixel_frames)))))
if isinstance(ratio, (int, float)) and ratio:
return ((max(1, int(pixel_frames)) - 1) // max(1, int(ratio))) + 1
except Exception:
pass
return None
def adapt_canvas(width: int, height: int, base_short_edge=768, max_pixels=768 * 1344):
"""H3's reference canvas rule: 768 short edge, area-capped, rounded to 32."""
width = max(1, int(width))
height = max(1, int(height))
ratio = width / height
if ratio >= 1.0:
nom_w, nom_h = base_short_edge * ratio, float(base_short_edge)
else:
nom_w, nom_h = float(base_short_edge), base_short_edge / ratio
if nom_w * nom_h > max_pixels:
s = math.sqrt(max_pixels / (nom_w * nom_h))
nom_w, nom_h = nom_w * s, nom_h * s
snap = lambda v: max(CANVAS_MULTIPLE, int(round(v / CANVAS_MULTIPLE)) * CANVAS_MULTIPLE)
return snap(nom_w), snap(nom_h)
# ── per-stream time axes ─────────────────────────────────────────────────────
def stream_time_dims(stream_count: int, h3: bool):
"""Time axis per latent stream, in comfy's pack order (video first).
LTXAV: every stream carries time on dim 2. H3: video on dim 2, audio on the
last axis (dim 2 there is the stereo channel). Returned as a plain list so
callers can index it by stream position without re-deciding the family.
"""
dims = [VIDEO_TIME_DIM] * max(1, int(stream_count))
if h3:
for i in range(1, len(dims)):
dims[i] = AUDIO_TIME_DIM
return dims
def time_len(tensor, dim: int) -> int:
return int(tensor.shape[dim])
def time_slice(tensor, start, end, dim: int):
idx = [slice(None)] * tensor.dim()
idx[dim] = slice(start, end)
return tensor[tuple(idx)]
def set_time_slice(tensor, start, end, value, dim: int):
idx = [slice(None)] * tensor.dim()
idx[dim] = slice(start, end)
tensor[tuple(idx)] = value
return tensor
def time_cat(tensors, dim: int):
return torch.cat(tensors, dim=dim if dim >= 0 else tensors[0].dim() + dim)
# ── attention bridge ─────────────────────────────────────────────────────────
# LTX calls ``comfy.ldm.modules.attention.optimized_attention(...)`` through the
# module, so FunPack's temperature / capture / K-V-lock patches take effect by
# rebinding that one symbol. H3 does ``from ... import optimized_attention`` at
# module load, which snapshots the function — rebinding the source module does
# nothing. Any patch that wants to reach H3 has to rebind H3's own copy too.
def attention_patch_targets():
"""Modules whose ``optimized_attention`` symbol must be rebound together.
Always includes comfy's attention module; includes the H3 DiT module only
when the PR is installed. Callers save/restore whatever they find.
"""
targets = []
try:
import comfy.ldm.modules.attention as attn_mod
targets.append(attn_mod)
except Exception:
return targets
try:
import comfy.ldm.minimax.model as h3_mod
if hasattr(h3_mod, "optimized_attention"):
targets.append(h3_mod)
except Exception:
pass
return targets
# H3 calls ``optimized_attention`` with ``skip_reshape=True`` and q/k/v already at
# ``[1, heads, S, head_dim]``. A patch that reads ``v.shape[1]`` as a token count (the
# LTX layout) is reading the HEAD count instead, so anything indexed by token position
# has to check that kwarg before assuming a layout. Temperature is layout agnostic.
# ── batch-size-1 constraint ──────────────────────────────────────────────────
def _split_cond_batch(value, index, batch):
"""Slice one entry of the ``c`` dict down to sample `index` of `batch`."""
if isinstance(value, torch.Tensor) and value.dim() >= 1 and value.shape[0] == batch:
return value[index:index + 1]
if getattr(value, "is_nested", False):
try:
import comfy.nested_tensor
parts = [t[index:index + 1] if t.shape[0] == batch else t for t in value.unbind()]
return comfy.nested_tensor.NestedTensor(parts)
except Exception:
return value
return value
def install_batch_split(model):
"""Run the H3 DiT one sample at a time when comfy hands it a batched forward.
``MiniMaxH3Model._forward`` raises on ``x.shape[0] != 1``. In practice comfy does
NOT batch the positive and negative conds together on H3 — each carries its own
``minimax_payload`` (different token tags, different packed layout), and an unequal
``CONDConstant`` blocks the concat — so the cfg>1 path survives on its own. What
does reach the DiT batched is a latent whose batch size is >1 (several videos in one
request). Splitting here keeps that working at exactly the cost the batch already
implies, and stays correct if comfy's batching rules change.
Returns the previous ``model_function_wrapper`` so the caller can restore it.
"""
old_wrapper = model.model_options.get("model_function_wrapper")
def _call(apply_fn, args):
if old_wrapper is not None:
return old_wrapper(apply_fn, args)
return apply_fn(args["input"], args["timestep"], **args.get("c", {}))
def _batch_split_wrapper(apply_fn, args, _old=old_wrapper):
x = args.get("input")
try:
batch = int(x.shape[0]) if isinstance(x, torch.Tensor) else 1
except Exception:
batch = 1
if batch <= 1:
return _call(apply_fn, args)
outs = []
ts = args.get("timestep")
c = args.get("c") or {}
for i in range(batch):
sub = dict(args)
sub["input"] = x[i:i + 1]
if isinstance(ts, torch.Tensor) and ts.shape[0] == batch:
sub["timestep"] = ts[i:i + 1]
sub["c"] = {k: _split_cond_batch(v, i, batch) for k, v in c.items()}
outs.append(_call(apply_fn, sub))
return torch.cat(outs, dim=0)
model.model_options["model_function_wrapper"] = _batch_split_wrapper
return old_wrapper, _batch_split_wrapper
# ── conditioning payload ─────────────────────────────────────────────────────
# H3 carries its visual conditioning on the CONDITIONING dict, not in the latent:
# minimax_keyframes — fl2va first/last frame pins (never denoised)
# minimax_frame_count— pixel length, so the layout can place the last-frame pin
# minimax_refs — ref2va identity/style/audio reference blocks
# model_base.MiniMaxH3.extra_conds turns these into the DiT payload.
def _set_values(conditioning, values):
out = []
for entry in conditioning or []:
cond, meta = entry[0], (entry[1] if len(entry) > 1 else {})
new_meta = dict(meta) if isinstance(meta, dict) else {}
for key, value in values.items():
if value is None:
new_meta.pop(key, None)
else:
new_meta[key] = value
out.append([cond, new_meta])
return out
def set_keyframes(conditioning, keyframes, frame_count):
"""Attach fl2va keyframe pins. `keyframes` = [{resolved_frame_index, latent}]."""
if not keyframes:
return conditioning
return _set_values(conditioning, {
"minimax_keyframes": list(keyframes),
"minimax_frame_count": int(frame_count),
})
def set_refs(conditioning, ref_blocks):
"""Attach ref2va reference blocks (images / videos / audio)."""
if not ref_blocks:
return conditioning
return _set_values(conditioning, {"minimax_refs": list(ref_blocks)})
def checkpoint_mode_note(has_keyframes: bool, has_refs: bool):
"""One line naming the checkpoint this run's conditioning actually needs, or None.
H3 ships as two separate DiTs — ``minimax_h3_fl2va_*`` (t2v / first-last frame) and
``minimax_h3_ref2va_*`` (references). Both load through the same detection and neither
rejects the other's conditioning, so the only failure signal is a bad generation. There
is no variant marker in the state dict to check against, so the run says what it is
doing and leaves matching the file to the user.
"""
if has_keyframes and has_refs:
return ("this run uses BOTH keyframe pins and reference blocks, which live in "
"DIFFERENT checkpoints (fl2va pins / ref2va references) — whichever model "
"is loaded, one of the two is untrained conditioning. Drop the anchor image "
"or the references, or run them as separate scenes.")
if has_keyframes:
return ("keyframe pin conditioning (fl2va) — this needs a minimax_h3_fl2va_* "
"checkpoint; ref2va weights will read it, but were not trained on it.")
if has_refs:
return ("reference conditioning (ref2va) — this needs a minimax_h3_ref2va_* "
"checkpoint; fl2va weights will read it, but were not trained on it.")
return None
# ── interior keyframe pins ────────────────────────────────────────────────────
#
# A pin is a block of condition rows carrying ONE time coordinate (``cond_t``) on the
# same spatial grid as the target video, never denoised (``img_update=False``). Upstream
# ``PackedLayout`` computes that coordinate in two hard-coded branches and raises for
# anything else:
#
# pixel_index == 0 -> cond_t = text_len
# pixel_index == frame_count - 1 -> cond_t = text_len + sum(_video_t_spans(latent_t))
# - FRAME_RESCALE
#
# Those are not two special cases. They are the two ENDPOINTS of one straight line: the
# video time axis advances exactly ``FRAME_RESCALE`` per PIXEL frame, so
#
# cond_t = text_len + FRAME_RESCALE * pixel_index
#
# reproduces both, exactly, at every clip length — including the irregular
# ``FRAME_PER_TOKEN = (1, 4, 4, 4, 4)`` latent grouping, because the grouping changes how
# many pixel frames a LATENT frame covers without changing the per-pixel-frame rate.
# :func:`_linear_rule_matches_upstream` re-derives that equality from upstream's own
# constants before anything is patched, so an upstream change to the grid turns this
# feature off instead of silently mis-placing pins.
#
# What this does NOT establish is that the WEIGHTS were trained on interior pins. fl2va
# saw condition rows at t=0 and t=end and nowhere between. MM-RoPE is continuous in t, so
# an interior coordinate is representable rather than undefined, and the pin reaches the
# target through ordinary self-attention with no special-cased path — but "representable"
# is not "learned". Treat an interior pin as experimental until a run shows the frame
# actually landing.
KEYFRAME_T_PER_FRAME = 5.0 / 3.0 # upstream FRAME_RESCALE, re-checked before patching
_INTERIOR_PINS = {"state": None} # None = not tried, True/False = patch verdict
_REGION_LOCKS = {"state": None} # same, for partial-frame pins
_LAYOUT_PATCH = {"state": None} # is PackedLayout.__init__ wrapped at all
#: key a keyframe pin carries its keep-mask under (bool tensor over the frame's patch rows)
REGION_META = "funpack_region"
def keyframe_cond_t(text_len, pixel_index):
"""The packed-sequence time coordinate of a pin at ``pixel_index``."""
return float(text_len) + KEYFRAME_T_PER_FRAME * float(int(pixel_index))
def _linear_rule_matches_upstream():
"""True when :func:`keyframe_cond_t` reproduces upstream's own two branches.
Checked against ``_video_t_spans`` and ``FRAME_RESCALE`` themselves rather than
against remembered numbers, so a changed frame grid is caught here instead of
becoming pins placed at the wrong instant.
"""
from comfy.ldm.minimax.model import FRAME_PER_TOKEN, FRAME_RESCALE, _video_t_spans
if abs(float(FRAME_RESCALE) - KEYFRAME_T_PER_FRAME) > 1e-9:
return False
text_len = 7 # any value; it cancels
for latent_t in (1, 2, 5, 6, 11, 24, 25, 26):
spans = _video_t_spans(latent_t)
frame_count = sum(FRAME_PER_TOKEN[k % 5] for k in range(latent_t))
first = float(text_len)
last = float(text_len) + sum(spans) - FRAME_RESCALE
if abs(first - keyframe_cond_t(text_len, 0)) > 1e-9:
return False
if abs(last - keyframe_cond_t(text_len, frame_count - 1)) > 1e-9:
return False
return True
def _region_keep(region, n_rows):
"""The keep-mask to actually apply to a pin's ``n_rows`` patch rows, or None.
None means "keep every row", and it is returned for every case that is not an
unambiguous partial selection: no region, a mask whose length disagrees with the rows it
would filter, an empty selection, or a full one. Both sides of the patch — the layout and
the condition rows — go through this one function with the same mask and the same row
count, so they cannot disagree about which rows survive.
"""
if region is None:
return None
try:
mask = region.reshape(-1).to(torch.bool)
except Exception:
return None
if mask.numel() != int(n_rows) or not bool(mask.any()) or bool(mask.all()):
return None
return mask
def region_rows_from_alpha(image, grid_h, grid_w, threshold=0.5):
"""An RGBA guide image's alpha channel as a keep-mask over one frame's patch rows.
Transparent means "I have nothing to say about this part of the frame". Alpha is used
rather than a separate MASK input because it needs no new wire: the Editor's fixed graph
has no mask source, so a MASK socket would be a sampler-only feature, and the standing
rule is that a new sampler capability reaches the Editor in the same change.
Returns None unless the alpha is a genuine PARTIAL selection — a fully opaque image (the
overwhelmingly common case) behaves exactly as it did before this existed.
"""
try:
if getattr(image, "ndim", 0) != 4 or image.shape[-1] < 4:
return None
alpha = image[:1, ..., 3].float() # [1, H, W]
pooled = torch.nn.functional.adaptive_avg_pool2d(
alpha.unsqueeze(0), (int(grid_h), int(grid_w))) # [1, 1, gh, gw]
except Exception:
return None
mask = (pooled.reshape(-1) >= float(threshold))
if not bool(mask.any()) or bool(mask.all()):
return None
return mask
def _apply_region_locks(layout, spans, regions):
"""Drop the condition rows a pin's region excludes, and re-derive the layout around them.
A region lock is a row DELETION, not a blanked or noised row. A blanked row still says
something ("this part is flat grey") and a noised one still occupies a position the
picture attends to; a row that does not exist says nothing at all, which is what "I only
care about this part of the frame" means. Attention is dense, so the removed rows also
stop costing anything.
Everything here is re-derived from upstream's own outputs rather than rebuilt, so a
change to how upstream lays the sequence out is inherited. ``img_pos``/``audio_pos`` are
renumbered even though the current forward never reads them — they are part of the
layout's contract, and a stale one would be a trap for whatever reads them next.
"""
keep = torch.ones(layout.seq_len, dtype=torch.bool)
for (start, stop), region in zip(spans, regions):
mask = _region_keep(region, stop - start)
if mask is not None:
keep[start:stop] = mask
if bool(keep.all()):
return False
new_index = keep.cumsum(0) - 1
img_keep = keep[layout.img_pos]
audio_keep = keep[layout.audio_pos]
layout.position_ids = layout.position_ids[keep]
layout.img_update = layout.img_update[img_keep]
layout.audio_update = layout.audio_update[audio_keep]
layout.img_pos = new_index[layout.img_pos[img_keep]]
layout.audio_pos = new_index[layout.audio_pos[audio_keep]]
segments, offset = [], 0
for a, b, kind in layout.segments:
n = int(keep[a:b].sum())
if n:
segments.append((offset, offset + n, kind))
offset += n
layout.segments = segments
layout.seq_len = offset
return True
def _install_layout_patch():
"""Wrap ``PackedLayout.__init__`` once, for both interior pins and region locks.
Deliberately NOT a re-implementation of it. Upstream builds the layout exactly as it
always has — with every interior pin declared at index 0, which is always legal — and
this corrects the ``t`` column of the resulting condition rows afterwards, then removes
the rows a region excludes. Row order, latent placement, ``img_update``, the segment
table, refs and audio all stay upstream's, so upstream changes are inherited rather than
frozen into a copy here.
A run with no interior pin and no region never enters either rewrite: it calls straight
through to the original, so existing behaviour is bit-identical.
"""
if _LAYOUT_PATCH["state"] is not None:
return _LAYOUT_PATCH["state"]
try:
from comfy.ldm.minimax.model import PackedLayout
except Exception as error: # no H3 in this ComfyUI
_LAYOUT_PATCH["state"] = False
print(f"[FunPack H3] packed-layout extensions unavailable ({error}).")
return False
if getattr(PackedLayout.__init__, "_funpack_layout", False):
_LAYOUT_PATCH["state"] = True
return True
original = PackedLayout.__init__
def __init__(self, text_len, latent_t, latent_h, latent_w, audio_t,
keyframes=None, refs=None, frame_count=None):
pins = list(keyframes or ())
# Each rewrite is gated on ITS OWN verdict, not on the patch being installed. The
# two share this wrapper, so region locks can install it while the interior-pin rule
# has been refused — and a refused rule must not then start moving pins.
interior = ([kf for kf in pins
if not keyframe_is_endpoint(kf.get("resolved_frame_index"), frame_count)]
if _INTERIOR_PINS["state"] else [])
regions = ([kf.get(REGION_META) for kf in pins]
if _REGION_LOCKS["state"] else [None] * len(pins))
if not interior and not any(r is not None for r in regions):
original(self, text_len, latent_t, latent_h, latent_w, audio_t,
keyframes=keyframes, refs=refs, frame_count=frame_count)
return
# Declared at 0 so upstream's branch accepts them; the coordinate is corrected below.
safe = [dict(kf, resolved_frame_index=0) for kf in pins] if interior else keyframes
original(self, text_len, latent_t, latent_h, latent_w, audio_t,
keyframes=safe, refs=refs, frame_count=frame_count)
# Condition segments are emitted in `keyframes` order, immediately after text.
spans = [(a, b) for a, b, kind in self.segments if kind == "cond"]
if interior:
for (start, stop), kf in zip(spans, pins):
self.position_ids[start:stop, 0] = keyframe_cond_t(
text_len, kf.get("resolved_frame_index", 0))
if any(r is not None for r in regions):
_apply_region_locks(self, spans, regions)
__init__._funpack_layout = True
__init__._funpack_interior_pins = True # kept: the old marker name
PackedLayout.__init__ = __init__
_LAYOUT_PATCH["state"] = True
return True
def install_interior_keyframes():
"""Let a keyframe pin sit at any pixel frame, not only the first or the last.
Returns True when interior pins are available.
"""
if _INTERIOR_PINS["state"] is not None:
return _INTERIOR_PINS["state"]
if not _install_layout_patch():
_INTERIOR_PINS["state"] = False
return False
if not _linear_rule_matches_upstream():
_INTERIOR_PINS["state"] = False
print("[FunPack H3] interior keyframe pins DISABLED: this ComfyUI's video time grid "
"no longer matches the rule FunPack derives pin coordinates from. Pins stay "
"first/last only. (Upstream comfy/ldm/minimax/model.py changed.)")
return False
_INTERIOR_PINS["state"] = True
return True
def install_region_locks():
"""Let a keyframe pin cover PART of a frame instead of all of it.
Needs two patches that must agree exactly on which rows survive: the layout drops the
excluded rows from the packed sequence, and ``_cond_video_rows`` drops the same rows from
the condition latents. Both go through :func:`_region_keep` with the same mask, so they
cannot diverge — and if either patch cannot be installed, neither is used.
EXPERIMENTAL for the same reason interior pins are: fl2va saw whole condition frames, so
a partial one is representable (nothing in the layout or the attention requires a cond
segment to be a complete frame) without being something the weights have seen.
"""
if _REGION_LOCKS["state"] is not None:
return _REGION_LOCKS["state"]
if not _install_layout_patch():
_REGION_LOCKS["state"] = False
return False
try:
from comfy.ldm.minimax.model import MiniMaxH3Model
except Exception as error:
_REGION_LOCKS["state"] = False
print(f"[FunPack H3] region locks unavailable ({error}).")
return False
if getattr(MiniMaxH3Model._cond_video_rows, "_funpack_regions", False):
_REGION_LOCKS["state"] = True
return True
original = MiniMaxH3Model._cond_video_rows
def _cond_video_rows(self, payload, device):
rows = original(self, payload, device)
try:
if rows is None or payload.get("refs"):
# With refs present upstream rebuilds cond_video_latents from the REFS, so
# the pins no longer line up with the rows. Regions are a pin feature.
return rows
pins = payload.get("keyframes") or []
latents = payload.get("cond_video_latents") or []
if len(pins) != len(latents):
return rows
if not any(pin.get(REGION_META) is not None for pin in pins):
return rows
kept, offset = [], 0
for pin, latent in zip(pins, latents):
b, _c, t, h, w = latent.shape
n = b * t * (h // 2) * (w // 2)
span = rows[offset:offset + n]
offset += n
mask = _region_keep(pin.get(REGION_META), n)
kept.append(span if mask is None else span[mask.to(span.device)])
if offset != rows.shape[0]:
# Our row accounting disagrees with upstream's: change nothing rather than
# hand the layout a set of rows it did not plan for.
return rows
return torch.cat(kept, dim=0)
except Exception as error: # noqa: BLE001
print(f"[FunPack H3] region lock skipped on the condition rows ({error}); "
f"the pin covers the whole frame.")
return rows
_cond_video_rows._funpack_regions = True
MiniMaxH3Model._cond_video_rows = _cond_video_rows
_REGION_LOCKS["state"] = True
return True
def keyframe_is_endpoint(frame_index, frame_count):
"""True for the first or last pixel frame — the two pins upstream places unaided."""
if frame_index is None:
return False
idx = int(frame_index)
if idx == 0:
return True
return frame_count is not None and idx == int(frame_count) - 1
def keyframe_indices_supported(frame_index, frame_count):
"""Can a pin be placed at this pixel frame?
The first and last frames always can. Anything between them needs
:func:`install_interior_keyframes`, which is attempted here rather than assumed, so a
ComfyUI whose frame grid has moved refuses the pin instead of mis-placing it.
"""
idx = int(frame_index)
if idx < 0 or idx > int(frame_count) - 1:
return False
if keyframe_is_endpoint(idx, frame_count):
return True
return install_interior_keyframes()
def encode_keyframe(vae, image, width, height, frame_index, crop="disabled"):
"""VAE-encode one pixel frame into an H3 keyframe pin entry.
`crop` mirrors the reference node: the frame-0 anchor is stretched to the
canvas (it defines the geometry), a last-frame follower is cover-cropped.
"""
import comfy.utils
# one frame only: a pin is a single condition frame, and an IMAGE batch here would
# otherwise encode into a latent with a batch dimension the packed layout cannot place.
# Alpha rides through the SAME resize as the colour so a cover-cropped pin's region ends
# up over the part of the frame it was painted on, not over the pre-crop coordinates.
channels = 4 if image.shape[-1] >= 4 else 3
samples = image[:1, ..., :channels].movedim(-1, 1)
samples = comfy.utils.common_upscale(samples, int(width), int(height), "lanczos", crop)
resized = samples.movedim(1, -1)
pixels = resized[..., :3]
region = None
if channels == 4 and install_region_locks():
grid_h = int(height) // SPATIAL_DOWNSCALE // 2
grid_w = int(width) // SPATIAL_DOWNSCALE // 2
region = region_rows_from_alpha(resized, grid_h, grid_w)
if region is not None:
# The excluded ROWS are dropped, but the VAE is convolutional: whatever colour sits
# under the transparent area still bleeds into the latent cells that are kept. An
# unpainted PNG leaves black there, which would print a hard edge into the kept
# boundary. Filling with the kept area's own mean colour makes that edge as quiet as
# the encoder can make it.
alpha = resized[..., 3:4]
keep = (alpha >= 0.5).float()
weight = keep.sum().clamp_min(1.0)
fill = (pixels * keep).sum(dim=(0, 1, 2), keepdim=True) / weight
pixels = pixels * keep + fill * (1.0 - keep)
pin = {"resolved_frame_index": int(frame_index), "latent": vae.encode(pixels)}
if region is not None:
pin[REGION_META] = region
return pin
def image_ref_block(vae, image, width, height, size_mode="match"):
"""Encode a pixel image into a ref2va IMAGE block (identity / style reference).
`size_mode` mirrors the reference node's own control: "match" scales the reference
(down only, keeping aspect) to the generation's pixel area; "max" uses the reference
pipeline's 2048px short edge for the best identity fidelity. Reference rows ride
through every sampling step, so "max" costs real time on every step of every scene.
"""
import comfy.utils
h, w = int(image.shape[1]), int(image.shape[2])
if str(size_mode).lower() == "max":
scale = min(1.0, REF_IMAGE_SHORT_EDGE / max(1, min(w, h)))
else:
scale = min(1.0, math.sqrt((int(width) * int(height)) / max(1, w * h)))
snap = lambda v: max(CANVAS_MULTIPLE, int(round(v / CANVAS_MULTIPLE)) * CANVAS_MULTIPLE)
tw, th = snap(w * scale), snap(h * scale)
samples = image[:1, ..., :3].movedim(-1, 1)
samples = comfy.utils.common_upscale(samples, tw, th, "lanczos", "disabled")
resized = samples.movedim(1, -1)
return ({"type": "image", "data": resized},
{"kind": "image", "latent_h": th // SPATIAL_DOWNSCALE,
"latent_w": tw // SPATIAL_DOWNSCALE, "latent": vae.encode(resized)})
def audio_ref_block(audio_vae, audio):
"""Encode an AUDIO dict into a ref2va AUDIO block."""
import torchaudio
waveform = audio["waveform"]
sr = int(audio.get("sample_rate", 32000))
vae_sr = int(getattr(audio_vae, "audio_sample_rate", 32000))
if sr != vae_sr:
waveform = torchaudio.functional.resample(waveform, sr, vae_sr)
z = audio_vae.encode(waveform[:1].movedim(1, -1))
return ({"type": "audio"},
{"kind": "audio", "ref_audio_t": int(z.shape[-1]), "audio_latent": z})
# ── ref2va: one ordered spec, two consumers ──────────────────────────────────
# A reference has to reach the model twice and in the SAME order both times:
# * as PRESENTATION — "<Picture 1>: <vision block>" spliced into the Qwen prompt, so
# the text encoder has seen it and the prompt can refer to "<Picture 1>";
# * as a LATENT BLOCK — packed into the DiT sequence, never denoised.
# Those two encodes happen in different nodes (Studio owns the CLIP, the Chain Sampler
# owns the VAE), so the ordered spec travels between them on the conditioning and both
# sides walk it with the helpers below. Ordinals are 1-based per type, exactly as the
# reference node numbers them, so a drift in order silently re-points "<Picture 2>".
REF_KINDS = ("image", "audio", "video")
def normalize_ref_spec(raw):
"""Coerce a user/JSON reference list into an ordered list of {kind, filename, ...}.
Unknown kinds and entries without a filename are dropped rather than guessed at.
Returns [] for anything unusable, so callers can treat "no references" and "bad
references" the same way.
"""
if isinstance(raw, str):
import json
try:
raw = json.loads(raw or "[]")
except Exception:
return []
if isinstance(raw, dict):
raw = raw.get("references") or raw.get("refs") or []
if not isinstance(raw, (list, tuple)):
return []
out = []
for item in raw:
if isinstance(item, str):
item = {"kind": "image", "filename": item}
if not isinstance(item, dict):
continue
kind = str(item.get("kind") or item.get("type") or "image").strip().lower()
filename = str(item.get("filename") or item.get("file") or "").strip()
if kind not in REF_KINDS or not filename:
continue
entry = {"kind": kind, "filename": filename}
if kind == "video" and item.get("audio"):
entry["audio"] = str(item["audio"]).strip()
if kind == "image":
size = str(item.get("size") or "match").strip().lower()
entry["size"] = size if size in ("match", "max") else "match"
out.append(entry)
return out
def load_input_image(filename):
"""Load an image from ComfyUI's input directory as [1, H, W, 3] in 0..1."""
import os
try:
import folder_paths
import numpy as np
from PIL import Image
except ImportError:
return None
path = os.path.join(folder_paths.get_input_directory(), filename)
if not os.path.isfile(path):
return None
try:
img = Image.open(path).convert("RGB")
arr = np.array(img).astype("float32") / 255.0
return torch.from_numpy(arr)[None,]
except Exception:
return None
def load_input_audio(filename):
"""Load an audio file from ComfyUI's input directory as comfy's AUDIO dict."""
import os
try:
import folder_paths
import torchaudio
except ImportError:
return None
path = os.path.join(folder_paths.get_input_directory(), filename)
if not os.path.isfile(path):
return None
try:
waveform, sample_rate = torchaudio.load(path)
return {"waveform": waveform.unsqueeze(0), "sample_rate": int(sample_rate)}
except Exception:
return None
def load_input_video(filename, fps=FPS, max_seconds=15.0):
"""Decode a video from ComfyUI's input directory to frames [T, H, W, 3] in 0..1.
ffmpeg, which FunPack already requires for preview/export, rather than a node
dependency — the reference pipeline wants frames at the model's own 24 fps and the
tokenizer wants every second frame of that, so decoding once here keeps both views
of the same clip consistent. Capped at `max_seconds` because reference tokens ride
through every sampling step: a long clip is a silent, permanent slowdown.
"""
import os
import shutil
import subprocess
try:
import folder_paths
import numpy as np
except ImportError:
return None
path = os.path.join(folder_paths.get_input_directory(), filename)
if not os.path.isfile(path):
return None
ff = shutil.which("ffmpeg")
if ff is None:
return None
# rgb24 rawvideo on stdout: no temp files, no container parsing on our side. The
# scale filter pins the frame size so the byte stream is a flat T x H x W x 3 block.
probe = shutil.which("ffprobe")
width = height = None
if probe is not None:
try:
out = subprocess.run(
[probe, "-v", "error", "-select_streams", "v:0", "-show_entries",
"stream=width,height", "-of", "csv=p=0:s=x", path],
capture_output=True, text=True, timeout=30)
width, height = (int(v) for v in out.stdout.strip().split("x")[:2])
except Exception:
width = height = None
if not width or not height:
return None
# even dimensions keep the decode simple and match the model's 32-multiple canvas rule
width -= width % 2
height -= height % 2
try:
proc = subprocess.run(
[ff, "-v", "error", "-i", path, "-t", str(float(max_seconds)),
"-vf", f"fps={int(fps)},scale={width}:{height}",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
capture_output=True, timeout=300)
except Exception:
return None
if proc.returncode != 0 or not proc.stdout:
return None
frame_bytes = width * height * 3
count = len(proc.stdout) // frame_bytes
if count < 5: # the model's floor: reference videos need at least 5 frames
return None
arr = np.frombuffer(proc.stdout[:count * frame_bytes], dtype=np.uint8)
arr = arr.reshape(count, height, width, 3).astype("float32") / 255.0
return torch.from_numpy(arr.copy())
def trim_video_to_grid(frames):
"""Trim reference video frames DOWN to the model's 17k+5 grid (never up).
Trims rather than pads because padding a reference invents motion that was never in
the source. Returns None when there is not a single whole grid step left, which is
the same floor the model enforces. Deliberately independent of the generation length:
Studio and the Chain Sampler both trim this way, so the clip the text encoder saw and
the clip that was encoded to latents are always the same frames.
"""
n = int(frames.shape[0])
while n > 0 and n % FRAME_GRID != FRAME_BASE:
n -= 1
if n < FRAME_BASE:
return None
return frames[:n]
def video_presentation_frames(frames):
"""The 2 fps view Qwen sees, with its timestamps — the tokenizer's own sampling rate.
Returns (frames_at_2fps, timestamps_in_seconds). The tokenizer repeat-pads an odd
count into its 2-frame temporal patch, so an odd number here is fine.
"""
step = max(1, FPS // 2)
idx = list(range(0, int(frames.shape[0]), step))
return frames[idx], [i / 2.0 for i in range(len(idx))]
def video_ref_block(vae, frames, audio_vae=None, audio=None):
"""Encode reference video frames (plus an optional soundtrack) into a ref2va block.
The block's audio rows pack immediately before its video rows and share a cursor
origin, which is what ties a soundtrack to its own clip rather than to the target.
"""
h, w = int(frames.shape[1]), int(frames.shape[2])
cw, ch = adapt_canvas(w, h)
if w * h < cw * ch:
# never upscale a reference: keep its own size, snapped to the canvas multiple
snap = lambda v: max(CANVAS_MULTIPLE, int(round(v / CANVAS_MULTIPLE)) * CANVAS_MULTIPLE)
cw, ch = snap(w), snap(h)
import comfy.utils
samples = frames[..., :3].movedim(-1, 1)
samples = comfy.utils.common_upscale(samples, cw, ch, "lanczos", "disabled")
resized = samples.movedim(1, -1)
z = vae.encode(resized)
audio_latent, ref_audio_t = None, 0
if audio is not None and audio_vae is not None:
_item, audio_block = audio_ref_block(audio_vae, audio)
audio_latent = audio_block["audio_latent"]
ref_audio_t = audio_block["ref_audio_t"]
qwen_frames, timestamps = video_presentation_frames(resized)
item = {"type": "video", "data": qwen_frames, "timestamps": timestamps}
block = {"kind": "video_audio" if ref_audio_t else "video",
"latent_t": int(z.shape[2]), "latent_h": ch // SPATIAL_DOWNSCALE,
"latent_w": cw // SPATIAL_DOWNSCALE, "ref_audio_t": ref_audio_t,
"latent": z, "audio_latent": audio_latent}
return item, block
def ref_items_from_spec(spec):
"""Spec -> the tokenizer's `minimax_ref_items` (presentation only, no VAE needed).
Only images carry pixels into Qwen; audio contributes a bare "<Audio j>: " label
(the reference node does the same — audio never enters the text encoder). Entries
whose file will not load are dropped from BOTH lists by the caller pairing this
with :func:`ref_blocks_from_spec` over the same resolved spec.
"""
items = []
for entry in spec:
kind = entry["kind"]
if kind == "image":
image = entry.get("image")
if image is None:
continue
items.append({"type": "image", "data": image})
elif kind == "audio":
items.append({"type": "audio"})
elif kind == "video":
frames = entry.get("frames")
if frames is None:
continue
# a soundtrack gets its own "<Audio j>" label, emitted BEFORE its "<Video k>",
# which is how the model learns the two belong to the same clip
if entry.get("audio_data") is not None:
items.append({"type": "audio"})
qwen_frames, timestamps = video_presentation_frames(frames)
items.append({"type": "video", "data": qwen_frames, "timestamps": timestamps})
return items
def resolve_ref_spec(spec, load_image=None, load_audio=None, load_video=None):
"""Load every reference's media once, dropping the ones that fail.
Returns (resolved, skipped) where `resolved` entries carry the loaded media under
"image" / "audio_data" / "frames". Both the presentation and the latent blocks are
built from this single resolved list, so the two can never fall out of order.
"""
load_image = load_image or load_input_image
load_audio = load_audio or load_input_audio
load_video = load_video or load_input_video
resolved, skipped = [], []
for entry in spec:
kind = entry["kind"]
if kind == "image":
image = load_image(entry["filename"])
if image is None:
skipped.append((entry["filename"], "image could not be loaded"))
continue
resolved.append({**entry, "image": image})
elif kind == "audio":
audio = load_audio(entry["filename"])
if audio is None:
skipped.append((entry["filename"], "audio could not be loaded"))
continue
resolved.append({**entry, "audio_data": audio})
elif kind == "video":
frames = load_video(entry["filename"])
if frames is None:
skipped.append((entry["filename"],
"video could not be decoded, or is shorter than 5 frames "
"(~0.2s) — ffmpeg must be on PATH"))
continue
trimmed = trim_video_to_grid(frames)
if trimmed is None:
skipped.append((entry["filename"],
"video is shorter than one 17-frame grid step (~0.9s at 24 fps)"))
continue
out = {**entry, "frames": trimmed}
# an index-paired soundtrack: the clip's own audio, not a standalone reference
if entry.get("audio"):
track = load_audio(entry["audio"])
if track is None:
skipped.append((entry["audio"],
"soundtrack could not be loaded — the video reference "
"is still used, silently"))
else:
out["audio_data"] = track
resolved.append(out)
return resolved, skipped
def ref_blocks_from_spec(resolved, vae, width, height, audio_vae=None):
"""Resolved spec -> the DiT's `minimax_refs` blocks, in the same order.
`vae` encodes image references; `audio_vae` encodes audio ones. An audio reference
with no audio VAE connected cannot produce a block, so it is dropped AND returned in
`skipped` — the caller must say so out loud, because the presentation was already
baked at encode time and every later "<Audio j>" in the prompt now points one
reference earlier than the user wrote it.
Reference videos are capped by :func:`load_input_video`'s own `max_seconds`, NOT by
the generation's length (which the reference node uses): the presentation is baked by
Studio, which never sees a frame count, so a sampler-side cap would silently show Qwen
more of the clip than the packed rows contain.
"""
blocks, skipped = [], []
for entry in resolved:
kind = entry["kind"]
if kind == "image":
_item, block = image_ref_block(vae, entry["image"], width, height,
size_mode=entry.get("size", "match"))
blocks.append(block)
elif kind == "audio":
if audio_vae is None:
skipped.append((entry["filename"],
"audio reference needs an audio_vae connected"))
continue
_item, block = audio_ref_block(audio_vae, entry["audio_data"])
blocks.append(block)
elif kind == "video":
track = entry.get("audio_data")
if track is not None and audio_vae is None:
# the clip still goes in, just mute — better than dropping the whole
# reference over its soundtrack, and the caller reports it either way
skipped.append((entry["filename"],
"video reference used WITHOUT its soundtrack — encoding "
"the audio needs an audio_vae connected"))
track = None
_item, block = video_ref_block(vae, entry["frames"],
audio_vae=audio_vae, audio=track)
blocks.append(block)
return blocks, skipped
def token_tags_length(meta):
"""Length of the ``minimax_token_tags`` run this conditioning carries, or None.
The DiT walks the tag vector position-by-position across the text span, so a
conditioning whose token count no longer matches its tags is a hard failure
(IndexError mid-forward), not a soft one. Anything that changes the token
count — appending identity tokens, truncating, concatenating prompts — must
either update the tags or drop the whole feature for H3.
"""
if not isinstance(meta, dict):
return None
tags = meta.get("minimax_token_tags")
if tags is None:
return None
try:
return int(tags.reshape(-1).shape[0])
except Exception:
return None
def tags_match(cond, meta) -> bool:
"""True when `meta`'s token tags still line up with `cond`'s token count."""
n = token_tags_length(meta)
if n is None:
return True # no tags to invalidate
try:
return int(cond.shape[1]) == n
except Exception:
return False
def trim_token_tags(meta, want):
"""Cut the tag vector down to `want` positions, keeping the head aligned.
Tags are built from the tokenizer's INPUT sequence (`embeds.shape[1]` in comfy's
MiniMaxQwen3VL.forward) while the conditioning is what the encoder returned, so an
image prompt normally comes back one tag long — the vision block's expansion does not
survive the encode 1:1. Position i still means position i in both, so trimming the tail
is exact; the alternative (dropping the tags) would throw away the vision span's
modality-0 marks and hand the image tokens to the DiT's text modulation.
"""
n = token_tags_length(meta)
if n is None or want < 0 or want >= n:
return meta
out = dict(meta)
out["minimax_token_tags"] = meta["minimax_token_tags"].reshape(-1)[:int(want)]
return out
def extend_token_tags(meta, added_tokens, tag=1):
"""Grow the tag vector by `added_tokens` positions so appended tokens stay legal.
Tag 1 is the text modality (0 = video, 2 = audio) — appended context tokens
are text-like, so they take the text adaLN row.
"""
n = token_tags_length(meta)
if n is None or added_tokens <= 0:
return meta
out = dict(meta)
tags = meta["minimax_token_tags"].reshape(-1)
pad = torch.full((int(added_tokens),), int(tag), dtype=tags.dtype, device=tags.device)
out["minimax_token_tags"] = torch.cat([tags, pad], dim=0)
return out
# ── per-modality AdaLN gain ──────────────────────────────────────────────────
#
# Every DiT block carries its own ``AdalnProj(t_dim, hidden, expand=6, modalities=3)``:
# six modulation vectors — shift/scale/gate for attention, the same three for the MLP —
# and THREE copies of each, one per modality. Rows are indexed ``t_row * 3 + tag``, so a
# modality's rows across every timestep are the slice ``[tag::3]``.
#
# The GATE is what scales a block's output as it is written back into a row range. Scaling
# one modality's gate is therefore per-block, per-stream write gain: how much all 50 blocks
# are allowed to say about the prompt rows, or the audio rows, independently of the video.
# At 1.0 nothing is touched and the tuple is returned as it arrived.
#
# WHAT THIS CANNOT SEPARATE: keyframe pins and reference images carry the VIDEO tag (0).
# They differ from the target video by TIMESTEP row, not by tag — and only while the
# condition's noise-augmentation timestep is above the video's, which stops being true
# later in the schedule. So the video gain moves the anchor with it. Splitting them needs
# the layout's row mapping, which AdalnProj never sees.
MODALITY_TAGS = {"video": 0, "text": 1, "audio": 2}
_ADALN_GATE_CHUNKS = (2, 5) # shift_msa scale_msa GATE_msa shift_mlp scale_mlp GATE_mlp
_ADALN_MODALITIES = 3
class AdalnModalityGain(torch.nn.Module):
"""Wraps one block's ``adaln_proj`` and scales its gates per modality."""
def __init__(self, inner, gains):
super().__init__()
self.inner = inner
self.gains = {int(t): float(g) for t, g in (gains or {}).items() if float(g) != 1.0}
def forward(self, t_emb):
out = self.inner(t_emb)
if not self.gains or getattr(self.inner, "modalities", 1) != _ADALN_MODALITIES:
return out
out = list(out)
for idx in _ADALN_GATE_CHUNKS:
gate = out[idx]
# chunk() returns views onto one buffer — never scale in place.
scaled = gate.clone()
for tag, factor in self.gains.items():
scaled[tag::_ADALN_MODALITIES] = gate[tag::_ADALN_MODALITIES] * factor
out[idx] = scaled
return tuple(out)
def apply_adaln_gains(model, gains):
"""Attach per-modality gate gains to every DiT block. Returns (model, note).
Uses ``add_object_patch`` rather than replacing methods by name: ComfyUI restores
object patches on unpatch, so nothing survives the run. Degradation that outlives a
reset has been a real bug here before, and this is the mechanism that prevents it.
`gains` maps a name in MODALITY_TAGS to a float. 1.0 (or absent) means untouched, and
an all-1.0 request returns the model unchanged rather than cloning it.
"""
live = {MODALITY_TAGS[name]: float(value)
for name, value in (gains or {}).items()
if name in MODALITY_TAGS and float(value) != 1.0}
if not live:
return model, None
try:
blocks = model.get_model_object("diffusion_model.blocks")
except Exception as error: # not H3, or a layout we do not know
return model, f"AdaLN gain skipped — no DiT blocks to patch ({error})."
patched = model.clone()
count = 0
for index in range(len(blocks)):
key = f"diffusion_model.blocks.{index}.adaln_proj"
try:
inner = patched.get_model_object(key)
except Exception:
continue
patched.add_object_patch(key, AdalnModalityGain(inner, live))
count += 1
if not count:
return model, "AdaLN gain skipped — no adaln_proj found on the blocks."
named = ", ".join(f"{n}={v:g}" for n, v in sorted((gains or {}).items()) if float(v) != 1.0)
return patched, (f"AdaLN modality gain on {count} blocks ({named}). Keyframe pins and "
f"reference images ride the VIDEO tag, so the video gain moves them too.")
# ── token-refiner output ─────────────────────────────────────────────────────
#
# `context` (Qwen, [1, L, 5120]) reaches the DiT through
# ``condition_proj`` (Linear 5120 -> 5376) then ``token_refiner`` (2 blocks + RMSNorm).
# Everything FunPack steers today happens BEFORE that, on the Qwen tensor, which means the
# refiner then processes the edit — its two attention blocks mix it across tokens and its
# final RMSNorm renormalizes the magnitude away. The direction that lands is not the
# direction that was learned.
#
# Editing the refiner's OUTPUT lands unchanged, and lands in the space the 50 blocks
# actually consume. Two edits are supported:
#
# scale — multiply the rows. The blocks read text through attention, so magnitude is how
# loudly the prompt competes with the picture for attention. Distinct from
# h3_gain_prompt, which controls how much each block WRITES BACK into these rows.
# bias — add a direction. A single [hidden] vector added to every prompt row is
# PROMPT-LENGTH INDEPENDENT, which per-position edits are not (Qwen does not pad,
# so H3's sequence length changes with every prompt edit). That is the shape a
# learned direction has to have here to transfer between prompts.
#
# Both are confined to a row range: the `<Picture N>` label and vision-block rows sit at the
# head of this span and are not ours to steer.
class TokenRefinerEdit(torch.nn.Module):
"""Wraps ``token_refiner`` and edits its output over a row range."""
def __init__(self, inner, scale=1.0, bias=None, row_start=0, row_end=None,
bias_relative=False):
super().__init__()
self.inner = inner
self.scale = float(scale)
self.bias = bias
self.row_start = int(row_start)
self.row_end = row_end
# A RELATIVE bias is a unit vector rescaled, at apply time, to a fraction of the
# span's own mean row norm. A learned direction has no natural magnitude in this
# space — the norms here belong to the checkpoint, not to anything the loop measured
# — so an absolute vector would be either inert or catastrophic with no way to tell
# which in advance. Relative makes the strength mean "this fraction of a typical
# prompt row", which is the same thing on every prompt and every checkpoint.
self.bias_relative = bool(bias_relative)
def forward(self, x, *args, **kwargs):
out = self.inner(x, *args, **kwargs)
try:
rows = out.shape[-2]
start = max(0, min(int(self.row_start), rows))
end = rows if self.row_end is None else max(start, min(int(self.row_end), rows))
if end <= start:
return out
out = out.clone()
span = out[..., start:end, :]
if self.scale != 1.0:
span = span * self.scale
if self.bias is not None:
bias = self.bias.to(device=span.device, dtype=span.dtype)
if self.bias_relative:
scale = span.float().norm(dim=-1).mean()
bias = bias * scale.to(bias.dtype)
span = span + bias
out[..., start:end, :] = span
except Exception: # a refiner shape we do not recognise: leave it alone
return self.inner(x, *args, **kwargs)
return out
def project_into_refiner_space(model, direction):
"""Map a conditioning-space (Qwen) direction into refiner space with ``condition_proj``.
The bias, not just the weight, would move the origin — a DIRECTION is a difference of two
points, so only the linear part carries over. Returns None when the projection cannot be
read or the width does not match.
"""
try:
proj = model.get_model_object("diffusion_model.condition_proj")
weight = proj.weight
except Exception:
return None
try:
vector = direction.reshape(-1).to(dtype=weight.dtype, device=weight.device)
if vector.shape[0] != weight.shape[1]:
return None
return torch.mv(weight, vector)
except Exception:
return None
def apply_token_refiner_edit(model, scale=1.0, bias=None, row_start=0, row_end=None,
bias_relative=False):
"""Attach a scale and/or bias to the token refiner's output. Returns (model, note).
``add_object_patch`` again, so ComfyUI restores it on unpatch and nothing outlives the
run. A scale of 1.0 with no bias returns the model unchanged rather than cloning it.
"""
if float(scale) == 1.0 and bias is None:
return model, None
key = "diffusion_model.token_refiner"
try:
inner = model.get_model_object(key)
except Exception as error:
return model, f"token-refiner edit skipped — no token_refiner to patch ({error})."
patched = model.clone()
patched.add_object_patch(key, TokenRefinerEdit(inner, scale, bias, row_start, row_end,
bias_relative=bias_relative))
parts = []
if float(scale) != 1.0:
parts.append(f"scale={float(scale):g}")
if bias is not None:
parts.append("bias (relative)" if bias_relative else "bias")
where = f"rows {row_start}:{row_end if row_end is not None else 'end'}"
return patched, f"token-refiner edit ({', '.join(parts)}) on {where}."