Disciplines · Integrations

ComfyUI Custom Node Development

ComfyUI's extensibility comes from its custom node system.

11sections2 minread

On this page

This guide covers how to develop, deploy, and manage custom ComfyUI nodes for the Oshun platform.

Table of Contents#


Overview#

ComfyUI's extensibility comes from its custom node system. Nodes are Python classes that perform specific operations within a workflow.

Why Custom Nodes?#

  • Specialized Processing: Implement domain-specific operations
  • Integration: Connect to external services and APIs
  • Optimization: Create optimized versions of common operations
  • Abstraction: Simplify complex workflows into single nodes

Node Types#

Type Description Example
Processing Transform data Image filter, text manipulation
Loading Load resources Model loader, image loader
Saving Export results Save to S3, database storage
Integration External services API calls, webhooks
Control Flow control Conditional, loop
Utility Helpers Math, string ops

Node Architecture#

Basic Structure#

python
class MyCustomNode:
    """
    A custom ComfyUI node.
    """

    # Unique identifier (must be unique across all nodes)
    @classmethod
    def INPUT_TYPES(cls):
        """Define node inputs."""
        return {
            "required": {
                "image": ("IMAGE",),
                "strength": ("FLOAT", {
                    "default": 1.0,
                    "min": 0.0,
                    "max": 2.0,
                    "step": 0.1
                }),
            },
            "optional": {
                "mask": ("MASK",),
            },
        }

    # Output types (tuple of type names)
    RETURN_TYPES = ("IMAGE",)

    # Output names (tuple of display names)
    RETURN_NAMES = ("processed_image",)

    # Function to call for execution
    FUNCTION = "process"

    # Category in the node menu
    CATEGORY = "Oshun/Image Processing"

    def process(self, image, strength, mask=None):
        """
        Main processing function.

        Args:
            image: Input image tensor [B, H, W, C]
            strength: Processing strength
            mask: Optional mask tensor

        Returns:
            Tuple of outputs matching RETURN_TYPES
        """
        # Process the image
        result = self.apply_effect(image, strength, mask)

        # Return as tuple
        return (result,)

    def apply_effect(self, image, strength, mask):
        """Apply the custom effect."""
        import torch

        # Your processing logic here
        processed = image * strength

        if mask is not None:
            processed = image * (1 - mask) + processed * mask

        return processed


# Node registration
NODE_CLASS_MAPPINGS = {
    "MyCustomNode": MyCustomNode,
}

# Display names
NODE_DISPLAY_NAME_MAPPINGS = {
    "MyCustomNode": "My Custom Effect",
}

File Structure#

text
custom_nodes/
└── oshun_nodes/
    ├── __init__.py           # Node registration
    ├── nodes/
    │   ├── __init__.py
    │   ├── image_processing.py
    │   ├── text_processing.py
    │   └── integration.py
    ├── utils/
    │   ├── __init__.py
    │   └── helpers.py
    ├── requirements.txt      # Python dependencies
    └── install.py           # Optional installation script

Registration (__init__.py)#

python
"""Oshun Custom Nodes for ComfyUI."""

from .nodes.image_processing import (
    OshunImageEnhance,
    OshunColorCorrect,
    OshunStyleTransfer,
)
from .nodes.text_processing import (
    OshunPromptEnhancer,
    OshunNegativeGenerator,
)
from .nodes.integration import (
    OshunS3Upload,
    OshunWebhookNotify,
)

# All nodes to register
NODE_CLASS_MAPPINGS = {
    # Image processing
    "OshunImageEnhance": OshunImageEnhance,
    "OshunColorCorrect": OshunColorCorrect,
    "OshunStyleTransfer": OshunStyleTransfer,

    # Text processing
    "OshunPromptEnhancer": OshunPromptEnhancer,
    "OshunNegativeGenerator": OshunNegativeGenerator,

    # Integration
    "OshunS3Upload": OshunS3Upload,
    "OshunWebhookNotify": OshunWebhookNotify,
}

