Initial commit: ComfyUI Workflow API

HTTP API for ComfyUI workflow submission with:
- POST /api/webhook endpoint for workflow submission
- Real-time progress events via callback URL
- Automatic output collection (no re-encoding)
- Input/Output nodes for webhook data access
This commit is contained in:
Isala Piyarisi
2026-01-17 18:30:08 +05:30
commit 796bb396c0
33 changed files with 6300 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.env
.venv
env/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
# OS
.DS_Store
Thumbs.db
# Project specific
outputs/
*.log
+202
View File
@@ -0,0 +1,202 @@
# ComfyUI Workflow API
HTTP API for ComfyUI with webhook-based workflow execution. Submit workflows via HTTP, receive real-time progress updates and outputs at your callback URL.
## Installation
```bash
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/isala404/comfy-workflow-api.git
pip install -r comfy-workflow-api/requirements.txt
```
## Use Cases
- **Backend Integration**: Run ComfyUI as a headless service behind your application
- **Batch Processing**: Submit multiple workflows and receive results asynchronously
- **Distributed Systems**: Decouple workflow submission from result handling
- **Mobile/Web Apps**: Trigger image generation from any HTTP client
## How It Works
```
Client ComfyUI Your Server
│ │ │
├─── POST /api/webhook ─────────►│ │
│ (workflow + callback_url) │ │
│ │ │
│◄── {request_id, prompt_id} ────┤ │
│ │ │
│ ├── workflow.progress ──────────►│
│ ├── workflow.progress ──────────►│
│ ├── ... │
│ │ │
│ ├── multipart (files) ──────────►│
│ │ │
```
## Quick Start
### 1. Create a workflow using webhook nodes
Use **WebhookReceiver** → **WebhookTransformer** → **WebhookSend** to read inputs from HTTP and send outputs back:
```
┌─► WebhookTransformer ──► CLIPTextEncode ──┐
WebhookReceiver ────┤ (extracts "prompt") ├──► KSampler ──► WebhookSend
└─► WebhookTransformer ──► VAEEncode ───────┘
(extracts "image")
```
### 2. Submit via HTTP
```bash
# Image-to-image example
curl -X POST http://localhost:8188/api/webhook \
-F "workflow=@workflow.json" \
-F "callback_url=http://your-server.com/webhook" \
-F "prompt=turn it into a watercolor painting" \
-F "image=@input.png"
```
### 3. Receive at your callback
Your server receives:
- **Progress events** (JSON): `{"event": "workflow.progress", "progress": {"value": 5, "max": 20}}`
- **Output files** (gzip multipart): Images, audio, video, etc.
## Nodes
### WebhookReceiver
Entry point for webhook workflows. Reads request configuration from the HTTP call.
| Output | Type | Description |
|--------|------|-------------|
| webhook_context | WEBHOOK_CONTEXT | Context for other webhook nodes |
### WebhookTransformer
Extracts a field from the HTTP request. Connects to any input type.
| Input | Description |
|-------|-------------|
| webhook_context | From WebhookReceiver |
| field | Field name to extract (e.g., "prompt") |
| default_value | Optional fallback if field missing |
| Output | Type | Description |
|--------|------|-------------|
| value | * (wildcard) | Extracted value, auto-typed |
**Type inference**: Strings are converted to INT, FLOAT, BOOLEAN when applicable. File uploads become IMAGE, AUDIO, VIDEO, MESH, or LATENT based on content type.
### WebhookSend
Sends outputs to the callback URL. Supports 5 output fields.
| Input | Description |
|-------|-------------|
| webhook_context | From WebhookReceiver |
| field_1..5 | Any ComfyUI type (IMAGE, AUDIO, STRING, etc.) |
| field_1..5_name | Names for each output |
**Encoding**: IMAGE→PNG, AUDIO→FLAC, VIDEO→MP4, MESH→GLB, LATENT→safetensors
## API Reference
### POST /api/webhook
| Field | Required | Description |
|-------|----------|-------------|
| workflow | Yes | Workflow JSON |
| callback_url | Yes | URL to receive events/outputs |
| auth_header | No | Auth header name |
| auth_value | No | Auth header value |
| timeout | No | HTTP timeout (default: 60s) |
| max_retries | No | Retry attempts (default: 3) |
| *custom fields* | No | Any field accessible via WebhookTransformer |
**Response:**
```json
{"request_id": "...", "prompt_id": "...", "status": "queued"}
```
### GET /api/webhook/status/{request_id}
Check request status.
### DELETE /api/webhook/{request_id}
Cancel a queued/running workflow.
## Webhook Events
| Event | Content-Type | Description |
|-------|--------------|-------------|
| workflow.started | application/json | Execution began |
| workflow.progress | application/json | Step progress (throttled 500ms) |
| workflow.completed | multipart/form-data (gzip) | Output files + metadata |
| workflow.error | application/json | Execution failed |
**Note**: Completion payload is gzip compressed. Decompress before parsing.
## Example
**workflow.json** (see `example_workflow.json`):
```json
{
"10": {
"class_type": "WebhookReceiver",
"inputs": {"debug": true}
},
"11": {
"class_type": "WebhookTransformer",
"inputs": {"field": "prompt", "webhook_context": ["10", 0]}
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["11", 0], "clip": ["4", 1]}
},
"...": "... rest of workflow ...",
"12": {
"class_type": "WebhookSend",
"inputs": {"webhook_context": ["10", 0], "field_1": ["8", 0]}
}
}
```
**Python client** (see `example_client.py`):
```python
import requests, json
# Load image for img2img
image_data = open("input.png", "rb").read()
response = requests.post(
"http://localhost:8188/api/webhook",
files={
"workflow": (None, json.dumps(workflow), "application/json"),
"callback_url": (None, "http://localhost:5001/webhook"),
"prompt": (None, "make it look like a painting"),
"image": ("input.png", image_data, "image/png"),
}
)
```
## Testing
```bash
# Terminal 1: Start ComfyUI
python main.py
# Terminal 2: Run example client (image-to-image)
cd custom_nodes/comfy-workflow-api
python example_client.py sample_input.png "turn it into a watercolor painting"
```
A sample image (`sample_input.png`) is included in the repo for testing.
## License
MIT
+77
View File
@@ -0,0 +1,77 @@
"""
ComfyUI Workflow API - Server extension for workflow integration.
Provides:
1. HTTP endpoint: POST /api/webhook for submitting workflows
2. WebhookReceiver node - Entry point for API-triggered workflows
3. WebhookTransformer node - Extract individual fields by name
4. WebhookSend node - Send outputs back to callback URL
5. Real-time progress updates via callback URL
"""
__version__ = "1.0.0"
import logging
logger = logging.getLogger(__name__)
# Import nodes
from .nodes.receiver import WebhookReceiver
from .nodes.transformer import WebhookTransformer
from .nodes.send import WebhookSend
# Node mappings for ComfyUI
NODE_CLASS_MAPPINGS = {
"WebhookReceiver": WebhookReceiver,
"WebhookTransformer": WebhookTransformer,
"WebhookSend": WebhookSend,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WebhookReceiver": "Workflow API Receiver",
"WebhookTransformer": "Workflow API Transformer",
"WebhookSend": "Workflow API Send",
}
# Initialize server extension
_initialized = False
def _initialize():
"""Initialize server extension and interceptor hooks."""
global _initialized
if _initialized:
return
try:
# Register HTTP routes
from .server.routes import register_routes
if register_routes():
logger.info(f"[comfy-workflow-api] v{__version__} - Routes registered")
# Install output interceptor for automatic webhook delivery
from .core.interceptor import install_output_interceptor
if install_output_interceptor():
logger.info(f"[comfy-workflow-api] v{__version__} - Output interceptor installed")
_initialized = True
logger.info(f"[comfy-workflow-api] v{__version__} loaded successfully")
except Exception as e:
logger.error(f"[comfy-workflow-api] Failed to initialize: {e}", exc_info=True)
# Initialize on import
_initialize()
# Cleanup function
def _cleanup():
"""Cleanup on unload."""
try:
from .core.interceptor import shutdown, uninstall_output_interceptor
uninstall_output_interceptor()
shutdown()
except Exception as e:
logger.warning(f"[comfy-workflow-api] Cleanup error: {e}")
+45
View File
@@ -0,0 +1,45 @@
"""Core components for webhook functionality."""
from .types import WebhookContext, WebhookEvent, WebhookResult, OutputInfo, WebhookField
from .context import (
register_webhook_context,
get_webhook_context,
unregister_webhook_context,
get_all_contexts,
clear_all_contexts,
cleanup_old_contexts,
# Legacy aliases
register_context,
get_context,
get_context_by_prompt,
update_context,
unregister_context,
)
from .client import get_webhook_client, WebhookClient
from .collector import collect_outputs
__all__ = [
# Types
"WebhookContext",
"WebhookEvent",
"WebhookResult",
"OutputInfo",
"WebhookField",
# Context functions
"register_webhook_context",
"get_webhook_context",
"unregister_webhook_context",
"get_all_contexts",
"clear_all_contexts",
"cleanup_old_contexts",
# Legacy aliases
"register_context",
"get_context",
"get_context_by_prompt",
"update_context",
"unregister_context",
# Client
"get_webhook_client",
"WebhookClient",
"collect_outputs",
]
+472
View File
@@ -0,0 +1,472 @@
"""
Production-grade async HTTP client for webhook delivery.
Supports multipart/form-data for file uploads with retry logic.
"""
import asyncio
import gzip
import io
import json
import logging
import random
import time
from typing import Optional
from pathlib import Path
import aiohttp
from .types import WebhookResult
logger = logging.getLogger(__name__)
# Singleton client instance
_webhook_client: Optional["WebhookClient"] = None
def get_webhook_client() -> "WebhookClient":
"""Get or create the singleton webhook client instance."""
global _webhook_client
if _webhook_client is None:
_webhook_client = WebhookClient()
return _webhook_client
class WebhookClient:
"""
Production-grade async HTTP client for multipart webhook delivery.
Features:
- Async HTTP with connection pooling
- Multipart/form-data support for file uploads
- Exponential backoff with jitter for retries
- Configurable timeout and retry settings
"""
# Status codes that should trigger retry
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
def __init__(
self,
timeout: float = 60.0,
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
jitter_factor: float = 0.2,
max_connections: int = 10,
max_connections_per_host: int = 5,
):
"""
Initialize the webhook client.
Args:
timeout: Request timeout in seconds
max_retries: Maximum number of retry attempts
base_delay: Base delay for exponential backoff
max_delay: Maximum delay between retries
jitter_factor: Random jitter factor (0-1)
max_connections: Maximum total connections
max_connections_per_host: Maximum connections per host
"""
self._session: Optional[aiohttp.ClientSession] = None
self._session_loop: Optional[asyncio.AbstractEventLoop] = None
self.timeout = aiohttp.ClientTimeout(total=timeout)
self.max_retries = max_retries
self.base_delay = base_delay
self.max_delay = max_delay
self.jitter_factor = jitter_factor
self.max_connections = max_connections
self.max_connections_per_host = max_connections_per_host
async def get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session with connection pooling."""
current_loop = asyncio.get_running_loop()
# Create new session if none exists, if closed, or if event loop changed
if (self._session is None or
self._session.closed or
self._session_loop is not current_loop):
# Close old session if it exists and loop is still valid
if self._session and not self._session.closed:
try:
await self._session.close()
except Exception:
pass
connector = aiohttp.TCPConnector(
limit=self.max_connections,
limit_per_host=self.max_connections_per_host,
keepalive_timeout=30,
enable_cleanup_closed=True,
)
self._session = aiohttp.ClientSession(
connector=connector,
timeout=self.timeout,
)
self._session_loop = current_loop
return self._session
async def send_json(
self,
url: str,
payload: dict,
headers: Optional[dict] = None,
) -> WebhookResult:
"""
Send JSON payload to webhook URL.
Used for progress events, errors, and status updates.
Args:
url: Webhook URL
payload: JSON-serializable dictionary
headers: Optional additional headers (e.g., auth)
Returns:
WebhookResult with success status and details
"""
session = await self.get_session()
all_headers = {"Content-Type": "application/json"}
if headers:
all_headers.update(headers)
return await self._execute_with_retry(
url=url,
session=session,
request_kwargs={"json": payload, "headers": all_headers}
)
async def send_multipart(
self,
url: str,
metadata: dict,
files: list[tuple[str, str, bytes, str]],
headers: Optional[dict] = None,
timeout: Optional[float] = None,
max_retries: Optional[int] = None,
compress: bool = False,
) -> WebhookResult:
"""
Send multipart/form-data request with files.
Args:
url: Webhook URL
metadata: JSON metadata dictionary
files: List of (field_name, filename, binary_data, mime_type)
headers: Optional additional headers (e.g., auth)
timeout: Optional request timeout (overrides default)
max_retries: Optional max retries (overrides default)
compress: If True, compress payload with gzip
Returns:
WebhookResult with success status and details
"""
session = await self.get_session()
if compress:
# Build compressed payload
return await self._send_multipart_compressed(
url=url,
session=session,
metadata=metadata,
files=files,
headers=headers,
timeout=timeout,
max_retries=max_retries,
)
def build_form():
# Build FormData for each request (cannot reuse)
form = aiohttp.FormData()
# Add metadata as JSON part
form.add_field(
"metadata",
json.dumps(metadata),
content_type="application/json",
)
# Add files
for name, filename, data, content_type in files:
form.add_field(
name,
data,
filename=filename,
content_type=content_type,
)
return form
return await self._execute_with_retry(
url=url,
session=session,
request_kwargs={"headers": headers},
form_builder=build_form,
timeout=timeout,
max_retries=max_retries,
)
async def _send_multipart_compressed(
self,
url: str,
session: aiohttp.ClientSession,
metadata: dict,
files: list[tuple[str, str, bytes, str]],
headers: Optional[dict] = None,
timeout: Optional[float] = None,
max_retries: Optional[int] = None,
) -> WebhookResult:
"""
Send gzip-compressed multipart/form-data request.
Builds the multipart body, compresses it with gzip, and sends
with Content-Encoding: gzip header.
"""
import secrets
def build_compressed_payload():
# Generate a unique boundary
boundary = f"----WebhookBoundary{secrets.token_hex(16)}"
boundary_bytes = boundary.encode("utf-8")
# Build multipart body manually
parts = []
# Add metadata part
parts.append(b"--" + boundary_bytes)
parts.append(b'Content-Disposition: form-data; name="metadata"')
parts.append(b"Content-Type: application/json")
parts.append(b"")
parts.append(json.dumps(metadata).encode("utf-8"))
# Add file parts
for name, filename, data, content_type in files:
parts.append(b"--" + boundary_bytes)
disposition = f'Content-Disposition: form-data; name="{name}"; filename="{filename}"'
parts.append(disposition.encode("utf-8"))
parts.append(f"Content-Type: {content_type}".encode("utf-8"))
parts.append(b"")
parts.append(data)
# Final boundary
parts.append(b"--" + boundary_bytes + b"--")
parts.append(b"")
# Join with CRLF
body = b"\r\n".join(parts)
# Compress with gzip
compressed = gzip.compress(body, compresslevel=6)
return compressed, boundary
compressed_data, boundary = build_compressed_payload()
# Build headers
all_headers = {
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Encoding": "gzip",
"Content-Length": str(len(compressed_data)),
}
if headers:
all_headers.update(headers)
return await self._execute_with_retry(
url=url,
session=session,
request_kwargs={"headers": all_headers, "data": compressed_data},
timeout=timeout,
max_retries=max_retries,
)
async def send_multipart_streaming(
self,
url: str,
metadata: dict,
file_paths: list[tuple[str, str, str, str]],
headers: Optional[dict] = None,
) -> WebhookResult:
"""
Send multipart request with files streamed from disk.
Use this for large files (>50MB) to avoid memory issues.
Args:
url: Webhook URL
metadata: JSON metadata dictionary
file_paths: List of (field_name, filename, file_path, mime_type)
headers: Optional additional headers (e.g., auth)
Returns:
WebhookResult with success status and details
"""
session = await self.get_session()
def build_writer():
# Build multipart writer for streaming
writer = aiohttp.MultipartWriter("form-data")
# Add metadata
metadata_payload = aiohttp.payload.JsonPayload(metadata)
metadata_payload.set_content_disposition("form-data", name="metadata")
writer.append_payload(metadata_payload)
# Add files (streamed from disk)
for name, filename, path, content_type in file_paths:
file_path = Path(path)
if file_path.exists():
with open(file_path, "rb") as f:
file_data = f.read()
payload = aiohttp.payload.BytesPayload(
file_data,
content_type=content_type,
)
payload.set_content_disposition(
"form-data", name=name, filename=filename
)
writer.append_payload(payload)
return writer
return await self._execute_with_retry(
url=url,
session=session,
request_kwargs={"headers": headers},
data_builder=build_writer
)
async def _execute_with_retry(
self,
url: str,
session: aiohttp.ClientSession,
request_kwargs: Optional[dict] = None,
form_builder=None,
data_builder=None,
timeout: Optional[float] = None,
max_retries: Optional[int] = None,
) -> WebhookResult:
"""
Execute request with exponential backoff and jitter.
Args:
url: Target URL
session: aiohttp session
request_kwargs: Additional kwargs for session.post()
form_builder: Function that returns FormData (for multipart)
data_builder: Function that returns MultipartWriter (for streaming)
timeout: Optional request timeout (overrides default)
max_retries: Optional max retries (overrides default)
Returns:
WebhookResult with success status and details
"""
last_error = None
start_time = time.time()
retries = 0
kwargs = request_kwargs or {}
# Use instance defaults if not specified
effective_max_retries = max_retries if max_retries is not None else self.max_retries
# Add timeout to kwargs if specified
if timeout is not None:
kwargs["timeout"] = aiohttp.ClientTimeout(total=timeout)
for attempt in range(effective_max_retries + 1):
try:
if attempt > 0:
delay = self._calculate_delay(attempt)
logger.info(
f"Webhook retry {attempt}/{effective_max_retries} after {delay:.2f}s"
)
await asyncio.sleep(delay)
retries = attempt
# Build request data
post_kwargs = dict(kwargs)
if form_builder:
post_kwargs["data"] = form_builder()
elif data_builder:
post_kwargs["data"] = data_builder()
# Make the request
response = await session.post(url, **post_kwargs)
try:
elapsed_ms = (time.time() - start_time) * 1000
if response.status < 400:
return WebhookResult(
success=True,
status=response.status,
elapsed_ms=elapsed_ms,
retries=retries,
)
# Check if retryable
if response.status in self.RETRYABLE_STATUS_CODES:
last_error = f"HTTP {response.status}"
logger.warning(
f"Webhook request failed with retryable status {response.status}"
)
continue
# Non-retryable error
body = await response.text()
return WebhookResult(
success=False,
status=response.status,
error=f"HTTP {response.status}: {body[:500]}",
elapsed_ms=elapsed_ms,
retries=retries,
)
finally:
response.close()
except aiohttp.ClientError as e:
last_error = f"Client error: {str(e)}"
logger.warning(f"Webhook client error: {e}")
continue
except asyncio.TimeoutError:
last_error = "Request timeout"
logger.warning("Webhook request timed out")
continue
except Exception as e:
last_error = f"Unexpected error: {str(e)}"
logger.error(f"Webhook unexpected error: {e}", exc_info=True)
continue
elapsed_ms = (time.time() - start_time) * 1000
return WebhookResult(
success=False,
error=last_error,
elapsed_ms=elapsed_ms,
retries=retries,
)
def _calculate_delay(self, attempt: int) -> float:
"""
Calculate retry delay with exponential backoff and jitter.
Args:
attempt: Current attempt number (1-based)
Returns:
Delay in seconds
"""
# Exponential backoff
delay = min(self.base_delay * (2 ** (attempt - 1)), self.max_delay)
# Add jitter
jitter = delay * self.jitter_factor * (random.random() * 2 - 1)
return max(0.1, delay + jitter)
async def close(self):
"""Close the HTTP session and release resources."""
if self._session and not self._session.closed:
await self._session.close()
self._session = None
logger.debug("Webhook client session closed")
async def close_webhook_client():
"""Close the global webhook client instance."""
global _webhook_client
if _webhook_client:
await _webhook_client.close()
_webhook_client = None
+237
View File
@@ -0,0 +1,237 @@
"""
Output collector for webhook delivery.
IMPORTANT: This collector does NOT re-encode outputs.
It reads the actual files saved by ComfyUI output nodes and sends them as-is.
- User saves as WebP → WebP is sent
- User saves as FLAC → FLAC is sent
- User saves as GIF → GIF is sent
Zero information loss, zero re-encoding overhead.
"""
import logging
import os
from typing import Optional
from .types import OutputInfo
from ..utils.mime import guess_mime_type
from ..utils.files import get_output_path, get_image_dimensions
logger = logging.getLogger(__name__)
async def collect_outputs(
outputs: dict,
meta: Optional[dict] = None
) -> tuple[list[tuple[str, str, bytes, str]], list[dict]]:
"""
Collect all outputs from a workflow execution.
This function reads the ACTUAL files saved by output nodes.
No re-encoding - files are sent exactly as saved.
Args:
outputs: Dictionary mapping node_id -> UI output dict
e.g., {"9": {"images": [{"filename": "out.webp", ...}]}}
meta: Optional metadata dict mapping node_id -> node metadata
Returns:
Tuple of:
- files: List of (field_name, filename, data, mime_type) tuples
- metadata: List of output info dicts for the metadata payload
"""
files = []
metadata = []
file_index = 0
for node_id, ui_output in outputs.items():
if not ui_output or not isinstance(ui_output, dict):
continue
# Get node type from metadata if available
node_type = None
if meta and node_id in meta:
node_meta = meta[node_id]
if isinstance(node_meta, dict):
node_type = node_meta.get("node_type")
# Process all output keys generically
# ComfyUI uses: images, audio, video, gifs, 3d, latents, text, etc.
for output_key, items in ui_output.items():
if not isinstance(items, list):
# Handle non-list outputs (e.g., text as tuple)
if output_key == "text":
text_content = _extract_text(items)
if text_content:
info = OutputInfo(
filename=f"text_{node_id}.txt",
mime_type="text/plain",
node_id=node_id,
node_type=node_type,
output_key=output_key,
file_size_bytes=len(text_content.encode("utf-8")),
)
files.append((
f"file_{file_index}",
info.filename,
text_content.encode("utf-8"),
info.mime_type,
))
metadata.append(info.to_dict())
file_index += 1
continue
# Process list of file references
for idx, item in enumerate(items):
if not isinstance(item, dict):
continue
if "filename" not in item:
continue
# This is a file reference
filename = item["filename"]
subfolder = item.get("subfolder", "")
folder_type = item.get("type", "output")
# Get full path and read file
file_path = get_output_path(filename, subfolder, folder_type)
if not os.path.exists(file_path):
logger.warning(f"Output file not found: {file_path}")
continue
try:
with open(file_path, "rb") as f:
file_data = f.read()
except Exception as e:
logger.error(f"Failed to read output file {file_path}: {e}")
continue
# Determine MIME type from actual filename
mime_type = guess_mime_type(filename)
# Build output info
info = OutputInfo(
filename=filename,
mime_type=mime_type,
node_id=node_id,
node_type=node_type,
output_key=output_key,
file_size_bytes=len(file_data),
subfolder=subfolder if subfolder else None,
folder_type=folder_type,
)
# Add type-specific metadata
_enrich_output_info(info, file_path, file_data)
# Add batch info if multiple items
if len(items) > 1:
info.batch_index = idx
info.batch_size = len(items)
files.append((
f"file_{file_index}",
filename,
file_data,
mime_type,
))
metadata.append(info.to_dict())
file_index += 1
logger.debug(f"Collected {len(files)} output files")
return files, metadata
def _extract_text(value) -> Optional[str]:
"""Extract text content from various formats."""
if isinstance(value, str):
return value
if isinstance(value, tuple) and len(value) > 0:
return str(value[0])
if isinstance(value, list) and len(value) > 0:
return str(value[0])
return None
def _enrich_output_info(info: OutputInfo, file_path: str, file_data: bytes):
"""
Add type-specific metadata to OutputInfo.
This reads metadata from the file without modifying it.
"""
# Extract format from filename
if "." in info.filename:
info.format = info.filename.rsplit(".", 1)[1].lower()
# Image dimensions
if info.mime_type.startswith("image/"):
try:
width, height = get_image_dimensions(file_path)
if width and height:
info.width = width
info.height = height
except Exception as e:
logger.debug(f"Could not get image dimensions: {e}")
# Audio metadata
elif info.mime_type.startswith("audio/"):
try:
_extract_audio_metadata(info, file_data)
except Exception as e:
logger.debug(f"Could not get audio metadata: {e}")
# Video metadata
elif info.mime_type.startswith("video/"):
try:
_extract_video_metadata(info, file_path)
except Exception as e:
logger.debug(f"Could not get video metadata: {e}")
def _extract_audio_metadata(info: OutputInfo, file_data: bytes):
"""Extract audio metadata without decoding the full file."""
# Simple detection based on file headers
if file_data[:4] == b"fLaC":
# FLAC - could parse header for sample rate
pass
elif file_data[:4] == b"RIFF":
# WAV - parse header
try:
import struct
if len(file_data) >= 44:
# Channels at offset 22 (2 bytes)
# Sample rate at offset 24 (4 bytes)
sample_rate = struct.unpack("<I", file_data[24:28])[0]
info.sample_rate = sample_rate
except Exception:
pass
def _extract_video_metadata(info: OutputInfo, file_path: str):
"""Extract video metadata using available tools."""
try:
import av
container = av.open(file_path)
video_stream = container.streams.video[0]
info.width = video_stream.width
info.height = video_stream.height
if video_stream.average_rate:
info.fps = float(video_stream.average_rate)
if video_stream.frames:
info.frame_count = video_stream.frames
if video_stream.duration and video_stream.time_base:
info.duration_seconds = float(video_stream.duration * video_stream.time_base)
container.close()
except ImportError:
pass
except Exception as e:
logger.debug(f"av video metadata extraction failed: {e}")
+198
View File
@@ -0,0 +1,198 @@
"""
Webhook context registry for managing active webhook requests.
Provides thread-safe storage keyed by prompt_id.
"""
import threading
import logging
from typing import Dict, Optional
from datetime import datetime, timedelta
from .types import WebhookContext
logger = logging.getLogger(__name__)
# Thread-safe registry keyed by prompt_id
_lock = threading.RLock()
_contexts: Dict[str, WebhookContext] = {}
def register_webhook_context(context: WebhookContext) -> None:
"""
Register a webhook context for tracking.
Args:
context: The WebhookContext to register
"""
with _lock:
if context.prompt_id:
_contexts[context.prompt_id] = context
context.start_time = datetime.now()
logger.debug(
f"Registered webhook context: prompt={context.prompt_id}, "
f"request={context.request_id}"
)
else:
logger.warning("Cannot register context without prompt_id")
def get_webhook_context(prompt_id: str) -> Optional[WebhookContext]:
"""
Get a context by prompt ID.
Args:
prompt_id: The ComfyUI prompt ID
Returns:
The WebhookContext if found, None otherwise
"""
with _lock:
return _contexts.get(prompt_id)
def unregister_webhook_context(prompt_id: str) -> Optional[WebhookContext]:
"""
Remove and return a context from the registry.
Args:
prompt_id: The prompt ID to unregister
Returns:
The removed WebhookContext if found, None otherwise
"""
with _lock:
context = _contexts.pop(prompt_id, None)
if context:
logger.debug(f"Unregistered webhook context: {prompt_id}")
return context
def get_all_contexts() -> Dict[str, WebhookContext]:
"""
Get a copy of all registered contexts.
Useful for debugging and monitoring.
Returns:
Dictionary mapping prompt_id to WebhookContext
"""
with _lock:
return dict(_contexts)
def clear_all_contexts() -> int:
"""
Clear all registered contexts.
Useful for testing and cleanup.
Returns:
Number of contexts that were cleared
"""
with _lock:
count = len(_contexts)
_contexts.clear()
logger.debug(f"Cleared {count} webhook contexts")
return count
def cleanup_old_contexts(max_age_hours: int = 24) -> int:
"""
Remove old completed contexts.
Args:
max_age_hours: Remove contexts older than this
Returns:
Number of contexts removed
"""
cutoff = datetime.now() - timedelta(hours=max_age_hours)
count = 0
with _lock:
to_remove = []
for prompt_id, context in _contexts.items():
# Clean up old contexts based on completed_at or start_time
if context.completed_at and context.completed_at < cutoff:
to_remove.append(prompt_id)
elif context.start_time and context.start_time < cutoff:
to_remove.append(prompt_id)
for prompt_id in to_remove:
_contexts.pop(prompt_id, None)
count += 1
if count > 0:
logger.info(f"Cleaned up {count} old webhook contexts")
return count
# Legacy function aliases for compatibility
def register_context(context: WebhookContext) -> None:
"""Legacy alias for register_webhook_context."""
register_webhook_context(context)
def get_context(request_id: str) -> Optional[WebhookContext]:
"""
Get a context by request ID (searches all contexts).
Args:
request_id: The request ID to look up
Returns:
The WebhookContext if found, None otherwise
"""
with _lock:
for context in _contexts.values():
if context.request_id == request_id:
return context
return None
def get_context_by_prompt(prompt_id: str) -> Optional[WebhookContext]:
"""Legacy alias for get_webhook_context."""
return get_webhook_context(prompt_id)
def update_context(request_id: str, **kwargs) -> bool:
"""
Update fields on a context by request ID.
Args:
request_id: The request ID to update
**kwargs: Fields to update
Returns:
True if context was found and updated
"""
with _lock:
for context in _contexts.values():
if context.request_id == request_id:
for key, value in kwargs.items():
if hasattr(context, key):
setattr(context, key, value)
return True
return False
def unregister_context(request_id: str) -> Optional[WebhookContext]:
"""
Remove a context by request ID.
Args:
request_id: The request ID to unregister
Returns:
The removed WebhookContext if found, None otherwise
"""
with _lock:
to_remove = None
for prompt_id, context in _contexts.items():
if context.request_id == request_id:
to_remove = prompt_id
break
if to_remove:
return _contexts.pop(to_remove, None)
return None
+571
View File
@@ -0,0 +1,571 @@
"""Output interception system for webhook delivery and progress events."""
import logging
import traceback
import time
import asyncio
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from typing import Any, Callable, Optional
import uuid
from .types import WebhookContext, WebhookEvent, OutputInfo
from .context import get_webhook_context, unregister_webhook_context
from .client import get_webhook_client
logger = logging.getLogger(__name__)
_original_task_done = None
_original_progress_hook = None
_interceptor_installed = False
_progress_hook_installed = False
_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="webhook_")
# Progress throttling - track last send time per prompt
_progress_last_sent: dict = {}
_PROGRESS_THROTTLE_MS = 500 # Minimum ms between progress updates
def install_output_interceptor() -> bool:
"""Install the output interceptor into ComfyUI's execution system."""
global _original_task_done, _interceptor_installed
if _interceptor_installed:
return True
try:
import execution
_original_task_done = execution.PromptQueue.task_done
execution.PromptQueue.task_done = _intercepted_task_done
_interceptor_installed = True
logger.info("Webhook output interceptor installed")
# Also install progress hook
install_progress_hook()
return True
except Exception as e:
logger.error(f"Failed to install output interceptor: {e}")
return False
def install_progress_hook() -> bool:
"""Install progress hook by wrapping the ProgressBar class."""
global _progress_hook_installed
if _progress_hook_installed:
return True
try:
import comfy.utils
# Wrap the ProgressBar.update_absolute method to intercept progress
original_update = comfy.utils.ProgressBar.update_absolute
def wrapped_update(self, value, total=None, preview=None):
# Call original first
result = original_update(self, value, total, preview)
# Then forward to webhook if applicable
try:
_handle_progress_update(value, self.total, preview, self.node_id)
except Exception as e:
logger.debug(f"Progress webhook error: {e}")
return result
comfy.utils.ProgressBar.update_absolute = wrapped_update
_progress_hook_installed = True
logger.info("Webhook progress hook installed")
return True
except Exception as e:
logger.error(f"Failed to install progress hook: {e}")
return False
def _handle_progress_update(value, total, preview_image, node_id):
"""Handle progress update and forward to webhooks."""
global _progress_last_sent
# Get the current prompt_id from ComfyUI's server
try:
from server import PromptServer
prompt_id = PromptServer.instance.last_prompt_id
except Exception:
return
if not prompt_id:
return
# Look up webhook context for this prompt
context = get_webhook_context(prompt_id)
if context is None or not context.callback_url or not context.send_progress:
return
# Throttle progress updates
now_ms = time.time() * 1000
last_sent = _progress_last_sent.get(prompt_id, 0)
# Always send first and last updates
is_first = value <= 1
is_last = value >= total
if not is_first and not is_last:
if now_ms - last_sent < _PROGRESS_THROTTLE_MS:
return
_progress_last_sent[prompt_id] = now_ms
# Send progress event asynchronously
_executor.submit(
_send_progress_sync,
context.callback_url,
context.request_id,
prompt_id,
node_id or "unknown",
value,
total,
context.get_auth_headers() if hasattr(context, 'get_auth_headers') else {}
)
def _webhook_progress_hook(value, total, preview_image=None, prompt_id=None, node_id=None):
"""Legacy progress hook wrapper - kept for compatibility."""
global _original_progress_hook, _progress_last_sent
# Always call the original hook first (ComfyUI's websocket progress)
if _original_progress_hook is not None:
try:
_original_progress_hook(value, total, preview_image, prompt_id, node_id)
except Exception as e:
logger.warning(f"Original progress hook error: {e}")
# Now handle webhook progress
if prompt_id is None:
return
# Look up webhook context for this prompt
context = get_webhook_context(prompt_id)
if context is None or not context.callback_url or not context.send_progress:
return
# Throttle progress updates
now_ms = time.time() * 1000
last_sent = _progress_last_sent.get(prompt_id, 0)
# Always send first (value=1) and last (value=total) updates
is_first = value == 1 or value == 0
is_last = value >= total
if not is_first and not is_last:
if now_ms - last_sent < _PROGRESS_THROTTLE_MS:
return
_progress_last_sent[prompt_id] = now_ms
# Send progress event asynchronously
_executor.submit(
_send_progress_sync,
context.callback_url,
context.request_id,
prompt_id,
node_id or "unknown",
value,
total,
context.get_auth_headers() if hasattr(context, 'get_auth_headers') else {}
)
def _send_progress_sync(callback_url, request_id, prompt_id, node_id, value, total, headers):
"""Send progress event synchronously (for thread pool)."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
_send_progress_async(callback_url, request_id, prompt_id, node_id, value, total, headers)
)
except Exception as e:
logger.debug(f"Progress send error: {e}")
finally:
loop.close()
async def _send_progress_async(callback_url, request_id, prompt_id, node_id, value, total, headers):
"""Send progress event to callback URL."""
payload = {
"event": WebhookEvent.PROGRESS.value,
"request_id": request_id,
"prompt_id": prompt_id,
"timestamp": datetime.now().isoformat(),
"node_id": node_id,
"progress": {
"value": value,
"max": total
}
}
try:
client = get_webhook_client()
result = await client.send_json(
url=callback_url,
payload=payload,
headers=headers
)
if result.success:
logger.debug(f"Progress {value}/{total} sent for {prompt_id}")
else:
logger.debug(f"Progress send failed: {result.error}")
except Exception as e:
logger.debug(f"Progress send exception: {e}")
def uninstall_output_interceptor() -> bool:
"""Uninstall the output interceptor."""
global _original_task_done, _interceptor_installed
if not _interceptor_installed:
return True
try:
import execution
if _original_task_done is not None:
execution.PromptQueue.task_done = _original_task_done
_original_task_done = None
_interceptor_installed = False
return True
except Exception as e:
logger.error(f"Failed to uninstall output interceptor: {e}")
return False
def _intercepted_task_done(self, item_id: Any, history_result: dict, status: Any, process_item: Optional[Callable] = None):
"""Intercepted task_done to send completion events."""
global _original_task_done
prompt_tuple = self.currently_running.get(item_id)
prompt_id = None
extra_data = {}
if prompt_tuple:
prompt_id = prompt_tuple[1] if len(prompt_tuple) > 1 else None
extra_data = prompt_tuple[3] if len(prompt_tuple) > 3 else {}
# Check if this is a webhook request (has webhook_config in extra_data)
webhook_config = extra_data.get("webhook_config") if isinstance(extra_data, dict) else None
if webhook_config and prompt_id:
try:
outputs = history_result.get("outputs", {})
meta = history_result.get("meta", {})
# Submit webhook delivery to thread pool
_executor.submit(
_send_webhook_completion_sync,
webhook_config,
prompt_id,
outputs,
meta,
status
)
logger.debug(f"Webhook delivery queued for prompt {prompt_id}")
except Exception as e:
logger.error(f"Failed to queue webhook send: {e}")
# Call original task_done - MUST happen for ComfyUI to work correctly
if _original_task_done:
return _original_task_done(self, item_id, history_result, status, process_item)
def _send_webhook_completion_sync(
webhook_config: dict,
prompt_id: str,
outputs: dict,
meta: dict,
status: Any
):
"""Send webhook completion in sync context (for thread pool)."""
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
_send_webhook_completion(webhook_config, prompt_id, outputs, meta, status)
)
except Exception as e:
logger.error(f"Webhook delivery failed for prompt {prompt_id}: {e}\n{traceback.format_exc()}")
finally:
loop.close()
async def _send_webhook_completion(
webhook_config: dict,
prompt_id: str,
outputs: dict,
meta: dict,
status: Any
):
"""Send completion event with outputs to callback URL."""
from ..processors.outputs import (
process_outputs_for_webhook,
load_output_file,
enrich_output_info
)
callback_url = webhook_config.get("callback_url")
if not callback_url:
logger.debug(f"No callback_url in webhook_config for prompt {prompt_id}")
return
request_id = webhook_config.get("request_id", str(uuid.uuid4()))
start_time = datetime.now()
try:
# Process all outputs from all nodes
all_outputs = process_outputs_for_webhook(outputs, meta)
if not all_outputs:
all_outputs = []
# Enrich outputs with dimensions, file sizes
for output in all_outputs:
try:
enrich_output_info(output)
except Exception as e:
logger.warning(f"Failed to enrich output {output.filename}: {e}")
# Build files list for multipart
files = []
output_metadata = []
for idx, output in enumerate(all_outputs):
output_metadata.append(output.to_dict())
if output.filename:
try:
file_data = load_output_file(output)
if file_data:
files.append((
f"file_{idx}",
output.filename,
file_data,
output.mime_type or "application/octet-stream"
))
except Exception as e:
logger.warning(f"Failed to load output file {output.filename}: {e}")
# Extract status information
status_str = "unknown"
status_messages = []
if status is not None:
if hasattr(status, "status_str"):
status_str = status.status_str
elif hasattr(status, "_asdict"):
status_dict = status._asdict()
status_str = status_dict.get("status_str", "unknown")
status_messages = status_dict.get("messages", [])
# Calculate execution time from context if available
execution_time_ms = None
context = get_webhook_context(prompt_id)
if context and context.start_time:
execution_time_ms = (datetime.now() - context.start_time).total_seconds() * 1000
# Build metadata payload
event_type = WebhookEvent.COMPLETED.value if status_str == "success" else WebhookEvent.ERROR.value
metadata = {
"event": event_type,
"request_id": request_id,
"prompt_id": prompt_id,
"timestamp": datetime.now().isoformat(),
"status": status_str,
"outputs": output_metadata,
"output_count": len(all_outputs),
"file_count": len(files),
"execution_time_ms": execution_time_ms,
"nodes_executed": len(outputs),
}
if status_messages:
metadata["messages"] = status_messages
# Get auth headers
headers = _get_auth_headers(webhook_config)
# Send the webhook
client = get_webhook_client()
if files:
result = await client.send_multipart(
url=callback_url,
metadata=metadata,
files=files,
headers=headers
)
else:
result = await client.send_json(
url=callback_url,
payload=metadata,
headers=headers
)
if result.success:
logger.info(
f"Webhook delivered for prompt {prompt_id}: "
f"{len(files)} files, {len(output_metadata)} outputs, "
f"status={result.status}, {result.elapsed_ms:.0f}ms"
)
else:
logger.error(
f"Webhook failed for prompt {prompt_id}: "
f"status={result.status}, error={result.error}"
)
except Exception as e:
logger.error(f"Error sending webhook for prompt {prompt_id}: {e}\n{traceback.format_exc()}")
finally:
# Clean up context
try:
unregister_webhook_context(prompt_id)
except Exception:
pass
def _get_auth_headers(webhook_config: dict) -> dict:
"""Build authentication headers from webhook config."""
headers = {}
auth_header = webhook_config.get("auth_header")
auth_value = webhook_config.get("auth_value")
if auth_header and auth_value:
headers[auth_header] = auth_value
custom_headers = webhook_config.get("headers")
if isinstance(custom_headers, dict):
headers.update(custom_headers)
return headers
async def send_progress_event(
context: WebhookContext,
node_id: str,
node_type: str,
progress: float,
message: str = ""
):
"""Send a progress event to the callback URL."""
if not context.send_progress or not context.callback_url:
return
elapsed_ms = None
if context.start_time:
elapsed_ms = (datetime.now() - context.start_time).total_seconds() * 1000
payload = {
"event": WebhookEvent.PROGRESS.value,
"request_id": context.request_id,
"prompt_id": context.prompt_id,
"timestamp": datetime.now().isoformat(),
"node_id": node_id,
"node_type": node_type,
"progress": progress,
"message": message,
"elapsed_ms": elapsed_ms,
}
try:
client = get_webhook_client()
result = await client.send_json(
url=context.callback_url,
payload=payload,
headers=context.get_auth_headers()
)
if not result.success:
logger.warning(f"Progress event failed: {result.error}")
except Exception as e:
logger.warning(f"Failed to send progress event: {e}")
async def send_started_event(context: WebhookContext):
"""Send workflow started event."""
if not context.callback_url:
return
payload = {
"event": WebhookEvent.STARTED.value,
"request_id": context.request_id,
"prompt_id": context.prompt_id,
"timestamp": datetime.now().isoformat(),
}
try:
client = get_webhook_client()
await client.send_json(
url=context.callback_url,
payload=payload,
headers=context.get_auth_headers()
)
except Exception as e:
logger.warning(f"Failed to send started event: {e}")
async def send_error_event(
context: WebhookContext,
error_type: str,
error_message: str,
node_id: Optional[str] = None,
node_type: Optional[str] = None,
traceback_str: Optional[str] = None
):
"""Send workflow error event."""
if not context.callback_url:
return
elapsed_ms = None
if context.start_time:
elapsed_ms = (datetime.now() - context.start_time).total_seconds() * 1000
payload = {
"event": WebhookEvent.ERROR.value,
"request_id": context.request_id,
"prompt_id": context.prompt_id,
"timestamp": datetime.now().isoformat(),
"error": {
"type": error_type,
"message": error_message,
"node_id": node_id,
"node_type": node_type,
},
"elapsed_ms": elapsed_ms,
}
if traceback_str:
payload["error"]["traceback"] = traceback_str[:2000]
try:
client = get_webhook_client()
await client.send_json(
url=context.callback_url,
payload=payload,
headers=context.get_auth_headers()
)
except Exception as e:
logger.warning(f"Failed to send error event: {e}")
def shutdown():
"""Shutdown the webhook executor gracefully."""
global _executor
try:
_executor.shutdown(wait=True, cancel_futures=False)
logger.info("Webhook executor shut down")
except Exception as e:
logger.warning(f"Error shutting down webhook executor: {e}")
+189
View File
@@ -0,0 +1,189 @@
"""Core data types for webhook functionality."""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, Optional
from datetime import datetime
import uuid
class WebhookEvent(Enum):
"""Types of webhook events."""
STARTED = "workflow.started"
PROGRESS = "workflow.progress"
COMPLETED = "workflow.completed"
ERROR = "workflow.error"
INTERRUPTED = "workflow.interrupted"
@dataclass
class WebhookField:
"""Individual field from webhook request."""
name: str
value: Any
is_file: bool = False
filename: Optional[str] = None
content_type: Optional[str] = None
size_bytes: Optional[int] = None
@dataclass
class WebhookContext:
"""
Context for a webhook request.
Tracks the entire lifecycle of a webhook request from submission
through execution and completion.
"""
# Callback configuration
callback_url: str = ""
auth_header: Optional[str] = None
auth_value: Optional[str] = None
timeout: int = 60
max_retries: int = 3
# Identity
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
prompt_id: Optional[str] = None
# Parsed webhook fields
fields: Dict[str, WebhookField] = field(default_factory=dict)
# Raw inputs for fallback
inputs: dict = field(default_factory=dict)
# Progress settings
send_progress: bool = True
progress_interval_ms: int = 500
# Request metadata
remote_ip: Optional[str] = None
content_type: Optional[str] = None
content_length: Optional[int] = None
user_agent: Optional[str] = None
# Workflow settings
include_workflow_in_response: bool = False
# Timestamps
timestamp: datetime = field(default_factory=datetime.now)
start_time: Optional[datetime] = None
completed_at: Optional[datetime] = None
def get_auth_headers(self) -> dict:
"""Get authentication headers for HTTP requests."""
if self.auth_header and self.auth_value:
return {self.auth_header: self.auth_value}
return {}
def censor_auth(self) -> str:
"""Get censored auth header for logging."""
if self.auth_header and self.auth_value:
value = self.auth_value
if len(value) > 12:
censored = value[:4] + "*" * (len(value) - 8) + value[-4:]
else:
censored = "*" * len(value)
return f"{self.auth_header}: {censored}"
return "(not set)"
def has_field(self, name: str) -> bool:
"""Check if a field exists."""
return name in self.fields or name in self.inputs
def get_field(self, name: str) -> Optional[WebhookField]:
"""Get a WebhookField by name."""
return self.fields.get(name)
def get_field_value(self, name: str, default: Any = None) -> Any:
"""Get a field's value."""
if name in self.fields:
return self.fields[name].value
if name in self.inputs:
return self.inputs[name]
return default
def get_field_names(self) -> list:
"""Get all field names."""
names = set(self.fields.keys())
names.update(self.inputs.keys())
return sorted(names)
def to_dict(self) -> dict:
"""Convert to dictionary for serialization."""
return {
"request_id": self.request_id,
"prompt_id": self.prompt_id,
"callback_url": self.callback_url,
"fields": list(self.fields.keys()),
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
}
@dataclass
class WebhookResult:
"""Result of a webhook HTTP request."""
success: bool
status: Optional[int] = None
error: Optional[str] = None
response_body: Optional[str] = None
elapsed_ms: float = 0.0
retries: int = 0
@dataclass
class OutputInfo:
"""Information about a collected output file."""
# Required fields
type: str = "UNKNOWN"
filename: str = ""
mime_type: str = "application/octet-stream"
# File info
file_size_bytes: Optional[int] = None
# Source info (for automatic collection)
node_id: Optional[str] = None
node_type: Optional[str] = None
output_key: Optional[str] = None
subfolder: Optional[str] = None
folder_type: str = "output"
# Type-specific metadata
width: Optional[int] = None
height: Optional[int] = None
channels: Optional[int] = None
dtype: Optional[str] = None
format: Optional[str] = None
# Audio/Video metadata
duration_seconds: Optional[float] = None
sample_rate: Optional[int] = None
frame_count: Optional[int] = None
fps: Optional[float] = None
# Batch info
batch_index: Optional[int] = None
batch_size: Optional[int] = None
# Inline content for small text
inline_content: Optional[str] = None
def to_dict(self) -> dict:
"""Convert to dictionary for metadata payload."""
result = {
"type": self.type,
"filename": self.filename,
"mime_type": self.mime_type,
}
# Add optional fields if present
for key in ["file_size_bytes", "node_id", "node_type", "output_key",
"width", "height", "channels", "dtype", "format",
"duration_seconds", "sample_rate", "frame_count", "fps",
"batch_index", "batch_size", "inline_content"]:
val = getattr(self, key)
if val is not None:
result[key] = val
return result
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""
Example client for comfy-workflow-api (Image-to-Image).
Submits an image + prompt to ComfyUI and receives the transformed image via webhook.
Usage:
python example_client.py <image_path> [prompt]
Examples:
python example_client.py input.png "make it look like a painting"
python example_client.py photo.jpg "add snow to the scene"
Requirements:
pip install requests
Make sure ComfyUI is running on http://localhost:8188
"""
import gzip
import json
import sys
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
import requests
# Configuration
COMFYUI_URL = "http://localhost:8188"
CALLBACK_PORT = 5001
OUTPUT_DIR = Path("./outputs")
WORKFLOW_FILE = Path(__file__).parent / "example_workflow.json"
class WebhookHandler(BaseHTTPRequestHandler):
"""Handles webhook callbacks from ComfyUI."""
files_received = []
done = threading.Event()
def log_message(self, *args):
pass # Suppress logging
def do_POST(self):
content_type = self.headers.get("Content-Type", "")
content_encoding = self.headers.get("Content-Encoding", "")
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
# Decompress gzip if needed (WebhookSend uses gzip compression)
if "gzip" in content_encoding:
body = gzip.decompress(body)
if "application/json" in content_type:
data = json.loads(body)
event = data.get("event")
if event == "workflow.progress":
progress = data.get("progress", {})
print(f" Progress: {progress.get('value')}/{progress.get('max')}")
elif event == "workflow.completed":
print(f" Completed in {data.get('execution_time_ms', 'N/A')}ms")
elif event == "workflow.error":
print(f" Error: {data.get('error')}")
WebhookHandler.done.set()
elif "multipart" in content_type:
# Parse multipart and save files
boundary = content_type.split("boundary=")[1]
parts = body.split(f"--{boundary}".encode())
OUTPUT_DIR.mkdir(exist_ok=True)
for part in parts:
if b"filename=" in part and b"\r\n\r\n" in part:
header, content = part.split(b"\r\n\r\n", 1)
filename = header.decode().split('filename="')[1].split('"')[0]
content = content.rstrip(b"\r\n--")
path = OUTPUT_DIR / filename
path.write_bytes(content)
WebhookHandler.files_received.append(str(path))
print(f" Saved: {path}")
WebhookHandler.done.set()
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK")
def main():
if len(sys.argv) < 2:
print("Usage: python example_client.py <image_path> [prompt]")
print("Example: python example_client.py input.png \"make it look like a painting\"")
sys.exit(1)
image_path = Path(sys.argv[1])
if not image_path.exists():
print(f"Error: Image not found: {image_path}")
sys.exit(1)
prompt = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else "enhance the image"
print(f"Image: {image_path}")
print(f"Prompt: {prompt}")
print(f"Output: {OUTPUT_DIR.absolute()}\n")
# Load workflow
workflow = json.loads(WORKFLOW_FILE.read_text())
# Load image
image_data = image_path.read_bytes()
image_mime = "image/png" if image_path.suffix.lower() == ".png" else "image/jpeg"
# Start webhook server
server = HTTPServer(("localhost", CALLBACK_PORT), WebhookHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
callback_url = f"http://localhost:{CALLBACK_PORT}/webhook"
# Submit workflow with image and prompt
print("Submitting workflow...")
response = requests.post(
f"{COMFYUI_URL}/api/webhook",
files={
"workflow": (None, json.dumps(workflow), "application/json"),
"callback_url": (None, callback_url),
"prompt": (None, prompt),
"image": (image_path.name, image_data, image_mime),
}
)
result = response.json()
print(f" Request ID: {result.get('request_id')}")
print(f" Prompt ID: {result.get('prompt_id')}\n")
# Wait for completion
print("Processing...")
WebhookHandler.done.wait(timeout=300)
server.shutdown()
if WebhookHandler.files_received:
print(f"\nDone! Files saved to {OUTPUT_DIR.absolute()}")
else:
print("\nNo files received.")
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
{
"3": {
"inputs": {
"seed": 798343486140665,
"steps": 20,
"cfg": 7,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 0.7,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0]
},
"class_type": "KSampler",
"_meta": {"title": "KSampler"}
},
"4": {
"inputs": {
"ckpt_name": "v1-5-pruned-emaonly-fp16.safetensors"
},
"class_type": "CheckpointLoaderSimple",
"_meta": {"title": "Load Checkpoint"}
},
"5": {
"inputs": {
"pixels": ["13", 0],
"vae": ["4", 2]
},
"class_type": "VAEEncode",
"_meta": {"title": "VAE Encode"}
},
"6": {
"inputs": {
"text": ["11", 0],
"clip": ["4", 1]
},
"class_type": "CLIPTextEncode",
"_meta": {"title": "CLIP Text Encode (Prompt)"}
},
"7": {
"inputs": {
"text": "text, watermark, blurry, low quality",
"clip": ["4", 1]
},
"class_type": "CLIPTextEncode",
"_meta": {"title": "CLIP Text Encode (Negative)"}
},
"8": {
"inputs": {
"samples": ["3", 0],
"vae": ["4", 2]
},
"class_type": "VAEDecode",
"_meta": {"title": "VAE Decode"}
},
"10": {
"inputs": {
"default_callback_url": "",
"default_timeout": 60,
"default_max_retries": 3,
"debug": true
},
"class_type": "WebhookReceiver",
"_meta": {"title": "Webhook Receiver"}
},
"11": {
"inputs": {
"field": "prompt",
"debug": true,
"webhook_context": ["10", 0]
},
"class_type": "WebhookTransformer",
"_meta": {"title": "Extract Prompt"}
},
"12": {
"inputs": {
"field_1_name": "output",
"debug": false,
"webhook_context": ["10", 0],
"field_1": ["8", 0]
},
"class_type": "WebhookSend",
"_meta": {"title": "Webhook Send"}
},
"13": {
"inputs": {
"field": "image",
"debug": true,
"webhook_context": ["10", 0]
},
"class_type": "WebhookTransformer",
"_meta": {"title": "Extract Image"}
}
}
+11
View File
@@ -0,0 +1,11 @@
"""ComfyUI nodes for webhook integration."""
from .receiver import WebhookReceiver
from .transformer import WebhookTransformer
from .send import WebhookSend
__all__ = [
"WebhookReceiver",
"WebhookTransformer",
"WebhookSend",
]
+258
View File
@@ -0,0 +1,258 @@
"""WebhookReceiver node - Entry point for webhook-triggered workflows."""
import asyncio
import logging
import uuid
from datetime import datetime
from typing import Any, Dict
from ..core.types import WebhookContext, WebhookField
from ..core.context import register_webhook_context
from ..core.interceptor import send_started_event
from ..utils.helpers import format_size
logger = logging.getLogger(__name__)
class WebhookReceiver:
"""
Entry point for webhook-triggered workflows.
Extracts webhook configuration from extra_pnginfo (passed from /api/webhook)
and creates a WebhookContext for use by downstream nodes.
Configuration comes from webhook_config in extra_pnginfo:
- callback_url: Where to send results
- auth_header: Auth header name (e.g., "Authorization")
- auth_value: Auth header value (e.g., "Bearer token123")
- request_id: Unique request identifier
- timeout: HTTP timeout
- max_retries: Retry attempts
Fields/uploads come from webhook_inputs in extra_pnginfo.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
"default_callback_url": ("STRING", {
"default": "",
"tooltip": "Fallback callback URL if not provided in request"
}),
"default_timeout": ("INT", {
"default": 60,
"min": 1,
"max": 300,
"tooltip": "HTTP request timeout in seconds"
}),
"default_max_retries": ("INT", {
"default": 3,
"min": 0,
"max": 10,
"tooltip": "Number of retry attempts on failure"
}),
"debug": ("BOOLEAN", {
"default": False,
"tooltip": "Enable debug logging"
}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("WEBHOOK_CONTEXT",)
RETURN_NAMES = ("webhook_context",)
FUNCTION = "receive"
CATEGORY = "webhook"
DESCRIPTION = "Entry point for webhook-triggered workflows. Extracts webhook config and creates context for downstream nodes."
@classmethod
def IS_CHANGED(cls, **kwargs):
"""Always return a unique value to prevent caching."""
# Webhook requests should never be cached as each request has unique data
import uuid
return str(uuid.uuid4())
def receive(
self,
prompt=None,
extra_pnginfo=None,
unique_id=None,
default_callback_url="",
default_timeout=60,
default_max_retries=3,
debug=False,
):
"""
Extract webhook configuration and create context.
The context is registered for output interception and passed
to WebhookTransformer nodes for field extraction.
"""
# Extract webhook_config and webhook_inputs from extra_data
extra_data = extra_pnginfo or {}
webhook_config = extra_data.get("webhook_config", {})
webhook_inputs = extra_data.get("webhook_inputs", {})
# Extract configuration from webhook_config
callback_url = webhook_config.get("callback_url") or default_callback_url
auth_header = webhook_config.get("auth_header")
auth_value = webhook_config.get("auth_value")
request_id = webhook_config.get("request_id") or str(uuid.uuid4())
timeout = webhook_config.get("timeout") or default_timeout
max_retries = webhook_config.get("max_retries") or default_max_retries
# Extract request metadata
remote_ip = webhook_config.get("remote_ip", "unknown")
content_type = webhook_config.get("content_type", "multipart/form-data")
content_length = webhook_config.get("content_length")
user_agent = webhook_config.get("user_agent", "unknown")
# Build fields dictionary from webhook_inputs
fields: Dict[str, WebhookField] = {}
for name, value in webhook_inputs.items():
# Skip reserved fields that start with _
if name.startswith("_"):
continue
# Detect if this is a file upload (has special structure)
is_file = False
filename = None
content_type_field = None
size_bytes = None
if isinstance(value, dict) and value.get("_is_file"):
# This is a file upload - load from saved path
is_file = True
filename = value.get("_filename")
content_type_field = value.get("_content_type")
size_bytes = value.get("_size_bytes", 0)
saved_path = value.get("_saved_path")
# Load file data from disk
if saved_path:
try:
from pathlib import Path
value = Path(saved_path).read_bytes()
except Exception as e:
logger.warning(f"Failed to load file from {saved_path}: {e}")
value = b""
else:
value = b""
elif isinstance(value, bytes):
# Raw bytes = file
is_file = True
size_bytes = len(value)
elif isinstance(value, str):
size_bytes = len(value.encode("utf-8"))
fields[name] = WebhookField(
name=name,
value=value,
is_file=is_file,
filename=filename,
content_type=content_type_field,
size_bytes=size_bytes,
)
# Create context
context = WebhookContext(
callback_url=callback_url,
request_id=request_id,
auth_header=auth_header,
auth_value=auth_value,
timeout=timeout,
max_retries=max_retries,
fields=fields,
remote_ip=remote_ip,
content_type=content_type,
content_length=content_length,
user_agent=user_agent,
prompt_id=unique_id,
inputs=webhook_inputs, # Raw inputs for fallback
send_progress=webhook_config.get("send_progress", True),
progress_interval_ms=webhook_config.get("progress_interval_ms", 500),
include_workflow_in_response=webhook_config.get("include_workflow_in_response", False),
)
# Debug output
if debug:
self._print_debug(context, fields)
# Register context for output interception
if context.prompt_id:
register_webhook_context(context)
if not debug:
logger.info(
f"WebhookReceiver: Registered context for prompt {context.prompt_id}, "
f"callback: {callback_url or '(none)'}, "
f"fields: {list(fields.keys())}"
)
# Send started event if callback URL is configured
if context.callback_url:
try:
# Try to get running loop (Python 3.10+ safe)
loop = asyncio.get_running_loop()
asyncio.create_task(send_started_event(context))
except RuntimeError:
# No running loop - run synchronously
try:
asyncio.run(send_started_event(context))
except RuntimeError:
# Fallback for nested event loops
from ..utils.helpers import run_async
run_async(send_started_event(context))
except Exception as e:
logger.warning(f"Failed to send started event: {e}")
return (context,)
def _print_debug(self, context: WebhookContext, fields: Dict[str, WebhookField]):
"""Print debug information about the incoming request."""
timestamp = context.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
lines = [
"",
"[Webhook Receiver] Incoming Request",
"=" * 50,
f" Remote IP: {context.remote_ip or 'unknown'}",
f" Timestamp: {timestamp}",
f" Request ID: {context.request_id}",
f" Content-Type: {context.content_type or 'unknown'}",
f" Content-Length: {format_size(context.content_length)}",
f" User-Agent: {context.user_agent or 'unknown'}",
"",
" Configuration:",
f" Callback URL: {context.callback_url or '(none)'}",
f" Auth Header: {context.censor_auth()}",
f" Timeout: {context.timeout}s",
f" Max Retries: {context.max_retries}",
"",
f" Fields Received ({len(fields)}):",
]
if fields:
field_names = sorted(fields.keys())
for i, name in enumerate(field_names):
field = fields[name]
prefix = " +-" if i == len(field_names) - 1 else " |-"
if field.is_file:
size_str = format_size(field.size_bytes)
ct = field.content_type or "application/octet-stream"
lines.append(f"{prefix} {name:20} (file) {ct}, {size_str}")
else:
size_str = format_size(field.size_bytes)
lines.append(f"{prefix} {name:20} (text) {size_str}")
else:
lines.append(" (no fields)")
lines.append("=" * 50)
print("\n".join(lines))
+774
View File
@@ -0,0 +1,774 @@
"""WebhookSend node - Send workflow outputs to callback URL."""
import io
import json
import logging
import struct
from datetime import datetime
from typing import Optional, Any, Dict, List, Tuple, Union
import numpy as np
import torch
from PIL import Image
from ..core.types import WebhookContext, WebhookResult, OutputInfo
from ..core.client import get_webhook_client
from ..utils.helpers import format_size, run_async
logger = logging.getLogger(__name__)
class WebhookSend:
"""
Send workflow outputs to the callback URL as multipart/form-data.
Supports 5 customizable wildcard fields. Each field accepts any
ComfyUI type (IMAGE, AUDIO, VIDEO, MESH, LATENT, MASK, STRING, etc.)
and automatically encodes it appropriately.
Field Encoding:
- IMAGE: PNG file
- AUDIO: FLAC file
- VIDEO: MP4 file
- MESH: GLB file
- LATENT: Safetensors file
- MASK: PNG file (grayscale)
- STRING: Text part
- INT/FLOAT/BOOLEAN: Text part
Always includes a metadata JSON field with request info.
Payload is always gzip compressed (Content-Encoding: gzip).
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"webhook_context": ("WEBHOOK_CONTEXT",),
},
"optional": {
# Field 1
"field_1": ("*", {"tooltip": "First output (any type)"}),
"field_1_name": ("STRING", {
"default": "output_1",
"tooltip": "Field name for first output"
}),
# Field 2
"field_2": ("*", {"tooltip": "Second output (any type)"}),
"field_2_name": ("STRING", {
"default": "output_2",
"tooltip": "Field name for second output"
}),
# Field 3
"field_3": ("*", {"tooltip": "Third output (any type)"}),
"field_3_name": ("STRING", {
"default": "output_3",
"tooltip": "Field name for third output"
}),
# Field 4
"field_4": ("*", {"tooltip": "Fourth output (any type)"}),
"field_4_name": ("STRING", {
"default": "output_4",
"tooltip": "Field name for fourth output"
}),
# Field 5
"field_5": ("*", {"tooltip": "Fifth output (any type)"}),
"field_5_name": ("STRING", {
"default": "output_5",
"tooltip": "Field name for fifth output"
}),
# Debug
"debug": ("BOOLEAN", {
"default": False,
"tooltip": "Enable debug logging"
}),
# Batch outputs
"batch_outputs": ("BOOLEAN", {
"default": False,
"tooltip": "Send all items in batched outputs (images/masks). Field names become prefixes with _0, _1, etc. suffixes."
}),
},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "send"
CATEGORY = "webhook"
DESCRIPTION = "Send workflow outputs to callback URL as gzip-compressed multipart/form-data. Supports 5 customizable wildcard fields."
def send(
self,
webhook_context,
field_1=None,
field_1_name="output_1",
field_2=None,
field_2_name="output_2",
field_3=None,
field_3_name="output_3",
field_4=None,
field_4_name="output_4",
field_5=None,
field_5_name="output_5",
debug=False,
batch_outputs=False,
):
if not isinstance(webhook_context, WebhookContext):
logger.warning("WebhookSend: Invalid webhook context")
return {}
if not webhook_context.callback_url:
logger.warning("WebhookSend: No callback URL configured")
return {}
# Collect non-None fields
fields_to_send = []
for field, name in [
(field_1, field_1_name),
(field_2, field_2_name),
(field_3, field_3_name),
(field_4, field_4_name),
(field_5, field_5_name),
]:
if field is not None:
fields_to_send.append((name, field))
try:
result = run_async(
self._send_async(webhook_context, fields_to_send, debug, batch_outputs)
)
return {}
except Exception as e:
logger.error(f"WebhookSend failed: {e}", exc_info=True)
if debug:
self._print_error(webhook_context, str(e))
return {}
async def _send_async(
self,
context: WebhookContext,
fields_to_send: List[Tuple[str, Any]],
debug: bool,
batch_outputs: bool = False,
):
"""Async implementation of send."""
files = []
outputs = []
start_time = datetime.now()
# Process each field
for name, value in fields_to_send:
encoded = self._encode_value(name, value, batch_outputs)
if encoded:
# Check if we got a list of encoded items (batched) or single item
if isinstance(encoded, list):
# Batched output - expand with _N suffixes
for i, (filename, data, mime_type, output_info) in enumerate(encoded):
batch_name = f"{name}_{i}"
files.append((batch_name, filename, data, mime_type))
outputs.append(output_info.to_dict())
else:
# Single item
filename, data, mime_type, output_info = encoded
files.append((name, filename, data, mime_type))
outputs.append(output_info.to_dict())
# Build metadata
metadata = {
"request_id": context.request_id,
"prompt_id": context.prompt_id,
"timestamp": datetime.now().isoformat(),
"fields": outputs,
}
# Send request
client = get_webhook_client()
result = await client.send_multipart(
url=context.callback_url,
metadata=metadata,
files=files,
headers=context.get_auth_headers(),
timeout=context.timeout,
max_retries=context.max_retries,
compress=True, # Always use gzip compression
)
elapsed_ms = (datetime.now() - start_time).total_seconds() * 1000
if debug:
self._print_debug(context, files, outputs, result, elapsed_ms)
if not result.success:
logger.error(f"WebhookSend failed: {result.error}")
else:
logger.info(
f"WebhookSend: Sent {len(files)} fields to {context.callback_url}"
)
def _encode_value(
self,
name: str,
value: Any,
batch_outputs: bool = False,
) -> Optional[Union[Tuple[str, bytes, str, OutputInfo], List[Tuple[str, bytes, str, OutputInfo]]]]:
"""
Encode value to appropriate format for multipart upload.
Returns:
tuple: (filename, data_bytes, mime_type, output_info) or None
list: List of tuples when batch_outputs=True and value is batched
"""
# IMAGE tensor
if torch.is_tensor(value) and len(value.shape) == 4:
# Batched images [B, H, W, C]
batch_size = value.shape[0]
if batch_outputs and batch_size > 1:
# Return all images as separate items
results = []
for i in range(batch_size):
img_bytes = self._tensor_to_png(value[i])
filename = f"{name}_{i}.png"
output_info = OutputInfo(
type="IMAGE",
filename=filename,
mime_type="image/png",
file_size_bytes=len(img_bytes),
width=value.shape[2],
height=value.shape[1],
channels=value.shape[3] if len(value.shape) > 3 else 3,
dtype="float32",
format="png",
batch_index=i,
batch_size=batch_size,
)
results.append((filename, img_bytes, "image/png", output_info))
return results
else:
# Send only first image (original behavior)
data = self._tensor_to_png(value[0])
filename = f"{name}.png"
output_info = OutputInfo(
type="IMAGE",
filename=filename,
mime_type="image/png",
file_size_bytes=len(data),
width=value.shape[2],
height=value.shape[1],
channels=value.shape[3] if len(value.shape) > 3 else 3,
dtype="float32",
format="png",
batch_index=0,
batch_size=batch_size,
)
return (filename, data, "image/png", output_info)
# MASK tensor [B, H, W] or [H, W] - check before IMAGE to avoid confusion
# Masks are 2D or 3D with small batch dimension (no color channel)
if torch.is_tensor(value) and len(value.shape) in (2, 3):
# Check if this looks like a mask:
# - 2D tensor [H, W] is definitely a mask
# - 3D tensor [B, H, W] where B < 10 is a batched mask
# - 3D tensor [H, W, C] where C is 3 or 4 is an image
is_mask = False
is_batched_mask = False
batch_size = 1
if len(value.shape) == 2:
is_mask = True
elif len(value.shape) == 3:
# [B, H, W] mask vs [H, W, C] image
# If last dim is 1, 3, or 4, it's likely an image
# If first dim is small and last dim is large, it's likely a mask batch
if value.shape[-1] not in (1, 3, 4) and value.shape[0] < 10:
is_mask = True
is_batched_mask = value.shape[0] > 1
batch_size = value.shape[0]
elif value.shape[0] == 1 and value.shape[-1] > 10:
# [1, H, W] is a batched mask
is_mask = True
is_batched_mask = False
batch_size = 1
if is_mask:
if batch_outputs and is_batched_mask and batch_size > 1:
# Return all masks as separate items
results = []
for i in range(batch_size):
mask = value[i]
mask_bytes = self._mask_to_png(mask)
filename = f"{name}_{i}.png"
output_info = OutputInfo(
type="MASK",
filename=filename,
mime_type="image/png",
file_size_bytes=len(mask_bytes),
width=mask.shape[1],
height=mask.shape[0],
format="png",
batch_index=i,
batch_size=batch_size,
)
results.append((filename, mask_bytes, "image/png", output_info))
return results
else:
# Send only first mask (original behavior)
mask = value[0] if len(value.shape) == 3 else value
data = self._mask_to_png(mask)
filename = f"{name}.png"
output_info = OutputInfo(
type="MASK",
filename=filename,
mime_type="image/png",
file_size_bytes=len(data),
width=mask.shape[1],
height=mask.shape[0],
format="png",
batch_index=0,
batch_size=batch_size,
)
return (filename, data, "image/png", output_info)
# IMAGE tensor (single) [H, W, C]
if torch.is_tensor(value) and len(value.shape) == 3:
data = self._tensor_to_png(value)
filename = f"{name}.png"
output_info = OutputInfo(
type="IMAGE",
filename=filename,
mime_type="image/png",
file_size_bytes=len(data),
width=value.shape[1],
height=value.shape[0],
channels=value.shape[2] if len(value.shape) > 2 else 3,
dtype="float32",
format="png",
)
return (filename, data, "image/png", output_info)
# AUDIO dict
if isinstance(value, dict) and "waveform" in value:
data = self._audio_to_flac(value)
if data:
filename = f"{name}.flac"
output_info = OutputInfo(
type="AUDIO",
filename=filename,
mime_type="audio/flac",
file_size_bytes=len(data),
sample_rate=value.get("sample_rate", 44100),
format="flac",
)
return (filename, data, "audio/flac", output_info)
# VIDEO dict or tensor
if isinstance(value, dict) and "images" in value:
data = self._video_to_mp4(value)
if data:
filename = f"{name}.mp4"
fps = value.get("fps", 24)
images = value.get("images")
output_info = OutputInfo(
type="VIDEO",
filename=filename,
mime_type="video/mp4",
file_size_bytes=len(data),
width=images.shape[2] if torch.is_tensor(images) else None,
height=images.shape[1] if torch.is_tensor(images) else None,
format="mp4",
)
return (filename, data, "video/mp4", output_info)
# MESH dict
if isinstance(value, dict) and ("vertices" in value or "faces" in value):
data = self._mesh_to_glb(value)
if data:
filename = f"{name}.glb"
vertices = value.get("vertices")
faces = value.get("faces")
output_info = OutputInfo(
type="MESH",
filename=filename,
mime_type="model/gltf-binary",
file_size_bytes=len(data),
format="glb",
)
return (filename, data, "model/gltf-binary", output_info)
# LATENT dict
if isinstance(value, dict) and "samples" in value:
data = self._latent_to_safetensors(value)
if data:
filename = f"{name}.safetensors"
samples = value.get("samples")
output_info = OutputInfo(
type="LATENT",
filename=filename,
mime_type="application/octet-stream",
file_size_bytes=len(data),
format="safetensors",
)
return (filename, data, "application/octet-stream", output_info)
# STRING
if isinstance(value, str):
data = value.encode("utf-8")
filename = f"{name}.txt"
output_info = OutputInfo(
type="STRING",
filename=filename,
mime_type="text/plain",
file_size_bytes=len(data),
inline_content=value if len(value) < 1000 else None,
)
return (filename, data, "text/plain", output_info)
# INT, FLOAT, BOOLEAN
if isinstance(value, (int, float, bool)):
str_value = str(value).lower() if isinstance(value, bool) else str(value)
data = str_value.encode("utf-8")
filename = f"{name}.txt"
type_name = type(value).__name__.upper()
output_info = OutputInfo(
type=type_name,
filename=filename,
mime_type="text/plain",
file_size_bytes=len(data),
inline_content=str_value,
)
return (filename, data, "text/plain", output_info)
# BYTES
if isinstance(value, bytes):
filename = f"{name}.bin"
output_info = OutputInfo(
type="BYTES",
filename=filename,
mime_type="application/octet-stream",
file_size_bytes=len(value),
)
return (filename, value, "application/octet-stream", output_info)
# Try JSON serialization for other types
try:
json_str = json.dumps(value, default=str)
data = json_str.encode("utf-8")
filename = f"{name}.json"
output_info = OutputInfo(
type="JSON",
filename=filename,
mime_type="application/json",
file_size_bytes=len(data),
)
return (filename, data, "application/json", output_info)
except Exception:
pass
logger.warning(f"WebhookSend: Unable to encode field '{name}' of type {type(value)}")
return None
def _tensor_to_png(self, tensor: torch.Tensor) -> bytes:
"""Convert image tensor [H, W, C] to PNG bytes."""
np_image = (tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
pil_image = Image.fromarray(np_image)
buffer = io.BytesIO()
pil_image.save(buffer, format="PNG", compress_level=4)
return buffer.getvalue()
def _mask_to_png(self, tensor: torch.Tensor) -> bytes:
"""Convert mask tensor [H, W] to grayscale PNG bytes."""
np_mask = (tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
pil_image = Image.fromarray(np_mask, mode='L')
buffer = io.BytesIO()
pil_image.save(buffer, format="PNG")
return buffer.getvalue()
def _audio_to_flac(self, audio: dict) -> Optional[bytes]:
"""Convert audio dict to FLAC bytes."""
try:
import av
except ImportError:
logger.warning("av library not available for audio encoding")
return None
waveform = audio.get("waveform")
sample_rate = audio.get("sample_rate", 44100)
if waveform is None:
return None
if len(waveform.shape) == 3:
waveform = waveform[0]
try:
buffer = io.BytesIO()
container = av.open(buffer, mode="w", format="flac")
layout = "mono" if waveform.shape[0] == 1 else "stereo"
stream = container.add_stream("flac", rate=sample_rate, layout=layout)
audio_np = waveform.cpu().numpy()
frame = av.AudioFrame.from_ndarray(
audio_np.reshape(1, -1).astype(np.float32),
format="flt",
layout=layout,
)
frame.sample_rate = sample_rate
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode(None):
container.mux(packet)
container.close()
buffer.seek(0)
return buffer.getvalue()
except Exception as e:
logger.error(f"Failed to encode audio: {e}")
return None
def _video_to_mp4(self, video: dict) -> Optional[bytes]:
"""Convert video dict to MP4 bytes."""
try:
import av
except ImportError:
logger.warning("av library not available for video encoding")
return None
images = video.get("images")
fps = video.get("fps", 24)
if images is None:
return None
try:
buffer = io.BytesIO()
container = av.open(buffer, mode="w", format="mp4")
stream = container.add_stream("libx264", rate=fps)
if len(images.shape) == 4:
height, width = images.shape[1], images.shape[2]
else:
height, width = images.shape[0], images.shape[1]
stream.width = width
stream.height = height
stream.pix_fmt = "yuv420p"
if len(images.shape) == 4:
for i in range(images.shape[0]):
np_frame = (images[i].cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
frame = av.VideoFrame.from_ndarray(np_frame, format="rgb24")
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode(None):
container.mux(packet)
container.close()
buffer.seek(0)
return buffer.getvalue()
except Exception as e:
logger.error(f"Failed to encode video: {e}")
return None
def _mesh_to_glb(self, mesh: dict) -> Optional[bytes]:
"""Convert mesh dict to GLB bytes."""
try:
vertices = mesh.get("vertices")
faces = mesh.get("faces")
if vertices is None or faces is None:
return None
if torch.is_tensor(vertices):
vertices = vertices.cpu().numpy()
if torch.is_tensor(faces):
faces = faces.cpu().numpy()
# Handle batch dimension
if len(vertices.shape) == 3:
vertices = vertices[0]
if len(faces.shape) == 3:
faces = faces[0]
vertices = vertices.astype(np.float32)
faces = faces.astype(np.uint32)
# Calculate bounding box
v_min = vertices.min(axis=0).tolist()
v_max = vertices.max(axis=0).tolist()
# Create buffer data
vertex_data = vertices.tobytes()
index_data = faces.flatten().tobytes()
def pad4(data):
remainder = len(data) % 4
if remainder:
data += b'\x00' * (4 - remainder)
return data
vertex_data = pad4(vertex_data)
index_data = pad4(index_data)
buffer_data = vertex_data + index_data
# Create glTF JSON
gltf = {
"asset": {"version": "2.0", "generator": "ComfyUI-Webhook"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0}],
"meshes": [{
"primitives": [{
"attributes": {"POSITION": 0},
"indices": 1
}]
}],
"accessors": [
{
"bufferView": 0,
"componentType": 5126,
"count": len(vertices),
"type": "VEC3",
"min": v_min,
"max": v_max
},
{
"bufferView": 1,
"componentType": 5125,
"count": faces.size,
"type": "SCALAR"
}
],
"bufferViews": [
{
"buffer": 0,
"byteOffset": 0,
"byteLength": len(vertex_data)
},
{
"buffer": 0,
"byteOffset": len(vertex_data),
"byteLength": len(index_data)
}
],
"buffers": [{"byteLength": len(buffer_data)}]
}
json_data = json.dumps(gltf, separators=(',', ':')).encode('utf-8')
json_data = pad4(json_data)
# Build GLB
glb = io.BytesIO()
glb.write(struct.pack('<4sII', b'glTF', 2, 12 + 8 + len(json_data) + 8 + len(buffer_data)))
glb.write(struct.pack('<II', len(json_data), 0x4E4F534A))
glb.write(json_data)
glb.write(struct.pack('<II', len(buffer_data), 0x004E4942))
glb.write(buffer_data)
return glb.getvalue()
except Exception as e:
logger.error(f"Failed to encode mesh: {e}")
return None
def _latent_to_safetensors(self, latent: dict) -> Optional[bytes]:
try:
from safetensors.torch import save
samples = latent.get("samples")
if samples is None:
return None
return save({"samples": samples})
except Exception as e:
logger.error(f"Failed to encode latent: {e}")
return None
def _print_debug(
self,
context: WebhookContext,
files: List[Tuple[str, str, bytes, str]],
outputs: List[Dict],
result: WebhookResult,
elapsed_ms: float
):
"""Print debug information about the outgoing request."""
lines = [
"",
"[Webhook Send] Outgoing Request",
"=" * 50,
f" Target URL: {context.callback_url}",
f" Method: POST",
f" Content-Type: multipart/form-data",
f" Auth Header: {context.censor_auth()}",
f" Request ID: {context.request_id}",
"",
f" Fields ({len(files)}):",
]
for i, (name, filename, data, mime_type) in enumerate(files):
prefix = " +-" if i == len(files) - 1 else " |-"
# Find output info for additional details
output = outputs[i] if i < len(outputs) else {}
type_name = output.get("type", "UNKNOWN")
if type_name == "IMAGE":
w = output.get("width", "?")
h = output.get("height", "?")
lines.append(f"{prefix} {name:20} ({type_name}) {w}x{h}, PNG, {format_size(len(data))}")
elif type_name == "MASK":
w = output.get("width", "?")
h = output.get("height", "?")
lines.append(f"{prefix} {name:20} ({type_name}) {w}x{h}, PNG, {format_size(len(data))}")
elif type_name == "STRING":
content = output.get("inline_content")
if content and len(content) < 30:
lines.append(f"{prefix} {name:20} ({type_name}) \"{content}\"")
else:
lines.append(f"{prefix} {name:20} ({type_name}) {format_size(len(data))}")
else:
lines.append(f"{prefix} {name:20} ({type_name}) {format_size(len(data))}")
lines.append("")
lines.append(" Response:")
if result.success:
lines.append(f" Status: {result.status} OK")
else:
lines.append(f" Status: {result.status or 'N/A'} {result.error or 'Failed'}")
lines.append(f" Time: {elapsed_ms:.0f} ms")
if result.response_body:
body = result.response_body
if len(body) > 100:
body = body[:97] + "..."
lines.append(f" Body: {body}")
if result.retries > 0:
lines.append(f" Retries: {result.retries}")
lines.append("=" * 50)
print("\n".join(lines))
def _print_error(self, context: WebhookContext, error: str):
"""Print error information."""
lines = [
"",
"[Webhook Send] Request Failed",
"=" * 50,
f" Target URL: {context.callback_url}",
f" Request ID: {context.request_id}",
"",
f" Error: {error}",
"=" * 50,
]
print("\n".join(lines))
+581
View File
@@ -0,0 +1,581 @@
"""WebhookTransformer node - Extract a single atomic field from webhook context."""
import io
import logging
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import torch
from PIL import Image
from ..core.types import WebhookContext, WebhookField
from ..processors.inputs import WebhookInputProcessor
from ..utils.helpers import format_size, run_async
logger = logging.getLogger(__name__)
_processor = None
def get_processor() -> WebhookInputProcessor:
global _processor
if _processor is None:
_processor = WebhookInputProcessor()
return _processor
class WebhookTransformer:
"""
Extract a single atomic field from the webhook request.
Field names are atomic (flat) - no JSON paths, no nesting.
Outputs wildcard type (*) that connects to any ComfyUI input.
Type Inference:
- Text fields with numbers -> INT or FLOAT
- Text fields -> STRING
- File uploads -> IMAGE, AUDIO, VIDEO, MESH, LATENT based on content type
- Boolean strings ("true"/"false") -> BOOLEAN
Missing Field Handling:
- If field missing AND default_value provided -> use default
- If field missing AND NO default -> STOP workflow with error
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"webhook_context": ("WEBHOOK_CONTEXT",),
"field": ("STRING", {
"default": "prompt",
"tooltip": "Field name to extract (atomic, no nesting)"
}),
},
"optional": {
"default_value": ("*", {
"tooltip": "Fallback value if field not found (leave empty to require field)"
}),
"debug": ("BOOLEAN", {
"default": False,
"tooltip": "Enable debug logging"
}),
},
}
RETURN_TYPES = ("*",)
RETURN_NAMES = ("value",)
FUNCTION = "transform"
CATEGORY = "webhook"
DESCRIPTION = "Extract a single atomic field from webhook request. Outputs wildcard type that connects to any input."
@classmethod
def VALIDATE_INPUTS(cls, webhook_context=None, field="", default_value=None, debug=False):
"""Validate inputs before execution."""
# Basic validation - actual field checking happens in transform()
if not field or not field.strip():
return "Field name cannot be empty"
return True
def transform(
self,
webhook_context,
field: str,
default_value=None,
debug=False,
):
"""
Extract field from webhook context.
Raises:
ValueError: If field is missing and no default provided
"""
# Validate context (use duck typing for flexibility with module loading)
if not hasattr(webhook_context, 'get_field_value') or not hasattr(webhook_context, 'has_field'):
if default_value is not None:
if debug:
self._print_debug_default(field, default_value, "Invalid webhook context")
return (default_value,)
raise ValueError(
f"[Webhook Transformer] ERROR: Invalid webhook context for field '{field}'"
)
# Check if field exists
has_field = webhook_context.has_field(field)
if not has_field:
# Field not found
if default_value is not None:
if debug:
self._print_debug_default(field, default_value, "Field not found")
return (default_value,)
# No default - STOP workflow
available_fields = webhook_context.get_field_names()
self._print_error(field, available_fields)
raise ValueError(
f"[Webhook Transformer] Required field '{field}' not found. "
f"Available fields: {', '.join(available_fields) if available_fields else '(none)'}. "
f"Provide field or set default_value."
)
# Get field value
webhook_field = webhook_context.get_field(field)
raw_value = webhook_context.get_field_value(field)
# Process and convert value based on type inference
processed_value, value_type, debug_info = self._process_value(
raw_value, webhook_field
)
if debug:
self._print_debug_found(field, processed_value, value_type, debug_info)
return (processed_value,)
def _process_value(
self,
raw_value: Any,
webhook_field: Optional[WebhookField]
) -> Tuple[Any, str, Dict]:
"""
Process raw value and infer type.
Returns:
tuple: (processed_value, type_name, debug_info_dict)
"""
debug_info = {}
# Handle file uploads
if webhook_field and webhook_field.is_file:
return self._process_file(raw_value, webhook_field)
# Handle string values
if isinstance(raw_value, str):
# Try to infer type from string content
# Boolean
if raw_value.lower() in ("true", "false", "yes", "no", "1", "0"):
bool_value = raw_value.lower() in ("true", "yes", "1")
debug_info["original"] = raw_value
return (bool_value, "BOOLEAN", debug_info)
# Integer
try:
if raw_value.isdigit() or (raw_value.startswith("-") and raw_value[1:].isdigit()):
int_value = int(raw_value)
debug_info["original"] = raw_value
return (int_value, "INT", debug_info)
except (ValueError, AttributeError):
pass
# Float
try:
if "." in raw_value or "e" in raw_value.lower():
float_value = float(raw_value)
debug_info["original"] = raw_value
return (float_value, "FLOAT", debug_info)
except (ValueError, AttributeError):
pass
# Check if it's base64 encoded data
if raw_value.startswith("data:"):
return self._process_data_uri(raw_value)
# Plain string
debug_info["length"] = len(raw_value)
return (raw_value, "STRING", debug_info)
# Handle numeric types directly
if isinstance(raw_value, bool):
return (raw_value, "BOOLEAN", debug_info)
if isinstance(raw_value, int):
return (raw_value, "INT", debug_info)
if isinstance(raw_value, float):
return (raw_value, "FLOAT", debug_info)
# Handle bytes (file data)
if isinstance(raw_value, bytes):
return self._process_bytes(raw_value)
# Handle torch tensors
if torch.is_tensor(raw_value):
debug_info["shape"] = list(raw_value.shape)
debug_info["dtype"] = str(raw_value.dtype)
return (raw_value, "TENSOR", debug_info)
# Handle dicts (could be LATENT, AUDIO, etc.)
if isinstance(raw_value, dict):
# Check for known dict types
if "samples" in raw_value:
return (raw_value, "LATENT", {"type": "latent_dict"})
if "waveform" in raw_value:
return (raw_value, "AUDIO", {"type": "audio_dict"})
# Generic dict - return as is
debug_info["keys"] = list(raw_value.keys())
return (raw_value, "DICT", debug_info)
# Handle lists
if isinstance(raw_value, list):
debug_info["length"] = len(raw_value)
return (raw_value, "LIST", debug_info)
# Default: return as-is
debug_info["python_type"] = type(raw_value).__name__
return (raw_value, "ANY", debug_info)
def _process_file(
self,
raw_value: Any,
webhook_field: WebhookField
) -> Tuple[Any, str, Dict]:
"""Process file upload based on content type."""
content_type = webhook_field.content_type or ""
debug_info = {
"filename": webhook_field.filename,
"content_type": content_type,
"size_bytes": webhook_field.size_bytes,
}
data = raw_value if isinstance(raw_value, bytes) else None
if data is None:
return (raw_value, "UNKNOWN", debug_info)
# Image types
if content_type.startswith("image/"):
try:
tensor, info = self._decode_image(data)
debug_info.update(info)
return (tensor, "IMAGE", debug_info)
except Exception as e:
logger.warning(f"Failed to decode image: {e}")
return (data, "BYTES", debug_info)
# Audio types
if content_type.startswith("audio/"):
try:
audio_dict, info = self._decode_audio(data)
debug_info.update(info)
return (audio_dict, "AUDIO", debug_info)
except Exception as e:
logger.warning(f"Failed to decode audio: {e}")
return (data, "BYTES", debug_info)
# Video types
if content_type.startswith("video/"):
try:
video, info = self._decode_video(data)
debug_info.update(info)
return (video, "VIDEO", debug_info)
except Exception as e:
logger.warning(f"Failed to decode video: {e}")
return (data, "BYTES", debug_info)
# 3D mesh types
if content_type in ("model/gltf-binary", "model/obj", "model/stl", "application/octet-stream"):
# Try to detect from filename
filename = webhook_field.filename or ""
if filename.endswith((".glb", ".gltf", ".obj", ".stl")):
try:
mesh, info = self._decode_mesh(data, filename)
debug_info.update(info)
return (mesh, "MESH", debug_info)
except Exception as e:
logger.warning(f"Failed to decode mesh: {e}")
return (data, "BYTES", debug_info)
# Safetensors (latent)
if webhook_field.filename and webhook_field.filename.endswith(".safetensors"):
try:
latent, info = self._decode_latent(data)
debug_info.update(info)
return (latent, "LATENT", debug_info)
except Exception as e:
logger.warning(f"Failed to decode latent: {e}")
return (data, "BYTES", debug_info)
# Default: return raw bytes
return (data, "BYTES", debug_info)
def _process_data_uri(self, data_uri: str) -> Tuple[Any, str, Dict]:
"""Process data URI (base64 encoded data)."""
processor = get_processor()
debug_info = {}
try:
# Detect type from data URI
if data_uri.startswith("data:image/"):
tensor = run_async(processor.process_image(data_uri))
debug_info["source"] = "base64"
debug_info["dimensions"] = f"{tensor.shape[2]}x{tensor.shape[1]}"
return (tensor, "IMAGE", debug_info)
elif data_uri.startswith("data:audio/"):
audio = run_async(processor.process_audio(data_uri))
debug_info["source"] = "base64"
return (audio, "AUDIO", debug_info)
elif data_uri.startswith("data:video/"):
video = run_async(processor.process_video(data_uri))
debug_info["source"] = "base64"
return (video, "VIDEO", debug_info)
except Exception as e:
logger.warning(f"Failed to process data URI: {e}")
# Return as string if can't process
debug_info["length"] = len(data_uri)
return (data_uri, "STRING", debug_info)
def _process_bytes(self, data: bytes) -> Tuple[Any, str, Dict]:
"""Process raw bytes by detecting format."""
debug_info = {"size_bytes": len(data)}
# Try to detect image from magic bytes
if data[:8] == b'\x89PNG\r\n\x1a\n' or data[:2] == b'\xff\xd8':
try:
tensor, info = self._decode_image(data)
debug_info.update(info)
return (tensor, "IMAGE", debug_info)
except Exception:
pass
# Try to detect audio
if data[:4] == b'fLaC' or data[:4] == b'RIFF':
try:
audio, info = self._decode_audio(data)
debug_info.update(info)
return (audio, "AUDIO", debug_info)
except Exception:
pass
# Default: return raw bytes
return (data, "BYTES", debug_info)
def _decode_image(self, data: bytes) -> Tuple[torch.Tensor, Dict]:
"""Decode image bytes to tensor."""
img = Image.open(io.BytesIO(data))
img = img.convert("RGB")
np_image = np.array(img).astype(np.float32) / 255.0
tensor = torch.from_numpy(np_image).unsqueeze(0) # [1, H, W, C]
info = {
"dimensions": f"{img.width}x{img.height}",
"channels": 3,
"dtype": "float32",
}
return tensor, info
def _decode_audio(self, data: bytes) -> Tuple[Dict, Dict]:
"""Decode audio bytes to audio dict."""
try:
import av
except ImportError:
raise ImportError("av library required for audio decoding")
buffer = io.BytesIO(data)
container = av.open(buffer)
audio_stream = container.streams.audio[0]
sample_rate = audio_stream.rate
frames = []
for frame in container.decode(audio=0):
frames.append(frame.to_ndarray())
container.close()
audio_np = np.concatenate(frames, axis=1)
waveform = torch.from_numpy(audio_np).unsqueeze(0)
info = {
"sample_rate": sample_rate,
"channels": audio_np.shape[0],
"duration_seconds": audio_np.shape[1] / sample_rate,
}
return {"waveform": waveform, "sample_rate": sample_rate}, info
def _decode_video(self, data: bytes) -> Tuple[Dict, Dict]:
"""Decode video bytes to video dict."""
try:
import av
except ImportError:
raise ImportError("av library required for video decoding")
buffer = io.BytesIO(data)
container = av.open(buffer)
video_stream = container.streams.video[0]
fps = float(video_stream.average_rate)
frames = []
for frame in container.decode(video=0):
np_frame = frame.to_ndarray(format="rgb24")
frames.append(np_frame)
container.close()
if not frames:
raise ValueError("No video frames found")
frames_np = np.stack(frames)
frames_tensor = torch.from_numpy(frames_np).float() / 255.0
info = {
"fps": fps,
"frame_count": len(frames),
"dimensions": f"{frames_np.shape[2]}x{frames_np.shape[1]}",
}
return {"images": frames_tensor, "fps": fps}, info
def _decode_mesh(self, data: bytes, filename: str) -> Tuple[Dict, Dict]:
"""Decode mesh bytes."""
processor = get_processor()
# Use processor's mesh parsing
if filename.endswith(".glb") or filename.endswith(".gltf"):
mesh = processor._parse_glb(data)
elif filename.endswith(".obj"):
mesh = processor._parse_obj(data.decode("utf-8"))
elif filename.endswith(".stl"):
mesh = processor._parse_stl_binary(data)
else:
raise ValueError(f"Unsupported mesh format: {filename}")
vertices = mesh.get("vertices")
faces = mesh.get("faces")
info = {
"vertex_count": vertices.shape[1] if vertices is not None else 0,
"face_count": faces.shape[1] if faces is not None else 0,
}
return mesh, info
def _decode_latent(self, data: bytes) -> Tuple[Dict, Dict]:
"""Decode latent from safetensors."""
from safetensors.torch import load
buffer = io.BytesIO(data)
tensors = load(buffer.read())
samples = tensors.get("samples")
if samples is None:
raise ValueError("No 'samples' key in safetensors")
info = {
"shape": list(samples.shape),
"dtype": str(samples.dtype),
}
return {"samples": samples}, info
def _print_debug_found(
self,
field: str,
value: Any,
value_type: str,
debug_info: dict
):
"""Print debug info for found field."""
lines = [
"",
f"[Webhook Transformer] Field: \"{field}\"",
"=" * 50,
f" Status: Found",
f" Type: {value_type}",
]
# Add type-specific info
if value_type == "STRING":
preview = str(value)
if len(preview) > 50:
preview = preview[:47] + "..."
lines.append(f" Value: \"{preview}\"")
lines.append(f" Length: {debug_info.get('length', len(str(value)))} characters")
elif value_type == "IMAGE":
lines.append(f" Dimensions: {debug_info.get('dimensions', 'unknown')}")
lines.append(f" Channels: {debug_info.get('channels', 'unknown')}")
lines.append(f" Dtype: {debug_info.get('dtype', 'unknown')}")
if debug_info.get('size_bytes'):
lines.append(f" Size: {format_size(debug_info['size_bytes'])}")
lines.append(f" Source: {debug_info.get('source', 'file upload')}")
elif value_type == "AUDIO":
lines.append(f" Sample Rate: {debug_info.get('sample_rate', 'unknown')} Hz")
lines.append(f" Channels: {debug_info.get('channels', 'unknown')}")
if debug_info.get('duration_seconds'):
lines.append(f" Duration: {debug_info['duration_seconds']:.2f}s")
elif value_type in ("INT", "FLOAT", "BOOLEAN"):
lines.append(f" Value: {value}")
if debug_info.get('original'):
lines.append(f" Original: \"{debug_info['original']}\"")
elif value_type == "LATENT":
if debug_info.get('shape'):
lines.append(f" Shape: {debug_info['shape']}")
elif value_type == "MESH":
lines.append(f" Vertices: {debug_info.get('vertex_count', 'unknown')}")
lines.append(f" Faces: {debug_info.get('face_count', 'unknown')}")
else:
# Generic info
preview = str(value)
if len(preview) > 50:
preview = preview[:47] + "..."
lines.append(f" Value: {preview}")
lines.append("=" * 50)
print("\n".join(lines))
def _print_debug_default(self, field: str, default_value: Any, reason: str):
"""Print debug info when using default value."""
lines = [
"",
f"[Webhook Transformer] Field: \"{field}\"",
"=" * 50,
f" Status: Not Found -> Using Default",
f" Reason: {reason}",
f" Type: {type(default_value).__name__}",
]
preview = str(default_value)
if len(preview) > 50:
preview = preview[:47] + "..."
lines.append(f" Value: {preview}")
lines.append("=" * 50)
print("\n".join(lines))
def _print_error(self, field: str, available_fields: List[str]):
"""Print error when field is missing and no default."""
lines = [
"",
"[Webhook Transformer] ERROR",
"=" * 50,
f" Field: \"{field}\"",
f" Status: NOT FOUND",
"",
" Available fields:",
]
if available_fields:
for i, name in enumerate(available_fields):
prefix = " +-" if i == len(available_fields) - 1 else " |-"
lines.append(f"{prefix} {name}")
else:
lines.append(" (none)")
lines.append("")
lines.append(" -> Workflow stopped. Provide field or set default_value.")
lines.append("=" * 50)
print("\n".join(lines))
+1
View File
@@ -0,0 +1 @@
"""Processors for input and output handling."""
+746
View File
@@ -0,0 +1,746 @@
"""Input processing for webhook data."""
import asyncio
import base64
import io
import json
import logging
import re
from typing import Any, List, Optional, Union
import numpy as np
import torch
from PIL import Image, ImageOps
try:
import aiohttp
except ImportError:
aiohttp = None
try:
import torchaudio
except ImportError:
torchaudio = None
logger = logging.getLogger(__name__)
DATA_URL_PATTERN = re.compile(r'^data:([^;,]+)?(;base64)?,(.*)$', re.DOTALL)
HTTP_URL_PATTERN = re.compile(r'^https?://', re.IGNORECASE)
class WebhookInputProcessor:
"""Processes webhook input data into ComfyUI-compatible formats."""
def __init__(self, download_timeout: float = 30.0):
self.download_timeout = download_timeout
async def process_inputs(self, webhook_inputs: dict) -> dict:
"""
Process all inputs and return ComfyUI-compatible values.
Args:
webhook_inputs: Dictionary of input name -> value
Returns:
Dictionary of input name -> processed value
"""
processed = {}
for key, value in webhook_inputs.items():
try:
processed[key] = await self._process_value(value)
except Exception as e:
logger.error(f"Failed to process input '{key}': {e}")
# Keep original value on error
processed[key] = value
return processed
async def _process_value(self, value: Any) -> Any:
"""Process a single value based on its type."""
if isinstance(value, str):
return await self._process_string(value)
elif isinstance(value, list):
return await self._process_array(value)
elif isinstance(value, dict):
# JSON data - serialize to string
return json.dumps(value)
else:
# Numbers, booleans - pass through
return value
async def _process_string(self, value: str) -> Any:
"""Process a string value, detecting if it's base64/URL data."""
if self._is_base64_image(value):
return await self._decode_base64_image(value)
elif self._is_base64_audio(value):
return await self._decode_base64_audio(value)
elif self._is_url(value):
return await self._download_and_decode(value)
else:
# Plain string
return value
async def _process_array(self, items: list) -> Union[torch.Tensor, list]:
"""
Process array of items.
If all items are images, returns a batched tensor [N, H, W, C].
Otherwise returns a list of processed items.
"""
if not items:
return []
# Check if all items are image data
if all(self._is_image_data(item) for item in items):
tensors = []
for item in items:
if isinstance(item, str):
if self._is_base64_image(item):
tensor = await self._decode_base64_image(item)
elif self._is_url(item):
tensor = await self._download_and_decode(item)
else:
continue
tensors.append(tensor)
if tensors:
# Stack into batch tensor [N, H, W, C]
return self._batch_images(tensors)
return []
# Otherwise process each item individually
return [await self._process_value(item) for item in items]
def _batch_images(self, tensors: List[torch.Tensor]) -> torch.Tensor:
"""
Batch multiple image tensors, handling different sizes.
Args:
tensors: List of [1, H, W, C] tensors
Returns:
Batched tensor [N, H, W, C]
"""
if not tensors:
return torch.zeros(0, 64, 64, 3)
# Check if all images have the same size
shapes = [t.shape[1:3] for t in tensors]
if all(s == shapes[0] for s in shapes):
# All same size, simple cat
return torch.cat(tensors, dim=0)
# Different sizes - pad to largest
max_h = max(t.shape[1] for t in tensors)
max_w = max(t.shape[2] for t in tensors)
padded = []
for t in tensors:
h, w = t.shape[1], t.shape[2]
if h < max_h or w < max_w:
# Center pad
pad_h_before = (max_h - h) // 2
pad_h_after = max_h - h - pad_h_before
pad_w_before = (max_w - w) // 2
pad_w_after = max_w - w - pad_w_before
# F.pad expects (left, right, top, bottom) for 4D tensor
t = torch.nn.functional.pad(
t.permute(0, 3, 1, 2), # [B, C, H, W]
(pad_w_before, pad_w_after, pad_h_before, pad_h_after),
mode='constant',
value=0
).permute(0, 2, 3, 1) # Back to [B, H, W, C]
padded.append(t)
return torch.cat(padded, dim=0)
# Detection methods
def _is_base64_image(self, value: str) -> bool:
"""Check if string is a base64-encoded image."""
if value.startswith("data:image/"):
return True
# Check for raw base64 that might be an image (heuristic)
if len(value) > 100 and re.match(r'^[A-Za-z0-9+/]+=*$', value):
# Try to detect PNG/JPEG magic bytes
try:
decoded = base64.b64decode(value[:32])
# PNG magic: 89 50 4E 47
if decoded.startswith(b'\x89PNG'):
return True
# JPEG magic: FF D8 FF
if decoded.startswith(b'\xff\xd8\xff'):
return True
# WebP magic: RIFF....WEBP
if decoded.startswith(b'RIFF') and b'WEBP' in decoded[:16]:
return True
except:
pass
return False
def _is_base64_audio(self, value: str) -> bool:
"""Check if string is a base64-encoded audio."""
return value.startswith("data:audio/")
def _is_url(self, value: str) -> bool:
"""Check if string is an HTTP(S) URL."""
return bool(HTTP_URL_PATTERN.match(value))
def _is_image_data(self, value: Any) -> bool:
"""Check if value is image data (base64 or URL)."""
if not isinstance(value, str):
return False
return self._is_base64_image(value) or (
self._is_url(value) and
any(ext in value.lower() for ext in ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'])
)
# Decoding methods
async def _decode_base64_image(self, data: str) -> torch.Tensor:
"""
Decode base64 image to tensor.
Supports:
- data:image/png;base64,...
- data:image/jpeg;base64,...
- data:image/webp;base64,...
- Raw base64 (no prefix)
Returns:
Tensor [1, H, W, C] with values 0-1
"""
# Strip data URL prefix if present
if data.startswith("data:"):
match = DATA_URL_PATTERN.match(data)
if match:
data = match.group(3)
else:
_, data = data.split(",", 1)
# Decode base64
image_bytes = base64.b64decode(data)
# Load with PIL
pil_image = Image.open(io.BytesIO(image_bytes))
pil_image = ImageOps.exif_transpose(pil_image)
# Convert to RGB if needed
if pil_image.mode == "I":
# 16-bit images
pil_image = pil_image.point(lambda i: i * (1 / 255))
if pil_image.mode != "RGB":
pil_image = pil_image.convert("RGB")
# Convert to tensor [1, H, W, C] with values 0-1
np_image = np.array(pil_image).astype(np.float32) / 255.0
tensor = torch.from_numpy(np_image).unsqueeze(0)
return tensor
async def _decode_base64_audio(self, data: str) -> dict:
"""
Decode base64 audio to ComfyUI audio format.
Returns:
{"waveform": tensor [1, channels, samples], "sample_rate": int}
"""
if torchaudio is None:
raise ImportError("torchaudio is required for audio processing")
# Strip data URL prefix if present
if data.startswith("data:"):
_, data = data.split(",", 1)
audio_bytes = base64.b64decode(data)
# Use torchaudio to load
buffer = io.BytesIO(audio_bytes)
waveform, sample_rate = torchaudio.load(buffer)
# Resample to 44100 if needed (common ComfyUI sample rate)
target_rate = 44100
if sample_rate != target_rate:
resampler = torchaudio.transforms.Resample(sample_rate, target_rate)
waveform = resampler(waveform)
sample_rate = target_rate
return {
"waveform": waveform.unsqueeze(0), # [1, channels, samples]
"sample_rate": sample_rate
}
async def _download_and_decode(self, url: str) -> Any:
"""
Download content from URL and decode based on content type.
Returns:
Decoded content (tensor for images, dict for audio, etc.)
"""
if aiohttp is None:
raise ImportError("aiohttp is required for URL downloads")
timeout = aiohttp.ClientTimeout(total=self.download_timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as response:
response.raise_for_status()
content_type = response.headers.get("Content-Type", "").lower()
data = await response.read()
# Detect type from content-type header or URL
if "image" in content_type or self._looks_like_image_url(url):
return await self._decode_image_bytes(data)
elif "audio" in content_type or self._looks_like_audio_url(url):
return await self._decode_audio_bytes(data)
else:
# Return as string
return data.decode("utf-8", errors="replace")
async def _decode_image_bytes(self, data: bytes) -> torch.Tensor:
"""Decode image bytes to tensor."""
pil_image = Image.open(io.BytesIO(data))
pil_image = ImageOps.exif_transpose(pil_image)
if pil_image.mode == "I":
pil_image = pil_image.point(lambda i: i * (1 / 255))
if pil_image.mode != "RGB":
pil_image = pil_image.convert("RGB")
np_image = np.array(pil_image).astype(np.float32) / 255.0
tensor = torch.from_numpy(np_image).unsqueeze(0)
return tensor
async def _decode_audio_bytes(self, data: bytes) -> dict:
"""Decode audio bytes to ComfyUI format."""
if torchaudio is None:
raise ImportError("torchaudio is required for audio processing")
buffer = io.BytesIO(data)
waveform, sample_rate = torchaudio.load(buffer)
target_rate = 44100
if sample_rate != target_rate:
resampler = torchaudio.transforms.Resample(sample_rate, target_rate)
waveform = resampler(waveform)
sample_rate = target_rate
return {
"waveform": waveform.unsqueeze(0),
"sample_rate": sample_rate
}
def _looks_like_image_url(self, url: str) -> bool:
"""Check if URL looks like an image based on extension."""
lower = url.lower()
return any(ext in lower for ext in ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'])
def _looks_like_audio_url(self, url: str) -> bool:
"""Check if URL looks like audio based on extension."""
lower = url.lower()
return any(ext in lower for ext in ['.mp3', '.wav', '.flac', '.ogg', '.opus', '.m4a'])
# Specialized extraction methods for nodes
async def process_image(self, value: Any) -> torch.Tensor:
"""
Process a single image input.
Args:
value: Base64 string, URL, or already a tensor
Returns:
Tensor [1, H, W, C]
"""
if isinstance(value, torch.Tensor):
return value
if isinstance(value, str):
if self._is_base64_image(value):
return await self._decode_base64_image(value)
elif self._is_url(value):
return await self._download_and_decode(value)
# Return empty tensor for invalid input
return torch.zeros(1, 64, 64, 3)
async def process_audio(self, value: Any) -> dict:
"""
Process a single audio input.
Args:
value: Base64 string, URL, or already an audio dict
Returns:
{"waveform": tensor, "sample_rate": int}
"""
if isinstance(value, dict) and "waveform" in value:
return value
if isinstance(value, str):
if self._is_base64_audio(value):
return await self._decode_base64_audio(value)
elif self._is_url(value):
return await self._download_and_decode(value)
# Return empty audio for invalid input
return {"waveform": torch.zeros(1, 1, 44100), "sample_rate": 44100}
async def process_mask(self, value: Any) -> torch.Tensor:
"""
Process a mask input (grayscale image).
Args:
value: Base64 string or URL of grayscale image
Returns:
Tensor [1, H, W] for MASK type
"""
if isinstance(value, torch.Tensor):
if len(value.shape) == 3:
return value
elif len(value.shape) == 4:
# Convert [B, H, W, C] to [B, H, W] using first channel
return value[:, :, :, 0]
if isinstance(value, str):
# Decode image
if self._is_base64_image(value):
tensor = await self._decode_base64_image(value)
elif self._is_url(value):
tensor = await self._download_and_decode(value)
else:
return torch.zeros(1, 64, 64)
# Convert to grayscale mask
if len(tensor.shape) == 4:
# Average RGB channels
mask = tensor.mean(dim=-1)
else:
mask = tensor
return mask
return torch.zeros(1, 64, 64)
async def process_video(self, value: Any) -> Any:
"""
Process video input.
Note: Video processing is more complex and may require
additional dependencies like decord or av.
Args:
value: Base64 string or URL
Returns:
Video data (format depends on ComfyUI video nodes)
"""
# Basic implementation - return None for now
# Full video support would require decord or av
logger.warning("Video input processing not fully implemented")
return None
async def process_latent(self, value: Any) -> dict:
"""
Process latent input (safetensors or torch file).
Args:
value: Base64 string (safetensors/pt) or URL
Returns:
{"samples": tensor, ...} latent dict
"""
if isinstance(value, dict) and "samples" in value:
return value
default_latent = {"samples": torch.zeros(1, 4, 64, 64)}
if isinstance(value, str):
try:
# Get data bytes
if value.startswith("data:"):
_, data_str = value.split(",", 1)
data = base64.b64decode(data_str)
elif self._is_url(value):
if aiohttp is None:
return default_latent
timeout = aiohttp.ClientTimeout(total=self.download_timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(value) as response:
response.raise_for_status()
data = await response.read()
else:
# Raw base64
data = base64.b64decode(value)
# Try safetensors first
try:
from safetensors.torch import load
buffer = io.BytesIO(data)
tensors = load(buffer.getvalue())
if "samples" in tensors:
return {"samples": tensors["samples"]}
# Return first tensor as samples
for key, tensor in tensors.items():
return {"samples": tensor}
except ImportError:
pass
except Exception:
pass
# Try torch load
try:
buffer = io.BytesIO(data)
loaded = torch.load(buffer, weights_only=True)
if isinstance(loaded, dict) and "samples" in loaded:
return loaded
if isinstance(loaded, torch.Tensor):
return {"samples": loaded}
except Exception:
pass
except Exception as e:
logger.error(f"Failed to process latent: {e}")
return default_latent
async def process_mesh(self, value: Any) -> dict:
"""
Process 3D mesh input (GLB, OBJ, STL).
Args:
value: Base64 string or URL
Returns:
{"vertices": tensor, "faces": tensor} mesh dict
"""
default_mesh = {
"vertices": torch.zeros(1, 3, 3),
"faces": torch.zeros(1, 1, 3, dtype=torch.long)
}
if isinstance(value, dict) and "vertices" in value:
return value
if isinstance(value, str):
try:
# Get data bytes
if value.startswith("data:"):
_, data_str = value.split(",", 1)
data = base64.b64decode(data_str)
elif self._is_url(value):
if aiohttp is None:
return default_mesh
timeout = aiohttp.ClientTimeout(total=self.download_timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(value) as response:
response.raise_for_status()
data = await response.read()
else:
# Raw base64
data = base64.b64decode(value)
# Detect format and parse
return self._parse_mesh_bytes(data, value)
except Exception as e:
logger.error(f"Failed to process mesh: {e}")
return default_mesh
def _parse_mesh_bytes(self, data: bytes, hint: str = "") -> dict:
"""Parse mesh from bytes based on format detection."""
default_mesh = {
"vertices": torch.zeros(1, 3, 3),
"faces": torch.zeros(1, 1, 3, dtype=torch.long)
}
# Detect format
if data.startswith(b'glTF'):
return self._parse_glb(data)
elif data.startswith(b'solid ') or data[:80].count(b'\x00') < 10:
# Text STL or ASCII format
if b'v ' in data[:1000]:
return self._parse_obj(data)
elif len(data) > 84:
# Binary STL check
try:
import struct
num_triangles = struct.unpack('<I', data[80:84])[0]
expected_size = 84 + num_triangles * 50
if len(data) == expected_size:
return self._parse_stl_binary(data)
except:
pass
# Try OBJ as fallback for text formats
if b'v ' in data[:1000]:
return self._parse_obj(data)
return default_mesh
def _parse_glb(self, data: bytes) -> dict:
"""Parse GLB (binary glTF) file."""
import struct
import json as json_mod
default_mesh = {
"vertices": torch.zeros(1, 3, 3),
"faces": torch.zeros(1, 1, 3, dtype=torch.long)
}
try:
# GLB header: magic (4), version (4), length (4)
if len(data) < 12:
return default_mesh
magic, version, length = struct.unpack('<4sII', data[:12])
if magic != b'glTF':
return default_mesh
# Read JSON chunk
chunk_length, chunk_type = struct.unpack('<II', data[12:20])
json_data = json_mod.loads(data[20:20+chunk_length])
# Read BIN chunk
bin_offset = 20 + chunk_length
if bin_offset + 8 > len(data):
return default_mesh
bin_length, bin_type = struct.unpack('<II', data[bin_offset:bin_offset+8])
bin_data = data[bin_offset+8:bin_offset+8+bin_length]
# Extract mesh from first primitive
meshes = json_data.get("meshes", [])
if not meshes:
return default_mesh
primitive = meshes[0].get("primitives", [{}])[0]
accessors = json_data.get("accessors", [])
buffer_views = json_data.get("bufferViews", [])
# Get vertices
pos_accessor_idx = primitive.get("attributes", {}).get("POSITION")
if pos_accessor_idx is None:
return default_mesh
pos_accessor = accessors[pos_accessor_idx]
pos_view = buffer_views[pos_accessor["bufferView"]]
pos_offset = pos_view.get("byteOffset", 0)
vertex_count = pos_accessor["count"]
vertices_flat = np.frombuffer(
bin_data[pos_offset:pos_offset + vertex_count * 12],
dtype=np.float32
)
vertices = vertices_flat.reshape(-1, 3)
# Get indices
indices_accessor_idx = primitive.get("indices")
if indices_accessor_idx is not None:
idx_accessor = accessors[indices_accessor_idx]
idx_view = buffer_views[idx_accessor["bufferView"]]
idx_offset = idx_view.get("byteOffset", 0)
idx_count = idx_accessor["count"]
# Handle different component types
comp_type = idx_accessor.get("componentType", 5125)
if comp_type == 5123: # UNSIGNED_SHORT
faces_flat = np.frombuffer(
bin_data[idx_offset:idx_offset + idx_count * 2],
dtype=np.uint16
)
else: # UNSIGNED_INT
faces_flat = np.frombuffer(
bin_data[idx_offset:idx_offset + idx_count * 4],
dtype=np.uint32
)
faces = faces_flat.reshape(-1, 3)
else:
# Generate faces from vertices
faces = np.arange(len(vertices)).reshape(-1, 3)
return {
"vertices": torch.from_numpy(vertices).unsqueeze(0).float(),
"faces": torch.from_numpy(faces.astype(np.int64)).unsqueeze(0)
}
except Exception as e:
logger.error(f"Failed to parse GLB: {e}")
return default_mesh
def _parse_obj(self, data: bytes) -> dict:
"""Parse OBJ file."""
default_mesh = {
"vertices": torch.zeros(1, 3, 3),
"faces": torch.zeros(1, 1, 3, dtype=torch.long)
}
try:
text = data.decode('utf-8', errors='replace')
vertices = []
faces = []
for line in text.split('\n'):
line = line.strip()
if line.startswith('v '):
parts = line.split()[1:4]
vertices.append([float(p) for p in parts])
elif line.startswith('f '):
parts = line.split()[1:]
# Handle v/vt/vn format
face_indices = []
for p in parts[:3]: # Only first 3 vertices for triangles
idx = int(p.split('/')[0]) - 1 # OBJ is 1-indexed
face_indices.append(idx)
if len(face_indices) == 3:
faces.append(face_indices)
if vertices and faces:
return {
"vertices": torch.tensor(vertices).unsqueeze(0).float(),
"faces": torch.tensor(faces, dtype=torch.long).unsqueeze(0)
}
except Exception as e:
logger.error(f"Failed to parse OBJ: {e}")
return default_mesh
def _parse_stl_binary(self, data: bytes) -> dict:
"""Parse binary STL file."""
import struct
default_mesh = {
"vertices": torch.zeros(1, 3, 3),
"faces": torch.zeros(1, 1, 3, dtype=torch.long)
}
try:
num_triangles = struct.unpack('<I', data[80:84])[0]
vertices = []
faces = []
offset = 84
for i in range(num_triangles):
# Skip normal (12 bytes)
offset += 12
# Read 3 vertices
for j in range(3):
v = struct.unpack('<fff', data[offset:offset+12])
vertices.append(list(v))
offset += 12
# Skip attribute byte count
offset += 2
# Create face indices
base_idx = i * 3
faces.append([base_idx, base_idx + 1, base_idx + 2])
if vertices and faces:
return {
"vertices": torch.tensor(vertices).unsqueeze(0).float(),
"faces": torch.tensor(faces, dtype=torch.long).unsqueeze(0)
}
except Exception as e:
logger.error(f"Failed to parse STL: {e}")
return default_mesh
+385
View File
@@ -0,0 +1,385 @@
"""
Output detection and processing for webhook delivery.
Handles extracting output information from ComfyUI UI structures.
"""
import json
import logging
import os
from typing import Optional
try:
import folder_paths
except ImportError:
folder_paths = None
# Handle imports for both package and standalone usage
try:
from ..core.types import OutputInfo
from ..utils.mime import guess_mime_type, get_format_from_filename
except ImportError:
from core.types import OutputInfo
from utils.mime import guess_mime_type, get_format_from_filename
logger = logging.getLogger(__name__)
def get_output_path(
filename: str,
subfolder: str = "",
folder_type: str = "output"
) -> str:
"""
Resolve the full path to an output file.
Args:
filename: Name of the file
subfolder: Subfolder within the directory
folder_type: Type of folder (output, temp, input)
Returns:
Full filesystem path to the file
"""
if folder_paths is None:
# Fallback if folder_paths not available
base_dir = os.getcwd()
return os.path.join(base_dir, folder_type, subfolder, filename)
if folder_type == "output":
base_dir = folder_paths.get_output_directory()
elif folder_type == "temp":
base_dir = folder_paths.get_temp_directory()
elif folder_type == "input":
base_dir = folder_paths.get_input_directory()
else:
base_dir = folder_paths.get_output_directory()
if subfolder:
return os.path.join(base_dir, subfolder, filename)
return os.path.join(base_dir, filename)
def detect_output_type(
ui_output: dict,
node_id: Optional[str] = None,
node_type: Optional[str] = None
) -> list[OutputInfo]:
"""
Detect output type from ComfyUI UI return structure.
Known UI structures:
- {"images": [{filename, subfolder, type}, ...]}
- {"audio": [{filename, subfolder, type}, ...]}
- {"video": [{filename, subfolder, type}, ...]}
- {"3d": [{filename, subfolder, type}, ...]}
- {"latents": [{filename, subfolder, type}, ...]}
- {"text": (value,)} or {"text": ["value"]}
- {"result": [model_file, camera_info, bg_image]}
Args:
ui_output: The UI output dictionary from a node
node_id: Optional node ID for metadata
node_type: Optional node type for metadata
Returns:
List of OutputInfo objects describing the outputs
"""
outputs = []
if not ui_output or not isinstance(ui_output, dict):
return outputs
# Handle images
if "images" in ui_output:
images = ui_output["images"]
if isinstance(images, list):
for idx, item in enumerate(images):
if isinstance(item, dict) and "filename" in item:
info = _create_output_info_from_file(
item, "image", node_id, node_type
)
outputs.append(info)
# Handle audio
if "audio" in ui_output:
audio_items = ui_output["audio"]
if isinstance(audio_items, list):
for item in audio_items:
if isinstance(item, dict) and "filename" in item:
info = _create_output_info_from_file(
item, "audio", node_id, node_type
)
outputs.append(info)
# Handle video
if "video" in ui_output:
video_items = ui_output["video"]
if isinstance(video_items, list):
for item in video_items:
if isinstance(item, dict) and "filename" in item:
info = _create_output_info_from_file(
item, "video", node_id, node_type
)
outputs.append(info)
# Handle 3D meshes
if "3d" in ui_output:
mesh_items = ui_output["3d"]
if isinstance(mesh_items, list):
for item in mesh_items:
if isinstance(item, dict) and "filename" in item:
info = _create_output_info_from_file(
item, "mesh", node_id, node_type
)
outputs.append(info)
# Handle latents
if "latents" in ui_output:
latent_items = ui_output["latents"]
if isinstance(latent_items, list):
for item in latent_items:
if isinstance(item, dict) and "filename" in item:
info = _create_output_info_from_file(
item, "latent", node_id, node_type
)
outputs.append(info)
# Handle text outputs
if "text" in ui_output:
text_data = ui_output["text"]
if isinstance(text_data, tuple) and len(text_data) > 0:
# Text is often returned as a tuple
text_value = str(text_data[0])
elif isinstance(text_data, list) and len(text_data) > 0:
text_value = str(text_data[0])
elif isinstance(text_data, str):
text_value = text_data
else:
text_value = str(text_data)
outputs.append(OutputInfo(
type="text",
inline_content=text_value,
mime_type="text/plain",
node_id=node_id,
node_type=node_type,
))
# Handle 3D preview (result structure)
if "result" in ui_output and "images" not in ui_output:
result_data = ui_output["result"]
outputs.append(OutputInfo(
type="3d_preview",
inline_content=json.dumps(result_data),
mime_type="application/json",
node_id=node_id,
node_type=node_type,
))
# Handle animated flag (for videos/GIFs saved as images)
if "animated" in ui_output:
# Mark the last image output as animated if applicable
for output in reversed(outputs):
if output.type == "image":
output.format = "animated"
break
# Fallback: try to extract any file references from unknown structures
if not outputs:
outputs = _extract_unknown_outputs(ui_output, node_id, node_type)
return outputs
def _create_output_info_from_file(
item: dict,
output_type: str,
node_id: Optional[str],
node_type: Optional[str]
) -> OutputInfo:
"""Create OutputInfo from a file reference dictionary."""
filename = item.get("filename", "")
subfolder = item.get("subfolder", "")
folder_type = item.get("type", "output")
# Get file path and size
file_path = get_output_path(filename, subfolder, folder_type)
file_size = None
if os.path.exists(file_path):
file_size = os.path.getsize(file_path)
return OutputInfo(
type=output_type,
filename=filename,
subfolder=subfolder,
folder_type=folder_type,
mime_type=guess_mime_type(filename),
format=get_format_from_filename(filename),
file_size_bytes=file_size,
node_id=node_id,
node_type=node_type,
)
def _extract_unknown_outputs(
ui_output: dict,
node_id: Optional[str],
node_type: Optional[str]
) -> list[OutputInfo]:
"""
Extract outputs from unknown UI structures.
Looks for any dictionary with 'filename' key.
"""
outputs = []
for key, value in ui_output.items():
if isinstance(value, list):
for item in value:
if isinstance(item, dict) and "filename" in item:
info = _create_output_info_from_file(
item, "unknown", node_id, node_type
)
outputs.append(info)
logger.info(
f"Extracted unknown output type '{key}': {item.get('filename')}"
)
# If still no outputs, serialize the entire structure as JSON
if not outputs and ui_output:
logger.warning(
f"Unknown output format, serializing as JSON: {list(ui_output.keys())}"
)
outputs.append(OutputInfo(
type="unknown",
inline_content=json.dumps(ui_output),
mime_type="application/json",
node_id=node_id,
node_type=node_type,
))
return outputs
def process_outputs_for_webhook(
outputs: dict,
meta: Optional[dict] = None
) -> list[OutputInfo]:
"""
Process all outputs from a workflow execution for webhook delivery.
Args:
outputs: Dictionary mapping node_id -> UI output dict
meta: Optional metadata dict mapping node_id -> node metadata
Returns:
List of all OutputInfo objects from all nodes
"""
all_outputs = []
for node_id, ui_output in outputs.items():
# Get node type from metadata if available
node_type = None
if meta and node_id in meta:
node_meta = meta[node_id]
if isinstance(node_meta, dict):
node_type = node_meta.get("node_type")
# Detect outputs for this node
node_outputs = detect_output_type(ui_output, node_id, node_type)
all_outputs.extend(node_outputs)
return all_outputs
def load_output_file(output_info: OutputInfo) -> Optional[bytes]:
"""
Load the binary content of an output file.
Args:
output_info: OutputInfo with file location details
Returns:
File bytes or None if file doesn't exist
"""
if not output_info.filename:
return None
file_path = get_output_path(
output_info.filename,
output_info.subfolder or "",
output_info.folder_type or "output"
)
if not os.path.exists(file_path):
logger.warning(f"Output file not found: {file_path}")
return None
try:
with open(file_path, "rb") as f:
return f.read()
except Exception as e:
logger.error(f"Failed to read output file {file_path}: {e}")
return None
def get_image_dimensions(output_info: OutputInfo) -> tuple[Optional[int], Optional[int]]:
"""
Get dimensions of an image output.
Args:
output_info: OutputInfo for an image
Returns:
Tuple of (width, height) or (None, None) if unable to determine
"""
if output_info.type != "image" or not output_info.filename:
return None, None
try:
from PIL import Image
file_path = get_output_path(
output_info.filename,
output_info.subfolder or "",
output_info.folder_type or "output"
)
if os.path.exists(file_path):
with Image.open(file_path) as img:
return img.size # (width, height)
except Exception as e:
logger.warning(f"Failed to get image dimensions: {e}")
return None, None
def enrich_output_info(output_info: OutputInfo) -> OutputInfo:
"""
Enrich OutputInfo with additional metadata.
Adds dimensions for images, file size, etc.
Args:
output_info: The OutputInfo to enrich
Returns:
The same OutputInfo with additional fields populated
"""
# Get file size if not already set
if output_info.file_size_bytes is None and output_info.filename:
file_path = get_output_path(
output_info.filename,
output_info.subfolder or "",
output_info.folder_type or "output"
)
if os.path.exists(file_path):
output_info.file_size_bytes = os.path.getsize(file_path)
# Get image dimensions
if output_info.type == "image" and (output_info.width is None or output_info.height is None):
width, height = get_image_dimensions(output_info)
if width and height:
output_info.width = width
output_info.height = height
return output_info
+4
View File
@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
python_files = test_*.py
asyncio_mode = auto
+18
View File
@@ -0,0 +1,18 @@
# ComfyUI Workflow API - Dependencies
# Required
aiohttp>=3.8.0 # Async HTTP client for webhook delivery
# Optional - for enhanced validation (uncomment if desired)
# pydantic>=2.0.0 # Data validation and serialization
# Development dependencies (install with: pip install -r requirements.txt[dev])
# pytest>=7.0.0 # Testing framework
# pytest-asyncio>=0.21.0 # Async test support
# pytest-cov>=4.0.0 # Coverage reporting
# Note: The following are typically already available in ComfyUI:
# - torch # Tensor operations
# - numpy # Array operations
# - Pillow # Image processing
# - av # Video encoding (for video output)
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Run all tests."""
import subprocess
import sys
from pathlib import Path
def main():
test_dir = Path(__file__).parent / "tests"
test_files = list(test_dir.glob("test_*.py"))
if not test_files:
print("No test files found")
return 1
failed = 0
for test_file in test_files:
print(f"\n{'='*50}")
print(f"Running {test_file.name}")
print('='*50)
result = subprocess.run([sys.executable, str(test_file)], cwd=Path(__file__).parent)
if result.returncode != 0:
failed += 1
print(f"\n{'='*50}")
if failed:
print(f"FAILED: {failed} test file(s) had failures")
else:
print("All tests passed!")
return failed
if __name__ == "__main__":
sys.exit(main())
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

+6
View File
@@ -0,0 +1,6 @@
"""Server extension for ComfyUI webhook integration."""
from .routes import register_routes
from .uploads import save_upload, cleanup_uploads
__all__ = ["register_routes", "save_upload", "cleanup_uploads"]
+308
View File
@@ -0,0 +1,308 @@
"""HTTP routes for webhook integration."""
import json
import logging
import uuid
from datetime import datetime
from typing import Optional
from aiohttp import web
logger = logging.getLogger(__name__)
def register_routes():
"""Register webhook routes with ComfyUI's PromptServer."""
try:
from server import PromptServer
routes = PromptServer.instance.routes
@routes.post("/api/webhook")
async def webhook_submit(request):
"""
Submit a workflow via multipart/form-data.
Fields:
- workflow: JSON workflow definition (required)
- callback_url: URL to receive events and outputs (required)
- auth_header: Auth header name (optional)
- auth_value: Auth header value (optional)
- timeout: HTTP timeout in seconds (optional, default: 60)
- max_retries: Max retry attempts (optional, default: 3)
- <any file>: Uploaded files, accessible by field name
- <any field>: Text fields, accessible by field name
Returns:
- request_id: Unique identifier for this request
- prompt_id: ComfyUI prompt ID
- status: "queued"
"""
from .uploads import save_upload
request_id = str(uuid.uuid4())
try:
# Parse multipart request
reader = await request.multipart()
workflow = None
callback_url = None
auth_header = None
auth_value = None
timeout = 60
max_retries = 3
send_progress = True
progress_interval_ms = 500
webhook_inputs = {}
# Get request metadata
remote_ip = request.remote
content_type = request.content_type
content_length = request.content_length
user_agent = request.headers.get("User-Agent", "unknown")
async for part in reader:
name = part.name
if name == "workflow":
content = await part.text()
try:
workflow = json.loads(content)
except json.JSONDecodeError as e:
return web.json_response(
{"error": f"Invalid workflow JSON: {e}"},
status=400
)
elif name == "callback_url":
callback_url = await part.text()
elif name == "auth_header":
auth_header = await part.text()
elif name == "auth_value":
auth_value = await part.text()
elif name == "timeout":
try:
timeout = int(await part.text())
except ValueError:
pass
elif name == "max_retries":
try:
max_retries = int(await part.text())
except ValueError:
pass
elif name == "send_progress":
value = await part.text()
send_progress = value.lower() in ("true", "1", "yes")
elif name == "progress_interval_ms":
try:
progress_interval_ms = int(await part.text())
except ValueError:
pass
elif part.filename:
# File upload - save to disk and store path (not raw data to avoid serialization issues)
data = await part.read()
filename = part.filename
file_content_type = part.headers.get("Content-Type", "application/octet-stream")
# Save to ComfyUI input directory
saved_path = save_upload(data, filename, request_id)
# Store file metadata (path, not raw bytes) in webhook_inputs
webhook_inputs[name] = {
"_is_file": True,
"_filename": filename,
"_content_type": file_content_type,
"_saved_path": saved_path,
"_size_bytes": len(data),
}
logger.debug(f"Saved upload '{name}' ({len(data)} bytes) to {saved_path}")
else:
# Text field
webhook_inputs[name] = await part.text()
# Validate required fields
if workflow is None:
return web.json_response(
{"error": "Missing required field: workflow"},
status=400
)
if callback_url is None:
return web.json_response(
{"error": "Missing required field: callback_url"},
status=400
)
# Generate prompt_id
prompt_id = str(uuid.uuid4())
# Build webhook_config for extra_data
webhook_config = {
"callback_url": callback_url,
"auth_header": auth_header,
"auth_value": auth_value,
"request_id": request_id,
"timeout": timeout,
"max_retries": max_retries,
"send_progress": send_progress,
"progress_interval_ms": progress_interval_ms,
"remote_ip": remote_ip,
"content_type": content_type,
"content_length": content_length,
"user_agent": user_agent,
}
# Build extra_data for workflow
# ComfyUI passes extra_data['extra_pnginfo'] to nodes with EXTRA_PNGINFO hidden input
extra_data = {
"webhook_config": webhook_config,
"webhook_inputs": webhook_inputs,
"extra_pnginfo": {
"webhook_config": webhook_config,
"webhook_inputs": webhook_inputs,
},
}
# Validate prompt and get outputs_to_execute
import execution
valid = await execution.validate_prompt(prompt_id, workflow, None)
if not valid[0]:
# Validation failed
logger.warning(f"Invalid workflow: {valid[1]}")
return web.json_response(
{"error": valid[1], "node_errors": valid[3]},
status=400
)
outputs_to_execute = valid[2]
# Register webhook context for progress tracking
from ..core.context import register_webhook_context
from ..core.types import WebhookContext
context = WebhookContext(
request_id=request_id,
prompt_id=prompt_id,
callback_url=callback_url,
auth_header=auth_header,
auth_value=auth_value,
timeout=timeout,
max_retries=max_retries,
send_progress=send_progress,
progress_interval_ms=progress_interval_ms,
)
register_webhook_context(context)
# Queue the workflow
queue = PromptServer.instance.prompt_queue
# ComfyUI expects: (priority, prompt_id, prompt, extra_data, outputs_to_execute, sensitive)
queue.put((0, prompt_id, workflow, extra_data, outputs_to_execute, {}))
logger.info(f"Webhook request {request_id} queued as prompt {prompt_id}")
return web.json_response({
"request_id": request_id,
"prompt_id": prompt_id,
"status": "queued",
})
except Exception as e:
logger.error(f"Webhook submit error: {e}", exc_info=True)
return web.json_response(
{"error": str(e)},
status=500
)
@routes.get("/api/webhook/status/{request_id}")
async def webhook_status(request):
"""
Get status of a webhook request.
Returns:
- request_id: The request ID
- prompt_id: ComfyUI prompt ID
- status: queued|running|completed|error|interrupted
- progress: Current progress info (if running)
- error: Error details (if error)
"""
from ..core.context import get_context
request_id = request.match_info["request_id"]
context = get_context(request_id)
if context is None:
return web.json_response(
{"error": "Request not found"},
status=404
)
response = {
"request_id": context.request_id,
"prompt_id": context.prompt_id,
"callback_url": context.callback_url,
"fields": context.get_field_names(),
}
if context.start_time:
response["started_at"] = context.start_time.isoformat()
if context.completed_at:
response["completed_at"] = context.completed_at.isoformat()
if context.start_time:
elapsed = (context.completed_at - context.start_time).total_seconds() * 1000
response["execution_time_ms"] = elapsed
return web.json_response(response)
@routes.delete("/api/webhook/{request_id}")
async def webhook_cancel(request):
"""Cancel a queued or running webhook request."""
from ..core.context import get_context, update_context
request_id = request.match_info["request_id"]
context = get_context(request_id)
if context is None:
return web.json_response(
{"error": "Request not found"},
status=404
)
# Try to interrupt the execution
try:
if context.prompt_id:
PromptServer.instance.prompt_queue.delete_queue_item(context.prompt_id)
update_context(request_id, completed_at=datetime.now())
return web.json_response({
"request_id": request_id,
"status": "cancelled",
})
except Exception as e:
logger.error(f"Failed to cancel request {request_id}: {e}")
return web.json_response(
{"error": str(e)},
status=500
)
logger.info("Webhook routes registered: POST /api/webhook, GET /api/webhook/status/{id}")
return True
except ImportError as e:
logger.warning(f"Could not register webhook routes (ComfyUI server not available): {e}")
return False
except Exception as e:
logger.error(f"Failed to register webhook routes: {e}", exc_info=True)
return False
+187
View File
@@ -0,0 +1,187 @@
"""Handle file uploads for webhook requests."""
import logging
import os
import shutil
import uuid
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# Track uploads per request for cleanup
_upload_registry: dict[str, list[str]] = {}
def get_upload_directory() -> str:
"""Get the directory for webhook uploads."""
try:
import folder_paths
# Use ComfyUI's input directory for uploads
input_dir = folder_paths.get_input_directory()
webhook_dir = os.path.join(input_dir, "webhook_uploads")
except ImportError:
# Fallback for testing
webhook_dir = os.path.join(os.getcwd(), "input", "webhook_uploads")
os.makedirs(webhook_dir, exist_ok=True)
return webhook_dir
def save_upload(data: bytes, filename: str, request_id: str) -> str:
"""
Save an uploaded file to the webhook uploads directory.
Args:
data: File content as bytes
filename: Original filename
request_id: Request ID for organizing uploads
Returns:
Full path to the saved file
"""
upload_dir = get_upload_directory()
# Create request-specific subdirectory
request_dir = os.path.join(upload_dir, request_id)
os.makedirs(request_dir, exist_ok=True)
# Sanitize filename and make unique
safe_filename = sanitize_filename(filename)
base, ext = os.path.splitext(safe_filename)
# Add short UUID to avoid collisions
unique_filename = f"{base}_{uuid.uuid4().hex[:8]}{ext}"
file_path = os.path.join(request_dir, unique_filename)
# Write file
with open(file_path, "wb") as f:
f.write(data)
# Track for cleanup
if request_id not in _upload_registry:
_upload_registry[request_id] = []
_upload_registry[request_id].append(file_path)
logger.debug(f"Saved upload: {file_path} ({len(data)} bytes)")
return file_path
def sanitize_filename(filename: str) -> str:
"""Sanitize a filename for safe storage."""
# Remove path separators and null bytes
filename = filename.replace("/", "_").replace("\\", "_").replace("\x00", "")
# Remove leading dots (hidden files)
while filename.startswith("."):
filename = filename[1:]
# Default name if empty
if not filename:
filename = "upload"
# Limit length
if len(filename) > 200:
base, ext = os.path.splitext(filename)
filename = base[:200 - len(ext)] + ext
return filename
def cleanup_uploads(request_id: str) -> int:
"""
Clean up uploaded files for a request.
Args:
request_id: Request ID to clean up
Returns:
Number of files removed
"""
count = 0
# Remove tracked files
if request_id in _upload_registry:
for file_path in _upload_registry[request_id]:
try:
if os.path.exists(file_path):
os.remove(file_path)
count += 1
except Exception as e:
logger.warning(f"Failed to remove upload {file_path}: {e}")
del _upload_registry[request_id]
# Also try to remove the request directory
upload_dir = get_upload_directory()
request_dir = os.path.join(upload_dir, request_id)
try:
if os.path.exists(request_dir):
shutil.rmtree(request_dir)
logger.debug(f"Removed upload directory: {request_dir}")
except Exception as e:
logger.warning(f"Failed to remove upload directory {request_dir}: {e}")
return count
def get_upload_path(request_id: str, field_name: str) -> Optional[str]:
"""
Get the path to an uploaded file by field name.
This is used by WebhookInput node to load files.
Args:
request_id: Request ID
field_name: Field name from the upload
Returns:
File path if found, None otherwise
"""
# This is primarily resolved via extra_data["webhook_uploads"]
# But we can search the registry as fallback
if request_id in _upload_registry:
for path in _upload_registry[request_id]:
if field_name in path:
return path
return None
def cleanup_old_uploads(max_age_hours: int = 24) -> int:
"""
Clean up old upload directories.
Args:
max_age_hours: Remove directories older than this
Returns:
Number of directories removed
"""
import time
upload_dir = get_upload_directory()
if not os.path.exists(upload_dir):
return 0
count = 0
cutoff = time.time() - (max_age_hours * 3600)
try:
for entry in os.scandir(upload_dir):
if entry.is_dir():
try:
mtime = entry.stat().st_mtime
if mtime < cutoff:
shutil.rmtree(entry.path)
count += 1
logger.debug(f"Cleaned up old upload directory: {entry.path}")
except Exception as e:
logger.warning(f"Failed to clean up {entry.path}: {e}")
except Exception as e:
logger.warning(f"Failed to scan upload directory: {e}")
if count > 0:
logger.info(f"Cleaned up {count} old upload directories")
return count
View File
+7
View File
@@ -0,0 +1,7 @@
"""Pytest configuration."""
import sys
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
+121
View File
@@ -0,0 +1,121 @@
"""Core functionality tests."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from core.types import WebhookContext, WebhookField, WebhookEvent, WebhookResult, OutputInfo
from core.context import register_webhook_context, get_webhook_context, unregister_webhook_context
def test_context_defaults():
ctx = WebhookContext()
assert ctx.callback_url == ""
assert ctx.timeout == 60
assert ctx.max_retries == 3
assert ctx.request_id
# start_time is None until register_webhook_context() is called
assert ctx.start_time is None
assert ctx.timestamp is not None
def test_context_with_values():
ctx = WebhookContext(
callback_url="https://example.com/webhook",
auth_header="Authorization",
auth_value="Bearer token123",
timeout=30,
)
assert ctx.callback_url == "https://example.com/webhook"
assert ctx.auth_header == "Authorization"
assert ctx.timeout == 30
def test_auth_headers():
ctx = WebhookContext(auth_header="X-API-Key", auth_value="secret")
assert ctx.get_auth_headers() == {"X-API-Key": "secret"}
ctx_no_auth = WebhookContext()
assert ctx_no_auth.get_auth_headers() == {}
def test_field_access():
field = WebhookField(name="prompt", value="hello world", size_bytes=11)
ctx = WebhookContext(fields={"prompt": field})
assert ctx.has_field("prompt")
assert not ctx.has_field("missing")
assert ctx.get_field("prompt") == field
assert ctx.get_field_value("prompt") == "hello world"
assert ctx.get_field_value("missing", "default") == "default"
def test_legacy_inputs():
ctx = WebhookContext(inputs={"old_field": "old_value"})
assert ctx.get_field_value("old_field") == "old_value"
assert ctx.has_field("old_field")
def test_censor_auth():
ctx = WebhookContext(auth_header="Authorization", auth_value="Bearer secrettoken123")
censored = ctx.censor_auth()
assert "Bear" in censored
assert "123" in censored
assert "secrettoken" not in censored
def test_context_registry():
ctx = WebhookContext(prompt_id="test-123", callback_url="https://example.com")
register_webhook_context(ctx)
assert get_webhook_context("test-123") is ctx
unregister_webhook_context("test-123")
assert get_webhook_context("test-123") is None
def test_output_info():
info = OutputInfo(type="image", filename="test.png", width=1024, height=768)
d = info.to_dict()
assert d["type"] == "image"
assert d["filename"] == "test.png"
assert d["width"] == 1024
def test_webhook_events():
assert WebhookEvent.STARTED.value == "workflow.started"
assert WebhookEvent.COMPLETED.value == "workflow.completed"
assert WebhookEvent.ERROR.value == "workflow.error"
def run_all():
tests = [
test_context_defaults,
test_context_with_values,
test_auth_headers,
test_field_access,
test_legacy_inputs,
test_censor_auth,
test_context_registry,
test_output_info,
test_webhook_events,
]
passed = 0
failed = 0
for test in tests:
try:
test()
print(f" PASS: {test.__name__}")
passed += 1
except Exception as e:
print(f" FAIL: {test.__name__} - {e}")
failed += 1
print(f"\n{passed} passed, {failed} failed")
return failed == 0
if __name__ == "__main__":
print("Running core tests...")
success = run_all()
sys.exit(0 if success else 1)
+16
View File
@@ -0,0 +1,16 @@
"""Utility functions for webhook functionality."""
from .mime import guess_mime_type, get_extension_for_mime, MIME_TYPES
from .files import get_output_path, get_image_dimensions, get_temp_directory
from .helpers import format_size, run_async
__all__ = [
"guess_mime_type",
"get_extension_for_mime",
"MIME_TYPES",
"get_output_path",
"get_image_dimensions",
"get_temp_directory",
"format_size",
"run_async",
]
+192
View File
@@ -0,0 +1,192 @@
"""File path resolution and utilities."""
import logging
import os
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
def get_output_path(
filename: str,
subfolder: str = "",
folder_type: str = "output"
) -> str:
"""
Resolve the full path to an output file.
Args:
filename: Name of the file
subfolder: Subfolder within the directory
folder_type: Type of folder (output, temp, input)
Returns:
Full filesystem path to the file
"""
try:
import folder_paths
if folder_type == "output":
base_dir = folder_paths.get_output_directory()
elif folder_type == "temp":
base_dir = folder_paths.get_temp_directory()
elif folder_type == "input":
base_dir = folder_paths.get_input_directory()
else:
base_dir = folder_paths.get_output_directory()
except ImportError:
# Fallback if folder_paths not available
base_dir = os.path.join(os.getcwd(), folder_type)
if subfolder:
return os.path.join(base_dir, subfolder, filename)
return os.path.join(base_dir, filename)
def get_image_dimensions(file_path: str) -> Tuple[Optional[int], Optional[int]]:
"""
Get dimensions of an image file.
Args:
file_path: Path to the image file
Returns:
Tuple of (width, height) or (None, None) if unable to determine
"""
if not os.path.exists(file_path):
return None, None
try:
from PIL import Image
with Image.open(file_path) as img:
return img.size # (width, height)
except ImportError:
logger.debug("PIL not available for image dimension detection")
except Exception as e:
logger.debug(f"Failed to get image dimensions: {e}")
return None, None
def get_temp_directory() -> str:
"""Get the webhook temp directory."""
try:
import folder_paths
temp_dir = folder_paths.get_temp_directory()
except ImportError:
temp_dir = os.path.join(os.getcwd(), "temp")
webhook_temp = os.path.join(temp_dir, "webhook")
os.makedirs(webhook_temp, exist_ok=True)
return webhook_temp
def save_temp_file(data: bytes, filename: str, request_id: str = "") -> str:
"""
Save data to a temp file.
Args:
data: File content
filename: Filename to use
request_id: Optional request ID for organization
Returns:
Path to saved file
"""
temp_dir = get_temp_directory()
if request_id:
subdir = os.path.join(temp_dir, request_id)
os.makedirs(subdir, exist_ok=True)
file_path = os.path.join(subdir, filename)
else:
file_path = os.path.join(temp_dir, filename)
with open(file_path, "wb") as f:
f.write(data)
return file_path
def cleanup_temp_files(request_id: str) -> int:
"""
Clean up temp files for a request.
Args:
request_id: Request ID
Returns:
Number of files removed
"""
import shutil
temp_dir = get_temp_directory()
subdir = os.path.join(temp_dir, request_id)
if not os.path.exists(subdir):
return 0
try:
count = len(os.listdir(subdir))
shutil.rmtree(subdir)
return count
except Exception as e:
logger.warning(f"Failed to clean up temp files: {e}")
return 0
def detect_file_type(file_path: str) -> Optional[str]:
"""
Detect file type from magic bytes.
Args:
file_path: Path to file
Returns:
Detected type: "image", "audio", "video", "3d", or None
"""
if not os.path.exists(file_path):
return None
try:
with open(file_path, "rb") as f:
header = f.read(32)
except Exception:
return None
# Image formats
if header.startswith(b"\x89PNG"):
return "image"
if header.startswith(b"\xff\xd8\xff"):
return "image"
if header.startswith(b"RIFF") and b"WEBP" in header[:16]:
return "image"
if header.startswith(b"GIF8"):
return "image"
if header.startswith(b"BM"):
return "image"
# Audio formats
if header.startswith(b"fLaC"):
return "audio"
if header.startswith(b"RIFF") and b"WAVE" in header[:16]:
return "audio"
if header.startswith(b"ID3") or header.startswith(b"\xff\xfb"):
return "audio"
if header.startswith(b"OggS"):
return "audio"
# Video formats
if header[4:8] == b"ftyp": # MP4/MOV
return "video"
if header.startswith(b"\x1a\x45\xdf\xa3"): # WebM/MKV
return "video"
# 3D formats
if header.startswith(b"glTF"):
return "3d"
if b"solid " in header.lower() or header.startswith(b"\x00"): # STL
return "3d"
return None
+30
View File
@@ -0,0 +1,30 @@
"""Shared helper functions."""
import asyncio
import concurrent.futures
def format_size(size_bytes: int) -> str:
"""Format bytes as human readable."""
if size_bytes is None:
return "unknown"
if size_bytes < 1024:
return f"{size_bytes} bytes"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
else:
return f"{size_bytes / (1024 * 1024):.1f} MB"
def run_async(coro):
"""Run an async coroutine in sync context."""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, coro)
return future.result()
else:
return loop.run_until_complete(coro)
except RuntimeError:
return asyncio.run(coro)
+141
View File
@@ -0,0 +1,141 @@
"""MIME type utilities for webhook file handling."""
import os
from typing import Optional
# Comprehensive MIME type mappings for ComfyUI output types
MIME_TYPES = {
# Images
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".tiff": "image/tiff",
".tif": "image/tiff",
# Audio
".flac": "audio/flac",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".opus": "audio/opus",
".m4a": "audio/mp4",
".aac": "audio/aac",
# Video
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".mkv": "video/x-matroska",
# 3D Models
".glb": "model/gltf-binary",
".gltf": "model/gltf+json",
".obj": "text/plain",
".stl": "model/stl",
".fbx": "application/octet-stream",
".ply": "application/octet-stream",
# Data/Other
".json": "application/json",
".safetensors": "application/octet-stream",
".pt": "application/octet-stream",
".pth": "application/octet-stream",
".ckpt": "application/octet-stream",
".latent": "application/octet-stream",
".txt": "text/plain",
".csv": "text/csv",
}
# Reverse mapping for getting extension from MIME type
EXTENSION_FOR_MIME = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
"audio/flac": ".flac",
"audio/mpeg": ".mp3",
"audio/wav": ".wav",
"audio/ogg": ".ogg",
"audio/opus": ".opus",
"video/mp4": ".mp4",
"video/webm": ".webm",
"model/gltf-binary": ".glb",
"model/gltf+json": ".gltf",
"application/json": ".json",
"text/plain": ".txt",
}
def guess_mime_type(filename: str) -> str:
"""
Guess MIME type from filename extension.
Args:
filename: Filename with extension
Returns:
MIME type string, defaults to "application/octet-stream"
"""
if not filename:
return "application/octet-stream"
ext = os.path.splitext(filename.lower())[1]
return MIME_TYPES.get(ext, "application/octet-stream")
def get_extension_for_mime(mime_type: str) -> Optional[str]:
"""
Get file extension for a MIME type.
Args:
mime_type: MIME type string
Returns:
File extension including dot, or None if unknown
"""
return EXTENSION_FOR_MIME.get(mime_type)
def get_output_type_from_mime(mime_type: str) -> str:
"""
Determine output type category from MIME type.
Args:
mime_type: MIME type string
Returns:
Output type: image, audio, video, mesh, text, or unknown
"""
if mime_type.startswith("image/"):
return "image"
elif mime_type.startswith("audio/"):
return "audio"
elif mime_type.startswith("video/"):
return "video"
elif mime_type.startswith("model/") or mime_type in ("application/octet-stream",):
# Check if it's a known 3D format
return "mesh"
elif mime_type.startswith("text/") or mime_type == "application/json":
return "text"
else:
return "unknown"
def get_format_from_filename(filename: str) -> Optional[str]:
"""
Extract format (extension without dot) from filename.
Args:
filename: Filename with extension
Returns:
Format string (e.g., "png", "mp3") or None
"""
if not filename:
return None
ext = os.path.splitext(filename.lower())[1]
return ext[1:] if ext else None
+191
View File
@@ -0,0 +1,191 @@
"""Validation utilities for webhook functionality."""
import re
import ipaddress
import logging
from urllib.parse import urlparse
from typing import Optional
logger = logging.getLogger(__name__)
# Regex for data URL detection
DATA_URL_PATTERN = re.compile(r'^data:([^;,]+)?(;base64)?,(.*)$', re.DOTALL)
URL_PATTERN = re.compile(r'^https?://')
def validate_url(
url: str,
require_https: bool = False,
block_private_ips: bool = False,
) -> tuple[bool, Optional[str]]:
"""
Validate a webhook URL for safety and correctness.
Args:
url: URL to validate
require_https: If True, only allow HTTPS URLs
block_private_ips: If True, block private/local IP addresses
Returns:
Tuple of (is_valid, error_message)
"""
if not url:
return False, "URL is empty"
try:
parsed = urlparse(url)
# Check scheme
if parsed.scheme not in ("http", "https"):
return False, f"Invalid URL scheme: {parsed.scheme}"
if require_https and parsed.scheme != "https":
return False, "HTTPS is required"
# Check host
if not parsed.netloc:
return False, "URL has no host"
# Check for private IPs if requested
if block_private_ips:
hostname = parsed.hostname
if hostname:
# Check for localhost
if hostname in ("localhost", "127.0.0.1", "::1"):
return False, "Localhost URLs are not allowed"
# Check for private IP ranges
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_loopback or ip.is_link_local:
return False, "Private IP addresses are not allowed"
except ValueError:
# Not an IP address, hostname is fine
pass
return True, None
except Exception as e:
return False, f"URL parsing error: {str(e)}"
def sanitize_for_logging(data: dict, sensitive_keys: Optional[set] = None) -> dict:
"""
Sanitize a dictionary for safe logging by redacting sensitive values.
Args:
data: Dictionary to sanitize
sensitive_keys: Set of key names to redact (case-insensitive)
Returns:
Sanitized copy of the dictionary
"""
if sensitive_keys is None:
sensitive_keys = {
"auth_value",
"authorization",
"api_key",
"apikey",
"token",
"password",
"secret",
"credential",
"bearer",
}
def redact_value(key: str, value):
if isinstance(value, dict):
return {k: redact_value(k, v) for k, v in value.items()}
elif isinstance(value, list):
return [redact_value(key, item) for item in value]
elif isinstance(value, str):
# Check if key is sensitive
if key.lower() in sensitive_keys:
return "[REDACTED]"
# Check if value looks like base64 data (long strings)
if len(value) > 100 and is_base64_data(value):
return f"[BASE64_DATA: {len(value)} chars]"
return value
return {k: redact_value(k, v) for k, v in data.items()}
def is_base64_data(value: str) -> bool:
"""
Check if a string appears to be base64 encoded data.
Args:
value: String to check
Returns:
True if the string looks like base64 data
"""
# Check for data URL prefix
if value.startswith("data:"):
return True
# Check for base64 character pattern (rough check)
# Base64 uses A-Z, a-z, 0-9, +, /, and = for padding
if len(value) > 50 and re.match(r'^[A-Za-z0-9+/]+=*$', value):
return True
return False
def is_url(value: str) -> bool:
"""
Check if a string is an HTTP(S) URL.
Args:
value: String to check
Returns:
True if the string is a valid URL
"""
return bool(URL_PATTERN.match(value))
def is_data_url(value: str) -> bool:
"""
Check if a string is a data URL.
Args:
value: String to check
Returns:
True if the string is a data URL
"""
return bool(DATA_URL_PATTERN.match(value))
def parse_data_url(value: str) -> tuple[Optional[str], Optional[bytes]]:
"""
Parse a data URL and extract the content.
Args:
value: Data URL string
Returns:
Tuple of (mime_type, decoded_bytes) or (None, None) if invalid
"""
import base64
match = DATA_URL_PATTERN.match(value)
if not match:
return None, None
mime_type = match.group(1) or "application/octet-stream"
is_base64 = match.group(2) is not None
data = match.group(3)
try:
if is_base64:
decoded = base64.b64decode(data)
else:
# URL-encoded data
from urllib.parse import unquote
decoded = unquote(data).encode()
return mime_type, decoded
except Exception as e:
logger.warning(f"Failed to parse data URL: {e}")
return None, None