Add Comfy API key nodes
This commit is contained in:
@@ -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!
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
}
|
||||
@@ -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)",
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user