# Display names
NODE_DISPLAY_NAME_MAPPINGS = {
    "OshunImageEnhance": "Oshun Image Enhance",
    "OshunColorCorrect": "Oshun Color Correct",
    "OshunStyleTransfer": "Oshun Style Transfer",
    "OshunPromptEnhancer": "Oshun Prompt Enhancer",
    "OshunNegativeGenerator": "Oshun Negative Generator",
    "OshunS3Upload": "Oshun S3 Upload",
    "OshunWebhookNotify": "Oshun Webhook Notify",
}

# Version
__version__ = "1.0.0"

Creating Custom Nodes#

Image Processing Node#

python
import torch
import torch.nn.functional as F
from typing import Tuple, Optional

class OshunImageEnhance:
    """
    Enhance image quality with multiple adjustable parameters.
    """

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "image": ("IMAGE",),
                "contrast": ("FLOAT", {
                    "default": 1.0,
                    "min": 0.0,
                    "max": 3.0,
                    "step": 0.05,
                    "display": "slider"
                }),
                "brightness": ("FLOAT", {
                    "default": 0.0,
                    "min": -1.0,
                    "max": 1.0,
                    "step": 0.05,
                    "display": "slider"
                }),
                "saturation": ("FLOAT", {
                    "default": 1.0,
                    "min": 0.0,
                    "max": 3.0,
                    "step": 0.05,
                    "display": "slider"
                }),
                "sharpness": ("FLOAT", {
                    "default": 0.0,
                    "min": 0.0,
                    "max": 2.0,
                    "step": 0.05,
                    "display": "slider"
                }),
            },
            "optional": {
                "mask": ("MASK",),
            },
        }

    RETURN_TYPES = ("IMAGE",)
    RETURN_NAMES = ("enhanced_image",)
    FUNCTION = "enhance"
    CATEGORY = "Oshun/Image Processing"

    def enhance(
        self,
        image: torch.Tensor,
        contrast: float,
        brightness: float,
        saturation: float,
        sharpness: float,
        mask: Optional[torch.Tensor] = None
    ) -> Tuple[torch.Tensor]:
        """
        Apply image enhancements.

        Args:
            image: [B, H, W, C] tensor, values in [0, 1]
            contrast: Contrast multiplier
            brightness: Brightness offset
            saturation: Saturation multiplier
            sharpness: Sharpness amount
            mask: Optional mask for selective enhancement

        Returns:
            Enhanced image tensor
        """
        # Store original for masking
        original = image.clone()

        # Apply contrast
        mean = image.mean(dim=(1, 2), keepdim=True)
        image = (image - mean) * contrast + mean

        # Apply brightness
        image = image + brightness

        # Apply saturation
        gray = image.mean(dim=-1, keepdim=True)
        image = gray + (image - gray) * saturation

        # Apply sharpness
        if sharpness > 0:
            image = self._apply_sharpness(image, sharpness)

        # Clamp to valid range
        image = torch.clamp(image, 0, 1)

        # Apply mask if provided
        if mask is not None:
            mask = mask.unsqueeze(-1)  # Add channel dimension
            image = original * (1 - mask) + image * mask

        return (image,)

    def _apply_sharpness(
        self,
        image: torch.Tensor,
        amount: float
    ) -> torch.Tensor:
        """Apply unsharp mask for sharpening."""
        # Convert to BCHW for convolution
        image = image.permute(0, 3, 1, 2)

        # Gaussian blur kernel
        kernel_size = 3
        sigma = 1.0
        kernel = self._gaussian_kernel(kernel_size, sigma, image.device)
        kernel = kernel.expand(image.shape[1], 1, -1, -1)

        # Apply blur
        padding = kernel_size // 2
        blurred = F.conv2d(
            image,
            kernel,
            padding=padding,
            groups=image.shape[1]
        )

        # Unsharp mask
        sharpened = image + amount * (image - blurred)

        # Convert back to BHWC
        return sharpened.permute(0, 2, 3, 1)

    def _gaussian_kernel(
        self,
        size: int,
        sigma: float,
        device: torch.device
    ) -> torch.Tensor:
        """Create Gaussian kernel."""
        coords = torch.arange(size, device=device).float() - size // 2
        g = torch.exp(-(coords ** 2) / (2 * sigma ** 2))
        g = g / g.sum()
        return torch.outer(g, g).unsqueeze(0).unsqueeze(0)

Conditioning Node#

