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:
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user