Add Comfy API image and chat nodes

This commit is contained in:
pandy
2026-10-11 17:57:58 +08:00
parent 0a84c603a5
commit f35863129e
7 changed files with 964 additions and 1 deletions
+19
View File
@@ -35,6 +35,25 @@ pip install -r comfyui-PD_comfy-api-node/requirements.txt
## 📝 更新日志
### 2026-10-11 - Grok 文生图
- 新增 `PDGrokImageAPIKey` 文生图节点,复用现有 Grok 模型列表;API Key 由节点输入提供。同步更新 Grok 编辑节点的取消检查与失败状态。
### 2026-10-11 - OpenAI ChatGPT / GPT Image 2.5
- 新增 `PD: OpenAI ChatGPT Latest (ComfyUI Key)`(`PDOpenAIChatLatestAPIKey`),在 `PD_Tools/Text_Generation`。模型列表复用本地官方节点的 18 个选项,支持批量图片、`OPENAI_INPUT_FILES` 文件输入及 `OPENAI_CHAT_CONFIG` 高级设置;可连接官方 OpenAI ChatGPT Input Files / Advanced Options 节点。输出文字。
- 新增 `PD: OpenAI GPT Image 2.5 (ComfyUI Key)`(`PDOpenAIGPTImage25APIKey`),在 `PD_Tools/Image_Generation`。支持 `gpt-image-2.5-flare` / `gpt-image-2.5-sunburst` / `gpt-image-2` / `gpt-image-1.5` / `gpt-image-1`;动态尺寸、质量、背景、最多 16 张参考图、单图遮罩和 1–8 张输出。保留 RGBA 透明度,自动对齐同批输出尺寸。seed 沿用官方控件,当前后端未实现。
- 两者手动填写 Comfy.org API Key,复用本地官方价格估算角标;完成状态显示服务返回的 usage,估算角标不代表实际扣费。旧 ChatGPT/GPT Image 节点保留,需包含上述官方模型节点的新版 ComfyUI。
- ChatGPT 每次独立请求;官方 `persist_context` 已废弃且无效,因此新节点省略该开关,不在本地保存历史。Responses 格式参考 [OpenAI Docs](https://developers.openai.com/api/docs/guides/conversation-state),Comfy.org 代理契约以本地 `comfy_api_nodes/nodes_openai.py` 为准。
- 请求/轮询/下载期间每秒显示耗时,每 0.1 秒检查取消,整次执行限时 600 秒;仅一次生成 POST,不自动重试。取消不保证退还远端已产生的费用。
- 离线测试:`python custom_nodes/comfyui-PD_comfy-api-node/tests/test_openai_latest.py`。覆盖全部模型、文件/高级设置、异步轮询、RGBA/尺寸对齐、遮罩、慢请求取消、超时不重试及请求前校验;本次未提交真实付费请求。
### 2026-10-11 - Nano Banana 2.1
- 新增 `PD: Nano Banana 2.1 (ComfyUI Key)`(`PDGeminiNanoBanana21`),位于 `PD_Tools/Image_Generation`;旧 Nano Banana 2 节点保留。
- 复用当前官方节点的动态模型参数:Nano Banana 2.1、Nano Banana 2、Nano Banana 2 Lite;Lite 仅支持 1K,2.1 支持 MINIMAL/MEDIUM/HIGH。支持最多 14 张参考图及 `GEMINI_INPUT_FILES`,输出成品图、文字、第一张思考图;无思考图时与官方节点一样输出空白占位图。
- 手动填写 Comfy.org API Key;无需通过官方节点的登录入口。需包含 `GeminiNanoBanana2V2` 的新版 ComfyUI,本机已验证版本为 0.39.0。费用角标沿用官方估算,实际扣费以账户账单为准。
- 按官方请求契约,2.1 不发送 temperature/top_p;所有三个模型当前都不发送 seed。控件提示已注明,固定 seed 不保证复现。
- 等待期间每秒显示耗时,取消检查间隔 0.1 秒;单次执行只提交一次生成请求,不自动重试。取消后不继续轮询、下载或返回部分结果;已受理的远端请求不保证取消扣费。
- 离线模拟验证:`python custom_nodes/comfyui-PD_comfy-api-node/tests/test_nano_banana21.py`,覆盖三个模型请求、思考图分离、慢生成/下载取消、超时不重试及请求前校验;不产生 API 费用。
### v2.0.1 - 2026.0424
- 修复 `PD_comfyplus_image (Multi-Size Pack)` -> GPT Image 2 `files` 输入不兼容的问题。
- 在发送 OpenAI 兼容的 multipart 请求前,对打包图片的 MIME type 做归一化处理,确保 `GeminiMimeType.image_png` / `image_png` 会以 `image/png` 发送。
+165
View File
@@ -0,0 +1,165 @@
import base64
import io
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
import numpy as np
import requests
import torch
from PIL import Image
import comfy.model_management
from comfy_api.latest import io as IO
from comfy_api_nodes.nodes_gemini import GeminiNanoBanana2V2
from server import PromptServer
from .IMAGE_GeminiNanoBanana2 import tensor_to_base64
MODELS = {
"Gemini Nano Banana 2.1": "gemini-nano-banana-2.1",
"Nano Banana 2 (Gemini 3.1 Flash Image)": "gemini-3.1-flash-image",
"Nano Banana 2 Lite": "gemini-3.1-flash-lite-image",
}
class PDGeminiNanoBanana21(IO.ComfyNode):
@classmethod
def define_schema(cls):
schema = GeminiNanoBanana2V2.define_schema()
schema.node_id = "PDGeminiNanoBanana21"
schema.display_name = "PD: Nano Banana 2.1 (ComfyUI Key)"
schema.category = "PD_Tools/Image_Generation"
schema.inputs.insert(0, IO.String.Input("comfy_api_key", default="", tooltip="Comfy.org API Key"))
for item in schema.inputs:
if item.id == "seed":
item.tooltip = "Matches the official node. The current Comfy.org endpoint does not receive seed; a fixed seed does not guarantee repeatable output."
elif item.id in ("temperature", "top_p"):
item.tooltip += " Ignored by Nano Banana 2.1, matching the official endpoint."
schema.hidden = [IO.Hidden.unique_id]
schema.is_api_node = False
return schema
@classmethod
def execute(cls, comfy_api_key, prompt, model, seed, response_modalities,
system_prompt="", temperature=1.0, top_p=0.95):
if not comfy_api_key.strip():
raise ValueError("请填写 ComfyUI API Key。")
if not prompt.strip():
raise ValueError("请填写提示词。")
choice = model["model"]
model_id = MODELS[choice]
if choice == "Nano Banana 2 Lite" and model["resolution"] != "1K":
raise ValueError("Nano Banana 2 Lite 仅支持 1K。")
if choice != "Gemini Nano Banana 2.1" and model["thinking_level"] == "MEDIUM":
raise ValueError("MEDIUM 仅适用于 Nano Banana 2.1。")
images = list((model.get("images") or {}).values())
if sum(image.shape[0] for image in images if image is not None) > 14:
raise ValueError("最多支持 14 张参考图。")
parts = [{"text": prompt}]
for image in images:
if image is not None:
for item in image:
comfy.model_management.throw_exception_if_processing_interrupted()
parts.append({"inlineData": {"mimeType": "image/png", "data": tensor_to_base64(item)}})
for part in model.get("files") or []:
parts.append(part.model_dump(exclude_none=True) if hasattr(part, "model_dump") else part)
image_config = {"imageSize": model["resolution"]}
if model["aspect_ratio"] != "auto":
image_config["aspectRatio"] = model["aspect_ratio"]
config = {
"responseModalities": ["IMAGE"] if response_modalities == "IMAGE" else ["TEXT", "IMAGE"],
"imageConfig": image_config,
"thinkingConfig": {"thinkingLevel": model["thinking_level"]},
}
# The official 2.1 endpoint omits sampling parameters and seed.
if choice != "Gemini Nano Banana 2.1":
config.update(temperature=temperature, topP=top_p)
payload = {"contents": [{"role": "user", "parts": parts}], "generationConfig": config}
if system_prompt:
payload["systemInstruction"] = {"parts": [{"text": system_prompt}]}
started = time.monotonic()
session = requests.Session()
executor = ThreadPoolExecutor(max_workers=1)
future = None
def status(text):
PromptServer.instance.send_sync("progress_text", {
"node": cls.hidden.unique_id,
"text": f"{text} | {time.monotonic() - started:.1f}s",
})
def request(method, url, **kwargs):
nonlocal future
comfy.model_management.throw_exception_if_processing_interrupted()
request_started = time.monotonic()
future = executor.submit(session.request, method, url, timeout=(15, 180), **kwargs)
last_status = 0.0
while True:
comfy.model_management.throw_exception_if_processing_interrupted()
if time.monotonic() - request_started > 195:
raise requests.Timeout("Request exceeded 195 seconds")
if time.monotonic() - last_status >= 1:
status("Running")
last_status = time.monotonic()
try:
return future.result(timeout=0.1)
except FutureTimeout:
if future.done():
raise
def decode(data):
comfy.model_management.throw_exception_if_processing_interrupted()
with Image.open(io.BytesIO(data)) as image:
return torch.from_numpy(np.array(image.convert("RGB")).astype(np.float32) / 255.0).unsqueeze(0)
try:
response = request("POST", f"https://api.comfy.org/proxy/vertexai/gemini/{model_id}",
headers={"X-API-KEY": comfy_api_key.strip()}, json=payload)
response.raise_for_status()
result = response.json()
generated, thoughts, texts = [], [], []
for candidate in result.get("candidates", []):
for part in candidate.get("content", {}).get("parts", []):
comfy.model_management.throw_exception_if_processing_interrupted()
if part.get("text"):
texts.append(part["text"])
inline = part.get("inlineData") or part.get("inline_data")
file_data = part.get("fileData") or part.get("file_data")
if inline and inline.get("mimeType", inline.get("mime_type", "")).startswith("image/"):
image = decode(base64.b64decode(inline["data"]))
elif file_data and file_data.get("mimeType", file_data.get("mime_type", "")).startswith("image/"):
downloaded = request("GET", file_data.get("fileUri") or file_data["file_uri"])
downloaded.raise_for_status()
image = decode(downloaded.content)
else:
continue
(thoughts if part.get("thought") else generated).append(image)
if not generated:
raise ValueError("Gemini 未返回成品图:" + ("\n".join(texts) or str(result.get("promptFeedback", {}))))
output = torch.cat(generated)
thought = thoughts[0] if thoughts else torch.zeros((1, 1024, 1024, 3))
comfy.model_management.throw_exception_if_processing_interrupted()
usage = result.get("usageMetadata", {})
status(f"Completed | Tokens: {usage.get('totalTokenCount', 'N/A')}")
return IO.NodeOutput(output, "\n".join(texts), thought)
except comfy.model_management.InterruptProcessingException:
session.close()
if future is not None:
future.cancel()
status("Cancelled")
raise
except (requests.Timeout, FutureTimeout):
status("Timed out")
raise
except Exception:
status("Failed")
raise
finally:
session.close()
executor.shutdown(wait=False, cancel_futures=True)
NODE_CLASS_MAPPINGS = {"PDGeminiNanoBanana21": PDGeminiNanoBanana21}
NODE_DISPLAY_NAME_MAPPINGS = {"PDGeminiNanoBanana21": "PD: Nano Banana 2.1 (ComfyUI Key)"}
+6 -1
View File
@@ -79,6 +79,7 @@ def send_status(node_id, text):
def request_with_interrupt(session, method, url, node_id, status, start_time, **kwargs):
mm.throw_exception_if_processing_interrupted()
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
future = executor.submit(method, url, **kwargs)
last_elapsed = -1
@@ -260,6 +261,7 @@ class PDGrokImageEditAPIKey:
timeout=600,
)
if response.status_code != 200:
send_status(unique_id, f"Failed\nTime elapsed: {int(time.monotonic() - start_time)}s")
return (empty_image(), f"API Error {response.status_code}: {response.text[:500]}")
result = response.json()
@@ -281,6 +283,7 @@ class PDGrokImageEditAPIKey:
output_images.append(load_image_to_tensor(base64.b64decode(item["b64_json"])))
if not output_images:
send_status(unique_id, f"Failed\nTime elapsed: {int(time.monotonic() - start_time)}s")
return (empty_image(), f"Error: response contained no images: {str(result)[:500]}")
image = torch.cat(output_images, dim=0)
@@ -289,6 +292,8 @@ class PDGrokImageEditAPIKey:
send_status(unique_id, f"Completed\nTime elapsed: {int(elapsed)}s")
print(f"[PD_GROK_EDIT] Completed {len(output_images)} image(s) in {elapsed:.2f}s")
return (image, price)
except mm.InterruptProcessingException:
raise
except requests.exceptions.Timeout:
send_status(unique_id, f"Timed out\nTime elapsed: {int(time.monotonic() - start_time)}s")
return (empty_image(), "Error: request timeout (600s)")
@@ -307,5 +312,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PDGrokImageEditAPIKey": "grok edit (comfyapikey)",
"PDGrokImageEditAPIKey": "PD-grok-image-edit",
}
+204
View File
@@ -0,0 +1,204 @@
import base64
import time
import comfy.model_management as mm
import requests
import torch
from .IMAGE_GrokEdit_apikey import (
ASPECT_RATIOS,
BASE_URL,
MODELS,
QUALITY_MODELS,
request_with_interrupt,
send_status,
)
from ._image_gpt_common import (
empty_image,
format_credits_value,
format_rmb_value,
load_image_to_tensor,
usd_to_comfy_credits,
)
GENERATION_ENDPOINT = "/proxy/xai/v1/images/generations"
def estimate_price(model, resolution, quality, number_of_images):
is_1k = resolution.lower() == "1k"
if model == "grok-imagine-image-2.0":
rate = (0.04 if is_1k else 0.06) if quality == "low" else (0.06 if is_1k else 0.08)
elif model == "grok-imagine-image-quality":
rate = 0.05 if is_1k else 0.07
elif model == "grok-imagine-image-pro":
rate = 0.07
else:
rate = 0.02
return rate * number_of_images
def format_price(result, response, model, resolution, quality, number_of_images):
usage = result.get("usage") or {}
ticks = usage.get("cost_in_usd_ticks")
credits_header = response.headers.get("X-Comfy-Credits-Used")
credits = None
try:
credits = float(credits_header) if credits_header is not None else None
except ValueError:
pass
usd = ticks / 10_000_000_000 if isinstance(ticks, (int, float)) else None
if usd is None and credits is not None:
usd = credits / usd_to_comfy_credits(1.0)
if credits is None and usd is not None:
credits = usd_to_comfy_credits(usd)
if usd is None:
usd = estimate_price(model, resolution, quality, number_of_images)
return "\n".join([
f"Estimated price (USD): ${usd:.6f}",
f"Estimated price (RMB): {format_rmb_value(usd)}",
f"Estimated price (Credits): {format_credits_value(usd)}",
])
lines = [
f"Actual price (USD): ${usd:.6f}",
f"Actual price (RMB): {format_rmb_value(usd)}",
]
if credits is not None:
lines.append(f"Credits used: {credits:.2f} credits")
return "\n".join(lines)
class PDGrokImageAPIKey:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"api_key": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Your ComfyUI API Key",
}),
"prompt": ("STRING", {"default": "", "multiline": True}),
"model": (MODELS, {"default": "grok-imagine-image-2.0"}),
"aspect_ratio": (ASPECT_RATIOS[1:], {"default": "1:1"}),
"resolution": (["1K", "2K"], {"default": "1K"}),
"quality": (["medium", "low"], {"default": "medium"}),
"number_of_images": ("INT", {"default": 1, "min": 1, "max": 10, "step": 1}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2147483647, "step": 1}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "show_price")
FUNCTION = "generate_image"
CATEGORY = "PD_Tools/Image_Generation"
def generate_image(
self,
api_key,
prompt,
model,
aspect_ratio,
resolution,
quality,
number_of_images,
seed,
unique_id,
):
if not api_key or not 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")
payload = {
"model": model,
"prompt": prompt.strip(),
"aspect_ratio": aspect_ratio,
"n": number_of_images,
"seed": seed,
"response_format": "url",
"resolution": resolution.lower(),
"quality": quality if model in QUALITY_MODELS else None,
}
headers = {
"X-API-KEY": api_key.strip(),
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "ComfyUI-PD-Node/2.1",
}
start_time = time.monotonic()
session = requests.Session()
try:
response = request_with_interrupt(
session,
session.post,
f"{BASE_URL}{GENERATION_ENDPOINT}",
unique_id,
"Waiting for Grok server",
start_time,
headers=headers,
json=payload,
timeout=600,
)
if response.status_code != 200:
send_status(unique_id, f"Failed\nTime elapsed: {int(time.monotonic() - start_time)}s")
return (empty_image(), f"API Error {response.status_code}: {response.text[:500]}")
result = response.json()
output_images = []
for item in result.get("data") or []:
if item.get("url"):
download = request_with_interrupt(
session,
session.get,
item["url"],
unique_id,
"Downloading result",
start_time,
timeout=120,
)
download.raise_for_status()
output_images.append(load_image_to_tensor(download.content))
elif item.get("b64_json"):
output_images.append(load_image_to_tensor(base64.b64decode(item["b64_json"])))
if not output_images:
send_status(unique_id, f"Failed\nTime elapsed: {int(time.monotonic() - start_time)}s")
return (empty_image(), f"Error: response contained no images: {str(result)[:500]}")
image = torch.cat(output_images, dim=0)
price = format_price(result, response, model, resolution, quality, number_of_images)
elapsed = time.monotonic() - start_time
send_status(unique_id, f"Completed\nTime elapsed: {int(elapsed)}s")
print(f"[PD_GROK_IMAGE] {model} completed {len(output_images)} image(s) in {elapsed:.2f}s")
return (image, price)
except mm.InterruptProcessingException:
raise
except requests.exceptions.Timeout:
send_status(unique_id, f"Timed out\nTime elapsed: {int(time.monotonic() - start_time)}s")
return (empty_image(), "Error: request timeout (600s)")
except requests.exceptions.ConnectionError as error:
send_status(unique_id, f"Connection failed\nTime elapsed: {int(time.monotonic() - start_time)}s")
return (empty_image(), f"Error: connection failed - {error}")
except Exception as error:
send_status(unique_id, f"Failed\nTime elapsed: {int(time.monotonic() - start_time)}s")
return (empty_image(), f"Error: {error}")
finally:
session.close()
NODE_CLASS_MAPPINGS = {
"PDGrokImageAPIKey": PDGrokImageAPIKey,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PDGrokImageAPIKey": "PD-grok-text to image",
}
+222
View File
@@ -0,0 +1,222 @@
import base64
import io
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
import numpy as np
import requests
import torch
from PIL import Image
import comfy.model_management
from comfy.utils import common_upscale
from comfy_api.latest import IO
from comfy_api_nodes.nodes_openai import (
OpenAIChatNode, OpenAIGPTImageNodeV2, GPT_IMAGE_MODELS,
SUPPORTED_REASONING_EFFORTS, SupportedOpenAIModel, downscale_image_tensor,
)
from server import PromptServer
from ._image_gpt_common import tensor_to_bytes, validate_gpt_image_2_size
BASE_URL = "https://api.comfy.org"
class _Request:
def __init__(self, node_id, api_key):
if not api_key.strip():
raise ValueError("请填写 Comfy.org API Key。")
self.node_id = node_id
self.started = time.monotonic()
self.session = requests.Session()
self.headers = {"X-API-KEY": api_key.strip(), "Accept": "application/json"}
self.executor = ThreadPoolExecutor(max_workers=1)
self.future = None
self.response_id = ""
self.usage = {}
self.last_status = 0
def status(self, text):
PromptServer.instance.send_sync("progress_text", {
"node": self.node_id,
"text": f"{text} | {time.monotonic() - self.started:.1f}s {self.response_id}",
})
def check(self):
comfy.model_management.throw_exception_if_processing_interrupted()
if time.monotonic() - self.started > 600:
raise requests.Timeout("Operation exceeded 600 seconds")
if time.monotonic() - self.last_status >= 1:
self.status("Running")
self.last_status = time.monotonic()
def request(self, method, url, **kwargs):
self.check()
headers = self.headers if url.startswith(BASE_URL + "/") else {}
self.future = self.executor.submit(self.session.request, method, url,
headers=headers, timeout=(15, 180), **kwargs)
while True:
self.check()
try:
response = self.future.result(timeout=0.1)
response.raise_for_status()
return response
except FutureTimeout:
if self.future.done():
raise
def sleep(self, seconds):
until = time.monotonic() + seconds
while time.monotonic() < until:
self.check()
time.sleep(min(0.1, max(0, until - time.monotonic())))
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback):
self.session.close()
if self.future is not None:
self.future.cancel()
self.executor.shutdown(wait=False, cancel_futures=True)
if exc_type is not None and issubclass(exc_type, comfy.model_management.InterruptProcessingException):
self.status("Cancelled")
elif exc_type is not None and issubclass(exc_type, (requests.Timeout, FutureTimeout)):
self.status("Timed out")
elif exc_type is not None:
self.status("Failed")
else:
self.status(f"Completed | Usage: {self.usage}")
def _schema(official, node_id, name, category):
schema = official.define_schema()
schema.node_id = node_id
schema.display_name = name
schema.category = category
schema.inputs.insert(0, IO.String.Input("comfy_api_key", default="", tooltip="Comfy.org API Key"))
schema.hidden = [IO.Hidden.unique_id]
schema.is_api_node = False
return schema
class PDOpenAIChatLatestAPIKey(IO.ComfyNode):
@classmethod
def define_schema(cls):
schema = _schema(OpenAIChatNode, "PDOpenAIChatLatestAPIKey", "PD: OpenAI ChatGPT Latest (ComfyUI Key)", "PD_Tools/Text_Generation")
# The official persist_context widget is deprecated and does nothing.
schema.inputs = [item for item in schema.inputs if item.id != "persist_context"]
return schema
@classmethod
def execute(cls, comfy_api_key, prompt, model, images=None, files=None, advanced_options=None):
if model not in [item.value for item in SupportedOpenAIModel]:
raise ValueError(f"Unknown model: {model}")
if not prompt.strip():
raise ValueError("请填写提示词。")
options = advanced_options.model_dump(exclude_none=True) if advanced_options else {}
effort = (options.get("reasoning") or {}).get("effort")
if effort is not None and effort not in SUPPORTED_REASONING_EFFORTS[model]:
raise ValueError(f"{model} 不支持 reasoning_effort={effort},请选择 default 或该模型支持的等级。")
content = OpenAIChatNode.create_input_message_contents(prompt, images, files)
payload = {**options, "model": model, "input": [{"role": "user", "content": [
item.model_dump(exclude_none=True) for item in content
]}], "store": True, "stream": False}
with _Request(cls.hidden.unique_id, comfy_api_key) as api:
result = api.request("POST", BASE_URL + "/proxy/openai/v1/responses", json=payload).json()
api.response_id = result.get("id", "")
while result.get("status") in ("queued", "in_progress"):
if not api.response_id:
raise ValueError("OpenAI response missing ID")
api.sleep(1)
result = api.request("GET", BASE_URL + "/proxy/openai/v1/responses/" + api.response_id).json()
api.check()
if result.get("error") or result.get("status") not in ("completed", "incomplete"):
raise ValueError(f"OpenAI response {result.get('status')}: {result.get('error')}")
texts = [item["text"] for output in result.get("output", []) if output.get("type") == "message"
for item in output.get("content", []) if item.get("type") == "output_text" and item.get("text")]
if not texts:
raise ValueError("OpenAI 未返回文字。")
api.usage = result.get("usage", {})
return IO.NodeOutput("\n".join(texts))
class PDOpenAIGPTImage25APIKey(IO.ComfyNode):
@classmethod
def define_schema(cls):
return _schema(OpenAIGPTImageNodeV2, "PDOpenAIGPTImage25APIKey", "PD: OpenAI GPT Image 2.5 (ComfyUI Key)", "PD_Tools/Image_Generation")
@classmethod
def execute(cls, comfy_api_key, prompt, model, n, seed):
model_id = model["model"]
if model_id not in GPT_IMAGE_MODELS:
raise ValueError(f"Unknown model: {model_id}")
if not prompt.strip():
raise ValueError("请填写提示词。")
if not 1 <= n <= 8:
raise ValueError("图片数量须为 1–8。")
size = model["size"]
if size == "Custom":
size = validate_gpt_image_2_size(size, model["custom_width"], model["custom_height"])
payload = {"model": model_id, "prompt": prompt, "n": n, "size": size,
"quality": model["quality"], "background": model["background"], "moderation": "low"}
if model_id == "gpt-image-2" and payload["background"] == "transparent":
raise ValueError("gpt-image-2 不支持透明背景。")
images = [item for image in (model.get("images") or {}).values() if image is not None for item in image]
if len(images) > 16:
raise ValueError("最多支持 16 张参考图。")
mask = model.get("mask")
if mask is not None and (len(images) != 1 or mask.shape != (1, *images[0].shape[:2])):
raise ValueError("遮罩要求恰好一张参考图,且尺寸相同。")
with _Request(cls.hidden.unique_id, comfy_api_key) as api:
uploads = []
for index, image in enumerate(images):
api.check()
uploads.append(("image" if len(images) == 1 else "image[]",
(f"image_{index}.png", tensor_to_bytes(image).getvalue(), "image/png")))
if mask is not None:
rgba = torch.zeros((*images[0].shape[:2], 4))
rgba[:, :, 3] = 1 - mask[0].cpu()
scaled = downscale_image_tensor(rgba.unsqueeze(0), total_pixels=2048 * 2048).squeeze(0)
data = io.BytesIO()
Image.fromarray((scaled.numpy() * 255).astype(np.uint8)).save(data, format="PNG")
uploads.append(("mask", ("mask.png", data.getvalue(), "image/png")))
if uploads:
result = api.request("POST", BASE_URL + "/proxy/openai/images/edits", data=payload, files=uploads).json()
else:
result = api.request("POST", BASE_URL + "/proxy/openai/images/generations", json=payload).json()
tensors = []
for item in result.get("data", []):
api.check()
if item.get("b64_json"):
data = base64.b64decode(item["b64_json"])
elif item.get("url"):
url = item["url"]
if url.startswith("/proxy/"):
url = BASE_URL + url
data = api.request("GET", url).content
else:
raise ValueError("图片响应缺少 URL 或 base64 数据。")
api.check()
with Image.open(io.BytesIO(data)) as image:
tensors.append(torch.from_numpy(np.array(image.convert("RGBA")).astype(np.float32) / 255.0))
if not tensors:
raise ValueError("OpenAI 未返回图片。")
height, width = tensors[0].shape[:2]
for index, image in enumerate(tensors):
if image.shape[:2] != (height, width):
tensors[index] = common_upscale(image.movedim(-1, 0).unsqueeze(0), width, height, "bilinear", "center")[0].movedim(0, -1)
api.check()
api.usage = result.get("usage", {})
return IO.NodeOutput(torch.stack(tensors))
NODE_CLASS_MAPPINGS = {
"PDOpenAIChatLatestAPIKey": PDOpenAIChatLatestAPIKey,
"PDOpenAIGPTImage25APIKey": PDOpenAIGPTImage25APIKey,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PDOpenAIChatLatestAPIKey": "PD: OpenAI ChatGPT Latest (ComfyUI Key)",
"PDOpenAIGPTImage25APIKey": "PD: OpenAI GPT Image 2.5 (ComfyUI Key)",
}
+160
View File
@@ -0,0 +1,160 @@
import base64
import importlib
import io
import sys
import threading
import time
import types
import unittest
from pathlib import Path
from unittest.mock import patch
import requests
import torch
from PIL import Image
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
sys.argv = [sys.argv[0], "--cpu"]
package = types.ModuleType("pd_api_test")
package.__path__ = [str(Path(__file__).resolve().parents[1])]
sys.modules[package.__name__] = package
module = importlib.import_module("pd_api_test.py.IMAGE_GeminiNanoBanana21")
Node = module.PDGeminiNanoBanana21
Node.hidden = types.SimpleNamespace(unique_id="test-node")
def image_part(color, thought=False):
data = io.BytesIO()
Image.new("RGB", (8, 8), color).save(data, format="PNG")
return {"thought": thought, "inlineData": {"mimeType": "image/png", "data": base64.b64encode(data.getvalue()).decode()}}
class Response:
def raise_for_status(self):
pass
def json(self):
return {"candidates": [{"content": {"parts": [
image_part("red", True), image_part("green"), {"text": "generated"},
]}}], "usageMetadata": {"totalTokenCount": 123}}
class Session:
def __init__(self, slow=False, timeout=False, download=False):
self.slow = slow
self.timeout = timeout
self.download = download
self.calls = []
self.closed = threading.Event()
def request(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
if self.timeout:
raise requests.Timeout("simulated")
if self.slow and (not self.download or method == "GET"):
self.closed.wait(5)
response = Response()
if self.download and method == "POST":
response.json = lambda: {"candidates": [{"content": {"parts": [
{"fileData": {"mimeType": "image/png", "fileUri": "https://example.test/image.png"}},
]}}]}
return response
def close(self):
self.closed.set()
class NanoBananaTests(unittest.TestCase):
def setUp(self):
self.statuses = []
self.cancel = threading.Event()
self.patches = [
patch.object(module.PromptServer, "instance", types.SimpleNamespace(send_sync=self.status), create=True),
patch.object(module.comfy.model_management, "throw_exception_if_processing_interrupted", self.interrupted),
]
for item in self.patches:
item.start()
def tearDown(self):
for item in reversed(self.patches):
item.stop()
def status(self, event, data):
self.assertEqual(event, "progress_text")
self.statuses.append((time.monotonic(), data["text"]))
def interrupted(self):
if self.cancel.is_set():
raise module.comfy.model_management.InterruptProcessingException()
def run_node(self, choice="Gemini Nano Banana 2.1", **model_args):
model = {"model": choice, "resolution": "1K", "aspect_ratio": "1:1", "thinking_level": "MINIMAL"}
model.update(model_args)
return Node.execute("test-key", "test prompt", model, 42, "IMAGE+TEXT")
def test_models_payload_and_separate_thought_image(self):
for choice, endpoint in module.MODELS.items():
session = Session()
with patch.object(module.requests, "Session", return_value=session):
result = self.run_node(choice)
self.assertEqual(len(session.calls), 1)
self.assertTrue(session.calls[0][1].endswith(endpoint))
config = session.calls[0][2]["json"]["generationConfig"]
self.assertEqual("temperature" in config, choice != "Gemini Nano Banana 2.1")
self.assertNotIn("seed", config)
self.assertEqual(result.result[1], "generated")
self.assertTrue(torch.equal(result.result[0][0, 0, 0], torch.tensor([0., 128 / 255, 0.])))
self.assertTrue(torch.equal(result.result[2][0, 0, 0], torch.tensor([1., 0., 0.])))
def test_cancel_slow_generation_and_download(self):
for download in (False, True):
self.cancel.clear()
self.statuses.clear()
session = Session(slow=True, download=download)
errors = []
def execute():
try:
self.run_node()
except module.comfy.model_management.InterruptProcessingException as exc:
errors.append(exc)
with patch.object(module.requests, "Session", return_value=session):
worker = threading.Thread(target=execute)
worker.start()
time.sleep(1.25)
started = time.monotonic()
self.cancel.set()
worker.join(0.5)
self.assertFalse(worker.is_alive())
self.assertLess(time.monotonic() - started, 0.5)
self.assertEqual(len(errors), 1)
self.assertTrue(session.closed.is_set())
self.assertEqual(sum(call[0] == "POST" for call in session.calls), 1)
running = [item for item in self.statuses if item[1].startswith("Running")]
self.assertGreaterEqual(len(running), 2)
self.assertTrue(all("s" in item[1] for item in running))
self.assertTrue(self.statuses[-1][1].startswith("Cancelled"))
def test_timeout_does_not_retry(self):
session = Session(timeout=True)
with patch.object(module.requests, "Session", return_value=session), self.assertRaises(requests.Timeout):
self.run_node()
self.assertEqual(len(session.calls), 1)
self.assertTrue(self.statuses[-1][1].startswith("Timed out"))
def test_validation_and_cancel_before_submission(self):
session = Session()
with patch.object(module.requests, "Session", return_value=session):
with self.assertRaises(ValueError):
self.run_node("Nano Banana 2 Lite", resolution="4K")
with self.assertRaises(ValueError):
self.run_node(images={"image_1": torch.zeros((15, 8, 8, 3))})
self.cancel.set()
with self.assertRaises(module.comfy.model_management.InterruptProcessingException):
self.run_node()
self.assertEqual(session.calls, [])
if __name__ == "__main__":
unittest.main(argv=[sys.argv[0]])
+188
View File
@@ -0,0 +1,188 @@
import base64
import importlib
import io
import sys
import threading
import time
import types
import unittest
from pathlib import Path
from unittest.mock import patch
import requests
import torch
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
sys.argv = [sys.argv[0], "--cpu"]
package = types.ModuleType("pd_openai_test")
package.__path__ = [str(Path(__file__).resolve().parents[1])]
sys.modules[package.__name__] = package
module = importlib.import_module("pd_openai_test.py.OPENAI_latest_apikey")
from comfy_api_nodes.apis.openai import InputFileContent, ModelResponseProperties, Reasoning
Chat = module.PDOpenAIChatLatestAPIKey
ImageNode = module.PDOpenAIGPTImage25APIKey
for node in (Chat, ImageNode):
node.hidden = types.SimpleNamespace(unique_id="test-node")
def png(size=(8, 8)):
data = io.BytesIO()
Image.new("RGBA", size, (255, 0, 0, 80)).save(data, format="PNG")
return data.getvalue()
class Response:
def __init__(self, data):
self.data = data
self.content = png()
def json(self):
return self.data
def raise_for_status(self):
pass
class Session:
def __init__(self, slow=None, timeout=False, queued=False, url_image=False):
self.calls = []
self.closed = threading.Event()
self.slow = slow
self.timeout = timeout
self.queued = queued
self.url_image = url_image
def request(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
if self.timeout:
raise requests.Timeout("simulated")
if method == self.slow:
self.closed.wait(5)
if "/responses" in url:
if method == "POST" and self.queued:
return Response({"id": "resp_test", "status": "queued"})
return Response({"id": "resp_test", "status": "completed", "usage": {"total_tokens": 12},
"output": [{"type": "message", "content": [
{"type": "output_text", "text": "one"}, {"type": "output_text", "text": "two"},
]}]})
if method == "GET":
return Response({})
if self.url_image:
return Response({"data": [{"url": "https://example.test/image.png"}]})
return Response({"data": [{"b64_json": base64.b64encode(png()).decode()},
{"b64_json": base64.b64encode(png((10, 12))).decode()}]})
def close(self):
self.closed.set()
class OpenAITests(unittest.TestCase):
def setUp(self):
self.cancel = threading.Event()
self.events = []
self.patches = [
patch.object(module.PromptServer, "instance", types.SimpleNamespace(send_sync=lambda event, data: self.events.append(data)), create=True),
patch.object(module.comfy.model_management, "throw_exception_if_processing_interrupted", self.check),
]
for item in self.patches:
item.start()
def tearDown(self):
for item in reversed(self.patches):
item.stop()
def check(self):
if self.cancel.is_set():
raise module.comfy.model_management.InterruptProcessingException()
def chat(self, **kwargs):
return Chat.execute("test-key", "test prompt", kwargs.pop("model", "gpt-6-astra"), **kwargs)
def image(self, **kwargs):
model = {"model": "gpt-image-2.5-flare", "size": "1024x1024", "quality": "low", "background": "transparent"}
model.update(kwargs)
return ImageNode.execute("test-key", "test prompt", model, 1, 0)
def test_chat_models_files_options_and_poll(self):
self.assertEqual(len(list(module.SupportedOpenAIModel)), 18)
for model in module.SupportedOpenAIModel:
session = Session(queued=True)
with patch.object(module.requests, "Session", return_value=session), patch.object(module._Request, "sleep"):
result = self.chat(model=model.value, images=torch.zeros((1, 8, 8, 3)),
files=[InputFileContent(type="input_file", filename="test.pdf", file_data="data:application/pdf;base64,AA==")],
advanced_options=ModelResponseProperties(instructions="system", max_output_tokens=32))
self.assertEqual(result.result, ("one\ntwo",))
self.assertEqual([item[0] for item in session.calls], ["POST", "GET"])
body = session.calls[0][2]["json"]
self.assertEqual(body["model"], model.value)
self.assertEqual(body["instructions"], "system")
self.assertEqual([item["type"] for item in body["input"][0]["content"]], ["input_text", "input_image", "input_file"])
self.assertNotIn("previous_response_id", body)
def test_image_models_generation_edit_mask_and_alpha(self):
for model in module.GPT_IMAGE_MODELS:
session = Session()
with patch.object(module.requests, "Session", return_value=session):
result = self.image(model=model, background="auto")
self.assertEqual(result.result[0].shape, (2, 8, 8, 4))
self.assertAlmostEqual(result.result[0][0, 0, 0, 3].item(), 80 / 255)
self.assertTrue(session.calls[0][1].endswith("/images/generations"))
self.assertNotIn("seed", session.calls[0][2]["json"])
session = Session()
with patch.object(module.requests, "Session", return_value=session):
self.image(size="Custom", custom_width=1024, custom_height=1024,
images={"image_1": torch.zeros((1, 8, 8, 3))}, mask=torch.ones((1, 8, 8)))
self.assertTrue(session.calls[0][1].endswith("/images/edits"))
self.assertEqual([item[0] for item in session.calls[0][2]["files"]], ["image", "mask"])
with Image.open(io.BytesIO(session.calls[0][2]["files"][1][1][1])) as mask:
self.assertEqual(mask.getpixel((0, 0))[3], 0)
def test_cancellation_generation_poll_and_download(self):
cases = [(self.chat, Session(slow="POST")), (self.chat, Session(slow="GET", queued=True)),
(self.image, Session(slow="POST")), (self.image, Session(slow="GET", url_image=True))]
for action, session in cases:
self.cancel.clear()
self.events.clear()
errors = []
def run():
try:
action()
except module.comfy.model_management.InterruptProcessingException as exc:
errors.append(exc)
with patch.object(module.requests, "Session", return_value=session):
worker = threading.Thread(target=run)
worker.start()
time.sleep(1.25)
started = time.monotonic()
self.cancel.set()
worker.join(0.5)
self.assertFalse(worker.is_alive())
self.assertLess(time.monotonic() - started, 0.5)
self.assertEqual(len(errors), 1)
self.assertEqual(sum(item[0] == "POST" for item in session.calls), 1)
self.assertTrue(session.closed.is_set())
self.assertTrue(self.events[-1]["text"].startswith("Cancelled"))
self.assertGreaterEqual(sum(item["text"].startswith("Running") for item in self.events), 2)
def test_timeout_and_invalid_inputs_never_retry(self):
session = Session(timeout=True)
with patch.object(module.requests, "Session", return_value=session), self.assertRaises(requests.Timeout):
self.chat()
self.assertEqual(len(session.calls), 1)
session = Session()
with patch.object(module.requests, "Session", return_value=session):
for action in (
lambda: self.chat(model="gpt-4.1", advanced_options=ModelResponseProperties(reasoning=Reasoning(effort="high"))),
lambda: self.image(images={"image_1": torch.zeros((17, 8, 8, 3))}),
lambda: self.image(mask=torch.zeros((1, 8, 8))),
lambda: self.image(size="Custom", custom_width=1025, custom_height=1024),
):
with self.assertRaises(ValueError):
action()
self.assertEqual(session.calls, [])
if __name__ == "__main__":
unittest.main(argv=[sys.argv[0]])