python
class OshunPromptEnhancer:
    """
    Enhance prompts with quality boosters and style keywords.
    """

    QUALITY_BOOSTERS = [
        "highly detailed",
        "4k",
        "8k",
        "masterpiece",
        "best quality",
        "sharp focus",
        "intricate details",
    ]

    STYLE_PRESETS = {
        "photorealistic": [
            "photorealistic",
            "hyperrealistic",
            "photography",
            "DSLR",
            "raw photo",
        ],
        "artistic": [
            "artistic",
            "creative",
            "expressive",
            "painterly",
        ],
        "anime": [
            "anime style",
            "manga",
            "2D illustration",
            "cel shading",
        ],
        "cinematic": [
            "cinematic",
            "movie still",
            "dramatic lighting",
            "film grain",
        ],
    }

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "prompt": ("STRING", {
                    "multiline": True,
                    "default": ""
                }),
                "style": (list(cls.STYLE_PRESETS.keys()),),
                "quality_boost": ("BOOLEAN", {"default": True}),
                "detail_level": (["low", "medium", "high", "ultra"],),
            },
            "optional": {
                "custom_keywords": ("STRING", {
                    "multiline": False,
                    "default": ""
                }),
            },
        }

    RETURN_TYPES = ("STRING",)
    RETURN_NAMES = ("enhanced_prompt",)
    FUNCTION = "enhance_prompt"
    CATEGORY = "Oshun/Text Processing"

    def enhance_prompt(
        self,
        prompt: str,
        style: str,
        quality_boost: bool,
        detail_level: str,
        custom_keywords: str = ""
    ) -> Tuple[str]:
        """Enhance the prompt with additional keywords."""
        parts = [prompt.strip()]

        # Add style keywords
        if style in self.STYLE_PRESETS:
            parts.extend(self.STYLE_PRESETS[style][:2])

        # Add quality boosters
        if quality_boost:
            detail_map = {
                "low": 1,
                "medium": 2,
                "high": 4,
                "ultra": 6,
            }
            count = detail_map.get(detail_level, 2)
            parts.extend(self.QUALITY_BOOSTERS[:count])

        # Add custom keywords
        if custom_keywords:
            parts.extend([k.strip() for k in custom_keywords.split(",")])

        enhanced = ", ".join(parts)
        return (enhanced,)

Integration Node#

python
import boto3
import requests
from typing import Tuple, Dict, Any
import numpy as np
from PIL import Image
import io
import os

class OshunS3Upload:
    """
    Upload generated images to S3.
    """

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "images": ("IMAGE",),
                "bucket": ("STRING", {"default": "oshun-outputs"}),
                "prefix": ("STRING", {"default": "generated/"}),
                "format": (["png", "jpg", "webp"],),
            },
            "optional": {
                "quality": ("INT", {
                    "default": 95,
                    "min": 1,
                    "max": 100,
                    "step": 1
                }),
                "metadata": ("STRING", {
                    "multiline": True,
                    "default": "{}"
                }),
            },
        }

    RETURN_TYPES = ("STRING", "STRING")
    RETURN_NAMES = ("s3_urls", "s3_keys")
    FUNCTION = "upload"
    CATEGORY = "Oshun/Integration"

    def __init__(self):
        self.s3_client = boto3.client(
            "s3",
            aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
            aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
            region_name=os.environ.get("AWS_REGION", "us-east-1"),
        )

    def upload(
        self,
        images: torch.Tensor,
        bucket: str,
        prefix: str,
        format: str,
        quality: int = 95,
        metadata: str = "{}"
    ) -> Tuple[str, str]:
        """
        Upload images to S3.

        Returns:
            Tuple of (comma-separated URLs, comma-separated keys)
        """
        import json
        from datetime import datetime
        import uuid

        urls = []
        keys = []

        # Parse metadata
        try:
            meta_dict = json.loads(metadata)
        except json.JSONDecodeError:
            meta_dict = {}

        # Process each image in batch
        for i, img_tensor in enumerate(images):
            # Convert to PIL Image
            img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8)
            pil_img = Image.fromarray(img_np)

            # Encode to bytes
            buffer = io.BytesIO()
            save_format = "JPEG" if format == "jpg" else format.upper()
            save_kwargs = {"quality": quality} if format in ["jpg", "webp"] else {}
            pil_img.save(buffer, format=save_format, **save_kwargs)
            buffer.seek(0)

            # Generate key
            timestamp = datetime.utcnow().strftime("%Y/%m/%d")
            filename = f"{uuid.uuid4().hex}.{format}"
            key = f"{prefix.rstrip('/')}/{timestamp}/{filename}"

            # Upload
            content_type = {
                "png": "image/png",
                "jpg": "image/jpeg",
                "webp": "image/webp",
            }.get(format, "image/png")

            self.s3_client.upload_fileobj(
                buffer,
                bucket,
                key,
                ExtraArgs={
                    "ContentType": content_type,
                    "Metadata": {k: str(v) for k, v in meta_dict.items()},
                }
            )

            # Generate URL
            url = f"https://{bucket}.s3.amazonaws.com/{key}"
            urls.append(url)
            keys.append(key)

        return (",".join(urls), ",".join(keys))


