Redesign the profile editor's dimension controls into a two-step flow: pick a shape (orientation + aspect-ratio chips), then a resolution tier on a fast-mock -> high-res scale. Width/height are derived by fitting the ratio into the tier's pixel budget and snapping to a multiple of 8, replacing the 33-entry preset dropdown and free-text custom field. Add an optional IMAGE input: when connected, the node adopts the image's aspect ratio at runtime while the selected profile still sets the resolution (pixel budget) and step count. A banner and form note surface this in the UI. Tests: +8 Python cases for image-dimension reading and ratio-to-area fitting (91 total); existing 65 JS pure-helper tests unchanged and passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
201 lines
7.9 KiB
Python
201 lines
7.9 KiB
Python
"""Unit tests for nodes.py."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
import types
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
NODES_PATH = Path(__file__).resolve().parents[1] / "nodes.py"
|
|
|
|
|
|
def load_nodes_module():
|
|
"""Load nodes.py with mocked external runtime modules."""
|
|
module_name = "nodes_under_test"
|
|
sys.modules.pop(module_name, None)
|
|
|
|
fake_torch = types.ModuleType("torch")
|
|
fake_torch.zeros = lambda shape, device=None: {"shape": shape, "device": device}
|
|
|
|
fake_comfy = types.ModuleType("comfy")
|
|
fake_model_management = types.ModuleType("comfy.model_management")
|
|
fake_model_management.intermediate_device = lambda: "cpu"
|
|
fake_comfy.model_management = fake_model_management
|
|
|
|
previous = {
|
|
"torch": sys.modules.get("torch"),
|
|
"comfy": sys.modules.get("comfy"),
|
|
"comfy.model_management": sys.modules.get("comfy.model_management"),
|
|
}
|
|
sys.modules["torch"] = fake_torch
|
|
sys.modules["comfy"] = fake_comfy
|
|
sys.modules["comfy.model_management"] = fake_model_management
|
|
|
|
try:
|
|
spec = importlib.util.spec_from_file_location(module_name, NODES_PATH)
|
|
if spec is None or spec.loader is None:
|
|
raise RuntimeError("Unable to load nodes.py spec")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
finally:
|
|
for name, original in previous.items():
|
|
if original is None:
|
|
sys.modules.pop(name, None)
|
|
else:
|
|
sys.modules[name] = original
|
|
|
|
|
|
class TestComfyUIImageProfile(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.nodes = load_nodes_module()
|
|
cls.node_cls = cls.nodes.ComfyUIImageProfile
|
|
|
|
def test_sanitize_dimension_rounds_and_clamps(self):
|
|
self.assertEqual(self.node_cls._sanitize_dimension(11), 8)
|
|
self.assertEqual(self.node_cls._sanitize_dimension(12), 16)
|
|
self.assertEqual(self.node_cls._sanitize_dimension(-100), 8)
|
|
self.assertEqual(self.node_cls._sanitize_dimension(20000), 16384)
|
|
|
|
def test_load_profiles_falls_back_to_defaults_on_invalid_json(self):
|
|
profiles = self.node_cls._load_profiles("not-json")
|
|
self.assertEqual(len(profiles), len(self.nodes.DEFAULT_PROFILES))
|
|
self.assertEqual(profiles[0]["id"], "default-landscape-low")
|
|
|
|
def test_load_profiles_filters_invalid_profiles(self):
|
|
payload = json.dumps(
|
|
[
|
|
{"id": "ok", "name": "Valid", "width": 512, "height": 768, "steps": 20},
|
|
{"id": "missing-name", "width": 512, "height": 768, "steps": 20},
|
|
{"name": "missing-id", "width": 512, "height": 768, "steps": 20},
|
|
]
|
|
)
|
|
profiles = self.node_cls._load_profiles(payload)
|
|
self.assertEqual(len(profiles), 1)
|
|
self.assertEqual(profiles[0]["id"], "ok")
|
|
self.assertEqual(profiles[0]["name"], "Valid")
|
|
self.assertEqual(profiles[0]["width"], 512)
|
|
self.assertEqual(profiles[0]["height"], 768)
|
|
self.assertEqual(profiles[0]["steps"], 20)
|
|
|
|
def test_resolve_selected_profile_falls_back_to_first_profile(self):
|
|
node = self.node_cls()
|
|
profiles = [
|
|
{"id": "first", "name": "First", "width": 512, "height": 768, "steps": 20},
|
|
{"id": "second", "name": "Second", "width": 768, "height": 512, "steps": 30},
|
|
]
|
|
selected = node._resolve_selected_profile(
|
|
profiles,
|
|
selected_profile_id="missing",
|
|
selected_width=111,
|
|
selected_height=222,
|
|
selected_steps=0,
|
|
)
|
|
self.assertEqual(selected["id"], "first")
|
|
self.assertEqual(selected["name"], "Selected")
|
|
self.assertEqual(selected["width"], 112)
|
|
self.assertEqual(selected["height"], 224)
|
|
self.assertEqual(selected["steps"], 1)
|
|
|
|
def test_generate_returns_latent_and_steps(self):
|
|
node = self.node_cls()
|
|
profiles_json = json.dumps(
|
|
[
|
|
{"id": "p1", "name": "Profile One", "width": 1152, "height": 864, "steps": 14},
|
|
]
|
|
)
|
|
latent, steps = node.generate(
|
|
profiles_json=profiles_json,
|
|
selected_profile_id="p1",
|
|
selected_width=8,
|
|
selected_height=8,
|
|
selected_steps=1,
|
|
)
|
|
|
|
self.assertEqual(steps, 14)
|
|
self.assertEqual(latent["samples"]["shape"], [1, 4, 108, 144])
|
|
self.assertEqual(latent["samples"]["device"], "cpu")
|
|
|
|
# -- image-driven aspect ratio -----------------------------------------
|
|
|
|
@staticmethod
|
|
def _fake_image(height: int, width: int, batch: int = 1, channels: int = 3):
|
|
# Mimics a ComfyUI IMAGE tensor: shape [batch, height, width, channels].
|
|
return types.SimpleNamespace(shape=(batch, height, width, channels))
|
|
|
|
def test_image_dimensions_reads_bhwc_shape(self):
|
|
image = self._fake_image(height=512, width=768)
|
|
self.assertEqual(self.node_cls._image_dimensions(image), (768, 512))
|
|
|
|
def test_image_dimensions_reads_hwc_shape(self):
|
|
image = types.SimpleNamespace(shape=(720, 1280, 3))
|
|
self.assertEqual(self.node_cls._image_dimensions(image), (1280, 720))
|
|
|
|
def test_image_dimensions_returns_none_for_invalid(self):
|
|
self.assertIsNone(self.node_cls._image_dimensions(None))
|
|
self.assertIsNone(self.node_cls._image_dimensions(types.SimpleNamespace(shape=(3,))))
|
|
self.assertIsNone(self.node_cls._image_dimensions(types.SimpleNamespace(shape=(1, 0, 100, 3))))
|
|
|
|
def test_fit_ratio_to_area_keeps_landscape_ratio_and_budget(self):
|
|
# 3:2 image fitted into a 1152x864 (~1.0 MP) profile budget.
|
|
image = self._fake_image(height=512, width=768)
|
|
width, height = self.node_cls._fit_ratio_to_area(image, 1152, 864)
|
|
self.assertGreater(width, height)
|
|
self.assertEqual(width % self.nodes.DIM_MULTIPLE, 0)
|
|
self.assertEqual(height % self.nodes.DIM_MULTIPLE, 0)
|
|
# Roughly preserves the source ratio (1.5) and the pixel budget.
|
|
self.assertAlmostEqual(width / height, 768 / 512, delta=0.05)
|
|
self.assertAlmostEqual((width * height) / (1152 * 864), 1.0, delta=0.05)
|
|
|
|
def test_fit_ratio_to_area_produces_portrait_for_tall_image(self):
|
|
image = self._fake_image(height=1024, width=512)
|
|
width, height = self.node_cls._fit_ratio_to_area(image, 1152, 864)
|
|
self.assertLess(width, height)
|
|
|
|
def test_fit_ratio_to_area_falls_back_without_shape(self):
|
|
self.assertEqual(self.node_cls._fit_ratio_to_area(None, 1152, 864), (1152, 864))
|
|
|
|
def test_generate_uses_image_ratio_with_profile_resolution(self):
|
|
node = self.node_cls()
|
|
profiles_json = json.dumps(
|
|
[{"id": "p1", "name": "Profile One", "width": 1152, "height": 864, "steps": 14}],
|
|
)
|
|
image = self._fake_image(height=512, width=768)
|
|
latent, steps = node.generate(
|
|
profiles_json=profiles_json,
|
|
selected_profile_id="p1",
|
|
selected_width=8,
|
|
selected_height=8,
|
|
selected_steps=1,
|
|
image=image,
|
|
)
|
|
|
|
# Steps still come from the profile; latent dims follow the image ratio.
|
|
self.assertEqual(steps, 14)
|
|
_, _, latent_h, latent_w = latent["samples"]["shape"]
|
|
self.assertGreater(latent_w, latent_h)
|
|
|
|
def test_generate_without_image_uses_profile_dimensions(self):
|
|
node = self.node_cls()
|
|
profiles_json = json.dumps(
|
|
[{"id": "p1", "name": "Profile One", "width": 1152, "height": 864, "steps": 14}],
|
|
)
|
|
latent, _ = node.generate(
|
|
profiles_json=profiles_json,
|
|
selected_profile_id="p1",
|
|
selected_width=8,
|
|
selected_height=8,
|
|
selected_steps=1,
|
|
)
|
|
# Unchanged behavior: 1152x864 -> latent [1, 4, 108, 144].
|
|
self.assertEqual(latent["samples"]["shape"], [1, 4, 108, 144])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|