Make the compatibility claim testable instead of aspirational
Browse filesEveryone runs a different ComfyUI, and Core's parameter ORDER has already moved
once -- master reorders MiniMaxH3ReferenceToVideo. Three things now stand
between that and a user's traceback.
**No positional Core call is left.** The two H3 nodes were converted when a
user hit "multiple values for argument 'ref_image_size'"; the other six were
not, and were the same failure waiting: MiniMaxH3SigmaShift, KSamplerSelect,
BasicScheduler, BasicGuider, RandomNoise, SamplerCustomAdvanced. All now go
through _core_call by keyword, so they also inherit its error message, which
names the node, the argument and what the installed Core actually takes.
**check_core_calls.py watches all nine nodes, not three.** It resolves each
one through the module that defines it, so nodes_custom_sampler is covered
alongside nodes_minimax_h3. Eleven call sites, every one order-independent and
name-checked against the Core on this machine.
**check_core_matrix.py checks the Core on everyone else's.** It reads the floor
from pyproject, enumerates every ComfyUI release at or above it plus today's
master, and binds all eleven call sites against each version's real signatures
-- node present, nothing positional, every keyword real, every required
parameter supplied. Signatures are read with ast, never imported: it downloads
code from the internet and parsing is not running.
Falsified by lowering the floor to 0.30.0, where it fails on v0.30.0, v0.31.0,
v0.32.0 and v0.33.1 with "MiniMaxH3AddGuide does not exist here" and passes on
v0.34.0 and master -- deriving the floor independently of the reasoning that
set it. Network-bound, so it is not in check_all.py.
Verified statically and by signature binding, not by a render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLXmbwfdXirQ5oFreXPcMi
- .gitignore +1 -0
- h3_ref_chain.py +25 -10
- tools/check_core_calls.py +19 -2
- tools/check_core_matrix.py +198 -0
|
@@ -20,3 +20,4 @@ htc_llm.json
|
|
| 20 |
# dead ends and machine specifics, and one of them is always the live queue.
|
| 21 |
# The root HANDOVER.md was already ignored; these are the same thing, dated.
|
| 22 |
docs/HANDOVER_*.md
|
|
|
|
|
|
| 20 |
# dead ends and machine specifics, and one of them is always the live queue.
|
| 21 |
# The root HANDOVER.md was already ignored; these are the same thing, dated.
|
| 22 |
docs/HANDOVER_*.md
|
| 23 |
+
.core_matrix_cache/
|
|
@@ -2879,9 +2879,17 @@ class HandTieClips:
|
|
| 2879 |
sampler = base_sigmas = None
|
| 2880 |
sigma_cache = {}
|
| 2881 |
if not dry:
|
| 2882 |
-
model = _result(
|
| 2883 |
-
|
| 2884 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2885 |
sigma_cache = {int(steps): base_sigmas}
|
| 2886 |
|
| 2887 |
print(
|
|
@@ -3587,23 +3595,30 @@ class HandTieClips:
|
|
| 3587 |
|
| 3588 |
_offload_text_encoder(clip, model)
|
| 3589 |
|
| 3590 |
-
guider = _result(
|
|
|
|
|
|
|
| 3591 |
if shot.get("seed") is not None:
|
| 3592 |
shot_seed = int(shot["seed"])
|
| 3593 |
else:
|
| 3594 |
shot_seed = (int(seed) + i) if seed_per_shot else int(seed)
|
| 3595 |
hop_steps = int(shot.get("steps") or steps)
|
| 3596 |
if hop_steps not in sigma_cache:
|
| 3597 |
-
sigma_cache[hop_steps] = _result(
|
| 3598 |
-
BasicScheduler
|
|
|
|
|
|
|
| 3599 |
hop_sigmas = sigma_cache[hop_steps]
|
| 3600 |
if hop_steps != int(steps) or shot.get("seed") is not None:
|
| 3601 |
print(f"[{TAG}] hop {i + 1} override: seed={shot_seed} "
|
| 3602 |
f"steps={hop_steps}", flush=True)
|
| 3603 |
-
noise = _result(
|
| 3604 |
-
|
| 3605 |
-
|
| 3606 |
-
|
|
|
|
|
|
|
|
|
|
| 3607 |
|
| 3608 |
imgs, audio = _decode_av(vae, audio_vae, sampled)
|
| 3609 |
imgs = imgs.contiguous().cpu()
|
|
|
|
| 2879 |
sampler = base_sigmas = None
|
| 2880 |
sigma_cache = {}
|
| 2881 |
if not dry:
|
| 2882 |
+
model = _result(_core_call(
|
| 2883 |
+
MiniMaxH3SigmaShift, "the sigma shift",
|
| 2884 |
+
model=model, shift_video=float(shift_video),
|
| 2885 |
+
shift_audio=float(shift_audio)))[0]
|
| 2886 |
+
sampler = _result(_core_call(
|
| 2887 |
+
KSamplerSelect, "the sampler",
|
| 2888 |
+
sampler_name=sampler_name))[0]
|
| 2889 |
+
base_sigmas = _result(_core_call(
|
| 2890 |
+
BasicScheduler, "the sigma schedule",
|
| 2891 |
+
model=model, scheduler=scheduler, steps=int(steps),
|
| 2892 |
+
denoise=1.0))[0]
|
| 2893 |
sigma_cache = {int(steps): base_sigmas}
|
| 2894 |
|
| 2895 |
print(
|
|
|
|
| 3595 |
|
| 3596 |
_offload_text_encoder(clip, model)
|
| 3597 |
|
| 3598 |
+
guider = _result(_core_call(
|
| 3599 |
+
BasicGuider, "the guider",
|
| 3600 |
+
model=model, conditioning=cond))[0]
|
| 3601 |
if shot.get("seed") is not None:
|
| 3602 |
shot_seed = int(shot["seed"])
|
| 3603 |
else:
|
| 3604 |
shot_seed = (int(seed) + i) if seed_per_shot else int(seed)
|
| 3605 |
hop_steps = int(shot.get("steps") or steps)
|
| 3606 |
if hop_steps not in sigma_cache:
|
| 3607 |
+
sigma_cache[hop_steps] = _result(_core_call(
|
| 3608 |
+
BasicScheduler, "the sigma schedule",
|
| 3609 |
+
model=model, scheduler=scheduler, steps=hop_steps,
|
| 3610 |
+
denoise=1.0))[0]
|
| 3611 |
hop_sigmas = sigma_cache[hop_steps]
|
| 3612 |
if hop_steps != int(steps) or shot.get("seed") is not None:
|
| 3613 |
print(f"[{TAG}] hop {i + 1} override: seed={shot_seed} "
|
| 3614 |
f"steps={hop_steps}", flush=True)
|
| 3615 |
+
noise = _result(_core_call(
|
| 3616 |
+
RandomNoise, "the noise source",
|
| 3617 |
+
noise_seed=shot_seed))[0]
|
| 3618 |
+
sampled = _result(_core_call(
|
| 3619 |
+
SamplerCustomAdvanced, "the sampler",
|
| 3620 |
+
noise=noise, guider=guider, sampler=sampler,
|
| 3621 |
+
sigmas=hop_sigmas, latent_image=latent))[0]
|
| 3622 |
|
| 3623 |
imgs, audio = _decode_av(vae, audio_vae, sampled)
|
| 3624 |
imgs = imgs.contiguous().cpu()
|
|
@@ -31,7 +31,20 @@ COMFY = os.path.dirname(os.path.dirname(HERE))
|
|
| 31 |
sys.path.insert(0, COMFY)
|
| 32 |
|
| 33 |
FAIL = []
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
def ck(name, cond, detail=""):
|
|
@@ -41,7 +54,11 @@ def ck(name, cond, detail=""):
|
|
| 41 |
|
| 42 |
|
| 43 |
def core_params(cls_name):
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
cls = getattr(core, cls_name, None)
|
| 46 |
if cls is None:
|
| 47 |
return None
|
|
|
|
| 31 |
sys.path.insert(0, COMFY)
|
| 32 |
|
| 33 |
FAIL = []
|
| 34 |
+
# Every Core node this pack calls, and the module that defines it. The H3 three
|
| 35 |
+
# are the ones whose order actually moved; the sampler five are the same failure
|
| 36 |
+
# class waiting to happen, and cost nothing to watch.
|
| 37 |
+
WATCHED = {
|
| 38 |
+
"MiniMaxH3ReferenceToVideo": "comfy_extras.nodes_minimax_h3",
|
| 39 |
+
"MiniMaxH3AddGuide": "comfy_extras.nodes_minimax_h3",
|
| 40 |
+
"MiniMaxH3ImageToVideo": "comfy_extras.nodes_minimax_h3",
|
| 41 |
+
"MiniMaxH3SigmaShift": "comfy_extras.nodes_minimax_h3",
|
| 42 |
+
"KSamplerSelect": "comfy_extras.nodes_custom_sampler",
|
| 43 |
+
"BasicScheduler": "comfy_extras.nodes_custom_sampler",
|
| 44 |
+
"BasicGuider": "comfy_extras.nodes_custom_sampler",
|
| 45 |
+
"RandomNoise": "comfy_extras.nodes_custom_sampler",
|
| 46 |
+
"SamplerCustomAdvanced": "comfy_extras.nodes_custom_sampler",
|
| 47 |
+
}
|
| 48 |
|
| 49 |
|
| 50 |
def ck(name, cond, detail=""):
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
def core_params(cls_name):
|
| 57 |
+
import importlib
|
| 58 |
+
try:
|
| 59 |
+
core = importlib.import_module(WATCHED[cls_name])
|
| 60 |
+
except (ImportError, KeyError):
|
| 61 |
+
return None
|
| 62 |
cls = getattr(core, cls_name, None)
|
| 63 |
if cls is None:
|
| 64 |
return None
|
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prove every Core call works on EVERY ComfyUI in the range we advertise.
|
| 2 |
+
|
| 3 |
+
check_core_calls.py checks the Core installed on this machine. That proves the
|
| 4 |
+
pack does not depend on parameter order, which is what broke a user once, but it
|
| 5 |
+
cannot prove the call works on the build somebody else is running -- and
|
| 6 |
+
everybody is running something different. pyproject.toml claims
|
| 7 |
+
`requires-comfyui = ">=0.34.0"` with no upper bound. This is what makes that
|
| 8 |
+
claim testable rather than aspirational.
|
| 9 |
+
|
| 10 |
+
For every ComfyUI release at or above the declared floor, plus today's master,
|
| 11 |
+
it fetches the modules that define the nodes this pack calls, reads their
|
| 12 |
+
`execute` signatures, and checks each call site against them:
|
| 13 |
+
|
| 14 |
+
* the node exists in that version at all
|
| 15 |
+
* nothing is passed positionally
|
| 16 |
+
* every keyword passed is a real parameter there
|
| 17 |
+
* every parameter that has no default there is one we supply
|
| 18 |
+
|
| 19 |
+
Signatures are read with `ast`, never imported -- this downloads code from the
|
| 20 |
+
internet, and parsing it is not the same as running it.
|
| 21 |
+
|
| 22 |
+
Network, so it is not in check_all.py. Run it when Core moves, when the floor
|
| 23 |
+
changes, or before a release.
|
| 24 |
+
|
| 25 |
+
D:\\ComfyUI\\venv\\Scripts\\python.exe tools\\check_core_matrix.py
|
| 26 |
+
... --refresh ignore the cache and re-fetch
|
| 27 |
+
"""
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import ast
|
| 31 |
+
import io
|
| 32 |
+
import json
|
| 33 |
+
import os
|
| 34 |
+
import re
|
| 35 |
+
import sys
|
| 36 |
+
import urllib.error
|
| 37 |
+
import urllib.request
|
| 38 |
+
|
| 39 |
+
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 40 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 41 |
+
|
| 42 |
+
# comfyanonymous/ComfyUI redirected to Comfy-Org/ComfyUI; the numeric id is
|
| 43 |
+
# stable across the rename and does not 301.
|
| 44 |
+
REPO = "https://api.github.com/repositories/589831718"
|
| 45 |
+
RAW = "https://raw.githubusercontent.com/Comfy-Org/ComfyUI/{ref}/{path}"
|
| 46 |
+
CACHE = os.path.join(HERE, ".core_matrix_cache")
|
| 47 |
+
|
| 48 |
+
MODULES = {
|
| 49 |
+
"comfy_extras.nodes_minimax_h3": "comfy_extras/nodes_minimax_h3.py",
|
| 50 |
+
"comfy_extras.nodes_custom_sampler": "comfy_extras/nodes_custom_sampler.py",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
FAILS = []
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def ck(label, ok, detail=""):
|
| 57 |
+
print(" %-4s %-46s %s" % ("ok" if ok else "FAIL", label, detail))
|
| 58 |
+
if not ok:
|
| 59 |
+
FAILS.append(label)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def fetch(url, cache_key, refresh=False):
|
| 63 |
+
path = os.path.join(CACHE, cache_key)
|
| 64 |
+
if not refresh and os.path.exists(path):
|
| 65 |
+
return io.open(path, encoding="utf-8").read()
|
| 66 |
+
with urllib.request.urlopen(url, timeout=60) as r:
|
| 67 |
+
body = r.read().decode("utf-8")
|
| 68 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 69 |
+
io.open(path, "w", encoding="utf-8", newline="").write(body)
|
| 70 |
+
return body
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def parse_version(tag):
|
| 74 |
+
m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)", tag or "")
|
| 75 |
+
return tuple(int(x) for x in m.groups()) if m else None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def floor_from_pyproject():
|
| 79 |
+
s = io.open(os.path.join(HERE, "pyproject.toml"), encoding="utf-8").read()
|
| 80 |
+
m = re.search(r'^requires-comfyui\s*=\s*"([^"]+)"', s, re.M)
|
| 81 |
+
if not m:
|
| 82 |
+
return None, ""
|
| 83 |
+
spec = m.group(1)
|
| 84 |
+
return parse_version(spec.lstrip(">=<!~ ")), spec
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def signatures(src):
|
| 88 |
+
"""{name: (param_names, required_names)} read without importing."""
|
| 89 |
+
out = {}
|
| 90 |
+
tree = ast.parse(src)
|
| 91 |
+
for cls in [n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)]:
|
| 92 |
+
for fn in cls.body:
|
| 93 |
+
if not (isinstance(fn, ast.FunctionDef) and fn.name == "execute"):
|
| 94 |
+
continue
|
| 95 |
+
a = fn.args
|
| 96 |
+
names = [x.arg for x in (a.posonlyargs + a.args)
|
| 97 |
+
if x.arg not in ("cls", "self")]
|
| 98 |
+
ndef = len(a.defaults)
|
| 99 |
+
req = names[:len(names) - ndef] if ndef else list(names)
|
| 100 |
+
names += [x.arg for x in a.kwonlyargs]
|
| 101 |
+
req += [x.arg for x, d in zip(a.kwonlyargs, a.kw_defaults)
|
| 102 |
+
if d is None]
|
| 103 |
+
# Indexed by the class name, because that is what this pack imports.
|
| 104 |
+
out[cls.name] = (names, req)
|
| 105 |
+
return out
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def call_sites():
|
| 109 |
+
"""Every watched Core call in the pack, with the keywords it passes."""
|
| 110 |
+
import check_core_calls as C
|
| 111 |
+
src = io.open(os.path.join(HERE, "h3_ref_chain.py"), encoding="utf-8").read()
|
| 112 |
+
sites = []
|
| 113 |
+
for node in ast.walk(ast.parse(src)):
|
| 114 |
+
if not isinstance(node, ast.Call):
|
| 115 |
+
continue
|
| 116 |
+
f, owner, args = node.func, None, None
|
| 117 |
+
if (isinstance(f, ast.Attribute) and f.attr == "execute"
|
| 118 |
+
and isinstance(f.value, ast.Name)):
|
| 119 |
+
owner, args = f.value.id, node.args
|
| 120 |
+
elif isinstance(f, ast.Name) and f.id == "_core_call" and node.args:
|
| 121 |
+
first = node.args[0]
|
| 122 |
+
if isinstance(first, ast.Name):
|
| 123 |
+
# _core_call(Node, "what", **kw) -- the first two are the
|
| 124 |
+
# wrapper's own, everything after would be positional to Core.
|
| 125 |
+
owner, args = first.id, node.args[2:]
|
| 126 |
+
if owner in C.WATCHED:
|
| 127 |
+
sites.append((owner, node.lineno, len(args or []),
|
| 128 |
+
[k.arg for k in node.keywords if k.arg]))
|
| 129 |
+
return sites
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def main():
|
| 133 |
+
refresh = "--refresh" in sys.argv
|
| 134 |
+
floor, spec = floor_from_pyproject()
|
| 135 |
+
if floor is None:
|
| 136 |
+
print("pyproject.toml declares no requires-comfyui; nothing to verify")
|
| 137 |
+
return 1
|
| 138 |
+
print("pyproject declares requires-comfyui = %r\n" % spec)
|
| 139 |
+
|
| 140 |
+
sites = call_sites()
|
| 141 |
+
print("%d call site(s) across %d node(s)"
|
| 142 |
+
% (len(sites), len({s[0] for s in sites})))
|
| 143 |
+
|
| 144 |
+
rels = json.loads(fetch(REPO + "/releases?per_page=100", "releases.json",
|
| 145 |
+
refresh=True))
|
| 146 |
+
tags = sorted({r["tag_name"] for r in rels
|
| 147 |
+
if parse_version(r["tag_name"])
|
| 148 |
+
and parse_version(r["tag_name"]) >= floor},
|
| 149 |
+
key=parse_version)
|
| 150 |
+
refs = tags + ["master"]
|
| 151 |
+
print("%d version(s) at or above the floor, plus master\n" % len(tags))
|
| 152 |
+
|
| 153 |
+
for ref in refs:
|
| 154 |
+
sigs, missing = {}, None
|
| 155 |
+
for path in MODULES.values():
|
| 156 |
+
try:
|
| 157 |
+
src = fetch(RAW.format(ref=ref, path=path),
|
| 158 |
+
os.path.join(ref, path.replace("/", "_")),
|
| 159 |
+
refresh=refresh or ref == "master")
|
| 160 |
+
except urllib.error.HTTPError as e:
|
| 161 |
+
missing = "%s (HTTP %d)" % (path, e.code)
|
| 162 |
+
continue
|
| 163 |
+
sigs.update(signatures(src))
|
| 164 |
+
if missing:
|
| 165 |
+
ck("%s: has the modules this pack imports" % ref, False, missing)
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
bad = []
|
| 169 |
+
for owner, line, npos, kws in sites:
|
| 170 |
+
if owner not in sigs:
|
| 171 |
+
bad.append("%s @:%d does not exist here" % (owner, line))
|
| 172 |
+
continue
|
| 173 |
+
names, req = sigs[owner]
|
| 174 |
+
if npos:
|
| 175 |
+
bad.append("%s @:%d passes %d positionally" % (owner, line, npos))
|
| 176 |
+
unknown = [k for k in kws if k not in names]
|
| 177 |
+
if unknown:
|
| 178 |
+
bad.append("%s @:%d passes %s; this version takes %s"
|
| 179 |
+
% (owner, line, unknown, names))
|
| 180 |
+
unfilled = [r for r in req if r not in kws]
|
| 181 |
+
if unfilled:
|
| 182 |
+
bad.append("%s @:%d omits required %s" % (owner, line, unfilled))
|
| 183 |
+
ck("%s: every call binds" % ref, not bad,
|
| 184 |
+
"%d site(s)" % len(sites) if not bad else "")
|
| 185 |
+
for b in bad:
|
| 186 |
+
print(" %s" % b)
|
| 187 |
+
|
| 188 |
+
if FAILS:
|
| 189 |
+
print("\nCORE MATRIX: %d version(s) this pack claims to support and "
|
| 190 |
+
"does not." % len(FAILS))
|
| 191 |
+
return 1
|
| 192 |
+
print("\nCORE MATRIX: every call site binds on every version at or above "
|
| 193 |
+
"the declared floor, and on master.")
|
| 194 |
+
return 0
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
if __name__ == "__main__":
|
| 198 |
+
sys.exit(main())
|