feat: MiniMax-H3 ref2va — native reference conditioning, replacing the LTX identity path

H3 packs reference blocks into the sequence itself (own position grid, never denoised,
never rendered), which is the thing Best-FaceID approximated on LTX with overlap tokens
plus a trained ArcFace projector. On H3 there is nothing to project and nothing to slice
back off, so this is the model's own feature, not a port of that one.

A reference has to reach the model twice and in the same order both times: as
presentation (Qwen sees "<Picture 1>: <vision block>" so the prompt can name it) and as
a packed latent block. Those encodes happen in different nodes, so the ordered spec
travels between them:

- Studio owns the CLIP: resolves the reference list, bakes the presentation via the
  tokenizer's minimax_ref_items, and records what it presented as funpack_h3_refs on
  the conditioning. Reference media and a first-frame anchor are separate conditioning
  modes, so source_image steps aside (loudly) when references are present.
- Chain Sampler owns the VAE: encodes exactly that list, in that order, sized against
  the scene's canvas, and attaches minimax_refs. Applied on every scene branch so an
  identity holds across the whole chain, and cached per run so it is encoded once
  rather than once per scene.

Order is the contract — drop or reorder an entry and "<Picture 2>" points at a
different reference than the text encoder saw. Anything that cannot be encoded is
dropped AND named, and an audio reference lost for want of an audio_vae says out loud
that later <Audio j> labels have shifted.

