File size: 20,542 Bytes
bc50ea0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 | """Let keyframes and refs coexist.
Older `MiniMaxH3.extra_conds` implementations in comfy/model_base.py fill the
payload from two independent `if` blocks. The keyframe block sets the Guide
video/audio lists, then the refs block **overwrites** them:
if keyframes is not None:
payload["cond_video_latents"] = [kf["latent"] for kf in keyframes]
if refs is not None:
payload["cond_video_latents"] = [r["latent"] for r in refs if "latent" in r]
payload["cond_audio_latents"] = [r["audio_latent"] for r in refs ...]
So attaching an audio-only ref alongside keyframes wipes the keyframe video
content: an audio-only block has no "latent" key, the list comes back empty,
and the cond rows the layout built have nothing to fill them.
The layout itself handles the combination fine. Keyframe cond rows are
emitted first, ref rows second, target rows last, which is exactly the order
the forward pass expects when it writes rows into the never-denoised slots.
Only this payload assignment is in the way.
This wrapper re-runs the same logic and concatenates instead, keeping
keyframe latents first to match the row order. Both video and audio must be
merged: native H3 Guides may carry continuation audio while Ref2VA carries a
separate tagged/source audio reference in the same conditioning.
It only does that for graphs that asked for it. The wrapper is installed
process-wide, but it returns stock output unless a keyframe or a ref
carries one of this pack's markers. Graphs that combine stock keyframes
and refs without going anywhere near Motion Context therefore behave
exactly as they did before the pack was installed. That matters because
such graphs exist: a Ref2VA workflow with a last_frame anchor is a
legitimate combination of both mechanisms and has nothing to do with
chaining. Whether stock's overwrite is a bug there is not this pack's
call to make.
Graphs using only one mechanism are unaffected either way: with no refs
the ref list is empty, with no keyframes the keyframe list is.
The order is what makes this safe under Ref2VA, where the ref list holds
the graph's own image, video and audio blocks as well as ours. Stock
builds the layout as text, then cond rows, then reference blocks in list
order, so keyframe latents first and reference latents in list order is
exactly the order the rows are waiting in. Audio is filled from the same
list in the same order, and an audio-only block contributes nothing to
the video list, so the two stay in step however the graph is wired.
"""
import logging
import sys
import torch
import comfy.model_base as model_base
_LOG = logging.getLogger("h3_motion_context")
# Duplicated from patch_layout rather than imported, so this module stays
# independently testable against a fake model_base. The marker strings are
# a shared ABI across the two patches, the node, and any pack vendoring
# them: rename in lockstep or not at all.
MC_KEY = "motion_context_index"
MC_AUDIO_KEY = "motion_context_audio_end_frame"
CHAIN_VISUAL_KEY = "h3_chain_context_visual"
CHAIN_AUDIO_KEY = "h3_chain_context_audio"
# Marker set on our wrapper so a second copy of this file, vendored into
# another pack, can recognise it and stand down instead of wrapping it.
# Shared ABI across every pack that vendors this patch.
PATCH_MARKER = "_h3_motion_context_payload_patch"
MULTISHOT_PATCH_MARKER = "_h3_avbank_merge"
_orig_extra_conds = None
_applied = False
def _callable_source(fn):
"""Best-effort source path for a dynamically imported wrapper."""
code = getattr(fn, "__code__", None)
source = str(getattr(code, "co_filename", "") or "")
owner_globals = getattr(fn, "__globals__", None)
if not source and isinstance(owner_globals, dict):
source = str(owner_globals.get("__file__") or "")
if not source:
owner_module = sys.modules.get(str(getattr(fn, "__module__", "")))
source = str(getattr(owner_module, "__file__", "") or "")
return source
def _custom_node_folder(source):
"""Extract ``custom_nodes/<pack>`` from POSIX or Windows paths."""
normalized = str(source or "").replace("\\", "/")
marker = "/custom_nodes/"
if marker not in normalized:
return "unknown"
tail = normalized.split(marker, 1)[1]
return tail.split("/", 1)[0] or "unknown"
def _loaded_payload_patch_modules(current=None):
"""List loaded modules that look capable of owning this wrapper family."""
found = []
for name, module in list(sys.modules.items()):
if module is None:
continue
try:
candidate = getattr(module, "_patched_extra_conds", None)
source = str(getattr(module, "__file__", "") or "")
basename = source.replace("\\", "/").rsplit("/", 1)[-1]
relevant = bool(
candidate is current
or getattr(candidate, PATCH_MARKER, False)
or basename == "patch_payload.py"
)
except Exception:
continue
if not relevant:
continue
relationship = "live_owner" if candidate is current else "same_family"
found.append("%s [relationship=%s; custom_node=%s; source=%s]" % (
name,
relationship,
_custom_node_folder(source),
source or "unknown",
))
return sorted(set(found))
def payload_owner_diagnostics(fn=None):
"""Describe the live payload owner without relying on import aliases.
ComfyUI loads custom-node packages under dynamic module names, and an old
wrapper can survive after its alias is replaced. Function globals and code
paths are therefore more authoritative than the current ``sys.modules``
entry. This text is intentionally suitable for pasting into an issue.
"""
if fn is None:
cls = getattr(model_base, "MiniMaxH3", None)
fn = getattr(cls, "extra_conds", None) if cls is not None else None
if fn is None:
return "live payload owner is unavailable"
module_name = str(getattr(fn, "__module__", "") or "unknown")
function_name = str(
getattr(fn, "__qualname__", "")
or getattr(fn, "__name__", "")
or type(fn).__name__)
source = _callable_source(fn)
markers = []
if getattr(fn, PATCH_MARKER, False):
markers.append(PATCH_MARKER)
if getattr(fn, MULTISHOT_PATCH_MARKER, False):
markers.append(MULTISHOT_PATCH_MARKER)
if hasattr(fn, "__wrapped__"):
markers.append("__wrapped__")
owner_globals = getattr(fn, "__globals__", None)
captures = []
if isinstance(owner_globals, dict):
for name, value in owner_globals.items():
lowered = str(name).lower()
if (callable(value) and "cond" in lowered
and ("orig" in lowered or "stock" in lowered)):
captures.append(str(name))
loaded = _loaded_payload_patch_modules(fn)
return (
"live_owner=%s.%s; custom_node=%s; source=%s; markers=%s; "
"captured_conditioning_methods=%s; loaded_payload_patch_modules=%s"
% (
module_name,
function_name,
_custom_node_folder(source),
source or "unknown",
",".join(markers) or "none",
",".join(sorted(captures)) or "none",
" | ".join(loaded) or "none",
)
)
def _patched_extra_conds(self, **kwargs):
out = _orig_extra_conds(self, **kwargs)
keyframes = kwargs.get("minimax_keyframes", None)
refs = kwargs.get("minimax_refs", None)
if not keyframes or not refs:
return out # only one mechanism in play, stock behaviour is correct
if not (any(MC_KEY in kf or CHAIN_VISUAL_KEY in kf
or CHAIN_AUDIO_KEY in kf for kf in keyframes)
or any(MC_AUDIO_KEY in r for r in refs)):
# nothing here came from this pack. The layout patch is gated the
# same way, so leaving the payload alone keeps the two consistent
# and leaves unrelated graphs bit-identical to stock.
return out
cond = out.get("minimax_payload", None)
payload = getattr(cond, "cond", None) if cond is not None else None
if not isinstance(payload, dict):
_LOG.warning("h3_motion_context: could not reach the H3 payload, "
"keyframe latents may have been overwritten by refs")
return out
kf_video = [kf["latent"] for kf in keyframes if "latent" in kf]
ref_video = [r["latent"] for r in refs if "latent" in r]
kf_audio = [kf["audio_latent"] for kf in keyframes
if kf.get("audio_latent") is not None]
ref_audio = [r["audio_latent"] for r in refs
if r.get("audio_latent") is not None]
payload["cond_video_latents"] = kf_video + ref_video
payload["cond_audio_latents"] = kf_audio + ref_audio
# only write frame_count when we actually have one. This wrapper fires
# for ANY graph combining keyframes and refs, not just ours; a graph
# that reaches here without minimax_frame_count may have a valid value
# already set by the original, and overwriting it with None would break
# the last-frame anchor branch downstream.
fc = kwargs.get("minimax_frame_count", None)
if fc is not None:
payload["frame_count"] = fc
return out
setattr(_patched_extra_conds, PATCH_MARKER, True)
def native_payload_merge_status():
"""Behaviorally probe the live keyframe + Ref2VA payload merge.
Native arbitrary Guide layout and payload merging landed in different
ComfyUI revisions. A partially updated runtime can therefore reserve both
Guide and reference rows in ``PackedLayout`` while ``extra_conds`` supplies
only the reference tensors. Probe the callable that is actually installed
instead of relying on a ComfyUI version or source inspection.
"""
status = {
"native_keyframe_ref_merge": False,
"native_keyframe_ref_audio_merge": False,
}
cls = getattr(model_base, "MiniMaxH3", None)
fn = getattr(cls, "extra_conds", None) if cls is not None else None
if fn is None:
status["error"] = "MiniMaxH3.extra_conds is unavailable"
return status
try:
probe = cls.__new__(cls)
# BaseModel.extra_conds only consults these fields when called without
# cross-attention, masks, or latent-shape inputs.
probe.concat_keys = ()
probe.latent_shapes = None
# Real tensors keep the probe compatible with otherwise harmless
# wrappers that validate latent shape/dtype before forwarding to core.
keyframe_video = torch.zeros((1, 24, 1, 2, 2))
reference_video = torch.ones((1, 24, 1, 2, 2))
keyframe_audio = torch.zeros((1, 32, 2, 2))
reference_audio = torch.ones((1, 32, 2, 2))
keyframes = [{
"resolved_frame_index": 0,
"latent": keyframe_video,
"audio_latent": keyframe_audio,
CHAIN_VISUAL_KEY: True,
CHAIN_AUDIO_KEY: True,
}]
refs = [
{"kind": "image", "latent": reference_video,
"latent_h": 2, "latent_w": 2},
{"kind": "audio", "ref_audio_t": 1,
"audio_latent": reference_audio},
]
result = fn(
probe, minimax_keyframes=keyframes, minimax_refs=refs)
wrapped = (result.get("minimax_payload")
if isinstance(result, dict) else None)
payload = getattr(wrapped, "cond", wrapped)
if not isinstance(payload, dict):
status["error"] = "MiniMaxH3 payload is not a dictionary"
return status
videos = payload.get("cond_video_latents", [])
audios = payload.get("cond_audio_latents", [])
status["native_keyframe_ref_merge"] = bool(
len(videos) == 2
and videos[0] is keyframe_video
and videos[1] is reference_video)
status["native_keyframe_ref_audio_merge"] = bool(
len(audios) == 2
and audios[0] is keyframe_audio
and audios[1] is reference_audio)
except Exception as exc:
status["error"] = repr(exc)
return status
def _already_patched(cls):
"""Has another copy of this file already wrapped extra_conds?
Returns None, "same", "other", "h3_multishot" or "foreign". The
marker only recognises copies new enough to set it, so a wrapper
merely NAMED like ours counts as another copy: an older version, or
a fork, and the second one in stands down. Anything else wrapping
extra_conds is a different pack solving the same problem its own way,
and this one refuses rather than stacking on it. See patch_layout's
version for how the detection works and what it cannot see.
"""
fn = getattr(cls, "extra_conds", None)
if fn is None:
return None
# H3-Multishot installs its AV-bank merge at package import time. Its
# wrapper calls stock first, then rebuilds cond_video_latents as keyframes
# followed by reference video latents -- the same row order required here.
# Stock already supplies reference audio latents and frame_count, so that
# wrapper is sufficient for our marked Ref2VA + motion-context payloads.
# Require both its public marker and source-module suffix: a coincidental
# attribute on an unrelated wrapper must remain a hard collision.
where = str(getattr(fn, "__module__", "") or "")
if (getattr(fn, MULTISHOT_PATCH_MARKER, False)
and (where == "h3_avbank_probe"
or where.endswith(".h3_avbank_probe"))):
return "h3_multishot"
# Check the specific Multishot identity before this generic shared marker.
# Some releases preserve attributes from the wrapper they replace and
# consequently carry both markers. Treating that dual-marker function as
# merely "same" skips the safe keyframe-audio layer above Multishot.
if getattr(fn, PATCH_MARKER, False):
return "same"
if getattr(fn, "__name__", "") == "_patched_extra_conds":
return "other"
if hasattr(fn, "__wrapped__"):
return "foreign"
home = getattr(cls, "__module__", None)
where = getattr(fn, "__module__", None)
if home and where and where != home:
return "foreign"
return None
def apply_patch():
global _orig_extra_conds, _applied
if _applied:
return True
cls = getattr(model_base, "MiniMaxH3", None)
if cls is None or not hasattr(cls, "extra_conds"):
_LOG.warning("h3_motion_context: MiniMaxH3.extra_conds not found, "
"keyframes and refs cannot be combined")
return False
who = _already_patched(cls)
if who == "foreign":
_LOG.warning(
"h3_motion_context: another pack has already patched "
"MiniMaxH3.extra_conds (it now comes from %r). Both packs are "
"solving the same keyframe/ref collision and they cannot both "
"own it, so this one is refusing. Disable the identified "
"competing pack or duplicate folder and fully restart ComfyUI. "
"%s",
getattr(getattr(cls, "extra_conds", None), "__module__", "?"),
payload_owner_diagnostics(getattr(cls, "extra_conds", None)))
return False
if who:
# report success: the patch IS active, just not ours, and the
# calling pack's nodes check is_applied() before they will run
_applied = True
if who == "same":
_LOG.info("h3_motion_context: keyframe/ref coexistence already "
"enabled by another pack, standing down")
elif who == "h3_multishot":
_LOG.info(
"h3_motion_context: H3-Multishot's compatible AV-bank "
"payload merge is active; standing down without stacking")
else:
_LOG.warning(
"h3_motion_context: the H3 payload patch is already "
"installed by a DIFFERENT copy of this code (another "
"version, or a fork). Standing down. If you have more than "
"one H3 Motion Context folder in custom_nodes, keep one and "
"remove the rest.")
return True
_orig_extra_conds = cls.extra_conds
cls.extra_conds = _patched_extra_conds
_applied = True
_LOG.info("h3_motion_context: keyframe/ref coexistence enabled")
return True
def claim_patch_ownership(*, require_keyframe_audio=False):
"""Prefer this copy over an older compatible copy of the same patch.
This is intentionally stricter than merely replacing whatever currently
owns ``extra_conds``. Only a wrapper carrying our shared marker (or the
exact legacy wrapper name) can be displaced, and its module must expose
the unwrapped stock callable it captured. H3-Multishot's known-compatible
merge remains in place; unknown wrappers are refused.
Returns ``(ok, detail)`` for the visible Patch Priority pass-through node.
"""
global _orig_extra_conds, _applied
cls = getattr(model_base, "MiniMaxH3", None)
current = getattr(cls, "extra_conds", None) if cls is not None else None
if current is None:
return False, "MiniMaxH3.extra_conds is unavailable"
if current is _patched_extra_conds:
_applied = True
return True, "payload owned by this pack"
who = _already_patched(cls)
if who == "h3_multishot":
if not require_keyframe_audio:
_applied = True
return True, "compatible H3-Multishot payload merge retained"
# Its known-compatible wrapper merges the video lists but historically
# leaves audio to the underlying ComfyUI implementation. If the live
# capability probe has already shown that keyframe audio is missing,
# layer our marker-gated AV merge over that known wrapper. Unrelated
# wrappers remain refused below.
_orig_extra_conds = current
cls.extra_conds = _patched_extra_conds
_applied = True
_LOG.info(
"h3_motion_context: marker-gated keyframe-audio merge layered "
"over H3-Multishot's compatible video merge")
return True, "keyframe-audio merge layered over H3-Multishot"
if who not in ("same", "other"):
return False, (
"payload owner is %s; only another H3 Motion Context copy can "
"be safely replaced. %s" % (
who or "stock/uninitialized",
payload_owner_diagnostics(current)))
# ComfyUI's custom-node loader can leave an active function reachable from
# the model class after its import alias has been reused by another copy or
# disappeared from sys.modules. A function keeps the exact globals
# dictionary it executes against, so that is the authoritative place to
# recover the stock method captured by another compatible copy. Keep the
# module lookup as a fallback for unusual callable wrappers.
owner_globals = getattr(current, "__globals__", None)
original = (owner_globals.get("_orig_extra_conds")
if isinstance(owner_globals, dict) else None)
if not callable(original):
owner_module = sys.modules.get(
str(getattr(current, "__module__", "")))
original = getattr(owner_module, "_orig_extra_conds", None)
if not callable(original) or original is current:
return False, (
"the existing H3 Motion Context payload wrapper does not expose "
"its captured stock method. Disable the competing pack or "
"duplicate folder identified below, then fully restart ComfyUI. "
"%s" % payload_owner_diagnostics(current))
home = str(getattr(cls, "__module__", "") or "")
where = str(getattr(original, "__module__", "") or "")
if (hasattr(original, "__wrapped__") or (home and where != home)
or getattr(original, PATCH_MARKER, False)):
return False, (
"the existing payload wrapper captured another unknown wrapper; "
"refusing to discard it. %s" %
payload_owner_diagnostics(current))
_orig_extra_conds = original
cls.extra_conds = _patched_extra_conds
_applied = True
_LOG.info(
"h3_motion_context: this pack claimed keyframe/ref payload ownership "
"from compatible module %s", getattr(current, "__module__", "?"))
return True, "payload ownership claimed from a compatible older copy"
def is_applied():
return _applied
|