From 26a8afd0ddc10ac347e274cc8a23cf209a3665fb Mon Sep 17 00:00:00 2001 From: puke Date: Wed, 14 May 2025 18:43:03 +0800 Subject: [PATCH] init --- .gitignore | 36 +++ README.md | 87 ++++++ README_CN.md | 87 ++++++ __init__.py | 24 ++ oneapi.py | 397 ++++++++++++++++++++++++ workflows/example_img2img_workflow.json | 139 +++++++++ workflows/example_workflow.json | 108 +++++++ 7 files changed, 878 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 README_CN.md create mode 100644 __init__.py create mode 100644 oneapi.py create mode 100644 workflows/example_img2img_workflow.json create mode 100644 workflows/example_workflow.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7446045 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# 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 + +/test + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Cursor +.cursorrules diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7d180f --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# ComfyUI-OneAPI + +ComfyUI-OneAPI is a plugin that provides simple REST API interfaces for ComfyUI, allowing you to execute ComfyUI workflows through a single API request. + +[中文文档](README_CN.md) + +## Features + +- **Simple Interface**: Execute complex ComfyUI workflows through a single API endpoint +- **Parameter Mapping**: Support for dynamically replacing parameters in workflows +- **Asynchronous Execution**: Support for both synchronous and asynchronous execution modes +- **No WebSocket Dependency**: Uses HTTP polling to get results, no need to handle WebSocket connections + +## Installation + +1. Download or clone this repository to the `custom_nodes` directory of ComfyUI +2. Restart ComfyUI + +## API Usage + +### Execute Workflow + +``` +POST /oneapi/v1/execute + +Request Body: +{ + "workflow": {Workflow JSON}, + "params": { + "paramName": "paramValue" + }, + "wait_for_result": true, + "timeout": 300 +} +``` + +## Workflow Parameter Mapping + +To use dynamic parameters in your workflow, you need to use special markers in the node titles: + +1. LoadImage node: Use `$image_param` format +2. Other nodes: Use `$param.field_name` format + +Examples: + +- A LoadImage node marked with `$input_image` will use the input_image parameter from params as the image filename +- A CLIPTextEncode node marked with `$prompt.text` will replace the text field with the prompt parameter from params + +## Examples + +### curl Examples + +#### Text-to-Image Example + +```bash +curl -X POST "http://localhost:8188/oneapi/v1/execute" \ + -H "Content-Type: application/json" \ + -d '{ + "workflow": '"$(cat workflows/example_workflow.json)"', + "params": { + "prompt": "a cute dog with a red hat" + }, + "wait_for_result": true + }' +``` + +#### Image-to-Image Example + +```bash +# Execute image-to-image workflow +curl -X POST "http://localhost:8188/oneapi/v1/execute" \ + -H "Content-Type: application/json" \ + -d '{ + "workflow": '"$(cat workflows/example_img2img_workflow.json)"', + "params": { + "prompt": "a cute dog with a red hat", + "image": "https://example.com/input.jpg" + }, + "wait_for_result": true + }' +``` + +## Notes + +- This plugin is designed to simplify API calls, and does not provide WebSocket real-time progress information +- Long-running workflows may cause request timeouts, consider setting appropriate timeout values +- Parameter mapping in workflows depends on special markers in node titles \ No newline at end of file diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 0000000..4082d4c --- /dev/null +++ b/README_CN.md @@ -0,0 +1,87 @@ +# ComfyUI-OneAPI + +ComfyUI-OneAPI 是一个为 ComfyUI 提供简单REST API接口的插件,使您能够通过一个简单的API请求执行ComfyUI工作流。 + +[English Documentation](README.md) + +## 特点 + +- **简单接口**:通过单个API接口执行复杂的ComfyUI工作流 +- **参数映射**:支持动态替换工作流中的参数 +- **异步执行**:支持同步或异步执行模式 +- **无WebSocket依赖**:使用HTTP轮询获取结果,不需要处理WebSocket连接 + +## 安装 + +1. 下载或克隆此仓库到ComfyUI的`custom_nodes`目录 +2. 重启ComfyUI + +## API使用 + +### 执行工作流 + +``` +POST /oneapi/v1/execute + +请求体: +{ + "workflow": {工作流JSON}, + "params": { + "参数名": "参数值" + }, + "wait_for_result": true, + "timeout": 300 +} +``` + +## 工作流参数映射 + +要在工作流中使用动态参数,需要在节点标题中使用特殊标记: + +1. LoadImage节点:使用 `$image_param` 格式 +2. 其他节点:使用 `$param.field_name` 格式 + +例如: + +- 标记为 `$input_image` 的LoadImage节点,将使用params中的input_image参数作为图片文件名 +- 标记为 `$prompt.text` 的CLIPTextEncode节点,将使用params中的prompt参数替换text字段 + +## 示例 + +### curl示例 + +#### 文生图示例 + +```bash +curl -X POST "http://localhost:8188/oneapi/v1/execute" \ + -H "Content-Type: application/json" \ + -d '{ + "workflow": '"$(cat workflows/example_workflow.json)"', + "params": { + "prompt": "a cute dog with a red hat" + }, + "wait_for_result": true + }' +``` + +#### 图生图示例 + +```bash +# 执行图生图工作流 +curl -X POST "http://localhost:8188/oneapi/v1/execute" \ + -H "Content-Type: application/json" \ + -d '{ + "workflow": '"$(cat workflows/example_img2img_workflow.json)"', + "params": { + "prompt": "a cute dog with a red hat", + "image": "https://example.com/input.jpg" + }, + "wait_for_result": true + }' +``` + +## 注意事项 + +- 此插件设计为简化API调用,不提供WebSocket实时进度信息 +- 超长时间的工作流可能导致请求超时,建议设置合理的超时时间 +- 工作流中的参数映射依赖于节点标题中的特殊标记 \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..bc0d5a8 --- /dev/null +++ b/__init__.py @@ -0,0 +1,24 @@ +import os +import sys +import importlib.util + +# Make sure oneapi.py can be properly loaded +current_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(current_dir) + +# Import oneapi module, register routes +def init_oneapi(): + try: + spec = importlib.util.spec_from_file_location("oneapi", os.path.join(current_dir, "oneapi.py")) + oneapi = importlib.util.module_from_spec(spec) + spec.loader.exec_module(oneapi) + print("ComfyUI-OneAPI loaded") + except Exception as e: + print(f"ComfyUI-OneAPI failed to load: {str(e)}") + +# This function will be automatically called when ComfyUI loads the plugin +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +# Initialize API +init_oneapi() \ No newline at end of file diff --git a/oneapi.py b/oneapi.py new file mode 100644 index 0000000..beb474a --- /dev/null +++ b/oneapi.py @@ -0,0 +1,397 @@ +import os +import json +import uuid +import time +import copy +import asyncio +import aiohttp +import tempfile +import mimetypes +from urllib.parse import urlparse +from aiohttp import web +from server import PromptServer +import execution + +# Get routes +routes = PromptServer.instance.routes + +@routes.post('/oneapi/v1/execute') +async def execute_workflow(request): + """ + Execute workflow API + + Parameters: + - workflow: Workflow JSON + - params: Parameter mapping dictionary + - wait_for_result: Whether to wait for results (default True) + - timeout: Timeout in seconds (default 300) + + Returns: + - Workflow execution results + """ + try: + # Get request data + data = await request.json() + + # Extract parameters + workflow = data.get('workflow') + params = data.get('params', {}) + wait_for_result = data.get('wait_for_result', True) + timeout = data.get('timeout', 300) + + if not workflow: + return web.json_response({"error": "Workflow data is missing"}, status=400) + + # Process workflow parameters + if params: + workflow = await _apply_params_to_workflow(workflow, params) + + # Generate client ID + client_id = str(uuid.uuid4()) + + # Submit workflow to ComfyUI queue + prompt_id = await _queue_prompt(workflow, client_id) + + if not prompt_id: + return web.json_response({"error": "Failed to submit workflow"}, status=500) + + result = { + "status": "queued", + "prompt_id": prompt_id, + "message": "Workflow submitted" + } + + # If not waiting for results, return immediately + if not wait_for_result: + return web.json_response(result) + + # Poll for results + result = await _wait_for_results(prompt_id, timeout, request) + return web.json_response(result) + + except Exception as e: + print(f"Error executing workflow: {str(e)}") + return web.json_response({"error": str(e)}, status=500) + +async def _apply_params_to_workflow(workflow, params): + """ + Apply parameters to the workflow + + Handles two types of parameter mappings: + 1. LoadImage node: $image_param + 2. Regular node: $param.field + """ + workflow = copy.deepcopy(workflow) + + for node_id, node_data in workflow.items(): + # Skip nodes that don't meet criteria + if not _is_valid_node(node_data): + continue + + # Process parameter markers in the node + await _process_node_params(node_data, params) + + return workflow + +def _is_valid_node(node_data): + """Check if node is valid and contains a title""" + return (isinstance(node_data, dict) and + "_meta" in node_data and + "title" in node_data["_meta"]) + +async def _process_node_params(node_data, params): + """Process parameter markers in the node""" + title = node_data["_meta"]["title"] + + # Split title and look for parameter markers + parts = title.split(',') + for part in parts: + part = part.strip() + if not part.startswith('$'): + continue + + # Process parameter marker + await _process_param_marker(node_data, part[1:], params) + +async def _process_param_marker(node_data, var_spec, params): + """ + Process individual parameter marker + + Format: param_name.field_name + - param_name: Parameter name, corresponding to key in params + - field_name: Node input field name + + Special handling for LoadImage node's image field + """ + # Must have field separator + if '.' not in var_spec: + print(f"Parameter marker format error, should be '$param.field': {var_spec}") + return + + # Parse parameter name and field name + var_name, input_field = var_spec.split('.', 1) + + # Check if parameter exists + if var_name not in params: + return + + # Get parameter value + param_value = params[var_name] + + # Special handling for LoadImage node's image field + if node_data.get('class_type') == 'LoadImage' and input_field == 'image': + await _handle_load_image(node_data, param_value) + else: + # Regular parameter setting + await _set_node_param(node_data, input_field, param_value) + +async def _handle_load_image(node_data, image_path_or_url): + """ + Handle LoadImage node's image parameter + + Args: + node_data: Node data + image_path_or_url: Image path or URL + """ + # Ensure inputs exists + if "inputs" not in node_data: + node_data["inputs"] = {} + + # If parameter value is a URL starting with http, upload the image first + if isinstance(image_path_or_url, str) and image_path_or_url.startswith(('http://', 'https://')): + try: + # Upload image and get uploaded image name + image_value = await _upload_image_from_source(image_path_or_url) + # Use uploaded image name as LoadImage node's image value + await _set_node_param(node_data, "image", image_value) + print(f"Image uploaded: {image_value}") + except Exception as e: + print(f"Failed to upload image: {str(e)}") + # Throw exception on upload failure + raise Exception(f"Image upload failed: {str(e)}") + else: + # Use parameter value directly as image name + await _set_node_param(node_data, "image", image_path_or_url) + +async def _set_node_param(node_data, input_field, param_value): + """ + Set node parameter + + Args: + node_data: Node data + input_field: Input field name + param_value: Parameter value + """ + # Ensure inputs exists + if "inputs" not in node_data: + node_data["inputs"] = {} + # Set parameter value + node_data["inputs"][input_field] = param_value + +async def _upload_image_from_source(image_url) -> str: + """ + Upload image from URL + + Args: + image_url: Image URL + + Returns: + Upload image file name + """ + # Download image from URL + async with aiohttp.ClientSession() as session: + async with session.get(image_url) as response: + if response.status != 200: + raise Exception(f"Failed to download image: HTTP {response.status}") + + # Extract filename from URL + parsed_url = urlparse(image_url) + filename = os.path.basename(parsed_url.path) + if not filename: + filename = f"temp_image_{hash(image_url)}.jpg" + + # Get image data + image_data = await response.read() + + # Save to temporary file + suffix = os.path.splitext(filename)[1] or ".jpg" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp.write(image_data) + temp_path = tmp.name + + try: + # Upload temporary file to ComfyUI + return await _upload_image(temp_path) + finally: + # Delete temporary file + os.unlink(temp_path) + +async def _upload_image(image_path) -> str: + """ + Upload image to ComfyUI + + Args: + image_path: Image path + + Returns: + Upload image file name + """ + # Read image data + with open(image_path, 'rb') as f: + image_data = f.read() + + # Extract filename + filename = os.path.basename(image_path) + + # Auto-detect file MIME type + mime_type = mimetypes.guess_type(filename)[0] + if mime_type is None: + # Default to generic image type + mime_type = 'application/octet-stream' + + # Prepare form data + data = aiohttp.FormData() + data.add_field('image', image_data, + filename=filename, + content_type=mime_type) + + # Upload image (internal ComfyUI API call still uses 127.0.0.1) + async with aiohttp.ClientSession() as session: + async with session.post("http://127.0.0.1:8188/upload/image", data=data) as response: + if response.status != 200: + raise Exception(f"Failed to upload image: HTTP {response.status}") + + # Get upload result + result = await response.json() + return result.get('name', '') + +async def _queue_prompt(workflow, client_id): + """Submit workflow to queue using HTTP API""" + try: + prompt_data = { + "prompt": workflow, + "client_id": client_id + } + + json_data = json.dumps(prompt_data) + + # Use aiohttp to send request + async with aiohttp.ClientSession() as session: + async with session.post("http://127.0.0.1:8188/prompt", + data=json_data, + headers={"Content-Type": "application/json"}) as response: + if response.status != 200: + print(f"Failed to submit workflow: {response.status}") + return None + + result = await response.json() + # Return prompt_id + return result.get("prompt_id") + except Exception as e: + print(f"Error submitting workflow: {str(e)}") + return None + +async def _get_base_url(request): + """ + Get base URL for building image URLs + + Args: + request: HTTP request object + + Returns: + Base URL string + """ + # Default base URL for local access + base_url = "http://127.0.0.1:8188" + + if request: + host = request.headers.get('Host') + if host: + # Try multiple methods to get request protocol + scheme = request.headers.get('X-Forwarded-Proto') or \ + request.headers.get('X-Scheme') or \ + request.headers.get('X-Forwarded-Scheme') or \ + (request.headers.get('X-Forwarded-Ssl') == 'on' and 'https') or \ + (request.headers.get('X-Forwarded-Protocol') == 'https' and 'https') or \ + ('https' if request.url.scheme == 'https' else 'http') + + # Build base URL + base_url = f"{scheme}://{host}" + + return base_url + +async def _wait_for_results(prompt_id, timeout=300, request=None): + """Wait for workflow execution results, get history using HTTP API""" + start_time = time.time() + result = { + "status": "processing", + "prompt_id": prompt_id, + "images": [] + } + + # Get base URL for image URLs + base_url = await _get_base_url(request) + + while True: + # Check timeout + if timeout > 0 and (time.time() - start_time) > timeout: + result["status"] = "timeout" + return result + + # Get history using HTTP API + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"http://127.0.0.1:8188/history") as response: + if response.status != 200: + # API call failed, retry after waiting + await asyncio.sleep(1.0) + continue + + # Get entire history + history_data = await response.json() + + # Check if specified prompt_id is in history + if prompt_id not in history_data: + # Workflow might not be completed yet, retry after waiting + await asyncio.sleep(1.0) + continue + + # Get history for specific prompt_id + prompt_history = history_data[prompt_id] + + # Check if completed + if "outputs" in prompt_history: + result["status"] = "completed" + + # Store original outputs + if 'outputs' in result: + del result['outputs'] # Remove outputs field + + # Process outputs, especially focusing on SaveImage nodes + for node_id, node_output in prompt_history["outputs"].items(): + if "images" in node_output: + for img_data in node_output["images"]: + filename = img_data.get("filename") + subfolder = img_data.get("subfolder", "") + img_type = img_data.get("type", "output") + + # Build image URL + img_url = f"{base_url}/view?filename={filename}" + if subfolder: + img_url += f"&subfolder={subfolder}" + if img_type: + img_url += f"&type={img_type}" + + # Simplified: directly add URL string to results + result["images"].append(img_url) + + # Return complete results + return result + except Exception as e: + print(f"Error getting history: {str(e)}") + # Continue trying after error + + # Wait before checking again + await asyncio.sleep(1.0) + +print("ComfyUI-OneAPI routes registered") \ No newline at end of file diff --git a/workflows/example_img2img_workflow.json b/workflows/example_img2img_workflow.json new file mode 100644 index 0000000..9468c1f --- /dev/null +++ b/workflows/example_img2img_workflow.json @@ -0,0 +1,139 @@ +{ + "3": { + "inputs": { + "seed": 212, + "steps": 20, + "cfg": 8, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 0.9, + "model": [ + "4", + 0 + ], + "positive": [ + "6", + 0 + ], + "negative": [ + "7", + 0 + ], + "latent_image": [ + "14", + 0 + ] + }, + "class_type": "KSampler", + "_meta": { + "title": "KSampler" + } + }, + "4": { + "inputs": { + "ckpt_name": "SDXL/dreamshaperXL_lightningDPMSDE.safetensors" + }, + "class_type": "CheckpointLoaderSimple", + "_meta": { + "title": "Load Checkpoint" + } + }, + "6": { + "inputs": { + "text": "beautiful scenery nature glass bottle landscape, , purple galaxy bottle,", + "clip": [ + "4", + 1 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP Text Encode (Prompt),$prompt.text" + } + }, + "7": { + "inputs": { + "text": "text, watermark", + "clip": [ + "4", + 1 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP Text Encode (Prompt)" + } + }, + "8": { + "inputs": { + "samples": [ + "3", + 0 + ], + "vae": [ + "4", + 2 + ] + }, + "class_type": "VAEDecode", + "_meta": { + "title": "VAE Decode" + } + }, + "9": { + "inputs": { + "filename_prefix": "ComfyUI", + "images": [ + "8", + 0 + ] + }, + "class_type": "SaveImage", + "_meta": { + "title": "Save Image" + } + }, + "10": { + "inputs": { + "image": "03.JPG" + }, + "class_type": "LoadImage", + "_meta": { + "title": "Load Image,$image.image" + } + }, + "11": { + "inputs": { + "width": 512, + "height": 512, + "interpolation": "nearest", + "method": "fill / crop", + "condition": "always", + "multiple_of": 0, + "image": [ + "10", + 0 + ] + }, + "class_type": "ImageResize+", + "_meta": { + "title": "🔧 Image Resize" + } + }, + "14": { + "inputs": { + "pixels": [ + "11", + 0 + ], + "vae": [ + "4", + 2 + ] + }, + "class_type": "VAEEncode", + "_meta": { + "title": "VAE Encode" + } + } +} \ No newline at end of file diff --git a/workflows/example_workflow.json b/workflows/example_workflow.json new file mode 100644 index 0000000..63a81d4 --- /dev/null +++ b/workflows/example_workflow.json @@ -0,0 +1,108 @@ +{ + "3": { + "inputs": { + "seed": 278586368967862, + "steps": 20, + "cfg": 8, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1, + "model": [ + "4", + 0 + ], + "positive": [ + "6", + 0 + ], + "negative": [ + "7", + 0 + ], + "latent_image": [ + "5", + 0 + ] + }, + "class_type": "KSampler", + "_meta": { + "title": "KSampler" + } + }, + "4": { + "inputs": { + "ckpt_name": "SDXL/dreamshaperXL_lightningDPMSDE.safetensors" + }, + "class_type": "CheckpointLoaderSimple", + "_meta": { + "title": "Load Checkpoint" + } + }, + "5": { + "inputs": { + "width": 512, + "height": 512, + "batch_size": 1 + }, + "class_type": "EmptyLatentImage", + "_meta": { + "title": "Empty Latent Image" + } + }, + "6": { + "inputs": { + "text": "beautiful scenery nature glass bottle landscape, , purple galaxy bottle,", + "clip": [ + + "4", + 1 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP Text Encode (Prompt),$prompt.text" + } + }, + "7": { + "inputs": { + "text": "text, watermark", + "clip": [ + "4", + 1 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP Text Encode (Prompt)" + } + }, + "8": { + "inputs": { + "samples": [ + "3", + 0 + ], + "vae": [ + "4", + 2 + ] + }, + "class_type": "VAEDecode", + "_meta": { + "title": "VAE Decode" + } + }, + "9": { + "inputs": { + "filename_prefix": "ComfyUI", + "images": [ + "8", + 0 + ] + }, + "class_type": "SaveImage", + "_meta": { + "title": "Save Image" + } + } +} \ No newline at end of file