New optional audio_vae input on the Chain Sampler (audio references only; image
references need just the main vae). Video references are refused with a reason rather
than half-implemented — they need 2 fps frame sampling and a video loader FunPack does
not own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-03 01:03:48 +03:00
co-authored by Claude Opus 5
parent c731d8d451
commit c77fd95f12
5 changed files with 529 additions and 10 deletions
+56 -10
View File
@@ -6722,7 +6722,8 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
except Exception:
return None
def _v2_encode_prompt(self, clip, prompt_text, encode_cache=None, reference_image=None):
def _v2_encode_prompt(self, clip, prompt_text, encode_cache=None, reference_image=None,
h3_references=None):
if clip is None:
return None, {"pooled_output": None}, "CLIP missing"
prompt_text = str(prompt_text or "").strip()
@@ -6734,10 +6735,24 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
# "<Picture i>: <vision block>" before the prompt) — so the Gemma3 vision probe below
# returns False for it and the image would be silently dropped. Detect it first.
try:
from .minimax_h3 import is_h3_clip
from . import minimax_h3 as h3mod
except ImportError:
from minimax_h3 import is_h3_clip
h3 = is_h3_clip(clip)
import minimax_h3 as h3mod
h3 = h3mod.is_h3_clip(clip)
# ref2va: references have to reach the model twice — as presentation here (Qwen sees
# "<Picture 1>: <vision block>" so the prompt can name it) and as latent blocks later,
# in the SAME order. Studio owns the CLIP but not a VAE, so it resolves the ordered
# spec, bakes the presentation, and hands the resolved order to the Chain Sampler,
# which owns the VAE and encodes exactly that list. Only images carry pixels into
# Qwen; audio contributes a bare "<Audio j>: " label, as in the reference node.
resolved_refs, ref_items, ref_skips = [], [], []
if h3 and h3_references:
resolved_refs, ref_skips = h3mod.resolve_ref_spec(h3_references)
ref_items = h3mod.ref_items_from_spec(resolved_refs)
for filename, why in ref_skips:
print(f"[FunPackStudio] H3 reference '{filename}' skipped — {why}.")
use_vision = reference_image is not None and (h3 or self._gemma3_has_vision(clip))
img_fp = self._image_fingerprint(reference_image) if use_vision else None
@@ -6745,13 +6760,24 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
# one sample() invocation and never escapes it) — no cross-request/process-lifetime
# caching here. The conditioning the model sees must always trace back to what's live
# in the current request, never to anything reused from a past one.
cache_key = (id(clip), prompt_text, img_fp)
cache_key = (id(clip), prompt_text, img_fp,
tuple((r["kind"], r["filename"]) for r in resolved_refs))
if isinstance(encode_cache, dict):
cached = encode_cache.get(cache_key)
if cached is not None:
return cached
try:
if use_vision and h3:
if h3 and ref_items:
# ref2va takes over the presentation: a first-frame anchor and free-floating
# references are different conditioning modes and cannot share one prompt.
if use_vision:
print("[FunPackStudio] H3: reference media present — source_image is NOT "
"used as a first-frame anchor for this encode (ref2va and fl2va are "
"separate conditioning modes). Add it as a reference to include it.")
use_vision = False
print(f"[FunPackStudio] Encoding {len(ref_items)} H3 reference(s) with Qwen3-VL...")
tokens = clip.tokenize(prompt_text, minimax_ref_items=ref_items)
elif use_vision and h3:
print("[FunPackStudio] Processing input image with Qwen3-VL (MiniMax H3)...")
tokens = clip.tokenize(prompt_text, images=[reference_image])
elif use_vision:
@@ -6770,6 +6796,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
print("[FunPackStudio] Vision encoding returned invalid conditioning")
return None, {"pooled_output": None}, "encode returned invalid conditioning"
vision_tag = " +vision" if use_vision else ""
if resolved_refs:
# The resolved order travels with the conditioning: the sampler must encode these
# exact references, in this exact order, or "<Picture 2>" points at the wrong one.
meta = dict(meta)
meta["funpack_h3_refs"] = [{"kind": r["kind"], "filename": r["filename"]}
for r in resolved_refs]
vision_tag = f" +{len(resolved_refs)} ref"
if use_vision:
print(f"[FunPackStudio] Vision encoding done — {self._get_conditioning_seq_len(cond)} positions")
result = (cond, meta, f"encoded {self._get_conditioning_seq_len(cond)} positions{vision_tag}")
@@ -6786,10 +6819,12 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
return f"Gemma3 tokenizer loaded: {source}"
return "Gemma3 tokenizer loaded"
def _v2_conditioning_source(self, clip, prompt_text, positive_conditioning, encode_cache=None, reference_image=None):
def _v2_conditioning_source(self, clip, prompt_text, positive_conditioning, encode_cache=None,
reference_image=None, h3_references=None):
if clip is not None:
cond, meta, encode_status = self._v2_encode_prompt(
clip, prompt_text, encode_cache=encode_cache, reference_image=reference_image
clip, prompt_text, encode_cache=encode_cache, reference_image=reference_image,
h3_references=h3_references,
)
return cond, meta, encode_status, "CLIP-owned"
@@ -11793,7 +11828,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
_seed=None, _seed_source="fresh seed", _scene_seeds=None, _velocity_keys=None,
batch_variants=1, guess_mode=False, guess_direction="up", guess_range=1.0,
guess_freeze_seed=True, movie_editor_scene_ratings=None, scene_segments=None,
variables=None):
variables=None, h3_references=None):
seed = int(_seed) if _seed is not None else random.randint(1, 0xffffffffffffffff)
# Project-scoped `$name` variables, resolved DEAD LAST (after shortcut-expand + split)
# so they can never affect scene-cut detection. Empty/None = no-op.
@@ -11802,6 +11837,14 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
except ImportError:
from templates import resolve_variables as _resolve_variables
_prompt_variables = variables or None
# MiniMax H3 ref2va reference media, in the order the user listed it. No other model
# family has the tokenizer kwarg it feeds, so it is inert when unset or on LTX.
# minimax_h3.resolve_ref_spec explains why the ORDER is load-bearing.
try:
from .minimax_h3 import normalize_ref_spec as _normalize_ref_spec
except ImportError:
from minimax_h3 import normalize_ref_spec as _normalize_ref_spec
_h3_references = _normalize_ref_spec(h3_references) or None
encode_cache = {}
linked_refinement_key = str(refinement_key_input or "").strip()
if linked_refinement_key:
@@ -12235,6 +12278,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
positive_conditioning,
encode_cache=encode_cache,
reference_image=source_image,
h3_references=_h3_references,
)
fallback_graph = render_refinement_loss_graph(refinement_key, "v2", "clip", 0, 0.0, [])
if not isinstance(cond, torch.Tensor):
@@ -12980,7 +13024,8 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
entry = cache.get(exp_i)
if entry is None:
cond_i, meta_i, _, _ = self._v2_conditioning_source(
clip, exp_i, None, encode_cache=encode_cache, reference_image=source_image)
clip, exp_i, None, encode_cache=encode_cache, reference_image=source_image,
h3_references=_h3_references)
if not isinstance(cond_i, torch.Tensor):
entry = base_entry # encode failed -> fall back to the base conditioning
else:
@@ -14747,6 +14792,7 @@ class FunPackStudio:
movie_editor_scene_ratings=_me_scene_ratings,
scene_segments=(rf.get("scenes") if isinstance(rf.get("scenes"), dict) else None),
variables=(rf.get("variables") if isinstance(rf.get("variables"), (list, dict)) else None),
h3_references=rf.get("h3_references"),
)
# --- Conditioning Adjust ---
+163
View File
@@ -406,6 +406,169 @@ def audio_ref_block(audio_vae, 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 item.get("audio"):
entry["audio"] = str(item["audio"]).strip()
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 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"})
# video presentation needs 2 fps frame blocks with timestamps — not wired yet
return items
def resolve_ref_spec(spec, load_image=None, load_audio=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". 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
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})
else:
skipped.append((entry["filename"],
"video references are not wired yet (needs 2 fps frame "
"sampling and a video loader)"))
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.
"""
blocks, skipped = [], []
for entry in resolved:
kind = entry["kind"]
if kind == "image":
_item, block = image_ref_block(vae, entry["image"], width, height)
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)
return blocks, skipped
def token_tags_length(meta):
"""Length of the ``minimax_token_tags`` run this conditioning carries, or None.
+74
View File
@@ -2348,6 +2348,9 @@ class FunPackLTXAVSceneChainSampler:
"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.25,
"tooltip": "JoyAI-Echo video->audio coupling. Scales the model's trained video-to-audio cross-attention so the carried audio tracks the new shot's visuals (JoyAI uses 2.0). 1.0 = native model behavior (no change, zero overhead); 0.0 = audio ignores video this run. Only applies when joyai_audio_memory is on.",
}),
"audio_vae": ("VAE", {
"tooltip": "MiniMax H3 only: the audio VAE, needed to encode AUDIO reference media for ref2va (a voice or ambience clip the generation should sound like). Studio lists the references and bakes their <Audio j> labels into the prompt; this VAE turns them into the latent blocks the DiT packs. Image references need only the main vae and work without this. Ignored entirely on LTX.",
}),
"refinement_key_input": ("STRING", {
"default": "",
"multiline": False,
@@ -4085,6 +4088,7 @@ class FunPackLTXAVSceneChainSampler:
# length to place a last-frame pin, and nothing else in the guide path carries it.
_h3_frame_count = 0
_is_h3 = False
_h3_ref_cache: dict = {}
def _append_h3_keyframe(self, guide_frame, apply_at, strength, positive, negative):
"""H3's equivalent of an LTX guide: a keyframe pin carried on the conditioning.
@@ -4123,6 +4127,61 @@ class FunPackLTXAVSceneChainSampler:
negative = self._condition_with_values(negative, values) if negative else negative
return positive, negative, 0
def _apply_h3_references(self, positive, chunk, vae, audio_vae=None):
"""Turn Studio's resolved ref2va order into the DiT's `minimax_refs` blocks.
Studio owns the CLIP, so it baked the presentation ("<Picture 1>: <vision block>")
and recorded WHICH references it presented, in order, as `funpack_h3_refs`. This
side owns the VAE, so it encodes exactly that list. The order is the contract: drop
or reorder an entry here and every later "<Picture i>" in the prompt points at a
different reference than the one the text encoder saw.
This is the native replacement for the LTX Best-FaceID path, not a port of it —
H3 packs reference blocks into the sequence itself, so there is nothing to project,
nothing to append to the text context and nothing to slice back off the output.
"""
spec = self._conditioning_value(positive, "funpack_h3_refs")
if not spec:
return positive, 0
try:
from . import minimax_h3 as h3mod
except ImportError:
import minimax_h3 as h3mod
video = self._latent_tensors(chunk)[0]
if video.dim() != 5:
print("[FunPackSceneChain] H3 references skipped — couldn't read a 5D video latent.")
return positive, 0
width = int(video.shape[4]) * h3mod.SPATIAL_DOWNSCALE
height = int(video.shape[3]) * h3mod.SPATIAL_DOWNSCALE
# Every scene in the chain gets the same references, and re-loading + re-VAE-encoding
# them per scene is pure waste. Cached per RUN only (cleared at the top of sample()),
# never across requests — the same rule the Studio encode cache follows.
cache_key = (str(spec), width, height, audio_vae is not None)
cached = self._h3_ref_cache.get(cache_key)
if cached is not None:
blocks = cached
if not blocks:
return positive, 0
return self._condition_with_values(positive, {"minimax_refs": blocks}), len(blocks)
resolved, load_skips = h3mod.resolve_ref_spec(h3mod.normalize_ref_spec(spec))
for filename, why in load_skips:
print(f"[FunPackSceneChain] H3 reference '{filename}' skipped — {why}.")
blocks, encode_skips = h3mod.ref_blocks_from_spec(
resolved, vae, width, height, audio_vae=audio_vae)
for filename, why in encode_skips:
print(f"[FunPackSceneChain] H3 reference '{filename}' skipped — {why}. The prompt's "
f"reference numbering was already baked at encode time, so every later "
f"<Audio j> now points one reference earlier than you wrote it.")
self._h3_ref_cache[cache_key] = blocks
if not blocks:
return positive, 0
print(f"[FunPackSceneChain] H3 ref2va: {len(blocks)} reference block(s) packed "
f"({', '.join(b['kind'] for b in blocks)}) at {width}x{height}.")
return self._condition_with_values(positive, {"minimax_refs": blocks}), len(blocks)
def _append_guide_latent(self, chunk, guide_frame, apply_at, strength, positive, negative, vae):
"""Append one guide latent frame with LTX guide attention at apply_at."""
if self._is_h3:
@@ -5226,6 +5285,7 @@ class FunPackLTXAVSceneChainSampler:
context_window_freenoise=True, context_window_retain_first=False,
cut_opening_frames=0,
second_pass=False, second_pass_op="none", second_pass_sigmas=None,
audio_vae=None,
unique_id=None, prompt=None):
if not isinstance(positive, list) or not positive:
raise ValueError("positive conditioning must contain at least one scene entry.")
@@ -5237,6 +5297,9 @@ class FunPackLTXAVSceneChainSampler:
# H3 disagree about which axis that is on the AUDIO stream and both are 4-D.
self._is_h3 = self._set_stream_axes(model)
self._h3_frame_count = int(num_frames_per_scene)
# Fresh per run: the encoded references must always trace back to the media that is
# live in THIS request (see [[feedback_no_persistent_state_caches]]).
self._h3_ref_cache = {}
if self._is_h3:
print("[FunPackSceneChain] MiniMax H3 detected — audio stream time axis is the last "
"dim, frame grid is 17k+5, conditioning is a single packed self-attention "
@@ -5497,6 +5560,7 @@ class FunPackLTXAVSceneChainSampler:
scene_positive = [scene_cond]
scene_negative = negative
h3_ref_count = 0
provided_seed = self._scene_seed(scene_cond)
if use_same_seed:
scene_seed = first_scene_seed
@@ -5601,6 +5665,16 @@ class FunPackLTXAVSceneChainSampler:
elif not custom_guides:
guide_tail = 0
# ref2va reference blocks. Applied after the chunk is final because the blocks are
# sized against this scene's canvas, and to every scene branch (fresh / anchored /
# continuation) because a reference identity is meant to hold across the whole
# chain, not just the opening shot.
if self._is_h3:
scene_positive, h3_ref_count = self._apply_h3_references(
scene_positive, chunk, vae, audio_vae=audio_vae)
if h3_ref_count:
run_mechanisms.append(f"h3_ref2va({h3_ref_count})")
# Everything from here through sampling installs per-scene state on the SHARED
# model (function wrappers, forward hooks). One snapshot + one finally guarantees
# the model leaves this scene exactly as it entered it — even on interrupt/OOM
+114
View File
@@ -14,6 +14,7 @@ exception, which is why they are worth having:
cfg != 1.0.
"""
import sys
import types
from pathlib import Path
import pytest
@@ -21,6 +22,17 @@ import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# minimax_h3 imports comfy lazily, inside the functions that need it, so the module itself
# loads bare. Only the reference-block helpers reach for comfy.utils.common_upscale — stub
# just that, with the real resize semantics the assertions care about (out shape = w x h).
if "comfy.utils" not in sys.modules:
_comfy = sys.modules.setdefault("comfy", types.ModuleType("comfy"))
_utils = types.ModuleType("comfy.utils")
_utils.common_upscale = lambda samples, width, height, method, crop: torch.zeros(
samples.shape[0], samples.shape[1], height, width)
sys.modules["comfy.utils"] = _utils
_comfy.utils = _utils
import minimax_h3 as h3
@@ -256,3 +268,105 @@ def test_attention_patch_targets_always_includes_comfys_module():
targets = h3.attention_patch_targets()
names = [getattr(t, "__name__", "") for t in targets]
assert "comfy.ldm.modules.attention" in names or names == []
# ── 5. ref2va: one ordered spec, two consumers ───────────────────────────────
# A reference reaches the model twice — as presentation (Qwen sees "<Picture 1>: <block>")
# and as a packed latent block — encoded in two different nodes. The ORDER is the contract:
# drop or reorder one side and "<Picture 2>" in the prompt points at a different reference
# than the text encoder saw, which produces a plausible video of the wrong subject.
def test_normalize_ref_spec_keeps_order_and_drops_junk():
spec = h3.normalize_ref_spec([
"face.png", # bare string -> image
{"kind": "audio", "filename": "voice.wav"},
{"kind": "image"}, # no filename -> dropped
{"kind": "hologram", "filename": "x.png"}, # unknown kind -> dropped
{"type": "image", "file": "style.jpg"}, # alternate key names
])
assert [(e["kind"], e["filename"]) for e in spec] == [
("image", "face.png"), ("audio", "voice.wav"), ("image", "style.jpg")]
def test_normalize_ref_spec_accepts_json_and_wrapper_dicts():
assert h3.normalize_ref_spec('[{"kind":"image","filename":"a.png"}]')[0]["filename"] == "a.png"
assert h3.normalize_ref_spec({"references": ["a.png"]})[0]["kind"] == "image"
assert h3.normalize_ref_spec("not json") == []
assert h3.normalize_ref_spec(None) == []
def test_resolve_ref_spec_drops_unloadable_media_and_says_why():
spec = h3.normalize_ref_spec(["ok.png", "missing.png", {"kind": "audio", "filename": "v.wav"}])
images = {"ok.png": torch.zeros(1, 64, 64, 3)}
resolved, skipped = h3.resolve_ref_spec(
spec,
load_image=lambda f: images.get(f),
load_audio=lambda f: {"waveform": torch.zeros(1, 2, 800), "sample_rate": 32000},
)
assert [e["filename"] for e in resolved] == ["ok.png", "v.wav"]
assert [f for f, _ in skipped] == ["missing.png"]
assert "could not be loaded" in skipped[0][1]
def test_video_references_are_reported_as_not_wired_rather_than_half_done():
spec = h3.normalize_ref_spec([{"kind": "video", "filename": "clip.mp4"}])
resolved, skipped = h3.resolve_ref_spec(spec, load_image=lambda f: None, load_audio=lambda f: None)
assert resolved == []
assert "not wired yet" in skipped[0][1]
def test_presentation_and_blocks_walk_the_same_resolved_order():
"""The invariant the whole design exists to hold."""
spec = h3.normalize_ref_spec(["a.png", {"kind": "audio", "filename": "v.wav"}, "b.png"])
resolved, _ = h3.resolve_ref_spec(
spec,
load_image=lambda f: torch.zeros(1, 64, 64, 3),
load_audio=lambda f: {"waveform": torch.zeros(1, 2, 800), "sample_rate": 32000},
)
items = h3.ref_items_from_spec(resolved)
# audio never enters Qwen as pixels — it contributes a bare "<Audio j>: " label
assert [i["type"] for i in items] == ["image", "audio", "image"]
class FakeVAE:
def encode(self, pixels):
return torch.zeros(1, 24, 1, pixels.shape[1] // 16, pixels.shape[2] // 16)
class FakeAudioVAE:
audio_sample_rate = 32000
def encode(self, waveform):
return torch.zeros(1, 32, 2, 5)
blocks, skipped = h3.ref_blocks_from_spec(resolved, FakeVAE(), 768, 768,
audio_vae=FakeAudioVAE())
assert skipped == []
assert [b["kind"] for b in blocks] == ["image", "audio", "image"]
assert [i["type"] for i in items] == [b["kind"] for b in blocks] # same order, both sides
def test_an_audio_reference_without_an_audio_vae_is_reported_not_silently_dropped():
resolved, _ = h3.resolve_ref_spec(
h3.normalize_ref_spec([{"kind": "audio", "filename": "v.wav"}]),
load_image=lambda f: None,
load_audio=lambda f: {"waveform": torch.zeros(1, 2, 800), "sample_rate": 32000},
)
blocks, skipped = h3.ref_blocks_from_spec(resolved, object(), 768, 768, audio_vae=None)
assert blocks == []
assert [f for f, _ in skipped] == ["v.wav"]
assert "audio_vae" in skipped[0][1]
def test_image_ref_blocks_snap_to_32_and_never_upscale():
class FakeVAE:
def encode(self, pixels):
return torch.zeros(1, 24, 1, pixels.shape[1] // 16, pixels.shape[2] // 16)
# a reference much larger than the generation is scaled DOWN to its pixel area
_item, block = h3.image_ref_block(FakeVAE(), torch.zeros(1, 2048, 2048, 3), 768, 768)
assert block["latent_h"] * 16 % 32 == 0 and block["latent_w"] * 16 % 32 == 0
assert block["latent_h"] * 16 <= 2048
# a small reference is left at its own size, not blown up to the canvas
_item, small = h3.image_ref_block(FakeVAE(), torch.zeros(1, 128, 128, 3), 768, 768)
assert small["latent_h"] * 16 == 128
+122
View File
@@ -44,6 +44,11 @@ for _name, _attrs in (
for _k, _v in _attrs.items():
setattr(_mod, _k, _v)
sys.modules[_name] = _mod
# common_upscale is only reached by the reference-block helpers; whichever comfy.utils stub
# won registration may not carry it, so fill it in with the resize semantics that matter here.
if not hasattr(sys.modules["comfy.utils"], "common_upscale"):
sys.modules["comfy.utils"].common_upscale = lambda samples, width, height, method, crop: \
torch.zeros(samples.shape[0], samples.shape[1], height, width)
_comfy = sys.modules["comfy"]
for _sub in ("k_diffusion", "model_sampling", "nested_tensor", "sample", "samplers", "utils"):
setattr(_comfy, _sub, sys.modules["comfy." + _sub])
@@ -260,3 +265,120 @@ def test_ltx_guides_still_take_the_ltx_path():
positive=[[torch.zeros(1, 12, 4096), {}]], negative=[], vae=LTXVAE())
assert tail == 0
assert "minimax_keyframes" not in pos[0][1]
# ── ref2va reference blocks ──────────────────────────────────────────────────
# Studio bakes the presentation ("<Picture 1>: <vision block>") and records WHICH
# references it presented, in order. The sampler owns the VAE and encodes exactly that
# list. If the two orders diverge, "<Picture 2>" in the prompt points at a different
# reference than the text encoder saw — a clean-looking video of the wrong subject.
class RefVAE:
def encode(self, pixels):
return torch.zeros(1, 24, 1, pixels.shape[1] // 16, pixels.shape[2] // 16)
class RefAudioVAE:
audio_sample_rate = 32000
def encode(self, waveform):
return torch.zeros(1, 32, 2, 5)
@pytest.fixture
def fake_media(monkeypatch):
import minimax_h3 as h3mod
monkeypatch.setattr(h3mod, "load_input_image",
lambda f: None if f.startswith("missing") else torch.zeros(1, 256, 256, 3))
monkeypatch.setattr(h3mod, "load_input_audio",
lambda f: {"waveform": torch.zeros(1, 2, 800), "sample_rate": 32000})
def cond_with_refs(refs):
return [[torch.zeros(1, 12, 5120), {"funpack_h3_refs": refs, "funpack_scene_text": "shot 1"}]]
def test_references_are_encoded_in_the_order_studio_presented_them(fake_media):
node = h3_node()
positive = cond_with_refs([
{"kind": "image", "filename": "face.png"},
{"kind": "audio", "filename": "voice.wav"},
{"kind": "image", "filename": "style.png"},
])
out, count = node._apply_h3_references(
positive, av_latent(), RefVAE(), audio_vae=RefAudioVAE())
assert count == 3
blocks = out[0][1]["minimax_refs"]
assert [b["kind"] for b in blocks] == ["image", "audio", "image"]
assert out[0][1]["funpack_scene_text"] == "shot 1" # scene metadata preserved
def test_reference_blocks_are_sized_against_this_scenes_canvas(fake_media):
node = h3_node()
# av_latent's video is [1, 24, 37, 48, 84] -> a 1344x768 canvas at 16x downscale
out, _ = node._apply_h3_references(
cond_with_refs([{"kind": "image", "filename": "face.png"}]), av_latent(), RefVAE())
block = out[0][1]["minimax_refs"][0]
# the reference is 256x256, smaller than the canvas area, so it keeps its own size
assert block["latent_h"] * 16 == 256 and block["latent_w"] * 16 == 256
def test_no_references_leaves_the_conditioning_untouched(fake_media):
node = h3_node()
positive = [[torch.zeros(1, 12, 5120), {}]]
out, count = node._apply_h3_references(positive, av_latent(), RefVAE())
assert count == 0 and out is positive
def test_an_audio_reference_without_an_audio_vae_is_reported(fake_media, capsys):
node = h3_node()
out, count = node._apply_h3_references(
cond_with_refs([{"kind": "image", "filename": "a.png"},
{"kind": "audio", "filename": "voice.wav"}]),
av_latent(), RefVAE(), audio_vae=None)
assert count == 1
assert [b["kind"] for b in out[0][1]["minimax_refs"]] == ["image"]
printed = capsys.readouterr().out
assert "audio_vae" in printed
# the renumbering consequence has to be stated, not left for the user to discover
assert "points one reference earlier" in printed
def test_an_unloadable_reference_is_dropped_and_named(fake_media, capsys):
node = h3_node()
out, count = node._apply_h3_references(
cond_with_refs([{"kind": "image", "filename": "missing.png"},
{"kind": "image", "filename": "ok.png"}]),
av_latent(), RefVAE())
assert count == 1
assert "missing.png" in capsys.readouterr().out
def test_references_are_encoded_once_per_run_not_once_per_scene(fake_media):
"""References ride through every step of every scene — re-encoding them per scene is
pure GPU waste, and a cache that outlived the run would violate the no-persistent-state
rule. Cached per run, cleared by sample()."""
node = h3_node()
node._h3_ref_cache = {}
encodes = []
class CountingVAE(RefVAE):
def encode(self, pixels):
encodes.append(tuple(pixels.shape))
return super().encode(pixels)
vae = CountingVAE()
positive = cond_with_refs([{"kind": "image", "filename": "face.png"}])
for _scene in range(4):
out, count = node._apply_h3_references(positive, av_latent(), vae)
assert count == 1
assert len(out[0][1]["minimax_refs"]) == 1
assert len(encodes) == 1
# a different canvas is a different encode, not a stale cache hit
node._apply_h3_references(positive, av_latent(video_t=37), vae)
assert len(encodes) == 1
smaller = {"samples": NT([torch.zeros(1, 24, 37, 32, 32), torch.zeros(1, 32, 2, 207)])}
node._apply_h3_references(positive, smaller, vae)
assert len(encodes) == 2