"""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 uuid import numpy as np 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), v0.34.0 or " "newer -- MiniMaxH3AddGuide does not exist before that. Update " "ComfyUI, 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 from . import music as _music from . import latents as _latents from . import audio_lock as _alock # 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 = "448p (0.34 MP)" DRAFT_STEPS = 6 # H3's canvas rules, mirrored from comfy_extras/nodes_minimax_h3.py # (CANVAS_MULTIPLE, MAX_PIXELS, adapt_canvas) so the two cannot drift silently. # Core applies them to reference VIDEOS only -- adapt_canvas has exactly one # call site and the generation canvas is not it. What core does with the size we # hand it is `height // 16`, which means an off-grid canvas does not raise: it # quietly builds a latent for a smaller frame than `master_imgs` was allocated # for. Hence _fit_canvas below, and the assertion at the call site. CANVAS_MULTIPLE = 32 CANVAS_AREA_CAP = 768 * 1344 # 1_032_192 # Ratio per aspect label, in dropdown order: widest landscape down to square, # then the portraits back out. LABELS ARE PART OF THE SAVED-WORKFLOW FORMAT -- # a combo widget stores its value as a string, so renaming one resets that # widget to the default on every graph that used it. The three 1.0.x labels # below are therefore verbatim, spacing included. ASPECTS = { "21:9 landscape": (21, 9), "16:9 landscape": (16, 9), "3:2 landscape": (3, 2), "4:3 landscape": (4, 3), "5:4 landscape": (5, 4), "1:1 square": (1, 1), "4:5 portrait": (4, 5), "3:4 portrait": (3, 4), "2:3 portrait": (2, 3), "9:16 portrait": (9, 16), "9:21 portrait": (9, 21), } DEFAULT_ASPECT = "16:9 landscape" # Short edge per resolution label. H3 is a 768-short-edge model: core's # adapt_canvas pins the short edge and derives the long one from the ratio, # capping the area at 768*1344. It does NOT work from an area budget, and the # distinction is not cosmetic -- 16:9 at the native tier is 1344x768, which an # area formula asking for "0.98 megapixels" of 10^6 pixels never reaches. # # The MP figure in each label is that tier at 16:9, in MEBIpixels (1024*1024), # which is where the number everyone quotes comes from: 1344*768 = 1_032_192, # and 1_032_192 / 1_048_576 = 0.984. It is quoted because people search for it. # It is exact for 16:9 only -- the same tier at 4:3 is 1024x768, which is # 0.75 MP -- so the label names the tier and the parenthesis is a signpost, not # a specification. tools/check_canvas.py asserts the 16:9 figure matches. RESOLUTIONS = { "768p (0.98 MP)": 768, # native "640p (0.70 MP)": 640, "576p (0.56 MP)": 576, "512p (0.44 MP)": 512, "448p (0.34 MP)": 448, } DEFAULT_RESOLUTION = "768p (0.98 MP)" # The complete 1.0.x canvas table, pinned. # # Through 1.0.x this was five hand-authored resolution labels by three aspects. # Those labels are off the dropdown now and no formula here reproduces them -- # they were never derived from the short edge, and half of them are not what an # area budget gives either. So all fifteen are pinned verbatim rather than # approximated, because width and height are in `chain_salt`: resolving them # differently would re-render every chain a 1.0.x user has on disk AND change # the pixels of a graph they already signed off. # # Nothing new can select one of these -- this exists only so an old saved # workflow keeps rendering what it always rendered. tools/check_canvas.py # asserts every entry against the table as it shipped. LEGACY_CANVAS = {} for _mp, _cells in { "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)}, }.items(): for _asp, _wh in _cells.items(): LEGACY_CANVAS[(_mp, _asp)] = _wh # The v1.1 pre-release labels. They shipped to nobody, but this repo's own # workflows and any graph saved while v1.1 was in progress carry them, and they # named the right tier under a wrong arithmetic. Alias rather than pin: these # should resolve to the tier they were trying to describe, not to the sizes the # area formula gave them. LEGACY_ALIAS = { "0.98 MP": "768p (0.98 MP)", "0.75 MP": "640p (0.70 MP)", "0.60 MP": "576p (0.56 MP)", "0.45 MP": "512p (0.44 MP)", "0.30 MP": "448p (0.34 MP)", } 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 _fit_canvas(ratio, short_edge): """comfy_extras.nodes_minimax_h3.adapt_canvas, with the short edge a knob. Line for line the same arithmetic as core, which is the point: pin the short edge, derive the long edge from the ratio, scale down if the area cap is exceeded, round each axis to the nearest 32. Core hard-codes BASE_SHORT_EDGE = 768; this takes it as an argument so the draft tiers below native run the identical path rather than a second implementation that agrees with it only at one rung. v1.1 briefly derived both axes from a megapixel budget instead. That is a different algorithm wearing the same rounding, and it disagreed with core at EVERY aspect ratio -- 16:9 came out 1312x736 against core's 1344x768, and 4:3 came out 1152x864 against 1024x768. The tell was in core's own docstring ("768-short-edge canvas with 768*1344 area cap") the whole time. """ short = max(CANVAS_MULTIPLE, int(short_edge)) if ratio >= 1.0: w, h = short * ratio, float(short) else: w, h = float(short), short / ratio if w * h > CANVAS_AREA_CAP: scale = math.sqrt(CANVAS_AREA_CAP / (w * h)) w, h = w * scale, h * scale m = CANVAS_MULTIPLE return (max(m, int(round(w / m)) * m), max(m, int(round(h / m)) * m)) def _canvas(resolution, aspect): """(width, height) for a resolution label and an aspect label. Three paths, in order. A 1.0.x label is pinned to the exact tuple it shipped with. A v1.1 pre-release label is aliased onto the tier it was trying to name. Anything current goes through core's arithmetic. An unreadable label is worth a line of output: before 1.1 this returned 1280x736 for any unrecognised input and said nothing, so a typo in an API-driven graph rendered at the wrong size with no evidence anywhere. """ res, asp = str(resolution), str(aspect) if (res, asp) in LEGACY_CANVAS: w, h = LEGACY_CANVAS[(res, asp)] print(f"[{TAG}] resolution {res!r} is a 1.0.x label: holding {w}x{h} so " f"this workflow keeps the pixels it was built with. Choose a " f"current resolution to move onto the 768p tier ladder.", flush=True) return w, h if res in LEGACY_ALIAS: moved = LEGACY_ALIAS[res] print(f"[{TAG}] resolution {res!r} was a v1.1 pre-release label and its " f"sizes were wrong; reading it as {moved!r}.", flush=True) res = moved ratio = ASPECTS.get(asp) if ratio is None: print(f"[{TAG}] unknown aspect {asp!r}; using {DEFAULT_ASPECT}", flush=True) ratio = ASPECTS[DEFAULT_ASPECT] short = RESOLUTIONS.get(res) if short is None: short = RESOLUTIONS[DEFAULT_RESOLUTION] print(f"[{TAG}] unknown resolution {resolution!r}; using " f"{DEFAULT_RESOLUTION}", flush=True) return _fit_canvas(ratio[0] / ratio[1], short) 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() # The base checkpoint itself. Everything below describes what was PATCHED # onto the model and nothing identified the model underneath it, so an int8 # build and a bf16 build of the same architecture, under the same LoRA # stack at the same strengths and the same attention settings, produced # byte-identical hop keys -- and the cache served frames rendered under the # other checkpoint. `model_dtype()` is ModelPatcher's own accessor # (comfy/model_patcher.py); it returns None when the inner model has no # `get_dtype`, which is hashed as a value rather than skipped so "no dtype" # and "some dtype" cannot collide. # # Residual gap, narrower than the one it closes: two *different* int8 # builds of the same architecture still match. Separating those needs # digests of a few fixed weight keys, which costs a state-dict walk per run. base = getattr(model, "model", None) try: base_dtype = model.model_dtype() if hasattr(model, "model_dtype") else None except Exception: # noqa: BLE001 -- a patcher that cannot answer is still a key base_dtype = "?" h.update(f"base:{type(base).__name__}:{base_dtype}".encode()) _dm = getattr(base, "diffusion_model", None) if _dm is not None: try: h.update(f":n{sum(p.numel() for p in _dm.parameters()):d}".encode()) except Exception: # noqa: BLE001 h.update(b":n?") 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 _object_scalars(obj): """Public scalar attributes of something that configures itself by instance rather than by closure. `_closure_scalars` digs settings out of a callable's cells, which is how H3-SLA-Attention carries its config. A node that installs a configured *object* instead -- `set_model_patch_replace(cache, "dit", "block_loop", 0)` with an instance on it -- has no closure at all, and an instance inherits neither `__qualname__` nor `__name__` from its class, so it rendered as the bare constant "fn()" and every setting on it vanished from the key. Toggling such a node moved the fingerprint (a new key appears in `patches_replace`); changing its settings did not. That is the SLA bug one type away. Scalars only, for the same reason the closure walk is scalars only. Note the tradeoff this accepts: a scalar attribute the node mutates during a run makes the fingerprint move between runs and the cache stop hitting while that node is installed. That direction is deliberate -- this pack treats serving frames from the wrong settings as worse than not serving them at all. """ try: items = vars(obj).items() except TypeError: # no __dict__ (slots, builtins) -- nothing to read return "" parts = [f"{k}={v!r}" for k, v in sorted(items, key=lambda kv: str(kv[0])) if not str(k).startswith("_") and (isinstance(v, (str, int, float, bool)) or v is None)] return "{" + ",".join(parts) + "}" if parts else "" def _callable_scalars(fn, depth=0): """Settings a callable carries, whichever way it carries them. There are four ways a node hands a configured callable to the model and all four have to reach the hash, because they are interchangeable from the installing node's point of view and indistinguishable from here: * a closure -- cells (`_closure_scalars`) * a configured instance -- its attributes (`_object_scalars`) * a BOUND METHOD of a configured instance -- neither. `vars()` on a bound method proxies to the underlying *function's* `__dict__`, which is empty, so the instance's settings were invisible; only `__qualname__` survived. A node registering `self.forward` rather than `self` is the object case one attribute away. * a `functools.partial` -- neither either. It has no `__name__`, no `__qualname__`, no `__closure__`, and an empty `__dict__`, so it collapsed to the constant "fn()" exactly as a bare instance did. Everything it carries is in `func`, `args` and `keywords`. Depth-bounded because `partial` can wrap `partial`. """ parts = [_closure_scalars(fn), _object_scalars(fn)] if depth <= 3: owner = getattr(fn, "__self__", None) if owner is not None: parts.append("@" + type(owner).__name__ + _object_scalars(owner)) inner = getattr(fn, "func", None) if inner is not None and callable(inner): bound = ["<" + _callable_scalars(inner, depth + 1)] for a in (getattr(fn, "args", None) or ()): bound.append(repr(a) if isinstance(a, (str, int, float, bool)) or a is None else type(a).__name__) kw = getattr(fn, "keywords", None) or {} for k in sorted(kw, key=str): v = kw[k] bound.append(f"{k}=" + (repr(v) if isinstance(v, (str, int, float, bool)) or v is None else type(v).__name__)) parts.append(",".join(bound) + ">") return "".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 _callable_scalars(obj) return type(obj).__name__ + _object_scalars(obj) 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 _refvid_cite(desc, ordinal=1): """One sentence saying what the author's reference clip is for. The clip has always gone in as