fix: bump to v1.3.11, remove accidental files, relax protobuf pin
- Remove node.zip and skills/ accidentally committed in previous push - Add node.zip, skills/, *.png to .gitignore - Bump version to 1.3.11 for registry publish (1.3.10 already claimed) - Update stale protobuf error message in __init__.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -160,3 +160,9 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Local files
|
||||
node.zip
|
||||
skills/
|
||||
*.png
|
||||
!icon.png
|
||||
|
||||
@@ -164,6 +164,7 @@ When modifying or extending this node:
|
||||
|
||||
## Version History
|
||||
|
||||
- **v1.3.11** (2026-03-10): Relaxed protobuf pin from ==3.20.3 to >=3.20.3 for DA3/onnx compatibility, removed accidental files from repo
|
||||
- **v1.3.10** (2026-03-09): Removed phantom WEB_DIRECTORY (no js/ dir exists), cleaned up __all__ exports, version bump for registry
|
||||
- **v1.3.9** (2026-03-09): Version bump to republish after registry conflict (v1.3.8 was claimed by a reverted commit)
|
||||
- **v1.3.8** (2026-03-09): Added DA3 v1.1 models (Large-1.1, Giant-1.1, Nested-Giant-Large-1.1), fixed duplicate error message in DA3 loading
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("DepthEstimation")
|
||||
|
||||
# Version info
|
||||
__version__ = "1.3.10"
|
||||
__version__ = "1.3.11"
|
||||
|
||||
# Node class mappings - will be populated based on dependency checks
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
@@ -136,7 +136,7 @@ else:
|
||||
|
||||
def error_message(self):
|
||||
if "Descriptors cannot be created directly" in str(e):
|
||||
message = "Protobuf version conflict. Run: pip install protobuf==3.20.3"
|
||||
message = "Protobuf version conflict. Run: pip install protobuf>=3.20.3"
|
||||
else:
|
||||
message = f"Error loading depth estimation: {str(e)}"
|
||||
return (message,)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyuidepthestimation"
|
||||
description = "A robust custom depth estimation node for ComfyUI using Depth-Anything models (V1, V2, and V3/DA3). It integrates depth estimation with configurable post-processing options including blur, median filtering, contrast enhancement, and gamma correction."
|
||||
version = "1.3.10"
|
||||
version = "1.3.11"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"transformers>=4.20.0",
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# ComfyUI Node Development Skill
|
||||
|
||||
A comprehensive skill for developing ComfyUI custom nodes following production best practices.
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides guidance for creating ComfyUI custom nodes, from basic structure to advanced patterns like memory management, batch processing, and model integration.
|
||||
|
||||
## Installation
|
||||
|
||||
### Local (Repository Only)
|
||||
```bash
|
||||
cp -r ComfyUI-node-development-skill ~/.claude/skills/
|
||||
```
|
||||
|
||||
### Global Installation
|
||||
```bash
|
||||
ln -s "$(pwd)/ComfyUI-node-development-skill" ~/.claude/skills/comfyui-node-development
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
This skill automatically activates when you mention:
|
||||
- "create ComfyUI node"
|
||||
- "build custom node"
|
||||
- "ComfyUI development"
|
||||
- "node for ComfyUI"
|
||||
- "custom ComfyUI"
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Node Template
|
||||
|
||||
```python
|
||||
class MyCustomNode:
|
||||
"""Description of what this node does."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"input_name": ("INPUT_TYPE", {"default": value}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("OUTPUT_TYPE",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "custom/category"
|
||||
|
||||
def execute(self, input_name):
|
||||
# Your logic here
|
||||
return (output,)
|
||||
```
|
||||
|
||||
### Register Your Node
|
||||
|
||||
In `__init__.py`:
|
||||
|
||||
```python
|
||||
from .my_module import MyCustomNode
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"MyCustomNode": MyCustomNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"MyCustomNode": "My Custom Node",
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Node architecture and class structure
|
||||
- ✅ Input/output type system
|
||||
- ✅ Widget configurations
|
||||
- ✅ Model loading patterns
|
||||
- ✅ Memory management
|
||||
- ✅ Batch processing
|
||||
- ✅ Error handling
|
||||
- ✅ Testing patterns
|
||||
|
||||
## Examples
|
||||
|
||||
See the `examples/` directory for working node implementations:
|
||||
- `basic_image_processor.py` - Simple image manipulation
|
||||
- `model_loader.py` - Custom model loading
|
||||
- `batch_processor.py` - Efficient batch operations
|
||||
|
||||
## References
|
||||
|
||||
- [ComfyUI Documentation](https://docs.comfy.org/)
|
||||
- [Custom Nodes Guide](https://docs.comfy.org/essentials/custom_nodes/)
|
||||
- [ComfyUI GitHub](https://github.com/comfyanonymous/ComfyUI)
|
||||
@@ -1,462 +0,0 @@
|
||||
---
|
||||
name: comfyui-node-development
|
||||
description: "This skill should be used when developing ComfyUI custom nodes, creating new node types, implementing AI model integrations, or extending ComfyUI functionality with Python-based nodes."
|
||||
category: ai-ml
|
||||
risk: safe
|
||||
source: community
|
||||
tags: "[comfyui, ai, stable-diffusion, nodes, python, pytorch]"
|
||||
date_added: "2026-03-09"
|
||||
---
|
||||
|
||||
# ComfyUI Node Development
|
||||
|
||||
## Purpose
|
||||
|
||||
Build production-ready ComfyUI custom nodes following official best practices. This skill covers node architecture, input/output handling, execution patterns, model loading, and UI integration for ComfyUI's node-based AI workflow system.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
This skill should be used when:
|
||||
- Creating new custom nodes for ComfyUI
|
||||
- Implementing AI model inference pipelines
|
||||
- Adding custom preprocessing or postprocessing nodes
|
||||
- Developing nodes for Stable Diffusion workflows
|
||||
- Extending ComfyUI with third-party integrations
|
||||
- Debugging node execution issues
|
||||
- Optimizing node performance for GPU/CPU execution
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
1. **Node Architecture** - ComfyUI's node class structure and inheritance patterns
|
||||
2. **Input/Output Types** - ComfyUI type system (IMAGE, LATENT, CONDITIONING, MODEL, etc.)
|
||||
3. **Execution Model** - Understanding the graph execution and caching mechanism
|
||||
4. **Model Management** - Loading, caching, and managing AI models in nodes
|
||||
5. **UI Integration** - Custom widgets, dropdowns, and node visual customization
|
||||
6. **Error Handling** - Graceful error reporting and recovery in nodes
|
||||
7. **Performance Optimization** - Memory management, batching, and GPU optimization
|
||||
|
||||
## Node Development Fundamentals
|
||||
|
||||
### Basic Node Structure
|
||||
|
||||
Every ComfyUI node follows this structure:
|
||||
|
||||
```python
|
||||
class MyCustomNode:
|
||||
"""Node description that appears in the UI."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"input_name": ("INPUT_TYPE", {"default": value, "min": 0, "max": 100}),
|
||||
},
|
||||
"optional": {
|
||||
"optional_input": ("INPUT_TYPE", {"default": value}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("OUTPUT_TYPE",)
|
||||
RETURN_NAMES = ("output_name",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "custom/category"
|
||||
|
||||
def execute(self, input_name, optional_input=None, prompt=None, extra_pnginfo=None):
|
||||
# Node logic here
|
||||
return (output,)
|
||||
```
|
||||
|
||||
### Input Types Reference
|
||||
|
||||
| Type | Description | Example Widget |
|
||||
|------|-------------|----------------|
|
||||
| `IMAGE` | Torch tensor (B, H, W, C) | Image input/output |
|
||||
| `LATENT` | Latent representation | Latent input/output |
|
||||
| `MODEL` | Diffusion model object | Model loader output |
|
||||
| `CLIP` | CLIP model for conditioning | CLIP loader output |
|
||||
| `CONDITIONING` | Text conditioning data | CLIP text encode output |
|
||||
| `VAE` | VAE model for encode/decode | VAE loader output |
|
||||
| `MASK` | Single channel image | Mask input/output |
|
||||
| `INT` | Integer value | Number widget |
|
||||
| `FLOAT` | Float value | Float widget |
|
||||
| `STRING` | Text string | Text input |
|
||||
| `BOOLEAN` | True/False | Toggle widget |
|
||||
|
||||
### Widget Configurations
|
||||
|
||||
```python
|
||||
# Integer with constraints
|
||||
"steps": ("INT", {"default": 20, "min": 1, "max": 10000, "step": 1})
|
||||
|
||||
# Float with slider
|
||||
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01})
|
||||
|
||||
# String with multiline
|
||||
"prompt": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": True})
|
||||
|
||||
# Dropdown/Combo
|
||||
"mode": (["option1", "option2", "option3"], {"default": "option1"})
|
||||
|
||||
# Boolean toggle
|
||||
"enabled": ("BOOLEAN", {"default": True})
|
||||
```
|
||||
|
||||
## Implementation Patterns
|
||||
|
||||
### Image Processing Node
|
||||
|
||||
```python
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
class ImagePreprocessor:
|
||||
"""Preprocess images for model input."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"target_width": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 64}),
|
||||
"target_height": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 64}),
|
||||
"interpolation": (["nearest", "bilinear", "bicubic"], {"default": "bilinear"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("processed_image",)
|
||||
FUNCTION = "preprocess"
|
||||
CATEGORY = "image/preprocessing"
|
||||
|
||||
def preprocess(self, image, target_width, target_height, interpolation):
|
||||
# image is (B, H, W, C) tensor in range [0, 1]
|
||||
batch_size, height, width, channels = image.shape
|
||||
|
||||
# Convert to PIL for resizing
|
||||
images = []
|
||||
for i in range(batch_size):
|
||||
img_np = (image[i].cpu().numpy() * 255).astype(np.uint8)
|
||||
pil_img = Image.fromarray(img_np)
|
||||
|
||||
# Resize
|
||||
pil_img = pil_img.resize((target_width, target_height),
|
||||
getattr(Image, interpolation.upper()))
|
||||
|
||||
# Convert back to tensor
|
||||
img_np = np.array(pil_img).astype(np.float32) / 255.0
|
||||
images.append(torch.from_numpy(img_np))
|
||||
|
||||
result = torch.stack(images)
|
||||
return (result,)
|
||||
```
|
||||
|
||||
### Model Wrapper Node
|
||||
|
||||
```python
|
||||
import comfy.model_management as model_management
|
||||
import folder_paths
|
||||
|
||||
class CustomModelLoader:
|
||||
"""Load custom models with proper memory management."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_name": (folder_paths.get_filename_list("checkpoints"),),
|
||||
"device": (["auto", "cpu", "cuda"], {"default": "auto"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "VAE")
|
||||
RETURN_NAMES = ("model", "clip", "vae")
|
||||
FUNCTION = "load_model"
|
||||
CATEGORY = "loaders"
|
||||
|
||||
def load_model(self, model_name, device):
|
||||
# Get full path
|
||||
model_path = folder_paths.get_full_path("checkpoints", model_name)
|
||||
|
||||
# Load with ComfyUI's model management
|
||||
if device == "auto":
|
||||
device = model_management.get_torch_device()
|
||||
|
||||
# Load checkpoint (implementation depends on model type)
|
||||
# Use comfy.utils.load_checkpoint_guess_config or similar
|
||||
|
||||
return (model, clip, vae)
|
||||
```
|
||||
|
||||
### Conditional Execution Node
|
||||
|
||||
```python
|
||||
class ConditionalImageProcessor:
|
||||
"""Process images conditionally based on inputs."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"enabled": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "image/processing"
|
||||
|
||||
def process(self, image, enabled, strength=1.0):
|
||||
if not enabled:
|
||||
return (image,) # Pass through
|
||||
|
||||
# Apply processing
|
||||
processed = image * strength
|
||||
processed = torch.clamp(processed, 0, 1)
|
||||
|
||||
return (processed,)
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Node with Side Effects
|
||||
|
||||
```python
|
||||
import json
|
||||
import os
|
||||
|
||||
class SaveMetadata:
|
||||
"""Save workflow metadata alongside images."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True # Mark as output node
|
||||
FUNCTION = "save"
|
||||
CATEGORY = "output"
|
||||
|
||||
def save(self, images, filename_prefix, prompt=None, extra_pnginfo=None):
|
||||
# Save logic here
|
||||
full_output_folder = folder_paths.get_output_directory()
|
||||
|
||||
for batch_number, image in enumerate(images):
|
||||
# Save image
|
||||
# Save metadata JSON
|
||||
if extra_pnginfo is not None:
|
||||
metadata = {
|
||||
"prompt": prompt,
|
||||
"workflow": extra_pnginfo.get("workflow", {})
|
||||
}
|
||||
# Write metadata file
|
||||
|
||||
return {} # Empty return for output nodes
|
||||
```
|
||||
|
||||
### Batch Processing Node
|
||||
|
||||
```python
|
||||
class BatchImageProcessor:
|
||||
"""Process multiple images efficiently."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"operation": (["normalize", "invert", "grayscale"],),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "process_batch"
|
||||
CATEGORY = "image/batch"
|
||||
|
||||
def process_batch(self, images, operation):
|
||||
# Process entire batch at once (GPU accelerated)
|
||||
if operation == "normalize":
|
||||
mean = images.mean(dim=(1, 2, 3), keepdim=True)
|
||||
std = images.std(dim=(1, 2, 3), keepdim=True)
|
||||
result = (images - mean) / (std + 1e-8)
|
||||
|
||||
elif operation == "invert":
|
||||
result = 1.0 - images
|
||||
|
||||
elif operation == "grayscale":
|
||||
# RGB to grayscale
|
||||
weights = torch.tensor([0.299, 0.587, 0.114], device=images.device)
|
||||
gray = (images * weights.view(1, 1, 1, 3)).sum(dim=-1, keepdim=True)
|
||||
result = gray.expand(-1, -1, -1, 3)
|
||||
|
||||
return (torch.clamp(result, 0, 1),)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Memory Management
|
||||
|
||||
```python
|
||||
import comfy.model_management as mm
|
||||
|
||||
class MemoryEfficientNode:
|
||||
def process(self, model, latent):
|
||||
# Get the appropriate device
|
||||
device = mm.get_torch_device()
|
||||
|
||||
# Load to device only when needed
|
||||
model = model.to(device)
|
||||
latent = latent.to(device)
|
||||
|
||||
# Process
|
||||
with torch.no_grad():
|
||||
result = model(latent)
|
||||
|
||||
# Clean up if needed
|
||||
mm.soft_empty_cache()
|
||||
|
||||
return (result,)
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```python
|
||||
class RobustNode:
|
||||
def execute(self, image, factor):
|
||||
try:
|
||||
if image is None:
|
||||
raise ValueError("Input image is None")
|
||||
|
||||
if factor <= 0:
|
||||
raise ValueError(f"Factor must be positive, got {factor}")
|
||||
|
||||
result = self._process(image, factor)
|
||||
return (result,)
|
||||
|
||||
except Exception as e:
|
||||
# Log error for debugging
|
||||
print(f"[RobustNode] Error: {e}")
|
||||
# Return input unchanged or raise
|
||||
raise
|
||||
```
|
||||
|
||||
### Caching Considerations
|
||||
|
||||
ComfyUI caches node outputs based on inputs. For nodes with non-deterministic behavior:
|
||||
|
||||
```python
|
||||
class RandomGenerator:
|
||||
"""Generate random values with proper seed handling."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
|
||||
"width": ("INT", {"default": 512, "min": 64, "max": 2048}),
|
||||
"height": ("INT", {"default": 512, "min": 64, "max": 2048}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "generators"
|
||||
|
||||
def generate(self, seed, width, height):
|
||||
# Use seed for reproducibility
|
||||
torch.manual_seed(seed)
|
||||
|
||||
# Generate random noise
|
||||
noise = torch.randn(1, height, width, 3)
|
||||
noise = (noise - noise.min()) / (noise.max() - noise.min())
|
||||
|
||||
return (noise,)
|
||||
```
|
||||
|
||||
## Node Registration
|
||||
|
||||
Nodes must be registered in `__init__.py`:
|
||||
|
||||
```python
|
||||
from .my_node_module import MyCustomNode, AnotherNode
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"MyCustomNode": MyCustomNode,
|
||||
"AnotherNode": AnotherNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"MyCustomNode": "My Custom Node",
|
||||
"AnotherNode": "Another Node",
|
||||
}
|
||||
```
|
||||
|
||||
## Testing and Debugging
|
||||
|
||||
### Unit Testing Pattern
|
||||
|
||||
```python
|
||||
def test_node():
|
||||
node = MyCustomNode()
|
||||
|
||||
# Create test input
|
||||
test_image = torch.rand(1, 64, 64, 3)
|
||||
|
||||
# Execute
|
||||
result = node.execute(test_image, factor=1.5)
|
||||
|
||||
# Verify
|
||||
assert result[0].shape == test_image.shape
|
||||
assert result[0].min() >= 0 and result[0].max() <= 1
|
||||
```
|
||||
|
||||
### Debug Logging
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DebuggableNode:
|
||||
def execute(self, input_tensor):
|
||||
logger.debug(f"Input shape: {input_tensor.shape}")
|
||||
logger.debug(f"Input device: {input_tensor.device}")
|
||||
logger.debug(f"Input range: [{input_tensor.min():.4f}, {input_tensor.max():.4f}]")
|
||||
|
||||
result = self.process(input_tensor)
|
||||
|
||||
logger.debug(f"Output shape: {result.shape}")
|
||||
return (result,)
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Shape Mismatches**: Always verify tensor shapes match expected inputs/outputs
|
||||
2. **Device Placement**: Ensure tensors are on the correct device (CPU/CUDA)
|
||||
3. **Value Ranges**: Images should be [0, 1] range, not [0, 255]
|
||||
4. **Batch Dimension**: Handle batch dimension (B) properly - nodes may receive batched inputs
|
||||
5. **Memory Leaks**: Use `torch.no_grad()` for inference; clear caches when done
|
||||
6. **Type Consistency**: RETURN_TYPES must match actual return values exactly
|
||||
|
||||
## References
|
||||
|
||||
- **ComfyUI Repository**: https://github.com/comfyanonymous/ComfyUI
|
||||
- **ComfyUI Custom Nodes Guide**: https://docs.comfy.org/essentials/custom_nodes/
|
||||
- **ComfyUI Node Examples**: https://github.com/comfyanonymous/ComfyUI/tree/master/custom_nodes
|
||||
@@ -1,126 +0,0 @@
|
||||
"""
|
||||
Example: Basic Image Processor Node
|
||||
|
||||
A complete working example of a ComfyUI node that resizes images
|
||||
with various interpolation methods.
|
||||
"""
|
||||
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class ExampleImageResizer:
|
||||
"""
|
||||
Resize images to target dimensions with multiple interpolation options.
|
||||
|
||||
This demonstrates:
|
||||
- Basic node structure
|
||||
- IMAGE input/output types
|
||||
- Dropdown widget configuration
|
||||
- PIL-based image processing
|
||||
- Batch processing support
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE", {
|
||||
"tooltip": "Input image tensor (B, H, W, C)"
|
||||
}),
|
||||
"width": ("INT", {
|
||||
"default": 512,
|
||||
"min": 64,
|
||||
"max": 8192,
|
||||
"step": 64,
|
||||
"tooltip": "Target width in pixels"
|
||||
}),
|
||||
"height": ("INT", {
|
||||
"default": 512,
|
||||
"min": 64,
|
||||
"max": 8192,
|
||||
"step": 64,
|
||||
"tooltip": "Target height in pixels"
|
||||
}),
|
||||
"interpolation": (["nearest", "bilinear", "bicubic", "lanczos"], {
|
||||
"default": "bilinear",
|
||||
"tooltip": "Resampling method"
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"maintain_aspect": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"tooltip": "Keep original aspect ratio"
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("resized_image",)
|
||||
FUNCTION = "resize"
|
||||
CATEGORY = "example/image"
|
||||
|
||||
# Maps string names to PIL constants
|
||||
INTERPOLATION_MAP = {
|
||||
"nearest": Image.NEAREST,
|
||||
"bilinear": Image.BILINEAR,
|
||||
"bicubic": Image.BICUBIC,
|
||||
"lanczos": Image.LANCZOS,
|
||||
}
|
||||
|
||||
def resize(self, image, width, height, interpolation, maintain_aspect=False):
|
||||
"""
|
||||
Resize input image(s) to target dimensions.
|
||||
|
||||
Args:
|
||||
image: Tensor of shape (B, H, W, C) with values in [0, 1]
|
||||
width: Target width
|
||||
height: Target height
|
||||
interpolation: Resampling method name
|
||||
maintain_aspect: Whether to preserve aspect ratio
|
||||
|
||||
Returns:
|
||||
Tuple containing resized image tensor
|
||||
"""
|
||||
# Get interpolation method
|
||||
interp_method = self.INTERPOLATION_MAP.get(interpolation, Image.BILINEAR)
|
||||
|
||||
batch_size, orig_h, orig_w, channels = image.shape
|
||||
|
||||
# Calculate dimensions if maintaining aspect ratio
|
||||
if maintain_aspect:
|
||||
aspect = orig_w / orig_h
|
||||
if width / height > aspect:
|
||||
width = int(height * aspect)
|
||||
else:
|
||||
height = int(width / aspect)
|
||||
|
||||
# Process each image in batch
|
||||
resized_images = []
|
||||
for i in range(batch_size):
|
||||
# Convert tensor to PIL (0-255 range)
|
||||
img_np = (image[i].cpu().numpy() * 255).astype(np.uint8)
|
||||
pil_img = Image.fromarray(img_np)
|
||||
|
||||
# Resize
|
||||
pil_img = pil_img.resize((width, height), interp_method)
|
||||
|
||||
# Convert back to tensor (0-1 range)
|
||||
img_np = np.array(pil_img).astype(np.float32) / 255.0
|
||||
resized_images.append(torch.from_numpy(img_np))
|
||||
|
||||
# Stack back into batch
|
||||
result = torch.stack(resized_images)
|
||||
|
||||
return (result,)
|
||||
|
||||
|
||||
# Node registration (would go in __init__.py)
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ExampleImageResizer": ExampleImageResizer,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ExampleImageResizer": "Example: Image Resizer",
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
# ComfyUI Node Development - Comprehensive Guide
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture Overview](#architecture-overview)
|
||||
2. [Type System Deep Dive](#type-system-deep-dive)
|
||||
3. [Execution Model](#execution-model)
|
||||
4. [Model Management](#model-management)
|
||||
5. [UI Customization](#ui-customization)
|
||||
6. [Performance Optimization](#performance-optimization)
|
||||
7. [Advanced Patterns](#advanced-patterns)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### How ComfyUI Works
|
||||
|
||||
ComfyUI operates on a node-graph execution model:
|
||||
|
||||
1. **Graph Definition**: Users create workflows by connecting nodes
|
||||
2. **Topological Sort**: Nodes are ordered based on dependencies
|
||||
3. **Execution**: Each node executes when all inputs are ready
|
||||
4. **Caching**: Outputs are cached based on input hashes for efficiency
|
||||
|
||||
### Node Lifecycle
|
||||
|
||||
```
|
||||
Class Definition → Registration → Instantiation → Execution → Cleanup
|
||||
```
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
my_custom_nodes/
|
||||
├── __init__.py # Node registration
|
||||
├── nodes.py # Node implementations
|
||||
├── utils.py # Helper functions
|
||||
├── models/ # Model definitions
|
||||
└── web/ # JavaScript extensions (optional)
|
||||
└── my_extension.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type System Deep Dive
|
||||
|
||||
### Core Types
|
||||
|
||||
#### IMAGE Type
|
||||
|
||||
```python
|
||||
# IMAGE is a torch.Tensor with shape (B, H, W, C)
|
||||
# Values are in range [0, 1] (float32)
|
||||
# Channel order is RGB
|
||||
|
||||
# Converting to/from PIL
|
||||
pil_image = Image.fromarray((tensor[0].numpy() * 255).astype(np.uint8))
|
||||
tensor = torch.from_numpy(np.array(pil_image).astype(np.float32) / 255.0)
|
||||
```
|
||||
|
||||
#### LATENT Type
|
||||
|
||||
```python
|
||||
# LATENT is a dictionary containing:
|
||||
# - "samples": torch.Tensor of shape (B, C, H, W)
|
||||
# - "batch_index": Optional list for partial batch processing
|
||||
|
||||
latent = {
|
||||
"samples": torch.randn(1, 4, 64, 64), # For SD 1.5
|
||||
"batch_index": [0, 1, 2]
|
||||
}
|
||||
```
|
||||
|
||||
#### MODEL Type
|
||||
|
||||
```python
|
||||
# MODEL wraps the diffusion model
|
||||
# Access the underlying model with model.model
|
||||
# Use model.apply_model(x, t, c) for inference
|
||||
```
|
||||
|
||||
#### CONDITIONING Type
|
||||
|
||||
```python
|
||||
# CONDITIONING is a list of tuples:
|
||||
# [(conditioning_vector, options_dict), ...]
|
||||
|
||||
cond = [
|
||||
(torch.randn(1, 77, 768), {"pooled_output": torch.randn(1, 768)})
|
||||
]
|
||||
```
|
||||
|
||||
### Custom Types
|
||||
|
||||
You can define custom types for type checking:
|
||||
|
||||
```python
|
||||
# In your __init__.py or a types module
|
||||
CUSTOM_TYPES = {
|
||||
"MY_CUSTOM_TYPE": "MY_CUSTOM_TYPE",
|
||||
}
|
||||
|
||||
# Use in node
|
||||
RETURN_TYPES = ("MY_CUSTOM_TYPE",)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution Model
|
||||
|
||||
### Graph Execution Flow
|
||||
|
||||
```python
|
||||
# Pseudo-code of ComfyUI execution
|
||||
def execute_graph(graph, inputs):
|
||||
cache = {}
|
||||
executed = set()
|
||||
|
||||
def execute_node(node_id):
|
||||
if node_id in executed:
|
||||
return cache[node_id]
|
||||
|
||||
node = graph[node_id]
|
||||
|
||||
# Execute dependencies first
|
||||
input_values = []
|
||||
for input_id in node.inputs:
|
||||
input_values.append(execute_node(input_id))
|
||||
|
||||
# Execute this node
|
||||
result = node.function(*input_values)
|
||||
|
||||
# Cache and return
|
||||
cache[node_id] = result
|
||||
executed.add(node_id)
|
||||
return result
|
||||
|
||||
return execute_node(output_node_id)
|
||||
```
|
||||
|
||||
### Forcing Re-execution
|
||||
|
||||
Nodes with random outputs should use unique inputs to bypass cache:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"seed": ("INT", {"default": 0}),
|
||||
}
|
||||
}
|
||||
|
||||
# Different seed = different cache key = re-execution
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model Management
|
||||
|
||||
### Model Loading
|
||||
|
||||
```python
|
||||
import folder_paths
|
||||
import comfy.utils
|
||||
|
||||
def load_checkpoint(self, ckpt_name):
|
||||
ckpt_path = folder_paths.get_full_path("checkpoints", ckpt_name)
|
||||
|
||||
out = comfy.utils.load_checkpoint_guess_config(
|
||||
ckpt_path,
|
||||
output_vae=True,
|
||||
output_clip=True,
|
||||
embedding_directory=folder_paths.get_folder_paths("embeddings"),
|
||||
)
|
||||
|
||||
return out[:3] # (model, clip, vae)
|
||||
```
|
||||
|
||||
### Model Patching
|
||||
|
||||
```python
|
||||
class ModelPatcher:
|
||||
"""Apply patches to models (LoRA, etc.)"""
|
||||
|
||||
def patch_model(self, model, patches):
|
||||
# Create patched copy
|
||||
model.patch_model(patches)
|
||||
return model
|
||||
|
||||
def unpatch_model(self, model):
|
||||
# Remove patches
|
||||
model.unpatch_model()
|
||||
```
|
||||
|
||||
### Memory Optimization
|
||||
|
||||
```python
|
||||
import comfy.model_management as mm
|
||||
|
||||
class EfficientNode:
|
||||
def process(self, model, latent):
|
||||
device = mm.get_torch_device()
|
||||
|
||||
# Move to device
|
||||
model = model.to(device)
|
||||
latent = latent.to(device)
|
||||
|
||||
# Process with automatic memory management
|
||||
with mm.autocast():
|
||||
result = model(latent)
|
||||
|
||||
# Optional: free memory
|
||||
mm.soft_empty_cache()
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Customization
|
||||
|
||||
### JavaScript Extensions
|
||||
|
||||
Create `web/my_extension.js`:
|
||||
|
||||
```javascript
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "MyCustomExtension",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "MyCustomNode") {
|
||||
// Customize node appearance
|
||||
nodeType.prototype.onDrawForeground = function(ctx) {
|
||||
// Custom drawing code
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Widgets
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
# Color picker (requires JS extension)
|
||||
"color": ("COLOR", {"default": "#ff0000"}),
|
||||
|
||||
# File upload
|
||||
"file": ("FILE", {"accept": ".png,.jpg"}),
|
||||
|
||||
# Range slider
|
||||
"value": ("FLOAT", {
|
||||
"default": 0.5,
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"step": 0.01,
|
||||
"display": "slider" # Requires JS extension
|
||||
}),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Batching
|
||||
|
||||
```python
|
||||
def process_batch(self, images):
|
||||
# Process all at once instead of loop
|
||||
# Much faster on GPU
|
||||
mean = images.mean(dim=(1, 2, 3), keepdim=True)
|
||||
return images - mean
|
||||
```
|
||||
|
||||
### In-Place Operations
|
||||
|
||||
```python
|
||||
def modify_inplace(self, tensor):
|
||||
# Use in-place operations to save memory
|
||||
tensor.add_(0.1) # In-place addition
|
||||
tensor.clamp_(0, 1) # In-place clamp
|
||||
return tensor
|
||||
```
|
||||
|
||||
### Mixed Precision
|
||||
|
||||
```python
|
||||
import torch
|
||||
|
||||
def mixed_precision_process(self, model, x):
|
||||
with torch.cuda.amp.autocast():
|
||||
output = model(x)
|
||||
return output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Dynamic Inputs
|
||||
|
||||
```python
|
||||
class DynamicInputNode:
|
||||
"""Node with variable number of inputs."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {},
|
||||
"optional": {
|
||||
f"input_{i}": ("IMAGE",) for i in range(10)
|
||||
}
|
||||
}
|
||||
|
||||
def combine(self, **kwargs):
|
||||
# Collect all non-None inputs
|
||||
images = [v for k, v in kwargs.items()
|
||||
if k.startswith("input_") and v is not None]
|
||||
return (torch.cat(images, dim=0),)
|
||||
```
|
||||
|
||||
### Node Composition
|
||||
|
||||
```python
|
||||
class ComposedNode:
|
||||
"""Combine multiple operations in one node."""
|
||||
|
||||
def __init__(self):
|
||||
self.sub_node_1 = SubNode1()
|
||||
self.sub_node_2 = SubNode2()
|
||||
|
||||
def execute(self, input_data):
|
||||
temp = self.sub_node_1.process(input_data)
|
||||
result = self.sub_node_2.process(temp)
|
||||
return (result,)
|
||||
```
|
||||
|
||||
### Async Operations
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
class AsyncNode:
|
||||
async def fetch_data(self, url):
|
||||
# Async HTTP request
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
return await response.read()
|
||||
|
||||
def execute(self, url):
|
||||
# Run async in sync context
|
||||
loop = asyncio.get_event_loop()
|
||||
data = loop.run_until_complete(self.fetch_data(url))
|
||||
return (data,)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging Techniques
|
||||
|
||||
### Visual Debugging
|
||||
|
||||
```python
|
||||
class DebugNode:
|
||||
"""Print debug information about tensors."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"tensor": ("*",), # Accept any type
|
||||
"print_stats": ("BOOLEAN", {"default": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("*",)
|
||||
FUNCTION = "debug"
|
||||
|
||||
def debug(self, tensor, print_stats):
|
||||
print(f"=== DEBUG NODE ===")
|
||||
print(f"Type: {type(tensor)}")
|
||||
|
||||
if isinstance(tensor, torch.Tensor):
|
||||
print(f"Shape: {tensor.shape}")
|
||||
print(f"Dtype: {tensor.dtype}")
|
||||
print(f"Device: {tensor.device}")
|
||||
print(f"Min/Max: {tensor.min():.4f} / {tensor.max():.4f}")
|
||||
print(f"Mean/Std: {tensor.mean():.4f} / {tensor.std():.4f}")
|
||||
elif isinstance(tensor, dict):
|
||||
print(f"Keys: {tensor.keys()}")
|
||||
|
||||
print(f"==================")
|
||||
|
||||
return (tensor,)
|
||||
```
|
||||
|
||||
### Progress Reporting
|
||||
|
||||
```python
|
||||
class ProgressNode:
|
||||
"""Show progress for long operations."""
|
||||
|
||||
def long_operation(self, items):
|
||||
from comfy.utils import ProgressBar
|
||||
|
||||
pbar = ProgressBar(len(items))
|
||||
results = []
|
||||
|
||||
for i, item in enumerate(items):
|
||||
result = self.process(item)
|
||||
results.append(result)
|
||||
pbar.update(1)
|
||||
|
||||
return results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Test Example
|
||||
|
||||
```python
|
||||
import unittest
|
||||
import torch
|
||||
|
||||
class TestMyNode(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = MyCustomNode()
|
||||
|
||||
def test_basic_execution(self):
|
||||
input_tensor = torch.rand(1, 64, 64, 3)
|
||||
result = self.node.execute(input_tensor)
|
||||
|
||||
self.assertEqual(result[0].shape, input_tensor.shape)
|
||||
self.assertTrue(torch.all(result[0] >= 0))
|
||||
self.assertTrue(torch.all(result[0] <= 1))
|
||||
|
||||
def test_batch_processing(self):
|
||||
batch = torch.rand(4, 64, 64, 3)
|
||||
result = self.node.execute(batch)
|
||||
|
||||
self.assertEqual(result[0].shape[0], 4)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Shape Mismatch
|
||||
|
||||
```python
|
||||
# Wrong: Assuming fixed batch size
|
||||
def wrong(self, image):
|
||||
return image[0] # Loses batch dimension
|
||||
|
||||
# Right: Preserve batch dimension
|
||||
def right(self, image):
|
||||
return image # Keep (B, H, W, C)
|
||||
```
|
||||
|
||||
### Issue: Device Mismatch
|
||||
|
||||
```python
|
||||
# Wrong: Mixing CPU and CUDA tensors
|
||||
def wrong(self, tensor1, tensor2):
|
||||
return tensor1 + tensor2 # May fail if different devices
|
||||
|
||||
# Right: Ensure same device
|
||||
def right(self, tensor1, tensor2):
|
||||
device = tensor1.device
|
||||
tensor2 = tensor2.to(device)
|
||||
return tensor1 + tensor2
|
||||
```
|
||||
|
||||
### Issue: Value Range
|
||||
|
||||
```python
|
||||
# Wrong: PIL expects 0-255, tensor is 0-1
|
||||
pil_img = Image.fromarray(tensor.numpy())
|
||||
|
||||
# Right: Scale appropriately
|
||||
pil_img = Image.fromarray((tensor.numpy() * 255).astype(np.uint8))
|
||||
```
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validation script for ComfyUI Node Development Skill
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_yaml_frontmatter(content):
|
||||
"""Validate YAML frontmatter format."""
|
||||
pattern = r'^---\n(.*?)\n---'
|
||||
match = re.match(pattern, content, re.DOTALL)
|
||||
|
||||
if not match:
|
||||
return False, "No YAML frontmatter found"
|
||||
|
||||
yaml_content = match.group(1)
|
||||
required_fields = ['name', 'description']
|
||||
|
||||
for field in required_fields:
|
||||
if f'{field}:' not in yaml_content:
|
||||
return False, f"Missing required field: {field}"
|
||||
|
||||
return True, "YAML frontmatter valid"
|
||||
|
||||
|
||||
def validate_word_count(content, max_words=5000):
|
||||
"""Check word count."""
|
||||
words = len(content.split())
|
||||
if words > max_words:
|
||||
return False, f"Word count too high: {words} (max {max_words})"
|
||||
return True, f"Word count: {words}"
|
||||
|
||||
|
||||
def validate_skill(skill_path):
|
||||
"""Validate a skill directory."""
|
||||
skill_path = Path(skill_path)
|
||||
|
||||
print(f"🔍 Validating skill: {skill_path.name}")
|
||||
print("-" * 50)
|
||||
|
||||
# Check required files
|
||||
required_files = ['SKILL.md', 'README.md']
|
||||
for file in required_files:
|
||||
file_path = skill_path / file
|
||||
if not file_path.exists():
|
||||
print(f"❌ Missing required file: {file}")
|
||||
return False
|
||||
print(f"✅ Found {file}")
|
||||
|
||||
# Validate SKILL.md
|
||||
skill_md = skill_path / 'SKILL.md'
|
||||
content = skill_md.read_text(encoding='utf-8')
|
||||
|
||||
# YAML validation
|
||||
valid, msg = validate_yaml_frontmatter(content)
|
||||
if valid:
|
||||
print(f"✅ {msg}")
|
||||
else:
|
||||
print(f"❌ {msg}")
|
||||
return False
|
||||
|
||||
# Word count
|
||||
valid, msg = validate_word_count(content)
|
||||
if valid:
|
||||
print(f"✅ {msg}")
|
||||
else:
|
||||
print(f"⚠️ {msg}")
|
||||
|
||||
print("-" * 50)
|
||||
print("✅ Skill validation complete!")
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
skill_path = sys.argv[1]
|
||||
else:
|
||||
skill_path = Path(__file__).parent.parent
|
||||
|
||||
success = validate_skill(skill_path)
|
||||
sys.exit(0 if success else 1)
|
||||
Reference in New Issue
Block a user