Align Image snaps width and height to a clean multiple - 16, 32, whatever the model wants - because Qwen image models, VAEs and plenty of samplers only behave on cleanly divisible sizes. Each mode implies its own rounding, so there is no separate direction widget: resize rescales to the nearest multiple, crop center-crops down, pad replicate-pads up, and a side smaller than one multiple always grows to exactly one rather than erroring. The new size comes out as INTs for wiring into latents. Image Size reads width, height, longest edge and shortest edge off the wire so resizes and latents key off the actual image instead of retyped numbers that drift. LM Studio Chat posts a prompt - and optionally one image - to a local LM Studio server, or anything else speaking the OpenAI-compatible chat API. The interface is the middle ground the installed ecosystem packs miss on either side: endpoint string, model string where EMPTY means whatever the server has loaded (a dropdown fetched at ComfyUI startup goes stale whenever LM Studio was not running yet), optional system prompt, prompt required unless an image is connected, temperature, max_tokens, seed as the re-roll knob, a timeout, and an image downscale cap. Reasoning-model think blocks land on their own output so the text stays clean for conditioning. Stdlib urllib only - no new dependencies - and the POST runs off the executor thread. Every error names what to fix, with the server's own words when it had any. Verified live against a running LM Studio: a JIT-loaded qwen3.5-9b answered a text round trip in 7.6 s through the real node; the no-model-loaded 400 and a dead port both surfaced their actionable messages. The vision payload shape is unit-tested; the two local VL quants refused images server-side (missing projector / failed load), so the live vision check stays on the deployment checklist. 48 offline tests cover the alignment math, both size nodes, the URL and payload builders, think-splitting, image encoding, and every mocked network error path.
180 lines
7.3 KiB
Python
180 lines
7.3 KiB
Python
#!/usr/bin/env python
|
|
"""Offline sanity checks for the node pack — no ComfyUI required.
|
|
|
|
Run from anywhere: python scripts/validate_nodes.py
|
|
|
|
Checks:
|
|
1. Every .py file in the pack compiles.
|
|
2. Every nodes/node_*.py keeps the registry contract from
|
|
scripts/registry_contract.py: both mappings assigned exactly once, at
|
|
module level, to a non-empty dictionary literal with string-literal
|
|
keys, never mentioned again afterwards, and carrying the same keys as
|
|
each other. Registry scanners parse the source rather than importing
|
|
it, so anything they cannot read statically makes the node invisible.
|
|
3. NODE_MODULES in __init__.py is parsed (not grepped) and matched
|
|
against the files on disk both ways - a listed module with no file and
|
|
a node file nobody imports are both errors - and no mapping key is
|
|
claimed by two modules.
|
|
5. No file outside nodes/ declares mapping keys. Registry scanners read
|
|
the whole checkout, so a fixture or sample that names AUSBOSS_NODES_*
|
|
in a mapping literal is advertised as an installable node.
|
|
4. Permanent public node IDs are still registered - measured against the
|
|
mapping keys parsed in check 2, not against the text of the file - and
|
|
each public transform node declares exactly IMAGE then MASK outputs.
|
|
|
|
Exit code 0 = all good, 1 = problems printed below.
|
|
"""
|
|
|
|
import ast
|
|
import pathlib
|
|
import py_compile
|
|
import sys
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
from registry_contract import (
|
|
class_mapping_keys,
|
|
duplicate_key_problems,
|
|
mapping_problems,
|
|
module_list,
|
|
)
|
|
|
|
errors = []
|
|
warnings = []
|
|
|
|
PUBLIC_NODE_IDS = {
|
|
"AUSBOSS_NODES_AlignImage",
|
|
"AUSBOSS_NODES_ColorMatch",
|
|
"AUSBOSS_NODES_Compare",
|
|
"AUSBOSS_NODES_CropForInpaint",
|
|
"AUSBOSS_NODES_DropShadow",
|
|
"AUSBOSS_NODES_FrameChooser",
|
|
"AUSBOSS_NODES_FrameInterpolate",
|
|
"AUSBOSS_NODES_ImageCropRotatePad",
|
|
"AUSBOSS_NODES_ImageSize",
|
|
"AUSBOSS_NODES_LaMaInpaint",
|
|
"AUSBOSS_NODES_LmStudioChat",
|
|
"AUSBOSS_NODES_LoadVideo",
|
|
"AUSBOSS_NODES_LoraLoader",
|
|
"AUSBOSS_NODES_PadImage",
|
|
"AUSBOSS_NODES_RefineMask",
|
|
"AUSBOSS_NODES_SaveVideo",
|
|
"AUSBOSS_NODES_SelectFrame",
|
|
"AUSBOSS_NODES_SelectFrameRange",
|
|
"AUSBOSS_NODES_VideoBundle",
|
|
"AUSBOSS_NODES_VideoBundleEdit",
|
|
"AUSBOSS_NODES_StitchInpaint",
|
|
"AUSBOSS_NODES_VideoCropRotatePad",
|
|
"AUSBOSS_NODES_VideoUnbundle",
|
|
}
|
|
|
|
# --- 1. everything compiles --------------------------------------------------
|
|
for path in sorted(ROOT.rglob("*.py")):
|
|
if "__pycache__" in path.parts:
|
|
continue
|
|
try:
|
|
py_compile.compile(str(path), doraise=True)
|
|
except py_compile.PyCompileError as exc:
|
|
errors.append(f"syntax error: {path.relative_to(ROOT)}\n {exc.msg}")
|
|
|
|
# --- 2. the registry contract each node module must keep --------------------
|
|
node_files = sorted((ROOT / "nodes").glob("node_*.py"))
|
|
keys_by_module = {}
|
|
for path in node_files:
|
|
source = path.read_text(encoding="utf-8")
|
|
errors.extend(mapping_problems(source, path.name))
|
|
keys_by_module[path.name] = class_mapping_keys(source)
|
|
|
|
# Two modules claiming one key is a silent drop: the last import wins.
|
|
errors.extend(duplicate_key_problems(keys_by_module))
|
|
|
|
# --- 3. NODE_MODULES list matches the files on disk --------------------------
|
|
# Parsed out of the list literal, not matched anywhere in the text: a name
|
|
# left behind in a comment used to satisfy this, and because an unlisted
|
|
# module was only a warning, forgetting to register a node exited 0 with
|
|
# nothing printed at all.
|
|
init_text = (ROOT / "__init__.py").read_text(encoding="utf-8")
|
|
listed = module_list(init_text)
|
|
on_disk = {path.stem for path in node_files}
|
|
|
|
if listed is None:
|
|
errors.append(
|
|
"__init__.py: NODE_MODULES must be a module-level list literal of "
|
|
"module names; this checker reads it without importing the package"
|
|
)
|
|
else:
|
|
for missing in sorted(listed - on_disk):
|
|
errors.append(f"__init__.py lists {missing} but nodes/{missing}.py does not exist")
|
|
for orphan in sorted(on_disk - listed):
|
|
# Not a warning: an unlisted module is never imported, so the node
|
|
# simply does not exist in ComfyUI.
|
|
errors.append(
|
|
f"nodes/{orphan}.py is not listed in NODE_MODULES, so it is never imported"
|
|
)
|
|
|
|
# --- 4. permanent transform contracts ---------------------------------------
|
|
# The keys parsed out of NODE_CLASS_MAPPINGS in check 2, not every
|
|
# AUSBOSS_NODES_* string in the file: a docstring, a comment or a search
|
|
# alias mentioning an id is not a registration, and matching those would let
|
|
# a node lose its mapping entry while this check kept passing.
|
|
mapping_keys = set().union(*keys_by_module.values()) if keys_by_module else set()
|
|
for path in node_files:
|
|
text = path.read_text(encoding="utf-8")
|
|
if path.stem in {"node_image_crop_rotate_pad", "node_video_crop_rotate_pad"}:
|
|
tree = ast.parse(text)
|
|
return_types = None
|
|
return_names = None
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Assign):
|
|
continue
|
|
for target in node.targets:
|
|
if isinstance(target, ast.Name) and target.id in {"RETURN_TYPES", "RETURN_NAMES"}:
|
|
try:
|
|
value = ast.literal_eval(node.value)
|
|
except Exception:
|
|
value = None
|
|
if target.id == "RETURN_TYPES":
|
|
return_types = value
|
|
else:
|
|
return_names = value
|
|
if return_types != ("IMAGE", "MASK"):
|
|
errors.append(f"{path.name}: RETURN_TYPES must be exactly ('IMAGE', 'MASK')")
|
|
if return_names != ("image", "mask"):
|
|
errors.append(f"{path.name}: RETURN_NAMES must be exactly ('image', 'mask')")
|
|
|
|
for missing_id in sorted(PUBLIC_NODE_IDS - mapping_keys):
|
|
errors.append(f"missing permanent mapping key: {missing_id}")
|
|
|
|
# --- 5. nothing outside nodes/ may declare mapping keys ----------------------
|
|
# A scanner reads the whole checkout, not just the modules __init__ imports,
|
|
# so any .py anywhere that assigns NODE_CLASS_MAPPINGS advertises those keys
|
|
# as installable nodes. Test fixtures did exactly that: they published ids no
|
|
# import ever registers, and Manager offered the pack for workflows using
|
|
# them. Fixtures live as .py.txt for this reason - real source, not a module.
|
|
for path in sorted(ROOT.rglob("*.py")):
|
|
parts = path.relative_to(ROOT).parts
|
|
# Scanners skip dot-directories and caches; so does this, or local
|
|
# scratch like .claude/worktrees would answer for the shipped tree.
|
|
if any(part.startswith(".") or part == "__pycache__" for part in parts):
|
|
continue
|
|
if path.parent == ROOT / "nodes":
|
|
continue
|
|
stray = class_mapping_keys(path.read_text(encoding="utf-8"))
|
|
for key in sorted(stray):
|
|
errors.append(
|
|
f"{path.relative_to(ROOT)}: declares mapping key {key} outside "
|
|
"nodes/, where registry scanners will advertise it as a node"
|
|
)
|
|
|
|
# --- report ------------------------------------------------------------------
|
|
for warning in warnings:
|
|
print(f"WARN {warning}")
|
|
for error in errors:
|
|
print(f"ERROR {error}")
|
|
|
|
if errors:
|
|
print(f"\n{len(errors)} problem(s) found.")
|
|
sys.exit(1)
|
|
|
|
print(f"OK - {len(node_files)} node file(s) validated.")
|