class OshunWebhookNotify:
    """
    Send webhook notification when processing completes.
    """

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "trigger": ("*",),  # Any input triggers the webhook
                "webhook_url": ("STRING", {"default": ""}),
                "event_type": ("STRING", {"default": "generation_complete"}),
            },
            "optional": {
                "payload": ("STRING", {
                    "multiline": True,
                    "default": "{}"
                }),
                "include_image_urls": ("BOOLEAN", {"default": True}),
                "image_urls": ("STRING", {"default": ""}),
            },
        }

    RETURN_TYPES = ("BOOLEAN",)
    RETURN_NAMES = ("success",)
    FUNCTION = "notify"
    CATEGORY = "Oshun/Integration"
    OUTPUT_NODE = True  # This is an output node

    def notify(
        self,
        trigger: Any,
        webhook_url: str,
        event_type: str,
        payload: str = "{}",
        include_image_urls: bool = True,
        image_urls: str = ""
    ) -> Tuple[bool]:
        """Send webhook notification."""
        import json

        if not webhook_url:
            return (False,)

        try:
            # Build payload
            data = json.loads(payload)
            data["event_type"] = event_type
            data["timestamp"] = datetime.utcnow().isoformat()

            if include_image_urls and image_urls:
                data["image_urls"] = [u.strip() for u in image_urls.split(",")]

            # Send webhook
            response = requests.post(
                webhook_url,
                json=data,
                headers={"Content-Type": "application/json"},
                timeout=30,
            )
            response.raise_for_status()

            return (True,)

        except Exception as e:
            print(f"Webhook failed: {e}")
            return (False,)

Input and Output Types#

Built-in Types#

Type Python Type Description
IMAGE torch.Tensor [B, H, W, C] float tensor
MASK torch.Tensor [B, H, W] float tensor
LATENT dict {"samples": tensor}
MODEL object Diffusion model
CLIP object CLIP text encoder
VAE object VAE model
CONDITIONING list Conditioning tensors
CONTROL_NET object ControlNet model
STRING str Text string
INT int Integer
FLOAT float Float number
BOOLEAN bool True/False

Widget Configuration#

python
@classmethod
def INPUT_TYPES(cls):
    return {
        "required": {
            # Text input
            "text": ("STRING", {
                "multiline": True,  # Multi-line text area
                "default": "",
                "placeholder": "Enter text...",
            }),

            # Integer with slider
            "steps": ("INT", {
                "default": 30,
                "min": 1,
                "max": 150,
                "step": 1,
                "display": "slider",  # or "number"
            }),

            # Float with slider
            "cfg": ("FLOAT", {
                "default": 7.5,
                "min": 1.0,
                "max": 30.0,
                "step": 0.5,
                "round": 2,  # Decimal places
                "display": "slider",
            }),

            # Dropdown selection
            "sampler": (["euler", "euler_ancestral", "dpmpp_2m"],),

            # Boolean toggle
            "enabled": ("BOOLEAN", {"default": True}),
        },
        "optional": {
            # Optional inputs (can be None)
            "mask": ("MASK",),
        },
        "hidden": {
            # Hidden inputs (not shown in UI)
            "node_id": "UNIQUE_ID",
            "prompt": "PROMPT",
        },
    }

