Refactor CI configuration, update Python version, and remove unused test files; enhance NativeImageSizes and add TextEncodeQwenImageEdit nodes

This commit is contained in:
Hans Dembinski
2026-05-10 15:48:25 +02:00
parent 13ceb336b2
commit e7ad76c92e
10 changed files with 163 additions and 166 deletions
+2 -4
View File
@@ -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
+1 -2
View File
@@ -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
-4
View File
@@ -1,8 +1,4 @@
{
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": [
"tests"
],
"python.analysis.typeCheckingMode": "basic",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
+4 -12
View File
@@ -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
+26 -2
View File
@@ -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"]
+23 -19
View File
@@ -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)
-71
View File
@@ -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
+107
View File
@@ -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)
-5
View File
@@ -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"
-47
View File
@@ -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"