Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de07ba5679 | ||
|
|
592558d396 | ||
|
|
03f26f2b63 | ||
|
|
5da620cc7c | ||
|
|
d608a9fcf7 | ||
|
|
5260eecd60 | ||
|
|
ca3216f936 |
@@ -1,100 +0,0 @@
|
||||
|
||||
# ☀️ 如何创建自定义 ComfyUI 节点模块 (`nodes/`)
|
||||
|
||||
## 🧩 文件结构约定
|
||||
|
||||
每一个节点模块放在 `custom_nodes/comfyui_sun_nodes/nodes/` 目录下,每个 `.py` 文件建议只放一组相关节点。
|
||||
|
||||
## 📄 新建节点文件
|
||||
|
||||
例如,你想写一个名为 `mynode_node.py` 的节点:
|
||||
|
||||
```bash
|
||||
touch nodes/mynode_node.py
|
||||
```
|
||||
|
||||
## ✨ 在 `mynode_node.py` 中写入如下格式:
|
||||
|
||||
```python
|
||||
class MyNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"default": "Hello"})
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "SunX🌞"
|
||||
|
||||
def run(self, text):
|
||||
return (f"你输入的是: {text}",)
|
||||
|
||||
# 固定后缀写法(前缀随意)
|
||||
mynode_CLASS_MAPPINGS = {
|
||||
"MyNode": MyNode,
|
||||
}
|
||||
|
||||
mynode_DISPLAY_NAME_MAPPINGS = {
|
||||
"MyNode": "🌞 My Custom Node",
|
||||
}
|
||||
```
|
||||
|
||||
## 🧠 命名规范建议
|
||||
|
||||
| 内容 | 建议写法 |
|
||||
| ------------ | ------------------------------------------------------------ |
|
||||
| 类名 | 使用大驼峰,例如 `MyNode` |
|
||||
| 内部节点名 | 不含空格、驼峰或下划线 |
|
||||
| 显示节点名 | 可加 Emoji,中文描述更友好 |
|
||||
| MAPPINGS 变量名 | `xxx_CLASS_MAPPINGS` 和 `xxx_DISPLAY_NAME_MAPPINGS`,前缀随意,后缀固定 |
|
||||
|
||||
---
|
||||
|
||||
## 🧩 在 `__init__.py` 中自动导入所有节点
|
||||
|
||||
路径:`custom_nodes/comfyui_sun_nodes/__init__.py`
|
||||
|
||||
```python
|
||||
import os
|
||||
import importlib
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
nodes_path = os.path.join(os.path.dirname(__file__), "nodes")
|
||||
|
||||
for filename in os.listdir(nodes_path):
|
||||
if filename.endswith(".py") and filename != "__init__.py":
|
||||
module_name = f"{__name__}.nodes.{filename[:-3]}"
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
for attr in dir(module):
|
||||
if attr.endswith("_CLASS_MAPPINGS"):
|
||||
NODE_CLASS_MAPPINGS.update(getattr(module, attr))
|
||||
elif attr.endswith("_DISPLAY_NAME_MAPPINGS"):
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(getattr(module, attr))
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 示例效果
|
||||
|
||||
你创建的节点文件如下:
|
||||
|
||||
```bash
|
||||
custom_nodes/
|
||||
└── comfyui_sun_nodes/
|
||||
├── __init__.py
|
||||
└── nodes/
|
||||
├── mynode_node.py ✅
|
||||
└── another_node.py ✅
|
||||
```
|
||||
|
||||
ComfyUI 启动后会自动加载并注册这些节点,不需手动添加字典。
|
||||
|
||||
|
||||
+9
-22
@@ -1,26 +1,13 @@
|
||||
import os
|
||||
import importlib.util
|
||||
from .nodes.loop_images import BatchImageLoopOpenSun, BatchImageLoopCloseSun
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SunxAI_BatchImageLoopOpenChen": BatchImageLoopOpenSun,
|
||||
"SunxAI_BatchImageLoopCloseChen": BatchImageLoopCloseSun,
|
||||
}
|
||||
|
||||
nodes_dir = os.path.join(os.path.dirname(__file__), "nodes")
|
||||
|
||||
for filename in os.listdir(nodes_dir):
|
||||
if filename.endswith(".py") and filename != "__init__.py":
|
||||
filepath = os.path.join(nodes_dir, filename)
|
||||
module_name = filename[:-3] # 不要带你的包名
|
||||
|
||||
# 动态加载模块(防止污染 __name__)
|
||||
spec = importlib.util.spec_from_file_location(module_name, filepath)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# 查找 *_CLASS_MAPPINGS
|
||||
for attr in dir(module):
|
||||
if attr.endswith("_CLASS_MAPPINGS"):
|
||||
NODE_CLASS_MAPPINGS.update(getattr(module, attr))
|
||||
elif attr.endswith("_DISPLAY_NAME_MAPPINGS"):
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(getattr(module, attr))
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SunxAI_BatchImageLoopOpenChen": "Batch Image Loop Open SunxAI",
|
||||
"SunxAI_BatchImageLoopCloseChen": "Batch Image Loop Close SunxAI",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from .loop_images import BatchImageLoopOpenSun, BatchImageLoopCloseSun
|
||||
|
||||
__all__ = ["BatchImageLoopOpenSun", "BatchImageLoopCloseSun"]
|
||||
|
||||
+58
-45
@@ -1,7 +1,12 @@
|
||||
import os
|
||||
import uuid
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
from comfy_execution.graph_utils import GraphBuilder, is_link
|
||||
import folder_paths
|
||||
|
||||
from comfyui_sun_nodes.tools.tools import VariantSupport
|
||||
from ..tools.tools import VariantSupport
|
||||
import torch.nn.functional as F
|
||||
import torch
|
||||
from nodes import NODE_CLASS_MAPPINGS as ALL_NODE_CLASS_MAPPINGS
|
||||
@@ -17,17 +22,19 @@ class BatchImageLoopOpenSun:
|
||||
inputs = {
|
||||
"required": {
|
||||
"segmented_images": ("IMAGE", {"forceInput": True}),
|
||||
"output_dir": ("STRING", {"default": ""}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"iteration_count": ("INT", {"default": 0}),
|
||||
"previous_image": ("IMAGE",),
|
||||
"batch_id": ("STRING",),
|
||||
}
|
||||
}
|
||||
return inputs
|
||||
|
||||
RETURN_TYPES = ("FLOW_CONTROL", "IMAGE", "INT", "INT")
|
||||
RETURN_NAMES = ("FLOW_CONTROL", "current_image", "max_iterations", "iteration_count")
|
||||
RETURN_TYPES = ("FLOW_CONTROL", "IMAGE", "INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("FLOW_CONTROL", "current_image", "max_iterations", "iteration_count", "batch_path")
|
||||
FUNCTION = "while_loop_open"
|
||||
CATEGORY = "CyberEveLoop🐰·Chen定制"
|
||||
|
||||
@@ -48,12 +55,25 @@ class BatchImageLoopOpenSun:
|
||||
image = image.permute(0, 2, 3, 1)
|
||||
return image
|
||||
|
||||
def while_loop_open(self, segmented_images, unique_id=None, iteration_count=0, previous_image=None):
|
||||
def while_loop_open(self, segmented_images, output_dir="", unique_id=None, iteration_count=0, previous_image=None, batch_id=None):
|
||||
print(f"[chen] Loop iteration: {iteration_count}")
|
||||
|
||||
images = self.standardize_images(segmented_images)
|
||||
max_iterations = images.shape[0]
|
||||
|
||||
if batch_id is None:
|
||||
batch_id = uuid.uuid4().hex[:8]
|
||||
|
||||
base_output = folder_paths.get_output_directory()
|
||||
if output_dir:
|
||||
batch_path = os.path.join(base_output, output_dir, batch_id)
|
||||
else:
|
||||
batch_path = os.path.join(base_output, "loop_batch", batch_id)
|
||||
|
||||
if iteration_count == 0:
|
||||
os.makedirs(batch_path, exist_ok=True)
|
||||
print(f"[chen] Created batch directory: {batch_path}")
|
||||
|
||||
if iteration_count >= max_iterations:
|
||||
raise ValueError(f"[chen] Iteration {iteration_count} exceeds max {max_iterations}")
|
||||
|
||||
@@ -63,7 +83,7 @@ class BatchImageLoopOpenSun:
|
||||
images[idx:idx+1] = previous_image
|
||||
|
||||
current_image = images[iteration_count:iteration_count+1]
|
||||
return ("stub", current_image, max_iterations, iteration_count)
|
||||
return ("stub", current_image, max_iterations, iteration_count, batch_path)
|
||||
|
||||
|
||||
@VariantSupport()
|
||||
@@ -78,6 +98,8 @@ class BatchImageLoopCloseSun:
|
||||
"flow_control": ("FLOW_CONTROL", {"rawLink": True}),
|
||||
"current_image": ("IMAGE",),
|
||||
"max_iterations": ("INT", {"forceInput": True}),
|
||||
"iteration_count": ("INT", {"forceInput": True}),
|
||||
"batch_path": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
"pass_back": ("BOOLEAN", {"default": False}),
|
||||
@@ -85,13 +107,11 @@ class BatchImageLoopCloseSun:
|
||||
"hidden": {
|
||||
"dynprompt": "DYNPROMPT",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"result_images": ("IMAGE",),
|
||||
"iteration_count": ("INT", {"default": 0}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("result_images",)
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("result_images", "output_path")
|
||||
FUNCTION = "while_loop_close"
|
||||
CATEGORY = "CyberEveLoop🐰·Chen定制"
|
||||
|
||||
@@ -101,37 +121,44 @@ class BatchImageLoopCloseSun:
|
||||
assert len(image.shape) == 4, f"Image must be 4D [B,H,W,C], got {image.shape}"
|
||||
return image
|
||||
|
||||
def initialize_results(self, max_iterations, current_image):
|
||||
assert len(current_image.shape) == 4
|
||||
return torch.zeros(
|
||||
(max_iterations, *current_image.shape[1:]),
|
||||
dtype=current_image.dtype,
|
||||
device=current_image.device
|
||||
)
|
||||
def save_image(self, image, batch_path, index):
|
||||
filepath = os.path.join(batch_path, f"{index:05d}.png")
|
||||
if os.path.exists(filepath):
|
||||
print(f"[chen] Skip (exists): {filepath}")
|
||||
return
|
||||
img_np = (image.squeeze(0).cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
|
||||
img_pil = Image.fromarray(img_np)
|
||||
img_pil.save(filepath)
|
||||
print(f"[chen] Saved: {filepath}")
|
||||
|
||||
def while_loop_close(self, flow_control, current_image, max_iterations,
|
||||
pass_back=False, iteration_count=0,
|
||||
result_images=None, dynprompt=None, unique_id=None):
|
||||
def load_all_images(self, batch_path, max_iterations, device):
|
||||
results = []
|
||||
for i in range(max_iterations):
|
||||
filepath = os.path.join(batch_path, f"{i:05d}.png")
|
||||
img_pil = Image.open(filepath).convert("RGB")
|
||||
img_np = np.array(img_pil).astype(np.float32) / 255.0
|
||||
img_tensor = torch.from_numpy(img_np).unsqueeze(0)
|
||||
results.append(img_tensor)
|
||||
return torch.cat(results, dim=0).to(device)
|
||||
|
||||
def while_loop_close(self, flow_control, current_image, max_iterations, batch_path,
|
||||
iteration_count, pass_back=False,
|
||||
dynprompt=None, unique_id=None):
|
||||
print(f"[chen] Iteration {iteration_count} / {max_iterations}")
|
||||
|
||||
current_image = self.standardize_image(current_image)
|
||||
device = current_image.device
|
||||
|
||||
if iteration_count >= max_iterations:
|
||||
raise ValueError(f"[chen] Iteration {iteration_count} exceeds max {max_iterations}")
|
||||
|
||||
if result_images is None:
|
||||
result_images = self.initialize_results(max_iterations, current_image)
|
||||
else:
|
||||
assert result_images.shape[0] == max_iterations
|
||||
|
||||
result_images[iteration_count:iteration_count+1] = current_image
|
||||
self.save_image(current_image, batch_path, iteration_count)
|
||||
|
||||
if iteration_count == max_iterations - 1:
|
||||
print(f"[chen] Loop finished")
|
||||
return (result_images,)
|
||||
print(f"[chen] Loop finished, loading results from {batch_path}")
|
||||
result_images = self.load_all_images(batch_path, max_iterations, device)
|
||||
return (result_images, batch_path)
|
||||
|
||||
# 构建图用于下一轮迭代
|
||||
this_node = dynprompt.get_node(unique_id)
|
||||
open_node = flow_control[0]
|
||||
|
||||
upstream = {}
|
||||
@@ -176,15 +203,15 @@ class BatchImageLoopCloseSun:
|
||||
|
||||
my_clone = graph.lookup_node("Recurse")
|
||||
my_clone.set_input("iteration_count", iteration_count + 1)
|
||||
my_clone.set_input("result_images", result_images)
|
||||
|
||||
new_open = graph.lookup_node(open_node)
|
||||
new_open.set_input("iteration_count", iteration_count + 1)
|
||||
new_open.set_input("batch_id", os.path.basename(batch_path))
|
||||
if pass_back:
|
||||
new_open.set_input("previous_image", current_image)
|
||||
|
||||
return {
|
||||
"result": (my_clone.out(0),),
|
||||
"result": (my_clone.out(0), my_clone.out(1)),
|
||||
"expand": graph.finalize()
|
||||
}
|
||||
|
||||
@@ -223,17 +250,3 @@ class BatchImageLoopCloseSun:
|
||||
if child_id not in contained:
|
||||
contained[child_id] = True
|
||||
self.collect_contained(child_id, upstream, contained)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Loop_CLASS_MAPPINGS = {
|
||||
"SunxAI_BatchImageLoopOpenChen": BatchImageLoopOpenSun,
|
||||
"SunxAI_BatchImageLoopCloseChen": BatchImageLoopCloseSun,
|
||||
}
|
||||
|
||||
Loop_DISPLAY_NAME_MAPPINGS = {
|
||||
"SunxAI_BatchImageLoopOpenChen": "Batch Image Loop Open SunxAI",
|
||||
"SunxAI_BatchImageLoopCloseChen": "Batch Image Loop Close SunxAI",
|
||||
}
|
||||
|
||||
+9
-34
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "comfyui_sun_nodes"
|
||||
version = "0.0.7"
|
||||
version = "0.1.6"
|
||||
description = "Custom ComfyUI nodes by SunX.AI"
|
||||
authors = [
|
||||
{name = "SunX AI", email = "sunx.ai@hotmail.com"}
|
||||
@@ -12,20 +12,14 @@ authors = [
|
||||
readme = "README.md"
|
||||
license = {text = "MIT license"}
|
||||
classifiers = []
|
||||
dependencies = [
|
||||
|
||||
]
|
||||
|
||||
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"bump-my-version",
|
||||
"coverage", # testing
|
||||
"mypy", # linting
|
||||
"pre-commit", # runs linting on commit
|
||||
"pytest", # testing
|
||||
"ruff", # linting
|
||||
"mypy",
|
||||
"pre-commit",
|
||||
"ruff",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -33,50 +27,31 @@ bugs = "https://github.com/upseem/comfyui_sun_nodes/issues"
|
||||
homepage = "https://github.com/upseem/comfyui_sun_nodes"
|
||||
Repository = "https://github.com/upseem/comfyui_sun_nodes"
|
||||
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "sunxai"
|
||||
DisplayName = "ComfyUI Sun Nodes"
|
||||
Icon = "https://avatars.githubusercontent.com/u/124853686"
|
||||
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["*.*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "8.0"
|
||||
testpaths = [
|
||||
"tests",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
files = "."
|
||||
|
||||
# Use strict defaults
|
||||
strict = true
|
||||
warn_unreachable = true
|
||||
warn_no_return = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
# Don't require test functions to include types
|
||||
module = "tests.*"
|
||||
allow_untyped_defs = true
|
||||
disable_error_code = "attr-defined"
|
||||
|
||||
[tool.ruff]
|
||||
# extend-exclude = ["static", "ci/templates"]
|
||||
line-length = 140
|
||||
src = ["src", "tests"]
|
||||
src = ["nodes", "tools"]
|
||||
target-version = "py39"
|
||||
|
||||
# Add rules to ban exec/eval
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"S102", # exec-builtin
|
||||
"S307", # eval-used
|
||||
"S102",
|
||||
"S307",
|
||||
"W293",
|
||||
"F", # The "F" series in Ruff stands for "Pyflakes" rules, which catch various Python syntax errors and undefined names.
|
||||
# See all rules here: https://docs.astral.sh/ruff/rules/#pyflakes-f
|
||||
"F",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.flake8-quotes]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Unit test package for comfyui_sun_nodes."""
|
||||
@@ -1,6 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the project root directory to Python path
|
||||
# This allows the tests to import the project
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
@@ -1,4 +0,0 @@
|
||||
[pytest]
|
||||
testpaths = . # Run tests in the current directory
|
||||
python_files = test_*.py # Run tests in files that start with "test_"
|
||||
norecursedirs = .. # Don't run tests in the parent directory
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""Tests for `comfyui_sun_nodes` package."""
|
||||
|
||||
import pytest
|
||||
from src.comfyui_sun_nodes.nodes import Example
|
||||
|
||||
@pytest.fixture
|
||||
def example_node():
|
||||
"""Fixture to create an Example node instance."""
|
||||
return Example()
|
||||
|
||||
def test_example_node_initialization(example_node):
|
||||
"""Test that the node can be instantiated."""
|
||||
assert isinstance(example_node, Example)
|
||||
|
||||
def test_return_types():
|
||||
"""Test the node's metadata."""
|
||||
assert Example.RETURN_TYPES == ("IMAGE",)
|
||||
assert Example.FUNCTION == "test"
|
||||
assert Example.CATEGORY == "Example"
|
||||
Reference in New Issue
Block a user