Refactor CI configuration, update Python version, and remove unused test files; enhance NativeImageSizes and add TextEncodeQwenImageEdit nodes
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Vendored
-4
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user