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:
co-authored by
Claude Opus 5
parent
c731d8d451
commit
c77fd95f12
+56
-10
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user