From ba920f2b6624018b9aadcdefc7966c988448657a Mon Sep 17 00:00:00 2001 From: Jony <951565127@qq.com> Date: Thu, 24 Sep 2026 19:56:48 +0800 Subject: [PATCH] Publish current ComfyUI O1Key code baseline Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source. --- .cursorrules | 756 --- .gitignore | 10 + AGENTS.md | 86 + README.md | 296 +- V3_DEV_GUIDE.py | 148 - __init__.py | 806 ++- cases/初始案例-亮度对比.json | 440 ++ clients/__init__.py | 44 +- clients/base_async_provider.py | 183 - clients/element_client.py | 74 + clients/flux_edit_client.py | 4 +- clients/gemini_async_provider.py | 194 - clients/gemini_client.py | 118 +- clients/gpt_image_client.py | 1232 ++-- clients/grok_image_client.py | 149 +- clients/grok_video_client.py | 646 +-- clients/kling_client.py | 285 - clients/minimax_h3_client.py | 242 + clients/newapi_veo_client.py | 63 +- clients/omni_flash_client.py | 358 ++ clients/openai_client.py | 784 --- clients/seedance_client.py | 79 +- clients/seedance_element_client.py | 320 ++ clients/seedream_image_client.py | 409 ++ clients/sora_client.py | 27 +- clients/veo_client.py | 27 +- docs/README.md | 33 + docs/architecture.md | 261 + docs/configuration.md | 59 + docs/decisions/0000-template.md | 25 + .../0001-ai-native-repository-guidance.md | 39 + .../0002-save-node-image-format-ownership.md | 37 + ...enerator-owned-image-save-configuration.md | 40 + ...ive-recoverable-image-workflow-metadata.md | 36 + ...-persistent-image-job-history-summaries.md | 35 + ...6-grok-video-api-and-workflow-migration.md | 46 + .../decisions/0007-panel-driven-video-jobs.md | 33 + .../0008-native-video-save-outputs.md | 30 + ...09-remove-unified-image-external-prompt.md | 25 + ...d-gpt-parameters-and-moderation-removal.md | 23 + ...ompt-optimization-and-colour-correction.md | 21 + ...2-batch-nano-save-and-seed-widget-order.md | 21 + ...emove-batch-nano-random-image-selection.md | 21 + .../0014-new-release-code-baseline.md | 25 + docs/decisions/README.md | 28 + docs/development.md | 108 + docs/maintenance/cleanup-2026-08-29.md | 74 + docs/testing.md | 53 + mockups/note-sidebar-sketch.html | 555 -- models_config.py | 8 +- nodes/AGENTS.md | 13 + nodes/K3_motion_control.py | 286 +- nodes/K3_video.py | 1189 +++- nodes/K3_video_firstlast.py | 283 - nodes/K_video_firstlast.py | 254 - nodes/K_video_image2video.py | 242 - nodes/__init__.py | 28 +- nodes/auto_red_cast.py | 289 + nodes/batch_nano_banana.py | 1313 +++-- nodes/color_remove_bg.py | 93 - nodes/flux_edit.py | 8 +- nodes/google_gemini.py | 6 +- nodes/gpt_image.py | 380 +- nodes/gpt_image_batch.py | 479 ++ nodes/grid_splitter.py | 32 +- nodes/grok_image.py | 7 +- nodes/grok_video.py | 426 +- nodes/kling_video.py | 738 --- nodes/load_images_from_folder.py | 136 + nodes/minimax_h3_video.py | 579 ++ nodes/nano_banana.py | 415 +- nodes/nano_banana_v2.py | 1237 ---- nodes/newapi_veo_video.py | 8 +- nodes/o1key_image_generator.py | 1034 ++++ nodes/o1key_video_generator.py | 196 + nodes/omni_flash_video.py | 182 + nodes/prompt_multi_function.py | 198 + nodes/save_image_format.py | 62 +- nodes/seedance_autopass.py | 881 +++ nodes/seedance_autopass_batch.py | 859 +++ nodes/seedance_element.py | 127 + nodes/seedance_video.py | 705 ++- nodes/sora_video.py | 6 +- nodes/universal_llm.py | 303 +- nodes/veo_video.py | 6 +- nodes/video_trim.py | 226 + prestartup_script.py | 24 + requirements.txt | 2 + tests/AGENTS.md | 11 + tests/run_all.py | 50 + tests/test_auto_red_cast.py | 46 + tests/test_chat_panel.py | 28 + tests/test_global_network_route.py | 60 + tests/test_gpt_image_nodes.py | 344 ++ tests/test_gpt_image_quality_frontend.mjs | 110 + tests/test_grok_image_references.py | 106 + tests/test_grok_video.py | 203 + tests/test_http2_client.py | 197 + tests/test_minimax_h3.py | 597 ++ tests/test_nano_banana_async_downloads.py | 418 ++ tests/test_nano_banana_dynamic_inputs.py | 334 ++ tests/test_nano_banana_temp_uploads.py | 389 ++ tests/test_o1key_image_generator.py | 1214 ++++ tests/test_o1key_image_generator_frontend.mjs | 4035 +++++++++++++ tests/test_o1key_image_jobs.py | 1558 +++++ tests/test_o1key_image_save.py | 338 ++ tests/test_o1key_image_thumbnail.py | 75 + tests/test_o1key_reference_image_editor.mjs | 90 + tests/test_o1key_update_button.mjs | 92 + tests/test_o1key_video_generator.py | 496 ++ tests/test_o1key_video_generator_frontend.mjs | 142 + tests/test_omni_flash.py | 261 + tests/test_omni_flash_frontend.mjs | 89 + tests/test_prompt_multi_function.py | 115 + tests/test_prompt_multi_function_dynamic.mjs | 96 + tests/test_prompt_multi_function_frontend.mjs | 45 + tests/test_prompt_optimizer.py | 176 + tests/test_reference_color_correction.py | 80 + tests/test_seedance_asset_service.py | 143 + tests/test_seedance_autopass_v3.py | 1037 ++++ tests/test_seedance_hc_assets.py | 151 + tests/test_seedance_parameter_sync.py | 294 + tests/test_seedream_image_client.py | 307 + tests/test_temp_media_uploads.py | 154 + tests/test_universal_llm.py | 40 + tests/test_updater.py | 60 +- tests/test_video_trim.py | 98 + tests/test_video_trim_frontend.mjs | 151 + update.bat | 73 - utils/__init__.py | 66 +- utils/chat_support.py | 691 +++ utils/color_key.py | 174 - utils/config.py | 67 +- utils/http2_client.py | 371 ++ utils/http_error.py | 204 +- utils/image_utils.py | 134 +- utils/minimax_h3_media.py | 231 + utils/nano_banana_async.py | 1570 ++++- utils/nano_banana_models.py | 49 + utils/o1key_image_catalog.py | 229 + utils/o1key_image_jobs.py | 2235 ++++++++ utils/o1key_image_save.py | 598 ++ utils/o1key_image_thumbnail.py | 140 + utils/o1key_video_catalog.py | 308 + utils/o1key_video_jobs.py | 1062 ++++ utils/r2_uploader.py | 230 +- utils/reference_color_correction.py | 332 ++ utils/seedance_assets.py | 257 + utils/update_checker.py | 148 - utils/updater.py | 79 +- utils/video_task.py | 292 +- version.txt | 1 - web/AGENTS.md | 13 + web/js/assetToggle.js | 2 +- web/js/batchNanoBananaImageQuality.js | 93 + web/js/batchNanoBananaReferenceLabels.js | 88 + web/js/casePanel.js | 280 + web/js/chatPanel.js | 72 +- web/js/dotGrid.js | 41 - web/js/elementPanel.js | 795 +++ web/js/gptImageBackgroundLabels.js | 34 + web/js/gptImageQuality.js | 60 + web/js/hideSidebar.js | 231 +- web/js/keepNodeSize.js | 59 + web/js/migrateWorkflow.js | 1455 +++++ web/js/minimaxH3ParameterGuard.js | 139 + web/js/nanoBananaRouteLabels.js | 36 + web/js/nanoBananaThinkingLevel.js | 88 + web/js/o1keyApiSettings.js | 416 ++ web/js/o1keyImageGenerator.js | 5096 +++++++++++++++++ web/js/o1keyReferenceImageEditor.js | 944 +++ web/js/o1keyUpdateButton.js | 100 + web/js/o1keyVideoGenerator.js | 1040 ++++ web/js/omniFlashVideo.js | 123 + web/js/promptLibrary.data.js | 80 + web/js/promptLibrary.js | 285 + web/js/promptMultiFunctionDynamic.js | 99 + web/js/restartButton.js | 175 - web/js/seedanceAutoPassDynamic.js | 301 + web/js/seedanceMultiModalDynamic.js | 95 + web/js/seedanceResolutionGuard.js | 137 + web/js/updateNotifier.js | 90 - web/js/videoTrim.js | 524 ++ 183 files changed, 49496 insertions(+), 9934 deletions(-) delete mode 100644 .cursorrules create mode 100644 AGENTS.md delete mode 100644 V3_DEV_GUIDE.py create mode 100644 cases/初始案例-亮度对比.json delete mode 100644 clients/base_async_provider.py create mode 100644 clients/element_client.py delete mode 100644 clients/gemini_async_provider.py delete mode 100644 clients/kling_client.py create mode 100644 clients/minimax_h3_client.py create mode 100644 clients/omni_flash_client.py delete mode 100644 clients/openai_client.py create mode 100644 clients/seedance_element_client.py create mode 100644 clients/seedream_image_client.py create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/decisions/0000-template.md create mode 100644 docs/decisions/0001-ai-native-repository-guidance.md create mode 100644 docs/decisions/0002-save-node-image-format-ownership.md create mode 100644 docs/decisions/0003-generator-owned-image-save-configuration.md create mode 100644 docs/decisions/0004-native-recoverable-image-workflow-metadata.md create mode 100644 docs/decisions/0005-persistent-image-job-history-summaries.md create mode 100644 docs/decisions/0006-grok-video-api-and-workflow-migration.md create mode 100644 docs/decisions/0007-panel-driven-video-jobs.md create mode 100644 docs/decisions/0008-native-video-save-outputs.md create mode 100644 docs/decisions/0009-remove-unified-image-external-prompt.md create mode 100644 docs/decisions/0010-unified-gpt-parameters-and-moderation-removal.md create mode 100644 docs/decisions/0011-remove-nano-prompt-optimization-and-colour-correction.md create mode 100644 docs/decisions/0012-batch-nano-save-and-seed-widget-order.md create mode 100644 docs/decisions/0013-remove-batch-nano-random-image-selection.md create mode 100644 docs/decisions/0014-new-release-code-baseline.md create mode 100644 docs/decisions/README.md create mode 100644 docs/development.md create mode 100644 docs/maintenance/cleanup-2026-08-29.md create mode 100644 docs/testing.md delete mode 100644 mockups/note-sidebar-sketch.html create mode 100644 nodes/AGENTS.md delete mode 100644 nodes/K3_video_firstlast.py delete mode 100644 nodes/K_video_firstlast.py delete mode 100644 nodes/K_video_image2video.py create mode 100644 nodes/auto_red_cast.py delete mode 100644 nodes/color_remove_bg.py create mode 100644 nodes/gpt_image_batch.py delete mode 100644 nodes/kling_video.py create mode 100644 nodes/load_images_from_folder.py create mode 100644 nodes/minimax_h3_video.py delete mode 100644 nodes/nano_banana_v2.py create mode 100644 nodes/o1key_image_generator.py create mode 100644 nodes/o1key_video_generator.py create mode 100644 nodes/omni_flash_video.py create mode 100644 nodes/prompt_multi_function.py create mode 100644 nodes/seedance_autopass.py create mode 100644 nodes/seedance_autopass_batch.py create mode 100644 nodes/seedance_element.py create mode 100644 nodes/video_trim.py create mode 100644 prestartup_script.py create mode 100644 tests/AGENTS.md create mode 100644 tests/run_all.py create mode 100644 tests/test_auto_red_cast.py create mode 100644 tests/test_chat_panel.py create mode 100644 tests/test_global_network_route.py create mode 100644 tests/test_gpt_image_nodes.py create mode 100644 tests/test_gpt_image_quality_frontend.mjs create mode 100644 tests/test_grok_image_references.py create mode 100644 tests/test_grok_video.py create mode 100644 tests/test_http2_client.py create mode 100644 tests/test_minimax_h3.py create mode 100644 tests/test_nano_banana_async_downloads.py create mode 100644 tests/test_nano_banana_dynamic_inputs.py create mode 100644 tests/test_nano_banana_temp_uploads.py create mode 100644 tests/test_o1key_image_generator.py create mode 100644 tests/test_o1key_image_generator_frontend.mjs create mode 100644 tests/test_o1key_image_jobs.py create mode 100644 tests/test_o1key_image_save.py create mode 100644 tests/test_o1key_image_thumbnail.py create mode 100644 tests/test_o1key_reference_image_editor.mjs create mode 100644 tests/test_o1key_update_button.mjs create mode 100644 tests/test_o1key_video_generator.py create mode 100644 tests/test_o1key_video_generator_frontend.mjs create mode 100644 tests/test_omni_flash.py create mode 100644 tests/test_omni_flash_frontend.mjs create mode 100644 tests/test_prompt_multi_function.py create mode 100644 tests/test_prompt_multi_function_dynamic.mjs create mode 100644 tests/test_prompt_multi_function_frontend.mjs create mode 100644 tests/test_prompt_optimizer.py create mode 100644 tests/test_reference_color_correction.py create mode 100644 tests/test_seedance_asset_service.py create mode 100644 tests/test_seedance_autopass_v3.py create mode 100644 tests/test_seedance_hc_assets.py create mode 100644 tests/test_seedance_parameter_sync.py create mode 100644 tests/test_seedream_image_client.py create mode 100644 tests/test_temp_media_uploads.py create mode 100644 tests/test_universal_llm.py create mode 100644 tests/test_video_trim.py create mode 100644 tests/test_video_trim_frontend.mjs delete mode 100644 update.bat create mode 100644 utils/chat_support.py delete mode 100644 utils/color_key.py create mode 100644 utils/http2_client.py create mode 100644 utils/minimax_h3_media.py create mode 100644 utils/nano_banana_models.py create mode 100644 utils/o1key_image_catalog.py create mode 100644 utils/o1key_image_jobs.py create mode 100644 utils/o1key_image_save.py create mode 100644 utils/o1key_image_thumbnail.py create mode 100644 utils/o1key_video_catalog.py create mode 100644 utils/o1key_video_jobs.py create mode 100644 utils/reference_color_correction.py create mode 100644 utils/seedance_assets.py delete mode 100644 utils/update_checker.py delete mode 100644 version.txt create mode 100644 web/AGENTS.md create mode 100644 web/js/batchNanoBananaImageQuality.js create mode 100644 web/js/batchNanoBananaReferenceLabels.js create mode 100644 web/js/casePanel.js delete mode 100644 web/js/dotGrid.js create mode 100644 web/js/elementPanel.js create mode 100644 web/js/gptImageBackgroundLabels.js create mode 100644 web/js/gptImageQuality.js create mode 100644 web/js/keepNodeSize.js create mode 100644 web/js/migrateWorkflow.js create mode 100644 web/js/minimaxH3ParameterGuard.js create mode 100644 web/js/nanoBananaRouteLabels.js create mode 100644 web/js/nanoBananaThinkingLevel.js create mode 100644 web/js/o1keyApiSettings.js create mode 100644 web/js/o1keyImageGenerator.js create mode 100644 web/js/o1keyReferenceImageEditor.js create mode 100644 web/js/o1keyUpdateButton.js create mode 100644 web/js/o1keyVideoGenerator.js create mode 100644 web/js/omniFlashVideo.js create mode 100644 web/js/promptLibrary.data.js create mode 100644 web/js/promptLibrary.js create mode 100644 web/js/promptMultiFunctionDynamic.js delete mode 100644 web/js/restartButton.js create mode 100644 web/js/seedanceAutoPassDynamic.js create mode 100644 web/js/seedanceMultiModalDynamic.js create mode 100644 web/js/seedanceResolutionGuard.js delete mode 100644 web/js/updateNotifier.js create mode 100644 web/js/videoTrim.js diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index d6e3250..0000000 --- a/.cursorrules +++ /dev/null @@ -1,756 +0,0 @@ -# Comfyui_o1key 开发指南 - -## 对话原则 -始终使用中文进行对话。 - -## 编码规范 ⚠️ 重要 - -### 文件编码要求 -- **所有文本文件必须使用 UTF-8 编码(无 BOM)** -- **行结束符使用 LF(Unix 风格),Windows 批处理文件除外(CRLF)** -- 项目已配置 `.gitattributes` 和 `.editorconfig` 来自动处理编码 - -### 编辑器配置 -确保编辑器设置: -- 文件编码:UTF-8(无 BOM) -- 行结束符:LF -- 自动插入文件末尾空行:开启 - -## Git 提交规范 - -### Commit Message 规范 -- **所有 commit message 必须使用英文**,避免中文编码问题 -- 使用 Conventional Commits 格式:`: ` - -### 常用类型 -- `feat`: 新增功能 -- `fix`: 修复问题 -- `docs`: 文档更新 -- `refactor`: 代码重构 -- `style`: 代码格式调整 -- `test`: 测试相关 -- `chore`: 构建/工具配置 - -### 示例 -```bash -git commit -m "feat: add new model support" -git commit -m "fix: resolve image encoding issue" -git commit -m "docs: update README installation guide" -``` - -## 配置文件管理 - -### 基本原则 - -`.config` 文件包含敏感信息(API 密钥),已添加到 `.gitignore` 中,**不会被提交到版本控制**。 - -### 配置方式 - -用户通过以下方式创建本地配置: - -1. **快捷脚本**(推荐) - - Windows: 双击 `设置API密钥(win).bat` - - Linux/Mac: 运行 `./设置API密钥(mac).sh` - - 脚本会自动创建 `.config` 文件 - -2. **手动创建** - - 参考 `.config.example` 模板 - - 在插件根目录创建 `.config` 文件 - - 填写 API 密钥 - -3. **环境变量** - - 设置 `O1KEY_API_KEY` 环境变量 - - 无需创建配置文件 - -### 注意事项 - -- `.config` 文件仅存在于本地,不会被 Git 追踪 -- 开发者无需担心意外提交密钥的问题 -- 提交代码时会自动忽略 `.config` 文件 - -## 项目概述 - -这是一个 ComfyUI 自定义节点插件,通过 api.o1key.com 调用 AI 模型进行图像生成。 - -### 技术栈 -- Python 3.7+ -- ComfyUI 框架 -- aiohttp (异步 HTTP) -- Pillow (图像处理) -- PyTorch (张量处理) - ---- - -## 目录结构 - -``` -Comfyui_o1key/ -├── __init__.py # 节点注册入口 -├── models_config.py # 模型配置中心 ⭐ 管理所有支持的模型 -├── version.txt # 版本号文件 -├── update.bat # Windows 自动更新脚本 -├── update.sh # Linux/Mac 自动更新脚本 -├── nodes/ # 节点模块 -│ ├── __init__.py -│ ├── nano_banana_pro.py # NanoBananaPro 节点 -│ └── batch_nano_banana_pro.py # 批量节点 -├── utils/ # 工具模块 -│ ├── __init__.py -│ ├── image_utils.py # 图像转换工具 -│ ├── config.py # 配置管理 -│ └── update_checker.py # 更新检查器 -├── clients/ # API 客户端 -│ ├── __init__.py -│ ├── base_client.py # 客户端基类 -│ └── gemini_client.py # Gemini API 客户端 -├── .config.example # 配置文件模板 -├── requirements.txt # 依赖包 -└── README.md # 用户文档 -├── 设置API密钥(win).bat # Windows 配置脚本 -└── 设置API密钥(mac).sh # Mac/Linux 配置脚本 - -注:.config 文件在本地自动创建,不提交到版本控制 -``` - ---- - -## 模型管理系统 - -### 概述 - -所有 Nano Banana Pro 支持的模型都在 `models_config.py` 中统一管理。要添加新模型或临时关闭某个模型,只需编辑这个文件即可。 - -### 模型配置文件 (models_config.py) - -#### 配置结构 - -```python -GEMINI_MODELS = [ - { - "id": "gemini-3-pro-image-preview-url", - "description": "URL 模式,根据分辨率自动选择端点 (1K/2K/4K)", - "enabled": True, - "endpoint_type": "dynamic", - "endpoint": None # 动态端点,由代码根据分辨率选择 - }, - { - "id": "gemini-3-pro-image-preview", - "description": "标准模式,固定端点", - "enabled": True, - "endpoint_type": "standard", - "endpoint": "/v1beta/models/gemini-3-pro-image-preview:generateContent" - }, - # 更多模型... -] -``` - -#### 字段说明 - -| 字段 | 类型 | 必需 | 说明 | -|------|------|------|------| -| `id` | string | 是 | 模型标识符,用于 API 调用 | -| `description` | string | 是 | 模型描述,说明特点和适用场景 | -| `enabled` | boolean | 是 | 是否启用该模型(false 则在节点中隐藏) | -| `endpoint_type` | string | 是 | 端点类型:"dynamic", "standard", "flatfee" | -| `endpoint` | string | 是 | API 端点路径(动态端点设为 None) | - -#### 端点类型说明 - -- **dynamic**: 根据分辨率动态选择端点(如 gemini-3-pro-image-preview-url) -- **standard**: 使用固定端点(如 gemini-3-pro-image-preview) -- **flatfee**: 固定费用模式端点(如 gemini-3-pro-image-preview-flatfee) - -### 常见操作 - -#### 1. 添加新模型 - -在 `GEMINI_MODELS` 列表末尾添加新模型: - -```python -GEMINI_MODELS = [ - # ... 现有模型 ... - { - "id": "gemini-新模型名称", - "description": "新模型的描述和特点", - "enabled": True, - "endpoint_type": "standard", # 根据实际情况选择 - "endpoint": "/v1beta/models/gemini-新模型名称:generateContent" # 配置端点 - } -] -``` - -**注意**: -- **固定端点模型**:直接在 `endpoint` 字段填写完整的端点路径即可,无需修改代码 -- **动态端点模型**:如果模型需要根据分辨率动态选择端点,设置 `endpoint_type: "dynamic"` 和 `endpoint: None`,并在 `gemini_client.py` 的 `get_endpoint()` 方法中添加对应逻辑 - -#### 2. 临时关闭模型 - -将模型的 `enabled` 字段设为 `False`: - -```python -{ - "id": "gemini-3-pro-image-preview-url", - "description": "URL 模式", - "enabled": False, # 临时关闭 - "endpoint_type": "dynamic" -} -``` - -关闭后,该模型将不会出现在 ComfyUI 节点的下拉列表中。 - -#### 3. 重新启用模型 - -将 `enabled` 改回 `True`: - -```python -{ - "id": "gemini-3-pro-image-preview-url", - "enabled": True, # 重新启用 - # ... -} -``` - -#### 4. 修改模型描述 - -直接编辑 `description` 字段: - -```python -{ - "id": "gemini-3-pro-image-preview", - "description": "标准模式,固定端点,适用于常规图像生成", # 更新描述 - # ... -} -``` - -### 工具函数 - -`models_config.py` 提供了一些工具函数,可在代码中使用: - -```python -from ..models_config import ( - get_enabled_models, # 获取启用的模型列表 - get_all_models, # 获取所有模型(包括禁用的) - get_model_config, # 获取指定模型的完整配置 - is_model_enabled, # 检查模型是否启用 - get_model_description, # 获取模型描述 - get_endpoint_type, # 获取端点类型 - get_model_endpoint # 获取模型端点 -) - -# 示例:获取启用的模型 -enabled = get_enabled_models() -# ['gemini-3-pro-image-preview-url', 'gemini-3-pro-image-preview', ...] - -# 示例:获取模型配置 -config = get_model_config("gemini-3-pro-image-preview-url") -# {'id': '...', 'description': '...', 'enabled': True, 'endpoint_type': 'dynamic', 'endpoint': None} - -# 示例:获取模型端点 -endpoint = get_model_endpoint("gemini-3-pro-image-preview") -# '/v1beta/models/gemini-3-pro-image-preview:generateContent' -``` - -### 节点集成 - -所有使用模型列表的节点都会自动从 `models_config.py` 加载: - -```python -from ..models_config import get_enabled_models - -class NanoBananaPro: - @classmethod - def INPUT_TYPES(cls): - # 自动从配置加载启用的模型 - enabled_models = get_enabled_models() - - return { - "required": { - "模型": (enabled_models, { - "default": enabled_models[0] - }), - # ... - } - } -``` - -### 配置验证 - -`models_config.py` 在加载时会自动验证配置: - -- 检查每个模型是否有必需字段(id, description, enabled, endpoint_type, endpoint) -- 检查 `endpoint_type` 是否合法(dynamic, standard, flatfee) -- 检查非动态端点模型必须配置有效的 `endpoint` -- 检查端点格式是否正确(应以 `/v1beta/models/` 开头) -- 确保至少有一个模型是启用的 - -如果配置不合法,会在终端打印警告信息。 - -### 最佳实践 - -1. **添加新模型前**: - - 确认模型使用 Gemini 原生接口格式 - - 确认端点规则(dynamic/standard/flatfee) - - 编写清晰的描述说明 - -2. **临时测试**: - - 关闭其他模型,只启用测试模型 - - 验证功能后再重新启用其他模型 - -3. **版本控制**: - - `models_config.py` 应纳入版本控制 - - 重大模型变更应记录在 `CHANGELOG.md` 中 - -4. **文档更新**: - - 添加新模型后,更新 `README.md` 中的模型列表 - - 如有特殊使用说明,添加到文档中 - ---- - -## 开发新节点流程 - -### 1. 创建节点文件 - -在 `nodes/` 目录下创建新的 Python 文件: - -```python -# nodes/my_new_node.py - -from typing import Optional, Tuple -import torch - -from ..utils.image_utils import tensor_to_pil, pil_to_tensor -from ..clients.gemini_client import GeminiAPIClient - - -class MyNewNode: - """节点描述""" - - def __init__(self): - self.client = None - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "prompt": ("STRING", {"default": "", "multiline": True}), - # 更多参数... - }, - "optional": { - "images": ("IMAGE",) - } - } - - RETURN_TYPES = ("IMAGE",) - RETURN_NAMES = ("images",) - FUNCTION = "execute" - CATEGORY = "image/generation" - - def execute(self, prompt: str, images: Optional[torch.Tensor] = None) -> Tuple[torch.Tensor]: - # 实现逻辑 - pass -``` - -### 2. 注册节点 - -在 `nodes/__init__.py` 中添加导出: - -```python -from .my_new_node import MyNewNode -__all__ = ['NanoBananaPro', 'MyNewNode'] -``` - -在根 `__init__.py` 中注册: - -```python -from .nodes import NanoBananaPro, MyNewNode - -NODE_CLASS_MAPPINGS = { - "NanoBananaPro": NanoBananaPro, - "MyNewNode": MyNewNode -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "NanoBananaPro": "Nano Banana Pro", - "MyNewNode": "My New Node" -} -``` - -### 3. 更新 CHANGELOG.md - -记录新增功能。 - ---- - -## ComfyUI 节点规范 - -### INPUT_TYPES 参数类型 - -| 类型 | 格式 | 示例 | -|------|------|------| -| 字符串 | `("STRING", {...})` | `("STRING", {"default": "", "multiline": True})` | -| 整数 | `("INT", {...})` | `("INT", {"default": 1, "min": 1, "max": 100})` | -| 浮点数 | `("FLOAT", {...})` | `("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.1})` | -| 下拉选项 | `([...], {...})` | `(["option1", "option2"], {"default": "option1"})` | -| 图像 | `("IMAGE",)` | 放在 optional 中 | - -### 返回值规范 - -```python -RETURN_TYPES = ("IMAGE", "MASK", "STRING") # 类型元组 -RETURN_NAMES = ("images", "mask", "text") # 名称元组 -``` - -### 必须的类属性 - -```python -FUNCTION = "execute" # 执行函数名 -CATEGORY = "image/generation" # 节点分类路径 -``` - ---- - -## 工具模块使用 - -### 图像转换 (utils/image_utils.py) - -```python -from ..utils.image_utils import tensor_to_pil, pil_to_tensor - -# ComfyUI Tensor → PIL Image 列表 -pil_images = tensor_to_pil(tensor) # tensor: [B, H, W, C], range [0, 1] - -# PIL Image 列表 → ComfyUI Tensor -tensor = pil_to_tensor(pil_images) # 返回 [B, H, W, C], range [0, 1] - -# PIL → Base64 -from ..utils.image_utils import encode_image_to_base64 -b64_str = encode_image_to_base64(pil_image) - -# Base64 → PIL -from ..utils.image_utils import decode_base64_to_pil -pil_image = decode_base64_to_pil(b64_str) -``` - -### 配置管理 (utils/config.py) - -```python -from ..utils.config import get_api_key, get_api_key_or_raise, load_config, get_api_base_url - -# 获取 API 密钥(返回 None 如果未找到) -api_key = get_api_key("O1KEY_API_KEY") - -# 获取 API 密钥(抛出异常如果未找到) -api_key = get_api_key_or_raise("O1KEY_API_KEY") - -# 获取 API 基础 URL(统一配置) -base_url = get_api_base_url() # 默认: https://vip.o1key.com - -# 加载完整配置 -config = load_config() -``` - -### API 基础 URL 配置 - -所有 API 客户端都使用统一的基础 URL 配置,默认为 `https://vip.o1key.com`。 - -#### 配置优先级 - -1. **环境变量** `O1KEY_API_BASE_URL`(优先级最高) -2. **.config 文件**中的 `O1KEY_API_BASE_URL` 配置项 -3. **默认值** `https://vip.o1key.com`(在 `utils/config.py` 中定义) - -#### 修改 API 地址 - -**方法 1:修改默认值(影响所有用户)** - -编辑 `utils/config.py`: - -```python -# 修改此常量 -DEFAULT_API_BASE_URL = "https://your-api-domain.com" -``` - -**方法 2:使用环境变量(推荐,不影响代码)** - -在系统环境变量中设置: -```bash -# Windows -set O1KEY_API_BASE_URL=https://your-api-domain.com - -# Linux/Mac -export O1KEY_API_BASE_URL=https://your-api-domain.com -``` - -**方法 3:在 .config 文件中配置** - -在插件根目录的 `.config` 文件中添加: -``` -O1KEY_API_BASE_URL=https://your-api-domain.com -``` - -#### 使用示例 - -所有客户端会自动使用统一配置: - -```python -from ..utils.config import get_api_base_url - -# 获取当前配置的 API 地址 -base_url = get_api_base_url() -print(f"当前 API 地址: {base_url}") -``` - ---- - -## API 客户端使用 - -### 使用 GeminiAPIClient - -```python -from ..clients.gemini_client import GeminiAPIClient - -# 初始化(自动读取配置) -client = GeminiAPIClient() - -# 同步生成(用于 ComfyUI 节点) -images = client.generate_sync( - prompt="描述文字", - model="gemini-3-pro-image-preview-url", - resolution="2K", - aspect_ratio="1:1", - batch_size=1, - images=None, # 可选:输入图像列表 - progress_callback=None -) -``` - -### 创建新的 API 客户端 - -继承 `BaseAPIClient` 并实现抽象方法: - -```python -from ..clients.base_client import BaseAPIClient - -class MyAPIClient(BaseAPIClient): - def __init__(self): - super().__init__( - base_url="https://api.example.com", - api_key=get_api_key_or_raise("MY_API_KEY"), - max_request_size=20 * 1024 * 1024 - ) - - def get_endpoint(self, **kwargs) -> str: - return "/v1/generate" - - def build_request_body(self, **kwargs) -> dict: - return {"prompt": kwargs.get("prompt", "")} - - def parse_response(self, response: dict) -> Any: - return response.get("result") -``` - ---- - -## API 端点说明 - -### Gemini 模型端点 - -**gemini-3-pro-image-preview-url** (根据分辨率动态选择): -- 1K: `/v1beta/models/gemini-3-pro-image-preview-url:generateContent` -- 2K: `/v1beta/models/gemini-3-pro-image-preview-2k-url:generateContent` -- 4K: `/v1beta/models/gemini-3-pro-image-preview-4k-url:generateContent` - -**gemini-3-pro-image-preview** (固定端点): -- `/v1beta/models/gemini-3-pro-image-preview:generateContent` - -**gemini-3-pro-image-preview-flatfee** (固定端点): -- `/v1beta/models/gemini-3-pro-image-preview-flatfee:generateContent` - -### 请求格式 - -```json -{ - "contents": [{ - "role": "user", - "parts": [ - {"text": "提示词"}, - {"inline_data": {"mime_type": "image/png", "data": "base64..."}} - ] - }], - "generationConfig": { - "responseModalities": ["TEXT", "IMAGE"], - "imageConfig": { - "aspectRatio": "1:1", - "imageSize": "2K" - } - } -} -``` - ---- - -## 代码规范 - -### 命名约定 - -- 类名:PascalCase(如 `NanoBananaPro`) -- 函数/方法:snake_case(如 `tensor_to_pil`) -- 常量:UPPER_CASE(如 `API_BASE_URL`) -- 私有方法:前缀下划线(如 `_load_config`) - -### 类型注解 - -所有公开函数必须有类型注解: - -```python -def function_name(param1: str, param2: Optional[int] = None) -> List[Image.Image]: - pass -``` - -### 文档字符串 - -使用 Google 风格的 docstring: - -```python -def function_name(param1: str, param2: int) -> bool: - """ - 函数简短描述 - - Args: - param1: 参数1说明 - param2: 参数2说明 - - Returns: - 返回值说明 - - Raises: - ValueError: 异常情况说明 - - Example: - >>> result = function_name("test", 42) - >>> print(result) - True - """ - pass -``` - -### 错误处理 - -```python -try: - # 业务逻辑 - pass -except ValueError as e: - # 用户输入错误 - print(f"节点名: 输入错误 - {str(e)}") - raise -except RuntimeError as e: - # API 或网络错误 - print(f"节点名: API 错误 - {str(e)}") - raise -except Exception as e: - # 未知错误 - print(f"节点名: 未知错误 - {str(e)}") - raise -``` - ---- - -## 限制与约束 - -| 限制项 | 值 | 说明 | -|--------|-----|------| -| 请求体大小 | 20MB | 超过会报错 | -| 输入图像数量 | 14张 | 图生图模式限制 | -| 批次大小 | 1-1000 | 并发生成数量 | -| 支持的分辨率 | 1K/2K/4K | API 限制 | - ---- - -## 测试检查清单 - -新节点开发完成后,验证以下场景: - -- [ ] 文生图基础功能 -- [ ] 图生图功能(如支持) -- [ ] 不同分辨率(1K/2K/4K) -- [ ] 不同宽高比 -- [ ] 批量生成 -- [ ] 错误处理(无 API 密钥、网络错误等) -- [ ] 边界条件(最大图像数、最大批次) - ---- - -## 更新日志 - -修改代码后,更新 `CHANGELOG.md` 记录变更。 - -格式: -```markdown -## [版本号] - 日期 - -### Added -- 新增功能 - -### Changed -- 变更内容 - -### Fixed -- 修复问题 -``` - ---- - -## 版本发布流程 - -### 1. 准备发布 - -发布新版本前确认以下事项: - -- [ ] 所有功能测试通过 -- [ ] 更新 `CHANGELOG.md`(记录本次变更) -- [ ] 更新 `version.txt`(更新版本号) -- [ ] 更新 `README.md`(如有新功能需要说明) - -### 2. 版本号规范 - -遵循语义化版本 (Semantic Versioning): - -- **主版本号** (Major): 重大架构变更、不兼容的 API 修改 -- **次版本号** (Minor): 新增功能、向后兼容 -- **修订号** (Patch): Bug 修复、小改进 - -示例:`v1.10.2` → Major.Minor.Patch - -### 3. 发布步骤 - -```bash -# 1. 更新版本号 -echo "v1.11.0" > version.txt - -# 2. 提交变更 -git add . -git commit -m "Release v1.11.0: 添加新功能描述" - -# 3. 创建标签 -git tag v1.11.0 - -# 4. 推送到远程 -git push origin main --tags -``` - -### 4. 用户更新 - -用户运行更新脚本即可获取最新版本: - -- **Windows**: 双击 `update.bat` -- **Linux/Mac**: 运行 `./update.sh` - -更新脚本会自动: -- 检查远程更新 -- 备份配置文件 -- 拉取最新代码 -- 更新依赖包 -- 显示更新日志 - ---- \ No newline at end of file diff --git a/.gitignore b/.gitignore index ebc61e1..f765e81 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,13 @@ Thumbs.db # 用户配置(含 API Key,不提交) .config + +# 本地开发工具与浏览器测试状态 +.claude/ +.codex/ +.playwright-mcp/ + +# 测试与覆盖率缓存 +.pytest_cache/ +.coverage +htmlcov/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..349f29a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# AGENTS.md + +## Project mission + +Maintain `comfyui_o1key` as a reliable ComfyUI custom-node package. Changes must preserve saved-workflow compatibility, keep credentials out of workflows and logs, and leave the plugin importable in the bundled Windows environment. + +## Read first + +- Start with `docs/README.md` and open only the document relevant to the task. +- Treat `__init__.py` as the runtime integration point and canonical node registry. +- Treat `nodes/__init__.py` as the package export list, not a second independent registry. +- `WEB_DIRECTORY = "./web"` means every JavaScript file under `web/` is runtime code. +- Preserve unrelated working-tree changes. Do not commit unless the user explicitly asks. + +## Repository map + +- `__init__.py`: node mappings, display names, HTTP routes, runtime hooks, and `WEB_DIRECTORY`. +- `prestartup_script.py`: startup policy applied before normal plugin import. +- `nodes/`: ComfyUI schemas and node execution adapters. +- `clients/`: provider-specific HTTP clients and response parsing. +- `utils/`: shared configuration, media conversion, retry, polling, upload, and job helpers. +- `web/js/`: auto-loaded ComfyUI frontend extensions. +- `cases/`: runtime case-library JSON files; do not treat as disposable fixtures. +- `tests/`: offline tests; use the isolated runner. +- `docs/`: architecture, configuration, development workflow, decisions, and maintenance history. + +## Non-negotiable invariants + +- Never expose `.config`, API keys, authorization headers, full base64 payloads, or signed temporary URLs in docs, tests, fixtures, or logs. +- Do not rename a released node ID or a `NODE_CLASS_MAPPINGS` key without a workflow migration and an explicit compatibility decision. +- When adding or removing a node, update its module, `nodes/__init__.py`, root mappings, display mappings, relevant frontend migrations, tests, and docs together. +- Keep API transport in `clients/` or `utils/`; node classes should focus on schema validation and orchestration. +- Keep tests offline. Mock network traffic, downloads, ComfyUI services, and user configuration. +- Do not add generated caches, screenshots, browser state, local AI-tool settings, ad-hoc reports, or real output media to the repository. +- Use UTF-8 text and preserve the repository line-ending policy in `.gitattributes`. + +## Change workflow + +1. Inspect `git status --short`, the relevant docs, and direct references with `rg`. +2. Identify the runtime boundary: backend node/client/util, server route, frontend extension, or compatibility migration. +3. Make the smallest coherent change across that boundary. +4. Add or update an offline regression test. +5. Run the relevant test file, then `tests/run_all.py` for cross-cutting changes. +6. Run an import smoke test when registration, imports, requirements, or startup behavior changes. +7. Update `docs/` when behavior, architecture, configuration, or maintenance expectations change. + +## Validation commands + +Run from the plugin root in the portable Windows layout: + +```powershell +..\..\..\python_embeded\python.exe tests\run_all.py +..\..\..\python_embeded\python.exe -m compileall -q __init__.py prestartup_script.py models_config.py clients nodes utils tests +..\..\..\python_embeded\python.exe -c "import sys; sys.path.insert(0, '..'); import comfyui_o1key; print(len(comfyui_o1key.NODE_CLASS_MAPPINGS))" +git diff --check +``` + +For a single Python test: + +```powershell +..\..\..\python_embeded\python.exe tests\test_http2_client.py +``` + +For the frontend test: + +```powershell +node tests\test_o1key_image_generator_frontend.mjs +``` + +## Documentation contract + +- `README.md` is user-facing installation and usage documentation. +- `docs/README.md` is the maintainer and agent entry point. +- Architecture changes belong in `docs/architecture.md`. +- Configuration changes belong in `docs/configuration.md` and must match `utils/config.py`. +- Reusable implementation procedures belong in `docs/development.md` or `docs/testing.md`. +- Significant irreversible or compatibility-sensitive choices require an ADR under `docs/decisions/`. +- One-off cleanup history belongs under `docs/maintenance/`, not in the repository root. + +## Scoped guidance + +Read the closest nested instructions before editing specialized areas: + +- `nodes/AGENTS.md` +- `web/AGENTS.md` +- `tests/AGENTS.md` diff --git a/README.md b/README.md index 5c950c0..0cb035a 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,24 @@ # Comfyui_o1key -通过 `api.o1key.com` 调用 AI 模型的 ComfyUI 自定义节点集合。 +通过 `api.o1key.cn` 调用 AI 模型的 ComfyUI 自定义节点集合。 ## 功能特性 - 🎨 文生图 / 图生图 - 🔄 批量并发生成(最多 1000 张) - 📐 10 种宽高比 -- 🎯 3 种分辨率(1K / 2K / 4K) +- 🎯 智能分辨率,或手动选择 1K / 2K / 4K - 🌱 可控随机种子 +- 💬 左侧「聊天」面板支持多模型对话,初始默认使用 `gpt-6-sol` +- 🧠「提示词专家」节点支持多模型文本与多模态输入,新建节点默认使用 `gpt-6-sol` + +## 项目文档 + +- 用户安装与使用:当前文件 +- 维护者与 AI 协作入口:[docs/README.md](docs/README.md) +- 项目工作约定:[AGENTS.md](AGENTS.md) +- 架构与运行边界:[docs/architecture.md](docs/architecture.md) +- 开发与测试:[docs/development.md](docs/development.md)、[docs/testing.md](docs/testing.md) --- @@ -25,25 +35,14 @@ ```bash cd ComfyUI/custom_nodes -git clone https://github.com/lizhongyi1209/comfyui_o1key.git +git clone https://git.o1key.com/publisher/comfyui_o1key.git cd comfyui_o1key pip install -r requirements.txt ``` 然后重启 ComfyUI。 -### 国内用户安装(GitHub 拉取慢或失败时) - -使用 Gitee 镜像安装与更新,避免网络问题: - -```bash -cd ComfyUI/custom_nodes -git clone https://gitee.com/resonLzy/comfyui_o1key.git -cd comfyui_o1key -pip install -r requirements.txt -``` - -自动更新脚本(见下方「更新插件」)已改为从 Gitee 拉取,国内用户可直接使用。 +Windows 用户也可以在 ComfyUI 左侧侧栏打开“更新”面板更新插件;使用前请阅读下方关于本地修改的提示。 --- @@ -58,45 +57,13 @@ pip install -r requirements.txt #### 配置 API 密钥(必需) -**方法一:快捷脚本配置(最简单)⭐** +**方法一:ComfyUI 界面配置(推荐)⭐** -我们提供了一键配置脚本,自动创建配置文件: +启动 ComfyUI 后,点击左侧栏的「令牌管理」,填写 API Key、选择网络线路,然后点击「保存并立即生效」。也可以在该窗口测试连接或清除已保存的 Key。 -**Windows 用户:** -双击运行 `设置API密钥(win).bat`,按提示输入 API 密钥即可。 +**方法二:手动创建配置文件** -**Linux/Mac 用户:** -```bash -# 添加执行权限(仅首次需要) -chmod +x 设置API密钥(mac).sh - -# 运行配置脚本 -./设置API密钥(mac).sh -``` - -按提示输入 API 密钥,配置完成后重启 ComfyUI。 - -**方法二:环境变量(推荐)** - -**Windows 用户:** -1. 右键 "此电脑" → 属性 → 高级系统设置 → 环境变量 -2. 在"用户变量"中新建: - - 变量名:`O1KEY_API_KEY` - - 变量值:你的 API 密钥 -3. 重启 ComfyUI - -**Linux/Mac 用户:** - -在 `~/.bashrc` 或 `~/.zshrc` 中添加: -```bash -export O1KEY_API_KEY="你的API密钥" -``` - -然后执行 `source ~/.bashrc` 并重启 ComfyUI。 - -**方法三:手动创建配置文件** - -在插件目录下创建 `.config` 文件(参考 `.config.example`): +在插件目录下创建 `.config` 文件: ``` O1KEY_API_KEY=你的API密钥 ``` @@ -108,78 +75,200 @@ O1KEY_API_KEY=你的API密钥 #### 配置 API 地址(可选) -默认使用 `https://vip.o1key.com`,通常无需修改。 +默认使用 `https://api.o1key.cn`,通常无需修改。 -如需自定义 API 地址,可通过以下方式: +通常应通过「令牌管理」选择全局网络线路。如需调试自定义地址,可在 `.config` 中添加: -1. **环境变量**(推荐): - ```bash - # Windows - set O1KEY_API_BASE_URL=https://your-api-domain.com - - # Linux/Mac - export O1KEY_API_BASE_URL=https://your-api-domain.com - ``` +```text +O1KEY_API_BASE_URL=https://your-api-domain.com +O1KEY_ASYNC_API_BASE_URL=https://your-async-api-domain.com +``` -2. **配置文件**:在 `.config` 中添加: - ``` - O1KEY_API_BASE_URL=https://your-api-domain.com - ``` - -3. **修改默认值**:编辑 `utils/config.py` 中的 `DEFAULT_API_BASE_URL` 常量 +配置键和线路解析规则见 [配置文档](docs/configuration.md)。 --- ## 🔄 更新插件 -### 界面更新 +本次发布以当前代码作为新基线,部分旧节点 ID 已移除。包含这些节点的旧工作流可能显示“缺失节点”;更新前请备份工作流。完整清单和处理办法见 [发布兼容性决定](docs/decisions/0014-new-release-code-baseline.md)。 -在 ComfyUI 左侧功能栏点击「更新」(位于「重启」下方)。按钮会从当前 Git 仓库的 `origin/main` 拉取最新版本。完成后点击「重启」使新版本生效。 +### 方法一:ComfyUI 侧栏 -界面更新需要通过 Git 安装、处于 `main` 分支,且节点包文件没有本地修改。更新仅允许快进,不会覆盖本地修改或删除配置。ZIP 安装、分支分叉或网络连接失败时,界面会显示原因,需要手动处理。 +1. 点击 ComfyUI 左侧侧栏的“更新”图标。 +2. 在面板中点击“检查并更新”,确认从 `git.o1key.com/publisher/comfyui_o1key` 获取后等待结果。 +3. 如果提示依赖变化,在 ComfyUI 使用的 Python 环境中安装 `requirements.txt`。 +4. 重启 ComfyUI。 -如果提示依赖列表已变化,请在 ComfyUI 使用的 Python 环境中执行: +> 面板只对没有已跟踪文件修改的 Git `main` 分支执行快进更新。本地代码有修改、历史分叉、网络故障或新版本与未跟踪文件冲突时会给出原因和处理建议,不会强制重置或清理本地文件。已有安装的 `origin` 地址不会被自动改写。 +### 方法二:手动更新 + +从 O1Key 发布仓库拉取: ```bash cd ComfyUI/custom_nodes/comfyui_o1key -python -m pip install -r requirements.txt +git fetch https://git.o1key.com/publisher/comfyui_o1key.git main +git merge --ff-only FETCH_HEAD +pip install -r requirements.txt --upgrade ``` -### 手动更新 - -```bash -cd ComfyUI/custom_nodes/comfyui_o1key -git pull --ff-only origin main -python -m pip install -r requirements.txt -``` - -更新保留环境变量中配置的 API 密钥。启动时仍会检查是否有新版本。 +**💡 提示:** 更新面板不会修改 `.config`;手动更新前请自行确认工作区没有未保存的代码修改。 --- ## 📚 节点说明 -### Nano Banana Pro +### 提示词(多功能) -高性能图像生成节点,支持文生图和图生图。 +在输入框中用单独一行的 `---` 分隔多套提示词,然后选择输出方式: -**参数:** -- **提示词**:描述你想生成的图像 -- **模型**:选择使用的 AI 模型 -- **分辨率**:1K / 2K / 4K -- **宽高比**:1:1, 16:9, 9:16, 4:3, 3:4, 21:9, 9:21, 3:2, 2:3, 16:10 -- **批次大小**:单次生成的图像数量(1-1000) -- **随机种子**:控制生成的随机性(-1 为随机) -- **输入图像**(可选):用于图生图模式 +- 「全部使用」输出全部提示词。 +- 「随机抽取n套」按“抽取数量”不重复随机选择,例如准备 10 套后填写 `1`、`3` 或 `5`;抽中的提示词会按原始顺序输出。 +- 「指定序号」按从 1 开始的序号选择并按填写顺序输出,支持 `1,3,5`、中文逗号、空格和 `2-4` 区间。 -### Batch Nano Banana Pro +控件会随模式动态切换:「随机抽取n套」只显示“抽取数量”,“指定序号”只显示“指定序号”,而“全部使用”会隐藏两者;显示切换不会清空已经填写的值。多套结果仍使用单独一行的 `---` 连接,可直接交给支持批量提示词的下游节点。抽取数量超过现有套数、序号越界、重复或格式错误时,节点会在执行前给出明确提示。旧工作流中的节点 ID、提示词和功能位置保持兼容;旧「随机抽取1套」会迁移为「随机抽取n套」并把数量设为 `1`,旧「随机抽取多套」会保留原数量并迁移到统一模式。新增的“抽取数量”和“指定序号”仍位于原控件之后。 -批量并发生成节点,适合大量图像生成。 +### 自动红偏校正 + +「自动红偏校正」的最后一个控件是 ComfyUI 原生 `seed`,支持生成后随机化。改变 seed 会让节点重新执行;校色算法是确定性的,同一图片和校色参数始终得到相同结果。旧工作流加载时会补入默认 seed `0`,保留已保存的灰卡亮度与色度值。 + +### o1key 图片生成 / o1key 保存图像 + +「o1key 图片生成」的提示词仅在节点内的多行编辑框填写,不显示提示词 `STRING` 输入端口。旧工作流加载时会移除原有的外部提示词连线,保留节点内已保存的提示词;如原先完全依赖上游字符串,请在面板内补填提示词。 + +模型列表新增 `GPT Image 2.5 Sunburst`(最新,高质量)和 `GPT Image 2.5 Flare`(快速,日常)。两者与 GPT Image 2 使用相同的「智能 / 1K / 2K / 4K」分辨率和「智能、1:1、3:2、2:3、4:3、3:4、16:9、9:16」宽高比;特价、优质、企业线路分别调用模型 ID 的 `-sp`、`-sd` 和无后缀版本。下文的 GPT Image 2 参数说明也适用于这两个 GPT Image 2.5 模型。 + +面板式图片生成节点统一支持 Nano Banana 系列、GPT Image 2 与 Seedream 5.0 Pro。提示词右下角使用带 `✨` 图标的「AI帮写」按钮;独立 Nano Banana 节点不再提供提示词优化按钮。参数区按“生成参数”和“保存设置”分组,采用双列网格布局;提示词独立显示为“画面描述”区域,随机种子按钮仍内嵌在种子输入框右侧。ComfyUI Nodes 1.0 因识别到 `seed` 输入而自动添加的「生成后控制」与该按钮功能重复,因此会由面板隐藏,不占用节点顶部空间。模型、线路、分辨率、宽高比、生图数量和种子常显;模型专属参数会随模型动态切换:仅 Nano Banana 2 显示「思考等级」和「在线搜索」,GPT Image 2 / 2.5 显示「质量」「背景」和「蒙版」,GPT Image 2 / 2.5 与 Seedream 5.0 Pro 都显示作为 API 参数的「输出格式」。在线搜索默认为「关闭」,关闭时不发送相关参数;选择「打开」时,请求体顶层发送 `google_search: true`。这些参数不收进高级设置弹层;切换模型只隐藏不适用参数,不会清空已选值。分辨率新增默认值「智能」;选择它时,Nano Banana、GPT Image 和 Seedream 的上游请求都不发送 `size`,由模型决定输出尺寸。选择具体档位时,GPT Image 2 会把分辨率与宽高比映射为具体像素尺寸;宽高比选择「智能」时按正方形映射为 `1024x1024 / 2048x2048 / 2880x2880`。背景支持自动、透明和不透明,输出格式支持 JPEG、PNG 和 WebP;新建 GPT 设置默认 PNG,已有工作流保留保存的格式,其中透明背景只能搭配 PNG 或 WebP。所有模型每次请求最多使用 10 张参考图。GPT Image 新选项支持每条提示词生成 1–8 张,旧工作流中的 9 张仍可执行;其余模型保持 `1 / 2 / 4 / 9`。GPT Image 2.5 的质量另支持「超高 / 最高」,分别发送 `xhigh / max`;GPT Image 2 仅支持原有四档。提示词支持用独占一行的 `---` 分隔多条内容;所有模型和执行入口都会按“提示词数量 × 生图数量”生成任务,任务排列为同一提示词的所选张数在前、下一条提示词随后,单次最多 1000 个任务。任务会按受控并发分批执行,GPT Image 与 Seedream 请求中的 `n` 固定为 `1`。模型线路在面板中显示为「特价(便宜)/ 优质(小贵)/ 企业(贵)」,工作流内部仍保存兼容值「畅速 / 直连 / 专线」;思考等级默认为「低」,显示为「低(耗时低,智力低)/ 高(耗时高,智力高)」;缩放图片显示为「不缩放(无大图)/ 智能缩放(有大图)」。带标签的下拉参数在菜单收起后也会显示当前选项的标签。 + +Seedream 5.0 Pro 位于 GPT Image 2 下方,支持「智能 / 1K / 2K」分辨率与「智能、1:1、4:3、3:4、16:9、9:16、3:2、2:3、21:9」宽高比;具体档位会转换为接口要求的精确像素尺寸。参考图通过当前全局线路的 `/v1/o1key/uploads` 上传为临时 HTTPS URL,再按原顺序传入 `images`。输出格式实际发送 `output_format=png/jpeg`;水印不提供界面开关,固定发送 `watermark=false`。 + +Seedream 5.0 Pro 的每张普通参考图需满足火山引擎当前限制:文件不超过 30MB,宽和高均大于 14px,宽高比在 1:16~16:1 之间,总像素不超过 6000×6000(3600 万)。「图层拆分」使用独立下限:单张待拆图片的总像素必须在 512×512~6000×6000 之间,文件大小和宽高比限制不变。面板会在本地文件上传前检查,后端也会在临时上传或生图请求前再次校验;不符合时会显示当前尺寸及对应限制,不会消耗生图请求。 + +Seedream 还提供「图层拆分」。开启后必须且只能上传 1 张待拆图片,生图数量固定为 1,批量出图关闭,输出固定为 PNG,分辨率改为「智能 / 1K / 1.5K / 2K」;提示词可以留空,也可以描述希望拆出的主体、背景或文字。一次任务返回 1 张底图和最多 16 个透明图层。从面板点击「开始生成」时会自动建立两条保存分支:`IMAGE` 连接的「o1key 保存图像」只接收底图,`LAYERS` 连接的「o1key 保存图层」接收其余透明图层;整批结果只保存一次,不会重复请求或重复落盘。普通模式默认只显示 `IMAGE` 输出端;选择 Seedream 并开启「图层拆分」后显示「图层」和「图层遮罩」,不常用的「图层信息」默认隐藏。旧工作流如果已经连接「图层信息」,该端口仍会显示并保持连线。`IMAGE` 是可直接预览、保存或继续处理的主图(拆分模式下为底图);「图层」是按层输出、尺寸可能不同的透明 RGB 图层列表,对应透明度由「图层遮罩」提供。 + +通过「o1key 图片生成」面板上传的参考图、素材图、目标图和蒙版直接写入 ComfyUI 当前配置的 `input` 根目录,不再新建 `o1key_uploads` 子目录。参考图、素材图和目标图轨道末尾始终保留「添加参考图/素材图/目标图」卡片;可以点击选择文件,也可以从系统文件管理器把图片直接拖到该卡片或整条轨道完成上传。外部文件松开后会立即清除整条轨道和上传卡片的拖放状态,不会在参考图区残留绿色背景或外框。参考图和目标图区均提供「画布取图」:先执行裁剪、缩放、预览或保存节点,再点击该按钮即可从当前画布已有的图片结果中选择一张;面板会读取所选结果并按普通上传重新写入 `input`,随后自动加入当前图片清单,无需手动下载再上传。若文件名已存在,复用 ComfyUI 的原生命名规则追加自然数字(例如 `商品.jpg`、`商品 (1).jpg`、`商品 (2).jpg`),不会覆盖已有文件;面板保存服务器返回的实际文件名。旧工作流中已经保存的 `input/o1key_uploads/...` 图片引用仍可继续读取,请勿移动或删除这些历史文件。 + +「批量出图」面向通用图像生产,不限定服装行业,也可用于商品替换、包装设计、空间改造、妆发迁移、材质替换、角色设计和视觉风格应用等场景。关闭时,参考图、任务数量和执行方式都保持原有行为。开启后,界面把图片分为两个职责明确的区域: + +- 「素材图」提供需要引用的对象、元素、风格、材质或结构。 +- 「目标图」是素材内容最终要应用到的图片。 + +批量模式提供三种执行方式: + +- 「整组素材 → 多个目标」会把当前所有素材图作为一组,并依次与每张目标图组成一次请求。例如 3 张素材图、10 张目标图、每组生图数为 1 时,共生成 10 张;该模式每组最多 9 张素材图,为当前目标图保留第 10 个参考位。 +- 「全匹配(素材 × 目标)」会把每张素材图分别与每张目标图组合。例如 10 张素材图和 10 张目标图会展开为 100 个请求。每个请求只携带当前素材图和当前目标图,不会把 20 张图片一起发送给模型。 +- 「单图批量(每张素材独立)」不需要目标图。将多张模特图上传到素材区后,每张图都会作为该次请求的唯一参考图,适合批量换动作、换表情或做其他无需额外对照图的修改。选中后目标图区会隐藏,已保存的目标图清单不参与该批次。 + +批量素材和目标清单的上限均为 50 张。「整组素材 → 多个目标」由于会把整组素材放入同一次请求,素材区仍限 9 张,但目标图可上传 50 张;「全匹配」和「单图批量」的素材区可上传 50 张。最终展开后仍受单次 1000 个任务上限约束。 + +素材图和目标图使用两条独立的横向大卡轨道,缩略图为 `102 × 102`,上传和「画布取图」收在各自标题行;普通模式沿用同一套大卡样式,只显示参考图轨道。轨道通过独立的最大 `256 × 256` WebP 预览加载图片,只有打开全屏大图时才读取原始文件;缩略图不会覆盖原图,AI帮写和生成 API 始终使用上传到 `input` 的原始图片。标题行同时显示当前图片数量,素材图区说明会随模式切换:「整组素材 → 多个目标」显示“整组参与”,「全匹配(素材 × 目标)」显示“匹配时每张独立”,「单图批量」显示“每张独立生成”。 + +上传后,每张缩略图左下角显示它在实际模型请求中的图号,例如「图2」或「图4」。缩略图不再显示独立的排序手柄;鼠标左键按住图片即可直接拖动排序。拖动时原卡片会明显缩小、变暗并显示“移动中”,当前轨道同步高亮,目标位置通过粗插入线和卡片位移展示。缩略图获得焦点后,也可用 `Alt + ←/→` 前后微调,或用 `Alt + Home/End` 移到首尾。素材图和目标图分别排序,调整后图号、工作流清单、AI帮写和实际生成请求会立即使用新顺序;仍有图片上传时会暂时锁定当前图片组的排序。第1张参考图会标明其主图/色彩基准语义。单击已上传的缩略图会在当前 ComfyUI 画布上打开与左侧「资产」一致的全屏大图预览;拖动操作结束后不会误触大图。可点击遮罩或关闭按钮,也可按 `Esc` 关闭,多图使用左右按钮或方向键切换。缩略图悬停时不显示额外文字标签。批量界面不显示额外的“描述示例”板块。 + +每张参考图、素材图和目标图的右下角都有「替换」按钮:选择一张本地图片后,新图会留在原位置,其他图片和顺序不变;验证或上传失败时保留旧图。左上角的编辑按钮继续用于裁剪和标记。点击后可按「自由 / 原图 / 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 16:9 / 9:16」快速裁剪,也可用「遮罩」画笔涂出半透明红色关注区域,或用彩色「画笔」自由圈选和批注物品。「箭头」从按下位置开始、以松开位置作为箭头尖端,用于精确指定需要关注或修改的画面位置,并支持颜色与粗细调整。「贴图」可从本地上传一张上层图片:拖动图片移动,拖动四角等比缩放,拖动顶部圆点旋转,按住 `Shift` 时以 15° 吸附;还可调节透明度、替换、移除或重置贴图。编辑器支持撤销和重做;应用后会把裁剪、贴图与标记合成为一张新的 PNG,通过 ComfyUI 原生上传接口写入 `input` 并替换当前清单项,原文件不会被覆盖。箭头、画笔和视觉遮罩位于贴图之上,确保指示内容不会被遮挡。贴图的控制框不会进入输出;贴图源文件只在当前弹窗中临时读取,不会写入工作流。「遮罩」是直接合成进图片的视觉标记,不会创建或修改 ComfyUI `MASK` 数据,也不会自动填入 GPT Image 的「蒙版」参数。 + +配对模式中的「生图数量」显示为「每组生图数」;单图批量中显示为「每图生成数」。界面会按“有效提示词数 × 配对或素材数 × 每组/每图生图数”显示任务总数,并受单次 1000 个任务上限约束。Nano Banana 与 GPT Image 2、面板后台任务与标准 ComfyUI 执行使用相同的展开顺序;单个失败槽位只重试原来的参考图组合。批量出图暂不支持 GPT 蒙版编辑,开启前需先移除蒙版。 + +提示词框右下角提供“魔法棒图标 + AI帮写”的文字按钮。填写提示词并按需上传参考图后,点击按钮会使用 `gpt-5.6-sol` 和高思考分析当前文字及全部参考图;处理期间会显示“AI帮写中…”,完成后再将一份可直接用于生图的提示词回填到原输入框。优化以视觉元素与具体对象的绑定为第一优先级;图生图场景会进一步明确必须保持不变的内容和需要改变的目标。API Key 只在服务端读取,不会传给浏览器或写入工作流。 + +Nano Banana 与 GPT Image 2 的最终 UTF-8 JSON 请求体(含 Base64)都按 `18 MiB` 本地安全上限检查。新建 GPT 设置的「缩放图片」默认为「智能缩放」,已有工作流保留保存的选择;「不缩放」时超限会直接拒绝。智能缩放使用 Lanczos 从原图等比缩小占用最大的参考图,直到请求体满足上限。GPT 的首张参考图与蒙版会绑定缩放,确保尺寸继续一致。界面会明确提示「可能发生像素偏移」。 + +生成任务完成后,如果任务查询中的大体积 Base64 因网络中断、内容截断或图片校验失败而不可用,节点会使用原 `task_id` 自动重新获取结果,不会重新提交生成任务或重复扣费;返回图片 URL 时则使用独立的下载与完整性校验重试。正常任务查询保持静默;仅在任务查询发生 HTTP 错误、读取中断、长度不一致、JSON 无效或响应任务 ID 不匹配时,ComfyUI 终端才打印不含响应正文、Base64、密钥或结果 URL 的「任务查询传输追踪」,显示请求与响应 `task_id`、HTTP 版本与状态、服务端 `Content-Length`、实际接收字节数、内容/传输编码、长度检查和 JSON 解析结果。未压缩且声明长度的响应必须字节数完全一致,否则会明确标记 `length_check=mismatch` 并重试同一任务;压缩响应会标记 `skipped-compressed`,避免把解压后字节数与压缩态长度错误比较。 + +以下错误封装作用于整个「o1key 图片生成」节点,与当前选择的模型无关;GPT Image 2、Nano Banana 2、Nano Banana Pro 及该节点支持的其他模型都会使用相同规则。上游返回 `content rejected: the image was flagged as unsafe by the content safety system` 时显示“内容被拒绝:该图像被内容安全系统标记为不安全。”;返回 `Your request was rejected by the safety system` 时显示“您的请求已被安全系统拒绝”;返回 `insufficient balance` 时显示“上游额度不足!”;返回 `Image generation returned empty response` 时显示“图片生成过程中被内容审查机制拒绝!”;返回 `The provided prompt is considered unsafe and it cannot be used to generate content` 时显示“提供的提示被认为是不安全的,不能用于生成内容。”。标准工作流执行会直接替换 ComfyUI 持久错误浮层中的通用说明,不再另外弹出一条短暂、重复的错误通知。这些错误不再直接展示上游英文内容。 + +每次生成前会优先复用已连接且尚无结果的「o1key 保存图像」节点;没有空白结果节点时才自动创建一个。节点内「开始生成」、顶部「运行」和选中输出节点后的蓝色执行按钮遵循同一规则;标准 ComfyUI 执行只把本次结果交给选中的空白结果节点,不会覆盖同一生成节点下已有结果的保存节点。保存节点现在只负责接收、写入和展示图片,不再包含命名、格式或路径参数;这些参数统一由上游「o1key 图片生成」节点提供。后台生成结果先放入 ComfyUI `temp`,只有「o1key 保存图像」节点会把它们写入设定的永久保存目录(默认为 `output`)。 + +每次点击面板内「开始生成」都会独立提交一个批次,按钮始终可继续提交,不会因为已有任务运行而失去并发。批次的排队、运行、完成、失败和取消状态统一显示在 ComfyUI 右上角原生「任务队列」中;队列项右侧使用图层图标和数字显示该批次计划生成的图片总数。最近 200 条终态任务的安全摘要会写入 ComfyUI 用户目录下的 `o1key/image_job_history.json`,因此重启 ComfyUI 后仍会重新出现在右侧历史;单条删除和清空历史会同步更新该文件。摘要只包含批次/节点 ID、状态、计数、时间、错误摘要和安全的 ComfyUI 图片描述,不保存提示词、参考图内容、Base64、API 密钥或签名 URL。可从对应队列项取消单个批次,也可使用队列的批量取消或清理功能。生成过程中切换到其他工作流不会把完成结果发给已经离开画布的旧节点;返回原工作流时,当前「o1key 保存图像」节点会按批次 ID 自动接回进度或最终图片。浏览器刷新后也会查询服务端保留的批次状态;若图片已经写入 `output`,恢复过程直接复用同一结果,不会再次保存一份。结果图下载不设置独立的并发上限,已就绪任务会直接并发下载;实际同时下载数由当时活跃的生成任务数自然决定。生成节点本身不再显示任务状态或切换为取消按钮。若付费请求已被上游接受,取消不能保证撤回已经发生的上游计费。 + +「o1key 图片生成」的通用「命名规则」默认显示为「自定义」,并显示「文件名前缀」,沿用 ComfyUI 的前缀和五位计数器样式。选择「和主图一致」后使用第1张参考图的文件名主干,批次内或重复保存遇到同名时依次追加自然数字;纯文生图没有主图文件名时回退为 `o1key`。选择「自然数字」后使用 `1、2、3…` 连续命名。「保存位置」留空时直接写入 ComfyUI 当前配置的 `output` 根目录;填写相对路径时写入 `output` 内的对应子文件夹;填写完整绝对路径(例如 `D:/图片/项目A`)时可保存到任意可写磁盘目录。相对路径不接受 `..` 越界,`D:图片` 这类不完整盘符路径也会被拒绝。所有命名规则都会检查目标文件且绝不覆盖;外部目录的结果会在 ComfyUI `temp` 中保留一份预览副本,不影响节点和批量结果预览。 + +本地「格式」只在 Banana 模型下显示,默认「原始」,也可选 `PNG / JPEG / WebP`;它控制保存时是否转换容器。Nano Banana 不接收 `output_format`,所以模型返回 PNG 就保留 PNG,返回 JPEG 就保留 JPEG,除非这里显式转换。GPT Image 2 不使用这个本地参数,而使用独立的 API「输出格式」;可选 `JPEG / PNG / WebP`,默认 `JPEG`,实际请求值为小写 `jpeg`。Seedream 同样使用 API「输出格式」,只提供 `PNG / JPEG`,并按所选值保存上游原始容器。透明背景仍只支持 GPT Image 的 PNG 或 WebP。保存节点写入可恢复工作流时遵循 ComfyUI 原生图片元数据约定:PNG 保存 `prompt` 和 `workflow` 文本块;由于 ComfyUI 不从 JPEG 读取工作流且 JPEG EXIF 容量有限,带工作流的 JPEG 来源或 JPEG 保存选项会自动落盘为 PNG。将该 PNG 拖回或载入 ComfyUI 即可恢复工作流;关闭 ComfyUI 全局元数据写入时仍按所选 JPEG 保存,但不会附带工作流。 + +生成参数使用适合中英文与数字混排的字体、字距和垂直居中的下拉箭头;模型列表会显示用途说明。新建节点默认尺寸为 `560 × 1035`,提示词框约显示 5 行内容;开启批量出图或切换到参数较多的 GPT Image 2 时,节点会增加高度以容纳额外控件。`Nano Banana 2` 提供 `1K / 2K / 4K` 分辨率,不再提供 `512`。批次提交后,「o1key 保存图像」会立即按实际任务展开顺序排列固定图片槽位;每张图片完整下载到 ComfyUI `temp` 后会立即显示在对应槽位,无需等待整批完成,全部成功后再切回 ComfyUI 原生图片预览并按设置永久保存。拖动保存节点改变宽度或高度时,原生单图和多图预览会同步使用全部可用区域重新排布并等比缩放,不裁切图片,也不保留旧节点尺寸产生的固定空白。若部分图片失败,成功图仍保留在原序号位置,失败槽位显示独立重试按钮;不同失败槽位可以连续点击并行重试,每个槽位只重新提交自己的原提示词和原素材组合,先完成的重试不会结束或覆盖仍在运行的其他槽位。槽位状态和安全的结果描述会随工作流保存、刷新恢复,且槽位视图与原生预览不会同时叠加。原生下载按钮会替换为白底黑色的重新生成图标;在全成功的多图结果中先选中目标图片,再点击该图标,即可沿用当前参数和新的随机种子只生成 1 张新图,且不会修改生成面板中原来选择的生图数量。生成过程中仍保留顶部细进度条,不显示百分比文案。 + +旧工作流加载时,会把保存节点原有的「文件名前缀 / 格式 / 保存位置 / 命名规则」迁移到相连的「o1key 图片生成」节点;保存节点自身的旧控件值随后清除。迁移不会改动节点 ID 或图像连线。 + +### Nano Banana / Nano Banana 批量跑图 + +两个独立 Nano Banana 节点与「o1key 图片生成」复用同一套异步请求、18 MiB 请求体检查、任务查询恢复和结果下载校验。两个节点使用手写提示词,不再提供提示词优化按钮。 + +「缩放图片」默认为「不缩放」:最终 UTF-8 JSON 请求体超过 18 MiB 时会在付费请求前拒绝;选择「智能缩放」后会使用 Lanczos 等比缩小占用最大的参考图,直至请求体满足限制。两个 Nano Banana 节点均不提供色彩纠正。 + +任务查询发生 HTTP 错误、读取中断、长度不一致或 JSON 无效时,会继续查询原 `task_id`,不会重新提交付费生成请求;任务返回的内联 Base64 不完整时同样重取原任务,结果 URL 下载失败或图片不完整时使用独立下载重试。日志不会打印上游结果 URL、完整 Base64 或授权信息。 + +`Nano Banana` 支持文生图、图生图、`1 / 2 / 4 / 9` 张生成和最多 14 张动态参考图;`Nano Banana 批量跑图` 从最多 5 个图片路径建立同名、同序号、全匹配或不配对任务,并可附加最多 9 张固定参考图。两个节点仅提供 `1K / 2K / 4K`,已移除 `512`;旧工作流中的 `512 / 512px` 会自动迁移为 `1K`。模型线路与统一节点一样显示为「特价 / 优质 / 企业」,但内部仍保存「畅速 / 直连 / 专线」以兼容已有工作流和请求映射。两个节点的种子、缩放等控件均直接显示。批量节点按「缩放图片 → 图片输出格式 → 图片质量 → 图片保存命名规则 → 图片保存路径 → seed」排列最后六项参数;JPEG 或 WebP 的图片质量为 `1–100` 的整数,默认 `95`。旧工作流会恢复字符串图片质量、补齐「不缩放」默认值、移除旧批量节点的「不纠正 / 智能纠正」值,并保留原值迁移到新顺序。 + +「Nano Banana 批量跑图」已移除「图片随机抽取」。所有已填写的图片路径都按所选配对模式参与组图;旧工作流保存的随机抽取值和连线会在加载时清除。若填写多个图片路径,请选择「相同文件名」「同序号」或「全匹配」。 + +### GPT Image / GPT Image 批量跑图 + +两个 GPT Image 节点的模型线路显示为「特价 / 优质 / 企业」,内部继续保存兼容值「畅速 / 直连 / 专线」。GPT Image 2.5 的畅速、直连、专线分别使用 `-sp`、`-sd` 和无后缀请求模型名。两个节点的新建默认模型均为 GPT Image 2.5 Sunburst;选择 GPT Image 2.5 系列时,「质量」会额外提供「超高」和「最高」,分别向接口传入 `xhigh` 和 `max`;切回 GPT Image 2 后恢复为标准质量选项。两个节点都不再提供提示词优化、色彩纠正或内容审查强度。 + +单图节点的「背景」位于「输出格式」下方;批量节点的「背景」位于「图片输出格式」下方。两个节点的「seed」都位于「缩放图片」下方,缩放默认值为「智能缩放」。单图节点使用与统一图片生成节点相同的 18 MiB 请求体策略;批量节点也会在请求体超限时智能等比缩小参考图。蒙版会与第一张参考图同步缩放。旧工作流会自动移除批量节点已删除的色彩纠正和内容审查值,并迁移背景、缩放和 seed 的控件顺序。 + +两个 GPT Image 节点的「背景」都显示为「自动 / 透明 / 不透明」,工作流和 API 仍使用 `auto / transparent / opaque`;透明背景不能搭配 JPEG。GPT Image 批量节点的额外参考图输入端会接续文件夹路径数量编号,例如 1 个路径时从「参考图2」开始,并随路径数量动态调整。seed、输出格式、命名规则、保存路径、缩放和背景参数均直接显示;批量任务默认全并发运行,不提供并发数或图片随机抽取控件。 + +任务结果查询只重试同一 `task_id`,不会重新提交生成请求。查询响应读取中断、长度不一致、无效 JSON、内联图片不完整,以及结果图片的临时 HTTP 错误、传输中断或解码失败,都会使用有限退避重试;日志不会输出签名下载地址、完整 Base64 或授权信息。 + +### Omni Flash 视频生成 + +「Omni Flash 视频生成」沿用 Seedance 全能生成视频的普通节点方式:选择文生视频、参考图视频、首尾帧或视频编辑模式,填写提示词,通过 `IMAGE`/`VIDEO` 输入点连接对应素材,再点击「开始生成」将当前节点加入 ComfyUI 原生队列。输入端随生成模式切换:文生视频无媒体输入,参考图视频显示参考图片,首尾帧显示首帧和尾帧图片,视频编辑显示源视频及可选参考图片;切换到不使用某种素材的模式会断开该素材的连线。参考图模式至少连接 1 张图片;首尾帧模式必须连接首帧,尾帧可选,只连接首帧时以单张图生视频方式提交,连接两张时按首尾帧转场提交;编辑模式连接不超过 20 MB 的 MP4/MOV 源视频,可另接最多 5 张参考图。普通生成固定使用 `omni_flash_10s`,模型参数由后端传入,节点不显示模型控件;分辨率支持 720p 或 1080p,宽高比支持横屏或竖屏。编辑模式自动使用专用模型,并在创建任务时发送 `X-No-Watermark: video` 请求头。 + +令牌沿用「o1key 图片生成」的设置:在侧边栏「令牌管理」保存 O1Key API Key,节点执行时从插件配置读取,不写入工作流。素材上传和视频生成都使用所选 O1Key 网络线路。执行期间节点会提交、轮询并下载视频;任务查询兼容嵌套状态、进度和结果地址,未识别的中间状态会继续轮询,接口错误码会显示对应的中文原因。接口返回的 `progress` 会直接同步到节点进度条,例如 50 显示为一半;视频保存完成后进度条到 100。提交、查询及下载接口的文本响应体会打印到 ComfyUI 终端,令牌、临时链接和大段媒体数据会被遮蔽。普通生成模式固定调用 `omni_flash_10s`,模型由后端传入且节点不显示模型参数;视频编辑模式仍使用专用编辑模型。完成后返回可连接后续节点的原生 `VIDEO`,节点本身不显示视频预览。视频保存在 ComfyUI 的 `output/omni_flash` 目录;无需额外结果节点或后台任务接口。 + +### Grok Video / Grok Video Edit + +`Grok Video` 使用 `/grok/v1/videos/generations`,支持 `grok-imagine-video` 和 `grok-imagine-video-1.5` 两个模型,以及文生、图生和多参考素材三种模式。生成时长为 1~15 秒,分辨率支持 `480p / 720p / 1080p`;其中 1080p 仅用于 `grok-imagine-video-1.5` 的文生或图生,多参考素材最高 720p。图生模式只连接「图片1」,提示词可以留空;参考模式必须填写提示词,可连接最多 7 张图片,并使用最多 3 个参考音频。参考音频既可来自 `AUDIO` 端口,也可在「参考音色ID(逗号分隔)」中填写 `voice_id`,两类输入合计不超过 3 个。 + +`Grok Video Edit` 使用独立的编辑和延长接口,并共用同一任务查询端点。两种操作都可选择上述两个模型。编辑输入视频不能超过 8.7 秒,输出保持原时长和宽高比且最高 720p;延长时长为 2~10 秒,最终总时长等于输入时长加延长时长。节点会先校验模式、模型、素材数量、分辨率和时长,再上传临时素材并提交任务;完成后立即下载结果并输出 ComfyUI 原生 `VIDEO`。 + +### 视频裁剪 + +「视频裁剪」只需点击节点内的「上传视频」选择文件,也可连接上游 `VIDEO`;上传后的内部文件路径会随工作流保存,但不显示为用户参数。视频加载后可直接预览并拖动时间轴。固定时长大于 0 时,绿色选区会保持该长度并可整体拖动;固定时长为 0 时使用开始、结束时间自由裁剪。 ### Google Gemini Google Gemini 模型节点,支持更多模型选择。 +### Seedance 视频生成 + +「Seedance 全能生成视频」和「Seedance 多模态参考生视频」会统一封装生成阶段的版权审查错误。上游响应包含 `The request failed because the output video may be related to copyright restriction`、其复数 `restrictions` 形式,或带有 `OutputVideoSensitiveContentDetected.PolicyViolation:` 前缀时,节点错误框显示“输出视频触发版权审查被拒绝生成!”,不再直接展示英文响应。 + +「Seedance 创建素材」接受照片、视频或音频三种输入,并沿用已发布的 `SeedanceElementCreate` 节点 ID。旧工作流中的“真人照片 / 真人视频 / 真人音频”端口会在加载时自动迁移,未经过浏览器加载的 API 工作流也继续兼容旧参数名。 + +「Seedance 多模态参考生视频」的参考图片、参考视频和参考音频端口会在连接后按需增加,不再一次铺开全部端口。图片、视频和音频素材 ID 也采用渐进填写:每组始终保留一个空输入框,填入当前项后才显示下一项。旧工作流中的编号端口、已填写素材 ID 和“真人素材ID”名称会自动迁移。 + +「Seedance 全能生成视频」保留“素材创建模式”参数,默认“关闭”:隐藏全部素材 ID,并按线路自动创建连接的素材;改为“打开”后,才显示下方的素材 ID 单行输入并使用已有 ID。输入行与「Seedance 多模态参考生视频」一样按编号渐进展开:每类先显示一行,填写后展开下一空行。切回关闭不会清除已填 ID;旧工作流的“自动创建 / 手动”和三类聚合 ID 会自动迁移。输入端随生成模式动态增删:多模态仅显示参考图片、视频、音频,并随连接渐进增加端口;首尾帧仅显示首帧图片、尾帧图片。切换模式会断开被移除端口的连线,切回后需重新连接。 + +「Seedance 多模态参考生视频」和「Seedance 全能生成视频」的模型线路均支持“海外”和“国内”;“海外”沿用原“海外HC”线路,旧工作流会自动迁移。国内线路会按所选主模型调用对应的 Seedance 2.0、fast、mini 或 2.5 国内模型,新建节点默认使用“国内”。 + +两个单节点及「Seedance 全能生成视频(批量)」共用模型能力限制:Seedance 2.0 系列支持 4~15 秒,Seedance 2.5 支持 4~30 秒;2.5 支持 `480p / 720p / 1080p / 4k`,并允许最多 30 个图片类、10 个视频类和 10 个音频类参考内容。多模态节点中的直接媒体与对应素材 ID 共用这组数量额度。fast 和 mini 模型仍只支持 `480p / 720p`。宽高比统一为智能、`16:9 / 9:16 / 4:3 / 3:4 / 1:1 / 21:9`。 + +「Seedance 全能生成视频」现与「o1key 视频生成」共用参数标准、素材限制、`content` 请求体构造和 Seedance 提交/轮询/下载客户端。生成模式简化为“多模态”和“首尾帧”:新节点默认多模态,无参考素材而仅填写提示词时自动按文生视频提交;首尾帧需要一个首帧素材,尾帧可选,没有尾帧时自动按仅首帧提交。“素材创建模式”默认关闭,此时上传连接素材并按线路创建 HC/Doubao Asset;打开后显示编号式素材 ID 输入并跳过上传,使用已有图片、视频和音频素材 ID,其中首尾帧模式填写一个或两个图片素材 ID。连接的图片会按 PNG 上传且单项不超过 30MB,宽高各为 `300~6000px`、比例为 `0.4~2.5`;视频仅支持 MP4/MOV,单项不超过 512MB,并额外要求总像素为 `407,696~8,295,044`;音频支持 WAV/MP3/M4A/AAC/FLAC/OGG,单项不超过 100MB。所有素材在上传和创建素材前完成校验,准备阶段最多并发处理三个素材并保持输入顺序。seed 保留在普通参数区最下方;“联网搜索”和“返回末帧图片”暂时隐藏,不提供用户设置,但保留旧工作流中的值及参数顺序。联网搜索仅为旧工作流保留,当前统一的 `content` 请求不会发送该参数。旧的四种生成模式、参数顺序和“海外HC”线路会在加载时自动迁移。 + +「o1key 视频生成」是面板式统一入口,新建节点默认使用“多模态参考”,并支持 Seedance 2.0、2.0 Fast、2.0 Mini 和 2.5,以及文生视频、首帧、首尾帧和多模态参考四种模式。切换到其他工作流再返回时,节点会恢复离开前的生成模式、提示词、参数、素材 ID 和已上传图片/视频/音频。直接上传的首帧、尾帧和多模态参考图片遵循火山方舟官方输入限制:宽、高各为 `300~6000px`,宽高比为 `0.4~2.5`;官方没有为参考图片另设总像素下限。参考视频除相同的宽高及比例限制外,总像素必须在 `407,696~8,295,044` 之间。校验在上传前提示,并在后端素材快照阶段再次强制执行;浏览器无法读取受支持的 MOV/H.265 元数据时会交由后端 PyAV 校验,不会仅因浏览器解码能力不足而误拒绝。使用需要参考素材的 Seedance 模式时,还可选择“素材创建”:默认“自动创建”并显示对应的上传区;多模态区右上角使用一个统一的“上传”按钮,可一次选择图片、视频和音频并自动分类。自动模式会复用「Seedance 创建素材」的统一服务,海外线路创建 HC 素材,国内线路创建 Doubao 素材,素材进入可用状态后才提交视频。相同内容会复用已验证可用的素材 ID,视频提交失败后的重新生成也会直接复用本次已创建的 ID。选择“手动”后上传区会切换为素材 ID 输入,首帧模式需要一个图片素材 ID,首尾帧模式需要两个图片素材 ID,多模态模式可填写图片、视频和音频素材 ID。手动 ID 可通过「Seedance 创建素材」节点提前获取。面板沿用「o1key 图片生成」的视觉语言:提示词卡片、自定义下拉、素材缩略图、状态条和主操作按钮保持一致;视频参数仍采用单列布局,切换生成模式或素材创建方式时,只展示当前模式需要的素材区并同步调整节点高度。提示词右下角的「✨ AI帮写」使用独立视频默认预设,根据当前模式、时长、宽高比和音频开关组织连续的动作与镜头;首帧、尾帧及参考图按当前角色和顺序参与视觉分析,参考视频和音频只提供数量与顺序语义,不会把媒体内容上传给帮写模型。参考视频缩略图直接使用浏览器原生视频解码显示首个可用画面,不生成额外封面文件;若当前浏览器无法解码该格式,才回退为播放图标和文件名。上传图片后可通过缩略图左上角按钮打开与图片生成节点相同的编辑器;多张参考图片、视频和音频都可拖拽调整提交顺序,编号角标会同步更新。它不使用 ComfyUI 原生任务队列:每点一次「开始生成」都会创建并连线原生「保存视频」节点,开启「返回尾帧」时再同时创建原生「保存图像」节点,然后提交一个可并行运行的后台任务。插件不限制独立视频任务的并行数量;每个任务的素材准备阶段最多同时处理三个素材,避免瞬间冲击上传和素材接口。生成按钮只在本次上传或提交期间短暂禁用。上游服务仍可能依据账户配额或服务状态限流。 + +该统一视频节点会为当前及后续新增的视频模型共用友好审查提示。错误中出现 `copyright` 时优先按版权限制处理,并结合 `audio / video / content / real` 字段或关键词分别提示输出音频、输出视频、提示词或真人内容触发限制;例如 `The request failed because the output audio may be related to copyright restrictions` 会显示“请求失败,输出视频中音频触发版权限制!”。其他安全、内容审查或策略拒绝错误也会按同样对象给出中文提示;普通网络和参数错误仍保留原诊断信息。 + +首帧和尾帧图片的“上传”按钮旁提供“画布取图”。它直接复用图片生成节点的画布结果发现、选择器和 `/view` 读取逻辑;选中后仍通过 ComfyUI 原生上传写入 `input`,并执行相同的 Seedance 图片限制校验。多模态参考区只保留统一的“上传”按钮,以减少重复入口。 + +点击“开始生成”会从生成节点的 `VIDEO` 输出自动创建并连接原生“保存视频”节点,不再创建“o1key 视频结果”节点;打开“返回尾帧”时,还会从 `LAST_FRAME` 输出同时创建并连接原生“保存图像”节点。生成进度和错误显示在生成节点状态栏,完成后结果直接出现在对应的原生保存节点中。旧工作流中的“o1key 视频结果”仍可加载,但已标记为兼容节点,新任务不会再创建它。参考文件先通过 ComfyUI 原生上传接口写入 `input`,任务启动时再复制到批次隔离的临时目录。工作流和任务历史只保存安全的文件描述,不保存 API Key、Base64、绝对输入路径或签名临时 URL。 + +### MiniMax H3 / H3 Max 视频生成 + +通过 New API 网关调用 `MiniMax-H3` 或 `MiniMax-H3-MAX`。两者均支持文生视频、 +首帧、尾帧和首尾帧生视频;`MiniMax-H3` 另外支持多图片、多视频、多音频参考素材生成。 +节点完成后会立即下载临时 CDN 视频并输出 ComfyUI 原生 `VIDEO`,可连接内置保存视频节点。 + +- `MiniMax-H3`:`768P` / `2K`,4~15 秒,支持最多 9 张参考图、3 个参考视频、3 个参考音频,参考素材合计最多 12 个 +- `MiniMax-H3-MAX`:`480P` / `768P`,5~15 秒,不支持参考素材模式 +- 查询任务按接口建议每 10 秒轮询;短暂的 `unknown` 状态按排队中处理,总等待上限为 2000 秒 +- `seed` 为原生生成参数,支持固定、递增、递减和每次随机化 +- 文生视频比例:21:9、16:9、4:3、1:1、3:4、9:16 +- 首帧、尾帧、首尾帧模式自动使用 `adaptive` +- 参考素材:最多 9 张图片、3 个视频、3 段音频;默认 `adaptive`,也可指定具体输出比例 +- API Token 从插件配置读取,不会保存在工作流中 + --- ## 📝 更新日志 @@ -198,6 +287,8 @@ Google Gemini 模型节点,支持更多模型选择。 欢迎提交 Issue 和 Pull Request! +开始开发前请阅读 [AGENTS.md](AGENTS.md) 和 [维护者文档](docs/README.md)。 + --- ## ⚠️ 开发者注意事项 @@ -221,29 +312,14 @@ git push gitee main # 再同步到 Gitee 镜像 **所有文本文件必须使用 UTF-8 编码(无 BOM)!** -如果你在 GitHub 上看到中文乱码,说明文件编码有问题。请使用以下方法修复: - -**Windows 用户:** - -```powershell -.\fix_encoding.ps1 -``` - -**Linux/Mac 用户:** - -```bash -chmod +x fix_encoding.sh -./fix_encoding.sh -``` - -详细说明请查看 [编码修复指南.md](./编码修复指南.md) +如果出现中文乱码,请确认编辑器按 UTF-8(无 BOM)读取和保存文件。 --- ## 📮 联系方式 - GitHub: [@lizhongyi1209](https://github.com/lizhongyi1209) -- 项目地址: https://github.com/lizhongyi1209/comfyui_o1key +- 项目地址: https://git.o1key.com/publisher/comfyui_o1key --- diff --git a/V3_DEV_GUIDE.py b/V3_DEV_GUIDE.py deleted file mode 100644 index 6723124..0000000 --- a/V3_DEV_GUIDE.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -ComfyUI V3 节点开发参考 -======================== - -本文件记录了将 V1 节点迁移到 V3 的关键经验,供后续节点开发快速参考。 -基于 nano_banana.py 的实际迁移总结。 - -核心发现:V3 节点可以直接放入 V1 的 NODE_CLASS_MAPPINGS 中注册, -ComfyUI 通过 issubclass(obj_class, _ComfyNodeInternal) 自动识别并 -调用 GET_NODE_INFO_V1() 生成前端所需的节点信息。无需 comfy_entrypoint。 - -=== 最小 V3 节点模板 === - - from comfy_api.latest import io - - class MyNode(io.ComfyNode): - - @classmethod - def define_schema(cls): - return io.Schema( - node_id="MyNode", # 必须与 NODE_CLASS_MAPPINGS 的 key 一致 - display_name="我的节点", - category="image/generation", - inputs=[...], - outputs=[io.Image.Output(display_name="输出")], - ) - - @classmethod - def execute(cls, input1, input2, ...) -> io.NodeOutput: - # 业务逻辑 - return io.NodeOutput(result) - -=== V1 → V3 对照表 === - - V1 V3 - ───────────────────────────────────────────────────── - INPUT_TYPES() classmethod define_schema() → io.Schema - RETURN_TYPES = ("IMAGE",) outputs=[io.Image.Output()] - RETURN_NAMES = ("输出",) io.Image.Output(display_name="输出") - FUNCTION = "generate" 固定为 execute - CATEGORY = "xxx" Schema(category="xxx") - generate(self, ...) execute(cls, ...) classmethod - self.xxx 实例状态 模块级单例函数 - -=== DynamicCombo(动态联动下拉框)=== - -场景:一个 combo 的选项决定其他 combo 显示哪些值。 - - io.DynamicCombo.Input("模型", options=[ - io.DynamicCombo.Option("选项A", [ - io.Combo.Input("子参数1", options=["x", "y"]), - io.Combo.Input("子参数2", options=["1K", "2K"]), - ]), - io.DynamicCombo.Option("选项B", [ - io.Combo.Input("子参数1", options=["x", "y", "z", "w"]), - io.Combo.Input("子参数2", options=["512px", "1K", "2K", "4K"]), - ]), - ]) - -execute 中接收为 dict: - def execute(cls, 模型, ...): - selected = 模型["模型"] # "选项A" 或 "选项B" - sub1 = 模型["子参数1"] # 对应选项下的子输入值 - sub2 = 模型["子参数2"] - -注意:dict 的 key 是 DynamicCombo.Input 的 id("模型"), -子输入的 key 是各 Combo.Input 的 id。 - -=== Autogrow(自动增长输入槽)=== - -场景:用户连接一个槽后自动出现下一个,最多 N 个。 - - io.Autogrow.Input("参考图", - template=io.Autogrow.TemplatePrefix( - input=io.Image.Input("img"), - prefix="参考图", # 生成 参考图0, 参考图1, ... - min=0, # 最少显示几个槽 - max=9, # 最多几个槽 - ), - ) - -execute 中接收为 dict(或 io.Autogrow.Type): - def execute(cls, 参考图=None, ...): - if 参考图: - for key, tensor in 参考图.items(): - # key = "参考图0", "参考图1", ... - # tensor = IMAGE tensor 或 None - -=== 实例状态处理 === - -V3 的 execute 是 classmethod,无法用 self。 -用模块级单例替代: - - _client = None - - def _get_client(): - global _client - if _client is None: - _client = MyAPIClient() - return _client - -=== 注册方式(与 V1 共存)=== - -在 __init__.py 中照常注册,无需任何特殊处理: - - NODE_CLASS_MAPPINGS = { - "MyV1Node": MyV1Node, # V1 节点 - "MyV3Node": MyV3Node, # V3 节点,自动识别 - } - - NODE_DISPLAY_NAME_MAPPINGS = { - "MyV1Node": "V1 节点", - "MyV3Node": "V3 节点", # 也可省略,V3 用 Schema.display_name - } - -=== 注意事项 === - -1. node_id 必须与 NODE_CLASS_MAPPINGS 的 key 完全一致 -2. V3 execute 返回 io.NodeOutput(tensor),不是 tuple -3. _wrap_generate_for_error_display 等 V1 包装器对 V3 无效 - (找不到 generate 方法会安全跳过) -4. V3 支持 async execute(直接加 async 即可) -5. 输入参数名必须与 Schema inputs 的 id 一致 -6. DynamicCombo 的子输入在前端会随选项切换动态显示/隐藏 -7. Autogrow 的 widget 输入会被强制为 force_input(仅连接,无控件) - -=== 可用输入类型速查 === - - io.String.Input(id, default="", multiline=False) - io.Int.Input(id, default=0, min=0, max=N, step=1) - io.Float.Input(id, default=0.0, min=0.0, max=N, step=0.01) - io.Combo.Input(id, options=[...], default="...") - io.Boolean.Input(id, default=False) - io.Image.Input(id) - io.Mask.Input(id) - io.Latent.Input(id) - io.DynamicCombo.Input(id, options=[DynamicCombo.Option(...)]) - io.Autogrow.Input(id, template=TemplatePrefix/TemplateNames) - -=== 可用输出类型速查 === - - io.Image.Output(display_name="...") - io.String.Output(display_name="...") - io.Int.Output() - io.Float.Output() - io.Latent.Output() - io.Mask.Output() -""" diff --git a/__init__.py b/__init__.py index 6cb010e..944beee 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ """ Comfyui_o1key - ComfyUI 自定义节点集合 -通过 api.o1key.com 调用 AI 模型进行图像生成和文本生成 +通过 api.o1key.cn 调用 AI 模型进行图像生成和文本生成 项目结构: ├── nodes/ # 节点实现 @@ -71,12 +71,20 @@ if not getattr(asyncio, "_o1key_new_event_loop_patched", False): asyncio.new_event_loop = _o1key_new_event_loop asyncio._o1key_new_event_loop_patched = True -from .nodes import NanoBananaPro, BatchNanoBananaPro, GoogleGemini, LoadFile, ImageStitchPro, BatchCleanMetadata, VideoPreview, GoogleVeo, Google31Video, FluxImageEdit, UniversalLLMChat, KlingVideo, KlingFirstLastFrame, KlingMotionControlTest, AspectRatioPreset, BatchImagesO1key, Seedance, SeedanceMultiModal, StreamPreview, DoubaoImage, O1keyGPTImage, O1keyGPTImageBatch, O1keyGrokImage, O1keyGrokVideo, KVideoFirstLast, KVideoImage2Video -from .nodes import K3Video, K3VideoFirstLast, K3MotionControl, K3MotionVideoCheck, NanoBananaV2, NanoBananaV2Batch, SaveImageFormat +from .nodes import NanoBananaPro, BatchNanoBananaPro, GoogleGemini, LoadFile, LoadImagesFromFolder, ImageStitchPro, BatchCleanMetadata, VideoPreview, GoogleVeo, Google31Video, MiniMaxH3Video, FluxImageEdit, UniversalLLMChat, BatchImagesO1key, SeedanceMultiModal, StreamPreview, DoubaoImage, O1keyGPTImage, O1keyGPTImageBatch, O1keyGrokImage, O1keyGrokVideo, O1keyGrokVideoEdit +from .nodes import K3Video, K3MotionControl, SaveImageFormat from .nodes import O1keySavePSD from .nodes import O1keyRemoveBackground -from .nodes import O1keyColorRemoveBG from .nodes import O1keyGridSplitter +from .nodes import O1keyPromptMultiFunction +from .nodes import O1keyVideoTrim +from .nodes import SeedanceElementCreate +from .nodes import SeedanceAutoPass +from .nodes import SeedanceAutoPassBatch +from .nodes import O1keyAutoRedCast +from .nodes import O1keyImageGenerator, O1keyImageSave +from .nodes import O1keyVideoGenerator, O1keyVideoResult +from .nodes import O1keyOmniFlashVideo # 报错弹框友好文案(不修改原节点代码,仅在外层统一处理) _MSG_TIMEOUT = "API 请求超时,请稍后重试或检查网络。" @@ -110,8 +118,6 @@ def _wrap_generate_for_error_display(cls, attr="generate"): _wrap_generate_for_error_display(NanoBananaPro) _wrap_generate_for_error_display(BatchNanoBananaPro) -_wrap_generate_for_error_display(NanoBananaV2) -_wrap_generate_for_error_display(NanoBananaV2Batch) # ComfyUI 节点注册 NODE_CLASS_MAPPINGS = { @@ -119,21 +125,18 @@ NODE_CLASS_MAPPINGS = { "BatchNanoBananaPro": BatchNanoBananaPro, "GoogleGemini": GoogleGemini, "LoadFile": LoadFile, + "O1keyLoadImagesFromFolder": LoadImagesFromFolder, "ImageStitchPro": ImageStitchPro, "BatchCleanMetadata": BatchCleanMetadata, "VideoPreview": VideoPreview, "GoogleVeo": GoogleVeo, "Google31Video": Google31Video, + "MiniMaxH3Video": MiniMaxH3Video, "FluxImageEdit": FluxImageEdit, "UniversalLLMChat": UniversalLLMChat, - "KlingVideo": KlingVideo, - "KlingFirstLastFrame": KlingFirstLastFrame, - "KlingMotionControlTest": KlingMotionControlTest, - "AspectRatioPreset": AspectRatioPreset, "BatchImagesO1key": BatchImagesO1key, - "Seedance": Seedance, "SeedanceMultiModal": SeedanceMultiModal, "StreamPreview": StreamPreview, "DoubaoImage": DoubaoImage, @@ -141,61 +144,68 @@ NODE_CLASS_MAPPINGS = { "O1keyGPTImageBatch": O1keyGPTImageBatch, "O1keyGrokImage": O1keyGrokImage, "O1keyGrokVideo": O1keyGrokVideo, - "KVideoFirstLast": KVideoFirstLast, - "KVideoImage2Video": KVideoImage2Video, + "O1keyGrokVideoEdit": O1keyGrokVideoEdit, "K3Video": K3Video, - "K3VideoFirstLast": K3VideoFirstLast, "K3MotionControl": K3MotionControl, - "K3MotionVideoCheck": K3MotionVideoCheck, - "NanoBananaV2": NanoBananaV2, - "NanoBananaV2Batch": NanoBananaV2Batch, "SaveImageFormat": SaveImageFormat, "O1keySavePSD": O1keySavePSD, "O1keyRemoveBackground": O1keyRemoveBackground, - "O1keyColorRemoveBG": O1keyColorRemoveBG, "O1keyGridSplitter": O1keyGridSplitter, + "O1keyPromptMultiFunction": O1keyPromptMultiFunction, + "O1keyVideoTrim": O1keyVideoTrim, + "SeedanceElementCreate": SeedanceElementCreate, + "SeedanceAutoPass": SeedanceAutoPass, + "SeedanceAutoPassBatch": SeedanceAutoPassBatch, + "O1keyAutoRedCast": O1keyAutoRedCast, + "O1keyImageGenerator": O1keyImageGenerator, + "O1keyImageSave": O1keyImageSave, + "O1keyVideoGenerator": O1keyVideoGenerator, + "O1keyVideoResult": O1keyVideoResult, + "O1keyOmniFlashVideo": O1keyOmniFlashVideo, } NODE_DISPLAY_NAME_MAPPINGS = { + "O1keyVideoGenerator": "o1key 视频生成", + "O1keyVideoResult": "o1key 视频结果", + "O1keyOmniFlashVideo": "Omni Flash 视频生成", "NanoBanana": "Nano Banana", - "BatchNanoBananaPro": "批量 Nano Banana", + "BatchNanoBananaPro": "Nano Banana 批量跑图", "GoogleGemini": "Google Gemini", "LoadFile": "加载文件", + "O1keyLoadImagesFromFolder": "加载图像(文件夹)", "ImageStitchPro": "图像拼接 Pro", "BatchCleanMetadata": "批量任务(防AI识别)", "VideoPreview": "预览视频", "GoogleVeo": "Google Veo - ab", "Google31Video": "Google 3.1 Video", + "MiniMaxH3Video": "MiniMax H3 / H3 Max 视频生成", "FluxImageEdit": "Flux2 图像编辑", - "UniversalLLMChat": "全能LLM对话助手", - "KlingVideo": "文/图生视频 自研模型", - "KlingFirstLastFrame": "首尾帧生视频 自研模型", - "KlingMotionControlTest": "动作控制 自研模型", - "AspectRatioPreset": "图片宽高比预设", + "UniversalLLMChat": "提示词专家", "BatchImagesO1key": "加载图像(批量)", - "Seedance": "Seedance 视频生成", "SeedanceMultiModal": "Seedance 多模态参考生视频", "StreamPreview": "流式文本预览", "DoubaoImage": "豆包生图", - "O1keyGPTImage": "o1key GPT Image", + "O1keyGPTImage": "gpt image", "O1keyGPTImageBatch": "o1key GPT Image(批量)", "O1keyGrokImage": "Grok Image", "O1keyGrokVideo": "Grok Video", - "KVideoFirstLast": "K26 图生视频(首尾帧)", - "KVideoImage2Video": "K26 图生视频", - "K3Video": "K3 图生视频 自研", - "K3VideoFirstLast": "首尾帧 K3 自研", - "K3MotionControl": "动作控制 K3 自研", - "K3MotionVideoCheck": "视频时长检测 K3", - "NanoBananaV2": "Nano Banana V2", - "NanoBananaV2Batch": "Nano Banana V2(批量)", + "O1keyGrokVideoEdit": "Grok Video Edit", + "K3Video": "K 视频生成", + "K3MotionControl": "K 动作模仿", "SaveImageFormat": "保存图像(格式转换)", "O1keySavePSD": "保存 PSD(分层)", "O1keyRemoveBackground": "去背景(rembg)", - "O1keyColorRemoveBG": "颜色去背景", "O1keyGridSplitter": "合并图智能切割", + "O1keyPromptMultiFunction": "提示词(多功能)", + "O1keyVideoTrim": "视频裁剪", + "SeedanceElementCreate": "Seedance 创建素材", + "SeedanceAutoPass": "Seedance 全能生成视频", + "SeedanceAutoPassBatch": "Seedance 全能生成视频(批量)", + "O1keyAutoRedCast": "自动红偏校正", + "O1keyImageGenerator": "o1key 图片生成", + "O1keyImageSave": "o1key 保存图像", } WEB_DIRECTORY = "./web" @@ -207,11 +217,149 @@ try: from aiohttp import web from server import PromptServer import folder_paths - from .utils.config import CONFIG_FILE, load_config, NETWORK_ROUTES + from .utils.config import ( + DEFAULT_NETWORK_ROUTE, + NETWORK_ROUTE_CONFIG_KEY, + NETWORK_ROUTE_OPTIONS, + NETWORK_ROUTES, + get_api_key, + get_network_route, + load_config, + update_config, + ) + from .utils.chat_support import ( + PROMPT_OPTIMIZER_TIMEOUT_SECONDS, + build_search_context, + expand_xlsx_attachments, + extract_search_query, + optimize_image_prompt, + rewrite_search_query, + web_search, + write_video_prompt, + ) + from .utils.o1key_image_jobs import register_o1key_image_job_routes + from .utils.o1key_image_thumbnail import register_o1key_image_thumbnail_route + from .utils.o1key_video_jobs import register_o1key_video_job_routes from .utils.updater import UpdateError, update_package - import threading as _update_threading - _update_lock = _update_threading.Lock() + _O1KEY_IMAGE_JOB_MANAGER = register_o1key_image_job_routes( + PromptServer, + web, + folder_paths, + ) + register_o1key_image_thumbnail_route(PromptServer, web, folder_paths) + _O1KEY_VIDEO_JOB_MANAGER = register_o1key_video_job_routes( + PromptServer, + web, + folder_paths, + ) + + # 每次 ComfyUI 进程启动都会生成新的标识。前端据此确认后端确实完成了 + # 重启,而不是仅仅重新加载了浏览器页面。 + import os as _restart_os + import sys as _restart_sys + import threading as _restart_threading + import time as _restart_time + import uuid as _restart_uuid + + _O1KEY_BOOT_ID = _restart_uuid.uuid4().hex + _o1key_restart_pending = _restart_threading.Event() + _o1key_update_lock = _restart_threading.Lock() + + @PromptServer.instance.routes.post("/o1key/update") + async def update_o1key_package(request): + if request.headers.get("X-O1Key-Update") != "1": + return web.json_response( + {"code": "invalid_request", "error": "无效的更新请求。", "suggestion": "请从 O1Key 更新面板重新操作。"}, + status=403, + ) + if not _o1key_update_lock.acquire(blocking=False): + return web.json_response( + {"code": "update_in_progress", "error": "更新正在进行。", "suggestion": "请等待当前操作完成,不要重复点击。"}, + status=409, + ) + try: + result = await asyncio.to_thread(update_package) + return web.json_response(result) + except UpdateError as exc: + return web.json_response(exc.as_dict(), status=exc.status) + except Exception: + logging.exception("O1Key 更新失败") + return web.json_response( + {"code": "internal_error", "error": "更新失败。", "suggestion": "请查看 ComfyUI 日志,并在确认本地文件安全后重试。"}, + status=500, + ) + finally: + _o1key_update_lock.release() + + def _o1key_restart_command(): + """复用当前解释器和启动参数,并禁止重启时额外打开浏览器。""" + auto_launch_flags = {"--auto-launch", "--auto_launch", "--launch"} + # orig_argv 包含嵌入式 Python 的 -s 等解释器参数;普通 sys.argv 不包含。 + # 保留这些参数可确保便携版重启前后的运行环境完全一致。 + original = getattr(_restart_sys, "orig_argv", None) + source_arguments = original[1:] if original else _restart_sys.argv + arguments = [arg for arg in source_arguments if arg not in auto_launch_flags] + if "--disable-auto-launch" not in arguments: + arguments.append("--disable-auto-launch") + return [_restart_sys.executable, *arguments] + + def _restart_o1key_comfyui_process(delay=1.25): + """在响应发送完成后,用相同终端进程重新启动 ComfyUI。""" + try: + _restart_time.sleep(delay) + try: + _restart_sys.stdout.flush() + _restart_sys.stderr.flush() + except Exception: + pass + + command = _o1key_restart_command() + print("[O1Key] 正在重启 ComfyUI...", flush=True) + _restart_os.execv(command[0], command) + except Exception: + _o1key_restart_pending.clear() + logging.exception("O1Key 无法重启 ComfyUI 进程") + + @PromptServer.instance.routes.get("/o1key/restart/status") + async def get_o1key_restart_status(request): + return web.json_response( + { + "ready": True, + "boot_id": _O1KEY_BOOT_ID, + "pid": _restart_os.getpid(), + }, + headers={"Cache-Control": "no-store"}, + ) + + @PromptServer.instance.routes.post("/o1key/restart") + async def restart_o1key_comfyui(request): + if _o1key_restart_pending.is_set(): + return web.json_response( + { + "success": False, + "error": "ComfyUI 正在重启,请稍候。", + "boot_id": _O1KEY_BOOT_ID, + }, + status=409, + ) + + _o1key_restart_pending.set() + worker = _restart_threading.Thread( + target=_restart_o1key_comfyui_process, + name="o1key-comfyui-restart", + daemon=True, + ) + worker.start() + return web.json_response( + { + "success": True, + "message": "ComfyUI 正在重启。", + "boot_id": _O1KEY_BOOT_ID, + "pid": _restart_os.getpid(), + }, + headers={"Cache-Control": "no-store"}, + ) def _get_o1key_server_port(): try: @@ -243,10 +391,30 @@ try: ) def _get_o1key_notes_file(): + # 笔记固定存 ComfyUI input 目录,插件更新/替换不会清空笔记 import os as _os_notes input_dir = _os_notes.path.abspath(folder_paths.get_input_directory()) _os_notes.makedirs(input_dir, exist_ok=True) - return _os_notes.path.join(input_dir, "o1key-notes.json") + notes_file = _os_notes.path.join(input_dir, "o1key-notes.json") + + return notes_file + + def _get_o1key_cases_dir(): + import os as _os_cases + cases_dir = _os_cases.path.join(_os_cases.path.dirname(__file__), "cases") + _os_cases.makedirs(cases_dir, exist_ok=True) + return cases_dir + + def _get_o1key_case_file(filename): + import os as _os_cases + safe_name = _os_cases.path.basename(filename or "") + if not safe_name.lower().endswith(".json"): + return None + cases_dir = _get_o1key_cases_dir() + path = _os_cases.path.abspath(_os_cases.path.join(cases_dir, safe_name)) + if not path.startswith(_os_cases.path.abspath(cases_dir) + _os_cases.sep): + return None + return path def _extract_o1key_notes(payload): if isinstance(payload, list): @@ -255,6 +423,46 @@ try: return payload["notes"] return None + @PromptServer.instance.routes.get("/o1key/cases") + async def get_o1key_cases(request): + import os as _os_cases + import json as _json_cases + + cases_dir = _get_o1key_cases_dir() + cases = [] + for filename in sorted(_os_cases.listdir(cases_dir), key=str.lower): + if not filename.lower().endswith(".json"): + continue + path = _get_o1key_case_file(filename) + if not path or not _os_cases.path.isfile(path): + continue + title = _os_cases.path.splitext(filename)[0] + try: + with open(path, "r", encoding="utf-8") as cf: + data = _json_cases.load(cf) + if isinstance(data, dict): + title = str(data.get("title") or data.get("name") or title) + except Exception: + pass + cases.append({"id": filename, "filename": filename, "title": title}) + return web.json_response({"cases": cases, "path": str(cases_dir)}) + + @PromptServer.instance.routes.get("/o1key/case") + async def get_o1key_case(request): + import os as _os_cases + import json as _json_cases + + filename = request.query.get("file", "") + path = _get_o1key_case_file(filename) + if not path or not _os_cases.path.isfile(path): + return web.json_response({"error": "case not found"}, status=404) + try: + with open(path, "r", encoding="utf-8") as cf: + data = _json_cases.load(cf) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + return web.json_response({"filename": _os_cases.path.basename(path), "case": data}) + @PromptServer.instance.routes.get("/o1key/notes") async def get_o1key_notes(request): import os as _os_notes @@ -327,32 +535,69 @@ try: masked = key[:3] + "****" + key[-4:] else: masked = "****" - return web.json_response({"has_key": bool(key), "masked": masked}) + return web.json_response({ + "has_key": bool(key), + "masked": masked, + "network_route": get_network_route(), + "network_route_options": NETWORK_ROUTE_OPTIONS, + }) + + @PromptServer.instance.routes.post("/o1key/config") + async def set_o1key_config(request): + data = await request.json() + route = str(data.get("network_route", "")).strip() + if route not in NETWORK_ROUTES: + return web.json_response({"error": "网络线路无效"}, status=400) + + updates = {NETWORK_ROUTE_CONFIG_KEY: route} + if data.get("api_key") is not None: + new_key = str(data.get("api_key", "")).strip() + if not new_key: + return web.json_response({"error": "API Key 不能为空"}, status=400) + if "\n" in new_key or "\r" in new_key: + return web.json_response({"error": "API Key 格式无效"}, status=400) + updates["O1KEY_API_KEY"] = new_key + + config = update_config(updates=updates) + key = config.get("O1KEY_API_KEY", "") + masked = key[:3] + "****" + key[-4:] if len(key) > 8 else ("****" if key else "") + return web.json_response({ + "success": True, + "has_key": bool(key), + "masked": masked, + "network_route": route, + }) + + @PromptServer.instance.routes.post("/o1key/network_route") + async def set_network_route(request): + data = await request.json() + route = str(data.get("network_route", "")).strip() + if route not in NETWORK_ROUTES: + return web.json_response({"error": "网络线路无效"}, status=400) + update_config(updates={NETWORK_ROUTE_CONFIG_KEY: route}) + return web.json_response({"success": True, "network_route": route}) @PromptServer.instance.routes.post("/o1key/api_key") async def set_api_key_route(request): - import os data = await request.json() new_key = data.get("api_key", "").strip() if not new_key: return web.json_response({"error": "API Key 不能为空"}, status=400) - config = load_config() - config["O1KEY_API_KEY"] = new_key - lines = [] - for k, v in config.items(): - lines.append(f"{k}={v}") - with open(CONFIG_FILE, 'w', encoding='utf-8') as f: - f.write("\n".join(lines) + "\n") + if "\n" in new_key or "\r" in new_key: + return web.json_response({"error": "API Key 格式无效"}, status=400) + update_config(updates={"O1KEY_API_KEY": new_key}) return web.json_response({"success": True}) @PromptServer.instance.routes.post("/o1key/test_key") async def test_api_key_route(request): import aiohttp as _aiohttp data = await request.json() - test_key = data.get("api_key", "").strip() + test_key = str(data.get("api_key") or "").strip() or get_api_key() if not test_key: - return web.json_response({"valid": False, "error": "密钥不能为空"}) - base_url = NETWORK_ROUTES.get("CF加速", "https://cf-api.o1key.com") + return web.json_response({"valid": False, "error": "请先输入或保存 API Key"}) + requested_route = str(data.get("network_route", "")).strip() + route = requested_route if requested_route in NETWORK_ROUTES else get_network_route() + base_url = NETWORK_ROUTES.get(route, NETWORK_ROUTES[DEFAULT_NETWORK_ROUTE]) url = f"{base_url}/v1/models" headers = {"Authorization": f"Bearer {test_key}"} try: @@ -370,15 +615,261 @@ try: @PromptServer.instance.routes.delete("/o1key/api_key") async def delete_api_key_route(request): - config = load_config() - config.pop("O1KEY_API_KEY", None) - lines = [] - for k, v in config.items(): - lines.append(f"{k}={v}") - with open(CONFIG_FILE, 'w', encoding='utf-8') as f: - f.write("\n".join(lines) + "\n") + update_config(remove=["O1KEY_API_KEY"]) return web.json_response({"success": True}) + # === 主体(Element)代理:转发到 {base}/kling/v1/general/*,后端注入令牌 === + _ELEMENT_PREFIX = "/kling/v1/general" + + def _element_base_url(_route=None): + return NETWORK_ROUTES[get_network_route()].rstrip("/") + + def _element_headers(): + config = load_config() + key = config.get("O1KEY_API_KEY", "") + if not key: + return None + return {"Authorization": f"Bearer {key}"} + + @PromptServer.instance.routes.get("/o1key/element/mine") + async def o1key_element_mine(request): + """列表接口:GET /kling/v1/general/advanced-custom-elements""" + import aiohttp as _aiohttp + headers = _element_headers() + if not headers: + return web.json_response({"success": False, "message": "未配置 API Key"}, status=401) + base = _element_base_url(request.query.get("route")) + url = f"{base}{_ELEMENT_PREFIX}/advanced-custom-elements" + params = {} + # 支持分页参数 + page_num = request.query.get("pageNum", "1") + page_size = request.query.get("pageSize", "100") + params["pageNum"] = page_num + params["pageSize"] = page_size + try: + async with _aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, params=params, + timeout=_aiohttp.ClientTimeout(total=30)) as up: + result = await up.json() + # 新API返回: {"success": true, "data": {"code": 0, "data": [...], "total": N}, "message": ""} + # 转换为前端期望的格式: {"success": true, "data": [...]} + if result.get("success") and isinstance(result.get("data"), dict): + elements = result["data"].get("data", []) + return web.json_response({"success": True, "data": elements, "message": ""}) + return web.json_response(result, status=up.status) + except Exception as e: + return web.json_response({"success": False, "message": str(e)}, status=502) + + @PromptServer.instance.routes.post("/o1key/element/upload") + async def o1key_element_upload(request): + """转发 multipart 文件上传:POST /kling/v1/general/upload + 视频可达 200MB,固定总超时会截断大文件上传,改用:不限总时长 + + 读空闲 120s 超时(连接卡死才超时,慢速大文件不会被一刀切断)。""" + import aiohttp as _aiohttp + headers = _element_headers() + if not headers: + return web.json_response({"success": False, "message": "未配置 API Key"}, status=401) + base = _element_base_url(request.query.get("route")) + url = f"{base}{_ELEMENT_PREFIX}/upload" + try: + reader = await request.multipart() + field = await reader.next() + if field is None or field.name != "file": + return web.json_response({"success": False, "message": "缺少 file 字段"}, status=400) + file_bytes = await field.read(decode=False) + filename = field.filename or "image.png" + form = _aiohttp.FormData() + form.add_field("file", file_bytes, filename=filename, + content_type=field.headers.get("Content-Type", "application/octet-stream")) + timeout = _aiohttp.ClientTimeout(total=None, sock_connect=30, sock_read=120) + async with _aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, data=form, + timeout=timeout) as up: + data = await up.json() + return web.json_response(data, status=up.status) + except Exception as e: + return web.json_response({"success": False, "message": str(e)}, status=502) + + @PromptServer.instance.routes.post("/o1key/element/create") + async def o1key_element_create(request): + """创建主体:POST /kling/v1/general/advanced-custom-elements + + 新API字段映射: + - name -> element_name + - description -> element_description + - reference_type -> reference_type (image_refer / video_refer) + - frontal_image -> frontal_image + - refer_images -> refer_images + - video_list -> video_list + - element_voice_id, tag_ids, channel_id 保持不变 + """ + import aiohttp as _aiohttp + import json as _json_element + headers = _element_headers() + if not headers: + return web.json_response({"success": False, "message": "未配置 API Key"}, status=401) + try: + payload = await request.json() + except Exception as e: + return web.json_response({"success": False, "message": f"请求体无效: {e}"}, status=400) + route = payload.pop("route", None) + base = _element_base_url(route) + url = f"{base}{_ELEMENT_PREFIX}/advanced-custom-elements" + + # 字段映射:前端使用旧字段名,转换为新API字段名 + api_payload = {} + if "name" in payload: + api_payload["element_name"] = payload["name"] + if "description" in payload: + api_payload["element_description"] = payload["description"] + # 其他字段直接透传 + for key in ["reference_type", "frontal_image", "refer_images", "video_list", + "element_voice_id", "tag_ids", "channel_id"]: + if key in payload: + api_payload[key] = payload[key] + + send_headers = {**headers, "Content-Type": "application/json"} + # 打印创建主体的请求信息 + try: + print(f"[主体创建] 请求 URL: {url}") + print("[主体创建] 请求体: " + _json_element.dumps(api_payload, ensure_ascii=False, indent=2)) + except Exception: + pass + try: + async with _aiohttp.ClientSession() as session: + async with session.post(url, headers=send_headers, json=api_payload, + timeout=_aiohttp.ClientTimeout(total=60)) as up: + data = await up.json() + # 打印创建主体的响应信息 + try: + print("[主体创建] 响应体: " + _json_element.dumps(data, ensure_ascii=False, indent=2)) + except Exception: + pass + return web.json_response(data, status=up.status) + except Exception as e: + return web.json_response({"success": False, "message": str(e)}, status=502) + + @PromptServer.instance.routes.post("/o1key/element/refresh") + async def o1key_element_refresh(request): + """查询主体:GET /kling/v1/general/advanced-custom-elements/{task_id} + + 前端传 id(数据库主键),需要先查本地库拿到 job_id(即 task_id),再查询上游。 + 为了简化,这里改为前端直接传 task_id(即创建时返回的 job_id)。 + """ + import aiohttp as _aiohttp + import json as _json_refresh + headers = _element_headers() + if not headers: + return web.json_response({"success": False, "message": "未配置 API Key"}, status=401) + try: + payload = await request.json() + except Exception: + payload = {} + task_id = payload.get("task_id") or payload.get("id") + if not task_id: + return web.json_response({"success": False, "message": "缺少 task_id"}, status=400) + base = _element_base_url(payload.get("route")) + url = f"{base}{_ELEMENT_PREFIX}/advanced-custom-elements/{task_id}" + try: + async with _aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, + timeout=_aiohttp.ClientTimeout(total=60)) as up: + data = await up.json() + # 打印查询响应 + try: + print(f"[主体查询] task_id={task_id}") + print("[主体查询] 响应体: " + _json_refresh.dumps(data, ensure_ascii=False, indent=2)) + except Exception: + pass + # 新API返回嵌套结构,需要提取 task_status 和 element_id + # 响应: {"success": true, "data": {"code": 0, "data": {"task_status": "succeed", "task_result": {"elements": [...]}}}} + if data.get("success") and isinstance(data.get("data"), dict): + inner = data["data"].get("data", {}) + task_status = inner.get("task_status", "") + # 转换为前端期望的格式 + element = { + "id": task_id, + "job_id": task_id, + "status": task_status, + "task_status": task_status, + } + if task_status == "succeed": + elements = inner.get("task_result", {}).get("elements", []) + if elements: + first = elements[0] + element["element_id"] = str(first.get("element_id", "")) + element["name"] = first.get("element_name", "") + element["frontal_image"] = first.get("element_image_list", {}).get("frontal_image", "") + elif task_status == "failed": + element["fail_reason"] = inner.get("task_status_msg", "") + return web.json_response({"success": True, "data": {"element": element, "detail": data["data"]}}) + return web.json_response(data, status=up.status) + except Exception as e: + return web.json_response({"success": False, "message": str(e)}, status=502) + + @PromptServer.instance.routes.post("/o1key/element/delete") + async def o1key_element_delete(request): + """删除主体:POST /kling/v1/general/delete-advanced-elements + + 请求体: {"element_id": "315320838184520"} + """ + import aiohttp as _aiohttp + headers = _element_headers() + if not headers: + return web.json_response({"success": False, "message": "未配置 API Key"}, status=401) + try: + payload = await request.json() + except Exception: + payload = {} + element_id = payload.get("element_id") or payload.get("id") + if not element_id: + return web.json_response({"success": False, "message": "缺少 element_id"}, status=400) + base = _element_base_url(payload.get("route")) + url = f"{base}{_ELEMENT_PREFIX}/delete-advanced-elements" + delete_payload = {"element_id": str(element_id)} + send_headers = {**headers, "Content-Type": "application/json"} + try: + async with _aiohttp.ClientSession() as session: + async with session.post(url, headers=send_headers, json=delete_payload, + timeout=_aiohttp.ClientTimeout(total=60)) as up: + data = await up.json() + return web.json_response(data, status=up.status) + except Exception as e: + return web.json_response({"success": False, "message": str(e)}, status=502) + + @PromptServer.instance.routes.get("/o1key/element/image") + async def o1key_element_image(request): + """图片同源代理:ComfyUI 的 CSP 限制 img-src 'self',外链缩略图无法直接显示。 + 前端把缩略图 src 指向本路由,后端取回字节再吐给浏览器,对浏览器即同源。 + 仅允许 o1key 资源域,避免被当成任意 URL 抓取的 SSRF 跳板。""" + import aiohttp as _aiohttp + from urllib.parse import urlparse, unquote + raw = request.query.get("url", "") + if not raw: + return web.json_response({"success": False, "message": "缺少 url"}, status=400) + target = unquote(raw) + try: + parsed = urlparse(target) + except Exception: + parsed = None + if not parsed or parsed.scheme not in ("http", "https"): + return web.json_response({"success": False, "message": "非法 url"}, status=400) + host = (parsed.hostname or "").lower() + if not (host.endswith(".o1key.com") or host.endswith(".o1key.cn") + or host in ("o1key.com", "o1key.cn")): + return web.json_response({"success": False, "message": "不允许的图片来源"}, status=403) + try: + async with _aiohttp.ClientSession() as session: + async with session.get(target, timeout=_aiohttp.ClientTimeout(total=30)) as up: + if up.status != 200: + return web.Response(status=up.status) + body = await up.read() + ctype = up.headers.get("Content-Type", "image/jpeg").split(";")[0].strip() + return web.Response(body=body, content_type=ctype or "image/jpeg", + headers={"Cache-Control": "max-age=3600"}) + except Exception as e: + return web.json_response({"success": False, "message": str(e)}, status=502) + @PromptServer.instance.routes.get("/o1key/output_history") async def get_output_history(request): """读取 output 目录文件,按执行分组返回 /api/jobs 兼容格式""" @@ -616,37 +1107,93 @@ try: pass return web.json_response({"success": True, "deleted": deleted_files}) - @PromptServer.instance.routes.post("/o1key/update") - async def update_node_package(request): - if request.headers.get("X-O1Key-Update") != "1": - return web.json_response({"error": "无效的更新请求。"}, status=403) - if not _update_lock.acquire(blocking=False): - return web.json_response({"error": "更新正在进行,请稍候。"}, status=409) - try: - result = await asyncio.to_thread(update_package) - return web.json_response(result) - except UpdateError as exc: - return web.json_response({"error": str(exc)}, status=409) - except Exception: - logging.exception("o1key update failed") - return web.json_response({"error": "更新失败,请查看 ComfyUI 日志。"}, status=500) - finally: - _update_lock.release() + # === 图片生成提示词优化(服务端读取参考图,避免前端接触 API Key) === + @PromptServer.instance.routes.post("/o1key/image/prompt-optimize") + async def optimize_o1key_image_prompt(request): + import aiohttp as _aiohttp - # === AI 聊天代理(流式 SSE 透传) === - @PromptServer.instance.routes.post("/o1key/restart") - async def restart_server(request): - import sys, os as _ros, subprocess, threading - def _do_restart(): - import time - time.sleep(1.5) - skip = {"--auto-launch", "--auto_launch", "--launch", "--windows-standalone-build"} - args = [a for a in sys.argv if a not in skip] - args.append("--disable-auto-launch") - subprocess.Popen([sys.executable] + args, cwd=_ros.getcwd()) - _ros._exit(0) - threading.Thread(target=_do_restart, daemon=True).start() - return web.json_response({"success": True, "message": "正在重启..."}) + try: + data = await request.json() + if not isinstance(data, dict): + raise ValueError("请求体必须是对象") + prompt = data.get("prompt", "") + references = data.get("references", []) + api_key = get_api_key() or "" + if not api_key: + return web.json_response({"error": "未配置 API Key"}, status=401) + + timeout = _aiohttp.ClientTimeout(total=PROMPT_OPTIMIZER_TIMEOUT_SECONDS + 15) + async with _aiohttp.ClientSession(timeout=timeout) as session: + optimized = await optimize_image_prompt( + session, + NETWORK_ROUTES[get_network_route()], + api_key, + prompt, + references, + folder_paths.get_input_directory(), + ) + return web.json_response( + { + "prompt": optimized, + "model": "gpt-5.6-sol", + "reasoning_effort": "high", + }, + headers={"Cache-Control": "no-store"}, + ) + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=400) + except RuntimeError as exc: + return web.json_response({"error": str(exc)}, status=502) + except Exception: + return web.json_response({"error": "提示词优化失败,请稍后重试"}, status=500) + + # === 视频生成 AI帮写(独立视频预设,仅分析安全的 input 图片描述) === + @PromptServer.instance.routes.post("/o1key/video/prompt-write") + async def write_o1key_video_prompt(request): + import aiohttp as _aiohttp + + try: + data = await request.json() + if not isinstance(data, dict): + raise ValueError("请求体必须是对象") + api_key = get_api_key() or "" + if not api_key: + return web.json_response({"error": "未配置 API Key"}, status=401) + + context = { + "generation_mode": data.get("generation_mode", "text"), + "duration": data.get("duration", "auto"), + "aspect_ratio": data.get("aspect_ratio", "auto"), + "generate_audio": data.get("generate_audio", False), + "reference_video_count": data.get("reference_video_count", 0), + "reference_audio_count": data.get("reference_audio_count", 0), + } + timeout = _aiohttp.ClientTimeout(total=PROMPT_OPTIMIZER_TIMEOUT_SECONDS + 15) + async with _aiohttp.ClientSession(timeout=timeout) as session: + written = await write_video_prompt( + session, + NETWORK_ROUTES[get_network_route()], + api_key, + data.get("prompt", ""), + data.get("references", []), + folder_paths.get_input_directory(), + context, + ) + return web.json_response( + { + "prompt": written, + "model": "gpt-5.6-sol", + "reasoning_effort": "high", + "preset": "video-default", + }, + headers={"Cache-Control": "no-store"}, + ) + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=400) + except RuntimeError as exc: + return web.json_response({"error": str(exc)}, status=502) + except Exception: + return web.json_response({"error": "视频 AI帮写失败,请稍后重试"}, status=500) # === AI 聊天代理(流式 SSE 透传) === @PromptServer.instance.routes.post("/o1key/chat/completions") @@ -660,45 +1207,88 @@ try: if not api_key: return web.json_response({"error": "未配置 API Key"}, status=401) - route = data.get("route", "CF加速") - base_url = NETWORK_ROUTES.get(route, "https://cf-api.o1key.com") - model = data.get("model", "gpt-5.5") + base_url = NETWORK_ROUTES[get_network_route()] + model = data.get("model", "gpt-6-sol") messages = data.get("messages", []) + if not isinstance(messages, list) or not messages: + return web.json_response({"error": "缺少对话内容"}, status=400) + + try: + messages = expand_xlsx_attachments(messages) + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=400) url = f"{base_url}/v1/chat/completions" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", } + reasoning = data.get("reasoning_effort", "high") + if reasoning not in ("low", "medium", "high"): + reasoning = "high" body = {"model": model, "messages": messages, "stream": True} + if model == "claude-fable-5": + budgets = {"low": 2048, "medium": 8192, "high": 16384} + budget = budgets[reasoning] + body["thinking"] = {"type": "enabled", "budget_tokens": budget} + body["max_tokens"] = budget + 8192 + elif model in ("gpt-5.5", "gpt-5.6-sol", "gpt-6-astra", "gpt-6-sol", "gemini-3.1-pro-preview"): + body["reasoning_effort"] = reasoning - resp = web.StreamResponse( - status=200, reason="OK", - headers={ - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - } - ) - await resp.prepare(request) + search_trace = None + timeout = _aiohttp.ClientTimeout(total=120) + async with _aiohttp.ClientSession(timeout=timeout) as session: + if data.get("web_search") is True: + raw_query = extract_search_query(messages) + if raw_query: + query = await rewrite_search_query(session, base_url, api_key, raw_query) or raw_query + try: + results = await web_search(session, query) + search_trace = { + "query": query, + "results": [ + {"title": item["title"], "url": item["url"]} + for item in results + ], + } + messages = list(messages) + messages.insert(max(0, len(messages) - 1), { + "role": "system", + "content": build_search_context(query, results), + }) + body["messages"] = messages + except Exception as exc: + search_trace = {"query": query, "results": [], "error": str(exc)} - try: - timeout = _aiohttp.ClientTimeout(total=120) - async with _aiohttp.ClientSession(timeout=timeout) as session: + resp = web.StreamResponse( + status=200, reason="OK", + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + } + ) + await resp.prepare(request) + + try: + if search_trace: + event = _cjson.dumps({"o1key_search": search_trace}, ensure_ascii=False) + await resp.write(f"data: {event}\n\n".encode("utf-8")) async with session.post(url, headers=headers, json=body) as upstream: if upstream.status != 200: err = await upstream.text() - await resp.write(f"data: {_cjson.dumps({'error': err})}\n\n".encode()) + event = _cjson.dumps({"error": err}, ensure_ascii=False) + await resp.write(f"data: {event}\n\n".encode("utf-8")) await resp.write(b"data: [DONE]\n\n") return resp async for chunk in upstream.content.iter_any(): await resp.write(chunk) - except Exception as e: - await resp.write(f"data: {_cjson.dumps({'error': str(e)})}\n\n".encode()) - await resp.write(b"data: [DONE]\n\n") + except Exception as e: + event = _cjson.dumps({"error": str(e)}, ensure_ascii=False) + await resp.write(f"data: {event}\n\n".encode("utf-8")) + await resp.write(b"data: [DONE]\n\n") return resp - # === 执行事件 Hook:持久化耗时元数据 === import time as _time, json as _json2, os as _os _execution_tracker = {} diff --git a/cases/初始案例-亮度对比.json b/cases/初始案例-亮度对比.json new file mode 100644 index 0000000..78fc52f --- /dev/null +++ b/cases/初始案例-亮度对比.json @@ -0,0 +1,440 @@ +{ + "revision": 0, + "last_node_id": 140, + "last_link_id": 0, + "nodes": [ + { + "id": 140, + "type": "916dff42-6166-4d45-b028-04eaf69fbb35", + "pos": [ + 500, + 1440 + ], + "size": [ + 250, + 178 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "label": "image", + "localized_name": "images.image0", + "name": "images.image0", + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "label": "IMAGE", + "localized_name": "IMAGE0", + "name": "IMAGE0", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "proxyWidgets": [ + [ + "4", + "value" + ], + [ + "5", + "value" + ] + ] + }, + "widgets_values": [], + "title": "Brightness and Contrast" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "916dff42-6166-4d45-b028-04eaf69fbb35", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 143, + "lastLinkId": 118, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Brightness and Contrast", + "inputNode": { + "id": -10, + "bounding": [ + 360, + -176, + 120, + 60 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 1410, + -176, + 120, + 60 + ] + }, + "inputs": [ + { + "id": "a5aae7ea-b511-4045-b5da-94101e269cd7", + "name": "images.image0", + "type": "IMAGE", + "linkIds": [ + 117 + ], + "localized_name": "images.image0", + "label": "image", + "pos": [ + 460, + -156 + ] + } + ], + "outputs": [ + { + "id": "30b72604-69b3-4944-b253-a9099bbd73a9", + "name": "IMAGE0", + "type": "IMAGE", + "linkIds": [ + 118 + ], + "localized_name": "IMAGE0", + "label": "IMAGE", + "pos": [ + 1430, + -156 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 4, + "type": "PrimitiveFloat", + "pos": [ + 540, + -280 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "label": "brightness", + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 115 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveFloat", + "min": 0, + "max": 100, + "precision": 1, + "step": 1, + "display": "gradientslider", + "gradient_stops": [ + { + "offset": 0, + "color": [ + 0, + 0, + 0 + ] + }, + { + "offset": 1, + "color": [ + 255, + 255, + 255 + ] + } + ] + }, + "widgets_values": [ + 0 + ] + }, + { + "id": 5, + "type": "PrimitiveFloat", + "pos": [ + 540, + -170 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "label": "contrast", + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 116 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveFloat", + "min": 0, + "max": 100, + "precision": 1, + "step": 1, + "display": "gradientslider", + "gradient_stops": [ + { + "offset": 0, + "color": [ + 136, + 136, + 136 + ] + }, + { + "offset": 0.4, + "color": [ + 68, + 68, + 68 + ] + }, + { + "offset": 0.6, + "color": [ + 187, + 187, + 187 + ] + }, + { + "offset": 0.8, + "color": [ + 0, + 0, + 0 + ] + }, + { + "offset": 1, + "color": [ + 255, + 255, + 255 + ] + } + ] + }, + "widgets_values": [ + 0 + ] + }, + { + "id": 143, + "type": "GLSLShader", + "pos": [ + 840, + -280 + ], + "size": [ + 400, + 212 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "label": "image0", + "localized_name": "images.image0", + "name": "images.image0", + "type": "IMAGE", + "link": 117 + }, + { + "label": "image1", + "localized_name": "images.image1", + "name": "images.image1", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "u_float0", + "localized_name": "floats.u_float0", + "name": "floats.u_float0", + "shape": 7, + "type": "FLOAT", + "link": 115 + }, + { + "label": "u_float1", + "localized_name": "floats.u_float1", + "name": "floats.u_float1", + "shape": 7, + "type": "FLOAT", + "link": 116 + }, + { + "label": "u_float2", + "localized_name": "floats.u_float2", + "name": "floats.u_float2", + "shape": 7, + "type": "FLOAT", + "link": null + }, + { + "label": "u_int0", + "localized_name": "ints.u_int0", + "name": "ints.u_int0", + "shape": 7, + "type": "INT", + "link": null + }, + { + "localized_name": "fragment_shader", + "name": "fragment_shader", + "type": "STRING", + "widget": { + "name": "fragment_shader" + }, + "link": null + }, + { + "localized_name": "size_mode", + "name": "size_mode", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "size_mode" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE0", + "name": "IMAGE0", + "type": "IMAGE", + "links": [ + 118 + ] + }, + { + "localized_name": "IMAGE1", + "name": "IMAGE1", + "type": "IMAGE", + "links": null + }, + { + "localized_name": "IMAGE2", + "name": "IMAGE2", + "type": "IMAGE", + "links": null + }, + { + "localized_name": "IMAGE3", + "name": "IMAGE3", + "type": "IMAGE", + "links": null + } + ], + "properties": { + "Node name for S&R": "GLSLShader" + }, + "widgets_values": [ + "#version 300 es\nprecision highp float;\n\nuniform sampler2D u_image0;\nuniform float u_float0; // Brightness slider -100..100\nuniform float u_float1; // Contrast slider -100..100\n\nin vec2 v_texCoord;\nout vec4 fragColor;\n\nconst float MID_GRAY = 0.18; // 18% reflectance\n\n// sRGB gamma 2.2 approximation\nvec3 srgbToLinear(vec3 c) {\n return pow(max(c, 0.0), vec3(2.2));\n}\n\nvec3 linearToSrgb(vec3 c) {\n return pow(max(c, 0.0), vec3(1.0/2.2));\n}\n\nfloat mapBrightness(float b) {\n return clamp(b / 100.0, -1.0, 1.0);\n}\n\nfloat mapContrast(float c) {\n return clamp(c / 100.0 + 1.0, 0.0, 2.0);\n}\n\nvoid main() {\n vec4 orig = texture(u_image0, v_texCoord);\n\n float brightness = mapBrightness(u_float0);\n float contrast = mapContrast(u_float1);\n\n vec3 lin = srgbToLinear(orig.rgb);\n\n lin = (lin - MID_GRAY) * contrast + brightness + MID_GRAY;\n\n // Convert back to sRGB\n vec3 result = linearToSrgb(clamp(lin, 0.0, 1.0));\n\n fragColor = vec4(result, orig.a);\n}\n", + "from_input" + ] + } + ], + "groups": [], + "links": [ + { + "id": 115, + "origin_id": 4, + "origin_slot": 0, + "target_id": 143, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 116, + "origin_id": 5, + "origin_slot": 0, + "target_id": 143, + "target_slot": 3, + "type": "FLOAT" + }, + { + "id": 117, + "origin_id": -10, + "origin_slot": 0, + "target_id": 143, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 118, + "origin_id": 143, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + } + ], + "extra": { + "workflowRendererVersion": "LG" + }, + "category": "Image Tools/Color adjust", + "description": "Adjusts image brightness and contrast using a real-time GPU fragment shader." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/clients/__init__.py b/clients/__init__.py index 16fe95d..d97b6bf 100644 --- a/clients/__init__.py +++ b/clients/__init__.py @@ -1,16 +1,34 @@ -""" -API 客户端模块 -包含与外部 API 通信的客户端实现 +"""API clients exposed through lazy imports. + +Importing one client submodule no longer imports every provider client. This +keeps plugin startup lightweight and isolates optional provider dependencies. """ -from .base_client import BaseAPIClient -from .gemini_client import GeminiAPIClient -from .gemini_flash_client import GeminiFlashClient -from .sora_client import SoraClient -from .kling_client import KlingClient -from .veo_client import VeoClient -from .newapi_veo_client import NewAPIVeoClient -from .grok_video_client import GrokVideoClient -from .openai_client import OpenAIAPIClient +from importlib import import_module -__all__ = ['BaseAPIClient', 'GeminiAPIClient', 'GeminiFlashClient', 'SoraClient', 'KlingClient', 'VeoClient', 'NewAPIVeoClient', 'GrokVideoClient', 'OpenAIAPIClient'] + +_EXPORTS = { + "BaseAPIClient": ("base_client", "BaseAPIClient"), + "GeminiAPIClient": ("gemini_client", "GeminiAPIClient"), + "GeminiFlashClient": ("gemini_flash_client", "GeminiFlashClient"), + "SoraClient": ("sora_client", "SoraClient"), + "VeoClient": ("veo_client", "VeoClient"), + "NewAPIVeoClient": ("newapi_veo_client", "NewAPIVeoClient"), + "MiniMaxH3Client": ("minimax_h3_client", "MiniMaxH3Client"), + "GrokVideoClient": ("grok_video_client", "GrokVideoClient"), + "OmniFlashClient": ("omni_flash_client", "OmniFlashClient"), + "SeedreamImageClient": ("seedream_image_client", "SeedreamImageClient"), +} + +__all__ = list(_EXPORTS) + + +def __getattr__(name): + try: + module_name, attribute_name = _EXPORTS[name] + except KeyError as exc: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc + + value = getattr(import_module(f".{module_name}", __name__), attribute_name) + globals()[name] = value + return value diff --git a/clients/base_async_provider.py b/clients/base_async_provider.py deleted file mode 100644 index 10f871f..0000000 --- a/clients/base_async_provider.py +++ /dev/null @@ -1,183 +0,0 @@ -""" -异步生图 Provider 抽象基类 -定义异步提交+轮询模式的统一接口,支持多种生图模型后端 - -每个 Provider 封装一种 API 后端的通信协议: - - 如何提交任务(端点、请求体格式) - - 如何轮询状态(端点、状态字段语义) - - 如何解析结果(响应格式、图片提取方式) - -新增第三方生图模型时,只需实现此接口即可接入异步节点。 -""" - -from abc import ABC, abstractmethod -from typing import Dict, List, Optional -from PIL import Image - - -class BaseAsyncImageProvider(ABC): - """异步生图 Provider 抽象基类""" - - def __init__(self, api_key: str, proxy_url: Optional[str] = None): - self.api_key = api_key - self.proxy_url = proxy_url - - # ======================================================================== - # 必须实现的抽象方法 - # ======================================================================== - - @property - @abstractmethod - def api_base_url(self) -> str: - """异步 API 的基础 URL,如 https://cf-api.o1key.com""" - ... - - @abstractmethod - def get_submit_endpoint(self, model: str, resolution: str) -> str: - """获取提交任务的 API 端点路径(不含 base_url)""" - ... - - @abstractmethod - def build_submit_body( - self, - prompt: str, - model: str, - resolution: str, - aspect_ratio: str, - images: Optional[List[Image.Image]] = None, - **kwargs - ) -> dict: - """构建提交任务的请求体""" - ... - - @abstractmethod - def extract_task_id(self, response: dict) -> str: - """从提交响应中提取 task_id""" - ... - - @abstractmethod - def extract_status(self, response: dict) -> str: - """从轮询响应中提取任务状态(如 SUBMITTED / IN_PROGRESS / SUCCESS / FAILURE)""" - ... - - @abstractmethod - async def parse_result( - self, - result_data: dict, - session - ) -> List[Image.Image]: - """从任务完成后的 result data 中解析生成的图像列表""" - ... - - @abstractmethod - def get_models(self) -> List[str]: - """获取此 Provider 支持的模型 ID 列表""" - ... - - @abstractmethod - def get_model_aspect_ratios(self, model_id: str) -> List[str]: - """获取指定模型支持的宽高比""" - ... - - @abstractmethod - def get_model_resolutions(self, model_id: str) -> List[str]: - """获取指定模型支持的分辨率""" - ... - - # ======================================================================== - # 可选的覆盖方法 - # ======================================================================== - - def get_poll_endpoint(self, task_id: str) -> str: - """获取轮询任务状态的 API 端点路径(默认实现适用于 o1key 异步 API)""" - return f"/async/v1/tasks/{task_id}" - - def get_headers(self) -> dict: - """获取 HTTP 请求头""" - return { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json" - } - - def get_all_aspect_ratios(self) -> List[str]: - """获取所有模型支持的宽高比(去重合并)""" - seen = set() - result = [] - for model_id in self.get_models(): - for ratio in self.get_model_aspect_ratios(model_id): - if ratio not in seen: - seen.add(ratio) - result.append(ratio) - return result - - def get_all_resolutions(self) -> List[str]: - """获取所有模型支持的分辨率(去重,按固定顺序排列)""" - _ORDER = ["512px", "1K", "2K", "4K"] - seen = set() - for model_id in self.get_models(): - for res in self.get_model_resolutions(model_id): - seen.add(res) - return [r for r in _ORDER if r in seen] - - def get_extra_inputs(self) -> dict: - """ - 返回此 Provider 特有的额外 ComfyUI 输入参数。 - 子类重写以声明 Provider 专有的选项(如 Google Search Grounding)。 - - Returns: - dict,格式与 ComfyUI INPUT_TYPES 的 optional 字段一致 - """ - return {} - - def get_extra_kwargs(self, **kwargs) -> dict: - """ - 从 ComfyUI kwargs 中提取此 Provider 特有的参数, - 转换为 build_submit_body 可接收的 kwargs。 - - 子类重写以处理 Provider 专有参数。 - """ - return {} - - def extract_progress(self, response: dict) -> Optional[float]: - """ - 从轮询响应中提取生成进度。 - - Args: - response: 轮询接口返回的完整响应字典 - - Returns: - 0.0-1.0 之间的进度值,或 None 表示该响应不含进度信息 - """ - return None - - def query_balance_sync(self) -> Optional[dict]: - """ - 同步查询账户余额(可选)。 - 返回 None 表示不支持。 - """ - return None - - def format_balance_info(self, balance_data: dict) -> str: - """格式化余额信息为展示文本""" - return "" - - # ======================================================================== - # 工具方法 - # ======================================================================== - - @staticmethod - def build_proxy_url(port: str) -> Optional[str]: - """ - 将端口号字符串转为 aiohttp 可用的 HTTP 代理 URL。 - 兼容 v2rayN (10808)、Clash Verge (7897) 等。 - - Args: - port: 用户填写的端口号,如 "7897",空字符串返回 None - - Returns: - 代理 URL 或 None - """ - port = (port or "").strip() - if not port or not port.isdigit(): - return None - return f"http://127.0.0.1:{port}" diff --git a/clients/element_client.py b/clients/element_client.py new file mode 100644 index 0000000..51cbdb5 --- /dev/null +++ b/clients/element_client.py @@ -0,0 +1,74 @@ +""" +可灵主体(Element)API 客户端 +封装对 {base}/kling/v1/general/* 的调用,统一注入 Authorization。 +两类调用方: +- 后端代理路由(__init__.py):面板的上传/创建/刷新/列表/删除。 +- 节点提交(K3_video.py):生视频前查列表拿 名称→element_id 映射。 +所有方法返回后端的响应信封 {"success", "message", "data"} 解出的 data, +失败抛 RuntimeError(带 message),由调用方决定如何呈现。 +""" + +import aiohttp + +from ..utils.config import get_api_key_or_raise, get_base_url_by_route + + +_ELEMENT_PREFIX = "/kling/v1/general" + + +def _headers(api_key: str = None) -> dict: + key = api_key or get_api_key_or_raise() + return {"Authorization": f"Bearer {key}"} + + +def _resolve_base(route: str = None, base_url: str = None) -> str: + """Prefer an explicit URL, otherwise use the global network route.""" + if base_url: + return base_url.rstrip("/") + return get_base_url_by_route().rstrip("/") + + +def _unwrap(payload: dict): + """从响应信封取 data;success=false 时抛 RuntimeError。""" + if not isinstance(payload, dict): + raise RuntimeError(f"主体接口返回异常:{payload!r}") + if not payload.get("success", False): + raise RuntimeError(payload.get("message") or "主体接口调用失败") + return payload.get("data") + + +async def list_elements(session: aiohttp.ClientSession, *, route=None, base_url=None, + include_all=False, api_key=None): + """GET /advanced-custom-elements:返回主体列表(默认仅 succeed)。""" + base = _resolve_base(route, base_url) + url = f"{base}{_ELEMENT_PREFIX}/advanced-custom-elements" + params = {"pageNum": "1", "pageSize": "100"} + async with session.get(url, headers=_headers(api_key), params=params) as resp: + data = await resp.json() + result = _unwrap(data) + # 新API返回: {"code": 0, "data": [...], "total": N} + if isinstance(result, dict) and "data" in result: + elements = result.get("data", []) + else: + elements = result if isinstance(result, list) else [] + # 过滤:默认只返回 succeed 状态 + if not include_all: + elements = [e for e in elements if e.get("status") == "succeed"] + return elements + + +async def fetch_name_to_id_map(session: aiohttp.ClientSession, *, route=None, + base_url=None, api_key=None) -> dict: + """生视频用:返回 {主体名称: element_id},仅含已成功的主体。 + + 注意:新API返回的 element_id 是 int64 数字类型,不是字符串。 + """ + elements = await list_elements(session, route=route, base_url=base_url, api_key=api_key) + mapping = {} + for e in elements: + name = (e.get("name") or "").strip() + eid = e.get("element_id") + # element_id 可能是数字或字符串,统一保持原始类型(生视频时需要数字) + if name and eid is not None: + mapping[name] = eid + return mapping diff --git a/clients/flux_edit_client.py b/clients/flux_edit_client.py index f4ba1f7..1b21cff 100644 --- a/clients/flux_edit_client.py +++ b/clients/flux_edit_client.py @@ -1,6 +1,6 @@ """ Flux 图像编辑 API 客户端 -通过 vip.o1key.com 调用 Flux2 图像编辑 + SeedVR2 超分辨率服务 +通过 api.o1key.cn 调用 Flux2 图像编辑 + SeedVR2 超分辨率服务 工作流程: 1. submit_task → POST /v1/images/edits (multipart/form-data 提交主图+参考图+提示词) @@ -30,7 +30,7 @@ class FluxEditClient: """ Flux 图像编辑客户端 - 对接 vip.o1key.com 上的 /v1/images/edits 接口, + 对接 api.o1key.cn 上的 /v1/images/edits 接口, 将图像编辑+超分辨率任务提交到远程服务器执行。 """ diff --git a/clients/gemini_async_provider.py b/clients/gemini_async_provider.py deleted file mode 100644 index 9ce237b..0000000 --- a/clients/gemini_async_provider.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -Gemini 异步生图 Provider -通过 cf-api.o1key.com 的异步提交+轮询接口调用 Gemini 图像生成模型 - -协议说明: - - 提交:POST {base}/async{gemini_endpoint}?image_format=url - - 轮询:GET {base}/async/v1/tasks/{task_id} - - 结果:可能直接返回 image_url,也可能返回 Gemini 标准 candidates 格式 -""" - -from io import BytesIO -from typing import Dict, List, Optional -from PIL import Image - -from .base_async_provider import BaseAsyncImageProvider -from .gemini_client import GeminiAPIClient -from ..utils.config import get_async_api_base_url, get_api_key_or_raise -from ..models_config import ( - get_enabled_models, - get_model_supported_aspect_ratios, - get_model_supported_resolutions, -) - - -class GeminiAsyncImageProvider(BaseAsyncImageProvider): - """ - Gemini 异步生图 Provider - - 委托 GeminiAPIClient 处理: - - 端点构造(get_endpoint) - - 请求体构建(build_request_body) - - 响应解析(parse_response_async) - """ - - def __init__(self, api_key: str = None, proxy_url: str = None): - if api_key is None: - api_key = get_api_key_or_raise("O1KEY_API_KEY") - super().__init__(api_key=api_key, proxy_url=proxy_url) - self._client = GeminiAPIClient(api_key=api_key) - - # ======================================================================== - # 抽象方法实现 - # ======================================================================== - - @property - def api_base_url(self) -> str: - return getattr(self, '_route_base_url', None) or get_async_api_base_url() - - def get_submit_endpoint(self, model: str, resolution: str) -> str: - gemini_endpoint = self._client.get_endpoint( - model=model, resolution=resolution, image_format="url" - ) - base = gemini_endpoint.split("?")[0] - async_endpoint = f"/async{base}" - if "?" in gemini_endpoint: - async_endpoint += "?" + gemini_endpoint.split("?", 1)[1] - return async_endpoint - - def build_submit_body( - self, - prompt: str, - model: str, - resolution: str, - aspect_ratio: str, - images: Optional[List[Image.Image]] = None, - **kwargs - ) -> dict: - return self._client.build_request_body( - prompt=prompt, - images=images, - aspect_ratio=aspect_ratio, - resolution=resolution, - enable_grounding=kwargs.get("enable_grounding", False), - enable_image_search=kwargs.get("enable_image_search", False), - image_compression=getattr(self, "image_compression", None), - thinking_level=kwargs.get("thinking_level"), - request_log_enabled=False, - ) - - def extract_task_id(self, response: dict) -> str: - task_id = response.get("task_id") - if not task_id: - raise RuntimeError(f"提交响应中未找到 task_id: {response}") - return task_id - - def extract_status(self, response: dict) -> str: - return response.get("status", "UNKNOWN") - - async def parse_result(self, result_data: dict, session) -> List[Image.Image]: - images = result_data.get("images") if isinstance(result_data, dict) else None - if isinstance(images, list) and images: - parsed = [] - for item in images: - if not isinstance(item, dict): - continue - image_url = item.get("url") or item.get("image_url") - if image_url: - async with session.get(image_url) as img_resp: - if img_resp.status == 200: - img_bytes = await img_resp.read() - parsed.append(Image.open(BytesIO(img_bytes)).convert("RGB")) - else: - raise RuntimeError(f"下载图片失败 ({img_resp.status}): {image_url}") - if parsed: - return parsed - - # 异步接口可能直接返回 image_url - image_url = result_data.get("image_url", "") if isinstance(result_data, dict) else "" - if image_url: - async with session.get(image_url) as img_resp: - if img_resp.status == 200: - img_bytes = await img_resp.read() - return [Image.open(BytesIO(img_bytes))] - raise RuntimeError(f"下载图片失败 ({img_resp.status}): {image_url}") - - # 否则按 Gemini 标准格式解析 - images_list, _ = await self._client.parse_response_async(result_data, session=session) - return images_list - - def get_models(self) -> List[str]: - return get_enabled_models() - - def get_model_aspect_ratios(self, model_id: str) -> List[str]: - return get_model_supported_aspect_ratios(model_id) - - def get_model_resolutions(self, model_id: str) -> List[str]: - return get_model_supported_resolutions(model_id) - - # ======================================================================== - # 可选方法覆盖 - # ======================================================================== - - def get_extra_inputs(self) -> dict: - """Gemini 专有:Google Search Grounding""" - return { - "联网功能": (["关闭", "打开"], {"default": "关闭"}), - } - - def get_extra_kwargs(self, **kwargs) -> dict: - return { - "enable_grounding": kwargs.pop("联网功能", "关闭") == "打开", - } - - def extract_progress(self, response: dict) -> Optional[float]: - """从轮询响应中提取进度(0.0-1.0)""" - - def _coerce(val) -> Optional[float]: - if val is None or isinstance(val, bool): - return None - if isinstance(val, (int, float)): - progress = float(val) - elif isinstance(val, str): - text = val.strip() - if not text: - return None - has_percent_suffix = text.endswith("%") - if has_percent_suffix: - text = text[:-1].strip() - try: - progress = float(text) - except ValueError: - return None - if has_percent_suffix: - progress /= 100.0 - else: - return None - if progress > 1.0: - progress /= 100.0 - return max(0.0, min(progress, 1.0)) - - # 直接字段:progress / percentage - for field in ("progress", "percentage", "percent"): - progress = _coerce(response.get(field)) - if progress is not None: - return progress - - # 嵌套字段:progressInfo / progress_info - progress_info = response.get("progressInfo") or response.get("progress_info") - if isinstance(progress_info, dict): - for field in ("progress", "percentage", "percent"): - progress = _coerce(progress_info.get(field)) - if progress is not None: - return progress - - return None - - def query_balance_sync(self) -> Optional[dict]: - try: - return self._client.query_balance_sync() - except Exception: - return None - - def format_balance_info(self, balance_data: dict) -> str: - return self._client.format_balance_info(balance_data) diff --git a/clients/gemini_client.py b/clients/gemini_client.py index 67c0cde..2accafc 100644 --- a/clients/gemini_client.py +++ b/clients/gemini_client.py @@ -1,12 +1,13 @@ """ Gemini API 客户端 -处理与 api.o1key.com 的通信,用于图像生成 +处理与 api.o1key.cn 的通信,用于图像生成 """ +import base64 import re import time from io import BytesIO -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Awaitable, Callable, Dict, List, Optional import aiohttp from PIL import Image @@ -80,7 +81,7 @@ class GeminiAPIClient(BaseAPIClient): endpoint = "/v1beta/models/nano-banana-pro-2k:generateContent" elif model == "nano-banana-2-次卡": - if resolution == "512px": + if resolution == "512": endpoint = "/v1beta/models/nano-banana-2-0.5k:generateContent" elif resolution == "1K": endpoint = "/v1beta/models/nano-banana-2-1k:generateContent" @@ -92,7 +93,7 @@ class GeminiAPIClient(BaseAPIClient): endpoint = "/v1beta/models/nano-banana-2-2k:generateContent" elif model == "nano-banana-2-官方计费": - if resolution == "512px": + if resolution == "512": endpoint = "/v1beta/models/nano-banana-2-0.5k-official:generateContent" elif resolution == "1K": endpoint = "/v1beta/models/nano-banana-2-1k-official:generateContent" @@ -214,7 +215,7 @@ class GeminiAPIClient(BaseAPIClient): # 添加文本部分 parts.append({"text": prompt}) - image_config = {"imageSize": resolution} + image_config = {"imageSize": {"512": "512px"}.get(resolution, resolution)} if aspect_ratio and aspect_ratio != "智能": image_config["aspectRatio"] = aspect_ratio @@ -364,7 +365,8 @@ class GeminiAPIClient(BaseAPIClient): async def parse_response_async( self, response: Dict[str, Any], - session: Optional[aiohttp.ClientSession] = None + session: Optional[aiohttp.ClientSession] = None, + image_downloader: Optional[Callable[[str], Awaitable[bytes]]] = None, ) -> tuple[List[Image.Image], Dict[str, Any]]: """ 异步解析 API 响应,提取生成的图像 @@ -432,6 +434,24 @@ class GeminiAPIClient(BaseAPIClient): if session is None: session = self._make_session() close_session = True + + def _tag_image(img: Image.Image, raw_bytes: bytes) -> Image.Image: + fmt = (img.format or "").upper() + if fmt == "JPG": + fmt = "JPEG" + if fmt: + img.format = fmt + setattr(img, "_o1key_original_format", fmt) + setattr(img, "_o1key_original_bytes", raw_bytes) + return img + + async def _download_url(url: str) -> bytes: + if image_downloader is not None: + return await image_downloader(url) + async with session.get(url) as img_response: + if img_response.status != 200: + raise RuntimeError(f"图片下载失败 ({img_response.status})") + return await img_response.read() try: for candidate_idx, candidate in enumerate(candidates): @@ -446,7 +466,7 @@ class GeminiAPIClient(BaseAPIClient): inline_data_key = "inline_data" elif "inlineData" in part: inline_data_key = "inlineData" - + if inline_data_key: # 跳过思考链草稿图(thought:true 标记的 inlineData 为模型自检用途,非最终输出) if part.get("thought") is True: @@ -458,6 +478,7 @@ class GeminiAPIClient(BaseAPIClient): if img_data: img = decode_base64_to_pil(img_data) + _tag_image(img, base64.b64decode(img_data)) images.append(img) # 记录格式信息 @@ -475,21 +496,20 @@ class GeminiAPIClient(BaseAPIClient): if url: try: download_start = time.time() - async with session.get(url) as img_response: - if img_response.status == 200: - img_bytes = await img_response.read() - download_time = time.time() - download_start - img_size = len(img_bytes) - speed = img_size / download_time if download_time > 0 else 0 + img_bytes = await _download_url(url) + download_time = time.time() - download_start + img_size = len(img_bytes) + speed = img_size / download_time if download_time > 0 else 0 - img = Image.open(BytesIO(img_bytes)) - images.append(img) + img = Image.open(BytesIO(img_bytes)) + _tag_image(img, img_bytes) + images.append(img) - if format_info["type"] is None: - format_info["type"] = "url" - format_info["size"] = img_size - format_info["resolution"] = f"{img.size[0]}x{img.size[1]}" - format_info["download_speed"] = speed + if format_info["type"] is None: + format_info["type"] = "url" + format_info["size"] = img_size + format_info["resolution"] = f"{img.size[0]}x{img.size[1]}" + format_info["download_speed"] = speed except Exception: pass # 静默失败 @@ -514,46 +534,44 @@ class GeminiAPIClient(BaseAPIClient): try: # 使用 aiohttp 异步下载 download_start = time.time() - async with session.get(url) as img_response: - if img_response.status == 200: - img_data = await img_response.read() - download_time = time.time() - download_start - img_size = len(img_data) - speed = img_size / download_time if download_time > 0 else 0 - - img = Image.open(BytesIO(img_data)) - images.append(img) - - # 记录格式信息(只记录第一张) - if format_info["type"] is None: - format_info["type"] = "url" - format_info["size"] = img_size - format_info["resolution"] = f"{img.size[0]}x{img.size[1]}" - format_info["download_speed"] = speed - except Exception as e: - pass # 静默失败,继续尝试其他URL - - # 方式3: 直接的 URL 字段 - 也改为异步 - elif "imageUrl" in part or "url" in part: - url = part.get("imageUrl") or part.get("url") - try: - download_start = time.time() - async with session.get(url) as img_response: - if img_response.status == 200: - img_data = await img_response.read() + img_data = await _download_url(url) download_time = time.time() - download_start img_size = len(img_data) speed = img_size / download_time if download_time > 0 else 0 - + img = Image.open(BytesIO(img_data)) + _tag_image(img, img_data) images.append(img) - - # 记录格式信息 + + # 记录格式信息(只记录第一张) if format_info["type"] is None: format_info["type"] = "url" format_info["size"] = img_size format_info["resolution"] = f"{img.size[0]}x{img.size[1]}" format_info["download_speed"] = speed + except Exception as e: + pass # 静默失败,继续尝试其他URL + + # 方式3: 直接的 URL 字段 - 也改为异步 + elif "imageUrl" in part or "url" in part: + url = part.get("imageUrl") or part.get("url") + try: + download_start = time.time() + img_data = await _download_url(url) + download_time = time.time() - download_start + img_size = len(img_data) + speed = img_size / download_time if download_time > 0 else 0 + + img = Image.open(BytesIO(img_data)) + _tag_image(img, img_data) + images.append(img) + + # 记录格式信息 + if format_info["type"] is None: + format_info["type"] = "url" + format_info["size"] = img_size + format_info["resolution"] = f"{img.size[0]}x{img.size[1]}" + format_info["download_speed"] = speed except Exception as e: pass # 静默失败 diff --git a/clients/gpt_image_client.py b/clients/gpt_image_client.py index e05439e..d173434 100644 --- a/clients/gpt_image_client.py +++ b/clients/gpt_image_client.py @@ -16,7 +16,10 @@ GPT Image API 客户端 import asyncio import base64 +import binascii import json +import math +import os import time from concurrent.futures import ThreadPoolExecutor from io import BytesIO @@ -25,11 +28,23 @@ from typing import Any, Callable, Dict, List, Optional import aiohttp import numpy as np import torch -from PIL import Image +from PIL import Image, UnidentifiedImageError from ..utils.config import get_api_key_or_raise, get_api_base_url from ..utils.image_utils import tensor_to_pil, encode_image_to_base64 -from ..utils.http_error import RETRYABLE_STATUS_CODES, HTTP_ERROR_MESSAGES, _compute_delay, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR, get_friendly_message +from ..utils.http_error import HTTP_ERROR_MESSAGES, get_friendly_message +from ..utils.http2_client import ( + format_response_body_diagnostics, + read_response_body_with_diagnostics, + response_task_id, + validate_response_task_id, +) +from ..utils.o1key_image_catalog import ( + GPT_IMAGE_BACKGROUND_OPTIONS, + GPT_IMAGE_MODEL_OPTIONS, + GPT_IMAGE_OUTPUT_FORMAT_OPTIONS, + UNIFIED_IMAGE_ROUTE_OPTIONS, +) try: from comfy.model_management import processing_interrupted, InterruptProcessingException @@ -45,57 +60,79 @@ _ENDPOINT_EDITS = "/v1/images/edits" _ENDPOINT_ASYNC_GENERATE = "/async/v1/generateImage" _ENDPOINT_ASYNC_TASK = "/async/v1/tasks/{task_id}" -# ── 模型名映射(UI 显示名 → API 实际参数名)───────────────────────────────── -_MODEL_NAME_MAP = { - "gpt-image-2-按量": "gpt-image-2", - "gpt-image-2-次卡": "gpt-image-2-special", +# ── 主模型与模型线路映射(界面值 → API 模型名)────────────────────────────── +GPT_IMAGE_ROUTE_OPTIONS = UNIFIED_IMAGE_ROUTE_OPTIONS +GPT_IMAGE_MODEL_MATRIX = { + ("gpt-image-2", "畅速"): "gpt-image-2-c-sp", + ("gpt-image-2", "直连"): "gpt-image-2-c-sd", + ("gpt-image-2", "专线"): "gpt-image-2", + ("gpt-image-2.5-sunburst", "畅速"): "gpt-image-2.5-sunburst-sp", + ("gpt-image-2.5-sunburst", "直连"): "gpt-image-2.5-sunburst-sd", + ("gpt-image-2.5-sunburst", "专线"): "gpt-image-2.5-sunburst", + ("gpt-image-2.5-flare", "畅速"): "gpt-image-2.5-flare-sp", + ("gpt-image-2.5-flare", "直连"): "gpt-image-2.5-flare-sd", + ("gpt-image-2.5-flare", "专线"): "gpt-image-2.5-flare", } +_MODEL_NAME_MAP = { + "畅速": "gpt-image-2-c-sp", + "直连": "gpt-image-2-c-sd", + "专线": "gpt-image-2", + # 兼容旧工作流和直接调用节点的旧参数值。 + "gpt-image-2-特价": "gpt-image-2-c-sp", + "gpt-image-2-官方": "gpt-image-2", + "gpt-image-2-次卡": "gpt-image-2-c-sp", + "gpt-image-2-按量": "gpt-image-2", +} +def resolve_gpt_image_model(model_name: str, route: str) -> str: + """由主模型和线路解析实际 API 模型名,并兼容旧的单参数调用。""" + # 旧节点曾把线路或旧显示模型名直接作为“模型”参数传入。 + if model_name in GPT_IMAGE_ROUTE_OPTIONS or model_name in _MODEL_NAME_MAP: + return _MODEL_NAME_MAP.get(model_name, model_name) + model = GPT_IMAGE_MODEL_MATRIX.get((model_name, route)) + # 兼容外部调用把旧线路显示值放在 route 参数中的情况。 + if model is None and route in _MODEL_NAME_MAP: + model = _MODEL_NAME_MAP[route] + if model is None: + raise ValueError(f"模型 '{model_name}' 不支持模型线路 '{route}'") + return model # ── 超时 ────────────────────────────────────────────────────────────────────── _REQUEST_TIMEOUT = 900 # 秒 _ASYNC_POLL_SCHEDULE = [5.0, 20.0] _ASYNC_POLL_INTERVAL = 3.0 + + +def _attach_original_image_info(image: Image.Image, data: bytes) -> Image.Image: + """Keep provider bytes alongside decoded pixels for format-preserving saves.""" + image_format = str(image.format or "").upper() + if image_format == "JPG": + image_format = "JPEG" + if image_format: + image.format = image_format + setattr(image, "_o1key_original_format", image_format) + setattr(image, "_o1key_original_bytes", data) + return image _ASYNC_MAX_WAIT = 600.0 -_ASYNC_RETRY_DELAYS = [2.0, 5.0, 10.0] -_ASYNC_RETRYABLE_ERROR_CODES = { - "image_rate_limited", - "image_upstream_busy", - "image_timeout", - "image_storage_failed", - "image_empty_result", - "image_upstream_error", - "image_internal_error", - "image_unknown_error", -} -_ASYNC_RETRYABLE_ERROR_CATEGORIES = { - "rate_limit", - "upstream_busy", - "timeout", - "storage", - "upstream_error", - "internal_error", - "unknown", -} -_ASYNC_NON_RETRYABLE_ERROR_CODES = { - "image_invalid_size", - "image_payload_too_large", - "image_invalid_mask", - "image_invalid_parameter", - "image_safety_blocked", - "image_provider_quota_exceeded", - "image_provider_permission_required", - "image_model_unavailable", - "image_reference_download_failed", -} +# Only use these after a task has been submitted: status/result GET requests +# are idempotent, while retrying task submission can create duplicate charges. +_RESPONSE_READ_MAX_RETRIES = 3 +_RESPONSE_READ_BASE_DELAY = 1.0 +_RESPONSE_READ_MAX_DELAY = 8.0 +_RETRYABLE_RESULT_HTTP_STATUSES = {408, 425, 429, 500, 502, 503, 504} REQUEST_LOG_ENABLED = False POLL_LOG_ENABLED = False +RESPONSE_LOG_ENABLED = os.environ.get("O1KEY_RESPONSE_LOG", "0").strip().lower() not in { + "0", "false", "no", "off" +} -class _AsyncImageTaskFailure(RuntimeError): - def __init__(self, message: str, error_detail: Optional[dict] = None): - super().__init__(message) - self.error_detail = error_detail or {} +class _IncompleteInlineImageError(RuntimeError): + """The task succeeded, but an inline result image is incomplete.""" + + +class _RetryableResultHTTPError(OSError): + """An idempotent task query or result download returned a transient status.""" class GptImageClient: @@ -112,6 +149,157 @@ class GptImageClient: def __init__(self): self.api_key = get_api_key_or_raise("O1KEY_API_KEY") self.base_url = get_api_base_url() + # Batch nodes can turn these off while keeping the same client API for + # single-image diagnostics. + self.response_log_enabled = RESPONSE_LOG_ENABLED + self.poll_log_enabled = POLL_LOG_ENABLED + + @staticmethod + def _response_retry_delay(attempt: int) -> float: + return min( + _RESPONSE_READ_BASE_DELAY * (2 ** attempt), + _RESPONSE_READ_MAX_DELAY, + ) + + async def _get_json_with_response_retry( + self, + session: aiohttp.ClientSession, + url: str, + headers: dict, + label: str, + expected_task_id: Optional[str] = None, + ) -> dict: + """Read a JSON GET response, retrying interrupted response bodies.""" + for attempt in range(_RESPONSE_READ_MAX_RETRIES + 1): + try: + async with session.get(url, headers=headers) as resp: + status = resp.status + body, diagnostics = await read_response_body_with_diagnostics(resp) + if self.response_log_enabled: + self._log_original_response_body( + f"{label} status={status}", + body.decode("utf-8", errors="replace"), + ) + if status != 200: + print( + f"[o1key GPT Image] 任务查询传输追踪" + f" | requested_task_id={expected_task_id or ''}" + f" | response_task_id=" + f" | {format_response_body_diagnostics(diagnostics)}" + f" | json=not-parsed" + ) + text = body.decode("utf-8", errors="replace") + message = self._extract_error_message(text, status) + if status in _RETRYABLE_RESULT_HTTP_STATUSES: + raise _RetryableResultHTTPError(message) + raise RuntimeError(message) + try: + payload = json.loads(body) + except json.JSONDecodeError: + print( + f"[o1key GPT Image] 任务查询传输追踪" + f" | requested_task_id={expected_task_id or ''}" + f" | response_task_id=" + f" | {format_response_body_diagnostics(diagnostics)}" + f" | json=invalid" + ) + raise + + actual_task_id = response_task_id(payload) + if expected_task_id is not None: + if actual_task_id is not None and actual_task_id != expected_task_id: + print( + f"[o1key GPT Image] 任务查询传输追踪" + f" | requested_task_id={expected_task_id}" + f" | response_task_id={actual_task_id}" + f" | task_id_check=mismatch" + f" | {format_response_body_diagnostics(diagnostics)}" + f" | json=valid" + ) + validate_response_task_id(payload, expected_task_id) + return payload + except ( + aiohttp.ClientError, + asyncio.TimeoutError, + OSError, + json.JSONDecodeError, + ) as exc: + if attempt >= _RESPONSE_READ_MAX_RETRIES: + raise RuntimeError( + f"{label}响应读取失败,已重试 " + f"{_RESPONSE_READ_MAX_RETRIES} 次:{exc}" + ) from None + + delay = self._response_retry_delay(attempt) + print( + f"[o1key GPT Image] {label}响应不完整 " + f"({type(exc).__name__}),{delay:.1f}s 后重试 " + f"({attempt + 1}/{_RESPONSE_READ_MAX_RETRIES})" + f" | requested_task_id={expected_task_id or ''} | {exc}" + ) + await asyncio.sleep(delay) + + raise RuntimeError(f"{label}响应读取失败") # pragma: no cover + + async def _download_image_with_response_retry( + self, + session: aiohttp.ClientSession, + url: str, + label: str, + ) -> tuple[Image.Image, int, float]: + """Download and fully decode an image, retrying interrupted GET reads.""" + download_started = time.perf_counter() + for attempt in range(_RESPONSE_READ_MAX_RETRIES + 1): + try: + async with session.get(url, allow_redirects=True) as resp: + image_bytes, diagnostics = await read_response_body_with_diagnostics(resp) + if resp.status != 200: + print( + f"[o1key GPT Image] 结果下载传输追踪" + f" | {format_response_body_diagnostics(diagnostics)}" + ) + message = f"{label}下载失败 HTTP {resp.status}" + if resp.status in _RETRYABLE_RESULT_HTTP_STATUSES: + raise _RetryableResultHTTPError(message) + raise RuntimeError(message) + + image = Image.open(BytesIO(image_bytes)) + image.load() + _attach_original_image_info(image, image_bytes) + return image, len(image_bytes), time.perf_counter() - download_started + except ( + aiohttp.ClientError, + asyncio.TimeoutError, + OSError, + UnidentifiedImageError, + ) as exc: + if attempt >= _RESPONSE_READ_MAX_RETRIES: + raise RuntimeError( + f"{label}下载失败,已重试 " + f"{_RESPONSE_READ_MAX_RETRIES} 次:{exc}" + ) from None + + delay = self._response_retry_delay(attempt) + print( + f"[o1key GPT Image] {label}下载中断 " + f"({type(exc).__name__}),{delay:.1f}s 后重试 " + f"({attempt + 1}/{_RESPONSE_READ_MAX_RETRIES})..." + ) + await asyncio.sleep(delay) + + raise RuntimeError(f"{label}下载失败") # pragma: no cover + + @staticmethod + def _format_transfer_size(size: int) -> str: + if size < 1024 * 1024: + return f"{size / 1024:.1f} KiB" + return f"{size / 1024 / 1024:.2f} MiB" + + @staticmethod + def _format_transfer_rate(size: int, elapsed: float) -> str: + if elapsed <= 0: + return "∞ MiB/s" + return f"{size / 1024 / 1024 / elapsed:.2f} MiB/s" # ── 认证头 ──────────────────────────────────────────────────────────────── @@ -148,6 +336,9 @@ class GptImageClient: # ── 请求体大小限制 ──────────────────────────────────────────────────────── _MAX_BODY_BYTES = 20 * 1024 * 1024 # 20 MB + _UNIFIED_BODY_LIMIT_BYTES = 18 * 1024 * 1024 + _SMART_RESIZE_MIN_LONG_EDGE = 256 + _SMART_RESIZE_MAX_ATTEMPTS = 32 @staticmethod def _shrink_png_to_limit(png_bytes: bytes, max_bytes: int, label: str = "") -> bytes: @@ -205,9 +396,18 @@ class GptImageClient: } if isinstance(obj, list): return [GptImageClient._shorten_data_urls_for_log(item, max_len) for item in obj] - if isinstance(obj, str) and obj.startswith("data:image") and len(obj) > max_len: - header, _, data = obj.partition(",") - return f"{header}," + if isinstance(obj, str) and len(obj) > max_len: + header, separator, data = obj.partition(",") + if separator and header.lower().startswith("data:") and ";base64" in header.lower(): + return f"{header}," + candidate = obj.strip() + if len(candidate) % 4 == 0: + try: + base64.b64decode(candidate, validate=True) + except Exception: + pass + else: + return f"" return obj def _log_original_request_body(self, label: str, body: dict) -> None: @@ -224,14 +424,22 @@ class GptImageClient: ) def _log_original_response_body(self, label: str, text: str) -> None: - if not REQUEST_LOG_ENABLED: + if not self.response_log_enabled: return size = len(text.encode("utf-8")) + try: + safe_body = json.dumps( + self._shorten_data_urls_for_log(json.loads(text)), + ensure_ascii=False, + indent=2, + ) + except Exception: + safe_body = f"" print( f"\n{'=' * 60}\n" f"[o1key GPT Image] 原始返回响应体日志 | {label} | " - f"响应体积: {self._format_body_size(size)}\n" - f"{text}\n" + f"响应体积: {self._format_body_size(size)} (base64 已折叠)\n" + f"{safe_body}\n" f"{'=' * 60}\n" ) @@ -247,16 +455,16 @@ class GptImageClient: if isinstance(payload, dict): error = payload.get("error") if isinstance(error, str) and error.strip(): - return error.strip() + return get_friendly_message(0, error.strip()) if isinstance(error, dict): msg = error.get("message") or error.get("msg") or error.get("error") if msg: - return str(msg) + return get_friendly_message(0, str(msg)) return json.dumps(error, ensure_ascii=False) msg = payload.get("message") or payload.get("msg") if msg: - return str(msg) + return get_friendly_message(0, str(msg)) return get_friendly_message(status_code, str(payload_or_text)) @@ -267,29 +475,6 @@ class GptImageClient: detail = payload.get("error_detail") return detail if isinstance(detail, dict) else {} - @staticmethod - def _should_retry_async_failure(error_detail: dict, retry_index: int) -> bool: - if not isinstance(error_detail, dict) or not error_detail: - return False - - code = error_detail.get("code") - category = error_detail.get("category") - retryable = error_detail.get("retryable") - - if code in _ASYNC_NON_RETRYABLE_ERROR_CODES: - return False - if code == "image_unknown_error": - return retry_index == 0 - if retryable is True: - return True - if retryable is False: - return False - - return ( - code in _ASYNC_RETRYABLE_ERROR_CODES - or category in _ASYNC_RETRYABLE_ERROR_CATEGORIES - ) - @staticmethod def _coerce_progress_percent(value: Any) -> Optional[int]: if value is None: @@ -324,9 +509,9 @@ class GptImageClient: def _resize_png_bytes(source_image: Image.Image, scale: float) -> bytes: if scale < 0.999: width, height = source_image.size - new_width = max(1, int(width * scale)) - new_height = max(1, int(height * scale)) - image = source_image.resize((new_width, new_height), Image.LANCZOS) + new_width = max(1, round(width * scale)) + new_height = max(1, round(height * scale)) + image = source_image.resize((new_width, new_height), Image.Resampling.LANCZOS) else: image = source_image @@ -340,66 +525,132 @@ class GptImageClient: image.save(buf, format="PNG") return buf.getvalue() - def _fit_png_assets_to_body_limit(self, assets: List[Dict[str, Any]], build_body) -> Dict[str, bytes]: - """ - 根据完整 JSON 请求体大小压缩图片资产,保证最终 body 不超过 20MB。 - 使用同一个缩放比例二分搜索,让压缩结果尽量贴近上限而不是过度压缩。 + def _fit_png_assets_to_body_limit( + self, + assets: List[Dict[str, Any]], + build_body, + *, + resize_mode: Optional[str] = None, + ) -> Dict[str, bytes]: + """Fit exact JSON body size while preserving unified-node resize semantics. + + ``resize_mode=None`` preserves the legacy GPT client behavior: automatically + fit to the provider's 20 MiB limit. The unified image node passes an + explicit mode and therefore uses the same 18 MiB safety ceiling as Nano + Banana. Smart mode resizes the largest encoded asset group first and + always resamples from the original pixels. A reference image and its mask + share a group so their dimensions remain aligned. """ + if resize_mode not in {None, "不缩放", "智能缩放"}: + raise ValueError("缩放图片参数无效") + + body_limit = ( + self._MAX_BODY_BYTES + if resize_mode is None + else self._UNIFIED_BODY_LIMIT_BYTES + ) + effective_mode = "智能缩放" if resize_mode is None else resize_mode asset_bytes = {asset["key"]: asset["bytes"] for asset in assets} initial_size = self._json_body_size(build_body(asset_bytes)) - if initial_size <= self._MAX_BODY_BYTES: + if initial_size <= body_limit: return asset_bytes + limit_mib = body_limit / (1024 * 1024) + if effective_mode == "不缩放": + raise ValueError( + f"GPT Image: 请求体 {initial_size / (1024 * 1024):.2f} MiB 超过 " + f"{limit_mib:.0f} MiB 上限;当前设置为“不缩放”," + "请在上游缩小图片或改用“智能缩放”" + ) if not assets: - raise RuntimeError( - f"请求体大小 {initial_size // 1024}KB 超过 20MB,且没有可压缩图片" + raise ValueError( + f"GPT Image: 请求体 {initial_size / (1024 * 1024):.2f} MiB 超过 " + f"{limit_mib:.0f} MiB 上限,且没有可缩放图片" ) - originals = [] - for asset in assets: - image = Image.open(BytesIO(asset["bytes"])) - image.load() - originals.append((asset, image.copy())) + originals: Dict[str, Image.Image] = {} + groups: Dict[str, List[str]] = {} + labels: Dict[str, str] = {} + try: + for asset in assets: + with Image.open(BytesIO(asset["bytes"])) as image: + image.load() + originals[asset["key"]] = image.copy() + group = str(asset.get("resize_group") or asset["key"]) + groups.setdefault(group, []).append(asset["key"]) + labels[asset["key"]] = str(asset.get("label") or asset["key"]) - low = 0.001 - high = 1.0 - best_bytes = None - best_size = 0 - best_scale = 0.0 + scales = {group: 1.0 for group in groups} + final_sizes = {key: image.size for key, image in originals.items()} + body_size = initial_size - for _ in range(16): - scale = (low + high) / 2 - candidate = {} - for asset, image in originals: - candidate[asset["key"]] = self._resize_png_bytes(image, scale) + for _attempt in range(self._SMART_RESIZE_MAX_ATTEMPTS): + if body_size <= body_limit: + changes = [ + f"{labels[key]} {originals[key].width}×{originals[key].height}" + f"→{final_sizes[key][0]}×{final_sizes[key][1]}" + for key in originals + if final_sizes[key] != originals[key].size + ] + print( + f"[o1key GPT Image] 智能缩放完成 | " + f"{initial_size / (1024 * 1024):.2f}→" + f"{body_size / (1024 * 1024):.2f} MiB | " + + ",".join(changes) + ) + return asset_bytes - body_size = self._json_body_size(build_body(candidate)) - if body_size <= self._MAX_BODY_BYTES: - best_bytes = candidate - best_size = body_size - best_scale = scale - low = scale - else: - high = scale + candidates = [] + for group, keys in groups.items(): + min_scale = max( + min(1.0, self._SMART_RESIZE_MIN_LONG_EDGE / max(originals[key].size)) + for key in keys + ) + current_scale = scales[group] + if current_scale <= min_scale + 1e-6: + continue + encoded_length = sum( + 4 * ((len(asset_bytes[key]) + 2) // 3) + for key in keys + ) + candidates.append((encoded_length, group, min_scale)) + if not candidates: + break - if best_bytes is None: - candidate = {} - for asset, image in originals: - candidate[asset["key"]] = self._resize_png_bytes(image, low) - body_size = self._json_body_size(build_body(candidate)) - if body_size > self._MAX_BODY_BYTES: - raise RuntimeError( - f"图片已压缩到最小比例,但请求体仍超过 20MB:{body_size // 1024}KB" - ) - best_bytes = candidate - best_size = body_size - best_scale = low + current_data_length, group, min_scale = max(candidates) + overflow = body_size - body_limit + desired_data_length = max(1024, current_data_length - overflow - 32 * 1024) + estimated_area_ratio = min(0.90, desired_data_length / current_data_length) + proposed_scale = scales[group] * math.sqrt(max(0.01, estimated_area_ratio)) * 0.98 + new_scale = max(min_scale, min(scales[group] * 0.92, proposed_scale)) - print( - f"[o1key GPT Image] 请求体超过 20MB,已等比压缩图片:" - f"{initial_size // 1024}KB → {best_size // 1024}KB,scale={best_scale:.3f}" - ) - return best_bytes + candidate_bytes = dict(asset_bytes) + candidate_sizes = dict(final_sizes) + for key in groups[group]: + candidate_bytes[key] = self._resize_png_bytes(originals[key], new_scale) + with Image.open(BytesIO(candidate_bytes[key])) as candidate_image: + candidate_sizes[key] = candidate_image.size + + if all(candidate_sizes[key] == final_sizes[key] for key in groups[group]): + new_scale = max(min_scale, scales[group] * 0.85) + for key in groups[group]: + candidate_bytes[key] = self._resize_png_bytes(originals[key], new_scale) + with Image.open(BytesIO(candidate_bytes[key])) as candidate_image: + candidate_sizes[key] = candidate_image.size + + asset_bytes = candidate_bytes + final_sizes = candidate_sizes + scales[group] = new_scale + body_size = self._json_body_size(build_body(asset_bytes)) + + final_size = self._json_body_size(build_body(asset_bytes)) + raise ValueError( + f"GPT Image: 智能缩放后请求体仍为 {final_size / (1024 * 1024):.2f} MiB," + f"无法安全降至 {limit_mib:.0f} MiB;请减少参考图或在上游进一步缩小" + ) + finally: + for image in originals.values(): + image.close() @staticmethod def _tensor_to_png_bytes(tensor: torch.Tensor) -> bytes: @@ -451,7 +702,13 @@ class GptImageClient: images = [placeholder] tensors = [] + source_metadata = [] for img in images: + source_metadata.append({ + "format": getattr(img, "_o1key_original_format", None) or img.format, + "bytes": getattr(img, "_o1key_original_bytes", None), + "modified": False, + }) arr = np.array(img.convert("RGBA")).astype(np.float32) / 255.0 tensors.append(torch.from_numpy(arr)) @@ -459,8 +716,9 @@ class GptImageClient: max_h = max(t.shape[0] for t in tensors) max_w = max(t.shape[1] for t in tensors) aligned = [] - for t in tensors: + for index, t in enumerate(tensors): if t.shape[0] != max_h or t.shape[1] != max_w: + source_metadata[index]["modified"] = True t = t.permute(2, 0, 1).unsqueeze(0) # [1, C, H, W] t = torch.nn.functional.interpolate( t, size=(max_h, max_w), mode="bilinear", align_corners=False @@ -468,7 +726,9 @@ class GptImageClient: t = t.squeeze(0).permute(1, 2, 0) # [H, W, C] aligned.append(t) - return torch.stack(aligned, dim=0) # [B, H, W, 4] + result = torch.stack(aligned, dim=0) # [B, H, W, 4] + result._o1key_source_metadata = source_metadata + return result # ── 响应解析(通用) ───────────────────────────────────────────────────── @@ -510,6 +770,8 @@ class GptImageClient: try: img_bytes = base64.b64decode(b64) img = Image.open(BytesIO(img_bytes)) + img.load() + _attach_original_image_info(img, img_bytes) images.append(img) print(f"[o1key GPT Image] 第 {idx + 1} 张 base64 解码完成 " f"({img.size[0]}×{img.size[1]})") @@ -518,16 +780,15 @@ class GptImageClient: elif url and url.startswith("http"): # 回退:下载 URL - async with session.get(url, allow_redirects=True) as r: - if r.status != 200: - raise RuntimeError( - f"图像下载失败 HTTP {r.status},URL: {url}" - ) - img_bytes = await r.read() - img = Image.open(BytesIO(img_bytes)) + img, byte_count, elapsed = await self._download_image_with_response_retry( + session, url, f"第 {idx + 1} 张图片" + ) images.append(img) - print(f"[o1key GPT Image] 第 {idx + 1} 张下载完成 " - f"({img.size[0]}×{img.size[1]})") + print( + f"[o1key GPT Image] 下载图像 {idx + 1}/{len(data_list)} 完成 | " + f"{img.size[0]}×{img.size[1]} | {self._format_transfer_size(byte_count)} | " + f"{elapsed:.2f}s | {self._format_transfer_rate(byte_count, elapsed)}" + ) else: print(f"[o1key GPT Image] 警告:第 {idx + 1} 条数据既无 b64_json 也无 url,已跳过") @@ -538,11 +799,29 @@ class GptImageClient: try: img_bytes = base64.b64decode(b64) img = Image.open(BytesIO(img_bytes)) + img.load() + _attach_original_image_info(img, img_bytes) print(f"[o1key GPT Image] {label} base64 解码完成 ({img.size[0]}×{img.size[1]})") return img except Exception as e: raise RuntimeError(f"{label} base64 解码失败: {e}") from None + @staticmethod + def _decode_inline_result_image(b64: str, label: str) -> tuple[Image.Image, int]: + """Strictly decode and fully load a completed task's inline image.""" + try: + if not isinstance(b64, str) or not b64: + raise ValueError("empty base64 payload") + image_bytes = base64.b64decode(b64, validate=True) + if not image_bytes: + raise ValueError("decoded image is empty") + image = Image.open(BytesIO(image_bytes)) + image.load() + _attach_original_image_info(image, image_bytes) + return image, len(image_bytes) + except (binascii.Error, ValueError, OSError, UnidentifiedImageError) as exc: + raise _IncompleteInlineImageError(f"{label}不完整或无法解码: {exc}") from None + async def _append_images_from_payload( self, payload: dict, @@ -720,6 +999,7 @@ class GptImageClient: return await self._parse_edit_stream_response(resp, session) text = await resp.text() + self._log_original_response_body(f"{label or 'sync'} status={resp.status}", text) stripped = text.lstrip() if stripped.startswith("data:") or stripped.startswith("event:"): return await self._parse_stream_text_response(text, session) @@ -776,12 +1056,28 @@ class GptImageClient: prompt: str, model: str, quality: str, - size: str, + size: Optional[str], n: int, image_list: Optional[List[torch.Tensor]] = None, mask_tensor: Optional[torch.Tensor] = None, output_format: str = "png", + background: str = "auto", + moderation: Optional[str] = None, + resize_mode: Optional[str] = None, ) -> dict: + if int(n) != 1: + raise ValueError("GPT Image 单次请求的 n 必须为 1;多图请使用外层并发") + output_format = str(output_format or "png").strip().lower() + background = str(background or "auto").strip().lower() + moderation = str(moderation).strip().lower() if moderation is not None else None + if output_format not in GPT_IMAGE_OUTPUT_FORMAT_OPTIONS: + raise ValueError("GPT Image 输出格式无效") + if background not in GPT_IMAGE_BACKGROUND_OPTIONS: + raise ValueError("GPT Image 背景参数无效") + if moderation not in {None, "low"}: + raise ValueError("GPT Image 内容审查强度无效") + if background == "transparent" and output_format == "jpeg": + raise ValueError("GPT Image 透明背景仅支持 PNG 或 WebP 输出格式") api_model = _MODEL_NAME_MAP.get(model, model) assets: List[Dict[str, Any]] = [] image_keys: List[str] = [] @@ -798,6 +1094,7 @@ class GptImageClient: "key": key, "label": f"参考图{idx_img + 1}", "bytes": self._pil_to_png_bytes(pil_images[0]), + "resize_group": key, }) if mask_tensor is not None: @@ -813,6 +1110,7 @@ class GptImageClient: "key": mask_key, "label": "蒙版", "bytes": self._mask_tensor_to_rgba_png_bytes(mask_tensor, image_size), + "resize_group": image_keys[0], }) def _make_body(asset_bytes: Dict[str, bytes]) -> dict: @@ -824,26 +1122,42 @@ class GptImageClient: for key in image_keys if key in asset_bytes ], - "size": size if size else "auto", "quality": quality, "n": int(n), - "output_format": output_format or "png", + "output_format": output_format, + "background": background, } + if size: + body["size"] = size if mask_key and mask_key in asset_bytes: body["mask"] = { "image_url": self._png_bytes_to_data_url(asset_bytes[mask_key]) } + if moderation == "low": + body["moderation"] = "low" return body original_asset_bytes = {asset["key"]: asset["bytes"] for asset in assets} original_body = _make_body(original_asset_bytes) self._log_original_request_body("async generateImage", original_body) - asset_bytes = self._fit_png_assets_to_body_limit(assets, _make_body) + asset_bytes = self._fit_png_assets_to_body_limit( + assets, + _make_body, + resize_mode=resize_mode, + ) body = _make_body(asset_bytes) body_size = self._json_body_size(body) - if body_size > self._MAX_BODY_BYTES: - raise RuntimeError(f"请求体超过 20MB:{body_size // 1024}KB") + body_limit = ( + self._MAX_BODY_BYTES + if resize_mode is None + else self._UNIFIED_BODY_LIMIT_BYTES + ) + if body_size > body_limit: + raise RuntimeError( + f"请求体超过 {body_limit / (1024 * 1024):.0f} MiB:" + f"{body_size / (1024 * 1024):.2f} MiB" + ) return body @@ -851,61 +1165,48 @@ class GptImageClient: self, session: aiohttp.ClientSession, payload: dict, + task_submitted_callback: Optional[Callable[[str, str, float], None]] = None, ) -> str: url = f"{self.base_url}{_ENDPOINT_ASYNC_GENERATE}" - last_status = None - for attempt in range(DEFAULT_MAX_RETRIES + 1): - t0 = time.time() - async with session.post( - url, - json=payload, - headers=self._json_headers(), - ) as resp: - elapsed = time.time() - t0 - text = await resp.text() - self._log_original_response_body( - f"submit generateImage status={resp.status}", - text, - ) + t0 = time.time() + async with session.post( + url, + json=payload, + headers=self._json_headers(), + ) as resp: + elapsed = time.time() - t0 + text = await resp.text() + self._log_original_response_body( + f"submit generateImage status={resp.status}", + text, + ) - if resp.status not in (200, 201, 202): - last_status = resp.status - if resp.status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES: - friendly = get_friendly_message(resp.status) - delay = _compute_delay( - attempt, - DEFAULT_BASE_DELAY, - DEFAULT_MAX_DELAY, - DEFAULT_BACKOFF_FACTOR, - ) - print(f"[o1key GPT Image] {friendly} {delay:.1f}s 后重试提交 ({attempt+1}/{DEFAULT_MAX_RETRIES})...") - await asyncio.sleep(delay) - continue - raise RuntimeError(self._extract_error_message(text, resp.status)) + if resp.status not in (200, 201, 202): + raise RuntimeError(self._extract_error_message(text, resp.status)) - try: - data = json.loads(text) - except Exception: - raise RuntimeError(f"提交响应 JSON 解析失败,原始内容:{text[:500]}") from None + try: + data = json.loads(text) + except Exception: + raise RuntimeError(f"提交响应 JSON 解析失败,原始内容:{text[:500]}") from None - task_id = data.get("task_id") - if not task_id: - raise RuntimeError(f"提交响应中未找到 task_id: {data}") + task_id = data.get("task_id") + if not task_id: + raise RuntimeError(f"提交响应中未找到 task_id: {data}") - status = data.get("status", "") + status = data.get("status", "") + if task_submitted_callback: + task_submitted_callback(task_id, status, elapsed) + else: print(f"[o1key GPT Image] 异步任务已提交 | task_id={task_id} | status={status} | 耗时 {elapsed:.1f}s") - return task_id - - if last_status and last_status in HTTP_ERROR_MESSAGES: - raise RuntimeError(HTTP_ERROR_MESSAGES[last_status]) - raise RuntimeError(f"异步任务提交失败: 重试 {DEFAULT_MAX_RETRIES} 次后仍然失败") + return task_id async def _poll_generate_image_task( self, session: aiohttp.ClientSession, task_id: str, progress_callback: Optional[Callable[[int], None]] = None, + initial_delay: bool = True, ) -> dict: url = f"{self.base_url}{_ENDPOINT_ASYNC_TASK.format(task_id=task_id)}" start_time = time.time() @@ -914,7 +1215,9 @@ class GptImageClient: last_poll_at = start_time while True: - if poll_count < len(_ASYNC_POLL_SCHEDULE): + if poll_count == 0 and not initial_delay: + next_poll_at = start_time + elif poll_count < len(_ASYNC_POLL_SCHEDULE): next_poll_at = start_time + _ASYNC_POLL_SCHEDULE[poll_count] else: next_poll_at = last_poll_at + _ASYNC_POLL_INTERVAL @@ -929,24 +1232,18 @@ class GptImageClient: raise RuntimeError(f"任务 {task_id} 超时(>{int(_ASYNC_MAX_WAIT)}秒),请稍后用 task_id 查询结果") poll_count += 1 - async with session.get(url, headers=self._auth_headers()) as resp: - text = await resp.text() - self._log_original_response_body( - f"poll task status={resp.status}", - text, - ) - if resp.status != 200: - raise RuntimeError(self._extract_error_message(text, resp.status)) - - try: - task = json.loads(text) - except Exception: - raise RuntimeError(f"任务查询响应 JSON 解析失败,原始内容:{text[:500]}") from None + task = await self._get_json_with_response_retry( + session, + url, + self._auth_headers(), + label=f"任务 {task_id} 查询", + expected_task_id=task_id, + ) status = task.get("status", "UNKNOWN") progress = task.get("progress") progress_pct = self._coerce_progress_percent(progress) - if POLL_LOG_ENABLED: + if self.poll_log_enabled: progress_text = f" | progress={progress}" if progress is not None else "" print(f"[o1key GPT Image] 查询任务 #{poll_count} | task_id={task_id} | status={status}{progress_text}") @@ -957,10 +1254,15 @@ class GptImageClient: self._emit_progress(progress_callback, progress_pct) if status == "FAILURE": error_message = self._extract_error_message(task, 500) or "生成失败" - raise _AsyncImageTaskFailure( - error_message, - self._extract_error_detail(task), - ) + detail = self._extract_error_detail(task) + code = detail.get("code") + category = detail.get("category") + parts = [p for p in ( + f"code={code}" if code else "", + f"category={category}" if category else "", + f"task_id={task_id}", + ) if p] + raise RuntimeError(f"{error_message} ({', '.join(parts)})") if status not in ("SUBMITTED", "IN_PROGRESS"): raise RuntimeError(f"未知任务状态 {status}: {task}") @@ -968,6 +1270,9 @@ class GptImageClient: self, task: dict, session: aiohttp.ClientSession, + result_url_callback: Optional[Callable[[str], None]] = None, + log_downloads: bool = True, + log_prefix: str = "[o1key GPT Image]", ) -> List[Image.Image]: data = task.get("data", {}) image_items = data.get("images") if isinstance(data, dict) else None @@ -976,52 +1281,147 @@ class GptImageClient: raise RuntimeError(f"任务结果中未找到 data.images: {task}") images: List[Image.Image] = [] - for idx, item in enumerate(image_items, 1): - if not isinstance(item, dict): - continue + result_urls: List[str] = [] + try: + for idx, item in enumerate(image_items, 1): + if not isinstance(item, dict): + continue - url = item.get("url") or item.get("image_url") - b64 = item.get("b64_json", "") + url = item.get("url") or item.get("image_url") + b64 = item.get("b64_json", "") - if url and isinstance(url, str) and url.startswith("data:image"): - try: - _, b64_data = url.split(",", 1) - img = Image.open(BytesIO(base64.b64decode(b64_data))) + if url and isinstance(url, str) and url.startswith("data:image"): + header, separator, b64_data = url.partition(",") + if not separator or ";base64" not in header.lower(): + raise _IncompleteInlineImageError( + f"第 {idx} 张内联 data URL 缺少有效 Base64 头" + ) + started = time.perf_counter() + img, byte_count = self._decode_inline_result_image( + b64_data, + f"第 {idx} 张内联 data URL 图片", + ) images.append(img) - print(f"[o1key GPT Image] 第 {idx} 张 data URL 解码完成 ({img.size[0]}×{img.size[1]})") - except Exception as e: - raise RuntimeError(f"第 {idx} 张 data URL 解码失败: {e}") from None - elif url and isinstance(url, str) and url.startswith("http"): - async with session.get(url, allow_redirects=True) as resp: - if resp.status != 200: - raise RuntimeError(f"图像下载失败 HTTP {resp.status},URL: {url}") - img_bytes = await resp.read() - img = Image.open(BytesIO(img_bytes)) - images.append(img) - print(f"[o1key GPT Image] 第 {idx} 张 URL 下载完成 ({img.size[0]}×{img.size[1]}) | {url}") - elif b64: - img = self._decode_b64_image(b64, f"第 {idx} 张") - images.append(img) - else: - print(f"[o1key GPT Image] 警告:第 {idx} 条结果既无 url 也无 b64_json,已跳过") + if log_downloads: + print( + f"{log_prefix} 结果图像 {idx}/{len(image_items)} 内联 data URL,无网络下载 | " + f"{img.size[0]}×{img.size[1]} | {self._format_transfer_size(byte_count)} | " + f"解码={time.perf_counter() - started:.2f}s" + ) + elif url and isinstance(url, str) and url.startswith("http"): + if log_downloads: + print(f"{log_prefix} 下载图像 {idx}/{len(image_items)} 开始") + img, byte_count, elapsed = await self._download_image_with_response_retry( + session, url, f"第 {idx} 张图片" + ) + images.append(img) + result_urls.append(url) + if log_downloads: + print( + f"{log_prefix} 下载图像 {idx}/{len(image_items)} 完成 | " + f"{img.size[0]}×{img.size[1]} | {self._format_transfer_size(byte_count)} | " + f"{elapsed:.2f}s | {self._format_transfer_rate(byte_count, elapsed)}" + ) + elif b64: + started = time.perf_counter() + img, byte_count = self._decode_inline_result_image( + b64, + f"第 {idx} 张内联 Base64 图片", + ) + images.append(img) + if log_downloads: + print( + f"{log_prefix} 结果图像 {idx}/{len(image_items)} 内联 base64,无网络下载 | " + f"{img.size[0]}×{img.size[1]} | {self._format_transfer_size(byte_count)} | " + f"解码={time.perf_counter() - started:.2f}s" + ) + else: + print(f"[o1key GPT Image] 警告:第 {idx} 条结果既无 url 也无 b64_json,已跳过") + except Exception: + for image in images: + image.close() + raise if not images: raise RuntimeError("任务成功但没有可用图片结果") + if result_url_callback: + for url in dict.fromkeys(result_urls): + result_url_callback(url) + return images + @staticmethod + def _inline_result_retry_delay(task_id: str, attempt: int) -> float: + base_delay = GptImageClient._response_retry_delay(attempt) + stable_jitter = (sum(task_id.encode("utf-8")) % 500) / 1000.0 + return base_delay + stable_jitter + + async def _parse_completed_task_images_with_retry( + self, + task: dict, + session: aiohttp.ClientSession, + task_id: str, + progress_callback: Optional[Callable[[int], None]] = None, + result_url_callback: Optional[Callable[[str], None]] = None, + log_downloads: bool = True, + log_prefix: str = "[o1key GPT Image]", + ) -> tuple[dict, List[Image.Image]]: + """Re-fetch a completed task when its inline image is incomplete.""" + current_task = task + for attempt in range(_RESPONSE_READ_MAX_RETRIES + 1): + try: + images = await self._parse_async_task_images( + current_task, + session, + result_url_callback=result_url_callback, + log_downloads=log_downloads, + log_prefix=log_prefix, + ) + return current_task, images + except _IncompleteInlineImageError as exc: + if attempt >= _RESPONSE_READ_MAX_RETRIES: + raise RuntimeError( + f"GPT Image: task_id={task_id} 的内联结果在 " + f"{attempt + 1} 次获取后仍不完整: {exc}" + ) from None + + delay = self._inline_result_retry_delay(task_id, attempt) + print( + f"[o1key GPT Image] 内联结果不完整,{delay:.1f}s 后重新查询同一任务 " + f"| task_id={task_id} | retry={attempt + 1}/{_RESPONSE_READ_MAX_RETRIES}" + ) + await asyncio.sleep(delay) + current_task = await self._poll_generate_image_task( + session, + task_id, + progress_callback=progress_callback, + initial_delay=False, + ) + + raise RuntimeError(f"GPT Image: task_id={task_id} 的内联结果读取失败") # pragma: no cover + async def _generate_image_task_async( self, prompt: str, model: str, quality: str, - size: str, + size: Optional[str], n: int, seed: int, image_tensor: Optional[List[torch.Tensor]] = None, mask_tensor: Optional[torch.Tensor] = None, output_format: str = "png", + background: str = "auto", + moderation: Optional[str] = None, progress_callback: Optional[Callable[[int], None]] = None, + result_url_callback: Optional[Callable[[str], None]] = None, + log_downloads: bool = True, + log_prefix: str = "[o1key GPT Image]", + task_submitted_callback: Optional[Callable[[str, str, float], None]] = None, + log_request_start: bool = True, + task_completed_callback: Optional[Callable[[str, int, float, List[str]], None]] = None, + resize_mode: Optional[str] = None, ) -> List[Image.Image]: body = self._build_async_generate_body( prompt=prompt, @@ -1032,52 +1432,141 @@ class GptImageClient: image_list=image_tensor, mask_tensor=mask_tensor, output_format=output_format, + background=background, + moderation=moderation, + resize_mode=resize_mode, ) mode = "图像编辑" if mask_tensor is not None else ("图生图" if image_tensor else "文生图") body_size = self._json_body_size(body) - print( - f"[o1key GPT Image] {mode} | 新异步接口 | 模型={model} | " - f"quality={quality} | size={size} | n={n} | body={body_size // 1024}KB" - ) + if log_request_start: + print( + f"[o1key GPT Image] {mode} | 新异步接口 | 模型={model} | " + f"quality={quality} | size={size} | n={n} | body={body_size // 1024}KB" + ) connector = aiohttp.TCPConnector(ssl=False, force_close=True) timeout = aiohttp.ClientTimeout(total=_ASYNC_MAX_WAIT + 120) + task_started = time.time() async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session: - last_error = None - for retry_index in range(len(_ASYNC_RETRY_DELAYS) + 1): - try: - task_id = await self._submit_generate_image_task(session, body) - task = await self._poll_generate_image_task( - session, - task_id, - progress_callback=progress_callback, - ) - return await self._parse_async_task_images(task, session) - except _AsyncImageTaskFailure as error: - last_error = error - detail = error.error_detail - if ( - retry_index < len(_ASYNC_RETRY_DELAYS) - and self._should_retry_async_failure(detail, retry_index) - ): - delay = _ASYNC_RETRY_DELAYS[retry_index] - code = detail.get("code", "unknown") - category = detail.get("category", "unknown") - failed_task_id = detail.get("task_id", "") - task_text = f" | failed_task_id={failed_task_id}" if failed_task_id else "" - print( - f"[o1key GPT Image] 任务失败但可重试 | code={code} | " - f"category={category}{task_text} | {delay:.0f}s 后重试 " - f"({retry_index + 1}/{len(_ASYNC_RETRY_DELAYS)})" - ) - await asyncio.sleep(delay) - continue - raise RuntimeError(str(error)) from None + task_id = await self._submit_generate_image_task( + session, body, task_submitted_callback=task_submitted_callback, + ) + task = await self._poll_generate_image_task( + session, + task_id, + progress_callback=progress_callback, + ) + upstream_urls: List[str] = [] + def _on_result_url(url: str) -> None: + upstream_urls.append(url) + if result_url_callback: + result_url_callback(url) + task, images = await self._parse_completed_task_images_with_retry( + task, + session, + task_id, + progress_callback=progress_callback, + result_url_callback=_on_result_url, + log_downloads=log_downloads, + log_prefix=log_prefix, + ) + if task_completed_callback: + task_completed_callback(task_id, len(images), time.time() - task_started, upstream_urls) + return images - if last_error is not None: - raise RuntimeError(str(last_error)) from None - raise RuntimeError("生成失败") + async def _generate_special_price_images_parallel( + self, + prompt: str, + model: str, + quality: str, + size: Optional[str], + n: int, + seed: int, + image_tensor: Optional[List[torch.Tensor]] = None, + mask_tensor: Optional[torch.Tensor] = None, + output_format: str = "png", + background: str = "auto", + moderation: Optional[str] = None, + progress_callback: Optional[Callable[[int], None]] = None, + result_url_callback: Optional[Callable[[str], None]] = None, + log_downloads: bool = True, + log_prefix: str = "[o1key GPT Image]", + task_submitted_callback: Optional[Callable[[str, str, float], None]] = None, + log_request_start: bool = True, + task_completed_callback: Optional[Callable[[str, int, float, List[str]], None]] = None, + resize_mode: Optional[str] = None, + ) -> List[Image.Image]: + """多图生成:并发提交 n 个单图任务,每个请求固定传 n=1。""" + request_count = max(1, int(n)) + progress_values = [0] * request_count + + def _task_progress_callback(task_index: int): + if progress_callback is None: + return None + + def _update(value: int): + try: + progress_values[task_index] = max(0, min(100, int(value))) + except (TypeError, ValueError): + return + progress_callback(int(sum(progress_values) / request_count)) + + return _update + + if log_request_start: + print( + f"[o1key GPT Image] 多图并发模式 | 并发请求={request_count} | 每个请求 n=1" + ) + tasks = [ + asyncio.create_task( + self._generate_image_task_async( + prompt=prompt, + model=model, + quality=quality, + size=size, + n=1, + seed=seed, + image_tensor=image_tensor, + mask_tensor=mask_tensor, + output_format=output_format, + background=background, + moderation=moderation, + progress_callback=_task_progress_callback(task_index), + result_url_callback=result_url_callback, + log_downloads=log_downloads, + log_prefix=log_prefix, + task_submitted_callback=task_submitted_callback, + log_request_start=log_request_start, + task_completed_callback=task_completed_callback, + resize_mode=resize_mode, + ) + ) + for task_index in range(request_count) + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + + images: List[Image.Image] = [] + errors = [] + for task_index, result in enumerate(results, 1): + if isinstance(result, BaseException): + errors.append(f"第{task_index}个请求: {str(result).splitlines()[0]}") + else: + images.extend(result) + + if progress_callback is not None: + progress_callback(100) + + if errors: + print( + f"[o1key GPT Image] 多图并发完成 | 成功={request_count - len(errors)} " + f"| 失败={len(errors)} | {'; '.join(errors)}" + ) + if not images: + detail = "; ".join(errors) or "未返回图片" + raise RuntimeError(f"多图并发请求全部失败:{detail}") + + return images # ── 文生图 / 图生图(generations 接口)─────────────────────────────────── @@ -1155,45 +1644,32 @@ class GptImageClient: async def _do_request(): async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session: - last_status = None - for attempt in range(DEFAULT_MAX_RETRIES + 1): - t0 = time.time() - async with session.post( - url, - data=_build_multipart_form(), - headers=self._auth_headers(), - ) as resp: - elapsed = time.time() - t0 + t0 = time.time() + async with session.post( + url, + data=_build_multipart_form(), + headers=self._auth_headers(), + ) as resp: + elapsed = time.time() - t0 - if resp.status != 200: - last_status = resp.status - text = await resp.text() - if resp.status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES: - friendly = get_friendly_message(resp.status) - delay = _compute_delay(attempt, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR) - print(f"[o1key GPT Image] {friendly} {delay:.1f}s 后重试 ({attempt+1}/{DEFAULT_MAX_RETRIES})...") - await asyncio.sleep(delay) - continue - if resp.status in HTTP_ERROR_MESSAGES: - raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status]) - try: - err_json = json.loads(text) - err_obj = err_json.get("error", {}) - msg = ( - err_obj.get("message") or err_obj.get("msg") or text - if isinstance(err_obj, dict) - else str(err_obj) or text - ) - except Exception: - msg = text - raise RuntimeError(get_friendly_message(resp.status, msg)) + if resp.status != 200: + text = await resp.text() + if resp.status in HTTP_ERROR_MESSAGES: + raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status]) + try: + err_json = json.loads(text) + err_obj = err_json.get("error", {}) + msg = ( + err_obj.get("message") or err_obj.get("msg") or text + if isinstance(err_obj, dict) + else str(err_obj) or text + ) + except Exception: + msg = text + raise RuntimeError(get_friendly_message(resp.status, msg)) - print(f"[o1key GPT Image] API 响应耗时 {elapsed:.1f}s") - return await self._parse_success_response(resp, session, "GENERATIONS") - - if last_status and last_status in HTTP_ERROR_MESSAGES: - raise RuntimeError(HTTP_ERROR_MESSAGES[last_status]) - raise RuntimeError(f"请求失败: 重试 {DEFAULT_MAX_RETRIES} 次后仍然失败") + print(f"[o1key GPT Image] API 响应耗时 {elapsed:.1f}s") + return await self._parse_success_response(resp, session, "GENERATIONS") return await self._run_with_interrupt(_do_request()) @@ -1291,45 +1767,32 @@ class GptImageClient: async def _do_request(): async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session: - last_status = None - for attempt in range(DEFAULT_MAX_RETRIES + 1): - t0 = time.time() - async with session.post( - url, - data=_build_multipart_form(), - headers=self._auth_headers(), - ) as resp: - elapsed = time.time() - t0 + t0 = time.time() + async with session.post( + url, + data=_build_multipart_form(), + headers=self._auth_headers(), + ) as resp: + elapsed = time.time() - t0 - if resp.status != 200: - text = await resp.text() - last_status = resp.status - if resp.status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES: - friendly = get_friendly_message(resp.status) - delay = _compute_delay(attempt, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR) - print(f"[o1key GPT Image] {friendly} retrying in {delay:.1f}s ({attempt+1}/{DEFAULT_MAX_RETRIES})...") - await asyncio.sleep(delay) - continue - if resp.status in HTTP_ERROR_MESSAGES: - raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status]) - try: - err_json = json.loads(text) - err_obj = err_json.get("error", {}) - msg = ( - err_obj.get("message") or err_obj.get("msg") or text - if isinstance(err_obj, dict) - else str(err_obj) or text - ) - except Exception: - msg = text - raise RuntimeError(get_friendly_message(resp.status, msg)) + if resp.status != 200: + text = await resp.text() + if resp.status in HTTP_ERROR_MESSAGES: + raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status]) + try: + err_json = json.loads(text) + err_obj = err_json.get("error", {}) + msg = ( + err_obj.get("message") or err_obj.get("msg") or text + if isinstance(err_obj, dict) + else str(err_obj) or text + ) + except Exception: + msg = text + raise RuntimeError(get_friendly_message(resp.status, msg)) - print(f"[o1key GPT Image] API 响应耗时 {elapsed:.1f}s") - return await self._parse_success_response(resp, session, "EDITS") - - if last_status and last_status in HTTP_ERROR_MESSAGES: - raise RuntimeError(HTTP_ERROR_MESSAGES[last_status]) - raise RuntimeError(f"Request failed after {DEFAULT_MAX_RETRIES} retries") + print(f"[o1key GPT Image] API 响应耗时 {elapsed:.1f}s") + return await self._parse_success_response(resp, session, "EDITS") return await self._run_with_interrupt(_do_request()) @@ -1385,24 +1848,103 @@ class GptImageClient: f"o1key GPT Image 请求超时(>{_REQUEST_TIMEOUT}s),请检查网络或稍后重试" ) - def generate_image_async_sync( + async def generate_image_async( self, prompt: str, model: str, quality: str, - size: str, + size: Optional[str], n: int, seed: int, image_tensor: Optional[List[torch.Tensor]] = None, mask_tensor: Optional[torch.Tensor] = None, output_format: str = "png", + background: str = "auto", + moderation: Optional[str] = None, progress_callback: Optional[Callable[[int], None]] = None, + special_price_parallel: bool = False, + result_url_callback: Optional[Callable[[str], None]] = None, + log_downloads: bool = True, + log_prefix: str = "[o1key GPT Image]", + task_submitted_callback: Optional[Callable[[str, str, float], None]] = None, + log_request_start: bool = True, + task_completed_callback: Optional[Callable[[str, int, float, List[str]], None]] = None, + resize_mode: Optional[str] = None, + ) -> List[Image.Image]: + """异步公共入口;多图始终拆成并发的 n=1 单图请求。""" + requested_n = max(1, int(n)) + if requested_n > 1: + return await self._generate_special_price_images_parallel( + prompt=prompt, + model=model, + quality=quality, + size=size, + n=requested_n, + seed=seed, + image_tensor=image_tensor, + mask_tensor=mask_tensor, + output_format=output_format, + background=background, + moderation=moderation, + progress_callback=progress_callback, + result_url_callback=result_url_callback, + log_downloads=log_downloads, + log_prefix=log_prefix, + task_submitted_callback=task_submitted_callback, + log_request_start=log_request_start, + task_completed_callback=task_completed_callback, + resize_mode=resize_mode, + ) + return await self._generate_image_task_async( + prompt=prompt, + model=model, + quality=quality, + size=size, + n=requested_n, + seed=seed, + image_tensor=image_tensor, + mask_tensor=mask_tensor, + output_format=output_format, + background=background, + moderation=moderation, + progress_callback=progress_callback, + result_url_callback=result_url_callback, + log_downloads=log_downloads, + log_prefix=log_prefix, + task_submitted_callback=task_submitted_callback, + log_request_start=log_request_start, + task_completed_callback=task_completed_callback, + resize_mode=resize_mode, + ) + + def generate_image_async_sync( + self, + prompt: str, + model: str, + quality: str, + size: Optional[str], + n: int, + seed: int, + image_tensor: Optional[List[torch.Tensor]] = None, + mask_tensor: Optional[torch.Tensor] = None, + output_format: str = "png", + background: str = "auto", + moderation: Optional[str] = None, + progress_callback: Optional[Callable[[int], None]] = None, + special_price_parallel: bool = False, + result_url_callback: Optional[Callable[[str], None]] = None, + log_downloads: bool = True, + log_prefix: str = "[o1key GPT Image]", + task_submitted_callback: Optional[Callable[[str, str, float], None]] = None, + log_request_start: bool = True, + task_completed_callback: Optional[Callable[[str, int, float, List[str]], None]] = None, + resize_mode: Optional[str] = None, ) -> List[Image.Image]: """ 新版异步任务入口,供节点调用。 旧 run_sync 保留兼容,但 GPT Image 节点不再使用旧同步接口。 """ - coro = self._generate_image_task_async( + coro = self.generate_image_async( prompt=prompt, model=model, quality=quality, @@ -1412,7 +1954,17 @@ class GptImageClient: image_tensor=image_tensor, mask_tensor=mask_tensor, output_format=output_format, + background=background, + moderation=moderation, progress_callback=progress_callback, + special_price_parallel=special_price_parallel, + result_url_callback=result_url_callback, + log_downloads=log_downloads, + log_prefix=log_prefix, + task_submitted_callback=task_submitted_callback, + log_request_start=log_request_start, + task_completed_callback=task_completed_callback, + resize_mode=resize_mode, ) def _run(): diff --git a/clients/grok_image_client.py b/clients/grok_image_client.py index 4fdefd5..b6cbd67 100644 --- a/clients/grok_image_client.py +++ b/clients/grok_image_client.py @@ -48,7 +48,7 @@ _RETRY_DELAY = 5 class GrokImageClient: - def __init__(self, route: str = "全球加速"): + def __init__(self, route: str = None): self.api_key = get_api_key_or_raise("O1KEY_API_KEY") self.base_url = get_base_url_by_route(route) @@ -73,8 +73,13 @@ class GrokImageClient: step = 0 while len(png_bytes) > max_bytes: scale = 0.894 - w = max(1, int(w * scale)) - h = max(1, int(h * scale)) + next_w = max(1, int(w * scale)) + next_h = max(1, int(h * scale)) + if (next_w, next_h) == (w, h): + raise RuntimeError( + f"Grok Image 无法将图像缩小到 {max_bytes} 字节以内" + ) + w, h = next_w, next_h img = img.resize((w, h), Image.LANCZOS) buf = BytesIO() img.save(buf, format="PNG") @@ -99,6 +104,116 @@ class GrokImageClient: tensors.append(torch.from_numpy(arr)) return torch.stack(tensors, dim=0) + @classmethod + def _build_edit_body( + cls, + prompt: str, + model: str, + aspect_ratio: str, + resolution: str, + image_list: List[torch.Tensor], + ) -> dict: + """构建最多三张参考图的编辑请求,并确保完整 JSON 不超过 20MB。""" + if len(image_list) > 3: + raise ValueError("Grok Image 最多支持 3 张参考图") + + reference_images = [] + for index, tensor in enumerate(image_list, start=1): + pil_images = tensor_to_pil(tensor) + if not pil_images: + raise ValueError(f"无法读取参考图{index}") + reference_images.append(pil_images[0]) + if not reference_images: + raise ValueError("图像编辑至少需要 1 张参考图") + + body: dict = { + "model": _MODEL_NAME_MAP.get(model, model), + "prompt": prompt, + "response_format": "b64_json", + } + if aspect_ratio and aspect_ratio != "auto": + body["aspect_ratio"] = aspect_ratio + if resolution: + body["resolution"] = resolution + + # base64 大约是原始字节的 4/3。预留 1MB 给提示词和 JSON 字段, + # 同时保留旧逻辑的单图 PNG 最大 10MB 上限。 + base_size = len(json.dumps(body, ensure_ascii=False).encode("utf-8")) + available = _MAX_BODY_BYTES - base_size - 1024 * 1024 + if available <= 0: + raise ValueError("提示词和请求参数已超过 Grok Image 20MB 请求体限制") + per_image_limit = min( + _MAX_BODY_BYTES // 2, + max(1, int(available * 0.75) // len(reference_images)), + ) + + png_images = [] + for index, image in enumerate(reference_images, start=1): + buffer = BytesIO() + image.save(buffer, format="PNG") + png_images.append( + cls._shrink_png_to_limit( + buffer.getvalue(), + per_image_limit, + label=f"参考图{index}", + ) + ) + + def _set_images() -> None: + encoded = [base64.b64encode(data).decode("ascii") for data in png_images] + if len(encoded) == 1: + # 单图保持当前 o1key 兼容格式,不改变既有请求行为。 + body.pop("images", None) + body["image"] = encoded[0] + else: + body.pop("image", None) + body["images"] = [ + { + "type": "image_url", + "url": f"data:image/png;base64,{value}", + } + for value in encoded + ] + + _set_images() + body_size = len(json.dumps(body, ensure_ascii=False).encode("utf-8")) + for _ in range(8): + if body_size <= _MAX_BODY_BYTES: + return body + shrink_ratio = max(0.1, (_MAX_BODY_BYTES / body_size) * 0.95) + png_images = [ + cls._shrink_png_to_limit( + data, + max(1, int(len(data) * shrink_ratio)), + label=f"参考图{index}", + ) + for index, data in enumerate(png_images, start=1) + ] + _set_images() + body_size = len(json.dumps(body, ensure_ascii=False).encode("utf-8")) + + raise RuntimeError( + f"Grok Image 参考图缩放后请求体仍超过 20MB:{body_size / 1024 / 1024:.2f}MB" + ) + + @staticmethod + def _redact_edit_body(body: dict) -> dict: + result = dict(body) + image = result.get("image") + if isinstance(image, str) and len(image) > 50: + result["image"] = image[:50] + "..." + images = result.get("images") + if isinstance(images, list): + result["images"] = [ + { + **item, + "url": item.get("url", "")[:50] + "...", + } + if isinstance(item, dict) else item + for item in images + ] + return result + # ── 中断轮询 ────────────────────────────────────────────────────────────── @staticmethod @@ -202,28 +317,16 @@ class GrokImageClient: n: int, image_list: List[torch.Tensor], ) -> List[Image.Image]: - api_model = _MODEL_NAME_MAP.get(model, model) - body: dict = { - "model": api_model, - "prompt": prompt, - "response_format": "b64_json", - } - if aspect_ratio and aspect_ratio != "auto": - body["aspect_ratio"] = aspect_ratio - if resolution: - body["resolution"] = resolution - - # 参考图转 base64 字符串 - pil_images = tensor_to_pil(image_list[0]) - img = pil_images[0] - buf = BytesIO() - img.save(buf, format="PNG") - png_bytes = buf.getvalue() - png_bytes = self._shrink_png_to_limit(png_bytes, _MAX_BODY_BYTES // 2) - body["image"] = base64.b64encode(png_bytes).decode("utf-8") + body = self._build_edit_body( + prompt=prompt, + model=model, + aspect_ratio=aspect_ratio, + resolution=resolution, + image_list=image_list, + ) url = f"{self.base_url}{_ENDPOINT_EDITS}" - log_body = {k: (v[:50] + "..." if k == "image" and len(v) > 50 else v) for k, v in body.items()} + log_body = self._redact_edit_body(body) print(f"[o1key Grok Image] 请求 URL: {url}") print(f"[o1key Grok Image] 请求体: {json.dumps(log_body, ensure_ascii=False)}") diff --git a/clients/grok_video_client.py b/clients/grok_video_client.py index 651d127..af0672e 100644 --- a/clients/grok_video_client.py +++ b/clients/grok_video_client.py @@ -1,19 +1,12 @@ -""" -Grok Video API client. - -Flow: - 1. POST /v1/videos - 2. GET /v1/videos/{task_id} - 3. GET /v1/videos/{task_id}/content, or download a URL from the status body -""" +"""Client for the complete O1Key Grok Imagine Video API.""" import asyncio -import base64 import json import os import re import time from typing import Any, Callable, Dict, List, Optional +from urllib.parse import quote import aiohttp @@ -21,44 +14,49 @@ from .base_client import BaseAPIClient from ..utils.config import get_api_base_url, get_api_key_or_raise from ..utils.http_error import RETRYABLE_STATUS_CODES, get_friendly_message from ..utils.video_task import ( + POLL_DEADLINE_SECONDS as VIDEO_POLL_DEADLINE_SECONDS, check_interrupt, + download_video_to_file, extract_error_message, - extract_progress, - extract_status, - extract_video_url, interruptible_sleep, - is_failure_status, - is_success_status, run_with_interrupt, ) class GrokVideoClient(BaseAPIClient): - CREATE_ENDPOINT = "/v1/videos" - STATUS_ENDPOINT = "/v1/videos/{task_id}" - CONTENT_ENDPOINT = "/v1/videos/{task_id}/content" + """Submit, poll, and download Grok video generation, edit, or extension tasks.""" - MODEL_OPTIONS = ["grok-imagine-video-1.5-preview", "grok-imagine-1.0-video"] - ASPECT_RATIO_OPTIONS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"] - QUALITY_OPTIONS = ["720p"] - MODEL_SECONDS_OPTIONS = { - "grok-imagine-1.0-video": [6, 10, 12, 16, 20], - } - QUALITY_API_MAP = { - "720p": "high", - "high": "high", + ENDPOINTS = { + "generate": "/grok/v1/videos/generations", + "edit": "/grok/v1/videos/edits", + "extend": "/grok/v1/videos/extensions", } + STATUS_ENDPOINT = "/grok/v1/videos/{request_id}" - SUCCESS_STATUSES = {"complete", "completed", "succeed", "succeeded", "success", "done", "finished"} - FAILURE_STATUSES = {"fail", "failed", "failure", "error", "expired", "timeout", "cancelled", "canceled"} + BASE_MODEL = "grok-imagine-video" + LATEST_MODEL = "grok-imagine-video-1.5" + DEFAULT_MODEL = LATEST_MODEL + # Kept as a compatibility alias for callers that imported the old constant. + TEXT_TO_VIDEO_MODEL = BASE_MODEL + IMAGE_TO_VIDEO_MODELS = (LATEST_MODEL,) + MODEL_OPTIONS = (BASE_MODEL, LATEST_MODEL) + ASPECT_RATIO_OPTIONS = ("16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3") + RESOLUTION_OPTIONS = ("480p", "720p", "1080p") + SUCCESS_STATUSES = {"done"} + FAILURE_STATUSES = {"failed", "expired"} + POLL_DEADLINE_SECONDS = VIDEO_POLL_DEADLINE_SECONDS def __init__(self, base_url: Optional[str] = None): - api_key = get_api_key_or_raise("O1KEY_API_KEY") - resolved_base_url = (base_url or "").strip() or get_api_base_url() - super().__init__(base_url=resolved_base_url.rstrip("/"), api_key=api_key) + super().__init__( + base_url=(base_url or get_api_base_url()).rstrip("/"), + api_key=get_api_key_or_raise("O1KEY_API_KEY"), + ) - def get_endpoint(self, **kwargs) -> str: - return self.CREATE_ENDPOINT + def get_endpoint(self, operation: str = "generate", **kwargs) -> str: + try: + return self.ENDPOINTS[operation] + except KeyError: + raise ValueError(f"不支持的 Grok 操作:{operation}。") from None def build_request_body(self, **kwargs) -> Dict[str, Any]: return self.build_video_body(**kwargs) @@ -66,418 +64,298 @@ class GrokVideoClient(BaseAPIClient): def parse_response(self, response: Dict[str, Any]) -> Any: return response + @staticmethod + def _locator( + value: Optional[Dict[str, str]], + label: str, + allowed_keys: tuple[str, ...], + ) -> Dict[str, str]: + if not isinstance(value, dict): + raise ValueError(f"{label}必须提供媒体定位对象。") + known_keys = ("url", "image_url", "file_id", "voice_id") + provided_keys = { + key + for key in known_keys + if value.get(key) is not None and str(value[key]).strip() + } + locator = { + key: str(value[key]).strip() + for key in allowed_keys + if value.get(key) is not None and str(value[key]).strip() + } + if len(locator) != 1 or provided_keys != set(locator): + supported = "、".join(allowed_keys) + raise ValueError(f"{label}必须且只能提供 {supported} 中的一项。") + return locator + + @classmethod + def _image_locator(cls, value: Optional[Dict[str, str]], label: str) -> Dict[str, str]: + return cls._locator(value, label, ("url", "image_url")) + + @classmethod + def _audio_locator(cls, value: Optional[Dict[str, str]], label: str) -> Dict[str, str]: + return cls._locator(value, label, ("url", "voice_id")) + + @classmethod + def _video_locator(cls, value: Optional[Dict[str, str]], label: str) -> Dict[str, str]: + return cls._locator(value, label, ("url", "file_id")) + + @classmethod + def _validate_common_generation( + cls, model: str, duration: int, aspect_ratio: str, resolution: str + ) -> int: + if model not in cls.MODEL_OPTIONS: + raise ValueError(f"模型仅支持:{', '.join(cls.MODEL_OPTIONS)}。") + try: + duration = int(duration) + except (TypeError, ValueError): + raise ValueError("时长必须是整数。") from None + if not 1 <= duration <= 15: + raise ValueError("生成时长仅支持 1 到 15 秒。") + if aspect_ratio not in cls.ASPECT_RATIO_OPTIONS: + raise ValueError(f"宽高比仅支持:{', '.join(cls.ASPECT_RATIO_OPTIONS)}。") + if resolution not in cls.RESOLUTION_OPTIONS: + raise ValueError(f"分辨率仅支持:{', '.join(cls.RESOLUTION_OPTIONS)}。") + return duration + @classmethod def build_video_body( cls, + *, + operation: str, prompt: str, model: str, - aspect_ratio: str, - seconds: int, - quality: str = "720p", - images: Optional[List[str]] = None, + duration: Optional[int] = None, + aspect_ratio: str = "16:9", + resolution: str = "480p", + image: Optional[Dict[str, str]] = None, + reference_images: Optional[List[Dict[str, str]]] = None, + reference_audios: Optional[List[Dict[str, str]]] = None, + video: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: + if operation not in cls.ENDPOINTS: + raise ValueError(f"不支持的 Grok 操作:{operation}。") prompt = (prompt or "").strip() - if not prompt: - raise ValueError("提示词不能为空。") + if reference_images is not None and not isinstance(reference_images, (list, tuple)): + raise ValueError("reference_images 必须是数组。") + if reference_audios is not None and not isinstance(reference_audios, (list, tuple)): + raise ValueError("reference_audios 必须是数组。") + references = list(reference_images or []) + audios = list(reference_audios or []) + + if operation == "generate": + duration = cls._validate_common_generation(model, duration, aspect_ratio, resolution) + normal_image = cls._image_locator(image, "图生视频参考图") if image else None + normal_references = [cls._image_locator(item, "参考图") for item in references] + normal_audios = [cls._audio_locator(item, "参考音频") for item in audios] + if normal_image and normal_references: + raise ValueError("image 和 reference_images 不能同时使用。") + if len(normal_references) > 7: + raise ValueError("参考生视频最多支持 7 张参考图。") + if len(normal_audios) > 3: + raise ValueError("参考生视频最多支持 3 个参考音频。") + + has_reference_assets = bool(normal_references or normal_audios) + if has_reference_assets: + if not prompt: + raise ValueError("参考图/音频生视频必须填写提示词。") + if resolution == "1080p": + raise ValueError("参考图/音频生视频不支持 1080p。") + elif not normal_image and not prompt: + raise ValueError("文生视频必须填写提示词。") + + if resolution == "1080p" and model != cls.LATEST_MODEL: + raise ValueError("1080p 仅支持 grok-imagine-video-1.5 的文生或图生视频。") + + body: Dict[str, Any] = { + "model": model, + "duration": duration, + "aspect_ratio": aspect_ratio, + "resolution": resolution, + } + if prompt: + body["prompt"] = prompt + if normal_image: + body["image"] = normal_image + if normal_references: + body["reference_images"] = normal_references + if normal_audios: + body["reference_audios"] = normal_audios + return body if model not in cls.MODEL_OPTIONS: - raise ValueError(f"模型仅支持: {', '.join(cls.MODEL_OPTIONS)}。") - - if aspect_ratio not in cls.ASPECT_RATIO_OPTIONS: - raise ValueError(f"宽高比仅支持: {', '.join(cls.ASPECT_RATIO_OPTIONS)}。") + raise ValueError(f"模型仅支持:{', '.join(cls.MODEL_OPTIONS)}。") + if not prompt: + raise ValueError(f"{operation} 必须填写提示词。") + normal_video = cls._video_locator(video, "输入视频") + if operation == "edit": + return {"model": model, "prompt": prompt, "video": normal_video} try: - seconds_value = int(seconds) + duration = int(duration) except (TypeError, ValueError): - raise ValueError("秒数必须是整数。") from None - - allowed_seconds = cls.MODEL_SECONDS_OPTIONS.get(model) - if allowed_seconds is not None: - if seconds_value not in allowed_seconds: - raise ValueError( - f"模型 {model} 仅支持秒数: " - f"{', '.join(str(s) for s in allowed_seconds)}。" - "请修改为正确的秒数后再发起请求。" - ) - elif seconds_value < 5 or seconds_value > 15: - raise ValueError("秒数仅支持 5 到 15。") - - api_quality = cls.QUALITY_API_MAP.get(str(quality), str(quality)) - if api_quality != "high": - raise ValueError("画质仅支持 720p。") - - body: Dict[str, Any] = { + raise ValueError("续写时长必须是整数。") from None + if not 2 <= duration <= 10: + raise ValueError("视频续写时长仅支持 2 到 10 秒。") + return { "model": model, "prompt": prompt, - "aspect_ratio": aspect_ratio, - "seconds": str(seconds_value), - "quality": api_quality, + "video": normal_video, + "duration": duration, } - image_list = [img for img in (images or []) if img] - if image_list: - body["images"] = image_list[:3] - - return body - @staticmethod - def _safe_task_filename(task_id: str) -> str: - safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", task_id).strip("._") - return safe or "grok_video" - - @staticmethod - def _mask_body_for_log(body: Dict[str, Any]) -> Dict[str, Any]: - log_body = dict(body) - images = log_body.get("images") - if isinstance(images, list): - log_body["images"] = [f"" for item in images] - return log_body - - @staticmethod - def _extract_task_id(payload: Dict[str, Any]) -> Optional[str]: - sources = [payload] - data = payload.get("data") - if isinstance(data, dict): - sources.append(data) - - for source in sources: - for key in ("id", "task_id", "video_id"): - value = source.get(key) - if value: - return str(value) + def _extract_request_id(payload: Dict[str, Any]) -> Optional[str]: + for source in (payload, payload.get("data")): + if isinstance(source, dict) and source.get("request_id"): + return str(source["request_id"]) return None @staticmethod - def _format_http_error(endpoint: str, status: int, error_text: str, task_id: Optional[str] = None) -> str: - message = get_friendly_message(status, error_text) - parts = [ - "Grok Video 请求失败。", - f"endpoint: {endpoint}", - f"http_status: {status}", - ] - if task_id: - parts.append(f"task_id: {task_id}") - if message: - parts.append(f"message: {message}") - return "\n".join(parts) + def _safe_filename(request_id: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "_", request_id).strip("._") or "grok_video" - @classmethod - def _format_task_failure(cls, task_id: str, payload: Dict[str, Any]) -> str: - return "\n".join( - [ - "Grok Video 任务失败。", - f"endpoint: {cls.STATUS_ENDPOINT.format(task_id=task_id)}", - f"task_id: {task_id}", - f"message: {extract_error_message(payload)}", - ] - ) + @staticmethod + def _safe_error_message(value: object) -> str: + message = str(value or "").strip() + message = re.sub(r"data:[^\s,;]+;base64,[A-Za-z0-9+/=_-]+", "", message) + message = re.sub(r"https?://[^\s\"'<>]+", "", message) + return message[:500] - async def _request_json_with_retry( + async def _request_json( self, method: str, endpoint: str, session: aiohttp.ClientSession, - task_id: Optional[str] = None, + *, json_body: Optional[Dict[str, Any]] = None, - max_retries: int = 3, timeout_seconds: int = 120, + request_id: Optional[str] = None, ) -> Dict[str, Any]: url = f"{self.base_url}{endpoint}" - headers = self.get_headers(use_bearer_token=True) timeout = aiohttp.ClientTimeout(total=timeout_seconds, connect=30, sock_read=timeout_seconds) - - last_status = 0 - last_text = "" - - for attempt in range(max_retries + 1): + last_status, last_text = 0, "" + for attempt in range(4): check_interrupt() response = None try: response = await run_with_interrupt( - session.request(method, url, json=json_body, headers=headers, timeout=timeout) + session.request( + method, url, json=json_body, + headers=self.get_headers(use_bearer_token=True), timeout=timeout, + ) ) text = await run_with_interrupt(response.text()) - last_status = response.status - last_text = text - + last_status, last_text = response.status, text if 200 <= response.status < 300: - if not text.strip(): - return {} try: - return json.loads(text) - except Exception: - raise RuntimeError(f"Grok Video 响应 JSON 解析失败,原始内容:{text[:500]}") from None - - if response.status in RETRYABLE_STATUS_CODES and attempt < max_retries: - delay = min(2 ** attempt, 8) - print( - f"Grok Video:{get_friendly_message(response.status)} " - f"{delay}s 后重试 ({attempt + 1}/{max_retries})..." - ) - await interruptible_sleep(delay) - continue - - break - - except (aiohttp.ClientError, asyncio.TimeoutError) as e: - if attempt < max_retries: - delay = min(2 ** attempt, 8) - print(f"Grok Video:网络错误,{delay}s 后重试 ({attempt + 1}/{max_retries})...") - await interruptible_sleep(delay) - continue - raise RuntimeError(f"Grok Video 网络错误: {e}") from None - + return json.loads(text) if text.strip() else {} + except json.JSONDecodeError: + raise RuntimeError("Grok Video 响应不是有效 JSON。") from None + if response.status not in RETRYABLE_STATUS_CODES or attempt == 3: + break + delay = min(2 ** attempt, 8) + print(f"Grok Video:HTTP {response.status},{delay}s 后重试…") + await interruptible_sleep(delay) + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: + if attempt == 3: + raise RuntimeError( + f"Grok Video 网络错误:{type(exc).__name__}" + ) from None + delay = min(2 ** attempt, 8) + print(f"Grok Video:网络错误,{delay}s 后重试…") + await interruptible_sleep(delay) finally: if response is not None: response.release() - raise RuntimeError(self._format_http_error(endpoint, last_status, last_text, task_id=task_id)) - - async def create_video_async( - self, - body: Dict[str, Any], - session: aiohttp.ClientSession, - ) -> Dict[str, Any]: - print("Grok Video:正在提交任务...") - return await self._request_json_with_retry( - "POST", - self.CREATE_ENDPOINT, - session=session, - json_body=body, - timeout_seconds=180, + message = self._safe_error_message( + get_friendly_message(last_status, last_text) or "请求失败" ) + detail = f"Grok Video 请求失败:HTTP {last_status},{message}" + if request_id: + detail += f"(request_id: {request_id})" + raise RuntimeError(detail) - async def poll_video_status_async( - self, - task_id: str, - session: aiohttp.ClientSession, - poll_interval: int = 5, - timeout: int = 900, - progress_callback: Optional[Callable[[int, str, float], None]] = None, + async def _poll( + self, request_id: str, session: aiohttp.ClientSession, *, poll_interval: int, + timeout: int, progress_callback: Optional[Callable[[int, str, float], None]], ) -> Dict[str, Any]: - endpoint = self.STATUS_ENDPOINT.format(task_id=task_id) - start = time.time() - interval = max(1, int(poll_interval)) - - await interruptible_sleep(interval) - + endpoint = self.STATUS_ENDPOINT.format(request_id=quote(request_id, safe="")) + started_at = time.monotonic() while True: - data = await self._request_json_with_retry( - "GET", - endpoint, - session=session, - task_id=task_id, - timeout_seconds=60, + await interruptible_sleep(poll_interval) + response = await self._request_json( + "GET", endpoint, session, timeout_seconds=60, request_id=request_id ) - - status = extract_status(data) - progress = extract_progress(data) - elapsed = time.time() - start - + status = str(response.get("status", "")).strip().lower() + try: + progress = max(0, min(100, int(float(response.get("progress") or 0)))) + except (TypeError, ValueError): + progress = 0 + elapsed = time.monotonic() - started_at if progress_callback: progress_callback(progress, status, elapsed) - - if status in self.SUCCESS_STATUSES or is_success_status(status): - return data - - if status in self.FAILURE_STATUSES or is_failure_status(status, data): - raise RuntimeError(self._format_task_failure(task_id, data)) - + if status in self.SUCCESS_STATUSES: + return response + if status in self.FAILURE_STATUSES: + message = self._safe_error_message( + extract_error_message(response, default="未知错误") + ) + raise RuntimeError( + f"Grok Video 任务{status}(request_id: {request_id}):" + f"{message}" + ) if elapsed >= timeout: raise TimeoutError( - "Grok Video 任务轮询超时;任务未被标记为失败,可用 task_id 继续查询。\n" - f"endpoint: {endpoint}\n" - f"task_id: {task_id}\n" - f"status: {status or 'unknown'}\n" - f"timeout: {timeout}s" + f"Grok Video 轮询超时(request_id: {request_id},状态:{status or 'unknown'})。" ) - await interruptible_sleep(min(interval, max(0.0, timeout - elapsed))) - - async def _download_url_to_file( - self, - url: str, - save_path: str, - session: aiohttp.ClientSession, - max_retries: int = 3, - ) -> str: - timeout = aiohttp.ClientTimeout(total=900, connect=30, sock_read=900) - last_status = 0 - last_text = "" - headers = None - resolved_url = url - - if url.startswith("data:"): - if "," not in url: - raise RuntimeError("Grok Video 下载失败:data URL 格式无效。") - _, b64_data = url.split(",", 1) - os.makedirs(os.path.dirname(save_path), exist_ok=True) - with open(save_path, "wb") as f: - f.write(base64.b64decode(b64_data)) - if not os.path.isfile(save_path) or os.path.getsize(save_path) <= 0: - raise RuntimeError("Grok Video 下载失败:保存后的文件为空。") - return save_path - - if url.startswith("/"): - resolved_url = f"{self.base_url}{url}" - headers = self.get_headers(use_bearer_token=True) - - for attempt in range(max_retries + 1): - check_interrupt() - async with session.get( - resolved_url, - headers=headers, - timeout=timeout, - allow_redirects=True, - ) as response: - if 200 <= response.status < 300: - os.makedirs(os.path.dirname(save_path), exist_ok=True) - with open(save_path, "wb") as f: - async for chunk in response.content.iter_chunked(1024 * 1024): - check_interrupt() - if chunk: - f.write(chunk) - - if not os.path.isfile(save_path) or os.path.getsize(save_path) <= 0: - raise RuntimeError("Grok Video 下载失败:保存后的文件为空。") - return save_path - - last_status = response.status - last_text = await response.text() - if response.status not in RETRYABLE_STATUS_CODES or attempt >= max_retries: - break - - delay = min(2 ** attempt, 8) - print(f"Grok Video:下载重试 {attempt + 1}/{max_retries},{delay}s 后继续...") - await interruptible_sleep(delay) - - raise RuntimeError(self._format_http_error("download_url", last_status, last_text)) - - async def download_video_async( - self, - task_id: str, - save_path: str, - session: aiohttp.ClientSession, - ) -> str: - endpoint = self.CONTENT_ENDPOINT.format(task_id=task_id) - url = f"{self.base_url}{endpoint}" - headers = self.get_headers(use_bearer_token=True) - timeout = aiohttp.ClientTimeout(total=900, connect=30, sock_read=900) - - last_status = 0 - last_text = "" - - for attempt in range(4): - check_interrupt() - async with session.get(url, headers=headers, timeout=timeout, allow_redirects=True) as response: - if 200 <= response.status < 300: - content_type = response.headers.get("Content-Type", "").lower() - if "application/json" in content_type: - data = await response.json(content_type=None) - download_url = extract_video_url(data) - if not download_url: - raise RuntimeError( - "Grok Video 下载失败:content 响应为 JSON,但未包含视频 URL。\n" - f"endpoint: {endpoint}\n" - f"task_id: {task_id}" - ) - return await self._download_url_to_file(download_url, save_path, session) - - os.makedirs(os.path.dirname(save_path), exist_ok=True) - with open(save_path, "wb") as f: - async for chunk in response.content.iter_chunked(1024 * 1024): - check_interrupt() - if chunk: - f.write(chunk) - - if not os.path.isfile(save_path) or os.path.getsize(save_path) <= 0: - raise RuntimeError( - "Grok Video 下载失败:保存后的文件为空。\n" - f"endpoint: {endpoint}\n" - f"task_id: {task_id}" - ) - return save_path - - last_status = response.status - last_text = await response.text() - if response.status not in RETRYABLE_STATUS_CODES or attempt >= 3: - break - - delay = min(2 ** attempt, 8) - print(f"Grok Video:content 下载重试 {attempt + 1}/3,{delay}s 后继续...") - await interruptible_sleep(delay) - - raise RuntimeError(self._format_http_error(endpoint, last_status, last_text, task_id=task_id)) - - def generate_video_sync( - self, - prompt: str, - model: str, - aspect_ratio: str, - seconds: int, - quality: str, - images: Optional[List[str]], - output_dir: Optional[str] = None, - save_path: Optional[str] = None, - poll_interval: int = 5, - timeout: int = 900, + def run_video_sync( + self, *, operation: str, prompt: str, model: str, duration: Optional[int] = None, + aspect_ratio: str = "16:9", resolution: str = "480p", + image: Optional[Dict[str, str]] = None, + reference_images: Optional[List[Dict[str, str]]] = None, + reference_audios: Optional[List[Dict[str, str]]] = None, + video: Optional[Dict[str, str]] = None, output_dir: Optional[str] = None, + poll_interval: int = 5, timeout: int = VIDEO_POLL_DEADLINE_SECONDS, progress_callback: Optional[Callable[[int, str, float], None]] = None, ) -> Dict[str, Any]: - async def _run(): + async def run_request() -> Dict[str, Any]: async with self._make_session() as session: + endpoint = self.get_endpoint(operation) body = self.build_video_body( - prompt=prompt, - model=model, - aspect_ratio=aspect_ratio, - seconds=seconds, - quality=quality, - images=images, + operation=operation, prompt=prompt, model=model, duration=duration, + aspect_ratio=aspect_ratio, resolution=resolution, image=image, + reference_images=reference_images, reference_audios=reference_audios, + video=video, ) - - create_response = await self.create_video_async(body, session) - task_id = self._extract_task_id(create_response) or "" - if not task_id: - raise RuntimeError( - "Grok Video 未返回任务 ID。\n" - f"endpoint: {self.CREATE_ENDPOINT}\n" - f"response: {json.dumps(create_response, ensure_ascii=False)[:1200]}" - ) - - print(f"Grok Video:任务已提交,任务ID:{task_id}") - print("Grok Video:视频生成中...") - status_response = await self.poll_video_status_async( - task_id=task_id, - session=session, - poll_interval=poll_interval, - timeout=timeout, - progress_callback=progress_callback, + print(f"Grok Video:正在提交{operation}任务…") + created = await self._request_json( + "POST", endpoint, session, json_body=body, timeout_seconds=180 ) - - video_url = extract_video_url(status_response) - print("Grok Video:视频生成完成,正在下载...") - if save_path is None: - resolved_output_dir = output_dir or os.getcwd() - os.makedirs(resolved_output_dir, exist_ok=True) - target_path = os.path.join( - resolved_output_dir, - f"{self._safe_task_filename(task_id)}.mp4", - ) - else: - target_path = save_path - - if video_url: - video_path = await self._download_url_to_file(video_url, target_path, session) - else: - video_path = await self.download_video_async(task_id, target_path, session) - + request_id = self._extract_request_id(created) + if not request_id: + raise RuntimeError("Grok Video 创建响应中没有 request_id。") + print(f"Grok Video:任务已提交,request_id:{request_id}") + completed = await self._poll( + request_id, session, poll_interval=max(1, int(poll_interval)), + timeout=timeout, progress_callback=progress_callback, + ) + video_data = completed.get("video") + video_url = video_data.get("url") if isinstance(video_data, dict) else None + if not video_url: + raise RuntimeError(f"Grok Video 完成响应中没有 video.url(request_id: {request_id})。") + directory = output_dir or os.getcwd() + os.makedirs(directory, exist_ok=True) + save_path = os.path.join(directory, f"{self._safe_filename(request_id)}.mp4") + print("Grok Video:视频生成完成,正在下载…") + video_path = await download_video_to_file(session, video_url, save_path, label="Grok Video") return { - "task_id": task_id, - "status": extract_status(status_response), + "request_id": request_id, "video_path": video_path, - "raw_json": { - "create": create_response, - "status": status_response, - }, + "duration": video_data.get("duration"), + "raw_json": {"create": created, "status": completed}, } - return self.run_async_in_thread(_run()) + return self.run_async_in_thread(run_request()) diff --git a/clients/kling_client.py b/clients/kling_client.py deleted file mode 100644 index d7c03c8..0000000 --- a/clients/kling_client.py +++ /dev/null @@ -1,285 +0,0 @@ -""" -Kling 视频生成 API 客户端 -""" - -import asyncio -import json -import os -from typing import Any, Callable, Dict, Optional - -import aiohttp - -from ..utils.config import get_api_key_or_raise, get_api_base_url -from ..utils.http_error import async_request_with_retry -from ..utils.video_task import ( - check_interrupt, - extract_error_message, - extract_progress, - extract_status, - interruptible_sleep, - is_failure_status, - is_success_status, - run_with_interrupt, -) - - -class KlingClient: - """Kling 视频生成客户端""" - - ENDPOINTS = { - "image2video": "/kling/v1/videos/image2video", - "text2video": "/kling/v1/videos/text2video", - "motion_control": "/kling/v1/videos/motion-control", - } - - # new API 三段式端点(动作控制走这里) - NEW_API_CREATE = "/v1/videos" - NEW_API_STATUS = "/v1/videos/{video_id}" - NEW_API_CONTENT = "/v1/videos/{video_id}/content" - - POLL_INITIAL_INTERVAL = 3 - POLL_MAX_INTERVAL = 15 - - def __init__(self): - self.api_key = get_api_key_or_raise() - self.base_url = get_api_base_url() - - def _headers(self) -> Dict[str, str]: - return { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } - - # ── 提交任务 ────────────────────────────────────────────────────── - - async def create_video_async( - self, - endpoint_type: str, - body: Dict[str, Any], - session: aiohttp.ClientSession, - ) -> Dict[str, Any]: - url = f"{self.base_url}{self.ENDPOINTS[endpoint_type]}" - - check_interrupt() - resp = await run_with_interrupt(async_request_with_retry( - session, "POST", url, json=body, headers=self._headers(), prefix="Kling 提交: " - )) - check_interrupt() - text = await resp.text() - return json.loads(text) - - # ── 轮询状态 ────────────────────────────────────────────────────── - - async def poll_status_async( - self, - task_id: str, - endpoint_type: str, - session: aiohttp.ClientSession, - on_progress: Optional[Callable[[int], None]] = None, - ) -> Dict[str, Any]: - url = f"{self.base_url}{self.ENDPOINTS[endpoint_type]}/{task_id}" - interval = self.POLL_INITIAL_INTERVAL - - while True: - check_interrupt() - async with session.get(url, headers=self._headers()) as resp: - text = await resp.text() - if resp.status != 200: - raise RuntimeError(f"状态查询失败 ({resp.status}): {text}") - result = json.loads(text) - - data = result.get("data", {}) - inner_data = data.get("data", {}) if isinstance(data, dict) else {} - status = extract_status(result) - - progress_pct = extract_progress(result) - - print(f"[视频生成] 生成中 {progress_pct}%") - - if on_progress: - on_progress(progress_pct) - - if is_success_status(status): - return result - elif is_failure_status(status, result): - error_msg = extract_error_message(result) - raise RuntimeError(f"生成失败:{error_msg}") - - await interruptible_sleep(interval) - interval = min(interval * 1.5, self.POLL_MAX_INTERVAL) - - # ── 下载视频 ────────────────────────────────────────────────────── - - async def download_video_async( - self, - video_url: str, - save_path: str, - session: aiohttp.ClientSession, - ) -> str: - print("[视频生成] 下载视频...") - check_interrupt() - async with session.get(video_url, allow_redirects=True) as resp: - if resp.status != 200: - raise RuntimeError(f"视频下载失败 ({resp.status})") - os.makedirs(os.path.dirname(save_path), exist_ok=True) - with open(save_path, "wb") as f: - async for chunk in resp.content.iter_chunked(8192): - check_interrupt() - f.write(chunk) - return save_path - - # ── 异步入口(供节点调用)──────────────────────────────────────── - - async def generate_async( - self, - endpoint_type: str, - body: Dict[str, Any], - save_path: str, - on_stage: Optional[Callable[[str], None]] = None, - on_progress: Optional[Callable[[int], None]] = None, - ) -> str: - """提交 → 轮询 → 下载,返回本地文件路径""" - connector = aiohttp.TCPConnector(ssl=False, force_close=True) - async with aiohttp.ClientSession(connector=connector) as session: - if on_stage: - on_stage("submitting") - - result = await self.create_video_async(endpoint_type, body, session) - # 提交响应结构:result.data.task_id - task_id = result.get("task_id") or result.get("data", {}).get("task_id") - if not task_id: - raise RuntimeError(f"API 未返回任务 ID,响应:{result}") - if on_stage: - on_stage(f"submitted:{task_id}") - - if on_stage: - on_stage("polling") - final = await self.poll_status_async( - task_id, endpoint_type, session, on_progress=on_progress - ) - - # 兼容多种URL路径 - # 响应结构:result.data.result_url 或 result.data.data.task_result.videos[0].url - data = final.get("data", {}) - inner_data = data.get("data", {}) if isinstance(data, dict) else {} - video_url = ( - data.get("result_url") or - final.get("url") or - final.get("video_url") or - (inner_data.get("task_result", {}).get("videos", [{}])[0].get("url") - if inner_data.get("task_result", {}).get("videos") else None) - ) - if not video_url: - raise RuntimeError(f"API 未返回视频 URL,响应:{final}") - - if on_stage: - on_stage("downloading") - path = await self.download_video_async(video_url, save_path, session) - - if on_stage: - on_stage("done") - return path - - # ── 动作控制:走 new API 三段式流程 ────────────────────────────── - - async def motion_control_async( - self, - body: Dict[str, Any], - save_path: str, - on_stage: Optional[Callable[[str], None]] = None, - on_progress: Optional[Callable[[int], None]] = None, - ) -> str: - """ - 动作控制专用入口: - POST /v1/videos → GET /v1/videos/{id} → GET /v1/videos/{id}/content - body 字段与 Kling 官方动作控制接口一致(image_url/video_url/prompt/...)。 - """ - headers = {"Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json"} - interval = self.POLL_INITIAL_INTERVAL - - connector = aiohttp.TCPConnector(ssl=False, force_close=True) - async with aiohttp.ClientSession(connector=connector) as session: - - # 1. 提交 - check_interrupt() - if on_stage: - on_stage("submitting") - create_url = f"{self.base_url}{self.NEW_API_CREATE}" - resp = await run_with_interrupt(async_request_with_retry( - session, "POST", create_url, json=body, headers=headers, prefix="Kling 动作控制提交: " - )) - check_interrupt() - text = await resp.text() - create_resp = json.loads(text) - - video_id = create_resp.get("id") - if not video_id: - raise RuntimeError(f"API 未返回视频 ID,响应:{create_resp}") - if on_stage: - on_stage(f"submitted:{video_id}") - - # 2. 轮询 - status_url = f"{self.base_url}{self.NEW_API_STATUS.format(video_id=video_id)}" - while True: - check_interrupt() - async with session.get(status_url, headers=headers) as resp: - text = await resp.text() - if resp.status != 200: - try: - err = json.loads(text) - msg = err.get("error", {}).get("message") or err.get("message") or text - except Exception: - msg = text - raise RuntimeError(f"状态查询失败 ({resp.status}): {msg}") - status_resp = json.loads(text) - - status = extract_status(status_resp) - progress_pct = extract_progress(status_resp) - - print(f"[动作控制] 生成中 {progress_pct}%") - if on_progress: - on_progress(progress_pct) - - if is_success_status(status): - break - if is_failure_status(status, status_resp): - error_msg = extract_error_message(status_resp) - raise RuntimeError(f"动作控制生成失败:{error_msg}") - - await interruptible_sleep(interval) - interval = min(interval * 1.5, self.POLL_MAX_INTERVAL) - - # 3. 下载 - check_interrupt() - if on_stage: - on_stage("downloading") - content_url = f"{self.base_url}{self.NEW_API_CONTENT.format(video_id=video_id)}" - async with session.get(content_url, headers=headers, - allow_redirects=True) as resp: - if resp.status != 200: - raise RuntimeError(f"视频下载失败 ({resp.status})") - content_type = resp.headers.get("Content-Type", "") - if "application/json" in content_type: - data = await resp.json() - download_url = data.get("url") or data.get("download_url") - if not download_url: - raise RuntimeError("视频下载失败:响应中未找到下载链接") - async with session.get(download_url) as dl_resp: - if dl_resp.status != 200: - raise RuntimeError(f"从下载链接获取视频失败 ({dl_resp.status})") - os.makedirs(os.path.dirname(save_path), exist_ok=True) - with open(save_path, "wb") as f: - async for chunk in dl_resp.content.iter_chunked(8192): - check_interrupt() - f.write(chunk) - else: - os.makedirs(os.path.dirname(save_path), exist_ok=True) - with open(save_path, "wb") as f: - async for chunk in resp.content.iter_chunked(8192): - check_interrupt() - f.write(chunk) - - if on_stage: - on_stage("done") - return save_path diff --git a/clients/minimax_h3_client.py b/clients/minimax_h3_client.py new file mode 100644 index 0000000..85cff1f --- /dev/null +++ b/clients/minimax_h3_client.py @@ -0,0 +1,242 @@ +"""MiniMax H3 video client for the New API gateway.""" + +import json +from typing import Any, Callable, Dict, Optional +from urllib.parse import quote + +import aiohttp + +from ..utils.config import get_api_key_or_raise +from ..utils.http_error import async_request_with_retry +from ..utils.video_task import ( + POLL_DEADLINE_SECONDS as VIDEO_POLL_DEADLINE_SECONDS, + PollDeadline, + check_interrupt, + download_video_to_file, + extract_error_message, + extract_progress, + interruptible_sleep, + run_with_interrupt, +) + + +PENDING_STATUSES = { + "NOT_START", + "SUBMITTED", + "QUEUED", + "IN_PROGRESS", + "RUNNING", + "UNKNOWN", +} +SUCCESS_STATUSES = {"SUCCESS", "COMPLETED", "SUCCEEDED"} +FAILURE_STATUSES = {"FAILURE", "FAILED", "CANCELLED", "CANCELED"} + + +def extract_public_task_id(payload: Dict[str, Any]) -> str: + """Return the New API public task ID, preferring ``id`` as documented.""" + task_id = payload.get("id") or payload.get("task_id") + if not task_id: + raise RuntimeError("MiniMax H3 创建成功但未返回任务 ID。") + return str(task_id) + + +def parse_task_snapshot(payload: Dict[str, Any]) -> Dict[str, Any]: + """Normalize New API's wrapper and MiniMax's official V2 task shape.""" + if not isinstance(payload, dict): + raise RuntimeError("MiniMax H3 查询响应不是 JSON 对象。") + + raw_data = payload.get("data") + data = raw_data if isinstance(raw_data, dict) else payload + raw_task = payload.get("task") + task = raw_task if isinstance(raw_task, dict) else {} + task_content = task.get("content") if isinstance(task.get("content"), dict) else {} + task_error = task.get("error") if isinstance(task.get("error"), dict) else {} + data_error = data.get("error") if isinstance(data.get("error"), dict) else {} + root_error = payload.get("error") if isinstance(payload.get("error"), dict) else {} + metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} + status = str( + data.get("status") or task.get("status") or payload.get("status") or "" + ).strip().upper() + + error_message = str( + task_error.get("message") + or data_error.get("message") + or root_error.get("message") + or "" + ).strip() + error_code = str( + task_error.get("code") + or data_error.get("code") + or root_error.get("code") + or "" + ).strip() + if error_message and error_code: + error_message = f"{error_message}(错误码 {error_code})" + elif error_code: + error_message = f"错误码 {error_code}" + + return { + "status": status, + "progress": extract_progress(payload), + "result_url": str( + data.get("result_url") + or task_content.get("url") + or metadata.get("url") + or data.get("url") + or payload.get("result_url") + or payload.get("url") + or "" + ).strip(), + "fail_reason": str( + data.get("fail_reason") + or error_message + or extract_error_message(payload, "视频生成失败") + ).strip(), + } + + +class MiniMaxH3Client: + """Create, poll, and immediately download a MiniMax-H3 video task.""" + + CREATE_ENDPOINT = "/v1/video/generations" + STATUS_ENDPOINT = "/v1/videos/{task_id}" + POLL_INTERVAL_SECONDS = 10.0 + POLL_DEADLINE_SECONDS = VIDEO_POLL_DEADLINE_SECONDS + + def __init__(self, base_url: str, api_key: Optional[str] = None): + self.base_url = (base_url or "").rstrip("/") + if not self.base_url: + raise ValueError("MiniMax H3 New API Base URL 不能为空。") + self.api_key = api_key or get_api_key_or_raise() + + def _headers(self) -> Dict[str, str]: + # Model API requests use the application API token. New-Api-User is + # intentionally not sent because it belongs to management API auth. + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + @staticmethod + async def _read_json(response: aiohttp.ClientResponse, action: str) -> Dict[str, Any]: + raw = await response.text() + try: + payload = json.loads(raw) + except json.JSONDecodeError: + raise RuntimeError(f"MiniMax H3 {action}返回了无效 JSON。") from None + if not isinstance(payload, dict): + raise RuntimeError(f"MiniMax H3 {action}响应不是 JSON 对象。") + return payload + + async def submit_async( + self, + body: Dict[str, Any], + session: aiohttp.ClientSession, + ) -> str: + url = f"{self.base_url}{self.CREATE_ENDPOINT}" + check_interrupt() + response = await run_with_interrupt( + async_request_with_retry( + session, + "POST", + url, + json=body, + headers=self._headers(), + prefix="MiniMax H3 创建任务:", + ) + ) + payload = await self._read_json(response, "创建任务") + return extract_public_task_id(payload) + + async def poll_async( + self, + task_id: str, + session: aiohttp.ClientSession, + on_progress: Optional[Callable[[int], None]] = None, + ) -> str: + encoded_task_id = quote(task_id, safe="") + url = f"{self.base_url}{self.STATUS_ENDPOINT.format(task_id=encoded_task_id)}" + deadline = PollDeadline( + seconds=self.POLL_DEADLINE_SECONDS, + label=f"MiniMax H3(任务 {task_id})", + ) + + while True: + deadline.check() + check_interrupt() + response = await run_with_interrupt( + async_request_with_retry( + session, + "GET", + url, + headers=self._headers(), + prefix="MiniMax H3 查询任务:", + ) + ) + payload = await self._read_json(response, "查询任务") + snapshot = parse_task_snapshot(payload) + status = snapshot["status"] + progress = snapshot["progress"] + + # A successful terminal state is authoritative even if an older + # gateway omits data.progress or returns a stale percentage. + if status in SUCCESS_STATUSES: + progress = 100 + + print(f"[MiniMax H3] 任务 {task_id}:{status or 'UNKNOWN'} {progress}%") + if on_progress: + on_progress(progress) + + if status in SUCCESS_STATUSES: + result_url = snapshot["result_url"] + if not result_url: + raise RuntimeError( + f"MiniMax H3 任务 {task_id} 已成功,但响应缺少 data.result_url。" + ) + return result_url + + if status in FAILURE_STATUSES: + raise RuntimeError( + f"MiniMax H3 任务 {task_id} 生成失败:{snapshot['fail_reason']}" + ) + + if status not in PENDING_STATUSES: + raise RuntimeError( + f"MiniMax H3 任务 {task_id} 返回不支持的状态 {status or '<空>'}。" + ) + + await interruptible_sleep(self.POLL_INTERVAL_SECONDS) + + async def generate_async( + self, + body: Dict[str, Any], + save_path: str, + on_stage: Optional[Callable[[str], None]] = None, + on_progress: Optional[Callable[[int], None]] = None, + ) -> tuple[str, str]: + connector = aiohttp.TCPConnector(force_close=True) + async with aiohttp.ClientSession(connector=connector) as session: + if on_stage: + on_stage("submitting") + task_id = await self.submit_async(body, session) + print(f"[MiniMax H3] 已提交公开任务 ID:{task_id}") + if on_stage: + on_stage(f"submitted:{task_id}") + + result_url = await self.poll_async( + task_id, + session, + on_progress=on_progress, + ) + + if on_stage: + on_stage("downloading") + await download_video_to_file( + session, + result_url, + save_path, + label=f"MiniMax H3 {task_id}", + ) + if on_stage: + on_stage("done") + return save_path, task_id diff --git a/clients/newapi_veo_client.py b/clients/newapi_veo_client.py index 5dc4b02..a493f3d 100644 --- a/clients/newapi_veo_client.py +++ b/clients/newapi_veo_client.py @@ -16,6 +16,10 @@ import aiohttp from .base_client import BaseAPIClient from ..utils.config import get_api_base_url, get_api_key_or_raise +from ..utils.video_task import ( + POLL_DEADLINE_SECONDS as VIDEO_POLL_DEADLINE_SECONDS, + download_video_to_file, +) class NewAPIVeoClient(BaseAPIClient): @@ -26,6 +30,7 @@ class NewAPIVeoClient(BaseAPIClient): RETRYABLE_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504} COMPLETED_STATUSES = {"completed", "succeeded", "success", "done"} FAILED_STATUSES = {"failed", "error", "cancelled", "canceled"} + POLL_DEADLINE_SECONDS = VIDEO_POLL_DEADLINE_SECONDS def __init__( self, @@ -318,7 +323,7 @@ class NewAPIVeoClient(BaseAPIClient): self, task_id: str, poll_interval: int = 5, - timeout: int = 900, + timeout: int = VIDEO_POLL_DEADLINE_SECONDS, progress_callback: Optional[Callable[[int, str, float], None]] = None, session: Optional[aiohttp.ClientSession] = None, ) -> Dict[str, Any]: @@ -376,30 +381,8 @@ class NewAPIVeoClient(BaseAPIClient): session: aiohttp.ClientSession, max_retries: int = 3, ) -> None: - timeout = aiohttp.ClientTimeout(total=900, connect=30, sock_read=900) - last_status = 0 - last_error = "" - - for attempt in range(max_retries + 1): - async with session.get(url, timeout=timeout, allow_redirects=True) as response: - if response.status < 300: - os.makedirs(os.path.dirname(save_path), exist_ok=True) - with open(save_path, "wb") as f: - async for chunk in response.content.iter_chunked(1024 * 1024): - if chunk: - f.write(chunk) - return - - last_status = response.status - last_error = await response.text() - if response.status not in self.RETRYABLE_STATUS_CODES or attempt >= max_retries: - break - - await asyncio.sleep(min(2 ** attempt, 8)) - - raise RuntimeError( - self._format_http_error("download_url", last_status, last_error) - ) + # 抗超时 / 断点续传 / 无限重试 / 可取消 + await download_video_to_file(session, url, save_path, label="VEO 视频") async def download_video_async( self, @@ -410,7 +393,7 @@ class NewAPIVeoClient(BaseAPIClient): endpoint = self.CONTENT_ENDPOINT.format(task_id=task_id) url = f"{self.base_url}{endpoint}" headers = self.get_headers(use_bearer_token=True) - timeout = aiohttp.ClientTimeout(total=900, connect=30, sock_read=900) + timeout = aiohttp.ClientTimeout(total=120, connect=30, sock_read=120) close_session = False if session is None: @@ -420,6 +403,7 @@ class NewAPIVeoClient(BaseAPIClient): try: last_status = 0 last_error = "" + # 先探测 content 端点:JSON 则取真实下载链接,否则视为视频流交给健壮下载器。 for attempt in range(4): async with session.get(url, headers=headers, timeout=timeout, allow_redirects=True) as response: if response.status < 300: @@ -440,30 +424,21 @@ class NewAPIVeoClient(BaseAPIClient): f"task_id: {task_id}" ) await self._download_url_to_file(download_url, save_path, session) - else: - os.makedirs(os.path.dirname(save_path), exist_ok=True) - with open(save_path, "wb") as f: - async for chunk in response.content.iter_chunked(1024 * 1024): - if chunk: - f.write(chunk) - - if not os.path.isfile(save_path) or os.path.getsize(save_path) <= 0: - raise RuntimeError( - "视频下载失败: 保存后的文件为空。\n" - f"endpoint: {endpoint}\n" - f"task_id: {task_id}" - ) - return save_path + return save_path + break # 非 JSON:content 端点即视频流(幂等 GET,可续传) last_status = response.status last_error = await response.text() if response.status not in self.RETRYABLE_STATUS_CODES or attempt >= 3: - break + raise RuntimeError( + self._format_http_error(endpoint, last_status, last_error, task_id=task_id) + ) await asyncio.sleep(min(2 ** attempt, 8)) - raise RuntimeError( - self._format_http_error(endpoint, last_status, last_error, task_id=task_id) + # 抗超时 / 断点续传 / 无限重试 / 可取消 + return await download_video_to_file( + session, url, save_path, headers=headers, label="VEO 视频", ) finally: if close_session: @@ -481,7 +456,7 @@ class NewAPIVeoClient(BaseAPIClient): generate_audio: bool = True, image_bytes: Optional[bytes] = None, poll_interval: int = 5, - timeout: int = 900, + timeout: int = VIDEO_POLL_DEADLINE_SECONDS, reuse_task_id: str = "", progress_callback: Optional[Callable[[int, str, float], None]] = None, ) -> Dict[str, Any]: diff --git a/clients/omni_flash_client.py b/clients/omni_flash_client.py new file mode 100644 index 0000000..747e076 --- /dev/null +++ b/clients/omni_flash_client.py @@ -0,0 +1,358 @@ +"""O1Key Omni Flash JSON video tasks.""" + +from __future__ import annotations + +import asyncio +import json +import re +import time +from typing import Any, Callable + +import aiohttp + +from ..utils.config import get_api_key_or_raise, get_base_url_by_route +from ..utils.video_task import ( + InterruptProcessingException, check_interrupt, download_video_to_file, + extract_error_message, extract_progress, extract_video_url, + interruptible_sleep, is_failure_status, is_success_status, +) + + +MODELS = {"omni_flash_8s", "omni_flash_10s", "omni_flash_abra_edit"} +RESOLUTIONS = {"720p", "1080p"} +RATIOS = {"16:9", "9:16"} +POLL_SECONDS = 7 +POLL_DEADLINE_SECONDS = 2000 +ERROR_HINTS = { + "invalid_request": "请求参数有误,请检查模型、分辨率、宽高比和素材", + "model_not_available": "当前模型不可用,请重新选择模型", + "image_url_required_for_i2v": "参考图地址缺失或无效,请连接图片后重试", + "invalid_api_key": "O1Key 令牌无效或已停用,请在令牌管理中更新", + "insufficient_balance": "O1Key 余额不足", + "task_not_found": "视频任务不存在或已失效", + "rate_limit_exceeded": "请求过于频繁,请稍后重试", +} +HTTP_HINTS = { + 400: "请求参数错误", 401: "令牌验证失败", 402: "余额不足", + 404: "任务不存在", 429: "请求过于频繁", +} +SENSITIVE_RESPONSE_KEYS = { + "authorization", "api_key", "apikey", "access_token", "refresh_token", + "token", "secret", "password", "b64_json", "base64", "image_base64", + "video_base64", +} +MAX_LOG_BODY = 16000 + + +def build_video_body( + *, model: str, prompt: str, resolution: str, aspect_ratio: str, + mode: str, references: list[str] | None = None, source_video_url: str = "", +) -> dict[str, Any]: + """Validate all scalar inputs before a paid request.""" + if model not in MODELS: + raise ValueError("Omni Flash 模型无效") + prompt = str(prompt or "").strip() + if not prompt: + raise ValueError("提示词不能为空") + if len(prompt) > 20000: + raise ValueError("提示词过长") + if resolution not in RESOLUTIONS or aspect_ratio not in RATIOS: + raise ValueError("分辨率或宽高比无效") + references = list(references or []) + if any(not isinstance(value, str) or len(value) > 4096 or not value.startswith(("https://", "http://")) for value in references): + raise ValueError("参考图必须是 HTTP(S) 直链") + body: dict[str, Any] = { + "model": model, "prompt": prompt, + "resolution": resolution, "aspect_ratio": aspect_ratio, + } + if mode == "edit": + if model != "omni_flash_abra_edit" or len(source_video_url) > 4096 or not source_video_url.startswith(("https://", "http://")): + raise ValueError("视频编辑需要编辑模型和源视频直链") + if len(references) > 5: + raise ValueError("视频编辑最多支持 5 张参考图") + body["source_video_url"] = source_video_url + elif mode in {"text", "reference", "first_last_frame"}: + if model == "omni_flash_abra_edit" or source_video_url: + raise ValueError("生成模式不能使用编辑模型或源视频") + if mode == "text" and references: + raise ValueError("文生视频不能提供参考图") + if mode == "reference" and not references: + raise ValueError("参考图模式至少需要 1 张图片") + if mode == "first_last_frame": + if not 1 <= len(references) <= 2: + raise ValueError("首尾帧模式需要首帧图片,尾帧图片可选") + # The provider's frame-pair flag is for a transition between two + # frames. A lone first frame uses the documented single-image i2v + # request, avoiding a pair request with a missing end frame. + if len(references) == 2: + body["first_last_frame"] = True + else: + raise ValueError("Omni Flash 生成模式无效") + if references: + body["input_reference"] = references[0] if len(references) == 1 else references + return body + + +def _submission_payload(body: dict[str, Any]) -> dict[str, Any]: + """Use a scalar JSON reference, or repeat the field in multipart for several.""" + references = body.get("input_reference") + if not isinstance(references, list): + return {"json": body} + form = aiohttp.FormData() + for name, value in body.items(): + values = value if name == "input_reference" else [value] + for item in values: + text = "true" if item is True else "false" if item is False else str(item) + form.add_field(name, text, content_type="text/plain") + return {"data": form} + + +def _redact_log_string(text: str) -> str: + text = re.sub(r"https?://[^\s\"'<>]+", "", text) + text = re.sub(r"(?i)bearer\s+[^\s\"']+", "Bearer <已隐藏>", text) + text = re.sub(r"(?i)(?:api[_-]?key|token|authorization)[\"']?\s*[:=]\s*[\"']?[^\s,;\"']+", "<凭据已隐藏>", text) + text = re.sub(r"(?i)data:[^,\s]+;base64,[A-Za-z0-9+/=]+", "", text) + return re.sub(r"[A-Za-z0-9+/]{256,}={0,2}", "<长数据已隐藏>", text) + + +def _safe_error(value: Any) -> str: + text = _redact_log_string(str(value or "请求失败")) + return text[:400] + + +def _log_value(value: Any, key: str = "", depth: int = 0) -> Any: + if key.lower() in SENSITIVE_RESPONSE_KEYS: + return "<已隐藏>" + if depth >= 12: + return "<嵌套内容已省略>" + if isinstance(value, dict): + return { + _redact_log_string(str(name)[:200]): _log_value(item, str(name), depth + 1) + for name, item in value.items() + } + if isinstance(value, list): + return [_log_value(item, key, depth + 1) for item in value[:50]] + ( + [f"<其余 {len(value) - 50} 项已省略>"] if len(value) > 50 else [] + ) + if isinstance(value, str): + if len(value) > 1200: + return f"<长文本 {len(value)} 字符已省略>" + return _redact_log_string(value) + return value + + +def _log_response_body(stage: str, status: int, raw_body: str) -> None: + try: + payload = json.loads(raw_body) + except (ValueError, TypeError): + safe_body = _safe_error(raw_body) if raw_body else "<空响应体>" + else: + safe_body = json.dumps(_log_value(payload), ensure_ascii=False, separators=(",", ":")) + if len(safe_body) > MAX_LOG_BODY: + safe_body = f"{safe_body[:MAX_LOG_BODY]}...<后续内容已省略>" + print(f"[Omni Flash] {stage} HTTP {status} 原始响应体(敏感值已隐藏):{safe_body}") + + +async def _response_text(response: aiohttp.ClientResponse, stage: str) -> str: + raw_body = await response.text() + _log_response_body(stage, response.status, raw_body) + return raw_body + + +def _error_code(payload: Any) -> str: + if not isinstance(payload, dict): + return "" + data = payload.get("data") + inner = data.get("data") if isinstance(data, dict) else None + for source in (inner, data, payload): + if not isinstance(source, dict): + continue + error = source.get("error") + for value in (error.get("code") if isinstance(error, dict) else None, source.get("code")): + if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_-]{1,80}", value): + return value.lower() + return "" + + +def _response_error(payload: Any, status: int) -> str: + detail = extract_error_message(payload, default="") if isinstance(payload, dict) else payload + code = _error_code(payload) + hint = ERROR_HINTS.get(code) or HTTP_HINTS.get(status, "视频接口请求失败") + detail = _safe_error(detail) if detail else "" + if detail == code or detail == hint: + detail = "" + suffix = f":{detail}" if detail else "" + code_note = f",{code}" if code else "" + return f"{hint}(HTTP {status}{code_note}){suffix}" + + +def _task_error(payload: dict[str, Any]) -> str: + code = _error_code(payload) + hint = ERROR_HINTS.get(code, "视频任务生成失败") + detail = extract_error_message(payload, default="") + detail = _safe_error(detail) if detail else "" + if detail == code or detail == hint: + detail = "" + code_note = f"({code})" if code else "" + return f"{hint}{code_note}{f':{detail}' if detail else ''}" + + +def _task_id(payload: Any) -> str | None: + if not isinstance(payload, dict): + return None + data = payload.get("data") + inner = data.get("data") if isinstance(data, dict) else None + for source in (inner, data, payload): + if isinstance(source, dict): + for name in ("id", "task_id", "video_id"): + value = source.get(name) + if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_-]{8,128}", value): + return value + return None + + +def _task_status(payload: dict[str, Any]) -> str: + data = payload.get("data") + inner = data.get("data") if isinstance(data, dict) else None + for source in (inner, data, payload): + if isinstance(source, dict): + for name in ("task_status", "task_state", "status", "state"): + value = source.get(name) + if value is not None and str(value).strip(): + return str(value).strip().lower() + return "" + + +def _video_url(payload: Any) -> str | None: + value = extract_video_url(payload) if isinstance(payload, dict) else None + if not value and isinstance(payload, dict): + data = payload.get("data") + for source in (data, payload): + if isinstance(source, dict): + output = source.get("output") + if isinstance(output, dict): + value = output.get("video_url") or output.get("url") + if value: + break + return value if isinstance(value, str) and len(value) <= 8192 and value.startswith(("https://", "http://")) else None + + +class OmniFlashClient: + def __init__(self, *, base_url: str | None = None, api_key: str | None = None): + self.base_url = (base_url or get_base_url_by_route()).rstrip("/") + self.api_key = api_key or get_api_key_or_raise("O1KEY_API_KEY") + + async def _download_completed( + self, session: aiohttp.ClientSession, task_id: str, save_path: str, + headers: dict[str, str], status_payload: dict[str, Any], + ) -> None: + content_url = f"{self.base_url}/v1/videos/{task_id}/content" + result_url = _video_url(status_payload) + download_url, download_headers = content_url, headers + try: + async with session.get(content_url, headers=headers, allow_redirects=True) as response: + if response.status >= 300: + raw_body = await _response_text(response, "下载") + if not result_url: + try: + error = json.loads(raw_body) + except ValueError: + error = raw_body + raise RuntimeError(_response_error(error, response.status)) + download_url, download_headers = result_url, {} + elif "json" in response.headers.get("Content-Type", "").lower(): + raw_body = await _response_text(response, "下载") + try: + content_payload = json.loads(raw_body) + except ValueError: + raise RuntimeError("视频下载接口返回了无效 JSON") from None + if _error_code(content_payload) in ERROR_HINTS: + raise RuntimeError(_task_error(content_payload)) + download_url = _video_url(content_payload) or result_url + if not download_url: + raise RuntimeError("任务已完成,但下载响应未提供视频地址") + download_headers = {} + else: + print(f"[Omni Flash] 下载 HTTP {response.status} 响应体:<视频二进制,未打印>") + except (aiohttp.ClientError, asyncio.TimeoutError): + # The streaming downloader handles transient connection failures and resumes. + if result_url: + download_url, download_headers = result_url, {} + await download_video_to_file( + session, download_url, save_path, headers=download_headers or None, + label="Omni Flash 视频", + ) + + async def generate( + self, body: dict[str, Any], save_path: str, + progress: Callable[[str, int, str], None] | None = None, + ) -> str: + headers = {"Authorization": f"Bearer {self.api_key}"} + submit_headers = dict(headers) + if body.get("model") == "omni_flash_abra_edit": + submit_headers["X-No-Watermark"] = "video" + timeout = aiohttp.ClientTimeout(total=120, connect=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{self.base_url}/v1/videos", headers=submit_headers, + **_submission_payload(body), + ) as response: + raw_body = await _response_text(response, "提交") + try: + payload = json.loads(raw_body) + except ValueError: + if response.status >= 300: + raise RuntimeError(_response_error(raw_body, response.status)) from None + raise RuntimeError("提交接口未返回有效 JSON") from None + if response.status >= 300: + raise RuntimeError(_response_error(payload, response.status)) + if _error_code(payload) in ERROR_HINTS: + raise RuntimeError(_task_error(payload)) + task_id = _task_id(payload) + if not task_id: + raise RuntimeError("接口未返回有效任务 ID") + if progress: + progress("polling", 0, task_id) + deadline = time.monotonic() + POLL_DEADLINE_SECONDS + last_status = "" + while time.monotonic() < deadline: + await interruptible_sleep(POLL_SECONDS) + check_interrupt() + try: + async with session.get(f"{self.base_url}/v1/videos/{task_id}", headers=headers) as response: + raw_body = await _response_text(response, "查询") + if response.status in {408, 500, 502, 503, 504}: + continue + try: + status_payload = json.loads(raw_body) + except ValueError: + if response.status >= 300: + raise RuntimeError(_response_error(raw_body, response.status)) from None + raise + if response.status >= 300: + raise RuntimeError(_response_error(status_payload, response.status)) + except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, ValueError): + continue + if not isinstance(status_payload, dict): + continue + status = _task_status(status_payload) + last_status = status or last_status + code = _error_code(status_payload) + if is_failure_status(status) or code in ERROR_HINTS or ( + not status and code and code not in {"ok", "success", "0"} + ): + raise RuntimeError(_task_error(status_payload)) + if is_success_status(status) or _video_url(status_payload): + if progress: + progress("downloading", 100, task_id) + try: + await self._download_completed(session, task_id, save_path, headers, status_payload) + except InterruptProcessingException: + raise + except Exception as exc: + raise RuntimeError(_safe_error(exc)) from None + return task_id + if progress: + progress("polling", extract_progress(status_payload), task_id) + status_note = f",最后状态:{_safe_error(last_status)}" if last_status else "" + raise TimeoutError(f"Omni Flash 任务 {task_id} 等待超时{status_note}") diff --git a/clients/openai_client.py b/clients/openai_client.py deleted file mode 100644 index 5815e9e..0000000 --- a/clients/openai_client.py +++ /dev/null @@ -1,784 +0,0 @@ -""" -OpenAI 兼容 API 客户端 -端点固定为 /v1/chat/completions,模型名放入请求体 model 字段 -""" - -import re -import time -from io import BytesIO -from typing import Any, Callable, Dict, List, Optional - -import aiohttp -from PIL import Image - -from ..utils.image_utils import encode_image_to_base64, decode_base64_to_pil -from ..utils.config import get_api_key_or_raise, get_api_base_url -from .base_client import BaseAPIClient - - -# 固定端点 -_ENDPOINT = "/v1/chat/completions" - - -class OpenAIAPIClient(BaseAPIClient): - """ - OpenAI 兼容格式的图像生成客户端 - - 与 GeminiAPIClient 的主要区别: - - 端点固定为 /v1/chat/completions(不再动态拼模型名到 URL) - - 解析后的模型字符串放入请求体的 model 字段 - - 请求体采用 messages 数组格式,图片以 data URI 内联 - - 顶层追加 modalities 和 image_config 字段 - - 响应解析对应 choices[0].message.content 结构 - """ - - def __init__(self, api_key: Optional[str] = None): - if api_key is None: - api_key = get_api_key_or_raise("O1KEY_API_KEY") - - super().__init__( - base_url=get_api_base_url(), - api_key=api_key, - max_request_size=100 * 1024 * 1024 - ) - - # ------------------------------------------------------------------ # - # 模型名解析 # - # 原 GeminiAPIClient.get_endpoint() 里动态拼 URL 的逻辑 # - # 现在改为:同样的输入 → 返回纯模型名字符串,放进请求体 # - # ------------------------------------------------------------------ # - - def resolve_model_name(self, model: str, resolution: str) -> str: - """ - 将「节点选中的模型 ID + 分辨率」解析为实际请求所用的模型名称。 - - 对应关系与原 GeminiAPIClient.get_endpoint() 完全一致, - 只是把拼在 URL 路径里的模型段提取出来单独返回。 - - Args: - model: 节点下拉框中的模型 ID,如 "nano-banana-pro-次卡" - resolution: 分辨率字符串,如 "1K" / "2K" / "4K" / "512" - - Returns: - 实际模型名,如 "nano-banana-pro-2k" - """ - # ── 动态端点模型 ────────────────────────────────────────────────── - if model == "nano-banana-pro-次卡": - if resolution == "1K": - return "nano-banana-pro" - elif resolution == "4K": - return "nano-banana-pro-4k" - else: # 2K(默认) - return "nano-banana-pro-2k" - - elif model == "nano-banana-pro-官方计费": - if resolution == "1K": - return "nano-banana-pro-1k-official" - elif resolution == "4K": - return "nano-banana-pro-4k-official" - else: # 2K(默认) - return "nano-banana-pro-2k-official" - - elif model == "nano-banana-2-官方计费": - if resolution == "512": - return "nano-banana-2-0.5k-official" - elif resolution == "1K": - return "nano-banana-2-1k-official" - elif resolution == "4K": - return "nano-banana-2-4k-official" - else: # 2K(默认) - return "nano-banana-2-2k-official" - - elif model == "gemini-3-pro-image-preview-url": - if resolution == "1K": - return "gemini-3-pro-image-preview-url" - elif resolution == "4K": - return "gemini-3-pro-image-preview-4k-url" - else: # 2K(默认) - return "gemini-3-pro-image-preview-2k-url" - - # ── 固定端点模型:从 models_config 里取端点,提取模型名段 ────────── - from ..models_config import get_model_endpoint - endpoint = get_model_endpoint(model) - if endpoint: - # 端点格式:/v1beta/models/:generateContent - # 提取 部分 - match = re.search(r"/models/([^:]+):", endpoint) - if match: - return match.group(1) - - # ── 兜底:直接用 model ID ────────────────────────────────────────── - return model - - # ------------------------------------------------------------------ # - # BaseAPIClient 抽象方法实现 # - # ------------------------------------------------------------------ # - - def get_endpoint(self, **kwargs) -> str: - """固定返回 /v1/chat/completions,模型信息已移入请求体。""" - return _ENDPOINT - - def build_request_body( - self, - prompt: str = "", - images: Optional[List[Image.Image]] = None, - aspect_ratio: str = "1:1", - resolution: str = "2K", - model: str = "", - **kwargs - ) -> Dict[str, Any]: - """ - 构建 OpenAI /v1/chat/completions 格式请求体。 - - 文生图示例输出: - { - "model": "nano-banana-pro-2k", - "messages": [ - { - "role": "user", - "content": [ - {"type": "text", "text": "一个中国女子的OOTD"} - ] - } - ], - "modalities": ["image", "text"], - "stream": false, - "extra_body": { - "google": { - "image_config": { - "aspect_ratio": "16:9", - "image_size": "2K" - } - } - } - } - - 图生图时 content 数组追加若干 image_url 块: - { - "type": "image_url", - "image_url": {"url": "data:image/png;base64,<...>"} - } - - Args: - prompt: 提示词 - images: 参考图列表(可选,图生图时传入) - aspect_ratio: 宽高比,如 "16:9" - resolution: 分辨率,如 "2K" - model: 已解析好的模型名(由 resolve_model_name 返回) - """ - # ── 构建 content 数组 ───────────────────────────────────────────── - content: List[Dict[str, Any]] = [] - - # 1. 文本部分(始终在最前) - content.append({ - "type": "text", - "text": prompt - }) - - # 2. 图片部分(图生图时追加,每张图一个 image_url block) - if images: - for img in images: - b64 = encode_image_to_base64(img) - content.append({ - "type": "image_url", - "image_url": { - "url": f"data:image/png;base64,{b64}" - } - }) - - # ── 分辨率映射(节点内部值 → API 所需值) ──────────────────────────── - _resolution_map = {"512": "0.5K", "1K": "1K", "2K": "2K", "4K": "4K"} - api_image_size = _resolution_map.get(resolution, resolution) - - # ── 组装完整请求体 ───────────────────────────────────────────────── - request_body: Dict[str, Any] = { - "model": model, - "messages": [ - { - "role": "user", - "content": content - } - ], - "modalities": ["image", "text"], - "stream": False, - "extra_body": { - "google": { - "image_config": { - "image_size": api_image_size - } - } - } - } - - if aspect_ratio and aspect_ratio != "智能": - request_body["extra_body"]["google"]["image_config"]["aspect_ratio"] = aspect_ratio - - return request_body - - def parse_response(self, response: Dict[str, Any]) -> List[Image.Image]: - """同步 parse_response,仅为满足抽象基类要求,实际不应被直接调用。""" - raise RuntimeError( - "parse_response() 不应被直接调用。" - "请使用 generate_single_async() 等高级方法。" - ) - - def get_http_error_message(self, status_code: int, error_message: str) -> Optional[str]: - """429 / 503 友好文案。""" - if status_code == 429: - return ( - "莫慌!该模型暂时超出速率限制啦\n" - "解决方案如下(任意一种):\n" - "1.切换当前模型\n" - "2.前往后台,修改令牌分组" - ) - if status_code == 503: - return ( - "警报!服务器当前过载!\n" - "解决方案如下:\n" - "1.摸会儿鱼吧,稍后会恢复,嘿嘿~\n" - "2.切换其他模型\n" - "3.前往后台,修改令牌分组" - ) - return None - - # ------------------------------------------------------------------ # - # 响应解析 # - # ------------------------------------------------------------------ # - - async def parse_response_async( - self, - response: Dict[str, Any], - session: Optional[aiohttp.ClientSession] = None - ) -> tuple[List[Image.Image], Dict[str, Any]]: - """ - 异步解析 /v1/chat/completions 格式响应,提取生成的图像。 - - 响应结构(OpenAI 格式): - { - "choices": [ - { - "message": { - "role": "assistant", - "content": [ - {"type": "text", "text": "..."}, - {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}} - // 或直接 inline_data / inlineData(兼容 Gemini 风格回包) - ] - }, - "finish_reason": "stop" - } - ], - "usage": {...} - } - """ - format_info: Dict[str, Any] = { - "type": None, # "base64" | "url" - "size": 0, - "resolution": None, - "download_speed": None - } - - # ── 错误前置检测 ─────────────────────────────────────────────────── - - # 1. usage.completion_tokens == 0 → 风控拦截(对齐 Gemini 的 candidatesTokenCount==0) - usage = response.get("usage", {}) - completion_tokens = usage.get("completion_tokens", -1) - if completion_tokens == 0: - raise RuntimeError( - "Damn!你触发顶级风控啦!还没到生图阶段就被拒了。\n" - "赶紧调整一下图片或提示词吧!该情况不会返回图片且正常扣费!下次小心哦~" - ) - - # 2. finish_reason 不是 "stop" → 安全过滤 / token 超限等 - choices = response.get("choices", []) - if choices: - for choice in choices: - finish_reason = choice.get("finish_reason", "") - if finish_reason and finish_reason != "stop": - raise RuntimeError( - "Ohh no! 生图过程触发风控,图片被拒绝生成!\n" - "可能原因如下:\n" - "1.违禁内容\n" - "2.触发安全过滤器\n" - "3.涉及版权问题\n" - "4. Token超限\n" - "赶紧调整一下图片或提示词吧!该情况不会返回图片且正常扣费!下次小心哦~" - ) - - # ── 图像提取 ─────────────────────────────────────────────────────── - images: List[Image.Image] = [] - text_responses: List[str] = [] - - close_session = False - if session is None: - session = self._make_session() - close_session = True - - try: - for choice in choices: - message = choice.get("message", {}) - - # ── 优先从 message.images 提取(非标准扩展字段) ────────────── - # 部分服务端把图片放在独立的 images 字段,content 同时为 null - msg_images = message.get("images") or [] - for img_part in msg_images: - part_type = img_part.get("type", "") - if part_type == "image_url": - url_obj = img_part.get("image_url", {}) - url = url_obj.get("url", "") - if url.startswith("data:"): - try: - _, b64_data = url.split(",", 1) - img = decode_base64_to_pil(b64_data) - images.append(img) - if format_info["type"] is None: - format_info["type"] = "base64" - format_info["size"] = len(b64_data) * 3 / 4 - format_info["resolution"] = f"{img.size[0]}x{img.size[1]}" - except Exception: - pass - elif url.startswith("http"): - try: - dl_start = time.time() - async with session.get(url) as img_resp: - if img_resp.status == 200: - img_data = await img_resp.read() - dl_time = time.time() - dl_start - speed = len(img_data) / dl_time if dl_time > 0 else 0 - img = Image.open(BytesIO(img_data)) - images.append(img) - if format_info["type"] is None: - format_info["type"] = "url" - format_info["size"] = len(img_data) - format_info["resolution"] = f"{img.size[0]}x{img.size[1]}" - format_info["download_speed"] = speed - except Exception: - pass - - # ── 再从 message.content 提取(标准 OpenAI 格式) ───────────── - # content 为 null 时用空列表兜底,避免 for in None 崩溃 - raw_content = message.get("content") or [] - - # content 可能是字符串(纯文本)或数组(多模态) - if isinstance(raw_content, str): - text_responses.append(raw_content) - continue - - for part in raw_content: - part_type = part.get("type", "") - - # ── 情况 A:OpenAI image_url 格式 ───────────────────── - if part_type == "image_url": - url_obj = part.get("image_url", {}) - url = url_obj.get("url", "") - - if url.startswith("data:"): - # data URI → 直接 base64 解码 - # 格式:data:image/png;base64, - try: - header, b64_data = url.split(",", 1) - img = decode_base64_to_pil(b64_data) - images.append(img) - if format_info["type"] is None: - format_info["type"] = "base64" - format_info["size"] = len(b64_data) * 3 / 4 - format_info["resolution"] = f"{img.size[0]}x{img.size[1]}" - except Exception: - pass - - elif url.startswith("http"): - # 远程 URL → 异步下载 - try: - dl_start = time.time() - async with session.get(url) as img_resp: - if img_resp.status == 200: - img_data = await img_resp.read() - dl_time = time.time() - dl_start - speed = len(img_data) / dl_time if dl_time > 0 else 0 - img = Image.open(BytesIO(img_data)) - images.append(img) - if format_info["type"] is None: - format_info["type"] = "url" - format_info["size"] = len(img_data) - format_info["resolution"] = f"{img.size[0]}x{img.size[1]}" - format_info["download_speed"] = speed - except Exception: - pass - - # ── 情况 B:Gemini 风格 inline_data / inlineData(兼容) ─ - elif part_type in ("inline_data", "inlineData") or \ - "inline_data" in part or "inlineData" in part: - inline_key = "inline_data" if "inline_data" in part else "inlineData" - inline = part.get(inline_key, {}) - b64_data = inline.get("data", "") - if b64_data: - try: - img = decode_base64_to_pil(b64_data) - images.append(img) - if format_info["type"] is None: - format_info["type"] = "base64" - format_info["size"] = len(b64_data) * 3 / 4 - format_info["resolution"] = f"{img.size[0]}x{img.size[1]}" - except Exception: - pass - - # ── 情况 C:text 中嵌套 URL(markdown 或纯链接) ───────── - elif part_type == "text": - text = part.get("text", "") - text_responses.append(text) - - # markdown 图片链接:![alt](url) - urls = re.findall(r'!\[.*?\]\((https?://[^\)]+)\)', text) - if not urls: - urls = re.findall(r'https?://[^\s<>"{}|\\^`\[\]]+', text) - - for url in urls: - try: - dl_start = time.time() - async with session.get(url) as img_resp: - if img_resp.status == 200: - img_data = await img_resp.read() - dl_time = time.time() - dl_start - speed = len(img_data) / dl_time if dl_time > 0 else 0 - img = Image.open(BytesIO(img_data)) - images.append(img) - if format_info["type"] is None: - format_info["type"] = "url" - format_info["size"] = len(img_data) - format_info["resolution"] = f"{img.size[0]}x{img.size[1]}" - format_info["download_speed"] = speed - except Exception: - pass - - except RuntimeError: - raise - except Exception as e: - raise RuntimeError(f"解析 API 响应失败: {str(e)}") - finally: - if close_session: - await session.close() - - # ── 3. 无图像但有文本 → API 拒绝说明 ───────────────────────────── - if not images and text_responses: - combined = "\n".join(text_responses) - raise RuntimeError( - f"API 拒绝响应\n\n" - f"API 返回说明:\n{combined}\n\n" - f"建议:\n" - f" - 根据上述说明调整请求内容\n" - f" - 确保提示词和参考图符合使用规范" - ) - - if not images: - raise RuntimeError("API 响应中未找到生成的图像") - - return images, format_info - - # ------------------------------------------------------------------ # - # 核心生成方法(接口与 GeminiAPIClient 保持一致,节点可无缝切换) # - # ------------------------------------------------------------------ # - - async def generate_single_async( - self, - prompt: str, - model: str, - resolution: str, - aspect_ratio: str, - images: Optional[List[Image.Image]] = None, - session: Optional[aiohttp.ClientSession] = None, - task_index: Optional[int] = None, - total_tasks: Optional[int] = None, - debug: bool = False, - debug_request: bool = False, - enable_grounding: bool = False, # 保留签名兼容,OpenAI 格式暂不使用 - enable_image_search: bool = False # 保留签名兼容,OpenAI 格式暂不使用 - ) -> tuple[List[Image.Image], Dict[str, Any]]: - """ - 单次异步生成请求(OpenAI /v1/chat/completions 格式)。 - - Args: - prompt: 提示词 - model: 节点选中的模型 ID(将自动解析为实际模型名) - resolution: 分辨率 - aspect_ratio: 宽高比 - images: 参考图列表(图生图时传入) - session: 复用的 aiohttp 会话 - task_index: 任务序号(批量时用于日志) - total_tasks: 总任务数(批量时用于日志) - debug: 打印完整 API 响应 - debug_request: 打印请求体(base64 自动截断) - - Returns: - (生成的图像列表, 计时信息字典) - """ - import json - - total_start = time.time() - task_prefix = f"[{task_index}/{total_tasks}]" if task_index is not None and total_tasks else "" - - # ── 1. 解析模型名 & 构建请求体 ──────────────────────────────────── - build_start = time.time() - resolved_model = self.resolve_model_name(model, resolution) - endpoint = self.get_endpoint() - - request_body = self.build_request_body( - prompt=prompt, - images=images, - aspect_ratio=aspect_ratio, - resolution=resolution, - model=resolved_model - ) - build_time = time.time() - build_start - - # ── 调试:打印请求体 ─────────────────────────────────────────────── - if debug_request: - import json as _json - def _shorten_b64(obj): - if isinstance(obj, dict): - return {k: _shorten_b64(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_shorten_b64(i) for i in obj] - if isinstance(obj, str): - if obj.startswith("data:"): - header, _, data = obj.partition(",") - return f"{header}," - if len(obj) > 200 and all( - c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" - for c in obj[:64] - ): - return f"" - return obj - print( - f"\n{'='*60}\n" - f"[请求体日志] 任务 {task_prefix or '?'}\n" - f"端点: {self.base_url}{endpoint}\n" - f"{_json.dumps(_shorten_b64(request_body), ensure_ascii=False, indent=2)}\n" - f"{'='*60}\n" - ) - - # ── 2. 计算请求体大小 ───────────────────────────────────────────── - request_size = len(json.dumps(request_body).encode("utf-8")) - size_str = ( - f"{request_size / 1024:.2f}KB" - if request_size < 1024 * 1024 - else f"{request_size / (1024 * 1024):.2f}MB" - ) - - # ── 3. 发送请求(Bearer Token 认证) ───────────────────────────── - request_start = time.time() - try: - response = await self.request_async( - endpoint, - request_body, - session, - use_bearer_token=True - ) - except Exception as e: - request_time = time.time() - request_start - error_first_line = str(e).split("\n")[0] - print(f"{task_prefix} 请求 {size_str} → API {request_time:.1f}s → 失败: {error_first_line} ✗") - raise - - request_time = time.time() - request_start - - # ── 调试:打印完整响应 ───────────────────────────────────────────── - if debug: - import json as _json - def _shorten_b64(obj): - if isinstance(obj, dict): - return {k: _shorten_b64(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_shorten_b64(i) for i in obj] - if isinstance(obj, str): - if obj.startswith("data:"): - header, _, data = obj.partition(",") - return f"{header}," - if len(obj) > 200 and all( - c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" - for c in obj[:64] - ): - return f"" - return obj - print( - f"\n{'='*60}\n" - f"[调试日志] 任务 {task_prefix or '?'} 完整 API 响应:\n" - f"{_json.dumps(_shorten_b64(response), ensure_ascii=False, indent=2)}\n" - f"{'='*60}\n" - ) - - # ── 4. 解析响应 ─────────────────────────────────────────────────── - parse_start = time.time() - try: - result_images, format_info = await self.parse_response_async(response, session) - except Exception as e: - parse_time = time.time() - parse_start - error_first_line = str(e).split("\n")[0] - print(f"{task_prefix} 请求 {size_str} → API {request_time:.1f}s → 解析失败: {error_first_line} ✗") - raise - - parse_time = time.time() - parse_start - - # ── 5. 单行日志输出 ─────────────────────────────────────────────── - img_size = format_info.get("size", 0) - img_size_str = ( - f"{img_size / 1024:.2f}KB" - if img_size < 1024 * 1024 - else f"{img_size / (1024 * 1024):.2f}MB" - ) - - if format_info.get("type") == "base64": - download_info = f"Base64 {img_size_str} ({parse_time:.1f}s)" - elif format_info.get("type") == "url": - speed = format_info.get("download_speed", 0) - download_info = f"URL {img_size_str} ({parse_time:.1f}s, {speed / (1024*1024):.1f}MB/s)" - else: - download_info = img_size_str - - timing = response.get("_timing", {}) - net_connect = timing.get("connect_time") - net_download = timing.get("download_time") - if net_connect is not None and net_download is not None: - net_str = f" | 连接 {net_connect:.2f}s | 下载 {net_download:.2f}s" - else: - net_str = "" - print(f"{task_prefix} 请求 {size_str} → API {request_time:.1f}s → {download_info} ✓{net_str}") - - total_time = time.time() - total_start - timing_info = { - "build_time": build_time, - "request_time": request_time, - "parse_time": parse_time, - "total_time": total_time, - "format_type": format_info.get("type", "unknown") - } - - return result_images, timing_info - - # ------------------------------------------------------------------ # - # 批量 & 同步接口(与 GeminiAPIClient 接口签名一致) # - # ------------------------------------------------------------------ # - - async def generate_batch_async( - self, - prompt: str, - model: str, - resolution: str, - aspect_ratio: str, - batch_size: int, - images: Optional[List[Image.Image]] = None, - progress_callback: Optional[Callable[[int, int, bool, Optional[str]], None]] = None, - debug: bool = False, - debug_request: bool = False, - enable_grounding: bool = False, - enable_image_search: bool = False - ) -> List[Image.Image]: - """批量全并发生成(单提示词 × batch_size 张)。""" - import asyncio - - all_images: List[Image.Image] = [] - completed = 0 - success_count = 0 - fail_count = 0 - first_error = None - - max_concurrent = 10 - num_batches = (batch_size + max_concurrent - 1) // max_concurrent - - print(f"OpenAIClient: 批量生成 {batch_size} 张,并发数: {max_concurrent},分 {num_batches} 批") - - connector = aiohttp.TCPConnector(ssl=False, limit=0, limit_per_host=0) - - async with aiohttp.ClientSession(connector=connector) as session: - for batch_idx in range(num_batches): - batch_start = batch_idx * max_concurrent - batch_end = min(batch_start + max_concurrent, batch_size) - batch_count = batch_end - batch_start - - if num_batches > 1: - print(f"OpenAIClient: 第 {batch_idx + 1}/{num_batches} 批 ({batch_start + 1}-{batch_end})") - - tasks = [ - asyncio.create_task( - self.generate_single_async( - prompt=prompt, - model=model, - resolution=resolution, - aspect_ratio=aspect_ratio, - images=images, - session=session, - task_index=batch_start + i + 1, - total_tasks=batch_size, - debug=debug, - debug_request=debug_request - ), - name=f"task_{batch_start + i}" - ) - for i in range(batch_count) - ] - - batch_images: List[Image.Image] = [] - - for coro in asyncio.as_completed(tasks): - completed += 1 - try: - result_imgs, _ = await coro - for img in result_imgs: - batch_images.append(img) - all_images.append(img) - success_count += 1 - if progress_callback: - progress_callback(completed, batch_size, True, None) - print(f"OpenAIClient: 任务 {completed}/{batch_size} 成功 ✓") - except Exception as e: - fail_count += 1 - if first_error is None: - first_error = e - if progress_callback: - progress_callback(completed, batch_size, False, str(e)) - print(f"OpenAIClient: 任务 {completed}/{batch_size} 失败 ✗") - - if batch_images: - print(f"OpenAIClient: 第 {batch_idx + 1} 批完成,生成 {len(batch_images)} 张") - import gc - gc.collect() - await asyncio.sleep(0.1) - - batch_images = [] - - if not all_images: - if first_error: - raise first_error - raise RuntimeError(f"批量生成失败,{fail_count} 个请求全部失败") - - print(f"OpenAIClient: 批量完成,成功 {success_count}/{batch_size},失败 {fail_count}") - return all_images - - def generate_sync( - self, - prompt: str, - model: str, - resolution: str, - aspect_ratio: str, - batch_size: int, - images: Optional[List[Image.Image]] = None, - progress_callback: Optional[Callable[[int, int, bool, Optional[str]], None]] = None, - debug: bool = False, - debug_request: bool = False, - enable_grounding: bool = False, - enable_image_search: bool = False - ) -> List[Image.Image]: - """同步生成接口(用于 ComfyUI 节点,接口与 GeminiAPIClient 完全一致)。""" - coro = self.generate_batch_async( - prompt=prompt, - model=model, - resolution=resolution, - aspect_ratio=aspect_ratio, - batch_size=batch_size, - images=images, - progress_callback=progress_callback, - debug=debug, - debug_request=debug_request, - enable_grounding=enable_grounding, - enable_image_search=enable_image_search - ) - return self.run_async_in_thread(coro) diff --git a/clients/seedance_client.py b/clients/seedance_client.py index de1ac45..f6dc753 100644 --- a/clients/seedance_client.py +++ b/clients/seedance_client.py @@ -3,17 +3,17 @@ Seedance 视频生成客户端 使用 new-api 原生格式:POST /v1/video/generations → GET /v1/video/generations/{task_id} """ -import asyncio import json -import os from typing import Any, Callable, Dict, Optional import aiohttp -from ..utils.config import get_api_key_or_raise +from ..utils.config import get_api_key_or_raise, get_base_url_by_route from ..utils.http_error import async_request_with_retry from ..utils.video_task import ( + PollDeadline, check_interrupt, + download_video_to_file, extract_error_message, extract_progress, extract_status, @@ -25,11 +25,16 @@ from ..utils.video_task import ( class SeedanceClient: - """Seedance 视频生成客户端(new-api 原生三段式)""" + """Seedance 视频生成客户端(new-api 原生三段式) - # 提交任务 + 注意:新旧格式模型(seedance-2-0-260128-d 等)共用同一套端点, + 区别仅在于请求体结构(顶层 content vs metadata.content), + 由调用方(节点层)通过 use_new_format 控制请求体拼装方式。 + """ + + # 提交任务(新旧格式模型共用) CREATE_ENDPOINT = "/v1/video/generations" - # 查询任务状态:{task_id} 占位 + # 查询任务状态:{task_id} 占位(新旧格式模型共用) STATUS_ENDPOINT = "/v1/video/generations/{task_id}" POLL_INITIAL_INTERVAL = 4 # 首次轮询等待秒数 @@ -41,7 +46,7 @@ class SeedanceClient: def __init__(self): self.api_key = get_api_key_or_raise() - self.base_url = "https://api.o1key.com" + self.base_url = get_base_url_by_route() def _headers(self) -> Dict[str, str]: return { @@ -55,9 +60,17 @@ class SeedanceClient: self, body: Dict[str, Any], session: aiohttp.ClientSession, + use_new_format: bool = False, ) -> str: - """提交视频生成任务,返回 task_id""" + """提交视频生成任务,返回 task_id + + use_new_format 仅用于调试日志标注请求体格式,不影响端点选择 + (新旧格式模型统一走 CREATE_ENDPOINT)。 + """ url = f"{self.base_url}{self.CREATE_ENDPOINT}" + + print(f"[Seedance] 提交 → {url} (body格式: {'新' if use_new_format else '旧'})") + check_interrupt() resp = await run_with_interrupt(async_request_with_retry( session, "POST", url, json=body, headers=self._headers(), prefix="Seedance 提交: " @@ -69,7 +82,7 @@ class SeedanceClient: # new-api 返回字段:id / task_id task_id = data.get("id") or data.get("task_id") if not task_id: - raise RuntimeError(f"API 未返回任务 ID,响应:{data}") + raise RuntimeError("API 未返回任务 ID") return task_id # ── 2. 轮询状态 ──────────────────────────────────────────────────── @@ -79,12 +92,15 @@ class SeedanceClient: task_id: str, session: aiohttp.ClientSession, on_progress: Optional[Callable[[int], None]] = None, + use_new_format: bool = False, ) -> str: - """轮询任务状态,成功后返回视频 URL""" + """轮询任务状态,成功后返回视频 URL(新旧格式模型统一走 STATUS_ENDPOINT)""" url = f"{self.base_url}{self.STATUS_ENDPOINT.format(task_id=task_id)}" interval = self.POLL_INITIAL_INTERVAL + deadline = PollDeadline(label="Seedance") while True: + deadline.check() check_interrupt() async with session.get(url, headers=self._headers()) as resp: text = await resp.text() @@ -123,7 +139,7 @@ class SeedanceClient: or inner.get("url") ) if not video_url: - raise RuntimeError(f"任务成功但未找到视频 URL,响应:{result}") + raise RuntimeError("任务成功但未找到视频 URL") # 末帧图片 URL 在 inner["data"]["content"]["last_frame_url"] last_frame_url = ( content.get("last_frame_url") @@ -149,16 +165,9 @@ class SeedanceClient: ) -> str: """下载视频到本地,返回本地路径""" print(f"[Seedance] 下载视频...") - check_interrupt() - async with session.get(video_url, allow_redirects=True) as resp: - if resp.status != 200: - raise RuntimeError(f"视频下载失败 ({resp.status})") - os.makedirs(os.path.dirname(save_path), exist_ok=True) - with open(save_path, "wb") as f: - async for chunk in resp.content.iter_chunked(8192): - check_interrupt() - f.write(chunk) - return save_path + return await download_video_to_file( + session, video_url, save_path, label="Seedance", + ) # ── 全流程入口(供节点调用)──────────────────────────────────────── @@ -168,6 +177,7 @@ class SeedanceClient: save_path: str, on_stage: Optional[Callable[[str], None]] = None, on_progress: Optional[Callable[[int], None]] = None, + use_new_format: bool = False, ) -> tuple: """提交 → 轮询 → 下载,返回 (本地视频路径, 末帧图片URL或None)""" connector = aiohttp.TCPConnector(ssl=False, force_close=True) @@ -177,19 +187,30 @@ class SeedanceClient: check_interrupt() if on_stage: on_stage("submitting") - task_id = await self.submit_async(body, session) + task_id = await self.submit_async(body, session, use_new_format=use_new_format) print(f"[Seedance] 任务已提交 → {task_id}") if on_stage: on_stage(f"submitted:{task_id}") # 轮询 - video_url, last_frame_url = await self.poll_async(task_id, session, on_progress=on_progress) + video_url, last_frame_url = await self.poll_async(task_id, session, on_progress=on_progress, use_new_format=use_new_format) - # 下载 + # 下载(带"Video not ready"重试) if on_stage: on_stage("downloading") - path = await self.download_async(video_url, save_path, session) - - if on_stage: - on_stage("done") - return path, last_frame_url + max_retries = 5 + retry_delay = 3.0 + for attempt in range(max_retries): + try: + path = await self.download_async(video_url, save_path, session) + if on_stage: + on_stage("done") + return path, last_frame_url + except Exception as e: + error_msg = str(e) + if "Video not ready" in error_msg and attempt < max_retries - 1: + print(f"[Seedance] 视频未就绪,{retry_delay}秒后重试 ({attempt + 1}/{max_retries})...") + await interruptible_sleep(retry_delay) + check_interrupt() + continue + raise diff --git a/clients/seedance_element_client.py b/clients/seedance_element_client.py new file mode 100644 index 0000000..4dfb917 --- /dev/null +++ b/clients/seedance_element_client.py @@ -0,0 +1,320 @@ +""" +Seedance 2.0 真人素材(Element)API 客户端 +封装标准素材接口与高并发素材接口 +""" + +import json +import aiohttp +from typing import Optional +from urllib.parse import quote + +from ..utils.config import get_api_key_or_raise, get_base_url_by_route +from ..utils.video_task import PollDeadline, check_interrupt, interruptible_sleep + + +class SeedanceElementClient: + """Seedance 2.0 真人素材客户端""" + + _ASSET_REQUEST_TYPES = {"hc", "doubao"} + + def __init__(self, base_url: str = None, api_key: str = None): + self.api_key = api_key or get_api_key_or_raise() + self.base_url = base_url or get_base_url_by_route() + + def _headers(self) -> dict: + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + def _hc_headers(self) -> dict: + return { + **self._headers(), + "Accept": "application/json", + } + + @classmethod + def _normalize_asset_request_type(cls, request_type: str) -> str: + normalized = str(request_type).strip().lower() + if normalized not in cls._ASSET_REQUEST_TYPES: + raise ValueError(f"不支持的素材请求类型:{request_type}") + return normalized + + @staticmethod + def _hc_error_message(payload: dict, default: str) -> str: + error = payload.get("error") + if isinstance(error, dict): + error = error.get("message") or error.get("detail") + data = payload.get("data") + base_resp = data.get("base_resp", {}) if isinstance(data, dict) else {} + return str( + error + or payload.get("message") + or base_resp.get("status_msg") + or default + ) + + @staticmethod + async def _read_json_response(resp: aiohttp.ClientResponse) -> dict: + text = await resp.text() + try: + payload = json.loads(text) + except json.JSONDecodeError: + raise RuntimeError("素材接口返回了无效 JSON") from None + if not isinstance(payload, dict): + raise RuntimeError("素材接口返回格式错误") + return payload + + async def create_element( + self, + name: str, + image_url: str, + description: Optional[str] = None, + channel_id: int = 0, + session: aiohttp.ClientSession = None, + ) -> dict: + """ + 创建素材 + + Args: + name: 素材名称 + image_url: 图片 URL(必须是 http/https) + description: 素材描述(可选) + channel_id: 渠道ID,0表示自动选择 + session: aiohttp会话,如果为None则创建临时会话 + + Returns: + { + "id": 123, + "name": "我的数字人", + "description": "真人形象描述", + "frontal_image": "https://xxx.jpg", + "element_id": "asset-abc123xyz", # 重要!上游Asset ID + "job_id": "group-xyz789", + "status": "succeed", + "created_at": 1719734400 + } + """ + url = f"{self.base_url}/api/element/seedance" + + body = { + "name": name, + "image_url": image_url, + "channel_id": channel_id, + } + + if description: + body["description"] = description + + should_close = session is None + if session is None: + connector = aiohttp.TCPConnector(ssl=False, force_close=True) + session = aiohttp.ClientSession(connector=connector) + + try: + async with session.post(url, json=body, headers=self._headers()) as resp: + result = await resp.json() + print( + "[Seedance素材][标准] 创建响应体:\n" + + json.dumps( + {"http_status": resp.status, "success": bool(result.get("success"))}, + ensure_ascii=False, + ) + ) + + if resp.status != 200: + error_msg = result.get("message", result.get("error", str(result))) + raise RuntimeError(f"创建素材失败 ({resp.status}): {error_msg}") + + if not result.get("success", False): + error_msg = result.get("message", "创建素材失败") + raise RuntimeError(error_msg) + + data = result.get("data", result) + if isinstance(data, dict): + data = dict(data) + data["_create_response"] = result + return data + finally: + if should_close: + await session.close() + + async def create_hc_asset( + self, + name: str, + asset_url: str, + asset_type: str, + session: aiohttp.ClientSession = None, + request_type: str = "hc", + ) -> dict: + """通过统一 Seedance 素材接口创建 HC 或 Doubao 素材。""" + normalized_asset_type = str(asset_type).strip().lower() + if normalized_asset_type not in {"image", "video", "audio"}: + raise ValueError(f"不支持的素材类型:{asset_type}") + normalized_request_type = self._normalize_asset_request_type(request_type) + request_label = "HC" if normalized_request_type == "hc" else "Doubao" + + url = f"{self.base_url}/v1/seedance/assets" + body = { + "type": normalized_request_type, + "url": asset_url, + "asset_type": normalized_asset_type, + } + if name: + body["name"] = name + should_close = session is None + if session is None: + connector = aiohttp.TCPConnector(ssl=False, force_close=True) + session = aiohttp.ClientSession(connector=connector) + + try: + check_interrupt() + async with session.post(url, json=body, headers=self._hc_headers()) as resp: + result = await self._read_json_response(resp) + print( + f"[Seedance素材][{request_label}] 创建响应体:\n" + + json.dumps( + {"http_status": resp.status, "success": bool(result.get("success"))}, + ensure_ascii=False, + ) + ) + if resp.status < 200 or resp.status >= 300: + message = self._hc_error_message(result, "创建素材失败") + raise RuntimeError(f"创建 {request_label} 素材失败 ({resp.status}): {message}") + if not result.get("success", False): + raise RuntimeError(self._hc_error_message(result, f"创建 {request_label} 素材失败")) + + data = result.get("data") + if not isinstance(data, dict) or not data.get("Id"): + raise RuntimeError(f"创建 {request_label} 素材成功但未返回 data.Id") + data = dict(data) + data["_create_response"] = result + return data + finally: + if should_close: + await session.close() + + async def get_hc_asset( + self, + asset_id: str, + session: aiohttp.ClientSession = None, + request_type: str = "hc", + ) -> dict: + """通过统一 Seedance 素材接口查询 HC 或 Doubao 素材状态。""" + normalized_request_type = self._normalize_asset_request_type(request_type) + request_label = "HC" if normalized_request_type == "hc" else "Doubao" + encoded_id = quote(asset_id, safe="") + url = f"{self.base_url}/v1/seedance/assets/{encoded_id}" + should_close = session is None + if session is None: + connector = aiohttp.TCPConnector(ssl=False, force_close=True) + session = aiohttp.ClientSession(connector=connector) + + try: + check_interrupt() + async with session.get( + url, + params={"type": normalized_request_type}, + headers=self._hc_headers(), + ) as resp: + result = await self._read_json_response(resp) + if resp.status < 200 or resp.status >= 300: + message = self._hc_error_message(result, "查询素材状态失败") + raise RuntimeError(f"查询 {request_label} 素材失败 ({resp.status}): {message}") + if not result.get("success", False): + raise RuntimeError(self._hc_error_message(result, f"查询 {request_label} 素材失败")) + + data = result.get("data") + if not isinstance(data, dict): + raise RuntimeError(f"查询 {request_label} 素材未返回 data") + return data + finally: + if should_close: + await session.close() + + async def create_hc_asset_and_wait( + self, + name: str, + asset_url: str, + asset_type: str, + poll_interval: float = 3.0, + request_type: str = "hc", + ) -> dict: + """创建 HC 或 Doubao 素材并等待其进入 Active 状态。""" + normalized_request_type = self._normalize_asset_request_type(request_type) + request_label = "HC" if normalized_request_type == "hc" else "Doubao" + connector = aiohttp.TCPConnector(ssl=False, force_close=True) + async with aiohttp.ClientSession(connector=connector) as session: + created = await self.create_hc_asset( + name=name, + asset_url=asset_url, + asset_type=asset_type, + session=session, + request_type=normalized_request_type, + ) + asset_id = str(created["Id"]) + deadline = PollDeadline(label=f"Seedance {request_label} 素材") + print(f"[Seedance素材][{request_label}] 已创建 {asset_id},等待素材可用...") + + while True: + deadline.check() + check_interrupt() + asset = await self.get_hc_asset( + asset_id, + session=session, + request_type=normalized_request_type, + ) + status = str(asset.get("Status", "")).strip() + normalized_status = status.lower() + print(f"[Seedance素材][{request_label}] {asset_id} 状态: {status or '未知'}") + + if normalized_status == "active": + asset = dict(asset) + asset["_create_response"] = created.get("_create_response", {}) + return asset + if normalized_status == "failed": + message = self._hc_error_message({"data": asset}, "素材处理失败") + raise RuntimeError(f"{request_label} 素材处理失败:{message}") + if normalized_status != "processing": + raise RuntimeError(f"{request_label} 素材返回未知状态:{status or '空状态'}") + + await interruptible_sleep(poll_interval) + + async def delete_element( + self, + element_internal_id: int, + session: aiohttp.ClientSession = None, + ) -> dict: + """ + 删除素材记录(仅删除平台记录,不删除上游Asset) + + Args: + element_internal_id: 平台内部记录ID(非element_id) + session: aiohttp会话 + + Returns: + {"message": "删除成功"} + """ + url = f"{self.base_url}/api/element/seedance/{element_internal_id}" + + should_close = session is None + if session is None: + connector = aiohttp.TCPConnector(ssl=False, force_close=True) + session = aiohttp.ClientSession(connector=connector) + + try: + async with session.delete(url, headers=self._headers()) as resp: + result = await resp.json() + + if resp.status != 200: + error_msg = result.get("message", result.get("error", str(result))) + raise RuntimeError(f"删除素材失败 ({resp.status}): {error_msg}") + + if not result.get("success", False): + error_msg = result.get("message", "删除素材失败") + raise RuntimeError(error_msg) + + return result.get("data", result) + finally: + if should_close: + await session.close() diff --git a/clients/seedream_image_client.py b/clients/seedream_image_client.py new file mode 100644 index 0000000..90ff644 --- /dev/null +++ b/clients/seedream_image_client.py @@ -0,0 +1,409 @@ +"""Seedream image client for O1Key's asynchronous image API.""" + +from __future__ import annotations + +import os +import time +from typing import Any, Awaitable, Callable, Optional, Sequence + +from PIL import Image + +from ..utils.nano_banana_async import ( + extract_async_image_result_urls, + image_to_upload_payload, + parse_completed_async_image_task, + poll_async_image_task, + submit_async_image_task, + upload_images_to_temp_urls, +) +from ..utils.o1key_image_catalog import ( + MAX_UNIFIED_REFERENCE_IMAGES, + SEEDREAM_MODEL_OPTIONS, + SEEDREAM_LAYER_RESOLUTION_OPTIONS, + SEEDREAM_OUTPUT_FORMAT_OPTIONS, + SEEDREAM_SIZE_MATRIX, + UNIFIED_IMAGE_ROUTE_OPTIONS, +) + + +SEEDREAM_API_MODEL_ID = "dola-seedream-5-0-pro-260628-ep" +SEEDREAM_REFERENCE_MAX_BYTES = 30 * 1024 * 1024 +SEEDREAM_REFERENCE_MAX_PIXELS = 6000 * 6000 +SEEDREAM_REFERENCE_MIN_SIDE_EXCLUSIVE = 14 +SEEDREAM_REFERENCE_MIN_ASPECT_RATIO = 1 / 16 +SEEDREAM_REFERENCE_MAX_ASPECT_RATIO = 16 +SEEDREAM_LAYER_REFERENCE_MIN_PIXELS = 512 * 512 + + +def validate_seedream_reference_dimensions( + width: int, + height: int, + *, + label: str = "Seedream 参考图", + layer_decomposition: bool = False, +) -> None: + """Validate the current Volcengine per-image reference-size contract.""" + width = int(width) + height = int(height) + if width <= 0 or height <= 0: + raise ValueError(f"{label}尺寸无效:{width}×{height}") + + pixels = width * height + if ( + not layer_decomposition + and ( + width <= SEEDREAM_REFERENCE_MIN_SIDE_EXCLUSIVE + or height <= SEEDREAM_REFERENCE_MIN_SIDE_EXCLUSIVE + ) + ): + raise ValueError( + f"{label}宽和高都必须大于 {SEEDREAM_REFERENCE_MIN_SIDE_EXCLUSIVE}px," + f"当前为 {width}×{height}" + ) + ratio = width / height + if ( + ratio < SEEDREAM_REFERENCE_MIN_ASPECT_RATIO + or ratio > SEEDREAM_REFERENCE_MAX_ASPECT_RATIO + ): + raise ValueError( + f"{label}宽高比必须在 1:16~16:1,当前为 {width}:{height}" + ) + if layer_decomposition: + if not ( + SEEDREAM_LAYER_REFERENCE_MIN_PIXELS + <= pixels + <= SEEDREAM_REFERENCE_MAX_PIXELS + ): + raise ValueError( + f"{label}总像素必须在 512×512(262144)~6000×6000(36000000)之间," + f"当前为 {width}×{height}({pixels})" + ) + return + if pixels > SEEDREAM_REFERENCE_MAX_PIXELS: + raise ValueError( + f"{label}总像素不能超过 6000×6000(36000000)," + f"当前为 {width}×{height}({pixels})" + ) + + +def validate_seedream_reference_image( + image: Image.Image, + *, + label: str = "Seedream 参考图", + layer_decomposition: bool = False, +) -> None: + """Validate reference dimensions and the exact bytes sent to the uploader.""" + validate_seedream_reference_dimensions( + image.width, + image.height, + label=label, + layer_decomposition=layer_decomposition, + ) + payload, _extension, _content_type = image_to_upload_payload(image) + try: + payload_size = ( + os.path.getsize(payload) + if isinstance(payload, (str, os.PathLike)) + else len(payload) + ) + except (OSError, TypeError) as exc: + raise ValueError(f"无法读取{label}文件大小") from exc + if payload_size > SEEDREAM_REFERENCE_MAX_BYTES: + raise ValueError( + f"{label}文件不能超过 30MB,当前为 {payload_size / 1024 / 1024:.2f}MB" + ) + + +def validate_seedream_reference_images( + images: Sequence[Image.Image], + *, + layer_decomposition: bool = False, +) -> None: + for index, image in enumerate(images, start=1): + validate_seedream_reference_image( + image, + label=f"Seedream 参考图{index}", + layer_decomposition=layer_decomposition, + ) + + +def resolve_seedream_model(model_name: str, route: str) -> str: + """Map the stable workflow value to Seedream's API model identifier.""" + if model_name == SEEDREAM_API_MODEL_ID: + return model_name + if model_name not in SEEDREAM_MODEL_OPTIONS: + raise ValueError(f"Seedream 模型无效:{model_name}") + if route not in UNIFIED_IMAGE_ROUTE_OPTIONS: + raise ValueError(f"Seedream 模型线路无效:{route}") + # O1Key currently exposes one Seedream endpoint for every displayed route. + return SEEDREAM_API_MODEL_ID + + +def build_seedream_submit_body( + *, + model: str, + prompt: str, + size: Optional[str], + output_format: str, + image_urls: Optional[Sequence[str]] = None, + layer_decomposition: bool = False, +) -> dict[str, Any]: + """Build and validate the paid Seedream request without logging URLs.""" + normalized_prompt = str(prompt or "").strip() + if not normalized_prompt and not layer_decomposition: + raise ValueError("请输入提示词") + if model != SEEDREAM_API_MODEL_ID: + raise ValueError(f"Seedream API 模型无效:{model}") + + normalized_format = str(output_format or "").strip().lower() + if normalized_format not in SEEDREAM_OUTPUT_FORMAT_OPTIONS: + raise ValueError("Seedream 输出格式仅支持 png 或 jpeg") + + if layer_decomposition and normalized_format != "png": + raise ValueError("Seedream 图层拆分仅支持 png 输出格式") + + normalized_size = str(size or "").strip().lower().replace("*", "x").replace("×", "x") + if normalized_size: + if layer_decomposition: + normalized_size = "auto" if normalized_size == "auto" else normalized_size.upper() + if normalized_size not in SEEDREAM_LAYER_RESOLUTION_OPTIONS: + raise ValueError(f"Seedream 图层拆分分辨率无效:{size}") + elif normalized_size not in set(SEEDREAM_SIZE_MATRIX.values()): + raise ValueError(f"Seedream 图片尺寸无效:{size}") + + urls = [str(url or "").strip() for url in (image_urls or ())] + if len(urls) > MAX_UNIFIED_REFERENCE_IMAGES: + raise ValueError(f"Seedream 参考图最多支持 {MAX_UNIFIED_REFERENCE_IMAGES} 张") + if any(not url.startswith("https://") for url in urls): + raise ValueError("Seedream 参考图必须使用临时素材 HTTPS URL") + if layer_decomposition and len(urls) != 1: + raise ValueError("Seedream 图层拆分必须且只能提供1张参考图") + + body: dict[str, Any] = { + "model": model, + "n": 1, + "output_format": normalized_format, + "watermark": False, + } + if normalized_size: + body["size"] = normalized_size + if normalized_prompt: + body["prompt"] = normalized_prompt + if urls: + body["images"] = urls + if layer_decomposition: + body["layer_decomposition"] = True + return body + + +def _seedream_result_items(payload: Any) -> list[dict[str, Any]]: + """Return the first documented image-item list without exposing its URLs.""" + pending = [payload] + seen: set[int] = set() + while pending: + value = pending.pop(0) + if not isinstance(value, dict) or id(value) in seen: + continue + seen.add(id(value)) + images = value.get("images") + if isinstance(images, list) and all(isinstance(item, dict) for item in images): + return images + for key in ("data", "result", "output"): + nested = value.get(key) + if isinstance(nested, dict): + pending.append(nested) + return [] + + +def _bounded_int_list(value: Any, *, length: int) -> list[int] | None: + if not isinstance(value, (list, tuple)) or len(value) != length: + return None + try: + return [int(item) for item in value] + except (TypeError, ValueError): + return None + + +def extract_seedream_layer_metadata(payload: Any) -> list[dict[str, Any]]: + """Sanitize layer metadata; result URLs are deliberately excluded.""" + metadata: list[dict[str, Any]] = [] + for offset, item in enumerate(_seedream_result_items(payload)): + try: + z_index = max(0, min(16, int(item.get("z_index", offset)))) + except (TypeError, ValueError): + z_index = offset + safe: dict[str, Any] = {"z_index": z_index} + for key, limit in (("name", 200), ("description", 1000), ("size", 64), ("output_format", 16)): + value = item.get(key) + if isinstance(value, str) and value.strip(): + safe[key] = value.strip()[:limit] + bounding_box = item.get("bounding_box") + if isinstance(bounding_box, dict): + absolute = _bounded_int_list(bounding_box.get("absolute"), length=4) + normalized = _bounded_int_list(bounding_box.get("normalized"), length=4) + safe_box = {} + if absolute is not None: + safe_box["absolute"] = absolute + if normalized is not None: + safe_box["normalized"] = normalized + if safe_box: + safe["bounding_box"] = safe_box + metadata.append(safe) + return metadata + + +class SeedreamImageClient: + """Upload references, submit one Seedream task, poll it, and decode results.""" + + def __init__(self, *, base_url: str, api_key: str): + self.base_url = str(base_url).rstrip("/") + self.api_key = api_key + + async def generate_async( + self, + *, + session: Any, + prompt: str, + model: str, + size: Optional[str], + output_format: str, + images: Optional[Sequence[Image.Image]] = None, + layer_decomposition: bool = False, + upload_cache: Optional[dict[int, Awaitable[str]]] = None, + check_interrupt: Optional[Callable[[], None]] = None, + progress_callback: Optional[Callable[[float], None]] = None, + result_url_callback: Optional[Callable[[str], None]] = None, + log_downloads: bool = True, + log_task_success: bool = True, + task_completed_callback: Optional[ + Callable[[str, int, float, list[str]], None] + ] = None, + ) -> tuple[list[Image.Image], dict[str, Any]]: + if check_interrupt: + check_interrupt() + + reference_images = list(images or ()) + validate_seedream_reference_images( + reference_images, + layer_decomposition=layer_decomposition, + ) + + task_started = time.time() + image_urls = await upload_images_to_temp_urls( + session=session, + base_url=self.base_url, + api_key=self.api_key, + images=reference_images, + node_label="Seedream", + check_interrupt=check_interrupt, + upload_cache=upload_cache, + log_success=log_task_success, + ) + body = build_seedream_submit_body( + model=model, + prompt=prompt, + size=size, + output_format=output_format, + image_urls=image_urls, + layer_decomposition=layer_decomposition, + ) + + task_id = await submit_async_image_task( + session, + self.base_url, + self.api_key, + body, + "Seedream", + log_body_enabled=False, + log_success=log_task_success, + ) + task_payload = await poll_async_image_task( + session, + self.base_url, + self.api_key, + task_id, + "Seedream", + check_interrupt=check_interrupt, + log_body_enabled=False, + progress_callback=progress_callback, + log_success=log_task_success, + ) + task_done = time.time() + parse_started = time.time() + task_payload, parsed = await parse_completed_async_image_task( + task_payload, + session, + self.base_url, + self.api_key, + task_id, + "Seedream", + check_interrupt=check_interrupt, + result_url_callback=None, + log_downloads=log_downloads, + ) + if isinstance(parsed, tuple) and len(parsed) == 2: + result_images, metrics = parsed + else: + result_images = parsed + metrics = { + "download_bytes": 0, + "download_seconds": 0.0, + "download_wall_seconds": 0.0, + "inline_images": 0, + } + + result_urls = extract_async_image_result_urls(task_payload) + result_metadata = extract_seedream_layer_metadata(task_payload) + if layer_decomposition: + paired = [] + for index, image in enumerate(result_images): + metadata = ( + result_metadata[index] + if index < len(result_metadata) + else {"z_index": index} + ) + setattr(image, "_o1key_seedream_layer", metadata) + paired.append((metadata.get("z_index", index), index, image)) + paired.sort(key=lambda item: (item[0], item[1])) + result_images = [item[2] for item in paired] + result_metadata = [ + getattr(image, "_o1key_seedream_layer", {"z_index": index}) + for index, image in enumerate(result_images) + ] + if result_url_callback: + for url in result_urls: + result_url_callback(url) + if task_completed_callback: + task_completed_callback( + task_id, + len(result_images), + time.time() - task_started, + result_urls, + ) + + return result_images, { + "task_id": task_id, + "task_ids": [task_id], + "task_ms": (task_done - task_started) * 1000, + "parse_ms": (time.time() - parse_started) * 1000, + "download_ms": metrics["download_wall_seconds"] * 1000, + "download_total_ms": metrics["download_seconds"] * 1000, + "download_bytes": metrics["download_bytes"], + "inline_images": metrics["inline_images"], + "result_metadata": result_metadata, + } + + +__all__ = [ + "SEEDREAM_API_MODEL_ID", + "SEEDREAM_LAYER_REFERENCE_MIN_PIXELS", + "SEEDREAM_REFERENCE_MAX_BYTES", + "SEEDREAM_REFERENCE_MAX_PIXELS", + "SeedreamImageClient", + "build_seedream_submit_body", + "extract_seedream_layer_metadata", + "resolve_seedream_model", + "validate_seedream_reference_dimensions", + "validate_seedream_reference_image", + "validate_seedream_reference_images", +] diff --git a/clients/sora_client.py b/clients/sora_client.py index c477920..f9c36a9 100644 --- a/clients/sora_client.py +++ b/clients/sora_client.py @@ -15,6 +15,7 @@ import aiohttp from .base_client import BaseAPIClient from ..utils.config import get_api_key_or_raise, get_api_base_url from ..utils.image_utils import encode_image_to_base64 +from ..utils.video_task import PollDeadline, download_video_to_file def _translate_error_message(msg: str) -> str: @@ -178,9 +179,11 @@ class SoraClient(BaseAPIClient): close_session = True interval = self.POLL_INITIAL_INTERVAL + deadline = PollDeadline(label="Sora 视频") try: while True: + deadline.check() async with session.get(url, headers=headers) as response: if response.status != 200: error_text = await response.text() @@ -253,18 +256,20 @@ class SoraClient(BaseAPIClient): raise RuntimeError(f"视频下载失败: {error_message}") content_type = response.headers.get("Content-Type", "") - + download_url = None if "application/json" in content_type: data = await response.json() download_url = data.get("url") or data.get("download_url") if not download_url: raise RuntimeError("视频下载失败: 响应中未找到下载链接") - await self._download_from_url(download_url, save_path, session) - else: - os.makedirs(os.path.dirname(save_path), exist_ok=True) - with open(save_path, "wb") as f: - async for chunk in response.content.iter_chunked(8192): - f.write(chunk) + + if download_url: + await self._download_from_url(download_url, save_path, session) + else: + # content 端点直接返回视频流(幂等 GET,可安全重连续传) + await download_video_to_file( + session, url, save_path, headers=headers, label="Sora 视频", + ) return save_path @@ -494,13 +499,7 @@ class SoraClient(BaseAPIClient): session: aiohttp.ClientSession, ) -> None: """从给定 URL 下载文件到本地路径""" - os.makedirs(os.path.dirname(save_path), exist_ok=True) - async with session.get(url) as response: - if response.status != 200: - raise RuntimeError(f"从下载链接获取视频失败 (状态码: {response.status})") - with open(save_path, "wb") as f: - async for chunk in response.content.iter_chunked(8192): - f.write(chunk) + await download_video_to_file(session, url, save_path, label="Sora 视频") @staticmethod def _extract_error_message(error_text: str, status_code: int) -> str: diff --git a/clients/veo_client.py b/clients/veo_client.py index b009f66..e413862 100644 --- a/clients/veo_client.py +++ b/clients/veo_client.py @@ -15,6 +15,7 @@ import aiohttp from .base_client import BaseAPIClient from ..utils.config import get_api_key_or_raise, get_api_base_url from ..utils.image_utils import encode_image_to_base64 +from ..utils.video_task import PollDeadline, download_video_to_file class VeoClient(BaseAPIClient): @@ -206,9 +207,11 @@ class VeoClient(BaseAPIClient): close_session = True interval = self.POLL_INITIAL_INTERVAL + deadline = PollDeadline(label="Veo 视频") try: while True: + deadline.check() async with session.get(url, headers=headers) as response: if response.status != 200: error_text = await response.text() @@ -276,18 +279,20 @@ class VeoClient(BaseAPIClient): raise RuntimeError(f"视频下载失败: {error_message}") content_type = response.headers.get("Content-Type", "") - + download_url = None if "application/json" in content_type: data = await response.json() download_url = data.get("url") or data.get("download_url") if not download_url: raise RuntimeError("视频下载失败: 响应中未找到下载链接") - await self._download_from_url(download_url, save_path, session) - else: - os.makedirs(os.path.dirname(save_path), exist_ok=True) - with open(save_path, "wb") as f: - async for chunk in response.content.iter_chunked(8192): - f.write(chunk) + + if download_url: + await self._download_from_url(download_url, save_path, session) + else: + # content 端点直接返回视频流(幂等 GET,可安全重连续传) + await download_video_to_file( + session, url, save_path, headers=headers, label="VEO 视频", + ) return save_path @@ -474,13 +479,7 @@ class VeoClient(BaseAPIClient): session: aiohttp.ClientSession, ) -> None: """从给定 URL 下载文件到本地路径""" - os.makedirs(os.path.dirname(save_path), exist_ok=True) - async with session.get(url) as response: - if response.status != 200: - raise RuntimeError(f"从下载链接获取视频失败 (状态码: {response.status})") - with open(save_path, "wb") as f: - async for chunk in response.content.iter_chunked(8192): - f.write(chunk) + await download_video_to_file(session, url, save_path, label="VEO 视频") @staticmethod def _extract_error_message(error_text: str, status_code: int) -> str: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..19916e9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,33 @@ +# Maintainer and agent documentation + +This directory is the structured knowledge base for maintaining `comfyui_o1key`. Keep executable rules concise in `AGENTS.md`; keep explanations, diagrams, and procedures here. + +## Start here + +- [Architecture](architecture.md): runtime boundaries, startup flow, storage, and dependency direction. +- [Development](development.md): how to add or change nodes, clients, routes, and frontend extensions. +- [Testing](testing.md): isolated offline suite, smoke tests, and validation matrix. +- [Configuration](configuration.md): supported settings, storage, precedence, and security rules. +- [Architecture decisions](decisions/README.md): durable decisions and the ADR template. +- [Maintenance history](maintenance/cleanup-2026-08-29.md): the deep-cleanup baseline for the current layout. + +## Knowledge ownership + +| Change | Required documentation | +| --- | --- | +| User installation or visible behavior | `README.md` | +| Runtime boundary or data flow | `docs/architecture.md` | +| Configuration key or precedence | `docs/configuration.md` | +| Development/test procedure | `docs/development.md` or `docs/testing.md` | +| Compatibility-sensitive design choice | `docs/decisions/NNNN-title.md` | +| One-time repository maintenance | `docs/maintenance/YYYY-MM-DD-topic.md` | + +## AI handoff checklist + +Before ending a non-trivial change, leave the repository in a state where the next maintainer or agent can answer: + +1. What runtime boundary changed? +2. Which invariant protects backward compatibility? +3. Which offline test proves the behavior? +4. Which document is now the source of truth? +5. Are any follow-up risks or decisions still open? diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5127339 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,261 @@ +# Architecture + +## Runtime overview + +```text +ComfyUI startup + | + +-- prestartup_script.py + | `-- disables built-in Partner/API nodes for this distribution + | + `-- __init__.py + |-- imports public node classes from nodes/ + |-- publishes NODE_CLASS_MAPPINGS and display names + |-- registers /o1key/* HTTP routes + |-- registers the parallel image-job manager + `-- exposes web/ through WEB_DIRECTORY + +Node execution + nodes/ --> clients/ --> O1Key/provider HTTP APIs + | | + `----------> utils/ --> config, uploads, retries, media, polling, jobs + +Browser UI + web/js/ --> /o1key/* routes --> ComfyUI input/output/temp storage +``` + +Dependency direction should remain one-way: frontend talks to registered routes; nodes orchestrate; clients own provider protocols; utilities own reusable infrastructure. Clients and utilities must not import node modules. + +## Repository boundaries + +### Plugin integration + +`__init__.py` is the public integration surface. It owns: + +- canonical node registration; +- display names; +- frontend exposure through `WEB_DIRECTORY`; +- server routes for configuration, cases, notes, history, chat, prompt optimization, element management, restart, safe updates, and image jobs. + +Keep route registration guarded so an unavailable optional ComfyUI service does not make all node imports fail silently. When changing this file, run the plugin import smoke test. + +### Nodes + +`nodes/` contains a mixture of legacy V1 classes and V3 `io.ComfyNode` classes. A node is public only when it is exported from `nodes/__init__.py` and added to the root mappings. Module-local mappings are not sufficient. + +Released node IDs and widget order form a persistence contract with saved workflows. Use `web/js/migrateWorkflow.js` when schema evolution changes positional `widgets_values`. + +`O1keyAutoRedCast` remains a V1, deterministic local colour-correction node. Its native `seed` input is the final optional widget, enables ComfyUI's control-after-generate behavior, and changes the node's cache input; it does not add random sampling to the colour algorithm. Frontend migration appends default `0` to old six-widget workflows and moves the interim seven-widget layout's seed after both gray-card values. + +`O1keyPromptMultiFunction` keeps its released node ID and its original `提示词` / `功能` widget positions. Its visible random mode is unified as `随机抽取n套`; workflow migration rewrites legacy `随机抽取1套` to that value with count `1`, and rewrites the interim `随机抽取多套` value while preserving its saved count. Backend aliases retain compatibility for API workflows that bypass frontend migration. Multi-selection remains append-only: `抽取数量` and `指定序号` occupy the next two positions, and the workflow migration supplies defaults `3` and `1,2,3` to older graphs. `promptMultiFunctionDynamic.js` changes only widget visibility: random mode shows the count, explicit mode shows the index field, and all mode hides both while retaining their serialized values. Random selection samples without replacement and restores source order before joining with standalone `---` lines; explicit selection uses one-based indexes and preserves the order written by the user. + +### Provider clients + +Omni Flash follows the `SeedanceAutoPass` native execution pattern. `O1keyOmniFlashVideo.execute` validates scalar and media inputs, uploads connected IMAGE/VIDEO values through the shared uploader, then uses `clients/omni_flash_client.py` to submit, poll, and download on the selected O1Key network route. Ordinary generation always submits `omni_flash_10s`; video editing selects the dedicated `omni_flash_abra_edit` model. The model is not a node widget. The frontend workflow migration removes the retired model value at widget index 2 from saved Omni Flash nodes before the new schema loads. The node returns a fresh `fingerprint_inputs` value for each queued execution so ComfyUI does not reuse a prior paid generation result when the same node is run again with unchanged inputs. Both upload and generation read the existing `O1KEY_API_KEY` from `.config`. The client reports the provider's task percentage to the node's native ComfyUI `ProgressBar`; repeated or older values cannot move it backwards, and 100 is reserved for a fully saved video. It returns only native `VIDEO`, with no node-local preview payload. The frontend changes visible media sockets with the generation mode, suspending Autogrow while removing inactive sockets; the `开始生成` button queues this output node through ComfyUI. No dedicated server route, result node, job history, or URL widget is involved. + +The client normalizes top-level and nested task IDs, statuses, progress, and result URLs. It polls through unrecognized nonterminal statuses until the deadline, maps documented API error codes to user-facing messages, and inspects the content endpoint before streaming so JSON download links are not saved as video bytes. A result URL from the status response is used if the content endpoint cannot serve the file. Submission, poll, and text download response bodies are printed to the ComfyUI terminal with secret fields, URLs, and large media strings masked; binary video bodies are never printed. Credentials and signed URLs are removed from surfaced error text. + +Only `omni_flash_abra_edit` task creation adds the `X-No-Watermark: video` header. Polling and content requests retain the regular authentication header. + +`clients/` owns request construction, provider endpoints, polling protocols, and response normalization. Its package exports are lazy so importing one provider does not initialize all providers. Video task polling uses the shared 2,000-second deadline unless a caller explicitly supplies a different value. + +The `MiniMaxH3Video` node keeps its released node ID and original first four +inputs. Its append-only `模型` widget selects `MiniMax-H3` or +`MiniMax-H3-MAX`, followed by an append-only native `seed` widget. Workflow +migration supplies `MiniMax-H3` and seed `0` to older saved graphs. The seed is +validated as an integer and passed unchanged in the provider request. Backend +validation owns the authoritative model-specific resolution, duration, mode, +and aggregate reference-count rules, while the frontend guard updates the +visible resolution and duration constraints and prevents H3 Max from selecting +reference mode. + +The MiniMax client creates tasks through `/v1/video/generations` and queries +them through `/v1/videos/{task_id}` every 10 seconds. New API's documented +post-submission `unknown` status is normalized as a pending state and remains +bounded by the shared 2,000-second polling deadline. Completed results prefer +`result_url` and retain compatibility fallbacks for wrapped gateway and official +V2 response shapes before the temporary CDN file is downloaded. + +Grok Video uses separate create endpoints for generation, edit, and extension: +`/grok/v1/videos/generations`, `/grok/v1/videos/edits`, and +`/grok/v1/videos/extensions`. Every operation then polls +`/grok/v1/videos/{request_id}` and downloads `video.url` only after a `done` +state. `clients/grok_video_client.py` owns the operation-specific payload +whitelists and model capability checks. The V1 nodes validate with placeholder +media locators before uploading local IMAGE, AUDIO, or VIDEO values, so an +invalid model, duration, resolution, mode, media count, or edit clip length +cannot consume an upload or paid generation request. Saved workflows retain the +`O1keyGrokVideo` ID and are migrated according to [ADR 0006](decisions/0006-grok-video-api-and-workflow-migration.md). + +### Shared utilities + +`utils/` contains cross-provider infrastructure: + +- `config.py`: atomic `.config` reads and writes plus route resolution; +- `http_error.py`: retry classification and friendly errors; +- `http2_client.py`: HTTP/2 with an aiohttp fallback; +- `image_utils.py` and `file_utils.py`: media conversion and file pairing; +- `r2_uploader.py`: temporary public media upload; +- `video_task.py`: interruption-aware polling and downloads; +- `nano_banana_async.py`: Nano Banana asynchronous lifecycle; +- `o1key_image_catalog.py`: canonical capabilities for the unified image generator; +- `o1key_image_jobs.py`: isolated parallel job snapshots, model-family dispatch, and results. +- `o1key_image_save.py`: original-byte preservation, format conversion, workflow metadata, and output naming for `O1keyImageSave`. +- `reference_color_correction.py`: bounded reference-guided chroma correction retained by the GPT Image batch node. + +Like `clients`, the `utils` package uses lazy exports to reduce startup work. + +### Frontend + +Every JavaScript file in `web/` is served as a ComfyUI extension. Major responsibilities include settings, chat, cases, notes, element management, workflow migration, upload helpers, previews, painting, trimming, and the panel-style image generator. + +`web/js/o1keyUpdateButton.js` registers an O1Key tab in the left sidebar. Its panel calls `/o1key/update`, which delegates to `utils/updater.py`. The updater fetches the public `main` branch from `https://git.o1key.com/publisher/comfyui_o1key.git` without changing the user's `origin`. It only fast-forwards a clean local Git `main`; local tracked changes, divergent history, and file collisions receive structured error codes and guidance. It never resets or cleans the worktree. A completed update requires a ComfyUI restart to load new Python and JavaScript code. + +Because the directory is auto-loaded, unused or experimental JavaScript must not be left here. + +`O1keyVideoTrim` keeps its released widget order and uses `视频路径` only as an +internal serialized value populated by the upload control. `web/js/videoTrim.js` +hides that backend widget through the supported Nodes 2.0 `options.hidden` +flag, without assigning a negative widget height. Uploaded files below the +configured ComfyUI input root are previewed through the native `/view` route; +other absolute paths retained by old workflows are never exposed through a +browser file route. Numeric widget callback wrappers must preserve ComfyUI's +receiver, argument list, and return value so Nodes 2.0 can render and edit the +controls safely. + +#### `O1keyImageSave` preview invariant + +`O1keyImageSave` uses ComfyUI's native image preview when every requested image succeeds. While a panel batch is active, a single DOM slot grid reserves the exact expected image positions. Each provider result is published to that grid immediately after its complete file has been written to ComfyUI `temp`; permanent promotion and native-output dispatch still wait for the terminal batch result. If the batch partially fails, that grid remains as the sole visible preview so successful images retain their request positions and failed positions remain individually actionable; the native preview is hidden during this state, never duplicated above or below it. Once all slots succeed, the temporary slot widget is removed from `node.widgets` and the native preview becomes the sole result renderer again. Removing it is required because ComfyUI treats every DOM widget row as expandable; merely hiding the grid element would leave `node-widgets` at `flex: 1` and consume half of the node's extra height above the preview. + +Ordinary image batches map slots by `request_index`, using the first returned image for each request while preserving every valid provider result in the native preview. A provider request that unexpectedly returns multiple images therefore cannot leave unrelated slots stuck in a running state. Result cardinality is treated as independent from request cardinality only when layer decomposition was explicitly enabled; it is never inferred solely because `result_count` exceeds `request_count`. Workflow loading reconciles stored result descriptors back into stale pending or running slots, which repairs state serialized by older frontend versions without starting another generation request. + +The replacement regeneration control inherits the native preview button geometry and uses a white surface with a black refresh icon. Generation progress remains a thin absolute overlay without percentage text. The slot grid is the only layout-reserving addition and exists specifically to make batch cardinality and per-image failure explicit; its height is derived from its measured content box without duplicate bottom padding, and it scrolls for large prompt batches. Once native results are visible, the save node recomputes its initial preview height from the loaded image dimensions so landscape, square, and portrait results do not inherit the placeholder batch height. After that initial fit, ComfyUI's Vue `NodeContent` and `ImagePreview` remain the sole layout authorities: their native `flex-auto`, minimum preview height, element-size observation, responsive grid, and `object-contain` rules make the preview occupy the remaining node area during resize. The extension must not override those native flex/min-height rules or mutate the legacy canvas preview widget from a resize hook. Save-node sizing otherwise follows the native `SaveImage` node without an o1key-specific permanent minimum-size clamp. + +The save node persists sanitized ComfyUI image descriptors in `properties.o1keyImageSaveResults` and bounded slot state in `properties.o1keyImageSlots`. Slot state contains request order, prompt text, sanitized ComfyUI input descriptors for that exact task, status, compact error text, and an optional sanitized output descriptor; it never contains credentials, Base64, signed URLs, or local paths. The input descriptors let a failed source/target pairing be retried after the panel manifests change or the workflow is reloaded. When a saved workflow is loaded or the page is refreshed, completed descriptors are replayed into ComfyUI's native executed-output store and an incomplete slot grid is restored without starting a generation request. + +Provider results from panel-triggered background jobs are written to the root of `folder_paths.get_temp_directory()` with their detected PNG/JPEG/WebP extension. When original bytes are available they are written without pixel re-encoding. The `/o1key/image/save` route accepts only batch-bound `type=temp` descriptors, and `O1keyImageSave` alone promotes them into the configured permanent destination. A blank location uses `folder_paths.get_output_directory()`, a relative location stays below that root, and an absolute location is used as an explicit external destination. External saves return a path-free `type=temp` preview copy below `o1key_external_preview//`, because ComfyUI's native `/view` endpoint cannot serve arbitrary filesystem roots. A live job record replaces its provider descriptors with the final output or preview descriptors under a per-record save lock, making browser-refresh and concurrent recovery saves idempotent. Disk recovery prefers root-level output filenames over matching temp files, then checks the legacy `output/o1key_parallel///` layout. + +Workflow-bearing saves follow ComfyUI's native `SaveImage` metadata contract: the execution prompt is stored under `prompt`, and the serialized graph supplied through `extra_pnginfo` is stored under `workflow`. Standard V3 execution reads both values from the executor-provided class `hidden` holder. Panel execution obtains both from one `app.graphToPrompt()` call so the API prompt and workflow describe the same graph snapshot. ComfyUI's image metadata loader restores PNG and WebP workflows but does not parse JPEG workflow metadata; JPEG EXIF also has a practical single-segment size ceiling. Therefore any JPEG target carrying a workflow is promoted to PNG and embeds the native text fields without truncation. When ComfyUI's global metadata switch disables metadata, format selection remains unchanged and no workflow is embedded. This compatibility decision is recorded in [ADR 0004](decisions/0004-native-recoverable-image-workflow-metadata.md). + +`O1keyImageSave` now has only its `images` input and forwarded `IMAGE` output. It remains the sole component that writes permanent files and renders results, but it receives save settings from its connected `O1keyImageGenerator`: direct execution carries a validated `_o1key_save_settings` tensor attribute, while panel jobs snapshot the same values in the server-side job record before generation. The generator owns append-only inputs `命名规则`, `filename_prefix`, `格式`, and `保存位置` at indexes 17 through 20. `命名规则` defaults to the serialized compatibility value `自定义前缀`, displayed as `自定义`; `filename_prefix` defaults to `o1key`. `和主图一致` uses the first reference-image stem, and `自然数字` allocates the first free integer filename. Every strategy checks under a process-wide lock and never overwrites an existing result. Save locations accept blank/output-root, safe output-relative subfolders, or normalized absolute directories; ambiguous drive-relative paths and relative parent traversal remain invalid. + +The generator's local `格式` input defaults to `原始` and is visible only for Nano Banana models. Explicit PNG and WebP conversions are lossless; JPEG uses quality 100 and 4:4:4 subsampling but remains intrinsically lossy. `原始` preserves provider bytes when valid and falls back to PNG after pixel changes, when bytes are unavailable, or when a JPEG result must carry a ComfyUI-restorable workflow. GPT Image and Seedream ignore this local conversion input and otherwise promote their provider result as `原始`; GPT's separate `输出格式` API parameter accepts `jpeg / png / webp`, while Seedream accepts `jpeg / png`. Transparent GPT backgrounds exclude JPEG before the paid request. + +#### Unified image-generator model dispatch + +`O1keyImageGenerator` is the stable public node ID for the panel-style multi-model generator. Its original nine input IDs and positions remain unchanged; GPT-specific inputs and the `缩放图片` widget are append-only, and the workflow migration fills their defaults without shifting old positional `widgets_values`. The existing `输出格式` widget remains at index 10, `背景` remains at index 13, and the retired `内容审查强度` widget is removed by an idempotent positional migration. Batch inputs occupy indexes 14 through 16. Generator-owned save inputs occupy indexes 17 through 20 in the stable order `命名规则`, `filename_prefix`, `格式`, `保存位置`. New GPT panel selections default index 10 to `png` and smart resize; legacy workflows retain saved values. The standalone `O1keyGPTImage` and `O1keyGPTImageBatch` node IDs remain registered for saved-workflow compatibility. + +The panel displays model-route labels as `特价 / 优质 / 企业`, but serializes and submits the established internal values `畅速 / 直连 / 专线`. This label/value separation is mandatory: changing the serialized values would require a workflow migration and provider-matrix compatibility work. + +The panel's `prompt` remains a socketless, serialized widget value edited in the node. The removed `external_prompt` input is not part of the V3 schema or execution signature. Before loading an older workflow, the frontend migration removes that input and its exact graph link from the generator, the link table, and the source output while retaining the saved panel prompt text. It is idempotent and applies inside subgraph definitions as well as the root graph. + +The unified generator accepts at most ten references in one provider request and offers GPT Image counts 1–8 (retaining saved 9-image jobs) and other-model counts `1 / 2 / 4 / 9`. Batch source and target manifests may each contain up to fifty images when the active pairing mode sends only one source or target per request; group mode still caps its source manifest at nine because it appends one target to the same ten-reference request. A prompt field containing `---` on a line by itself expands into prompt-major tasks. The unified boundary rejects more than 1000 tasks before any paid request, and background/direct GPT and Seedream execution keeps at most nine provider requests in flight per batch. Every GPT Image and Seedream request sends `n=1`; the outer scheduler owns concurrency and partial-result isolation. + +The generator's canvas-image picker reads only image descriptors already exposed by nodes in the current graph through `app.nodeOutputs`, native preview URLs, or persisted `O1keyImageSave` results. A selected `input`, `output`, or `temp` descriptor is fetched through ComfyUI's `/view` route and immediately re-uploaded through the native `/upload/image` route as a new `type=input` reference. The picker never sends an `output` or `temp` path directly to the background-job API; therefore job validation, batch-owned snapshots, refresh recovery, filename collision handling, and input-root containment keep one shared transport contract. This is a frontend convenience and adds no node inputs or serialized workflow fields. Reference tracks use `/o1key/image/thumbnail` for bounded `256 × 256` WebP previews with at most two concurrent server-side decodes and browser caching. Pending uploads render a placeholder rather than decoding their local full-resolution blobs. The lightbox alone uses the original `/view` descriptor, and prompt optimization plus provider requests continue resolving the untouched original input file. + +New reference, source, target, and mask uploads use ComfyUI's native `/upload/image` route with `type=input` and no subfolder, so their descriptors point directly at the configured input root. All o1key uploads in the browser share one serial queue: this lets the native non-overwrite allocator append its natural-number suffix for duplicate names without two concurrent requests racing for the same path. The frontend persists the actual `name`, empty `subfolder`, and `type=input` returned by the server. Resolvers must continue accepting non-empty subfolders so saved workflows that reference the legacy `input/o1key_uploads/...` layout remain valid; existing files are not migrated or deleted. + +Reference, source, and target thumbnails expose a bottom-right replacement action that uploads one local image through the existing non-overwriting queue and swaps only the original manifest entry after success. The original image and order survive validation or upload failure. The same thumbnails expose the browser-only image editor from their top-left action. `web/js/o1keyReferenceImageEditor.js` owns its modal, crop geometry, pointer drawing, single upper sticker layer, vector-arrow annotations, undo history, and PNG composition. Fixed aspect-ratio presets create the largest centred crop and allow repositioning; free mode also allows a new crop or corner resizing. The visual mask brush, coloured annotation brush, and arrows are flattened into the exported pixels and never become a ComfyUI `MASK` value. An arrow stores its exact source-coordinate start and end points; its tip is the pointer-release position, and its filled head scales with line width. A sticker is read from a temporary browser object URL, initially fitted and centred, and represented in source-image coordinates by centre, dimensions, rotation, and opacity. Its selection border, corner scale handles, and rotation handle are interaction chrome only. The source object URL is revoked when the editor closes and is never serialized or uploaded independently; only the flattened final pixels leave the modal. Sticker pixels are composed above the base image, then arrows and brush annotations are composed above the sticker so positional guidance stays visible. Applying an edit creates a non-overwriting PNG through the existing serialized `/upload/image` queue and atomically replaces that exact manifest entry; it does not overwrite the source file, add a workflow field, or change provider transport. If the entry is removed while the modal is open, the exported file is not attached to another position. + +Optional source/target batching adds a pairing dimension without changing normal-mode task expansion. The panel calls the two roles `素材图` and `目标图`; these terms cover objects, elements, styles, materials, structures, or any other source content applied to a destination image. The legacy serialized value `一组搭配+多模特` treats all uploaded source references as one ordered group and appends exactly one target reference per pairing; because a provider request still accepts at most ten references, that mode allows nine source images plus one target image. The legacy value `全部搭配×全部模特` creates the source-major Cartesian product and sends exactly one source plus one target per task, allowing ten uploaded sources and ten uploaded targets to produce 100 pairings. The appended value `单图素材批量` creates one task per source image, sends that image as the sole provider reference, ignores the target manifest, and hides the target lane in the panel. This supports changing poses or expressions across several model images without an additional comparison reference. The complete order is prompt-major, then source/target pairing or source index, then copies for `每组生图数`; the 1000-task ceiling applies after all dimensions are expanded. Background jobs snapshot only the manifests used by the selected mode, retain reference indexes in the task plan, and resolve the exact references immediately before each paid request. Direct V3 execution uses the same task-plan helper. GPT masks are rejected while batch generation is enabled because a single edit mask cannot safely describe multiple changing reference sets. + +The panel exposes both manifest identity and provider-request position on every batch thumbnail. The group mode labels sources as request images `图1...图N` and every target as `图N+1` in its own request; Cartesian mode always labels the current source as `图1` and current target as `图2`; single-reference mode labels every source as `图1` because it is the sole reference in its request. Historical widget names and the first two batch-mode values remain unchanged for saved-workflow compatibility. + +`O1keyImageGenerator.execute` is a native async V3 execution method. GPT Image and Seedream await their asynchronous clients directly, while the legacy synchronous Nano Banana adapter runs in a worker thread; none of these paths may create a nested event loop inside ComfyUI's executor. Before a standard top-level or selected-output queue is serialized, the frontend routes each unified generator to its first connected `O1keyImageSave` without persisted/native results and creates a new save node only when no blank destination exists. Other connected save branches are removed from that prompt payload without changing their workflow nodes, modes, stored result descriptors, or previews. Queue reservations are attached to the selected save node: global ComfyUI execution-start callbacks activate the save-node progress indicator only for that destination and keep the generator panel idle, matching panel-triggered background jobs. A selected downstream branch preserves its exact upstream save node so the new IMAGE output remains executable. The panel's own background queue uses the same blank-first allocation rule. Unified result downloads have no separate semaphore or concurrency ceiling: every ready task enters its download immediately, while the generation scheduler still bounds active provider tasks. Unified image jobs omit the retired moderation parameter, including when a legacy request still supplies it. + +Panel-triggered batches use `/o1key/image/jobs` rather than ComfyUI's native prompt executor. The scheduler exposes a one-based position among waiting batches and emits `queued`, `running`, `completed`, `failed`, and `cancelled` states together with total/success/failed counts and structured failed request indexes. A frontend bridge merges those batch records into ComfyUI's public jobs API results so they appear as independent items in the native top-right task queue and completed history; each o1key row receives a total-count button that reuses ComfyUI's native secondary/medium button and asset-stack utility classes without a plugin-owned visual CSS implementation, while native single/bulk cancellation is routed to the matching o1key batch endpoint. Activating the count delegates to the native task-row result viewer. Terminal summaries are atomically indexed in `/o1key/image_job_history.json`, capped at 200 entries, and exposed through `GET/POST /o1key/image/jobs/history`. On its first native-history request, the frontend hydrates up to 64 recent summaries into the bridge; native single deletion and clear-history operations update both the in-memory bridge and disk index, so removed entries do not reappear after restart. The persisted schema deliberately excludes prompts, manifests, provider payloads, absolute paths, credentials, Base64, and signed URLs. The generator panel stays idle and its primary action remains available for further submissions, preserving scheduler concurrency. Queue clearing cancels waiting o1key batches without interrupting already-running ones. `POST /o1key/image/jobs/{batch_id}/cancel` is idempotent for terminal records and cancels both semaphore-waiting and active local tasks when explicitly selected; cancellation cannot retract a provider request that was already accepted upstream. Batch executors keep normal-mode references and the bounded group-mode source set reusable, but load large Cartesian, single-reference, and group-target manifests only for the active task. Reference preprocessing is ordered and bounded before concurrent provider calls, and task-local PIL images/tensors are released as soon as encoding or provider retrieval no longer needs them; this changes lifetime only, never source pixels or request ordering. + +The frontend batch registry stores immutable generator/save node IDs separately from live node objects. Workflow unload removes only the stale object references while polling and terminal details remain registered. Events are applied only when the matching IDs resolve to the exact node instances in `app.graph`; an existing registry entry is the batch-binding authority during concurrent failed-slot retries, while the serialized single batch property remains the restart-recovery fallback. A save node tracks active, saving, and terminal background batch IDs independently. Different failed slots can therefore submit against the same save node concurrently, update only their own slot, and finish independently; the node and generator remain busy until the last active retry batch terminates. Returning to the workflow rebinds terminal details to its current node instances, and a full browser refresh reconstructs the latest registration by querying `/o1key/image/jobs/{batch_id}` from the serialized save-node batch identity. This prevents off-screen nodes from receiving results or serializing metadata from the wrong active workflow. The native save preview uses the complete remaining node content area and recomputes its canvas-widget height on every resize; images retain `object-fit: contain` semantics. + +Nano Banana reference images are submitted directly in the generation JSON as `images[].inlineData`. Each item contains raw base64 in `data` (without a data-URL prefix) and an explicit `mimeType` derived from the encoded PNG or JPEG byte signature. The unified generator must not upload these references to obtain a temporary public URL. Concurrent output requests reuse the same encoded payload. + +Nano Banana has no `output_format` request parameter. Its completed-image byte signature is authoritative, so a PNG response remains PNG and a JPEG response remains JPEG until `O1keyImageSave` applies the generator's Banana-only local `格式` conversion. GPT Image's `output_format` is a provider API parameter accepting `jpeg / png / webp`. Seedream's `output_format` is also a provider parameter but accepts only `jpeg / png`; its request always includes `watermark: false`, with no serialized watermark widget. The local `格式` value is ignored for both provider-format model families. + +Seedream references use the documented `POST /v1/o1key/uploads` endpoint on the same globally selected base URL as generation. The multipart request contains only the `file` field and bearer authentication; the returned HTTPS URLs retain manifest order and become Seedream's `images` string array. Generation submits `dola-seedream-5-0-pro-260628-ep` to `POST /async/v1/generateImage`, then polls `GET /async/v1/tasks/{task_id}`. `o1key_image_catalog.py` maps Seedream's explicit `1K / 2K` choices and supported aspect ratios to documented exact `WIDTHxHEIGHT` values because the provider request has no separate aspect-ratio field. The default `智能` choice omits `size` and delegates sizing to the provider. Its panel capability exposes only the existing API `输出格式` control, restricted to `png / jpeg`; quality, background, moderation, mask, resize, and local `格式` stay hidden, and no serialized watermark widget is added. Seedream reuses the same idempotent task-query recovery, result validation, download retry, and node-wide error normalization as the other unified image families. + +Seedream reference validation mirrors the current Volcengine per-image contract at both entry points. Normal image-generation references must have both dimensions greater than 14 px, an inclusive width/height ratio of `1/16~16`, no more than `36,000,000` pixels, and an exact temporary-upload payload no larger than 30 MiB. Layer decomposition instead requires `262,144~36,000,000` pixels with the same ratio and byte ceiling. Browser-selected files are rejected before ComfyUI's native upload; direct execution validates every converted reference before creating concurrent provider tasks; background jobs validate all source and target manifests before making their immutable snapshots. `SeedreamImageClient` repeats the check immediately before `/v1/o1key/uploads`, so no caller can reach a reference upload or paid generation request with an out-of-contract image. The shared uploader normalizes unsupported source containers to PNG, so the provider receives only JPEG or PNG bytes while saved workflow descriptors and node inputs remain unchanged. + +Seedream layer decomposition is an append-only mode on `O1keyImageGenerator`. Widget index 23 stores `图层拆分=false` for old workflows. Enabled mode accepts exactly one reference, one provider request, optional prompt text, `智能 / 1K / 1.5K / 2K`, and PNG output; batching is rejected before upload. Historical serialized `auto` values remain accepted and are normalized to the equivalent visible `智能` choice by the panel. The client sends `layer_decomposition=true`, sorts returned images by `z_index`, and retains only bounded `z_index`, `size`, `output_format`, `bounding_box`, `name`, and `description` metadata. Result URLs remain live transport data and never enter workflow or history metadata. Background jobs preserve every provider image byte-for-byte in temp storage and distinguish request count from result count because one successful request may yield a base image plus sixteen layers. The save route promotes all descriptors in one request; the frontend then routes the first descriptor to the `IMAGE` save node and the remaining descriptors to a paired, auto-created `o1key 保存图层` node on `LAYERS`. The paired node stores only safe node-role and pairing IDs, so refresh recovery can rediscover the branch without duplicating provider work or saved files. Direct execution preserves the original first `IMAGE` port for the base and appends list-valued `LAYERS`, list-valued `LAYER_MASKS`, and JSON `LAYER_INFO` outputs; RGB plus a separate mask follows ComfyUI's IMAGE/MASK contract while allowing provider layers to have different dimensions. In layer mode the frontend normally exposes only `IMAGE`, `LAYERS`, and `LAYER_MASKS`; the less-used `LAYER_INFO` remains in backend position four but is hidden unless already connected. Outside layer mode, appended ports collapse to the highest connected output. This display-only policy cannot discard a saved-workflow link or change backend output order. + +Completed Nano Banana and GPT Image task queries use two recovery layers. Transport interruptions and incomplete JSON retry the idempotent result `GET`; after a successful JSON parse, inline Base64 must pass strict alphabet/padding validation and the decoded image must load completely. The shared response reader counts bytes while streaming so an exception retains the partial byte count. For uncompressed responses with a valid `Content-Length`, the completed byte count must match exactly; content-encoded responses skip this direct comparison because aiohttp/httpx expose decoded bytes, and responses without a declared length rely on clean stream completion plus JSON/image validation. Successful task-query responses remain silent. HTTP failures, interrupted reads, length mismatches, invalid JSON, and explicit returned-task-ID mismatches emit a compact terminal trace containing requested/returned task IDs, HTTP status/version, declared and received sizes, encodings, length verdict, JSON verdict, and the existing Eagleid when available. It must never log response bodies, Base64, authorization data, or signed result URLs. An explicit returned `task_id` must match the requested ID; absence remains compatible with providers that omit it. A failed inline-image validation re-fetches the same `task_id` with bounded exponential backoff and stable jitter, but never repeats the paid generation `POST`. HTTP image URLs retain their independent download-and-decode retries. Large response bodies and Base64 payloads remain disabled in logs by default. + +Error normalization belongs to the `O1keyImageGenerator` node boundary, not to a single provider model. Both standard execution and panel background jobs apply the same mapping after dispatching either GPT Image or any supported Nano Banana model. The unsafe-image response phrase `content rejected: the image was flagged as unsafe by the content safety system` maps to `内容被拒绝:该图像被内容安全系统标记为不安全。`, `Your request was rejected by the safety system` maps to `您的请求已被安全系统拒绝`, `insufficient balance` maps to `上游额度不足!`, `Image generation returned empty response` maps to `图片生成过程中被内容审查机制拒绝!`, and `The provided prompt is considered unsafe and it cannot be used to generate content` maps to `提供的提示被认为是不安全的,不能用于生成内容。`. All unrelated errors retain their existing diagnostic text or status mapping. The frontend repeats these narrow matches as a compatibility fallback for already-running or restored jobs. For standard executions, it updates the current ComfyUI error overlay through the overlay's stable `data-testid` hooks after Vue rendering, retaining the core title, dismissal, and details actions while replacing the generic body copy; it does not emit a duplicate short-lived toast. + +The exact compact UTF-8 request JSON for both Nano Banana and unified-node GPT Image has an 18 MiB local ceiling, leaving 2 MiB below the provider's 20 MiB boundary. `不缩放` rejects an oversized body before any paid request. `智能缩放` resamples the largest encoded references from their originals with aspect-preserving Lanczos until the exact serialized body fits; intermediate candidates are never resized from an earlier candidate. For GPT edits, the first reference and mask form one resize group so they retain identical dimensions. The UI must warn `可能发生像素偏移` whenever that mode is selected. Legacy standalone GPT client callers that omit the resize mode retain their existing automatic 20 MiB compatibility behavior. + +`O1keyImageGenerator`, `NanoBanana`, `BatchNanoBananaPro`, and `O1keyGPTImage` no longer expose or apply reference-guided colour correction. The frontend migration removes their former serialized correction values before ComfyUI maps positional widget arrays, preserving the settings that followed them. `O1keyGPTImageBatch` retains its correction control and uses `reference_color_correction.py` for batch outputs. + +GPT Image sizing is represented in the unified panel as separate resolution (`智能 / 1K / 2K / 4K`) and aspect-ratio controls. Resolution defaults to `智能`; all three unified provider families omit `size` at that value. `o1key_image_catalog.py` owns the mapping from an explicit tier plus ratio to the exact GPT pixel size; `智能` aspect ratio deliberately selects the square size for the chosen explicit tier (`1024x1024 / 2048x2048 / 2880x2880`) rather than sending a bare tier label, while legacy standalone GPT nodes keep their combined size labels. Active user controls are rendered as a single vertical list, with a fixed label column on the left and the control column on the right, and filtered through a frontend model-capability matrix: thinking level and online search are Nano Banana 2-only; resize mode is available to Nano Banana and GPT Image; API output format is available to GPT Image and Seedream; and quality, background, moderation, plus mask are GPT Image-only. Switching models updates existing field visibility without recreating controls, so model-specific values survive a round trip. Online search is omitted by default and becomes the top-level provider field `google_search: true` only when enabled for Nano Banana 2. Transport-only manifest widgets remain hidden. Nodes 1.0 group conversion automatically injects a `control_after_generate` widget for inputs named `seed` or `noise_seed`; the unified panel hides that generated widget because its embedded seed randomizer is the sole visible authority, while retaining the released `seed` input ID and serialized value for workflow compatibility. GPT Image background values (`auto / transparent / opaque`) and output formats (`png / webp / jpeg`) are validated before a paid request and travel unchanged through both direct execution and background jobs; `transparent` is rejected with `jpeg`, and the frontend removes JPEG from the available formats while transparency is selected. Seedream validates `png / jpeg` and hides unsupported GPT-only controls. Moderation accepts only the UI values `自动 / 低`; `自动` omits the provider parameter and `低` sends `moderation: "low"`. + +The unified image prompt editor's visible `AI帮写` action calls the compatibility route `POST /o1key/image/prompt-optimize`. The browser sends only the current prompt and sanitized ComfyUI input descriptors; the server resolves those paths inside the input root, creates ordered analysis images, and calls `gpt-5.6-sol` with `reasoning_effort=high` through the configured O1Key route. The request is non-streaming and uses a dedicated system instruction that prioritizes binding visual attributes to concrete subjects before expressing preserve/change directives. Reference analysis images use exact `image/jpeg` data URLs and the compact request body is capped at 18 MiB. API credentials remain server-side, and neither request bodies nor base64 image data may be logged. + +The released `NanoBanana` and `BatchNanoBananaPro` nodes no longer expose prompt optimization or colour correction. The unified `O1keyImageGenerator` retains its `AI帮写` action. Both Nano nodes expose only `1K / 2K / 4K`; saved `512 / 512px` values are migrated idempotently to `1K` before widget configuration. Their route combos use ComfyUI's display-only `getOptionLabel` hook to show `特价 / 优质 / 企业`, while the widget values, saved workflows, and provider matrix remain `畅速 / 直连 / 专线`. They share `nano_banana_async.py` for exact 18 MiB body enforcement, same-task polling recovery, inline-image validation, and download retries. The batch node represents JPEG/WebP compression quality as an integer input, and its frontend migration converts a serialized numeric string back to an integer before widget configuration. The batch node's final inputs are resize, output format, quality, naming rule, save path, and seed. The frontend migration removes the former correction value, appends `不缩放` when needed, and reorders the six trailing values while retaining saved path, quality, resize, and seed settings. + +`BatchNanoBananaPro` no longer creates random image pools. Every filled folder path participates in the selected pairing mode. Its saved-workflow migration removes the retired dynamic `图片随机抽取` widget value after earlier layout migrations and removes any connected input and link while preserving later socket indices. Saved workflows with multiple paths and `不配对` must select a pairing mode before execution. + +The released `O1keyGPTImage` and `O1keyGPTImageBatch` nodes reuse the same text-only prompt-optimization action and display-only route labels. The standalone node retains the order of remaining inputs and migrates its old `色彩纠正` and `内容审查强度` values out of the positional array; `缩放图片` and `背景` remain in order. The batch node retains all four controls, including colour correction. The batch node's operational inputs are visible rather than marked advanced. Both nodes pass an explicit resize mode to `GptImageClient`, selecting the same exact 18 MiB JSON ceiling as the unified generator while preserving the client's 20 MiB compatibility behavior for external callers that omit this argument. Background is validated before paid submission, and batch colour correction runs against each task's first reference before saving. Query retries remain restricted to the idempotent task `GET`; transient result-query statuses and result-download statuses use bounded backoff, and download diagnostics omit signed URLs. + +#### Seedance execution-error normalization + +`SeedanceElementCreate` is displayed as `Seedance 创建素材` and exposes the neutral `照片 / 视频 / 音频` input names while preserving its released node ID, widget order, and output order. `migrateWorkflow.js` rewrites the former `真人照片 / 真人视频 / 真人音频` socket names before graph configuration, and the backend accepts those former kwargs as execution-time aliases for API-workflow compatibility. The node preserves `HC` as the default request-mode value and appends `Doubao` for saved-workflow compatibility. Both modes use the unified `/v1/seedance/assets` create/query boundary; the client submits and polls with the normalized lowercase API `type` (`hc` or `doubao`). + +`SeedanceAutoPass` and `SeedanceMultiModal` share a narrowly scoped generation-error formatter at their node execution boundaries. When an upstream response contains `The request failed because the output video may be related to copyright restriction`, its plural `restrictions` form, or an `OutputVideoSensitiveContentDetected.PolicyViolation:` prefix, either node raises `输出视频触发版权审查被拒绝生成!`; unrelated exceptions preserve their original type and text. The frontend execution-error listener recognizes only these two released node IDs and replaces ComfyUI's generic persistent-overlay body after Vue rendering while retaining the native title, dismissal, and details actions. `SeedanceAutoPassBatch` is intentionally outside this mapping. + +`SeedanceMultiModal` uses V3 `Autogrow.TemplateNames` for its released numbered image, video, and audio inputs. The saved node ID and Autogrow leaf names remain stable; `migrateWorkflow.js` rewrites legacy flat input names such as `参考图片1` to `参考图片.参考图片1` and renames `真人素材IDn` inputs to `图片素材IDn` before graph configuration. Both migrations are safe to repeat, and the backend retains the former material-ID kwargs as aliases for API-workflow compatibility. Autogrow cannot retain editable string widgets because widget templates become connection-only, so the material-ID inputs remain ordinary append-order string widgets. `seedanceMultiModalDynamic.js` hides only the unused trailing widgets in each ID family, reveals one new empty row after the highest filled value, and recomputes the node height from the currently visible rows while preserving its width. This frontend visibility rule never reorders values, allowing legacy positional `widgets_values` to load unchanged. Before submission, the node prints its finalized request body to the ComfyUI log; the logged copy preserves ordinary parameters and `asset://` IDs but folds credentials, Base64 media, binary data, and HTTP(S) temporary media URLs without changing the submitted body. + +Seedance model capabilities are shared across the single, multimodal, and batch boundaries. Seedance 2.5 accepts 4–30 seconds, all four exposed resolutions (`480p / 720p / 1080p / 4k`), and 30 image, 10 video, and 10 audio content items; direct media and matching `asset://` IDs count toward the same per-type limit. Seedance 2.0 variants remain capped at 4–15 seconds and 9/3/3 content items, while fast and mini retain the `480p / 720p` resolution restriction. `SeedanceMultiModal` expands its Autogrow leaf-name lists to the 2.5 maxima. Its original 9 image, 3 video, and 3 audio ID widgets retain their exact positional order, and additional ID widgets are appended after that legacy block so saved positional `widgets_values` remain compatible; only the former image-widget name is migrated from `真人素材IDn` to `图片素材IDn`. + +`SeedanceMultiModal`, `SeedanceAutoPass`, and `SeedanceAutoPassBatch` expose both `国内` and `海外` model routes and default newly created nodes to `国内`; `海外` is the display-name replacement for the former `海外HC` value. The domestic route maps the four existing base-model choices to `doubao-seedance-2-0-260128-max`, `doubao-seedance-2-0-fast-260128-max`, `doubao-seedance-2-0-mini-260615-max`, and `doubao-seedance-2-5-260628-max`; it reuses the same capability envelope and new-format request body as the corresponding overseas choices. `SeedanceAutoPass` exposes only `多模态` and `首尾帧`: the backend resolves prompt-only multimodal calls to `text`, and resolves one or two frame images to `first_frame` or `first_last_frame`. Its `素材创建` branch mirrors the unified generator's `auto/manual` contract; automatic media is validated and converted into HC/Doubao assets, while manual IDs bypass upload and enter `build_seedance_video_body` through the `assets` fields. The node translates its Chinese widget values into `o1key_video_catalog.py` and submits through `SeedanceClient`, so the unified panel and graph node cannot drift in scalar parameters or request shape. `migrateWorkflow.js` performs an idempotent compatibility migration for the former four mode values, moved web-search widget, former route label, last-frame default, and newly added asset-creation selector. + +#### Panel-driven unified video generation + +`O1keyVideoGenerator` exposes append-only `VIDEO` and `LAST_FRAME` outputs while keeping paid generation exclusively behind `POST /o1key/video/jobs`. Every panel click creates and connects a native `SaveVideo`; when `return_last_frame` is enabled, it also creates and connects a native `SaveImage`. The completed safe descriptors are dispatched to those native nodes for preview and persisted on them for workflow reload recovery. The generator's native `execute` may resolve only the latest completed local descriptors and can never submit or retry a paid request. `O1keyVideoResult` remains registered as deprecated compatibility support for saved workflows but is never created by the current panel. `ParallelVideoJobManager` still starts every accepted job immediately without entering ComfyUI's native queue or imposing an internal concurrency ceiling. Provider-side quotas and rate limits remain authoritative. + +Review-error normalization for `O1keyVideoGenerator` belongs to the `ParallelVideoJobManager` failure boundary, so every current and future provider adapter uses the same mapping. A case-insensitive `copyright` marker takes priority; `audio`, `video`, `content`, and `real` subject fields or keywords distinguish output-audio, output-video, prompt, and real-person failures. Other safety, moderation, rejection, and policy-violation messages use the same subject classification with review-specific Chinese text. Errors without those markers retain their original diagnostic text. + +The video generator deliberately reuses the image generator's frontend grammar: a 560-pixel default width, prompt card, single-column label/control rows, custom dropdowns, 102-pixel media tiles, compact status text, and a light primary action. Reference-video tiles reuse the sanitized input descriptor through `/view` in a muted, non-playing native `