Add Comfy API key nodes

This commit is contained in:
pandy
2026-07-07 20:10:18 +08:00
parent 282be1b75d
commit 25449804f9
8 changed files with 883 additions and 11 deletions
+2 -1
View File
@@ -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!
---
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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,
+318
View File
@@ -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)",
}
+256
View File
@@ -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)",
}
+2 -2
View File
@@ -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}")
+284
View File
@@ -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)"
}
+15 -2
View File
@@ -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