| name | comfyui-nodes |
| description | Use this skill when creating, modifying, or debugging ComfyUI custom nodes. Triggers on: 'ComfyUI custom node', 'ComfyUI node development', 'ComfyUI plugin', 'INPUT_TYPES', 'NODE_CLASS_MAPPINGS', 'RETURN_TYPES', 'define_schema', 'io.ComfyNode', or any request to build a new node for ComfyUI. Covers both V1 (current) and V3 (new) node APIs. |
| license | MIT |
| metadata | {"author":"marduk191","version":"2.1.0"} |
ComfyUI Custom Node Development Skill
Comprehensive guide to creating custom nodes for ComfyUI. Covers both the current V1 API and the new V3 schema API.
Quick Reference
Official Resources:
File Structure
Custom nodes go in ComfyUI/custom_nodes/:
ComfyUI/
└── custom_nodes/
└── my_custom_nodes/
├── __init__.py # REQUIRED: Node registration
├── nodes.py # Node class definitions
├── requirements.txt # Optional: pip dependencies
└── web/ # Optional: Frontend JS extensions
└── js/
└── my_extension.js
Basic Node Template
ALL FOUR class attributes (CATEGORY, RETURN_TYPES, FUNCTION, INPUT_TYPES) are REQUIRED. Missing any one will cause silent failure.
class MyCustomNode:
"""Description of what this node does."""
CATEGORY = "my_nodes/utilities"
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("output_image",)
FUNCTION = "process"
OUTPUT_NODE = False
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"strength": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 2.0,
"step": 0.1,
"display": "slider",
"tooltip": "Effect strength"
}),
},
"optional": {
"mask": ("MASK",),
},
"hidden": {
"node_id": "UNIQUE_ID",
}
}
def process(self, image, strength, mask=None, node_id=None):
result = image * strength
return (result,)
Node Registration (init.py)
Keep __init__.py minimal. ComfyUI auto-discovers nodes via NODE_CLASS_MAPPINGS. No registration API calls needed.
from .nodes import MyCustomNode, AnotherNode
NODE_CLASS_MAPPINGS = {
"MyProject_CustomNode": MyCustomNode,
"MyProject_AnotherNode": AnotherNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MyProject_CustomNode": "My Custom Node",
"MyProject_AnotherNode": "Another Node",
}
WEB_DIRECTORY = "./web/js"
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
NEVER do any of these in __init__.py:
from nodes import ... (bare import — conflicts with ComfyUI's nodes module)
sys.path.insert(0, ...) (path manipulation — causes import conflicts)
register_custom_node() or register_node() (these APIs don't exist)
sys.modules['NODE_CLASS_MAPPINGS'] = ... (nonsensical)
- Runtime
CATEGORY patching with hasattr checks
INPUT_TYPES Reference
Input Categories
@classmethod
def INPUT_TYPES(cls):
return {
"required": { ... },
"optional": { ... },
"hidden": { ... }
}
Primitive Types
INT
"count": ("INT", {
"default": 1,
"min": 0,
"max": 100,
"step": 1,
"display": "number",
"tooltip": "Number of iterations"
})
FLOAT
"strength": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 10.0,
"step": 0.1,
"round": 0.01,
"display": "slider",
"tooltip": "Effect strength"
})
STRING
"prompt": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "Enter text...",
"dynamicPrompts": True,
"tooltip": "Text input"
})
BOOLEAN
"enabled": ("BOOLEAN", {
"default": True,
"label_on": "Enabled",
"label_off": "Disabled",
"tooltip": "Toggle feature"
})
COMBO (Dropdown)
"mode": (["option1", "option2", "option3"], {"default": "option1"})
COLOR
"color": ("INT", {
"default": 0xFF0000,
"min": 0,
"max": 0xFFFFFF,
"display": "color"
})
Common Input Options
| Option | Type | Description |
|---|
default | any | Initial value |
min / max | number | Value bounds |
step | number | Increment step |
round | number | Decimal precision |
display | str | "number", "slider", "color" |
tooltip | str | Hover help text |
forceInput | bool | Force socket (no widget) |
lazy | bool | Enable lazy evaluation |
multiline | bool | Multi-line text (STRING) |
Hidden Inputs
"hidden": {
"node_id": "UNIQUE_ID",
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
}
Data Types
Tensor Types
| Type | Shape | Description |
|---|
IMAGE | [B,H,W,C] | Image batch (C=3 RGB, values 0-1) |
MASK | [B,H,W] | Grayscale mask (values 0-1) |
LATENT | dict {"samples": [B,C,H,W]} | Latent space (channel-first) |
AUDIO | dict {"waveform": [B,C,T]} | Audio data |
Model Types
| Type | Description |
|---|
MODEL | Diffusion model (UNet) |
CLIP | Text encoder |
VAE | Variational autoencoder |
CONDITIONING | Text/image conditioning |
CONTROL_NET | ControlNet model |
CLIP_VISION | Vision encoder |
Sampling Types
| Type | Description |
|---|
SAMPLER | Sampling algorithm |
SIGMAS | Noise schedule |
NOISE | Noise generator |
GUIDER | Guidance strategy |
Custom Types
RETURN_TYPES = ("MY_CUSTOM_DATA",)
"my_input": ("MY_CUSTOM_DATA", {"forceInput": True})
"any_input": ("*",)
Working with Tensors
Image Format
import torch
def process(self, image):
batch, height, width, channels = image.shape
single = image[0]
result = single.unsqueeze(0)
return (result,)
Mask Format
def process(self, mask):
if mask.dim() == 2:
mask = mask.unsqueeze(0)
inverted = 1.0 - mask
return (inverted,)
Latent Format
def process(self, latent):
samples = latent["samples"]
processed = samples * 0.5
return ({"samples": processed},)
Format Conversion
chw = image.permute(0, 3, 1, 2)
hwc = tensor.permute(0, 2, 3, 1)
Advanced Features
IS_CHANGED (Cache Control)
@classmethod
def IS_CHANGED(cls, image, seed):
return float("NaN")
return hash(seed)
WARNING: Do NOT return bool. Returning True means "unchanged"!
VALIDATE_INPUTS
@classmethod
def VALIDATE_INPUTS(cls, image, strength):
if strength < 0:
return "Strength must be non-negative"
return True
Lazy Evaluation
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image1": ("IMAGE", {"lazy": True}),
"image2": ("IMAGE", {"lazy": True}),
"blend": ("FLOAT", {"default": 0.5}),
}
}
def check_lazy_status(self, image1, image2, blend):
needed = []
if blend > 0 and image1 is None:
needed.append("image1")
if blend < 1 and image2 is None:
needed.append("image2")
return needed
def process(self, image1, image2, blend):
return (image1 * blend + image2 * (1 - blend),)
List Processing
class BatchProcessor:
INPUT_IS_LIST = True
OUTPUT_IS_LIST = (True,)
RETURN_TYPES = ("IMAGE",)
FUNCTION = "process"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"images": ("IMAGE",)}}
def process(self, images):
results = [process_single(img) for img in images]
return (results,)
Progress Updates
from server import PromptServer
def process(self, count, node_id):
for i in range(count):
PromptServer.instance.send_sync(
"progress",
{"node": node_id, "value": i, "max": count}
)
do_work(i)
return (count,)
Return UI Data
def process(self, image):
result = process_image(image)
return {
"ui": {"message": ["Processing complete!"]},
"result": (result,)
}
JavaScript Extensions
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "my.custom.extension",
async setup() {
console.log("Extension loaded");
app.api.addEventListener("my.custom.message", (event) => {
console.log("Received:", event.detail);
});
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "MyProject_CustomNode") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
if (onNodeCreated) onNodeCreated.apply(this, arguments);
};
}
}
});
Complete Example: Image Filter Node
import torch
class ImageBrightnessContrast:
"""Adjust image brightness and contrast."""
CATEGORY = "image/adjustments"
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "adjust"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"brightness": ("FLOAT", {
"default": 0.0,
"min": -1.0,
"max": 1.0,
"step": 0.05,
"display": "slider",
"tooltip": "Brightness adjustment (-1 to 1)"
}),
"contrast": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 3.0,
"step": 0.1,
"display": "slider",
"tooltip": "Contrast multiplier"
}),
}
}
def adjust(self, image, brightness, contrast):
result = (image - 0.5) * contrast + 0.5
result = result + brightness
result = torch.clamp(result, 0.0, 1.0)
return (result,)
class ImageBlend:
"""Blend two images together."""
CATEGORY = "image/composite"
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("blended",)
FUNCTION = "blend"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image1": ("IMAGE",),
"image2": ("IMAGE",),
"blend_mode": (["normal", "multiply", "screen", "overlay"],),
"opacity": ("FLOAT", {
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"display": "slider"
}),
},
"optional": {
"mask": ("MASK",),
}
}
def blend(self, image1, image2, blend_mode, opacity, mask=None):
if blend_mode == "normal":
blended = image2
elif blend_mode == "multiply":
blended = image1 * image2
elif blend_mode == "screen":
blended = 1 - (1 - image1) * (1 - image2)
elif blend_mode == "overlay":
blended = torch.where(
image1 < 0.5,
2 * image1 * image2,
1 - 2 * (1 - image1) * (1 - image2)
)
result = image1 * (1 - opacity) + blended * opacity
if mask is not None:
if mask.dim() == 2:
mask = mask.unsqueeze(0)
mask = mask.unsqueeze(-1)
result = image1 * (1 - mask) + result * mask
return (result,)
from .nodes import ImageBrightnessContrast, ImageBlend
NODE_CLASS_MAPPINGS = {
"MyNodes_BrightnessContrast": ImageBrightnessContrast,
"MyNodes_Blend": ImageBlend,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MyNodes_BrightnessContrast": "Brightness/Contrast",
"MyNodes_Blend": "Image Blend",
}
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
Best Practices
- Use unique prefixes in NODE_CLASS_MAPPINGS to avoid conflicts
- Add tooltips to all inputs for better UX
- Validate inputs with VALIDATE_INPUTS for clear error messages
- Handle batch dimensions - always expect
[B,H,W,C] for images
- Clamp outputs to valid ranges (0-1 for images)
- Use lazy evaluation for expensive optional inputs
- Document dependencies in requirements.txt
- Test with various batch sizes and edge cases
⚠️ CRITICAL: Common Mistakes That Break Nodes
These mistakes will cause nodes to silently fail to register, not appear in workflows, or crash at runtime. Every single one has been encountered in real node development.
Mistake 1: Using OUTPUT_TYPES Instead of RETURN_TYPES
WRONG — Node will not register:
class MyNode:
@classmethod
def OUTPUT_TYPES(cls):
return {"result": ("IMAGE",)}
CORRECT:
class MyNode:
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("result",)
Key points:
- It's
RETURN_TYPES, never OUTPUT_TYPES
- It's a class attribute (tuple), NOT a classmethod
- For multiple outputs:
RETURN_TYPES = ("IMAGE", "IMAGE") with RETURN_NAMES = ("left", "right")
Mistake 2: Missing FUNCTION Attribute
WRONG — ComfyUI doesn't know which method to call:
class MyNode:
RETURN_TYPES = ("IMAGE",)
def execute(self, image):
return (image,)
CORRECT:
class MyNode:
RETURN_TYPES = ("IMAGE",)
FUNCTION = "process"
def process(self, image):
return (image,)
Mistake 3: Missing CATEGORY Attribute
WRONG — Node won't appear in the menu:
class MyNode:
RETURN_TYPES = ("IMAGE",)
FUNCTION = "process"
CORRECT:
class MyNode:
CATEGORY = "image/restoration"
RETURN_TYPES = ("IMAGE",)
FUNCTION = "process"
Mistake 4: Using execute(**kwargs) Instead of Named Parameters
WRONG — Parameters won't be received correctly:
class MyNode:
FUNCTION = "execute"
def execute(self, **kwargs):
image = kwargs.get("image")
strength = kwargs.get("strength", 1.0)
return (image * strength,)
CORRECT:
class MyNode:
FUNCTION = "process"
def process(self, image, strength=1.0):
return (image * strength,)
Key points:
- Method name must match the
FUNCTION attribute value
- Parameter names must match the keys in
INPUT_TYPES
- Optional inputs should have default values (e.g.,
mask=None)
- Don't use
**kwargs — ComfyUI passes arguments by name
Mistake 5: Returning Dicts Instead of Tuples
WRONG — ComfyUI expects tuples:
def process(self, image):
result = do_something(image)
return {"result": result}
CORRECT:
def process(self, image):
result = do_something(image)
return (result,)
For multiple outputs:
RETURN_TYPES = ("IMAGE", "IMAGE")
RETURN_NAMES = ("left", "right")
def process(self, image):
return (left_result, right_result)
Mistake 6: Bad __init__.py — Bare Import from nodes
WRONG — Conflicts with ComfyUI's own nodes module:
import sys, os
NODES_DIR = os.path.dirname(os.path.abspath(__file__))
if NODES_DIR not in sys.path:
sys.path.insert(0, NODES_DIR)
from nodes import NODE_CLASS_MAPPINGS
CORRECT:
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
Key points:
- ALWAYS use relative imports (
.nodes, not nodes)
- NEVER manipulate
sys.path — it will cause import conflicts
- NEVER use bare
from nodes import — nodes is a ComfyUI core module
Mistake 7: Calling Non-Existent Registration APIs
WRONG — These functions do not exist in ComfyUI:
from comfy.utils import register_custom_node
register_custom_node(name, cls)
from nodes import register_node
register_node(cls)
sys.modules['NODE_CLASS_MAPPINGS'] = mappings
CORRECT — ComfyUI auto-discovers nodes via NODE_CLASS_MAPPINGS:
NODE_CLASS_MAPPINGS = {
"MyProject_NodeName": MyNodeClass,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MyProject_NodeName": "My Node Name",
}
ComfyUI reads NODE_CLASS_MAPPINGS from your package's __init__.py automatically. No registration function calls are needed.
Mistake 8: Missing NODE_DISPLAY_NAME_MAPPINGS
WRONG — Nodes show ugly internal names in UI:
NODE_CLASS_MAPPINGS = {
"NAFNetDenoiseNode": NAFNetDenoiseNode,
}
CORRECT:
NODE_CLASS_MAPPINGS = {
"NAFNet_Denoise": NAFNetDenoiseNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NAFNet_Denoise": "NAFNet Denoise",
}
V1 Node Required Attributes Checklist
Every V1 node class MUST have ALL of these:
class MyNode:
CATEGORY = "my_category"
RETURN_TYPES = ("IMAGE",)
FUNCTION = "my_method"
@classmethod
def INPUT_TYPES(cls):
return {"required": { ... }}
def my_method(self, param1, param2):
return (result,)
Missing ANY of these will cause silent failures.
Minimal Valid __init__.py
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
Never add:
sys.path manipulation
register_custom_node() or register_node() calls
sys.modules manipulation
- Manual
CATEGORY patching via hasattr checks
WEB_DIRS manual registration
- Bare
from nodes import (always use from .nodes import)
Debugging
- Check ComfyUI console for Python errors
- Use
print() statements (output goes to terminal)
- Browser DevTools (F12) for JS extension errors
- Verify tensor shapes match expected formats
- Test with minimal workflows first
V3 Node API (New)
The V3 API is the future of ComfyUI node development. It uses a centralized schema approach instead of scattered class attributes. V3 nodes are stateless classmethods, making them compatible with isolated/distributed environments.
V3 Basic Template
from comfy.nodes.common import io
class MyV3Node(io.ComfyNode):
"""Description of what this node does."""
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="MyProject_V3Node",
display_name="My V3 Node",
category="my_nodes/utilities",
description="A node that processes images",
inputs=[
io.Image.Input("image"),
io.Float.Input("strength",
default=1.0, min=0.0, max=2.0, step=0.1,
tooltip="Effect strength"
),
io.Mask.Input("mask", optional=True),
],
outputs=[
io.Image.Output("image"),
],
is_output_node=False,
)
@classmethod
def execute(cls, image, strength, mask=None) -> io.NodeOutput:
result = image * strength
if mask is not None:
if mask.dim() == 2:
mask = mask.unsqueeze(0)
mask = mask.unsqueeze(-1)
result = image * (1 - mask) + result * mask
return io.NodeOutput(result)
V3 Extension Registration
from comfy.nodes.common import io
class MyExtension(io.ComfyExtension):
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [MyV3Node, AnotherV3Node]
async def comfy_entrypoint() -> MyExtension:
return MyExtension()
V3 Input/Output Types
| V1 Format | V3 Equivalent |
|---|
("INT", {...}) | io.Int.Input("name", default=0, min=0, max=100) |
("FLOAT", {...}) | io.Float.Input("name", default=1.0, min=0.0, max=10.0) |
("STRING", {...}) | io.String.Input("name", multiline=True) |
("BOOLEAN", {...}) | io.Boolean.Input("name", default=True) |
("IMAGE",) | io.Image.Input("name") |
("MASK",) | io.Mask.Input("name") |
("LATENT",) | io.Latent.Input("name") |
("MODEL",) | io.Model.Input("name") |
("CLIP",) | io.Clip.Input("name") |
("VAE",) | io.Vae.Input("name") |
("CONDITIONING",) | io.Conditioning.Input("name") |
(["opt1", "opt2"],) | io.Combo.Input("name", options=["opt1", "opt2"]) |
| Custom type | io.Custom("MY_TYPE").Input("name") |
V3 Special Methods
| V1 Method | V3 Method | Purpose |
|---|
INPUT_TYPES() | define_schema() | Node configuration |
VALIDATE_INPUTS() | validate_inputs() | Input validation |
IS_CHANGED() | fingerprint_inputs() | Cache control |
check_lazy_status() | check_lazy_status() | Lazy evaluation (now classmethod) |
FUNCTION = "process" | execute() | Always named execute |
V3 UI Output
@classmethod
def execute(cls, image) -> io.NodeOutput:
result = process_image(image)
return io.NodeOutput(result, ui=io.ui.PreviewImage(result, cls=cls))
V3 Custom API Routes
from aiohttp import web
from server import PromptServer
class MyExtension(io.ComfyExtension):
async def get_node_list(self):
return [MyNode]
async def init_custom_routes(self):
@PromptServer.instance.routes.get("/my-extension/status")
async def get_status(request):
return web.json_response({"status": "ok"})
Error Handling Patterns
Graceful Input Validation
@classmethod
def VALIDATE_INPUTS(cls, image, strength):
if strength < 0:
return "Strength must be non-negative"
if strength > 10:
return "Strength too high (max 10)"
return True
Try/Catch in Processing
def process(self, image, **kwargs):
try:
result = complex_operation(image)
return (result,)
except RuntimeError as e:
if "out of memory" in str(e):
small = torch.nn.functional.interpolate(
image.permute(0, 3, 1, 2),
scale_factor=0.5,
mode='bilinear'
).permute(0, 2, 3, 1)
result = complex_operation(small)
return (result,)
raise
Device & Memory Management
import torch
import comfy.model_management as mm
def process(self, image):
device = mm.get_torch_device()
offload_device = mm.unet_offload_device()
image = image.to(device)
result = heavy_computation(image)
result = result.to(offload_device)
return (result,)
Publishing Custom Nodes
ComfyUI Manager Registration
Create a pyproject.toml in your node pack root:
[project]
name = "comfyui-my-nodes"
description = "Description of your node pack"
version = "1.0.0"
license = "MIT"
requires-python = ">=3.9"
dependencies = ["torch", "numpy"]
[project.urls]
Repository = "https://github.com/user/comfyui-my-nodes"
[tool.comfy]
PublisherId = "your-publisher-id"
DisplayName = "My Custom Nodes"
Icon = "https://example.com/icon.png"
Submit to ComfyUI Registry
pip install comfy-cli
comfy node login
comfy node publish
Resources