diff --git a/.obsidian/app.json b/.obsidian/app.json new file mode 100644 index 0000000..df7a3ba --- /dev/null +++ b/.obsidian/app.json @@ -0,0 +1,5 @@ +{ + "newFileLocation": "root", + "newFileFolderPath": "", + "attachmentFolderPath": "./image" +} \ No newline at end of file diff --git a/.obsidian/appearance.json b/.obsidian/appearance.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.obsidian/appearance.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/.obsidian/core-plugins.json b/.obsidian/core-plugins.json new file mode 100644 index 0000000..639b90d --- /dev/null +++ b/.obsidian/core-plugins.json @@ -0,0 +1,33 @@ +{ + "file-explorer": true, + "global-search": true, + "switcher": true, + "graph": true, + "backlink": true, + "canvas": true, + "outgoing-link": true, + "tag-pane": true, + "footnotes": false, + "properties": true, + "page-preview": true, + "daily-notes": true, + "templates": true, + "note-composer": true, + "command-palette": true, + "slash-command": false, + "editor-status": true, + "bookmarks": true, + "markdown-importer": false, + "zk-prefixer": false, + "random-note": false, + "outline": true, + "word-count": true, + "slides": false, + "audio-recorder": false, + "workspaces": false, + "file-recovery": true, + "publish": false, + "sync": true, + "bases": true, + "webviewer": false +} \ No newline at end of file diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json new file mode 100644 index 0000000..135555e --- /dev/null +++ b/.obsidian/workspace.json @@ -0,0 +1,196 @@ +{ + "main": { + "id": "d17591b790a4e845", + "type": "split", + "children": [ + { + "id": "3177b9c61827cc7a", + "type": "tabs", + "children": [ + { + "id": "1c73e0277fd6237f", + "type": "leaf", + "state": { + "type": "markdown", + "state": { + "file": "README.md", + "mode": "source", + "source": false + }, + "icon": "lucide-file", + "title": "README" + } + } + ] + } + ], + "direction": "vertical" + }, + "left": { + "id": "85a0d9b441e10230", + "type": "split", + "children": [ + { + "id": "0ac3115ffa34abca", + "type": "tabs", + "children": [ + { + "id": "2657aa2522003f89", + "type": "leaf", + "state": { + "type": "file-explorer", + "state": { + "sortOrder": "alphabetical", + "autoReveal": false + }, + "icon": "lucide-folder-closed", + "title": "文件列表" + } + }, + { + "id": "4db09918e8fceb8b", + "type": "leaf", + "state": { + "type": "search", + "state": { + "query": "", + "matchingCase": false, + "explainSearch": false, + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical" + }, + "icon": "lucide-search", + "title": "搜索" + } + }, + { + "id": "cc6ce1517e5ab669", + "type": "leaf", + "state": { + "type": "bookmarks", + "state": {}, + "icon": "lucide-bookmark", + "title": "书签" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300 + }, + "right": { + "id": "31dbf3f34798f6f3", + "type": "split", + "children": [ + { + "id": "3db7b52c4d00a03a", + "type": "tabs", + "children": [ + { + "id": "f18aa31ed938818a", + "type": "leaf", + "state": { + "type": "backlink", + "state": { + "file": "README.md", + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical", + "showSearch": false, + "searchQuery": "", + "backlinkCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-coming-in", + "title": "README 的反向链接列表" + } + }, + { + "id": "d7007bd9e7fb6503", + "type": "leaf", + "state": { + "type": "outgoing-link", + "state": { + "file": "README.md", + "linksCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-going-out", + "title": "README 的出链列表" + } + }, + { + "id": "2d52fd729103eb97", + "type": "leaf", + "state": { + "type": "tag", + "state": { + "sortOrder": "frequency", + "useHierarchy": true, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-tags", + "title": "标签" + } + }, + { + "id": "7eeba929d8dc0368", + "type": "leaf", + "state": { + "type": "all-properties", + "state": { + "sortOrder": "frequency", + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-archive", + "title": "添加笔记属性" + } + }, + { + "id": "1811ca85d176dcc3", + "type": "leaf", + "state": { + "type": "outline", + "state": { + "file": "README.md", + "followCursor": false, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-list", + "title": "README 的大纲" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300, + "collapsed": true + }, + "left-ribbon": { + "hiddenItems": { + "switcher:打开快速切换": false, + "graph:查看关系图谱": false, + "canvas:新建白板": false, + "daily-notes:打开/创建今天的日记": false, + "templates:插入模板": false, + "command-palette:打开命令面板": false, + "bases:新建数据库": false + } + }, + "active": "1c73e0277fd6237f", + "lastOpenFiles": [ + "py/IMAGE_GPT2_apikey.py", + "py/IMAGE_GeminiNanoBanana2.py", + "image/Pasted image 20260422192905.png", + "image/Pasted image 20260422192828.png", + "image/Pasted image 20260422192822.png", + "Pasted image 20260422192201.png", + "README.md" + ] +} \ No newline at end of file diff --git a/Pasted image 20260422192201.png b/Pasted image 20260422192201.png new file mode 100644 index 0000000..d364fe4 Binary files /dev/null and b/Pasted image 20260422192201.png differ diff --git a/README.md b/README.md index 93e5781..461b5ca 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,28 @@ pip install -r comfyui-PD_comfy-api-node/requirements.txt 2. 登录并创建 API Key 3. 复制 Key 并粘贴到节点中 +## 📝 更新日志 +### v2.0.0 -2026.0422 +- 更新chatgpt image2 节点 +- PD: Nano Banana 2 (ComfyUI Key) 节点更新,增加重试3次机制,放置出黑图,稳定性增加。 +### v2.0.0 -2026.0112 +- 新增 Auth Token 认证方式 +- 新增 GPT Image 节点 +- 优化成本计算 +- 改进错误处理 + +### v1.0.0 +- 初始版本 +- 支持 Gemini 图像生成 +- 支持 API Key 认证 + +### 节点介绍 +#### PD: GPT Image 2 (comfyui_apikey) +- 支持输入多图参考 +- 支持单图输入,GPT image2 节点comfyui 格式。 +![[Pasted image 20260422192905.png]] +![[Pasted image 20260422192828.png]] #### PD: Gemini Image (ComfyUI Key) ![alt text](image.png) @@ -54,23 +75,11 @@ pip install -r comfyui-PD_comfy-api-node/requirements.txt ![Auth Token 节点示例](image/comfyui-node-auth.png) + **优点**: - ✅ 无需申请 API Key - ✅ 使用现有账号权限 -## 📝 更新日志 - -### v2.0.0 -2026.0112 -- 新增 Auth Token 认证方式 -- 新增 GPT Image 节点 -- 优化成本计算 -- 改进错误处理 - -### v1.0.0 -- 初始版本 -- 支持 Gemini 图像生成 -- 支持 API Key 认证 - ## ⚠️ 注意事项 1. **API Key 安全**:请妥善保管 API Key,不要分享给他人 diff --git a/image/Pasted image 20260422192822.png b/image/Pasted image 20260422192822.png new file mode 100644 index 0000000..c0ec6f7 Binary files /dev/null and b/image/Pasted image 20260422192822.png differ diff --git a/image/Pasted image 20260422192828.png b/image/Pasted image 20260422192828.png new file mode 100644 index 0000000..c0ec6f7 Binary files /dev/null and b/image/Pasted image 20260422192828.png differ diff --git a/image/Pasted image 20260422192905.png b/image/Pasted image 20260422192905.png new file mode 100644 index 0000000..29a9fa6 Binary files /dev/null and b/image/Pasted image 20260422192905.png differ diff --git a/py/IMAGE_GPT2_apikey.py b/py/IMAGE_GPT2_apikey.py index f68f49d..83d7035 100644 --- a/py/IMAGE_GPT2_apikey.py +++ b/py/IMAGE_GPT2_apikey.py @@ -8,9 +8,9 @@ import requests import torch from ._image_gpt_common import ( - build_openai_edit_files, - calculate_exact_cost_gpt_image_2_from_usage, GPT_IMAGE_2_PRESET_SIZES, + build_openai_edit_files_from_sources, + calculate_exact_cost_gpt_image_2_from_usage, calculate_token_price_2, decode_response_images, empty_image, @@ -18,7 +18,6 @@ from ._image_gpt_common import ( format_credits_value, format_rmb_range, format_rmb_value, - mask_to_png_bytes, post_with_retries, resolve_gpt_image_2_size, validate_gpt_image_2_size, @@ -91,36 +90,55 @@ class PDOpenAIGPTImage2APIKey: "mask": ("MASK", { "tooltip": "Optional mask for inpainting (white areas will be replaced)" }), + "files": ("GEMINI_INPUT_FILES", { + "tooltip": "Optional packed multi-image files input, for example from PD_comfyplus_image." + }), }, "hidden": { "unique_id": "UNIQUE_ID", } } - RETURN_TYPES = ("IMAGE", "STRING") - RETURN_NAMES = ("image", "info") + RETURN_TYPES = ("IMAGE", "STRING", "STRING") + RETURN_NAMES = ("image", "info", "price") FUNCTION = "generate_image" CATEGORY = "PD_Tools/Image_Generation" - def generate_image(self, api_key, prompt, model, seed, quality, background, size, custom_width, custom_height, num_images, unique_id, image=None, mask=None): + def generate_image( + self, + api_key, + prompt, + model, + seed, + quality, + background, + size, + custom_width, + custom_height, + num_images, + unique_id, + image=None, + mask=None, + files=None, + ): if not api_key or not api_key.strip(): error_msg = "Error: ComfyUI API Key is required" print(f"[IMAGE_GPT2_APIKEY] {error_msg}") - return (empty_image(), error_msg) + return (empty_image(), error_msg, "") try: resolved_size = validate_gpt_image_2_size(size, custom_width, custom_height) except Exception as e: - return (empty_image(), f"Error: {str(e)}") + return (empty_image(), f"Error: {str(e)}", "") api_key = api_key.strip() - is_edit_mode = image is not None + is_edit_mode = image is not None or files is not None base_url = "https://api.comfy.org/proxy/openai/images/edits" if is_edit_mode else "https://api.comfy.org/proxy/openai/images/generations" start_time = time.time() try: if is_edit_mode: - files = build_openai_edit_files(image, mask) + request_files = build_openai_edit_files_from_sources(image=image, mask=mask, input_files=files) data = { "model": model, @@ -137,7 +155,7 @@ class PDOpenAIGPTImage2APIKey: "Connection": "close", "User-Agent": "ComfyUI-PD-Node/2.1" } - response = post_with_retries(base_url, headers, data=data, files=files, timeout=300) + response = post_with_retries(base_url, headers, data=data, files=request_files, timeout=300) else: payload = { "model": model, @@ -160,14 +178,14 @@ class PDOpenAIGPTImage2APIKey: if response.status_code != 200: error_msg = f"API Error {response.status_code}: {response.text[:200]}" print(f"[IMAGE_GPT2_APIKEY] {error_msg}") - return (empty_image(), error_msg) + return (empty_image(), error_msg, "") result = response.json() image_tensors = decode_response_images(result) if not image_tensors: error_msg = "Error: Failed to process output images" print(f"[IMAGE_GPT2_APIKEY] {error_msg}") - return (empty_image(), error_msg) + return (empty_image(), error_msg, "") final_image = torch.cat(image_tensors, dim=0) duration = time.time() - start_time @@ -176,13 +194,13 @@ class PDOpenAIGPTImage2APIKey: size_note = resolve_gpt_image_2_size(size, custom_width, custom_height) exact_cost = calculate_exact_cost_gpt_image_2_from_usage(result) - lines = [ + mode_label = "编辑" if is_edit_mode else "生成" + info_lines = [ f"模型: {model}", - f"显示名称: OpenAI GPT Image 2", - f"模式: {'编辑' if is_edit_mode else '生成'}", - f"状态: 成功", + "状态: 成功", + f"模式: {mode_label}", f"耗时: {duration:.2f} 秒", - f"图片数量: {len(image_tensors)}", + f"输出数量: {len(image_tensors)}", f"尺寸预设: {size}", f"实际尺寸: {size_note}", f"质量: {quality}", @@ -190,33 +208,31 @@ class PDOpenAIGPTImage2APIKey: ] if exact_cost is not None: - lines.extend([ - f"实际价格(USD): ${exact_cost['usd']:.4f}", + price_lines = [ + f"Tokens: 图像:{exact_cost['image_tokens']} | 文本:{exact_cost['text_tokens']} | 输出:{exact_cost['output_tokens']}", + f"实际价格(USD): ${exact_cost['usd']:.6f}", f"实际价格(RMB): {format_rmb_value(exact_cost['usd'])}", f"实际价格(Credits): {format_credits_value(exact_cost['usd'])}", - f"图像输入 Tokens: {exact_cost['image_tokens']}", - f"文本输入 Tokens: {exact_cost['text_tokens']}", - f"输出 Tokens: {exact_cost['output_tokens']}", - ]) + ] else: - lines.extend([ - f"预估价格区间(USD): ~${min_cost:.3f}-${max_cost:.3f}", - f"预估价格区间(RMB): {format_rmb_range(min_cost, max_cost)}", - f"Token 公式估算(USD): ${token_cost:.4f}", - f"Token 公式估算(RMB): {format_rmb_value(token_cost)}", - ]) + price_lines = [ + "Tokens: 未返回精确 usage,以下为估算", + f"估算价格(USD): ~${min_cost:.3f}-${max_cost:.3f}", + f"估算价格(RMB): {format_rmb_range(min_cost, max_cost)}", + f"Token 估算(USD): ${token_cost:.4f}", + f"Token 估算(RMB): {format_rmb_value(token_cost)}", + ] - info_str = "\n".join(lines) - return (final_image, info_str) + return (final_image, "\n".join(info_lines), "\n".join(price_lines)) except requests.exceptions.Timeout: - return (empty_image(), "Error: Request timeout (300s)") + return (empty_image(), "Error: Request timeout (300s)", "") except requests.exceptions.ConnectionError as e: - return (empty_image(), f"Error: Connection failed - {str(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)}") + return (empty_image(), f"Error: {str(e)}", "") NODE_CLASS_MAPPINGS = { diff --git a/py/IMAGE_GPT2_authtoken.py b/py/IMAGE_GPT2_authtoken.py index f19a359..257571e 100644 --- a/py/IMAGE_GPT2_authtoken.py +++ b/py/IMAGE_GPT2_authtoken.py @@ -8,9 +8,9 @@ import requests import torch from ._image_gpt_common import ( - build_openai_edit_files, - calculate_exact_cost_gpt_image_2_from_usage, GPT_IMAGE_2_PRESET_SIZES, + build_openai_edit_files_from_sources, + calculate_exact_cost_gpt_image_2_from_usage, calculate_token_price_2, decode_response_images, empty_image, @@ -18,7 +18,6 @@ from ._image_gpt_common import ( format_credits_value, format_rmb_range, format_rmb_value, - mask_to_png_bytes, post_with_retries, resolve_gpt_image_2_size, validate_gpt_image_2_size, @@ -86,40 +85,57 @@ class PDOpenAIGPTImage2AuthToken: "optional": { "image": ("IMAGE", {"default": None, "tooltip": "Input image for editing"}), "mask": ("MASK", {"default": None, "tooltip": "Mask for inpainting"}), + "files": ("GEMINI_INPUT_FILES", {"default": None, "tooltip": "Packed multi-image files input"}), }, "hidden": { "unique_id": "UNIQUE_ID", } } - RETURN_TYPES = ("IMAGE", "STRING") - RETURN_NAMES = ("image", "info") + RETURN_TYPES = ("IMAGE", "STRING", "STRING") + RETURN_NAMES = ("image", "info", "price") FUNCTION = "generate_image" CATEGORY = "PD_Tools/Image_Generation" - def generate_image(self, auth_token, prompt, model, quality, background, size, custom_width, custom_height, num_images, seed, unique_id, image=None, mask=None): + def generate_image( + self, + auth_token, + prompt, + model, + quality, + background, + size, + custom_width, + custom_height, + num_images, + seed, + unique_id, + image=None, + mask=None, + files=None, + ): if not auth_token or not auth_token.strip(): error_msg = "Error: Auth Token is required" print(f"[IMAGE_GPT2_AUTHTOKEN] {error_msg}") - return (empty_image(), error_msg) + return (empty_image(), error_msg, "") try: resolved_size = validate_gpt_image_2_size(size, custom_width, custom_height) except Exception as e: - return (empty_image(), f"Error: {str(e)}") + return (empty_image(), f"Error: {str(e)}", "") auth_token = auth_token.strip() if auth_token.lower().startswith("bearer "): auth_token = auth_token[7:].strip() base_url = "https://api.comfy.org" - is_edit_mode = image is not None + is_edit_mode = image is not None or files is not None start_time = time.time() try: if is_edit_mode: url = f"{base_url}/proxy/openai/images/edits" - files = build_openai_edit_files(image, mask) + request_files = build_openai_edit_files_from_sources(image=image, mask=mask, input_files=files) data = { "model": model, @@ -135,7 +151,7 @@ class PDOpenAIGPTImage2AuthToken: "Connection": "close", "User-Agent": "ComfyUI-PD-Node/2.1" } - response = post_with_retries(url, headers, data=data, files=files, timeout=300) + response = post_with_retries(url, headers, data=data, files=request_files, timeout=300) else: url = f"{base_url}/proxy/openai/images/generations" payload = { @@ -158,14 +174,14 @@ class PDOpenAIGPTImage2AuthToken: if response.status_code != 200: error_msg = f"API Error {response.status_code}: {response.text[:200]}" print(f"[IMAGE_GPT2_AUTHTOKEN] {error_msg}") - return (empty_image(), error_msg) + return (empty_image(), error_msg, "") result = response.json() image_tensors = decode_response_images(result) if not image_tensors: error_msg = "Error: Failed to process output images" print(f"[IMAGE_GPT2_AUTHTOKEN] {error_msg}") - return (empty_image(), error_msg) + return (empty_image(), error_msg, "") final_image = torch.cat(image_tensors, dim=0) duration = time.time() - start_time @@ -174,13 +190,13 @@ class PDOpenAIGPTImage2AuthToken: size_note = resolve_gpt_image_2_size(size, custom_width, custom_height) exact_cost = calculate_exact_cost_gpt_image_2_from_usage(result) - lines = [ + mode_label = "编辑" if is_edit_mode else "生成" + info_lines = [ f"模型: {model}", - f"显示名称: OpenAI GPT Image 2", - f"模式: {'编辑' if is_edit_mode else '生成'}", - f"状态: 成功", + "状态: 成功", + f"模式: {mode_label}", f"耗时: {duration:.2f} 秒", - f"图片数量: {len(image_tensors)}", + f"输出数量: {len(image_tensors)}", f"尺寸预设: {size}", f"实际尺寸: {size_note}", f"质量: {quality}", @@ -188,33 +204,31 @@ class PDOpenAIGPTImage2AuthToken: ] if exact_cost is not None: - lines.extend([ - f"实际价格(USD): ${exact_cost['usd']:.4f}", + price_lines = [ + f"Tokens: 图像:{exact_cost['image_tokens']} | 文本:{exact_cost['text_tokens']} | 输出:{exact_cost['output_tokens']}", + f"实际价格(USD): ${exact_cost['usd']:.6f}", f"实际价格(RMB): {format_rmb_value(exact_cost['usd'])}", f"实际价格(Credits): {format_credits_value(exact_cost['usd'])}", - f"图像输入 Tokens: {exact_cost['image_tokens']}", - f"文本输入 Tokens: {exact_cost['text_tokens']}", - f"输出 Tokens: {exact_cost['output_tokens']}", - ]) + ] else: - lines.extend([ - f"预估价格区间(USD): ~${min_cost:.3f}-${max_cost:.3f}", - f"预估价格区间(RMB): {format_rmb_range(min_cost, max_cost)}", - f"Token 公式估算(USD): ${token_cost:.4f}", - f"Token 公式估算(RMB): {format_rmb_value(token_cost)}", - ]) + price_lines = [ + "Tokens: 未返回精确 usage,以下为估算", + f"估算价格(USD): ~${min_cost:.3f}-${max_cost:.3f}", + f"估算价格(RMB): {format_rmb_range(min_cost, max_cost)}", + f"Token 估算(USD): ${token_cost:.4f}", + f"Token 估算(RMB): {format_rmb_value(token_cost)}", + ] - info_str = "\n".join(lines) - return (final_image, info_str) + return (final_image, "\n".join(info_lines), "\n".join(price_lines)) except requests.exceptions.Timeout: - return (empty_image(), "Error: Request timeout (300s)") + return (empty_image(), "Error: Request timeout (300s)", "") except requests.exceptions.ConnectionError as e: - return (empty_image(), f"Error: Connection failed - {str(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)}") + return (empty_image(), f"Error: {str(e)}", "") NODE_CLASS_MAPPINGS = { diff --git a/py/IMAGE_GeminiNanoBanana2.py b/py/IMAGE_GeminiNanoBanana2.py index 5d82445..0faec7f 100644 --- a/py/IMAGE_GeminiNanoBanana2.py +++ b/py/IMAGE_GeminiNanoBanana2.py @@ -1,45 +1,43 @@ """ PD: Nano Banana 2 (ComfyUI Key) -基于 Google Gemini 3.1 Flash Image 模型的图像生成/编辑节点 -支持进度反馈、Token 成本计算、及其独立输出接口 +Google Gemini 3.1 Flash Image node with limited retries and Chinese pricing output. """ -import torch import base64 -import json -import time -import uuid -import requests -import numpy as np import io +import time + +import numpy as np +import requests +import torch from PIL import Image -from typing import Optional, List from server import PromptServer import comfy.utils -# 默认系统提示词 +from ._image_gpt_common import format_credits_value, format_rmb_value + + GEMINI_IMAGE_SYS_PROMPT = ( "You are an expert image-generation engine. You must ALWAYS produce an image.\n" - "Interpret all user input—regardless of " - "format, intent, or abstraction—as literal visual directives for image composition.\n" + "Interpret all user input regardless of format, intent, or abstraction as literal visual directives for image composition.\n" "If a prompt is conversational or lacks specific visual details, " "you must creatively invent a concrete visual scenario that depicts the concept.\n" "Prioritize generating the visual representation above any text, formatting, or conversational requests." ) + def tensor_to_base64(tensor): - """将 Tensor 转换为 base64 字符串""" if tensor.ndim == 4: tensor = tensor[0] - - array = np.clip(255. * tensor.cpu().numpy(), 0, 255).astype(np.uint8) + + array = np.clip(255.0 * tensor.cpu().numpy(), 0, 255).astype(np.uint8) image = Image.fromarray(array) buffered = io.BytesIO() image.save(buffered, format="PNG") return base64.b64encode(buffered.getvalue()).decode("utf-8") + def bytes_to_tensor(data): - """将图片字节数据转换为 Tensor""" try: img = Image.open(io.BytesIO(data)).convert("RGB") image_np = np.array(img).astype(np.float32) / 255.0 @@ -48,14 +46,15 @@ def bytes_to_tensor(data): print(f"[PD_NB2] Image decode error: {e}") return torch.zeros((1, 512, 512, 3)) + class PDGeminiNanoBanana2: @classmethod def INPUT_TYPES(cls): return { "required": { "comfy_api_key": ("STRING", { - "default": "", - "multiline": False, + "default": "", + "multiline": False, "placeholder": "Paste your ComfyUI API Key here" }), "prompt": ("STRING", { @@ -68,7 +67,7 @@ class PDGeminiNanoBanana2: "seed": ("INT", { "default": 42, "min": 0, - "max": 0x7FFFFFFF, + "max": 0x7FFFFFFF, }), "aspect_ratio": (["auto", "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"], { "default": "auto", @@ -84,8 +83,8 @@ class PDGeminiNanoBanana2: }), }, "optional": { - "images": ("IMAGE", ), - "files": ("GEMINI_INPUT_FILES", ), + "images": ("IMAGE",), + "files": ("GEMINI_INPUT_FILES",), "system_prompt": ("STRING", { "multiline": True, "default": GEMINI_IMAGE_SYS_PROMPT, @@ -95,28 +94,39 @@ class PDGeminiNanoBanana2: "unique_id": "UNIQUE_ID", } } - + 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, seed, aspect_ratio, resolution, response_modalities, thinking_level, unique_id, images=None, files=None, system_prompt=""): + def generate_image( + self, + comfy_api_key, + prompt, + model, + seed, + aspect_ratio, + resolution, + response_modalities, + thinking_level, + unique_id, + images=None, + files=None, + system_prompt="", + ): pbar = comfy.utils.ProgressBar(100) pbar.update(10) - + if not comfy_api_key or not comfy_api_key.strip(): - return (torch.zeros((1, 512, 512, 3)), "Error: Missing ComfyUI API Key", "$0.00") - + return (torch.zeros((1, 512, 512, 3)), "错误: 缺少 ComfyUI API Key", "¥0.00") + api_key = comfy_api_key.strip() api_model = "gemini-3.1-flash-image-preview" + self._send_status(unique_id, "准备请求...") - # 发送进度文字到前端 - self._send_status(unique_id, "Preparing tokens...") - - # 构建请求内容 parts = [{"text": prompt}] - + if images is not None: for i in range(min(images.shape[0], 14)): b64_str = tensor_to_base64(images[i]) @@ -126,8 +136,7 @@ class PDGeminiNanoBanana2: "data": b64_str } }) - - # Files 输入处理 + if files: for file_part in files: if hasattr(file_part, "model_dump"): @@ -135,8 +144,6 @@ class PDGeminiNanoBanana2: else: parts.append(file_part) - # 核心参数修正:匹配官方 Pydantic 模型的 camelCase 结构 - # 且不发送 seed (官方 GeminiNanoBanana2.execute 并没有发送 seed 到 API) image_config = { "imageSize": resolution, "imageOutputOptions": {"mimeType": "image/png"} @@ -157,7 +164,7 @@ class PDGeminiNanoBanana2: }], "generationConfig": generation_config } - + if system_prompt and system_prompt.strip(): payload["systemInstruction"] = { "parts": [{"text": system_prompt.strip()}] @@ -167,34 +174,31 @@ class PDGeminiNanoBanana2: "X-API-KEY": api_key, "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "ComfyUI-PD-Node/2.0" + "User-Agent": "ComfyUI-PD-Node/2.1", } - + base_url = f"https://api.comfy.org/proxy/vertexai/gemini/{api_model}" - - self._send_status(unique_id, "Sending request to Google...") + + self._send_status(unique_id, "正在发送请求...") pbar.update(30) - + start_time = time.time() try: - # 打印发送的 Payload 调试 (可选) - # print(f"[PD_NB2] Payload: {json.dumps(payload)}") - - response = requests.post(base_url, headers=headers, json=payload, timeout=120) + response = self._post_with_retries(base_url, headers, payload, unique_id) pbar.update(80) - + if response.status_code != 200: error_body = response.text error_msg = f"API Error {response.status_code}: {error_body}" - self._send_status(unique_id, "Error occurred") + self._send_status(unique_id, "请求失败") return (torch.zeros((1, 512, 512, 3)), error_msg, "N/A") - + result = response.json() image_tensors = [] text_parts = [] - - self._send_status(unique_id, "Processing response...") - + + self._send_status(unique_id, "正在处理响应...") + candidates = result.get("candidates", []) for cand in candidates: content = cand.get("content", {}) @@ -206,49 +210,67 @@ class PDGeminiNanoBanana2: if data_b64: raw_data = base64.b64decode(data_b64) image_tensors.append(bytes_to_tensor(raw_data)) - + if "text" in part and part["text"]: text_parts.append(part["text"]) if not image_tensors: - return (torch.zeros((1, 512, 512, 3)), "Error: No images found in response", "N/A") - + return (torch.zeros((1, 512, 512, 3)), "错误: 响应中没有图片", "N/A") + final_image = torch.cat(image_tensors, dim=0) - final_text = "\n".join(text_parts) if text_parts else "No description" - - # 计算成本 + final_text = "\n".join(text_parts) if text_parts else "无文字描述" cost_info = self._calculate_cost(result, api_model, time.time() - start_time) - + pbar.update(100) - self._send_status(unique_id, "Success") - + self._send_status(unique_id, "成功") + return (final_image, final_text, cost_info) except Exception as e: - self._send_status(unique_id, "Exception caught") + self._send_status(unique_id, "发生异常") return (torch.zeros((1, 512, 512, 3)), f"Error: {str(e)}", "N/A") def _send_status(self, node_id, text): - """发送状态文字到节点下方显示""" try: PromptServer.instance.send_sync("progress_text", {"text": text, "node": node_id}) - except: + except Exception: pass + def _post_with_retries(self, url, headers, payload, unique_id, retries=3, timeout=90): + last_error = None + for attempt in range(retries): + try: + if attempt > 0: + self._send_status(unique_id, f"网络波动,正在重试 {attempt + 1}/{retries}...") + return requests.post( + url, + headers={**headers, "Connection": "close"}, + json=payload, + timeout=timeout, + ) + except ( + requests.exceptions.SSLError, + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + ) as exc: + last_error = exc + if attempt == retries - 1: + raise + time.sleep(min(4, attempt + 1)) + raise last_error + def _calculate_cost(self, result, model, duration): - """基于 API 响应计算成本""" usage = result.get("usageMetadata", {}) prompt_tokens = usage.get("promptTokenCount", 0) candidates_tokens = usage.get("candidatesTokenCount", 0) details = usage.get("candidatesTokensDetails", []) thoughts_tokens = usage.get("thoughtsTokenCount", 0) - - # 价格配置 (3.1 Flash Image Preview) + prices = {"input": 0.5, "output_text": 3.0, "output_image": 60.0} - + text_tokens = thoughts_tokens img_tokens = 0 - + for d in details: modality = d.get("modality") count = d.get("tokenCount", 0) @@ -256,24 +278,26 @@ class PDGeminiNanoBanana2: img_tokens += count else: text_tokens += count - + if not details and candidates_tokens > 0: text_tokens += candidates_tokens - + input_cost = (prompt_tokens / 1_000_000) * prices["input"] text_cost = (text_tokens / 1_000_000) * prices["output_text"] img_cost = (img_tokens / 1_000_000) * prices["output_image"] total_cost = input_cost + text_cost + img_cost info = [ - f"Model: {model}", - f"Status: Success", - f"Time: {duration:.2f}s", - f"Tokens: In:{prompt_tokens:,} | Out Text:{text_tokens:,} | Out Img:{img_tokens:,}", - f"Est. Cost: ${total_cost:.6f}" + f"模型: {model}", + f"状态: 成功", + f"耗时: {duration:.2f} 秒", + f"Tokens: 输入:{prompt_tokens:,} | 输出文本:{text_tokens:,} | 输出图像:{img_tokens:,}", + f"实际价格(USD): ${total_cost:.6f}", + f"实际价格(RMB): {format_rmb_value(total_cost)}", + f"实际价格(Credits): {format_credits_value(total_cost)}", ] return "\n".join(info) -# 注册 + NODE_CLASS_MAPPINGS = {"PDGeminiNanoBanana2": PDGeminiNanoBanana2} NODE_DISPLAY_NAME_MAPPINGS = {"PDGeminiNanoBanana2": "PD: Nano Banana 2 (ComfyUI Key)"} diff --git a/py/_image_gpt_common.py b/py/_image_gpt_common.py index b40ddd6..f7ff34b 100644 --- a/py/_image_gpt_common.py +++ b/py/_image_gpt_common.py @@ -110,6 +110,68 @@ def build_openai_edit_files(image, mask=None): return files +def append_gemini_input_files_to_openai_files(files, input_files): + if not input_files: + return files + + image_index = sum(1 for field_name, _ in files if field_name in ("image", "image[]")) + for file_part in input_files: + if not isinstance(file_part, dict): + continue + + inline_data = file_part.get("inlineData") or file_part.get("inline_data") + if not isinstance(inline_data, dict): + continue + + 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 + + data_b64 = inline_data.get("data") + if not data_b64: + continue + + image_bytes = base64.b64decode(data_b64) + field_name = "image" if image_index == 0 and len(files) == 0 else "image[]" + extension = mime_type.split("/")[-1] or "png" + files.append((field_name, (f"image_{image_index}.{extension}", image_bytes, mime_type))) + image_index += 1 + + return files + + +def build_openai_edit_files_from_sources(image=None, mask=None, input_files=None): + files = [] + + if image is not None: + files.extend(build_openai_edit_files(image, mask)) + elif mask is not None: + raise ValueError("Mask editing requires an input image") + + append_gemini_input_files_to_openai_files(files, input_files) + + image_count = sum(1 for field_name, _ in files if field_name in ("image", "image[]")) + if image_count == 0: + raise ValueError("No valid images were provided") + + if image_count > 1: + normalized_files = [] + for field_name, file_value in files: + if field_name in ("image", "image[]"): + normalized_files.append(("image[]", file_value)) + else: + normalized_files.append((field_name, file_value)) + files = normalized_files + + if mask is not None and image is None: + raise ValueError("Mask editing does not support files-only input") + + if mask is not None and image_count != 1: + raise ValueError("Mask editing only supports a single input image") + + return files + + def post_with_retries(url, headers, *, json_payload=None, data=None, files=None, timeout=300, retries=3, retry_delay=1.0): last_error = None for attempt in range(retries):