From ac9cc5859252a3d4e9c40e65cb7e21b60ae9a3f3 Mon Sep 17 00:00:00 2001 From: ausboss <102309304+ausboss@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:43:01 -0500 Subject: [PATCH] Report each failed input check once, and let ComfyUI check the rest A VALIDATE_INPUTS that takes **kwargs makes ComfyUI skip its own range and list checks for every input, and a failed check comes back once per input: Image Crop + Rotate + Pad without a picture showed 20 copies of "Select or upload a source file first". Each validator now names only what it reads. - Image Crop + Rotate + Pad, Load Video and LaMa Inpaint read one input and report one error, on it. Video Crop + Rotate + Pad (Frame and Clip) read the three that choose the source. LoRA Loader reads the stack and on_missing; Save Image reads its two names and output_dir. - Save Video has no validator any more: ComfyUI's own check holds a typed fps to 0.01-240. - Load Video and the Clip node check the trim window, force_rate and Fixed frames as they run, where a wired bound has its value. They are async, so ComfyUI starts them ahead of other nodes. - The sources still skip ComfyUI's list check on purpose, so uploads, subfolders, MaskEditor saves and a stale video choice in local path mode keep working. The path checks behind them are unchanged. - crop_aspect_ratio accepts any whole-number W:H, as the transform always has, so a workflow made with someone else's presets still runs; anything else is now refused before the run. tests/test_validate_inputs.py pins what each validator names (runs in CI) and, with ComfyUI, drives ComfyUI's own validation. --- .github/workflows/validate_action.yml | 1 + docs/adding_a_node.md | 1 + nodes/_transform_inputs.py | 17 +- nodes/_video_load_helpers.py | 2 +- nodes/node_image_crop_rotate_pad.py | 7 +- nodes/node_lama_inpaint.py | 5 +- nodes/node_load_video.py | 16 +- nodes/node_lora_loader.py | 7 +- nodes/node_save_image.py | 10 +- nodes/node_save_video.py | 10 +- nodes/node_video_crop_rotate_pad.py | 7 +- nodes/node_video_crop_rotate_pad_clip.py | 44 ++-- tests/test_validate_inputs.py | 277 +++++++++++++++++++++++ tests/test_video_clip_transform.py | 33 +-- tests/test_video_load_helpers.py | 30 +-- 15 files changed, 376 insertions(+), 91 deletions(-) create mode 100644 tests/test_validate_inputs.py diff --git a/.github/workflows/validate_action.yml b/.github/workflows/validate_action.yml index 98877b4..8213962 100644 --- a/.github/workflows/validate_action.yml +++ b/.github/workflows/validate_action.yml @@ -41,6 +41,7 @@ jobs: tests/test_resolution.py tests/test_status_helpers.py tests/test_utility_nodes.py + tests/test_validate_inputs.py tests/test_workflow_contract.py # `node --check file.js` passes a broken file that uses import or # export when no package.json marks it as a module, so each file goes diff --git a/docs/adding_a_node.md b/docs/adding_a_node.md index 264e664..1ec05eb 100644 --- a/docs/adding_a_node.md +++ b/docs/adding_a_node.md @@ -26,6 +26,7 @@ Every visible field and output must earn its place. Published IDs, input order, 6. Chain lifecycle hooks through `chainCallback`; never replace core or third-party prototypes directly. 7. Keep normal workflow and API execution independent from the custom frontend. 8. Ship user-facing toggles as ComfyUI settings under the `AusBoss.*` namespace. Node coloring is automatic for `AUSBOSS_NODES_*` classes via `js/appearance/` — no per-node work. +9. A `VALIDATE_INPUTS` names only the inputs it reads, never `**kwargs`. ComfyUI skips its own range and list checks for every input a validator names (naming `input_types` also switches off its link checks), and repeats a failed check once per named input. Checks that need several values can run in the node itself. `tests/test_validate_inputs.py` pins what each validator names. ## Document and prove diff --git a/nodes/_transform_inputs.py b/nodes/_transform_inputs.py index 94075d0..6e1cba3 100644 --- a/nodes/_transform_inputs.py +++ b/nodes/_transform_inputs.py @@ -71,13 +71,28 @@ def load_custom_aspect_ratios(path: Path | None = None) -> list[str]: return valid +class AspectRatioChoices(list): + """The crop_aspect_ratio choices: built-in ratios plus this machine's presets. + + ComfyUI checks a list input with ``in`` before a run. A workflow made on + a machine with other presets can carry a ratio missing here, and the + transform draws any whole-number W:H, so any such ratio counts as a + member. Everything else is still refused before the run. + """ + + def __contains__(self, value) -> bool: + if list.__contains__(self, value): + return True + return isinstance(value, str) and _RATIO_PATTERN.match(value.strip()) is not None + + def aspect_ratio_options(path: Path | None = None) -> list[str]: """Built-in ratios extended with any user presets, order preserved.""" options = list(ASPECT_RATIOS) for entry in load_custom_aspect_ratios(path): if entry not in options: options.append(entry) - return options + return AspectRatioChoices(options) def transform_inputs(*, feather: int = 24, fill_color: str = "#808080") -> dict[str, tuple]: diff --git a/nodes/_video_load_helpers.py b/nodes/_video_load_helpers.py index f5349ab..f3d37e0 100644 --- a/nodes/_video_load_helpers.py +++ b/nodes/_video_load_helpers.py @@ -325,7 +325,7 @@ def core_trim_args(start_seconds: float, end_seconds: float) -> tuple[float, flo Core's VIDEO trim treats duration 0 as "until the end", which matches end_seconds 0. Degenerate windows (end at or before start) also collapse - to 0 here, but VALIDATE_INPUTS rejects those graphs before execution. + to 0 here, but the decode's trim_window has already refused them. """ start = max(0.0, float(start_seconds)) end = float(end_seconds) diff --git a/nodes/node_image_crop_rotate_pad.py b/nodes/node_image_crop_rotate_pad.py index d24236e..c2bc06b 100644 --- a/nodes/node_image_crop_rotate_pad.py +++ b/nodes/node_image_crop_rotate_pad.py @@ -113,7 +113,12 @@ class AusBossImageCropRotatePad: ) @classmethod - def VALIDATE_INPUTS(cls, image, **_values): + def VALIDATE_INPUTS(cls, image): + # ComfyUI skips its own range and list checks for every input named + # here and files a failure once per named input, so only the source + # is named. Its list check would refuse uploads it has not listed yet + # and MaskEditor saves ("... [input]"); resolve_input_path takes any + # file inside the input folder instead, and nothing outside it. try: resolve_input_path(image) except Exception as exc: diff --git a/nodes/node_lama_inpaint.py b/nodes/node_lama_inpaint.py index 43c1d6c..4f777d2 100644 --- a/nodes/node_lama_inpaint.py +++ b/nodes/node_lama_inpaint.py @@ -81,7 +81,10 @@ class AusBossLaMaInpaint: return preview_payload(inpainted, self._prefix, "LaMa Inpaint", (inpainted,)) @classmethod - def VALIDATE_INPUTS(cls, model, **_values): + def VALIDATE_INPUTS(cls, model): + # Only the model is named: a failure is filed once per named input, + # and ComfyUI keeps checking the rest. The model skips its list check + # so a file dropped in after the list was read still resolves. from ._lama_helpers import resolve_lama_model try: diff --git a/nodes/node_load_video.py b/nodes/node_load_video.py index a6a7f08..36aa891 100644 --- a/nodes/node_load_video.py +++ b/nodes/node_load_video.py @@ -223,18 +223,18 @@ class AusBossLoadVideo: return f"{video}:{stat.st_mtime_ns}:{stat.st_size}" @classmethod - def VALIDATE_INPUTS(cls, video, start_seconds, end_seconds, **_values): + def VALIDATE_INPUTS(cls, video): + # ComfyUI files a failed check once per input named here and skips + # its own range and list checks for those inputs, so only the source + # is named. video skips the list check on purpose: resolve_input_path + # takes any file inside the input folder and nothing outside it. The + # trim window is checked when the loader runs (trim_window), where + # wired bounds are known and single_frame applies; ComfyUI starts + # async nodes such as this one ahead of the others. try: resolve_input_path(video) except Exception as exc: return f"Load Video: {exc}" - # A single-frame load ignores end_seconds, so a stale trim window left - # over from trim mode must not block the graph. A wired bound reaches - # validation as None (its value only exists at execution), so the - # window cannot be checked yet; the loader checks it again when it runs. - if not _values.get("single_frame") and None not in (start_seconds, end_seconds): - if float(end_seconds) > 0.0 and float(start_seconds) >= float(end_seconds): - return "Load Video: start_seconds must be smaller than end_seconds." return True diff --git a/nodes/node_lora_loader.py b/nodes/node_lora_loader.py index fe33d4c..c60746b 100644 --- a/nodes/node_lora_loader.py +++ b/nodes/node_lora_loader.py @@ -107,12 +107,15 @@ class AusBossLoraLoader: return model, clip, collect_trigger_words(worded, str(trigger_separator)) @classmethod - def VALIDATE_INPUTS(cls, loras, on_missing="error", **_values): + def VALIDATE_INPUTS(cls, loras, on_missing="error"): # Structural validation always. A row whose FILE is gone blocks the # queue unless the node was switched to 'skip': apply_lora_stack # resolves moved files by basename either way, so only a genuinely # missing file gets this far. Old graphs saved without the input get - # the default, which is strict. + # the default, which is strict. Only the two inputs read here are + # named: ComfyUI keeps checking the rest, and files a failure once + # per named input. The LoRA names live inside the loras text, so no + # list check ever applied to them. try: rows = parse_lora_stack(loras) except ValueError as exc: diff --git a/nodes/node_save_image.py b/nodes/node_save_image.py index 021f51e..cc75118 100644 --- a/nodes/node_save_image.py +++ b/nodes/node_save_image.py @@ -394,17 +394,17 @@ class AusBossSaveImage: return float("nan") @classmethod - def VALIDATE_INPUTS(cls, exact_name="", filename_prefix="", **_values): - # Surface a bad name before the run instead of mid-save. + def VALIDATE_INPUTS(cls, exact_name="", filename_prefix="", output_dir=""): + # Surface a bad name before the run instead of mid-save. Only the + # three inputs read here are named, so ComfyUI still checks the + # others (format, on_existing, ...) itself. for label, value in (("exact_name", exact_name), ("filename_prefix", filename_prefix)): if isinstance(value, str) and value.strip(): try: sanitize_exact_name(value) except ValueError as exc: return str(exc).replace("exact_name", label) - # **_values carries every input, so core skips its own checks here: - # a folder outside the output folder is refused before the run. - output_dir = _values.get("output_dir") + # A folder outside the output folder is refused before the run. if isinstance(output_dir, str) and output_dir.strip() and folder_paths is not None: try: resolve_output_root(output_dir, folder_paths.get_output_directory()) diff --git a/nodes/node_save_video.py b/nodes/node_save_video.py index aa2e501..98b5305 100644 --- a/nodes/node_save_video.py +++ b/nodes/node_save_video.py @@ -261,13 +261,9 @@ class AusBossSaveVideo: "result": (str(output_path),), } - @classmethod - def VALIDATE_INPUTS(cls, fps=None, **_values): - # fps arrives as None when wired from another node; the encoder - # re-checks the resolved value at execution time. - if fps is not None and float(fps) <= 0: - return "Save Video: fps must be greater than zero." - return True + # No VALIDATE_INPUTS: ComfyUI's own check holds a typed fps to the + # widget's 0.01-240 and every other value to its range or list. A wired + # fps is only known at execution, where the encoder checks it again. NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_SaveVideo": AusBossSaveVideo} diff --git a/nodes/node_video_crop_rotate_pad.py b/nodes/node_video_crop_rotate_pad.py index 1b5e424..59f9811 100644 --- a/nodes/node_video_crop_rotate_pad.py +++ b/nodes/node_video_crop_rotate_pad.py @@ -123,7 +123,12 @@ class AusBossVideoCropRotatePad: ) @classmethod - def VALIDATE_INPUTS(cls, video, source_mode, local_path, **_values): + def VALIDATE_INPUTS(cls, video, source_mode, local_path): + # Only the source is read here, so ComfyUI still checks every other + # value's range and list. video and source_mode skip its list check: + # in local path mode the video choice is unused and may be stale. + # resolve_video_path reads only the input folder, or a local path + # inside ComfyUI's input, output or temp folder. try: resolve_video_path(source_mode, video, local_path) except Exception as exc: diff --git a/nodes/node_video_crop_rotate_pad_clip.py b/nodes/node_video_crop_rotate_pad_clip.py index 0a9cbdd..5fa7a25 100644 --- a/nodes/node_video_crop_rotate_pad_clip.py +++ b/nodes/node_video_crop_rotate_pad_clip.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import math from ._inpaint_crop_helpers import build_transform_stitcher as clip_stitcher from ._media_helpers import list_input_videos, register_video_routes, resolve_video_path, video_metadata @@ -344,39 +343,22 @@ class AusBossVideoCropRotatePadClip: ) @classmethod - def VALIDATE_INPUTS( - cls, video, source_mode, local_path, start_seconds, end_seconds, - start_frame=None, end_frame=None, force_rate=0.0, fixed_frames=0, every_nth=1, input_types=None, **_values, - ): + def VALIDATE_INPUTS(cls, video, source_mode, local_path): + # Only the source is read here. ComfyUI files a failed check once per + # input named here and skips its own range and list checks for them; + # naming input_types, as this signature once did, also switched off + # its link-type checks for the whole node. video and source_mode + # skip the list check on purpose: in local path mode the + # video choice is unused and may be stale. resolve_video_path reads + # only the input folder, or a local path inside ComfyUI's input, + # output or temp folder. The trim window, force_rate and Fixed frames + # are checked when the node runs (clip_load_window, fixed_clip_window, + # decode_video_range), where wired values are known; ComfyUI starts + # async nodes such as this one ahead of the others. try: - path = resolve_video_path(source_mode, video, local_path) - if force_rate is not None and ( - not math.isfinite(float(force_rate)) or not 0 <= float(force_rate) <= 1000 - ): - return "Video Crop + Rotate + Pad: force_rate must be between 0 and 1000 fps." - # Linked values are evaluated at execution; stale widget values must - # not reject a valid override before those values are available. - schema = cls.INPUT_TYPES() - definitions = schema["required"] | schema["optional"] - for name, actual in (input_types or {}).items(): - expected = definitions.get(name, (None,))[0] - received = {part.strip() for part in str(actual).split(",")} - if isinstance(expected, str) and "*" not in received and not received.intersection(expected.split(",")): - return f"Video Crop + Rotate + Pad: incompatible input type for {name}." - if any(name in (input_types or {}) for name in ("start_frame", "end_frame", "start_seconds", "end_seconds", "fixed_frames", "force_rate", "every_nth")): - return True - start_seconds, end_seconds, _ = clip_load_window( - path, start_seconds, end_seconds, start_frame=start_frame, end_frame=end_frame - ) - if not 0 <= int(fixed_frames or 0) <= 100000: - return "Fixed frames must be between 0 and 100000." - if fixed_frames: - fixed_clip_window(video_metadata(path), start_seconds, fixed_frames, force_rate, every_nth) - return True + resolve_video_path(source_mode, video, local_path) except Exception as exc: return f"Video Crop + Rotate + Pad: {exc}" - if float(end_seconds) > 0.0 and float(start_seconds) >= float(end_seconds): - return "Video Crop + Rotate + Pad: start_seconds must be smaller than end_seconds." return True @classmethod diff --git a/tests/test_validate_inputs.py b/tests/test_validate_inputs.py new file mode 100644 index 0000000..f413051 --- /dev/null +++ b/tests/test_validate_inputs.py @@ -0,0 +1,277 @@ +"""Each node's VALIDATE_INPUTS names only the inputs it reads. + +ComfyUI treats the inputs a VALIDATE_INPUTS signature names specially: it +skips its own range and list checks for them, and when the check fails it +files the message once per named input (naming input_types also switches +off its link-type checks for the whole node). A signature that takes +**kwargs names every input, so one missing picture came back as twenty +identical errors and nothing else on the node was checked at all. + +The signature rules are read from the source and run anywhere (CI included). +The rest drive ComfyUI's own validation and need ComfyUI's Python: + + AUSBOSS_COMFY_ROOT= python tests/test_validate_inputs.py +""" + +from __future__ import annotations + +from pathlib import Path +import ast +import asyncio +import inspect +import json +import sys +import tempfile +import unittest +from unittest.mock import patch + +from test_node_api import COMFY_ROOT, load_pack + +ROOT = Path(__file__).resolve().parent.parent +VALIDATORS = ("VALIDATE_INPUTS", "validate_inputs") + +# What each validator reads. Naming an input switches ComfyUI's own checks +# off for it and repeats a failure on it, so every name here is on purpose: +# a source that must take files ComfyUI has not listed (uploads, subfolders, +# MaskEditor saves), a LoRA stack in JSON, or a value the check needs. +NAMED = { + "AusBossImageCropRotatePad": ["image"], + "AusBossLaMaInpaint": ["model"], + "AusBossLoadImagePad": ["image", "source_image"], + "AusBossLoadVideo": ["video"], + "AusBossLoraLoader": ["loras", "on_missing"], + "AusBossSaveImage": ["exact_name", "filename_prefix", "output_dir"], + "AusBossStitchInpaint": ["seam"], + "AusBossVideoCropRotatePad": ["video", "source_mode", "local_path"], + "AusBossVideoCropRotatePadClip": ["video", "source_mode", "local_path"], +} + + +def validators_in(source: str, filename: str) -> dict[str, ast.FunctionDef]: + """Class name -> its validator function, for one module.""" + found = {} + for node in ast.walk(ast.parse(source, filename)): + if isinstance(node, ast.ClassDef): + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name in VALIDATORS: + found[node.name] = item + return found + + +def pack_validators() -> dict[str, tuple[str, ast.FunctionDef]]: + found = {} + for path in sorted((ROOT / "nodes").glob("*.py")): + for name, function in validators_in(path.read_text(encoding="utf-8"), path.name).items(): + found[name] = (path.name, function) + return found + + +class SignatureRuleTests(unittest.TestCase): + def test_no_validator_takes_every_input(self): + problems = [ + f"{filename}:{function.lineno} {name}.{function.name}(**{function.args.kwarg.arg})" + for name, (filename, function) in pack_validators().items() + if function.args.kwarg is not None + ] + self.assertEqual( + problems, [], + "**kwargs switches off ComfyUI's range and list checks for every input and " + "repeats one failure per input; name only what the check reads.", + ) + + def test_validators_name_what_they_read(self): + named = { + name: [arg.arg for arg in function.args.args[1:] + function.args.kwonlyargs] + for name, (_filename, function) in pack_validators().items() + } + self.assertEqual(named, NAMED) + + def test_the_rule_sees_kwargs(self): + source = "class N:\n @classmethod\n def VALIDATE_INPUTS(cls, image, **values):\n return True\n" + function = validators_in(source, "n.py")["N"] + self.assertEqual(function.args.kwarg.arg, "values") + + +@unittest.skipUnless(COMFY_ROOT, "set AUSBOSS_COMFY_ROOT to a ComfyUI checkout") +class ComfyValidationTests(unittest.TestCase): + """What ComfyUI itself reports for a prompt, in-process.""" + + @classmethod + def setUpClass(cls): + cls.pack = load_pack() + import execution + import folder_paths + import nodes + + nodes.NODE_CLASS_MAPPINGS.update(cls.pack.NODE_CLASS_MAPPINGS) + cls.execution = execution + cls.tmp = tempfile.TemporaryDirectory() + root = Path(cls.tmp.name) + cls.input_dir = root / "input" + cls.output_dir = root / "output" + for folder in (cls.input_dir / "sub", cls.input_dir / "clipspace", cls.output_dir): + folder.mkdir(parents=True, exist_ok=True) + cls.saved_dirs = (folder_paths.get_input_directory(), folder_paths.get_output_directory()) + folder_paths.set_input_directory(str(cls.input_dir)) + folder_paths.set_output_directory(str(cls.output_dir)) + cls.folder_paths = folder_paths + # Validation only asks whether a source exists, so empty files do. + for name in ("pic.png", "sub/pic.png", "clipspace/painted.png", "clip.mp4"): + (cls.input_dir / name).write_bytes(b"") + + @classmethod + def tearDownClass(cls): + cls.folder_paths.set_input_directory(cls.saved_dirs[0]) + cls.folder_paths.set_output_directory(cls.saved_dirs[1]) + cls.tmp.cleanup() + + def node(self, key, **values): + """A prompt entry the way the frontend sends one: every widget filled.""" + inputs = {} + definition = self.pack.NODE_CLASS_MAPPINGS[key].INPUT_TYPES() + for group in ("required", "optional"): + for name, entry in definition.get(group, {}).items(): + kind, options = entry[0], (entry[1] if len(entry) > 1 else {}) + if options.get("forceInput"): + continue + if "default" in options: + inputs[name] = options["default"] + elif isinstance(kind, list) and kind: + inputs[name] = kind[0] + elif kind == "STRING": + inputs[name] = "" + return {"class_type": key, "inputs": {**inputs, **values}} + + def errors(self, prompt, node_id): + validate = self.execution.validate_inputs + args = (prompt, node_id, {}) + if "prompt_id" in inspect.signature(validate).parameters: + args = ("test",) + args + result = validate(*args) + if inspect.isawaitable(result): + result = asyncio.run(result) + return [(error["type"], error["details"], error["extra_info"].get("input_name")) for error in result[1]] + + def image(self): + return {"class_type": "EmptyImage", "inputs": {"width": 64, "height": 64, "batch_size": 1, "color": 0}} + + # One error, on the right input -------------------------------------- + + def test_a_missing_picture_is_one_error_on_the_picture(self): + for key, label in ( + ("AUSBOSS_NODES_ImageCropRotatePad", "Image Crop + Rotate + Pad"), + ("AUSBOSS_NODES_LoadImagePad", "Load Image + Pad"), + ): + for name, reason in (("", "Select or upload a source file first."), ("gone.png", "no longer exists")): + with self.subTest(node=key, image=name): + errors = self.errors({"1": self.node(key, image=name)}, "1") + self.assertEqual(len(errors), 1, errors) + kind, details, input_name = errors[0] + self.assertEqual((kind, input_name), ("custom_validation_failed", "image")) + self.assertTrue(details.startswith(f"image - {label}: "), details) + self.assertIn(reason, details) + + def test_a_missing_video_is_one_error_on_the_video(self): + errors = self.errors({"1": self.node("AUSBOSS_NODES_LoadVideo", video="gone.mp4")}, "1") + self.assertEqual([(kind, name) for kind, _details, name in errors], [("custom_validation_failed", "video")]) + + def test_a_missing_lama_model_is_one_error_on_the_model(self): + prompt = { + "1": self.image(), + "2": {"class_type": "LoadImage", "inputs": {"image": "pic.png"}}, + "3": self.node("AUSBOSS_NODES_LaMaInpaint", image=["1", 0], mask=["2", 1], model="gone.pt"), + } + errors = self.errors(prompt, "3") + self.assertEqual([(kind, name) for kind, _details, name in errors], [("custom_validation_failed", "model")]) + + def test_checks_that_read_several_inputs_repeat_once_per_input_they_read(self): + # ComfyUI's rule, not ours: the pack's frontend folds these into one. + cases = ( + ("AUSBOSS_NODES_VideoCropRotatePad", {"video": "gone.mp4"}, ["video", "source_mode", "local_path"]), + ("AUSBOSS_NODES_VideoCropRotatePadClip", {"video": "gone.mp4"}, ["video", "source_mode", "local_path"]), + ) + for key, values, names in cases: + with self.subTest(node=key): + errors = self.errors({"1": self.node(key, **values)}, "1") + self.assertEqual(sorted(name for _kind, _details, name in errors), sorted(names)) + self.assertEqual(len({details.split(" - ", 1)[1] for _kind, details, _name in errors}), 1) + + # ComfyUI's own checks are back ------------------------------------------ + + def test_ranges_and_lists_are_checked_again(self): + cases = ( + ({"1": self.node("AUSBOSS_NODES_ImageCropRotatePad", image="pic.png", crop_x=-5)}, "1", + ("value_smaller_than_min", "crop_x")), + ({"1": self.node("AUSBOSS_NODES_ImageCropRotatePad", image="pic.png", resize_method="sharpest")}, "1", + ("value_not_in_list", "resize_method")), + ({"1": self.node("AUSBOSS_NODES_LoadVideo", video="clip.mp4", custom_width=-2)}, "1", + ("value_smaller_than_min", "custom_width")), + ({"1": self.node("AUSBOSS_NODES_LoadVideo", video="clip.mp4"), + "2": self.node("AUSBOSS_NODES_SaveVideo", frames=["1", 0], fps=0)}, "2", + ("value_smaller_than_min", "fps")), + ({"1": self.image(), "2": self.node("AUSBOSS_NODES_SaveImage", images=["1", 0], format="webp")}, "2", + ("value_not_in_list", "format")), + ({"1": self.node("AUSBOSS_NODES_VideoCropRotatePad", video="clip.mp4", seek_mode="halfway")}, "1", + ("value_not_in_list", "seek_mode")), + ) + for prompt, node_id, (kind, input_name) in cases: + with self.subTest(expected=(kind, input_name)): + self.assertEqual([(k, n) for k, _d, n in self.errors(prompt, node_id)], [(kind, input_name)]) + + def test_a_wrong_link_type_on_the_clip_node_is_caught(self): + prompt = { + "1": self.node("AUSBOSS_NODES_Text", text="12"), + "2": self.node("AUSBOSS_NODES_VideoCropRotatePadClip", video="clip.mp4", start_frame=["1", 0]), + } + self.assertEqual([(k, n) for k, _d, n in self.errors(prompt, "2")], [("return_type_mismatch", "start_frame")]) + + # ... without refusing what the nodes deliberately accept ---------------- + + def test_sources_comfy_has_not_listed_still_pass(self): + for name in ("pic.png", "sub/pic.png", "clipspace/painted.png [input]"): + with self.subTest(image=name): + self.assertEqual(self.errors({"1": self.node("AUSBOSS_NODES_ImageCropRotatePad", image=name)}, "1"), []) + + def test_a_stale_video_choice_does_not_block_local_path_mode(self): + output_clip = self.output_dir / "render.mp4" + output_clip.write_bytes(b"") + for key in ("AUSBOSS_NODES_VideoCropRotatePad", "AUSBOSS_NODES_VideoCropRotatePadClip"): + with self.subTest(node=key): + prompt = {"1": self.node(key, video="someone-elses.mp4", source_mode="local path", local_path=str(output_clip))} + self.assertEqual(self.errors(prompt, "1"), []) + + def test_a_crop_ratio_from_someone_elses_presets_still_runs(self): + for ratio in ("5:4", "4:5", "2:1"): + with self.subTest(ratio=ratio): + prompt = {"1": self.node("AUSBOSS_NODES_ImageCropRotatePad", image="pic.png", crop_aspect_ratio=ratio)} + self.assertEqual(self.errors(prompt, "1"), []) + prompt = {"1": self.node("AUSBOSS_NODES_ImageCropRotatePad", image="pic.png", crop_aspect_ratio="wide")} + self.assertEqual([(k, n) for k, _d, n in self.errors(prompt, "1")], [("value_not_in_list", "crop_aspect_ratio")]) + + def test_a_wired_trim_bound_waits_for_the_run(self): + prompt = { + "1": self.node("AUSBOSS_NODES_Float", value=0.25), + "2": self.node("AUSBOSS_NODES_LoadVideo", video="clip.mp4", start_seconds=["1", 0], end_seconds=0.75), + } + self.assertEqual(self.errors(prompt, "2"), []) + + def test_a_lora_stack_is_checked_by_the_node_not_by_a_list(self): + loader = self.pack.NODE_CLASS_MAPPINGS["AUSBOSS_NODES_LoraLoader"] + node_lora_loader = sys.modules[loader.__module__] + stack = json.dumps([{"name": "styles/sub/look.safetensors", "strength": 1.0, "enabled": True}]) + prompt = { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x.safetensors"}}, + "2": self.node("AUSBOSS_NODES_LoraLoader", model=["1", 0], loras=stack), + } + with patch.object(node_lora_loader, "missing_lora_rows", return_value=[]): + self.assertEqual(self.errors(prompt, "2"), []) + with patch.object(node_lora_loader, "missing_lora_rows", return_value=[("look.safetensors", "not found")]): + errors = self.errors(prompt, "2") + self.assertEqual(sorted(name for _k, _d, name in errors), ["loras", "on_missing"]) + prompt["2"]["inputs"].pop("on_missing") + with patch.object(node_lora_loader, "missing_lora_rows", return_value=[("look.safetensors", "not found")]): + self.assertEqual([name for _k, _d, name in self.errors(prompt, "2")], ["loras"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_video_clip_transform.py b/tests/test_video_clip_transform.py index 062f213..23ba280 100644 --- a/tests/test_video_clip_transform.py +++ b/tests/test_video_clip_transform.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import contextlib +import inspect import io import sys import tempfile @@ -122,11 +123,18 @@ class VideoClipNodeTests(unittest.TestCase): self.assertAlmostEqual(out[7], 0.75) self.assertAlmostEqual(out[2]["waveform"].shape[-1] / out[2]["sample_rate"], 0.75, delta=0.01) - def test_linked_bounds_do_not_validate_stale_widget_window(self): - self.assertIs(AusBossVideoCropRotatePadClip.VALIDATE_INPUTS( - "", "local path", str(self.video), 9, 2, force_rate=None, input_types={"start_frame": "INT", "force_rate": "INT"}), True) - self.assertIsNot(AusBossVideoCropRotatePadClip.VALIDATE_INPUTS( - "", "local path", str(self.video), 0, 0, input_types={"start_frame": "STRING"}), True) + def test_validation_reads_only_the_source(self): + # ComfyUI files a failed check once per input VALIDATE_INPUTS names + # and skips its own range, list and link checks for them, so only the + # source is named. A stale widget window beside a linked bound can + # no longer block the run; the window is checked when the node runs. + validate = AusBossVideoCropRotatePadClip.VALIDATE_INPUTS + self.assertEqual(list(inspect.signature(validate).parameters), ["video", "source_mode", "local_path"]) + self.assertIs(validate("", "local path", str(self.video)), True) + self.assertEqual( + validate("", "local path", ""), + "Video Crop + Rotate + Pad: Local path mode requires a video path.", + ) def test_decode_errors_name_this_node_and_only_its_inputs(self): # The shared decode used to report "Load Video" and advise @@ -175,10 +183,8 @@ class VideoClipNodeTests(unittest.TestCase): self.run_node(fixed_frames=FRAMES + 1) free = self.run_node(start_seconds=0.5, end_seconds=1, fixed_frames=0) self.assertEqual(free[3], 6) - self.assertIs(AusBossVideoCropRotatePadClip.VALIDATE_INPUTS( - "", "local path", str(self.video), 9, 2, fixed_frames=12), True) - self.assertIn("source is only", AusBossVideoCropRotatePadClip.VALIDATE_INPUTS( - "", "local path", str(self.video), 0, 0, fixed_frames=FRAMES + 1)) + # A backwards free window is ignored once Fixed frames places its own. + self.assertEqual(self.run_node(start_seconds=9, end_seconds=2, fixed_frames=12)[3], 12) def test_padding_applies_to_every_frame_and_lands_in_the_mask(self): frames, mask, audio, count, fps, width, height, duration = self.run_node(pad_left=16, feather=0)[:8] @@ -215,12 +221,9 @@ class VideoClipNodeTests(unittest.TestCase): padded = AusBossVideoCropRotatePadClip.IS_CHANGED(**common, **transform_defaults(pad_right=9)) self.assertNotEqual(one, padded) - def test_validate_rejects_a_backwards_window(self): - message = AusBossVideoCropRotatePadClip.VALIDATE_INPUTS( - "", "local path", str(self.video), start_seconds=2.0, end_seconds=1.0 - ) - self.assertIn("start_seconds", str(message)) - self.assertIs(AusBossVideoCropRotatePadClip.VALIDATE_INPUTS("", "local path", str(self.video), 0.0, 0.0), True) + def test_a_backwards_window_stops_the_node_when_it_runs(self): + with self.assertRaisesRegex(ValueError, "needs start_seconds smaller than end_seconds"): + self.run_node(start_seconds=2.0, end_seconds=1.0) def test_pad_fit_preserves_source_pixels_and_mask(self): original = self.run_node(feather=0)[0] diff --git a/tests/test_video_load_helpers.py b/tests/test_video_load_helpers.py index 38e0cec..0098532 100644 --- a/tests/test_video_load_helpers.py +++ b/tests/test_video_load_helpers.py @@ -433,28 +433,22 @@ class LoadVideoNodeTests(unittest.TestCase): self.assertAlmostEqual(fps, FPS, places=3) self.assertAlmostEqual(duration, 1 / FPS, delta=0.02) - def test_validation_ignores_the_trim_window_in_single_frame_mode(self): + def test_validation_reads_only_the_source(self): + # ComfyUI files a failed check once per input VALIDATE_INPUTS names, + # so naming the trim bounds too turned one missing file into four + # errors. The window is checked when the loader runs, where a wired + # bound has its value and single_frame applies. node = node_load_video.AusBossLoadVideo + self.assertEqual(list(inspect.signature(node.VALIDATE_INPUTS).parameters), ["video"]) with patch.object(node_load_video, "resolve_input_path", lambda _name: self.video): - self.assertIn("start_seconds", node.VALIDATE_INPUTS("clip.mp4", 5.0, 1.0)) - self.assertIs( - node.VALIDATE_INPUTS("clip.mp4", 5.0, 1.0, single_frame=True), True - ) - - def test_validation_skips_the_window_check_for_a_wired_bound(self): - # ComfyUI hands a wired input to VALIDATE_INPUTS as None: its value - # only exists at execution, so the window cannot be judged yet. - node = node_load_video.AusBossLoadVideo - with patch.object(node_load_video, "resolve_input_path", lambda _name: self.video): - self.assertIs(node.VALIDATE_INPUTS("clip.mp4", 0.5, None), True) - self.assertIs(node.VALIDATE_INPUTS("clip.mp4", None, 1.5), True) - self.assertIs(node.VALIDATE_INPUTS("clip.mp4", None, None), True) - # A wired bound must not switch off the checks that still apply. - self.assertIn("start_seconds", node.VALIDATE_INPUTS("clip.mp4", 5.0, 1.0)) + self.assertIs(node.VALIDATE_INPUTS("clip.mp4"), True) gone = ValueError("The selected source file no longer exists.") with patch.object(node_load_video, "resolve_input_path", side_effect=gone): - self.assertIn("Load Video", node.VALIDATE_INPUTS("gone.mp4", None, 1.5)) - self.assertIn("Load Video", node.VALIDATE_INPUTS("gone.mp4", 0.5, None)) + self.assertEqual(node.VALIDATE_INPUTS("gone.mp4"), "Load Video: The selected source file no longer exists.") + + def test_a_backwards_trim_window_stops_the_loader_when_it_runs(self): + with self.assertRaisesRegex(ValueError, "Load Video needs start_seconds smaller than end_seconds"): + run_node(node_load_video.AusBossLoadVideo().load_video(str(self.video), 1.5, 0.5, 0, 0)) def test_the_node_function_is_a_coroutine(self): node = node_load_video.AusBossLoadVideo