Custom Types#

python
# Define custom type
class OshunStyleData:
    """Custom data type for style information."""

    def __init__(self, style_name: str, weights: dict):
        self.style_name = style_name
        self.weights = weights


# Node that outputs custom type
class OshunStyleLoader:
    RETURN_TYPES = ("OSHUN_STYLE",)  # Custom type name
    RETURN_NAMES = ("style",)

    def load_style(self, name: str) -> Tuple[OshunStyleData]:
        style = OshunStyleData(name, {"contrast": 1.2})
        return (style,)


# Node that accepts custom type
class OshunApplyStyle:
    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "image": ("IMAGE",),
                "style": ("OSHUN_STYLE",),  # Accepts custom type
            },
        }

Advanced Node Features#

Lazy Evaluation#

Only execute when output is needed:

python
class OshunConditionalProcess:
    """Process only if condition is met."""

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "image": ("IMAGE",),
                "condition": ("BOOLEAN",),
            },
        }

    RETURN_TYPES = ("IMAGE",)
    FUNCTION = "process"

    # Enable lazy evaluation
    @classmethod
    def IS_CHANGED(cls, image, condition):
        """Return value that changes when re-execution needed."""
        # Return hash of inputs or None to always execute
        return hash((id(image), condition))

    def process(self, image, condition):
        if not condition:
            return (image,)  # Pass through unchanged

        # Process image
        return (self._apply_processing(image),)

Progress Reporting#

python
from comfy.utils import ProgressBar

class OshunBatchProcess:
    """Process multiple items with progress."""

    FUNCTION = "process"

    def process(self, images, **kwargs):
        results = []
        pbar = ProgressBar(len(images))

        for i, image in enumerate(images):
            result = self._process_single(image, **kwargs)
            results.append(result)
            pbar.update_absolute(i + 1)

        return (torch.stack(results),)

Caching#

python
import hashlib
from functools import lru_cache

class OshunCachedLoader:
    """Load resources with caching."""

    _cache = {}

    @classmethod
    def IS_CHANGED(cls, resource_name):
        # Only reload if file changed
        import os
        path = cls._get_path(resource_name)
        if os.path.exists(path):
            return os.path.getmtime(path)
        return None

    @classmethod
    @lru_cache(maxsize=10)
    def _load_resource(cls, resource_name: str):
        """Cached resource loading."""
        # Load and return resource
        pass

    def load(self, resource_name: str):
        return (self._load_resource(resource_name),)

Dynamic Inputs#

python
class OshunDynamicInputs:
    """Node with dynamic number of inputs."""

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "count": ("INT", {"default": 2, "min": 1, "max": 10}),
            },
        }

    # Dynamic inputs added at runtime
    @classmethod
    def VALIDATE_INPUTS(cls, **kwargs):
        """Validate dynamic inputs."""
        count = kwargs.get("count", 2)
        for i in range(count):
            if f"image_{i}" not in kwargs:
                return f"Missing image_{i}"
        return True

    RETURN_TYPES = ("IMAGE",)
    FUNCTION = "combine"

    def combine(self, count, **kwargs):
        images = [kwargs[f"image_{i}"] for i in range(count)]
        return (torch.cat(images, dim=0),)

Testing Nodes#

Unit Tests#

python
import pytest
import torch
from oshun_nodes.nodes.image_processing import OshunImageEnhance

