v2.4.1: queue-time validation, absolute pixel ceiling, CI hardening

- Reject invalid configs in validate_inputs instead of mid-execution:
  Strict-mode multiple_of above max_res, out-of-range min_res/max_res/
  multiple_of, and unknown constraint modes
- Share the resolution and multiple_of bounds between define_schema() and
  validate_inputs() so the widget and the queue-time check cannot drift
- Cap the min-res pixel budget at an absolute 64 MP, floored at max_res
  squared; the multiplier alone permitted ~12 GB outputs at max_res=8192
- Warn on large upscales in Strict mode as well as min-res mode
- Pin GitHub Actions by commit SHA on node24-native majors; drop issues:write
  from the publish workflow and set contents:read on both
- Pin Comfy-Org/publish-node-action to the commit @v1 resolves to; the 1.0.1
  tag calls `comfy tracking disable`, removed from comfy-cli
- Add requirements-ci.txt with pip caching; add ruff config and a CI lint step
- Add schema-contract, tooltip, budget, crop-guard and validation tests
- Replace typing.Tuple with tuple
- Bump version to 2.4.1
This commit is contained in:
EnragedAntelope
2026-08-24 14:11:51 -04:00
parent 2bc791c5d5
commit c385f389ee
8 changed files with 263 additions and 49 deletions
+9 -3
View File
@@ -7,8 +7,10 @@ on:
paths:
- "pyproject.toml"
# The publish action only reads the repo and authenticates to the registry API
# with its own token input; it never touches issues or any other scope.
permissions:
issues: write
contents: read
jobs:
publish-node:
@@ -17,8 +19,12 @@ jobs:
if: ${{ github.repository_owner == 'EnragedAntelope' }}
steps:
- name: Check out code
uses: actions/checkout@v4
# actions/checkout v7.0.1 (node24), pinned by SHA for supply-chain safety.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
# The SHA the upstream-documented @v1 branch points at (verified 2026-08-24).
# Do NOT pin the 1.0.1 tag instead: that commit predates the v1 branch and
# still calls `comfy tracking disable`, which current comfy-cli no longer has.
uses: Comfy-Org/publish-node-action@0eb6cd742945443bee7f79eeddd4f732780029a1
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+9 -4
View File
@@ -19,14 +19,19 @@ jobs:
python-version: ["3.10", "3.11", "3.12"]
steps:
- name: Check out code
uses: actions/checkout@v4
# actions/checkout v7.0.1 (node24), pinned by SHA for supply-chain safety.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Python
uses: actions/setup-python@v5
# actions/setup-python v7.0.0 (node24)
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: requirements-ci.txt
- name: Install test dependencies
# comfy_api is mocked in conftest.py, so a full ComfyUI install isn't needed.
# numpy silences torch's "Failed to initialize NumPy" import warning.
run: python -m pip install --upgrade pip pytest torch numpy
run: python -m pip install --upgrade pip -r requirements-ci.txt
- name: Lint
run: python -m ruff check .
- name: Run tests
run: python -m pytest -q
+10 -7
View File
@@ -4,11 +4,11 @@ A ComfyUI node that resizes any image to fit your model's resolution rules: a mi
## Current state
_Last verified: 2026-08-19_
_Last verified: 2026-08-24_
- **Status:** released v2.4.0 (`pyproject.toml`). Stable and feature-complete for what it sets out to do. Published to the Comfy Registry — `.github/workflows/publish_action.yml` fires on a `pyproject.toml` version change on `main`, so a functional change needs a version bump or it never ships.
- **Status:** released v2.4.1 (`pyproject.toml`). Stable and feature-complete for what it sets out to do. Published to the Comfy Registry — `.github/workflows/publish_action.yml` fires on a `pyproject.toml` version change on `main`, so a functional change needs a version bump or it never ships.
- **Works:** both constraint modes (Prioritize Min Resolution, Prioritize Max Resolution strict); divisibility by 2/8/16/32/64 enforced together with the min/max limits so reported width/height always match the output tensor; minimal smart cropping with a configurable crop position; all five resize methods; the full output set (resized image, original passthrough, width, height, final and original aspect ratios). `.github/workflows/test.yml` runs the pytest suite.
- **In progress:** nothing — v2.4.0 closed out a full audit: a memory-exhaustion guard on extreme-aspect-ratio upscales, fixes for configurations that silently no-opped, a `validate_inputs` signature that was disabling ComfyUI's own input validation, and removal of a duplicate resize.
- **In progress:** nothing — v2.4.1 polished the v2.4.0 audit: `validate_inputs` now rejects at queue time (Strict-mode `multiple_of > max_res`, out-of-range resolutions, unknown constraint modes), the min-res memory guard gained an absolute pixel ceiling, large-upscale warnings fire in both modes, CI actions are SHA-pinned with pip caching (`requirements-ci.txt`, CI-only), and a schema-contract test keeps `define_schema()` in sync with `execute()`.
- **Known gaps / next steps:** no batch-specific handling beyond what ComfyUI's own resizer provides; behaviour under extreme aspect ratios is covered by tests but has had little real-world exercise; the README screenshots track v2.3.1 and would need refreshing alongside any visible change.
- **Deep docs:** none — `README.md` is the user-facing reference and `nodes.py` is the whole implementation.
@@ -44,17 +44,20 @@ git clone https://github.com/EnragedAntelope/ComfyUI-ConstrainResolution.git
# No pip dependencies — Python 3.10+ and ComfyUI itself are the only requirements
# Run tests
# Run tests (conftest.py mocks comfy_api, so no ComfyUI install is needed)
pip install -r requirements-ci.txt
pytest
ruff check .
```
## Conventions & gotchas
- Single-file node — `nodes.py` is the entire feature. Keep it self-contained.
- Zero pip dependencies. Drops into `custom_nodes/` — no pip install needed.
- Zero pip dependencies for the pack itself. `requirements-ci.txt` exists only for CI (pytest/torch/numpy); never add runtime deps.
- `validate_inputs` must NOT take `**kwargs`: ComfyUI then skips its own range and combo-option validation for *every* input on the node (`execution.py`, `validate_has_kwargs`). It also skips those checks for each input the function names explicitly, so anything listed in the signature must have its bounds re-asserted inside the function. `MIN_RESOLUTION`/`MAX_RESOLUTION`/`MIN_MULTIPLE_OF`/`MAX_MULTIPLE_OF` are shared by `define_schema()` and `validate_inputs()` so the widget and the queue-time check cannot drift.
- The GitHub Actions pins are commit SHAs; the comment above each one records the version it corresponds to. Keep them on node24-native majors — GitHub warns on, and will eventually stop running, node20 actions. `Comfy-Org/publish-node-action` is pinned to the commit its `v1` *branch* points at — its `1.0.1` tag is older than that branch and still calls `comfy tracking disable`, which current comfy-cli does not have.
- Divisibility, min/max, and crop dimensions are enforced together — reported width/height always match the actual output tensor.
- Unsatisfiable or memory-exhausting settings raise `ValueError` rather than returning a zero dimension; `calculate_optimal_dimensions` never returns 0 for a non-empty input.
- `validate_inputs` must NOT take `**kwargs`: ComfyUI skips its own range and combo-option validation for every input on the node if it does (see `validate_inputs` in ComfyUI's `execution.py`).
- Unsatisfiable or memory-exhausting settings raise `ValueError` rather than returning a zero dimension; `calculate_optimal_dimensions` never returns 0 for a non-empty input. The min-res pixel budget is `max(box, min(box * FACTOR, MAX_OUTPUT_PIXELS))` where `box = max_res ** 2` — a size the user's own `max_res` permits is never refused.
- `resize_method` defaults to `lanczos` (sharpest). Options: lanczos, bicubic, bilinear, nearest-exact (pixel art/masks), area (big downscales).
- When `crop_as_required=False` and `multiple_of=1`, no cropping occurs — keeps every pixel.
- The node uses ComfyUI v3 schema (`comfy_api.latest`).
+2 -1
View File
@@ -35,7 +35,7 @@ Every image is rescaled so its longest side lands on `max_res` — images alread
**Constraint modes** — with a very wide or very tall image, min and max can conflict:
- **Prioritize Min Resolution**: neither dimension goes below `min_res`, even if the long side must exceed `max_res`. Best default. Beyond roughly a 52:1 aspect ratio the required output would be large enough to exhaust memory, so the node reports an error instead of trying — switch to Strict mode for those.
- **Prioritize Min Resolution**: neither dimension goes below `min_res`, even if the long side must exceed `max_res`. Best default. At the default settings, beyond roughly a 52:1 aspect ratio the required output would be large enough to exhaust memory, so the node reports an error instead of trying — switch to Strict mode for those. The limit scales with `max_res` up to an absolute ceiling of 64 MP, and never refuses an output that fits inside your own `max_res × max_res` box.
- **Prioritize Max Resolution (Strict)**: output always fits in a `max_res × max_res` box (hard VRAM cap), even if the short side lands below `min_res`.
## Outputs
@@ -74,6 +74,7 @@ Restart ComfyUI. No dependencies beyond ComfyUI itself (Python 3.10+).
## Version history
- **v2.4.1**: Invalid settings are now rejected when the workflow is queued instead of mid-execution — `multiple_of` above `max_res` in Strict mode, plus out-of-range `min_res`/`max_res`/`multiple_of` and unknown constraint modes coming from hand-written API workflows, which the widget bounds alone no longer covered; the memory guard gained an absolute 64 MP ceiling, so it stays meaningful at large `max_res` instead of permitting multi-gigabyte outputs; large-upscale warnings now fire in Strict mode too, so tiny sources blown up 10x+ are no longer silent; CI hardening (GitHub Actions pinned by commit SHA, pip dependency caching, torch bounded to known-good majors); new tests keep the node schema in sync with `execute()` and enforce tooltips on every input
- **v2.4.0**: Refuse extreme-aspect-ratio upscales that would exhaust memory instead of attempting them (a 1×690 input at default settings previously asked for 704×485760, roughly 4 GB per batch item); fixed settings that silently passed the image through untouched (`multiple_of` above `max_res` in Strict mode, and thin strips at `multiple_of=1`); restored ComfyUI's built-in range and combo-option validation, which the previous `validate_inputs` signature disabled for every input; ~1.85× faster when cropping by dropping a resize whose result was discarded; crop decisions now use exact aspect ratios instead of 4-decimal rounded ones
- **v2.3.2**: Added CI test workflow (pytest on Python 3.10–3.12); warn on very large upscales in "Prioritize Min Resolution" mode; pass malformed zero-dimension inputs through unchanged instead of erroring
- **v2.3.1**: Rewrote README with current screenshots; enforce `min_res` even when rounding to a large `multiple_of` would dip below it
+75 -21
View File
@@ -1,7 +1,6 @@
import logging
import math
from enum import Enum
from typing import Tuple
import torch
import torch.nn.functional as F
@@ -16,9 +15,24 @@ logger = logging.getLogger(__name__)
# more than this many max_res-sized boxes and tell the user to switch modes.
MAX_OUTPUT_PIXEL_BUDGET_FACTOR = 16
# The relative budget above is a multiplier, so it grows with the square of
# max_res: at max_res=8192 it would permit a 1-gigapixel output (~12 GB per
# batch item), which is the very thing it exists to prevent. Cap it in absolute
# terms too. 64 MP is ~768 MB per batch item at float32 RGB.
MAX_OUTPUT_PIXELS = 64 * 1024 * 1024
# Upscales beyond this factor are legal but worth flagging in the log.
UPSCALE_WARN_FACTOR = 4
# Bounds shared by define_schema() and validate_inputs(). ComfyUI applies a
# node's declared min/max only to inputs that validate_inputs does NOT name
# (execution.py: `if x not in validate_function_inputs`), so every input named
# there must have its range re-checked by hand or it goes unvalidated.
MIN_RESOLUTION = 1
MAX_RESOLUTION = 65536
MIN_MULTIPLE_OF = 1
MAX_MULTIPLE_OF = 256
# (str, Enum) instead of StrEnum keeps the pack importable on Python 3.10,
# which ComfyUI still supports.
@@ -82,8 +96,8 @@ class ConstrainResolution(io.ComfyNode):
io.Int.Input(
"min_res",
default=704,
min=1,
max=65536,
min=MIN_RESOLUTION,
max=MAX_RESOLUTION,
tooltip=(
"Minimum resolution in pixels for width and height. "
"Neither output dimension will fall below this."
@@ -92,8 +106,8 @@ class ConstrainResolution(io.ComfyNode):
io.Int.Input(
"max_res",
default=1280,
min=1,
max=65536,
min=MIN_RESOLUTION,
max=MAX_RESOLUTION,
tooltip=(
"Maximum resolution in pixels. Every image is rescaled so its longest side "
"lands here, so images already in range are resized too. In "
@@ -104,8 +118,8 @@ class ConstrainResolution(io.ComfyNode):
io.Int.Input(
"multiple_of",
default=2,
min=1,
max=256,
min=MIN_MULTIPLE_OF,
max=MAX_MULTIPLE_OF,
tooltip=(
"Ensures output dimensions are multiples of this number. "
"Common values: 2 (most models), 8, 16, 32, or 64 (optimal performance). "
@@ -193,23 +207,51 @@ class ConstrainResolution(io.ComfyNode):
)
@classmethod
def validate_inputs(cls, min_res, max_res, multiple_of):
def validate_inputs(cls, min_res, max_res, multiple_of, constraint_mode):
"""Validate input parameters.
The signature deliberately declares only the values checked here and
takes no **kwargs: ComfyUI skips its own range and combo-option
validation for every input once this function accepts **kwargs
(see ``validate_inputs`` in ComfyUI's execution.py). Listing just these
three leaves the combo inputs to ComfyUI's built-in checks.
four leaves the other inputs to ComfyUI's built-in checks.
The flip side is that ComfyUI also skips its checks for the four names
listed here, so their schema bounds are re-asserted below. The widgets
already clamp in the UI; a hand-written or generated API workflow does
not, and an unbounded max_res is what makes the pixel budget in
``calculate_optimal_dimensions`` meaningless.
"""
for name, value in (("min_res", min_res), ("max_res", max_res)):
if not MIN_RESOLUTION <= value <= MAX_RESOLUTION:
return (
f"{name} must be between {MIN_RESOLUTION} and {MAX_RESOLUTION}, got {value}"
)
if not MIN_MULTIPLE_OF <= multiple_of <= MAX_MULTIPLE_OF:
return (
f"multiple_of must be between {MIN_MULTIPLE_OF} and {MAX_MULTIPLE_OF}, "
f"got {multiple_of}"
)
valid_modes = [mode.value for mode in ConstraintMode]
if constraint_mode not in valid_modes:
return f"constraint_mode must be one of {valid_modes}, got {constraint_mode!r}"
if max_res < min_res:
return f"max_res ({max_res}) must be greater than or equal to min_res ({min_res})"
if multiple_of < 1:
return f"multiple_of must be at least 1, got {multiple_of}"
if min_res < 1:
return f"min_res must be at least 1, got {min_res}"
if (
constraint_mode == ConstraintMode.MAX_RES_STRICT.value
and multiple_of > max_res
):
# Same failure calculate_optimal_dimensions raises at execution time;
# catching it here rejects the workflow at queue time instead.
return (
f"multiple_of ({multiple_of}) is larger than max_res ({max_res}), so no valid "
f"output size exists in '{ConstraintMode.MAX_RES_STRICT.value}' mode. "
f"Lower multiple_of or raise max_res."
)
return True
@@ -237,7 +279,7 @@ class ConstrainResolution(io.ComfyNode):
max_res: int,
multiple_of: int,
constraint_mode: str
) -> Tuple[int, int]:
) -> tuple[int, int]:
"""Calculate optimal dimensions based on constraints"""
if multiple_of < 1:
raise ValueError(f"multiple_of must be at least 1, got {multiple_of}")
@@ -307,7 +349,11 @@ class ConstrainResolution(io.ComfyNode):
# process. Only reachable in min-res mode, which is the only mode
# without an upper bound.
if constraint_mode == ConstraintMode.MIN_RES.value:
budget = max(min_res, max_res) ** 2 * MAX_OUTPUT_PIXEL_BUDGET_FACTOR
# Allow the user's own max_res box unconditionally, a multiple of it
# for moderately extreme ratios, and never more than the absolute
# ceiling — so the guard stays meaningful at every max_res.
box = max(min_res, max_res) ** 2
budget = max(box, min(box * MAX_OUTPUT_PIXEL_BUDGET_FACTOR, MAX_OUTPUT_PIXELS))
if final_width * final_height > budget:
raise ValueError(
f"'{ConstraintMode.MIN_RES.value}' needs {final_width}x{final_height} "
@@ -470,16 +516,24 @@ class ConstrainResolution(io.ComfyNode):
original_aspect_ratio, original_aspect_ratio
)
# Warn when Prioritize Min Resolution forces a large upscale (extreme aspect
# ratios can blow up the long side and risk OOM). Strict mode caps this instead.
if constraint_mode == ConstraintMode.MIN_RES.value:
upscale_factor = max(target_width / width, target_height / height)
if upscale_factor > UPSCALE_WARN_FACTOR:
# Warn when a resize forces a large upscale (>4x): legal, but tiny sources
# produce visibly soft results, and min-res mode can also blow up memory
# on extreme aspect ratios. Fires in both modes — strict mode bounds the
# size, not the blur.
upscale_factor = max(target_width / width, target_height / height)
if upscale_factor > UPSCALE_WARN_FACTOR:
if constraint_mode == ConstraintMode.MIN_RES.value:
logger.warning(
"Upscaling by %.1fx to %dx%d to satisfy min_res on an extreme aspect ratio. "
"Use 'Prioritize Max Resolution (Strict)' to cap output size and avoid large upscales.",
upscale_factor, target_width, target_height
)
else:
logger.warning(
"Upscaling by %.1fx to %dx%d from a %dx%d source; the result will look soft. "
"A larger source image will give better quality.",
upscale_factor, target_width, target_height, width, height
)
final_aspect_ratio = cls.calculate_aspect_ratio(target_width, target_height)
+11 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "constrainresolution"
description = "Resize images to fit min/max resolution limits with dimensions guaranteed divisible by 2/8/16/32/64. Aspect-ratio-preserving resize with smart cropping — ideal for image-to-image and image-to-video workflows."
version = "2.4.0"
version = "2.4.1"
license = {file = "LICENSE"}
requires-python = ">=3.10"
dependencies = []
@@ -14,6 +14,16 @@ Repository = "https://github.com/EnragedAntelope/ComfyUI-ConstrainResolution"
testpaths = ["tests"]
addopts = "--import-mode=importlib"
[tool.ruff]
# Long descriptive strings (tooltips, error messages) run past the 88-char
# default; 130 is the widest line actually in the tree.
line-length = 130
[tool.ruff.lint]
# Pyflakes + pycodestyle errors + isort + pyupgrade + flake8-bugbear.
# Pinned explicitly so a new ruff release cannot silently add rules and fail CI.
select = ["E", "F", "I", "UP", "B"]
[tool.comfy]
PublisherId = "enragedantelope"
DisplayName = "ComfyUI-ConstrainResolution"
+9
View File
@@ -0,0 +1,9 @@
# CI-only test dependencies (used by .github/workflows/test.yml).
# The node pack itself ships with zero pip dependencies: conftest.py mocks
# comfy_api, and ComfyUI provides torch at runtime. torch is floored at 2.2
# and capped below 3 so a future major release cannot silently change what
# CI tests against.
pytest>=8
numpy>=1.26
torch>=2.2,<3
ruff>=0.6,<1
+138 -12
View File
@@ -1,8 +1,18 @@
import inspect
import sys
import types
import pytest
import torch
from nodes import ConstrainResolution, ConstraintMode, ResizeMethod
from nodes import (
MAX_MULTIPLE_OF,
MAX_OUTPUT_PIXELS,
MAX_RESOLUTION,
ConstrainResolution,
ConstraintMode,
ResizeMethod,
)
STRICT = ConstraintMode.MAX_RES_STRICT.value
MIN = ConstraintMode.MIN_RES.value
@@ -198,13 +208,22 @@ class TestExecuteGuards:
run_node(image, constraint_mode=MIN)
assert any("Upscaling by" in r.message for r in caplog.records)
def test_normal_image_does_not_warn(self, caplog):
"""A normal image must not trigger the upscale warning."""
@pytest.mark.parametrize("mode", [MIN, STRICT])
def test_normal_image_does_not_warn(self, caplog, mode):
"""A normal image must not trigger the upscale warning in either mode."""
image = torch.rand(1, 800, 1000, 3)
with caplog.at_level("WARNING"):
run_node(image, constraint_mode=MIN)
run_node(image, constraint_mode=mode)
assert not any("Upscaling by" in r.message for r in caplog.records)
def test_strict_mode_large_upscale_warns(self, caplog):
"""Strict mode bounds the size, not the blur: a tiny source blown up
12.8x deserves the same log signal min-res mode gives."""
image = torch.rand(1, 100, 100, 3)
with caplog.at_level("WARNING"):
run_node(image, constraint_mode=STRICT)
assert any("Upscaling by" in r.message for r in caplog.records)
class TestUpscaleBudget:
"""A tiny sliver of an image must not be blown up into a multi-GB tensor."""
@@ -233,10 +252,24 @@ class TestUpscaleBudget:
def test_budget_scales_with_max_res(self):
"""A larger max_res raises the ceiling rather than being a fixed cap."""
with pytest.raises(ValueError):
calc(1, 400, 704, 1280, 2, MIN)
w, h = calc(1, 400, 704, 8192, 2, MIN)
calc(1, 100, 704, 1280, 2, MIN)
w, h = calc(1, 100, 704, 2048, 2, MIN)
assert min(w, h) >= 704
def test_budget_has_an_absolute_ceiling(self):
"""The relative budget grows with max_res squared, so on its own it
stops guarding anything at large max_res: 704x281600 is ~2.4 GB per
batch item, which the multiplier alone would have waved through."""
with pytest.raises(ValueError, match="safety limit"):
calc(1, 400, 704, 8192, 2, MIN)
def test_ceiling_never_refuses_the_users_own_max_res_box(self):
"""Strict mode allows a full max_res x max_res output, so min-res mode
must not refuse the same size just because it exceeds the ceiling."""
w, h = calc(1000, 1000, 16384, 16384, 2, MIN)
assert w * h > MAX_OUTPUT_PIXELS
assert w == h == 16384
class TestDegenerateConfigs:
"""Configurations that used to yield a zero dimension and a silent no-op."""
@@ -323,21 +356,67 @@ class TestValidateInputs:
def test_signature_has_no_kwargs(self):
"""**kwargs makes ComfyUI skip its own range and combo validation for
every input on this node (execution.py: validate_has_kwargs)."""
import inspect
spec = inspect.getfullargspec(ConstrainResolution.validate_inputs.__func__)
assert spec.varkw is None
assert spec.varargs is None
assert spec.args == ["cls", "min_res", "max_res", "multiple_of"]
assert spec.args == ["cls", "min_res", "max_res", "multiple_of", "constraint_mode"]
def test_accepts_valid_inputs(self):
assert ConstrainResolution.validate_inputs(704, 1280, 2) is True
assert ConstrainResolution.validate_inputs(704, 1280, 2, MIN) is True
def test_rejects_max_below_min(self):
assert isinstance(ConstrainResolution.validate_inputs(1280, 704, 2), str)
assert isinstance(ConstrainResolution.validate_inputs(1280, 704, 2, MIN), str)
def test_rejects_bad_multiple_and_min(self):
assert isinstance(ConstrainResolution.validate_inputs(704, 1280, 0), str)
assert isinstance(ConstrainResolution.validate_inputs(0, 1280, 2), str)
assert isinstance(ConstrainResolution.validate_inputs(704, 1280, 0, MIN), str)
assert isinstance(ConstrainResolution.validate_inputs(0, 1280, 2, MIN), str)
def test_strict_mode_rejects_multiple_of_above_max_res_at_queue_time(self):
# calculate_optimal_dimensions raises this at execution time; catching
# it in validate_inputs rejects the workflow when it is queued instead.
# 256 > max_res=128 with min_res <= max_res, so this is the only failing rule.
message = ConstrainResolution.validate_inputs(64, 128, 256, STRICT)
assert isinstance(message, str)
assert "larger than max_res" in message
def test_min_res_mode_tolerates_multiple_of_above_max_res(self):
# Min-res mode bumps dimensions up to the next multiple (64x128 input,
# multiple_of=256 -> 256x256), so a large multiple_of is valid there.
assert ConstrainResolution.validate_inputs(64, 128, 256, MIN) is True
def test_strict_mode_accepts_valid_combination(self):
assert ConstrainResolution.validate_inputs(704, 1280, 32, STRICT) is True
@pytest.mark.parametrize(
"args",
[
(704, MAX_RESOLUTION + 1, 2, MIN),
(MAX_RESOLUTION + 1, MAX_RESOLUTION + 1, 2, MIN),
(704, 1280, MAX_MULTIPLE_OF + 1, MIN),
],
)
def test_rejects_out_of_range_values(self, args):
"""Naming an input in validate_inputs makes ComfyUI skip its own
min/max check for it, so the schema bounds have to be re-asserted here.
An unbounded max_res would also make the pixel budget meaningless."""
message = ConstrainResolution.validate_inputs(*args)
assert isinstance(message, str), f"{args} should have been rejected"
assert "must be between" in message
def test_rejects_unknown_constraint_mode(self):
message = ConstrainResolution.validate_inputs(704, 1280, 2, "TypoMode")
assert isinstance(message, str)
assert "constraint_mode" in message
def test_schema_bounds_match_validate_inputs(self, monkeypatch):
"""The bounds are shared constants precisely so the widget and the
queue-time check cannot drift apart."""
captured = TestSchemaContract._capture(monkeypatch)
ConstrainResolution.define_schema()
bounds = {name: kwargs for name, kwargs in captured["inputs"]}
assert bounds["max_res"]["max"] == MAX_RESOLUTION
assert bounds["min_res"]["max"] == MAX_RESOLUTION
assert bounds["multiple_of"]["max"] == MAX_MULTIPLE_OF
class TestExtremeRatioCropping:
@@ -348,3 +427,50 @@ class TestExtremeRatioCropping:
image, multiple_of=32, constraint_mode=STRICT
)
assert resized.shape == (1, out_h, out_w, 3)
class TestSchemaContract:
"""conftest mocks comfy_api, so nothing else exercises define_schema().
Schema drift - an input renamed while execute() was not - would only
surface as a load failure inside real ComfyUI."""
@staticmethod
def _capture(monkeypatch):
mock_io = sys.modules['comfy_api.latest'].io
captured = {"inputs": [], "schema": None}
def make_recorder(bucket):
def factory(*args, **kwargs):
bucket.append((args[0] if args else "", kwargs))
return None
return factory
def fake_schema(**kwargs):
captured["schema"] = kwargs
return kwargs
for kind in ("Image", "Mask", "Latent", "Int", "Float", "String", "Combo", "Boolean"):
namespace = types.SimpleNamespace()
namespace.Input = make_recorder(captured["inputs"])
namespace.Output = make_recorder([])
monkeypatch.setattr(mock_io, kind, namespace, raising=False)
monkeypatch.setattr(mock_io, "Schema", fake_schema, raising=False)
return captured
def test_execute_params_are_all_schema_inputs(self, monkeypatch):
captured = self._capture(monkeypatch)
ConstrainResolution.define_schema()
schema_names = {name for name, _ in captured["inputs"]}
execute_params = set(inspect.signature(ConstrainResolution.execute).parameters)
missing = execute_params - schema_names
assert not missing, f"execute() params missing from define_schema(): {missing}"
def test_every_input_documents_itself(self, monkeypatch):
captured = self._capture(monkeypatch)
ConstrainResolution.define_schema()
unexplained = [name for name, kwargs in captured["inputs"] if not kwargs.get("tooltip")]
assert not unexplained, f"schema inputs missing tooltips: {unexplained}"
assert captured["schema"], "io.Schema(...) was never called"
assert captured["schema"].get("description")