From 25449804f989c759f866a1ca2ce7fcb6c06be31d Mon Sep 17 00:00:00 2001 From: pandy <1272175942@qq.com> Date: Tue, 7 Jul 2026 20:10:18 +0800 Subject: [PATCH] Add Comfy API key nodes --- README.md | 3 +- py/IMAGE_GPT2_apikey.py | 6 +- py/IMAGE_GPT2_authtoken.py | 6 +- py/IMAGE_Krea2_apikey.py | 318 ++++++++++++++++++++++++++++++++ py/LLM_OpenAIChatGPT_apikey.py | 256 +++++++++++++++++++++++++ py/PD_comfyplus_image.py | 4 +- py/VIDEO_SeedanceMini_apikey.py | 284 ++++++++++++++++++++++++++++ py/_image_gpt_common.py | 17 +- 8 files changed, 883 insertions(+), 11 deletions(-) create mode 100644 py/IMAGE_Krea2_apikey.py create mode 100644 py/LLM_OpenAIChatGPT_apikey.py create mode 100644 py/VIDEO_SeedanceMini_apikey.py diff --git a/README.md b/README.md index 7e20741..67da074 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # ComfyUI PD Comfy API Node +仓库定位:把官方 Comfy API 节点复刻成普通自定义节点,用户自己填 `comfy_api_key` 调用。 + ComfyUI 自定义节点套件,集成 Gemini 和 GPT 图像生成 API,支持两种认证方式。 ## ✨ 功能特性 @@ -107,4 +109,3 @@ pip install -r comfyui-PD_comfy-api-node/requirements.txt ## 🤝 贡献 欢迎提交 Issue 和 Pull Request! --- - diff --git a/py/IMAGE_GPT2_apikey.py b/py/IMAGE_GPT2_apikey.py index 83d7035..f9b1273 100644 --- a/py/IMAGE_GPT2_apikey.py +++ b/py/IMAGE_GPT2_apikey.py @@ -59,21 +59,21 @@ class PDOpenAIGPTImage2APIKey: }), "size": (GPT_IMAGE_2_PRESET_SIZES, { "default": "1024x1024", - "tooltip": "Preset output size" + "tooltip": "Preset output size, or Custom with custom_width/custom_height" }), "custom_width": ("INT", { "default": 0, "min": 0, "max": 3840, "step": 16, - "tooltip": "Override width when both width and height are non-zero" + "tooltip": "Custom width. Must be paired with custom_height." }), "custom_height": ("INT", { "default": 0, "min": 0, "max": 3840, "step": 16, - "tooltip": "Override height when both width and height are non-zero" + "tooltip": "Custom height. Must be paired with custom_width." }), "num_images": ("INT", { "default": 1, diff --git a/py/IMAGE_GPT2_authtoken.py b/py/IMAGE_GPT2_authtoken.py index 257571e..fdd53d9 100644 --- a/py/IMAGE_GPT2_authtoken.py +++ b/py/IMAGE_GPT2_authtoken.py @@ -53,21 +53,21 @@ class PDOpenAIGPTImage2AuthToken: }), "size": (GPT_IMAGE_2_PRESET_SIZES, { "default": "1024x1024", - "tooltip": "Preset output size" + "tooltip": "Preset output size, or Custom with custom_width/custom_height" }), "custom_width": ("INT", { "default": 0, "min": 0, "max": 3840, "step": 16, - "tooltip": "Override width when both width and height are non-zero" + "tooltip": "Custom width. Must be paired with custom_height." }), "custom_height": ("INT", { "default": 0, "min": 0, "max": 3840, "step": 16, - "tooltip": "Override height when both width and height are non-zero" + "tooltip": "Custom height. Must be paired with custom_width." }), "num_images": ("INT", { "default": 1, diff --git a/py/IMAGE_Krea2_apikey.py b/py/IMAGE_Krea2_apikey.py new file mode 100644 index 0000000..eb2f34d --- /dev/null +++ b/py/IMAGE_Krea2_apikey.py @@ -0,0 +1,318 @@ +""" +PD: Krea 2 image-generation nodes using a ComfyUI API key. +""" + +import re +import time + +import requests + +from ._image_gpt_common import ( + empty_image, + format_credits_value, + format_rmb_value, + load_image_to_tensor, + tensor_to_bytes, +) + + +BASE_URL = "https://api.comfy.org" +KREA_MODEL_ENDPOINTS = { + "Krea 2 Medium": "/proxy/krea/generate/image/krea/krea-2/medium", + "Krea 2 Medium Turbo": "/proxy/krea/generate/image/krea/krea-2/medium-turbo", + "Krea 2 Large": "/proxy/krea/generate/image/krea/krea-2/large", +} +KREA_ASPECT_RATIOS = ["1:1", "4:3", "3:2", "16:9", "2.35:1", "4:5", "2:3", "9:16"] +KREA_RESOLUTIONS = ["1K"] +KREA_CREATIVITY_LEVELS = ["raw", "low", "medium", "high"] +KREA_QUEUED_STATUSES = {"backlogged", "queued", "scheduled", "running", "processing", "in_progress"} +KREA_DONE_STATUSES = {"succeeded", "success", "completed", "complete", "done"} +KREA_FAILED_STATUSES = {"failed", "error", "cancelled", "canceled"} +KREA_PRICE_USD = { + "Krea 2 Medium Turbo": {"text": 0.015, "style": 0.0175, "moodboard": 0.02}, + "Krea 2 Medium": {"text": 0.03, "style": 0.035, "moodboard": 0.04}, + "Krea 2 Large": {"text": 0.06, "style": 0.065, "moodboard": 0.07}, +} +KREA_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + +def _headers(api_key, content_type="application/json"): + headers = { + "X-API-KEY": api_key.strip(), + "Accept": "application/json", + "Connection": "close", + "User-Agent": "ComfyUI-PD-Node/2.1", + } + if content_type: + headers["Content-Type"] = content_type + return headers + + +def _estimate_krea_price(model, has_style_reference, has_moodboard): + rates = KREA_PRICE_USD.get(model, KREA_PRICE_USD["Krea 2 Medium"]) + if has_moodboard: + return rates["moodboard"] + if has_style_reference: + return rates["style"] + return rates["text"] + + +def _upload_krea_style_asset(api_key, image): + image_bytes = tensor_to_bytes(image, max_pixels=2048 * 2048).getvalue() + response = requests.post( + f"{BASE_URL}/proxy/krea/assets", + headers=_headers(api_key, content_type=None), + files=[("file", ("style_reference.png", image_bytes, "image/png"))], + timeout=180, + ) + if response.status_code != 200: + raise RuntimeError(f"Krea asset upload error {response.status_code}: {response.text[:500]}") + + result = response.json() + image_url = result.get("image_url") + if not image_url: + raise RuntimeError(f"Krea asset response missing image_url: {str(result)[:500]}") + return image_url + + +def _poll_krea_job(api_key, job_id): + status_url = f"{BASE_URL}/proxy/krea/jobs/{job_id}" + for attempt in range(240): + time.sleep(3 if attempt else 1) + response = requests.get(status_url, headers=_headers(api_key), timeout=120) + if response.status_code != 200: + raise RuntimeError(f"Krea poll error {response.status_code}: {response.text[:500]}") + + result = response.json() + status = str(result.get("status") or "").lower() + if status in KREA_DONE_STATUSES: + return result + if status in KREA_FAILED_STATUSES: + raise RuntimeError(f"Krea job {status}: {str(result)[:500]}") + if status not in KREA_QUEUED_STATUSES: + print(f"[PD_KREA2] Waiting on unknown Krea status: {status}") + + raise RuntimeError("Krea job timed out while polling") + + +class PDKrea2ImageAPIKey: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "comfy_api_key": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "Your ComfyUI API Key", + }), + "prompt": ("STRING", { + "default": "", + "multiline": True, + "tooltip": "Text prompt for Krea 2 image generation.", + }), + "model": (list(KREA_MODEL_ENDPOINTS.keys()), { + "default": "Krea 2 Medium Turbo", + "tooltip": "Medium Turbo is fastest; Large is best for photorealism.", + }), + "aspect_ratio": (KREA_ASPECT_RATIOS, { + "default": "1:1", + "tooltip": "Output aspect ratio.", + }), + "resolution": (KREA_RESOLUTIONS, { + "default": "1K", + "tooltip": "Resolution scale.", + }), + "creativity": (KREA_CREATIVITY_LEVELS, { + "default": "medium", + "tooltip": "Prompt interpretation strength.", + }), + "seed": ("INT", { + "default": 0, + "min": 0, + "max": 2147483647, + "step": 1, + "tooltip": "Random seed for reproducibility.", + }), + "moodboard_id": ("STRING", { + "default": "", + "multiline": False, + "tooltip": "Optional Krea moodboard UUID from the Krea website.", + }), + "moodboard_strength": ("FLOAT", { + "default": 0.35, + "min": -0.5, + "max": 1.5, + "step": 0.05, + "tooltip": "Moodboard influence; ignored when moodboard_id is empty.", + }), + }, + "optional": { + "style_reference": ("KREA_STYLE_REF", { + "tooltip": "Optional chained style references from PD: Krea 2 Style Reference.", + }), + }, + } + + RETURN_TYPES = ("IMAGE", "STRING", "STRING") + RETURN_NAMES = ("image", "info", "price") + FUNCTION = "generate_image" + CATEGORY = "PD_Tools/Image_Generation" + + def generate_image( + self, + comfy_api_key, + prompt, + model, + aspect_ratio, + resolution, + creativity, + seed, + moodboard_id, + moodboard_strength, + style_reference=None, + ): + if not comfy_api_key or not comfy_api_key.strip(): + return (empty_image(), "Error: ComfyUI API Key is required", "") + if not prompt or not prompt.strip(): + return (empty_image(), "Error: prompt is required", "") + + endpoint = KREA_MODEL_ENDPOINTS.get(model) + if endpoint is None: + return (empty_image(), f"Error: unknown Krea model: {model}", "") + + moodboard_id = (moodboard_id or "").strip() + if moodboard_id and not KREA_UUID_RE.match(moodboard_id): + return (empty_image(), f"Error: moodboard_id must be a UUID: {moodboard_id}", "") + + image_style_references = None + if style_reference: + if len(style_reference) > 10: + return (empty_image(), f"Error: Krea accepts at most 10 style references, got {len(style_reference)}", "") + image_style_references = [ + {"url": ref["url"], "strength": float(ref["strength"])} + for ref in style_reference + ] + + payload = { + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "resolution": resolution, + "seed": int(seed), + "creativity": creativity, + } + if moodboard_id: + payload["moodboards"] = [{"id": moodboard_id, "strength": float(moodboard_strength)}] + if image_style_references: + payload["image_style_references"] = image_style_references + + start_time = time.time() + + try: + response = requests.post( + f"{BASE_URL}{endpoint}", + headers=_headers(comfy_api_key), + json=payload, + timeout=120, + ) + if response.status_code != 200: + return (empty_image(), f"API Error {response.status_code}: {response.text[:500]}", "") + + initial = response.json() + job_id = initial.get("job_id") + if not job_id: + return (empty_image(), f"Error: job_id missing in response: {str(initial)[:500]}", "") + + job = _poll_krea_job(comfy_api_key, job_id) + result = job.get("result") or {} + urls = result.get("urls") or [] + if not urls: + return (empty_image(), f"Error: Krea job {job_id} completed without image urls", "") + + image = load_image_to_tensor(urls[0]) + elapsed = time.time() - start_time + usd = _estimate_krea_price(model, bool(image_style_references), bool(moodboard_id)) + + info_lines = [ + f"Model: {model}", + "Status: Success", + f"Job ID: {job_id}", + f"Time: {elapsed:.2f}s", + f"Aspect Ratio: {aspect_ratio}", + f"Resolution: {resolution}", + f"Creativity: {creativity}", + f"Style References: {len(image_style_references or [])}", + f"Moodboard: {'on' if moodboard_id else 'off'}", + ] + price_lines = [ + f"Estimated price (USD): ${usd:.6f}", + f"Estimated price (RMB): {format_rmb_value(usd)}", + f"Estimated price (Credits): {format_credits_value(usd)}", + ] + return (image, "\n".join(info_lines), "\n".join(price_lines)) + + except requests.exceptions.Timeout: + return (empty_image(), "Error: Request timeout", "") + except requests.exceptions.ConnectionError as e: + return (empty_image(), f"Error: Connection failed - {str(e)}", "") + except Exception as e: + import traceback + + traceback.print_exc() + return (empty_image(), f"Error: {str(e)}", "") + + +class PDKrea2StyleReferenceAPIKey: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "comfy_api_key": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "Your ComfyUI API Key", + }), + "image": ("IMAGE", { + "tooltip": "Reference image whose style influences Krea 2 generation.", + }), + "strength": ("FLOAT", { + "default": 1.0, + "min": -2.0, + "max": 2.0, + "step": 0.05, + "tooltip": "Reference strength; negative values invert the style influence.", + }), + }, + "optional": { + "style_reference": ("KREA_STYLE_REF", { + "tooltip": "Optional incoming chain; this node appends one more reference.", + }), + }, + } + + RETURN_TYPES = ("KREA_STYLE_REF",) + RETURN_NAMES = ("style_reference",) + FUNCTION = "make_style_reference" + CATEGORY = "PD_Tools/Image_Generation" + + def make_style_reference(self, comfy_api_key, image, strength, style_reference=None): + if not comfy_api_key or not comfy_api_key.strip(): + raise ValueError("ComfyUI API Key is required") + + chain = list(style_reference) if style_reference else [] + if len(chain) >= 10: + raise ValueError("Krea accepts at most 10 style references in one generation") + + url = _upload_krea_style_asset(comfy_api_key, image) + chain.append({"url": url, "strength": float(strength)}) + return (chain,) + + +NODE_CLASS_MAPPINGS = { + "PDKrea2ImageAPIKey": PDKrea2ImageAPIKey, + "PDKrea2StyleReferenceAPIKey": PDKrea2StyleReferenceAPIKey, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "PDKrea2ImageAPIKey": "PD: Krea 2 Image (comfyui_apikey)", + "PDKrea2StyleReferenceAPIKey": "PD: Krea 2 Style Reference (comfyui_apikey)", +} diff --git a/py/LLM_OpenAIChatGPT_apikey.py b/py/LLM_OpenAIChatGPT_apikey.py new file mode 100644 index 0000000..4bbc47a --- /dev/null +++ b/py/LLM_OpenAIChatGPT_apikey.py @@ -0,0 +1,256 @@ +""" +PD: OpenAI ChatGPT text node using a ComfyUI API key. +""" + +import base64 +import time + +import requests + +from ._image_gpt_common import format_credits_value, format_rmb_value, tensor_to_bytes + + +BASE_URL = "https://api.comfy.org" +RESPONSES_ENDPOINT = "/proxy/openai/v1/responses" + +OPENAI_CHAT_MODELS = [ + "gpt-5.5-pro", + "gpt-5.5", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "o4-mini", + "o3", + "o1-pro", + "o1", +] + +OPENAI_CHAT_PRICES_PER_MILLION = { + "gpt-5.5-pro": (30.0, 180.0), + "gpt-5.5": (5.0, 30.0), + "gpt-5": (1.25, 10.0), + "gpt-5-mini": (0.25, 2.0), + "gpt-5-nano": (0.05, 0.4), + "gpt-4.1": (2.0, 8.0), + "gpt-4.1-mini": (0.4, 1.6), + "gpt-4.1-nano": (0.1, 0.4), + "o4-mini": (1.1, 4.4), + "o3": (10.0, 40.0), + "o1-pro": (150.0, 600.0), + "o1": (15.0, 60.0), +} + + +def _headers(api_key): + return { + "X-API-KEY": api_key.strip(), + "Accept": "application/json", + "Content-Type": "application/json", + "Connection": "close", + "User-Agent": "ComfyUI-PD-Node/2.1", + } + + +def _image_content_from_tensor(image): + contents = [] + batch_size = image.shape[0] if image.ndim == 4 else 1 + for i in range(batch_size): + tensor = image[i : i + 1] if image.ndim == 4 else image + image_bytes = tensor_to_bytes(tensor).getvalue() + contents.append({ + "type": "input_image", + "detail": "auto", + "image_url": f"data:image/png;base64,{base64.b64encode(image_bytes).decode('ascii')}", + }) + return contents + + +def _extract_text(response): + for output in response.get("output") or []: + if output.get("type") != "message": + continue + for content in output.get("content") or []: + if content.get("type") == "output_text" and content.get("text"): + return str(content["text"]) + return "" + + +def _estimate_price(model, usage): + if not isinstance(usage, dict): + return None + rates = OPENAI_CHAT_PRICES_PER_MILLION.get(model) + if rates is None: + return None + + input_tokens = usage.get("input_tokens") or 0 + output_tokens = usage.get("output_tokens") or 0 + input_rate, output_rate = rates + return (input_tokens * input_rate + output_tokens * output_rate) / 1_000_000.0 + + +class PDOpenAIChatGPTAPIKey: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "comfy_api_key": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "Your ComfyUI API Key", + }), + "prompt": ("STRING", { + "default": "", + "multiline": True, + "tooltip": "Text input to the model.", + }), + "model": (OPENAI_CHAT_MODELS, { + "default": "gpt-5", + "tooltip": "OpenAI model used through the Comfy API proxy.", + }), + "system_prompt": ("STRING", { + "default": "", + "multiline": True, + "tooltip": "Optional instructions for the model.", + }), + "max_output_tokens": ("INT", { + "default": 4096, + "min": 16, + "max": 16384, + "step": 1, + "tooltip": "Upper bound for generated tokens.", + }), + "truncation": (["auto", "disabled"], { + "default": "auto", + "tooltip": "Context truncation strategy.", + }), + }, + "optional": { + "images": ("IMAGE", { + "tooltip": "Optional image batch to use as context.", + }), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("text", "info", "price") + FUNCTION = "generate_text" + CATEGORY = "PD_Tools/Text_Generation" + + def generate_text( + self, + comfy_api_key, + prompt, + model, + system_prompt, + max_output_tokens, + truncation, + images=None, + ): + if not comfy_api_key or not comfy_api_key.strip(): + return ("", "Error: ComfyUI API Key is required", "") + if not prompt or not prompt.strip(): + return ("", "Error: prompt is required", "") + + content = [{"type": "input_text", "text": prompt}] + if images is not None: + content.extend(_image_content_from_tensor(images)) + + payload = { + "model": model, + "input": [ + { + "role": "user", + "content": content, + } + ], + "store": True, + "stream": False, + "max_output_tokens": int(max_output_tokens), + "truncation": truncation, + } + if system_prompt and system_prompt.strip(): + payload["instructions"] = system_prompt.strip() + + start_time = time.time() + + try: + response = requests.post( + f"{BASE_URL}{RESPONSES_ENDPOINT}", + headers=_headers(comfy_api_key), + json=payload, + timeout=180, + ) + if response.status_code != 200: + return ("", f"API Error {response.status_code}: {response.text[:500]}", "") + + result = response.json() + response_id = result.get("id") + if response_id and result.get("status") not in ("completed", "incomplete"): + result = self._poll_response(comfy_api_key, response_id) + + if result.get("error"): + error = result["error"] + return ("", f"Error: {error.get('code')}: {error.get('message')}", "") + + text = _extract_text(result) + if not text: + return ("", f"Error: no text output in response: {str(result)[:500]}", "") + + elapsed = time.time() - start_time + usage = result.get("usage") or {} + usd = _estimate_price(model, usage) + + info_lines = [ + f"Model: {model}", + "Status: Success", + f"Response ID: {response_id or ''}", + f"Time: {elapsed:.2f}s", + ] + if usage: + info_lines.append(f"Usage: {usage}") + + if usd is None: + price = "Estimated price: token-based" + else: + price = "\n".join([ + f"Estimated price (USD): ${usd:.6f}", + f"Estimated price (RMB): {format_rmb_value(usd)}", + f"Estimated price (Credits): {format_credits_value(usd)}", + ]) + + return (text, "\n".join(info_lines), price) + + except requests.exceptions.Timeout: + return ("", "Error: Request timeout", "") + except requests.exceptions.ConnectionError as e: + return ("", f"Error: Connection failed - {str(e)}", "") + except Exception as e: + import traceback + + traceback.print_exc() + return ("", f"Error: {str(e)}", "") + + def _poll_response(self, api_key, response_id): + status_url = f"{BASE_URL}{RESPONSES_ENDPOINT}/{response_id}" + for attempt in range(180): + time.sleep(2 if attempt else 1) + response = requests.get(status_url, headers=_headers(api_key), timeout=120) + if response.status_code != 200: + raise RuntimeError(f"Poll Error {response.status_code}: {response.text[:500]}") + result = response.json() + status = result.get("status") + if status in ("completed", "incomplete", "failed", "cancelled"): + return result + raise RuntimeError("OpenAI response timed out while polling") + + +NODE_CLASS_MAPPINGS = { + "PDOpenAIChatGPTAPIKey": PDOpenAIChatGPTAPIKey, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "PDOpenAIChatGPTAPIKey": "PD: OpenAI ChatGPT (comfyui_apikey)", +} diff --git a/py/PD_comfyplus_image.py b/py/PD_comfyplus_image.py index 16b5138..a2b7672 100644 --- a/py/PD_comfyplus_image.py +++ b/py/PD_comfyplus_image.py @@ -56,12 +56,12 @@ class PD_ComfyPlusImage: # 构造为 Gemini 认证的 Part new_part = GeminiPart( inlineData=GeminiInlineData( - mimeType=GeminiMimeType.image_png, + mimeType=GeminiMimeType.image_png.value, data=img_b64 ) ) # 转成字典防止 Pydantic 跨模块传递对象时校验失败 - files_list.append(new_part.model_dump(exclude_none=True)) + files_list.append(new_part.model_dump(mode="json", exclude_none=True)) except Exception as e: print(f"[Error] PD_comfyplus_image pack failed for an image: {e}") diff --git a/py/VIDEO_SeedanceMini_apikey.py b/py/VIDEO_SeedanceMini_apikey.py new file mode 100644 index 0000000..0ef0728 --- /dev/null +++ b/py/VIDEO_SeedanceMini_apikey.py @@ -0,0 +1,284 @@ +""" +PD: Seedance 2.0 Text-to-Video node using a ComfyUI API key. +""" + +import io +import time + +import requests +import torch + +try: + from comfy_api.latest import InputImpl +except Exception: + InputImpl = None + +from ._image_gpt_common import empty_image, format_credits_value, format_rmb_value + + +BASE_URL = "https://api.comfy.org" +TASK_ENDPOINT = "/proxy/byteplus/api/v3/contents/generations/tasks" +SEEDANCE2_STATUS_ENDPOINT = "/proxy/byteplus-seedance2/api/v3/contents/generations/tasks" +SEEDANCE_MODELS = { + "Seedance 2.0": "dreamina-seedance-2-0-260128", + "Seedance 2.0 Fast": "dreamina-seedance-2-0-fast-260128", + "Seedance 2.0 Mini": "dreamina-seedance-2-0-mini", +} +SEEDANCE_MODEL_RESOLUTIONS = { + "Seedance 2.0": ["480p", "720p", "1080p", "4k"], + "Seedance 2.0 Fast": ["480p", "720p"], + "Seedance 2.0 Mini": ["480p", "720p"], +} +SEEDANCE2_PRICE_PER_1K_TOKENS = { + ("dreamina-seedance-2-0-260128", "480p"): 0.007, + ("dreamina-seedance-2-0-260128", "720p"): 0.007, + ("dreamina-seedance-2-0-260128", "1080p"): 0.0077, + ("dreamina-seedance-2-0-260128", "4k"): 0.004, + ("dreamina-seedance-2-0-fast-260128", "480p"): 0.0056, + ("dreamina-seedance-2-0-fast-260128", "720p"): 0.0056, + ("dreamina-seedance-2-0-mini", "480p"): 0.0035, + ("dreamina-seedance-2-0-mini", "720p"): 0.0035, +} + + +def _download_video_to_output(video_url, headers): + request_url = video_url + request_headers = {} + if isinstance(video_url, str) and video_url.startswith("/"): + request_url = f"{BASE_URL}{video_url}" + request_headers = headers + + response = requests.get(request_url, headers=request_headers, timeout=300) + response.raise_for_status() + buffer = io.BytesIO(response.content) + buffer.seek(0) + + if InputImpl is not None: + return InputImpl.VideoFromFile(buffer) + + return buffer + + +def _estimate_seedance_usd(model_id, resolution, duration, usage=None): + token_rates = { + "480p": 10044, + "720p": 21600, + "1080p": 48800, + "4k": 195200, + } + price_per_1k = SEEDANCE2_PRICE_PER_1K_TOKENS.get((model_id, resolution)) + if price_per_1k is None: + return 0.0 + + if isinstance(usage, dict): + total_tokens = usage.get("total_tokens") or usage.get("completion_tokens") or 0 + if total_tokens: + return total_tokens * price_per_1k / 1000.0 + + return duration * token_rates.get(resolution, token_rates["720p"]) * price_per_1k / 1000.0 + + +class PDSeedanceMiniTextToVideoAPIKey: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "comfy_api_key": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "Your ComfyUI API Key", + }), + "prompt": ("STRING", { + "default": "", + "multiline": True, + "tooltip": "Text prompt for Seedance 2.0 video generation.", + }), + "model": (list(SEEDANCE_MODELS.keys()), { + "default": "Seedance 2.0 Fast", + "tooltip": "Seedance 2.0 for quality, Fast for speed, Mini for lowest cost.", + }), + "resolution": (["480p", "720p", "1080p", "4k"], { + "default": "720p", + "tooltip": "Seedance 2.0 supports 480p/720p/1080p/4k. Fast and Mini support 480p/720p.", + }), + "ratio": (["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"], { + "default": "16:9", + "tooltip": "Output video aspect ratio.", + }), + "duration": ("INT", { + "default": 7, + "min": 4, + "max": 15, + "step": 1, + "display": "slider", + "tooltip": "Output duration in seconds.", + }), + "generate_audio": ("BOOLEAN", { + "default": True, + "tooltip": "Enable audio generation for the output video.", + }), + "watermark": ("BOOLEAN", { + "default": False, + "tooltip": "Whether to add a watermark.", + }), + "seed": ("INT", { + "default": 0, + "min": 0, + "max": 2147483647, + "step": 1, + "tooltip": "Seed controls rerun behavior; Seedance results are still non-deterministic.", + }), + } + } + + RETURN_TYPES = ("VIDEO", "STRING", "STRING") + RETURN_NAMES = ("video", "info", "price") + FUNCTION = "generate_video" + CATEGORY = "PD_Tools/Video_Generation" + + def generate_video( + self, + comfy_api_key, + prompt, + model, + resolution, + ratio, + duration, + generate_audio, + watermark, + seed, + ): + if not comfy_api_key or not comfy_api_key.strip(): + return (None, "Error: ComfyUI API Key is required", "") + + if not prompt or not prompt.strip(): + return (None, "Error: prompt is required", "") + + allowed_resolutions = SEEDANCE_MODEL_RESOLUTIONS.get(model, []) + if resolution not in allowed_resolutions: + return (None, f"Error: {model} supports {', '.join(allowed_resolutions)} only", "") + + model_id = SEEDANCE_MODELS[model] + api_key = comfy_api_key.strip() + headers = { + "X-API-KEY": api_key, + "Accept": "application/json", + "Content-Type": "application/json", + "Connection": "close", + "User-Agent": "ComfyUI-PD-Node/2.1", + } + + payload = { + "model": model_id, + "content": [ + { + "type": "text", + "text": prompt.strip(), + } + ], + "generate_audio": bool(generate_audio), + "resolution": resolution, + "ratio": ratio, + "duration": int(duration), + "seed": int(seed), + "watermark": bool(watermark), + } + + start_time = time.time() + + try: + create_response = requests.post( + f"{BASE_URL}{TASK_ENDPOINT}", + headers=headers, + json=payload, + timeout=120, + ) + + if create_response.status_code != 200: + error_msg = f"API Error {create_response.status_code}: {create_response.text[:500]}" + print(f"[PD_SEEDANCE_MINI] {error_msg}") + return (None, error_msg, "") + + create_result = create_response.json() + task_id = create_result.get("id") + if not task_id: + return (None, f"Error: task id missing in response: {str(create_result)[:500]}", "") + + status_url = f"{BASE_URL}{SEEDANCE2_STATUS_ENDPOINT}/{task_id}" + final_result = None + max_attempts = 480 + + for attempt in range(max_attempts): + time.sleep(9 if attempt else 2) + status_response = requests.get(status_url, headers=headers, timeout=120) + + if status_response.status_code != 200: + error_msg = f"Poll Error {status_response.status_code}: {status_response.text[:500]}" + print(f"[PD_SEEDANCE_MINI] {error_msg}") + return (None, error_msg, "") + + status_result = status_response.json() + status = status_result.get("status") + if status == "succeeded": + final_result = status_result + break + if status in ("failed", "cancelled"): + error = status_result.get("error") or {} + message = error.get("message") or error.get("code") or str(status_result) + return (None, f"Error: Seedance task {status}: {message}", "") + + if final_result is None: + return (None, "Error: Seedance task timed out while polling", "") + + content = final_result.get("content") or {} + video_url = content.get("video_url") + if not video_url: + return (None, f"Error: video_url missing in response: {str(final_result)[:500]}", "") + + video = _download_video_to_output(video_url, headers) + elapsed = time.time() - start_time + usage = final_result.get("usage") or {} + usd = _estimate_seedance_usd(model_id, resolution, duration, usage) + + info_lines = [ + f"Model: {model}", + f"Model ID: {model_id}", + "Status: Success", + f"Task ID: {task_id}", + f"Time: {elapsed:.2f}s", + f"Resolution: {resolution}", + f"Ratio: {ratio}", + f"Duration: {duration}s", + f"Audio: {'on' if generate_audio else 'off'}", + f"Watermark: {'on' if watermark else 'off'}", + ] + + if usage: + info_lines.append(f"Usage: {usage}") + + price_lines = [ + f"Estimated price (USD): ${usd:.6f}", + f"Estimated price (RMB): {format_rmb_value(usd)}", + f"Estimated price (Credits): {format_credits_value(usd)}", + ] + + return (video, "\n".join(info_lines), "\n".join(price_lines)) + + except requests.exceptions.Timeout: + return (None, "Error: Request timeout", "") + except requests.exceptions.ConnectionError as e: + return (None, f"Error: Connection failed - {str(e)}", "") + except Exception as e: + import traceback + + traceback.print_exc() + return (None, f"Error: {str(e)}", "") + + +NODE_CLASS_MAPPINGS = { + "PDSeedanceMiniTextToVideoAPIKey": PDSeedanceMiniTextToVideoAPIKey +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "PDSeedanceMiniTextToVideoAPIKey": "PD: Seedance 2.0 T2V (comfyui_apikey)" +} diff --git a/py/_image_gpt_common.py b/py/_image_gpt_common.py index f7ff34b..b4bf136 100644 --- a/py/_image_gpt_common.py +++ b/py/_image_gpt_common.py @@ -17,6 +17,7 @@ GPT_IMAGE_2_PRESET_SIZES = [ "2048x1152", "3840x2160", "2160x3840", + "Custom", "auto", ] @@ -123,7 +124,7 @@ def append_gemini_input_files_to_openai_files(files, input_files): if not isinstance(inline_data, dict): continue - mime_type = inline_data.get("mimeType") or inline_data.get("mime_type") or "image/png" + mime_type = normalize_mime_type(inline_data.get("mimeType") or inline_data.get("mime_type") or "image/png") if not isinstance(mime_type, str) or not mime_type.startswith("image/"): continue @@ -140,6 +141,14 @@ def append_gemini_input_files_to_openai_files(files, input_files): return files +def normalize_mime_type(mime_type): + if hasattr(mime_type, "value"): + return mime_type.value + if isinstance(mime_type, str) and "/" not in mime_type and "." in mime_type: + return mime_type.rsplit(".", 1)[-1].replace("_", "/") + return mime_type + + def build_openai_edit_files_from_sources(image=None, mask=None, input_files=None): files = [] @@ -250,7 +259,11 @@ def calculate_token_price_2(n): def resolve_gpt_image_2_size(size, custom_width, custom_height): - if custom_width > 0 and custom_height > 0: + custom_requested = str(size).lower() == "custom" + has_custom_value = custom_width > 0 or custom_height > 0 + if custom_requested or has_custom_value: + if custom_width <= 0 or custom_height <= 0: + raise ValueError("Custom size requires both custom_width and custom_height to be greater than 0") return f"{custom_width}x{custom_height}" return size