"""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 from . import music as _music from . import latents as _latents # 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() 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 _refvid_cite(desc, ordinal=1): """One sentence saying what the author's reference clip is for. The clip has always gone in as