From b48b2a62659bbf68c32bc0459af66c625f2efe25 Mon Sep 17 00:00:00 2001 From: AIYang007 Date: Tue, 11 Nov 2025 01:53:55 +0800 Subject: [PATCH] Add files via upload --- README.md | 497 ++++---- __init__.py | 6 + example.json | 607 +++++++++ nanobanana_comfy_node.py | 2503 ++++++++++++++++++++++++++++++++++++++ requirements.txt | 4 + 5 files changed, 3366 insertions(+), 251 deletions(-) create mode 100644 __init__.py create mode 100644 example.json create mode 100644 nanobanana_comfy_node.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index 09a1b3d..d624d37 100644 --- a/README.md +++ b/README.md @@ -1,251 +1,246 @@ -# AIYang_TripleAPI -comfyui nodes Triple API Generate (NanoBanana + Doubao + HiDream) - - -# ComfyUI 三合一 AI 图像生成节点 - -这个项目为 ComfyUI 提供了两个强大的自定义节点,集成了三个主流的中文AI图像生成平台:**即梦AI**、**火山引擎豆包**和**NanoBanana**。支持文生图和图生图功能。 - -## 功能特点 - -- 🎨 **三平台集成**:同时支持即梦AI、火山引擎豆包、NanoBanana三个平台 -- 🔄 **双模式支持**:文生图 (Text-to-Image) 和 图生图 (Image-to-Image) -- ⚡ **并行生成**:可同时调用多个平台生成图片,提高效率 -- 🎯 **智能轮询**:自动等待图片生成完成,无需手动查询 -- 🛡️ **容错处理**:单个平台失败不影响其他平台正常工作 -- 📊 **详细状态**:实时显示每个平台的生成状态和错误信息 -- 🔗 **URL输出**:提供每个API生成的图片URL,便于后续处理和分享 -- 🔄 **重试机制**:支持API调用失败时自动重试,提高成功率 -- ⏱️ **超时控制**:可配置请求和轮询超时时间,避免长时间等待 - -## 系统要求 - -- Python 3.8 或更高版本 -- ComfyUI 已安装并运行 -- 至少 4GB 可用内存 - -## 依赖说明 - -本项目需要以下 Python 包: - -- **Pillow** (>=9.0.0): 用于图像处理 -- **numpy** (>=1.21.0): 用于数值计算和数组操作 -- **torch** (>=1.12.0): PyTorch 深度学习框架(ComfyUI 通常已包含) - -> **注意**: ComfyUI 通常已经包含了 `torch` 和 `numpy`,如果您的 ComfyUI 环境已配置好,可能只需要安装 `Pillow`。 - -## 安装方法 - -### 方法一:使用 Git 克隆(推荐) - -1. 将此仓库克隆到 ComfyUI 的 `custom_nodes` 目录: -```bash -cd ComfyUI/custom_nodes -git clone https://github.com/AlexXia007/AIYang_TripleAPI.git -``` - -2. 进入项目目录并安装依赖: -```bash -cd AIYang_comfyui_tripapi -pip install -r requirements.txt -``` - -3. 重启 ComfyUI - -### 方法二:手动安装 - -1. 下载项目 ZIP 文件并解压到 `ComfyUI/custom_nodes` 目录 - -2. 在项目目录下打开终端,执行: -```bash -pip install -r requirements.txt -``` - -3. 重启 ComfyUI - -### 验证安装 - -重启 ComfyUI 后,在节点菜单中应该能看到: -- `TripleAPIGenerate` (三合一文生图节点) -- `TripleAPIImg2Img` (三合一图生图节点) - -如果节点未出现,请检查: -- 依赖是否安装成功 -- ComfyUI 控制台是否有错误信息 -- 项目是否在正确的 `custom_nodes` 目录下 - -## 节点说明 - -### 1. TripleAPIGenerate (三合一文生图节点) - -用于根据文字描述生成图片。 - -#### 输入参数详解 - -**基础参数** -- **`prompt`** (STRING): - - **描述**: 图片生成的主要提示词 - - **填写**: 详细描述你想要生成的图片内容,如"一只可爱的小猫坐在花园里,阳光明媚,高清摄影风格" - - **建议**: 越详细越好,包含风格、场景、光线、质量等描述 - -- **`negative_prompt`** (STRING): - - **描述**: 负面提示词,指定不想要的元素 - - **填写**: 如"模糊,低质量,变形,多余的手指,nsfw" - - **可选**: 可以留空,但建议填写以提高图片质量 - -- **`image_urls_input`** (STRING): - - **描述**: 参考图片的URL地址(多个URL用换行分隔) - - **填写**: 如"https://example.com/image1.jpg" - - **用途**: 提供风格参考或构图参考 - - **可选**: 纯文生图时可留空 - -**平台开关** -- **`enable_hidream`** (BOOLEAN): 是否启用即梦AI (默认: True) -- **`enable_volcengine`** (BOOLEAN): 是否启用火山引擎豆包 (默认: True) -- **`enable_nanobanana`** (BOOLEAN): 是否启用NanoBanana (默认: True) - -**API配置** -- **`hidream_req_key`** (STRING): 即梦AI的API密钥 -- **`volcengine_api_key`** (STRING): 火山引擎的API密钥 -- **`nanobanana_api_key`** (STRING): NanoBanana的API密钥 - -**生成参数** -- **`image_width`** (INT): 生成图片宽度 (默认: 1024, 范围: 256-2048) -- **`image_height`** (INT): 生成图片高度 (默认: 1024, 范围: 256-2048) -- **`num_images`** (INT): 生成图片数量 (默认: 1, 范围: 1-4) - -**高级设置** -- **`request_timeout`** (INT): 单次请求超时时间,秒 (默认: 240) -- **`poll_interval`** (INT): 轮询间隔时间,秒 (默认: 3) -- **`poll_timeout`** (INT): 总轮询超时时间,秒 (默认: 240) -- **`max_retries`** (INT): 最大重试次数 (默认: 3, 范围: 1-5) -- **`white_image_width`** (INT): 失败时白色占位图宽度 (默认: 512) -- **`white_image_height`** (INT): 失败时白色占位图高度 (默认: 512) - -**特定平台参数** -- **`nanobanana_model`** (STRING): NanoBanana使用的模型 (默认: "flux") -- **`nanobanana_style`** (STRING): NanoBanana的艺术风格 (默认: "默认") - -#### 输出说明 - -**图片输出** -- **`nanobanana_image`** (IMAGE): NanoBanana生成的图片张量 -- **`doubao_image`** (IMAGE): 豆包生成的图片张量 -- **`hidream_image`** (IMAGE): 即梦AI生成的图片张量 - -**任务信息** -- **`nanobanana_task_id`** (STRING): NanoBanana任务ID -- **`hidream_task_id`** (STRING): 即梦AI任务ID - -**API响应** -- **`nanobanana_response`** (STRING): NanoBanana的完整API响应 (JSON格式) -- **`doubao_response`** (STRING): 豆包的完整API响应 (JSON格式) -- **`hidream_response`** (STRING): 即梦AI的完整API响应 (JSON格式) - -**状态信息** -- **`status_info`** (STRING): 详细的状态信息,包含每个平台的执行结果 - -**图片URL输出** ⭐ **新功能** -- **`nanobanana_image_url`** (STRING): NanoBanana生成的图片URL -- **`doubao_image_url`** (STRING): 豆包生成的图片URL -- **`hidream_image_url`** (STRING): 即梦AI生成的图片URL - -**URL值说明**: -- **成功时**: 返回实际的图片URL地址 -- **API错误时**: 返回 `"api_error"` -- **API禁用时**: 返回 `"api_disabled"` - -### 2. TripleAPIImg2Img (三合一图生图节点) - -用于基于输入图片进行图像转换和编辑。 - -#### 输入参数详解 - -**图片输入** -- **`input_image`** (IMAGE): - - **描述**: 输入的基础图片张量 - - **连接**: 从图片加载节点或其他图像处理节点连接 - - **必填**: 图生图必须提供基础图片 - -**基础参数** -- **`prompt`** (STRING): - - **描述**: 对图片进行修改的描述 - - **填写**: 如"将这个人的头发改为金色,背景改为海滩" - - **建议**: 描述你想要的变化,而不是重复描述原图内容 - -- **`negative_prompt`** (STRING): - - **描述**: 不想要的效果 - - **填写**: 如"模糊,扭曲,多余的肢体" - -**其他参数**: 与文生图节点相同 - -#### 输出说明 - -输出与文生图节点完全相同。 - -## URL输出功能详解 - -### 什么是URL输出? - -URL输出功能为每个API生成的图片提供直接的URL链接,让您可以: - -- **直接访问图片**:无需下载到本地即可查看生成的图片 -- **分享图片**:将URL分享给他人查看 -- **集成到其他系统**:将URL传递给其他应用或服务 -- **调试和监控**:快速检查API是否成功生成了图片 - -### URL值含义 - -| 值 | 含义 | 说明 | -|---|---|---| -| `https://example.com/image.jpg` | 成功 | 返回实际的图片URL地址 | -| `api_error` | API错误 | API调用失败或响应格式错误 | -| `api_disabled` | API禁用 | 该API被禁用或未提供密钥 | - -### 使用场景 - -1. **质量对比**:同时获取三个平台的图片URL,对比生成效果 -2. **自动化流程**:将URL传递给后续的图片处理节点 -3. **错误诊断**:通过URL值快速判断哪个API出现问题 -4. **批量处理**:收集多个生成的图片URL进行批量操作 - -## 使用示例 - -### 文生图工作流 - -1. 添加 `TripleAPIGenerate` 节点 -2. 填写提示词: "一只橘色的小猫在阳光下睡觉,温暖的午后,高清摄影" -3. 填写负面提示词: "模糊,低质量,变形" -4. 配置API密钥 -5. 设置图片尺寸 (如 1024x1024) -6. 运行生成 - -**获取图片URL**: -- 连接 `nanobanana_image_url` 输出到文本显示节点,查看NanoBanana生成的图片URL -- 连接 `doubao_image_url` 输出到文本显示节点,查看豆包生成的图片URL -- 连接 `hidream_image_url` 输出到文本显示节点,查看即梦AI生成的图片URL - -### 图生图工作流 - -1. 添加图片加载节点,加载基础图片 -2. 添加 `TripleAPIImg2Img` 节点 -3. 将图片加载节点的输出连接到 `input_image` -4. 填写修改提示词: "将背景改为星空,保持人物不变" -5. 配置其他参数 -6. 运行生成 - -### API密钥获取 - -**即梦AI** -1. 访问 [即梦AI官网](https://jimeng.jianying.com/) -2. 注册账号并获取API密钥 - -**火山引擎豆包** -1. 访问 [火山引擎控制台](https://console.volcengine.com/) -2. 开通豆包大模型服务 -3. 创建API密钥 - -**NanoBanana** -1. 访问 [NanoBanana官网](https://nanobanana.ai/) -2. 注册账号并获取API访问权限 - +# ComfyUI 三合一 AI 图像生成节点 + +这个项目为 ComfyUI 提供了两个强大的自定义节点,集成了三个主流的中文AI图像生成平台:**即梦AI**、**火山引擎豆包**和**NanoBanana**。支持文生图和图生图功能。 + +## 功能特点 + +- 🎨 **三平台集成**:同时支持即梦AI、火山引擎豆包、NanoBanana三个平台 +- 🔄 **双模式支持**:文生图 (Text-to-Image) 和 图生图 (Image-to-Image) +- ⚡ **并行生成**:可同时调用多个平台生成图片,提高效率 +- 🎯 **智能轮询**:自动等待图片生成完成,无需手动查询 +- 🛡️ **容错处理**:单个平台失败不影响其他平台正常工作 +- 📊 **详细状态**:实时显示每个平台的生成状态和错误信息 +- 🔗 **URL输出**:提供每个API生成的图片URL,便于后续处理和分享 +- 🔄 **重试机制**:支持API调用失败时自动重试,提高成功率 +- ⏱️ **超时控制**:可配置请求和轮询超时时间,避免长时间等待 + +## 系统要求 + +- Python 3.8 或更高版本 +- ComfyUI 已安装并运行 +- 至少 4GB 可用内存 + +## 依赖说明 + +本项目需要以下 Python 包: + +- **Pillow** (>=9.0.0): 用于图像处理 +- **numpy** (>=1.21.0): 用于数值计算和数组操作 +- **torch** (>=1.12.0): PyTorch 深度学习框架(ComfyUI 通常已包含) + +> **注意**: ComfyUI 通常已经包含了 `torch` 和 `numpy`,如果您的 ComfyUI 环境已配置好,可能只需要安装 `Pillow`。 + +## 安装方法 + +### 方法一:使用 Git 克隆(推荐) + +1. 将此仓库克隆到 ComfyUI 的 `custom_nodes` 目录: +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/AlexXia007/AIYang_TripleAPI.git +``` + +2. 进入项目目录并安装依赖: +```bash +cd AIYang_comfyui_tripapi +pip install -r requirements.txt +``` + +3. 重启 ComfyUI + +### 方法二:手动安装 + +1. 下载项目 ZIP 文件并解压到 `ComfyUI/custom_nodes` 目录 + +2. 在项目目录下打开终端,执行: +```bash +pip install -r requirements.txt +``` + +3. 重启 ComfyUI + +### 验证安装 + +重启 ComfyUI 后,在节点菜单中应该能看到: +- `TripleAPIGenerate` (三合一文生图节点) +- `TripleAPIImg2Img` (三合一图生图节点) + +如果节点未出现,请检查: +- 依赖是否安装成功 +- ComfyUI 控制台是否有错误信息 +- 项目是否在正确的 `custom_nodes` 目录下 + +## 节点说明 + +### 1. TripleAPIGenerate (三合一文生图节点) + +用于根据文字描述生成图片。 + +#### 输入参数详解 + +**基础参数** +- **`prompt`** (STRING): + - **描述**: 图片生成的主要提示词 + - **填写**: 详细描述你想要生成的图片内容,如"一只可爱的小猫坐在花园里,阳光明媚,高清摄影风格" + - **建议**: 越详细越好,包含风格、场景、光线、质量等描述 + +- **`negative_prompt`** (STRING): + - **描述**: 负面提示词,指定不想要的元素 + - **填写**: 如"模糊,低质量,变形,多余的手指,nsfw" + - **可选**: 可以留空,但建议填写以提高图片质量 + +- **`image_urls_input`** (STRING): + - **描述**: 参考图片的URL地址(多个URL用换行分隔) + - **填写**: 如"https://example.com/image1.jpg" + - **用途**: 提供风格参考或构图参考 + - **可选**: 纯文生图时可留空 + +**平台开关** +- **`enable_hidream`** (BOOLEAN): 是否启用即梦AI (默认: True) +- **`enable_volcengine`** (BOOLEAN): 是否启用火山引擎豆包 (默认: True) +- **`enable_nanobanana`** (BOOLEAN): 是否启用NanoBanana (默认: True) + +**API配置** +- **`hidream_req_key`** (STRING): 即梦AI的API密钥 +- **`volcengine_api_key`** (STRING): 火山引擎的API密钥 +- **`nanobanana_api_key`** (STRING): NanoBanana的API密钥 + +**生成参数** +- **`image_width`** (INT): 生成图片宽度 (默认: 1024, 范围: 256-2048) +- **`image_height`** (INT): 生成图片高度 (默认: 1024, 范围: 256-2048) +- **`num_images`** (INT): 生成图片数量 (默认: 1, 范围: 1-4) + +**高级设置** +- **`request_timeout`** (INT): 单次请求超时时间,秒 (默认: 240) +- **`poll_interval`** (INT): 轮询间隔时间,秒 (默认: 3) +- **`poll_timeout`** (INT): 总轮询超时时间,秒 (默认: 240) +- **`max_retries`** (INT): 最大重试次数 (默认: 3, 范围: 1-5) +- **`white_image_width`** (INT): 失败时白色占位图宽度 (默认: 512) +- **`white_image_height`** (INT): 失败时白色占位图高度 (默认: 512) + +**特定平台参数** +- **`nanobanana_model`** (STRING): NanoBanana使用的模型 (默认: "flux") +- **`nanobanana_style`** (STRING): NanoBanana的艺术风格 (默认: "默认") + +#### 输出说明 + +**图片输出** +- **`nanobanana_image`** (IMAGE): NanoBanana生成的图片张量 +- **`doubao_image`** (IMAGE): 豆包生成的图片张量 +- **`hidream_image`** (IMAGE): 即梦AI生成的图片张量 + +**任务信息** +- **`nanobanana_task_id`** (STRING): NanoBanana任务ID +- **`hidream_task_id`** (STRING): 即梦AI任务ID + +**API响应** +- **`nanobanana_response`** (STRING): NanoBanana的完整API响应 (JSON格式) +- **`doubao_response`** (STRING): 豆包的完整API响应 (JSON格式) +- **`hidream_response`** (STRING): 即梦AI的完整API响应 (JSON格式) + +**状态信息** +- **`status_info`** (STRING): 详细的状态信息,包含每个平台的执行结果 + +**图片URL输出** ⭐ **新功能** +- **`nanobanana_image_url`** (STRING): NanoBanana生成的图片URL +- **`doubao_image_url`** (STRING): 豆包生成的图片URL +- **`hidream_image_url`** (STRING): 即梦AI生成的图片URL + +**URL值说明**: +- **成功时**: 返回实际的图片URL地址 +- **API错误时**: 返回 `"api_error"` +- **API禁用时**: 返回 `"api_disabled"` + +### 2. TripleAPIImg2Img (三合一图生图节点) + +用于基于输入图片进行图像转换和编辑。 + +#### 输入参数详解 + +**图片输入** +- **`input_image`** (IMAGE): + - **描述**: 输入的基础图片张量 + - **连接**: 从图片加载节点或其他图像处理节点连接 + - **必填**: 图生图必须提供基础图片 + +**基础参数** +- **`prompt`** (STRING): + - **描述**: 对图片进行修改的描述 + - **填写**: 如"将这个人的头发改为金色,背景改为海滩" + - **建议**: 描述你想要的变化,而不是重复描述原图内容 + +- **`negative_prompt`** (STRING): + - **描述**: 不想要的效果 + - **填写**: 如"模糊,扭曲,多余的肢体" + +**其他参数**: 与文生图节点相同 + +#### 输出说明 + +输出与文生图节点完全相同。 + +## URL输出功能详解 + +### 什么是URL输出? + +URL输出功能为每个API生成的图片提供直接的URL链接,让您可以: + +- **直接访问图片**:无需下载到本地即可查看生成的图片 +- **分享图片**:将URL分享给他人查看 +- **集成到其他系统**:将URL传递给其他应用或服务 +- **调试和监控**:快速检查API是否成功生成了图片 + +### URL值含义 + +| 值 | 含义 | 说明 | +|---|---|---| +| `https://example.com/image.jpg` | 成功 | 返回实际的图片URL地址 | +| `api_error` | API错误 | API调用失败或响应格式错误 | +| `api_disabled` | API禁用 | 该API被禁用或未提供密钥 | + +### 使用场景 + +1. **质量对比**:同时获取三个平台的图片URL,对比生成效果 +2. **自动化流程**:将URL传递给后续的图片处理节点 +3. **错误诊断**:通过URL值快速判断哪个API出现问题 +4. **批量处理**:收集多个生成的图片URL进行批量操作 + +## 使用示例 + +### 文生图工作流 + +1. 添加 `TripleAPIGenerate` 节点 +2. 填写提示词: "一只橘色的小猫在阳光下睡觉,温暖的午后,高清摄影" +3. 填写负面提示词: "模糊,低质量,变形" +4. 配置API密钥 +5. 设置图片尺寸 (如 1024x1024) +6. 运行生成 + +**获取图片URL**: +- 连接 `nanobanana_image_url` 输出到文本显示节点,查看NanoBanana生成的图片URL +- 连接 `doubao_image_url` 输出到文本显示节点,查看豆包生成的图片URL +- 连接 `hidream_image_url` 输出到文本显示节点,查看即梦AI生成的图片URL + +### 图生图工作流 + +1. 添加图片加载节点,加载基础图片 +2. 添加 `TripleAPIImg2Img` 节点 +3. 将图片加载节点的输出连接到 `input_image` +4. 填写修改提示词: "将背景改为星空,保持人物不变" +5. 配置其他参数 +6. 运行生成 + +### API密钥获取 + +**即梦AI** +1. 访问 [即梦AI官网](https://jimeng.jianying.com/) +2. 注册账号并获取API密钥 + +**火山引擎豆包** +1. 访问 [火山引擎控制台](https://console.volcengine.com/) +2. 开通豆包大模型服务 +3. 创建API密钥 + +**NanoBanana** +1. 访问 [NanoBanana官网](https://nanobanana.ai/) +2. 注册账号并获取API访问权限 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..4b0d386 --- /dev/null +++ b/__init__.py @@ -0,0 +1,6 @@ +from .nanobanana_comfy_node import ( + NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS, +) + + diff --git a/example.json b/example.json new file mode 100644 index 0000000..ea45a78 --- /dev/null +++ b/example.json @@ -0,0 +1,607 @@ +{ + "id": "4b0a7210-6a3c-4f83-9ce2-9a4da25fd469", + "revision": 0, + "last_node_id": 464, + "last_link_id": 708, + "nodes": [ + { + "id": 454, + "type": "ShowText|pysssss", + "pos": [ + -6000, + -930 + ], + "size": [ + 740, + 320 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 695 + } + ], + "outputs": [ + { + "label": "字符串", + "name": "STRING", + "shape": 6, + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-custom-scripts", + "ver": "aac13aa7ce35b07d43633c3bbe654a38c00d74f5", + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "{\"error\": \"API已禁用或未提供密钥\"}" + ] + }, + { + "id": 456, + "type": "ShowText|pysssss", + "pos": [ + -5980, + 200 + ], + "size": [ + 730, + 130 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 701 + } + ], + "outputs": [ + { + "label": "字符串", + "name": "STRING", + "shape": 6, + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-custom-scripts", + "ver": "aac13aa7ce35b07d43633c3bbe654a38c00d74f5", + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "{\"code\": 10000, \"data\": {\"aigc_meta_tagged\": false, \"binary_data_base64\": null, \"image_urls\": [\"https://p26-aiop-sign.byteimg.com/tos-cn-i-vuqhorh59i/20251111010955E6AF8158BBCE466E488A-1585-0~tplv-vuqhorh59i-image-v1.image?rk3s=7f9e702d&x-expires=1762880995&x-signature=SWRBW%2BmzCDbPb5CnqiLstZfi9Ic%3D\"], \"status\": \"done\", \"video_url\": \"\"}, \"message\": \"Success\", \"request_id\": \"20251111010955E6AF8158BBCE466E488A\", \"status\": 10000, \"time_elapsed\": \"224.396757ms\"}" + ] + }, + { + "id": 459, + "type": "ShowText|pysssss", + "pos": [ + -5960, + 400 + ], + "size": [ + 290, + 170 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 700 + } + ], + "outputs": [ + { + "label": "字符串", + "name": "STRING", + "shape": 6, + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-custom-scripts", + "ver": "aac13aa7ce35b07d43633c3bbe654a38c00d74f5", + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "[\"https://p26-aiop-sign.byteimg.com/tos-cn-i-vuqhorh59i/20251111010955E6AF8158BBCE466E488A-1585-0~tplv-vuqhorh59i-image-v1.image?rk3s=7f9e702d&x-expires=1762880995&x-signature=SWRBW%2BmzCDbPb5CnqiLstZfi9Ic%3D\"]" + ] + }, + { + "id": 451, + "type": "PreviewImage", + "pos": [ + -6540, + -850 + ], + "size": [ + 500, + 400 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 692 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + }, + { + "id": 457, + "type": "ShowText|pysssss", + "pos": [ + -5990, + -550 + ], + "size": [ + 290, + 170 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 698 + } + ], + "outputs": [ + { + "label": "字符串", + "name": "STRING", + "shape": 6, + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-custom-scripts", + "ver": "aac13aa7ce35b07d43633c3bbe654a38c00d74f5", + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "[\"api_disabled\"]" + ] + }, + { + "id": 455, + "type": "ShowText|pysssss", + "pos": [ + -5980, + -320 + ], + "size": [ + 740, + 170 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 696 + } + ], + "outputs": [ + { + "label": "字符串", + "name": "STRING", + "shape": 6, + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-custom-scripts", + "ver": "aac13aa7ce35b07d43633c3bbe654a38c00d74f5", + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "{\"model\": \"doubao-seedream-4-0-250828\", \"created\": 1762794587, \"data\": [{\"url\": \"https://ark-content-generation-v2-cn-beijing.tos-cn-beijing.volces.com/doubao-seedream-4-0/021762794572349de244866fb4ff96ba22fbbe18ed495f043eee7_0.jpeg?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Credential=AKLTYWJkZTExNjA1ZDUyNDc3YzhjNTM5OGIyNjBhNDcyOTQ%2F20251110%2Fcn-beijing%2Ftos%2Frequest&X-Tos-Date=20251110T170947Z&X-Tos-Expires=86400&X-Tos-Signature=0ba88586e6d6e96315323844c88db6a1203599c914ec6614852ba7f73395ebe8&X-Tos-SignedHeaders=host\", \"size\": \"1312x736\"}], \"usage\": {\"generated_images\": 1, \"output_tokens\": 3772, \"total_tokens\": 3772}}" + ] + }, + { + "id": 458, + "type": "ShowText|pysssss", + "pos": [ + -5990, + -90 + ], + "size": [ + 290, + 170 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 699 + } + ], + "outputs": [ + { + "label": "字符串", + "name": "STRING", + "shape": 6, + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-custom-scripts", + "ver": "aac13aa7ce35b07d43633c3bbe654a38c00d74f5", + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "[\"https://ark-content-generation-v2-cn-beijing.tos-cn-beijing.volces.com/doubao-seedream-4-0/021762794572349de244866fb4ff96ba22fbbe18ed495f043eee7_0.jpeg?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Credential=AKLTYWJkZTExNjA1ZDUyNDc3YzhjNTM5OGIyNjBhNDcyOTQ%2F20251110%2Fcn-beijing%2Ftos%2Frequest&X-Tos-Date=20251110T170947Z&X-Tos-Expires=86400&X-Tos-Signature=0ba88586e6d6e96315323844c88db6a1203599c914ec6614852ba7f73395ebe8&X-Tos-SignedHeaders=host\"]" + ] + }, + { + "id": 460, + "type": "ShowText|pysssss", + "pos": [ + -7110, + -800 + ], + "size": [ + 290, + 170 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 702 + } + ], + "outputs": [ + { + "label": "字符串", + "name": "STRING", + "shape": 6, + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-custom-scripts", + "ver": "aac13aa7ce35b07d43633c3bbe654a38c00d74f5", + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "NanoBanana: 已禁用或未提供API密钥; 豆包: 成功(multi_img_fusion, 1张图片, 第1次尝试); 即梦: 成功(第1次尝试);" + ] + }, + { + "id": 453, + "type": "PreviewImage", + "pos": [ + -6470, + 180 + ], + "size": [ + 450, + 470 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 694 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + }, + { + "id": 452, + "type": "PreviewImage", + "pos": [ + -6610, + -350 + ], + "size": [ + 440, + 450 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 693 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + }, + { + "id": 464, + "type": "TripleAPIGenerate", + "pos": [ + -7270, + -530 + ], + "size": [ + 470, + 1220 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "nanobanana_image", + "type": "IMAGE", + "links": [ + 692 + ] + }, + { + "name": "doubao_image", + "type": "IMAGE", + "links": [ + 693 + ] + }, + { + "name": "hidream_image", + "type": "IMAGE", + "links": [ + 694 + ] + }, + { + "name": "nanobanana_task_id", + "type": "STRING", + "links": null + }, + { + "name": "nanobanana_response", + "type": "STRING", + "links": [ + 695 + ] + }, + { + "name": "doubao_response", + "type": "STRING", + "links": [ + 696 + ] + }, + { + "name": "hidream_response", + "type": "STRING", + "links": [ + 701 + ] + }, + { + "name": "hidream_task_id", + "type": "STRING", + "links": null + }, + { + "name": "status_info", + "type": "STRING", + "links": [ + 702 + ] + }, + { + "name": "nanobanana_image_urls", + "type": "STRING", + "links": [ + 698 + ] + }, + { + "name": "doubao_image_urls", + "type": "STRING", + "links": [ + 699 + ] + }, + { + "name": "hidream_image_urls", + "type": "STRING", + "links": [ + 700 + ] + } + ], + "properties": { + "Node name for S&R": "TripleAPIGenerate" + }, + "widgets_values": [ + "一个可爱的泰迪犬和参考图女孩,在故宫的雪地里抛雪", + "https://api.nanobananaapi.ai/api/v1/nanobanana/generate", + "", + "https://ark.cn-beijing.volces.com/api/v3/images/generations", + "", + "https://visual.volcengineapi.com", + "", + false, + true, + true, + 49052489, + "randomize", + "https://pinchuai.oss-cn-hangzhou.aliyuncs.com/test/niuzai.jpg\nhttps://pinchuai.oss-cn-hangzhou.aliyuncs.com/test/piyi.jpg\n", + 3, + 120, + "https://pinchuai.oss-cn-hangzhou.aliyuncs.com/test/heiqun.jpg", + "IMAGETOIAMGE", + 1, + "auto", + "", + "doubao-seedream-4-0-250828", + "2K", + false, + "multi_img_fusion", + "auto", + 6, + "img2img", + 2, + 2, + 2 + ] + } + ], + "links": [ + [ + 692, + 464, + 0, + 451, + 0, + "IMAGE" + ], + [ + 693, + 464, + 1, + 452, + 0, + "IMAGE" + ], + [ + 694, + 464, + 2, + 453, + 0, + "IMAGE" + ], + [ + 695, + 464, + 4, + 454, + 0, + "STRING" + ], + [ + 696, + 464, + 5, + 455, + 0, + "STRING" + ], + [ + 698, + 464, + 9, + 457, + 0, + "STRING" + ], + [ + 699, + 464, + 10, + 458, + 0, + "STRING" + ], + [ + 700, + 464, + 11, + 459, + 0, + "STRING" + ], + [ + 701, + 464, + 6, + 456, + 0, + "STRING" + ], + [ + 702, + 464, + 8, + 460, + 0, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.7247295000000494, + "offset": [ + 8256.876743851146, + 1143.2934700394585 + ] + }, + "frontendVersion": "1.23.4", + "0246.VERSION": [ + 0, + 0, + 4 + ], + "ue_links": [], + "links_added_by_ue": [], + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4 +} \ No newline at end of file diff --git a/nanobanana_comfy_node.py b/nanobanana_comfy_node.py new file mode 100644 index 0000000..d772c07 --- /dev/null +++ b/nanobanana_comfy_node.py @@ -0,0 +1,2503 @@ +import json +import time +import urllib.error +import urllib.request +import urllib.parse +import io +import re +import hashlib +import hmac +import threading +import socket +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple +from PIL import Image +import numpy as np +import torch + + +DEFAULT_SERVER_URL = "https://api.nanobananaapi.ai" +ENDPOINT_PATH = "/api/v1/nanobanana/generate" +DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/126.0.0.0 Safari/537.36" +) + +# 豆包API相关常量 +# API端点路径常量 +# DOUBAO_ENDPOINT_PATH = "/images/generations" # 不再使用,用户填写完整接口地址 + +# 火山引擎API签名相关 +VOLCENGINE_HOST = "visual.volcengineapi.com" +VOLCENGINE_REGION = "cn-north-1" +VOLCENGINE_SERVICE = "cv" + + +def _create_volcengine_signature( + access_key_id: str, + secret_access_key: str, + method: str, + uri: str, + query_string: str, + headers: Dict[str, str], + body: str, + timestamp: str +) -> str: + """创建火山引擎API签名 - 基于官方示例""" + + # 计算请求体哈希 + body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest() + + # 规范化头部 - 按照官方示例的顺序 + signed_headers_list = ["content-type", "host", "x-content-sha256", "x-date"] + signed_headers_str = ";".join(signed_headers_list) + + # 构建规范头部字符串 + canonical_headers_list = [ + f"content-type:{headers.get('Content-Type', '')}", + f"host:{headers.get('Host', '')}", + f"x-content-sha256:{body_hash}", + f"x-date:{timestamp}" + ] + canonical_headers_str = "\n".join(canonical_headers_list) + + # 创建规范请求 + canonical_request = "\n".join([ + method.upper(), + uri, + query_string, + canonical_headers_str, + "", # 空行 + signed_headers_str, + body_hash + ]) + + # 计算规范请求的哈希 + canonical_request_hash = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest() + + # 创建待签名字符串 + date_part = timestamp[:8] # YYYYMMDD + credential_scope = f"{date_part}/{VOLCENGINE_REGION}/{VOLCENGINE_SERVICE}/request" + + string_to_sign = "\n".join([ + "HMAC-SHA256", + timestamp, + credential_scope, + canonical_request_hash + ]) + + # 派生签名密钥 + k_date = hmac.new(secret_access_key.encode('utf-8'), date_part.encode('utf-8'), hashlib.sha256).digest() + k_region = hmac.new(k_date, VOLCENGINE_REGION.encode('utf-8'), hashlib.sha256).digest() + k_service = hmac.new(k_region, VOLCENGINE_SERVICE.encode('utf-8'), hashlib.sha256).digest() + k_signing = hmac.new(k_service, "request".encode('utf-8'), hashlib.sha256).digest() + + # 计算签名 + signature = hmac.new(k_signing, string_to_sign.encode('utf-8'), hashlib.sha256).hexdigest() + + # 创建Authorization头 + authorization = f"HMAC-SHA256 Credential={access_key_id}/{credential_scope}, SignedHeaders={signed_headers_str}, Signature={signature}" + + return authorization + + +def _calculate_aspect_ratio(width: int, height: int) -> str: + """计算图片的宽高比,返回最接近的NanoBanana支持的比例""" + # 验证输入参数 + if width is None or height is None or width <= 0 or height <= 0: + print(f"[警告] 无效的图片尺寸 width={width}, height={height},使用默认比例 1:1") + return "1:1" + + ratio = width / height + print(f"[调试] 计算宽高比: {width}/{height} = {ratio:.6f}") + + # 定义NanoBanana支持的比例及其阈值 + supported_ratios = { + "1:1": 1.0, # 正方形 + "9:16": 9/16, # 0.5625 (竖屏手机) + "16:9": 16/9, # 1.777... (横屏宽屏) + "3:4": 3/4, # 0.75 (竖屏) + "4:3": 4/3, # 1.333... (横屏) + "3:2": 3/2, # 1.5 (横屏) + "2:3": 2/3, # 0.666... (竖屏) + "5:4": 5/4, # 1.25 (横屏) + "4:5": 4/5, # 0.8 (竖屏) + "21:9": 21/9, # 2.333... (超宽屏) + } + + # 找到差值最小的比例 + min_diff = float('inf') + best_ratio = "1:1" # 默认值 + + for ratio_name, target_ratio in supported_ratios.items(): + diff = abs(ratio - target_ratio) + print(f"[调试] 比例 {ratio_name} ({target_ratio:.6f}): 差值 = {diff:.6f}") + if diff < min_diff: + min_diff = diff + best_ratio = ratio_name + + print(f"[调试] 最终匹配比例: {best_ratio} (差值 = {min_diff:.6f})") + return best_ratio + + +def _get_image_size_with_exif(image: Image.Image) -> Tuple[int, int]: + """获取图片的实际尺寸,考虑EXIF方向信息 + + 当图片有EXIF方向信息(orientation)时,需要根据方向信息调整宽高。 + 例如:如果orientation=6(顺时针旋转90度),则实际显示时需要交换宽高。 + + 如果没有EXIF信息或EXIF中没有orientation信息,则返回图片的默认尺寸。 + + Args: + image: PIL Image对象 + + Returns: + (width, height): 实际显示的尺寸 + """ + width, height = image.size + + # 检查EXIF方向信息 + try: + exif = image.getexif() + orientation = exif.get(274) # EXIF标签274是Orientation + if orientation: + # orientation值说明: + # 1 = 正常(0度)- 不需要交换 + # 3 = 旋转180度 - 不需要交换(尺寸不变) + # 6 = 顺时针旋转90度(需要交换宽高) + # 8 = 逆时针旋转90度(需要交换宽高) + if orientation in [6, 8]: # 需要旋转90度或270度 + # 交换宽高 + width, height = height, width + # 如果没有orientation信息或orientation为None/1/3,使用原始尺寸(已赋值,无需修改) + except Exception: + # 如果获取EXIF失败或图片没有EXIF信息,使用原始尺寸(已赋值,无需修改) + pass + + return width, height + + +def _encode_url_for_request(url: str) -> str: + """对URL进行编码处理,处理中文字符 + + 智能判断是否需要编码: + 1. 如果路径已经编码(包含%XX格式),不再次编码,避免破坏签名 + 2. 如果路径包含中文字符但未编码,进行编码 + 3. 对于签名URL(通常已编码),直接返回原URL + + Args: + url: 原始URL + + Returns: + 编码后的URL(如果需要编码)或原URL(如果已编码或无需编码) + """ + try: + parsed = urllib.parse.urlparse(url) + + # 检查路径是否已经编码(如果包含%XX格式,说明已经编码) + # 对于已经编码的URL,直接返回原URL,避免双重编码破坏签名 + if '%' in parsed.path: + # 路径已经编码,直接返回原URL(特别是OSS签名URL) + return url + + # 检查路径是否包含中文字符(需要编码) + # 判断是否包含非ASCII字符(包括中文) + try: + # 尝试解码路径,如果包含非ASCII字符,quote会进行编码 + decoded_path = urllib.parse.unquote(parsed.path) + # 检查是否包含中文字符或其他非ASCII字符 + has_non_ascii = any(ord(c) > 127 for c in decoded_path) + + if has_non_ascii: + # 包含中文字符或其他非ASCII字符,需要进行编码 + encoded_path = urllib.parse.quote(parsed.path, safe='/:') + # 重新组装URL + safe_url = urllib.parse.urlunparse(( + parsed.scheme, + parsed.netloc, + encoded_path, + parsed.params, + parsed.query, + parsed.fragment + )) + return safe_url + else: + # 不包含非ASCII字符,无需编码 + return url + except Exception: + # 如果解码失败,尝试直接检查原始路径 + has_non_ascii = any(ord(c) > 127 for c in parsed.path) + if has_non_ascii: + # 包含非ASCII字符,进行编码 + encoded_path = urllib.parse.quote(parsed.path, safe='/:') + safe_url = urllib.parse.urlunparse(( + parsed.scheme, + parsed.netloc, + encoded_path, + parsed.params, + parsed.query, + parsed.fragment + )) + return safe_url + else: + return url + except Exception as e: + # 如果编码失败,返回原URL(避免破坏签名URL) + return url + + +def _get_image_dimensions_from_url(url: str) -> Tuple[int, int]: + """从图片URL获取图片尺寸 + + 采用多级备选方案: + 1. 优先使用GET请求(适用于大多数情况,包括签名URL) + 2. 如果GET请求失败,尝试HEAD请求作为备选方案 + 3. 支持中文路径的URL编码 + + Args: + url: 图片URL + + Returns: + (width, height): 图片尺寸,如果获取失败返回 (None, None) + """ + # 对URL进行编码处理,避免中文路径导致的问题 + safe_url = _encode_url_for_request(url) + + # 方案1: 优先尝试GET请求(适用于大多数情况,包括签名URL) + try: + request = urllib.request.Request( + url=safe_url, + method="GET", + headers={"User-Agent": DEFAULT_USER_AGENT} + ) + with urllib.request.urlopen(request, timeout=30) as resp: + # 检查是否为图片 + content_type = resp.headers.get('Content-Type', '') + if content_type and 'image' not in content_type.lower(): + print(f"非图片类型: {content_type}") + return None, None + + # 尝试多种方法获取图片尺寸 + + # 方法1: 尝试读取部分数据(8KB) + try: + img_data = resp.read(8192) # 读取前8KB + with Image.open(io.BytesIO(img_data)) as im: + return _get_image_size_with_exif(im) # (width, height) + except Exception as e1: + print(f"[GET请求] 方法1失败: {str(e1)}") + + # 方法2: 重新请求,读取完整图片 + try: + request2 = urllib.request.Request( + url=safe_url, + method="GET", + headers={"User-Agent": DEFAULT_USER_AGENT} + ) + with urllib.request.urlopen(request2, timeout=30) as resp2: + img_data = resp2.read() # 读取完整图片 + with Image.open(io.BytesIO(img_data)) as im: + return _get_image_size_with_exif(im) # (width, height) + except Exception as e2: + print(f"[GET请求] 方法2失败: {str(e2)}") + + # 方法3: 尝试从HTTP头获取尺寸信息 + try: + content_length = resp.headers.get('Content-Length') + if content_length: + print(f"图片大小: {content_length} bytes") + # 某些服务器可能在头信息中包含尺寸 + return None, None + except Exception as e3: + print(f"[GET请求] 方法3失败: {str(e3)}") + + except urllib.error.HTTPError as e: + # HTTP错误(如403、404等),尝试HEAD请求作为备选方案 + error_code = e.code + error_msg = str(e) + print(f"[GET请求] HTTP错误 {error_code}: {error_msg},尝试HEAD请求作为备选方案") + + # 方案2: HEAD请求备选方案 + try: + head_request = urllib.request.Request( + url=safe_url, + method="HEAD", + headers={"User-Agent": DEFAULT_USER_AGENT} + ) + with urllib.request.urlopen(head_request, timeout=30) as head_resp: + # 检查是否为图片 + content_type = head_resp.headers.get('Content-Type', '') + if content_type and 'image' not in content_type.lower(): + print(f"[HEAD请求] 非图片类型: {content_type}") + return None, None + + # HEAD请求通常不返回响应体,但某些服务器可能在头信息中包含尺寸 + # 如果HEAD请求成功,尝试使用GET请求获取实际数据 + print(f"[HEAD请求] 请求成功,尝试使用GET请求获取图片数据") + + # 重新尝试GET请求(可能HEAD请求验证了URL有效性) + try: + get_request = urllib.request.Request( + url=safe_url, + method="GET", + headers={"User-Agent": DEFAULT_USER_AGENT} + ) + with urllib.request.urlopen(get_request, timeout=30) as get_resp: + img_data = get_resp.read(8192) # 读取前8KB + with Image.open(io.BytesIO(img_data)) as im: + return _get_image_size_with_exif(im) + except Exception as get_e: + print(f"[HEAD备选方案] GET请求仍然失败: {str(get_e)}") + return None, None + + except urllib.error.HTTPError as head_e: + # HEAD请求也失败 + print(f"[HEAD请求] HTTP错误 {head_e.code}: {str(head_e)}") + return None, None + except Exception as head_e: + # HEAD请求其他错误 + print(f"[HEAD请求] 错误: {str(head_e)}") + return None, None + + except Exception as e: + # 其他错误(网络错误、超时等) + try: + error_msg = str(e) + print(f"获取图片尺寸失败: {error_msg}") + except UnicodeEncodeError: + print(f"获取图片尺寸失败: {repr(e)}") + return None, None + + +def _determine_image_size(image_size_mode: str, image_urls: Optional[List[str]]) -> str: + """确定最终的image_size参数""" + + # 如果用户选择了特定比例,直接使用 + if image_size_mode != "auto": + print(f"用户选择固定比例: {image_size_mode}") + return image_size_mode + + # AUTO模式:需要从输入图片计算比例 + if not image_urls or len(image_urls) == 0: + print("AUTO模式但未提供输入图片,使用默认比例 1:1") + return "1:1" + + # 获取第一张输入图片的尺寸 + first_image_url = image_urls[0] + print(f"[AUTO模式] 分析输入图片: {first_image_url}") + + width, height = _get_image_dimensions_from_url(first_image_url) + + if width is None or height is None: + print(f"[AUTO模式] 无法获取图片尺寸,使用默认比例 1:1") + return "1:1" + + # 验证尺寸有效性 + if width <= 0 or height <= 0: + print(f"[AUTO模式] 警告: 获取到无效的图片尺寸 {width}×{height},使用默认比例 1:1") + return "1:1" + + # 输出获取到的尺寸信息 + print(f"[AUTO模式] 成功获取图片尺寸: {width}×{height}") + print(f"[AUTO模式] 图片宽高比: {width/height:.6f} ({width}:{height})") + + # 计算最接近的比例 + calculated_ratio = _calculate_aspect_ratio(width, height) + print(f"[AUTO模式] 最终选择比例: {calculated_ratio}") + + return calculated_ratio + + +def _build_request_body( + prompt: str, + generation_type: str, + num_images: int, + image_urls: Optional[List[str]], + seed: Optional[int], + watermark: Optional[str], + image_size_mode: str = "auto", +) -> Dict[str, Any]: + if not prompt: + raise ValueError("prompt 不能为空") + if generation_type not in {"IMAGETOIAMGE", "TEXTTOIAMGE"}: + raise ValueError("type 只能是 TEXTTOIAMGE 或 IMAGETOIAMGE") + if not (1 <= num_images <= 4): + raise ValueError("numImages 必须在 1 到 4 之间") + + body: Dict[str, Any] = { + "prompt": prompt, + "type": generation_type, + "numImages": num_images, + "callBackUrl": "https://api.nanobananaapi.ai/callback", # 必填的回调URL,使用官方默认值 + } + + print(f"banana请求api参数: {json.dumps(body, ensure_ascii=False)}") + + # 处理图片尺寸 + final_image_size = _determine_image_size(image_size_mode, image_urls) + body["image_size"] = final_image_size + + if watermark: + body["watermark"] = watermark + if seed is not None: + try: + body["seed"] = int(seed) + except Exception: + pass + + if generation_type == "IMAGETOIAMGE": + if not image_urls: + raise ValueError("IMAGETOIAMGE 模式下必须提供 image_urls") + body["imageUrls"] = image_urls + + return body + + +def _send_request( + server_url: str, + api_key: str, + body: Dict[str, Any], + timeout_seconds: int = 60, + user_agent: str = DEFAULT_USER_AGENT, +) -> Dict[str, Any]: + # 如果server_url已经包含完整路径,则不添加ENDPOINT_PATH + if "/api/v1/nanobanana/generate" in server_url: + url = server_url.rstrip("/") + else: + url = server_url.rstrip("/") + ENDPOINT_PATH + data_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8") + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"Bearer {api_key}", + "x-api-key": api_key, + "User-Agent": user_agent or DEFAULT_USER_AGENT, + "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7", + "Connection": "keep-alive", + } + + request = urllib.request.Request(url=url, data=data_bytes, headers=headers, method="POST") + + try: + with urllib.request.urlopen(request, timeout=timeout_seconds) as resp: + resp_text = resp.read().decode("utf-8", errors="replace") + print(f"banana api响应: {resp_text}") + try: + return json.loads(resp_text) + except json.JSONDecodeError: + return {"raw": resp_text} + except urllib.error.HTTPError as e: + err_text = e.read().decode("utf-8", errors="replace") + try: + payload = json.loads(err_text) + except json.JSONDecodeError: + payload = {"error": err_text} + payload["http_status"] = e.code + return payload + except urllib.error.URLError as e: + return {"error": str(e)} + + +def _clean_url_token(token: str) -> Optional[str]: + s = (token or "").strip() + if not s: + return None + # 支持以 @ 开头的输入格式 + if s.startswith("@"): + s = s.lstrip("@").strip() + # 去除可能的包裹符 <...> + if s.startswith("<") and s.endswith(">"): + s = s[1:-1].strip() + # 仅接受 http/https + if not (s.startswith("http://") or s.startswith("https://")): + return None + return s + + +def _parse_image_urls_field(text: str) -> Optional[List[str]]: + """解析任意输入(JSON 数组/单字符串/按换行或逗号或空白分隔,支持 @ 前缀)为 URL 列表。""" + txt = (text or "").strip() + if not txt: + return None + urls: List[str] = [] + # 优先尝试 JSON + try: + parsed = json.loads(txt) + if isinstance(parsed, list): + for u in parsed: + if isinstance(u, str): + cu = _clean_url_token(u) + if cu: + urls.append(cu) + seen = set() + return [u for u in urls if not (u in seen or seen.add(u))] or None + elif isinstance(parsed, str): + cu = _clean_url_token(parsed) + return ([cu] if cu else None) + except Exception: + pass + # 自由格式:按换行/逗号/空白分割 + tokens = [t for t in re.split(r"[\r\n,\s]+", txt) if t.strip()] + for t in tokens: + cu = _clean_url_token(t) + if cu: + urls.append(cu) + if not urls: + return None + seen = set() + return [u for u in urls if not (u in seen or seen.add(u))] + + +class NanoBananaGenerate: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "prompt": ("STRING", {"multiline": True, "default": ""}), + "type": (["TEXTTOIAMGE", "IMAGETOIAMGE"], {"default": "TEXTTOIAMGE"}), + "num_images": ("INT", {"default": 1, "min": 1, "max": 4}), + "api_key": ("STRING", {"default": "", "multiline": False}), + "image_size_mode": (["auto", "1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5", "21:9"], {"default": "auto"}), + }, + "optional": { + "image_urls_json": ("STRING", {"default": "[]", "multiline": True}), + "seed": ("INT", {"default": -1, "min": -1, "max": 99999999}), + "watermark": ("STRING", {"default": ""}), + "server": ("STRING", {"default": DEFAULT_SERVER_URL}), + "timeout_seconds": ("INT", {"default": 60, "min": 5, "max": 300}), + "wait_for_result": ("BOOLEAN", {"default": False}), + "poll_interval_seconds": ("INT", {"default": 3, "min": 1, "max": 60}), + "poll_timeout_seconds": ("INT", {"default": 240, "min": 5, "max": 3600}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "IMAGE") + RETURN_NAMES = ("task_id", "response_json", "result_image") + FUNCTION = "generate" + CATEGORY = "NanoBanana" + + def generate( + self, + prompt: str, + type: str, + num_images: int, + api_key: str, + image_urls_json: str = "[]", + seed: int = -1, + watermark: str = "", + image_size_mode: str = "auto", + server: str = DEFAULT_SERVER_URL, + timeout_seconds: int = 60, + wait_for_result: bool = False, + poll_interval_seconds: int = 3, + poll_timeout_seconds: int = 240, # 与 INPUT_TYPES 保持一致 + ) -> Tuple[str, str, Any]: + if not api_key: + raise ValueError("未提供 API Key。请在节点中填写 api_key。") + + image_urls: Optional[List[str]] = _parse_image_urls_field(image_urls_json) + + # 限制种子值范围 + processed_seed = None + if seed is not None and seed >= 0: + # 如果种子值超出范围,取模限制在合理范围内 + processed_seed = int(seed) % 100000000 if seed > 99999999 else int(seed) + + body = _build_request_body( + prompt=prompt, + generation_type=type, + num_images=num_images, + image_urls=image_urls, + seed=processed_seed, + watermark=watermark or None, + image_size_mode=image_size_mode, + ) + + response = _send_request( + server_url=server, + api_key=api_key, + body=body, + timeout_seconds=timeout_seconds, + user_agent=DEFAULT_USER_AGENT, + ) + + task_id: str = "" + try: + task_id = str(response.get("data", {}).get("taskId", "")) + print(f"NanoBanana: API响应解析 - taskId: {task_id}") + if not task_id: + print(f"NanoBanana: 完整响应内容: {json.dumps(response, ensure_ascii=False, indent=2)}") + except Exception as e: + task_id = "" + print(f"NanoBanana: 解析taskId时出错: {e}") + print(f"NanoBanana: 原始响应: {json.dumps(response, ensure_ascii=False, indent=2)}") + + # 如果用户选择等待结果且拿到了 task_id,则进入轮询 + if wait_for_result and task_id: + print(f"NanoBanana: 开始轮询任务 {task_id}") + final_info = _poll_task( + server_url=server, + api_key=api_key, + task_id=task_id, + interval_seconds=poll_interval_seconds, + timeout_seconds=poll_timeout_seconds, + user_agent=DEFAULT_USER_AGENT, + ) + print(f"NanoBanana: 轮询完成,结果: {json.dumps(final_info, ensure_ascii=False, indent=2)}") + + # 提取结果图片地址(支持多张) + result_image_urls: List[str] = [] + try: + data_obj = final_info.get("data") or {} + if isinstance(data_obj, dict): + # 1) data.response.* + resp_obj = data_obj.get("response") or {} + if isinstance(resp_obj, dict): + candidates: List[str] = [] + # 优先收集数组字段 + for key in ("resultImageUrls", "resultImages", "images", "imageUrls", "image_urls", "result_image_urls", "data"): + v = resp_obj.get(key) + if isinstance(v, list): + for item in v: + if isinstance(item, str) and item.strip(): + candidates.append(item) + elif isinstance(item, dict) and "url" in item: + # 处理字典格式的图片项 + url = item.get("url") + if isinstance(url, str) and url.strip(): + candidates.append(url) + # 兼容单字段 + for key in ("resultImageUrl", "imageUrl", "image_url", "result_image_url"): + v = resp_obj.get(key) + if isinstance(v, str) and v.strip(): + candidates.append(v) + # 去重并保序 + seen: set = set() + result_image_urls = [u for u in candidates if not (u in seen or seen.add(u))] + # 2) data.* 顶层数组字段 + if not result_image_urls: + for key in ("images", "resultImages", "imageUrls", "image_urls", "result_image_urls", "data"): + v = data_obj.get(key) + if isinstance(v, list): + for item in v: + if isinstance(item, str) and item.strip(): + result_image_urls.append(item) + elif isinstance(item, dict) and "url" in item: + # 处理字典格式的图片项 + url = item.get("url") + if isinstance(url, str) and url.strip(): + result_image_urls.append(url) + # 兼容处理 - 处理所有图片,不只是第一张 + if not result_image_urls: + images_list = data_obj.get("images") or data_obj.get("resultImages") + if isinstance(images_list, list) and images_list: + # 处理所有图片项,不只是第一张 + for item in images_list: + if isinstance(item, str) and item.strip(): + result_image_urls.append(item) + elif isinstance(item, dict) and "url" in item: + # 如果item是字典且包含url字段 + url = item.get("url") + if isinstance(url, str) and url.strip(): + result_image_urls.append(url) + except Exception as e: + print(f"NanoBanana: 解析图片URL失败: {str(e)}") + pass + + print(f"NanoBanana: 解析到图片URLs: {result_image_urls}") + + # 下载并转换为 ComfyUI IMAGE 批次张量 + result_image = None + if result_image_urls: + images_tensors: List[torch.Tensor] = [] + for idx, url in enumerate(result_image_urls): + try: + print(f"NanoBanana: 下载图片 {idx+1}/{len(result_image_urls)}: {url}") + # 保持每张图片的原始尺寸,不强制调整 + tensor_img = _download_image_to_tensor_with_size(url, target_size=None, timeout_seconds=min(timeout_seconds, 60)) + images_tensors.append(tensor_img) + # 打印每张图片的实际尺寸(使用编码后的URL) + try: + safe_url = _encode_url_for_request(url) + with urllib.request.urlopen(urllib.request.Request(safe_url, headers={"User-Agent": DEFAULT_USER_AGENT}), timeout=min(timeout_seconds, 60)) as resp: + img_bytes = resp.read() + with Image.open(io.BytesIO(img_bytes)) as im: + width, height = _get_image_size_with_exif(im) + print(f"NanoBanana: 图片{idx+1}实际尺寸: {width}x{height}") + except Exception as size_e: + print(f"NanoBanana: 无法读取图片{idx+1}尺寸: {size_e}") + except Exception as e: + print(f"NanoBanana: 图片下载失败 {url}: {str(e)}") + continue + if images_tensors: + try: + result_image = torch.cat(images_tensors, dim=0) + print(f"NanoBanana: 成功下载 {len(images_tensors)} 张图片") + except Exception as e: + print(f"NanoBanana: 图片拼接失败: {str(e)}") + # 退化到第一张 + result_image = images_tensors[0] + else: + print("NanoBanana: 所有图片下载失败") + else: + print("NanoBanana: 未找到图片URL") + + response_json = json.dumps(final_info, ensure_ascii=False) + return (task_id, response_json, result_image) + else: + # 如果不等待结果,返回任务ID和响应,但图片为None + # 提示用户需要等待结果才能获取图片 + response_json = json.dumps(response, ensure_ascii=False) + if not wait_for_result: + print("NanoBanana: 未启用等待结果,请勾选 wait_for_result 选项以获取图片") + elif not task_id: + print("NanoBanana: 未获取到任务ID,可能API调用失败") + return (task_id, response_json, None) + + +def _get_task_info( + server_url: str, + api_key: str, + task_id: str, + timeout_seconds: int = 30, + user_agent: str = DEFAULT_USER_AGENT, +) -> Dict[str, Any]: + # 处理server_url,如果包含完整路径则提取基础URL + base = server_url.rstrip("/") + if "/api/v1/nanobanana/generate" in base: + base = base.replace("/api/v1/nanobanana/generate", "") + + query = urllib.parse.urlencode({"taskId": task_id}) + url = f"{base}/api/v1/nanobanana/record-info?{query}" + print(f"NanoBanana: 轮询URL: {url}") + + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {api_key}", + "x-api-key": api_key, + "User-Agent": user_agent or DEFAULT_USER_AGENT, + "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7", + "Connection": "keep-alive", + } + + request = urllib.request.Request(url=url, headers=headers, method="GET") + try: + with urllib.request.urlopen(request, timeout=timeout_seconds) as resp: + resp_text = resp.read().decode("utf-8", errors="replace") + print(f"banana 轮询api响应: {resp_text}") + try: + result = json.loads(resp_text) + print(f"NanoBanana: 解析后的响应: {json.dumps(result, ensure_ascii=False)}") + return result + except json.JSONDecodeError: + return {"raw": resp_text} + except urllib.error.HTTPError as e: + err_text = e.read().decode("utf-8", errors="replace") + try: + payload = json.loads(err_text) + except json.JSONDecodeError: + payload = {"error": err_text} + payload["http_status"] = e.code + return payload + except urllib.error.URLError as e: + return {"error": str(e)} + + +def _poll_task( + server_url: str, + api_key: str, + task_id: str, + interval_seconds: int, + timeout_seconds: int, + user_agent: str = DEFAULT_USER_AGENT, +) -> Dict[str, Any]: + start_ts = time.time() + last_payload: Dict[str, Any] = {} + consecutive_timeouts = 0 # 连续超时计数 + max_consecutive_timeouts = 3 # 最多允许连续3次超时 + + while True: + try: + # 使用传入的 timeout_seconds 参数作为单个请求的超时时间 + payload = _get_task_info( + server_url=server_url, + api_key=api_key, + task_id=task_id, + timeout_seconds=timeout_seconds, + user_agent=user_agent, + ) + last_payload = payload + + # 检查是否是错误响应(包括超时错误) + if "error" in payload: + error_msg = payload.get("error", "") + if "timeout" in error_msg.lower() or "timed out" in error_msg.lower(): + consecutive_timeouts += 1 + print(f"NanoBanana: 轮询请求超时 ({consecutive_timeouts}/{max_consecutive_timeouts}): {error_msg}") + if consecutive_timeouts >= max_consecutive_timeouts: + print(f"NanoBanana: 连续{consecutive_timeouts}次轮询请求超时,停止轮询") + return { + "error": f"连续轮询请求超时({consecutive_timeouts}次)", + "last": payload, + "consecutive_timeouts": consecutive_timeouts + } + else: + # 其他错误,也记录但允许重试 + print(f"NanoBanana: 轮询请求错误: {error_msg}") + consecutive_timeouts += 1 + if consecutive_timeouts >= max_consecutive_timeouts: + return { + "error": f"连续轮询错误", + "last": payload, + "consecutive_timeouts": consecutive_timeouts + } + else: + consecutive_timeouts = 0 # 重置超时计数(成功响应) + + # HTTP错误检查 + if payload.get("http_status") and payload["http_status"] >= 500: + consecutive_timeouts += 1 + print(f"NanoBanana: HTTP {payload['http_status']} 错误 ({consecutive_timeouts}/{max_consecutive_timeouts})") + if consecutive_timeouts >= max_consecutive_timeouts: + return { + "error": f"HTTP {payload['http_status']} 错误", + "last": payload, + "consecutive_timeouts": consecutive_timeouts + } + + except Exception as e: + # 捕获所有未预期的异常(包括socket.timeout等) + consecutive_timeouts += 1 + print(f"NanoBanana: 轮询请求异常 ({consecutive_timeouts}/{max_consecutive_timeouts}): {str(e)}") + if consecutive_timeouts >= max_consecutive_timeouts: + return { + "error": f"轮询异常: {str(e)}", + "last": last_payload, + "consecutive_timeouts": consecutive_timeouts + } + # 等待后继续尝试 + time.sleep(max(1, interval_seconds)) + continue + + # 尝试解析状态 + status = 0 + data = {} + try: + data = payload.get("data") or {} + if not isinstance(data, dict): + data = payload if isinstance(payload, dict) else {} + + status = int(data.get("successFlag", 0)) + print(f"NanoBanana: 轮询状态 {status}, 数据: {json.dumps(data, ensure_ascii=False)}") + except Exception as e: + status = 0 + print(f"NanoBanana: 解析状态失败: {str(e)}, 原始响应: {json.dumps(payload, ensure_ascii=False)}") + + # 检查任务是否完成 + if status in (1, 2, 3): + print(f"NanoBanana: 任务完成,状态: {status}") + return payload + + # 检查总超时 + elapsed = time.time() - start_ts + if elapsed >= timeout_seconds: + print(f"NanoBanana: 轮询超时 ({timeout_seconds}秒),最后状态: {status}, 已耗时: {elapsed:.1f}秒") + return { + "error": "poll_timeout", + "data": data, + "last": last_payload, + "timeout_seconds": timeout_seconds, + "elapsed": elapsed + } + + # 等待下一次轮询 + time.sleep(max(1, interval_seconds)) + + +def _download_image_to_tensor(url: str, timeout_seconds: int = 30): + """下载图片并转换为 ComfyUI IMAGE(torch.Tensor,形状 [1, H, W, 3],值域 0-1)。""" + # 对URL进行编码处理 + safe_url = _encode_url_for_request(url) + request = urllib.request.Request( + url=safe_url, + method="GET", + headers={ + "User-Agent": DEFAULT_USER_AGENT, + "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7", + "Connection": "keep-alive", + }, + ) + with urllib.request.urlopen(request, timeout=timeout_seconds) as resp: + img_bytes = resp.read() + with Image.open(io.BytesIO(img_bytes)) as im: + im = im.convert("RGB") + arr = np.array(im, dtype=np.float32) / 255.0 + tensor = torch.from_numpy(arr) + if tensor.ndim == 2: + tensor = tensor.unsqueeze(-1).repeat(1, 1, 3) + # [H, W, C] -> add batch dim -> [1, H, W, C] + tensor = tensor.unsqueeze(0).contiguous() + return tensor + + +def _download_image_to_tensor_with_size( + url: str, + target_size: Optional[Tuple[int, int]] = None, # (W, H) + return_size: bool = False, + timeout_seconds: int = 30, +): + """下载图片并转换为 ComfyUI IMAGE;可选对齐到 target_size; + return_size=True 时返回 (tensor, (W, H)) + """ + # 对URL进行编码处理 + safe_url = _encode_url_for_request(url) + request = urllib.request.Request( + url=safe_url, + method="GET", + headers={ + "User-Agent": DEFAULT_USER_AGENT, + "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7", + "Connection": "keep-alive", + }, + ) + with urllib.request.urlopen(request, timeout=timeout_seconds) as resp: + img_bytes = resp.read() + with Image.open(io.BytesIO(img_bytes)) as im: + im = im.convert("RGB") + if target_size is not None: + # target_size 为 (W, H) + im = im.resize((int(target_size[0]), int(target_size[1])), Image.BICUBIC) + width, height = _get_image_size_with_exif(im) + arr = np.array(im, dtype=np.float32) / 255.0 + tensor = torch.from_numpy(arr) + if tensor.ndim == 2: + tensor = tensor.unsqueeze(-1).repeat(1, 1, 3) + tensor = tensor.unsqueeze(0).contiguous() + if return_size: + return tensor, (width, height) + return tensor + + +def _create_white_image_tensor(width: int = 512, height: int = 512) -> torch.Tensor: + """创建一个白色图片张量,用于错误情况下的返回。""" + # 创建白色图片 [1, H, W, 3],值域 0-1 + white_array = np.ones((height, width, 3), dtype=np.float32) + tensor = torch.from_numpy(white_array).unsqueeze(0).contiguous() + return tensor + +def _create_fallback_or_white_image_tensor( + fallback_image_url: str, + timeout_seconds: int = 60, +) -> torch.Tensor: + """ + 优先返回前端提供的链接图片;若无或下载失败,则返回白色底图。 + """ + try: + if fallback_image_url and isinstance(fallback_image_url, str) and fallback_image_url.strip(): + return _download_image_to_tensor_with_size( + fallback_image_url.strip(), + timeout_seconds=timeout_seconds, + ) + except Exception: + pass + return _create_white_image_tensor() + + +def _extract_image_urls_from_response(response_data: Dict[str, Any]) -> List[str]: + """从API响应中提取图片URLs""" + urls: List[str] = [] + + try: + # 处理NanoBanana响应格式 + if "data" in response_data and isinstance(response_data["data"], dict): + data_obj = response_data["data"] + + # 1) data.response.* + resp_obj = data_obj.get("response") or {} + if isinstance(resp_obj, dict): + candidates: List[str] = [] + # 优先收集数组字段 + for key in ("resultImageUrls", "resultImages", "images", "imageUrls", "image_urls", "result_image_urls", "data"): + v = resp_obj.get(key) + if isinstance(v, list): + for item in v: + if isinstance(item, str) and item.strip(): + candidates.append(item) + elif isinstance(item, dict) and "url" in item: + url = item.get("url") + if isinstance(url, str) and url.strip(): + candidates.append(url) + # 兼容单字段 + for key in ("resultImageUrl", "imageUrl", "image_url", "result_image_url"): + v = resp_obj.get(key) + if isinstance(v, str) and v.strip(): + candidates.append(v) + # 去重并保序 + seen: set = set() + urls.extend([u for u in candidates if not (u in seen or seen.add(u))]) + + # 2) data.* 顶层数组字段 + if not urls: + for key in ("images", "resultImages", "imageUrls", "image_urls", "result_image_urls", "data"): + v = data_obj.get(key) + if isinstance(v, list): + for item in v: + if isinstance(item, str) and item.strip(): + urls.append(item) + elif isinstance(item, dict) and "url" in item: + url = item.get("url") + if isinstance(url, str) and url.strip(): + urls.append(url) + # 兼容处理 - 处理所有图片,不只是第一张 + if not urls: + images_list = data_obj.get("images") or data_obj.get("resultImages") + if isinstance(images_list, list) and images_list: + for item in images_list: + if isinstance(item, str) and item.strip(): + urls.append(item) + elif isinstance(item, dict) and "url" in item: + url = item.get("url") + if isinstance(url, str) and url.strip(): + urls.append(url) + + # 处理豆包响应格式 + elif "data" in response_data and isinstance(response_data["data"], list): + for item in response_data["data"]: + if isinstance(item, dict) and "url" in item: + url = item.get("url") + if isinstance(url, str) and url.strip(): + urls.append(url) + + # 处理即梦响应格式 + elif "data" in response_data and isinstance(response_data["data"], dict): + data_obj = response_data["data"] + if "image_urls" in data_obj and isinstance(data_obj["image_urls"], list): + urls.extend([url for url in data_obj["image_urls"] if isinstance(url, str) and url.strip()]) + elif "image_url" in data_obj and isinstance(data_obj["image_url"], str): + urls.append(data_obj["image_url"]) + + except Exception as e: + print(f"提取图片URLs失败: {str(e)}") + + return urls + + +def _send_doubao_request( + api_key: str, + prompt: str, + size: str = "1K", + model: str = "doubao-seedream-4-0-250828", + watermark: bool = True, + seed: Optional[int] = None, + image_urls: Optional[List[str]] = None, + sequential_generation: str = "disabled", + num_images: int = 1, + timeout_seconds: int = 60, + server_url: str = "https://ark.cn-beijing.volces.com/api/v3/images/generations", +) -> Dict[str, Any]: + """发送豆包 API 请求 - 使用HTTP请求方式,支持转发站""" + try: + # 构建API URL - 用户填写的是完整的接口地址 + url = server_url.rstrip('/') + + # 构建请求体 + body = { + "model": model, + "prompt": prompt, + "size": size, + "response_format": "url", + "watermark": watermark, + "stream": False + } + + # 添加图片输入(如果提供) + if image_urls and len(image_urls) > 0: + if len(image_urls) == 1: + body["image"] = image_urls[0] # 单图模式 + else: + body["image"] = image_urls # 多图模式 + + # 添加组图生成控制 + if sequential_generation == "auto": + body["sequential_image_generation"] = "auto" + + # 计算实际可生成的最大图片数量 + input_image_count = len(image_urls) if image_urls else 0 + max_allowed_images = 15 - input_image_count + + if max_allowed_images <= 0: + print(f"TripleAPI: 豆包API错误 - 输入图片数量({input_image_count})已达到15张限制") + return {"error": f"输入图片数量({input_image_count})已达到15张限制"} + + body["sequential_image_generation_options"] = { + "max_images": min(num_images, max_allowed_images) + } + + print(f"TripleAPI: 豆包组图生成 - 输入图片:{input_image_count}张, 请求生成:{num_images}张, 实际生成:{min(num_images, max_allowed_images)}张") + else: + body["sequential_image_generation"] = sequential_generation + + # 添加 seed 参数(如果提供) + if seed is not None and seed >= 0: + limited_seed = seed % 100000000 if seed > 99999999 else seed + body["seed"] = limited_seed + + print(f"豆包请求API参数: {json.dumps(body, ensure_ascii=False, indent=2)}") + + # 构建请求头 + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + "User-Agent": DEFAULT_USER_AGENT, + } + + # 发送HTTP请求 + data_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8") + request = urllib.request.Request(url=url, data=data_bytes, headers=headers, method="POST") + + with urllib.request.urlopen(request, timeout=timeout_seconds) as resp: + resp_text = resp.read().decode("utf-8", errors="replace") + try: + response_data = json.loads(resp_text) + print(f"豆包API响应: {response_data}") + return response_data + except json.JSONDecodeError: + return {"raw": resp_text} + + except urllib.error.HTTPError as e: + err_text = e.read().decode("utf-8", errors="replace") + try: + payload = json.loads(err_text) + except json.JSONDecodeError: + payload = {"error": err_text} + payload["http_status"] = e.code + print(f"豆包API HTTP错误: {payload}") + return payload + except urllib.error.URLError as e: + error_msg = str(e) + print(f"豆包API URL错误: {error_msg}") + return {"error": error_msg} + except Exception as e: + error_msg = str(e) + print(f"豆包API未知错误: {error_msg}") + return {"error": error_msg} + + +def _send_hidream_request_multi( + req_key: str, + prompt: str, + hidream_type: str = "txt2img", + image_urls: Optional[List[str]] = None, + seed: Optional[int] = None, + scale: float = 0.5, + timeout_seconds: int = 60, + server_url: str = "https://visual.volcengineapi.com", +) -> Dict[str, Any]: + """发送即梦 API 4.0 请求 - 支持多图输入(最多10张)""" + + # 从req_key解析access_key_id和secret_access_key + if ":" not in req_key: + return {"error": "req_key格式错误,应为 'access_key_id:secret_access_key'"} + + access_key_id, secret_access_key = req_key.split(":", 1) + + # API基本信息 + method = "POST" + uri = "/" + query_params = { + "Action": "CVSync2AsyncSubmitTask", + "Version": "2022-08-31" + } + query_string = "&".join([f"{k}={v}" for k, v in sorted(query_params.items())]) + + # 构建请求体 + body_data = { + "req_key": "jimeng_t2i_v40", + "prompt": prompt, + "scale": scale, + } + + # 添加 seed 参数(如果提供) + if seed is not None and seed >= 0: + limited_seed = seed % 100000000 if seed > 99999999 else seed + body_data["seed"] = limited_seed + + # 如果是图生图类型,添加图片URLs(支持多图,最多10张) + if hidream_type in ["img2img", "shopImg2img", "vton"] and image_urls: + # 限制最多10张图片 + limited_urls = image_urls[:10] if len(image_urls) > 10 else image_urls + body_data["image_urls"] = limited_urls + if len(image_urls) > 10: + print(f"即梦: 输入图片超过10张,已截取前10张: {len(limited_urls)}张") + + body = json.dumps(body_data) + print(f"即梦请求api参数: {body}") + + # 创建时间戳 + now = datetime.utcnow() + timestamp = now.strftime("%Y%m%dT%H%M%SZ") + + # 计算请求体哈希 + body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest() + + # 从server_url中提取host + from urllib.parse import urlparse + parsed_url = urlparse(server_url) + host = parsed_url.hostname or VOLCENGINE_HOST + + # 构建请求头 + headers = { + "Host": host, + "Content-Type": "application/json", + "X-Date": timestamp, + "X-Content-Sha256": body_hash, + "User-Agent": DEFAULT_USER_AGENT, + } + + # 创建签名 + authorization = _create_volcengine_signature( + access_key_id=access_key_id, + secret_access_key=secret_access_key, + method=method, + uri=uri, + query_string=query_string, + headers=headers, + body=body, + timestamp=timestamp + ) + + headers["Authorization"] = authorization + + # 构建完整URL + url = f"{server_url.rstrip('/')}{uri}?{query_string}" + + # 发送请求 + request = urllib.request.Request( + url=url, + data=body.encode("utf-8"), + headers=headers, + method=method + ) + + try: + with urllib.request.urlopen(request, timeout=timeout_seconds) as resp: + resp_text = resp.read().decode("utf-8", errors="replace") + try: + return json.loads(resp_text) + except json.JSONDecodeError: + return {"raw": resp_text} + except urllib.error.HTTPError as e: + err_text = e.read().decode("utf-8", errors="replace") + try: + payload = json.loads(err_text) + except json.JSONDecodeError: + payload = {"error": err_text} + payload["http_status"] = e.code + return payload + except urllib.error.URLError as e: + return {"error": str(e)} + + +def _send_hidream_request( + req_key: str, + prompt: str, + hidream_type: str = "txt2img", + image_url: Optional[str] = None, + seed: Optional[int] = None, + scale: float = 0.5, + timeout_seconds: int = 60, + server_url: str = "https://visual.volcengineapi.com", +) -> Dict[str, Any]: + """发送即梦 API 4.0 请求 - 提交任务""" + + # 从req_key解析access_key_id和secret_access_key + # 格式应该是: "access_key_id:secret_access_key" + if ":" not in req_key: + return {"error": "req_key格式错误,应为 'access_key_id:secret_access_key'"} + + access_key_id, secret_access_key = req_key.split(":", 1) + + # API基本信息 + method = "POST" + uri = "/" + query_params = { + "Action": "CVSync2AsyncSubmitTask", + "Version": "2022-08-31" + } + query_string = "&".join([f"{k}={v}" for k, v in sorted(query_params.items())]) + + # 构建请求体 + body_data = { + "req_key": "jimeng_t2i_v40", + "prompt": prompt, + "scale": scale, + } + + # 添加 seed 参数(如果提供) + if seed is not None and seed >= 0: + # 限制种子值范围 + limited_seed = seed % 100000000 if seed > 99999999 else seed + body_data["seed"] = limited_seed + + # 如果是图生图类型,添加图片URL + if hidream_type in ["img2img", "shopImg2img", "vton"] and image_url: + body_data["image_urls"] = [image_url] + + body = json.dumps(body_data) + + # 创建时间戳 + now = datetime.utcnow() + timestamp = now.strftime("%Y%m%dT%H%M%SZ") + + # 计算请求体哈希 + body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest() + + # 从server_url中提取host + from urllib.parse import urlparse + parsed_url = urlparse(server_url) + host = parsed_url.hostname or VOLCENGINE_HOST + + # 构建请求头 + headers = { + "Host": host, + "Content-Type": "application/json", + "X-Date": timestamp, + "X-Content-Sha256": body_hash, + "User-Agent": DEFAULT_USER_AGENT, + } + + # 创建签名 + authorization = _create_volcengine_signature( + access_key_id=access_key_id, + secret_access_key=secret_access_key, + method=method, + uri=uri, + query_string=query_string, + headers=headers, + body=body, + timestamp=timestamp + ) + + headers["Authorization"] = authorization + + # 构建完整URL + url = f"{server_url.rstrip('/')}{uri}?{query_string}" + + # 发送请求 + request = urllib.request.Request( + url=url, + data=body.encode("utf-8"), + headers=headers, + method=method + ) + + try: + with urllib.request.urlopen(request, timeout=timeout_seconds) as resp: + resp_text = resp.read().decode("utf-8", errors="replace") + try: + return json.loads(resp_text) + except json.JSONDecodeError: + return {"raw": resp_text} + except urllib.error.HTTPError as e: + err_text = e.read().decode("utf-8", errors="replace") + try: + payload = json.loads(err_text) + except json.JSONDecodeError: + payload = {"error": err_text} + payload["http_status"] = e.code + return payload + except urllib.error.URLError as e: + return {"error": str(e)} + + +def _check_hidream_status( + req_key: str, + task_id: str, + timeout_seconds: int = 30, + server_url: str = "https://visual.volcengineapi.com", +) -> Dict[str, Any]: + """查询即梦 API 4.0 任务状态""" + + # 从req_key解析access_key_id和secret_access_key + # 格式应该是: "access_key_id:secret_access_key" + if ":" not in req_key: + return {"error": "req_key格式错误,应为 'access_key_id:secret_access_key'"} + + access_key_id, secret_access_key = req_key.split(":", 1) + + # API基本信息 + method = "POST" + uri = "/" + query_params = { + "Action": "CVSync2AsyncGetResult", + "Version": "2022-08-31" + } + query_string = "&".join([f"{k}={v}" for k, v in sorted(query_params.items())]) + + # 构建请求体 + body_data = { + "req_key": "jimeng_t2i_v40", + "task_id": task_id, + "req_json": json.dumps({ + "return_url": True, + "logo_info": { + "add_logo": False + } + }, ensure_ascii=False) + } + + body = json.dumps(body_data) + + # 创建时间戳 + now = datetime.utcnow() + timestamp = now.strftime("%Y%m%dT%H%M%SZ") + + # 计算请求体哈希 + body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest() + + # 从server_url中提取host + from urllib.parse import urlparse + parsed_url = urlparse(server_url) + host = parsed_url.hostname or VOLCENGINE_HOST + + # 构建请求头 + headers = { + "Host": host, + "Content-Type": "application/json", + "X-Date": timestamp, + "X-Content-Sha256": body_hash, + "User-Agent": DEFAULT_USER_AGENT, + } + + # 创建签名 + authorization = _create_volcengine_signature( + access_key_id=access_key_id, + secret_access_key=secret_access_key, + method=method, + uri=uri, + query_string=query_string, + headers=headers, + body=body, + timestamp=timestamp + ) + + headers["Authorization"] = authorization + + # 构建完整URL + url = f"{server_url.rstrip('/')}{uri}?{query_string}" + + # 发送请求 + request = urllib.request.Request( + url=url, + data=body.encode("utf-8"), + headers=headers, + method=method + ) + + try: + with urllib.request.urlopen(request, timeout=timeout_seconds) as resp: + resp_text = resp.read().decode("utf-8", errors="replace") + try: + return json.loads(resp_text) + except json.JSONDecodeError: + return {"raw": resp_text} + except urllib.error.HTTPError as e: + err_text = e.read().decode("utf-8", errors="replace") + try: + payload = json.loads(err_text) + except json.JSONDecodeError: + payload = {"error": err_text} + payload["http_status"] = e.code + return payload + except urllib.error.URLError as e: + return {"error": str(e)} + + +def _call_nanobanana_api( + prompt: str, + nanobanana_type: str, + nanobanana_num_images: int, + nanobanana_image_size_mode: str, + nanobanana_api_key: str, + image_urls_input: str, + seed: int, + nanobanana_watermark: str, + nanobanana_server_url: str, + fallback_image_url: str, + poll_interval: int, + poll_timeout: int, + max_retries: int = 3, + retry_interval: int = 2, +) -> Tuple[Any, str, str, str]: + """调用NanoBanana API的并行执行函数(支持重试)""" + last_error = None + + for attempt in range(max_retries): + try: + if attempt > 0: + print(f"TripleAPI: NanoBanana第{attempt + 1}次重试...") + else: + print(f"TripleAPI: 开始调用NanoBanana API,类型: {nanobanana_type}") + + nanobanana_node = NanoBananaGenerate() + task_id, response_json, result_image = nanobanana_node.generate( + prompt=prompt, + type=nanobanana_type, + num_images=nanobanana_num_images, + api_key=nanobanana_api_key, + image_urls_json=image_urls_input or "[]", + seed=seed, + watermark=nanobanana_watermark, + image_size_mode=nanobanana_image_size_mode, + server=nanobanana_server_url, + timeout_seconds=poll_timeout, + wait_for_result=True, + poll_interval_seconds=poll_interval, + poll_timeout_seconds=poll_timeout, + ) + + # 检查响应是否包含错误(API调用失败的情况) + has_error = False + error_msg = None + try: + if response_json: + response_data = json.loads(response_json) if isinstance(response_json, str) else response_json + # 检查是否有错误字段或HTTP错误状态 + if isinstance(response_data, dict): + if "error" in response_data or "http_status" in response_data: + has_error = True + error_msg = response_data.get("error", f"HTTP错误: {response_data.get('http_status', 'unknown')}") + except Exception: + pass # 如果解析失败,继续检查其他条件 + + # 如果result_image为None且task_id为空,说明API调用失败,应该重试 + if result_image is None and (not task_id or has_error): + if has_error: + error_msg = error_msg or "API返回错误响应" + else: + error_msg = "未获取到任务ID,API调用可能失败" + # 抛出异常以触发重试逻辑 + raise Exception(error_msg) + + if result_image is not None: + status_info = f"NanoBanana: 成功(第{attempt + 1}次尝试); " + print(f"TripleAPI: NanoBanana成功,图片形状: {result_image.shape}") + else: + # 这种情况应该是正常情况(比如用户选择不等待结果) + status_info = f"NanoBanana: 成功但无图片(第{attempt + 1}次尝试); " + print("TripleAPI: NanoBanana成功但未返回图片") + + return result_image, task_id, response_json, status_info + + except Exception as e: + last_error = e + print(f"TripleAPI: NanoBanana第{attempt + 1}次尝试失败: {str(e)}") + if attempt < max_retries - 1: + print(f"TripleAPI: NanoBanana将在{retry_interval}秒后重试...") + time.sleep(retry_interval) # 固定重试间隔 + + # 所有重试都失败了 + status_info = f"NanoBanana: 失败(已重试{max_retries}次) - {str(last_error)}; " + print(f"TripleAPI: NanoBanana最终失败: {str(last_error)}") + result_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + response_json = json.dumps({"error": str(last_error), "retries": max_retries}, ensure_ascii=False) + return result_image, "", response_json, status_info + + +def _call_doubao_api( + prompt: str, + doubao_type: str, + doubao_api_key: str, + image_urls_input: str, + seed: int, + doubao_model: str, + doubao_size: str, + doubao_watermark: bool, + doubao_sequential_generation: str, + doubao_num_images: int, + fallback_image_url: str, + poll_timeout: int, + max_retries: int = 3, + retry_interval: int = 2, + doubao_server_url: str = "https://ark.cn-beijing.volces.com", +) -> Tuple[Any, str, str]: + """调用豆包API的并行执行函数(支持重试)""" + last_error = None + + for attempt in range(max_retries): + try: + if attempt > 0: + print(f"TripleAPI: 豆包第{attempt + 1}次重试...") + else: + print(f"TripleAPI: 开始调用豆包 API,类型: {doubao_type}") + + # 从统一的图片输入中解析URL用于豆包 + doubao_image_urls = [] + if doubao_type in ["img2img", "multi_img_fusion"]: + if image_urls_input: + parsed_urls = _parse_image_urls_field(image_urls_input) + if parsed_urls: + if doubao_type == "img2img": + # 图生图模式:只使用第一张图片 + doubao_image_urls = [parsed_urls[0]] + print(f"TripleAPI: 豆包图生图模式,使用第一张图片: {doubao_image_urls[0]}") + elif doubao_type == "multi_img_fusion": + # 多图融合模式:使用所有图片 + doubao_image_urls = parsed_urls + print(f"TripleAPI: 豆包多图融合模式,使用所有图片: {len(doubao_image_urls)}张") + + if not doubao_image_urls: + status_info = "豆包: 图生图模式需要提供图片URL; " + print("TripleAPI: 豆包图生图模式需要提供图片URL") + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + doubao_response = json.dumps({"error": "图生图模式需要提供图片URL"}, ensure_ascii=False) + return doubao_image, doubao_response, status_info + else: + # 有图片URL,继续API调用 + print(f"TripleAPI: 豆包使用图片URLs: {doubao_image_urls}") + doubao_result = _send_doubao_request( + api_key=doubao_api_key, + prompt=prompt, + size=doubao_size, + model=doubao_model, + watermark=doubao_watermark, + seed=(None if seed < 0 else seed), + image_urls=doubao_image_urls, + sequential_generation=doubao_sequential_generation, + num_images=doubao_num_images, + timeout_seconds=poll_timeout, + server_url=doubao_server_url, + ) + + doubao_response = json.dumps(doubao_result, ensure_ascii=False) + print(f"TripleAPI: 豆包API响应: {doubao_result}") + + # 检查响应是否包含错误(API调用失败的情况) + has_error = False + error_msg = None + if isinstance(doubao_result, dict): + if "error" in doubao_result or "http_status" in doubao_result: + has_error = True + error_msg = doubao_result.get("error", f"HTTP错误: {doubao_result.get('http_status', 'unknown')}") + + # 如果API返回错误,抛出异常以触发重试逻辑 + if has_error: + raise Exception(error_msg or "API返回错误响应") + + # 提取图片URL并下载(支持多张组图) + if "data" in doubao_result and isinstance(doubao_result["data"], list) and doubao_result["data"]: + image_urls = [] + for item in doubao_result["data"]: + if isinstance(item, dict) and "url" in item: + image_urls.append(item["url"]) + + if image_urls: + try: + print(f"TripleAPI: 豆包下载{len(image_urls)}张图片") + images_tensors = [] + target_size = None + + for idx, image_url in enumerate(image_urls): + print(f"TripleAPI: 豆包下载图片 {idx+1}/{len(image_urls)}: {image_url}") + if target_size is None: + tensor, wh = _download_image_to_tensor_with_size(image_url, return_size=True, timeout_seconds=min(poll_timeout, 60)) + target_size = wh + images_tensors.append(tensor) + else: + tensor = _download_image_to_tensor_with_size(image_url, target_size=target_size, timeout_seconds=min(poll_timeout, 60)) + images_tensors.append(tensor) + + if images_tensors: + doubao_image = torch.cat(images_tensors, dim=0) + status_info = f"豆包: 成功({doubao_type}, {len(images_tensors)}张图片, 第{attempt + 1}次尝试); " + print(f"TripleAPI: 豆包成功,图片形状: {doubao_image.shape}") + else: + status_info = "豆包: 所有图片下载失败; " + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + except Exception as e: + status_info = f"豆包: 图片下载失败 - {str(e)}; " + print(f"TripleAPI: 豆包图片下载失败: {str(e)}") + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + else: + status_info = "豆包: 响应中未找到图片URL; " + print("TripleAPI: 豆包响应中未找到图片URL") + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + else: + status_info = "豆包: API响应格式错误; " + print("TripleAPI: 豆包API响应格式错误") + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + else: + # 文生图模式,不需要图片 + print("TripleAPI: 豆包文生图模式") + doubao_result = _send_doubao_request( + api_key=doubao_api_key, + prompt=prompt, + size=doubao_size, + model=doubao_model, + watermark=doubao_watermark, + seed=(None if seed < 0 else seed), + image_urls=None, + sequential_generation=doubao_sequential_generation, + num_images=doubao_num_images, + timeout_seconds=poll_timeout, + server_url=doubao_server_url, + ) + + doubao_response = json.dumps(doubao_result, ensure_ascii=False) + print(f"TripleAPI: 豆包API响应: {doubao_result}") + + # 检查响应是否包含错误(API调用失败的情况) + has_error = False + error_msg = None + if isinstance(doubao_result, dict): + if "error" in doubao_result or "http_status" in doubao_result: + has_error = True + error_msg = doubao_result.get("error", f"HTTP错误: {doubao_result.get('http_status', 'unknown')}") + + # 如果API返回错误,抛出异常以触发重试逻辑 + if has_error: + raise Exception(error_msg or "API返回错误响应") + + # 提取图片URL并下载(支持多张组图) + if "data" in doubao_result and isinstance(doubao_result["data"], list) and doubao_result["data"]: + image_urls = [] + for item in doubao_result["data"]: + if isinstance(item, dict) and "url" in item: + image_urls.append(item["url"]) + + if image_urls: + try: + print(f"TripleAPI: 豆包下载{len(image_urls)}张图片") + images_tensors = [] + target_size = None + + for idx, image_url in enumerate(image_urls): + print(f"TripleAPI: 豆包下载图片 {idx+1}/{len(image_urls)}: {image_url}") + if target_size is None: + tensor, wh = _download_image_to_tensor_with_size(image_url, return_size=True, timeout_seconds=min(poll_timeout, 60)) + target_size = wh + images_tensors.append(tensor) + else: + tensor = _download_image_to_tensor_with_size(image_url, target_size=target_size, timeout_seconds=min(poll_timeout, 60)) + images_tensors.append(tensor) + + if images_tensors: + doubao_image = torch.cat(images_tensors, dim=0) + status_info = f"豆包: 成功(txt2img, {len(images_tensors)}张图片, 第{attempt + 1}次尝试); " + print(f"TripleAPI: 豆包成功,图片形状: {doubao_image.shape}") + else: + status_info = "豆包: 所有图片下载失败; " + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + except Exception as e: + status_info = f"豆包: 图片下载失败 - {str(e)}; " + print(f"TripleAPI: 豆包图片下载失败: {str(e)}") + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + else: + status_info = "豆包: 响应中未找到图片URL; " + print("TripleAPI: 豆包响应中未找到图片URL") + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + else: + status_info = "豆包: API响应格式错误; " + print("TripleAPI: 豆包API响应格式错误") + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + + return doubao_image, doubao_response, status_info + + except Exception as e: + last_error = e + print(f"TripleAPI: 豆包第{attempt + 1}次尝试失败: {str(e)}") + if attempt < max_retries - 1: + print(f"TripleAPI: 豆包将在{retry_interval}秒后重试...") + time.sleep(retry_interval) # 固定重试间隔 + + # 所有重试都失败了 + status_info = f"豆包: 失败(已重试{max_retries}次) - {str(last_error)}; " + print(f"TripleAPI: 豆包最终失败: {str(last_error)}") + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + doubao_response = json.dumps({"error": str(last_error), "retries": max_retries}, ensure_ascii=False) + return doubao_image, doubao_response, status_info + + +def _call_hidream_api( + prompt: str, + hidream_type: str, + hidream_req_key: str, + image_urls_input: str, + seed: int, + hidream_scale: float, + fallback_image_url: str, + poll_interval: int, + poll_timeout: int, + max_retries: int = 3, + retry_interval: int = 2, + hidream_server_url: str = "https://visual.volcengineapi.com", +) -> Tuple[Any, str, str, str]: + """调用即梦API的并行执行函数(支持重试)""" + last_error = None + + for attempt in range(max_retries): + try: + if attempt > 0: + print(f"TripleAPI: 即梦第{attempt + 1}次重试...") + else: + print(f"TripleAPI: 开始调用即梦 API,类型: {hidream_type}") + + # 从统一的图片输入中解析URLs用于即梦(支持多图,最多10张) + hidream_image_urls = [] + if image_urls_input: + parsed_urls = _parse_image_urls_field(image_urls_input) + if parsed_urls: + # 限制最多10张图片 + hidream_image_urls = parsed_urls[:10] if len(parsed_urls) > 10 else parsed_urls + if len(parsed_urls) > 10: + print(f"TripleAPI: 即梦输入图片超过10张,已截取前10张: {len(hidream_image_urls)}张") + print(f"TripleAPI: 即梦使用图片URLs: {hidream_image_urls}") + + hidream_result = _send_hidream_request_multi( + req_key=hidream_req_key, + prompt=prompt, + hidream_type=hidream_type, + image_urls=hidream_image_urls if hidream_image_urls else None, + seed=(None if seed < 0 else seed), + scale=hidream_scale, + timeout_seconds=poll_timeout, + server_url=hidream_server_url, + ) + + hidream_response = json.dumps(hidream_result, ensure_ascii=False) + print(f"TripleAPI: 即梦API响应: {hidream_result}") + + # 检查响应是否包含错误(API调用失败的情况) + has_error = False + error_msg = None + if isinstance(hidream_result, dict): + # 检查HTTP错误状态 + if "http_status" in hidream_result: + has_error = True + error_msg = f"HTTP错误: {hidream_result.get('http_status', 'unknown')}" + # 检查即梦API错误码(非10000表示错误) + elif "code" in hidream_result and hidream_result["code"] != 10000: + has_error = True + error_msg = hidream_result.get("message", f"API错误码: {hidream_result.get('code', 'unknown')}") + # 检查是否有error字段 + elif "error" in hidream_result: + has_error = True + error_msg = hidream_result.get("error", "API返回错误") + + # 如果API返回错误,抛出异常以触发重试逻辑 + if has_error: + raise Exception(error_msg or "API返回错误响应") + + # 根据API响应处理结果 (即梦API 4.0使用10000作为成功码) + if "code" in hidream_result and hidream_result["code"] == 10000: + # 成功响应,检查是否有task_id需要轮询 + if "data" in hidream_result and "task_id" in hidream_result["data"]: + hidream_task_id = str(hidream_result["data"]["task_id"]) + + # 轮询任务状态 + print(f"即梦: 开始轮询任务 {hidream_task_id}") + start_time = time.time() + poll_count = 0 + while time.time() - start_time < poll_timeout: + poll_count += 1 + try: + print(f"即梦: 第{poll_count}次查询状态...") + status_result = _check_hidream_status( + req_key=hidream_req_key, + task_id=hidream_task_id, + timeout_seconds=poll_timeout, + server_url=hidream_server_url, + ) + + print(f"即梦: 状态查询响应: {json.dumps(status_result, ensure_ascii=False)}") + + # 检查状态查询响应是否包含HTTP错误(需要重试整个API调用) + if isinstance(status_result, dict) and "http_status" in status_result: + http_status = status_result.get("http_status", 0) + if http_status >= 500: # 5xx错误表示服务器错误,应该重试 + error_msg = f"状态查询HTTP错误: {http_status}" + raise Exception(error_msg) + + if "code" in status_result and status_result["code"] == 10000: + if "data" in status_result and "status" in status_result["data"]: + task_status = status_result["data"]["status"] + print(f"即梦: 任务状态: {task_status}") + + if task_status == "done": + # 任务完成,提取图片URLs(支持多张图片) + image_urls = [] + if "image_urls" in status_result["data"] and status_result["data"]["image_urls"]: + image_urls = status_result["data"]["image_urls"] + elif "image_url" in status_result["data"]: + image_urls = [status_result["data"]["image_url"]] + + print(f"即梦: 提取到{len(image_urls)}个图片URL") + + if image_urls: + try: + # 下载所有图片并合并(类似豆包的处理方式) + print(f"TripleAPI: 即梦下载{len(image_urls)}张图片") + images_tensors = [] + target_size = None + + for idx, image_url in enumerate(image_urls): + print(f"TripleAPI: 即梦下载图片 {idx+1}/{len(image_urls)}: {image_url}") + if target_size is None: + tensor, wh = _download_image_to_tensor_with_size(image_url, return_size=True, timeout_seconds=min(poll_timeout, 60)) + target_size = wh + images_tensors.append(tensor) + else: + tensor = _download_image_to_tensor_with_size(image_url, target_size=target_size, timeout_seconds=min(poll_timeout, 60)) + images_tensors.append(tensor) + + # 合并所有图片为一张 + if images_tensors: + hidream_image = torch.cat(images_tensors, dim=0) + print(f"TripleAPI: 即梦成功合并{len(images_tensors)}张图片") + else: + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + + status_info = f"即梦: 成功(第{attempt + 1}次尝试); " + hidream_response = json.dumps(status_result, ensure_ascii=False) + print("即梦: 图片下载成功!") + return hidream_image, hidream_response, hidream_task_id, status_info + except Exception as e: + status_info = f"即梦: 图片下载失败 - {str(e)}; " + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + print(f"即梦: 图片下载失败 - {str(e)}") + return hidream_image, hidream_response, hidream_task_id, status_info + else: + status_info = "即梦: 响应中未找到图片URL; " + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + print("即梦: 响应中未找到图片URL") + return hidream_image, hidream_response, hidream_task_id, status_info + elif task_status in ["not_found", "expired", "failed"]: + error_msg = status_result.get("message", f"任务{task_status}") + status_info = f"即梦: {error_msg}; " + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + print(f"即梦: 任务失败 - {error_msg}") + return hidream_image, hidream_response, hidream_task_id, status_info + # 如果是 in_queue, generating 或其他状态,继续等待 + else: + print(f"即梦: 任务进行中,状态: {task_status},继续等待...") + else: + print(f"即梦: 状态响应格式异常: {status_result}") + status_info = f"即梦: 状态响应格式异常; " + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + return hidream_image, hidream_response, hidream_task_id, status_info + else: + # API返回错误 + error_msg = status_result.get("message", "API查询失败") + status_info = f"即梦: 查询错误 - {error_msg}; " + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + print(f"即梦: API查询错误 - {error_msg}") + return hidream_image, hidream_response, hidream_task_id, status_info + + time.sleep(poll_interval) + + except Exception as e: + status_info = f"即梦: 状态查询错误 - {str(e)}; " + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + print(f"即梦: 状态查询异常 - {str(e)}") + return hidream_image, hidream_response, hidream_task_id, status_info + else: + # 超时 + status_info = "即梦: 轮询超时; " + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + return hidream_image, hidream_response, hidream_task_id, status_info + + # 检查是否直接返回了图片URLs(某些接口可能直接返回) + elif "data" in hidream_result and ("image_url" in hidream_result["data"] or "image_urls" in hidream_result["data"]): + image_urls = [] + if "image_urls" in hidream_result["data"] and hidream_result["data"]["image_urls"]: + image_urls = hidream_result["data"]["image_urls"] + elif "image_url" in hidream_result["data"]: + image_urls = [hidream_result["data"]["image_url"]] + + if image_urls: + try: + # 下载所有图片并合并(类似豆包的处理方式) + print(f"TripleAPI: 即梦直接返回{len(image_urls)}张图片") + images_tensors = [] + target_size = None + + for idx, image_url in enumerate(image_urls): + print(f"TripleAPI: 即梦下载图片 {idx+1}/{len(image_urls)}: {image_url}") + if target_size is None: + tensor, wh = _download_image_to_tensor_with_size(image_url, return_size=True, timeout_seconds=min(poll_timeout, 60)) + target_size = wh + images_tensors.append(tensor) + else: + tensor = _download_image_to_tensor_with_size(image_url, target_size=target_size, timeout_seconds=min(poll_timeout, 60)) + images_tensors.append(tensor) + + # 合并所有图片为一张 + if images_tensors: + hidream_image = torch.cat(images_tensors, dim=0) + print(f"TripleAPI: 即梦成功合并{len(images_tensors)}张图片") + else: + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + + status_info = f"即梦: 成功(第{attempt + 1}次尝试); " + return hidream_image, hidream_response, "", status_info + except Exception as e: + status_info = f"即梦: 图片下载失败 - {str(e)}; " + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + return hidream_image, hidream_response, "", status_info + else: + status_info = "即梦: API响应格式错误; " + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + return hidream_image, hidream_response, "", status_info + else: + # 这种情况不应该发生,因为已经在前面检查过了,但为了安全起见保留 + error_msg = hidream_result.get("message", "未知错误") + status_info = f"即梦: API错误 - {error_msg}; " + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + # 如果code不是10000,抛出异常以触发重试 + raise Exception(f"API错误: {error_msg}") + + except Exception as e: + last_error = e + print(f"TripleAPI: 即梦第{attempt + 1}次尝试失败: {str(e)}") + if attempt < max_retries - 1: + print(f"TripleAPI: 即梦将在{retry_interval}秒后重试...") + time.sleep(retry_interval) # 固定重试间隔 + + # 所有重试都失败了 + status_info = f"即梦: 失败(已重试{max_retries}次) - {str(last_error)}; " + print(f"TripleAPI: 即梦最终失败: {str(last_error)}") + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + hidream_response = json.dumps({"error": str(last_error), "retries": max_retries}, ensure_ascii=False) + return hidream_image, hidream_response, "", status_info + + +class TripleAPIGenerate: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "prompt": ("STRING", {"multiline": True, "default": ""}), + # API网址配置(按顺序:网址在前,KEY在后) + "nanobanana_server_url": ("STRING", {"default": DEFAULT_SERVER_URL, "multiline": False}), + "nanobanana_api_key": ("STRING", {"default": "", "multiline": False}), + "doubao_server_url": ("STRING", {"default": "https://ark.cn-beijing.volces.com/api/v3/images/generations", "multiline": False}), + "doubao_api_key": ("STRING", {"default": "", "multiline": False}), + "hidream_server_url": ("STRING", {"default": "https://visual.volcengineapi.com", "multiline": False}), + "hidream_req_key": ("STRING", {"default": "", "multiline": False}), + # 平台开关 + "enable_nanobanana": ("BOOLEAN", {"default": True}), + "enable_doubao": ("BOOLEAN", {"default": True}), + "enable_hidream": ("BOOLEAN", {"default": True}), + "seed": ("INT", {"default": -1, "min": -1, "max": 99999999}), + }, + "optional": { + # 通用图片输入(统一所有API使用) + "image_urls_input": ("STRING", {"default": "", "multiline": True}), + + # 通用轮询和超时设置 + "poll_interval": ("INT", {"default": 3, "min": 1, "max": 60}), + "poll_timeout": ("INT", {"default": 240, "min": 5, "max": 3600}), + + # 错误回退图片(错误时优先返回该URL图片,否则返白图) + "fallback_image_url": ("STRING", {"default": "", "multiline": False}), + + # NanoBanana 参数 + "nanobanana_type": (["TEXTTOIAMGE", "IMAGETOIAMGE"], {"default": "TEXTTOIAMGE"}), + "nanobanana_num_images": ("INT", {"default": 1, "min": 1, "max": 4}), + "nanobanana_image_size_mode": (["auto", "1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5", "21:9"], {"default": "auto"}), + "nanobanana_watermark": ("STRING", {"default": ""}), + + # 豆包参数 + "doubao_model": ("STRING", {"default": "doubao-seedream-4-0-250828"}), + "doubao_size": (["1K", "2K", "4K"], {"default": "1K"}), + "doubao_watermark": ("BOOLEAN", {"default": True}), + "doubao_type": (["txt2img", "img2img", "multi_img_fusion"], {"default": "txt2img"}), + "doubao_sequential_generation": (["disabled", "auto"], {"default": "disabled"}), + "doubao_num_images": ("INT", {"default": 1, "min": 1, "max": 15}), + + # 即梦参数 + "hidream_type": (["txt2img", "img2img", "shopImg2img", "vton"], {"default": "txt2img"}), + "hidream_scale": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 2.0, "step": 0.1}), + + # 重试参数 + "max_retries": ("INT", {"default": 3, "min": 1, "max": 5}), + "retry_interval": ("INT", {"default": 2, "min": 1, "max": 60}), + }, + } + + RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("nanobanana_image", "doubao_image", "hidream_image", "nanobanana_task_id", "nanobanana_response", "doubao_response", "hidream_response", "hidream_task_id", "status_info", "nanobanana_image_urls", "doubao_image_urls", "hidream_image_urls") + FUNCTION = "triple_generate" + CATEGORY = "AIYang_TripleAPI" + + def triple_generate( + self, + prompt: str, + # API网址配置(按顺序:网址在前,KEY在后) + nanobanana_server_url: str = DEFAULT_SERVER_URL, + nanobanana_api_key: str = "", + doubao_server_url: str = "https://ark.cn-beijing.volces.com/api/v3/images/generations", + doubao_api_key: str = "", + hidream_server_url: str = "https://visual.volcengineapi.com", + hidream_req_key: str = "", + # 平台开关 + enable_nanobanana: bool = True, + enable_doubao: bool = True, + enable_hidream: bool = True, + seed: int = -1, + # 通用图片输入 + image_urls_input: str = "", + # 错误回退图片 + fallback_image_url: str = "", + # 通用轮询和超时设置 + poll_interval: int = 3, + poll_timeout: int = 240, + # NanoBanana 参数 + nanobanana_type: str = "TEXTTOIAMGE", + nanobanana_num_images: int = 1, + nanobanana_image_size_mode: str = "auto", + nanobanana_watermark: str = "", + # 豆包参数 + doubao_model: str = "doubao-seedream-4-0-250828", + doubao_size: str = "1K", + doubao_watermark: bool = True, + doubao_type: str = "txt2img", + doubao_sequential_generation: str = "disabled", + doubao_num_images: int = 1, + # 即梦参数 + hidream_type: str = "txt2img", + hidream_scale: float = 0.5, + # 重试参数 + max_retries: int = 3, + retry_interval: int = 2, + ) -> Tuple[Any, Any, Any, str, str, str, str, str, str, str, str, str]: + + if not prompt: + raise ValueError("提示词不能为空") + + # 限制种子值范围 + if seed is not None and seed > 99999999: + seed = seed % 100000000 + + # 初始化结果变量 + nanobanana_image = None + doubao_image = None + hidream_image = None + nanobanana_task_id = "" + nanobanana_response = "" + doubao_response = "" + hidream_response = "" + hidream_task_id = "" + status_info = "" + + # 初始化图片URL数组变量 + nanobanana_image_urls = "[]" + doubao_image_urls = "[]" + hidream_image_urls = "[]" + + # 并行调用三个API + print("TripleAPI: 开始并行调用三个API...") + + # 准备并行任务 + tasks = [] + + # NanoBanana任务 + if enable_nanobanana and nanobanana_api_key: + tasks.append(('nanobanana', _call_nanobanana_api, { + 'prompt': prompt, + 'nanobanana_type': nanobanana_type, + 'nanobanana_num_images': nanobanana_num_images, + 'nanobanana_image_size_mode': nanobanana_image_size_mode, + 'nanobanana_api_key': nanobanana_api_key, + 'image_urls_input': image_urls_input, + 'seed': seed, + 'nanobanana_watermark': nanobanana_watermark, + 'nanobanana_server_url': nanobanana_server_url, + 'fallback_image_url': fallback_image_url, + 'poll_interval': poll_interval, + 'poll_timeout': poll_timeout, + 'max_retries': max_retries, # 使用用户配置的重试次数 + 'retry_interval': retry_interval, # 使用用户配置的重试间隔 + })) + else: + status_info += "NanoBanana: 已禁用或未提供API密钥; " + print("TripleAPI: NanoBanana已禁用或未提供API密钥") + nanobanana_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + nanobanana_task_id = "" + nanobanana_response = json.dumps({"error": "API已禁用或未提供密钥"}, ensure_ascii=False) + nanobanana_image_urls = json.dumps(["api_disabled"], ensure_ascii=False) + + # 豆包任务 + if enable_doubao and doubao_api_key: + tasks.append(('doubao', _call_doubao_api, { + 'prompt': prompt, + 'doubao_type': doubao_type, + 'doubao_api_key': doubao_api_key, + 'image_urls_input': image_urls_input, + 'seed': seed, + 'doubao_model': doubao_model, + 'doubao_size': doubao_size, + 'doubao_watermark': doubao_watermark, + 'doubao_sequential_generation': doubao_sequential_generation, + 'doubao_num_images': doubao_num_images, + 'fallback_image_url': fallback_image_url, + 'poll_timeout': poll_timeout, + 'max_retries': max_retries, # 使用用户配置的重试次数 + 'retry_interval': retry_interval, # 使用用户配置的重试间隔 + 'doubao_server_url': doubao_server_url, + })) + else: + status_info += "豆包: 已禁用或未提供API密钥; " + print("TripleAPI: 豆包已禁用或未提供API密钥") + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + doubao_response = json.dumps({"error": "API已禁用或未提供密钥"}, ensure_ascii=False) + doubao_image_urls = json.dumps(["api_disabled"], ensure_ascii=False) + + # 即梦任务 + if enable_hidream and hidream_req_key: + tasks.append(('hidream', _call_hidream_api, { + 'prompt': prompt, + 'hidream_type': hidream_type, + 'hidream_req_key': hidream_req_key, + 'image_urls_input': image_urls_input, + 'seed': seed, + 'hidream_scale': hidream_scale, + 'fallback_image_url': fallback_image_url, + 'poll_interval': poll_interval, + 'poll_timeout': poll_timeout, + 'max_retries': max_retries, # 使用用户配置的重试次数 + 'retry_interval': retry_interval, # 使用用户配置的重试间隔 + 'hidream_server_url': hidream_server_url, + })) + else: + status_info += "即梦: 已禁用或未提供API密钥; " + print("TripleAPI: 即梦已禁用或未提供API密钥") + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + hidream_response = json.dumps({"error": "API已禁用或未提供密钥"}, ensure_ascii=False) + hidream_task_id = "" + hidream_image_urls = json.dumps(["api_disabled"], ensure_ascii=False) + + # 使用线程池并行执行 + with ThreadPoolExecutor(max_workers=3) as executor: + # 提交所有任务 + future_to_api = { + executor.submit(func, **kwargs): api_name + for api_name, func, kwargs in tasks + } + + # 等待所有任务完成并收集结果 + for future in as_completed(future_to_api): + api_name = future_to_api[future] + try: + result = future.result() + + if api_name == 'nanobanana': + nanobanana_image, nanobanana_task_id, nanobanana_response, api_status = result + status_info += api_status + # 提取NanoBanana图片URLs + try: + response_data = json.loads(nanobanana_response) + urls = _extract_image_urls_from_response(response_data) + if urls: + nanobanana_image_urls = json.dumps(urls, ensure_ascii=False) + print(f"TripleAPI: NanoBanana提取到{len(urls)}个URL") + else: + nanobanana_image_urls = json.dumps(["api_error"], ensure_ascii=False) + except Exception as e: + print(f"TripleAPI: NanoBanana URL提取失败: {str(e)}") + nanobanana_image_urls = json.dumps(["api_error"], ensure_ascii=False) + elif api_name == 'doubao': + doubao_image, doubao_response, api_status = result + status_info += api_status + # 提取豆包图片URLs + try: + response_data = json.loads(doubao_response) + urls = _extract_image_urls_from_response(response_data) + if urls: + doubao_image_urls = json.dumps(urls, ensure_ascii=False) + print(f"TripleAPI: 豆包提取到{len(urls)}个URL") + else: + doubao_image_urls = json.dumps(["api_error"], ensure_ascii=False) + except Exception as e: + print(f"TripleAPI: 豆包URL提取失败: {str(e)}") + doubao_image_urls = json.dumps(["api_error"], ensure_ascii=False) + elif api_name == 'hidream': + hidream_image, hidream_response, hidream_task_id, api_status = result + status_info += api_status + # 提取即梦图片URLs + try: + response_data = json.loads(hidream_response) + urls = _extract_image_urls_from_response(response_data) + if urls: + hidream_image_urls = json.dumps(urls, ensure_ascii=False) + print(f"TripleAPI: 即梦提取到{len(urls)}个URL") + else: + hidream_image_urls = json.dumps(["api_error"], ensure_ascii=False) + except Exception as e: + print(f"TripleAPI: 即梦URL提取失败: {str(e)}") + hidream_image_urls = json.dumps(["api_error"], ensure_ascii=False) + + except Exception as e: + print(f"TripleAPI: {api_name} 并行执行异常: {str(e)}") + if api_name == 'nanobanana': + nanobanana_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + nanobanana_task_id = "" + nanobanana_response = json.dumps({"error": str(e)}, ensure_ascii=False) + status_info += f"NanoBanana: 并行执行异常 - {str(e)}; " + nanobanana_image_urls = json.dumps(["api_error"], ensure_ascii=False) + elif api_name == 'doubao': + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + doubao_response = json.dumps({"error": str(e)}, ensure_ascii=False) + status_info += f"豆包: 并行执行异常 - {str(e)}; " + doubao_image_urls = json.dumps(["api_error"], ensure_ascii=False) + elif api_name == 'hidream': + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + hidream_task_id = "" + hidream_response = json.dumps({"error": str(e)}, ensure_ascii=False) + status_info += f"即梦: 并行执行异常 - {str(e)}; " + hidream_image_urls = json.dumps(["api_error"], ensure_ascii=False) + + print("TripleAPI: 所有API并行执行完成") + + # 确保所有图片都不为空 + if nanobanana_image is None: + print("TripleAPI: NanoBanana图片为空,使用白图占位") + nanobanana_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + if doubao_image is None: + print("TripleAPI: 豆包图片为空,使用白图占位") + doubao_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + if hidream_image is None: + print("TripleAPI: 即梦图片为空,使用白图占位") + hidream_image = _create_fallback_or_white_image_tensor( + fallback_image_url=fallback_image_url, + timeout_seconds=min(poll_timeout, 60), + ) + + print(f"TripleAPI: 最终状态信息: {status_info.strip()}") + return ( + nanobanana_image, + doubao_image, + hidream_image, + nanobanana_task_id, + nanobanana_response, + doubao_response, + hidream_response, + hidream_task_id, + status_info.strip(), + nanobanana_image_urls, + doubao_image_urls, + hidream_image_urls + ) + + +NODE_CLASS_MAPPINGS = { + "NanoBananaGenerate": NanoBananaGenerate, + "TripleAPIGenerate": TripleAPIGenerate, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "NanoBananaGenerate": "NanoBanana Generate", + "TripleAPIGenerate": "Triple API Generate (NanoBanana + Doubao + HiDream)", +} + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c019770 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Pillow>=9.0.0 +numpy>=1.21.0 +torch>=1.12.0 +