Files
EwaldandClaude Sonnet 5 019a5c9172 Load through comfy_entrypoint and adopt current ComfyUI APIs
Defining NODE_CLASS_MAPPINGS made ComfyUI skip comfy_entrypoint, so
/imageops/viewmedia and node replacements were never registered. Load
as a V3 extension only, move server.py to routes.py so it stops
shadowing ComfyUI's server module, and transcode animated previews
with PyAV instead of an external ffmpeg.

- Require ComfyUI 0.19.0 (has_intermediate_output); CI floor updated.
- Process 11 nodes on ComfyUI's torch device when the frames fit in
  free memory (IMAGEOPS_COMPUTE_DEVICE=cpu disables it).
- Add core CURVE input to Color Correct, BOUNDING_BOX output/input to
  Crop and Crop Stitch, LAYERS output to Comp (ComfyUI 0.31+),
  essentials_category, and Merge blend modes with golden parity.
- Report progress through the public set_progress API and drop the
  hidden unique_id input.
- Add ImageOps settings and a refresh-previews command.
- Fix ImageOpsComp rotation and preview saving (undefined
  to_display_range) and masked Transform edge colors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 14:53:14 +02:00

203 lines
7.0 KiB
Python

"""Generic contract tests for all ImageOps nodes.
Auto-discovers all registered node classes and validates:
- bypass returns source unchanged (pixel identity)
- handles RGB input
- handles RGBA input
- single-frame and multi-frame batch
"""
from __future__ import annotations
import math
import sys
import importlib
from pathlib import Path
import pytest
import torch
import os
# Ensure the majoor-imageops package is importable
_ROOT = Path(__file__).resolve().parent.parent.parent
COMFY_ROOT = Path(os.environ.get("COMFYUI_ROOT", _ROOT.parent.parent))
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
def _load_node_classes():
"""Load all node classes from the nodes/ package."""
nodes_dir = _ROOT / "nodes"
classes = {}
for py_file in sorted(nodes_dir.glob("*.py")):
if py_file.name.startswith("_"):
continue
mod_name = f"nodes.{py_file.stem}"
try:
mod = importlib.import_module(mod_name)
except Exception:
continue
for attr_name in dir(mod):
obj = getattr(mod, attr_name)
if (isinstance(obj, type)
and hasattr(obj, "define_schema")
and hasattr(obj, "execute")
and attr_name.startswith("ImageOps")):
classes[attr_name] = obj
return classes
NODE_CLASSES = _load_node_classes()
def _node_has_bypass(cls) -> bool:
try:
schema = cls.define_schema()
for inp in schema.inputs:
if inp.name == "bypass":
return True
return False
except Exception:
return False
def _node_has_image_input(cls) -> bool:
try:
schema = cls.define_schema()
for inp in schema.inputs:
if inp.name in ("image", "A"):
return True
return False
except Exception:
return False
def _get_required_inputs(cls):
try:
schema = cls.define_schema()
# V3 widget inputs
req = {}
for inp in schema.inputs:
req[inp.name] = inp
return req
except Exception:
return {}
class TestNodeDiscovery:
"""Basic sanity checks on node registration."""
def test_nodes_found(self):
assert len(NODE_CLASSES) > 0, "No ImageOps node classes found"
def test_all_have_function(self):
for name, cls in NODE_CLASSES.items():
assert hasattr(cls, "execute"), f"{name} missing execute"
def test_all_have_define_schema(self):
for name, cls in NODE_CLASSES.items():
assert callable(getattr(cls, "define_schema", None)), f"{name} missing define_schema"
def test_all_have_category(self):
for name, cls in NODE_CLASSES.items():
schema = cls.define_schema()
assert hasattr(schema, "category"), f"{name} missing CATEGORY in schema"
# Collect nodes that have bypass + image input for parameterized bypass tests
_BYPASS_NODES = {
name: cls for name, cls in NODE_CLASSES.items()
if _node_has_bypass(cls) and _node_has_image_input(cls)
# Skip complex interactive nodes that require special state
and name not in ("ImageOpsDraw", "ImageOpsComp", "ImageOpsCrop")
}
class TestBypassContract:
"""Bypass must return the source image unchanged."""
@pytest.mark.parametrize("node_name", sorted(_BYPASS_NODES.keys()))
def test_bypass_returns_source_rgb(self, node_name):
cls = _BYPASS_NODES[node_name]
fn = getattr(cls, "execute")
src = torch.rand(1, 32, 32, 3, dtype=torch.float32)
kwargs = {"bypass": True, "image": src}
# Add required defaults for non-image inputs
required = _get_required_inputs(cls)
for input_name, spec in required.items():
if input_name in kwargs:
continue
if hasattr(spec, 'default'):
kwargs[input_name] = spec.default
elif hasattr(spec, 'options') and spec.options:
kwargs[input_name] = spec.options[0]
try:
result = fn(**kwargs)
except Exception:
pytest.skip(f"{node_name} requires additional inputs")
return
if isinstance(result, dict):
# build_node_preview_result returns dict with "ui" and "result"
out_image = result.get("result", (None,))[0]
elif isinstance(result, tuple):
out_image = result[0]
elif type(result).__name__ == "NodeOutput":
out_image = result[0]
else:
pytest.fail(f"{node_name} returned unexpected type: {type(result)}")
return
if out_image is None:
pytest.skip(f"{node_name} returned None image on bypass")
return
assert out_image.shape == src.shape, (
f"{node_name} bypass changed shape: {src.shape} -> {out_image.shape}"
)
assert torch.allclose(out_image, src, atol=1e-6), (
f"{node_name} bypass modified pixel values"
)
def test_color_correct_master_curve(imageops_extension):
import torch
node = next(n for n in imageops_extension.NODES if n.define_schema().node_id == "ImageOpsColorAjust")
image = torch.rand(1, 16, 16, 4)
inverted = node.execute(image=image, curve={"points": [[0.0, 1.0], [1.0, 0.0]], "interpolation": "linear"})
identity = node.execute(image=image, curve=[(0.0, 0.0), (1.0, 1.0)])
untouched = node.execute(image=image)
assert torch.allclose(inverted.args[0][..., :3], 1.0 - image[..., :3], atol=1e-4)
assert torch.equal(inverted.args[0][..., 3], image[..., 3])
assert torch.equal(identity.args[0], untouched.args[0])
assert "curve" in {i.id for i in node.define_schema().inputs}
def test_comp_exports_core_layers_document(imageops_extension):
from comfy_api.latest import io
if not hasattr(io, "Layers"):
pytest.skip("io.Layers needs ComfyUI 0.31+")
node = next(n for n in imageops_extension.NODES if n.define_schema().node_id == "ImageOpsComp")
base = torch.rand(1, 40, 60, 3)
top = torch.rand(1, 20, 20, 4)
layers_json = '{"version":1,"layers":[{"slot":"image_2","center_x":0.25,"center_y":0.5,"scale":2.0,"rotate_deg":30,"opacity":0.5,"mode":"add"}]}'
out = node.execute(image_1=base, image_2=top, mask_2=torch.ones(1, 20, 20), layers_json=layers_json)
document = out.args[2]
assert [o.id for o in node.define_schema().outputs] == ["image", "mask", "layers"]
assert document["version"] == 1 and document["canvas"] == (60, 40)
first, second = document["layers"]
assert (first["z_index"], second["z_index"]) == (0, 1)
assert first["blend_mode"] == "normal" and (first["x"], first["y"], first["w"], first["h"]) == (0, 0, 60, 40) or first["w"] == 60
assert second["blend_mode"] == "linear-dodge" and second["opacity"] == 0.5 and second["rotation"] == pytest.approx(math.radians(30.0))
assert (second["w"], second["h"]) == (40, 40)
assert (second["x"], second["y"]) == (round(0.25 * 60 - 20), round(0.5 * 40 - 20))
assert second["mask"].shape == (1, 20, 20) and second["image"].shape == (1, 20, 20, 4)