"""Native MiniMax H3 Ref2VA chain: several generates joined into one clip. Hop 1 is a full Ref2VA generate and may carry an official six-field H3 prompt. Hop 2+ is a *continuation*, not a second generate: a continuation beat with the previous hop pinned in front of it. The pin is the whole point. Hop 2+ slices the previous hop's sampler AV latent through MiniMaxH3MotionContext, which keeps the join in the latent domain and end-aligns the audio window. MiniMaxH3AddGuide on decoded pixels is the fallback -- taken when Motion-Context is missing, or when the previous hop came from the cache and so has no sampler latent to slice. Authoring is `shot_plan` + `ref_plan`, both JSON strings, both edited by the DOM panel in js/ and both the single source of truth. The legacy `prompt` widget (optional --- / JSON blocks) still runs for `hop_script=verbatim`. """ from __future__ import annotations import base64 import hashlib import queue import threading import io as pyio import json import math import os import re import torch try: from server import PromptServer except Exception: PromptServer = None import comfy.model_management as mm import comfy.samplers import comfy.utils from comfy_extras.nodes_audio import vae_decode_audio from comfy_extras.nodes_custom_sampler import ( BasicGuider, BasicScheduler, KSamplerSelect, RandomNoise, SamplerCustomAdvanced, ) try: from comfy_extras.nodes_minimax_h3 import ( MiniMaxH3AddGuide, MiniMaxH3ReferenceToVideo, MiniMaxH3SigmaShift, align_frame_count, ) except ImportError as _exc: # pragma: no cover - depends on the host build # The one failure a first-time installer is actually likely to hit. Left # bare it surfaces as "cannot import name 'MiniMaxH3AddGuide'", which says # nothing about what to do. The pack cannot work without these, so it still # refuses to load -- it just says why. raise ImportError( "Hand Tie Clips needs MiniMax H3 support in ComfyUI itself " "(comfy_extras/nodes_minimax_h3.py, ComfyUI PR #15439). Update ComfyUI " "to a build that ships it, then restart. Original error: %s" % _exc ) from _exc from nodes import VAEDecode from . import directives as _d from . import plan as _plan from . import refs as _refs from . import store as _store from . import media as _media from . import tone as _tone from . import sheet as _sheet # One definition, in refs.py -- routes.py publishes that copy to the editor, so # a second constant here meant the node's slot count and the number the UI was # told could drift apart. from .refs import MAX_REF_IMAGES FPS = 24 TAG = "HandTieClips" # quality=draft. Low enough to be genuinely fast, high enough that blocking, # camera and whether a join lands are all still readable. Both values are in # the cache key already, so a draft never overwrites the matching final. DRAFT_RESOLUTION = "0.3 MP" DRAFT_STEPS = 6 # H3 canvas: multiples of 32, short-edge ~768, area cap 768*1344. CANVAS = { "0.2 MP": { "16:9 landscape": (608, 352), "9:16 portrait": (352, 608), "1:1 square": (448, 448), }, "0.3 MP": { "16:9 landscape": (736, 416), "9:16 portrait": (416, 736), "1:1 square": (544, 544), }, "0.5 MP": { "16:9 landscape": (960, 544), "9:16 portrait": (544, 960), "1:1 square": (704, 704), }, "0.7 MP": { "16:9 landscape": (1120, 640), "9:16 portrait": (640, 1120), "1:1 square": (832, 832), }, "1.0 MP": { "16:9 landscape": (1280, 736), "9:16 portrait": (736, 1280), "1:1 square": (992, 992), }, } DURATION_FRAMES = { # Every value satisfies align_frame_count (n % 17 == 5) at FPS 24, so the # label and the frames the model actually renders agree to a tenth. "5 s": 124, "7 s": 175, "8 s": 192, "10 s": 243, "15 s": 362, } OVERLAP_FRAMES = { "0.9 s": 22, "0.2 s": 5, "1.6 s": 39, } # MiniMaxH3MotionContext.apply takes `context_length` as a *string* combo and # accepts only these values. Derived from OVERLAP_FRAMES so the two cannot # drift: add an overlap without a matching context_length and the pin would # silently clamp to 22 while the master trims the real value -- a misaligned # seam with no error. _pin_continue logs and falls back instead. MC_CONTEXT_LENGTHS = frozenset(str(v) for v in OVERLAP_FRAMES.values()) def _canvas(resolution, aspect): try: return CANVAS[str(resolution)][str(aspect)] except KeyError: return (1280, 736) def _duration_frames(duration): return int(DURATION_FRAMES.get(str(duration), 243)) def _overlap_frames(overlap): key = str(overlap) if key in OVERLAP_FRAMES: return OVERLAP_FRAMES[key] return int(overlap) # Prompt phrasing rule: AFFIRMATIVE ONLY. # Sampling runs through BasicGuider at cfg 1.0 with no negative branch, so every # concept named in the prompt is additive and cannot be subtracted -- "Do not # restart the scene" puts `restart` in front of the encoder. State what the shot # IS doing, never what it must not do. Keep this rule when editing below. CONTINUE_PREFIX = ( "The clip opens on the action already in progress from the pinned frames. " "The same people continue from where the pinned frames leave off, in the same " "wardrobe, the same room, and the same lighting. " "After a brief hold, the action carries forward from its current point.\n\n" ) ADVANCE_BEAT = ( "The action already in progress carries forward from its current point." ) MAX_REF_VIDEOS = 3 def _result(out): if hasattr(out, "args"): return out.args if isinstance(out, (tuple, list)): return tuple(out) return (out,) def _model_fingerprint(model): """Identify the incoming MODEL by what has been patched onto it. The hop cache has to notice when a hop was rendered under a different LoRA stack or a different attention path, or it will happily serve frames that do not belong to the current graph -- silently wrong output, which is worse than no cache at all. When the patch nodes were a widget on this node the parsed plan went into the key directly; with them drawn upstream the only thing available is the ModelPatcher itself. Cheap and content-derived: the set of weight keys any LoRA touched plus the per-key strength scalars, and the scalar half of `transformer_options`, which is where the SLA and low-VRAM attention overrides land. Patch *values* are tensors and are deliberately not hashed. `patches_uuid` is not usable here: `ModelPatcher.add_patches` assigns a fresh `uuid4()` on every call, so it would change every run and bust the cache even when nothing about the graph moved. Known collision: two different LoRAs touching an identical key set at identical strengths fingerprint the same. Rare, and the alternative costs a full state-dict walk per run. """ h = hashlib.sha256() patches = getattr(model, "patches", None) or {} for key in sorted(patches): h.update(str(key).encode()) for entry in patches[key]: # (strength_patch, weights, strength_model, offset, function) try: h.update(f"{float(entry[0]):.6g}".encode()) if len(entry) > 2 and isinstance(entry[2], (int, float)): h.update(f"{float(entry[2]):.6g}".encode()) except (TypeError, ValueError, IndexError): h.update(b"?") opts = getattr(model, "model_options", None) or {} transformer = opts.get("transformer_options") or {} def _closure_scalars(fn): """The scalar settings a callable closed over. H3-SLA-Attention installs its config by closure -- `_make_override(state, float(sparsity_ratio), blkq, blkk, int(min_seq_len), bool(protect_audio))` -- so a callable rendered as `type(fn).__name__` hashes to the bare string "function" and SLA's settings vanish from the key. Changing sparsity 0.90 -> 0.50 then left the fingerprint unmoved and the cache served hops rendered under a different attention path. Scalars only, deliberately: the first cell is a mutable `state` dict the sampler counts into during the run, and hashing that would change the fingerprint on every queue and never hit the cache at all. """ parts = [getattr(fn, "__qualname__", "") or getattr(fn, "__name__", "")] for cell in (getattr(fn, "__closure__", None) or ()): try: v = cell.cell_contents except ValueError: # empty cell, e.g. a recursive closure parts.append("?") continue parts.append(repr(v) if isinstance(v, (str, int, float, bool)) or v is None else type(v).__name__) return "fn(" + ",".join(parts) + ")" def _scalars(obj, depth=0): """Only names and scalars -- tensors and mutable state are not stable.""" if depth > 3: return "..." if isinstance(obj, dict): # sorted(obj, key=str), not sorted(map(str, obj)): stringifying the # keys first drops every non-str key from the hash, because the # `k in obj` guard then fails against the real key. return "{" + ",".join( f"{k}:{_scalars(obj[k], depth + 1)}" for k in sorted(obj, key=str) ) + "}" if isinstance(obj, (list, tuple)): return "[" + ",".join(_scalars(v, depth + 1) for v in obj) + "]" if isinstance(obj, (str, int, float, bool)) or obj is None: return repr(obj) if callable(obj): return _closure_scalars(obj) return type(obj).__name__ h.update(_scalars(transformer).encode()) return h.hexdigest()[:16] def _parse_shots(text): text = (text or "").strip() if not text: raise ValueError(f"{TAG}: prompt is empty") if text.startswith("{") or text.startswith("["): try: data = json.loads(text) except json.JSONDecodeError as e: raise ValueError(f"{TAG}: prompt looks like JSON but does not parse ({e})") from e if isinstance(data, dict): shots = [str(p).strip() for p in data.get("prompts", []) if str(p).strip()] elif isinstance(data, list): shots = [str(p).strip() for p in data if str(p).strip()] else: shots = [] if shots: return shots parts = [b.strip() for b in re.split(r"(?m)^---\s*$", text) if b.strip()] return parts or [text] def _parse_state(text): """continuity_state input: blank -> no-op, else a JSON object (from HTCContinuityState).""" text = (text or "").strip() if not text: return {} try: data = json.loads(text) except json.JSONDecodeError as e: raise ValueError(f"{TAG}: continuity_state looks like JSON but does not parse ({e})") from e if not isinstance(data, dict): raise ValueError(f"{TAG}: continuity_state must be a JSON object") return data def _continue_prompt(block): """Wrap a verbatim-mode block as a continuation. Never rewrite the official summary task types here. This used to replace `[keyframe completion]` with `[video continuation + reference generation]`, which is what made hop 2 of chain_00030/00031 a new stills generate instead of a first-frame continue. Combine types with ` + `; never drop one that is already present. """ text = (block or "").strip() if _d.is_full_h3_prompt(text): text = _d.flatten_official_continue(text) return CONTINUE_PREFIX + (text or ADVANCE_BEAT) def _expand_shots(blocks, chains, hop_script="verbatim"): original = list(blocks) if len(blocks) > chains: print(f"[{TAG}] dropping {len(blocks) - chains} extra --- block(s)", flush=True) blocks = blocks[:chains] unique = len(original) if len(original) <= chains else chains while len(blocks) < chains: if hop_script == "next" and unique == 1: blocks.append("") else: blocks.append(blocks[-1]) if hop_script == "next": return blocks, unique out = [] for i, block in enumerate(blocks): wrap = i > 0 and (unique == 1 or i >= unique) out.append(_continue_prompt(block) if wrap else block) return out, unique def _state_entry_text(entry, hop_index): """locked (verbatim) + context (current-state) + this hop's mutable beat.""" if not isinstance(entry, dict): return "" locked = str(entry.get("locked") or "").strip() context = str(entry.get("context") or "").strip() mutable = entry.get("mutable") or [] if isinstance(mutable, str): mutable = _parse_shots(mutable) if mutable.strip() else [] mutable = [str(b).strip() for b in mutable if str(b).strip()] beat = "" if mutable: idx = hop_index if hop_index < len(mutable) else len(mutable) - 1 beat = mutable[idx] return "\n".join(p for p in (locked, context, beat) if p) def _state_header(state, hop_index): """Compose the continuity_state block for this hop (locked/context every hop, mutable indexed).""" if not state: return "" sections = [] setting_text = _state_entry_text(state.get("setting") or {}, hop_index) if setting_text: sections.append("setting:\n" + setting_text) for char_id, entry in (state.get("characters") or {}).items(): char_text = _state_entry_text(entry, hop_index) if char_text: sections.append(f"character {char_id}:\n" + char_text) return "\n\n".join(sections) def _identity_lock(n_stills, live_picture, identity_ordinals=None, n_subjects=None): """Name the pictures that are people, and only those. `identity_ordinals` comes from the reference register, which is the only thing that knows a picture is a face rather than a room. Without it this falls back to "every wired still is an identity", which is what it always did and is right when nothing better is known -- but with a register wired that fallback tells the encoder a photograph of a kitchen has a face and a hairstyle to match exactly. At cfg 1.0 there is no negative branch, so that is additive noise on every hop. `n_subjects` drives number agreement, not the picture count: two photographs of one person is still one identity. """ ords = (list(identity_ordinals) if identity_ordinals is not None else list(range(1, int(n_stills) + 1))) if not ords: # A register with no subject-bearing refs -- setting plates only. There # is no identity to lock, and _live_cite still cites the live frame. return "" pics = ", ".join(f"" for i in ords) count = int(n_subjects) if n_subjects is not None else len(ords) # Qwen3-VL is a language encoder, so number agreement is not cosmetic: # " are the only identities" is what a single wired ref produced. if count == 1: line = ( f"{pics} is the only identity. That face, bone structure, and hairstyle " "match the photograph exactly." ) if len(ords) == 1 else ( f"{pics} are the same one person, and the only identity. That face, bone " "structure, and hairstyle match those photographs exactly." ) else: line = ( f"{pics} are the only identities. Each face, bone structure, and hairstyle " "matches its photograph exactly." ) if live_picture and live_picture not in ords: who = ("that same person as they stand" if count == 1 else "those same people as they stand") line += f" shows {who} right now, mid-action." return line def _live_cite(live_picture, live_video): bits = [] if live_picture: bits.append( f" is the live frame at the start of this clip, " "already in progress from the pinned tail." ) if live_video: bits.append( f"