From e7ad76c92eb5d4236baf3e8230c87c0b4068e0ed Mon Sep 17 00:00:00 2001 From: Hans Dembinski Date: Sun, 10 May 2026 15:48:25 +0200 Subject: [PATCH] Refactor CI configuration, update Python version, and remove unused test files; enhance NativeImageSizes and add TextEncodeQwenImageEdit nodes --- .github/workflows/ci.yml | 6 +- .gitignore | 3 +- .vscode/settings.json | 4 - README.md | 16 +--- __init__.py | 28 ++++++- nodes/native_image_sizes.py | 42 ++++++----- nodes/registry.py | 71 ------------------ nodes/text_encode_qwen_image_edit.py | 107 +++++++++++++++++++++++++++ pyproject.toml | 5 -- tests/test_native_image_sizes.py | 47 ------------ 10 files changed, 163 insertions(+), 166 deletions(-) delete mode 100644 nodes/registry.py create mode 100644 nodes/text_encode_qwen_image_edit.py delete mode 100644 tests/test_native_image_sizes.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77e4e66..aadb791 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,12 @@ on: pull_request: jobs: - lint-and-test: + lint-and-type: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.13"] steps: - name: Checkout @@ -35,5 +35,3 @@ jobs: - name: Type check run: ty check nodes - - name: Test - run: pytest diff --git a/.gitignore b/.gitignore index 83972fa..355d1b9 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,7 @@ MANIFEST pip-log.txt pip-delete-this-directory.txt -# Unit test / coverage reports +# Coverage reports htmlcov/ .tox/ .nox/ @@ -48,7 +48,6 @@ coverage.xml *.cover *.py.cover .hypothesis/ -.pytest_cache/ cover/ # Translations diff --git a/.vscode/settings.json b/.vscode/settings.json index 4ed4ff4..7261446 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,4 @@ { - "python.testing.pytestEnabled": true, - "python.testing.pytestArgs": [ - "tests" - ], "python.analysis.typeCheckingMode": "basic", "editor.formatOnSave": true, "editor.codeActionsOnSave": { diff --git a/README.md b/README.md index c12886c..ffa4019 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ Starter infrastructure for developing custom ComfyUI nodes in this repository. - ComfyUI plugin entrypoint in `__init__.py` - Node modules under `nodes/` - Native image sizes node in `nodes/native_image_sizes.py` -- Local test setup in `tests/` - Python project metadata in `pyproject.toml` - Development dependency file in `requirements-dev.txt` - VS Code Python + Ruff defaults in `.vscode/` @@ -43,18 +42,15 @@ Starter infrastructure for developing custom ComfyUI nodes in this repository. ├── __init__.py ├── nodes/ │ └── native_image_sizes.py -├── tests/ -│ └── test_native_image_sizes.py ├── scripts/ │ └── link_to_comfyui.ps1 ├── pyproject.toml └── requirements-dev.txt ``` -## Running tests and lint +## Running lint ```powershell -pytest ruff check . ty check nodes ``` @@ -62,13 +58,9 @@ ty check nodes ## Add a new node 1. Create a new module in `nodes/`, for example `nodes/image_nodes.py`. -2. Define one or more node classes with ComfyUI fields such as: - - `INPUT_TYPES` - - `RETURN_TYPES` - - `FUNCTION` - - `CATEGORY` -3. Export the class from `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS`. -4. Import and merge those mappings in top-level `__init__.py`. +2. Define the node implementation in that module. +3. Register node classes in top-level `comfy_entrypoint()` by returning them from `PluginExtension.get_node_list()` in `__init__.py`. +4. Use `define_schema()` on `io.ComfyNode` classes to set `node_id`, category, inputs, and outputs. ## Notes diff --git a/__init__.py b/__init__.py index 06d74d1..a2ee089 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,27 @@ -from .nodes.native_image_sizes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +# pyright: reportMissingImports=false -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] +from typing import Any + +from .nodes.native_image_sizes import NativeImageSizes + + +async def comfy_entrypoint() -> Any: + from comfy_api.latest import ComfyExtension, io + + from .nodes.text_encode_qwen_image_edit import ( + TextEncodeQwenImageEditAlt, + TextEncodeQwenImageEditPlusAlt, + ) + + class PluginExtension(ComfyExtension): + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ + NativeImageSizes, + TextEncodeQwenImageEditAlt, + TextEncodeQwenImageEditPlusAlt, + ] + + return PluginExtension() + + +__all__ = ["comfy_entrypoint"] diff --git a/nodes/native_image_sizes.py b/nodes/native_image_sizes.py index 56e8168..6f86c20 100644 --- a/nodes/native_image_sizes.py +++ b/nodes/native_image_sizes.py @@ -1,4 +1,6 @@ -from .registry import build_node_mappings +# pyright: reportMissingImports=false + +from comfy_api.latest import io IMAGE_SIZE_PRESETS: dict[str, tuple[int, int]] = { @@ -14,28 +16,30 @@ IMAGE_SIZE_PRESETS: dict[str, tuple[int, int]] = { SCALE_PRESETS: tuple[str, str, str] = ("0.5", "1", "2") -class NativeImageSizesNode: - NODE_KEY: str = "NativeImageSizes" - DISPLAY_NAME: str = "NativeImageSizes" +class NativeImageSizes(io.ComfyNode): @classmethod - def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[object, ...]]]: - return { - "required": { - "preset": (list(IMAGE_SIZE_PRESETS.keys()),), - "scale": (list(SCALE_PRESETS), {"default": "1"}), - } - } + def define_schema(cls): + return io.Schema( + node_id=cls.__name__, + category="image/resolution", + inputs=[ + io.String.Input("preset", choices=list(IMAGE_SIZE_PRESETS.keys())), + io.String.Input("scale", choices=list(SCALE_PRESETS), default="1"), + ], + outputs=[ + io.Int.Output(display_name="width"), + io.Int.Output(display_name="height"), + ], + ) - RETURN_TYPES: tuple[str, str] = ("INT", "INT") - RETURN_NAMES: tuple[str, str] = ("width", "height") - FUNCTION: str = "select_size" - CATEGORY: str = "image/resolution" - - def select_size(self, preset: str, scale: str) -> tuple[int, int]: + @staticmethod + def select_size(preset: str, scale: str) -> tuple[int, int]: width, height = IMAGE_SIZE_PRESETS[preset] factor = float(scale) return (round(width * factor), round(height * factor)) - -NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS = build_node_mappings(globals()) + @classmethod + def execute(cls, preset: str, scale: str): + width, height = cls.select_size(preset, scale) + return io.NodeOutput(width, height) diff --git a/nodes/registry.py b/nodes/registry.py deleted file mode 100644 index 206cfa9..0000000 --- a/nodes/registry.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Utilities for dynamic ComfyUI node registration. - -This module inspects a node module's globals, finds classes that look like -ComfyUI nodes, and builds the two dictionaries ComfyUI expects: -NODE_CLASS_MAPPINGS and NODE_DISPLAY_NAME_MAPPINGS. -""" - -from collections.abc import Mapping -from typing import Any, TypeAlias - - -NodeClass: TypeAlias = type -ModuleGlobals: TypeAlias = Mapping[str, Any] - - -def _is_comfy_node_class(value: Any) -> bool: - """Return True when a value appears to be a valid ComfyUI node class.""" - if not isinstance(value, type): - return False - required_attributes = ("INPUT_TYPES", "RETURN_TYPES", "FUNCTION", "CATEGORY") - if not all(hasattr(value, attr) for attr in required_attributes): - return False - function_name = getattr(value, "FUNCTION", None) - return isinstance(function_name, str) and hasattr(value, function_name) - - -def _discover_node_classes(module_globals: ModuleGlobals) -> list[NodeClass]: - """Collect and stably sort ComfyUI-like classes from a module globals dict.""" - node_classes = [] - for value in module_globals.values(): - if _is_comfy_node_class(value): - node_classes.append(value) - node_classes.sort(key=lambda node_class: node_class.__name__) - return node_classes - - -def _node_key(node_class: NodeClass) -> str: - """Resolve the registry key for a node class. - - NODE_KEY is used when present; otherwise the class name is used. - """ - return getattr(node_class, "NODE_KEY", node_class.__name__) - - -def _display_name(node_class: NodeClass) -> str: - """Resolve the display name for a node class. - - DISPLAY_NAME is used when present; otherwise the resolved node key is used. - """ - return getattr(node_class, "DISPLAY_NAME", _node_key(node_class)) - - -def build_node_mappings(module_globals: ModuleGlobals) -> tuple[dict[str, NodeClass], dict[str, str]]: - """Build ComfyUI node mapping dictionaries from module globals. - - Args: - module_globals: The globals() dictionary from a node module. - - Returns: - A tuple of (NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS). - """ - node_classes = _discover_node_classes(module_globals) - class_mappings = { - _node_key(node_class): node_class - for node_class in node_classes - } - display_name_mappings = { - _node_key(node_class): _display_name(node_class) - for node_class in node_classes - } - return class_mappings, display_name_mappings diff --git a/nodes/text_encode_qwen_image_edit.py b/nodes/text_encode_qwen_image_edit.py new file mode 100644 index 0000000..5907dcf --- /dev/null +++ b/nodes/text_encode_qwen_image_edit.py @@ -0,0 +1,107 @@ +# pyright: reportMissingImports=false + +import node_helpers +import comfy.utils +import math +from comfy_api.latest import io + +class TextEncodeQwenImageEditAlt(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id=cls.__name__, + category="advanced/conditioning", + inputs=[ + io.Clip.Input("clip"), + io.String.Input("prompt", multiline=True, dynamic_prompts=True), + io.Vae.Input("vae", optional=True), + io.Image.Input("image", optional=True), + ], + outputs=[ + io.Conditioning.Output(), + ], + ) + + @classmethod + def execute(cls, clip, prompt, vae=None, image=None) -> io.NodeOutput: + ref_latent = None + if image is None: + images = [] + else: + samples = image.movedim(-1, 1) + total = int(1024 * 1024) + + scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2])) + width = round(samples.shape[3] * scale_by) + height = round(samples.shape[2] * scale_by) + + s = comfy.utils.common_upscale(samples, width, height, "area", "disabled") + image = s.movedim(1, -1) + images = [image[:, :, :, :3]] + if vae is not None: + ref_latent = vae.encode(image[:, :, :, :3]) + + tokens = clip.tokenize(prompt, images=images) + conditioning = clip.encode_from_tokens_scheduled(tokens) + if ref_latent is not None: + conditioning = node_helpers.conditioning_set_values(conditioning, {"reference_latents": [ref_latent]}, append=True) + return io.NodeOutput(conditioning) + + +class TextEncodeQwenImageEditPlusAlt(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id=cls.__name__, + category="advanced/conditioning", + inputs=[ + io.Clip.Input("clip"), + io.String.Input("prompt", multiline=True, dynamic_prompts=True), + io.Vae.Input("vae", optional=True), + io.Image.Input("image1", optional=True), + io.Image.Input("image2", optional=True), + io.Image.Input("image3", optional=True), + io.Image.Input("image4", optional=True), + io.Latent.Input("target_latent", optional=True), + ], + outputs=[ + io.Conditioning.Output(), + ], + ) + + @classmethod + def execute(cls, clip, prompt, vae=None, image1=None, image2=None, image3=None, image4=None, target_latent=None) -> io.NodeOutput: + ref_latents = [] + images = [image1, image2, image3, image4] + images_vl = [] + llama_template = "<|im_start|>system\nDescribe key details of the input image (including any objects, characters, poses, facial features, clothing, setting, textures and style), then explain how the user's text instruction should alter, modify or recreate the image. Generate a new image that meets the user's requirements, which can vary from a small change to a completely new image using inputs as a guide.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n" + image_prompt = "" + + for i, image in enumerate(images): + if image is not None: + samples = image.movedim(-1, 1) + total = int(384 * 384) + + scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2])) + width = round(samples.shape[3] * scale_by) + height = round(samples.shape[2] * scale_by) + + s = comfy.utils.common_upscale(samples, width, height, "lanczos", "center") + images_vl.append(s.movedim(1, -1)) + if vae is not None: + if target_latent is not None: + twidth = target_latent["samples"].shape[-1] * 8 + theight = target_latent["samples"].shape[-2] * 8 + s = comfy.utils.common_upscale(samples, twidth, theight, "lanczos", "center") + else: + s = samples + ref_latents.append(vae.encode(s.movedim(1, -1)[:, :, :, :3])) + + image_prompt += "Picture {}: <|vision_start|><|image_pad|><|vision_end|>".format(i + 1) + + tokens = clip.tokenize(image_prompt + prompt, images=images_vl, llama_template=llama_template) + conditioning = clip.encode_from_tokens_scheduled(tokens) + if len(ref_latents) > 0: + conditioning = node_helpers.conditioning_set_values(conditioning, {"reference_latents": ref_latents}, append=True) + return io.NodeOutput(conditioning) + diff --git a/pyproject.toml b/pyproject.toml index fa78155..719f652 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,15 +14,10 @@ dependencies = [] [project.optional-dependencies] dev = [ - "pytest>=8.3.0", "ruff>=0.6.0", "ty>=0.0.1a0", ] -[tool.pytest.ini_options] -pythonpath = ["."] -testpaths = ["tests"] - [tool.ruff] line-length = 100 target-version = "py310" diff --git a/tests/test_native_image_sizes.py b/tests/test_native_image_sizes.py deleted file mode 100644 index 1dfff7a..0000000 --- a/tests/test_native_image_sizes.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest - -from nodes.native_image_sizes import ( - IMAGE_SIZE_PRESETS, - NODE_CLASS_MAPPINGS, - NODE_DISPLAY_NAME_MAPPINGS, - NativeImageSizesNode, -) - - -@pytest.mark.parametrize( - "preset,expected", - IMAGE_SIZE_PRESETS.items(), -) -def test_native_image_sizes_node_returns_expected_dimensions(preset, expected): - node = NativeImageSizesNode() - result = node.select_size(preset, "1") - assert result == expected - - -def test_native_image_sizes_node_returns_int_dimensions(): - node = NativeImageSizesNode() - width, height = node.select_size("16:9", "1") - assert isinstance(width, int) - assert isinstance(height, int) - - -@pytest.mark.parametrize( - "scale,expected", - [ - ("0.5", (832, 464)), - ("1", (1664, 928)), - ("2", (3328, 1856)), - ], -) -def test_native_image_sizes_node_applies_scale(scale, expected): - node = NativeImageSizesNode() - assert node.select_size("16:9", scale) == expected - - -def test_node_class_mappings_are_discovered_dynamically(): - assert "NativeImageSizes" in NODE_CLASS_MAPPINGS - assert NODE_CLASS_MAPPINGS["NativeImageSizes"] is NativeImageSizesNode - - -def test_node_display_name_mappings_are_discovered_dynamically(): - assert NODE_DISPLAY_NAME_MAPPINGS["NativeImageSizes"] == "NativeImageSizes"