class TestOshunImageEnhance:
    """Tests for OshunImageEnhance node."""

    @pytest.fixture
    def node(self):
        return OshunImageEnhance()

    @pytest.fixture
    def sample_image(self):
        # Create test image [B, H, W, C]
        return torch.rand(1, 64, 64, 3)

    def test_input_types(self):
        """Test INPUT_TYPES class method."""
        types = OshunImageEnhance.INPUT_TYPES()

        assert "required" in types
        assert "image" in types["required"]
        assert "contrast" in types["required"]

    def test_enhance_no_change(self, node, sample_image):
        """Test with neutral settings."""
        result = node.enhance(
            image=sample_image,
            contrast=1.0,
            brightness=0.0,
            saturation=1.0,
            sharpness=0.0,
        )

        assert len(result) == 1
        assert result[0].shape == sample_image.shape
        torch.testing.assert_close(result[0], sample_image, atol=1e-5, rtol=1e-5)

    def test_enhance_contrast(self, node, sample_image):
        """Test contrast adjustment."""
        result = node.enhance(
            image=sample_image,
            contrast=2.0,
            brightness=0.0,
            saturation=1.0,
            sharpness=0.0,
        )

        # Contrast should increase variance
        original_var = sample_image.var()
        result_var = result[0].var()
        assert result_var > original_var

    def test_enhance_with_mask(self, node, sample_image):
        """Test masked enhancement."""
        mask = torch.zeros(1, 64, 64)
        mask[:, 32:, :] = 1.0  # Mask bottom half

        result = node.enhance(
            image=sample_image,
            contrast=2.0,
            brightness=0.0,
            saturation=1.0,
            sharpness=0.0,
            mask=mask,
        )

        # Top half should be unchanged
        torch.testing.assert_close(
            result[0][:, :32, :, :],
            sample_image[:, :32, :, :],
            atol=1e-5,
            rtol=1e-5
        )

    def test_output_range(self, node, sample_image):
        """Test output is in valid range."""
        result = node.enhance(
            image=sample_image,
            contrast=3.0,
            brightness=0.5,
            saturation=2.0,
            sharpness=1.0,
        )

        assert result[0].min() >= 0.0
        assert result[0].max() <= 1.0

Integration Tests#

python
import pytest
from comfy.workflow import Workflow

class TestOshunNodesIntegration:
    """Integration tests with ComfyUI."""

    @pytest.fixture
    def comfy_api(self):
        """Connect to local ComfyUI."""
        from comfyui_api import ComfyUIAPI
        return ComfyUIAPI(host="localhost", port=8188)

    def test_workflow_execution(self, comfy_api):
        """Test node in a complete workflow."""
        workflow = {
            "1": {
                "class_type": "LoadImage",
                "inputs": {"image": "test.png"}
            },
            "2": {
                "class_type": "OshunImageEnhance",
                "inputs": {
                    "image": ["1", 0],
                    "contrast": 1.2,
                    "brightness": 0.1,
                    "saturation": 1.1,
                    "sharpness": 0.3,
                }
            },
            "3": {
                "class_type": "PreviewImage",
                "inputs": {"images": ["2", 0]}
            }
        }

        result = comfy_api.execute(workflow)
        assert result["status"] == "success"

Packaging and Distribution#

requirements.txt#

text
torch>=2.0.0
numpy>=1.24.0
Pillow>=9.0.0
boto3>=1.26.0  # For S3 integration
requests>=2.28.0  # For webhooks

install.py#

python
"""Installation script for Oshun custom nodes."""

import subprocess
import sys
import os

def install_requirements():
    """Install Python requirements."""
    requirements_path = os.path.join(
        os.path.dirname(__file__),
        "requirements.txt"
    )

    if os.path.exists(requirements_path):
        subprocess.check_call([
            sys.executable,
            "-m",
            "pip",
            "install",
            "-r",
            requirements_path,
        ])

def main():
    print("Installing Oshun Custom Nodes...")
    install_requirements()
    print("Installation complete!")

if __name__ == "__main__":
    main()

pyproject.toml#

toml
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "oshun-comfyui-nodes"
version = "1.0.0"
description = "Oshun custom nodes for ComfyUI"
requires-python = ">=3.10"
dependencies = [
    "torch>=2.0.0",
    "numpy>=1.24.0",
    "Pillow>=9.0.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0.0",
    "pytest-cov>=4.0.0",
]
integration = [
    "boto3>=1.26.0",
    "requests>=2.28.0",
]

Deployment to Oshun#

Docker Integration#

Add nodes to RunPod Docker image:

dockerfile
# docker/runpod/comfyui/Dockerfile
FROM comfyanonymous/comfyui:latest

# Copy custom nodes
COPY custom_nodes/oshun_nodes /app/ComfyUI/custom_nodes/oshun_nodes

# Install dependencies
RUN pip install -r /app/ComfyUI/custom_nodes/oshun_nodes/requirements.txt

# Verify installation
RUN python -c "from oshun_nodes import NODE_CLASS_MAPPINGS; print(f'Loaded {len(NODE_CLASS_MAPPINGS)} nodes')"

