Compare commits

..
8 Commits
Author SHA1 Message Date
Jony 10a8700589 Restart ComfyUI automatically after plugin updates 2026-09-24 22:27:38 +08:00
Jony 96010c058f Add update verification button to sidebar 2026-09-24 22:10:42 +08:00
Jony 0cf99740c0 Polish customer update flow and prepare ZIP installation 2026-09-24 21:52:01 +08:00
Jony ba920f2b66 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.
2026-09-24 19:56:48 +08:00
lizhongyi1209 3e337722ab Publish GitHub 5fccb3e release tree 2026-09-24 10:59:26 +00:00
lizhongyi1209 3a05bb6277 Document first sync from release repository 2026-09-24 10:27:46 +00:00
lizhongyi1209 322b25acaa Document single Gitea release workflow 2026-09-24 10:24:09 +00:00
Codex fa9d571c18 Harden local updater and install changed dependencies 2026-09-24 10:07:37 +00:00
185 changed files with 50222 additions and 9937 deletions
-756
View File
@@ -1,756 +0,0 @@
# Comfyui_o1key 开发指南
## 对话原则
始终使用中文进行对话。
## 编码规范 ⚠️ 重要
### 文件编码要求
- **所有文本文件必须使用 UTF-8 编码(无 BOM**
- **行结束符使用 LFUnix 风格),Windows 批处理文件除外(CRLF**
- 项目已配置 `.gitattributes` 和 `.editorconfig` 来自动处理编码
### 编辑器配置
确保编辑器设置:
- 文件编码:UTF-8(无 BOM
- 行结束符:LF
- 自动插入文件末尾空行:开启
## Git 提交规范
### Commit Message 规范
- **所有 commit message 必须使用英文**,避免中文编码问题
- 使用 Conventional Commits 格式:`<type>: <description>`
### 常用类型
- `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`
更新脚本会自动:
- 检查远程更新
- 备份配置文件
- 拉取最新代码
- 更新依赖包
- 显示更新日志
---
+10
View File
@@ -24,3 +24,13 @@ Thumbs.db
# 用户配置(含 API Key,不提交)
.config
# 本地开发工具与浏览器测试状态
.claude/
.codex/
.playwright-mcp/
# 测试与覆盖率缓存
.pytest_cache/
.coverage
htmlcov/
+86
View File
@@ -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`
+193 -106
View File
@@ -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,24 @@
```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 拉取慢或失败时
### 方法三:客户压缩包安装(Windows 便携版
使用 Gitee 镜像安装与更新,避免网络问题
把压缩包中的 `comfyui_o1key` 文件夹完整解压到 `ComfyUI\custom_nodes\`,不要只复制其中的 Python 文件。然后在插件目录运行
```bash
cd ComfyUI/custom_nodes
git clone https://gitee.com/resonLzy/comfyui_o1key.git
cd comfyui_o1key
pip install -r requirements.txt
```powershell
..\..\..\python_embeded\python.exe -m pip install -r requirements.txt
```
自动更新脚本(见下方「更新插件」)已改为从 Gitee 拉取,国内用户可直接使用
重启 ComfyUI 后,在左侧「令牌管理」中填写自己的 API Key。不要把其他人的 `.config` 文件复制到新安装目录。使用内置更新还需要系统能够运行 `git --version`
Windows 用户也可以在 ComfyUI 左侧侧栏打开“更新”面板更新插件;使用前请阅读下方关于本地修改的提示。
---
@@ -58,45 +67,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 +85,201 @@ 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
```text
O1KEY_API_BASE_URL=https://your-api-domain.com
O1KEY_ASYNC_API_BASE_URL=https://your-async-api-domain.com
```
# Linux/Mac
export O1KEY_API_BASE_URL=https://your-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. 等待版本检查。如果发现新版本,确认后等待更新完成;需要时可在面板中重新检查。
3. 更新完成后,ComfyUI 会自动重启并刷新页面。如果面板提示需要技术支持,请联系维护人员完成配置后再重启。
如果提示依赖列表已变化,请在 ComfyUI 使用的 Python 环境中执行:
> 首次从旧版本更新到支持自动重启的版本时,请按旧版面板提示手动重启一次;之后的更新会自动重启。
> 如果当前安装包含自定义修改,面板会停止更新并保留现有内容。此时请联系维护人员处理。
### 方法二:手动更新
从 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 的图片质量为 `1100` 的整数,默认 `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 系列支持 415 秒,Seedance 2.5 支持 430 秒;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,宽高各为 `3006000px`、比例为 `0.42.5`;视频仅支持 MP4/MOV,单项不超过 512MB,并额外要求总像素为 `407,6968,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 和已上传图片/视频/音频。直接上传的首帧、尾帧和多模态参考图片遵循火山方舟官方输入限制:宽、高各为 `3006000px`,宽高比为 `0.42.5`;官方没有为参考图片另设总像素下限。参考视频除相同的宽高及比例限制外,总像素必须在 `407,6968,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`515 秒,不支持参考素材模式
- 查询任务按接口建议每 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 +298,8 @@ Google Gemini 模型节点,支持更多模型选择。
欢迎提交 Issue 和 Pull Request
开始开发前请阅读 [AGENTS.md](AGENTS.md) 和 [维护者文档](docs/README.md)。
---
## ⚠️ 开发者注意事项
@@ -221,29 +323,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
---
-148
View File
@@ -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()
"""
+706 -97
View File
@@ -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,168 @@ try:
from aiohttp import web
from server import PromptServer
import folder_paths
from .utils.config import CONFIG_FILE, load_config, NETWORK_ROUTES
from .utils.updater import UpdateError, update_package
import threading as _update_threading
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, check_for_update, update_package
_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.get("/o1key/update/check")
async def check_o1key_update(request):
if not _o1key_update_lock.acquire(blocking=False):
return web.json_response(
{"code": "update_in_progress", "error": "更新正在进行。"}, status=409,
)
try:
result = await asyncio.to_thread(check_for_update)
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": "检查更新失败。"}, status=500,
)
finally:
_o1key_update_lock.release()
@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 +410,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 +442,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 +554,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 +634,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 +1126,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,17 +1226,58 @@ 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
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)}
resp = web.StreamResponse(
status=200, reason="OK",
@@ -683,22 +1290,24 @@ try:
await resp.prepare(request)
try:
timeout = _aiohttp.ClientTimeout(total=120)
async with _aiohttp.ClientSession(timeout=timeout) as session:
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())
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 = {}
+440
View File
@@ -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": {}
}
+31 -13
View File
@@ -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
-183
View File
@@ -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}"
+74
View File
@@ -0,0 +1,74 @@
"""
可灵主体(ElementAPI 客户端
封装对 {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):
"""从响应信封取 datasuccess=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
+2 -2
View File
@@ -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 接口,
将图像编辑+超分辨率任务提交到远程服务器执行。
"""
-194
View File
@@ -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)
+33 -15
View File
@@ -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 响应,提取生成的图像
@@ -433,6 +435,24 @@ class GeminiAPIClient(BaseAPIClient):
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):
content = candidate.get("content", {})
@@ -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,14 +496,13 @@ 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()
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))
_tag_image(img, img_bytes)
images.append(img)
if format_info["type"] is None:
@@ -514,14 +534,13 @@ 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()
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)
# 记录格式信息(只记录第一张)
@@ -538,14 +557,13 @@ class GeminiAPIClient(BaseAPIClient):
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)
# 记录格式信息
File diff suppressed because it is too large Load Diff
+126 -23
View File
@@ -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)}")
+257 -379
View File
@@ -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 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("提示词不能为空")
raise ValueError("参考图/音频生视频必须填写提示词。")
if resolution == "1080p":
raise ValueError("参考图/音频生视频不支持 1080p。")
elif not normal_image and not prompt:
raise ValueError("文生视频必须填写提示词。")
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)}")
try:
seconds_value = int(seconds)
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。")
if resolution == "1080p" and model != cls.LATEST_MODEL:
raise ValueError("1080p 仅支持 grok-imagine-video-1.5 的文生或图生视频")
body: Dict[str, Any] = {
"model": model,
"prompt": prompt,
"duration": duration,
"aspect_ratio": aspect_ratio,
"seconds": str(seconds_value),
"quality": api_quality,
"resolution": resolution,
}
image_list = [img for img in (images or []) if img]
if image_list:
body["images"] = image_list[:3]
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
@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"
if model not in cls.MODEL_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:
duration = int(duration)
except (TypeError, ValueError):
raise ValueError("续写时长必须是整数。") from None
if not 2 <= duration <= 10:
raise ValueError("视频续写时长仅支持 2 到 10 秒。")
return {
"model": model,
"prompt": prompt,
"video": normal_video,
"duration": duration,
}
@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"<data-url chars={len(item)}>" 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+/=_-]+", "<base64 omitted>", message)
message = re.sub(r"https?://[^\s\"'<>]+", "<temporary URL omitted>", 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
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
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})...")
print(f"Grok VideoHTTP {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)
continue
raise RuntimeError(f"Grok Video 网络错误: {e}") from None
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 Videocontent 下载重试 {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:正在提交{operation}任务…")
created = await self._request_json(
"POST", endpoint, session, json_body=body, timeout_seconds=180
)
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,
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_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)
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.urlrequest_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())
-285
View File
@@ -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
+242
View File
@@ -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
+18 -43
View File
@@ -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
break # 非 JSONcontent 端点即视频流(幂等 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]:
+358
View File
@@ -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\"'<>]+", "<URL 已隐藏>", 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+/=]+", "<Base64 已隐藏>", 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}")
-784
View File
@@ -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/<model-name>:generateContent
# 提取 <model-name> 部分
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", "")
# ── 情况 AOpenAI 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,<data>
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
# ── 情况 BGemini 风格 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
# ── 情况 Ctext 中嵌套 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},<base64 {len(data)} chars>"
if len(obj) > 200 and all(
c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
for c in obj[:64]
):
return f"<base64 {len(obj)} chars>"
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},<base64 {len(data)} chars>"
if len(obj) > 200 and all(
c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
for c in obj[:64]
):
return f"<base64 {len(obj)} chars>"
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)
+46 -25
View File
@@ -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")
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
+320
View File
@@ -0,0 +1,320 @@
"""
Seedance 2.0 真人素材(ElementAPI 客户端
封装标准素材接口与高并发素材接口
"""
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: 渠道ID0表示自动选择
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()
+409
View File
@@ -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×512262144)~6000×600036000000)之间,"
f"当前为 {width}×{height}{pixels}"
)
return
if pixels > SEEDREAM_REFERENCE_MAX_PIXELS:
raise ValueError(
f"{label}总像素不能超过 6000×600036000000),"
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",
]
+11 -12
View File
@@ -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("视频下载失败: 响应中未找到下载链接")
if download_url:
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)
# 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:
+11 -12
View File
@@ -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("视频下载失败: 响应中未找到下载链接")
if download_url:
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)
# 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:
+33
View File
@@ -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?
+264
View File
@@ -0,0 +1,264 @@
# 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` places an Update button directly below the Token Manager button in the left toolbar, before Restart. Opening the Update dialog immediately calls `GET /o1key/update/check` and asks for confirmation only when a newer version is available; confirmation calls `POST /o1key/update`. Both routes delegate to `utils/updater.py` and share a lock. The updater fetches the public `main` branch from `https://git.o1key.com/publisher/comfyui_o1key.git` without changing the user's `origin`. It reports an already current installation before checking local modifications, and only fast-forwards a clean local Git `main` when an update exists. Local tracked changes, divergent history, and file collisions receive structured error codes; the UI maps those codes to customer-facing messages without exposing the repository or Git details. It never resets or cleans the worktree. After a successful update with unchanged requirements, the frontend invokes the shared `web/js/o1keyRestart.js` flow, waits for a new process boot ID and a ready system endpoint, then reloads the page. A requirements change still asks for maintenance before restart; an automatic restart failure leaves the manual Restart button available.
The updater dialog keeps the check result, update confirmation, progress, and retry actions in one ComfyUI-styled modal. Opening it runs the check; a newer version changes the primary action to “立即更新”, while “稍后再说” closes without updating.
Because an update runs in the old frontend and server process, installations upgrading from a version without automatic restart must restart manually once to load this behavior.
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/<uuid>/`, 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/<date>/<batch-id>/` 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 18 (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 `<ComfyUI user directory>/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/1616`, no more than `36,000,000` pixels, and an exact temporary-upload payload no larger than 30 MiB. Layer decomposition instead requires `262,14436,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 430 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 415 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 `<video>` element, seek to the first decodable instant after metadata loads, and retain the icon/name fallback when decoding fails. Re-rendering releases removed video sources so stale elements do not keep network or decoder resources. No canvas capture, Base64 poster, FFmpeg process, server route, or generated thumbnail file is involved. Mode-specific media sections are mounted above the prompt and hidden when irrelevant; the node height follows the active mode instead of reserving blank space.
Video image tiles call the shared `o1keyReferenceImageEditor` and replace the exact source descriptor with a newly uploaded, non-overwriting PNG only if that source is still present. Their header actions also call the image generator's namespaced canvas-image picker: candidate discovery and `/view` loading remain single-source, while the selected file is validated against the Seedance image envelope and copied through the normal non-overwriting ComfyUI input upload before entering the video manifest. This is available for the first-frame, last-frame, and multimodal image sections, but not for video or audio sections. Multi-item image, video, and audio tracks reorder their manifest arrays directly through drag-and-drop (with Alt+arrow keyboard parity); visible order badges therefore match provider submission order. Single first/last-frame slots remain replaceable and editable but are not reorderable.
The video prompt editor's `AI帮写` action calls `POST /o1key/video/prompt-write`. It uses a video-only default system preset with `gpt-5.6-sol`, high reasoning, and non-streaming output; the preset emphasizes temporal continuity, subject/action binding, camera motion, visual consistency, first/last-frame transitions, and synchronized sound when audio generation is enabled. The browser submits scalar generation context plus sanitized input-image descriptors. The server derives image roles from the generation mode and analyzes them in manifest order under the input-root boundary. Reference video and audio content is not sent to the writing model; only bounded counts are supplied so the preset does not invent unseen media details. The API key remains server-side and the exact multimodal body retains the shared 18 MiB ceiling.
The first provider adapter is Seedance. `o1key_video_catalog.py` is the canonical public model/capability matrix; `o1key_video_jobs.py` validates model, route, mode, asset-creation policy, duration, resolution, media counts, descriptors, and save location before uploading media or submitting provider work. Direct first-frame, last-frame, and multimodal reference images follow the published Seedance bounds of `3006000px` per dimension and an inclusive `0.42.5` aspect ratio; no image total-pixel floor is invented locally. Reference videos use the same dimension and ratio bounds plus the official inclusive `407,6968,295,044` total-pixel range. The frontend rejects readable invalid media before upload, while the authoritative PyAV backend check runs before snapshot copying, upload, or paid provider work; a browser codec limitation does not by itself reject an otherwise supported MOV/H.265 file. The append-only `asset_creation_mode` generator widget defaults to `auto`; `migrateWorkflow.js` appends that value to older positional workflows. Automatic mode stores browser-selected media as sanitized `type=input` descriptors and copies them into `temp/o1key_video_jobs/<batch-id>/inputs` before the background task starts, isolating repeated submissions from later file changes. It then uses the shared `seedance_assets.py` service for both routes: overseas HC maps to asset API `type=hc`, domestic maps to `type=doubao`, and video submission receives only `asset://` references after every material reaches `Active`. Material preparation is bounded to three concurrent items and preserves manifest order. A route-and-media-type-scoped SHA-256 cache under `<ComfyUI user directory>/o1key/seedance_asset_cache.json` stores only content fingerprints, asset IDs, and timestamps; a hit is queried for `Active` before reuse and skips the upload. Completed material IDs are included in safe job summaries, allowing a failed video submission to retry as a manual-ID request without recreating material. Manual mode accepts validated image, video, and audio asset IDs from `SeedanceElementCreate`, excludes hidden/stale upload descriptors from the submitted request, and maps one or two image IDs to first-frame or first/last-frame roles when those generation modes are selected. Legacy requests that already combined direct references with the former `persons` ID key remain accepted and retain their combined capability limit. Upload URLs, local paths, credentials, and provider response bodies are never written to the cache or job history. Both unified image and video generator panels reserve the prompt editor as their single vertically flexible region: its minimum height remains fixed for compact layouts, while any user-added node height expands the editor instead of leaving blank space below the primary action.
Completed videos are atomically promoted into the configured output location with collision-safe names. An absolute external destination receives an additional path-free preview copy in ComfyUI temp. Auto-created native save nodes persist only the batch identity, generator association, safe output descriptors, compact terminal state, and the sanitized request required for recovery. Reloading a workflow restores their previews and resumes polling unfinished jobs. Terminal summaries are atomically bounded in `<ComfyUI user directory>/o1key/video_jobs.json`. See [ADR 0008](decisions/0008-native-video-save-outputs.md); [ADR 0007](decisions/0007-panel-driven-video-jobs.md) remains the historical job-scheduling decision.
## State and storage
- `.config`: plugin-local credentials and route settings; ignored by Git.
- `ComfyUI/input/o1key-notes.json`: persistent user notes.
- `cases/*.json`: bundled runtime case definitions.
- ComfyUI `input/`, `output/`, and `temp/`: uploaded references, generated artifacts, previews, and job snapshots.
- Saved workflows: node IDs and positional widget values; treat as long-lived external data.
## Compatibility invariants
- Node IDs and mapping keys are stable APIs.
- Secrets never enter workflow JSON.
- Long operations remain interruptible.
- Retried requests respect non-retryable status codes and server retry hints.
- Nodes whose provider protocol requires temporary uploads must resolve them to HTTPS URLs before paid generation begins; Nano Banana image references use validated inline base64 instead.
- File paths from HTTP requests are resolved beneath their intended ComfyUI root.
- Frontend migrations are narrow and idempotent.
- `O1keyImageSave` renders results only through the ComfyUI native preview; dual previews are forbidden.
- A saved `O1keyImageSave` result must survive workflow reload and browser refresh through its serialized image descriptors.
- Background generators produce temp descriptors; only `O1keyImageSave` writes new permanent results into ComfyUI's configured output root.
- `O1keyImageSave` has no user-configurable widgets; it reads validated generator-owned save settings and remains the sole permanent writer.
- `O1keyImageGenerator` appends `命名规则`, `filename_prefix`, `格式`, and `保存位置` at indexes 1720; old connected save-node values migrate into those slots once and are then removed from the save node.
- Local `格式` is Banana-only. GPT Image saves its API-selected container as `原始`, and its API `输出格式` defaults to lowercase `png` for new GPT panel selections.
- Seedream saves its API-selected `png / jpeg` container as `原始`, uploads references through `/v1/o1key/uploads`, always sends `watermark=false`, and has no watermark widget. Layer decomposition forces PNG and preserves every returned transparent layer.
- Unified image-generation batch manifests hold at most fifty sources or targets, while every provider request still uses at most ten references and uses GPT counts 18 or the other-model values `1`, `2`, `4`, and `9`.
- GPT Image batches never send a provider request with `n` greater than `1`.
- Seedream batches never send a provider request with `n` greater than `1`.
- Seedream layer decomposition is one request with one reference; result cardinality is independent and may be 117 images.
- Standard and panel-triggered unified image runs reuse a connected blank `O1keyImageSave` before creating another result node, and never overwrite populated sibling save nodes.
- Unified GPT Image requests preserve the selected background and output format; transparent backgrounds are limited to PNG and WebP.
- Unified GPT Image requests omit the retired moderation parameter.
- `GPT Image 2.5 Sunburst` and `GPT Image 2.5 Flare` share GPT Image 2's resolution/aspect-ratio matrix and capability controls. Their `畅速 / 直连 / 专线` route values resolve to API IDs ending in `-sp / -sd /` no suffix, respectively.
- The GPT Image batch node pairs every populated folder through its selected pairing mode and runs the resulting tasks concurrently. It has no random-folder selector or concurrency widget; workflow migration removes both values from older saved nodes.
- The Nano Banana batch node's optional colour correction never resizes the generated image, uses only reference image 1, and runs exactly once after generation and before save; its standalone counterpart and `O1keyImageGenerator` have no correction stage.
- Route-label changes never alter the serialized values `畅速`, `直连`, and `专线`.
- `SeedanceAutoPass` retains a visible `素材创建模式` combo with default `关闭` (automatic assets and hidden IDs) and `打开` (manual IDs shown below). Material IDs use ordinary numbered single-line widgets in image/video/audio order (30/10/10), revealing one empty successor after the highest filled row as in `SeedanceMultiModal`. Visibility never removes serialized widgets or clears IDs. The former `素材创建` input and `自动创建 / 手动` values migrate to this toggle without shifting its position; aggregate fields migrate into numbered rows, and legacy API kwargs remain accepted. Web-search, seed, and last-frame parameters remain at the bottom. The ordinary generation-mode combo drives actual media-socket removal/restoration in the frontend: only progressive reference-image/video/audio sockets in multimodal, only first/last-frame sockets in frame mode. Inactive Autogrow groups are suspended to prevent ghost sockets; switching disconnects removed media links but preserves unrelated widget sockets, and saved workflows are reconciled after configuration.
+59
View File
@@ -0,0 +1,59 @@
# Configuration
## Source of truth
Runtime credential and route behavior is implemented in `utils/config.py`. Update this document and `README.md` whenever supported keys or precedence changes.
## Configuration file
The plugin stores settings in a UTF-8 `.config` file at the plugin root:
```text
O1KEY_API_KEY=replace-with-your-key
O1KEY_NETWORK_ROUTE=全球加速
```
Use the ComfyUI sidebar's「令牌管理」window to write settings atomically. `.config` is ignored by Git and must never be copied into docs, fixtures, logs, screenshots, or issue reports.
Supported file keys:
| Key | Purpose |
| --- | --- |
| `O1KEY_API_KEY` | Authentication token used by provider clients |
| `O1KEY_NETWORK_ROUTE` | Global named route selected by the settings UI |
| `O1KEY_API_BASE_URL` | Custom synchronous fallback base URL |
| `O1KEY_ASYNC_API_BASE_URL` | Custom asynchronous fallback base URL |
Omni Flash reads `O1KEY_API_KEY` through the same configuration helper as image generation. Connected media uploads and generation use the selected O1Key network route; the key never becomes a workflow widget value.
Named routes and defaults are defined in `utils/config.py`; do not duplicate endpoint constants in nodes.
## Resolution behavior
- A recognized global named route resolves to its configured endpoint.
- Direct base-URL helpers fall back to the custom URL and then the built-in default when no named route is stored.
- Route-aware callers using `get_base_url_by_route()` use the explicit route when supplied, otherwise the global route.
- Clients that cache configuration should key that cache with `get_runtime_config_signature()` so UI changes take effect without restarting.
## Diagnostic environment flags
Environment variables are currently used for logging behavior, not as the primary credential store:
| Variable | Effect |
| --- | --- |
| `O1KEY_DEBUG_LOG=1` | Enables Nano Banana debug logging |
| `O1KEY_VERBOSE_LOG=1` | Enables verbose transport logging |
| `O1KEY_RESPONSE_LOG=1` | Enables response logging where supported |
Verbose response logging can expose sensitive metadata, so keep it disabled by default and never enable it in committed test configuration. Complete base64 media payloads are always redacted from logs, including when verbose logging is enabled.
## Security rules
- Mask API keys in UI responses and error reports.
- Do not serialize credentials into ComfyUI workflows.
- Do not log authorization headers, signed URLs, or complete user media payloads.
- Keep `.config` during plugin upgrades and exclude it from cleanup scripts except for explicit user-requested credential removal.
## Runtime history storage
Completed, failed, and cancelled panel-image jobs are indexed at `<ComfyUI user directory>/o1key/image_job_history.json`. This is runtime UI state rather than configuration and is capped at 200 records. It contains only batch and node IDs, terminal state, bounded counts/timestamps/error summaries, and sanitized ComfyUI image descriptors. Prompts, reference manifests, local absolute save paths, credentials, Base64 payloads, and signed URLs must never enter this file. Native single-item history deletion and clear-history actions update the same index atomically.
+25
View File
@@ -0,0 +1,25 @@
# NNNN: Decision title
- Status: Proposed
- Date: YYYY-MM-DD
- Owners: Maintainers
## Context
What problem, constraint, or compatibility risk requires a durable decision?
## Decision
What will the project do?
## Consequences
What becomes easier, harder, required, or intentionally unsupported?
## Alternatives considered
Which realistic alternatives were rejected, and why?
## Validation
Which tests, measurements, or operational checks prove the decision works?
@@ -0,0 +1,39 @@
# 0001: AI-native repository guidance and documentation
- Status: Accepted
- Date: 2026-08-29
- Owners: Maintainers
## Context
The plugin accumulated implementation notes, local tool state, root-level tests, and stale scripts. Important knowledge was scattered between code and temporary reports, making it difficult for a new maintainer or coding agent to identify runtime boundaries, compatibility constraints, and the correct validation commands.
Codex supports layered `AGENTS.md` files from repository root to working directory. ComfyUI also auto-loads frontend files, so generic repository assumptions can be unsafe for `web/` and node-schema work.
## Decision
- Use a concise root `AGENTS.md` for repository-wide executable rules.
- Use scoped `AGENTS.md` files only in `nodes/`, `web/`, and `tests/`, where local constraints materially differ.
- Use `docs/` as the structured maintainer knowledge base.
- Keep user documentation in root `README.md` and durable design choices in numbered ADRs.
- Keep one-off cleanup reports under `docs/maintenance/`.
- Keep tests in `tests/` and run them in isolated processes through `tests/run_all.py`.
## Consequences
- Agents receive the right constraints close to the files they edit without overloading the root instruction file.
- Architecture and operational knowledge becomes reviewable and versioned.
- Changes that affect configuration, compatibility, or runtime boundaries must update documentation in the same work unit.
- Maintainers must keep links and commands synchronized as the portable layout evolves.
## Alternatives considered
- One large root instruction file: rejected because it mixes executable rules with background knowledge and approaches instruction-size limits as the project grows.
- Documentation only in README: rejected because user guidance and maintainer internals have different audiences and change rates.
- Tool-specific hidden configuration directories: rejected because they are not portable across agents and should not be runtime project state.
## Validation
- Verify root and scoped instruction files are discoverable from their directories.
- Verify every path linked from `docs/README.md` exists.
- Run the full isolated test suite and the plugin import smoke test after structural changes.
@@ -0,0 +1,37 @@
# 0002: Save-node ownership of generated image formats
- Status: Superseded by [0003](0003-generator-owned-image-save-configuration.md)
- Date: 2026-08-30
- Owners: Maintainers
## Context
The unified background generator previously decoded provider results and permanently re-encoded Nano Banana output as PNG. This obscured whether `output_format` was a provider capability or a local save choice, and discarded the original PNG/JPEG container returned by Nano Banana. The released `O1keyImageSave` node also delegated to ComfyUI's PNG-only helper.
## Decision
Generation paths retain provider bytes and detected format. Panel-triggered jobs place those bytes in ComfyUI temp storage and return `type=temp` descriptors. `O1keyImageSave` is the only component that promotes them into output storage, through its append-only `格式` widget and the batch-bound `/o1key/image/save` route.
`格式=原始` preserves PNG/JPEG/WebP compressed image data; workflow metadata may be inserted into the container without recompressing pixels. Explicit PNG and WebP use lossless encoding. Explicit JPEG uses quality 100 with 4:4:4 subsampling. If pixels were modified after generation or original bytes are unavailable, `原始` falls back to PNG.
Nano Banana payloads never contain `output_format`. GPT Image keeps its API-level `output_format`, which determines provider output but does not bypass the save node.
## Consequences
- Nano Banana PNG and JPEG responses remain distinguishable and recoverable.
- API output options and local save options have separate owners and labels.
- Background generation must retain temp results until the save route succeeds.
- Original-format preservation requires private in-memory tensor metadata on the direct execution path; ordinary downstream tensor operations may invalidate it and trigger the PNG fallback.
- Existing workflows gain only one appended save-node widget and migrate to `原始` idempotently.
## Alternatives considered
- Continue saving every background result as PNG. Rejected because it destroys the provider container and implies a Nano Banana capability that does not exist.
- Let the generator select the permanent local format. Rejected because it duplicates the output responsibility and leaves the save node semantically misleading.
- Store original bytes inside workflow JSON. Rejected because large binary payloads do not belong in workflows and would make saved graphs unsafe and impractical.
## Validation
- `tests/test_o1key_image_save.py` verifies original JPEG/PNG preservation, metadata injection, explicit conversions, and PNG fallback.
- `tests/test_o1key_image_jobs.py` verifies Nano Banana ignores GPT-only fields, jobs produce temp descriptors, and save-route path binding.
- `tests/test_o1key_image_generator_frontend.mjs` verifies temp promotion, workflow metadata submission, result restoration, and idempotent widget migration.
@@ -0,0 +1,40 @@
# 0003: Generator-owned image save configuration
- Status: Accepted
- Date: 2026-09-02
- Owners: Maintainers
- Supersedes: [0002](0002-save-node-image-format-ownership.md)
## Context
The unified image workflow exposed naming, local format conversion, and output location on every `O1keyImageSave` result node. A single generator may create several result nodes, so this duplicated settings and made model-level behavior hard to understand. GPT Image's API `output_format` is also semantically different from Nano Banana's optional local save conversion.
Moving released positional widgets between nodes risks silently changing saved workflows. Multiple old save nodes may also carry different settings even though the new design has one shared generator configuration.
## Decision
`O1keyImageGenerator` owns four append-only save inputs at indexes 1922: `命名规则`, `filename_prefix`, `格式`, and `保存位置`. `命名规则` keeps the serialized value `自定义前缀` for compatibility but displays `自定义`. `O1keyImageSave` removes all configuration widgets and remains the sole permanent file writer and result renderer.
Direct execution attaches validated save settings to the output tensor. Panel jobs snapshot them in the server-side job record at submission, and the save route prefers that immutable snapshot. The frontend migration finds the generator connected to each legacy save node, moves the first connected save node's positional settings into the generator, clears migrated save-node values, and is safe to repeat.
Local `格式` is visible and effective only for Banana models. GPT Image always uses local `原始`; its independent API `输出格式` displays `JPEG / PNG / WebP`, stores lowercase values, and defaults new nodes to `jpeg`. Legacy generators retain saved API-format values and use the historical `png` default only when migrating workflows that predate that field.
## Consequences
- One generator controls naming and destination for all of its current and future result nodes.
- Multiple legacy save nodes with different settings cannot all be represented; migration deterministically uses the first connected save node.
- Existing node IDs, image ports, and the first nineteen generator widget positions remain unchanged.
- Save-node preview and retry behavior remains independent from save configuration.
- GPT provider format and Banana local conversion have separate controls and cannot accidentally override one another.
## Alternatives considered
- Keep configuration duplicated on every save node. Rejected because it conflicts with generator-wide settings and obscures provider capabilities.
- Keep only `filename_prefix` on the save node. Rejected because the default custom naming rule would then be split across two nodes.
- Rename the serialized `自定义前缀` value to `自定义`. Rejected because the visible label can change without breaking saved workflows or backend validation.
## Validation
- `tests/test_o1key_image_generator.py` verifies schema positions, defaults, tensor-carried settings, GPT lowercase `jpeg`, and the parameter-free save node.
- `tests/test_o1key_image_jobs.py` verifies immutable job save settings and GPT's forced local `原始` behavior.
- `tests/test_o1key_image_generator_frontend.mjs` verifies model-dependent visibility, temp promotion settings, zero-widget result nodes, and idempotent cross-node migration.
@@ -0,0 +1,36 @@
# 0004: Native-recoverable image workflow metadata
- Status: Accepted
- Date: 2026-09-04
- Owners: Maintainers
## Context
`O1keyImageSave` already serialized ComfyUI's `prompt` and `extra_pnginfo`, but original JPEG provider results were kept as JPEG and stored those values in EXIF. The current ComfyUI frontend restores embedded workflows from PNG and WebP only; its file metadata parser has no JPEG workflow branch. Large workflows can also exceed JPEG's APP1 segment length, causing the EXIF payload to be omitted even though the image itself still saves.
Panel-triggered generation bypasses the native prompt executor. It previously obtained the API prompt from `app.graphToPrompt()` but serialized the workflow through a separate `graph.serialize()` call, allowing the two metadata values to describe different graph snapshots.
## Decision
Use ComfyUI's native `SaveImage` field names and PNG container behavior for every JPEG target that carries a workflow. Store the API prompt as PNG text key `prompt` and the serialized graph as PNG text key `workflow`. This conversion applies to original JPEG provider bytes and to an explicit JPEG save choice. It is skipped when ComfyUI's global metadata setting disables metadata or when no workflow is present.
PNG and WebP sources retain their existing metadata paths. Panel saves take both `output` and `workflow` from one `app.graphToPrompt()` result, with direct graph serialization retained only as a compatibility fallback for older frontends.
## Consequences
- Saved o1key output images can be loaded or dragged into ComfyUI to restore their workflow using the native parser.
- Workflow-bearing JPEG requests produce a `.png` permanent artifact, so recoverability takes precedence over preserving the requested JPEG container.
- Large workflow JSON is no longer constrained by JPEG APP1 length.
- JPEG bytes remain untouched when no workflow metadata is being written.
- Existing JPEG files that were saved without readable workflow metadata cannot be repaired retroactively.
## Alternatives considered
- Add a custom JPEG workflow parser to the frontend. Rejected because it would create an o1key-only recovery path and would still require nonstandard chunking for large metadata.
- Keep JPEG and silently omit oversized EXIF. Rejected because the saved image appears successful but cannot restore its workflow.
- Convert every result format to PNG. Rejected because ComfyUI already restores WebP metadata and non-workflow saves should keep their requested container.
## Validation
- `tests/test_o1key_image_save.py` verifies that a JPEG carrying a workflow larger than 64 KiB becomes PNG with complete native `prompt` and `workflow` fields, while a metadata-free JPEG retains its exact provider bytes.
- `tests/test_o1key_image_generator_frontend.mjs` verifies that panel save metadata uses the prompt and workflow returned by one `app.graphToPrompt()` call without invoking the fallback serializer.
@@ -0,0 +1,35 @@
# 0005: Persistent image-job history summaries
- Status: Accepted
- Date: 2026-09-04
- Owners: Maintainers
## Context
Panel-triggered o1key image jobs do not pass through ComfyUI's native prompt executor. Their native task-queue rows are virtual records assembled by the frontend from the process-local `ParallelImageJobManager`. Restarting ComfyUI clears both that manager and the browser bridge, so completed o1key rows disappear even though their saved images remain.
Output-directory scanning cannot rebuild a complete history: users may choose custom prefixes, natural-number naming, subfolders, or absolute destinations. Persisting entire job snapshots would retain prompts and manifests that the history UI does not need.
## Decision
Persist at most 200 terminal job summaries in `<ComfyUI user directory>/o1key/image_job_history.json` using atomic replacement. A summary contains only batch and node IDs, terminal state, counts, millisecond timestamps, bounded error/warning fields, failed request indexes, and sanitized ComfyUI image descriptors.
Expose the summaries through `GET /o1key/image/jobs/history`. The frontend loads up to 64 recent records before returning its first merged native history page. `POST /o1key/image/jobs/history` synchronizes single deletion and clear-history actions. History write failures are non-fatal to generation and image saving.
## Consequences
- Completed, failed, and cancelled o1key rows survive ComfyUI and browser restarts.
- History remains available independently of the currently loaded workflow and output naming rule.
- Prompts, reference manifests, provider bodies, absolute paths, credentials, Base64 data, and signed URLs are intentionally unrecoverable from the history index.
- Existing rows created before this decision cannot be reconstructed reliably and begin appearing only after the first new terminal job is indexed.
## Alternatives considered
- Scan output filenames at startup. Rejected because supported naming and destination choices do not preserve batch identity.
- Persist complete `JobRecord` objects. Rejected because prompts, manifests, and transient execution data exceed the history UI's needs and security boundary.
- Store only in browser local storage. Rejected because it is browser-profile-specific and does not survive browser data clearing or serve multiple connected clients consistently.
## Validation
- `tests/test_o1key_image_jobs.py` recreates the store from disk, verifies terminal manager persistence, and checks that prompt, Base64, and signed URL fields are absent.
- `tests/test_o1key_image_generator_frontend.mjs` verifies first-page hydration into native history and independent concurrent retry-batch lifetime.
@@ -0,0 +1,46 @@
# 0006: Grok Video API and workflow migration
- Status: Accepted
- Date: 2026-09-05
- Owners: Maintainers
## Context
The released `O1keyGrokVideo` node used the legacy `/v1/videos` API, retired model names, and a positional widget layout containing a per-node network route. The current O1Key Grok Video API has separate generation, edit, and extension endpoints, new model identifiers, different duration and resolution limits, and one shared task-status endpoint.
ComfyUI stores widget values by position. Replacing the schema without migrating `widgets_values` would assign old prompts, models, durations, and resolutions to the wrong controls. Renaming the optional reference-image sockets would also make old connections harder to restore reliably.
## Decision
- Keep the released `O1keyGrokVideo` node ID and the existing `VIDEO` output position.
- Use `O1keyGrokVideo` for text, image, and multi-reference generation, and `O1keyGrokVideoEdit` for edit and extension operations.
- Keep API transport, payload validation, polling, and response parsing in `clients/grok_video_client.py`.
- Validate all user-controlled parameters before temporary uploads or paid generation calls.
- Migrate legacy generation workflows before ComfyUI maps positional widget values:
- remove the legacy per-node network route through the shared route migration;
- map `grok-imagine-video-1.5-preview` to `grok-imagine-video-1.5`;
- map `grok-imagine-1.0-video` to `grok-imagine-video`;
- infer multi-reference mode when a legacy reference-image socket is connected, otherwise use text mode;
- rename `参考图1` through `参考图7` sockets to `图片1` through `图片7`;
- clamp legacy durations above the new API maximum to 15 seconds;
- append new controls rather than shifting values in workflows already saved with the intermediate schema.
- Never write temporary upload or result URLs to logs or workflows.
## Consequences
Existing workflow node IDs and output links remain valid, while old parameter values are converted to the new API contract. A legacy 16- or 20-second selection becomes 15 seconds because the replacement generation API has a hard 115 second range.
The frontend migration remains required runtime code as long as pre-migration workflows are supported. Future Grok widget additions must remain append-only or include another idempotent migration and regression test.
## Alternatives considered
- Register entirely new node IDs and leave the old node untouched. Rejected because it would strand saved workflows on an obsolete API.
- Reinterpret old widget arrays in backend execution only. Rejected because ComfyUI assigns widget values before execution, so the visible controls and saved values would still be corrupted.
- Preserve retired model names as aliases in the node dropdown. Rejected because those identifiers are not valid for the current endpoints and would allow avoidable failed requests.
## Validation
- `tests/test_grok_video.py` protects payload construction, capability limits, schema defaults, media locators, edit duration preflight, and secret-safe error formatting.
- `tests/test_o1key_image_generator_frontend.mjs` protects legacy Grok widget and socket migration, including idempotency.
- `tests/test_temp_media_uploads.py` verifies that temporary upload URLs are returned to callers without being printed.
- `tests/run_all.py`, compile checks, and the package import smoke test validate the bundled Windows environment.
@@ -0,0 +1,33 @@
# 0007: Panel-driven independent video jobs
- Status: Superseded by [ADR 0008](0008-native-video-save-outputs.md)
- Date: 2026-09-07
- Owners: Maintainers
## Context
Video generation is asynchronous and long-running. The unified video node must support repeated clicks that run independently, while saved workflows still need a durable node that can preview a completed artifact and feed native ComfyUI video consumers. The user explicitly does not want these generation requests represented by ComfyUI's native queue.
## Decision
`O1keyVideoGenerator` is a panel-only V3 node with no outputs and a side-effect-free native `execute`. Every click creates an `O1keyVideoResult` node and submits an independent server-side job through `/o1key/video/jobs`. Every accepted job starts immediately; the plugin does not impose a semaphore, concurrency ceiling, or internal waiting slot on video-job scheduling. A job may still bound its own prerequisite media preparation. Provider-side quotas and rate limits remain authoritative.
The result node stores only safe ComfyUI file descriptors and bounded status/request metadata. Once a job completes, its native execution resolves the already-downloaded file and emits `VIDEO` plus optional `LAST_FRAME`; it cannot initiate or retry generation. Existing released video nodes and their mapping keys remain registered unchanged.
For Seedance automatic mode, both domestic and overseas routes use the same material-service boundary as `SeedanceElementCreate`; the route selects the Doubao or HC namespace. Material preparation is bounded to three concurrent items without limiting independent video jobs. Safe resolved IDs and content fingerprints may be persisted so retries and repeated content reuse existing Active material, but temporary upload URLs and local paths may not be persisted.
Prompt assistance is a separate, non-job operation at `/o1key/video/prompt-write`. It uses a dedicated video default preset rather than the image preset. Only sanitized input-image descriptors and scalar generation context cross the browser/server boundary; reference video and audio are represented by counts, not media payloads. This operation does not create a result node or enter either job system.
## Consequences
Repeated submissions remain available while earlier videos run, and every job has its own visible result node, status, cancellation, and retry lifecycle. Native queue controls do not display or control these jobs. A burst of clicks can therefore create the same number of simultaneous provider requests and may encounter upstream rate limits. Cancelling after provider acceptance cannot retract the remote request. New providers must be added through the shared catalog/job adapter boundary rather than by adding paid work to the generator node's `execute` method.
## Alternatives considered
- Represent jobs in ComfyUI's native queue: rejected because it conflicts with the required click-driven independent workflow.
- Return `VIDEO` directly from the generator: rejected because native execution would either duplicate paid work or require a blocking queue run.
- Reuse one result node for all clicks: rejected because concurrent completions could overwrite one another and obscure per-request state.
## Validation
`tests/test_o1key_video_generator.py` verifies the side-effect-free schema, Seedance matrix and validation, request-body roles, safe descriptors, and immediate unbounded independent execution. `tests/test_o1key_video_generator_frontend.mjs` verifies one-result-per-click wiring, the jobs API/event bridge, repeated-submit behavior, and absence of native queue submission calls.
@@ -0,0 +1,30 @@
# 0008: Native save nodes for panel video outputs
- Status: Accepted
- Date: 2026-09-12
- Owners: Maintainers
- Supersedes: [ADR 0007](0007-panel-driven-video-jobs.md) for result presentation
## Context
The dedicated `O1keyVideoResult` made every panel submission visible and recoverable, but duplicated capabilities already provided by ComfyUI's native save nodes and occupied a large result card. Users expect the generator's outputs to be visible through ordinary graph connections. Saved workflows containing the released result node must remain loadable, and native graph execution must never submit a second paid generation request.
## Decision
`O1keyVideoGenerator` exposes `VIDEO` followed by `LAST_FRAME`. Every panel submission creates and connects one native `SaveVideo`; it additionally creates and connects one native `SaveImage` when the submitted `return_last_frame` value is enabled. The independent background job keeps saving the provider result atomically, then dispatches its safe file descriptor to the exact native save node for preview. Batch identity, generator association, terminal state, and safe descriptors are stored on the native node so workflow reload can restore completed previews or resume polling unfinished jobs.
The generator's appended result-manifest widgets allow native execution to resolve the latest completed local video and image without contacting the provider. `O1keyVideoResult` remains registered and executable as a deprecated compatibility node for existing workflows, but the frontend no longer creates it.
## Consequences
New jobs use familiar native save nodes and visible typed connections. Returning a last frame produces two clearly separated native outputs. Repeated clicks remain independent and may create multiple save-node pairs. The panel job has already persisted the artifact before its preview is dispatched, so executing a native save node again is unnecessary and may create another copy of the generator's latest completed output.
## Alternatives considered
- Remove the released result-node registration: rejected because saved workflows would fail to load.
- Route paid video generation through ComfyUI's native queue: rejected because it would remove immediate independent submissions and risk duplicate provider calls.
- Reuse one native save node across every click: rejected because concurrent jobs would overwrite each other's visible result association.
## Validation
`tests/test_o1key_video_generator.py` verifies append-only manifest inputs, typed generator outputs, side-effect-free local resolution, and deprecated result compatibility. `tests/test_o1key_video_generator_frontend.mjs` verifies native node creation, conditional last-frame saving, typed connections, descriptor dispatch, recovery, and the absence of new `O1keyVideoResult` creation or native queue submission.
@@ -0,0 +1,25 @@
# 0009: Remove the unified image generator's external prompt input
- Status: Accepted
- Date: 2026-09-24
- Owners: Maintainers
## Context
`O1keyImageGenerator` offered both an in-panel prompt editor and a separate `external_prompt` STRING socket. The socket added a second prompt source, changed panel behavior when connected, and required a separate execution path when the panel button was used. The requested node interaction uses the in-panel editor only.
## Decision
Keep the serialized `prompt` widget and its existing index. Remove `external_prompt` from the V3 schema, execution signature, and frontend panel behavior. On workflow load, remove an old `external_prompt` input and its specific graph link while retaining the saved in-panel prompt and unrelated links.
## Consequences
Older workflows that relied on an upstream STRING value must place their prompt text in the panel. The upstream node remains in the graph, and any other connections from it remain intact. Standard ComfyUI execution and panel jobs now read the same prompt widget.
## Alternatives considered
Hiding the socket while retaining the backend override would leave an invisible second prompt source in saved workflows. Keeping the socket solely for old workflows would preserve the interaction the user requested to remove.
## Validation
The image generator schema and execution tests verify the single prompt source. Frontend tests verify empty-prompt validation and idempotent removal of old socket links while preserving unrelated graph links.
@@ -0,0 +1,23 @@
# 0010: Sync unified GPT Image controls and remove moderation
- Status: Accepted
- Date: 2026-09-24
- Owners: Maintainers
## Context
The standalone GPT Image node supports two additional GPT Image 2.5 quality levels and counts 18. The unified panel used four quality levels, counts 1/2/4/9, and exposed moderation after the background control. Removing a middle widget would otherwise shift saved batch and save settings.
## Decision
`O1keyImageGenerator` uses the standalone GPT Image quality resolver for GPT Image 2 and 2.5. The panel offers counts 18 for GPT models and retains the older 9-image value when loading a saved workflow. New GPT panel selections default to PNG and smart resize; existing saved output and resize values remain intact. Other model families keep their count and default behavior.
The unified generator no longer exposes or submits `内容审查强度` / `moderation`. Its old positional widget at index 14 is removed after the existing old-layout migrations finish, preserving batch and save widget values at their new positions. Legacy panel job payloads that contain `moderation` are ignored. The provider client retains optional low-level support for callers outside the unified node.
## Compatibility
The node ID, earlier widget positions, model-to-route mapping, and provider request scheduler stay stable. The workflow migration is idempotent for both old 24-value arrays and new 23-value arrays. GPT Image continues to send one provider request per selected output image with `n=1`.
## Validation
Offline node, job, standalone GPT, and frontend tests cover quality conversion, count options, omission of moderation, and the saved-workflow migration.
@@ -0,0 +1,21 @@
# 0011: Remove Nano Banana prompt optimization and batch colour correction
- Status: Accepted
- Date: 2026-09-24
- Owners: Maintainers
## Context
The standalone and batch Nano Banana nodes added a browser-side prompt optimization button. The batch node also offered optional colour correction after generation. The batch control was the last serialized widget, so removing it requires migration for saved workflows.
## Decision
Remove the Nano-specific prompt optimization extension and the batch node's colour correction input and post-processing. Keep the unified image generator's `AI帮写` action and the GPT Image batch node's colour correction behavior.
## Compatibility
`migrateWorkflow.js` removes the retired batch correction value before ComfyUI configures widgets. It then adds the `不缩放` default only when an old workflow lacks a resize value. Running the migration twice leaves the same values. Existing batch image quality, naming, and resize values stay in place.
## Validation
Node schema and execution tests cover the absent correction input and unchanged generated image. Frontend tests cover removed extension and old batch workflow values with both default and smart resize.
@@ -0,0 +1,21 @@
# 0012: Place batch Nano Banana save settings and seed last
- Status: Accepted
- Date: 2026-09-24
- Owners: Maintainers
## Context
The batch Nano Banana node displayed seed before file-save controls and displayed resize after them. Saved workflows serialize these widgets by position, so changing their order without migration would assign the old values to the wrong inputs.
## Decision
Place resize after the dynamic reference inputs, followed by output format, quality, naming rule, and save path. Place seed last. Keep all input IDs, defaults, and execution behavior.
## Compatibility
The frontend first applies earlier colour-correction removal, missing-resize defaults, and string-quality conversion. It then recognizes the old six-value tail and rewrites it into the new order. The new tail is not changed on later loads. The rule uses the tail pattern so the number of dynamic folder paths does not matter.
## Validation
Schema and frontend tests cover final widget order, old workflows without resize, saved string quality, smart resize, three folder paths, preserved save settings, and repeat migration.
@@ -0,0 +1,21 @@
# 0013: Remove batch Nano Banana random image selection
- Status: Accepted
- Date: 2026-09-24
- Owners: Maintainers
## Context
The batch Nano Banana path selector exposed `图片随机抽取`. A selected path became a random image pool instead of participating in ordinary image pairing. Removing the control changes both serialized dynamic widgets and task assembly.
## Decision
Remove the random-selection widget and its pool-loading and image-draw logic. All filled folder paths now participate in the selected pairing mode. Keep the node ID, path selector, pairing choices, fixed references, save controls, and seed.
## Compatibility
The frontend drops the former widget value after applying older layout migrations. It removes a connected random-selection input and its graph link and shifts later target slot indices. API workflows that still send the retired value have it ignored. An old workflow with multiple paths and `不配对` must select a pairing mode before running; the node reports this rather than silently choosing one.
## Validation
Offline node tests cover schema and execution forwarding. Frontend tests cover saved one-path and multi-path widget arrays, idempotence, and connected-input link cleanup.
@@ -0,0 +1,25 @@
# 0014: Publish the current code as a new release baseline
- Status: Accepted
- Date: 2026-09-24
- Owners: Maintainers
## Context
The public Gitea release tree still reflects the older GitHub package. The maintainer requested that the current working code replace it as the new published baseline. The current registry contains 39 node IDs, while the previous release contains 37. Twelve old IDs have no implementation in the current code.
## Decision
Publish the current worktree as a new commit on top of the existing Gitea `main` history. Keep the current node registry and do not restore retired implementations solely for this release. The retired IDs are `KlingVideo`, `KlingFirstLastFrame`, `KlingMotionControlTest`, `AspectRatioPreset`, `Seedance`, `KVideoFirstLast`, `KVideoImage2Video`, `K3VideoFirstLast`, `K3MotionVideoCheck`, `NanoBananaV2`, `NanoBananaV2Batch`, and `O1keyColorRemoveBG`.
## Consequences
Saved workflows containing those retired IDs will report missing nodes. Users should keep a copy of such workflows and either use the previous release in a separate installation or replace the missing nodes manually. Existing IDs retained in the new registry remain unchanged; migrations for their widget changes remain in `web/js/migrateWorkflow.js`. Later releases should not remove additional published IDs without a separate compatibility decision.
## Alternatives considered
Restoring all retired providers and nodes would reintroduce code the current package intentionally removed. A semantic mapping from those IDs to newer nodes is not established, so an automatic migration could silently change workflow behavior.
## Validation
Compare the old and new `NODE_CLASS_MAPPINGS` keys, run the offline tests, compile the Python files, and import the package in the bundled ComfyUI environment before publication.
+28
View File
@@ -0,0 +1,28 @@
# Architecture decision records
Use ADRs for decisions that constrain future changes: node ID compatibility, package boundaries, frontend loading strategy, persistent storage, dependencies, and supported migration paths.
## Process
1. Copy `0000-template.md` to the next available four-digit number.
2. Use a short kebab-case title, for example `0002-split-server-routes.md`.
3. Set status to `Proposed` while discussion is open.
4. Change status to `Accepted`, `Superseded`, or `Rejected` when decided.
5. Link superseding ADRs in both directions; do not rewrite old decisions as if history changed.
## Current decisions
- [0001: AI-native repository guidance and documentation](0001-ai-native-repository-guidance.md)
- [0002: Save-node ownership of generated image formats](0002-save-node-image-format-ownership.md)
- [0003: Generator-owned image save configuration](0003-generator-owned-image-save-configuration.md)
- [0004: Native-recoverable image workflow metadata](0004-native-recoverable-image-workflow-metadata.md)
- [0005: Persistent image-job history summaries](0005-persistent-image-job-history-summaries.md)
- [0006: Grok Video API and workflow migration](0006-grok-video-api-and-workflow-migration.md)
- [0007: Panel-driven independent video jobs](0007-panel-driven-video-jobs.md)
- [0008: Native save nodes for panel video outputs](0008-native-video-save-outputs.md)
- [0009: Remove the unified image generator's external prompt input](0009-remove-unified-image-external-prompt.md)
- [0010: Sync unified GPT Image controls and remove moderation](0010-unified-gpt-parameters-and-moderation-removal.md)
- [0011: Remove Nano Banana prompt optimization and batch colour correction](0011-remove-nano-prompt-optimization-and-colour-correction.md)
- [0012: Place batch Nano Banana save settings and seed last](0012-batch-nano-save-and-seed-widget-order.md)
- [0013: Remove batch Nano Banana random image selection](0013-remove-batch-nano-random-image-selection.md)
- [0014: Publish the current code as a new release baseline](0014-new-release-code-baseline.md)
+108
View File
@@ -0,0 +1,108 @@
# Development workflow
## Environment
The primary development target is the portable Windows installation containing this plugin. Run commands from the plugin root and use its embedded interpreter:
```powershell
..\..\..\python_embeded\python.exe --version
..\..\..\python_embeded\python.exe tests\run_all.py
```
Do not install packages into a system Python when validating the portable build.
The left toolbar Update button sits after Token Manager and before Restart. Opening its dialog immediately checks `GET /o1key/update/check` before requesting confirmation, then calls `POST /o1key/update`. Both routes use `utils/updater.py` and one lock. Its release source is `https://git.o1key.com/publisher/comfyui_o1key.git`, independent of the installation's `origin`. Keep its Git operation fast-forward only and preserve local files. The backend returns stable error codes; map these to customer-facing text in the frontend rather than displaying raw Git errors, repository URLs, or implementation details. A current installation should report no update even when tracked files were changed locally. Run `tests/test_updater.py` and `tests/test_o1key_update_button.mjs` after changing this path; restart ComfyUI to load backend route changes.
## Add or change a node
1. Choose the closest module in `nodes/`; create a new module only for a distinct responsibility.
2. Define a stable, globally unique node ID. For V3 nodes, make `Schema.node_id` match the root mapping key.
3. Keep input validation before uploads and paid API requests.
4. Move reusable provider logic into `clients/` and reusable infrastructure into `utils/`.
5. Export the class from `nodes/__init__.py`.
6. Add it to root `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS`.
7. If widget order changes, add an idempotent migration to `web/js/migrateWorkflow.js`.
8. Add an offline test and update user/architecture docs when behavior is visible.
For `O1keyPromptMultiFunction`, keep the original first two widgets unchanged and retain backend aliases for the legacy `随机抽取1套` / `随机抽取多套` values. The visible mode remains `随机抽取n套`; migration must map old single-random workflows to count `1`. Its dynamic extension may hide inactive selection widgets but must retain their values and serialization. Future selection controls must remain append-only, and every multi-prompt result must use a standalone `---` line so downstream batch nodes retain the same parsing contract.
## Add or change a provider client
For Omni Flash, keep `O1keyOmniFlashVideo` as the sole node ID. Its `execute` validates all modes and media before upload or paid submission, then returns a native `VIDEO`. Ordinary generation hard-codes `omni_flash_10s`; edit mode uses `omni_flash_abra_edit`. If changing the widget schema, keep the migration that removes the former model value at index 2 so saved workflows remain aligned. The button only queues the current node using ComfyUI's native queue. O1Key's JSON `/v1/videos` gateway requires `input_reference` to be a string for one image; send several references as repeated multipart fields, never a JSON array under `input_reference`. Keep provider URLs and credentials out of widgets, workflow metadata, and logs. Use `tests/test_omni_flash.py` and `tests/test_omni_flash_frontend.mjs` for offline regression coverage.
- Normalize authentication and base URL selection through `utils/config.py`.
- Use shared retry, interruption, polling, upload, and download helpers before creating provider-local variants.
- Convert provider-specific failures into actionable messages without printing secrets or full payloads.
- If the class is part of the package API, add it to the lazy `_EXPORTS` table in `clients/__init__.py`.
- Test payload construction, success envelopes, failure envelopes, retryability, and cancellation offline.
## Add a server route
- Keep `/o1key/*` route names unique and explicit.
- Validate JSON types, identifiers, filenames, and root containment before accessing files.
- Return structured JSON errors with meaningful HTTP status codes.
- Never return the stored API key; existing configuration routes expose only presence/masked state.
- Register reusable route groups from a utility module when they grow beyond a cohesive section.
## Add a frontend extension
- Remember that adding a `.js` file under `web/` activates it automatically.
- Prefer one extension per coherent UI feature.
- Make setup idempotent across frontend reloads.
- Prefix extension names, DOM IDs, CSS IDs, and events with `o1key`.
- Use the existing ComfyUI `app` and `api` modules; do not add a bundler for a small extension.
- Add or extend a Node-based test for logic that can run without a browser.
- For display-only Combo aliases, set `widget.options.getOptionLabel` in an idempotent frontend extension; keep the actual option values unchanged so prompt payloads and saved workflows remain compatible.
Panel-triggered image batches must be represented in ComfyUI's native task queue, not by task-state text or a cancel action inside `O1keyImageGenerator`. Keep the jobs API bridge idempotent, retain a distinct queue item and expected-output count per batch, route native single and bulk cancellation to `/o1key/image/jobs/{batch_id}/cancel`, and leave the generator action available for consecutive submissions. Preserve native queue/history records returned by ComfyUI when merging o1key records, hydrate persisted terminal summaries before returning the first history page, and keep queue clearing semantics limited to waiting batches. History persistence must remain bounded, atomic, credential-free, and synchronized with native delete/clear actions; a persistence failure must never change the result of a paid generation or successful image save.
`O1keyVideoGenerator` is intentionally different from the image generator: its panel jobs must not enter or imitate ComfyUI's native queue. Keep paid work exclusively behind `/o1key/video/jobs`; native generator execution may resolve completed local descriptors but must never submit or retry generation. One click must create one connected native `SaveVideo`, plus one connected native `SaveImage` only when `return_last_frame` is enabled, and one independent server job record. Dispatch completed safe descriptors to those exact native nodes and persist enough safe association state to restore previews or resume polling after workflow reload. Keep the released `O1keyVideoResult` registered as deprecated compatibility support, but never create it for new panel submissions. Re-enable the action as soon as submission returns so later clicks remain independent. Provider adapters belong behind `o1key_video_catalog.py` and `o1key_video_jobs.py`: add the capability entry, scalar/media validation, request-body builder, executor dispatch, frontend controls, and offline tests together. Seedance automatic material creation must go through `seedance_assets.py`, map the video route to the matching HC or Doubao asset namespace, wait for `Active`, and submit `asset://` references only. Keep material concurrency bounded independently of video-job concurrency, preserve input order after concurrent preparation, and let terminal results retain only safe resolved IDs so retries do not recreate assets. Content reuse may persist fingerprints and IDs, but never upload URLs, local paths, credentials, or complete provider envelopes. Validate all limits before uploads or paid requests, snapshot only sanitized Comfy input descriptors, and persist neither credentials, Base64, signed URLs, nor absolute input paths.
`SeedanceAutoPass` is the graph-connected counterpart to the panel generator. Preserve its released Chinese input IDs or provide an explicit idempotent `migrateWorkflow.js` rewrite when a user-requested layout change moves positional widgets. Translate displayed values into `normalize_seedance_parameters`, use the shared reference-dimension/count constants, delegate request-body construction to `build_seedance_video_body`, and submit through `SeedanceClient`. Its two displayed modes must resolve to the canonical `text / multimodal / first_frame / first_last_frame` values from the connected media count. Automatic assets validate every connected tensor or media source before the bounded three-item HC/Doubao preparation stage; manual assets skip upload and pass normalized IDs through the shared body's `assets` fields.
In automatic multimodal mode, render reference images, videos, and audio cards in one shared horizontal material track. Keep one mixed-media upload control in the track header, and do not render trailing type-specific image, video, or audio add cards. Detect each selected file's media type before upload, retain type-specific validation and type-local ordering, and preserve the existing `reference_images` / `reference_videos` / `reference_audios` workflow fields; the compact layout is presentation-only and must not merge or reorder those serialized manifests.
The video generator defaults new nodes to multimodal mode. Its custom panel is created before saved widget values may be applied, so restore every visible control and material track from the configured node state in `onConfigure`; `loadedGraphNode` must repeat that restoration after graph loading. Never let panel initialization write defaults over a returning workflow's prompt, parameters, manual asset IDs, or `o1keyVideoMedia` properties.
Keep source/target batching behind the append-only `批量出图` switch. The visible panel must use the industry-neutral names `素材图`, `目标图`, `整组素材 → 多个目标`, `全匹配(素材 × 目标)`, and `单图批量(每张素材独立)`; the historical widget and first two mode values remain compatibility identifiers. Render references as a `102 × 102` horizontal card track with upload and canvas-picker actions in the track header. Every track must retain a trailing add card after its uploaded and pending thumbnails; both that card and the complete track accept external image-file drops. Stop accepted file-drop propagation before ComfyUI's canvas handler while leaving internal thumbnail sorting drags untouched. A drop handled by a nested upload card must clear the `drag` class from both that card and its parent track; the track must not retain a coloured background or outline after the file is released. Track images must use the bounded `/o1key/image/thumbnail` representation, must not decode pending local blobs, and must retain the original `/view` descriptor only for the lightbox; never substitute thumbnail bytes into manifests, prompt optimization, or provider requests. Normal mode shows one `参考图` track; paired batch modes show separate `素材图` and `目标图` tracks, while single-reference mode hides the target track. Header hints must include the current manifest count and the active participation rule. Make the complete thumbnail the pointer drag target rather than rendering a separate handle. During a drag, dim and scale the source card, label it `移动中`, highlight its track, and use a thick insertion edge plus target displacement; suppress the immediate post-drag preview click. Keep `Alt + Arrow/Home/End` ordering on the focused thumbnail as the keyboard path. With the switch off, ignore the stored target manifest and preserve the historical reference/prompt path. With it on, use `expand_image_generation_tasks` as the shared ordering authority for direct and background execution: prompts first, then pairings or source indexes, then per-pair copies. Batch manifests cap at fifty images, but provider requests remain capped at ten references: group mode therefore allows nine sources plus one target, while its target manifest may hold fifty; Cartesian and single-reference source manifests may hold fifty because each task selects one source. Snapshot only manifests used by the active mode, never serialize file paths into a workflow, and reconstruct task-specific references from validated indexes. Load large batch manifests only for active tasks, serialize reference preprocessing to bound full-resolution decode pressure while preserving request order, and release task-local images/tensors immediately after their last consumer. A failed-slot retry must flatten only that slot's exact references into a one-task normal-mode request. Keep request-position thumbnail badges derived from the same ordering. Treat ordered manifests as the source of truth for sorting and persist moves immediately. Uploaded thumbnails must reuse ComfyUI's `MediaLightbox` interaction contract; do not expose signed URLs or open a browser tab. Keep the batch summary hidden until all inputs required by the active mode are present, then show the exact task count, per-item count, prompt multiplier, and total.
Keep reference-image editing frontend-only and descriptor-based. The top-left thumbnail action may crop and flatten visual mask/brush/arrow marks plus one transformed upper sticker layer into a new PNG, but it must not create a `MASK` value or mutate the GPT mask widget. Keep sticker placement and arrow endpoints in source-image coordinates so preview resizing cannot alter the exported geometry; the arrow tip must remain the exact pointer-release position. Compose annotations above the sticker and never export selection handles. Revoke local sticker object URLs on close and never serialize them. Save through the shared serialized native upload queue with `overwrite=false`, replace the original descriptor only after upload succeeds, and leave the source file untouched. Keep crop math, sticker fit/hit geometry, arrow-head geometry, and filename normalization independently testable in `tests/test_o1key_reference_image_editor.mjs`; run that test together with `test_o1key_image_generator_frontend.mjs` after editor changes.
When changing `O1keyImageSave`, preserve the mutually-exclusive preview rules documented in `architecture.md`: the bounded slot-grid DOM widget exists only while a batch is active or has incomplete slots, and the native preview is hidden in that state; after all slots succeed, remove the temporary widget from `node.widgets` and restore the native preview. Publish a partial slot result only after its complete file is atomically present in ComfyUI `temp`, and send only sanitized descriptors; do not promote it to permanent output or dispatch native execution results before the batch reaches a terminal state. Do not leave an invisible DOM widget behind: ComfyUI assigns expandable DOM widget rows an `auto` grid track and `flex: 1`, which steals resize space from the image preview. Never stack both renderers. Leave ComfyUI's Vue `NodeContent`/`ImagePreview` flex, minimum-height, element-size observer, responsive grid, and `object-contain` behavior intact so the native preview fills the remaining node area during resize. Keep structured request indexes through temp promotion, preserve exact failed-slot prompts for one-image retries, and serialize only sanitized slot/output metadata. Failed-slot buttons must be disabled by their own slot state rather than the save node's aggregate busy flag; registrations, saving guards, and terminal guards must be keyed by batch ID so concurrent retries cannot overwrite or finish one another. The save node has no configuration widgets and remains the sole permanent writer; direct execution reads generator settings attached to the IMAGE tensor, while panel jobs use the immutable settings snapshot in their job record. Preserve provider bytes when possible, keep custom locations relative to the configured output root, and use the native prompt/`extra_pnginfo` metadata convention. Obtain panel prompt and workflow metadata from the same `app.graphToPrompt()` result. Never save a workflow-bearing JPEG: ComfyUI does not restore workflows from JPEG, and EXIF may silently exceed its segment limit; promote that result to PNG and verify the complete `prompt` and `workflow` text fields instead.
When adding a model to `O1keyImageGenerator`, update `utils/o1key_image_catalog.py`, the frontend model descriptions/capability switch, the job payload validator, and the model-family executor together. Preserve the existing route values even when their labels change. Provider-specific multi-image parameters must not bypass the unified scheduler: each selected output image is one concurrent provider request, and GPT Image always receives `n=1`. Keep GPT-only controls appended after the original generator widgets so old workflow arrays do not shift.
For Seedream, keep the stable workflow value `Seedream 5.0 Pro` separate from API model ID `dola-seedream-5-0-pro-260628-ep`. Treat the unified panel's default `智能` resolution as an omitted provider `size`; resolve explicit `1K / 2K` plus aspect ratio through the catalog's exact size matrix, submit one image per async task with `n=1`, pass the selected `png / jpeg` as `output_format`, and always send `watermark=false` without adding a widget. Upload ordered references through `{base_url}/v1/o1key/uploads` and submit only the returned HTTPS URL strings; never log those temporary URLs. Layer decomposition is the exception to the normal size/prompt rules: accept exactly one reference, optional prompt text, `智能 / 1K / 1.5K / 2K`, forced PNG, and no batching. Keep all returned images in `z_index` order, preserve transparent provider bytes, expose alpha as MASK in direct execution, and sanitize metadata before it reaches descriptors, history, or workflows.
Keep batch-prompt behavior model-independent. A `---` line is the only prompt separator, task order is prompt-major, and both direct execution and panel background jobs must calculate `valid prompt segments × pairing count × images per pair` and reject totals above the shared 1000-task ceiling before provider calls. Pairing count is one in normal mode. Panel background jobs and direct execution limit each batch to nine in-flight provider requests.
Do not add a download semaphore or an independent download-concurrency limit to `O1keyImageGenerator`. Ready result URLs must download immediately; the active provider-task scheduler is the only natural bound. The shared Nano transport may retain its optional semaphore for other released nodes, but unified direct and background paths must pass no limiter.
For GPT Image, keep resolution/aspect-ratio pairs in the catalog size matrix rather than duplicating exact pixel labels in the panel. `智能` is the first and default unified resolution for every model; it must become `None` at the provider boundary so Nano Banana, GPT Image, and Seedream request builders omit `size` entirely. Explicit tiers retain their existing mappings. Keep active user controls in one persistent vertical list with labels on the left and controls on the right, but apply the model capability matrix when synchronizing the model: thinking level and online search are Nano Banana 2-only, resize mode is shared by Nano and GPT Image, and quality, background, API output format, plus mask are GPT Image-only. Online search defaults to `关闭`; omit `google_search` unless it is `打开`, then submit top-level `google_search: true`. The local `格式` field is visible only for Banana models; `命名规则`, `filename_prefix`, and `保存位置` are shared. Hide unsupported fields in place instead of rebuilding them so values and listeners survive model switching. Display `JPEG` but serialize and submit lowercase `jpeg`; new GPT panel selections use `png` while saved values remain intact. Reject `transparent + jpeg` before any paid request and keep JPEG out of the frontend list while transparency is active.
Keep prompt writing behind dedicated server routes. The unified image and video prompt actions must retain both the magic-wand icon and the visible `AI帮写` label so their purpose is understandable without hover; the image compatibility route remains `/o1key/image/prompt-optimize`, while video uses `/o1key/video/prompt-write` and a separate video-only system preset. The frontend must submit input descriptors rather than API credentials or filesystem paths. The server must enforce the input-root boundary, preserve reference order, cap the exact multimodal request body, use `gpt-5.6-sol` with high reasoning, and return only the optimized prompt. Tests must mock the upstream completion call and generate reference images at runtime; never add encoded reference fixtures or log complete data URLs.
Keep unified-generator post-processing, batch, and save widgets in their established order; migrate any removed middle widget before ComfyUI restores saved values. The retired `色彩纠正` value is removed from legacy index 13 before later values are mapped; `背景` occupies index 13; the retired `内容审查强度` value is removed from legacy index 14 after the old-layout migrations. Batch fields occupy indexes 1416, generator save fields 1720, `在线搜索` index 21, and Seedream `图层拆分` index 22. `migrateWorkflow.js` must first remove the retired value and fill older generator defaults, then move the connected legacy save node's four positional values into those slots, append later defaults, and clear the old save-node array idempotently. The original `IMAGE` output remains first; new provider-specific outputs must be appended. Display-only collapsing of provider-specific outputs must restore them when their mode is enabled and must never hide a connected output. Layer-decomposition background generation must save the returned descriptors once, then route the base descriptor to the output-0 save node and the remaining descriptors to its paired output-1 layer save node. The frontend, direct V3 execution path, and background job payload must use the same save settings; GPT forces local `格式=原始`.
Keep the unified generator's canvas-image picker descriptor-based. Discover current-graph images from public execution outputs and native preview descriptors, fetch them through `/view`, and reuse the serialized `/upload/image` queue before adding them to a reference role. Do not pass `output` or `temp` descriptors to the image-job API, do not serialize picker state, and keep candidate text and filenames out of `innerHTML`.
## Documentation and decisions
- Keep `README.md` focused on users.
- Update the matching file in `docs/` in the same change as an architectural or operational behavior change.
- Record decisions that constrain future work as ADRs. Copy `docs/decisions/0000-template.md`, choose the next number, and describe consequences rather than meeting history.
`SeedanceAutoPass` keeps its mode and asset-policy selectors as ordinary combos for frontend compatibility. `seedanceAutoPassDynamic.js` removes inactive media sockets through `removeInput`: multimodal exposes only the three native Autogrow reference groups, while frame mode exposes only `首帧图片` and `尾帧图片`. Cache and suspend the reference Autogrow configurations before structural edits, bypass their connection callbacks during those edits, and restore them on return to multimodal; socket names remain unchanged and ordinary converted-widget inputs are untouched. Reconcile saved sockets again in `loadedGraphNode`. Switching modes disconnects removed sockets and does not restore their links. Its `素材创建模式` selector must remain visible and default to `关闭`; only `打开` reveals and uses the numbered single-line material-ID widgets (30/10/10). The extension uses the same progressive one-empty-row rule as `SeedanceMultiModal`, without removing widgets, clearing values, or changing serialization order. Migrate the former `素材创建` name and `自动创建 / 手动` values idempotently, expand the previous aggregate ID strings, and preserve the trailing ordinary web-search, seed, and last-frame parameters. The backend accepts old names and aggregate ID kwargs as legacy aliases.
`SeedanceAutoPass` temporarily hides the `联网搜索` and `返回末帧图片` controls with the same zero-height widget mechanism. Keep both schema entries and serialized values/order for compatibility; do not force-reset existing workflow values. Apply visibility on creation, graph loading, and material-ID/mode updates, before recalculating height. `seed` and the asset-mode selector remain visible. This restriction is scoped to the single all-in-one node, not the batch node or unified video panel.
## Completion checklist
- Direct references searched with `rg`.
- Registration and frontend migration synchronized.
- Offline regression test added or updated.
- Relevant isolated test passes.
- Full isolated suite passes for cross-cutting changes.
- Import smoke test passes for startup or dependency changes.
- `git diff --check` reports no content errors.
- Generated caches and local credentials remain untracked.
+74
View File
@@ -0,0 +1,74 @@
# 文件清理与结构优化说明
清理日期:2026-08-29
本次仅整理当前工作区,没有创建 Git 提交,也没有覆盖清理前已经存在的业务代码修改。
## 清理结果
- 插件文件(不含 `.git`)由约 5.00 MB 降至约 1.86 MB。
- 删除所有 Python 字节码和 `__pycache__`;测试验证后生成的缓存也已再次清除。
- 根目录仅保留插件入口、配置、说明、依赖、更新工具和源码目录。
- 保留 `.git``.config``cases/` 中的有效案例,以及所有已注册节点和前端运行资源。
## 已删除的临时与工具文件
- 误生成文件:`45deea2``=1.2.0``screen_capture.png`
- 本地工具状态:`.agents/``.claude/``.codex/``.playwright-mcp/`
- 编辑器专用旧配置:`.cursorrules`
- 无效占位文件:`cases/.gitkeep`
- 所有 `__pycache__/``*.pyc`
## 已删除的历史资料
- `notes/` 中的阶段性重构记录和旧版笔记迁移样本。
- `mockups/note-sidebar-sketch.html`
- `V3_DEV_GUIDE.py`
- `SEEDANCE_ELEMENT_QUICKSTART.md``SEEDANCE_ELEMENT_README.md``SEEDANCE_NEW_FORMAT.md`
笔记面板仍会使用前端内置样本初始化,并将用户数据保存到 ComfyUI 的 `input/o1key-notes.json`,不再从插件目录迁移旧文件。
## 已删除的死代码
- 旧更新通知链:`utils/update_checker.py``version.txt``web/js/updateNotifier.js`
- 已移除颜色去背节点的遗留工具:`utils/color_key.py`
- 未被活动节点调用的客户端:
- `clients/base_async_provider.py`
- `clients/gemini_async_provider.py`
- `clients/kling_client.py`
- `clients/openai_client.py`
- 未注册且没有可用入口的节点:`nodes/seedance_firstlast.py`
相关的包导出、前端工作流线路集合和旧笔记迁移逻辑已同步清理,没有留下悬空引用。
## 测试目录整理
- 将仍有价值的离线测试从根目录移动到 `tests/`
- 删除带硬编码旧路径或会调用真实接口的手工脚本:
- `test_autopass_live.py`
- `test_upload.py`
- `test_upload_real.py`
- 删除已被新测试覆盖、断言已经过期的 `test_seedance_element.py`
- 新增 `tests/run_all.py`,让每个测试文件在独立进程中运行,避免 ComfyUI 测试桩污染其他测试模块。
运行全部离线测试:
```powershell
..\..\..\python_embeded\python.exe tests\run_all.py
```
## 代码结构优化
- `clients/__init__.py` 改为延迟导入,加载单个客户端时不再初始化所有模型供应商客户端。
- `utils/__init__.py` 改为延迟导入,减少插件启动期间不必要的图像和文件工具初始化。
- `.gitignore` 增加本地 AI 工具、Playwright、pytest 和覆盖率缓存规则。
- `README.md` 删除对不存在的配置、Mac 更新和编码修复脚本的说明,并改为当前可用的令牌管理界面与 `update.bat` 使用方法。
- 保留 `WEB_DIRECTORY = "./web"`;除确认失效的更新通知脚本外,所有前端扩展继续由 ComfyUI 自动加载。
## 验证结果
- Python 静态编译检查通过。
- 14 个隔离测试文件全部通过,共 107 个 Python 测试用例。
- `test_o1key_image_generator_frontend.mjs` 前端测试通过。
- 使用便携版 ComfyUI Python 成功导入插件。
- 插件成功注册 36 个节点,`WEB_DIRECTORY``./web`
+53
View File
@@ -0,0 +1,53 @@
# Testing
## Why tests run in isolation
Several tests replace ComfyUI modules in `sys.modules` with lightweight stubs. A single `unittest discover` process allows those stubs to leak into later test modules. `tests/run_all.py` therefore launches every Python file in a fresh interpreter and runs the frontend test separately when Node.js is available.
## Full offline suite
```powershell
..\..\..\python_embeded\python.exe tests\run_all.py
```
The suite must not contact real O1Key/provider endpoints or read the developer's `.config`.
When Node.js is available, `run_all.py` also runs every `tests/test_*.mjs`
frontend regression test in filename order.
## Focused tests
```powershell
..\..\..\python_embeded\python.exe tests\test_nano_banana_dynamic_inputs.py
..\..\..\python_embeded\python.exe tests\test_seedance_autopass_v3.py
node tests\test_o1key_image_generator_frontend.mjs
node tests\test_o1key_reference_image_editor.mjs
node tests\test_video_trim_frontend.mjs
```
## Static and import checks
```powershell
..\..\..\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('registered_nodes=', len(comfyui_o1key.NODE_CLASS_MAPPINGS)); print('web_directory=', comfyui_o1key.WEB_DIRECTORY)"
git diff --check
```
`compileall` creates ignored `__pycache__` directories. They do not belong in Git.
## Validation matrix
| Change | Minimum validation |
| --- | --- |
| Node schema or mapping | focused node test, full suite, import smoke |
| Client payload or response parsing | focused client test, retry/failure cases |
| Upload/download/polling | cancellation, size limit, retry, and failure tests |
| `__init__.py`, requirements, package exports | compile, full suite, import smoke |
| `web/js/o1keyImageGenerator.js` | frontend Node test |
| `web/js/o1keyReferenceImageEditor.js` | editor and image-generator frontend Node tests |
| `web/js/videoTrim.js` | video-trim frontend Node test |
| Workflow widget order | migration regression test and manual old-workflow check |
| Documentation only | link/path review and `git diff --check` |
## Manual checks
Use a real ComfyUI session only when behavior cannot be proven offline, such as canvas interaction, sidebar layout, or native preview rendering. Never turn a credit-consuming live request into a default automated test.
-555
View File
@@ -1,555 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>O1Key 笔记侧栏草图</title>
<style>
:root {
--bg: #151515;
--rail: #1b1b1b;
--panel: #202020;
--panel-2: #262626;
--field: #181818;
--line: rgba(255,255,255,.09);
--line-2: rgba(255,255,255,.16);
--text: #e6e6e6;
--soft: #b5b5b5;
--muted: #777;
--blue: #4f8cff;
--blue-2: #7eb8f7;
--blue-soft: rgba(79,140,255,.14);
--danger: #d76565;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: #101010;
color: var(--text);
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
display: grid;
place-items: center;
}
.frame {
width: 1180px;
height: 740px;
background: var(--bg);
border: 1px solid #303030;
display: grid;
grid-template-columns: 52px 352px 1fr;
overflow: hidden;
box-shadow: 0 18px 60px rgba(0,0,0,.45);
position: relative;
}
.rail {
background: var(--rail);
border-right: 1px solid var(--line);
padding: 10px 7px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.rail button {
width: 36px;
height: 36px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #8a8a8a;
display: grid;
place-items: center;
cursor: default;
font-size: 15px;
}
.rail button.active {
color: var(--blue-2);
background: rgba(79,140,255,.13);
border-color: rgba(79,140,255,.36);
}
.note-panel {
background: var(--panel);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.header {
padding: 14px 14px 10px;
border-bottom: 1px solid rgba(255,255,255,.06);
flex: 0 0 auto;
}
.title-row,
.actions,
.tag-strip,
.note-top,
.note-meta,
.edit-top,
.edit-actions,
.tag-row {
display: flex;
align-items: center;
}
.title-row {
justify-content: space-between;
margin-bottom: 12px;
}
.title {
font-size: 14px;
font-weight: 700;
letter-spacing: .2px;
}
.actions { gap: 5px; }
.icon-btn {
width: 28px;
height: 28px;
border: 1px solid var(--line);
border-radius: 7px;
color: #9b9b9b;
background: rgba(255,255,255,.035);
display: grid;
place-items: center;
}
.icon-btn.primary {
color: white;
background: var(--blue);
border-color: transparent;
}
.search {
height: 34px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255,255,255,.045);
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
color: var(--muted);
font-size: 12px;
}
.tag-strip {
gap: 6px;
margin-top: 10px;
overflow: hidden;
}
.tag-filter {
height: 26px;
padding: 0 9px;
border-radius: 7px;
border: 1px solid var(--line);
background: transparent;
color: #969696;
font-size: 12px;
white-space: nowrap;
}
.tag-filter.active {
color: var(--blue-2);
border-color: rgba(79,140,255,.36);
background: var(--blue-soft);
}
.list {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 8px 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.scroll-hint {
height: 24px;
color: #606060;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
border-top: 1px solid rgba(255,255,255,.04);
margin: 2px 4px 0;
}
.note {
border: 1px solid transparent;
border-radius: 8px;
padding: 10px;
background: transparent;
}
.note.active {
background: rgba(255,255,255,.055);
border-color: rgba(255,255,255,.11);
box-shadow: inset 2px 0 0 var(--blue);
}
.note-title {
font-size: 13px;
color: #e0e0e0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.note-action {
color: #777;
font-size: 11px;
margin-left: 8px;
}
.note-text {
margin-top: 6px;
font-size: 12px;
line-height: 1.45;
color: #8c8c8c;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.note-meta {
margin-top: 8px;
justify-content: space-between;
color: #666;
font-size: 10px;
gap: 8px;
}
.tags {
display: flex;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.tag {
color: var(--blue-2);
background: rgba(79,140,255,.1);
border: 1px solid rgba(79,140,255,.2);
border-radius: 5px;
padding: 2px 5px;
white-space: nowrap;
}
.canvas {
position: relative;
background:
linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
background-size: 26px 26px;
min-width: 0;
overflow: hidden;
}
.canvas-label {
position: absolute;
left: 24px;
top: 22px;
color: #5f5f5f;
font-size: 12px;
}
.node {
position: absolute;
width: 186px;
height: 92px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,.12);
background: #222;
box-shadow: 0 12px 30px rgba(0,0,0,.2);
}
.node.one { left: 160px; top: 160px; }
.node.two { left: 430px; top: 290px; }
.node::before {
content: "";
display: block;
height: 28px;
border-bottom: 1px solid rgba(255,255,255,.08);
background: rgba(79,140,255,.12);
border-radius: 8px 8px 0 0;
}
.edit-popover {
position: absolute;
right: 24px;
top: 76px;
width: 420px;
height: 588px;
border: 1px solid rgba(79,140,255,.28);
border-radius: 10px;
background: #202020;
box-shadow: 0 22px 70px rgba(0,0,0,.46);
display: flex;
flex-direction: column;
overflow: hidden;
}
.edit-top {
height: 48px;
padding: 0 14px;
border-bottom: 1px solid rgba(255,255,255,.08);
justify-content: space-between;
flex: 0 0 auto;
}
.edit-title {
font-size: 13px;
font-weight: 700;
}
.edit-body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 9px;
min-height: 0;
flex: 1;
}
.input,
.textarea {
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255,255,255,.045);
color: #ddd;
font-family: inherit;
}
.input {
height: 34px;
padding: 0 10px;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
}
.textarea {
flex: 1;
min-height: 220px;
padding: 12px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
font-family: "JetBrains Mono", "Consolas", monospace;
}
.tag-editor {
min-height: 74px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--field);
padding: 8px;
}
.tag-row {
gap: 6px;
flex-wrap: wrap;
}
.tag-token {
height: 24px;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 6px;
padding: 0 7px;
border: 1px solid rgba(79,140,255,.25);
color: var(--blue-2);
background: rgba(79,140,255,.1);
font-size: 12px;
}
.tag-token .x {
color: #8baee8;
font-size: 13px;
}
.tag-input {
height: 24px;
min-width: 106px;
padding: 0 6px;
border: 1px dashed rgba(255,255,255,.14);
border-radius: 6px;
color: #8f8f8f;
display: inline-flex;
align-items: center;
font-size: 12px;
}
.hint {
margin-top: 7px;
color: #666;
font-size: 11px;
}
.edit-actions {
gap: 8px;
padding: 12px;
border-top: 1px solid rgba(255,255,255,.08);
flex: 0 0 auto;
}
.btn {
height: 32px;
border-radius: 7px;
border: 1px solid var(--line);
background: rgba(255,255,255,.04);
color: #aaa;
padding: 0 12px;
font-size: 12px;
}
.btn.primary {
background: var(--blue);
border-color: transparent;
color: white;
font-weight: 700;
flex: 1;
}
.btn.cancel {
color: #ccc;
}
.btn.danger {
color: #ee9f9f;
border-color: rgba(215,101,101,.24);
}
</style>
</head>
<body>
<div class="frame">
<nav class="rail">
<button></button>
<button>💬</button>
<button class="active"></button>
<button>🖼</button>
<button></button>
</nav>
<aside class="note-panel">
<div class="header">
<div class="title-row">
<div class="title">笔记</div>
<div class="actions">
<button class="icon-btn"></button>
<button class="icon-btn primary"></button>
</div>
</div>
<div class="search">⌕ 搜索笔记内容或标签</div>
<div class="tag-strip">
<div class="tag-filter active">全部 18</div>
<div class="tag-filter">#产品图</div>
<div class="tag-filter">#Nano Banana</div>
<div class="tag-filter">#负向词</div>
</div>
</div>
<div class="list">
<div class="note active">
<div class="note-top">
<div class="note-title">产品主图:高级玻璃质感</div>
<div class="note-action">编辑</div>
</div>
<div class="note-text">Clean studio lighting, translucent glass material, subtle caustics, soft shadow, premium product photography...</div>
<div class="note-meta"><div class="tags"><span class="tag">#产品图</span><span class="tag">#玻璃</span></div><span>今天 14:22</span></div>
</div>
<div class="note">
<div class="note-top">
<div class="note-title">Nano Banana 参考图经验</div>
<div class="note-action">编辑</div>
</div>
<div class="note-text">参考图越多越容易跑偏,主体一致性优先用 1-3 张图;复杂场景建议分两步...</div>
<div class="note-meta"><div class="tags"><span class="tag">#Nano Banana</span><span class="tag">#参考图</span></div><span>昨天</span></div>
</div>
<div class="note">
<div class="note-top">
<div class="note-title">电商模特换装模板</div>
<div class="note-action">编辑</div>
</div>
<div class="note-text">Keep face identity, preserve pose, replace outfit with [服装描述], realistic fabric texture...</div>
<div class="note-meta"><div class="tags"><span class="tag">#电商</span><span class="tag">#换装</span></div><span>05/25</span></div>
</div>
<div class="note">
<div class="note-top">
<div class="note-title">常用负向词</div>
<div class="note-action">编辑</div>
</div>
<div class="note-text">low quality, blurry, deformed hands, extra fingers, bad anatomy, distorted text, watermark...</div>
<div class="note-meta"><div class="tags"><span class="tag">#负向词</span><span class="tag">#通用</span></div><span>05/20</span></div>
</div>
<div class="note">
<div class="note-top">
<div class="note-title">批量任务命名经验</div>
<div class="note-action">编辑</div>
</div>
<div class="note-text">小批量先跑 2-3 张确认风格,固定提示词和参考图后再放大批量数量...</div>
<div class="note-meta"><div class="tags"><span class="tag">#批量</span><span class="tag">#工作流</span></div><span>05/18</span></div>
</div>
<div class="scroll-hint">列表默认可滚动,编辑框不常驻</div>
</div>
</aside>
<main class="canvas">
<div class="canvas-label">ComfyUI 画布区域,笔记列表不再占用右侧空间</div>
<div class="node one"></div>
<div class="node two"></div>
</main>
<section class="edit-popover">
<div class="edit-top">
<div class="edit-title">编辑笔记</div>
<button class="icon-btn">×</button>
</div>
<div class="edit-body">
<div class="input">产品主图:高级玻璃质感</div>
<div class="tag-editor">
<div class="tag-row">
<span class="tag-token">产品图 <span class="x">×</span></span>
<span class="tag-token">玻璃 <span class="x">×</span></span>
<span class="tag-token">灯光 <span class="x">×</span></span>
<span class="tag-input"> 添加标签</span>
</div>
<div class="hint">只保留标签作为组织方式;可搜索、筛选、删除。</div>
</div>
<div class="textarea">Clean studio lighting, translucent glass material, subtle caustics, soft shadow, premium product photography, 85mm lens, minimal background, high detail.
使用方式:
1. 把产品图作为参考图输入
2. 保留主体轮廓,只调整材质和灯光
3. 如果玻璃过亮,降低 “caustics” 权重</div>
</div>
<div class="edit-actions">
<button class="btn cancel">取消</button>
<button class="btn">复制</button>
<button class="btn danger">删除</button>
<button class="btn primary">保存</button>
</div>
</section>
</div>
</body>
</html>
+4 -4
View File
@@ -69,7 +69,7 @@ GEMINI_MODELS = [
},
{
"id": "nano-banana-2-次卡",
"description": "Nano Banana 2 次卡,根据分辨率自动选择端点 (512px/1K/2K/4K),图像生成模型",
"description": "Nano Banana 2 次卡,根据分辨率自动选择端点 (512/1K/2K/4K),图像生成模型",
"enabled": True,
"provider": "gemini_async",
"endpoint_type": "dynamic",
@@ -78,7 +78,7 @@ GEMINI_MODELS = [
"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4",
"8:1", "9:16", "16:9", "21:9"
],
"supported_resolutions": ["512px", "1K", "2K", "4K"]
"supported_resolutions": ["512", "1K", "2K", "4K"]
},
{
"id": "nano-banana-2-官方计费",
@@ -91,7 +91,7 @@ GEMINI_MODELS = [
"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4",
"8:1", "9:16", "16:9", "21:9"
],
"supported_resolutions": ["512px", "1K", "2K", "4K"]
"supported_resolutions": ["512", "1K", "2K", "4K"]
},
{
"id": "nano-banana-次卡",
@@ -309,7 +309,7 @@ def get_all_supported_resolutions() -> List[str]:
>>> get_all_supported_resolutions()
['512', '1K', '2K', '4K']
"""
_ORDER = ["512px", "1K", "2K", "4K"]
_ORDER = ["512", "1K", "2K", "4K"]
seen = set()
for model in GEMINI_MODELS:
+13
View File
@@ -0,0 +1,13 @@
# Node implementation rules
These instructions apply to `nodes/` and override broader guidance where more specific.
- Preserve released node IDs, input IDs, output order, and widget ordering unless a tested workflow migration is included.
- Root `NODE_CLASS_MAPPINGS` in `../__init__.py` is the canonical public registry. Keep `nodes/__init__.py` exports synchronized with it.
- V1 and V3 nodes currently coexist. Follow the API style already used by the target node; do not migrate unrelated nodes opportunistically.
- V3 `node_id` must match the root mapping key. Return `io.NodeOutput` from V3 execution methods.
- Validate user inputs before uploads or paid API calls. Check interruption during polling, retries, and long downloads.
- Do not store API keys or resolved authorization data in node attributes that can enter serialized workflows.
- Put reusable HTTP behavior, retry logic, uploads, media validation, and response parsing in `clients/` or `utils/`.
- If widget layout changes, update `../web/js/migrateWorkflow.js` and add a regression test for legacy `widgets_values`.
- Add new node tests under `../tests/` and run them through `../tests/run_all.py`.
+144 -112
View File
@@ -1,11 +1,20 @@
"""
K3 动作控制 自研节点
K3 动作控制节点
用参考视频驱动参考图中人物动作,生成视频。
视频通过 R2 上传后传 URL,图片转 base64 直传。
支持的模型:
- v3:标准动作控制,支持 5~30s 时长
- v2-6:标准动作控制,支持 5~30s 时长
- v3-t / v2-6-t:腾讯 Kling 网关渠道(保留兼容)
接口端点:
- 动作控制:POST /kling/v1/videos/motion-control
- 腾讯渠道:POST /v1/videos
"""
import asyncio
import io
import io as _stdio
import json
import os
import struct
@@ -13,12 +22,16 @@ import tempfile
import aiohttp
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from comfy_api.latest import io
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, get_base_url_by_route
from ..utils.r2_uploader import upload_video, upload_image
from ..utils.image_utils import tensor_to_pil
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,
@@ -39,9 +52,26 @@ except Exception:
# ── 常量 ──────────────────────────────────────────────────────────────────────
# 官方标准模型名映射
_STANDARD_MODELS = {
"v3": "kling-v3",
"v2-6": "kling-v2-6",
}
# 官方标准端点
_ENDPOINT_CREATE = "/kling/v1/videos/motion-control"
_ENDPOINT_STATUS = "/kling/v1/videos/motion-control/{task_id}"
# 腾讯 Kling 网关渠道(-t):保留兼容
_ENDPOINT_T_CREATE = "/v1/videos"
_ENDPOINT_T_STATUS = "/v1/videos/{task_id}"
# -t 渠道模型名映射(服务端已部署,需在「模型倍率」各配一行 =1)
_MODEL_T_MAP = {
"v3-t": "kling-v3-motion-t",
"v2-6-t": "kling-v2-6-motion-t",
}
_POLL_INIT = 5
_POLL_MAX = 15
@@ -80,7 +110,7 @@ def _get_video_duration(reference_video) -> float | None:
if isinstance(source, str) and os.path.isfile(source):
with open(source, "rb") as f:
data = f.read()
elif isinstance(source, io.BytesIO):
elif isinstance(source, _stdio.BytesIO):
source.seek(0)
data = source.read()
else:
@@ -106,51 +136,79 @@ def _validate_video_duration(reference_video, character_orientation: str):
)
# ── 模型 DynamicCombo 选项构建 ─────────────────────────────────────────────────
def _build_model_input():
"""构建「模型」DynamicCombo。
支持的模型:
- v3:标准动作控制
- v2-6:标准动作控制
- v3-t / v2-6-t:腾讯网关渠道(保留兼容)
"""
def _duration_input():
return io.Combo.Input(
"时长", options=[5, 10, 15, 20, 25, 30], default=5,
tooltip="输出视频时长(秒)。须 ≥ 参考视频时长。",
)
return io.DynamicCombo.Input(
"模型",
options=[
io.DynamicCombo.Option("v3", [_duration_input()]),
io.DynamicCombo.Option("v2-6", [_duration_input()]),
io.DynamicCombo.Option("v3-t", []), # 腾讯网关,无时长参数
io.DynamicCombo.Option("v2-6-t", []), # 腾讯网关,无时长参数
],
tooltip="v3/v2-6:官方标准模型;v3-t/v2-6-t:腾讯网关渠道(兼容)。",
)
# ── 节点 ──────────────────────────────────────────────────────────────────────
class K3MotionControl:
class K3MotionControl(io.ComfyNode):
"""K3 动作控制 自研 —— 用参考视频驱动参考图人物动作"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"参考图片": ("IMAGE",),
"参考视频": ("VIDEO",),
"提示词": ("STRING", {"multiline": True, "default": ""}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
"模型": (["v3", "v2-6"], {"default": "v3"}),
"模式": (["720p", "1080p"], {"default": "1080p"}),
"时长": ([5, 10, 15, 20, 25, 30], {"default": 5}),
"角色朝向": (["图片", "视频"], {"default": "图片"}),
"保留原声": (["打开", "关闭"], {"default": "打开"}),
"seed": ("INT", {
"default": 0, "min": 0, "max": 2147483647,
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
}),
},
}
def define_schema(cls):
return io.Schema(
node_id="K3MotionControl",
display_name="K 动作模仿",
category="comfyui_o1key/KVideo",
inputs=[
io.Image.Input("参考图片"),
io.Video.Input("参考视频"),
_build_model_input(),
io.String.Input("提示词", multiline=True, default=""),
io.Combo.Input("模式", options=["720p", "1080p"], default="1080p"),
io.Combo.Input("角色朝向", options=["图片", "视频"], default="图片"),
io.Combo.Input("保留原声", options=["打开", "关闭"], default="打开"),
io.Int.Input("seed", default=0, min=0, max=2147483647,
tooltip="seed 仅控制节点是否重新运行,结果本身不可复现。"),
],
outputs=[io.Video.Output(display_name="视频")],
accept_all_inputs=True,
)
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/KVideo"
async def generate(self, 参考图片, 参考视频, 提示词, 保留原声, 角色朝向, 模式, 模型, 时长, 网络线路, seed, **kwargs):
@classmethod
async def execute(cls, 参考图片, 参考视频, 模型, 提示词, 模式, 角色朝向, 保留原声, seed, **_kwargs) -> io.NodeOutput:
api_key = get_api_key_or_raise()
base_url = get_base_url_by_route(网络线路)
# ── 渠道判定(模型为 DynamicCombo dict)─────────────────────────
模型代号 = 模型["模型"]
is_t_channel = 模型代号 in _MODEL_T_MAP
时长 = int(模型.get("时长", 5)) # -t 渠道无此子输入
mode_api = "std" if 模式 == "720p" else "pro"
character_orientation = "image" if 角色朝向 == "图片" else "video"
keep_sound = "yes" if 保留原声 == "打开" else "no"
prompt = 提示词.strip()
base_url = get_base_url_by_route()
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# ── 参数映射 ──────────────────────────────────────────────────
mode_api = "std" if 模式 == "720p" else "pro"
model_name = f"kling-{模型}-motion-{mode_api}-{时长}s"
character_orientation = "image" if 角色朝向 == "图片" else "video"
keep_sound = "yes" if 保留原声 == "打开" else "no"
prompt = 提示词.strip()
if len(prompt) > 2500:
raise ValueError(f"提示词长度({len(prompt)})超过上限 2500 个字符,请缩短后重试。")
@@ -163,7 +221,7 @@ class K3MotionControl:
def _stage(s: str):
if s == "uploading":
print("[K3 动作控制] 上传视频到 R2...")
print("[K3 动作控制] 上传图片/视频到 OSS...")
if pbar: pbar.update_absolute(0, 100)
elif s == "submitting":
print("[K3 动作控制] 提交任务...")
@@ -182,9 +240,11 @@ class K3MotionControl:
if pbar: pbar.update_absolute(15 + int(pct * 0.84), 100)
# ── 视频时长校验 ──────────────────────────────────────────────
# 参考视频时长约束(image≤10s / video≤30s,下限 3s)两渠道通用。
_validate_video_duration(参考视频, character_orientation)
# 参考视频时长不得超过所选时长(防止用长视频生成短计费)
# 参考视频不得超过所选时长」仅标准渠道有意义:-t 渠道无时长入参
if not is_t_channel:
_dur = _get_video_duration(参考视频)
if _dur is not None and _dur > 时长 + 0.5:
raise ValueError(
@@ -192,26 +252,53 @@ class K3MotionControl:
f"请将时长调整为 ≥{_dur:.0f}s 的档位,或更换更短的参考视频。"
)
# ── 图片 & 视频上传 R2 → 获取公网 URL ────────────────────────
# ── 图片 & 视频上传 OSS → 获取公网 URL ────────────────────────
_stage("uploading")
check_interrupt()
pil_list = tensor_to_pil(参考图片)
image_url = await upload_image(pil_list[0].convert("RGB"))
img = pil_list[0]
# 转换为 RGBA 以支持透明通道,PNG 格式上传
if img.mode not in ("RGBA", "RGB"):
img = img.convert("RGBA" if "A" in img.mode or img.mode == "LA" else "RGB")
image_url = await upload_image(img, base_url=base_url)
check_interrupt()
video_url = await upload_video(参考视频)
video_url = await upload_video(参考视频, base_url=base_url)
# ── 构建请求体 ────────────────────────────────────────────────
body: dict = {
"model_name": model_name,
"model": model_name,
if is_t_channel:
# 腾讯 Kling 网关渠道:动作控制专有字段进 metadata 透传(PascalCase),
# 顶层只放标准字段。无 duration / mode 由网关按模型处理。
body = {
"model": _MODEL_T_MAP[模型代号],
"prompt": prompt or "动作与参考视频保持一致", # 网关强制非空
"image": image_url,
"metadata": {
"Video": video_url,
"CharacterOrientation": character_orientation,
"KeepOriginalSound": keep_sound,
"Mode": mode_api, # 720p→std / 1080p→pro
},
}
create_path = _ENDPOINT_T_CREATE
status_path = _ENDPOINT_T_STATUS
else:
# 标准渠道:使用官方标准接口,参数扁平传递
# 获取实际的模型名(v3 → kling-v3
actual_model_name = _STANDARD_MODELS.get(模型代号, f"kling-{模型代号}")
body = {
"model_name": actual_model_name,
"image_url": image_url,
"video_url": video_url,
"character_orientation": character_orientation,
"mode": mode_api,
"keep_original_sound": keep_sound,
"duration": str(时长),
}
if prompt:
body["prompt"] = prompt
create_path = _ENDPOINT_CREATE
status_path = _ENDPOINT_STATUS
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
tmp_fd, save_path = tempfile.mkstemp(suffix=".mp4", prefix="k3_motion_")
@@ -222,7 +309,7 @@ class K3MotionControl:
# 1. 提交任务
check_interrupt()
_stage("submitting")
create_url = f"{base_url}{_ENDPOINT_CREATE}"
create_url = f"{base_url}{create_path}"
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", create_url,
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
@@ -243,11 +330,13 @@ class K3MotionControl:
_stage(f"submitted:{task_id}")
# 2. 轮询
status_url = f"{base_url}{_ENDPOINT_STATUS.format(task_id=task_id)}"
status_url = f"{base_url}{status_path.format(task_id=task_id)}"
interval = _POLL_INIT
video_result_url = None
deadline = PollDeadline(label="K3 动作控制")
while True:
deadline.check()
await interruptible_sleep(interval)
check_interrupt()
async with session.get(status_url, headers=headers) as resp:
@@ -281,84 +370,27 @@ class K3MotionControl:
if not video_result_url:
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
# 3. 下载视频
# 3. 下载视频(抗超时 / 断点续传 / 无限重试 / 可取消)
check_interrupt()
_stage("downloading")
async with session.get(video_result_url, allow_redirects=True) as resp:
if resp.status != 200:
raise RuntimeError(f"视频下载失败 ({resp.status})")
os.close(tmp_fd)
with open(save_path, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
check_interrupt()
f.write(chunk)
await download_video_to_file(
session, video_result_url, save_path, label="K3 动作控制",
)
_stage("done")
if _FOLDER_PATHS_OK:
return (InputImpl.VideoFromFile(save_path),)
return (save_path,)
# ── 视频时长检测测试节点 ──────────────────────────────────────────────────────
class K3MotionVideoCheck:
"""检测视频时长并校验是否满足动作控制的限制,不调用 API。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"参考视频": ("VIDEO",),
"角色朝向": (["图片", "视频"], {"default": "图片"}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("检测结果",)
FUNCTION = "check"
CATEGORY = "comfyui_o1key/KVideo"
OUTPUT_NODE = True
def check(self, 参考视频, 角色朝向):
character_orientation = "image" if 角色朝向 == "图片" else "video"
duration = _get_video_duration(参考视频)
if duration is None:
result = "❌ 无法解析视频时长(格式不支持或文件损坏)"
print(f"[K3 视频检测] {result}")
return (result,)
limit = 10 if character_orientation == "image" else 30
orientation_label = 角色朝向
ok = 3 <= duration <= limit
if ok:
result = (
f"✅ 时长检测通过\n"
f"视频时长: {duration:.2f}s\n"
f"角色朝向: {orientation_label}(限制 3~{limit}s"
)
else:
result = (
f"❌ 时长检测不通过\n"
f"视频时长: {duration:.2f}s\n"
f"角色朝向: {orientation_label}(限制 3~{limit}s\n"
f"请更换时长在 3~{limit}s 之间的视频。"
)
print(f"[K3 视频检测] {result}")
return (result,)
return io.NodeOutput(InputImpl.VideoFromFile(save_path))
return io.NodeOutput(save_path)
# ── 节点注册 ──────────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {
"K3MotionControl": K3MotionControl,
"K3MotionVideoCheck": K3MotionVideoCheck,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"K3MotionControl": "动作控制 K3 自研",
"K3MotionVideoCheck": "视频时长检测 K3",
"K3MotionControl": "K 动作模仿",
}
+908 -161
View File
File diff suppressed because it is too large Load Diff
-283
View File
@@ -1,283 +0,0 @@
"""
首尾帧 K3 自研节点
基于 K3 图生视频 自研,去掉分镜功能,新增尾帧可选输入。
"""
import asyncio
import json
import os
import tempfile
import aiohttp
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.http_error import async_request_with_retry
from ..utils.video_task import (
check_interrupt,
extract_error_message,
extract_progress,
extract_status,
extract_video_url,
interruptible_sleep,
is_failure_status,
is_success_status,
run_with_interrupt,
)
try:
from comfy_api.latest import InputImpl
import folder_paths
_FOLDER_PATHS_OK = True
except Exception:
_FOLDER_PATHS_OK = False
# ── 常量 ──────────────────────────────────────────────────────────────────────
_MODEL_BASE = "kling-v3"
_MODES = ["720p", "1080p", "4K"]
_MODE_MAP = {"720p": "std", "1080p": "pro", "4K": "4k"}
_ENDPOINT_CREATE = "/v1/video/generations"
_ENDPOINT_STATUS = "/v1/video/generations/{task_id}"
_POLL_INIT = 3
_POLL_MAX = 15
# ── 工具函数 ───────────────────────────────────────────────────────────────────
def _prepare_image_base64(tensor) -> str:
"""转换并校验图片,不符合约束时自动等比缩放后返回 base64。"""
import io
import base64
pil_list = tensor_to_pil(tensor)
img = pil_list[0].convert("RGB")
w, h = img.size
# 1. 宽高比校验
ratio = w / h
if ratio < 1 / 2.5 or ratio > 2.5:
raise RuntimeError(
f"图片宽高比 {w}:{h}{ratio:.2f})超出允许范围 1:2.5 ~ 2.5:1,请裁剪后重试。"
)
# 2. 最小尺寸:任意边 < 300px 时等比放大
if w < 300 or h < 300:
scale = max(300 / w, 300 / h)
img = img.resize((int(w * scale), int(h * scale)), resample=1)
# 3. 文件大小:循环等比缩小直到 ≤ 10MB
MAX_BYTES = 10 * 1024 * 1024
for _ in range(20):
buf = io.BytesIO()
img.save(buf, format="PNG")
if buf.tell() <= MAX_BYTES:
break
scale = (MAX_BYTES / buf.tell()) ** 0.5 * 0.95
new_w = int(img.width * scale)
new_h = int(img.height * scale)
if new_w < 300 or new_h < 300:
raise RuntimeError(
f"图片压缩至 10MB 以内后尺寸({new_w}x{new_h})低于最小限制 300px,无法同时满足两项约束。"
)
img = img.resize((new_w, new_h), resample=1)
else:
raise RuntimeError("图片经过 20 次缩放仍超过 10MB,请检查原始图片。")
buf.seek(0)
return base64.b64encode(buf.read()).decode("utf-8")
# ── 节点 ──────────────────────────────────────────────────────────────────────
class K3VideoFirstLast:
"""首尾帧 K3 自研"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"起始帧": ("IMAGE",),
"提示词": ("STRING", {"multiline": True, "default": ""}),
"负向提示词": ("STRING", {"multiline": True, "default": ""}),
"时长": ([5, 10, 15], {"default": 5}),
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
"模式": (_MODES, {"default": "720p"}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
"seed": ("INT", {
"default": 0, "min": 0, "max": 2147483647,
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
}),
},
"optional": {
"尾帧": ("IMAGE", {"tooltip": "可选。传入后将作为视频尾帧参考。"}),
},
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/KVideo"
async def generate(self, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, 网络线路, seed, 尾帧=None):
api_key = get_api_key_or_raise()
base_url = get_base_url_by_route(网络线路)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
voice = "voice" if 生成音频 == "打开" else "novoice"
mode_api = _MODE_MAP[模式]
if mode_api == "4k":
model_name = f"{_MODEL_BASE}-4k-{时长}s"
else:
model_name = f"{_MODEL_BASE}-{mode_api}-{时长}s-{voice}"
if not 提示词.strip():
raise RuntimeError("提示词不能为空。")
# ── 构建请求体 ────────────────────────────────────────────────
body: dict = {
"model": model_name,
"prompt": 提示词.strip(),
"mode": mode_api,
"duration": 时长,
"image": _prepare_image_base64(起始帧),
}
if 负向提示词.strip():
body["negative_prompt"] = 负向提示词.strip()
# metadata:尾帧 + 音频
metadata: dict = {}
if 尾帧 is not None:
metadata["image_tail"] = _prepare_image_base64(尾帧)
if 生成音频 == "打开":
metadata["sound"] = "on"
if metadata:
body["metadata"] = metadata
# generate_audio 字段(非 metadata 路径)
if 生成音频 == "打开" and not metadata.get("sound"):
body["generate_audio"] = True
# ── 进度条 ────────────────────────────────────────────────────
try:
from comfy.utils import ProgressBar
pbar = ProgressBar(100)
except Exception:
pbar = None
def _stage(s: str):
if s == "submitting":
print("[K3 首尾帧] 提交中...")
if pbar: pbar.update_absolute(0, 100)
elif s.startswith("submitted:"):
print(f"[K3 首尾帧] 任务已提交 → {s.split(':', 1)[1]}")
if pbar: pbar.update_absolute(5, 100)
elif s == "downloading":
print("[K3 首尾帧] 下载视频...")
if pbar: pbar.update_absolute(99, 100)
elif s == "done":
print("[K3 首尾帧] 完成")
if pbar: pbar.update_absolute(100, 100)
def _progress(pct: int):
if pbar: pbar.update_absolute(5 + int(pct * 0.94), 100)
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
tmp_fd, save_path = tempfile.mkstemp(suffix=".mp4", prefix="k3fl_")
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
# 1. 提交
check_interrupt()
_stage("submitting")
create_url = f"{base_url}{_ENDPOINT_CREATE}"
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", create_url, json=body, headers=headers, prefix="K3 首尾帧提交: "
))
check_interrupt()
text = await resp.text()
create_resp = json.loads(text)
task_id = (
create_resp.get("task_id")
or create_resp.get("id")
or create_resp.get("data", {}).get("task_id")
)
if not task_id:
raise RuntimeError(f"API 未返回任务 ID,响应:{create_resp}")
_stage(f"submitted:{task_id}")
# 2. 轮询
status_url = f"{base_url}{_ENDPOINT_STATUS.format(task_id=task_id)}"
interval = _POLL_INIT
video_url = None
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}")
sr = json.loads(text)
data = sr.get("data", sr)
status = extract_status(sr)
pct = extract_progress(sr)
print(f"[K3 首尾帧] 生成中 {pct}%")
_progress(pct)
if is_success_status(status):
video_url = extract_video_url(sr)
break
if is_failure_status(status, sr):
err_msg = extract_error_message(sr)
raise RuntimeError(f"K3 首尾帧生成失败:{err_msg}")
await interruptible_sleep(interval)
interval = min(interval * 1.5, _POLL_MAX)
if not video_url:
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
# 3. 下载
check_interrupt()
_stage("downloading")
async with session.get(video_url, allow_redirects=True) as resp:
if resp.status != 200:
raise RuntimeError(f"视频下载失败 ({resp.status})")
os.close(tmp_fd)
with open(save_path, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
check_interrupt()
f.write(chunk)
_stage("done")
if _FOLDER_PATHS_OK:
return (InputImpl.VideoFromFile(save_path),)
return (save_path,)
# ── 节点注册 ──────────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {
"K3VideoFirstLast": K3VideoFirstLast,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"K3VideoFirstLast": "首尾帧 K3 自研",
}
-254
View File
@@ -1,254 +0,0 @@
"""
K26 图生视频节点
"""
import asyncio
import json
import math
import os
import tempfile
import aiohttp
from ..utils.config import get_api_key_or_raise, get_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.http_error import async_request_with_retry
from ..utils.video_task import (
check_interrupt,
extract_error_message,
extract_progress,
extract_status,
extract_video_url,
interruptible_sleep,
is_failure_status,
is_success_status,
run_with_interrupt,
)
try:
from comfy_api.latest import InputImpl
import folder_paths
_FOLDER_PATHS_OK = True
except ImportError:
_FOLDER_PATHS_OK = False
# 模型基础名,运行时动态拼接完整名称
_MODEL_BASE = "kling-v2-6"
# API 端点
_ENDPOINT_CREATE = "/v1/video/generations"
_ENDPOINT_STATUS = "/v1/video/generations/{task_id}"
_POLL_INIT = 3
_POLL_MAX = 15
def _image_to_base64(tensor, scale=1.0) -> str:
from PIL import Image
pil = tensor_to_pil(tensor)
img = pil[0]
if scale < 1.0:
w, h = img.size
new_w = max(1, int(w * scale))
new_h = max(1, int(h * scale))
img = img.resize((new_w, new_h), Image.LANCZOS)
return encode_image_to_base64(img, format="PNG")
class KVideoFirstLast:
"""K26 图生视频节点(首尾帧)"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"起始帧": ("IMAGE",),
"提示词": ("STRING", {"multiline": True, "default": ""}),
"模式": (["1080p"],),
"时长": ([5, 10],),
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
"seed": ("INT", {
"default": 0, "min": 0, "max": 2147483647,
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
}),
},
"optional": {
"尾帧": ("IMAGE",),
},
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/KVideo"
async def generate(self, 起始帧, 提示词, 模式, 时长, 生成音频="关闭", 网络线路="全球加速", 尾帧=None, seed=0):
api_key = get_api_key_or_raise()
base_url = get_base_url_by_route(网络线路)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# ── 动态拼接模型名 ────────────────────────────────────────────
mode_api = "pro" # 1080p 映射为 pro
voice = "voice" if 生成音频 == "打开" else "novoice"
model_name = f"{_MODEL_BASE}-{mode_api}-{时长}s-{voice}"
# ── 构建请求体(超过 10MB 自动缩放图片)────────────────────────
MAX_BODY = 10 * 1024 * 1024
scale = 1.0
print(f"[K26 图生视频] 请求体大小限制: 10MB,超出将自动缩放图片")
while True:
body = {
"model": model_name,
"prompt": 提示词.strip(),
"image": _image_to_base64(起始帧, scale),
"mode": mode_api,
"duration": 时长,
}
metadata = {}
if 尾帧 is not None:
metadata["image_tail"] = _image_to_base64(尾帧, scale)
if 生成音频 == "打开":
metadata["sound"] = "on"
if metadata:
body["metadata"] = metadata
body_str = json.dumps(body, ensure_ascii=False)
body_size = len(body_str.encode("utf-8"))
if body_size <= MAX_BODY:
print(f"[K26 图生视频] 请求体大小: {body_size / 1024 / 1024:.2f}MB"
+ (f"(已缩放至 {scale:.1%}" if scale < 1.0 else ""))
break
# 等比缩放:图片像素面积与 base64 长度近似线性
target_ratio = MAX_BODY / body_size
scale = scale * math.sqrt(target_ratio) * 0.95 # 5% 安全余量
if scale < 0.01:
raise RuntimeError("图片缩放后仍超过10MB限制,请使用更小的参考图")
w, h = tensor_to_pil(起始帧)[0].size
print(f"[K26 图生视频] 请求体 {body_size / 1024 / 1024:.2f}MB 超限,"
f"自动缩放至 {scale:.1%}{int(w * scale)}x{int(h * scale)}")
# ── 进度条 ────────────────────────────────────────────────────
try:
from comfy.utils import ProgressBar
pbar = ProgressBar(100)
except Exception:
pbar = None
def _stage(s: str):
if s == "submitting":
print("[K26 图生视频] 提交中...")
if pbar: pbar.update_absolute(0, 100)
elif s.startswith("submitted:"):
print(f"[K26 图生视频] 任务已提交 → {s.split(':', 1)[1]}")
if pbar: pbar.update_absolute(5, 100)
elif s == "downloading":
print("[K26 图生视频] 下载视频...")
if pbar: pbar.update_absolute(99, 100)
elif s == "done":
print("[K26 图生视频] 完成")
if pbar: pbar.update_absolute(100, 100)
def _progress(pct: int):
if pbar: pbar.update_absolute(5 + int(pct * 0.94), 100)
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
tmp_fd, save_path = tempfile.mkstemp(suffix=".mp4", prefix="k26_")
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
# 1. 提交
check_interrupt()
_stage("submitting")
create_url = f"{base_url}{_ENDPOINT_CREATE}"
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", create_url, json=body, headers=headers, prefix="K26 提交: "
))
check_interrupt()
text = await resp.text()
create_resp = json.loads(text)
task_id = (
create_resp.get("task_id")
or create_resp.get("id")
or create_resp.get("data", {}).get("task_id")
)
if not task_id:
raise RuntimeError(f"API 未返回任务 ID,响应:{create_resp}")
_stage(f"submitted:{task_id}")
# 2. 轮询
status_url = f"{base_url}{_ENDPOINT_STATUS.format(task_id=task_id)}"
interval = _POLL_INIT
video_url = None
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}")
sr = json.loads(text)
data = sr.get("data", sr)
status = extract_status(sr)
pct = extract_progress(sr)
print(f"[K26 图生视频] 生成中 {pct}%")
_progress(pct)
if is_success_status(status):
# 提取视频 URL
video_url = extract_video_url(sr)
break
if is_failure_status(status, sr):
err_msg = extract_error_message(sr)
raise RuntimeError(f"K26 生成失败:{err_msg}")
await interruptible_sleep(interval)
interval = min(interval * 1.5, _POLL_MAX)
if not video_url:
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
# 3. 下载
check_interrupt()
_stage("downloading")
async with session.get(video_url, allow_redirects=True) as resp:
if resp.status != 200:
raise RuntimeError(f"视频下载失败 ({resp.status})")
os.close(tmp_fd)
with open(save_path, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
check_interrupt()
f.write(chunk)
_stage("done")
if _FOLDER_PATHS_OK:
return (InputImpl.VideoFromFile(save_path),)
return (save_path,)
NODE_CLASS_MAPPINGS = {
"KVideoFirstLast": KVideoFirstLast,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"KVideoFirstLast": "K26 图生视频(首尾帧)",
}
-242
View File
@@ -1,242 +0,0 @@
"""
K26 图生视频节点
支持 720p 和 1080p 模式
"""
import asyncio
import json
import math
import os
import tempfile
import aiohttp
from ..utils.config import get_api_key_or_raise, get_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.http_error import async_request_with_retry
from ..utils.video_task import (
check_interrupt,
extract_error_message,
extract_progress,
extract_status,
extract_video_url,
interruptible_sleep,
is_failure_status,
is_success_status,
run_with_interrupt,
)
try:
from comfy_api.latest import InputImpl
import folder_paths
_FOLDER_PATHS_OK = True
except ImportError:
_FOLDER_PATHS_OK = False
# 模型基础名,运行时动态拼接完整名称
_MODEL_BASE = "kling-v2-6"
# API 端点
_ENDPOINT_CREATE = "/v1/video/generations"
_ENDPOINT_STATUS = "/v1/video/generations/{task_id}"
_POLL_INIT = 3
_POLL_MAX = 15
def _image_to_base64(tensor, scale=1.0) -> str:
from PIL import Image
pil = tensor_to_pil(tensor)
img = pil[0]
if scale < 1.0:
w, h = img.size
new_w = max(1, int(w * scale))
new_h = max(1, int(h * scale))
img = img.resize((new_w, new_h), Image.LANCZOS)
return encode_image_to_base64(img, format="PNG")
class KVideoImage2Video:
"""K26 图生视频节点"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"起始帧": ("IMAGE",),
"提示词": ("STRING", {"multiline": True, "default": ""}),
"模式": (["720p", "1080p"], {"default": "720p"}),
"时长": ([5, 10], {"default": 5}),
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
"seed": ("INT", {
"default": 0, "min": 0, "max": 2147483647,
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
}),
},
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/KVideo"
async def generate(self, 起始帧, 提示词, 模式, 时长, 生成音频="关闭", 网络线路="全球加速", seed=0):
if 模式 == "720p" and 生成音频 == "打开":
raise RuntimeError("K26 仅1080p支持音频,请将模式切换为1080p或关闭生成音频。")
api_key = get_api_key_or_raise()
base_url = get_base_url_by_route(网络线路)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# ── 动态拼接模型名 ────────────────────────────────────────────
mode_api = "std" if 模式 == "720p" else "pro"
voice = "voice" if 生成音频 == "打开" else "novoice"
model_name = f"{_MODEL_BASE}-{mode_api}-{时长}s-{voice}"
# ── 构建请求体(超过 10MB 自动缩放图片)────────────────────────
MAX_BODY = 10 * 1024 * 1024
scale = 1.0
print(f"[K26 图生视频] 请求体大小限制: 10MB,超出将自动缩放图片")
while True:
body = {
"model": model_name,
"prompt": 提示词.strip(),
"image": _image_to_base64(起始帧, scale),
"mode": mode_api,
"duration": 时长,
}
if 生成音频 == "打开":
body["metadata"] = {"sound": "on"}
body_str = json.dumps(body, ensure_ascii=False)
body_size = len(body_str.encode("utf-8"))
if body_size <= MAX_BODY:
print(f"[K26 图生视频] 请求体大小: {body_size / 1024 / 1024:.2f}MB"
+ (f"(已缩放至 {scale:.1%}" if scale < 1.0 else ""))
break
# 等比缩放:图片像素面积与 base64 长度近似线性
target_ratio = MAX_BODY / body_size
scale = scale * math.sqrt(target_ratio) * 0.95 # 5% 安全余量
if scale < 0.01:
raise RuntimeError("图片缩放后仍超过10MB限制,请使用更小的参考图")
w, h = tensor_to_pil(起始帧)[0].size
print(f"[K26 图生视频] 请求体 {body_size / 1024 / 1024:.2f}MB 超限,"
f"自动缩放至 {scale:.1%}{int(w * scale)}x{int(h * scale)}")
# ── 进度条 ────────────────────────────────────────────────────
try:
from comfy.utils import ProgressBar
pbar = ProgressBar(100)
except Exception:
pbar = None
def _stage(s: str):
if s == "submitting":
print("[K26 图生视频] 提交中...")
if pbar: pbar.update_absolute(0, 100)
elif s.startswith("submitted:"):
print(f"[K26 图生视频] 任务已提交 → {s.split(':', 1)[1]}")
if pbar: pbar.update_absolute(5, 100)
elif s == "downloading":
print("[K26 图生视频] 下载视频...")
if pbar: pbar.update_absolute(99, 100)
elif s == "done":
print("[K26 图生视频] 完成")
if pbar: pbar.update_absolute(100, 100)
def _progress(pct: int):
if pbar: pbar.update_absolute(5 + int(pct * 0.94), 100)
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
tmp_fd, save_path = tempfile.mkstemp(suffix=".mp4", prefix="k26_")
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
# 1. 提交
check_interrupt()
_stage("submitting")
create_url = f"{base_url}{_ENDPOINT_CREATE}"
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", create_url, json=body, headers=headers, prefix="K26 图生视频提交: "
))
check_interrupt()
sr = await resp.json()
task_id = sr.get("task_id") or sr.get("id")
if not task_id:
raise RuntimeError(f"API 未返回 task_id,响应:{sr}")
_stage(f"submitted:{task_id}")
# 2. 轮询
status_url = f"{base_url}{_ENDPOINT_STATUS.format(task_id=task_id)}"
interval = _POLL_INIT
video_url = None
while True:
await interruptible_sleep(interval)
check_interrupt()
async with session.get(status_url, headers=headers) as resp:
if resp.status != 200:
err_text = await resp.text()
raise RuntimeError(f"查询失败 ({resp.status}): {err_text}")
sr = await resp.json()
data = sr.get("data", {}) or {}
status = extract_status(sr)
pct = extract_progress(sr)
print(f"[K26 图生视频] 生成中 {pct}%")
_progress(pct)
if is_success_status(status):
# 提取视频 URL
video_url = extract_video_url(sr)
break
if is_failure_status(status, sr):
err_msg = extract_error_message(sr)
raise RuntimeError(f"K26 生成失败:{err_msg}")
interval = min(interval * 1.5, _POLL_MAX)
if not video_url:
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
# 3. 下载
check_interrupt()
_stage("downloading")
async with session.get(video_url, allow_redirects=True) as resp:
if resp.status != 200:
raise RuntimeError(f"视频下载失败 ({resp.status})")
os.close(tmp_fd)
with open(save_path, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
check_interrupt()
f.write(chunk)
_stage("done")
if _FOLDER_PATHS_OK:
return (InputImpl.VideoFromFile(save_path),)
return (save_path,)
NODE_CLASS_MAPPINGS = {
"KVideoImage2Video": KVideoImage2Video,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"KVideoImage2Video": "K26 图生视频",
}
+17 -11
View File
@@ -9,30 +9,36 @@ NanoBananaPro = NanoBanana
from .batch_nano_banana import BatchNanoBananaPro
from .google_gemini import GoogleGemini
from .load_file import LoadFile
from .load_images_from_folder import LoadImagesFromFolder
from .image_stitch_pro import ImageStitchPro
from .remove_metadata import BatchCleanMetadata
from .video_preview import VideoPreview
from .kling_video import KlingVideo, KlingFirstLastFrame, KlingMotionControlTest, AspectRatioPreset
from .veo_video import GoogleVeo
from .newapi_veo_video import Google31Video
from .minimax_h3_video import MiniMaxH3Video
from .flux_edit import FluxImageEdit
from .universal_llm import UniversalLLMChat
from .batch_images_o1key import BatchImagesO1key
from .seedance_video import Seedance, SeedanceMultiModal
from .nano_banana_v2 import NanoBananaV2, NanoBananaV2Batch, AsyncImageGenerator, BatchAsyncImageGenerator
from .seedance_video import SeedanceMultiModal
from .doubao_image import DoubaoImage
from .gpt_image import O1keyGPTImage, O1keyGPTImageBatch
from .gpt_image import O1keyGPTImage
from .gpt_image_batch import O1keyGPTImageBatch
from .grok_image import O1keyGrokImage
from .grok_video import O1keyGrokVideo
from .K_video_firstlast import KVideoFirstLast
from .K_video_image2video import KVideoImage2Video
from .grok_video import O1keyGrokVideo, O1keyGrokVideoEdit
from .K3_video import K3Video
from .K3_video_firstlast import K3VideoFirstLast
from .K3_motion_control import K3MotionControl, K3MotionVideoCheck
from .K3_motion_control import K3MotionControl
from .save_image_format import SaveImageFormat
from .save_psd import O1keySavePSD
from .remove_bg import O1keyRemoveBackground
from .color_remove_bg import O1keyColorRemoveBG
from .grid_splitter import O1keyGridSplitter
from .auto_red_cast import O1keyAutoRedCast
from .prompt_multi_function import O1keyPromptMultiFunction
from .video_trim import O1keyVideoTrim
from .seedance_element import SeedanceElementCreate
from .seedance_autopass import SeedanceAutoPass
from .seedance_autopass_batch import SeedanceAutoPassBatch
from .o1key_image_generator import O1keyImageGenerator, O1keyImageSave
from .o1key_video_generator import O1keyVideoGenerator, O1keyVideoResult
from .omni_flash_video import O1keyOmniFlashVideo
__all__ = ['NanoBananaV2', 'NanoBananaV2Batch', 'NanoBanana', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'Google31Video', 'FluxImageEdit', 'UniversalLLMChat', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'O1keyGPTImageBatch', 'O1keyGrokImage', 'O1keyGrokVideo', 'KVideoFirstLast', 'KVideoImage2Video', 'K3Video', 'K3VideoFirstLast', 'K3MotionControl', 'K3MotionVideoCheck', 'AsyncImageGenerator', 'BatchAsyncImageGenerator', 'SaveImageFormat', 'O1keySavePSD', 'O1keyRemoveBackground', 'O1keyColorRemoveBG', 'O1keyGridSplitter']
__all__ = ['NanoBanana', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'LoadImagesFromFolder', 'ImageStitchPro', 'BatchCleanMetadata', 'VideoPreview', 'GoogleVeo', 'Google31Video', 'MiniMaxH3Video', 'FluxImageEdit', 'UniversalLLMChat', 'BatchImagesO1key', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'O1keyGPTImageBatch', 'O1keyGrokImage', 'O1keyGrokVideo', 'O1keyGrokVideoEdit', 'K3Video', 'K3MotionControl', 'SaveImageFormat', 'O1keySavePSD', 'O1keyRemoveBackground', 'O1keyGridSplitter', 'O1keyAutoRedCast', 'O1keyPromptMultiFunction', 'O1keyVideoTrim', 'SeedanceElementCreate', 'SeedanceAutoPass', 'SeedanceAutoPassBatch', 'O1keyImageGenerator', 'O1keyImageSave', 'O1keyVideoGenerator', 'O1keyVideoResult', 'O1keyOmniFlashVideo']
+289
View File
@@ -0,0 +1,289 @@
"""
自动红偏校正。
纯 torch 实现的确定性白平衡:把图像转到 CIE Lab,自动挑选高亮低饱和区域
当作灰卡,测出红轴(a 通道)偏移量后只做减法校正。不调用模型或网络 API,
CPU / GPU 都能跑。支持单张(连接图像端口)和批量(填写文件夹路径)两种模式。
"""
from __future__ import annotations
import os
from typing import List, Optional
import torch
from PIL import Image
from ..utils.image_utils import pil_to_tensor
D65_WHITE = (0.95047, 1.0, 1.08883)
LAB_EPSILON = 216.0 / 24389.0
LAB_KAPPA = 24389.0 / 27.0
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tiff", ".tif")
def _load_folder_images(folder: str) -> List[Image.Image]:
"""从文件夹加载所有图片,返回 PIL Image 列表(RGB)。"""
if not os.path.isdir(folder):
raise ValueError(f"自动红偏校正:路径不是有效的文件夹:{folder}")
names = sorted(
n for n in os.listdir(folder)
if n.lower().endswith(_IMAGE_EXTS) and os.path.isfile(os.path.join(folder, n))
)
if not names:
raise ValueError(f"自动红偏校正:文件夹中没有可读取的图片:{folder}")
images: List[Image.Image] = []
for name in names:
path = os.path.join(folder, name)
with Image.open(path) as img:
images.append(img.convert("RGB"))
return images
def _srgb_to_lab(image: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
rgb = image[..., :3].clamp(0.0, 1.0)
linear = torch.where(
rgb <= 0.04045,
rgb / 12.92,
((rgb + 0.055) / 1.055).pow(2.4),
)
red, green, blue = linear.unbind(dim=-1)
x = (0.4124564 * red + 0.3575761 * green + 0.1804375 * blue) / D65_WHITE[0]
y = 0.2126729 * red + 0.7151522 * green + 0.0721750 * blue
z = (0.0193339 * red + 0.1191920 * green + 0.9503041 * blue) / D65_WHITE[2]
def pivot(value: torch.Tensor) -> torch.Tensor:
return torch.where(
value > LAB_EPSILON,
value.clamp_min(0.0).pow(1.0 / 3.0),
(LAB_KAPPA * value + 16.0) / 116.0,
)
fx, fy, fz = pivot(x), pivot(y), pivot(z)
lightness = 116.0 * fy - 16.0
a = 500.0 * (fx - fy)
b = 200.0 * (fy - fz)
return lightness, a, b
def _lab_to_srgb(lightness: torch.Tensor, a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
fy = (lightness + 16.0) / 116.0
fx = fy + a / 500.0
fz = fy - b / 200.0
def inverse_pivot(value: torch.Tensor) -> torch.Tensor:
cubed = value.pow(3.0)
return torch.where(cubed > LAB_EPSILON, cubed, (116.0 * value - 16.0) / LAB_KAPPA)
x = D65_WHITE[0] * inverse_pivot(fx)
y = inverse_pivot(fy)
z = D65_WHITE[2] * inverse_pivot(fz)
red = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
green = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z
blue = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
linear = torch.stack((red, green, blue), dim=-1)
positive = linear.clamp_min(0.0)
srgb = torch.where(
linear <= 0.0031308,
12.92 * linear,
1.055 * positive.pow(1.0 / 2.4) - 0.055,
)
return srgb.clamp(0.0, 1.0)
def _smoothstep(value: torch.Tensor) -> torch.Tensor:
value = value.clamp(0.0, 1.0)
return value * value * (3.0 - 2.0 * value)
class O1keyAutoRedCast:
"""自动检测并移除商品图红偏,零 API 成本。支持单张和批量文件夹两种模式。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"强度": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.5,
"step": 0.05,
"tooltip": "1.0 为自动测得的完整校正量。",
},
),
"最大校正量": (
"FLOAT",
{
"default": 8.0,
"min": 0.0,
"max": 20.0,
"step": 0.5,
"tooltip": "限制 Lab 红轴最大校正量,防止极端图片过度校色。",
},
),
"高饱和保护": (
"FLOAT",
{
"default": 0.1,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "保护橙色、蓝色等高饱和区域;0 为统一白平衡,1 为最大保护。",
},
),
"图片路径": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": (
"批量模式:填写文件夹路径后将处理其中所有图片,忽略上方图像输入。"
"文件夹内图片必须尺寸一致。留空则使用图像输入端口。"
),
},
),
},
"optional": {
"图像": (
"IMAGE",
{
"tooltip": "单张模式输入;填写图片路径进入批量模式后可不连接。",
},
),
"灰卡最低亮度": (
"FLOAT",
{
"default": 58.0,
"min": 20.0,
"max": 95.0,
"step": 1.0,
"tooltip": "灰卡候选区域的最低 Lab 亮度。",
},
),
"灰卡最大色度": (
"FLOAT",
{
"default": 18.0,
"min": 3.0,
"max": 40.0,
"step": 1.0,
"tooltip": "灰卡候选区域允许的最大色度。",
},
),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"step": 1,
"control_after_generate": True,
"tooltip": "ComfyUI 原生随机种子;改变 seed 可重新运行节点,校色结果由图像和校色参数决定。",
},
),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
RETURN_NAMES = ("校正图像", "取样遮罩", "检测报告")
FUNCTION = "correct"
CATEGORY = "o1key/image"
DESCRIPTION = (
"零成本自动检测并移除商品图红偏,不调用模型或网络 API。"
"填写图片路径可批量处理整个文件夹;留空则处理连接的图像输入。"
)
@torch.inference_mode()
def correct(
self,
图像: Optional[torch.Tensor] = None,
强度: float = 1.0,
最大校正量: float = 8.0,
高饱和保护: float = 0.1,
图片路径: str = "",
seed: int = 0,
灰卡最低亮度: float = 58.0,
灰卡最大色度: float = 18.0,
):
# --- 数据来源:文件夹 or 图像输入 ---
if 图片路径 and 图片路径.strip():
pil_images = _load_folder_images(图片路径.strip())
# 校验所有图片尺寸一致(不同尺寸无法合并为批次张量)
sizes = {img.size for img in pil_images}
if len(sizes) > 1:
raise ValueError(
f"自动红偏校正:文件夹中的图片尺寸不统一 {sizes}"
"请确保所有图片宽高相同,或分批放入不同文件夹。"
)
source = pil_to_tensor(pil_images) # (B, H, W, 3), float32, [0,1]
print(f"[o1key 自动红偏校正] 批量模式:加载 {len(pil_images)} 张图片,尺寸 {pil_images[0].size}")
else:
if 图像 is None:
raise ValueError(
"自动红偏校正:请连接图像输入,或填写批量图片文件夹路径。"
)
source = 图像
source_float = source.float()
lightness, a, b = _srgb_to_lab(source_float)
chroma = torch.hypot(a, b)
corrected_a = a.clone()
masks = []
report_lines = []
for index in range(source_float.shape[0]):
sample_mask = (lightness[index] >= 灰卡最低亮度) & (chroma[index] <= 灰卡最大色度)
minimum_pixels = max(1024, int(sample_mask.numel() * 0.001))
# 中性像素太少时放宽一档,避免深色背景图直接放弃校正
if int(sample_mask.sum().item()) < minimum_pixels:
sample_mask = (lightness[index] >= max(40.0, 灰卡最低亮度 - 12.0)) & (
chroma[index] <= 灰卡最大色度 + 8.0
)
sample_count = int(sample_mask.sum().item())
masks.append(sample_mask.float())
if sample_count < minimum_pixels:
report_lines.append(f"{index + 1} 张:中性取样不足,保持原图")
continue
measured_a = float(a[index][sample_mask].mean().item())
correction = max(0.0, min(float(最大校正量), measured_a + 0.2))
applied = correction * float(强度)
saturation = _smoothstep((chroma[index] - 18.0) / 36.0)
protection = 1.0 - float(高饱和保护) * saturation
corrected_a[index] = a[index] - applied * protection
sample_ratio = 100.0 * sample_count / sample_mask.numel()
if applied > 0.01:
report_lines.append(
f"{index + 1} 张:检测红轴 {measured_a:+.2f}"
f"校正 {-applied:.2f},取样 {sample_ratio:.1f}%"
)
else:
report_lines.append(
f"{index + 1} 张:未检测到红偏,保持原图,取样 {sample_ratio:.1f}%"
)
corrected_rgb = _lab_to_srgb(lightness, corrected_a, b)
# 保留原始 alpha 通道(如有)
if source_float.shape[-1] > 3:
corrected = torch.cat((corrected_rgb, source_float[..., 3:]), dim=-1)
else:
corrected = corrected_rgb
report = "\n".join(report_lines)
print("[o1key 自动红偏校正] " + " | ".join(report_lines))
return (corrected.to(dtype=source.dtype), torch.stack(masks), report)
+739 -452
View File
File diff suppressed because it is too large Load Diff
-93
View File
@@ -1,93 +0,0 @@
"""
o1key 颜色去背景节点
基于颜色距离计算精确可控不依赖 AI 模型
"""
import numpy as np
import torch
from PIL import Image
class O1keyColorRemoveBG:
"""
颜色去背景 - 精确移除纯色背景
模式说明
- 白色(white): 移除白色背景适合大多数场景
- 白色保护(white-preserve): 移除白底但保护浅色前景物体
- 自动检测(corner): 自动采样四角颜色作为背景色
- 指定颜色(color): 手动指定要移除的背景颜色
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"模式": (["白色", "白色保护", "自动检测", "指定颜色"], {
"default": "白色",
}),
"容差": ("FLOAT", {
"default": 8.0,
"min": 0.0,
"max": 100.0,
"step": 1.0,
"tooltip": "颜色距离阈值,越大去除范围越广",
}),
"羽化": ("FLOAT", {
"default": 45.0,
"min": 0.0,
"max": 200.0,
"step": 1.0,
"tooltip": "边缘过渡范围,越大边缘越柔和",
}),
},
"optional": {
"背景色R": ("INT", {"default": 255, "min": 0, "max": 255}),
"背景色G": ("INT", {"default": 255, "min": 0, "max": 255}),
"背景色B": ("INT", {"default": 255, "min": 0, "max": 255}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("RGBA图像",)
FUNCTION = "remove_bg"
CATEGORY = "o1key/image"
_MODE_MAP = {
"白色": "white",
"白色保护": "white-preserve",
"自动检测": "corner",
"指定颜色": "color",
}
def remove_bg(self, image, 模式, 容差, 羽化, 背景色R=255, 背景色G=255, 背景色B=255):
from ..utils.color_key import remove_background
mode = self._MODE_MAP.get(模式, "white")
bg_color = (背景色R, 背景色G, 背景色B)
batch_size = image.shape[0]
results = []
for i in range(batch_size):
frame = image[i] # [H, W, C]
arr = (frame.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
if arr.shape[2] == 4:
pil_img = Image.fromarray(arr, mode="RGBA")
else:
pil_img = Image.fromarray(arr, mode="RGB")
result = remove_background(
pil_img, mode=mode, bg_color=bg_color,
tolerance=容差, feather=羽化,
)
result_arr = np.array(result.convert("RGBA")).astype(np.float32) / 255.0
results.append(torch.from_numpy(result_arr))
output = torch.stack(results, dim=0)
print(f"[o1key 颜色去背景] 模式={模式}, 容差={容差}, 羽化={羽化}, "
f"处理 {batch_size}")
return (output,)
+6 -2
View File
@@ -1,6 +1,6 @@
"""
Flux2 图像编辑节点
通过 vip.o1key.com 调用 Flux2 + SeedVR2 远程服务进行图像编辑和超分辨率
通过 api.o1key.cn 调用 Flux2 + SeedVR2 远程服务进行图像编辑和超分辨率
功能
- 接收主图和参考图
@@ -17,6 +17,7 @@ import torch
from PIL import Image
from ..utils.image_utils import tensor_to_pil, pil_to_tensor
from ..utils.config import get_runtime_config_signature
from ..clients.flux_edit_client import FluxEditClient
@@ -32,6 +33,7 @@ class FluxImageEdit:
def __init__(self):
self.client = None
self._client_config_signature = None
@classmethod
def INPUT_TYPES(cls):
@@ -100,8 +102,10 @@ class FluxImageEdit:
try:
# 初始化客户端
if self.client is None:
config_signature = get_runtime_config_signature()
if self.client is None or config_signature != self._client_config_signature:
self.client = FluxEditClient()
self._client_config_signature = config_signature
# Tensor → PIL(取第一张)
main_pils = tensor_to_pil(主图)
+5 -1
View File
@@ -15,6 +15,7 @@ from PIL import Image
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.file_types import FileData
from ..utils.config import get_runtime_config_signature
from ..clients.gemini_flash_client import GeminiFlashClient
from ..models_config import get_enabled_flash_models
@@ -67,6 +68,7 @@ class GoogleGemini:
def __init__(self):
"""初始化节点"""
self.client = None
self._client_config_signature = None
@classmethod
def INPUT_TYPES(cls):
@@ -559,9 +561,11 @@ class GoogleGemini:
try:
# 初始化 API 客户端
if self.client is None:
config_signature = get_runtime_config_signature()
if self.client is None or config_signature != self._client_config_signature:
try:
self.client = GeminiFlashClient()
self._client_config_signature = config_signature
except ValueError as e:
raise ValueError(f"初始化失败: {str(e)}")
+220 -160
View File
@@ -1,6 +1,6 @@
"""
o1key GPT Image 节点
支持 gpt-image-1 / gpt-image-1.5 模型的文生图图生图图像编辑带蒙版
支持 GPT Image 2 / 2.5 系列的文生图图生图和带蒙版图像编辑
"""
import os
@@ -8,10 +8,24 @@ import time
from typing import List, Optional, Tuple
from PIL import Image
from comfy_api.latest import io
from ..clients.gpt_image_client import GptImageClient
from ..clients.gpt_image_client import (
GPT_IMAGE_MODEL_OPTIONS,
GPT_IMAGE_ROUTE_OPTIONS,
GptImageClient,
resolve_gpt_image_model,
)
from ..utils.image_utils import parse_batch_prompts, pil_to_tensor, tensor_to_pil
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.config import get_base_url_by_route
from ..utils.o1key_image_catalog import (
GPT_IMAGE_BACKGROUND_OPTIONS,
GPT_IMAGE_EXACT_SIZE_OPTIONS,
GPT_IMAGE_OUTPUT_FORMAT_OPTIONS,
GPT_IMAGE_25_QUALITY_OPTIONS,
resolve_gpt_image_quality,
resolve_gpt_image_size,
)
from ..utils.file_utils import (
ImageInfo,
generate_timestamp_filename,
@@ -68,31 +82,20 @@ def _make_node_progress_callback(progress_bar, task_index: int, total_tasks: int
def _resolve_async_size(value: str) -> str:
value = (value or "").strip()
if not value or value == "智能" or value.lower() == "auto":
return "auto"
first_part = value.split("")[0].strip()
normalized_size = first_part.lower().replace("*", "x").replace("×", "x")
size_parts = [part.strip() for part in normalized_size.split("x")]
if len(size_parts) == 2 and all(part.isdigit() for part in size_parts):
return f"{int(size_parts[0])}x{int(size_parts[1])}"
allowed = {"auto", "1024x1024", "1K", "2K", "4K"}
if first_part in allowed:
return first_part
if "4K" in value:
return "4K"
if "2K" in value:
return "2K"
if "1K" in value:
return "1K"
return "auto"
return resolve_gpt_image_size(value)
class O1keyGPTImage:
MAX_GPT_IMAGE_REFERENCES = 9
def _collect_autogrow_inputs(value) -> list:
"""收集已连接的 Autogrow 输入,并兼容单个旧值。"""
if value is None:
return []
if isinstance(value, dict):
return [item for item in value.values() if item is not None]
return [value]
class O1keyGPTImage(io.ComfyNode):
"""
o1key GPT Image 节点
@@ -104,7 +107,8 @@ class O1keyGPTImage:
参数
- prompt : 文本提示词多行 --- 独占一行分隔批量提示词
- 模型 : 模型选择
- 模型 : GPT Image 主模型
- 模型线路 : 畅速直连或专线
- 分辨率 : 图像尺寸auto API 自动决定
- 生图数量 : 每条提示词生成数量 1-8
- 质量 : 生成质量
@@ -114,110 +118,144 @@ class O1keyGPTImage:
"""
@classmethod
def INPUT_TYPES(cls):
# 创建9个独立的参考图输入
optional_inputs = {}
for i in range(1, 10):
optional_inputs[f"参考图{i}"] = ("IMAGE", {
"tooltip": f"Optional reference image {i} for image editing.",
})
def define_schema(cls):
reference_images = io.Autogrow.Input(
"参考图组",
template=io.Autogrow.TemplateNames(
input=io.Image.Input("参考图"),
names=[f"参考图{i}" for i in range(1, MAX_GPT_IMAGE_REFERENCES + 1)],
min=0,
),
tooltip=f"连接后自动增加输入端口,合计最多 {MAX_GPT_IMAGE_REFERENCES} 张参考图。",
)
return io.Schema(
node_id="O1keyGPTImage",
display_name="gpt image",
category="o1key/image",
inputs=[
io.String.Input(
"prompt",
default="",
multiline=True,
tooltip="Text prompt for GPT Image. Use --- on its own line to separate batch prompts.",
),
io.Combo.Input(
"模型",
options=GPT_IMAGE_MODEL_OPTIONS,
default="gpt-image-2.5-sunburst",
),
io.Combo.Input(
"模型线路",
options=GPT_IMAGE_ROUTE_OPTIONS,
default="畅速",
),
io.Combo.Input(
"分辨率",
options=GPT_IMAGE_EXACT_SIZE_OPTIONS,
default="智能",
tooltip="Image size (智能 = API decides)",
),
io.Int.Input(
"生图数量",
default=1,
min=1,
max=8,
step=1,
display_mode=io.NumberDisplay.number,
tooltip="How many images to generate per prompt",
),
io.Combo.Input(
"质量",
options=GPT_IMAGE_25_QUALITY_OPTIONS,
default="自动",
tooltip="GPT Image 2 支持高/中/低/自动;GPT Image 2.5 另支持超高=xhigh、最高=max。",
),
io.Combo.Input(
"输出格式",
options=["png", "jpeg", "webp"],
default="png",
tooltip="Generated image output format",
),
io.Combo.Input(
"背景",
options=list(GPT_IMAGE_BACKGROUND_OPTIONS),
default="auto",
tooltip="透明背景仅支持 PNG 或 WebP 输出格式。",
),
io.Mask.Input(
"遮罩",
optional=True,
tooltip="Optional mask for inpainting (white areas will be replaced)",
),
reference_images,
io.Combo.Input(
"缩放图片",
options=["不缩放", "智能缩放"],
default="智能缩放",
tooltip="请求体超过 18 MiB 时,智能缩放会等比缩小占用最大的参考图。",
),
io.Int.Input(
"seed",
default=0,
min=0,
max=2**31 - 1,
step=1,
display_mode=io.NumberDisplay.number,
control_after_generate=io.ControlAfterGenerate.randomize,
tooltip="Random seed (0 = not specified)",
),
],
outputs=[io.Image.Output(display_name="IMAGE")],
# 兼容 Autogrow 改造前保存的参考图1~参考图9端口。
accept_all_inputs=True,
)
optional_inputs["模型"] = ([
"gpt-image-2-按量",
"gpt-image-2-次卡",
], {
"default": "gpt-image-2-次卡",
})
optional_inputs["网络"] = (NETWORK_ROUTE_OPTIONS, {
"default": "全球加速",
})
optional_inputs["分辨率"] = ([
"智能",
# ── 1K ──
"1024x10241K 正方形 1:1",
"1536x10241K 横版 3:2",
"1024x15361K 竖版 2:3",
"1360x10241K 横版 4:3",
"1024x13601K 竖版 3:4",
"1824x10241K 横版 16:9",
"1024x18241K 竖版 9:16",
# ── 2K ──
"2048x20482K 正方形 1:1",
"3072x20482K 横版 3:2",
"2048x30722K 竖版 2:3",
"2736x20482K 横版 4:3",
"2048x27362K 竖版 3:4",
"3648x20482K 横版 16:9",
"2048x36482K 竖版 9:16",
# ── 4K ──
"2880x28804K 正方形 1:1",
"3504x23364K 横版 3:2",
"2336x35044K 竖版 2:3",
"3264x24484K 横版 4:3",
"2448x32644K 竖版 3:4",
"3840x21604K 横版 16:9",
"2160x38404K 竖版 9:16",
], {
"default": "智能",
"tooltip": "Image size (智能 = API decides)",
})
optional_inputs["生图数量"] = ("INT", {
"default": 1,
"min": 1,
"max": 8,
"step": 1,
"display": "number",
"tooltip": "How many images to generate per prompt",
})
optional_inputs["质量"] = (["", "", "", "自动"], {
"default": "自动",
"tooltip": "Image quality: 高=high, 中=medium, 低=low, 自动=auto",
})
optional_inputs["输出格式"] = (["png", "jpeg", "webp"], {
"default": "jpeg",
"tooltip": "Generated image output format",
})
optional_inputs["seed"] = ("INT", {
"default": 0,
"min": 0,
"max": 2**31 - 1,
"step": 1,
"display": "number",
"control_after_generate": True,
"tooltip": "Random seed (0 = not specified)",
})
optional_inputs["遮罩"] = ("MASK", {
"tooltip": "Optional mask for inpainting (white areas will be replaced)",
})
return {
"required": {
"prompt": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "Text prompt for GPT Image. Use --- on its own line to separate batch prompts.",
}),
},
"optional": optional_inputs,
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("IMAGE",)
FUNCTION = "generate"
CATEGORY = "o1key/image"
OUTPUT_NODE = False
def generate(
self,
@classmethod
def execute(
cls,
prompt: str,
模型: str = "gpt-image-2-次卡",
网络: str = "全球加",
分辨率: str = "auto",
模型: str = "gpt-image-2.5-sunburst",
模型线路: str = "",
分辨率: str = "智能",
质量: str = "自动",
输出格式: str = "jpeg",
输出格式: str = "png",
生图数量: int = 1,
seed: int = 0,
遮罩=None,
缩放图片: str = "智能缩放",
背景: str = "auto",
**kwargs,
) -> io.NodeOutput:
result = cls.generate(
prompt=prompt,
模型=模型,
模型线路=模型线路,
分辨率=分辨率,
质量=质量,
输出格式=输出格式,
生图数量=生图数量,
seed=seed,
遮罩=遮罩,
缩放图片=缩放图片,
背景=背景,
**kwargs,
)
return io.NodeOutput(*result)
@classmethod
def generate(
cls,
prompt: str,
模型: str = "gpt-image-2.5-sunburst",
模型线路: str = "畅速",
分辨率: str = "智能",
质量: str = "自动",
输出格式: str = "png",
生图数量: int = 1,
seed: int = 0,
遮罩=None,
缩放图片: str = "智能缩放",
背景: str = "auto",
**kwargs,
):
"""
@@ -230,35 +268,45 @@ class O1keyGPTImage:
- prompt --- 批量模式逐条调用上述接口
"""
start_time = time.time()
# ── 0. 收集多参考图输入 ────────────────────────────────────────────────
reference_tensors = []
for i in range(1, 10):
key = f"参考图{i}"
if key in kwargs and kwargs[key] is not None:
reference_tensors.append(kwargs[key])
reference_tensors = _collect_autogrow_inputs(kwargs.get("参考图组"))
if not reference_tensors:
# 兼容 Autogrow 改造前保存的固定参考图端口。
reference_tensors = [
kwargs[f"参考图{i}"]
for i in range(1, MAX_GPT_IMAGE_REFERENCES + 1)
if kwargs.get(f"参考图{i}") is not None
]
图片 = reference_tensors if reference_tensors else None
# ── 1. 参数校验 ───────────────────────────────────────────────────────
if 遮罩 is not None and 图片 is None:
raise ValueError("提供了遮罩但未提供图片,请同时提供图片和遮罩")
if 缩放图片 not in {"不缩放", "智能缩放"}:
raise ValueError("缩放图片参数无效")
if 输出格式 not in GPT_IMAGE_OUTPUT_FORMAT_OPTIONS:
raise ValueError("GPT Image 输出格式无效")
if 背景 not in GPT_IMAGE_BACKGROUND_OPTIONS:
raise ValueError("GPT Image 背景参数无效")
if 背景 == "transparent" and 输出格式 == "jpeg":
raise ValueError("GPT Image 透明背景仅支持 PNG 或 WebP 输出格式")
# ── 2. 解析分辨率显示值 → API 参数值 ──────────────────────────────────
size = _resolve_async_size(分辨率)
# ── 2b. 解析模型显示值 → API 参数值 ───────────────────────────────────
_model_map = {"gpt-image-2-次卡": "gpt-image-2-c", "gpt-image-2-按量": "gpt-image-2"}
model = _model_map.get(模型, 模型)
# ── 2b. 主模型与线路共同解析为 API 模型名 ───────────────────────────
model = resolve_gpt_image_model(模型, 模型线路)
# ── 2c. 解析质量显示值 → API 参数值 ───────────────────────────────────
_quality_map = {"": "high", "": "medium", "": "low", "自动": "auto"}
quality = _quality_map.get(质量, "auto")
quality = resolve_gpt_image_quality(模型, 质量)
# ── 3. 创建客户端 ─────────────────────────────────────────────────────
try:
client = GptImageClient()
client.base_url = get_base_url_by_route(网络)
client.base_url = get_base_url_by_route()
client.response_log_enabled = False
client.poll_log_enabled = False
except ValueError as e:
if str(e) == "未授权!":
print("[o1key GPT Image] 请联系作者授权后方可使用!")
@@ -271,6 +319,12 @@ class O1keyGPTImage:
# ── 5. 调用 API ───────────────────────────────────────────────────
all_pil_images = []
def _submitted(task_id, status, elapsed):
print(f"[o1key GPT Image] 已提交 | task_id={task_id} | 状态={status} | 耗时={elapsed:.1f}s")
def _completed(task_id, image_count, elapsed, urls):
del urls
print(f"[o1key GPT Image] 完成 ✓ | task_id={task_id} | 生成={image_count} 张 | 耗时={elapsed:.1f}s")
progress_total = len(batch_prompts) if batch_prompts else 1
progress_bar = ProgressBar(progress_total * 100) if _PROGRESS_BAR_AVAILABLE else None
@@ -293,17 +347,22 @@ class O1keyGPTImage:
image_tensor=图片,
mask_tensor=遮罩,
output_format=输出格式,
background=背景,
progress_callback=_make_node_progress_callback(progress_bar, idx, total),
special_price_parallel=True,
task_submitted_callback=_submitted,
task_completed_callback=_completed,
log_request_start=False,
log_downloads=True,
log_prefix=f"[o1key GPT Image] [{idx}/{total}]",
resize_mode=缩放图片,
)
all_pil_images.extend(pil_images)
snippet = p[:30] + ("..." if len(p) >= 30 else "")
print(f"[o1key GPT Image] [{idx}/{total}] ✓ {snippet}")
except InterruptProcessingException:
raise
except Exception as e:
error_msg = str(e).split('\n')[0]
snippet = p[:30] + ("..." if len(p) >= 30 else "")
print(f"[o1key GPT Image] [{idx}/{total}] ❌ {snippet}{error_msg}")
print(f"[o1key GPT Image] [{idx}/{total}] ❌ {error_msg}")
if progress_bar is not None:
progress_bar.update_absolute(idx * 100, total * 100)
else:
@@ -321,7 +380,14 @@ class O1keyGPTImage:
image_tensor=图片,
mask_tensor=遮罩,
output_format=输出格式,
background=背景,
progress_callback=_make_node_progress_callback(progress_bar, 1, 1),
special_price_parallel=True,
task_submitted_callback=_submitted,
task_completed_callback=_completed,
log_request_start=False,
log_downloads=True,
resize_mode=缩放图片,
)
all_pil_images.extend(pil_images)
except InterruptProcessingException:
@@ -349,9 +415,10 @@ class O1keyGPTImage:
return (output_tensor,)
finally:
self._print_balance(client)
cls._print_balance(client)
def _print_balance(self, client):
@staticmethod
def _print_balance(client):
try:
balance_data = client.query_balance_sync()
balance_info = client.format_balance_info(balance_data)
@@ -360,7 +427,7 @@ class O1keyGPTImage:
pass
class O1keyGPTImageBatch:
class _LegacyO1keyGPTImageBatch:
"""
o1key GPT Image 批量节点
@@ -374,7 +441,7 @@ class O1keyGPTImageBatch:
PAIRING_MODES = ["按相同图片命名", "1*N", "不配对"]
IMAGE_FORMATS = ["原始", "JPEG", "PNG", "WebP"]
MODEL_OPTIONS = ["gpt-image-2-按量", "gpt-image-2-次卡"]
MODEL_OPTIONS = GPT_IMAGE_ROUTE_OPTIONS
QUALITY_OPTIONS = ["", "", "", "自动"]
RESOLUTION_OPTIONS = [
"智能",
@@ -424,11 +491,8 @@ class O1keyGPTImageBatch:
"multiline": True,
"tooltip": "提示词;可用独占一行的 --- 分隔多条批量提示词。",
}),
"模型": (cls.MODEL_OPTIONS, {
"default": "gpt-image-2-次卡",
}),
"网络": (NETWORK_ROUTE_OPTIONS, {
"default": "全球加速",
"模型线路": (cls.MODEL_OPTIONS, {
"default": "畅速",
}),
"分辨率": (cls.RESOLUTION_OPTIONS, {
"default": "智能",
@@ -561,12 +625,9 @@ class O1keyGPTImageBatch:
return _resolve_async_size(分辨率)
@staticmethod
def _resolve_model(模型: str) -> str:
model_map = {
"gpt-image-2-次卡": "gpt-image-2-c",
"gpt-image-2-按量": "gpt-image-2",
}
return model_map.get(模型, 模型)
def _resolve_model(模型线路: str) -> str:
# 直接返回,客户端会映射到 API 值
return 模型线路
@staticmethod
def _resolve_quality(质量: str) -> str:
@@ -642,8 +703,7 @@ class O1keyGPTImageBatch:
def process_batch(
self,
prompt: str,
模型: str,
网络: str,
模型线路: str,
分辨率: str,
生图数量: int,
质量: str,
@@ -707,12 +767,12 @@ class O1keyGPTImageBatch:
output_folder = self._ensure_output_folder(保存路径)
size = self._resolve_size(分辨率)
model = self._resolve_model(模型)
model = self._resolve_model(模型线路)
quality = self._resolve_quality(质量)
output_format = self._resolve_output_format(图片格式)
client = GptImageClient()
client.base_url = get_base_url_by_route(网络)
client.base_url = get_base_url_by_route()
progress_bar = ProgressBar(total_tasks * 100) if _PROGRESS_BAR_AVAILABLE else None
results = []
+479
View File
@@ -0,0 +1,479 @@
"""GPT Image V3 批量跑图节点。"""
import asyncio
import gc
import os
import time
from concurrent.futures import ThreadPoolExecutor
from typing import List, Optional, Tuple
from PIL import Image
from comfy_api.latest import io
from ..clients.gpt_image_client import (
GPT_IMAGE_MODEL_OPTIONS,
GPT_IMAGE_ROUTE_OPTIONS,
GptImageClient,
resolve_gpt_image_model,
)
from .gpt_image import GPT_IMAGE_25_QUALITY_OPTIONS, resolve_gpt_image_quality
from ..utils.config import get_base_url_by_route
from ..utils.file_utils import (
ImageInfo,
load_images_from_folder,
pair_images_by_name,
pair_images_cartesian,
pair_images_indexed,
save_image,
)
from ..utils.image_utils import parse_batch_prompts, pil_to_tensor, tensor_to_pil
from ..utils.o1key_image_catalog import (
GPT_IMAGE_BACKGROUND_OPTIONS,
GPT_IMAGE_OUTPUT_FORMAT_OPTIONS,
)
try:
from comfy.model_management import InterruptProcessingException
except ImportError:
InterruptProcessingException = RuntimeError
try:
from comfy.utils import ProgressBar
_PROGRESS_AVAILABLE = True
except ImportError:
_PROGRESS_AVAILABLE = False
try:
import folder_paths
_FOLDER_PATHS_AVAILABLE = True
except ImportError:
_FOLDER_PATHS_AVAILABLE = False
_MAX_PATHS = 5
_MAX_REFERENCES = 9
_PAIRING_MODES = ["不配对", "相同文件名", "同序号", "全匹配"]
_RESOLUTION_OPTIONS = [
"智能",
"1024x10241K 正方形 1:1", "1536x10241K 横版 3:2",
"1024x15361K 竖版 2:3", "1360x10241K 横版 4:3",
"1024x13601K 竖版 3:4", "1824x10241K 横版 16:9",
"1024x18241K 竖版 9:16", "2048x20482K 正方形 1:1",
"3072x20482K 横版 3:2", "2048x30722K 竖版 2:3",
"2736x20482K 横版 4:3", "2048x27362K 竖版 3:4",
"3648x20482K 横版 16:9", "2048x36482K 竖版 9:16",
"2880x28804K 正方形 1:1", "3504x23364K 横版 3:2",
"2336x35044K 竖版 2:3", "3264x24484K 横版 4:3",
"2448x32644K 竖版 3:4", "3840x21604K 横版 16:9",
"2160x38404K 竖版 9:16",
]
def _path_name(index: int) -> str:
return "参考图1(主图)" if index == 1 else f"参考图{index}"
def _path_count(value) -> int:
try:
return max(1, min(_MAX_PATHS, int(str(value).split("", 1)[0])))
except (TypeError, ValueError):
return 1
def _path_option(count: int):
inputs = [
io.String.Input(
_path_name(index),
default="",
placeholder="填写图片文件夹路径",
tooltip="主图文件夹路径" if index == 1 else f"{index} 个参考图文件夹路径",
)
for index in range(1, count + 1)
]
if count >= 2:
inputs.append(io.Combo.Input(
"图片配对模式", options=_PAIRING_MODES, default="不配对",
tooltip="支持不配对、相同文件名、同序号和全部组合。",
))
return io.DynamicCombo.Option(f"{count}个路径", inputs)
def _collect_group(value) -> list:
if isinstance(value, dict):
return [item for item in value.values() if item is not None]
return [] if value is None else [value]
def _resolve_size(value: str) -> str:
value = (value or "").strip()
if not value or value == "智能":
return "auto"
return value.split("", 1)[0].strip().lower().replace("×", "x").replace("*", "x")
class O1keyGPTImageBatch(io.ComfyNode):
"""动态文件夹、Autogrow 参考图和并发异步任务版 GPT Image 批量节点。"""
@classmethod
def define_schema(cls):
references = io.Autogrow.Input(
"参考图组",
template=io.Autogrow.TemplateNames(
input=io.Image.Input("参考图"),
names=[f"参考图{i}" for i in range(1, _MAX_REFERENCES + 1)],
min=0,
),
tooltip="固定追加到每个批量任务末尾;端口序号接在图片路径数量之后,连接后自动增加,最多 9 张。",
)
return io.Schema(
node_id="O1keyGPTImageBatch",
display_name="GPT Image 批量跑图",
category="o1key/image",
inputs=[
io.String.Input("prompt", default="", multiline=True,
tooltip="可用独占一行的 --- 分隔多条提示词。"),
io.Combo.Input(
"模型", options=GPT_IMAGE_MODEL_OPTIONS,
default="gpt-image-2.5-sunburst",
),
io.Combo.Input("模型线路", options=GPT_IMAGE_ROUTE_OPTIONS, default="畅速"),
io.Combo.Input("分辨率", options=_RESOLUTION_OPTIONS, default="智能"),
io.Int.Input("生图数量", default=1, min=1, max=8, step=1),
io.Combo.Input(
"质量", options=GPT_IMAGE_25_QUALITY_OPTIONS, default="自动",
tooltip="GPT Image 2.5 另支持超高=xhigh、最高=max。",
),
io.DynamicCombo.Input(
"图片路径数量",
options=[_path_option(count) for count in range(1, _MAX_PATHS + 1)],
tooltip="按需显示 15 个图片文件夹路径。",
),
io.Mask.Input("遮罩", optional=True,
tooltip="应用到每个任务的第一张参考图。"),
references,
io.Combo.Input("图片输出格式", options=["原始", "JPEG", "PNG", "WebP"],
default="原始"),
io.Combo.Input(
"背景",
options=list(GPT_IMAGE_BACKGROUND_OPTIONS),
default="auto",
tooltip="透明背景仅支持 PNG 或 WebP 输出格式。",
),
io.Combo.Input("图片保存命名规则", options=["和原始图片名保持一致", "自然数字"],
default="和原始图片名保持一致"),
io.String.Input("图片保存路径", default="",
placeholder="留空时保存到 ComfyUI output 目录"),
io.Combo.Input(
"缩放图片",
options=["不缩放", "智能缩放"],
default="智能缩放",
tooltip="请求体超过 18 MiB 时,智能缩放会等比缩小占用最大的参考图。",
),
io.Int.Input(
"seed", default=0, min=0, max=2**31 - 1, step=1,
control_after_generate=io.ControlAfterGenerate.randomize,
),
],
outputs=[io.Image.Output(display_name="输出图像")],
accept_all_inputs=True,
)
@staticmethod
def _normalize_pairing(value: str) -> str:
return {
"按相同图片命名": "相同文件名",
"1*N": "全匹配",
}.get(value, value if value in _PAIRING_MODES else "不配对")
@staticmethod
def _manual_images(values: list) -> List[ImageInfo]:
images = []
for input_index, tensor in enumerate(values, 1):
for frame_index, image in enumerate(tensor_to_pil(tensor)):
images.append(ImageInfo(image, f"manual_{input_index}_{frame_index}", ".png", ""))
return images
@classmethod
def _create_pairs(
cls,
image_lists: List[List[ImageInfo]],
pairing_mode: str,
manual_images: List[ImageInfo],
) -> List[Tuple[ImageInfo, ...]]:
pairing_mode = cls._normalize_pairing(pairing_mode)
if pairing_mode == "不配对":
if len(image_lists) > 1:
raise ValueError("「不配对」模式只支持一个配对路径。")
if image_lists:
base_pairs = [(item,) for item in image_lists[0]]
else:
return []
elif not image_lists:
return []
elif len(image_lists) == 1:
base_pairs = [(item,) for item in image_lists[0]]
elif pairing_mode == "相同文件名":
base_pairs = list(pair_images_by_name(*image_lists))
elif pairing_mode == "同序号":
base_pairs = list(pair_images_indexed(*image_lists))
else:
base_pairs = list(pair_images_cartesian(*image_lists))
manual_tuple = tuple(manual_images)
return [pair + manual_tuple for pair in base_pairs]
@staticmethod
def _output_folder(path: str) -> str:
folder = (path or "").strip()
if not folder and _FOLDER_PATHS_AVAILABLE:
folder = folder_paths.get_output_directory()
if not folder:
raise ValueError("未设置保存路径,且无法获取 ComfyUI output 目录")
os.makedirs(folder, exist_ok=True)
return folder
@staticmethod
def _save_images(
images: List[Image.Image], folder: str, image_format: str,
naming_rule: str, task_index: int, base_filename: Optional[str],
) -> List[str]:
saved = []
for image_index, image in enumerate(images, 1):
if image_format == "原始":
fmt = str(getattr(image, "format", None) or "PNG").upper()
fmt = "JPEG" if fmt in ("JPG", "JPEG") else "WEBP" if fmt == "WEBP" else "PNG"
else:
fmt = image_format.upper()
ext = {"JPEG": ".jpg", "WEBP": ".webp"}.get(fmt, ".png")
if naming_rule == "自然数字":
stem = str(task_index + 1)
if image_index > 1:
stem += f"_{image_index}"
else:
stem = base_filename or f"task_{task_index + 1}"
if image_index > 1:
stem += f"+{image_index - 1}"
path = os.path.join(folder, f"{stem}{ext}")
collision = 1
while os.path.exists(path):
path = os.path.join(folder, f"{stem}+{collision}{ext}")
collision += 1
if fmt == "JPEG":
image.convert("RGB").save(path, format="JPEG", quality=100, subsampling=0)
elif fmt == "WEBP":
image.save(path, format="WEBP", lossless=True, quality=100)
else:
save_image(image, path)
saved.append(path)
return saved
@staticmethod
def _progress_callback(progress_values, index, pbar):
if pbar is None:
return None
def update(value):
try:
progress_values[index] = max(0, min(100, int(value)))
except (TypeError, ValueError):
return
pbar.update_absolute(sum(progress_values), len(progress_values) * 100)
return update
@classmethod
async def _run_task(
cls, client, pair, task_prompt, task_index, total_tasks,
model, quality, size, image_count, seed, mask, output_format,
background, resize_mode,
folder, naming_rule, save_lock, progress_callback,
) -> dict:
try:
task_started = time.time()
images = await client.generate_image_async(
prompt=task_prompt, model=model, quality=quality, size=size,
n=image_count, seed=seed,
image_tensor=[pil_to_tensor([item.image]) for item in pair],
mask_tensor=mask, output_format=output_format,
background=background,
progress_callback=progress_callback,
special_price_parallel=True,
log_downloads=True,
log_prefix=f"[GPT Image Batch] [{task_index + 1}/{total_tasks}]",
task_submitted_callback=lambda task_id, status, elapsed: print(
f"[GPT Image Batch] [{task_index + 1}/{total_tasks}] 已提交 | "
f"task_id={task_id} | 状态={status} | 耗时={elapsed:.1f}s"
),
log_request_start=False,
resize_mode=resize_mode,
)
async with save_lock:
files = cls._save_images(
images, folder, output_format if output_format != "png" else "PNG",
naming_rule, task_index, pair[0].filename if pair else None,
)
print(
f"[GPT Image Batch] [{task_index + 1}/{total_tasks}] 完成 ✓ | "
f"生成={len(images)} 张 | 耗时={time.time() - task_started:.1f}s"
)
return {"task_index": task_index, "success": True,
"generated_count": len(images), "saved_files": files, "error": None}
except (InterruptProcessingException, asyncio.CancelledError):
raise
except Exception as error:
message = str(error).splitlines()[0]
print(f"[GPT Image Batch] [{task_index + 1}/{total_tasks}] ❌ {message}")
return {"task_index": task_index, "success": False,
"generated_count": 0, "saved_files": [], "error": message}
@classmethod
async def _process_async(
cls, client, task_defs, model, quality, size, image_count, seed,
mask, output_format, background, resize_mode,
folder, naming_rule, pbar,
) -> List[dict]:
total = len(task_defs)
progress_values = [0] * total
save_lock = asyncio.Lock()
tasks = [
asyncio.create_task(cls._run_task(
client, pair, prompt, index, total, model, quality, size,
image_count, seed, mask, output_format, background,
resize_mode, folder, naming_rule,
save_lock, cls._progress_callback(progress_values, index, pbar),
))
for index, pair, prompt in task_defs
]
batch = await asyncio.gather(*tasks, return_exceptions=True)
results = []
for index, item in enumerate(batch):
if isinstance(item, (InterruptProcessingException, asyncio.CancelledError)):
raise item
if isinstance(item, BaseException):
item = {"task_index": index, "success": False,
"generated_count": 0, "saved_files": [], "error": str(item)}
results.append(item)
gc.collect()
print(f"[GPT Image Batch] 进度 {total}/{total}")
return results
@classmethod
def execute(
cls, prompt, 模型="gpt-image-2.5-sunburst", 模型线路="畅速", 分辨率="智能", 生图数量=1,
质量="自动", 图片路径数量=None, 遮罩=None, seed=0,
图片输出格式="原始", 图片保存命名规则="和原始图片名保持一致",
图片保存路径="", 缩放图片="智能缩放", 背景="auto", **kwargs,
) -> io.NodeOutput:
if not prompt or not str(prompt).strip():
raise ValueError("提示词不能为空")
if 缩放图片 not in {"不缩放", "智能缩放"}:
raise ValueError("缩放图片参数无效")
if 背景 not in GPT_IMAGE_BACKGROUND_OPTIONS:
raise ValueError("GPT Image 背景参数无效")
actual_model = resolve_gpt_image_model(模型, 模型线路)
# 兼容旧节点通过命名参数调用时使用的字段名。
if 图片输出格式 == "原始" and kwargs.get("图片格式"):
图片输出格式 = kwargs["图片格式"]
if not str(图片保存路径 or "").strip() and kwargs.get("保存路径"):
图片保存路径 = kwargs["保存路径"]
legacy_group = kwargs.get("图片文件夹数量")
nested = 图片路径数量 if isinstance(图片路径数量, dict) else (
legacy_group if isinstance(legacy_group, dict) else None
)
values = nested or kwargs
selected_count = _path_count(values.get(
"图片路径数量", values.get("图片文件夹数量", 图片路径数量 or legacy_group)
))
paths = [
values.get(_path_name(i), values.get(f"图片路径{i}", kwargs.get(f"文件夹{i}", "")))
for i in range(1, _MAX_PATHS + 1)
]
if nested is not None:
paths = paths[:selected_count] + [""] * (_MAX_PATHS - selected_count)
if not any(str(path).strip() for path in paths if path is not None):
raise ValueError("请至少填写一个图片文件夹路径")
pairing = cls._normalize_pairing(values.get("图片配对模式", kwargs.get("图片配对模式", "不配对")))
image_lists = []
for index, path in enumerate(paths, 1):
if path and str(path).strip():
image_lists.append(load_images_from_folder(str(path).strip()))
reference_values = _collect_group(kwargs.get("参考图组"))
if not reference_values:
reference_values = [kwargs[f"参考图{i}"] for i in range(1, _MAX_REFERENCES + 1)
if kwargs.get(f"参考图{i}") is not None]
pairs = cls._create_pairs(
image_lists, pairing, cls._manual_images(reference_values)
)
if not pairs:
raise ValueError("图片配对结果为空,请检查路径和配对模式")
batch_prompts = parse_batch_prompts(prompt)
prompts = batch_prompts or [prompt]
task_defs = []
for pair in pairs:
for task_prompt in prompts:
task_defs.append((len(task_defs), pair, task_prompt))
folder = cls._output_folder(图片保存路径)
quality = resolve_gpt_image_quality(模型, 质量)
output_format = {"原始": "png", "JPEG": "jpeg", "PNG": "png", "WebP": "webp"}.get(图片输出格式, "png")
if output_format not in GPT_IMAGE_OUTPUT_FORMAT_OPTIONS:
raise ValueError("GPT Image 输出格式无效")
if 背景 == "transparent" and output_format == "jpeg":
raise ValueError("GPT Image 透明背景仅支持 PNG 或 WebP 输出格式")
client = GptImageClient()
client.base_url = get_base_url_by_route()
client.response_log_enabled = False
client.poll_log_enabled = False
print(
f"[GPT Image Batch] 开始 | 任务={len(task_defs)} | 全并发 | "
f"模型={actual_model} | 每任务={生图数量}"
)
pbar = ProgressBar(len(task_defs) * 100) if _PROGRESS_AVAILABLE else None
start_time = time.time()
def run_async():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
coro = cls._process_async(
client, task_defs, actual_model, quality, _resolve_size(分辨率),
生图数量, seed, 遮罩, output_format, 背景,
缩放图片, folder,
图片保存命名规则, pbar,
)
return loop.run_until_complete(client._run_with_interrupt(coro))
finally:
loop.close()
try:
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="gpt-image-batch") as executor:
results = executor.submit(run_async).result()
finally:
try:
print(f"[GPT Image Batch] {client.format_balance_info(client.query_balance_sync())}")
except Exception:
pass
successful = [item for item in results if item.get("success")]
if not successful:
raise RuntimeError("所有 GPT Image 批量任务均生成失败")
saved_files = [path for item in successful for path in item["saved_files"]]
preview = []
for path in saved_files[-10:]:
try:
image = Image.open(path)
image.load()
preview.append(image.copy())
except Exception as error:
print(f"[GPT Image Batch] 预览加载失败 {path}: {error}")
output = GptImageClient._pil_list_to_tensor(preview)
generated = sum(item["generated_count"] for item in successful)
print(
f"[GPT Image Batch] 完成 | 成功={len(successful)}/{len(results)} "
f"| 生成={generated} | 耗时={time.time() - start_time:.1f}s | 保存={folder}"
)
return io.NodeOutput(output)
+30
View File
@@ -8,6 +8,7 @@ separator bands near the expected grid lines, then crops each cell.
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import List, Sequence, Tuple
@@ -327,6 +328,30 @@ def _split_one(
return crops, info
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tiff", ".tif", ".gif")
def _load_folder_images(folder: str) -> List[Image.Image]:
if not os.path.isdir(folder):
raise ValueError(f"合并图切割:图片路径不是有效的文件夹:{folder}")
names = sorted(
name for name in os.listdir(folder)
if name.lower().endswith(_IMAGE_EXTS)
)
images: List[Image.Image] = []
for name in names:
path = os.path.join(folder, name)
if not os.path.isfile(path):
continue
with Image.open(path) as opened:
images.append(opened.convert("RGB"))
if not images:
raise ValueError(f"合并图切割:文件夹中没有可读取的图片:{folder}")
return images
class O1keyGridSplitter:
"""Split AI-generated grid/contact-sheet images into individual cells."""
@@ -343,6 +368,7 @@ class O1keyGridSplitter:
"裁掉外边距": ("BOOLEAN", {"default": True}),
"最小分隔线px": ("INT", {"default": 2, "min": 0, "max": 64, "step": 1}),
"最大输出张数": ("INT", {"default": 16, "min": 1, "max": 144, "step": 1}),
"图片路径": ("STRING", {"default": "", "multiline": False}),
}
}
@@ -366,7 +392,11 @@ class O1keyGridSplitter:
裁掉外边距: bool = True,
最小分隔线px: int = 2,
最大输出张数: int = 16,
图片路径: str = "",
):
if 图片路径 and 图片路径.strip():
source_images = _load_folder_images(图片路径.strip())
else:
source_images = tensor_to_pil(图像)
all_crops: List[Image.Image] = []
info_lines: List[str] = []
+1 -6
View File
@@ -6,7 +6,6 @@ o1key Grok Image 节点
import time
from ..clients.grok_image_client import GrokImageClient
from ..utils.image_utils import parse_batch_prompts
from ..utils.config import NETWORK_ROUTE_OPTIONS
try:
from comfy.model_management import processing_interrupted, InterruptProcessingException
@@ -57,9 +56,6 @@ class O1keyGrokImage:
"step": 1,
"display": "number",
}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {
"default": NETWORK_ROUTE_OPTIONS[0],
}),
"seed": ("INT", {
"default": 0,
"min": 0,
@@ -85,7 +81,6 @@ class O1keyGrokImage:
宽高比: str = "auto",
分辨率: str = "1k",
生图数量: int = 1,
网络线路: str = "全球加速",
seed: int = 0,
**kwargs,
):
@@ -99,7 +94,7 @@ class O1keyGrokImage:
image_list = reference_tensors if reference_tensors else None
try:
client = GrokImageClient(route=网络线路)
client = GrokImageClient()
except ValueError as e:
if str(e) == "未授权!":
print("[o1key Grok Image] 请联系作者授权后方可使用!")
+223 -195
View File
@@ -1,30 +1,25 @@
"""
Grok Video node.
"""Lean ComfyUI nodes for O1Key Grok Imagine Video."""
Submits a /v1/videos task, polls until completion, downloads the mp4,
and returns ComfyUI's native VIDEO object.
"""
import json
import asyncio
import math
import os
from typing import List, Optional
import re
from typing import Dict
from ..clients.grok_video_client import GrokVideoClient
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.image_utils import encode_images_for_request_body_limit, tensor_to_pil
from ..utils.config import get_base_url_by_route
from ..utils.image_utils import tensor_to_pil
from ..utils.r2_uploader import upload_audio, upload_image, upload_video
try:
import folder_paths
FOLDER_PATHS_AVAILABLE = True
except ImportError:
FOLDER_PATHS_AVAILABLE = False
folder_paths = None
try:
from comfy.utils import ProgressBar
PROGRESS_BAR_AVAILABLE = True
except ImportError:
ProgressBar = None
PROGRESS_BAR_AVAILABLE = False
try:
from comfy_api.input_impl import VideoFromFile
@@ -36,135 +31,94 @@ except Exception:
VideoFromFile = None
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"]
QUALITY_VALUE_MAP = {
"720p": "high",
}
MODEL_SECONDS_OPTIONS = {
"grok-imagine-1.0-video": [6, 10, 12, 16, 20],
}
MAX_REFERENCE_IMAGES = 3
MAX_REQUEST_BODY_BYTES = 20 * 1024 * 1024
MODEL_OPTIONS = list(GrokVideoClient.MODEL_OPTIONS)
ASPECT_RATIO_OPTIONS = list(GrokVideoClient.ASPECT_RATIO_OPTIONS)
RESOLUTION_OPTIONS = list(GrokVideoClient.RESOLUTION_OPTIONS)
GENERATION_MODE_OPTIONS = ["文生视频", "图生视频", "参考生视频"]
EDIT_MODE_OPTIONS = ["编辑视频", "续写视频"]
IMAGE_INPUT_NAMES = [f"图片{i}" for i in range(1, 8)]
AUDIO_INPUT_NAMES = ["音频素材", "音频素材2", "音频素材3"]
def _get_output_dir() -> str:
if FOLDER_PATHS_AVAILABLE:
base = folder_paths.get_output_directory()
if folder_paths is not None:
base_dir = folder_paths.get_temp_directory()
else:
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
comfy_root = os.path.dirname(os.path.dirname(plugin_dir))
base = os.path.join(comfy_root, "output")
output_dir = os.path.join(base, "grok_video")
base_dir = os.path.join(os.path.dirname(os.path.dirname(plugin_dir)), "temp")
output_dir = os.path.join(base_dir, "grok_video")
os.makedirs(output_dir, exist_ok=True)
return output_dir
def _format_mb(size_bytes: int) -> str:
return f"{size_bytes / 1024 / 1024:.2f}MB"
def _image_tensor_to_first_pil(image_tensor):
def _single_pil_image(image_tensor, input_name: str):
if image_tensor is None:
return None
pil_images = tensor_to_pil(image_tensor)
if not pil_images:
return None
image = pil_images[0]
if image.mode not in ("RGB", "L"):
image = image.convert("RGB")
return image
images = tensor_to_pil(image_tensor)
if len(images) != 1:
raise ValueError(f"{input_name} 只能连接 1 张图片,请拆分批次后再连接。")
return images[0].convert("RGB")
def _collect_reference_images(**kwargs) -> List[object]:
images = []
for i in range(1, MAX_REFERENCE_IMAGES + 1):
image = _image_tensor_to_first_pil(kwargs.get(f"参考图{i}"))
if image is not None:
images.append(image)
return images
def _parse_voice_ids(value: object) -> list[str]:
voice_ids = [item.strip() for item in re.split(r"[,\n]", str(value or "")) if item.strip()]
if len(voice_ids) > 3:
raise ValueError("参考音色 ID 最多填写 3 个。")
return voice_ids
def _to_data_urls(encoded_images) -> List[str]:
return [f"data:{mime};base64,{b64}" for mime, b64 in encoded_images]
def _video_duration_seconds(video) -> float:
getter = getattr(video, "get_duration", None)
if not callable(getter):
raise ValueError("无法读取输入视频时长;请连接 ComfyUI 原生 VIDEO 输出。")
try:
duration = float(getter())
except Exception as exc:
raise ValueError("无法读取输入视频时长;请确认视频文件可以正常解码。") from exc
if not math.isfinite(duration) or duration <= 0:
raise ValueError("输入视频时长无效;请确认视频文件可以正常解码。")
return duration
def _encode_image_data_urls(
images: List[object],
prompt: str,
model: str,
aspect_ratio: str,
seconds: int,
quality: str,
) -> Optional[List[str]]:
if not images:
return None
def _progress_callback():
progress_bar = ProgressBar(100) if ProgressBar is not None else None
progress_value = [0]
def build_body(encoded_images):
return GrokVideoClient.build_video_body(
prompt=prompt,
model=model,
aspect_ratio=aspect_ratio,
seconds=seconds,
quality=quality,
images=_to_data_urls(encoded_images),
)
def callback(progress: int, _status: str, _elapsed: float) -> None:
current = max(0, min(100, int(progress or 0)))
if progress_bar is not None and current > progress_value[0]:
progress_bar.update(current - progress_value[0])
progress_value[0] = current
encoded = encode_images_for_request_body_limit(
images,
build_body=build_body,
max_body_bytes=MAX_REQUEST_BODY_BYTES,
)
data_urls = _to_data_urls(encoded)
return data_urls
return progress_bar, progress_value, callback
def _validate_request_body_size(body: dict) -> None:
body_size = len(json.dumps(body, ensure_ascii=False).encode("utf-8"))
if body_size > MAX_REQUEST_BODY_BYTES:
raise ValueError(
f"Grok Video 请求体大小 {_format_mb(body_size)} 超过 "
f"{_format_mb(MAX_REQUEST_BODY_BYTES)} 限制,请减少参考图片或降低图片尺寸。"
)
def _finish_video(result: Dict[str, object], progress_bar, progress_value):
if progress_bar is not None and progress_value[0] < 100:
progress_bar.update(100 - progress_value[0])
video_path = result["video_path"]
print(f"Grok Video:下载完成:{video_path}")
return (VideoFromFile(video_path),)
class O1keyGrokVideo:
"""文生、图生或参考素材生 Grok 视频。素材会自动上传为 URL。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"提示词": (
"STRING",
{
"default": "",
"multiline": True,
},
),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": NETWORK_ROUTE_OPTIONS[0]}),
"模型": (MODEL_OPTIONS, {"default": MODEL_OPTIONS[0]}),
"生成模式": (GENERATION_MODE_OPTIONS, {"default": "文生视频"}),
"提示词": ("STRING", {"default": "", "multiline": True}),
"模型": (MODEL_OPTIONS, {"default": GrokVideoClient.DEFAULT_MODEL}),
"时长(秒)": ("INT", {"default": 8, "min": 1, "max": 15, "step": 1}),
"宽高比": (ASPECT_RATIO_OPTIONS, {"default": "16:9"}),
"秒数(按模型限制)": (
"INT",
{
"default": 5,
"min": 5,
"max": 20,
"step": 1,
"display": "number",
},
),
"画质": (QUALITY_OPTIONS, {"default": "720p"}),
"分辨率": (RESOLUTION_OPTIONS, {"default": "480p"}),
"参考音色ID(逗号分隔)": ("STRING", {"default": ""}),
},
"optional": {
"参考图1": ("IMAGE",),
"参考图2": ("IMAGE",),
"参考图3": ("IMAGE",),
**{input_name: ("IMAGE",) for input_name in IMAGE_INPUT_NAMES},
**{input_name: ("AUDIO",) for input_name in AUDIO_INPUT_NAMES},
},
}
@@ -172,112 +126,186 @@ class O1keyGrokVideo:
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Video"
DESCRIPTION = (
"Grok Video /v1/videos task node. Supports prompt plus up to "
"three image references, multiple aspect ratios, model-specific seconds, 720p output."
"支持文生、图生和多参考素材生成。图生视频只连接图片 1;参考生视频最多使用 7 张图和 "
"3 个参考音频(AUDIO 或 voice_id 合计)。Grok 1.5 的文生/图生可选 1080p,多参考最高 720p。"
)
def generate(
self,
**kwargs,
):
def generate(self, **kwargs):
if VideoFromFile is None:
raise RuntimeError("当前 ComfyUI 版本不支持原生 VIDEO 输入实现 VideoFromFile。")
raise RuntimeError("当前 ComfyUI 版本不支持 VideoFromFile,无法输出 VIDEO")
提示词 = kwargs.get("提示词", "")
网络线路 = kwargs.get("网络线路", NETWORK_ROUTE_OPTIONS[0])
模型 = kwargs.get("模型", MODEL_OPTIONS[0])
宽高比 = kwargs.get("宽高比", "16:9")
秒数 = kwargs.get("秒数(按模型限制)", kwargs.get("秒数(≤15s", kwargs.get("秒数", 5)))
画质 = kwargs.get("画质", "720p")
mode = kwargs.get("生成模式", "文生视频")
model = kwargs.get("模型", GrokVideoClient.DEFAULT_MODEL)
prompt = (kwargs.get("提示词") or "").strip()
connected_images = [
(input_name, image)
for input_name in IMAGE_INPUT_NAMES
if (image := _single_pil_image(kwargs.get(input_name), input_name)) is not None
]
images = [image for _, image in connected_images]
audios = [kwargs.get(input_name) for input_name in AUDIO_INPUT_NAMES if kwargs.get(input_name) is not None]
voice_ids = _parse_voice_ids(kwargs.get("参考音色ID(逗号分隔)"))
duration = kwargs.get("时长(秒)", 8)
aspect_ratio = kwargs.get("宽高比", "16:9")
resolution = kwargs.get("分辨率", "480p")
prompt = (提示词 or "").strip()
if not prompt:
raise ValueError("提示词不能为空。")
if 模型 not in MODEL_OPTIONS:
raise ValueError(f"模型仅支持: {', '.join(MODEL_OPTIONS)}")
if 宽高比 not in ASPECT_RATIO_OPTIONS:
raise ValueError(f"宽高比仅支持: {', '.join(ASPECT_RATIO_OPTIONS)}")
seconds = int(秒数)
allowed_seconds = MODEL_SECONDS_OPTIONS.get(模型)
if allowed_seconds is not None:
if seconds not in allowed_seconds:
raise ValueError(
f"模型 {模型} 仅支持秒数: "
f"{', '.join(str(s) for s in allowed_seconds)}"
"请修改为正确的秒数后再发起请求。"
if mode not in GENERATION_MODE_OPTIONS:
raise ValueError(f"不支持的生成模式:{mode}")
if len(audios) + len(voice_ids) > 3:
raise ValueError("参考音频与参考音色 ID 合计最多 3 个。")
if mode == "文生视频":
if images or audios or voice_ids:
raise ValueError("文生视频不需要连接图像或音频素材。")
elif mode == "图生视频":
if len(images) != 1 or connected_images[0][0] != "图片1":
raise ValueError("图生视频需要在“图片 1”连接 1 张图片,其他图片端口请留空。")
if audios or voice_ids:
raise ValueError("图生视频不支持音频素材,请使用参考生视频。")
else:
if not images and not audios and not voice_ids:
raise ValueError("参考生视频至少需要连接图像素材或音频素材。")
placeholder_image = {"url": "https://example.invalid/image"} if mode == "图生视频" else None
placeholder_references = (
[{"url": f"https://example.invalid/reference-{index}"} for index in range(len(images))]
if mode == "参考生视频"
else []
)
elif seconds < 5 or seconds > 15:
raise ValueError("秒数仅支持 5 到 15。")
if 画质 not in QUALITY_OPTIONS:
raise ValueError("画质仅支持 720p。")
quality = QUALITY_VALUE_MAP[画质]
reference_images = _collect_reference_images(**kwargs)
image_data_urls = _encode_image_data_urls(
reference_images,
placeholder_audios = (
[{"url": f"https://example.invalid/audio-{index}"} for index in range(len(audios))]
+ [{"voice_id": voice_id} for voice_id in voice_ids]
if mode == "参考生视频"
else []
)
# Validate every user-controlled field before temporary uploads or paid generation calls.
GrokVideoClient.build_video_body(
operation="generate",
prompt=prompt,
model=模型,
aspect_ratio=宽高比,
seconds=seconds,
quality=quality,
model=model,
duration=duration,
aspect_ratio=aspect_ratio,
resolution=resolution,
image=placeholder_image,
reference_images=placeholder_references,
reference_audios=placeholder_audios,
)
request_body = GrokVideoClient.build_video_body(
prompt=prompt,
model=模型,
aspect_ratio=宽高比,
seconds=seconds,
quality=quality,
images=image_data_urls,
base_url = get_base_url_by_route()
client = GrokVideoClient(base_url=base_url)
async def upload_materials():
image_urls, audio_urls = await asyncio.gather(
asyncio.gather(*(upload_image(image, base_url=base_url) for image in images)),
asyncio.gather(*(upload_audio(audio, base_url=base_url) for audio in audios)),
)
_validate_request_body_size(request_body)
return list(image_urls), list(audio_urls)
pbar = ProgressBar(100) if PROGRESS_BAR_AVAILABLE else None
last_progress = [0]
image_urls, audio_urls = client.run_async_in_thread(upload_materials())
if mode == "文生视频":
image = None
reference_images = []
reference_audios = []
elif mode == "图生视频":
image = {"url": image_urls[0]}
reference_images = []
reference_audios = []
else:
image = None
reference_images = [{"url": url} for url in image_urls]
reference_audios = [
*({"url": url} for url in audio_urls),
*({"voice_id": voice_id} for voice_id in voice_ids),
]
def progress_callback(progress: int, status: str, elapsed: float):
progress_value = max(0, min(100, int(progress or 0)))
if pbar is not None and progress_value > last_progress[0]:
pbar.update(progress_value - last_progress[0])
last_progress[0] = progress_value
client = GrokVideoClient(base_url=get_base_url_by_route(网络线路))
try:
result = client.generate_video_sync(
progress_bar, progress_value, callback = _progress_callback()
result = client.run_video_sync(
operation="generate",
prompt=prompt,
model=模型,
aspect_ratio=宽高比,
seconds=seconds,
quality=quality,
model=model,
duration=duration,
aspect_ratio=aspect_ratio,
resolution=resolution,
image=image,
reference_images=reference_images,
reference_audios=reference_audios,
output_dir=_get_output_dir(),
images=image_data_urls,
poll_interval=5,
timeout=1200,
progress_callback=progress_callback,
progress_callback=callback,
)
return _finish_video(result, progress_bar, progress_value)
class O1keyGrokVideoEdit:
"""编辑或续写 Grok 视频。输入 VIDEO 会自动上传为 URL。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"操作": (EDIT_MODE_OPTIONS, {"default": "编辑视频"}),
"提示词": ("STRING", {"default": "", "multiline": True}),
"续写时长(秒)": ("INT", {"default": 6, "min": 2, "max": 10, "step": 1}),
"模型": (MODEL_OPTIONS, {"default": GrokVideoClient.DEFAULT_MODEL}),
},
"optional": {"视频素材": ("VIDEO",)},
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Video"
DESCRIPTION = (
"编辑或续写视频。编辑输入最长 8.7 秒,并保留原时长和宽高比,输出最高 720p;"
"续写时长为 2–10 秒,输出总时长等于输入时长加续写时长。"
)
if pbar is not None and last_progress[0] < 100:
pbar.update(100 - last_progress[0])
def generate(self, **kwargs):
if VideoFromFile is None:
raise RuntimeError("当前 ComfyUI 版本不支持 VideoFromFile,无法输出 VIDEO。")
video = kwargs.get("视频素材")
if video is None:
raise ValueError("请连接一个 VIDEO 类型的视频素材。")
video_path = result["video_path"]
print(f"Grok Video:下载完成:{video_path}")
return (VideoFromFile(video_path),)
finally:
try:
balance_data = client.query_balance_sync()
balance_info = client.format_balance_info(balance_data)
print(f"Grok Video{balance_info}")
except Exception:
pass
selected_operation = kwargs.get("操作", "编辑视频")
if selected_operation not in EDIT_MODE_OPTIONS:
raise ValueError(f"不支持的 Grok 视频操作:{selected_operation}")
operation = "edit" if selected_operation == "编辑视频" else "extend"
prompt = (kwargs.get("提示词") or "").strip()
model = kwargs.get("模型", GrokVideoClient.DEFAULT_MODEL)
duration = kwargs.get("续写时长(秒)", 6)
if operation == "edit" and _video_duration_seconds(video) > 8.7:
raise ValueError("Grok 视频编辑的输入视频不能超过 8.7 秒。")
GrokVideoClient.build_video_body(
operation=operation,
prompt=prompt,
model=model,
duration=duration,
video={"url": "https://example.invalid/video"},
)
base_url = get_base_url_by_route()
client = GrokVideoClient(base_url=base_url)
video_url = client.run_async_in_thread(upload_video(video, base_url=base_url))
progress_bar, progress_value, callback = _progress_callback()
result = client.run_video_sync(
operation=operation,
prompt=prompt,
model=model,
duration=duration,
video={"url": video_url},
output_dir=_get_output_dir(),
progress_callback=callback,
)
return _finish_video(result, progress_bar, progress_value)
NODE_CLASS_MAPPINGS = {
"O1keyGrokVideo": O1keyGrokVideo,
"O1keyGrokVideoEdit": O1keyGrokVideoEdit,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"O1keyGrokVideo": "Grok Video",
"O1keyGrokVideoEdit": "Grok Video Edit",
}
-738
View File
@@ -1,738 +0,0 @@
"""
Kling 3.0 Video Nodes
"""
import os
import tempfile
from ..clients.kling_client import KlingClient
from ..clients.gemini_client import GeminiAPIClient
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from comfy_api.latest import InputImpl
def _tensor_to_base64(tensor) -> str:
"""ComfyUI IMAGE tensor → base64 PNG 字符串"""
pil_images = tensor_to_pil(tensor)
return encode_image_to_base64(pil_images[0], format="PNG")
def _validate_prompt(prompt: str, *, required: bool = True) -> None:
"""校验单条提示词。
Args:
prompt: 提示词字符串
required: True 时不允许为空多镜头关闭或 shot_type intelligence 时适用
"""
if required and not prompt.strip():
raise ValueError("提示词不能为空(非多镜头模式下必填)。")
if len(prompt) > 2500:
raise ValueError(
f"提示词长度 ({len(prompt)}) 超过上限 2500 个字符,请缩短后重试。"
)
def _validate_multi_prompt(multi_prompt_list: list, total_duration: int) -> None:
"""校验多镜头分镜列表。
规则
- 分镜数量1 ~ 6
- 每个分镜提示词不超过 512 个字符
- 每个分镜时长 1 total_duration
- 所有分镜时长之和必须等于 total_duration
"""
count = len(multi_prompt_list)
if count < 1 or count > 6:
raise ValueError(
f"多镜头分镜数量须在 1~6 之间,当前为 {count}"
)
duration_sum = 0
for entry in multi_prompt_list:
idx = entry["index"]
p = entry.get("prompt", "")
dur = entry.get("duration", 0)
if len(p) > 512:
raise ValueError(
f"镜头 {idx} 提示词长度 ({len(p)}) 超过上限 512 个字符。"
)
if dur < 1:
raise ValueError(
f"镜头 {idx} 时长 ({dur}s) 不能小于 1 秒。"
)
if dur > total_duration:
raise ValueError(
f"镜头 {idx} 时长 ({dur}s) 超过任务总时长 ({total_duration}s)。"
)
duration_sum += dur
if duration_sum != total_duration:
raise ValueError(
f"所有分镜时长之和 ({duration_sum}s) 必须等于任务总时长 ({total_duration}s)。"
)
def _validate_image(tensor, label: str = "图片") -> None:
"""校验图片张量。
规则
- 文件大小PNG不超过 10MB
- 高均不小于 300px
- 宽高比介于 1:2.5 ~ 2.5:1 之间 ratio [0.4, 2.5]
"""
import io
pil_images = tensor_to_pil(tensor)
img = pil_images[0]
w, h = img.size
# ── 最小尺寸 ──────────────────────────────────────────────────────
if w < 300 or h < 300:
raise ValueError(
f"{label} 宽高不得小于 300px,当前为 {w}×{h}px。"
)
# ── 宽高比 ────────────────────────────────────────────────────────
ratio = w / h
if ratio < 1 / 2.5 or ratio > 2.5:
raise ValueError(
f"{label} 宽高比须在 1:2.5 ~ 2.5:1 之间,"
f"当前为 {w}:{h}(比值 {ratio:.2f})。"
)
# ── 文件大小 ──────────────────────────────────────────────────────
buf = io.BytesIO()
img.save(buf, format="PNG")
size_mb = buf.tell() / (1024 * 1024)
if size_mb > 10:
raise ValueError(
f"{label} PNG 大小 ({size_mb:.1f}MB) 超过上限 10MB。"
)
class KlingVideo:
"""Kling 视频生成节点(支持多镜头)"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"提示词": ("STRING", {"multiline": True, "default": ""}),
"反向提示词": ("STRING", {"multiline": True, "default": ""}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
"模型版本": (["v3", "v2-6"], {"default": "v3"}),
"时长": ([5, 10, 15],),
"分辨率": (["1080p", "720p"],),
"宽高比": (["智能", "16:9", "9:16", "1:1"], {"default": "智能"}),
"生成音频": (["打开", "关闭"], {"default": "打开"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
},
"optional": {
"起始帧": ("IMAGE",),
"镜头1_提示词": ("STRING", {"multiline": True, "default": ""}),
"镜头1_时长": ("STRING", {"default": "5"}),
"镜头2_提示词": ("STRING", {"multiline": True, "default": ""}),
"镜头2_时长": ("STRING", {"default": "5"}),
"镜头3_提示词": ("STRING", {"multiline": True, "default": ""}),
"镜头3_时长": ("STRING", {"default": "5"}),
"镜头4_提示词": ("STRING", {"multiline": True, "default": ""}),
"镜头4_时长": ("STRING", {"default": "5"}),
"镜头5_提示词": ("STRING", {"multiline": True, "default": ""}),
"镜头5_时长": ("STRING", {"default": "5"}),
"镜头6_提示词": ("STRING", {"multiline": True, "default": ""}),
"镜头6_时长": ("STRING", {"default": "5"}),
}
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Kling"
async def generate(self, **kwargs):
"""生成视频(支持多镜头)"""
prompt = kwargs["提示词"]
negative_prompt = kwargs["反向提示词"]
model_ver = kwargs.get("模型版本", "v3")
duration = kwargs["时长"]
resolution = kwargs["分辨率"]
aspect_ratio = kwargs["宽高比"]
generate_audio = kwargs["生成音频"]
start_frame = kwargs.get("起始帧", None)
seed = kwargs.get("seed", 0) # noqa: F841 — 触发 ComfyUI 缓存刷新
mode = "pro" if resolution == "1080p" else "std"
voice = "voice" if generate_audio == "打开" else "novoice"
# ── v2-6 模型约束校验 ──────────────────────────────────────────
if model_ver == "v2-6":
if duration == 15:
raise ValueError(
"v2-6 模型不支持 15s 时长,请选择 5s 或 10s。"
)
if mode == "std" and voice == "voice":
raise ValueError(
"v2-6 模型的标准画质(720p)不支持生成音频,请关闭生成音频或切换至 1080p。"
)
# ── 多镜头检测 ────────────────────────────────────────────────
multi_prompt_list = []
for i in range(1, 7):
sb_prompt = kwargs.get(f"镜头{i}_提示词", "").strip()
if sb_prompt:
raw_dur = kwargs.get(f"镜头{i}_时长", "5")
try:
sb_duration = int(str(raw_dur).strip()) if str(raw_dur).strip() else 5
except ValueError:
sb_duration = 5
multi_prompt_list.append({
"index": i,
"prompt": sb_prompt,
"duration": sb_duration,
})
multi_shot_enabled = len(multi_prompt_list) > 0
if multi_shot_enabled:
total_duration = sum(e["duration"] for e in multi_prompt_list)
if total_duration < 3 or total_duration > 15:
raise ValueError(
f"多镜头总时长 ({total_duration}s) 必须在 3~15 秒之间。"
)
_validate_multi_prompt(multi_prompt_list, total_duration)
duration = total_duration
else:
_validate_prompt(prompt, required=True)
# ── 构建模型名 & 请求体 ───────────────────────────────────────
import json, base64, copy
model_name = f"kling-{model_ver}-{mode}-{duration}s-{voice}"
body = {
"model": model_name,
"mode": mode,
"duration": duration,
}
sound = "on" if generate_audio == "打开" else "off"
if multi_shot_enabled or sound == "on":
ms_payload = {}
ms_payload["prompt"] = prompt
if sound == "on":
ms_payload["sound"] = "on"
if multi_shot_enabled:
ms_payload["multi_shot"] = True
ms_payload["shot_type"] = "customize"
ms_payload["multi_prompt"] = multi_prompt_list
encoded = base64.b64encode(
json.dumps(ms_payload, ensure_ascii=False).encode("utf-8")
).decode("utf-8")
body["prompt"] = f"__MS__:{encoded}"
else:
body["prompt"] = prompt
if negative_prompt.strip():
body["negative_prompt"] = negative_prompt
if start_frame is not None:
_validate_image(start_frame, "起始帧")
body["image"] = _tensor_to_base64(start_frame)
endpoint_type = "image2video"
else:
if aspect_ratio and aspect_ratio != "智能":
body["metadata"] = {"aspect_ratio": aspect_ratio}
endpoint_type = "text2video"
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
_, save_path = tempfile.mkstemp(suffix=".mp4", prefix="kling_")
client = KlingClient()
client.base_url = get_base_url_by_route(kwargs.get("网络线路", "全球加速"))
# ── 进度条 ────────────────────────────────────────────────────
try:
from comfy.utils import ProgressBar
pbar = ProgressBar(100)
except Exception:
pbar = None
def on_stage(stage: str):
if stage == "submitting":
print("[视频生成] 提交中...")
if pbar: pbar.update_absolute(0, 100)
elif stage.startswith("submitted:"):
print(f"[视频生成] 任务已提交 → {stage.split(':',1)[1]}")
if pbar: pbar.update_absolute(5, 100)
elif stage == "downloading":
print("[视频生成] 下载视频...")
if pbar: pbar.update_absolute(99, 100)
elif stage == "done":
print("[视频生成] 完成")
if pbar: pbar.update_absolute(100, 100)
def on_progress(pct: int):
mapped = 5 + int(pct * 0.94)
if pbar: pbar.update_absolute(mapped, 100)
try:
result_path = await client.generate_async(
endpoint_type=endpoint_type,
body=body,
save_path=save_path,
on_stage=on_stage,
on_progress=on_progress,
)
return (InputImpl.VideoFromFile(result_path),)
finally:
# 查询余额
try:
_balance_client = GeminiAPIClient()
balance_data = _balance_client.query_balance_sync()
balance_info = _balance_client.format_balance_info(balance_data)
print(f"自研视频模型: {balance_info}")
except Exception:
pass
class KlingFirstLastFrame:
"""Kling 首尾帧到视频节点"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"首帧": ("IMAGE",),
"尾帧": ("IMAGE",),
"提示词": ("STRING", {"multiline": True, "default": ""}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
"模型": (["v3", "v2-6"], {"default": "v3"}),
"分辨率": (["1080p", "720p"],),
"时长": ([5, 10, 15],),
"生成音频": (["打开", "关闭"], {"default": "打开"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
}
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Kling"
async def generate(self, **kwargs):
first_frame = kwargs["首帧"]
end_frame = kwargs["尾帧"]
prompt = kwargs["提示词"]
duration = kwargs["时长"]
generate_audio = kwargs["生成音频"]
model_base = kwargs["模型"]
model_base = "kling-" + model_base # v3/v2-6 → kling-v3/kling-v2-6(后端值还原)
resolution = kwargs["分辨率"]
seed = kwargs.get("seed", 0) # noqa: F841 — 触发 ComfyUI 缓存刷新
_validate_prompt(prompt, required=True)
# 时长校验
if duration not in (5, 10, 15):
raise ValueError(f"时长仅支持 5、10、15 秒,当前值为 {duration},请重新选择。")
# 拼接模型名:kling-{ver}-{mode}-{dur}s-{voice}
mode = "pro" if resolution == "1080p" else "std"
voice = "voice" if generate_audio == "打开" else "novoice"
# ── v2-6 模型约束校验 ──────────────────────────────────────────
model_ver = kwargs["模型"] # "v3" or "v2-6"
if model_ver == "v2-6":
if duration == 15:
raise ValueError(
"v2-6 模型不支持 15s 时长,请选择 5s 或 10s。"
)
if mode == "std" and voice == "voice":
raise ValueError(
"v2-6 模型的标准画质(720p)不支持生成音频,请关闭生成音频或切换至 1080p。"
)
model_name = f"{model_base}-{mode}-{duration}s-{voice}"
# 图片校验 & 转 base64
_validate_image(first_frame, "首帧")
_validate_image(end_frame, "尾帧")
image_b64 = _tensor_to_base64(first_frame)
image_tail_b64 = _tensor_to_base64(end_frame)
# ── 按规范编码 prompt 和 sound ──────────────────────────
import json, base64
sound = "on" if generate_audio == "打开" else "off"
body = {
"model": model_name,
"image": image_b64,
"mode": mode,
"duration": duration,
"metadata": {
"image_tail": image_tail_b64,
},
}
if sound == "on":
ms_payload = {
"prompt": prompt,
"sound": "on",
}
encoded = base64.b64encode(
json.dumps(ms_payload, ensure_ascii=False).encode("utf-8")
).decode("utf-8")
body["prompt"] = f"__MS__:{encoded}"
else:
body["prompt"] = prompt
# 保存路径(临时文件,避免与下游保存节点重复落盘)
_, save_path = tempfile.mkstemp(suffix=".mp4", prefix="kling_")
client = KlingClient()
client.base_url = get_base_url_by_route(kwargs.get("网络线路", "全球加速"))
# 进度条:0~100 步
try:
from comfy.utils import ProgressBar
pbar = ProgressBar(100)
except Exception:
pbar = None
def on_stage(stage: str):
if stage == "submitting":
print("[视频生成] 提交中...")
if pbar:
pbar.update_absolute(0, 100)
elif stage.startswith("submitted:"):
print(f"[视频生成] 任务已提交 → {stage.split(':',1)[1]}")
if pbar:
pbar.update_absolute(5, 100)
elif stage == "downloading":
print("[视频生成] 下载视频...")
if pbar:
pbar.update_absolute(99, 100)
elif stage == "done":
print("[视频生成] 完成")
if pbar:
pbar.update_absolute(100, 100)
def on_progress(pct: int):
# pct 来自 API progress 字段,如 50 表示 50%
# 生成阶段占 5~99 区间
mapped = 5 + int(pct * 0.94)
if pbar:
pbar.update_absolute(mapped, 100)
try:
result_path = await client.generate_async(
endpoint_type="image2video",
body=body,
save_path=save_path,
on_stage=on_stage,
on_progress=on_progress,
)
return (InputImpl.VideoFromFile(result_path),)
finally:
# 查询余额
try:
_balance_client = GeminiAPIClient()
balance_data = _balance_client.query_balance_sync()
balance_info = _balance_client.format_balance_info(balance_data)
print(f"自研视频模型: {balance_info}")
except Exception:
pass
class KlingMotionControlTest:
"""Kling 动作控制(测试)节点 —— reference_video 接受 VIDEO 类型输入"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"提示词": ("STRING", {"multiline": True, "default": ""}),
"参考图片": ("IMAGE",),
"参考视频": ("VIDEO",),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
},
"optional": {
"模型": (["v3", "v2-6"], {"default": "v3"}),
"分辨率": (["1080p", "720p"],),
"时长": ([5, 10, 15], {"default": 5}),
"人物朝向": (["video", "image"],),
"保留原声": (["打开", "关闭"], {"default": "打开"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
},
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Kling"
async def generate(self, **kwargs):
"""动作控制:VIDEO 类型参考视频 + 图片人物动作迁移(走 new API 三段式)"""
import base64
prompt = kwargs["提示词"]
reference_image = kwargs["参考图片"]
reference_video = kwargs["参考视频"]
keep_original_sound = kwargs.get("保留原声", "打开")
character_orientation = kwargs.get("人物朝向", "video")
mode = kwargs.get("分辨率", "1080p")
duration = kwargs.get("时长", 5)
mode_api = "pro" if mode == "1080p" else "std" # 映射为 API 参数值
model = kwargs.get("模型", "v3")
model_name = f"kling-{model}-motion-{mode_api}-{duration}s"
seed = kwargs.get("seed", 0) # noqa: F841 — 触发 ComfyUI 缓存刷新
# ── 校验提示词 ────────────────────────────────────────────────
_validate_prompt(prompt, required=True)
# ── 校验参考图片 ──────────────────────────────────────────────
_validate_image(reference_image, "参考图片")
image_b64 = _tensor_to_base64(reference_image)
# ── 从 VIDEO 对象获取本地文件路径并读取 ───────────────────────
video_path = None
if hasattr(reference_video, "source_path"):
video_path = reference_video.source_path
elif hasattr(reference_video, "path"):
video_path = reference_video.path
elif isinstance(reference_video, str):
video_path = reference_video.strip()
if not video_path or not os.path.isfile(video_path):
raise ValueError(
f"无法获取参考视频文件路径,请确保连接的是本地视频文件。"
f"(当前路径:{video_path}"
)
# ── 校验视频时长约束 ──────────────────────────────────────────
# 人物朝向="video" → 3~30 秒;人物朝向="image" → 3~10 秒
try:
import subprocess, json as _json
ffprobe_cmd = [
"ffprobe", "-v", "quiet",
"-print_format", "json",
"-show_format",
video_path,
]
result_proc = subprocess.run(ffprobe_cmd, capture_output=True, text=True, timeout=30)
if result_proc.returncode == 0:
info = _json.loads(result_proc.stdout)
duration_sec = float(info.get("format", {}).get("duration", 0))
if character_orientation == "video":
if not (3 <= duration_sec <= 30):
raise ValueError(
f"当人物朝向为 'video' 时,"
f"参考视频时长须在 3~30 秒之间,当前为 {duration_sec:.1f}s。"
)
else: # "image"
if not (3 <= duration_sec <= 10):
raise ValueError(
f"当人物朝向为 'image' 时,"
f"参考视频时长须在 3~10 秒之间,当前为 {duration_sec:.1f}s。"
)
except FileNotFoundError:
print("[动作控制] 警告:ffprobe 未找到,跳过视频时长校验。")
except ValueError:
raise
except Exception as e:
print(f"[动作控制] 时长校验异常(已跳过):{e}")
# ── 视频转 base64 ─────────────────────────────────────────────
with open(video_path, "rb") as f:
video_b64 = base64.b64encode(f.read()).decode("utf-8")
# ── 构建请求体(new API 格式)─────────────────────────────────
body = {
"model": model_name,
"prompt": prompt,
"image_url": image_b64,
"video_url": video_b64,
"character_orientation": character_orientation,
"mode": mode_api,
"keep_original_sound": "yes" if keep_original_sound == "打开" else "no",
}
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
_, save_path = tempfile.mkstemp(suffix=".mp4", prefix="kling_motion_")
client = KlingClient()
client.base_url = get_base_url_by_route(kwargs.get("网络线路", "全球加速"))
# ── 进度条 ────────────────────────────────────────────────────
try:
from comfy.utils import ProgressBar
pbar = ProgressBar(100)
except Exception:
pbar = None
def on_stage(stage: str):
if stage == "submitting":
print("[动作控制] 提交中...")
if pbar: pbar.update_absolute(0, 100)
elif stage.startswith("submitted:"):
print(f"[动作控制] 任务已提交 → {stage.split(':',1)[1]}")
if pbar: pbar.update_absolute(5, 100)
elif stage == "downloading":
print("[动作控制] 下载视频...")
if pbar: pbar.update_absolute(99, 100)
elif stage == "done":
print("[动作控制] 完成")
if pbar: pbar.update_absolute(100, 100)
def on_progress(pct: int):
mapped = 5 + int(pct * 0.94)
if pbar: pbar.update_absolute(mapped, 100)
try:
result_path = await client.motion_control_async(
body=body,
save_path=save_path,
on_stage=on_stage,
on_progress=on_progress,
)
return (InputImpl.VideoFromFile(result_path),)
finally:
# 查询余额
try:
_balance_client = GeminiAPIClient()
balance_data = _balance_client.query_balance_sync()
balance_info = _balance_client.format_balance_info(balance_data)
print(f"自研视频模型: {balance_info}")
except Exception:
pass
class AspectRatioPreset:
"""图片宽高比预设节点"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"图像": ("IMAGE",),
"宽高比": (["智能", "16:9", "9:16", "4:3", "3:4", "1:1"], {"default": "智能"}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("图像",)
FUNCTION = "resize"
CATEGORY = "comfyui_o1key/Utils"
def resize(self, 图像, 宽高比):
import torch
from PIL import Image
import numpy as np
pil_images = tensor_to_pil(图像)
img = pil_images[0]
w, h = img.size
img_ratio = w / h
# 确定原图所属的宽高比家族
ratios = {"16:9": 16/9, "9:16": 9/16, "4:3": 4/3, "3:4": 3/4, "1:1": 1.0}
closest_ratio = min(ratios.keys(), key=lambda k: abs(ratios[k] - img_ratio))
# 智能模式:使用最接近的比例
if 宽高比 == "智能":
宽高比 = closest_ratio
# 解析目标比例
target_w, target_h = map(int, 宽高比.split(":"))
target_ratio = target_w / target_h
# 确定分辨率级别(1K/2K
max_dim = max(w, h)
if max_dim <= 1080:
base = 1080
elif max_dim <= 2160:
base = 2160
else:
base = 2160
# 计算目标尺寸
if target_ratio >= 1:
target_width = base
target_height = int(base / target_ratio)
else:
target_height = base
target_width = int(base * target_ratio)
# 判断是否同家族(横向家族:16:9, 4:3;纵向家族:9:16, 3:4;正方形:1:1
horizontal_family = ["16:9", "4:3"]
vertical_family = ["9:16", "3:4"]
same_family = False
if closest_ratio in horizontal_family and 宽高比 in horizontal_family:
same_family = True
elif closest_ratio in vertical_family and 宽高比 in vertical_family:
same_family = True
elif closest_ratio == "1:1" and 宽高比 == "1:1":
same_family = True
# 同家族:直接缩放或裁剪(无白底)
if same_family:
if img_ratio > target_ratio:
# 图像更宽,以高度为准缩放后裁剪
scale = target_height / h
scaled_w = int(w * scale)
scaled_h = target_height
scaled = img.resize((scaled_w, scaled_h), Image.LANCZOS)
left = (scaled_w - target_width) // 2
result = scaled.crop((left, 0, left + target_width, target_height))
else:
# 图像更高,以宽度为准缩放后裁剪
scale = target_width / w
scaled_w = target_width
scaled_h = int(h * scale)
scaled = img.resize((scaled_w, scaled_h), Image.LANCZOS)
top = (scaled_h - target_height) // 2
result = scaled.crop((0, top, target_width, top + target_height))
# 不同家族:保持宽高比 + 白底填充
else:
if img_ratio > target_ratio:
scaled_w = target_width
scaled_h = int(target_width / img_ratio)
else:
scaled_h = target_height
scaled_w = int(target_height * img_ratio)
scaled = img.resize((scaled_w, scaled_h), Image.LANCZOS)
canvas = Image.new("RGB", (target_width, target_height), (255, 255, 255))
paste_x = (target_width - scaled_w) // 2
paste_y = (target_height - scaled_h) // 2
canvas.paste(scaled, (paste_x, paste_y))
result = canvas
# 转回 tensor
arr = np.array(result).astype(np.float32) / 255.0
tensor = torch.from_numpy(arr).unsqueeze(0)
return (tensor,)
NODE_CLASS_MAPPINGS = {
"KlingVideo": KlingVideo,
"KlingFirstLastFrame": KlingFirstLastFrame,
"KlingMotionControlTest": KlingMotionControlTest,
"AspectRatioPreset": AspectRatioPreset,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"KlingVideo": "文/图生视频 自研模型",
"KlingFirstLastFrame": "首尾帧生视频 自研模型",
"KlingMotionControlTest": "动作控制 自研模型",
"AspectRatioPreset": "图片宽高比预设",
}
+136
View File
@@ -0,0 +1,136 @@
"""Load every image in a local folder and emit them one by one."""
from __future__ import annotations
import os
import re
from pathlib import Path
import numpy as np
import torch
from PIL import Image, ImageOps, UnidentifiedImageError
from comfy_api.latest import io
def _resolve_folder(folder_path: str) -> Path:
raw_path = str(folder_path or "").strip().strip('"').strip("'")
if not raw_path:
raise ValueError("加载图像(文件夹):请输入文件夹路径")
expanded = os.path.expandvars(os.path.expanduser(raw_path))
folder = Path(expanded)
if not folder.is_absolute():
folder = Path.cwd() / folder
folder = folder.resolve()
if not folder.exists():
raise ValueError(f"加载图像(文件夹):文件夹不存在:{folder}")
if not folder.is_dir():
raise ValueError(f"加载图像(文件夹):路径不是文件夹:{folder}")
return folder
def _natural_sort_key(path: Path):
"""Sort image2 before image10 while remaining case-insensitive."""
return tuple(
int(part) if part.isdigit() else part.casefold()
for part in re.split(r"(\d+)", path.name)
)
def _list_image_files(folder: Path) -> list[Path]:
# Pillow's registry reflects the formats supported by the current runtime,
# including optional formats supplied by installed Pillow plugins.
Image.init()
supported_extensions = {suffix.casefold() for suffix in Image.registered_extensions()}
image_files = sorted(
(
path
for path in folder.iterdir()
if path.is_file() and path.suffix.casefold() in supported_extensions
),
key=_natural_sort_key,
)
if not image_files:
raise ValueError(f"加载图像(文件夹):文件夹中没有可读取的图片:{folder}")
return image_files
def _load_image_tensor(path: Path) -> torch.Tensor:
try:
with Image.open(path) as opened:
image = ImageOps.exif_transpose(opened)
image.seek(0)
if image.mode == "I":
image = image.point(lambda value: value * (1 / 255))
rgb_image = image.convert("RGB")
array = np.asarray(rgb_image, dtype=np.float32) / 255.0
except (OSError, ValueError, UnidentifiedImageError) as exc:
raise ValueError(f"加载图像(文件夹):无法读取图片 {path.name}{exc}") from exc
# ComfyUI IMAGE tensors use [batch, height, width, channels]. Each list
# item is kept as a separate batch of one so original dimensions survive.
return torch.from_numpy(array).unsqueeze(0)
class LoadImagesFromFolder(io.ComfyNode):
"""Load local images in natural filename order as a ComfyUI output list."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="O1keyLoadImagesFromFolder",
display_name="加载图像(文件夹)",
category="image",
description=(
"读取本地文件夹第一层中的所有图片,按文件名自然顺序逐张输出。"
"每张图片保留原始分辨率,可直接连接普通图像处理节点。"
),
search_aliases=[
"文件夹图片",
"批量加载图片",
"folder images",
"load images from folder",
],
inputs=[
io.String.Input(
"文件夹路径",
default="",
placeholder=r"例如:D:\images",
),
],
outputs=[
io.Image.Output(display_name="图像", is_output_list=True),
],
)
@classmethod
def fingerprint_inputs(cls, 文件夹路径: str):
"""Invalidate ComfyUI's cache when the folder's image set changes."""
try:
folder = _resolve_folder(文件夹路径)
return tuple(
(path.name, path.stat().st_size, path.stat().st_mtime_ns)
for path in _list_image_files(folder)
)
except (OSError, ValueError):
# Execution will provide the user-facing validation error.
return str(文件夹路径 or "")
@classmethod
def execute(cls, 文件夹路径: str) -> io.NodeOutput:
folder = _resolve_folder(文件夹路径)
image_files = _list_image_files(folder)
images = []
for index, path in enumerate(image_files, start=1):
tensor = _load_image_tensor(path)
images.append(tensor)
height, width = tensor.shape[1:3]
print(
f"加载图像(文件夹):{index}/{len(image_files)} "
f"{path.name} ({width}×{height})"
)
print(f"加载图像(文件夹):已从 {folder} 加载 {len(images)} 张图片")
return io.NodeOutput(images)
+579
View File
@@ -0,0 +1,579 @@
"""MiniMax-H3 video generation through a New API gateway."""
import json
import os
import tempfile
from typing import Optional
import folder_paths
from comfy_api.latest import InputImpl, io
from ..clients.minimax_h3_client import MiniMaxH3Client
from ..utils.config import get_base_url_by_route
from ..utils.image_utils import tensor_to_pil
from ..utils.minimax_h3_media import (
MAX_REFERENCE_AUDIOS,
MAX_REFERENCE_IMAGES,
MAX_REFERENCE_VIDEOS,
validate_image,
validate_reference_audios,
validate_reference_videos,
)
from ..utils.r2_uploader import upload_audio, upload_image, upload_video
MODEL_ID = "MiniMax-H3"
MODEL_MAX_ID = "MiniMax-H3-MAX"
MODEL_OPTIONS = [MODEL_ID, MODEL_MAX_ID]
MODE_TEXT = "文生视频"
MODE_FIRST = "首帧图生视频"
MODE_LAST = "尾帧图生视频"
MODE_FIRST_LAST = "首尾帧生视频"
MODE_REFERENCE = "参考素材生视频"
RESOLUTION_OPTIONS = ["2K", "768P", "480P"]
MODEL_RESOLUTIONS = {
MODEL_ID: {"768P", "2K"},
MODEL_MAX_ID: {"480P", "768P"},
}
MODEL_DURATION_RANGES = {
MODEL_ID: (4, 15),
MODEL_MAX_ID: (5, 15),
}
RATIO_OPTIONS = ["16:9", "21:9", "4:3", "1:1", "3:4", "9:16"]
REFERENCE_RATIO_OPTIONS = ["adaptive", *RATIO_OPTIONS]
MAX_PROMPT_CHARS = 7000
MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024
MAX_REFERENCE_MATERIALS = 12
MAX_SEED = 2**31 - 1
def _validate_prompt(prompt: str) -> str:
prompt = (prompt or "").strip()
if not prompt:
raise ValueError("MiniMax H3 提示词不能为空。")
if len(prompt) > MAX_PROMPT_CHARS:
raise ValueError(
f"MiniMax H3 提示词最多 {MAX_PROMPT_CHARS} 字符,当前为 {len(prompt)} 字符。"
)
return prompt
def _validate_seed(seed: int) -> int:
if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed <= MAX_SEED:
raise ValueError(f"MiniMax H3 seed 必须是 0{MAX_SEED} 的整数。")
return seed
def _validate_generation_options(
model: str,
resolution: str,
duration: int,
mode: Optional[str] = None,
) -> None:
if model not in MODEL_OPTIONS:
raise ValueError(f"不支持的 MiniMax 模型:{model}")
allowed_resolutions = MODEL_RESOLUTIONS[model]
if resolution not in allowed_resolutions:
allowed_text = "".join(
value for value in RESOLUTION_OPTIONS if value in allowed_resolutions
)
raise ValueError(f"{model} 分辨率仅支持 {allowed_text}")
minimum_duration, maximum_duration = MODEL_DURATION_RANGES[model]
if (
isinstance(duration, bool)
or not isinstance(duration, int)
or not minimum_duration <= duration <= maximum_duration
):
raise ValueError(
f"{model} 时长必须是 {minimum_duration}{maximum_duration} 的整数。"
)
if model == MODEL_MAX_ID and mode == MODE_REFERENCE:
raise ValueError("MiniMax-H3-MAX 不支持图片、视频或音频参考素材模式。")
def _validate_reference_counts(
reference_images,
reference_videos,
reference_audios,
) -> None:
if len(reference_images) > MAX_REFERENCE_IMAGES:
raise ValueError(f"参考图片最多 {MAX_REFERENCE_IMAGES} 张。")
if len(reference_videos) > MAX_REFERENCE_VIDEOS:
raise ValueError(f"参考视频最多 {MAX_REFERENCE_VIDEOS} 个。")
if len(reference_audios) > MAX_REFERENCE_AUDIOS:
raise ValueError(f"参考音频最多 {MAX_REFERENCE_AUDIOS} 个。")
total = len(reference_images) + len(reference_videos) + len(reference_audios)
if total > MAX_REFERENCE_MATERIALS:
raise ValueError(
f"参考图片、视频和音频合计最多 {MAX_REFERENCE_MATERIALS} 个,当前为 {total} 个。"
)
def _validate_image_tensor(image, label: str):
if image is None:
raise ValueError(f"{label}不能为空。")
ndim = getattr(image, "dim", lambda: None)()
if ndim == 4 and int(image.shape[0]) != 1:
raise ValueError(f"{label}仅支持单张图片,当前批次包含 {int(image.shape[0])} 张。")
def _image_to_pil(image, label: str):
_validate_image_tensor(image, label)
images = tensor_to_pil(image)
if not images:
raise ValueError(f"无法读取{label}")
result = images[0]
if result.mode not in ("RGB", "RGBA"):
result = result.convert("RGB")
validate_image(result, label)
return result
def _validate_body_size(body: dict) -> None:
body_size = len(
json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
)
if body_size > MAX_REQUEST_BODY_BYTES:
raise ValueError(
f"MiniMax H3 请求体大小 {body_size / 1024 / 1024:.2f} MB 超过 64 MB 限制。"
)
def build_request_body(
*,
prompt: str,
resolution: str,
duration: int,
mode: str,
model: str = MODEL_ID,
seed: int = 0,
ratio: Optional[str] = None,
first_url: Optional[str] = None,
last_url: Optional[str] = None,
reference_image_urls: Optional[list[str]] = None,
reference_video_urls: Optional[list[str]] = None,
reference_audio_urls: Optional[list[str]] = None,
) -> dict:
"""Build and validate the documented MiniMax H3 / H3 Max request body."""
prompt = _validate_prompt(prompt)
seed = _validate_seed(seed)
_validate_generation_options(model, resolution, duration, mode)
reference_image_urls = [url for url in (reference_image_urls or []) if url]
reference_video_urls = [url for url in (reference_video_urls or []) if url]
reference_audio_urls = [url for url in (reference_audio_urls or []) if url]
_validate_reference_counts(
reference_image_urls,
reference_video_urls,
reference_audio_urls,
)
has_first_last_material = bool(first_url or last_url)
has_reference_material = bool(
reference_image_urls or reference_video_urls or reference_audio_urls
)
content = [{"type": "text", "text": prompt}]
if mode == MODE_TEXT:
if has_first_last_material or has_reference_material:
raise ValueError("文生视频不能同时使用首尾帧或参考素材。")
if ratio not in RATIO_OPTIONS:
raise ValueError("MiniMax H3 文生视频必须选择具体宽高比,不能使用 adaptive。")
request_ratio = ratio
elif mode == MODE_FIRST:
if last_url or has_reference_material:
raise ValueError("首帧图生视频不能同时使用尾帧或参考素材。")
if not first_url:
raise ValueError("首帧图生视频必须连接首帧图片。")
content.append({
"type": "image_url",
"image_url": {"url": first_url},
"role": "first_frame",
})
request_ratio = "adaptive"
elif mode == MODE_LAST:
if first_url or has_reference_material:
raise ValueError("尾帧图生视频不能同时使用首帧或参考素材。")
if not last_url:
raise ValueError("尾帧图生视频必须连接尾帧图片。")
content.append({
"type": "image_url",
"image_url": {"url": last_url},
"role": "last_frame",
})
request_ratio = "adaptive"
elif mode == MODE_FIRST_LAST:
if has_reference_material:
raise ValueError("首尾帧模式和参考素材模式不能混用。")
if not first_url or not last_url:
raise ValueError("首尾帧生视频必须同时连接首帧和尾帧图片。")
content.extend([
{
"type": "image_url",
"image_url": {"url": first_url},
"role": "first_frame",
},
{
"type": "image_url",
"image_url": {"url": last_url},
"role": "last_frame",
},
])
request_ratio = "adaptive"
elif mode == MODE_REFERENCE:
if has_first_last_material:
raise ValueError("参考素材模式和首尾帧模式不能混用。")
if not has_reference_material:
raise ValueError("参考素材生视频至少需要一张图片、一个视频或一段音频。")
content.extend(
{
"type": "image_url",
"image_url": {"url": url},
"role": "reference_image",
}
for url in reference_image_urls
)
content.extend(
{
"type": "video_url",
"video_url": {"url": url},
"role": "reference_video",
}
for url in reference_video_urls
)
content.extend(
{
"type": "audio_url",
"audio_url": {"url": url},
"role": "reference_audio",
}
for url in reference_audio_urls
)
request_ratio = ratio or "adaptive"
if request_ratio not in REFERENCE_RATIO_OPTIONS:
raise ValueError(f"MiniMax H3 参考素材模式不支持宽高比:{request_ratio}")
else:
raise ValueError(f"不支持的 MiniMax H3 生成模式:{mode}")
body = {
"model": model,
"content": content,
"resolution": resolution,
"duration": duration,
"ratio": request_ratio,
"seed": seed,
}
_validate_body_size(body)
return body
def _build_mode_input():
reference_images = io.Autogrow.Input(
"参考图片组",
template=io.Autogrow.TemplateNames(
input=io.Image.Input("参考图片"),
names=[f"参考图片{i}" for i in range(1, MAX_REFERENCE_IMAGES + 1)],
min=0,
),
tooltip="可动态连接,最多 9 张。",
)
reference_videos = io.Autogrow.Input(
"参考视频组",
template=io.Autogrow.TemplateNames(
input=io.Video.Input("参考视频"),
names=[f"参考视频{i}" for i in range(1, MAX_REFERENCE_VIDEOS + 1)],
min=0,
),
tooltip="可动态连接,最多 3 个;每段 2~15 秒,总时长不超过 15 秒。",
)
reference_audios = io.Autogrow.Input(
"参考音频组",
template=io.Autogrow.TemplateNames(
input=io.Audio.Input("参考音频"),
names=[f"参考音频{i}" for i in range(1, MAX_REFERENCE_AUDIOS + 1)],
min=0,
),
tooltip="可动态连接,最多 3 段;每段 2~15 秒,总时长不超过 15 秒。",
)
return io.DynamicCombo.Input(
"生成模式",
options=[
io.DynamicCombo.Option(
MODE_TEXT,
[
io.Combo.Input(
"宽高比",
options=RATIO_OPTIONS,
default="16:9",
tooltip="文生视频必须使用具体比例,不能使用 adaptive。",
)
],
),
io.DynamicCombo.Option(
MODE_FIRST,
[io.Image.Input("首帧图片", tooltip="输入图片决定视频比例。")],
),
io.DynamicCombo.Option(
MODE_LAST,
[io.Image.Input("尾帧图片", tooltip="输入图片决定视频比例。")],
),
io.DynamicCombo.Option(
MODE_FIRST_LAST,
[
io.Image.Input("首帧图片"),
io.Image.Input("尾帧图片"),
],
),
io.DynamicCombo.Option(
MODE_REFERENCE,
[
reference_images,
reference_videos,
reference_audios,
io.Combo.Input(
"宽高比",
options=REFERENCE_RATIO_OPTIONS,
default="adaptive",
tooltip="参考素材模式默认 adaptive,也可指定具体比例。",
),
],
),
],
tooltip="首尾帧模式与参考素材模式互斥。",
)
def _collect_autogrow(value) -> list:
"""Collect connected values while tolerating a legacy single input value."""
if value is None:
return []
if isinstance(value, dict):
return [item for item in value.values() if item is not None]
return [value]
def _make_progress_callbacks():
try:
from comfy.utils import ProgressBar
pbar = ProgressBar(100)
except Exception:
pbar = None
last_progress = -1
def set_progress(value: int):
nonlocal last_progress
value = max(0, min(100, int(value)))
if value <= last_progress:
return
last_progress = value
if pbar:
pbar.update_absolute(value, 100)
def on_stage(stage: str):
if stage == "submitting":
print("[MiniMax H3] 正在创建任务...")
set_progress(0)
elif stage.startswith("submitted:"):
print(f"[MiniMax H3] 任务已进入队列:{stage.split(':', 1)[1]}")
elif stage == "downloading":
print("[MiniMax H3] 生成成功,正在立即下载临时 CDN 视频...")
elif stage == "done":
set_progress(100)
def on_progress(progress: int):
# New API returns the authoritative task percentage in data.progress.
# Mirror it directly in ComfyUI while preventing stale poll responses
# from moving the node progress bar backwards.
set_progress(progress)
return on_stage, on_progress
class MiniMaxH3Video(io.ComfyNode):
"""MiniMax H3 / H3 Max text, frame, and reference video generation."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="MiniMaxH3Video",
display_name="MiniMax H3 / H3 Max 视频生成",
category="comfyui_o1key/Video",
description=(
"通过 New API 网关调用 MiniMax-H3 或 MiniMax-H3-MAXH3 支持多模态参考,H3 Max 支持文生和首尾帧模式。"
),
inputs=[
io.String.Input(
"提示词",
multiline=True,
default="",
placeholder="描述画面、动作、镜头与声音...",
tooltip="必填,最多 7000 字符。",
),
_build_mode_input(),
io.Combo.Input(
"分辨率",
options=RESOLUTION_OPTIONS,
default="2K",
),
io.Int.Input(
"时长",
default=5,
min=4,
max=15,
step=1,
display_mode=io.NumberDisplay.slider,
tooltip="H3 支持 415 秒;H3 Max 支持 515 秒。",
),
io.Combo.Input(
"模型",
options=MODEL_OPTIONS,
default=MODEL_ID,
tooltip="H3 支持 768P/2K 和多模态参考;H3 Max 支持 480P/768P,不支持参考素材模式。",
),
io.Int.Input(
"seed",
default=0,
min=0,
max=MAX_SEED,
step=1,
display_mode=io.NumberDisplay.number,
control_after_generate=io.ControlAfterGenerate.randomize,
tooltip="原生随机种子;相同参数与 seed 可用于复现结果。",
),
],
outputs=[io.Video.Output(display_name="视频")],
not_idempotent=True,
search_aliases=["MiniMax H3", "MiniMax H3 Max", "海螺视频", "H3 视频"],
accept_all_inputs=True,
)
@classmethod
async def execute(
cls,
提示词,
生成模式,
分辨率,
时长,
模型=MODEL_ID,
seed=0,
**_kwargs,
) -> io.NodeOutput:
prompt = _validate_prompt(提示词)
if not isinstance(生成模式, dict):
raise ValueError("MiniMax H3 生成模式参数无效。")
mode = 生成模式.get("生成模式")
_validate_generation_options(模型, 分辨率, int(时长), mode)
base_url = get_base_url_by_route()
first_url = None
last_url = None
reference_image_urls = []
reference_video_urls = []
reference_audio_urls = []
if mode == MODE_FIRST:
first_image = 生成模式.get("首帧图片")
first_url = await upload_image(
_image_to_pil(first_image, "首帧图片"),
base_url=base_url,
)
elif mode == MODE_LAST:
last_image = 生成模式.get("尾帧图片")
last_url = await upload_image(
_image_to_pil(last_image, "尾帧图片"),
base_url=base_url,
)
elif mode == MODE_FIRST_LAST:
first_image = 生成模式.get("首帧图片")
last_image = 生成模式.get("尾帧图片")
first_url = await upload_image(
_image_to_pil(first_image, "首帧图片"),
base_url=base_url,
)
last_url = await upload_image(
_image_to_pil(last_image, "尾帧图片"),
base_url=base_url,
)
elif mode == MODE_REFERENCE:
reference_images = _collect_autogrow(
生成模式.get("参考图片组", 生成模式.get("参考图片"))
)
reference_videos = _collect_autogrow(
生成模式.get("参考视频组", 生成模式.get("参考视频"))
)
reference_audios = _collect_autogrow(
生成模式.get("参考音频组", 生成模式.get("参考音频"))
)
_validate_reference_counts(
reference_images,
reference_videos,
reference_audios,
)
if not reference_images and not reference_videos and not reference_audios:
raise ValueError("参考素材生视频至少需要连接一种参考素材。")
reference_pil_images = [
_image_to_pil(image, f"参考图片{index}")
for index, image in enumerate(reference_images, start=1)
]
validate_reference_videos(reference_videos)
validate_reference_audios(reference_audios)
for image in reference_pil_images:
reference_image_urls.append(await upload_image(image, base_url=base_url))
for video in reference_videos:
reference_video_urls.append(await upload_video(video, base_url=base_url))
for audio in reference_audios:
reference_audio_urls.append(await upload_audio(audio, base_url=base_url))
body = build_request_body(
prompt=prompt,
resolution=分辨率,
duration=int(时长),
mode=mode,
model=模型,
seed=int(seed),
ratio=生成模式.get("宽高比"),
first_url=first_url,
last_url=last_url,
reference_image_urls=reference_image_urls,
reference_video_urls=reference_video_urls,
reference_audio_urls=reference_audio_urls,
)
temp_dir = folder_paths.get_temp_directory()
os.makedirs(temp_dir, exist_ok=True)
fd, save_path = tempfile.mkstemp(
suffix=".mp4",
prefix="minimax_h3_",
dir=temp_dir,
)
os.close(fd)
on_stage, on_progress = _make_progress_callbacks()
client = MiniMaxH3Client(base_url=base_url)
try:
result_path, _task_id = await client.generate_async(
body=body,
save_path=save_path,
on_stage=on_stage,
on_progress=on_progress,
)
return io.NodeOutput(InputImpl.VideoFromFile(result_path))
except BaseException:
try:
if os.path.isfile(save_path):
os.remove(save_path)
except OSError:
pass
raise
NODE_CLASS_MAPPINGS = {"MiniMaxH3Video": MiniMaxH3Video}
NODE_DISPLAY_NAME_MAPPINGS = {"MiniMaxH3Video": "MiniMax H3 / H3 Max 视频生成"}
+275 -128
View File
@@ -8,9 +8,8 @@ import time
import math
import random
import asyncio
import aiohttp
from concurrent.futures import ThreadPoolExecutor
from typing import Callable, List, Optional
from typing import Any, Callable, List, Optional
import torch
import numpy as np
@@ -20,12 +19,21 @@ from comfy_api.latest import io
from ..utils.image_utils import tensor_to_pil, pil_to_tensor, parse_batch_prompts
from ..utils.config import (
NETWORK_ROUTE_OPTIONS,
get_base_url_by_route,
get_api_key_or_raise,
get_runtime_config_signature,
)
from ..utils.nano_banana_async import generate_nano_banana_async
from ..utils.nano_banana_async import (
generate_nano_banana_async,
VERBOSE_LOG_ENABLED,
)
from ..utils.http2_client import create_http_client
from ..clients.gemini_client import GeminiAPIClient
from ..utils.nano_banana_models import (
NANO_BANANA_MODEL_OPTIONS,
NANO_BANANA_ROUTE_OPTIONS,
resolve_nano_banana_model,
)
try:
from comfy.utils import ProgressBar
@@ -41,19 +49,28 @@ except ImportError:
InterruptProcessingException = RuntimeError
processing_interrupted = lambda: False
REQUEST_LOG_ENABLED = False
# 完整原始报文仅在显式开启详细日志时打印。
REQUEST_LOG_ENABLED = VERBOSE_LOG_ENABLED
_NODE = "Nano Banana"
_REQUEST_TIMEOUT = 900
_INTERRUPT_CHECK_INTERVAL = 0.2
MAX_REFERENCE_IMAGES = 14
_MAX_GENERATION_CONCURRENCY = 12
_MAX_DOWNLOAD_CONCURRENCY = 6
_HTTP_MAX_CONNECTIONS = 32
_HTTP_MAX_KEEPALIVE_CONNECTIONS = 16
_client_instance = None
_client_config_signature = None
def _get_client():
global _client_instance
if _client_instance is None:
global _client_instance, _client_config_signature
config_signature = get_runtime_config_signature()
if _client_instance is None or config_signature != _client_config_signature:
_client_instance = GeminiAPIClient()
_client_config_signature = config_signature
return _client_instance
@@ -132,47 +149,29 @@ def _images_to_tensor_safe(images: List[Image.Image], node_label: str) -> torch.
return pil_to_tensor(matched)
MODEL_ID_MAP = {
"Nano Banana Pro": "nano-banana-pro",
"Nano Banana 2": "nano-banana-2",
"Nano Banana": "nano-banana",
def _collect_autogrow_inputs(value) -> list:
"""Collect connected Autogrow slots while tolerating a single legacy value."""
if value is None:
return []
if isinstance(value, dict):
return [item for item in value.values() if item is not None]
return [value]
THINKING_LEVEL_MAP = {
"": "minimal",
"": "high",
}
RESOLUTION_KEY_MAP = {
"512px": "0.5k",
"1K": "1k",
"2K": "2k",
"4K": "4k",
}
BILLING_SPECIAL_ONLY = {"nano-banana"}
def _build_model_id(model_name: str, resolution: str, billing: str) -> str:
base = MODEL_ID_MAP.get(model_name, "nano-banana-pro")
if base == "nano-banana":
if billing == "官方":
raise ValueError(f"模型 \"{model_name}\" 仅支持特价计费")
return "nano-banana"
res_key = RESOLUTION_KEY_MAP.get(resolution, "2k")
is_official = (billing == "官方")
if base == "nano-banana-pro" and res_key == "1k" and not is_official:
return "nano-banana-pro"
if base == "nano-banana-2" and res_key == "0.5k":
if is_official:
raise ValueError("Nano Banana 2 的 512px 分辨率仅支持特价计费")
return "nano-banana-2-0.5k"
model_id = f"{base}-{res_key}"
if is_official:
model_id += "-official"
return model_id
def _build_model_id(model_name: str, resolution: str, route: str) -> str:
"""兼容旧调用签名;新模型名只由主模型和线路决定。"""
del resolution
return resolve_nano_banana_model(model_name, route)
async def _generate_single(
session: aiohttp.ClientSession,
session: Any,
base_url: str,
api_key: str,
prompt: str,
@@ -180,10 +179,17 @@ async def _generate_single(
resolution: str,
aspect_ratio: str,
images: Optional[List[Image.Image]] = None,
enable_grounding: bool = False,
image_urls: Optional[List[str]] = None,
thinking_level: Optional[str] = None,
progress_callback: Optional[Callable[[float], None]] = None,
) -> List[Image.Image]:
node_label: str = "Nano Banana",
result_url_callback: Optional[Callable[[str], None]] = None,
log_task_success: bool = True,
upload_cache: Optional[dict] = None,
download_semaphore: Optional[asyncio.Semaphore] = None,
resize_mode: str = "不缩放",
google_search: bool = False,
) -> tuple[List[Image.Image], dict]:
result_images, timing = await generate_nano_banana_async(
session=session,
base_url=base_url,
@@ -193,18 +199,24 @@ async def _generate_single(
resolution=resolution,
aspect_ratio=aspect_ratio,
images=images,
enable_grounding=enable_grounding,
image_urls=image_urls,
upload_cache=upload_cache,
download_semaphore=download_semaphore,
thinking_level=thinking_level,
node_label="Nano Banana",
google_search=google_search,
node_label=node_label,
request_log_enabled=REQUEST_LOG_ENABLED,
check_interrupt=_check_interrupt,
progress_callback=progress_callback,
result_url_callback=result_url_callback,
log_task_success=log_task_success,
resize_mode=resize_mode,
)
return result_images, timing["task_ms"], timing["parse_ms"]
return result_images, timing
async def _generate_single_task(
session: aiohttp.ClientSession,
session: Any,
base_url: str,
api_key: str,
prompt: str,
@@ -212,10 +224,14 @@ async def _generate_single_task(
resolution: str,
aspect_ratio: str,
images: Optional[List[Image.Image]],
image_urls: Optional[List[str]],
global_task_index: int,
enable_grounding: bool = False,
thinking_level: Optional[str] = None,
progress_callback: Optional[Callable[[float], None]] = None,
upload_cache: Optional[dict] = None,
download_semaphore: Optional[asyncio.Semaphore] = None,
resize_mode: str = "不缩放",
google_search: bool = False,
) -> dict:
result = {
"global_task_index": global_task_index,
@@ -225,8 +241,9 @@ async def _generate_single_task(
"output_images": [],
"error": None,
}
task_started = time.time()
try:
gen_images, task_ms, parse_ms = await _generate_single(
gen_images, timing = await _generate_single(
session=session,
base_url=base_url,
api_key=api_key,
@@ -235,14 +252,22 @@ async def _generate_single_task(
resolution=resolution,
aspect_ratio=aspect_ratio,
images=images if images else None,
enable_grounding=enable_grounding,
image_urls=image_urls if image_urls else None,
thinking_level=thinking_level,
google_search=google_search,
progress_callback=progress_callback,
node_label=f"Nano Banana#{global_task_index + 1}",
upload_cache=upload_cache,
download_semaphore=download_semaphore,
resize_mode=resize_mode,
)
del task_ms, parse_ms
result["output_images"] = gen_images
result["success"] = True
result["generated_count"] = len(gen_images)
print(
f"Nano Banana#{global_task_index + 1}: 完成 ✓ | "
f"生成={len(gen_images)} 张 | 耗时={time.time() - task_started:.1f}s"
)
except InterruptProcessingException:
raise
except Exception as e:
@@ -260,8 +285,10 @@ async def _process_batch_async(
images_per_prompt: int,
input_images: Optional[List[Image.Image]],
pbar=None,
enable_grounding: bool = False,
thinking_level: Optional[str] = None,
resize_mode: str = "不缩放",
unlimited_downloads: bool = False,
google_search: bool = False,
) -> List[dict]:
tasks_def = []
for p_idx, prompt in enumerate(prompts):
@@ -269,7 +296,7 @@ async def _process_batch_async(
tasks_def.append((p_idx, sub_idx, prompt))
total_tasks = len(tasks_def)
max_concurrent = 50
max_concurrent = _MAX_GENERATION_CONCURRENCY
num_batches = math.ceil(total_tasks / max_concurrent)
all_results = []
@@ -277,9 +304,18 @@ async def _process_batch_async(
success_count = 0
fail_count = 0
connector = aiohttp.TCPConnector(ssl=False, limit=0, limit_per_host=0)
upload_cache = {}
download_semaphore = (
None
if unlimited_downloads
else asyncio.Semaphore(_MAX_DOWNLOAD_CONCURRENCY)
)
async with aiohttp.ClientSession(connector=connector) as session:
async with create_http_client(
http2=True,
max_connections=_HTTP_MAX_CONNECTIONS,
max_keepalive_connections=_HTTP_MAX_KEEPALIVE_CONNECTIONS,
) as session:
for batch_idx in range(num_batches):
_check_interrupt()
start_idx = batch_idx * max_concurrent
@@ -299,10 +335,14 @@ async def _process_batch_async(
resolution=resolution,
aspect_ratio=aspect_ratio,
images=input_images,
image_urls=None,
global_task_index=i,
enable_grounding=enable_grounding,
thinking_level=thinking_level,
google_search=google_search,
progress_callback=_make_progress_callback(pbar),
upload_cache=upload_cache,
download_semaphore=download_semaphore,
resize_mode=resize_mode,
)
)
tasks.append(task)
@@ -327,18 +367,23 @@ async def _process_batch_async(
batch_results.append(result_data)
completed += 1
prompt_snippet = (result_data.get("prompt", "") or "")[:30]
task_num = result_data.get("global_task_index", "?")
if isinstance(task_num, int):
task_num += 1
if result_data and result_data.get("success", False):
success_count += 1
count = result_data.get("generated_count", 1)
print(f"Nano Banana: [{completed}/{total_tasks}] {prompt_snippet}{'...' if len(prompt_snippet) >= 30 else ''} → ✓成功({count}张)")
else:
fail_count += 1
error_msg = result_data.get("error", "未知错误") if result_data else "未知错误"
print(f"Nano Banana: [{completed}/{total_tasks}] {prompt_snippet}{'...' if len(prompt_snippet) >= 30 else ''} → ✗失败: {error_msg}")
print(f"Nano Banana#{task_num}: 失败 | error={error_msg}")
all_results.extend(batch_results)
print(
f"Nano Banana: 批次 {batch_idx + 1}/{num_batches} 完成 "
f"| 成功={success_count} | 失败={fail_count} "
f"| 总进度={completed}/{total_tasks}"
)
import gc; gc.collect()
await asyncio.sleep(0.1)
@@ -347,8 +392,62 @@ async def _process_batch_async(
class NanoBanana(io.ComfyNode):
@staticmethod
def _validate_model_config(model_name: str, aspect_ratio: str, resolution: str):
"""验证模型配置是否合法"""
# 验证模型名称
valid_models = set(NANO_BANANA_MODEL_OPTIONS)
if model_name not in valid_models:
raise ValueError(
f"模型 '{model_name}' 无效,支持的模型:{', '.join(sorted(valid_models))}"
)
# 验证宽高比
valid_aspect_ratios = {
"智能", "1:1", "1:4", "1:8", "2:3", "3:2", "3:4",
"4:1", "4:3", "4:5", "5:4", "8:1",
"9:16", "16:9", "21:9",
}
if aspect_ratio not in valid_aspect_ratios:
raise ValueError(
f"宽高比 '{aspect_ratio}' 无效,支持的宽高比:{', '.join(sorted(valid_aspect_ratios))}"
)
# 验证分辨率
# “智能”仅由统一生图节点传入;独立节点仍保持原有下拉选项。
valid_resolutions = {"智能", "1K", "2K", "4K"}
if resolution not in valid_resolutions:
raise ValueError(
f"分辨率 '{resolution}' 无效,支持的分辨率:{', '.join(sorted(valid_resolutions))}"
)
# Nano Banana 2 系列特有的宽高比
nano_2_exclusive_ratios = {"1:4", "1:8", "4:1", "8:1"}
# 其他模型使用了 Nano Banana 2 系列专属宽高比
if model_name not in {"Nano Banana 2", "Nano Banana 2 Lite"} and aspect_ratio in nano_2_exclusive_ratios:
raise ValueError(
f"宽高比 {aspect_ratio} 仅支持 Nano Banana 2 系列模型,"
f"当前模型 {model_name} 不支持此宽高比"
)
# Nano Banana 只支持 1K
if model_name == "Nano Banana" and resolution not in {"智能", "1K"}:
raise ValueError(
f"Nano Banana 模型仅支持 1K 分辨率,"
f"当前选择的 {resolution} 不支持"
)
@classmethod
def define_schema(cls):
reference_images = io.Autogrow.Input(
"参考图组",
template=io.Autogrow.TemplateNames(
input=io.Image.Input("参考图"),
names=[f"参考图{i}" for i in range(1, MAX_REFERENCE_IMAGES + 1)],
min=0,
),
tooltip=f"连接后自动增加输入端口,合计最多 {MAX_REFERENCE_IMAGES} 张参考图。",
)
return io.Schema(
node_id="NanoBanana",
display_name="Nano Banana",
@@ -356,74 +455,97 @@ class NanoBanana(io.ComfyNode):
inputs=[
io.String.Input(
"prompt",
default="一个中国女子的OOTD",
default="",
multiline=True,
),
io.DynamicCombo.Input("模型", options=[
io.DynamicCombo.Option("Nano Banana Pro", [
io.Combo.Input("宽高比", options=[
"智能", "1:1", "2:3", "3:2", "3:4", "4:3",
"4:5", "5:4", "9:16", "16:9", "21:9",
], default="智能"),
io.Combo.Input("模型", options=NANO_BANANA_MODEL_OPTIONS, default="Nano Banana 2"),
io.Combo.Input(
"模型线路",
options=NANO_BANANA_ROUTE_OPTIONS,
default="畅速",
),
io.Combo.Input(
"思考等级",
options=["", ""],
default="",
tooltip="仅 Nano Banana 2 生效:低=minimal,高=high。",
),
io.Combo.Input("分辨率", options=["1K", "2K", "4K"], default="2K"),
]),
io.DynamicCombo.Option("Nano Banana 2", [
io.Combo.Input("宽高比", options=[
"智能", "1:1", "1:4", "1:8", "2:3", "3:2", "3:4",
"4:1", "4:3", "4:5", "5:4", "8:1",
"9:16", "16:9", "21:9",
], default="智能"),
io.Combo.Input("分辨率", options=["512px", "1K", "2K", "4K"], default="2K"),
io.Combo.Input("思考深度", options=["", ""], default=""),
]),
io.DynamicCombo.Option("Nano Banana", [
io.Combo.Input("宽高比", options=[
"智能", "1:1", "2:3", "3:2", "3:4", "4:3",
"4:5", "5:4", "9:16", "16:9", "21:9",
], default="智能"),
io.Combo.Input("分辨率", options=["1K"], default="1K"),
]),
]),
io.Int.Input("生图数量", default=1, min=1, max=1000, step=1),
io.Combo.Input("网络", options=NETWORK_ROUTE_OPTIONS, default="全球加速"),
io.Combo.Input("计费", options=["特价", "官方"], default="特价"),
io.Combo.Input("谷歌搜索", options=["关闭", "打开"], default="关闭"),
io.Int.Input("seed", default=0, min=0, max=0xFFFFFFFFFFFFFFFF),
io.Image.Input("参考图1", optional=True),
io.Image.Input("参考图2", optional=True),
io.Image.Input("参考图3", optional=True),
io.Image.Input("参考图4", optional=True),
io.Image.Input("参考图5", optional=True),
io.Image.Input("参考图6", optional=True),
io.Image.Input("参考图7", optional=True),
io.Image.Input("参考图8", optional=True),
io.Image.Input("参考图9", optional=True),
io.Combo.Input(
"生图数量",
options=["1", "2", "4", "9"],
default="1",
tooltip="选择本次生成的图像数量。",
),
io.Int.Input(
"seed",
default=0,
min=0,
max=0xFFFFFFFFFFFFFFFF,
),
reference_images,
io.Combo.Input(
"缩放图片",
options=["不缩放", "智能缩放"],
default="不缩放",
tooltip="请求体超过 18 MiB 时,智能缩放会等比缩小占用最大的参考图",
),
],
outputs=[
io.Image.Output(display_name="输出图像"),
],
# Accept the former 参考图1~参考图9 keys when executing workflows
# saved before the Autogrow migration.
accept_all_inputs=True,
)
@classmethod
def execute(cls, prompt, 模型, 生图数量, 计费, 网络, 谷歌搜索, seed, **kwargs) -> io.NodeOutput:
def execute(
cls,
prompt,
模型,
分辨率,
宽高比,
生图数量,
模型线路="畅速",
seed=0,
思考等级="",
缩放图片="不缩放",
**kwargs,
) -> io.NodeOutput:
start_time = time.time()
was_interrupted = False
生图数量 = int(生图数量)
model_name = 模型["模型"]
宽高比 = 模型["宽高比"]
分辨率 = 模型["分辨率"]
思考深度 = 模型.get("思考深度")
model_name = 模型
# 兼容旧工作流/外部调用传入的“计费”字段。
模型线路 = kwargs.pop("计费", 模型线路)
unlimited_downloads = kwargs.pop("_o1key_unlimited_downloads", False) is True
requested_google_search = kwargs.pop("_o1key_google_search", False) is True
google_search = model_name == "Nano Banana 2" and requested_google_search
resize_mode = str(缩放图片)
if resize_mode not in {"不缩放", "智能缩放"}:
raise ValueError("缩放图片参数无效")
# 验证模型和宽高比、分辨率的组合是否合法
cls._validate_model_config(model_name, 宽高比, 分辨率)
enable_grounding = (谷歌搜索 == "打开")
if 思考等级 not in THINKING_LEVEL_MAP:
raise ValueError("思考等级无效,仅支持:低、高")
thinking_level = (
THINKING_LEVEL_MAP[思考等级]
if model_name == "Nano Banana 2"
else None
)
thinking_level = None
if model_name == "Nano Banana 2" and 思考深度:
thinking_level = "High" if 思考深度 == "" else "Low"
actual_model = _build_model_id(model_name, 分辨率, 计费)
actual_model = _build_model_id(model_name, 分辨率, 模型线路)
api_key = get_api_key_or_raise("O1KEY_API_KEY")
base_url = get_base_url_by_route(网络)
base_url = get_base_url_by_route()
pbar = ProgressBar(生图数量) if PROGRESS_BAR_AVAILABLE else None
@@ -431,30 +553,37 @@ class NanoBanana(io.ComfyNode):
random.seed(seed)
np.random.seed(seed % (2**32))
input_images = []
for i in range(1, 10):
key = f"参考图{i}"
if key in kwargs and kwargs[key] is not None:
pil_imgs = tensor_to_pil(kwargs[key])
input_images.extend(pil_imgs)
reference_inputs = _collect_autogrow_inputs(kwargs.get("参考图组"))
if not reference_inputs:
# Keep execution compatibility with workflows created before
# the Autogrow input replaced the nine fixed image sockets.
reference_inputs = [
kwargs[f"参考图{i}"]
for i in range(1, 10)
if kwargs.get(f"参考图{i}") is not None
]
if len(input_images) > 14:
raise ValueError(f"输入图像数量 {len(input_images)} 超过限制 14 张")
input_images = []
for image_input in reference_inputs:
input_images.extend(tensor_to_pil(image_input))
if len(input_images) > MAX_REFERENCE_IMAGES:
raise ValueError(
f"输入图像数量 {len(input_images)} 超过限制 {MAX_REFERENCE_IMAGES}"
)
batch_prompts = parse_batch_prompts(prompt)
grounding_str = " | 谷歌搜索接地" if enable_grounding else ""
thinking_str = f" | 思考:{thinking_level}" if thinking_level else ""
if batch_prompts:
num_prompts = len(batch_prompts)
total_images = num_prompts * 生图数量
mode_str = f"批量提示词模式 ({num_prompts}个提示词)"
if input_images:
mode_str += f" (输入{len(input_images)}张)"
print(f"Nano Banana: {mode_str} | {分辨率} {宽高比} | 共{total_images}{grounding_str}{thinking_str}")
print(f"Nano Banana: {mode_str} | {分辨率} {宽高比} | 共{total_images}")
else:
mode_str = f"图生图模式 (输入{len(input_images)}张)" if input_images else "文生图模式"
print(f"Nano Banana: {mode_str} | {分辨率} {宽高比} | {生图数量}{grounding_str}{thinking_str}")
print(f"Nano Banana: {mode_str} | {分辨率} {宽高比} | {生图数量}")
if batch_prompts or 生图数量 > 1:
prompts = batch_prompts if batch_prompts else [prompt]
@@ -479,11 +608,14 @@ class NanoBanana(io.ComfyNode):
images_per_prompt=images_per_prompt,
input_images=input_images,
pbar=pbar,
enable_grounding=enable_grounding,
thinking_level=thinking_level,
google_search=google_search,
resize_mode=resize_mode,
unlimited_downloads=unlimited_downloads,
))
)
finally:
asyncio.set_event_loop(None)
loop.close()
with ThreadPoolExecutor(max_workers=1) as executor:
@@ -516,8 +648,11 @@ class NanoBanana(io.ComfyNode):
asyncio.set_event_loop(loop)
try:
async def _do():
connector = aiohttp.TCPConnector(ssl=False)
async with aiohttp.ClientSession(connector=connector) as session:
async with create_http_client(
http2=True,
max_connections=_HTTP_MAX_CONNECTIONS,
max_keepalive_connections=_HTTP_MAX_KEEPALIVE_CONNECTIONS,
) as session:
return await _generate_single(
session=session,
base_url=base_url,
@@ -527,25 +662,37 @@ class NanoBanana(io.ComfyNode):
resolution=分辨率,
aspect_ratio=宽高比,
images=input_images if input_images else None,
enable_grounding=enable_grounding,
thinking_level=thinking_level,
google_search=google_search,
download_semaphore=(
None
if unlimited_downloads
else asyncio.Semaphore(_MAX_DOWNLOAD_CONCURRENCY)
),
progress_callback=_make_progress_callback(pbar),
resize_mode=resize_mode,
)
return loop.run_until_complete(_run_with_interrupt(_do()))
finally:
asyncio.set_event_loop(None)
loop.close()
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(run_single)
generated_images, task_ms, parse_ms = future.result(timeout=_REQUEST_TIMEOUT)
generated_images, timing = future.result(timeout=_REQUEST_TIMEOUT)
output_tensor = _images_to_tensor_safe(generated_images, _NODE)
elapsed = time.time() - start_time
time_str = f"{elapsed:.3f}s" if elapsed < 1 else f"{elapsed:.2f}s"
task_str = f"{task_ms/1000:.2f}s"
parse_str = f"{parse_ms/1000:.2f}s"
print(f"完成!总耗时 {time_str} | 异步任务 {task_str} | 解析 {parse_str} | 成功 {len(generated_images)}")
task_str = f"{timing['task_ms']/1000:.2f}s"
download_str = f"{timing['download_ms']/1000:.2f}s"
parse_str = f"{max(0, timing['parse_ms'] - timing['download_ms'])/1000:.2f}s"
inline_suffix = f" | 内联={timing['inline_images']}" if timing['inline_images'] else ""
print(
f"Nano Banana: 完成 ✓ | task_id={timing['task_id']} | "
f"生成={len(generated_images)} 张 | 耗时={time_str} "
f"(生成 {task_str} | 下载 {download_str} | 解析 {parse_str}{inline_suffix})"
)
import gc; gc.collect()
return io.NodeOutput(output_tensor)
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -11,7 +11,7 @@ from typing import Optional, Tuple
from ..clients.newapi_veo_client import NewAPIVeoClient
from ..utils.image_utils import tensor_to_pil
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.config import get_base_url_by_route
try:
import folder_paths
@@ -138,7 +138,6 @@ class Google31Video:
},
),
"负向提示词": ("STRING", {"default": "", "multiline": True}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
"模型": (MODEL_OPTIONS, {"default": MODEL_OPTIONS[0]}),
"时长": (DURATION_OPTIONS, {"default": "8"}),
"宽高比": (ASPECT_RATIO_OPTIONS, {"default": "16:9"}),
@@ -173,7 +172,6 @@ class Google31Video:
self,
提示词: str,
负向提示词: str,
网络线路: str,
模型: str,
时长: str,
宽高比: str,
@@ -181,6 +179,7 @@ class Google31Video:
生成音频: str,
seed: int,
参考图像=None,
**_kwargs,
):
if VideoFromFile is None:
raise RuntimeError("当前 ComfyUI 版本不支持原生 VIDEO 输入实现 VideoFromFile。")
@@ -217,7 +216,7 @@ class Google31Video:
pbar.update(progress - last_progress[0])
last_progress[0] = progress
client = NewAPIVeoClient(base_url=get_base_url_by_route(网络线路))
client = NewAPIVeoClient(base_url=get_base_url_by_route())
result = client.generate_video_sync(
prompt=prompt,
@@ -230,7 +229,6 @@ class Google31Video:
generate_audio=(生成音频 == "打开"),
image_bytes=image_bytes,
poll_interval=10,
timeout=900,
progress_callback=progress_callback,
)
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
"""Panel-driven parallel video generation and durable result nodes."""
from __future__ import annotations
import json
import os
from typing import Any
import folder_paths
from PIL import Image
from comfy_api.latest import InputImpl, io, ui
from ..utils.image_utils import pil_to_tensor
from ..utils.o1key_video_catalog import (
SEEDANCE_ASSET_CREATION_MODE_OPTIONS,
SEEDANCE_MODEL_OPTIONS,
SEEDANCE_ROUTE_OPTIONS,
VIDEO_ASPECT_RATIO_OPTIONS,
VIDEO_DURATION_OPTIONS,
VIDEO_GENERATION_MODE_OPTIONS,
VIDEO_PROVIDER_OPTIONS,
VIDEO_RESOLUTION_OPTIONS,
)
def _parse_result_descriptor(value: str | dict[str, Any] | None) -> dict[str, str] | None:
if value in (None, "", "{}"):
return None
try:
item = json.loads(value) if isinstance(value, str) else value
except json.JSONDecodeError:
raise ValueError("视频结果描述符不是有效 JSON") from None
if not isinstance(item, dict):
raise ValueError("视频结果描述符必须是对象")
filename = os.path.basename(str(item.get("filename") or "").strip())
subfolder = str(item.get("subfolder") or "").strip().replace("\\", "/")
folder_type = str(item.get("type") or "output").strip()
if not filename or folder_type not in {"output", "temp"}:
raise ValueError("视频结果描述符无效")
if subfolder.startswith("/") or any(part == ".." for part in subfolder.split("/")):
raise ValueError("视频结果子目录无效")
return {"filename": filename, "subfolder": subfolder, "type": folder_type}
def _resolve_result_path(descriptor: dict[str, str]) -> str:
root = (
folder_paths.get_output_directory()
if descriptor["type"] == "output"
else folder_paths.get_temp_directory()
)
root = os.path.realpath(os.path.abspath(root))
candidate = os.path.realpath(
os.path.abspath(os.path.join(root, descriptor["subfolder"], descriptor["filename"]))
)
try:
inside = os.path.commonpath((root, candidate)) == root
except ValueError:
inside = False
if not inside or not os.path.isfile(candidate):
raise ValueError("视频结果文件不存在或已超出允许目录")
return candidate
def _result_values(
video_manifest: str | dict[str, Any] | None,
last_frame_manifest: str | dict[str, Any] | None,
) -> tuple[Any, Any]:
video_descriptor = _parse_result_descriptor(video_manifest)
if video_descriptor is None:
return None, None
video_path = _resolve_result_path(video_descriptor)
last_frame_tensor = None
last_frame_descriptor = _parse_result_descriptor(last_frame_manifest)
if last_frame_descriptor is not None:
last_frame_path = _resolve_result_path(last_frame_descriptor)
with Image.open(last_frame_path) as image:
image.load()
last_frame_tensor = pil_to_tensor([image.convert("RGB")])
return InputImpl.VideoFromFile(video_path), last_frame_tensor
class O1keyVideoGenerator(io.ComfyNode):
"""A frontend-operated generator; each click creates an independent job."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="O1keyVideoGenerator",
display_name="o1key 视频生成",
category="o1key/video",
description="点击节点内按钮提交独立后台视频任务,并自动连接原生保存节点。",
inputs=[
io.String.Input("prompt", default="", multiline=True, socketless=True),
io.Combo.Input("provider", options=VIDEO_PROVIDER_OPTIONS, default="seedance", socketless=True),
io.Combo.Input("model", options=SEEDANCE_MODEL_OPTIONS, default="seedance-2.0", socketless=True),
io.Combo.Input("route", options=SEEDANCE_ROUTE_OPTIONS, default="domestic", socketless=True),
io.Combo.Input("generation_mode", options=VIDEO_GENERATION_MODE_OPTIONS, default="multimodal", socketless=True),
io.Combo.Input("resolution", options=VIDEO_RESOLUTION_OPTIONS, default="720p", socketless=True),
io.Combo.Input("aspect_ratio", options=VIDEO_ASPECT_RATIO_OPTIONS, default="auto", socketless=True),
io.Combo.Input("duration", options=VIDEO_DURATION_OPTIONS, default="5", socketless=True),
io.Boolean.Input("generate_audio", default=False, socketless=True),
io.Boolean.Input("return_last_frame", default=False, socketless=True),
io.Int.Input("seed", default=0, min=0, max=0xFFFFFFFFFFFFFFFF, socketless=True),
io.String.Input("media_manifest", default="{}", multiline=True, socketless=True),
io.String.Input("asset_manifest", default="{}", multiline=True, socketless=True),
io.String.Input("provider_options", default="{}", multiline=True, socketless=True),
io.String.Input("filename_prefix", default="o1key_video", socketless=True),
io.String.Input("save_location", default="video", socketless=True),
# Append-only: keep every released widgets_values position stable.
io.Combo.Input(
"asset_creation_mode",
options=SEEDANCE_ASSET_CREATION_MODE_OPTIONS,
default="auto",
socketless=True,
),
io.String.Input("video_manifest", default="{}", multiline=True, socketless=True),
io.String.Input("last_frame_manifest", default="{}", multiline=True, socketless=True),
],
outputs=[
io.Video.Output("VIDEO", display_name="VIDEO"),
io.Image.Output("LAST_FRAME", display_name="LAST_FRAME"),
],
not_idempotent=True,
)
@classmethod
def execute(
cls,
video_manifest: str = "{}",
last_frame_manifest: str = "{}",
**_kwargs,
) -> io.NodeOutput:
# Generation remains owned by /o1key/video/jobs. Native execution only
# resolves the latest completed local descriptors and never spends again.
video, last_frame = _result_values(video_manifest, last_frame_manifest)
if video is None:
return io.NodeOutput(block_execution="请先在 o1key 视频生成节点中完成一次生成")
return io.NodeOutput(video, last_frame)
class O1keyVideoResult(io.ComfyNode):
"""A completed background result that can later feed native VIDEO workflows."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="O1keyVideoResult",
display_name="o1key 视频结果",
category="o1key/video",
description="显示独立后台任务状态;完成后可向下游输出原生 VIDEO。",
is_deprecated=True,
inputs=[
io.String.Input("batch_id", default="", socketless=True),
io.String.Input("video_manifest", default="{}", multiline=True, socketless=True),
io.String.Input("last_frame_manifest", default="{}", multiline=True, socketless=True),
],
outputs=[
io.Video.Output("VIDEO", display_name="VIDEO"),
io.Image.Output("LAST_FRAME", display_name="LAST_FRAME"),
],
)
@classmethod
def execute(
cls,
batch_id: str = "",
video_manifest: str = "{}",
last_frame_manifest: str = "{}",
) -> io.NodeOutput:
del batch_id
video_descriptor = _parse_result_descriptor(video_manifest)
if video_descriptor is None:
raise ValueError("视频任务尚未完成,没有可输出的视频")
video, last_frame_tensor = _result_values(video_manifest, last_frame_manifest)
preview = ui.PreviewVideo([video_descriptor])
return io.NodeOutput(
video,
last_frame_tensor,
ui=preview,
)
NODE_CLASS_MAPPINGS = {
"O1keyVideoGenerator": O1keyVideoGenerator,
"O1keyVideoResult": O1keyVideoResult,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"O1keyVideoGenerator": "o1key 视频生成",
"O1keyVideoResult": "o1key 视频结果",
}
__all__ = ["O1keyVideoGenerator", "O1keyVideoResult"]
+182
View File
@@ -0,0 +1,182 @@
"""Omni Flash video generation through a normal ComfyUI execution."""
from __future__ import annotations
import io as py_io
import os
import uuid
from pathlib import Path
import folder_paths
from comfy_api.latest import InputImpl, io
from ..clients.omni_flash_client import OmniFlashClient, build_video_body
from ..utils.config import get_api_key_or_raise, get_base_url_by_route
from ..utils.image_utils import tensor_to_pil
from ..utils.r2_uploader import upload_image, upload_video
from ..utils.video_task import check_interrupt
_MODES = {
"文生视频": "text",
"参考图视频": "reference",
"首尾帧": "first_last_frame",
"视频编辑": "edit",
}
_MAX_EDIT_VIDEO_BYTES = 20 * 1024 * 1024
def _make_progress_callback():
try:
from comfy.utils import ProgressBar
bar = ProgressBar(100)
except ImportError:
bar = None
last_progress = -1
def update(stage: str, value: int, _task_id: str) -> None:
nonlocal last_progress
# The provider percentage maps directly to the node bar. Reserve the
# final point for the local video download and save.
current = max(0, min(100, int(value)))
if stage != "done":
current = min(current, 99)
if current <= last_progress:
return
last_progress = current
if bar is not None:
bar.update_absolute(current, 100)
return update
class O1keyOmniFlashVideo(io.ComfyNode):
"""One graph node owns validation, submission, polling, and VIDEO output."""
@classmethod
def fingerprint_inputs(cls, **kwargs):
"""Do not reuse a completed generation when this node is queued again."""
return uuid.uuid4().hex
@classmethod
def define_schema(cls):
return io.Schema(
node_id="O1keyOmniFlashVideo",
display_name="Omni Flash 视频生成",
description="连接图片或视频后生成;开始生成按钮运行当前节点。",
category="comfyui_o1key/视频",
is_output_node=True,
not_idempotent=True,
inputs=[
io.String.Input("提示词", multiline=True, default=""),
io.Combo.Input("生成模式", options=list(_MODES), default="文生视频"),
io.Combo.Input("分辨率", options=["720p", "1080p"], default="720p"),
io.Combo.Input("宽高比", options=["16:9", "9:16"], default="16:9"),
io.Autogrow.Input(
"参考图片",
optional=True,
template=io.Autogrow.TemplateNames(
input=io.Image.Input("参考图片"),
names=[f"参考图片{i}" for i in range(1, 6)],
min=0,
),
),
io.Image.Input("首帧图片", optional=True),
io.Image.Input("尾帧图片", optional=True, tooltip="可不连接;只连接首帧也能生成。"),
io.Video.Input("源视频", optional=True),
],
outputs=[io.Video.Output("VIDEO", display_name="视频")],
)
@staticmethod
def _reference_images(kwargs):
group = kwargs.get("参考图片")
if isinstance(group, dict):
return [value for value in group.values() if value is not None]
return [kwargs[f"参考图片{index}"] for index in range(1, 6)
if kwargs.get(f"参考图片{index}") is not None]
@staticmethod
def _one_image(value, label):
images = tensor_to_pil(value)
if len(images) != 1:
raise ValueError(f"{label}必须恰好包含 1 张图片")
return images[0].convert("RGB")
@staticmethod
def _check_source_video(video):
source = video.get_stream_source() if hasattr(video, "get_stream_source") else None
if isinstance(source, py_io.BytesIO):
size = source.getbuffer().nbytes
elif isinstance(source, str) and os.path.isfile(source):
size = os.path.getsize(source)
if Path(source).suffix.lower() not in {".mp4", ".mov"}:
raise ValueError("源视频须为 MP4 或 MOV")
else:
raise ValueError("无法读取源视频文件")
if size > _MAX_EDIT_VIDEO_BYTES:
raise ValueError("源视频不能超过 20 MB")
@classmethod
async def execute(cls, **kwargs):
mode = _MODES.get(kwargs.get("生成模式"))
if mode is None:
raise ValueError("生成模式无效")
images = cls._reference_images(kwargs)
first = kwargs.get("首帧图片")
last = kwargs.get("尾帧图片")
source = kwargs.get("源视频")
if mode == "text" and (images or first is not None or last is not None or source is not None):
raise ValueError("文生视频模式不接受媒体输入")
if mode == "reference" and (not images or first is not None or last is not None or source is not None):
raise ValueError("参考图视频模式只接受参考图片")
if mode == "first_last_frame" and (images or first is None or source is not None):
raise ValueError("首尾帧模式须连接首帧图片;尾帧图片可选")
if mode == "edit" and (source is None or first is not None or last is not None):
raise ValueError("视频编辑模式须连接源视频,不能连接首尾帧")
if mode == "edit" and len(images) > 5:
raise ValueError("视频编辑最多支持 5 张参考图")
model = "omni_flash_abra_edit" if mode == "edit" else "omni_flash_10s"
prompt = kwargs["提示词"]
resolution = kwargs["分辨率"]
ratio = kwargs["宽高比"]
# Validate the scalar contract before any upload or paid request.
upload_images = ([first] + ([last] if last is not None else [])) if mode == "first_last_frame" else images
build_video_body(model=model, prompt=prompt, resolution=resolution,
aspect_ratio=ratio, mode=mode,
references=["https://example.invalid/media"] * len(upload_images),
source_video_url="https://example.invalid/video" if mode == "edit" else "")
pil_images = [cls._one_image(value, "输入") for value in upload_images]
if source is not None:
cls._check_source_video(source)
api_key = get_api_key_or_raise("O1KEY_API_KEY")
base_url = get_base_url_by_route()
urls = []
for image in pil_images:
check_interrupt()
urls.append(await upload_image(image, base_url=base_url))
source_url = await upload_video(source, base_url=base_url) if source is not None else ""
body = build_video_body(model=model, prompt=prompt, resolution=resolution,
aspect_ratio=ratio, mode=mode, references=urls,
source_video_url=source_url)
output_dir = Path(folder_paths.get_output_directory()) / "omni_flash"
output_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex}.mp4"
target = output_dir / filename
partial = output_dir / f"{filename}.part"
report_progress = _make_progress_callback()
report_progress("polling", 0, "")
try:
await OmniFlashClient(base_url=base_url, api_key=api_key).generate(
body, str(partial), progress=report_progress,
)
check_interrupt()
os.replace(partial, target)
report_progress("done", 100, "")
except BaseException:
partial.unlink(missing_ok=True)
raise
return io.NodeOutput(InputImpl.VideoFromFile(str(target)))
+198
View File
@@ -0,0 +1,198 @@
"""
提示词多功能节点
支持以第一套---第二套---第三套格式填入多套提示词并选择处理方式
- 全部使用保留 --- 分隔符输出全部套数交给下游批量节点并发跑
- 随机抽取n套按指定数量不重复抽取按原始顺序输出数量为 1 时只抽 1
- 指定序号按填写顺序输出一套或多套提示词
下游需连接支持批量提示词按单独行 --- 分割并发执行的节点
Nano Banana 批量跑图
"""
import random
import re
from typing import List
from ..utils.image_utils import parse_batch_prompts
_MODE_ALL = "全部使用"
_MODE_RANDOM = "随机抽取n套"
_MODE_SELECTED = "指定序号"
_LEGACY_MODE_RANDOM_ONE = "随机抽取1套"
_LEGACY_MODE_RANDOM_MANY = "随机抽取多套"
_RANDOM_MODES = {
_MODE_RANDOM,
_LEGACY_MODE_RANDOM_ONE,
_LEGACY_MODE_RANDOM_MANY,
}
_MODES = [_MODE_ALL, _MODE_RANDOM, _MODE_SELECTED]
_DEFAULT_SAMPLE_COUNT = 3
_DEFAULT_SELECTED_INDICES = "1,2,3"
_INDEX_SEPARATOR_RE = re.compile(r"[\s,;]+")
_INDEX_RANGE_RE = re.compile(r"^(\d+)[-~~—–](\d+)$")
def _split_prompt_sets(text: str) -> List[str]:
"""按单独行 --- 切分多套提示词;无分隔符时整段视为 1 套。"""
stripped = (text or "").strip()
if not stripped:
return []
sets = parse_batch_prompts(text)
if not sets:
return [stripped]
return sets
def _parse_prompt_indices(value: str, total: int) -> List[int]:
"""解析从 1 开始的序号列表,拒绝重复并保留填写顺序。"""
raw_value = str(value or "").strip()
if not raw_value:
raise ValueError("提示词(多功能):指定序号为空,请填写如 1,3,5。")
tokens = [token for token in _INDEX_SEPARATOR_RE.split(raw_value) if token]
selected: List[int] = []
seen = set()
def append_index(index: int) -> None:
if index < 1 or index > total:
raise ValueError(
f"提示词(多功能):序号 {index} 超出范围,当前共有 {total} 套提示词。"
)
if index in seen:
raise ValueError(f"提示词(多功能):序号 {index} 重复,请勿重复填写。")
seen.add(index)
selected.append(index)
for token in tokens:
if token.isdigit():
append_index(int(token))
continue
match = _INDEX_RANGE_RE.fullmatch(token)
if match:
start, end = (int(part) for part in match.groups())
if end < start:
raise ValueError(
f"提示词(多功能):区间 {token} 必须从小到大填写。"
)
if start < 1 or start > total:
append_index(start)
if end < 1 or end > total:
append_index(end)
for index in range(start, end + 1):
append_index(index)
continue
raise ValueError(
f"提示词(多功能):无法识别序号“{token}”,请填写如 1,3,5 或 2-4。"
)
return selected
class O1keyPromptMultiFunction:
"""多功能提示词节点:全部使用、随机抽取或按序号选择。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"提示词": ("STRING", {
"multiline": True,
"default": "第一套提示词\n---\n第二套提示词\n---\n第三套提示词",
"placeholder": "多套提示词请用单独一行的 --- 分隔",
}),
"功能": (_MODES, {"default": _MODE_ALL}),
"抽取数量": ("INT", {
"default": _DEFAULT_SAMPLE_COUNT,
"min": 1,
"max": 1000,
"step": 1,
"tooltip": "仅“随机抽取n套”生效;从全部提示词中不重复抽取。",
}),
"指定序号": ("STRING", {
"default": _DEFAULT_SELECTED_INDICES,
"placeholder": "例如:1,3,5 或 2-4",
"tooltip": "仅“指定序号”生效;序号从 1 开始,按填写顺序输出。",
}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("提示词",)
FUNCTION = "process"
CATEGORY = "o1key/prompt"
DESCRIPTION = (
"多套提示词用单独一行的 --- 分隔。\n"
"全部使用:保留 --- 输出全部,交给下游批量节点并发跑每一套。\n"
"随机抽取n套:按“抽取数量”不重复随机选择,并按原始顺序输出。\n"
"指定序号:支持 1,3,5、中文逗号、空格和 2-4 区间,按填写顺序输出。"
)
def process(
self,
提示词: str,
功能: str = _MODE_ALL,
抽取数量: int = _DEFAULT_SAMPLE_COUNT,
指定序号: str = _DEFAULT_SELECTED_INDICES,
):
sets = _split_prompt_sets(提示词)
if not sets:
raise ValueError("提示词(多功能):提示词为空,请至少填写 1 套。")
# 旧 API 工作流可能绕过前端迁移直接提交原模式值,继续保持只抽 1 套。
if 功能 == _LEGACY_MODE_RANDOM_ONE:
chosen = random.choice(sets)
print(f"[o1key 提示词多功能] 随机抽取 1/{len(sets)}")
return (chosen,)
if 功能 in {_MODE_RANDOM, _LEGACY_MODE_RANDOM_MANY}:
try:
sample_count = int(抽取数量)
except (TypeError, ValueError) as exc:
raise ValueError("提示词(多功能):抽取数量必须是整数。") from exc
if sample_count < 1:
raise ValueError("提示词(多功能):抽取数量必须至少为 1。")
if sample_count > len(sets):
raise ValueError(
f"提示词(多功能):抽取数量 {sample_count} 超过当前提示词总数 {len(sets)}"
)
chosen_indices = sorted(random.sample(range(len(sets)), sample_count))
chosen = [sets[index] for index in chosen_indices]
display_indices = ",".join(str(index + 1) for index in chosen_indices)
print(
f"[o1key 提示词多功能] 随机抽取 {sample_count}/{len(sets)} 套,"
f"序号:{display_indices}"
)
return ("\n---\n".join(chosen),)
if 功能 == _MODE_SELECTED:
selected_indices = _parse_prompt_indices(指定序号, len(sets))
chosen = [sets[index - 1] for index in selected_indices]
display_indices = ",".join(str(index) for index in selected_indices)
print(
f"[o1key 提示词多功能] 指定使用 {len(chosen)}/{len(sets)} 套,"
f"序号:{display_indices}"
)
return ("\n---\n".join(chosen),)
# 全部使用:保留单独行 --- 分隔符,下游批量节点可并发分割执行
joined = "\n---\n".join(sets)
print(f"[o1key 提示词多功能] 全部使用,共 {len(sets)}")
return (joined,)
@classmethod
def IS_CHANGED(
cls,
提示词,
功能=_MODE_ALL,
抽取数量=_DEFAULT_SAMPLE_COUNT,
指定序号=_DEFAULT_SELECTED_INDICES,
):
# 随机模式每次都重新抽取
if 功能 in _RANDOM_MODES:
return float("nan")
if 功能 == _MODE_SELECTED:
return f"{功能}|{指定序号}|{提示词}"
return f"{功能}|{提示词}"
+46 -2
View File
@@ -27,8 +27,15 @@ class SaveImageFormat:
"图像": ("IMAGE",),
"文件名前缀": ("STRING", {"default": "ComfyUI"}),
"输出格式": (cls.FORMATS, {"default": "PNG"}),
"质量": ("INT", {
"default": 100, "min": 1, "max": 100, "step": 1,
"tooltip": "图片质量。100=不压缩(JPEG 最高质量 / WebP 无损);"
"小于 100 时按该数值压缩(如 90),仅对 JPEG / WebP 生效。",
}),
},
"optional": {
"保存路径": ("STRING", {"default": ""}),
},
"optional": {},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
@@ -43,11 +50,28 @@ class SaveImageFormat:
_EXT_MAP = {"PNG": ".png", "JPEG": ".jpg", "WebP": ".webp"}
@classmethod
def IS_CHANGED(cls, 图像=None, **kwargs):
"""保存节点属于有副作用的输出节点,不能复用上次的缓存结果。"""
return float("nan")
def save_images(self, 图像=None, 文件名前缀="ComfyUI", 输出格式="PNG",
prompt=None, extra_pnginfo=None):
质量=100, 保存路径="", prompt=None, extra_pnginfo=None):
images = 图像
filename_prefix = 文件名前缀
format = 输出格式
quality = int(质量)
custom_dir = (保存路径 or "").strip()
if custom_dir:
# 保存到用户指定的文件夹。自定义路径不会经过
# folder_paths.get_save_image_path(),因此需要在此处自行避让重名。
full_output_folder = custom_dir
os.makedirs(full_output_folder, exist_ok=True)
filename = filename_prefix
counter = 1
subfolder = ""
else:
full_output_folder, filename, counter, subfolder, filename_prefix = \
folder_paths.get_save_image_path(
filename_prefix, self.output_dir,
@@ -57,6 +81,20 @@ class SaveImageFormat:
ext = self._EXT_MAP.get(format, ".png")
results = []
# 为整个输入批次预留一段连续编号,避免重复运行时覆盖已有文件。
# 默认 output 路径和自定义保存路径都在这里复核一次;这样即使外部
# 编号器返回了已使用的计数,也不会覆盖。兼容 %batch_num% 占位符。
while any(
os.path.exists(
os.path.join(
full_output_folder,
f"{filename.replace('%batch_num%', str(batch_number))}_{counter + batch_number:05}_{ext}",
)
)
for batch_number in range(len(images))
):
counter += 1
for batch_number, image in enumerate(images):
i = 255.0 * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
@@ -80,9 +118,15 @@ class SaveImageFormat:
elif format == "JPEG":
if img.mode == "RGBA":
img = img.convert("RGB")
if quality >= 100:
img.save(filepath, quality=100, optimize=True)
else:
img.save(filepath, quality=quality, optimize=True)
elif format == "WebP":
if quality >= 100:
img.save(filepath, lossless=True)
else:
img.save(filepath, quality=quality, method=6)
results.append({
"filename": file,
+881
View File
@@ -0,0 +1,881 @@
"""
Seedance 2.0 / 2.5 自动过审节点xinhankr/可美线路
Seedance / SeedanceMultiModal 的差异
- 模型名用 seedance-2.0 / seedance-2.0-fast / seedance-2.0-minifastmini 仅支持 480p/720p
- 界面仅保留多模态首尾帧两种生成模式
- 多模态无素材时自动作为文生视频首尾帧根据尾帧是否连接自动选择首帧/首尾帧
- 参考素材可自动创建为素材也可直接使用手动填写的 asset ID
- /尾帧和 asset:// 素材使用 content body
端点不变POST /v1/video/generationsGET /v1/video/generations/{task_id}
"""
import asyncio
import io as py_io
import json
import os
import re
import tempfile
import aiohttp
from PIL import Image
from ..clients.seedance_client import SeedanceClient
from ..clients.seedance_element_client import SeedanceElementClient
from ..utils.config import get_base_url_by_route
from ..utils.r2_uploader import upload_image, upload_video, upload_audio
from ..utils.image_utils import pil_to_tensor, tensor_to_pil
from ..utils.video_task import format_seedance_generation_error
from ..utils.o1key_video_catalog import (
SEEDANCE_CAPABILITIES,
SEEDANCE_MODEL_MATRIX,
SEEDANCE_REFERENCE_AUDIO_MAX_BYTES,
SEEDANCE_REFERENCE_IMAGE_MAX_BYTES,
SEEDANCE_REFERENCE_VIDEO_MAX_BYTES,
normalize_seedance_parameters,
resolve_seedance_model,
validate_seedance_media_counts,
validate_seedance_reference_dimensions,
)
from ..utils.o1key_video_jobs import build_seedance_video_body
from comfy_api.latest import InputImpl, io
_BASE_MODELS = ["seedance 2.0", "seedance 2.0 fast", "seedance 2.0 mini", "seedance 2.5"]
_MODEL_ROUTES = ["海外", "国内"]
_LEGACY_MODEL_ROUTES = {
"海外HC": "海外",
"海外破限高并发": "海外",
"海外破限": "海外",
"海外破限标准": "海外",
"海外标准": "海外",
}
_ASSET_CREATION_MODES = {"国内": "Doubao", "海外": "HC"}
_CANONICAL_MODELS = {
"seedance 2.0": "seedance-2.0",
"seedance 2.0 fast": "seedance-2.0-fast",
"seedance 2.0 mini": "seedance-2.0-mini",
"seedance 2.5": "seedance-2.5",
}
_CANONICAL_ROUTES = {"国内": "domestic", "海外": "overseas_hc"}
# 主模型 × 模型线路 → 实际模型ID 映射表
_MODEL_MATRIX = {
(display_model, display_route): SEEDANCE_MODEL_MATRIX[(model, route)]
for display_model, model in _CANONICAL_MODELS.items()
for display_route, route in _CANONICAL_ROUTES.items()
}
_MODEL_CAPABILITIES = {
display_model: {
**SEEDANCE_CAPABILITIES[model],
"routes": set(_MODEL_ROUTES),
}
for display_model, model in _CANONICAL_MODELS.items()
}
# 旧模型列表(保留用于旧的 _resolve_model 函数)
_MODELS = ["seedance-2.0", "seedance-2.0-fast", "seedance-2.0-mini"]
_RESOLUTIONS = ["480p", "720p", "1080p", "4k"]
_FAST_RESOLUTIONS = {"480p", "720p"}
_LIMITED_RESOLUTION_MODELS = {
"seedance-2.0-fast", "seedance-2.0-mini",
"dreamina-seedance-2-0-fast-hc", "dreamina-seedance-2-0-mini-hc",
"seedance-2-0-fast-260128-d", "seedance-2-0-fast-d-ep",
"seedance-2-0-mini-260615-d", "seedance-2-0-mini-260615-d-ep",
}
def _normalize_model_route(route: str) -> str:
"""兼容旧工作流保存的线路显示名。"""
return _LEGACY_MODEL_ROUTES.get(route, route)
def _resolve_model_matrix(base_model: str, route: str) -> str:
"""矩阵式解析:主模型 + 模型线路 → 实际模型ID"""
route = _normalize_model_route(route)
canonical_model = _CANONICAL_MODELS.get(base_model)
canonical_route = _CANONICAL_ROUTES.get(route)
if canonical_model is None or canonical_route is None:
raise ValueError(f"{base_model} 不支持模型线路:{route}")
return resolve_seedance_model(canonical_model, canonical_route)
def _resolve_asset_creation_mode(route: str) -> str:
"""根据模型线路匹配素材创建方法。"""
route = _normalize_model_route(route)
mode = _ASSET_CREATION_MODES.get(route)
if mode is None:
raise ValueError(f"模型线路 {route} 未配置素材创建方法")
return mode
_RATIOS = ["智能", "16:9", "9:16", "4:3", "3:4", "1:1", "21:9"]
_DURATIONS = ["自动"] + [f"{i}" for i in range(4, 31)]
_MODE_MULTIMODAL = "多模态参考生视频"
_MODE_FIRST_FRAME = "图生视频-首帧"
_MODE_FIRST_LAST = "图生视频-首尾帧"
_MODE_TEXT = "文生视频"
_GENERATION_MODES = [
_MODE_MULTIMODAL,
_MODE_FIRST_FRAME,
_MODE_FIRST_LAST,
_MODE_TEXT,
]
_UI_MODE_MULTIMODAL = "多模态"
_UI_MODE_FIRST_LAST = "首尾帧"
_UI_GENERATION_MODES = [_UI_MODE_MULTIMODAL, _UI_MODE_FIRST_LAST]
_CANONICAL_GENERATION_MODES = {
_MODE_MULTIMODAL: "multimodal",
_MODE_FIRST_FRAME: "first_frame",
_MODE_FIRST_LAST: "first_last_frame",
_MODE_TEXT: "text",
_UI_MODE_MULTIMODAL: "multimodal",
_UI_MODE_FIRST_LAST: "first_last_frame",
}
_ASSET_MODE_AUTO = "关闭"
_ASSET_MODE_MANUAL = "打开"
_ASSET_MODES = [_ASSET_MODE_AUTO, _ASSET_MODE_MANUAL]
_SUCCESS_STATUSES = {"succeeded", "success", "completed", "done", "finished"}
_FAILURE_STATUSES = {"failed", "fail", "failure", "error", "expired", "cancelled", "canceled"}
class SeedanceAutoPass(io.ComfyNode):
"""Seedance 全能生成视频(根据模型线路自动创建素材)"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="SeedanceAutoPass",
display_name="Seedance 全能生成视频",
description="支持多模态(含文生视频)和首尾帧(尾帧可选)两种生成模式。",
category="comfyui_o1key/Seedance",
inputs=[
io.String.Input("提示词", multiline=True, default=""),
io.Combo.Input(
"生成模式",
options=_UI_GENERATION_MODES,
default=_UI_MODE_MULTIMODAL,
tooltip="多模态无素材时支持文生视频;首尾帧的尾帧图片可以不连接。",
),
io.Combo.Input("主模型", options=_BASE_MODELS, default="seedance 2.0"),
io.Combo.Input("模型线路", options=_MODEL_ROUTES, default="国内"),
io.Combo.Input("分辨率", options=_RESOLUTIONS, default="720p"),
io.Combo.Input("宽高比", options=_RATIOS, default="智能"),
io.Combo.Input("时长", options=_DURATIONS, default="5秒"),
io.Combo.Input("生成音频", options=["关闭", "打开"], default="关闭"),
# 当前 ComfyUI 前端会把 DynamicCombo 触发项当作输入插槽处理,
# 在创建节点时抛出“Failed to find input socket”。这里使用稳定的
# 普通下拉,并保留全部可选素材插槽;执行时只读取当前模式对应项。
io.Autogrow.Input(
"参考图片",
optional=True,
template=io.Autogrow.TemplateNames(
input=io.Image.Input("参考图片"),
names=[f"参考图片{i}" for i in range(1, 31)],
min=0,
),
),
io.Autogrow.Input(
"参考视频",
optional=True,
template=io.Autogrow.TemplateNames(
input=io.Video.Input("参考视频"),
names=[f"参考视频{i}" for i in range(1, 11)],
min=0,
),
),
io.Autogrow.Input(
"参考音频",
optional=True,
template=io.Autogrow.TemplateNames(
input=io.Audio.Input("参考音频"),
names=[f"参考音频{i}" for i in range(1, 11)],
min=0,
),
),
io.Image.Input("首帧图片", optional=True),
io.Image.Input("尾帧图片", optional=True),
io.Combo.Input(
"素材创建模式",
options=_ASSET_MODES,
default=_ASSET_MODE_AUTO,
tooltip="关闭:隐藏素材 ID 并自动创建连接的素材;打开:显示并使用已有素材 ID。",
),
*[
io.String.Input(f"{prefix}{index}", default="", tooltip="手动模式使用;填写一个 Asset ID。")
for prefix, maximum in (("图片素材ID", 30), ("视频素材ID", 10), ("音频素材ID", 10))
for index in range(1, maximum + 1)
],
# 原高级参数改为普通参数,并统一放在节点最下方。
io.Combo.Input(
"联网搜索",
options=["关闭", "打开"],
default="关闭",
tooltip="兼容旧工作流;当前 content 请求不发送联网搜索参数。",
),
io.Int.Input(
"seed",
default=0,
min=0,
max=0xffffffffffffffff,
),
io.Combo.Input(
"返回末帧图片",
options=["关闭", "打开"],
default="关闭",
),
],
outputs=[
io.Video.Output(display_name="视频"),
io.Image.Output(display_name="末帧图片"),
],
)
@staticmethod
def _autogrow_values(kwargs, group_name, legacy_prefix, legacy_max):
"""按定义顺序提取动态输入,并兼容直接调用时传入的旧编号参数。"""
group = kwargs.get(group_name)
if isinstance(group, dict):
return [value for value in group.values() if value is not None]
return [
kwargs[f"{legacy_prefix}{index}"]
for index in range(1, legacy_max + 1)
if kwargs.get(f"{legacy_prefix}{index}") is not None
]
@staticmethod
def _mode_inputs(kwargs):
"""提取 DynamicCombo 当前分支;旧工作流/直接调用默认按多模态处理。"""
mode_inputs = kwargs.get("生成模式")
if isinstance(mode_inputs, dict):
mode = mode_inputs.get("生成模式", _UI_MODE_MULTIMODAL)
return mode, mode_inputs
if isinstance(mode_inputs, str):
return mode_inputs, kwargs
return _UI_MODE_MULTIMODAL, kwargs
@staticmethod
def _asset_creation_inputs(kwargs):
"""读取素材创建分支;旧工作流没有该控件时默认自动创建。"""
asset_inputs = kwargs.get("素材创建模式", kwargs.get("素材创建", _ASSET_MODE_AUTO))
if isinstance(asset_inputs, dict):
mode = asset_inputs.get("素材创建模式", asset_inputs.get("素材创建", _ASSET_MODE_AUTO))
inputs = asset_inputs
else:
mode = asset_inputs
inputs = kwargs
if mode in {"manual", "手动", _ASSET_MODE_MANUAL}:
return _ASSET_MODE_MANUAL, inputs
return _ASSET_MODE_AUTO, inputs
@staticmethod
def _parse_asset_ids(value):
"""接受换行、中英文逗号或分号分隔的 Asset ID。"""
if isinstance(value, (list, tuple)):
raw_values = value
else:
raw_values = re.split(r"[\s,;]+", str(value or ""))
return [str(item).strip() for item in raw_values if str(item).strip()]
@classmethod
def _manual_asset_ids(cls, inputs, prefix, maximum):
"""按编号读取单行 ID,并兼容旧版聚合文本框/API 参数。"""
values = [
asset_id
for index in range(1, maximum + 1)
for asset_id in cls._parse_asset_ids(inputs.get(f"{prefix}{index}", ""))
]
return values or cls._parse_asset_ids(inputs.get(prefix, ""))
@staticmethod
def _canonical_generation_mode(
generation_mode,
image_count,
video_count,
audio_count,
):
"""把两种界面模式和旧工作流模式解析为接口的四种语义。"""
if generation_mode == _MODE_TEXT:
return "text"
if generation_mode == _MODE_FIRST_FRAME:
return "first_frame"
if generation_mode == _MODE_FIRST_LAST:
return "first_last_frame"
if generation_mode in {_UI_MODE_FIRST_LAST}:
return "first_frame" if image_count == 1 else "first_last_frame"
if generation_mode in {_UI_MODE_MULTIMODAL, _MODE_MULTIMODAL}:
return "multimodal" if image_count or video_count or audio_count else "text"
if generation_mode in {"text", "first_frame", "first_last_frame", "multimodal"}:
return generation_mode
raise ValueError(f"不支持的生成模式:{generation_mode}")
@staticmethod
def _validate_mode_inputs(
generation_mode,
base_model,
prompt,
ref_images,
ref_videos,
ref_audios,
):
"""校验两种界面模式,并返回接口使用的实际生成模式。"""
canonical_mode = SeedanceAutoPass._canonical_generation_mode(
generation_mode,
len(ref_images),
len(ref_videos),
len(ref_audios),
)
if canonical_mode == "text":
if not prompt:
raise ValueError("文生视频模式下提示词不能为空")
return canonical_mode
if canonical_mode in {"first_frame", "first_last_frame"}:
if generation_mode == _MODE_FIRST_FRAME and len(ref_images) != 1:
raise ValueError("图生视频-首帧模式必须提供首帧图片")
if generation_mode == _MODE_FIRST_LAST and len(ref_images) != 2:
raise ValueError("图生视频-首尾帧模式必须同时提供首帧图片和尾帧图片")
if len(ref_images) not in {1, 2}:
raise ValueError("首尾帧模式必须提供首帧图片,尾帧图片可以不提供")
if ref_videos or ref_audios:
raise ValueError("首尾帧模式只支持图片素材")
return "first_frame" if len(ref_images) == 1 else "first_last_frame"
if not (prompt or ref_images or ref_videos or ref_audios):
raise ValueError("多模态模式下,提示词和参考素材不能同时为空")
if base_model != "seedance 2.5" and ref_audios and not (ref_images or ref_videos):
raise ValueError("Seedance 2.0 系列不可单独输入参考音频,须同时提供参考图片或参考视频")
return canonical_mode
@staticmethod
def _validate_dynamic_parameters(
base_model,
model_route,
duration_s,
ref_images,
ref_videos,
ref_audios,
):
"""按主模型校验线路、时长和动态参考素材数量。"""
model_route = _normalize_model_route(model_route)
capabilities = _MODEL_CAPABILITIES.get(base_model)
if capabilities is None:
raise ValueError(f"不支持的主模型:{base_model}")
if model_route not in capabilities["routes"]:
supported = "".join(sorted(capabilities["routes"]))
raise ValueError(f"{base_model} 仅支持模型线路:{supported}")
if duration_s != "自动":
try:
duration = int(str(duration_s).removesuffix(""))
except (TypeError, ValueError):
raise ValueError(f"无效的时长:{duration_s}") from None
minimum = capabilities["duration_min"]
maximum = capabilities["duration_max"]
if not minimum <= duration <= maximum:
raise ValueError(f"{base_model} 的时长仅支持 {minimum}-{maximum}")
validate_seedance_media_counts(
_CANONICAL_MODELS[base_model],
len(ref_images),
len(ref_videos),
len(ref_audios),
model_label=base_model,
)
@staticmethod
def _normalize_generation_parameters(
generation_mode,
base_model,
model_route,
prompt,
resolution,
ratio,
duration_s,
gen_audio,
return_last,
seed,
asset_creation_mode="auto",
):
"""Translate the released Chinese widgets into the shared video catalog."""
route = _normalize_model_route(model_route)
try:
canonical_model = _CANONICAL_MODELS[base_model]
canonical_route = _CANONICAL_ROUTES[route]
canonical_mode = generation_mode
if canonical_mode not in {"text", "first_frame", "first_last_frame", "multimodal"}:
canonical_mode = _CANONICAL_GENERATION_MODES[generation_mode]
except KeyError as exc:
raise ValueError(f"Seedance 参数无效:{exc.args[0]}") from None
return normalize_seedance_parameters({
"provider": "seedance",
"model": canonical_model,
"route": canonical_route,
"generation_mode": canonical_mode,
"asset_creation_mode": asset_creation_mode,
"prompt": prompt,
"resolution": resolution,
"aspect_ratio": "auto" if ratio == "智能" else ratio,
"duration": "auto" if duration_s == "自动" else str(duration_s).removesuffix(""),
"generate_audio": gen_audio,
"return_last_frame": return_last,
"seed": seed,
})
@classmethod
async def execute(cls, **kwargs):
generation_mode, mode_inputs = cls._mode_inputs(kwargs)
asset_mode, asset_inputs = cls._asset_creation_inputs(kwargs)
manual_assets = asset_mode == _ASSET_MODE_MANUAL
prompt = (kwargs.get("提示词", mode_inputs.get("提示词", "")) or "").strip()
base_model = kwargs["主模型"]
model_route = _normalize_model_route(kwargs["模型线路"])
model = _resolve_model_matrix(base_model, model_route)
resolution = kwargs["分辨率"]
ratio = kwargs["宽高比"]
duration_s = kwargs["时长"]
gen_audio = kwargs["生成音频"] == "打开"
web_search = kwargs.get("联网搜索", mode_inputs.get("联网搜索", "关闭")) == "打开"
return_last = kwargs.get("返回末帧图片", "关闭") == "打开"
create_mode = _resolve_asset_creation_mode(model_route)
seed = int(kwargs.get("seed", 0))
if generation_mode in {_UI_MODE_MULTIMODAL, _MODE_MULTIMODAL}:
ref_images = cls._autogrow_values(mode_inputs, "参考图片", "参考图片", 30)
ref_videos = cls._autogrow_values(mode_inputs, "参考视频", "参考视频", 10)
ref_audios = cls._autogrow_values(mode_inputs, "参考音频", "参考音频", 10)
elif generation_mode in {_UI_MODE_FIRST_LAST, _MODE_FIRST_FRAME, _MODE_FIRST_LAST}:
first_frame = mode_inputs.get("首帧图片")
last_frame = mode_inputs.get("尾帧图片")
if not manual_assets and first_frame is None:
raise ValueError("首尾帧模式必须提供首帧图片,尾帧图片可以不提供")
ref_images = [first_frame, last_frame]
ref_videos = []
ref_audios = []
else:
ref_images = []
ref_videos = []
ref_audios = []
ref_images = [value for value in ref_images if value is not None]
if manual_assets:
image_urls = cls._manual_asset_ids(asset_inputs, "图片素材ID", 30)
video_urls = cls._manual_asset_ids(asset_inputs, "视频素材ID", 10)
audio_urls = cls._manual_asset_ids(asset_inputs, "音频素材ID", 10)
validation_images = image_urls
validation_videos = video_urls
validation_audios = audio_urls
else:
image_urls = video_urls = audio_urls = None
validation_images = ref_images
validation_videos = ref_videos
validation_audios = ref_audios
canonical_mode = cls._validate_mode_inputs(
generation_mode,
base_model,
prompt,
validation_images,
validation_videos,
validation_audios,
)
cls._validate_dynamic_parameters(
base_model,
model_route,
duration_s,
validation_images,
validation_videos,
validation_audios,
)
normalized = cls._normalize_generation_parameters(
canonical_mode,
base_model,
model_route,
prompt,
resolution,
ratio,
duration_s,
gen_audio,
return_last,
seed,
"manual" if manual_assets else "auto",
)
if not manual_assets:
cls._validate_reference_media(ref_images, ref_videos, ref_audios)
base_url = get_base_url_by_route()
if not manual_assets:
image_urls, video_urls, audio_urls = await cls._create_assets(
ref_images, ref_videos, ref_audios, base_url, create_mode
)
body = cls._build_body(
model, prompt, resolution, ratio, duration_s,
gen_audio, web_search, seed,
image_urls, video_urls, audio_urls,
use_asset_protocol=True,
generation_mode=canonical_mode,
return_last_frame=normalized["return_last_frame"],
asset_creation_mode=normalized["asset_creation_mode"],
)
pretty = json.dumps(body, ensure_ascii=False, indent=2)
print("[Seedance自动过审] ── 提交请求体 ─────────────────")
print(f"[Seedance自动过审] POST {base_url}/v1/video/generations")
print(pretty)
try:
result_path, last_frame_url = await cls._submit_poll_download(body, base_url)
except Exception as exc:
message = format_seedance_generation_error(exc)
if message == str(exc):
raise
raise RuntimeError(message) from None
last_frame = None
if return_last and last_frame_url:
last_frame = await cls._url_to_tensor(last_frame_url)
return io.NodeOutput(InputImpl.VideoFromFile(result_path), last_frame)
@staticmethod
def _build_body(model, prompt, resolution, ratio, duration_s,
gen_audio, web_search, seed,
image_urls, video_urls, audio_urls,
use_asset_protocol=False,
generation_mode=_MODE_MULTIMODAL,
return_last_frame=False,
asset_creation_mode="auto"):
"""
按主站验证过的格式拼装请求体
- 多模态直传扁平格式HTTPS URL
- 自动创建素材content 格式支持 asset:// 协议
- 首帧/首尾帧始终使用 content 格式以携带 frame role
"""
del web_search, use_asset_protocol
canonical_mode = SeedanceAutoPass._canonical_generation_mode(
generation_mode,
len(image_urls),
len(video_urls),
len(audio_urls),
)
duration = 5 if duration_s == "自动" else int(str(duration_s).removesuffix(""))
manual_assets = asset_creation_mode == "manual"
prepared = {
"first_frame": None if manual_assets else (image_urls[0] if image_urls else None),
"last_frame": None if manual_assets else (image_urls[1] if len(image_urls) > 1 else None),
"reference_images": (
[] if manual_assets or canonical_mode != "multimodal" else list(image_urls)
),
"reference_videos": [] if manual_assets else list(video_urls),
"reference_audios": [] if manual_assets else list(audio_urls),
}
return build_seedance_video_body({
"actual_model": model,
"prompt": prompt,
"generation_mode": canonical_mode,
"asset_creation_mode": asset_creation_mode,
"assets": {
"images": list(image_urls) if manual_assets else [],
"videos": list(video_urls) if manual_assets else [],
"audios": list(audio_urls) if manual_assets else [],
},
"duration": duration,
"resolution": resolution,
"aspect_ratio": "auto" if ratio == "智能" else ratio,
"generate_audio": bool(gen_audio),
"return_last_frame": bool(return_last_frame),
"seed": int(seed),
}, prepared)
@classmethod
async def _submit_poll_download(cls, body, base_url):
"""Use the same submit/poll/download client as o1key 视频生成."""
file_handle, save_path = tempfile.mkstemp(
suffix=".mp4",
prefix="seedance_autopass_",
)
os.close(file_handle)
client = SeedanceClient()
client.base_url = base_url
try:
return await client.generate_async(
body=body,
save_path=save_path,
use_new_format=True,
)
except BaseException:
try:
if os.path.isfile(save_path):
os.remove(save_path)
except OSError:
pass
raise
@staticmethod
def _extract_video_url(sdata: dict):
"""上游已调整:成品直链放在 data.result_url(此前是 localhost 占位)。
优先取 result_url保留递归下钻兜底沿 data/content/result/videos
跳过 localhost/127.0.0.1防上游结构再变"""
def _usable(v):
return (isinstance(v, str)
and v.startswith(("http://", "https://"))
and "localhost" not in v
and "127.0.0.1" not in v)
# 首选:data.result_url(兼容顶层 result_url
for holder in (sdata.get("data"), sdata):
if isinstance(holder, dict) and _usable(holder.get("result_url")):
return holder["result_url"]
# 兜底:递归下钻找第一个可用直链
def _walk(node):
if isinstance(node, dict):
for key in ("url", "video_url"):
if _usable(node.get(key)):
return node.get(key)
for key in ("data", "content", "result", "videos"):
if key in node:
found = _walk(node.get(key))
if found:
return found
elif isinstance(node, list):
for item in node:
found = _walk(item)
if found:
return found
return None
return _walk(sdata)
@staticmethod
def _to_first_pil(image):
"""统一接收 ComfyUI IMAGE tensor 或批量节点加载的 PIL 图片。"""
if isinstance(image, Image.Image):
return image.convert("RGB") if image.mode != "RGB" else image
pil_images = tensor_to_pil(image)
if not pil_images:
return None
pil = pil_images[0]
return pil.convert("RGB") if pil.mode != "RGB" else pil
@staticmethod
def _video_source(video):
if hasattr(video, "get_stream_source"):
return video.get_stream_source()
if isinstance(video, dict):
return (
video.get("video")
or video.get("path")
or video.get("file")
or video.get("filename")
or video.get("source_path")
)
if isinstance(video, (str, os.PathLike, py_io.BytesIO)):
return video
for attribute in ("source_path", "path", "video", "file", "filename"):
if hasattr(video, attribute):
return getattr(video, attribute)
return None
@staticmethod
def _stream_size(source, label):
if isinstance(source, py_io.BytesIO):
return source.getbuffer().nbytes
if isinstance(source, (str, os.PathLike)):
path = os.fspath(source)
if not os.path.isfile(path):
raise ValueError(f"{label}文件不存在")
return os.path.getsize(path)
raise ValueError(f"无法读取{label}文件")
@classmethod
def _validate_reference_media(cls, ref_images, ref_videos, ref_audios):
"""Validate every reference before the first upload or asset request."""
for index, image in enumerate(ref_images, start=1):
label = f"参考图片{index}"
pil = cls._to_first_pil(image)
if pil is None:
raise ValueError(f"{label}无法读取")
validate_seedance_reference_dimensions(pil.width, pil.height, label)
buffer = py_io.BytesIO()
pil.save(buffer, format="PNG")
if not 0 < buffer.getbuffer().nbytes <= SEEDANCE_REFERENCE_IMAGE_MAX_BYTES:
raise ValueError(f"{label}文件大小必须在 1 字节到 30MB 之间")
for index, video in enumerate(ref_videos, start=1):
label = f"参考视频{index}"
source = cls._video_source(video)
size = cls._stream_size(source, label)
if not 0 < size <= SEEDANCE_REFERENCE_VIDEO_MAX_BYTES:
raise ValueError(f"{label}文件大小必须在 1 字节到 512MB 之间")
if isinstance(source, (str, os.PathLike)):
extension = os.path.splitext(os.fspath(source))[1].lower()
if extension not in {".mp4", ".mov"}:
raise ValueError(f"{label}格式须为 mp4 或 mov")
try:
if hasattr(video, "get_dimensions"):
width, height = video.get_dimensions()
else:
import av
if isinstance(source, py_io.BytesIO):
source.seek(0)
with av.open(source, mode="r") as container:
stream = next(
(item for item in container.streams if item.type == "video"),
None,
)
if stream is None:
raise ValueError
width, height = stream.width, stream.height
except Exception:
raise ValueError(f"{label}不是可读取的视频") from None
finally:
if isinstance(source, py_io.BytesIO):
source.seek(0)
validate_seedance_reference_dimensions(
width,
height,
label,
require_video_pixel_range=True,
)
supported_audio = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg"}
for index, audio in enumerate(ref_audios, start=1):
label = f"参考音频{index}"
if isinstance(audio, (str, os.PathLike)):
extension = os.path.splitext(os.fspath(audio))[1].lower()
if extension not in supported_audio:
raise ValueError(f"{label}格式须为 wav/mp3/m4a/aac/flac/ogg")
size = cls._stream_size(audio, label)
elif isinstance(audio, dict) and audio.get("waveform") is not None:
waveform = audio["waveform"]
try:
sample_count = int(waveform.shape[-1])
except Exception:
raise ValueError(f"{label}无法读取") from None
size = 44 + sample_count * 2
else:
raise ValueError(f"{label}无法读取")
if not 0 < size <= SEEDANCE_REFERENCE_AUDIO_MAX_BYTES:
raise ValueError(f"{label}文件大小必须在 1 字节到 100MB 之间")
@staticmethod
async def _url_to_tensor(url):
"""Download a requested last frame without forwarding API credentials."""
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status != 200:
return None
data = await response.read()
if not data or len(data) > 32 * 1024 * 1024:
return None
with Image.open(py_io.BytesIO(data)) as image:
image.load()
return pil_to_tensor([image.convert("RGB")])
except Exception as exc:
print(f"[Seedance自动过审] 末帧图片下载失败: {exc}")
return None
@staticmethod
async def _upload_assets(ref_images, ref_videos, ref_audios, base_url):
"""直传模式:上传素材到R2,返回公开URL列表"""
image_urls = []
for idx, image in enumerate(ref_images, start=1):
pil = SeedanceAutoPass._to_first_pil(image)
if pil is None:
raise ValueError(f"{idx} 张参考图片无法读取")
print(f"[Seedance自动过审][直传] 上传参考图片 {idx}/{len(ref_images)}...")
image_urls.append(await upload_image(pil, base_url=base_url))
video_urls = []
for idx, v in enumerate(ref_videos, start=1):
print(f"[Seedance自动过审][直传] 上传参考视频 {idx}/{len(ref_videos)}...")
video_urls.append(await upload_video(v, base_url=base_url))
audio_urls = []
for idx, a in enumerate(ref_audios, start=1):
print(f"[Seedance自动过审][直传] 上传参考音频 {idx}/{len(ref_audios)}...")
audio_urls.append(await upload_audio(a, base_url=base_url))
return image_urls, video_urls, audio_urls
@staticmethod
async def _create_assets(ref_images, ref_videos, ref_audios, base_url, create_mode):
"""先上传到 R2,再调用匹配的素材 API,返回 asset:// 格式的 URL 列表。"""
request_types = {"HC": "hc", "Doubao": "doubao"}
request_type = request_types.get(create_mode)
if request_type is None:
raise ValueError(f"不支持的素材创建模式:{create_mode}")
element_client = SeedanceElementClient(base_url=base_url)
items = [
("image", index, value, len(ref_images))
for index, value in enumerate(ref_images, start=1)
] + [
("video", index, value, len(ref_videos))
for index, value in enumerate(ref_videos, start=1)
] + [
("audio", index, value, len(ref_audios))
for index, value in enumerate(ref_audios, start=1)
]
semaphore = asyncio.Semaphore(3)
async def prepare(kind, index, value, total):
async with semaphore:
labels = {"image": "图片", "video": "视频", "audio": "音频"}
asset_types = {"image": "Image", "video": "Video", "audio": "Audio"}
label = labels[kind]
print(f"[Seedance自动过审][自动创建] 上传参考{label} {index}/{total}...")
if kind == "image":
pil = SeedanceAutoPass._to_first_pil(value)
if pil is None:
raise ValueError(f"{index} 张参考图片无法读取")
uploaded_url = await upload_image(pil, base_url=base_url)
elif kind == "video":
uploaded_url = await upload_video(value, base_url=base_url)
else:
uploaded_url = await upload_audio(value, base_url=base_url)
if not str(uploaded_url).startswith("https://"):
raise ValueError(f"参考{label}上传后未获得 HTTPS 公网地址")
name = f"参考{label}{index}"
result = await element_client.create_hc_asset_and_wait(
name=name,
asset_url=uploaded_url,
asset_type=asset_types[kind],
request_type=request_type,
)
element_id = str(result.get("Id") or "").strip()
if not element_id:
raise RuntimeError(f"创建{label}素材失败,未返回 ID")
return kind, f"asset://{element_id}"
prepared = await asyncio.gather(*(prepare(*item) for item in items))
result = {"image": [], "video": [], "audio": []}
for kind, asset_url in prepared:
result[kind].append(asset_url)
return result["image"], result["video"], result["audio"]
NODE_CLASS_MAPPINGS = {
"SeedanceAutoPass": SeedanceAutoPass,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SeedanceAutoPass": "Seedance 全能生成视频",
}
+859
View File
@@ -0,0 +1,859 @@
"""Seedance 全能生成视频(批量)。
注册节点使用文件下方的 V3 实现功能参数与单节点一致媒体端口替换为
图片视频和音频文件夹路径并保留分批并发与输出目录控制
"""
import os
import json
import asyncio
from pathlib import Path
import aiohttp
from comfy_api.latest import io
from ..utils.config import (
get_api_key_or_raise,
get_base_url_by_route,
)
from ..utils.r2_uploader import upload_image, upload_video
from ..utils.file_utils import load_images_from_folder
from ..utils.image_utils import parse_batch_prompts
from ..utils.video_task import (
PollDeadline,
check_interrupt,
download_video_to_file,
interruptible_sleep,
run_with_interrupt,
InterruptProcessingException,
)
from ..utils.http_error import async_request_with_retry
from .seedance_autopass import (
SeedanceAutoPass,
_BASE_MODELS,
_SUCCESS_STATUSES,
_FAILURE_STATUSES,
_RATIOS,
_DURATIONS,
_RESOLUTIONS,
_MODEL_ROUTES,
_GENERATION_MODES,
_MODE_MULTIMODAL,
_MODE_FIRST_FRAME,
_MODE_FIRST_LAST,
_MODE_TEXT,
_FAST_RESOLUTIONS,
_LIMITED_RESOLUTION_MODELS,
_normalize_model_route,
_resolve_model_matrix,
_resolve_asset_creation_mode,
)
from .seedance_video import (
_MM_MODELS,
_MM_RESOLUTIONS,
_resolve_model,
_is_new_format_model,
_check_fast_resolution,
)
try:
import folder_paths
FOLDER_PATHS_AVAILABLE = True
except ImportError:
FOLDER_PATHS_AVAILABLE = False
print("⚠️ SeedanceAutoPassBatch: folder_paths 不可用,将无法定位 output 目录")
_LABEL = "Seedance全能生成视频(批量)"
_MAX_BATCH = 10 # 每批最多并发提交数(用户要求硬上限 10)
_VIDEO_EXTENSIONS = {".mp4", ".mov"}
_AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg"}
_MM_DEFAULT_MODEL = _MM_MODELS[0]
def load_video_paths_from_folder(folder_path: str):
"""从文件夹按文件名升序收集 mp4/mov 视频路径。"""
folder_path = (folder_path or "").strip()
if not folder_path:
return []
path = Path(folder_path)
if not path.exists():
raise ValueError(f"视频文件夹不存在: {folder_path}")
if not path.is_dir():
raise ValueError(f"视频路径不是文件夹: {folder_path}")
files = [
f for f in path.iterdir()
if f.is_file() and f.suffix.lower() in _VIDEO_EXTENSIONS
]
files.sort(key=lambda x: x.name.lower())
return [str(f) for f in files]
def load_audio_paths_from_folder(folder_path: str):
"""从文件夹按文件名升序收集常见音频文件路径。"""
folder_path = (folder_path or "").strip()
if not folder_path:
return []
path = Path(folder_path)
if not path.exists():
raise ValueError(f"音频文件夹不存在: {folder_path}")
if not path.is_dir():
raise ValueError(f"音频路径不是文件夹: {folder_path}")
files = [
f for f in path.iterdir()
if f.is_file() and f.suffix.lower() in _AUDIO_EXTENSIONS
]
files.sort(key=lambda x: x.name.lower())
return [str(f) for f in files]
def _unique_output_path(out_dir: str, stem: str, ext: str = ".mp4") -> str:
"""在 out_dir 下生成不覆盖已有文件的目标路径。"""
os.makedirs(out_dir, exist_ok=True)
candidate = os.path.join(out_dir, f"{stem}{ext}")
if not os.path.exists(candidate):
return candidate
counter = 1
while True:
candidate = os.path.join(out_dir, f"{stem}_{counter}{ext}")
if not os.path.exists(candidate):
return candidate
counter += 1
def _build_mm_body(model_id, prompt, resolution, ratio, duration_s,
gen_audio, web_search, seed,
ref_url, kind):
"""
SeedanceMultiModal 的规则构建请求体
kind: "image" | "video"
"""
use_new_format = _is_new_format_model(model_id)
# 构建 content 列表
content = []
if kind == "image":
content.append({
"type": "image_url",
"image_url": {"url": ref_url},
"role": "reference_image",
})
else:
content.append({
"type": "video_url",
"video_url": {"url": ref_url},
"role": "reference_video",
})
if prompt:
content.append({"type": "text", "text": prompt})
duration = int(duration_s.replace("", "")) if duration_s != "自动" else -1
if use_new_format:
# 新格式:顶层 content,文本放最前面
ordered = [item for item in content if item.get("type") == "text"]
ordered += [item for item in content if item.get("type") != "text"]
body = {
"model": model_id,
"content": ordered,
"duration": duration if duration != -1 else 5,
"resolution": resolution,
"ratio": ratio if ratio not in ("智能",) else "16:9",
"generate_audio": gen_audio,
"watermark": False,
"return_last_frame": False,
}
if seed != 0:
body["seed"] = seed
else:
# 旧格式:metadata.content
metadata: dict = {
"resolution": resolution,
"watermark": False,
"content": content,
}
if ratio != "智能":
metadata["ratio"] = ratio
if duration != -1:
metadata["duration"] = duration
if gen_audio:
metadata["generate_audio"] = True
if web_search:
metadata["tools"] = [{"type": "web_search"}]
if seed != 0:
metadata["seed"] = seed
body = {
"model": model_id,
"prompt": prompt if prompt else " ",
"metadata": metadata,
}
if kind == "image":
body["image"] = ref_url
return body
class _LegacySeedanceAutoPassBatch:
"""Seedance 2.0 自动过审 · 批量(文件夹 → 并发生成 → 落地 output)"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"提示词": ("STRING", {"multiline": True, "default": ""}),
"图片文件夹": ("STRING", {"default": "", "multiline": False}),
"视频文件夹": ("STRING", {"default": "", "multiline": False}),
"模型": (_MM_MODELS, {"default": _MM_DEFAULT_MODEL}),
"分辨率": (_MM_RESOLUTIONS, {"default": "720p"}),
"宽高比": (_RATIOS, {"default": "智能"}),
"时长": (_DURATIONS, {"default": "5秒"}),
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
"联网搜索": (["关闭", "打开"], {"default": "关闭"}),
"每批并发数": ("INT", {"default": _MAX_BATCH, "min": 1, "max": _MAX_BATCH}),
"输出子目录": ("STRING", {"default": "", "multiline": False}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("结果汇总",)
FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = "comfyui_o1key/Seedance"
async def generate(self, **kwargs):
prompt = (kwargs["提示词"] or "").strip()
image_dir = (kwargs.get("图片文件夹") or "").strip()
video_dir = (kwargs.get("视频文件夹") or "").strip()
model_label = kwargs["模型"]
resolution = kwargs["分辨率"]
ratio = kwargs["宽高比"]
duration_s = kwargs["时长"]
gen_audio = kwargs["生成音频"] == "打开"
web_search = kwargs["联网搜索"] == "打开"
batch_size = max(1, min(int(kwargs.get("每批并发数", _MAX_BATCH)), _MAX_BATCH))
sub_dir = (kwargs.get("输出子目录") or "").strip()
seed = int(kwargs.get("seed", 0))
# 解析真实模型 ID 并做分辨率校验
model_id = _resolve_model(model_label)
_check_fast_resolution(model_id, resolution)
if not prompt:
raise ValueError("提示词不能为空")
if not image_dir and not video_dir:
raise ValueError("请至少填写「图片文件夹」或「视频文件夹」其中一个路径")
# ── 收集任务清单(每个文件一个任务)─────────────────────────────
tasks_meta = [] # [(kind, source, stem)]
if image_dir:
images = load_images_from_folder(image_dir)
if not images:
print(f"[{_LABEL}] 图片文件夹无可用图片: {image_dir}")
for info in images:
pil = info.image
if pil.mode == "RGBA":
pil = pil.convert("RGB")
tasks_meta.append(("image", pil, info.filename))
if video_dir:
videos = load_video_paths_from_folder(video_dir)
if not videos:
print(f"[{_LABEL}] 视频文件夹无可用视频(mp4/mov): {video_dir}")
for vpath in videos:
stem = os.path.splitext(os.path.basename(vpath))[0]
tasks_meta.append(("video", vpath, stem))
if not tasks_meta:
raise ValueError("两个文件夹中都没有可用素材,无法生成")
# ── 输出目录 ──────────────────────────────────────────────────
if not FOLDER_PATHS_AVAILABLE:
raise RuntimeError("folder_paths 不可用,无法定位 ComfyUI output 目录")
out_dir = os.path.abspath(folder_paths.get_output_directory())
if sub_dir:
out_dir = os.path.join(out_dir, sub_dir)
os.makedirs(out_dir, exist_ok=True)
base_url = get_base_url_by_route()
api_key = get_api_key_or_raise()
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
total = len(tasks_meta)
num_batches = (total + batch_size - 1) // batch_size
print(f"[{_LABEL}] 共 {total} 个任务,按每批 {batch_size} 个并发,分 {num_batches} 批提交")
results = []
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):
check_interrupt()
start = batch_idx * batch_size
batch = tasks_meta[start:start + batch_size]
print(f"[{_LABEL}] 执行第 {batch_idx + 1}/{num_batches}"
f"{start + 1}-{start + len(batch)}...")
coros = [
self._run_one(
session, base_url, headers, out_dir,
model_id, prompt, resolution, ratio, duration_s,
gen_audio, web_search, seed,
kind, source, stem, start + i + 1, total,
)
for i, (kind, source, stem) in enumerate(batch)
]
# return_exceptions=True:单个任务异常不影响同批其它任务
batch_results = await asyncio.gather(*coros, return_exceptions=True)
for r in batch_results:
if isinstance(r, InterruptProcessingException):
raise r # 用户主动取消,立即中止整批流程
if isinstance(r, Exception):
results.append({"success": False, "error": str(r), "source": "?"})
else:
results.append(r)
# ── 汇总 ──────────────────────────────────────────────────────
success = [r for r in results if r.get("success")]
failed = [r for r in results if not r.get("success")]
lines = [
f"任务总数: {total}",
f"成功: {len(success)}",
f"失败: {len(failed)}",
f"输出目录: {out_dir}",
]
if success:
lines.append("")
lines.append("成功文件:")
lines.extend(f"{os.path.basename(r['path'])}" for r in success)
if failed:
lines.append("")
lines.append("失败项:")
lines.extend(f"{os.path.basename(str(r.get('source', '?')))} - {r.get('error')}"
for r in failed)
summary = "\n".join(lines)
print(f"[{_LABEL}] 全部完成 — 成功 {len(success)} / 失败 {len(failed)}")
return (summary,)
async def _run_one(
self, session, base_url, headers, out_dir,
model_id, prompt, resolution, ratio, duration_s,
gen_audio, web_search, seed,
kind, source, stem, task_no, total,
) -> dict:
"""提交 → 轮询 → 下载单个任务;异常收敛为 result dict(中断异常除外)。"""
try:
# 1) 参考素材 → 公开 URL
if kind == "image":
ref_url = await upload_image(source, base_url=base_url)
else:
ref_url = await upload_video(source, base_url=base_url)
body = _build_mm_body(
model_id, prompt, resolution, ratio, duration_s,
gen_audio, web_search, seed,
ref_url, kind,
)
# 2) 提交
submit_url = f"{base_url}/v1/video/generations"
check_interrupt()
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", submit_url, json=body, headers=headers,
prefix=f"{_LABEL} 提交[{task_no}/{total}]: ",
))
text = await resp.text()
data = json.loads(text)
task_id = data.get("task_id") or data.get("id")
if not task_id:
raise RuntimeError(f"未返回 task_id,响应:{text[:300]}")
print(f"[{_LABEL}] 任务 {task_no}/{total} 已提交,task_id={task_id}")
# 3) 轮询
status_url = f"{base_url}/v1/video/generations/{task_id}"
deadline = PollDeadline(label=f"{_LABEL}#{task_no}")
interval = 4
video_url = None
download_headers = None
while True:
deadline.check()
check_interrupt()
async with session.get(status_url, headers=headers) as sresp:
stext = await sresp.text()
if sresp.status != 200:
raise RuntimeError(f"状态查询失败 ({sresp.status}): {stext[:300]}")
sdata = json.loads(stext)
status = (sdata.get("status")
or (sdata.get("data") or {}).get("status")
or "").lower()
if status in _SUCCESS_STATUSES:
video_url = SeedanceAutoPass._extract_video_url(sdata)
if not video_url:
video_url = f"{base_url}/v1/videos/{task_id}/content"
# 平台自有域名(含 content 代理)需带鉴权;第三方 CDN 直链绝不带 Bearer
if video_url.startswith(base_url):
download_headers = headers
break
if status in _FAILURE_STATUSES:
raise RuntimeError(f"生成失败,响应:{stext[:300]}")
await interruptible_sleep(interval)
interval = min(interval * 1.5, 15)
# 4) 下载到 output 目录
out_path = _unique_output_path(out_dir, stem)
await download_video_to_file(
session, video_url, out_path,
headers=download_headers, label=f"{_LABEL}#{task_no}",
)
print(f"[{_LABEL}] 任务 {task_no}/{total} 成功 ✓ → {out_path}")
return {"success": True, "path": out_path, "source": source}
except InterruptProcessingException:
raise
except Exception as e:
src_name = source if kind == "video" else stem
print(f"[{_LABEL}] 任务 {task_no}/{total} 失败 ✗ - {e}")
return {"success": False, "error": str(e), "source": src_name}
# V3 批量节点。保留上方旧实现只用于读取该版本文件时的历史语义说明;
# 注册映射使用下面这个同名类,节点 ID 不变,因此旧工作流仍能识别节点。
class SeedanceAutoPassBatch(io.ComfyNode):
"""Seedance 全能生成视频的文件夹批量版本。"""
@classmethod
def define_schema(cls):
web_search = lambda: io.Combo.Input(
"联网搜索",
options=["关闭", "打开"],
default="关闭",
advanced=True,
)
return io.Schema(
node_id="SeedanceAutoPassBatch",
display_name="Seedance 全能生成视频(批量)",
description=(
"参数与 Seedance 全能生成视频一致,媒体改为文件夹路径。"
"多模态素材按文件名排序后按序号组成任务;首尾帧按序号一一配对。"
"提示词支持用单独一行的 --- 分隔多条,与素材做笛卡尔组合。"
),
category="comfyui_o1key/Seedance",
inputs=[
io.String.Input(
"提示词",
multiline=True,
default="",
tooltip=(
"支持批量提示词:用单独一行的 --- 分隔多个提示词,"
"每个素材会与每个提示词组合成一个任务(素材数 × 提示词数)。"
"--- 不单独占一行时按单个提示词处理。"
),
),
io.DynamicCombo.Input(
"生成模式",
options=[
io.DynamicCombo.Option(
_MODE_MULTIMODAL,
[
web_search(),
io.String.Input(
"图片文件夹",
default="",
tooltip="图片按文件名升序,每张参与一条任务。",
),
io.String.Input(
"视频文件夹",
default="",
tooltip="支持 mp4/mov,与图片和音频按排序后的序号配对。",
),
io.String.Input(
"音频文件夹",
default="",
tooltip="支持 wav/mp3/m4a/aac/flac/ogg,按序号配对。",
),
],
),
io.DynamicCombo.Option(
_MODE_FIRST_FRAME,
[
web_search(),
io.String.Input(
"首帧图片文件夹",
default="",
tooltip="文件夹内每张图片分别生成一个视频。",
),
],
),
io.DynamicCombo.Option(
_MODE_FIRST_LAST,
[
web_search(),
io.String.Input(
"首帧图片文件夹",
default="",
tooltip="按文件名升序与尾帧图片一一配对。",
),
io.String.Input(
"尾帧图片文件夹",
default="",
tooltip="图片数量必须与首帧文件夹一致。",
),
],
),
io.DynamicCombo.Option(
_MODE_TEXT,
[
web_search(),
io.Int.Input(
"生成数量",
default=1,
min=1,
max=100,
tooltip=(
"使用相同参数批量提交的文生视频任务数。"
"批量提示词模式下总任务数为本数量 × 提示词数。"
),
),
],
),
],
tooltip="切换后仅显示当前模式需要的文件夹输入。",
),
io.Combo.Input("主模型", options=_BASE_MODELS, default="seedance 2.0"),
io.Combo.Input("模型线路", options=_MODEL_ROUTES, default="国内"),
io.Combo.Input("分辨率", options=_RESOLUTIONS, default="720p"),
io.Combo.Input("宽高比", options=_RATIOS, default="智能"),
io.Combo.Input("时长", options=_DURATIONS, default="5秒"),
io.Combo.Input("生成音频", options=["关闭", "打开"], default="关闭"),
io.Int.Input(
"seed",
default=0,
min=0,
max=0xffffffffffffffff,
advanced=True,
),
io.Int.Input(
"每批并发数",
default=_MAX_BATCH,
min=1,
max=_MAX_BATCH,
advanced=True,
),
io.String.Input(
"输出子目录",
default="",
advanced=True,
tooltip="留空时直接保存到 ComfyUI output 目录。",
),
],
outputs=[io.String.Output(display_name="结果汇总")],
is_output_node=True,
)
@staticmethod
def _mode_inputs(kwargs):
mode_inputs = kwargs.get("生成模式")
if isinstance(mode_inputs, dict):
return mode_inputs.get("生成模式", _MODE_MULTIMODAL), mode_inputs
if isinstance(mode_inputs, str):
return mode_inputs, kwargs
return _MODE_MULTIMODAL, kwargs
@classmethod
def _build_tasks(cls, generation_mode, mode_inputs, prompt):
"""构造最终任务列表:媒体任务 × 提示词。
提示词用单独一行的 --- 分隔时进入批量提示词模式每个媒体任务与每个
提示词组合成一条任务否则所有任务共用同一个提示词
"""
batch_prompts = parse_batch_prompts(prompt)
media_tasks = cls._build_media_tasks(generation_mode, mode_inputs, prompt)
if not batch_prompts:
for task in media_tasks:
task["prompt"] = prompt
return media_tasks
width = len(str(len(batch_prompts)))
tasks = []
for media_task in media_tasks:
for prompt_index, task_prompt in enumerate(batch_prompts, start=1):
task = dict(media_task)
task["prompt"] = task_prompt
task["stem"] = f"{media_task['stem']}_p{prompt_index:0{width}d}"
task["source"] = f"{media_task['source']} [提示词{prompt_index}]"
tasks.append(task)
return tasks
@classmethod
def _build_media_tasks(cls, generation_mode, mode_inputs, prompt):
"""读取文件夹并按当前模式构造媒体任务(不含提示词)。"""
if generation_mode not in _GENERATION_MODES:
raise ValueError(f"不支持的生成模式:{generation_mode}")
if generation_mode == _MODE_TEXT:
if not prompt:
raise ValueError("文生视频模式下提示词不能为空")
count = int(mode_inputs.get("生成数量", 1))
return [
{
"images": [], "videos": [], "audios": [],
"stem": f"seedance_text_{index:03d}",
"source": f"文生视频任务{index}",
}
for index in range(1, count + 1)
]
if generation_mode == _MODE_FIRST_FRAME:
images = load_images_from_folder(mode_inputs.get("首帧图片文件夹", ""))
if not images:
raise ValueError("首帧图片文件夹中没有可用图片")
return [
{
"images": [item.image], "videos": [], "audios": [],
"stem": item.filename, "source": item.source_path,
}
for item in images
]
if generation_mode == _MODE_FIRST_LAST:
first_images = load_images_from_folder(mode_inputs.get("首帧图片文件夹", ""))
last_images = load_images_from_folder(mode_inputs.get("尾帧图片文件夹", ""))
if not first_images or not last_images:
raise ValueError("首帧和尾帧图片文件夹都必须包含可用图片")
if len(first_images) != len(last_images):
raise ValueError(
"首帧与尾帧图片数量必须一致:"
f"当前首帧 {len(first_images)} 张,尾帧 {len(last_images)}"
)
return [
{
"images": [first.image, last.image],
"videos": [], "audios": [],
"stem": first.filename,
"source": f"{first.source_path} + {last.source_path}",
}
for first, last in zip(first_images, last_images)
]
images = load_images_from_folder(mode_inputs.get("图片文件夹", ""))
videos = load_video_paths_from_folder(mode_inputs.get("视频文件夹", ""))
audios = load_audio_paths_from_folder(mode_inputs.get("音频文件夹", ""))
task_count = max(len(images), len(videos), len(audios), 1 if prompt else 0)
if task_count == 0:
raise ValueError("请至少填写一个包含可用素材的文件夹,或提供提示词")
tasks = []
for index in range(task_count):
image = images[index] if index < len(images) else None
video = videos[index] if index < len(videos) else None
audio = audios[index] if index < len(audios) else None
sources = [item for item in (image, video, audio) if item is not None]
if sources:
first = sources[0]
stem = first.filename if hasattr(first, "filename") else Path(first).stem
source = first.source_path if hasattr(first, "source_path") else str(first)
else:
stem = f"seedance_{index + 1:03d}"
source = f"多模态任务{index + 1}"
tasks.append({
"images": [image.image] if image is not None else [],
"videos": [video] if video is not None else [],
"audios": [audio] if audio is not None else [],
"stem": stem,
"source": source,
})
return tasks
@classmethod
async def execute(cls, **kwargs):
generation_mode, mode_inputs = cls._mode_inputs(kwargs)
prompt = (kwargs.get("提示词", "") or "").strip()
base_model = kwargs["主模型"]
model_route = _normalize_model_route(kwargs["模型线路"])
model = _resolve_model_matrix(base_model, model_route)
resolution = kwargs["分辨率"]
ratio = kwargs["宽高比"]
duration_s = kwargs["时长"]
gen_audio = kwargs["生成音频"] == "打开"
web_search = mode_inputs.get("联网搜索", "关闭") == "打开"
create_mode = _resolve_asset_creation_mode(model_route)
seed = int(kwargs.get("seed", 0))
batch_size = max(1, min(int(kwargs.get("每批并发数", _MAX_BATCH)), _MAX_BATCH))
sub_dir = (kwargs.get("输出子目录") or "").strip()
if model in _LIMITED_RESOLUTION_MODELS and resolution not in _FAST_RESOLUTIONS:
raise ValueError(f"{model} 仅支持 {'/'.join(sorted(_FAST_RESOLUTIONS))}")
tasks = cls._build_tasks(generation_mode, mode_inputs, prompt)
for task in tasks:
SeedanceAutoPass._validate_mode_inputs(
generation_mode, base_model, task["prompt"],
task["images"], task["videos"], task["audios"],
)
SeedanceAutoPass._validate_dynamic_parameters(
base_model, model_route, duration_s,
task["images"], task["videos"], task["audios"],
)
SeedanceAutoPass._validate_reference_media(
task["images"], task["videos"], task["audios"],
)
if not FOLDER_PATHS_AVAILABLE:
raise RuntimeError("folder_paths 不可用,无法定位 ComfyUI output 目录")
out_dir = os.path.abspath(folder_paths.get_output_directory())
if sub_dir:
out_dir = os.path.join(out_dir, sub_dir)
os.makedirs(out_dir, exist_ok=True)
base_url = get_base_url_by_route()
headers = {
"Authorization": f"Bearer {get_api_key_or_raise()}",
"Content-Type": "application/json",
}
total = len(tasks)
num_batches = (total + batch_size - 1) // batch_size
batch_prompt_count = len(parse_batch_prompts(prompt))
if batch_prompt_count:
print(
f"[{_LABEL}] 批量提示词模式:{total // batch_prompt_count} 个素材 × "
f"{batch_prompt_count} 个提示词"
)
print(f"[{_LABEL}] 共 {total} 个任务,每批最多 {batch_size} 个并发,共 {num_batches}")
results = []
connector = aiohttp.TCPConnector(ssl=False, limit=0, limit_per_host=0)
async with aiohttp.ClientSession(connector=connector) as session:
for batch_index in range(num_batches):
check_interrupt()
start = batch_index * batch_size
batch = tasks[start:start + batch_size]
coroutines = [
cls._run_one(
session, base_url, headers, out_dir,
model, resolution, ratio, duration_s,
gen_audio, web_search, seed, create_mode,
generation_mode, task, start + offset + 1, total,
)
for offset, task in enumerate(batch)
]
batch_results = await asyncio.gather(*coroutines, return_exceptions=True)
for result in batch_results:
if isinstance(result, InterruptProcessingException):
raise result
if isinstance(result, Exception):
results.append({"success": False, "error": str(result), "source": "?"})
else:
results.append(result)
succeeded = [item for item in results if item.get("success")]
failed = [item for item in results if not item.get("success")]
lines = [
f"任务总数: {total}",
f"成功: {len(succeeded)}",
f"失败: {len(failed)}",
f"输出目录: {out_dir}",
]
if succeeded:
lines.extend(["", "成功文件:"])
lines.extend(f"{os.path.basename(item['path'])}" for item in succeeded)
if failed:
lines.extend(["", "失败项:"])
lines.extend(
f"{os.path.basename(str(item.get('source', '?')))} - {item.get('error')}"
for item in failed
)
summary = "\n".join(lines)
print(f"[{_LABEL}] 全部完成 — 成功 {len(succeeded)} / 失败 {len(failed)}")
return io.NodeOutput(summary)
@classmethod
async def _run_one(
cls, session, base_url, headers, out_dir,
model, resolution, ratio, duration_s,
gen_audio, web_search, seed, create_mode,
generation_mode, task, task_no, total,
):
prompt = task["prompt"]
try:
image_urls, video_urls, audio_urls = await SeedanceAutoPass._create_assets(
task["images"], task["videos"], task["audios"], base_url, create_mode
)
body = SeedanceAutoPass._build_body(
model, prompt, resolution, ratio, duration_s,
gen_audio, web_search, seed,
image_urls, video_urls, audio_urls,
use_asset_protocol=True,
generation_mode=generation_mode,
)
response = await run_with_interrupt(async_request_with_retry(
session,
"POST",
f"{base_url}/v1/video/generations",
json=body,
headers=headers,
prefix=f"{_LABEL} 提交[{task_no}/{total}]: ",
))
response_text = await response.text()
data = json.loads(response_text)
task_id = data.get("task_id") or data.get("id")
if not task_id:
raise RuntimeError(f"未返回 task_id,响应:{response_text[:300]}")
status_url = f"{base_url}/v1/video/generations/{task_id}"
deadline = PollDeadline(label=f"{_LABEL}#{task_no}")
interval = 4
download_headers = None
while True:
deadline.check()
check_interrupt()
async with session.get(status_url, headers=headers) as status_response:
status_text = await status_response.text()
if status_response.status != 200:
raise RuntimeError(
f"状态查询失败 ({status_response.status}): {status_text[:300]}"
)
status_data = json.loads(status_text)
status = (
status_data.get("status")
or (status_data.get("data") or {}).get("status")
or ""
).lower()
if status in _SUCCESS_STATUSES:
video_url = SeedanceAutoPass._extract_video_url(status_data)
if not video_url:
video_url = f"{base_url}/v1/videos/{task_id}/content"
if video_url.startswith(base_url):
download_headers = headers
break
if status in _FAILURE_STATUSES:
raise RuntimeError(f"生成失败,响应:{status_text[:300]}")
await interruptible_sleep(interval)
interval = min(interval * 1.5, 15)
out_path = _unique_output_path(out_dir, task["stem"])
await download_video_to_file(
session, video_url, out_path,
headers=download_headers,
label=f"{_LABEL}#{task_no}",
)
print(f"[{_LABEL}] 任务 {task_no}/{total} 成功 ✓ → {out_path}")
return {"success": True, "path": out_path, "source": task["source"]}
except InterruptProcessingException:
raise
except Exception as error:
print(f"[{_LABEL}] 任务 {task_no}/{total} 失败 ✗ - {error}")
return {"success": False, "error": str(error), "source": task["source"]}
NODE_CLASS_MAPPINGS = {
"SeedanceAutoPassBatch": SeedanceAutoPassBatch,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SeedanceAutoPassBatch": "Seedance 全能生成视频(批量)",
}
+127
View File
@@ -0,0 +1,127 @@
"""
Seedance 素材节点
节点列表:
- SeedanceElementCreate: 创建图片视频或音频素材
"""
import json
from ..clients.seedance_element_client import SeedanceElementClient
from ..utils.image_utils import tensor_to_pil
from ..utils.r2_uploader import upload_audio, upload_image, upload_video
from ..utils.config import get_base_url_by_route
from ..utils.seedance_assets import SeedanceAssetService
class SeedanceElementCreate:
"""Seedance 创建素材"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"素材名称": ("STRING", {"default": ""}),
"请求模式": (["HC", "Doubao"], {"default": "HC"}),
},
"optional": {
"照片": ("IMAGE",),
"视频": ("VIDEO",),
"音频": ("AUDIO",),
"素材描述": ("STRING", {"multiline": True, "default": ""}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("查询信息", "提取ID")
FUNCTION = "create_element"
CATEGORY = "comfyui_o1key/Seedance"
async def create_element(self, **kwargs):
name = kwargs["素材名称"].strip()
request_mode = kwargs.get("请求模式", "HC")
# 旧名称保留为执行期别名,兼容未经过前端迁移的 API 工作流。
image_tensor = kwargs.get("照片")
video = kwargs.get("视频")
audio = kwargs.get("音频")
if image_tensor is None:
image_tensor = kwargs.get("真人照片")
if video is None:
video = kwargs.get("真人视频")
if audio is None:
audio = kwargs.get("真人音频")
if request_mode in {"标准", "高并发"}:
request_mode = "HC"
request_types = {"HC": "hc", "Doubao": "doubao"}
if request_mode not in request_types:
raise ValueError(f"不支持的请求模式:{request_mode}")
request_type = request_types[request_mode]
base_url = get_base_url_by_route()
sources = []
if image_tensor is not None:
sources.append(("Image", image_tensor))
if video is not None:
sources.append(("Video", video))
if audio is not None:
sources.append(("Audio", audio))
if len(sources) != 1:
raise ValueError(
f"{request_mode} 模式必须在照片、视频、音频中恰好提供一种素材"
)
asset_type, source = sources[0]
if asset_type == "Image":
pil_images = tensor_to_pil(source)
if not pil_images:
raise ValueError("无法读取图片")
pil_image = pil_images[0]
if pil_image.mode == "RGBA":
pil_image = pil_image.convert("RGB")
print(f"[Seedance素材][{request_mode}] 上传图片中...")
asset_url = await upload_image(pil_image, base_url=base_url)
elif asset_type == "Video":
print(f"[Seedance素材][{request_mode}] 上传视频中...")
asset_url = await upload_video(source, base_url=base_url)
else:
print(f"[Seedance素材][{request_mode}] 上传音频中...")
asset_url = await upload_audio(source, base_url=base_url)
if not asset_url.startswith("https://"):
raise ValueError(f"{request_mode} 素材上传后未获得 HTTPS 公网地址")
print(
f"[Seedance素材][{request_mode}] 创建素材: "
f"Name={name or '(空)'}, AssetType={asset_type}, URL=<临时地址已折叠>"
)
service = SeedanceAssetService(
client=SeedanceElementClient(base_url=base_url),
)
result = await service.create_from_url(
name=name,
asset_url=asset_url,
asset_type=asset_type,
request_type=request_type,
)
element_id = result.get("Id")
if not element_id:
raise RuntimeError(f"{request_mode} 素材已激活但未返回 Id,响应:{result}")
create_response = result.get("_create_response", {})
response_json = json.dumps(create_response, ensure_ascii=False, indent=2)
print(f"[Seedance素材] 创建成功 → {element_id}")
return (response_json, element_id)
# ── 节点注册 ──────────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {
"SeedanceElementCreate": SeedanceElementCreate,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SeedanceElementCreate": "Seedance 创建素材",
}
+388 -249
View File
@@ -1,13 +1,9 @@
"""
Seedance 视频生成节点
节点列表:
- Seedance: 文生视频 / 图生视频 / 首尾帧生视频根据图片输入自动切换模式
Seedance 多模态参考生视频节点
"""
import base64
import io
import io as py_io
import json
import os
import tempfile
import aiohttp
@@ -16,19 +12,78 @@ import torch
from ..clients.seedance_client import SeedanceClient
from ..clients.gemini_client import GeminiAPIClient
from ..utils.image_utils import tensor_to_pil, pil_to_tensor
from ..utils.r2_uploader import upload_video, upload_audio
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.r2_uploader import upload_image, upload_video, upload_audio
from ..utils.config import get_base_url_by_route
from .seedance_autopass import (
SeedanceAutoPass,
_BASE_MODELS as _LATEST_BASE_MODELS,
_MODEL_CAPABILITIES as _LATEST_MODEL_CAPABILITIES,
_MODEL_ROUTES as _LATEST_MODEL_ROUTES,
_RATIOS as _LATEST_RATIOS,
_resolve_model_matrix as _resolve_latest_model_matrix,
)
from ..utils.video_task import format_seedance_generation_error
from comfy_api.latest import InputImpl
from comfy_api.latest import InputImpl, io
# ── 模型列表 ──────────────────────────────────────────────────────────────────
_MODELS = [
"doubao-seedance-2-0-260128",
]
_MM_MODEL_LABEL_TO_ID = {
"seedance 2.0 海外版(高并发)": "dreamina-seedance-2-0-hc",
"seedance 2.0 fast 海外版(高并发)": "dreamina-seedance-2-0-fast-hc",
"seedance 2.0 mini 海外版(高并发)": "dreamina-seedance-2-0-mini-hc",
"seedance 2.0 海外版": "seedance-2-0-260128-d",
"seedance 2.0 海外版(破限)": "seedance-2-0-260128-d-ep",
"seedance 2.0 fast 海外版": "seedance-2-0-fast-260128-d",
"seedance 2.0 fast 海外版(破限)": "seedance-2-0-fast-d-ep",
"seedance 2.0 mini 海外版": "seedance-2-0-mini-260615-d",
"seedance 2.0 mini 海外版(破限)": "seedance-2-0-mini-260615-d-ep",
}
_MM_MODELS = list(_MM_MODEL_LABEL_TO_ID.keys())
_RESOLUTIONS = ["720p", "1080p", "480p"]
# 多模态节点矩阵式模型配置(主模型 × 模型线路 → 实际模型ID)
_MM_BASE_MODELS = list(_LATEST_BASE_MODELS)
_MM_ROUTES = list(dict.fromkeys([*_LATEST_MODEL_ROUTES, "国内"]))
_MM_MODEL_MATRIX = {
("seedance 2.0", "国内"): "doubao-seedance-2-0-260128-max",
("seedance 2.0 fast", "国内"): "doubao-seedance-2-0-fast-260128-max",
("seedance 2.0 mini", "国内"): "doubao-seedance-2-0-mini-260615-max",
("seedance 2.5", "国内"): "doubao-seedance-2-5-260628-max",
}
_MM_RATIOS = list(_LATEST_RATIOS)
_MM_MAX_CAPABILITIES = _LATEST_MODEL_CAPABILITIES["seedance 2.5"]
_MM_IMAGE_LIMIT = _MM_MAX_CAPABILITIES["images"]
_MM_VIDEO_LIMIT = _MM_MAX_CAPABILITIES["videos"]
_MM_AUDIO_LIMIT = _MM_MAX_CAPABILITIES["audios"]
def _resolve_model_matrix(base_model: str, route: str) -> str:
"""矩阵式解析:主模型 + 模型线路 → 实际模型ID"""
model = _MM_MODEL_MATRIX.get((base_model, route))
if model is not None:
return model
return _resolve_latest_model_matrix(base_model, route)
def _resolve_model(model: str) -> str:
"""兼容批量节点保存的模型展示名;真实模型 ID 原样返回。"""
return _MM_MODEL_LABEL_TO_ID.get(model, model)
# 多模态参考生视频支持 4k
_MM_RESOLUTIONS = ["720p", "1080p", "4k", "480p"]
# 受限模型仅支持 480p / 720p(前端做选择时报错提示)
_LIMITED_RESOLUTION_MODELS = {
"doubao-seedance-2-0-fast-260128",
"doubao-seedance-2-0-fast-260128-max",
"doubao-seedance-2-0-mini-260615-max",
"dreamina-seedance-2-0-fast-hc",
"seedance-2-0-fast-260128-d",
"seedance-2-0-fast-d-ep",
"dreamina-seedance-2-0-mini-hc",
}
_FAST_UNSUPPORTED_RESOLUTIONS = ["1080p", "4k"]
_MAX_IMAGE_BYTES = 30 * 1024 * 1024
_MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024
@@ -36,9 +91,37 @@ _MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024
# ── 模型能力判断 ──────────────────────────────────────────────────────────────
def _supports_camera_fixed(model: str) -> bool:
"""2.0 系列不支持固定镜头"""
return False # 当前仅 2.0 模型,均不支持
def _check_fast_resolution(model: str, resolution: str):
"""受限模型不支持 1080p / 4k,提交前拦截(保留旧函数名兼容调用方)。"""
if model in _LIMITED_RESOLUTION_MODELS and resolution in _FAST_UNSUPPORTED_RESOLUTIONS:
raise ValueError(
f"{model} 不支持 {resolution} 分辨率,"
f"请改用 {_FAST_UNSUPPORTED_RESOLUTIONS} 以外的分辨率(如 720p)。"
)
def _is_new_format_model(model: str) -> bool:
"""判断是否使用新请求体格式的模型(顶层 contentrole 用 subject
端点与老格式模型相同均为 /v1/video/generations
仅请求体结构不同由此函数控制节点侧如何拼装 body
"""
return model in [
"dreamina-seedance-2-0-hc",
"dreamina-seedance-2-0-fast-hc",
"dreamina-seedance-2-0-mini-hc",
"dreamina-seedance-2-5-hc",
"seedance-2-0-260128-d",
"seedance-2-0-260128-d-ep",
"seedance-2-0-fast-260128-d",
"seedance-2-0-fast-d-ep",
"seedance-2-0-mini-260615-d",
"seedance-2-0-mini-260615-d-ep",
"doubao-seedance-2-0-260128-max",
"doubao-seedance-2-0-fast-260128-max",
"doubao-seedance-2-0-mini-260615-max",
"doubao-seedance-2-5-260628-max",
]
# ── 工具函数 ──────────────────────────────────────────────────────────────────
@@ -47,14 +130,14 @@ def _format_mb(size_bytes: int) -> str:
return f"{size_bytes / 1024 / 1024:.2f}MB"
def _tensor_to_base64_url(tensor, label: str = "图片") -> str:
"""ComfyUI IMAGE tensor → data:image/png;base64,xxx"""
def _tensor_to_png(tensor, label: str = "图片"):
"""ComfyUI IMAGE tensor → (PIL.Image RGB, png_bytes),并做单图大小校验。"""
pil_images = tensor_to_pil(tensor)
image = pil_images[0]
if image.mode == "RGBA":
image = image.convert("RGB")
buffered = io.BytesIO()
buffered = py_io.BytesIO()
image.save(buffered, format="PNG")
image_bytes = buffered.getvalue()
image_size = len(image_bytes)
@@ -65,8 +148,17 @@ def _tensor_to_base64_url(tensor, label: str = "图片") -> str:
f"{_format_mb(_MAX_IMAGE_BYTES)} 限制,请先压缩或缩小图片。"
)
b64 = base64.b64encode(image_bytes).decode("utf-8")
return f"data:image/png;base64,{b64}"
return image, image_bytes
async def _tensor_to_uploaded_url(tensor, base_url: str, label: str = "图片") -> str:
"""ComfyUI IMAGE tensor → 上传到 R2 并返回公网 URL(保留单图大小校验)。
与参考视频/音频一致图片改走上传 URL 而非内联 base64
以显著缩小请求体避免触碰 64MB 请求体上限
"""
image, _ = _tensor_to_png(tensor, label)
return await upload_image(image, base_url=base_url)
def _validate_request_body_size(body: dict, tag: str):
@@ -82,6 +174,57 @@ def _validate_request_body_size(body: dict, tag: str):
)
_REQUEST_LOG_SECRET_FIELDS = {
"authorization",
"api_key",
"apikey",
"api-key",
"access_token",
"token",
}
_REQUEST_LOG_BASE64_FIELDS = {"data", "b64_json", "base64", "image_base64"}
_REQUEST_LOG_URL_FIELDS = {"url", "image", "image_url", "video_url", "audio_url"}
def _sanitize_request_body_for_log(value, field_name: str = ""):
"""Copy a request body for logging without exposing credentials or media URLs."""
normalized_field = field_name.lower()
if normalized_field in _REQUEST_LOG_SECRET_FIELDS:
return "<redacted>"
if isinstance(value, dict):
return {
key: _sanitize_request_body_for_log(item, str(key))
for key, item in value.items()
}
if isinstance(value, list):
return [_sanitize_request_body_for_log(item, field_name) for item in value]
if isinstance(value, (bytes, bytearray)):
return f"<binary data, {len(value)} bytes>"
if not isinstance(value, str):
return value
if normalized_field in _REQUEST_LOG_BASE64_FIELDS:
return f"<base64 data, {len(value)} chars>"
header, separator, data = value.partition(",")
if separator and header.lower().startswith("data:") and ";base64" in header.lower():
return f"{header},<base64 data, {len(data)} chars>"
if normalized_field in _REQUEST_LOG_URL_FIELDS and value.lower().startswith(("http://", "https://")):
return "<temporary URL omitted>"
return value
def _log_original_request_body(body: dict):
safe_body = _sanitize_request_body_for_log(body)
print(
"[SeedanceMultiModal] 原始请求体(临时 URL 与媒体数据已折叠):\n"
f"{json.dumps(safe_body, ensure_ascii=False, indent=2)}"
)
async def _url_to_tensor(url: str) -> torch.Tensor:
"""从 URL 下载图片并转为 ComfyUI IMAGE tensor,失败时返回 None"""
@@ -92,7 +235,7 @@ async def _url_to_tensor(url: str) -> torch.Tensor:
if resp.status != 200:
return None
data = await resp.read()
img = Image.open(io.BytesIO(data)).convert("RGB")
img = Image.open(py_io.BytesIO(data)).convert("RGB")
return pil_to_tensor([img])
except Exception as e:
print(f"[Seedance] 末帧图片下载失败: {e}")
@@ -139,280 +282,241 @@ def _make_callbacks(tag: str, pbar):
# ── 统一节点 ─────────────────────────────────────────────────────────────────
#
# 模式由图片输入自动判断:
# 首帧 = None → T2V 文生视频 (联网搜索生效)
# 首帧 = 图片,尾帧 = None → I2V 图生视频 (固定镜头生效,当前 2.0 不支持故忽略)
# 首帧 = 图片,尾帧 = 图片 → FlipFlop 首尾帧(联网搜索/固定镜头均忽略)
class Seedance:
"""Seedance 视频生成(文生视频 / 图生视频 / 首尾帧,自动判断模式)"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"提示词": ("STRING", {"multiline": True, "default": ""}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
"模型": (_MODELS, {"default": "doubao-seedance-2-0-260128"}),
"分辨率": (_RESOLUTIONS, {"default": "720p"}),
"宽高比": (["16:9", "adaptive", "9:16", "1:1", "4:3", "3:4", "21:9"],
{"default": "16:9"}),
"时长秒(-1=自动)": ("INT", {"default": 5, "min": -1, "max": 30, "step": 1}),
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
"联网搜索": (["关闭", "打开"], {"default": "关闭"}),
"返回末帧图片": (["关闭", "打开"], {"default": "关闭"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
},
"optional": {
"首帧图片": ("IMAGE",),
"尾帧图片": ("IMAGE",),
},
}
RETURN_TYPES = ("VIDEO", "IMAGE")
RETURN_NAMES = ("视频", "末帧图片")
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Seedance"
async def generate(self, **kwargs):
prompt = kwargs["提示词"].strip()
model = kwargs["模型"]
resolution = kwargs["分辨率"]
ratio = kwargs["宽高比"]
duration = kwargs["时长秒(-1=自动)"]
gen_audio = kwargs["生成音频"] == "打开"
web_search = kwargs["联网搜索"] == "打开"
return_last = kwargs["返回末帧图片"] == "打开"
seed = kwargs.get("seed", 0)
first_image = kwargs.get("首帧图片", None)
last_image = kwargs.get("尾帧图片", None)
# 模式判断
if first_image is None and last_image is not None:
raise ValueError("请同时接入首帧图片,或仅接入首帧图片。")
if first_image is None:
mode = "t2v"
tag = "Seedance文生视频"
file_prefix = "seedance_t2v"
elif last_image is None:
mode = "i2v"
tag = "Seedance图生视频"
file_prefix = "seedance_i2v"
else:
mode = "flipflop"
tag = "Seedance首尾帧"
file_prefix = "seedance_flip"
if not prompt:
raise ValueError("提示词不能为空。")
if duration == -1 and mode == "t2v":
pass # 2.0 均支持自动时长
elif duration == -1 and mode != "t2v":
pass # 2.0 均支持自动时长
metadata: dict = {
"resolution": resolution,
"watermark": False,
}
if ratio != "adaptive":
metadata["ratio"] = ratio
if duration != -1:
metadata["duration"] = duration
if gen_audio:
metadata["generate_audio"] = True
if return_last:
metadata["return_last_frame"] = True
if seed != 0:
metadata["seed"] = seed
# 模式专属参数
if mode == "t2v":
if web_search:
metadata["tools"] = [{"type": "web_search"}]
body = {
"model": model,
"prompt": prompt,
"metadata": metadata,
}
elif mode == "i2v":
first_url = _tensor_to_base64_url(first_image, "首帧图片")
metadata["content"] = [
{
"type": "image_url",
"image_url": {"url": first_url},
"role": "first_frame",
},
{"type": "text", "text": prompt},
]
body = {
"model": model,
"prompt": prompt,
"images": [first_url],
"metadata": metadata,
}
else: # flipflop
first_url = _tensor_to_base64_url(first_image, "首帧图片")
last_url = _tensor_to_base64_url(last_image, "尾帧图片")
metadata["content"] = [
{
"type": "image_url",
"image_url": {"url": first_url},
"role": "first_frame",
},
{
"type": "image_url",
"image_url": {"url": last_url},
"role": "last_frame",
},
{"type": "text", "text": prompt},
]
body = {
"model": model,
"prompt": prompt,
"images": [first_url],
"metadata": metadata,
}
_validate_request_body_size(body, tag)
# 保存路径(临时文件,避免与下游保存节点重复落盘)
_, save_path = tempfile.mkstemp(suffix=".mp4", prefix=f"{file_prefix}_")
client = SeedanceClient()
client.base_url = get_base_url_by_route(kwargs.get("网络线路", "全球加速"))
pbar = _make_pbar()
on_stage, on_prog = _make_callbacks(tag, pbar)
try:
result_path, last_frame_url = await client.generate_async(
body=body, save_path=save_path,
on_stage=on_stage, on_progress=on_prog,
)
last_frame_tensor = None
if return_last and last_frame_url:
last_frame_tensor = await _url_to_tensor(last_frame_url)
return (InputImpl.VideoFromFile(result_path), last_frame_tensor)
finally:
_show_balance()
# ── 多模态参考生视频节点 ──────────────────────────────────────────────────────
class SeedanceMultiModal:
"""Seedance 2.0 多模态参考生视频(参考图片 + 参考视频 + 参考音频 + 文本)"""
class SeedanceMultiModal(io.ComfyNode):
"""Seedance 2.0 / 2.5 多模态参考生视频"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"提示词": ("STRING", {"multiline": True, "default": ""}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
"模型": (_MODELS, {"default": "doubao-seedance-2-0-260128"}),
"分辨率": (_RESOLUTIONS, {"default": "720p"}),
"宽高比": (["adaptive", "16:9", "9:16", "1:1", "4:3", "3:4", "21:9"],
{"default": "adaptive"}),
"时长秒(-1=自动)": ("INT", {"default": 5, "min": -1, "max": 15, "step": 1}),
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
"联网搜索": (["关闭", "打开"], {"default": "关闭"}),
"返回末帧图片": (["关闭", "打开"], {"default": "关闭"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
},
"optional": {
"参考图片": ("IMAGE",),
"参考视频1": ("VIDEO",),
"参考视频2": ("VIDEO",),
"参考视频3": ("VIDEO",),
"参考音频1": ("AUDIO",),
"参考音频2": ("AUDIO",),
"参考音频3": ("AUDIO",),
},
}
def define_schema(cls):
return io.Schema(
node_id="SeedanceMultiModal",
display_name="Seedance 多模态参考生视频",
description="支持动态增加参考图片、视频、音频,以及渐进填写素材 ID。",
category="comfyui_o1key/Seedance",
inputs=[
io.String.Input("提示词", multiline=True, default=""),
io.Combo.Input("主模型", options=_MM_BASE_MODELS, default="seedance 2.0"),
io.Combo.Input("模型线路", options=_MM_ROUTES, default="国内"),
io.Combo.Input("分辨率", options=_MM_RESOLUTIONS, default="720p"),
io.Combo.Input(
"宽高比",
options=_MM_RATIOS,
default="智能",
),
io.Combo.Input(
"时长",
options=["自动"] + [f"{i}" for i in range(4, 31)],
default="5秒",
),
io.Combo.Input("生成音频", options=["关闭", "打开"], default="关闭"),
io.Combo.Input("联网搜索", options=["关闭", "打开"], default="关闭"),
io.Combo.Input("返回末帧图片", options=["关闭", "打开"], default="关闭"),
io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff),
io.Autogrow.Input(
"参考图片",
optional=True,
template=io.Autogrow.TemplateNames(
input=io.Image.Input("参考图片"),
names=[f"参考图片{i}" for i in range(1, _MM_IMAGE_LIMIT + 1)],
min=0,
),
),
io.Autogrow.Input(
"参考视频",
optional=True,
template=io.Autogrow.TemplateNames(
input=io.Video.Input("参考视频"),
names=[f"参考视频{i}" for i in range(1, _MM_VIDEO_LIMIT + 1)],
min=0,
),
),
io.Autogrow.Input(
"参考音频",
optional=True,
template=io.Autogrow.TemplateNames(
input=io.Audio.Input("参考音频"),
names=[f"参考音频{i}" for i in range(1, _MM_AUDIO_LIMIT + 1)],
min=0,
),
),
# 保留已发布的 9/3/3 widget 顺序;只更新图片素材的显示名称,
# 2.5 的新增素材 ID 仍仅追加,避免 widgets_values 发生位置漂移。
*[io.String.Input(f"图片素材ID{i}", default="") for i in range(1, 10)],
*[io.String.Input(f"视频素材ID{i}", default="") for i in range(1, 4)],
*[io.String.Input(f"音频素材ID{i}", default="") for i in range(1, 4)],
*[io.String.Input(f"图片素材ID{i}", default="") for i in range(10, _MM_IMAGE_LIMIT + 1)],
*[io.String.Input(f"视频素材ID{i}", default="") for i in range(4, _MM_VIDEO_LIMIT + 1)],
*[io.String.Input(f"音频素材ID{i}", default="") for i in range(4, _MM_AUDIO_LIMIT + 1)],
],
outputs=[
io.Video.Output(display_name="视频"),
io.Image.Output(display_name="末帧图片"),
],
)
RETURN_TYPES = ("VIDEO", "IMAGE")
RETURN_NAMES = ("视频", "末帧图片")
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Seedance"
INPUT_IS_LIST = True
@staticmethod
def _autogrow_values(kwargs, group_name, legacy_prefix, legacy_max):
"""读取 V3 动态输入,同时兼容旧工作流的编号端口直接调用。"""
group = kwargs.get(group_name)
if isinstance(group, dict):
return [value for value in group.values() if value is not None]
async def generate(self, **kwargs):
# INPUT_IS_LIST=True 时所有参数都是列表,取第一个元素
def _first(value):
if isinstance(value, list):
return value[0] if value else None
return value
return [
value
for index in range(1, legacy_max + 1)
if (value := _first(kwargs.get(f"{legacy_prefix}{index}"))) is not None
]
@classmethod
async def execute(cls, **kwargs):
return await cls.generate(**kwargs)
@classmethod
async def generate(cls, **kwargs):
# V3 传入标量;保留列表兼容旧版 INPUT_IS_LIST 的直接调用。
def _first(v, default=None):
if isinstance(v, list):
return v[0] if v else default
return v if v is not None else default
prompt = _first(kwargs.get("提示词"), "").strip()
model = _first(kwargs.get("模型"))
base_model = _first(kwargs.get("模型"), "seedance 2.0")
route = _first(kwargs.get("模型线路"), "国内")
model = _resolve_model_matrix(base_model, route)
resolution = _first(kwargs.get("分辨率"))
ratio = _first(kwargs.get("宽高比"))
duration = _first(kwargs.get("时长秒(-1=自动)"), 5)
duration_str = _first(kwargs.get("时长"), "5秒")
# 解析时长:自动 → -1,其他提取数字
if duration_str == "自动":
duration = -1
else:
duration = int(duration_str.replace("", ""))
gen_audio = _first(kwargs.get("生成音频"), "关闭") == "打开"
web_search = _first(kwargs.get("联网搜索"), "关闭") == "打开"
return_last = _first(kwargs.get("返回末帧图片"), "关闭") == "打开"
seed = _first(kwargs.get("seed"), 0)
network_route = _first(kwargs.get("网络线路"), "全球加速")
# 参考图片:INPUT_IS_LIST 时是 [tensor, tensor, ...] 列表,直接保留
raw_images = kwargs.get("参考图片", None)
ref_images = [img for img in raw_images if img is not None] if raw_images else None
ref_images = cls._autogrow_values(kwargs, "参考图片", "参考图片", _MM_IMAGE_LIMIT)
ref_videos = cls._autogrow_values(kwargs, "参考视频", "参考视频", _MM_VIDEO_LIMIT)
ref_audios = cls._autogrow_values(kwargs, "参考音频", "参考音频", _MM_AUDIO_LIMIT)
# 新名称优先;旧名称兼容未经过浏览器迁移的 API 工作流。
element_ids = []
for i in range(1, _MM_IMAGE_LIMIT + 1):
current_id = _first(kwargs.get(f"图片素材ID{i}"), "").strip()
legacy_id = _first(kwargs.get(f"真人素材ID{i}"), "").strip()
element_ids.append(current_id or legacy_id)
video_ids = [_first(kwargs.get(f"视频素材ID{i}"), "").strip() for i in range(1, _MM_VIDEO_LIMIT + 1)]
audio_ids = [_first(kwargs.get(f"音频素材ID{i}"), "").strip() for i in range(1, _MM_AUDIO_LIMIT + 1)]
ref_videos = [_first(kwargs.get(f"参考视频{i}")) for i in range(1, 4)]
ref_audios = [_first(kwargs.get(f"参考音频{i}")) for i in range(1, 4)]
ref_videos = [v for v in ref_videos if v is not None]
ref_audios = [a for a in ref_audios if a is not None]
element_ids = [eid for eid in element_ids if eid]
video_ids = [vid for vid in video_ids if vid]
audio_ids = [aid for aid in audio_ids if aid]
# ── 校验 ──────────────────────────────────────────────────────────
has_image = bool(ref_images)
has_video = len(ref_videos) > 0
has_audio = len(ref_audios) > 0
has_element = len(element_ids) > 0
has_video_id = len(video_ids) > 0
has_audio_id = len(audio_ids) > 0
if not has_image and not has_video and not has_audio and not prompt:
raise ValueError("至少需要提供参考图片、参考视频或提示词之一。")
if has_audio and not has_image and not has_video:
raise ValueError("不可单独输入音频,请至少连接一张参考图片一个参考视频。")
if not has_image and not has_video and not has_audio and not has_element and not has_video_id and not has_audio_id and not prompt:
raise ValueError("至少需要提供参考图片、参考视频、图片素材ID、视频素材ID或提示词之一。")
if base_model != "seedance 2.5" and (has_audio or has_audio_id) and not has_image and not has_video and not has_element and not has_video_id:
raise ValueError("不可单独输入音频,请至少连接一张参考图片一个参考视频或提供图片/视频素材ID")
# 国内线路仅替换实际模型 ID,能力限制与对应主模型保持一致。
SeedanceAutoPass._validate_dynamic_parameters(
base_model,
route,
duration_str,
[*ref_images, *element_ids],
[*ref_videos, *video_ids],
[*ref_audios, *audio_ids],
)
_check_fast_resolution(model, resolution)
# 判断是否使用新格式
use_new_format = _is_new_format_model(model)
# ── 构建 content 列表 ─────────────────────────────────────────────
content = []
base_url = get_base_url_by_route()
# 参考图片(批次,最多9张
# 真人素材 ID(与参考图片共用当前模型的图片额度,优先级最高
if has_element:
for idx, eid in enumerate(element_ids, start=1):
# 确保 element_id 格式正确
asset_url = eid if eid.startswith("asset://") else f"asset://{eid}"
# 第一个素材ID作为主体(subject),其余作为参考图片(reference_image
if idx == 1:
role = "subject" if use_new_format else "reference_image"
else:
role = "reference_image"
content.append({
"type": "image_url",
"image_url": {"url": asset_url},
"role": role,
})
role_label = "主体" if role == "subject" else "参考"
print(f"[SeedanceMultiModal] 使用图片素材ID{idx}{role_label}{asset_url}")
# 参考图片(2.5 最多 30 个独立槽位,用户自行选择连接哪几个)
if has_image:
imgs = ref_images[:9]
if len(ref_images) > 9:
print(f"[SeedanceMultiModal] 参考图片超过9张,仅取前9张(共{len(ref_images)}张)")
for idx, img_tensor in enumerate(imgs, start=1):
for idx, img_tensor in enumerate(ref_images, start=1):
# 每个 tensor 可能是 [1,H,W,C] 或 [H,W,C],统一确保有 batch 维
if img_tensor.dim() == 3:
img_tensor = img_tensor.unsqueeze(0)
url = _tensor_to_base64_url(img_tensor, f"参考图片{idx}")
url = await _tensor_to_uploaded_url(img_tensor, base_url, f"参考图片{idx}")
content.append({
"type": "image_url",
"image_url": {"url": url},
"role": "reference_image",
})
# 参考视频(最多3个)
# 参考视频(2.5 最多 10 个)
for v in ref_videos:
url = await upload_video(v)
url = await upload_video(v, base_url=base_url)
content.append({
"type": "video_url",
"video_url": {"url": url},
"role": "reference_video",
})
# 参考音频(最多3段
# 视频素材 ID(与参考视频共用当前模型的视频额度
for idx, vid in enumerate(video_ids, start=1):
asset_url = vid if vid.startswith("asset://") else f"asset://{vid}"
content.append({
"type": "video_url",
"video_url": {"url": asset_url},
"role": "reference_video",
})
print(f"[SeedanceMultiModal] 使用视频素材ID{idx}{asset_url}")
# 参考音频(2.5 最多 10 段)
for a in ref_audios:
url = await upload_audio(a)
url = await upload_audio(a, base_url=base_url)
content.append({
"type": "audio_url",
"audio_url": {"url": url},
"role": "reference_audio",
})
# 音频素材 ID(与参考音频共用当前模型的音频额度)
for idx, aid in enumerate(audio_ids, start=1):
asset_url = aid if aid.startswith("asset://") else f"asset://{aid}"
content.append({
"type": "audio_url",
"audio_url": {"url": asset_url},
"role": "reference_audio",
})
print(f"[SeedanceMultiModal] 使用音频素材ID{idx}{asset_url}")
# 文本提示词(放最后)
if prompt:
content.append({"type": "text", "text": prompt})
@@ -420,14 +524,43 @@ class SeedanceMultiModal:
if not content:
raise ValueError("content 为空,请至少提供参考图片、参考视频或提示词。")
# ── 构建请求体new-api 兼容格式)──────────────────────────────────
# ── 构建请求体 ──────────────────────────────────────────────────────
if use_new_format:
# 新格式:顶层 content
# 注意:文本提示词应该放在最前面
ordered_content = []
# 先添加文本
text_items = [item for item in content if item.get("type") == "text"]
ordered_content.extend(text_items)
# 再添加其他内容(图片、视频、音频)
non_text_items = [item for item in content if item.get("type") != "text"]
ordered_content.extend(non_text_items)
body = {
"model": model,
"content": ordered_content,
"duration": duration if duration != -1 else 5,
"resolution": resolution,
"ratio": ratio if ratio not in ("智能", "adaptive") else "16:9", # adaptive 为旧工作流兼容
"generate_audio": gen_audio,
"watermark": False,
"return_last_frame": return_last,
}
if seed != 0:
body["seed"] = seed
print(f"[SeedanceMultiModal] 新格式请求体: model={model}, content_len={len(ordered_content)}, duration={body['duration']}, resolution={body['resolution']}")
else:
# 老格式:metadata.content
metadata: dict = {
"resolution": resolution,
"watermark": False,
"content": content,
}
if ratio != "adaptive":
if ratio not in ("智能", "adaptive"): # adaptive 为旧工作流兼容
metadata["ratio"] = ratio
if duration != -1:
metadata["duration"] = duration
@@ -440,7 +573,7 @@ class SeedanceMultiModal:
if web_search:
metadata["tools"] = [{"type": "web_search"}]
# 顶层 image:取第一张参考图的 base64new-api 单图字段
# 顶层 image:取第一张图的 URL(优先真人素材,其次参考图的上传 URL
first_image_url = next(
(item["image_url"]["url"] for item in content if item["type"] == "image_url"),
None,
@@ -455,24 +588,32 @@ class SeedanceMultiModal:
body["image"] = first_image_url
_validate_request_body_size(body, "Seedance多模态")
_log_original_request_body(body)
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
_, save_path = tempfile.mkstemp(suffix=".mp4", prefix="seedance_mm_")
client = SeedanceClient()
client.base_url = get_base_url_by_route(network_route)
client.base_url = base_url
pbar = _make_pbar()
on_stage, on_prog = _make_callbacks("Seedance多模态", pbar)
try:
try:
result_path, last_frame_url = await client.generate_async(
body=body, save_path=save_path,
on_stage=on_stage, on_progress=on_prog,
use_new_format=use_new_format,
)
except Exception as exc:
message = format_seedance_generation_error(exc)
if message == str(exc):
raise
raise RuntimeError(message) from None
last_frame_tensor = None
if return_last and last_frame_url:
last_frame_tensor = await _url_to_tensor(last_frame_url)
return (InputImpl.VideoFromFile(result_path), last_frame_tensor)
return io.NodeOutput(InputImpl.VideoFromFile(result_path), last_frame_tensor)
finally:
_show_balance()
@@ -480,11 +621,9 @@ class SeedanceMultiModal:
# ── 节点注册 ──────────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {
"Seedance": Seedance,
"SeedanceMultiModal": SeedanceMultiModal,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Seedance": "Seedance 视频生成",
"SeedanceMultiModal": "Seedance 多模态参考生视频",
}
+5 -1
View File
@@ -12,6 +12,7 @@ from typing import Optional, Tuple
import torch
from ..utils.image_utils import tensor_to_pil
from ..utils.config import get_runtime_config_signature
from ..clients.sora_client import SoraClient
from ..models_config import (
get_enabled_sora_models,
@@ -242,6 +243,7 @@ class SoraVideo:
def __init__(self):
self.client = None
self._client_config_signature = None
@classmethod
def INPUT_TYPES(cls):
@@ -417,8 +419,10 @@ class SoraVideo:
pbar = ProgressBar(生成数量 if 生成数量 > 1 else 100)
try:
if self.client is None:
config_signature = get_runtime_config_signature()
if self.client is None or config_signature != self._client_config_signature:
self.client = SoraClient()
self._client_config_signature = config_signature
if 生成数量 == 1:
# ── 单个视频:保留详细进度(提交→轮询→下载)
+201 -86
View File
@@ -1,5 +1,5 @@
"""
全能LLM对话助手节点
提示词专家节点
ComfyUI 自定义节点通过 OpenAI 兼容协议调用市面上主流的 AI 对话大模型
支持多模态图片输入单轮对话非流式输出
@@ -15,25 +15,44 @@ from typing import Optional, Tuple, List
import torch
from PIL import Image
from comfy_api.latest import io
from ..utils.image_utils import tensor_to_pil
from ..utils.config import get_api_key_or_raise, get_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.config import get_api_key_or_raise, get_base_url_by_route
from ..utils.file_types import FileList
# ============================================================================
# 模型配置
# ============================================================================
DEFAULT_MODEL = "gpt-6-sol"
SUPPORTED_MODELS = [
DEFAULT_MODEL,
"gpt-6-astra",
"gpt-5.6-sol",
"gpt-5.5",
"gemini-3.1-pro-preview",
"deepseek-v4-pro",
"claude-opus-4-7",
"claude-opus-4-6",
"gemini-3.5-flash",
"claude-opus-5",
"doubao-seed-2.0-pro",
]
MAX_IMAGE_INPUTS = 9
REASONING_DEPTH_OPTIONS = ["", "", ""]
REASONING_DEPTH_VALUE_MAP = {
"": "low",
"": "medium",
"": "high",
"low": "low",
"medium": "medium",
"high": "high",
}
# 节点内实时 token 预览开关。设为 True 即可恢复原打字机效果。
ENABLE_NODE_TYPEWRITER_PREVIEW = False
# 图片缩放最大尺寸
MAX_IMAGE_DIMENSION = 1568
@@ -41,9 +60,18 @@ MAX_IMAGE_DIMENSION = 1568
MAX_IMAGE_SIZE = 20 * 1024 * 1024
class UniversalLLMChat:
def _collect_autogrow_inputs(value) -> list:
"""收集已连接的 Autogrow 输入,并兼容单个旧值。"""
if value is None:
return []
if isinstance(value, dict):
return [item for item in value.values() if item is not None]
return [value]
class UniversalLLMChat(io.ComfyNode):
"""
全能LLM对话助手
提示词专家
功能
- 通过 OpenAI 兼容协议调用主流大模型
@@ -52,51 +80,64 @@ class UniversalLLMChat:
- API 密钥和地址继承插件统一配置
"""
def __init__(self):
self._api_key = None
self._base_url = None
def _ensure_config(self):
"""延迟加载配置,首次调用时初始化"""
if self._api_key is None:
self._api_key = get_api_key_or_raise("O1KEY_API_KEY")
self._base_url = get_api_base_url()
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"网络线路": (NETWORK_ROUTE_OPTIONS, {
"default": "全球加速"
}),
"模型": (SUPPORTED_MODELS, {
"default": SUPPORTED_MODELS[0]
}),
"提示词": ("STRING", {
"default": "",
"multiline": True,
}),
},
"optional": {
"图片": ("IMAGE",),
"视频": ("VIDEO",),
"文件": ("FILE_LIST",),
"令牌": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "留空则使用默认 API Key",
}),
},
"hidden": {
"node_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("回复",)
FUNCTION = "generate"
CATEGORY = "text/generation"
OUTPUT_NODE = True
def define_schema(cls):
image_inputs = io.Autogrow.Input(
"图片组",
template=io.Autogrow.TemplateNames(
input=io.Image.Input("图片"),
names=[f"图片{i}" for i in range(1, MAX_IMAGE_INPUTS + 1)],
min=0,
),
tooltip=f"连接后自动增加输入端口,合计最多 {MAX_IMAGE_INPUTS} 张图片。",
)
return io.Schema(
node_id="UniversalLLMChat",
display_name="提示词专家",
category="text/generation",
inputs=[
io.Combo.Input(
"模型",
options=SUPPORTED_MODELS,
default=DEFAULT_MODEL,
),
io.Combo.Input(
"思考深度",
options=REASONING_DEPTH_OPTIONS,
default="",
),
io.Int.Input(
"seed",
default=0,
min=0,
max=2**31 - 1,
step=1,
display_mode=io.NumberDisplay.number,
control_after_generate=io.ControlAfterGenerate.randomize,
),
io.String.Input(
"api(可选)",
default="",
multiline=False,
placeholder="留空则使用默认 API Key",
),
io.String.Input(
"提示词",
default="",
multiline=True,
),
io.Video.Input("视频", optional=True),
io.Custom("FILE_LIST").Input("文件", optional=True),
image_inputs,
],
outputs=[
io.String.Output(display_name="回复"),
],
hidden=[io.Hidden.unique_id],
is_output_node=True,
# 接收旧版固定图片端口及旧令牌字段,避免旧工作流直接失效。
accept_all_inputs=True,
)
def _resize_image(self, img: Image.Image) -> Image.Image:
"""如果图片过长边超过限制,等比缩放"""
@@ -105,7 +146,7 @@ class UniversalLLMChat:
if max_dim > MAX_IMAGE_DIMENSION:
scale = MAX_IMAGE_DIMENSION / max_dim
new_w, new_h = int(w * scale), int(h * scale)
print(f"全能LLM: 图片缩放 {w}x{h} -> {new_w}x{new_h}")
print(f"提示词专家: 图片缩放 {w}x{h} -> {new_w}x{new_h}")
return img.resize((new_w, new_h), Image.Resampling.LANCZOS)
return img
@@ -197,14 +238,14 @@ class UniversalLLMChat:
},
})
print(f"全能LLM: 加载文件 {filename} ({file_size / 1024:.1f}KB, {mime})")
print(f"提示词专家: 加载文件 {filename} ({file_size / 1024:.1f}KB, {mime})")
return parts
def _build_input(
self,
prompt: str,
images: Optional[torch.Tensor] = None,
image_tensors: Optional[List[torch.Tensor]] = None,
file_paths: str = "",
file_list: Optional[FileList] = None,
video=None,
@@ -213,9 +254,11 @@ class UniversalLLMChat:
image_data_urls = []
pil_images_cache = [] # 保留 PIL Image 用于总体积重新编码
if images is not None:
pil_images = tensor_to_pil(images)
for img in pil_images:
if image_tensors:
for tensor in image_tensors:
if tensor is None:
continue
for img in tensor_to_pil(tensor):
img_resized = self._resize_image(img)
if img_resized.mode in ('RGBA', 'P'):
img_resized = img_resized.convert('RGB')
@@ -228,7 +271,7 @@ class UniversalLLMChat:
len(base64.b64decode(url.split(',', 1)[1])) for url in image_data_urls
)
if total_bytes > MAX_IMAGE_SIZE:
print(f"全能LLM: 图片总体积 {total_bytes / 1024 / 1024:.2f}MB 超过 {MAX_IMAGE_SIZE // 1024 // 1024}MB 限制,正在压缩...")
print(f"提示词专家: 图片总体积 {total_bytes / 1024 / 1024:.2f}MB 超过 {MAX_IMAGE_SIZE // 1024 // 1024}MB 限制,正在压缩...")
# 降质量
compressed = False
@@ -242,7 +285,7 @@ class UniversalLLMChat:
total_bytes = sum(len(base64.b64decode(u.split(',', 1)[1])) for u in new_urls)
if total_bytes <= MAX_IMAGE_SIZE:
image_data_urls = new_urls
print(f"全能LLM: 图片压缩完成,总体积 {total_bytes / 1024 / 1024:.2f}MB ({len(pil_images_cache)}张图片,质量{quality})")
print(f"提示词专家: 图片压缩完成,总体积 {total_bytes / 1024 / 1024:.2f}MB ({len(pil_images_cache)}张图片,质量{quality})")
compressed = True
break
@@ -260,12 +303,12 @@ class UniversalLLMChat:
total_bytes = sum(len(base64.b64decode(u.split(',', 1)[1])) for u in new_urls)
if total_bytes <= MAX_IMAGE_SIZE:
image_data_urls = new_urls
print(f"全能LLM: 图片压缩完成,总体积 {total_bytes / 1024 / 1024:.2f}MB ({len(pil_images_cache)}张图片,缩放{int(scale*100)}%)")
print(f"提示词专家: 图片压缩完成,总体积 {total_bytes / 1024 / 1024:.2f}MB ({len(pil_images_cache)}张图片,缩放{int(scale*100)}%)")
compressed = True
break
if not compressed:
print(f"全能LLM: 无法将 {len(pil_images_cache)} 张图片压缩到 {MAX_IMAGE_SIZE // 1024 // 1024}MB 以内,请减少图片数量或降低分辨率")
print(f"提示词专家: 无法将 {len(pil_images_cache)} 张图片压缩到 {MAX_IMAGE_SIZE // 1024 // 1024}MB 以内,请减少图片数量或降低分辨率")
raise ValueError(f"图片总体积 {total_bytes / 1024 / 1024:.2f}MB 超过限制,无法压缩到 {MAX_IMAGE_SIZE // 1024 // 1024}MB 以内")
# 处理视频输入(ComfyUI VIDEO 类型)
@@ -305,7 +348,7 @@ class UniversalLLMChat:
ext = os.path.splitext(vp)[1].lower()
mime = mime_map.get(ext, "video/mp4")
file_size = os.path.getsize(vp)
print(f"全能LLM: 加载视频 {os.path.basename(vp)} ({file_size / 1024 / 1024:.1f}MB, {mime})")
print(f"提示词专家: 加载视频 {os.path.basename(vp)} ({file_size / 1024 / 1024:.1f}MB, {mime})")
with open(vp, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
video_url_str = f"data:{mime};base64,{b64}"
@@ -314,7 +357,7 @@ class UniversalLLMChat:
file_parts = []
if file_list:
for fd in file_list:
print(f"全能LLM: 使用文件 {fd.filename}{fd.extension} ({fd.size / 1024:.1f}KB)")
print(f"提示词专家: 使用文件 {fd.filename}{fd.extension} ({fd.size / 1024:.1f}KB)")
file_parts.append({
"type": "file",
"file": {
@@ -369,44 +412,89 @@ class UniversalLLMChat:
except Exception:
pass
@classmethod
def execute(
cls,
模型: str,
思考深度: str = "",
seed: int = 0,
提示词: str = "",
视频=None,
文件: Optional[FileList] = None,
**kwargs,
) -> io.NodeOutput:
worker = cls()
result = worker.generate(
模型=模型,
思考深度=思考深度,
seed=seed,
提示词=提示词,
视频=视频,
文件=文件,
node_id=str(cls.hidden.unique_id or ""),
**kwargs,
)
return io.NodeOutput(*result)
def generate(
self,
模型: str,
提示词: str,
网络线路: str = "全球加速",
图片: Optional[torch.Tensor] = None,
思考深度: str = "",
seed: int = 0,
提示词: str = "",
视频=None,
文件: Optional[FileList] = None,
令牌: str = "",
node_id: str = "",
**kwargs,
) -> Tuple[str]:
start_time = time.time()
try:
self._ensure_config()
self._base_url = get_base_url_by_route(网络线路)
# 用户填写 api 时覆盖默认 API Key;保留旧字段名兼容旧工作流。
api_value = kwargs.get(
"api(可选)",
kwargs.get("分组令牌(可留空)", kwargs.get("令牌", "")),
)
effective_api_key = str(api_value).strip() if api_value else ""
if not effective_api_key:
effective_api_key = get_api_key_or_raise("O1KEY_API_KEY")
base_url = get_base_url_by_route()
reasoning_effort = REASONING_DEPTH_VALUE_MAP.get(思考深度, "medium")
# 如果用户传入了自定义令牌,则覆盖默认 API Key
effective_api_key = 令牌.strip() if 令牌 and 令牌.strip() else self._api_key
image_tensors = _collect_autogrow_inputs(kwargs.get("图片组"))
if not image_tensors:
# 兼容 Autogrow 改造前的「图片」及「图片1~图片9」端口。
旧图片 = kwargs.get("图片")
if 旧图片 is not None:
image_tensors.append(旧图片)
image_tensors.extend(
kwargs[f"图片{i}"]
for i in range(1, MAX_IMAGE_INPUTS + 1)
if kwargs.get(f"图片{i}") is not None
)
# 构建 input
input_data = self._build_input(提示词, 图片, "", 文件, 视频)
input_data = self._build_input(提示词, image_tensors, "", 文件, 视频)
img_count = len(tensor_to_pil(图片)) if 图片 is not None else 0
img_count = sum(len(tensor_to_pil(t)) for t in image_tensors)
file_count = len(文件) if 文件 else 0
input_desc = "文本"
if img_count: input_desc += f" + {img_count}张图片"
if 视频 is not None: input_desc += " + 视频"
if file_count: input_desc += f" + {file_count}个文件"
print(f"全能LLM: 模型 = {模型}")
print(f"全能LLM: 输入 = {input_desc}")
print(f"提示词专家: 模型 = {模型}")
print(f"提示词专家: 思考深度 = {思考深度} ({reasoning_effort})")
print(f"提示词专家: seed = {seed}")
print(f"提示词专家: 输入 = {input_desc}")
# 构建请求体(chat/completions 格式)
request_body = {
"model": 模型,
"messages": input_data,
"stream": True,
"reasoning_effort": reasoning_effort,
"seed": seed,
}
# 打印请求体,base64 截断显示
@@ -415,10 +503,10 @@ class UniversalLLMChat:
return {k: _truncate_for_log(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_truncate_for_log(i) for i in obj]
if isinstance(obj, str) and (obj.startswith("data:image") or obj.startswith("data:application") or obj.startswith("data:text")):
if isinstance(obj, str) and obj.startswith("data:"):
return obj[:60] + f"...[{len(obj)}chars]"
return obj
print(f"全能LLM: 请求原始内容 = {json.dumps(_truncate_for_log(request_body), ensure_ascii=False)}")
print(f"提示词专家: 请求原始内容 = {json.dumps(_truncate_for_log(request_body), ensure_ascii=False)}")
# 发送请求(在独立线程中运行异步请求,避免与 ComfyUI 事件循环冲突)
import aiohttp
@@ -430,8 +518,10 @@ class UniversalLLMChat:
"Content-Type": "application/json",
"Authorization": f"Bearer {effective_api_key}",
}
url = f"{self._base_url}/v1/chat/completions"
timeout = aiohttp.ClientTimeout(total=120)
url = f"{base_url}/v1/chat/completions"
# 流式接口不设整体 total 上限(否则会掐断高思考深度的长生成),
# 改用连接超时 + 单次读取超时:只要在 sock_read 间隔内有数据返回就不超时。
timeout = aiohttp.ClientTimeout(total=None, sock_connect=30, sock_read=300)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, headers=headers, json=request_body) as resp:
@@ -439,6 +529,7 @@ class UniversalLLMChat:
if status != 200:
body = await resp.text()
print(f"提示词专家: 响应体 = {body}")
try:
err_data = json.loads(body)
err_msg = err_data.get("error", {}).get("message", body[:200])
@@ -458,17 +549,30 @@ class UniversalLLMChat:
# 流式读取,拼接 delta content
reply_parts = []
response_body_parts = []
async for raw_line in resp.content:
line = raw_line.decode("utf-8").strip()
if not line or not line.startswith("data:"):
continue
data_str = line[len("data:"):].strip()
response_body_parts.append(f"data: {data_str}")
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
except Exception:
continue
stream_error = chunk.get("error")
if stream_error:
response_body = "\n".join(response_body_parts)
print(f"提示词专家: 响应体 = {response_body}")
if isinstance(stream_error, dict):
error_message = stream_error.get("message", "上游服务暂时不可用")
error_type = stream_error.get("type", "upstream_error")
else:
error_message = str(stream_error)
error_type = "upstream_error"
raise RuntimeError(f"上游服务错误 ({error_type}): {error_message}")
choices = chunk.get("choices")
if not choices:
continue
@@ -476,10 +580,17 @@ class UniversalLLMChat:
content = delta.get("content")
if content:
reply_parts.append(content)
if ENABLE_NODE_TYPEWRITER_PREVIEW:
UniversalLLMChat._send_stream_token(node_id, content)
response_body = "\n".join(response_body_parts)
print(f"提示词专家: 响应体 = {response_body}")
if ENABLE_NODE_TYPEWRITER_PREVIEW:
UniversalLLMChat._send_stream_token(node_id, "", done=True)
return "".join(reply_parts)
reply = "".join(reply_parts)
if not reply:
raise RuntimeError("模型未返回有效文本内容")
return reply
def _run_in_thread():
loop = asyncio.new_event_loop()
@@ -492,22 +603,26 @@ class UniversalLLMChat:
reply = pool.submit(_run_in_thread).result()
elapsed = time.time() - start_time
print(f"全能LLM: 生成完成 (耗时: {elapsed:.2f}s)")
print(f"提示词专家: 生成完成 (耗时: {elapsed:.2f}s)")
if reply:
preview = reply[:100] + "..." if len(reply) > 100 else reply
print(f"全能LLM: 回复预览: {preview}")
print(f"提示词专家: 回复预览: {preview}")
return (reply,)
except ValueError as e:
if str(e) == "未授权!":
print("全能LLM: 请联系作者授权后方可使用!")
print("提示词专家: 请联系作者授权后方可使用!")
raise ValueError("未授权!") from None
error_msg = str(e).split('\n')[0]
print(f"全能LLM: ❌ {error_msg}")
print(f"提示词专家: ❌ {error_msg}")
raise
except Exception as e:
error_msg = str(e).split('\n')[0]
print(f"全能LLM: ❌ {error_msg}")
import asyncio as _asyncio
if isinstance(e, _asyncio.TimeoutError):
error_msg = "请求超时:服务端长时间未返回数据(可能是模型思考过久或网络不稳定),请重试或降低思考深度/图片数量"
else:
error_msg = str(e).split('\n')[0] or f"未知错误({type(e).__name__}"
print(f"提示词专家: ❌ {error_msg}")
raise RuntimeError(error_msg) from None
+5 -1
View File
@@ -11,6 +11,7 @@ from typing import Optional, Tuple
import torch
from ..utils.image_utils import tensor_to_pil
from ..utils.config import get_runtime_config_signature
from ..clients.veo_client import VeoClient
from ..models_config import (
get_enabled_veo_models,
@@ -127,6 +128,7 @@ class GoogleVeo:
def __init__(self):
self.client = None
self._client_config_signature = None
@classmethod
def INPUT_TYPES(cls):
@@ -304,8 +306,10 @@ class GoogleVeo:
pbar = ProgressBar(生成数量 if 生成数量 > 1 else 100)
try:
if self.client is None:
config_signature = get_runtime_config_signature()
if self.client is None or config_signature != self._client_config_signature:
self.client = VeoClient()
self._client_config_signature = config_signature
if 生成数量 == 1:
save_path = os.path.join(video_dir, f"veo_{counter:05d}.mp4")
+226
View File
@@ -0,0 +1,226 @@
"""
视频裁剪节点
上传本地视频或接入上游 VIDEO用前端时间轴选段 [开始, 结束] 物理裁剪
为什么物理裁剪而不用 VideoFromFile 的惰性 trim
r2_uploader.upload_video / 各生视频节点读取的是 get_stream_source()整段原文件
惰性 trim 窗口在上传时会被忽略这里用 ffmpeg 真正切出一段独立 mp4
保证预览上传保存三条路径都拿到裁剪后的内容
ffmpeg 解析顺序系统 PATH imageio-ffmpeg 自带二进制随整合包分发
无需用户单独安装 ffmpeg
"""
import io
import os
import re
import shutil
import subprocess
import tempfile
try:
from comfy_api.latest import InputImpl
_VIDEO_OK = True
except Exception:
_VIDEO_OK = False
# ── ffmpeg / 时长解析 ───────────────────────────────────────────────────────────
def _resolve_ffmpeg() -> str:
exe = shutil.which("ffmpeg")
if exe:
return exe
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
pass
raise RuntimeError(
"未找到 ffmpeg。请在便携 Python 中执行:"
"python_embeded\\python.exe -m pip install imageio-ffmpeg"
)
_DUR_RE = re.compile(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)")
def _probe_duration(src: str) -> float | None:
"""取视频时长(秒)。优先 ffprobe;缺失时解析 `ffmpeg -i` 的 stderr。"""
ffprobe = shutil.which("ffprobe")
if ffprobe:
try:
out = subprocess.run(
[ffprobe, "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", src],
capture_output=True, text=True, timeout=30,
)
val = (out.stdout or "").strip()
if val:
return float(val)
except Exception:
pass
# 退化:imageio-ffmpeg 只带 ffmpeg,没有 ffprobe → 解析 ffmpeg -i 输出
try:
ffmpeg = _resolve_ffmpeg()
out = subprocess.run([ffmpeg, "-i", src], capture_output=True, text=True, timeout=30)
m = _DUR_RE.search(out.stderr or "")
if m:
h, mi, s = int(m.group(1)), int(m.group(2)), float(m.group(3))
return h * 3600 + mi * 60 + s
except Exception:
pass
return None
# ── 源文件解析 ──────────────────────────────────────────────────────────────────
def _resolve_source(video_obj, video_path: str):
"""返回 (源文件路径, 是否为临时文件)。临时文件用完需删除。"""
if video_obj is not None and hasattr(video_obj, "get_stream_source"):
source = video_obj.get_stream_source()
if isinstance(source, io.BytesIO):
fd, tmp = tempfile.mkstemp(suffix=".mp4", prefix="o1key_trim_src_")
os.close(fd)
source.seek(0)
with open(tmp, "wb") as f:
f.write(source.read())
return tmp, True
return source, False
p = (video_path or "").strip().strip('"').strip("'")
if not p:
raise ValueError("请先点节点上的「上传视频」按钮,或连接一个「视频」输入。")
if not os.path.isfile(p):
raise ValueError(f"视频文件不存在:{p}")
return p, False
class O1keyVideoTrim:
"""
视频裁剪上传本地视频 时间轴拖拽选段 输出裁剪后的 VIDEO
- 视频路径由前端上传视频按钮自动填入也可手动粘贴绝对路径
- 开始时间由时间轴拖拽同步单位秒
- 固定时长> 0 裁剪区间长度固定为该值前端可整体拖动这个窗口定时快剪
- 固定时长= 0 结束时间结束时间为 0 表示到片尾自由两端拖拽
- 可选视频输入连接上游 VIDEO 时优先裁剪它忽略视频路径
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"视频路径": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "点节点上的「上传视频」按钮,或粘贴视频绝对路径",
}),
"开始时间": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 86400.0, "step": 0.1,
"tooltip": "裁剪起点(秒)",
}),
"结束时间": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 86400.0, "step": 0.1,
"tooltip": "裁剪终点(秒);0 表示到片尾。固定时长 > 0 时忽略此项",
}),
"固定时长": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 86400.0, "step": 0.5,
"tooltip": "> 0 时裁剪区间长度固定为该值(定时快剪);0 表示用结束时间",
}),
},
"optional": {
"视频": ("VIDEO",),
},
}
RETURN_TYPES = ("VIDEO", "FLOAT")
RETURN_NAMES = ("视频", "时长")
FUNCTION = "trim"
CATEGORY = "comfyui_o1key/Utils"
def trim(self, 视频路径: str = "", 开始时间: float = 0.0, 固定时长: float = 0.0,
结束时间: float = 0.0, 视频=None):
if not _VIDEO_OK:
raise RuntimeError("当前环境缺少 comfy_api,无法输出 VIDEO 类型。")
src, is_tmp = _resolve_source(视频, 视频路径)
try:
duration = _probe_duration(src)
start = max(0.0, float(开始时间))
fixed = max(0.0, float(固定时长))
# 计算结束时间
if fixed > 0.0:
# 固定时长模式:窗口整体不超过片尾
if duration and fixed >= duration:
start, end = 0.0, duration
else:
if duration:
start = min(start, max(duration - fixed, 0.0))
end = start + fixed
if duration:
end = min(end, duration)
else:
end = float(结束时间)
if end <= 0.0:
end = duration if duration else 0.0 # 0 → 到片尾
if duration:
end = min(end, duration)
start = min(start, max(duration - 0.05, 0.0))
if end > 0.0 and end <= start:
raise ValueError(
f"结束时间({end:.2f}s)必须大于开始时间({start:.2f}s)。"
)
# 整段未裁剪且源为磁盘文件:直接透传,避免无谓重编码
full_range = (
duration is not None and start <= 0.01 and end >= duration - 0.05
)
if full_range and not is_tmp:
return (InputImpl.VideoFromFile(src), float(duration))
seg_dur = (end - start) if end > 0.0 else (
(duration - start) if duration else 0.0
)
if seg_dur <= 0.0:
raise ValueError("裁剪区间长度为 0,请调整开始/结束时间或固定时长。")
fd, out_path = tempfile.mkstemp(suffix=".mp4", prefix="o1key_trim_")
os.close(fd)
ffmpeg = _resolve_ffmpeg()
cmd = [
ffmpeg, "-y",
"-ss", f"{start:.3f}",
"-i", src,
"-t", f"{seg_dur:.3f}",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-movflags", "+faststart",
out_path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0 or not os.path.isfile(out_path) or os.path.getsize(out_path) == 0:
tail = (proc.stderr or "").strip().splitlines()[-8:]
raise RuntimeError("ffmpeg 裁剪失败:\n" + "\n".join(tail))
return (InputImpl.VideoFromFile(out_path), float(seg_dur))
finally:
if is_tmp:
try:
os.remove(src)
except Exception:
pass
NODE_CLASS_MAPPINGS = {
"O1keyVideoTrim": O1keyVideoTrim,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"O1keyVideoTrim": "视频裁剪",
}
+24
View File
@@ -0,0 +1,24 @@
"""Disable ComfyUI Partner/API nodes when the launch command did not do so."""
import sys
DISABLE_API_NODES_FLAG = "--disable-api-nodes"
def _has_disable_api_nodes_flag(argv):
return any(
argument == DISABLE_API_NODES_FLAG
or argument.startswith(f"{DISABLE_API_NODES_FLAG}=")
for argument in argv[1:]
)
if _has_disable_api_nodes_flag(sys.argv):
print("[comfyui_o1key] --disable-api-nodes already set; prestartup override skipped")
else:
from comfy.cli_args import args
args.disable_api_nodes = True
sys.argv.append(DISABLE_API_NODES_FLAG)
print("[comfyui_o1key] Partner/API Nodes disabled by prestartup override")
+2
View File
@@ -1,4 +1,6 @@
aiohttp>=3.9.0
httpx[http2]>=0.28.0
Pillow>=10.0.0
requests>=2.31.0
rembg[cpu]>=2.0.50
imageio-ffmpeg>=0.5.1
+11
View File
@@ -0,0 +1,11 @@
# Test rules
These instructions apply to `tests/`.
- Tests must be offline and deterministic. Never consume credits, use a real API key, or depend on a developer's `.config`.
- Mock HTTP clients, ComfyUI globals, filesystem roots, clocks, sleeps, and downloads at the narrowest useful boundary.
- Some tests install ComfyUI stubs in `sys.modules`; run the suite with `run_all.py`, which isolates each file in its own process.
- Resolve the plugin root with `Path(__file__).resolve().parents[1]` when loading source files directly.
- Name Python tests `test_*.py`; keep direct execution support through `unittest.main()` where practical.
- Put live/manual diagnostics outside the repository or behind an explicit opt-in harness. They do not belong in the default suite.
- A regression test should describe the behavior being protected, especially node IDs, schema ordering, route selection, retry semantics, and workflow migration.
+50
View File
@@ -0,0 +1,50 @@
"""Run each test file in an isolated process.
Several tests install lightweight ComfyUI stubs in ``sys.modules``. Running
every file in one unittest discovery process lets those stubs leak between
modules, so isolation is intentional here.
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
TEST_DIR = Path(__file__).resolve().parent
def main() -> int:
commands = [
[sys.executable, str(path)]
for path in sorted(TEST_DIR.glob("test_*.py"))
]
node = shutil.which("node")
if node:
commands.extend(
[node, str(path)]
for path in sorted(TEST_DIR.glob("test_*.mjs"))
)
failures = []
for command in commands:
print(f"\n>>> {' '.join(command)}", flush=True)
completed = subprocess.run(command, cwd=TEST_DIR.parent, check=False)
if completed.returncode:
failures.append((command[-1], completed.returncode))
if failures:
print("\nFailed tests:")
for path, returncode in failures:
print(f"- {path}: exit {returncode}")
return 1
print(f"\nAll {len(commands)} isolated test files passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+46
View File
@@ -0,0 +1,46 @@
"""Offline coverage for the native seed on the red-cast correction node."""
import sys
import unittest
from pathlib import Path
import torch
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PLUGIN_ROOT.parent))
from comfyui_o1key.nodes.auto_red_cast import O1keyAutoRedCast # noqa: E402
class AutoRedCastSeedTests(unittest.TestCase):
def test_schema_exposes_native_seed_as_final_widget(self):
inputs = O1keyAutoRedCast.INPUT_TYPES()
required = inputs["required"]
self.assertEqual(
list(required),
["强度", "最大校正量", "高饱和保护", "图片路径"],
)
self.assertEqual(
list(inputs["optional"]),
["图像", "灰卡最低亮度", "灰卡最大色度", "seed"],
)
kind, options = inputs["optional"]["seed"]
self.assertEqual(kind, "INT")
self.assertEqual(options["default"], 0)
self.assertEqual(options["max"], 0xFFFFFFFFFFFFFFFF)
self.assertTrue(options["control_after_generate"])
def test_seed_is_accepted_without_changing_deterministic_correction(self):
image = torch.full((1, 40, 40, 3), 0.7)
image[..., 0] = 0.75
node = O1keyAutoRedCast()
first = node.correct(图像=image, seed=0)
second = node.correct(图像=image, seed=1234)
self.assertTrue(torch.equal(first[0], second[0]))
self.assertTrue(torch.equal(first[1], second[1]))
self.assertEqual(first[2], second[2])
if __name__ == "__main__":
unittest.main(verbosity=2)
+28
View File
@@ -0,0 +1,28 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class ChatPanelModelTests(unittest.TestCase):
def test_gpt_6_astra_is_the_frontend_and_proxy_default(self):
frontend = (ROOT / "web" / "js" / "chatPanel.js").read_text(encoding="utf-8")
backend = (ROOT / "__init__.py").read_text(encoding="utf-8")
self.assertIn('const DEFAULT_MODEL = "gpt-6-sol";', frontend)
self.assertIn("let currentModel = DEFAULT_MODEL;", frontend)
models = re.search(r"const MODELS = \[(.*?)\];", frontend, re.DOTALL)
self.assertIsNotNone(models)
self.assertEqual(models.group(1).strip().splitlines()[0].strip(), "DEFAULT_MODEL,")
self.assertIn('data.get("model", "gpt-6-sol")', backend)
self.assertRegex(
backend,
r'elif model in \([^\n]*"gpt-6-sol"[^\n]*\):\n\s+body\["reasoning_effort"\] = reasoning',
)
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
"""Offline tests for the global O1Key network route setting."""
import os
import sys
import unittest
from unittest.mock import patch
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, PLUGIN_DIR)
from utils.config import (
DEFAULT_NETWORK_ROUTE,
NETWORK_ROUTE_CONFIG_KEY,
NETWORK_ROUTES,
get_api_base_url,
get_async_api_base_url,
get_base_url_by_route,
get_network_route,
)
class GlobalNetworkRouteTests(unittest.TestCase):
def test_global_route_controls_all_base_url_helpers(self):
config = {NETWORK_ROUTE_CONFIG_KEY: "CF加速"}
with patch("utils.config.load_config", return_value=config):
self.assertEqual(get_network_route(), "CF加速")
self.assertEqual(get_base_url_by_route(), NETWORK_ROUTES["CF加速"])
self.assertEqual(get_api_base_url(), NETWORK_ROUTES["CF加速"])
self.assertEqual(get_async_api_base_url(), NETWORK_ROUTES["CF加速"])
def test_invalid_global_route_falls_back_safely(self):
with patch(
"utils.config.load_config",
return_value={NETWORK_ROUTE_CONFIG_KEY: "invalid"},
):
self.assertEqual(get_network_route(), DEFAULT_NETWORK_ROUTE)
self.assertEqual(
get_base_url_by_route(),
NETWORK_ROUTES[DEFAULT_NETWORK_ROUTE],
)
def test_explicit_legacy_route_is_still_resolvable(self):
with patch("utils.config.load_config", return_value={}):
self.assertEqual(
get_base_url_by_route("美国直连"),
NETWORK_ROUTES["美国直连"],
)
def test_custom_base_url_remains_fallback_before_global_route_is_saved(self):
custom_url = "https://gateway.example.com/"
with patch(
"utils.config.load_config",
return_value={"O1KEY_API_BASE_URL": custom_url},
):
self.assertEqual(get_api_base_url(), custom_url.rstrip("/"))
if __name__ == "__main__":
unittest.main(verbosity=2)
+344
View File
@@ -0,0 +1,344 @@
"""Offline regression tests for the standalone GPT Image nodes."""
import asyncio
import os
import sys
import unittest
from io import BytesIO
from types import ModuleType
from unittest.mock import AsyncMock, patch
import torch
from PIL import Image
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CUSTOM_NODES_DIR = os.path.dirname(PLUGIN_DIR)
COMFY_ROOT = os.path.dirname(CUSTOM_NODES_DIR)
for child in ("nodes", "utils", "clients"):
package = ModuleType(f"comfyui_o1key.{child}")
package.__path__ = [os.path.join(PLUGIN_DIR, child)]
sys.modules[package.__name__] = package
plugin_package = ModuleType("comfyui_o1key")
plugin_package.__path__ = [PLUGIN_DIR]
sys.modules[plugin_package.__name__] = plugin_package
model_management = ModuleType("comfy.model_management")
model_management.processing_interrupted = lambda: False
model_management.InterruptProcessingException = RuntimeError
sys.modules[model_management.__name__] = model_management
sys.path.insert(0, COMFY_ROOT)
sys.path.insert(0, CUSTOM_NODES_DIR)
from comfyui_o1key.clients.gpt_image_client import ( # noqa: E402
GptImageClient,
resolve_gpt_image_model,
)
from comfyui_o1key.nodes.gpt_image import ( # noqa: E402
O1keyGPTImage,
resolve_gpt_image_quality,
)
from comfyui_o1key.nodes.gpt_image_batch import ( # noqa: E402
O1keyGPTImageBatch,
_path_option,
)
from comfyui_o1key.utils.file_utils import ImageInfo # noqa: E402
class GPTImageNodeSchemaTests(unittest.TestCase):
def assert_combo_values_are_strings(self, schema):
for item in schema.inputs:
if item.io_type != "COMBO":
continue
self.assertTrue(
all(isinstance(option, str) for option in item.options),
item.id,
)
if item.default is not None:
self.assertIsInstance(item.default, str, item.id)
def test_single_node_keeps_request_controls_without_color_correction(self):
schema = O1keyGPTImage.define_schema()
schema.validate()
self.assert_combo_values_are_strings(schema)
inputs = {item.id: item for item in schema.inputs}
self.assertEqual(inputs["缩放图片"].options, ["不缩放", "智能缩放"])
self.assertEqual(inputs["缩放图片"].default, "智能缩放")
self.assertEqual(inputs["模型"].default, "gpt-image-2.5-sunburst")
self.assertNotIn("色彩纠正", inputs)
self.assertEqual(inputs["背景"].options, ["auto", "transparent", "opaque"])
self.assertNotIn("内容审查强度", inputs)
self.assertEqual(
[item.id for item in schema.inputs],
[
"prompt", "模型", "模型线路", "分辨率", "生图数量", "质量",
"输出格式", "背景", "遮罩", "参考图组", "缩放图片", "seed",
],
)
self.assertEqual(
inputs["质量"].options,
["", "", "", "自动", "超高", "最高"],
)
def test_25_quality_values_are_model_specific(self):
self.assertEqual(resolve_gpt_image_quality("gpt-image-2.5-sunburst", "超高"), "xhigh")
self.assertEqual(resolve_gpt_image_quality("gpt-image-2.5-flare", "最高"), "max")
self.assertEqual(resolve_gpt_image_quality("gpt-image-2", ""), "high")
with self.assertRaisesRegex(ValueError, "仅支持 GPT Image 2.5"):
resolve_gpt_image_quality("gpt-image-2", "超高")
def test_gpt_image_25_models_resolve_all_route_ids(self):
expected = {
("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",
}
for (model, route), actual_model in expected.items():
with self.subTest(model=model, route=route):
self.assertEqual(resolve_gpt_image_model(model, route), actual_model)
def test_batch_node_keeps_operational_controls_outside_advanced_inputs(self):
schema = O1keyGPTImageBatch.define_schema()
schema.validate()
self.assert_combo_values_are_strings(schema)
inputs = {item.id: item for item in schema.inputs}
self.assertEqual(inputs["模型"].default, "gpt-image-2.5-sunburst")
self.assertEqual(inputs["缩放图片"].default, "智能缩放")
self.assertEqual(inputs["质量"].options, ["", "", "", "自动", "超高", "最高"])
self.assertNotIn("色彩纠正", inputs)
self.assertNotIn("内容审查强度", inputs)
self.assertEqual(
[item.id for item in schema.inputs],
[
"prompt", "模型", "模型线路", "分辨率", "生图数量", "质量",
"图片路径数量", "遮罩", "参考图组", "图片输出格式",
"背景", "图片保存命名规则", "图片保存路径", "缩放图片", "seed",
],
)
self.assertNotIn("并发数", inputs)
self.assertEqual(
[item.id for item in _path_option(1).inputs],
["参考图1(主图)"],
)
self.assertEqual(
[item.id for item in _path_option(2).inputs],
["参考图1(主图)", "参考图2", "图片配对模式"],
)
for input_name in (
"seed",
"图片输出格式",
"图片保存命名规则",
"图片保存路径",
"缩放图片",
"背景",
):
self.assertFalse(inputs[input_name].advanced, input_name)
class GPTImageNodeExecutionTests(unittest.TestCase):
def test_single_node_forwards_request_controls_without_postprocess(self):
calls = []
generated = torch.ones((1, 4, 6, 3), dtype=torch.float32)
class FakeClient:
def __init__(self):
self.base_url = ""
self.response_log_enabled = True
self.poll_log_enabled = True
def generate_image_async_sync(self, **kwargs):
calls.append(kwargs)
return [Image.new("RGB", (6, 4), "green")]
@staticmethod
def _pil_list_to_tensor(_images):
return generated
with (
patch("comfyui_o1key.nodes.gpt_image.GptImageClient", FakeClient),
patch(
"comfyui_o1key.nodes.gpt_image.get_base_url_by_route",
return_value="https://example.invalid",
),
):
result = O1keyGPTImage.generate(
prompt="测试",
输出格式="webp",
缩放图片="智能缩放",
背景="transparent",
)
self.assertIs(result[0], generated)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["model"], "gpt-image-2.5-sunburst-sp")
self.assertEqual(calls[0]["resize_mode"], "智能缩放")
self.assertEqual(calls[0]["background"], "transparent")
self.assertNotIn("moderation", calls[0])
def test_single_node_forwards_25_quality_api_values(self):
calls = []
generated = torch.ones((1, 4, 6, 3), dtype=torch.float32)
class FakeClient:
def __init__(self):
self.base_url = ""
self.response_log_enabled = True
self.poll_log_enabled = True
def generate_image_async_sync(self, **kwargs):
calls.append(kwargs)
return [Image.new("RGB", (6, 4), "green")]
@staticmethod
def _pil_list_to_tensor(_images):
return generated
with (
patch("comfyui_o1key.nodes.gpt_image.GptImageClient", FakeClient),
patch(
"comfyui_o1key.nodes.gpt_image.get_base_url_by_route",
return_value="https://example.invalid",
),
):
for display_quality, api_quality in (("超高", "xhigh"), ("最高", "max")):
O1keyGPTImage.generate(
prompt="测试",
模型="gpt-image-2.5-sunburst",
质量=display_quality,
)
self.assertEqual([call["quality"] for call in calls], ["xhigh", "max"])
class GPTImageBatchExecutionTests(unittest.IsolatedAsyncioTestCase):
async def test_batch_task_forwards_request_controls_without_removed_options(self):
calls = []
reference = Image.new("RGB", (6, 4), "red")
generated = Image.new("RGB", (6, 4), "green")
pair = (ImageInfo(reference, "source", ".png", ""),)
class FakeClient:
async def generate_image_async(self, **kwargs):
calls.append(kwargs)
return [generated]
with (
patch.object(
O1keyGPTImageBatch,
"_save_images",
return_value=["output.png"],
) as save_images,
patch("builtins.print"),
):
result = await O1keyGPTImageBatch._run_task(
FakeClient(), pair, "测试", 0, 1,
"gpt-image-2-c-sp", "auto", "1024x1024", 1, 0, None, "webp",
"transparent", "智能缩放",
"output", "和原始图片名保持一致", asyncio.Lock(), None,
)
self.assertTrue(result["success"])
self.assertEqual(calls[0]["resize_mode"], "智能缩放")
self.assertEqual(calls[0]["background"], "transparent")
self.assertNotIn("moderation", calls[0])
self.assertIs(save_images.call_args.args[0][0], generated)
async def test_batch_starts_all_tasks_together(self):
task_count = 4
started = []
all_started = asyncio.Event()
async def fake_run_task(cls, *_args):
task_index = _args[3]
started.append(task_index)
if len(started) == task_count:
all_started.set()
await asyncio.wait_for(all_started.wait(), timeout=1)
return {
"task_index": task_index,
"success": True,
"generated_count": 1,
"saved_files": [],
"error": None,
}
task_defs = [(index, tuple(), f"prompt {index}") for index in range(task_count)]
with patch.object(O1keyGPTImageBatch, "_run_task", classmethod(fake_run_task)), patch("builtins.print"):
results = await O1keyGPTImageBatch._process_async(
object(), task_defs, "model", "auto", "auto", 1, 0,
None, "png", "auto", "智能缩放", "output",
"自然数字", None,
)
self.assertEqual(sorted(started), list(range(task_count)))
self.assertEqual(len(results), task_count)
self.assertTrue(all(item["success"] for item in results))
class GPTImageDownloadRetryTests(unittest.IsolatedAsyncioTestCase):
async def test_result_download_retries_transient_http_status_without_logging_url(self):
buffer = BytesIO()
Image.new("RGB", (3, 2), "purple").save(buffer, format="PNG")
image_bytes = buffer.getvalue()
class Content:
def __init__(self, body):
self.body = body
async def iter_chunked(self, _chunk_size):
yield self.body
class Response:
http_version = "HTTP/1.1"
def __init__(self, status, body):
self.status = status
self.headers = {"Content-Length": str(len(body))}
self.content = Content(body)
async def __aenter__(self):
return self
async def __aexit__(self, _exc_type, _exc, _tb):
return None
class Session:
def __init__(self):
self.responses = [Response(503, b"busy"), Response(200, image_bytes)]
self.calls = 0
def get(self, _url, **_kwargs):
response = self.responses[self.calls]
self.calls += 1
return response
client = object.__new__(GptImageClient)
session = Session()
signed_url = "https://example.invalid/private?signature=secret"
with (
patch(
"comfyui_o1key.clients.gpt_image_client.asyncio.sleep",
new=AsyncMock(),
),
patch("builtins.print") as print_mock,
):
image, byte_count, _elapsed = await client._download_image_with_response_retry(
session,
signed_url,
"第 1 张图片",
)
self.assertEqual(session.calls, 2)
self.assertEqual(image.size, (3, 2))
self.assertEqual(byte_count, len(image_bytes))
rendered = " ".join(str(value) for call in print_mock.call_args_list for value in call.args)
self.assertNotIn(signed_url, rendered)
self.assertNotIn("signature=secret", rendered)
if __name__ == "__main__":
unittest.main()
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
const sourcePath = new URL("../web/js/gptImageQuality.js", import.meta.url);
const source = fs.readFileSync(sourcePath, "utf8").replace(/^import .*;\s*$/gm, "");
let extension;
vm.runInNewContext(source, {
app: { registerExtension(value) { extension = value; } },
});
const backgroundLabelsPath = new URL("../web/js/gptImageBackgroundLabels.js", import.meta.url);
const backgroundLabelsSource = fs.readFileSync(backgroundLabelsPath, "utf8").replace(/^import .*;\s*$/gm, "");
let backgroundLabelsExtension;
vm.runInNewContext(backgroundLabelsSource, {
app: { registerExtension(value) { backgroundLabelsExtension = value; } },
});
function makeNode(model, quality, nodeType = "O1keyGPTImage") {
const calls = [];
const modelWidget = {
name: "模型",
value: model,
callback() { calls.push("model"); },
};
const qualityWidget = {
name: "质量",
value: quality,
options: { values: ["高", "中", "低", "自动", "超高", "最高"] },
callback(value) { calls.push(value); },
};
return {
comfyClass: nodeType,
widgets: [modelWidget, qualityWidget],
dirty: 0,
setDirtyCanvas() { this.dirty += 1; },
modelWidget,
qualityWidget,
calls,
};
}
const node = makeNode("gpt-image-2", "自动");
extension.nodeCreated(node);
assert.deepEqual(Array.from(node.qualityWidget.options.values), ["高", "中", "低", "自动"]);
const newNode = makeNode("gpt-image-2.5-sunburst", "自动");
extension.nodeCreated(newNode);
assert.deepEqual(
Array.from(newNode.qualityWidget.options.values),
["高", "中", "低", "自动", "超高", "最高"],
);
node.modelWidget.value = "gpt-image-2.5-flare";
node.modelWidget.callback();
assert.deepEqual(
Array.from(node.qualityWidget.options.values),
["高", "中", "低", "自动", "超高", "最高"],
);
node.qualityWidget.value = "最高";
node.modelWidget.value = "gpt-image-2";
node.modelWidget.callback();
assert.equal(node.qualityWidget.value, "自动");
assert.deepEqual(Array.from(node.qualityWidget.options.values), ["高", "中", "低", "自动"]);
assert.equal(node.calls.at(-1), "自动");
const restoredNode = makeNode("gpt-image-2.5-sunburst", "最高");
extension.loadedGraphNode(restoredNode);
assert.equal(restoredNode.qualityWidget.value, "最高");
assert.deepEqual(
Array.from(restoredNode.qualityWidget.options.values),
["高", "中", "低", "自动", "超高", "最高"],
);
const batchNode = makeNode("gpt-image-2.5-flare", "超高", "O1keyGPTImageBatch");
extension.nodeCreated(batchNode);
assert.deepEqual(
Array.from(batchNode.qualityWidget.options.values),
["高", "中", "低", "自动", "超高", "最高"],
);
const batchBackgroundWidget = {
name: "背景",
value: "opaque",
options: { values: ["auto", "transparent", "opaque"] },
};
backgroundLabelsExtension.nodeCreated({
comfyClass: "O1keyGPTImageBatch",
widgets: [batchBackgroundWidget],
});
assert.equal(batchBackgroundWidget.options.getOptionLabel("auto"), "自动");
assert.equal(batchBackgroundWidget.options.getOptionLabel("transparent"), "透明");
assert.equal(batchBackgroundWidget.options.getOptionLabel("opaque"), "不透明");
const backgroundWidget = {
name: "背景",
value: "transparent",
options: { values: ["auto", "transparent", "opaque"] },
};
const backgroundNode = {
comfyClass: "O1keyGPTImage",
widgets: [backgroundWidget],
};
backgroundLabelsExtension.nodeCreated(backgroundNode);
assert.equal(backgroundWidget.options.getOptionLabel("auto"), "自动");
assert.equal(backgroundWidget.options.getOptionLabel("transparent"), "透明");
assert.equal(backgroundWidget.options.getOptionLabel("opaque"), "不透明");
assert.equal(backgroundWidget.value, "transparent");
assert.deepEqual(Array.from(backgroundWidget.options.values), ["auto", "transparent", "opaque"]);
+106
View File
@@ -0,0 +1,106 @@
import base64
import importlib.util
import json
import sys
import types
import unittest
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
def _load_client():
if importlib.util.find_spec("numpy") is None:
numpy_stub = types.ModuleType("numpy")
numpy_stub.float32 = float
sys.modules["numpy"] = numpy_stub
if importlib.util.find_spec("torch") is None:
torch_stub = types.ModuleType("torch")
torch_stub.Tensor = object
sys.modules["torch"] = torch_stub
comfy = types.ModuleType("comfy")
comfy.__path__ = []
model_management = types.ModuleType("comfy.model_management")
model_management.processing_interrupted = lambda: False
model_management.InterruptProcessingException = RuntimeError
sys.modules[comfy.__name__] = comfy
sys.modules[model_management.__name__] = model_management
package = types.ModuleType("comfyui_o1key")
package.__path__ = [str(ROOT)]
clients_package = types.ModuleType("comfyui_o1key.clients")
clients_package.__path__ = [str(ROOT / "clients")]
utils_package = types.ModuleType("comfyui_o1key.utils")
utils_package.__path__ = [str(ROOT / "utils")]
sys.modules[package.__name__] = package
sys.modules[clients_package.__name__] = clients_package
sys.modules[utils_package.__name__] = utils_package
config = types.ModuleType("comfyui_o1key.utils.config")
config.get_api_key_or_raise = lambda *_args, **_kwargs: "secret"
config.get_base_url_by_route = lambda _route=None: "https://api.o1key.cn"
sys.modules[config.__name__] = config
image_utils = types.ModuleType("comfyui_o1key.utils.image_utils")
image_utils.tensor_to_pil = lambda value: [value]
sys.modules[image_utils.__name__] = image_utils
spec = importlib.util.spec_from_file_location(
"comfyui_o1key.clients.grok_image_client",
ROOT / "clients" / "grok_image_client.py",
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
GROK = _load_client()
class GrokReferenceTests(unittest.TestCase):
def _build(self, images):
return GROK.GrokImageClient._build_edit_body(
prompt="combine references",
model="Grok Image Pro",
aspect_ratio="auto",
resolution="1k",
image_list=images,
)
def test_single_reference_keeps_legacy_image_field(self):
body = self._build([Image.new("RGB", (8, 8), "red")])
self.assertIn("image", body)
self.assertNotIn("images", body)
self.assertTrue(base64.b64decode(body["image"]).startswith(b"\x89PNG"))
def test_three_references_use_multi_image_field(self):
body = self._build([
Image.new("RGB", (8, 8), "red"),
Image.new("RGB", (8, 8), "green"),
Image.new("RGB", (8, 8), "blue"),
])
self.assertNotIn("image", body)
self.assertEqual(len(body["images"]), 3)
for item in body["images"]:
self.assertEqual(item["type"], "image_url")
self.assertTrue(item["url"].startswith("data:image/png;base64,"))
self.assertLessEqual(
len(json.dumps(body, ensure_ascii=False).encode("utf-8")),
GROK._MAX_BODY_BYTES,
)
def test_more_than_three_references_are_rejected(self):
images = [Image.new("RGB", (2, 2)) for _ in range(4)]
with self.assertRaisesRegex(ValueError, "最多支持 3 张"):
self._build(images)
if __name__ == "__main__":
unittest.main(verbosity=2)
+203
View File
@@ -0,0 +1,203 @@
"""Offline regression tests for the current O1Key Grok Video API contract."""
import sys
import unittest
from inspect import signature
from pathlib import Path
from unittest.mock import AsyncMock, patch
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PLUGIN_ROOT.parent))
from comfyui_o1key.clients.grok_video_client import GrokVideoClient
from comfyui_o1key.nodes import grok_video
class GrokVideoPayloadTests(unittest.TestCase):
def test_poll_deadline_is_shared_2000_seconds(self):
self.assertEqual(GrokVideoClient.POLL_DEADLINE_SECONDS, 2000)
self.assertEqual(
signature(GrokVideoClient.run_video_sync)
.parameters["timeout"].default,
2000,
)
def build(self, **overrides):
values = {
"operation": "generate",
"prompt": "cinematic shot",
"model": "grok-imagine-video-1.5",
"duration": 8,
"aspect_ratio": "16:9",
"resolution": "480p",
}
values.update(overrides)
return GrokVideoClient.build_video_body(**values)
def test_text_generation_supports_15_seconds_and_1080p_on_15(self):
body = self.build(duration=15, resolution="1080p")
self.assertEqual(body, {
"model": "grok-imagine-video-1.5",
"prompt": "cinematic shot",
"duration": 15,
"aspect_ratio": "16:9",
"resolution": "1080p",
})
def test_image_generation_prompt_is_optional_and_preserves_image_url(self):
body = self.build(
prompt="",
resolution="1080p",
image={"image_url": "data:image/png;base64,AAAA"},
)
self.assertNotIn("prompt", body)
self.assertEqual(body["image"], {"image_url": "data:image/png;base64,AAAA"})
def test_multireference_generation_supports_15_seconds_on_both_models(self):
for model in GrokVideoClient.MODEL_OPTIONS:
with self.subTest(model=model):
body = self.build(
model=model,
duration=15,
resolution="720p",
reference_images=[{"url": "https://example.invalid/character.png"}],
reference_audios=[{"voice_id": "nova"}],
)
self.assertEqual(body["duration"], 15)
self.assertEqual(body["reference_audios"], [{"voice_id": "nova"}])
def test_multireference_generation_rejects_1080p(self):
with self.assertRaisesRegex(ValueError, "不支持 1080p"):
self.build(
resolution="1080p",
reference_images=[{"url": "https://example.invalid/reference.png"}],
)
def test_base_model_rejects_1080p(self):
with self.assertRaisesRegex(ValueError, "仅支持 grok-imagine-video-1.5"):
self.build(model="grok-imagine-video", resolution="1080p")
def test_image_and_reference_images_are_mutually_exclusive(self):
with self.assertRaisesRegex(ValueError, "image 和 reference_images"):
self.build(
image={"url": "https://example.invalid/input.png"},
reference_images=[{"url": "https://example.invalid/reference.png"}],
)
def test_reference_audio_accepts_only_url_or_voice_id_and_caps_at_three(self):
body = self.build(reference_audios=[
{"url": "https://example.invalid/one.wav"},
{"voice_id": "nova"},
{"voice_id": "alloy"},
])
self.assertEqual(len(body["reference_audios"]), 3)
with self.assertRaisesRegex(ValueError, "最多支持 3"):
self.build(reference_audios=[{"voice_id": str(index)} for index in range(4)])
with self.assertRaisesRegex(ValueError, "url、voice_id"):
self.build(reference_audios=[{"file_id": "not-supported"}])
def test_edit_and_extension_payloads_use_their_exact_parameter_sets(self):
for model in GrokVideoClient.MODEL_OPTIONS:
with self.subTest(operation="edit", model=model):
edit = self.build(
operation="edit",
model=model,
video={"file_id": "file_grok_1"},
duration=9,
resolution="1080p",
)
self.assertEqual(edit, {
"model": model,
"prompt": "cinematic shot",
"video": {"file_id": "file_grok_1"},
})
with self.subTest(operation="extend", model=model):
extension = self.build(
operation="extend",
model=model,
video={"url": "data:video/mp4;base64,AAAA"},
duration=5,
)
self.assertEqual(extension, {
"model": model,
"prompt": "cinematic shot",
"video": {"url": "data:video/mp4;base64,AAAA"},
"duration": 5,
})
def test_extension_duration_is_limited_to_two_through_ten_seconds(self):
for duration in (1, 11):
with self.subTest(duration=duration):
with self.assertRaisesRegex(ValueError, "2 到 10 秒"):
self.build(
operation="extend",
video={"url": "https://example.invalid/input.mp4"},
duration=duration,
)
def test_error_sanitizer_removes_temporary_urls_and_base64(self):
message = GrokVideoClient._safe_error_message(
"failed https://cdn.example.invalid/result.mp4?signature=secret "
"data:video/mp4;base64,QUJDREVGRw=="
)
self.assertNotIn("signature=secret", message)
self.assertNotIn("QUJDREVGRw", message)
self.assertIn("<temporary URL omitted>", message)
self.assertIn("<base64 omitted>", message)
class GrokVideoNodeTests(unittest.TestCase):
def test_generation_schema_exposes_current_models_defaults_and_three_audio_inputs(self):
required = grok_video.O1keyGrokVideo.INPUT_TYPES()["required"]
optional = grok_video.O1keyGrokVideo.INPUT_TYPES()["optional"]
self.assertEqual(required["模型"][1]["default"], "grok-imagine-video-1.5")
self.assertEqual(required["分辨率"][1]["default"], "480p")
self.assertIn("参考音色ID(逗号分隔)", required)
self.assertEqual(
[name for name in optional if name.startswith("音频素材")],
["音频素材", "音频素材2", "音频素材3"],
)
def test_edit_schema_appends_model_selector(self):
required = grok_video.O1keyGrokVideoEdit.INPUT_TYPES()["required"]
self.assertEqual(
list(required),
["操作", "提示词", "续写时长(秒)", "模型"],
)
self.assertEqual(required["模型"][1]["default"], "grok-imagine-video-1.5")
def test_edit_rejects_video_over_87_seconds_before_upload(self):
video = type("Video", (), {"get_duration": lambda self: 8.71})()
with (
patch.object(grok_video, "VideoFromFile", object),
patch.object(grok_video, "upload_video", new=AsyncMock()) as upload,
):
with self.assertRaisesRegex(ValueError, "不能超过 8.7 秒"):
grok_video.O1keyGrokVideoEdit().generate(**{
"操作": "编辑视频",
"提示词": "make the sky golden",
"续写时长(秒)": 6,
"模型": "grok-imagine-video-1.5",
"视频素材": video,
})
upload.assert_not_awaited()
def test_voice_ids_split_on_chinese_comma_comma_and_newline(self):
self.assertEqual(
grok_video._parse_voice_ids("novaalloy\necho"),
["nova", "alloy", "echo"],
)
if __name__ == "__main__":
unittest.main()
+197
View File
@@ -0,0 +1,197 @@
import importlib.util
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[1]
def _load_module():
spec = importlib.util.spec_from_file_location(
"o1key_http2_client_test_module",
ROOT / "utils" / "http2_client.py",
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _load_module_without_httpx():
module_name = "o1key_http2_client_without_httpx_test_module"
spec = importlib.util.spec_from_file_location(
module_name,
ROOT / "utils" / "http2_client.py",
)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {"httpx": None}):
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
HTTP2_CLIENT = _load_module()
class _ChunkContent:
def __init__(self, chunks, error=None):
self.chunks = chunks
self.error = error
async def iter_chunked(self, _chunk_size):
for chunk in self.chunks:
yield chunk
if self.error is not None:
raise self.error
class _TraceResponse:
def __init__(self, chunks, headers=None, error=None):
self.status = 200
self.http_version = "HTTP/2"
self.headers = headers or {}
self.content = _ChunkContent(chunks, error=error)
class Http2ClientTests(unittest.TestCase):
def test_enables_http2_when_runtime_support_is_present(self):
fake_client = MagicMock()
with (
patch.object(HTTP2_CLIENT, "http2_runtime_available", return_value=True),
patch.object(HTTP2_CLIENT.httpx, "AsyncClient", return_value=fake_client) as constructor,
):
client = HTTP2_CLIENT.O1keyAsyncHttpClient(http2=True)
self.assertTrue(client.http2_enabled)
self.assertTrue(constructor.call_args.kwargs["http2"])
self.assertTrue(constructor.call_args.kwargs["verify"])
def test_falls_back_to_http11_when_h2_runtime_is_missing(self):
fake_client = MagicMock()
with (
patch.object(HTTP2_CLIENT, "http2_runtime_available", return_value=False),
patch.object(HTTP2_CLIENT.httpx, "AsyncClient", return_value=fake_client) as constructor,
):
client = HTTP2_CLIENT.O1keyAsyncHttpClient(http2=True)
self.assertFalse(client.http2_enabled)
self.assertFalse(constructor.call_args.kwargs["http2"])
def test_task_id_validation_uses_only_explicit_task_fields(self):
payload = {"id": "result-image-id", "data": {"taskId": "task-7"}}
self.assertEqual(HTTP2_CLIENT.response_task_id(payload), "task-7")
self.assertEqual(
HTTP2_CLIENT.validate_response_task_id(payload, "task-7"),
"task-7",
)
with self.assertRaisesRegex(
HTTP2_CLIENT.ResponseTaskIdMismatchError,
"requested_task_id=task-8.*response_task_id=task-7",
):
HTTP2_CLIENT.validate_response_task_id(payload, "task-8")
class ResponseBodyDiagnosticsTests(unittest.IsolatedAsyncioTestCase):
async def test_exact_content_length_is_reported_as_match(self):
response = _TraceResponse(
[b'{"ok":', b'true}'],
headers={"Content-Length": "11"},
)
body, diagnostics = await HTTP2_CLIENT.read_response_body_with_diagnostics(response)
self.assertEqual(body, b'{"ok":true}')
self.assertEqual(diagnostics["declared_bytes"], 11)
self.assertEqual(diagnostics["received_bytes"], 11)
self.assertEqual(diagnostics["length_check"], "match")
async def test_short_content_length_raises_with_received_byte_count(self):
response = _TraceResponse(
[b"1234"],
headers={"Content-Length": "10"},
)
with self.assertRaisesRegex(
HTTP2_CLIENT.ResponseBodyIntegrityError,
r"Content-Length=10.*received=4B.*length_check=mismatch",
):
await HTTP2_CLIENT.read_response_body_with_diagnostics(response)
async def test_stream_failure_keeps_partial_received_byte_count(self):
response = _TraceResponse(
[b"1234"],
headers={"Content-Length": "10"},
error=OSError("connection closed"),
)
with self.assertRaisesRegex(
HTTP2_CLIENT.ResponseBodyIntegrityError,
r"读取提前中断.*Content-Length=10.*received=4B.*connection closed",
):
await HTTP2_CLIENT.read_response_body_with_diagnostics(response)
async def test_compressed_response_does_not_compare_decoded_size(self):
response = _TraceResponse(
[b"decoded body"],
headers={"Content-Length": "5", "Content-Encoding": "gzip"},
)
_body, diagnostics = await HTTP2_CLIENT.read_response_body_with_diagnostics(response)
self.assertEqual(diagnostics["length_check"], "skipped-compressed")
class AiohttpFallbackTests(unittest.IsolatedAsyncioTestCase):
async def test_missing_httpx_uses_working_aiohttp_session(self):
module = _load_module_without_httpx()
self.assertFalse(module.HTTPX_AVAILABLE)
self.assertFalse(module.http2_runtime_available())
self.assertIsInstance(
module.create_timeout(
120.0,
connect=30.0,
read=60.0,
write=30.0,
pool=30.0,
),
module.aiohttp.ClientTimeout,
)
client = module.O1keyAsyncHttpClient(http2=True)
self.assertEqual(client.backend, "aiohttp")
self.assertFalse(client.http2_enabled)
async with client as active_client:
self.assertIsInstance(active_client._client, module.aiohttp.ClientSession)
async def test_missing_httpx_converts_files_to_aiohttp_multipart(self):
module = _load_module_without_httpx()
client = module.O1keyAsyncHttpClient(http2=True)
fake_session = MagicMock()
sentinel_context = object()
fake_session.post.return_value = sentinel_context
client._client = fake_session
result = client.post(
"https://example.invalid/upload",
headers={"Authorization": "Bearer test"},
files={"file": ("reference.jpg", b"jpeg", "image/jpeg")},
timeout=module.create_timeout(
120.0,
connect=30.0,
read=60.0,
write=30.0,
pool=30.0,
),
)
self.assertIs(result, sentinel_context)
payload = fake_session.post.call_args.kwargs["data"]
self.assertIsInstance(payload, module.aiohttp.FormData)
self.assertEqual(len(payload._fields), 1)
if __name__ == "__main__":
unittest.main(verbosity=2)
+597
View File
@@ -0,0 +1,597 @@
"""Offline tests for the MiniMax-H3 node and New API response parsing."""
import json
import os
import sys
import tempfile
import unittest
from inspect import signature
from unittest.mock import AsyncMock, MagicMock, call, patch
import av
import numpy as np
import torch
from PIL import Image
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CUSTOM_NODES_DIR = os.path.dirname(PLUGIN_DIR)
COMFY_ROOT = os.environ.get(
"COMFYUI_ROOT",
r"F:\ComfyUI_windows_portable\ComfyUI",
)
sys.path.insert(0, COMFY_ROOT)
sys.path.insert(0, CUSTOM_NODES_DIR)
from comfyui_o1key.clients.minimax_h3_client import ( # noqa: E402
FAILURE_STATUSES,
MiniMaxH3Client,
PENDING_STATUSES,
SUCCESS_STATUSES,
extract_public_task_id,
parse_task_snapshot,
)
from comfyui_o1key.clients.newapi_veo_client import NewAPIVeoClient # noqa: E402
from comfyui_o1key.nodes.minimax_h3_video import ( # noqa: E402
MAX_SEED,
MODEL_MAX_ID,
MODE_FIRST,
MODE_FIRST_LAST,
MODE_LAST,
MODE_REFERENCE,
MODE_TEXT,
MiniMaxH3Video,
_make_progress_callbacks,
build_request_body,
)
from comfyui_o1key.utils.minimax_h3_media import ( # noqa: E402
inspect_audio,
probe_video,
validate_reference_audios,
validate_reference_videos,
validate_image,
validate_video_info,
)
from comfyui_o1key.utils.video_task import POLL_DEADLINE_SECONDS # noqa: E402
class MiniMaxH3PayloadTests(unittest.TestCase):
def test_text_to_video_payload(self):
body = build_request_body(
prompt="月球上的宇航员",
resolution="2K",
duration=5,
mode=MODE_TEXT,
ratio="16:9",
)
self.assertEqual(body["model"], "MiniMax-H3")
self.assertEqual(body["seed"], 0)
self.assertEqual(body["ratio"], "16:9")
self.assertEqual(body["content"], [{"type": "text", "text": "月球上的宇航员"}])
self.assertNotIn("callback_url", body)
self.assertNotIn("aigc_watermark", body)
def test_native_seed_is_forwarded_and_validated(self):
body = build_request_body(
prompt="固定镜头",
resolution="2K",
duration=5,
mode=MODE_TEXT,
ratio="16:9",
seed=123456789,
)
self.assertEqual(body["seed"], 123456789)
for invalid_seed in (-1, MAX_SEED + 1, True, 1.5):
with self.subTest(seed=invalid_seed), self.assertRaisesRegex(
ValueError,
"seed 必须是",
):
build_request_body(
prompt="x",
resolution="2K",
duration=5,
mode=MODE_TEXT,
ratio="16:9",
seed=invalid_seed,
)
def test_first_frame_payload_uses_adaptive(self):
body = build_request_body(
prompt="镜头缓慢推进",
resolution="768P",
duration=4,
mode=MODE_FIRST,
first_url="https://cdn.example.com/first.png",
)
self.assertEqual(body["ratio"], "adaptive")
self.assertEqual(body["content"][1]["role"], "first_frame")
def test_first_last_payload_roles(self):
body = build_request_body(
prompt="自然过渡",
resolution="2K",
duration=15,
mode=MODE_FIRST_LAST,
first_url="https://cdn.example.com/first.png",
last_url="https://cdn.example.com/last.png",
)
self.assertEqual(
[item.get("role") for item in body["content"][1:]],
["first_frame", "last_frame"],
)
def test_last_frame_only_payload(self):
body = build_request_body(
prompt="镜头最终停在城市夜景",
resolution="2K",
duration=6,
mode=MODE_LAST,
last_url="https://cdn.example.com/last.png",
)
self.assertEqual(body["ratio"], "adaptive")
self.assertEqual(body["content"][1]["role"], "last_frame")
def test_reference_payload_roles(self):
body = build_request_body(
prompt="保持参考人物外观",
resolution="2K",
duration=5,
mode=MODE_REFERENCE,
ratio="4:3",
reference_image_urls=[
"https://cdn.example.com/person.png",
"https://cdn.example.com/style.png",
],
reference_video_urls=["https://cdn.example.com/motion.mp4"],
reference_audio_urls=["https://cdn.example.com/voice.wav"],
)
self.assertEqual(body["ratio"], "4:3")
self.assertEqual(
[item.get("role") for item in body["content"][1:]],
["reference_image", "reference_image", "reference_video", "reference_audio"],
)
def test_reference_ratio_defaults_to_adaptive(self):
body = build_request_body(
prompt="保持参考风格",
resolution="768P",
duration=4,
mode=MODE_REFERENCE,
reference_image_urls=["https://cdn.example.com/person.png"],
)
self.assertEqual(body["ratio"], "adaptive")
def test_reference_max_counts(self):
body = build_request_body(
prompt="多素材参考",
resolution="2K",
duration=5,
mode=MODE_REFERENCE,
reference_image_urls=[f"https://cdn.example.com/image-{i}.png" for i in range(6)],
reference_video_urls=[f"https://cdn.example.com/video-{i}.mp4" for i in range(3)],
reference_audio_urls=[f"https://cdn.example.com/audio-{i}.wav" for i in range(3)],
)
roles = [item.get("role") for item in body["content"][1:]]
self.assertEqual(roles.count("reference_image"), 6)
self.assertEqual(roles.count("reference_video"), 3)
self.assertEqual(roles.count("reference_audio"), 3)
def test_reference_material_total_cannot_exceed_twelve(self):
with self.assertRaisesRegex(ValueError, "合计最多 12 个"):
build_request_body(
prompt="多素材参考",
resolution="2K",
duration=5,
mode=MODE_REFERENCE,
reference_image_urls=[
f"https://cdn.example.com/image-{i}.png" for i in range(9)
],
reference_video_urls=[
f"https://cdn.example.com/video-{i}.mp4" for i in range(3)
],
reference_audio_urls=["https://cdn.example.com/audio.wav"],
)
def test_h3_max_payload_and_model_specific_limits(self):
body = build_request_body(
prompt="电影感城市延时",
model=MODEL_MAX_ID,
resolution="480P",
duration=5,
mode=MODE_TEXT,
ratio="16:9",
)
self.assertEqual(body["model"], "MiniMax-H3-MAX")
self.assertEqual(body["resolution"], "480P")
invalid_cases = [
{"resolution": "2K", "duration": 5, "mode": MODE_TEXT, "ratio": "16:9"},
{"resolution": "480P", "duration": 4, "mode": MODE_TEXT, "ratio": "16:9"},
{
"resolution": "768P",
"duration": 5,
"mode": MODE_REFERENCE,
"reference_image_urls": ["https://cdn.example.com/reference.png"],
},
]
for case in invalid_cases:
with self.subTest(case=case), self.assertRaises(ValueError):
build_request_body(prompt="x", model=MODEL_MAX_ID, **case)
def test_validation(self):
with self.assertRaises(ValueError):
build_request_body(
prompt="x",
resolution="2K",
duration=5,
mode=MODE_TEXT,
ratio="adaptive",
)
with self.assertRaises(ValueError):
build_request_body(
prompt="x" * 7001,
resolution="2K",
duration=5,
mode=MODE_TEXT,
ratio="16:9",
)
with self.assertRaises(ValueError):
build_request_body(
prompt="x",
resolution="2K",
duration=3,
mode=MODE_TEXT,
ratio="16:9",
)
with self.assertRaises(ValueError):
build_request_body(
prompt="x",
resolution="2K",
duration=5,
mode=MODE_REFERENCE,
)
with self.assertRaises(ValueError):
build_request_body(
prompt="x",
resolution="2K",
duration=5,
mode=MODE_FIRST_LAST,
first_url="https://cdn.example.com/first.png",
last_url="https://cdn.example.com/last.png",
reference_image_urls=["https://cdn.example.com/reference.png"],
)
with self.assertRaises(ValueError):
build_request_body(
prompt="x",
resolution="2K",
duration=5,
mode=MODE_REFERENCE,
reference_image_urls=[f"https://cdn.example.com/{i}.png" for i in range(10)],
)
class MiniMaxH3ClientTests(unittest.TestCase):
def test_video_clients_share_2000_second_poll_deadline(self):
self.assertEqual(POLL_DEADLINE_SECONDS, 2000)
self.assertEqual(MiniMaxH3Client.POLL_DEADLINE_SECONDS, 2000)
self.assertEqual(NewAPIVeoClient.POLL_DEADLINE_SECONDS, 2000)
self.assertEqual(
signature(NewAPIVeoClient.poll_video_status_async)
.parameters["timeout"].default,
2000,
)
self.assertEqual(
signature(NewAPIVeoClient.generate_video_sync)
.parameters["timeout"].default,
2000,
)
def test_public_id_precedence(self):
self.assertEqual(
extract_public_task_id({"id": "public-id", "task_id": "fallback-id"}),
"public-id",
)
def test_wrapped_success_snapshot(self):
snapshot = parse_task_snapshot({
"code": "success",
"data": {
"status": "SUCCESS",
"progress": "100%",
"result_url": "https://cdn.example.com/result.mp4",
},
})
self.assertEqual(snapshot["status"], "SUCCESS")
self.assertEqual(snapshot["progress"], 100)
self.assertEqual(snapshot["result_url"], "https://cdn.example.com/result.mp4")
def test_http_200_failure_snapshot(self):
snapshot = parse_task_snapshot({
"code": "success",
"data": {
"status": "FAILURE",
"fail_reason": "上游拒绝",
},
})
self.assertEqual(snapshot["status"], "FAILURE")
self.assertEqual(snapshot["fail_reason"], "上游拒绝")
def test_official_v2_success_snapshot(self):
snapshot = parse_task_snapshot({
"task": {
"id": "424010985738629",
"status": "succeeded",
"content": {"url": "https://cdn.example.com/official.mp4"},
}
})
self.assertEqual(snapshot["status"], "SUCCEEDED")
self.assertEqual(snapshot["result_url"], "https://cdn.example.com/official.mp4")
def test_official_v2_failure_snapshot(self):
snapshot = parse_task_snapshot({
"task": {
"status": "failed",
"error": {"code": "1026", "message": "sensitive content"},
}
})
self.assertEqual(snapshot["status"], "FAILED")
self.assertIn("1026", snapshot["fail_reason"])
def test_official_status_sets(self):
self.assertIn("RUNNING", PENDING_STATUSES)
self.assertIn("UNKNOWN", PENDING_STATUSES)
self.assertIn("SUCCEEDED", SUCCESS_STATUSES)
self.assertIn("CANCELLED", FAILURE_STATUSES)
def test_latest_completed_and_failed_response_shapes(self):
completed = parse_task_snapshot({
"id": "task-public-id",
"status": "completed",
"progress": 100,
"metadata": {"url": "https://cdn.example.com/metadata-result.mp4"},
})
self.assertEqual(completed["status"], "COMPLETED")
self.assertEqual(
completed["result_url"],
"https://cdn.example.com/metadata-result.mp4",
)
failed = parse_task_snapshot({
"task_id": "task-public-id",
"status": "failed",
"error": {"code": "upstream_rejected", "message": "内容被拒绝"},
})
self.assertEqual(failed["status"], "FAILED")
self.assertIn("upstream_rejected", failed["fail_reason"])
def test_headers_do_not_include_management_user_header(self):
headers = MiniMaxH3Client(
base_url="https://new-api.example.com/",
api_key="test-token",
)._headers()
self.assertEqual(headers["Authorization"], "Bearer test-token")
self.assertNotIn("New-Api-User", headers)
def test_v3_schema_and_registration(self):
schema = MiniMaxH3Video.define_schema()
schema.validate()
self.assertIsNotNone(schema)
self.assertEqual(
[item.id for item in schema.inputs[-2:]],
["模型", "seed"],
)
seed_input = schema.inputs[-1]
self.assertEqual(seed_input.default, 0)
self.assertEqual(seed_input.min, 0)
self.assertEqual(seed_input.max, MAX_SEED)
from comfyui_o1key import NODE_CLASS_MAPPINGS
self.assertIs(NODE_CLASS_MAPPINGS["MiniMaxH3Video"], MiniMaxH3Video)
@patch("comfy.utils.ProgressBar")
def test_node_progress_mirrors_gateway_percentage_without_regressing(self, progress_cls):
progress_bar = progress_cls.return_value
on_stage, on_progress = _make_progress_callbacks()
on_stage("submitting")
on_stage("submitted:task-public-id")
on_progress(20)
on_progress(20)
on_progress(10)
on_progress(50)
on_progress(100)
on_stage("downloading")
on_stage("done")
self.assertEqual(
progress_bar.update_absolute.call_args_list,
[call(0, 100), call(20, 100), call(50, 100), call(100, 100)],
)
class MiniMaxH3PollingTests(unittest.IsolatedAsyncioTestCase):
@staticmethod
def _response(payload):
response = AsyncMock()
response.text = AsyncMock(return_value=json.dumps(payload))
return response
async def test_documented_unknown_continues_until_gateway_syncs(self):
request = AsyncMock(side_effect=[
self._response({
"id": "task-public-id",
"status": "unknown",
"progress": 0,
}),
self._response({
"data": {
"status": "QUEUED",
"progress": 1,
"result_url": "",
}
}),
self._response({
"data": {
"status": "SUCCESS",
"progress": 100,
"result_url": "https://cdn.example.com/result.mp4",
}
}),
])
sleep = AsyncMock()
client = MiniMaxH3Client(
base_url="https://new-api.example.com",
api_key="test-token",
)
with (
patch(
"comfyui_o1key.clients.minimax_h3_client.async_request_with_retry",
request,
),
patch(
"comfyui_o1key.clients.minimax_h3_client.interruptible_sleep",
sleep,
),
):
result_url = await client.poll_async("task-public-id", object())
self.assertEqual(result_url, "https://cdn.example.com/result.mp4")
self.assertEqual(request.await_count, 3)
self.assertEqual(sleep.await_args_list, [call(10.0), call(10.0)])
self.assertEqual(
request.await_args_list[0].args[2],
"https://new-api.example.com/v1/videos/task-public-id",
)
async def test_generate_downloads_completed_result_without_api_headers(self):
session = object()
session_context = MagicMock()
session_context.__aenter__ = AsyncMock(return_value=session)
session_context.__aexit__ = AsyncMock(return_value=False)
submit = AsyncMock(return_value="task-public-id")
poll = AsyncMock(return_value="https://cdn.example.com/result.mp4")
download = AsyncMock(return_value="result.mp4")
stages = []
client = MiniMaxH3Client(
base_url="https://new-api.example.com",
api_key="test-token",
)
with (
patch(
"comfyui_o1key.clients.minimax_h3_client.aiohttp.TCPConnector",
return_value=object(),
),
patch(
"comfyui_o1key.clients.minimax_h3_client.aiohttp.ClientSession",
return_value=session_context,
),
patch.object(client, "submit_async", submit),
patch.object(client, "poll_async", poll),
patch(
"comfyui_o1key.clients.minimax_h3_client.download_video_to_file",
download,
),
):
result = await client.generate_async(
body={"model": "MiniMax-H3"},
save_path="result.mp4",
on_stage=stages.append,
)
self.assertEqual(result, ("result.mp4", "task-public-id"))
self.assertEqual(
stages,
["submitting", "submitted:task-public-id", "downloading", "done"],
)
download.assert_awaited_once_with(
session,
"https://cdn.example.com/result.mp4",
"result.mp4",
label="MiniMax H3 task-public-id",
)
class MiniMaxH3MediaValidationTests(unittest.TestCase):
def test_image_limits(self):
info = validate_image(Image.new("RGB", (256, 256)), "测试图片")
self.assertEqual(info["width"], 256)
with self.assertRaises(ValueError):
validate_image(Image.new("RGB", (255, 256)), "过小图片")
def test_audio_duration(self):
audio = {
"waveform": torch.zeros((1, 1, 48000 * 2)),
"sample_rate": 48000,
}
info = inspect_audio(audio)
self.assertEqual(info["duration"], 2.0)
with self.assertRaises(ValueError):
inspect_audio({
"waveform": torch.zeros((1, 1, 48000)),
"sample_rate": 48000,
})
with self.assertRaises(ValueError):
validate_reference_audios([
{"waveform": torch.zeros((1, 1, 48000 * 8)), "sample_rate": 48000},
{"waveform": torch.zeros((1, 1, 48000 * 8)), "sample_rate": 48000},
])
def test_video_metadata_limits(self):
valid = {
"size": 1024,
"width": 1920,
"height": 1080,
"duration": 5.0,
"fps": 24.0,
"video_codec": "h264",
"audio_codecs": {"aac"},
"format_names": {"mov", "mp4"},
}
validate_video_info(valid)
# The current API contract constrains the MP4/MOV container, duration,
# and frame rate, but does not impose a client-side codec allowlist.
validate_video_info(dict(valid, video_codec="vp9", audio_codecs={"opus"}))
invalid = dict(valid, format_names={"matroska"})
with self.assertRaises(ValueError):
validate_video_info(invalid)
with patch(
"comfyui_o1key.utils.minimax_h3_media.probe_video",
side_effect=[dict(valid, duration=8.0), dict(valid, duration=8.0)],
):
with self.assertRaises(ValueError):
validate_reference_videos([object(), object()])
def test_probe_real_mp4(self):
fd, path = tempfile.mkstemp(suffix=".mp4", prefix="minimax_h3_probe_")
os.close(fd)
try:
container = av.open(path, mode="w")
stream = container.add_stream("libx264", rate=24)
stream.width = 256
stream.height = 256
stream.pix_fmt = "yuv420p"
frame_data = np.zeros((256, 256, 3), dtype=np.uint8)
for _ in range(72):
frame = av.VideoFrame.from_ndarray(frame_data, format="rgb24")
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
container.close()
info = probe_video(path)
validate_video_info(info)
self.assertEqual(info["video_codec"], "h264")
self.assertGreaterEqual(info["duration"], 2.0)
finally:
try:
os.remove(path)
except OSError:
pass
if __name__ == "__main__":
unittest.main(verbosity=2)
+418
View File
@@ -0,0 +1,418 @@
import asyncio
import base64
import importlib.util
import io
import json
import sys
import threading
import types
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
def _load_download_module():
package = types.ModuleType("comfyui_o1key")
package.__path__ = [str(ROOT)]
utils_package = types.ModuleType("comfyui_o1key.utils")
utils_package.__path__ = [str(ROOT / "utils")]
clients_package = types.ModuleType("comfyui_o1key.clients")
clients_package.__path__ = [str(ROOT / "clients")]
sys.modules[package.__name__] = package
sys.modules[utils_package.__name__] = utils_package
sys.modules[clients_package.__name__] = clients_package
http_error_spec = importlib.util.spec_from_file_location(
"comfyui_o1key.utils.http_error",
ROOT / "utils" / "http_error.py",
)
http_error = importlib.util.module_from_spec(http_error_spec)
sys.modules[http_error_spec.name] = http_error
http_error_spec.loader.exec_module(http_error)
gemini_module = types.ModuleType("comfyui_o1key.clients.gemini_client")
gemini_module.GeminiAPIClient = object
sys.modules[gemini_module.__name__] = gemini_module
module_spec = importlib.util.spec_from_file_location(
"comfyui_o1key.utils.nano_banana_async",
ROOT / "utils" / "nano_banana_async.py",
)
module = importlib.util.module_from_spec(module_spec)
sys.modules[module_spec.name] = module
module_spec.loader.exec_module(module)
return module
NANO_ASYNC = _load_download_module()
def _load_batch_module():
torch_module = types.ModuleType("torch")
torch_module.Tensor = type("Tensor", (), {})
sys.modules["torch"] = torch_module
numpy_module = types.ModuleType("numpy")
numpy_module.random = types.SimpleNamespace(seed=lambda _seed: None)
sys.modules["numpy"] = numpy_module
comfy_api = types.ModuleType("comfy_api")
comfy_latest = types.ModuleType("comfy_api.latest")
comfy_latest.io = types.SimpleNamespace(ComfyNode=object, NodeOutput=object)
sys.modules["comfy_api"] = comfy_api
sys.modules["comfy_api.latest"] = comfy_latest
comfy = types.ModuleType("comfy")
comfy.__path__ = []
comfy_utils = types.ModuleType("comfy.utils")
comfy_utils.ProgressBar = object
comfy_model_management = types.ModuleType("comfy.model_management")
comfy_model_management.processing_interrupted = lambda: False
comfy_model_management.InterruptProcessingException = type(
"InterruptProcessingException",
(RuntimeError,),
{},
)
sys.modules["comfy"] = comfy
sys.modules["comfy.utils"] = comfy_utils
sys.modules["comfy.model_management"] = comfy_model_management
folder_paths = types.ModuleType("folder_paths")
folder_paths.get_output_directory = lambda: str(ROOT)
sys.modules["folder_paths"] = folder_paths
psutil = types.ModuleType("psutil")
psutil.Process = object
sys.modules["psutil"] = psutil
image_utils = types.ModuleType("comfyui_o1key.utils.image_utils")
image_utils.tensor_to_pil = lambda _value: []
image_utils.pil_to_tensor = lambda value: value
image_utils.parse_batch_prompts = lambda _value: []
sys.modules[image_utils.__name__] = image_utils
file_utils = types.ModuleType("comfyui_o1key.utils.file_utils")
file_utils.ImageInfo = type("ImageInfo", (), {})
for name in (
"load_images_from_folder",
"pair_images_indexed",
"pair_images_by_name",
"pair_images_cartesian",
"generate_timestamp_filename",
"save_image",
):
setattr(file_utils, name, lambda *_args, **_kwargs: [])
sys.modules[file_utils.__name__] = file_utils
config = types.ModuleType("comfyui_o1key.utils.config")
config.NETWORK_ROUTE_OPTIONS = []
config.get_base_url_by_route = lambda _route: "https://example.invalid"
config.get_api_key_or_raise = lambda _name: "test"
sys.modules[config.__name__] = config
models_config = types.ModuleType("comfyui_o1key.models_config")
models_config.get_model_supported_aspect_ratios = lambda _model: []
models_config.get_all_supported_aspect_ratios = lambda: []
models_config.get_model_supported_resolutions = lambda _model: []
models_config.get_all_supported_resolutions = lambda: []
sys.modules[models_config.__name__] = models_config
module_spec = importlib.util.spec_from_file_location(
"comfyui_o1key.nodes.batch_nano_banana",
ROOT / "nodes" / "batch_nano_banana.py",
)
module = importlib.util.module_from_spec(module_spec)
sys.modules[module_spec.name] = module
module_spec.loader.exec_module(module)
return module
BATCH_NODE = _load_batch_module()
def _png_bytes():
buffer = io.BytesIO()
Image.new("RGB", (2, 2), (12, 34, 56)).save(buffer, format="PNG")
return buffer.getvalue()
class _FakeContent:
def __init__(self, chunks, delay=0):
self._chunks = chunks
self._delay = delay
async def iter_chunked(self, _chunk_size):
for chunk in self._chunks:
if self._delay:
await asyncio.sleep(self._delay)
yield chunk
class _FakeResponse:
def __init__(self, session, chunks, headers=None, delay=0, status=200):
self._session = session
self.status = status
self.headers = headers or {}
self.content = _FakeContent(chunks, delay=delay)
async def __aenter__(self):
self._session.active += 1
self._session.max_active = max(self._session.max_active, self._session.active)
return self
async def __aexit__(self, _exc_type, _exc, _tb):
self._session.active -= 1
class _FakeSession:
def __init__(self, chunks, headers=None, delay=0, status=200):
self._chunks = chunks
self._headers = headers
self._delay = delay
self._status = status
self.calls = []
self.active = 0
self.max_active = 0
def get(self, url, **_kwargs):
self.calls.append(url)
return _FakeResponse(
self,
self._chunks,
headers=self._headers,
delay=self._delay,
status=self._status,
)
class DownloadTests(unittest.IsolatedAsyncioTestCase):
async def test_successful_task_query_keeps_transport_trace_silent(self):
body = json.dumps(
{"task_id": "nano-trace", "status": "SUCCESS", "data": {"images": []}},
separators=(",", ":"),
).encode("utf-8")
session = _FakeSession(
[body],
headers={"Content-Length": str(len(body))},
)
with patch("builtins.print") as print_mock:
payload = await NANO_ASYNC._poll_task(
session,
"https://example.invalid",
"test-key",
"nano-trace",
"Nano Banana",
log_success=False,
initial_delay=False,
)
self.assertEqual(payload["status"], "SUCCESS")
rendered = " ".join(str(value) for call in print_mock.call_args_list for value in call.args)
self.assertNotIn("任务查询传输追踪", rendered)
async def test_task_query_retries_when_content_length_is_short(self):
body = b'{"task_id":"nano-short","status":"SUCCESS"}'
session = _FakeSession(
[body],
headers={"Content-Length": str(len(body) + 12)},
)
with patch.object(
NANO_ASYNC,
"_interruptible_sleep",
new=AsyncMock(),
):
with self.assertRaisesRegex(
RuntimeError,
rf"Content-Length={len(body) + 12}.*received={len(body)}B.*length_check=mismatch",
):
await NANO_ASYNC._poll_task(
session,
"https://example.invalid",
"test-key",
"nano-short",
"Nano Banana",
log_success=False,
initial_delay=False,
)
self.assertEqual(len(session.calls), 4)
def test_unparseable_response_log_never_prints_partial_base64(self):
partial_secret = "A" * 4097
with patch("builtins.print") as print_mock:
NANO_ASYNC._log_body(
"task response",
'{"data":{"images":[{"b64_json":"' + partial_secret,
)
rendered = " ".join(str(value) for call in print_mock.call_args_list for value in call.args)
self.assertNotIn(partial_secret, rendered)
self.assertIn("content omitted", rendered)
async def test_downloaded_result_is_decoded_only_once(self):
image_bytes = _png_bytes()
session = _FakeSession([image_bytes])
original_open = NANO_ASYNC._open_result_image
with patch.object(NANO_ASYNC, "_open_result_image", wraps=original_open) as open_image:
image = await NANO_ASYNC._image_from_url_or_data(
"https://example.invalid/result.png",
session,
)
self.assertEqual(image.size, (2, 2))
self.assertEqual(open_image.call_count, 1)
async def test_deduplicates_urls_and_caps_parallel_downloads_at_50(self):
image_bytes = _png_bytes()
session = _FakeSession(
[image_bytes],
delay=0.01,
)
urls = [f"https://example.invalid/{index}.png" for index in range(60)]
payload = {
"images": urls,
"result": {"image_url": urls[0]},
}
images = await NANO_ASYNC._parse_direct_images(
payload,
session,
download_semaphore=asyncio.Semaphore(50),
)
self.assertEqual(len(images), 60)
self.assertEqual(len(session.calls), 60)
self.assertEqual(session.max_active, 50)
async def test_retries_when_downloaded_bytes_are_not_a_complete_image(self):
session = _FakeSession([b"not a valid image"])
original_delays = NANO_ASYNC._DOWNLOAD_RETRY_DELAYS
NANO_ASYNC._DOWNLOAD_RETRY_DELAYS = (0, 0)
try:
with self.assertRaisesRegex(RuntimeError, "transport failed"):
await NANO_ASYNC._image_from_url_or_data(
"https://example.invalid/truncated.png",
session,
)
finally:
NANO_ASYNC._DOWNLOAD_RETRY_DELAYS = original_delays
self.assertEqual(len(session.calls), 3)
async def test_interrupt_check_cancels_during_stream(self):
session = _FakeSession([b"1234", b"5678"], delay=0.01)
checks = 0
def check_interrupt():
nonlocal checks
checks += 1
if checks >= 2:
raise asyncio.CancelledError()
with self.assertRaises(asyncio.CancelledError):
await NANO_ASYNC._download_image_bytes(
"https://example.invalid/cancel.png",
session,
check_interrupt=check_interrupt,
)
async def test_refetches_same_task_when_inline_base64_is_incomplete(self):
invalid_payload = {
"status": "SUCCESS",
"data": {"images": [{"b64_json": "truncated-base64"}]},
}
valid_payload = {
"status": "SUCCESS",
"data": {"images": [{
"b64_json": base64.b64encode(_png_bytes()).decode("ascii"),
}]},
}
session = object()
with (
patch.object(
NANO_ASYNC,
"_poll_task",
new=AsyncMock(return_value=valid_payload),
) as poll_task,
patch.object(
NANO_ASYNC,
"_interruptible_sleep",
new=AsyncMock(),
) as retry_sleep,
):
final_payload, parsed = await NANO_ASYNC._parse_completed_task_images_with_retry(
invalid_payload,
session=session,
base_url="https://example.invalid",
api_key="test-key",
task_id="task-1",
node_label="Nano Banana",
)
images, metrics = parsed
try:
self.assertIs(final_payload, valid_payload)
self.assertEqual(len(images), 1)
self.assertEqual(images[0].size, (2, 2))
self.assertEqual(metrics["inline_images"], 1)
retry_sleep.assert_awaited_once()
poll_task.assert_awaited_once_with(
session,
"https://example.invalid",
"test-key",
"task-1",
"Nano Banana",
check_interrupt=None,
log_body_enabled=False,
progress_callback=None,
log_success=False,
initial_delay=False,
)
finally:
for image in images:
image.close()
class BatchCancellationTests(unittest.IsolatedAsyncioTestCase):
def test_timeout_scales_per_50_task_batch(self):
per_batch = BATCH_NODE._PER_BATCH_TIMEOUT_SECONDS
grace = BATCH_NODE._BATCH_TIMEOUT_GRACE_SECONDS
self.assertEqual(BATCH_NODE._batch_timeout_seconds(1), per_batch + grace)
self.assertEqual(BATCH_NODE._batch_timeout_seconds(50), per_batch + grace)
self.assertEqual(BATCH_NODE._batch_timeout_seconds(51), per_batch * 2 + grace)
async def test_stop_event_cancels_active_coroutine(self):
stop_event = threading.Event()
started = asyncio.Event()
cleaned_up = asyncio.Event()
async def active_work():
started.set()
try:
await asyncio.sleep(60)
finally:
cleaned_up.set()
task = asyncio.create_task(
BATCH_NODE._run_with_interrupt(active_work(), stop_event)
)
await started.wait()
stop_event.set()
with self.assertRaises(BATCH_NODE._BatchStopRequested):
await asyncio.wait_for(task, timeout=1)
self.assertTrue(cleaned_up.is_set())
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More