CI/CD Pipeline#

yaml
# .github/workflows/deploy-custom-nodes.yml
name: Deploy Custom Nodes

on:
  push:
    paths:
      - 'custom_nodes/oshun_nodes/**'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          pip install -r custom_nodes/oshun_nodes/requirements.txt
          pip install pytest pytest-cov

      - name: Run tests
        run: |
          pytest custom_nodes/oshun_nodes/tests/ -v --cov

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build Docker image
        run: |
          docker build -t oshunai/runpod-comfyui:latest \
            -f docker/runpod/comfyui/Dockerfile .

      - name: Push to registry
        run: |
          docker push oshunai/runpod-comfyui:latest

Verifying Deployment#

python
# Test node availability
async def verify_custom_nodes():
    """Verify custom nodes are available on provider."""
    from comfyui_api import ComfyUIAPI

    api = ComfyUIAPI(endpoint_url=os.environ["COMFYUI_ENDPOINT"])

    # Get available nodes
    nodes = await api.get_node_info()

    # Check for Oshun nodes
    oshun_nodes = [n for n in nodes if n.startswith("Oshun")]

    expected_nodes = [
        "OshunImageEnhance",
        "OshunColorCorrect",
        "OshunPromptEnhancer",
        "OshunS3Upload",
    ]

    for node in expected_nodes:
        assert node in oshun_nodes, f"Missing node: {node}"

    print(f"All {len(expected_nodes)} custom nodes verified!")

Best Practices#

1. Use Type Hints#

python
from typing import Tuple, Optional
import torch

def process(
    self,
    image: torch.Tensor,
    strength: float,
    mask: Optional[torch.Tensor] = None
) -> Tuple[torch.Tensor]:
    """
    Process image with documented parameters.

    Args:
        image: Input tensor [B, H, W, C] in range [0, 1]
        strength: Effect strength in range [0, 2]
        mask: Optional mask tensor [B, H, W]

    Returns:
        Processed image tensor
    """
    pass

2. Handle Edge Cases#

python
def process(self, image: torch.Tensor, **kwargs) -> Tuple[torch.Tensor]:
    # Validate input
    if image.dim() != 4:
        raise ValueError(f"Expected 4D tensor, got {image.dim()}D")

    if image.shape[-1] not in [1, 3, 4]:
        raise ValueError(f"Expected 1/3/4 channels, got {image.shape[-1]}")

    # Handle empty batch
    if image.shape[0] == 0:
        return (image,)

    # Handle single-channel (grayscale)
    if image.shape[-1] == 1:
        image = image.repeat(1, 1, 1, 3)

    return self._process_impl(image, **kwargs)

3. Preserve Memory#

python
def process(self, image: torch.Tensor, **kwargs) -> Tuple[torch.Tensor]:
    # Process in chunks for large batches
    if image.shape[0] > 4:
        results = []
        for i in range(0, image.shape[0], 4):
            chunk = image[i:i+4]
            results.append(self._process_chunk(chunk, **kwargs))
            torch.cuda.empty_cache()  # Free memory
        return (torch.cat(results),)

    return self._process_chunk(image, **kwargs)

4. Provide Meaningful Errors#

python
def process(self, image: torch.Tensor, model_name: str, **kwargs):
    try:
        model = self.load_model(model_name)
    except FileNotFoundError:
        raise ValueError(
            f"Model '{model_name}' not found. "
            f"Available models: {', '.join(self.list_models())}"
        )
    except Exception as e:
        raise RuntimeError(f"Failed to load model '{model_name}': {e}")

    return self._process_with_model(image, model, **kwargs)

5. Document Everything#

python
class OshunImageEnhance:
    """
    Enhance image quality with multiple adjustable parameters.

    This node provides professional-grade image enhancement with
    control over contrast, brightness, saturation, and sharpness.

    Features:
        - Non-destructive processing
        - Optional mask support for selective enhancement
        - GPU-accelerated operations

    Example workflow:
        LoadImage → OshunImageEnhance → SaveImage

    Notes:
        - All adjustments preserve the [0, 1] range
        - Use mask input for localized adjustments
        - Sharpening uses unsharp mask technique
    """