1 Commits
Author SHA1 Message Date
o1key 9ee29e17d0 Initial commit: Comfyui_o1key v1.10.0 2026-02-06 15:56:30 +08:00
199 changed files with 3021 additions and 68117 deletions
+634
View File
@@ -0,0 +1,634 @@
# Comfyui_o1key 开发指南
## 对话原则
始终使用中文进行对话。
## 项目概述
这是一个 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 # API 配置文件(不提交)
├── .config.example # 配置示例
├── requirements.txt # 依赖包
└── README.md # 用户文档
```
---
## 模型管理系统
### 概述
所有 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
# 获取 API 密钥(返回 None 如果未找到)
api_key = get_api_key("O1KEY_API_KEY")
# 获取 API 密钥(抛出异常如果未找到)
api_key = get_api_key_or_raise("O1KEY_API_KEY")
# 加载完整配置
config = load_config()
```
---
## 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`
更新脚本会自动:
- 检查远程更新
- 备份配置文件
- 拉取最新代码
- 更新依赖包
- 显示更新日志
---
-38
View File
@@ -1,38 +0,0 @@
# EditorConfig 配置文件
# https://editorconfig.org
root = true
# 默认配置
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
# Python 文件
[*.py]
indent_size = 4
# Shell 脚本
[*.sh]
indent_size = 4
# Windows 批处理文件
[*.{bat,cmd}]
end_of_line = crlf
indent_size = 4
# Markdown 文件
[*.md]
trim_trailing_whitespace = false
# YAML 文件
[*.{yml,yaml}]
indent_size = 2
# JSON 文件
[*.json]
indent_size = 2
-31
View File
@@ -1,31 +0,0 @@
# 默认自动处理行结束符
* text=auto
# Python 文件使用 LF
*.py text eol=lf
# Shell 脚本使用 LF
*.sh text eol=lf
# Windows 批处理文件使用 CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# 配置文件使用 LF
.config text eol=lf
.config.* text eol=lf
# Markdown 文档使用 LF
*.md text eol=lf
# 二进制文件
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.mov binary
*.mp4 binary
*.mp3 binary
*.zip binary
*.psd binary
+3 -13
View File
@@ -1,3 +1,6 @@
# 隐私文件(已弃用配置文件,改用环境变量)
# .config
# Python 缓存
__pycache__/
*.py[cod]
@@ -21,16 +24,3 @@ venv/
# OS
.DS_Store
Thumbs.db
# 用户配置(含 API Key,不提交)
.config
# 本地开发工具与浏览器测试状态
.claude/
.codex/
.playwright-mcp/
# 测试与覆盖率缓存
.pytest_cache/
.coverage
htmlcov/
-86
View File
@@ -1,86 +0,0 @@
# 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`
+36 -49
View File
@@ -6,59 +6,46 @@
---
## [1.10.4] - 2026-04-13
## [1.10.0] - 2026-02-06
### 修复
- 修复香蕉2画草图导致生成2张图片问题
---
## [1.10.3] - 2026-04-13
### 修复
- 香蕉节点修复返回2张图、灰色图片问题
- 修复 SSL 证书报错,统一由【保存图像】节点保存
### 新增
- 支持【立刻取消】生图请求,可立即重新运行
- 支持工作流历史记录恢复
- 并发上限提升,批量生图速度大幅提升
- 全能LLM新增视频/文档分析、流式实时预览
- Seedance 2.0 新增多图及视频URL参考(优化中)
---
## [Unreleased]
### Added ✨
- **快捷配置脚本**
- 新增 `设置API密钥(win).bat` - Windows 一键配置工具
- 新增 `设置API密钥(mac).sh` - Mac/Linux 一键配置工具
- 自动生成 `.config` 配置文件
- 交互式提示引导用户输入 API 密钥
- 自动检测并提示覆盖已存在的配置文件
- 彩色输出和友好的用户提示信息
- **配置模板文件**
- 新增 `.config.example` 作为配置文件示例
### Added ⭐
- **自动更新系统** - 让用户轻松更新插件到最新版本
- 新增 `update.bat` - Windows 自动更新脚本
- 新增 `update.sh` - Linux/Mac 自动更新脚本
- 新增 `version.txt` - 版本号管理文件
- 新增 `utils/update_checker.py` - 启动时自动检查更新
- 新增更新检查功能:每次启动 ComfyUI 时自动检测是否有新版本
- **更新脚本功能**
- ✅ 自动检查远程更新
- ✅ 自动备份和恢复 `.config` 配置文件
- ✅ 自动拉取最新代码
- ✅ 自动更新 Python 依赖包
- ✅ 显示版本变更信息
- ✅ 显示最近更新日志(前 20 行)
- ✅ 友好的彩色终端输出(Linux/Mac)
- ✅ 完善的错误处理和提示
### Changed
- **502 错误提示优化** (`clients/base_client.py`)
- 当 API 返回 502 时,弹框显示友好文案:「糟糕!请求到上游时遇到超时或过载!别担心,过会儿再次点击运行即可!」
- `request_async``request_get_async` 中均增加 502 专用分支
- **配置管理策略**
- `.config` 文件现在完全忽略提交(添加到 `.gitignore`
- 简化配置流程,用户通过快捷脚本自动创建本地配置
- 移除配置文件安全检查机制(不再需要)
- **README 文档**
- 更新配置章节,添加快捷脚本使用说明
- 调整配置方法优先级:快捷脚本 > 环境变量 > 手动配置
- 简化安全提示说明
- **插件启动流程** (`__init__.py`)
- 集成更新检查模块
- 启动时自动检查是否有新版本
- 如有更新,终端显示友好的更新提示
- 静默失败机制,不影响插件正常加载
### Removed
- **安全检查工具**(不再需要)
- 删除 `check_config_safety.py` 配置安全检查脚本
- 删除 `.git-hooks-install.bat` Git Hook 安装脚本
- 彻底杜绝配置文件泄密风险
- **文档更新** (`README.md`)
- 新增"🔄 更新插件"章节
- 提供两种更新方法:自动更新(推荐)和手动更新
- 详细的跨平台更新说明
- 更新提示和注意事项
### Benefits
- 🎯 **用户友好** - 一键更新,无需手动操作 Git
- 🔒 **配置安全** - 自动备份恢复配置,不会丢失设置
-**依赖同步** - 自动更新 Python 包,确保兼容性
- 📋 **信息透明** - 显示版本变更和更新日志
- 🌍 **跨平台** - 支持 Windows/Linux/Mac
- 🛡️ **稳定可靠** - 完善的错误处理,不影响插件运行
---
+2 -327
View File
@@ -1,337 +1,12 @@
# Comfyui_o1key
通过 `api.o1key.cn` 调用 AI 模型的 ComfyUI 自定义节点集合。
通过 `api.o1key.com` 调用 AI 模型的 ComfyUI 自定义节点集合。
## 功能特性
- 🎨 文生图 / 图生图
- 🔄 批量并发生成(最多 1000 张)
- 📐 10 种宽高比
- 🎯 智能分辨率,或手动选择 1K / 2K / 4K
- 🎯 3 种分辨率(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)
---
## 📦 安装
### 方法一:通过 ComfyUI Manager(推荐)
1. 在 ComfyUI 中打开 Manager
2. 搜索 `Comfyui_o1key`
3. 点击安装
4. 重启 ComfyUI
### 方法二:手动安装
```bash
cd ComfyUI/custom_nodes
git clone https://git.o1key.com/publisher/comfyui_o1key.git
cd comfyui_o1key
pip install -r requirements.txt
```
然后重启 ComfyUI。
### 方法三:客户压缩包安装(Windows 便携版)
把压缩包中的 `comfyui_o1key` 文件夹完整解压到 `ComfyUI\custom_nodes\`,不要只复制其中的 Python 文件。然后在插件目录运行:
```powershell
..\..\..\python_embeded\python.exe -m pip install -r requirements.txt
```
重启 ComfyUI 后,在左侧「令牌管理」中填写自己的 API Key。不要把其他人的 `.config` 文件复制到新安装目录。使用内置更新还需要系统能够运行 `git --version`
Windows 用户也可以在 ComfyUI 左侧侧栏打开“更新”面板更新插件;使用前请阅读下方关于本地修改的提示。
---
## ⚙️ 配置
### 获取 API 密钥
1. 访问 [vip.o1key.com](https://vip.o1key.com)
2. 注册并获取 API 密钥
### 配置方式
#### 配置 API 密钥(必需)
**方法一:ComfyUI 界面配置(推荐)⭐**
启动 ComfyUI 后,点击左侧栏的「令牌管理」,填写 API Key、选择网络线路,然后点击「保存并立即生效」。也可以在该窗口测试连接或清除已保存的 Key。
**方法二:手动创建配置文件**
在插件目录下创建 `.config` 文件:
```
O1KEY_API_KEY=你的API密钥
```
> **⚠️ 安全提示**
>
> `.config` 文件包含敏感信息,已添加到 `.gitignore` 中,不会被提交到版本控制。
> 请妥善保管你的 API 密钥,不要分享给他人。
#### 配置 API 地址(可选)
默认使用 `https://api.o1key.cn`,通常无需修改。
通常应通过「令牌管理」选择全局网络线路。如需调试自定义地址,可在 `.config` 中添加:
```text
O1KEY_API_BASE_URL=https://your-api-domain.com
O1KEY_ASYNC_API_BASE_URL=https://your-async-api-domain.com
```
配置键和线路解析规则见 [配置文档](docs/configuration.md)。
---
## 🔄 更新插件
本次发布以当前代码作为新基线,部分旧节点 ID 已移除。包含这些节点的旧工作流可能显示“缺失节点”;更新前请备份工作流。完整清单和处理办法见 [发布兼容性决定](docs/decisions/0014-new-release-code-baseline.md)。
### 方法一:ComfyUI 侧栏
1. 点击 ComfyUI 左侧工具栏中“令牌管理”下方的“更新”按钮。
2. 等待版本检查。如果发现新版本,确认后等待更新完成;需要时可在面板中重新检查。
3. 更新完成后,ComfyUI 会自动重启并刷新页面。如果面板提示需要技术支持,请联系维护人员完成配置后再重启。
> 首次从旧版本更新到支持自动重启的版本时,请按旧版面板提示手动重启一次;之后的更新会自动重启。
> 如果当前安装包含自定义修改,面板会停止更新并保留现有内容。此时请联系维护人员处理。
### 方法二:手动更新
从 O1Key 发布仓库拉取:
```bash
cd ComfyUI/custom_nodes/comfyui_o1key
git fetch https://git.o1key.com/publisher/comfyui_o1key.git main
git merge --ff-only FETCH_HEAD
pip install -r requirements.txt --upgrade
```
**💡 提示:** 更新面板不会修改 `.config`;手动更新前请自行确认工作区没有未保存的代码修改。
---
## 📚 节点说明
### 提示词(多功能)
在输入框中用单独一行的 `---` 分隔多套提示词,然后选择输出方式:
- 「全部使用」输出全部提示词。
- 「随机抽取n套」按“抽取数量”不重复随机选择,例如准备 10 套后填写 `1``3``5`;抽中的提示词会按原始顺序输出。
- 「指定序号」按从 1 开始的序号选择并按填写顺序输出,支持 `1,3,5`、中文逗号、空格和 `2-4` 区间。
控件会随模式动态切换:「随机抽取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 从插件配置读取,不会保存在工作流中
---
## 📝 更新日志
查看 [CHANGELOG.md](./CHANGELOG.md) 了解详细的版本更新记录。
---
## 📄 许可证
本项目采用 Apache License 2.0 许可证。
---
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
开始开发前请阅读 [AGENTS.md](AGENTS.md) 和 [维护者文档](docs/README.md)。
---
## ⚠️ 开发者注意事项
### 维护者:发布流程与镜像同步
代码**先提交并推送到 GitHub**,再**同步到 Gitee 镜像**,国内用户通过 Gitee 拉取以解决网络问题。
**首次配置**(仅需一次):
```bash
git remote add gitee https://gitee.com/resonLzy/comfyui_o1key.git
```
**每次发布**
```bash
git push origin main # 先更新 GitHub
git push gitee main # 再同步到 Gitee 镜像
```
### 文件编码要求
**所有文本文件必须使用 UTF-8 编码(无 BOM)!**
如果出现中文乱码,请确认编辑器按 UTF-8(无 BOM)读取和保存文件。
---
## 📮 联系方式
- GitHub: [@lizhongyi1209](https://github.com/lizhongyi1209)
- 项目地址: https://git.o1key.com/publisher/comfyui_o1key
---
**当前版本:v1.10.1**
+15 -1342
View File
File diff suppressed because it is too large Load Diff
-440
View File
@@ -1,440 +0,0 @@
{
"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": {}
}
+7 -31
View File
@@ -1,34 +1,10 @@
"""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.
"""
API 客户端模块
包含与外部 API 通信的客户端实现
"""
from importlib import import_module
from .base_client import BaseAPIClient
from .gemini_client import GeminiAPIClient
from .gemini_flash_client import GeminiFlashClient
_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
__all__ = ['BaseAPIClient', 'GeminiAPIClient', 'GeminiFlashClient']
+99 -260
View File
@@ -9,14 +9,8 @@ import threading
from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, List, Optional
import os
import time
import aiohttp
from ..utils.http_error import HTTP_ERROR_MESSAGES, RETRYABLE_STATUS_CODES, _compute_delay, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR, get_friendly_message
class BaseAPIClient(ABC):
"""
@@ -32,21 +26,19 @@ class BaseAPIClient(ABC):
self,
base_url: str,
api_key: str,
max_request_size: int = 100 * 1024 * 1024
max_request_size: int = 20 * 1024 * 1024
):
"""
初始化客户端
Args:
base_url: API 基础 URL
api_key: API 密钥
max_request_size: 兼容参数;基类不再用它限制 JSON 请求体,
部分子类仍用它作为上传文件大小限制
max_request_size: 最大请求体大小(字节),默认 20MB
"""
self.base_url = base_url
self.api_key = api_key
self.max_request_size = max_request_size
self.proxy_url: Optional[str] = None # 由节点在调用前注入,如 "http://127.0.0.1:7897"
@abstractmethod
def get_endpoint(self, **kwargs) -> str:
@@ -87,15 +79,6 @@ class BaseAPIClient(ABC):
"""
pass
def _make_session(self) -> aiohttp.ClientSession:
"""
创建统一的 aiohttp ClientSession,全局禁用 SSL 验证。
所有需要独立创建 session 的地方都应调用此方法,
避免因客户端系统缺少根证书导致 SSLCertVerificationError。
"""
connector = aiohttp.TCPConnector(ssl=False, limit=0, limit_per_host=0)
return aiohttp.ClientSession(connector=connector, trust_env=False)
def get_headers(self, use_bearer_token: bool = False) -> Dict[str, str]:
"""
获取请求头
@@ -117,19 +100,26 @@ class BaseAPIClient(ABC):
"Content-Type": "application/json"
}
def get_http_error_message(self, status_code: int, error_message: str) -> Optional[str]:
def check_request_size(self, request_body: Dict[str, Any]) -> None:
"""
子类可重写:为指定 HTTP 状态码返回自定义错误文案。
若返回 None,则使用基类默认拼接文案。
检查请求体大小是否超过限制
Args:
status_code: HTTP 状态码(如 429、503
error_message: API 返回的原始错误信息
request_body: 请求体字典
Returns:
自定义完整错误文案,或 None 表示使用默认
Raises:
ValueError: 如果请求体超过限制
"""
return None
request_json = json.dumps(request_body)
request_size = len(request_json.encode('utf-8'))
if request_size > self.max_request_size:
size_mb = request_size / 1024 / 1024
limit_mb = self.max_request_size / 1024 / 1024
raise ValueError(
f"请求体大小 {size_mb:.2f}MB 超过限制 {limit_mb:.0f}MB"
"请降低分辨率或减少图片数量"
)
async def request_async(
self,
@@ -140,155 +130,84 @@ class BaseAPIClient(ABC):
timeout: Optional[int] = None
) -> Dict[str, Any]:
"""
发送异步 HTTP 请求(带详细计时)
发送异步 HTTP 请求
Args:
endpoint: API 端点
request_body: 请求体
session: aiohttp 会话(可选)
use_bearer_token: 是否使用 Bearer Token 认证
timeout: 超时时间(秒),默认 900 秒
Returns:
响应 JSON
Raises:
RuntimeError: 请求失败时
InterruptProcessingException: 用户点击终止按钮时
"""
import time
# 尝试导入 ComfyUI 中断机制
try:
from comfy.model_management import processing_interrupted, InterruptProcessingException
_interrupt_available = True
except ImportError:
_interrupt_available = False
url = f"{self.base_url}{endpoint}"
headers = self.get_headers(use_bearer_token)
# 检查请求大小
self.check_request_size(request_body)
close_session = False
if session is None:
session = self._make_session()
session = aiohttp.ClientSession()
close_session = True
# 设置请求超时:连接超时 30s,读取超时 900s(防止服务器出图后卡住)
_timeout_seconds = timeout if timeout is not None else 900
_aiohttp_timeout = aiohttp.ClientTimeout(
total=_timeout_seconds,
connect=30,
sock_read=_timeout_seconds
)
async def _do_request():
connect_start = time.time()
async with session.post(url, json=request_body, headers=headers, timeout=_aiohttp_timeout, proxy=self.proxy_url) as response:
connect_time = time.time() - connect_start
try:
# 设置超时
timeout_obj = aiohttp.ClientTimeout(total=timeout) if timeout else None
async with session.post(url, json=request_body, headers=headers, timeout=timeout_obj) as response:
if response.status != 200:
error_text = await response.text()
# 返回状态码和错误文本,由外层处理重试
return {"_error": True, "_status": response.status, "_text": error_text}
wait_start = time.time()
response_data = await response.json()
download_time = time.time() - wait_start
response_size = len(str(response_data))
if not isinstance(response_data, dict):
response_data = {"data": response_data}
response_data["_timing"] = {
"connect_time": connect_time,
"download_time": download_time,
"response_size": response_size
}
return response_data
async def _poll_interrupt():
"""每 0.5s 轮询一次中断标志"""
while True:
await asyncio.sleep(0.5)
if processing_interrupted():
return
try:
last_error_status = None
last_error_text = ""
for attempt in range(DEFAULT_MAX_RETRIES + 1):
if _interrupt_available:
request_task = asyncio.ensure_future(_do_request())
interrupt_task = asyncio.ensure_future(_poll_interrupt())
done, pending = await asyncio.wait(
[request_task, interrupt_task],
return_when=asyncio.FIRST_COMPLETED
)
for t in pending:
t.cancel()
try:
await t
except (asyncio.CancelledError, Exception):
pass
if interrupt_task in done and request_task not in done:
raise InterruptProcessingException()
result = request_task.result()
else:
result = await _do_request()
if isinstance(result, dict) and result.get("_error"):
status = result["_status"]
error_text = result["_text"]
last_error_status = status
last_error_text = error_text
if status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES:
friendly = HTTP_ERROR_MESSAGES.get(status, f"请求失败 ({status})")
delay = _compute_delay(attempt, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR)
print(f"{friendly} {delay:.1f}s 后重试 ({attempt+1}/{DEFAULT_MAX_RETRIES})...")
await asyncio.sleep(delay)
continue
if status in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[status])
raise RuntimeError(get_friendly_message(status, error_text))
return result
if last_error_status and last_error_status in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[last_error_status])
raise RuntimeError(get_friendly_message(last_error_status or 0, last_error_text))
except InterruptProcessingException:
raise
except aiohttp.ServerTimeoutError as e:
raise RuntimeError(
f"请求超时!等待服务器响应超过 {_timeout_seconds} 秒。\n"
f"服务器可能仍在生成图片,请稍后重试,或检查网络连接。"
) from e
except aiohttp.ClientConnectorError as e:
raise RuntimeError(
f"无法连接到服务器:{str(e)}\n"
f"请检查网络连接是否正常。"
) from e
except asyncio.TimeoutError as e:
raise RuntimeError(
f"请求超时!等待服务器响应超过 {_timeout_seconds} 秒。\n"
f"服务器可能仍在生成图片,请稍后重试,或检查网络连接。"
) from e
# 针对常见错误状态码提供友好提示
if response.status == 504:
raise RuntimeError(
f"API 请求超时 (504 Gateway Timeout)\n"
f"原因:服务器响应超时或该端点暂时不可用\n"
f"建议:\n"
f" - 尝试使用其他模型\n"
f" - 稍后重试\n"
f" - 降低分辨率或减少输入图像数量\n"
f"详细错误: {error_text[:200]}"
)
elif response.status == 503:
raise RuntimeError(
f"服务暂时不可用 (503 Service Unavailable)\n"
f"原因:模型服务过载或维护中\n"
f"建议:\n"
f" - 稍后重试\n"
f" - 尝试使用其他模型"
)
elif response.status == 429:
raise RuntimeError(
f"请求频率超限 (429 Too Many Requests)\n"
f"原因:API 配额用尽或请求过于频繁\n"
f"建议:\n"
f" - 等待一段时间后重试\n"
f" - 检查 API 配额是否充足"
)
elif response.status == 404:
raise RuntimeError(
f"端点不存在 (404 Not Found)\n"
f"原因:API 端点路径错误或模型不存在\n"
f"建议:\n"
f" - 检查模型名称是否正确\n"
f" - 使用其他可用模型"
)
else:
raise RuntimeError(
f"API 请求失败 (状态码: {response.status}): {error_text}"
)
return await response.json()
finally:
if close_session:
await session.close()
async def request_get_async(
self,
endpoint: str,
@@ -303,7 +222,6 @@ class BaseAPIClient(ABC):
endpoint: API 端点
session: aiohttp 会话(可选)
use_bearer_token: 是否使用 Bearer Token 认证(默认为 True
timeout: 超时时间(秒)- 已废弃,由服务器端控制
Returns:
响应 JSON
@@ -316,67 +234,41 @@ class BaseAPIClient(ABC):
close_session = False
if session is None:
session = self._make_session()
session = aiohttp.ClientSession()
close_session = True
try:
_get_start = time.time()
async with session.get(url, headers=headers) as response:
_get_elapsed = time.time() - _get_start
# 设置超时
timeout_obj = aiohttp.ClientTimeout(total=timeout) if timeout else None
async with session.get(url, headers=headers, timeout=timeout_obj) as response:
if response.status != 200:
error_text = await response.text()
# 尝试解析 JSON 错误信息,提取关键内容
error_message = error_text
try:
error_json = json.loads(error_text)
# 尝试从多个常见位置提取错误信息
if "error" in error_json:
if isinstance(error_json["error"], dict):
error_message = error_json["error"].get("message", error_text)
else:
error_message = str(error_json["error"])
elif "message" in error_json:
error_message = error_json["message"]
except:
# 如果不是 JSON,使用原始文本
pass
# 针对常见错误状态码提供友好提示
if response.status == 400:
if response.status == 504:
raise RuntimeError(
f"请求参数错误 (400 Bad Request)\n"
f"API 返回错误:{error_message}\n"
f"建议:检查请求参数"
f"API 请求超时 (504 Gateway Timeout)\n"
f"原因:服务器响应超时或该端点暂时不可用\n"
f"建议:稍后重试"
)
elif response.status == 401:
elif response.status == 503:
raise RuntimeError(
f"认证失败 (401 Unauthorized)\n"
f"API 返回错误:{error_message}\n"
f"建议:检查 API 密钥"
f"服务暂时不可用 (503 Service Unavailable)\n"
f"原因:服务过载或维护中\n"
f"建议:稍后重试"
)
elif response.status == 429:
custom = self.get_http_error_message(429, error_message)
if custom is not None:
raise RuntimeError(custom)
raise RuntimeError(HTTP_ERROR_MESSAGES[429])
elif response.status == 503:
custom = self.get_http_error_message(503, error_message)
if custom is not None:
raise RuntimeError(custom)
raise RuntimeError(HTTP_ERROR_MESSAGES[503])
elif response.status == 504:
raise RuntimeError(HTTP_ERROR_MESSAGES[504])
elif response.status == 502:
raise RuntimeError(HTTP_ERROR_MESSAGES[502])
raise RuntimeError(
f"请求频率超限 (429 Too Many Requests)\n"
f"原因:API 配额用尽或请求过于频繁\n"
f"建议:等待一段时间后重试"
)
else:
raise RuntimeError(
f"API 请求失败 (状态码: {response.status})\n"
f"API 返回错误:{error_message}"
f"API 请求失败 (状态码: {response.status}): {error_text}"
)
_resp_data = await response.json()
return _resp_data
return await response.json()
finally:
if close_session:
@@ -402,7 +294,9 @@ class BaseAPIClient(ABC):
total = len(requests)
# 创建无限制的连接器
async with self._make_session() as session:
connector = aiohttp.TCPConnector(limit=0, limit_per_host=0)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = []
for req in requests:
@@ -472,58 +366,3 @@ class BaseAPIClient(ABC):
raise RuntimeError("异步任务未返回结果")
return result_container[0]
async def query_balance_async(self) -> Dict[str, Any]:
"""
异步查询账户余额
Returns:
余额信息字典,包含 name、total_available 等字段
Raises:
RuntimeError: 查询失败时
"""
endpoint = "/api/usage/token"
response = await self.request_get_async(endpoint, use_bearer_token=True)
if not response.get("code"):
raise RuntimeError("余额查询响应格式错误")
data = response.get("data", {})
return data
def query_balance_sync(self) -> Dict[str, Any]:
"""
同步查询账户余额(用于 ComfyUI 节点)
Returns:
余额信息字典
Raises:
RuntimeError: 查询失败时
"""
coro = self.query_balance_async()
return self.run_async_in_thread(coro)
def format_balance_info(self, balance_data: Dict[str, Any]) -> str:
"""
格式化余额信息为展示文本
Args:
balance_data: 余额信息字典
Returns:
格式化文本,如 "当前余额:100.00 | APIxxx"
Example:
>>> data = {"name": "test-api", "total_available": 50000000}
>>> client.format_balance_info(data)
'当前余额:100.00 | APItest-api'
"""
api_name = balance_data.get("name", "未知")
total_available = balance_data.get("total_available", 0)
# 实际显示余额 = total_available / 500000,单位:美元
balance_in_dollars = total_available / 500000
return f"当前余额:{balance_in_dollars:.2f} | API{api_name}"
-358
View File
@@ -1,358 +0,0 @@
"""
豆包生图 API 客户端
端点:POST /v1/images/generations/
兼容 new-api 透传格式(OpenAI images/generations 兼容)
设计原则:
- 发送完整正确的请求体,new-api 丢弃字段是其侧问题
- 响应永远是同步 JSONnew-api 强制 stream=false
- 图像输入以 data:image/png;base64,... 格式内联传递
"""
import asyncio
import json
import time
from io import BytesIO
from concurrent.futures import ThreadPoolExecutor
from typing import List, Optional, Union
import aiohttp
from PIL import Image
from ..utils.config import get_api_key_or_raise, get_api_base_url
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.http_error import RETRYABLE_STATUS_CODES, HTTP_ERROR_MESSAGES, _compute_delay, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR
# ── 固定端点 ──────────────────────────────────────────────────────────────────
_ENDPOINT = "/v1/images/generations/"
# ── 轮询 / 请求超时 ───────────────────────────────────────────────────────────
_REQUEST_TIMEOUT = 300 # 单次请求超时秒数(豆包图像生成最长约 60s)
class DoubaoImageClient:
"""
豆包生图客户端(new-api 原生 OpenAI 兼容格式)
new-api 兼容性说明(基于源码分析):
✅ 透传:model / prompt / size / response_format / watermark / image
❌ 丢弃:seed / sequential_image_generation / sequential_image_generation_options
(进入 Extra map,但 MarshalJSON 中合并代码被注释)
❌ 强制:stream 硬编码 false,图像接口无流式处理
❌ 未实现:/v1/files 文件上传(501
节点仍发送完整字段,待 new-api 修复后自动生效。
"""
def __init__(self):
self.api_key = get_api_key_or_raise("O1KEY_API_KEY")
self.base_url = get_api_base_url()
# ── 认证头 ────────────────────────────────────────────────────────────────
def _headers(self) -> dict:
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
# ── 图像字段构建 ──────────────────────────────────────────────────────────
def _tensor_to_image_field(self, tensor) -> Union[str, List[str]]:
"""
ComfyUI IMAGE tensor → API image 字段值
单张返回字符串,多张返回字符串列表,格式:
data:image/png;base64,<base64数据>
"""
pil_images = tensor_to_pil(tensor)
data_urls = []
for img in pil_images:
b64 = encode_image_to_base64(img, format="PNG")
data_urls.append(f"data:image/png;base64,{b64}")
return data_urls[0] if len(data_urls) == 1 else data_urls
# ── 请求体构建 ────────────────────────────────────────────────────────────
def _build_body(
self,
model: str,
prompt: str,
size: str,
seed: int,
sequential_image_generation: str,
max_images: int,
image_field=None, # str | list[str] | None
) -> dict:
"""
构建完整请求体。
字段说明(对照官方示例):
- response_format: 固定 "url"new-api 原样透传给豆包)
- watermark: 固定 FalseUI 已移除该参数)
- stream: 固定 Falsenew-api 强制非流式,此字段不被读取,仅显式注明)
- sequential_image_generation_options: 仅 sequential=auto 时发送
"""
body = {
"model": model,
"prompt": prompt,
"size": size,
"response_format": "url",
"watermark": False,
"seed": seed,
"sequential_image_generation": sequential_image_generation,
}
# 仅 auto 模式才发送 max_images 选项
if sequential_image_generation == "auto":
body["sequential_image_generation_options"] = {
"max_images": max_images
}
# 图像输入(图生图)
if image_field is not None:
body["image"] = image_field
return body
# ── 图像下载 ──────────────────────────────────────────────────────────────
async def _download_image(
self,
url: str,
session: aiohttp.ClientSession,
) -> Image.Image:
"""从 URL 下载图像,返回 PIL.Image。"""
async with session.get(url, allow_redirects=True) as resp:
if resp.status != 200:
raise RuntimeError(
f"图像下载失败,HTTP {resp.status}URL: {url}"
)
data = await resp.read()
try:
img = Image.open(BytesIO(data)).convert("RGB")
except Exception as e:
raise RuntimeError(f"图像解码失败: {e}")
return img
# ── 响应解析 ──────────────────────────────────────────────────────────────
async def _parse_response(
self,
resp_json: dict,
session: aiohttp.ClientSession,
) -> List[Image.Image]:
"""
解析 /v1/images/generations 响应,返回 PIL.Image 列表。
期望格式(new-api 原样透传豆包响应):
{
"created": 1234567890,
"data": [
{"url": "https://..."},
{"url": "https://..."}
]
}
兼容 b64_json 字段(豆包理论上也支持)。
"""
# 检查 API 层级错误
if "error" in resp_json:
err = resp_json["error"]
if isinstance(err, dict):
msg = err.get("message") or err.get("msg") or json.dumps(err, ensure_ascii=False)
else:
msg = str(err)
raise RuntimeError(f"API 返回错误: {msg}")
data_list = resp_json.get("data")
if not data_list:
raise RuntimeError(
f"API 响应中未找到 data 字段,完整响应:\n"
f"{json.dumps(resp_json, ensure_ascii=False, indent=2)}"
)
images: List[Image.Image] = []
for idx, item in enumerate(data_list):
url = item.get("url", "")
b64 = item.get("b64_json", "")
if url and url.startswith("http"):
# 优先使用 URL 模式
img = await self._download_image(url, session)
images.append(img)
print(f"[豆包生图] 第 {idx + 1} 张下载完成 ({img.size[0]}×{img.size[1]})")
elif b64:
# 回退到 base64 模式
import base64 as _b64
try:
img_data = _b64.b64decode(b64)
img = Image.open(BytesIO(img_data)).convert("RGB")
images.append(img)
print(f"[豆包生图] 第 {idx + 1} 张 base64 解码完成 ({img.size[0]}×{img.size[1]})")
except Exception as e:
raise RuntimeError(f"{idx + 1} 张 base64 解码失败: {e}")
else:
print(f"[豆包生图] 警告:第 {idx + 1} 条数据既无 url 也无 b64_json,已跳过")
return images
# ── 核心异步生成方法 ──────────────────────────────────────────────────────
async def _generate_async(
self,
model: str,
prompt: str,
size: str,
seed: int,
sequential_image_generation: str,
max_images: int,
image_tensor=None,
) -> List[Image.Image]:
"""
异步完整流程:构建请求 → POST → 解析 → 下载图像。
"""
# 1. 构建 image 字段
image_field = None
if image_tensor is not None:
image_field = self._tensor_to_image_field(image_tensor)
n_imgs = len(image_field) if isinstance(image_field, list) else 1
print(f"[豆包生图] 图生图模式,参考图 {n_imgs}")
else:
print(f"[豆包生图] 文生图模式")
# 2. 构建请求体
body = self._build_body(
model=model,
prompt=prompt,
size=size,
seed=seed,
sequential_image_generation=sequential_image_generation,
max_images=max_images,
image_field=image_field,
)
url = f"{self.base_url}{_ENDPOINT}"
print(f"[豆包生图] 提交请求 → {model} | {size}")
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
# 3. 发送 POST 请求(带退避重试)
last_status = None
for attempt in range(DEFAULT_MAX_RETRIES + 1):
t0 = time.time()
async with session.post(
url,
json=body,
headers=self._headers(),
) as resp:
elapsed_req = time.time() - t0
text = await resp.text()
if resp.status != 200:
last_status = resp.status
if resp.status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES:
friendly = HTTP_ERROR_MESSAGES.get(resp.status)
delay = _compute_delay(attempt, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR)
print(f"[豆包生图] {friendly} {delay:.1f}s 后重试 ({attempt+1}/{DEFAULT_MAX_RETRIES})...")
await asyncio.sleep(delay)
continue
if resp.status in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status])
try:
err_json = json.loads(text)
err_obj = err_json.get("error", {})
if isinstance(err_obj, dict):
msg = (
err_obj.get("message")
or err_obj.get("msg")
or text
)
else:
msg = str(err_obj) or text
except Exception:
msg = text
raise RuntimeError(
f"请求失败 HTTP {resp.status}: {msg}"
)
try:
resp_json = json.loads(text)
except Exception:
raise RuntimeError(f"响应 JSON 解析失败,原始内容:{text[:500]}")
break
else:
if last_status and last_status in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[last_status])
raise RuntimeError(f"请求失败: 重试 {DEFAULT_MAX_RETRIES} 次后仍然失败")
print(f"[豆包生图] API 响应耗时 {elapsed_req:.1f}s,开始下载图像...")
# 4. 解析响应 & 下载图像(session 复用)
images = await self._parse_response(resp_json, session)
return images
# ── 同步入口(供 ComfyUI 节点调用)──────────────────────────────────────
def generate_sync(
self,
model: str,
prompt: str,
size: str,
seed: int,
sequential_image_generation: str,
max_images: int,
image_tensor=None,
) -> List[Image.Image]:
"""
同步生成接口(在独立线程中运行事件循环,避免与 ComfyUI 主循环冲突)。
Args:
model: 模型 ID
prompt: 提示词
size: 尺寸字符串,如 "2048x2048"
seed: 随机种子
sequential_image_generation: "disabled" | "auto"
max_images: 最大图片数(auto 模式生效)
image_tensor: ComfyUI IMAGE tensor(可选,图生图用)
Returns:
List[PIL.Image]
"""
coro = self._generate_async(
model=model,
prompt=prompt,
size=size,
seed=seed,
sequential_image_generation=sequential_image_generation,
max_images=max_images,
image_tensor=image_tensor,
)
def _run():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_run)
try:
return future.result(timeout=_REQUEST_TIMEOUT + 30)
except TimeoutError:
raise RuntimeError(
f"豆包生图超时(>{_REQUEST_TIMEOUT}s),请检查网络或稍后重试"
)
-74
View File
@@ -1,74 +0,0 @@
"""
可灵主体(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
-199
View File
@@ -1,199 +0,0 @@
"""
Flux 图像编辑 API 客户端
通过 api.o1key.cn 调用 Flux2 图像编辑 + SeedVR2 超分辨率服务
工作流程:
1. submit_task → POST /v1/images/edits (multipart/form-data 提交主图+参考图+提示词)
2. poll_result → GET /v1/images/edits/{task_id} (直连容器轮询)
"""
import base64
import time
from typing import Optional
import requests
from ..utils.config import get_api_key_or_raise, get_api_base_url
from ..utils.http_error import HTTP_ERROR_MESSAGES
# 显示名 → 实际请求值的映射
SIZE_DISPLAY_MAP = {
"2K": "2048",
"4K": "4096",
}
POLL_BASE_URL = "https://xrrh7tn08tfgwa8w-8188.container.x-gpu.com"
class FluxEditClient:
"""
Flux 图像编辑客户端
对接 api.o1key.cn 上的 /v1/images/edits 接口,
将图像编辑+超分辨率任务提交到远程服务器执行。
"""
SUBMIT_ENDPOINT = "/v1/images/edits"
STATUS_ENDPOINT = "/v1/images/edits/{task_id}"
DEFAULT_POLL_INTERVAL = 15 # 秒
def __init__(self):
self.api_key = get_api_key_or_raise()
self.base_url = get_api_base_url()
# ------------------------------------------------------------------
# 同步方法(供 ComfyUI 节点调用)
# ------------------------------------------------------------------
def submit_and_wait(
self,
image_bytes: bytes,
mask_bytes: bytes,
prompt: str,
size: str = "4K",
poll_interval: int = DEFAULT_POLL_INTERVAL,
progress_callback=None,
) -> bytes:
"""
提交任务并同步等待结果(阻塞直到完成)
Args:
image_bytes: 主图二进制数据
mask_bytes: 参考图二进制数据
prompt: 编辑提示词
size: 分辨率显示名 ("2K""4K")
poll_interval: 轮询间隔(秒)
progress_callback: 进度回调 fn(status_str)
Returns:
结果图像的二进制数据
Raises:
RuntimeError: 任务失败
"""
size_value = SIZE_DISPLAY_MAP.get(size, size)
task_id = self._submit_task_sync(image_bytes, mask_bytes, prompt, size_value)
if progress_callback:
progress_callback(f"任务已提交: {task_id[:8]}...")
# 2. 轮询等待(直连容器)
return self._poll_result_sync(
task_id, poll_interval, progress_callback
)
def _submit_task_sync(
self,
image_bytes: bytes,
mask_bytes: bytes,
prompt: str,
size: str,
) -> str:
"""同步提交任务,返回 task_id"""
url = f"{self.base_url}{self.SUBMIT_ENDPOINT}"
headers = {"Authorization": f"Bearer {self.api_key}"}
files = {
"image": ("image.jpg", image_bytes, "image/jpeg"),
"mask": ("mask.jpg", mask_bytes, "image/jpeg"),
}
data = {
"prompt": prompt,
"size": size,
"model": "flux2-fp8-dualr",
}
try:
resp = requests.post(url, files=files, data=data, headers=headers, timeout=60)
except requests.exceptions.Timeout:
raise RuntimeError("提交任务超时,请检查网络连接")
except requests.exceptions.ConnectionError:
raise RuntimeError("无法连接到服务器,请检查网络或服务器地址")
if resp.status_code != 200:
if resp.status_code in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status_code])
raise RuntimeError(
f"提交任务失败 (HTTP {resp.status_code})\n"
f"响应: {resp.text[:500]}"
)
result = resp.json()
task_id = result.get("id")
if not task_id:
raise RuntimeError(f"服务器返回异常: 未获取到任务ID\n{result}")
return task_id
def _poll_result_sync(
self,
task_id: str,
poll_interval: int,
progress_callback=None,
) -> bytes:
"""同步轮询任务状态(直连容器),返回结果图像二进制"""
url = f"{POLL_BASE_URL}{self.STATUS_ENDPOINT.format(task_id=task_id)}"
start_time = time.time()
last_status = None
while True:
elapsed = time.time() - start_time
try:
resp = requests.get(url, timeout=30)
except requests.exceptions.ConnectionError:
raise RuntimeError("轮询时无法连接到服务器,请检查网络")
if resp.status_code != 200:
raise RuntimeError(
f"查询任务状态失败 (HTTP {resp.status_code})\n"
f"响应: {resp.text[:500]}"
)
result = resp.json()
status = result.get("status", "unknown")
# 状态变化时打印日志
if status != last_status:
elapsed_str = f"{elapsed:.0f}s"
print(f"Flux Edit: [{elapsed_str}] 任务 {task_id[:8]}... → {status}")
last_status = status
if progress_callback:
elapsed_str = f"{elapsed:.0f}s"
status_desc = {
"pending": "排队中",
"processing": "处理中",
"generating": "生图中,请耐心等待,预计耗时140s左右",
}.get(status, status)
progress_callback(f"{status_desc} (当前进度:{elapsed_str})")
if status == "completed":
# 解码 base64 图像
b64_data = result.get("result")
if not b64_data:
raise RuntimeError("任务完成但未返回图像数据")
return base64.b64decode(b64_data)
elif status == "failed":
error_msg = result.get("error", "未知错误")
raise RuntimeError(
f"图像编辑任务失败\n"
f"错误: {error_msg}"
)
elif status in ("not_found",):
raise RuntimeError(
f"任务未找到: {task_id}\n"
f"可能已被清理或 ID 无效"
)
# 继续等待
time.sleep(poll_interval)
def query_balance_sync(self) -> dict:
"""查询余额(兼容现有节点的 finally 块调用)"""
return {"name": "flux-edit", "total_available": 0}
+331 -753
View File
File diff suppressed because it is too large Load Diff
+46 -76
View File
@@ -8,15 +8,15 @@ from typing import Any, Dict, List, Optional
import aiohttp
from ..utils.config import get_api_key_or_raise, get_api_base_url
from ..models_config import (
get_flash_model_endpoint,
get_enabled_flash_models,
get_flash_model_thinking_level_value,
)
from ..utils.config import get_api_key_or_raise
from ..models_config import get_flash_model_endpoint, get_enabled_flash_models
from .base_client import BaseAPIClient
# API 基础配置
API_BASE_URL = "https://api.o1key.com"
class GeminiFlashClient(BaseAPIClient):
"""
Gemini Flash API 客户端
@@ -24,7 +24,8 @@ class GeminiFlashClient(BaseAPIClient):
特点:
- 支持图片和视频输入
- 支持动态思考等级端点(不思考/低/中/高)
- 支持系统指令
- 支持不同思考深度
"""
def __init__(self, api_key: Optional[str] = None):
@@ -38,65 +39,46 @@ class GeminiFlashClient(BaseAPIClient):
api_key = get_api_key_or_raise("O1KEY_API_KEY")
super().__init__(
base_url=get_api_base_url(),
base_url=API_BASE_URL,
api_key=api_key,
max_request_size=100 * 1024 * 1024 # 100MB
max_request_size=20 * 1024 * 1024 # 20MB
)
def get_endpoint(
self,
self,
model: str = "gemini-3-flash-preview",
thinking_depth: str = "不思考",
**kwargs
) -> str:
"""
获取模型的 API 端点
根据模型和思考深度获取 API 端点
Args:
model: 模型名称
thinking_depth: 思考深度 ("不思考""")
Returns:
API 端点路径
"""
endpoint = get_flash_model_endpoint(model)
endpoint = get_flash_model_endpoint(model, thinking_depth)
if endpoint is None:
# 回退到第一个启用的模型端点
# 回退到默认端点
default_models = get_enabled_flash_models()
if default_models:
endpoint = get_flash_model_endpoint(default_models[0])
endpoint = get_flash_model_endpoint(default_models[0], thinking_depth)
if endpoint is None:
raise ValueError(f"无法获取模型 '{model}' 的端点")
raise ValueError(f"无法获取模型 '{model}' 的端点 (思考深度: {thinking_depth})")
return endpoint
def get_http_error_message(self, status_code: int, error_message: str) -> Optional[str]:
"""Gemini 请求 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
def build_request_body(
self,
prompt: str = "",
model: str = "gemini-3-flash-preview",
thinking_level: str = "不思考",
system_instruction: Optional[str] = None,
image_data: Optional[List[Dict[str, str]]] = None,
video_data: Optional[Dict[str, str]] = None,
document_data: Optional[Dict[str, str]] = None,
**kwargs
) -> Dict[str, Any]:
"""
@@ -104,11 +86,9 @@ class GeminiFlashClient(BaseAPIClient):
Args:
prompt: 用户提示词
model: 模型名称
thinking_level: 思考等级(不思考/低/中/高)- 通过动态端点控制,不需要在请求体中传递
system_instruction: 系统指令(可选)
image_data: 图片数据列表,每个元素包含 mime_type 和 data
video_data: 视频数据,包含 mime_type 和 data
document_data: 文档数据,包含 mime_type 和 data
Returns:
请求体字典
@@ -138,15 +118,6 @@ class GeminiFlashClient(BaseAPIClient):
}
})
# 添加文档部分(如果有)
if document_data:
parts.append({
"inline_data": {
"mime_type": document_data["mime_type"],
"data": document_data["data"]
}
})
# 构建请求体
request_body = {
"contents": [
@@ -156,15 +127,12 @@ class GeminiFlashClient(BaseAPIClient):
]
}
# 对于支持 thinkingConfig 的固定端点模型(如 gemini-3-pro-preview
# 通过请求体传递思考等级;动态端点模型(如 gemini-3-flash-preview
# 通过不同 URL 端点控制,无需此字段
thinking_level_value = get_flash_model_thinking_level_value(model, thinking_level)
if thinking_level_value is not None:
request_body["generationConfig"] = {
"thinkingConfig": {
"thinkingLevel": thinking_level_value
}
# 添加系统指令(如果有
if system_instruction and system_instruction.strip():
request_body["system_instruction"] = {
"parts": [
{"text": system_instruction}
]
}
return request_body
@@ -229,10 +197,10 @@ class GeminiFlashClient(BaseAPIClient):
self,
prompt: str,
model: str = "gemini-3-flash-preview",
thinking_level: str = "不思考",
thinking_depth: str = "不思考",
system_instruction: Optional[str] = None,
image_data: Optional[List[Dict[str, str]]] = None,
video_data: Optional[Dict[str, str]] = None,
document_data: Optional[Dict[str, str]] = None,
session: Optional[aiohttp.ClientSession] = None
) -> str:
"""
@@ -241,29 +209,31 @@ class GeminiFlashClient(BaseAPIClient):
Args:
prompt: 用户提示词
model: 模型名称
thinking_level: 思考等级(不思考/低/中/高)
thinking_depth: 思考深度
system_instruction: 系统指令
image_data: 图片数据列表
video_data: 视频数据
document_data: 文档数据
session: aiohttp 会话
Returns:
生成的文本内容
"""
endpoint = self.get_endpoint(model=model)
endpoint = self.get_endpoint(model=model, thinking_depth=thinking_depth)
request_body = self.build_request_body(
prompt=prompt,
model=model,
thinking_level=thinking_level,
system_instruction=system_instruction,
image_data=image_data,
video_data=video_data,
document_data=document_data
video_data=video_data
)
# 根据是否有视频设置超时(视频处理需要更长时间)
timeout = 300 if video_data else 180
response = await self.request_async(
endpoint,
request_body,
session
session,
timeout=timeout
)
return self.parse_response(response)
@@ -272,10 +242,10 @@ class GeminiFlashClient(BaseAPIClient):
self,
prompt: str,
model: str = "gemini-3-flash-preview",
thinking_level: str = "不思考",
thinking_depth: str = "不思考",
system_instruction: Optional[str] = None,
image_data: Optional[List[Dict[str, str]]] = None,
video_data: Optional[Dict[str, str]] = None,
document_data: Optional[Dict[str, str]] = None
video_data: Optional[Dict[str, str]] = None
) -> str:
"""
同步生成文本(用于 ComfyUI 节点)
@@ -283,10 +253,10 @@ class GeminiFlashClient(BaseAPIClient):
Args:
prompt: 用户提示词
model: 模型名称
thinking_level: 思考等级(不思考/低/中/高)
thinking_depth: 思考深度
system_instruction: 系统指令
image_data: 图片数据列表
video_data: 视频数据
document_data: 文档数据
Returns:
生成的文本内容
@@ -294,10 +264,10 @@ class GeminiFlashClient(BaseAPIClient):
coro = self.generate_async(
prompt=prompt,
model=model,
thinking_level=thinking_level,
thinking_depth=thinking_depth,
system_instruction=system_instruction,
image_data=image_data,
video_data=video_data,
document_data=document_data
video_data=video_data
)
return self.run_async_in_thread(coro)
File diff suppressed because it is too large Load Diff
-458
View File
@@ -1,458 +0,0 @@
"""
Grok Image API 客户端
支持两个接口:
- POST /v1/images/generations 文生图
- POST /v1/images/edits 图生图(带参考图)
上游 API 格式与 OpenAI Images API 兼容。
"""
import asyncio
import base64
import json
import time
from concurrent.futures import ThreadPoolExecutor
from io import BytesIO
from typing import List, Optional
import aiohttp
import numpy as np
import torch
from PIL import Image
from ..utils.config import get_api_key_or_raise, get_base_url_by_route
from ..utils.image_utils import tensor_to_pil
try:
from comfy.model_management import processing_interrupted, InterruptProcessingException
_INTERRUPT_AVAILABLE = True
except ImportError:
_INTERRUPT_AVAILABLE = False
InterruptProcessingException = RuntimeError
processing_interrupted = lambda: False
_ENDPOINT_GENERATIONS = "/v1/images/generations"
_ENDPOINT_EDITS = "/v1/images/edits"
_MODEL_NAME_MAP = {
"Grok Image": "grok-imagine-image",
"Grok Image Pro": "grok-imagine-image-quality",
}
_REQUEST_TIMEOUT = 900
_MAX_BODY_BYTES = 20 * 1024 * 1024
_MAX_RETRIES = 3
_RETRY_DELAY = 5
class GrokImageClient:
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)
def _json_headers(self) -> dict:
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
def _auth_headers(self) -> dict:
return {"Authorization": f"Bearer {self.api_key}"}
# ── 图像工具 ──────────────────────────────────────────────────────────────
@staticmethod
def _shrink_png_to_limit(png_bytes: bytes, max_bytes: int, label: str = "") -> bytes:
if len(png_bytes) <= max_bytes:
return png_bytes
img = Image.open(BytesIO(png_bytes))
w, h = img.size
original_size = len(png_bytes)
step = 0
while len(png_bytes) > max_bytes:
scale = 0.894
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")
png_bytes = buf.getvalue()
step += 1
tag = f" ({label})" if label else ""
print(
f"[o1key Grok Image] 图像{tag}超出 {max_bytes // (1024*1024)}MB 限制,"
f"已等比缩放 {step} 次:{original_size // 1024}KB → {len(png_bytes) // 1024}KB "
f"{w}×{h}"
)
return png_bytes
@staticmethod
def _pil_list_to_tensor(images: List[Image.Image]) -> torch.Tensor:
if not images:
placeholder = Image.new("RGB", (512, 512), (128, 128, 128))
images = [placeholder]
tensors = []
for img in images:
arr = np.array(img.convert("RGB")).astype(np.float32) / 255.0
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
async def _poll_interrupt():
while True:
await asyncio.sleep(0.5)
if _INTERRUPT_AVAILABLE and processing_interrupted():
return
@staticmethod
async def _run_with_interrupt(coro):
if not _INTERRUPT_AVAILABLE:
return await coro
request_task = asyncio.ensure_future(coro)
interrupt_task = asyncio.ensure_future(GrokImageClient._poll_interrupt())
done, pending = await asyncio.wait(
[request_task, interrupt_task],
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
try:
await t
except (asyncio.CancelledError, Exception):
pass
if interrupt_task in done and request_task not in done:
raise InterruptProcessingException()
return request_task.result()
# ── 响应解析 ──────────────────────────────────────────────────────────────
async def _parse_response(self, resp_json: dict, session: aiohttp.ClientSession) -> List[Image.Image]:
if "error" in resp_json:
err = resp_json["error"]
msg = (
err.get("message") or err.get("msg") or json.dumps(err, ensure_ascii=False)
if isinstance(err, dict) else str(err)
)
raise RuntimeError(f"API 返回错误: {msg}")
data_list = resp_json.get("data")
if not data_list:
raise RuntimeError(f"API 响应中未找到 data 字段")
images: List[Image.Image] = []
for idx, item in enumerate(data_list):
b64 = item.get("b64_json", "")
url = item.get("url", "")
if b64:
img_bytes = base64.b64decode(b64)
img = Image.open(BytesIO(img_bytes))
images.append(img)
elif url and url.startswith("http"):
async with session.get(url, allow_redirects=True) as r:
if r.status != 200:
raise RuntimeError(f"图像下载失败 HTTP {r.status}")
img_bytes = await r.read()
images.append(Image.open(BytesIO(img_bytes)))
else:
print(f"[o1key Grok Image] 警告:第 {idx + 1} 条数据无有效图像,已跳过")
return images
# ── 文生图(generations 接口)─────────────────────────────────────────────
async def _generate_async(
self,
prompt: str,
model: str,
aspect_ratio: str,
resolution: str,
n: int,
) -> List[Image.Image]:
api_model = _MODEL_NAME_MAP.get(model, model)
body: dict = {
"model": api_model,
"prompt": prompt,
"aspect_ratio": aspect_ratio if aspect_ratio else "auto",
"resolution": resolution if resolution else "1k",
"response_format": "b64_json",
}
url = f"{self.base_url}{_ENDPOINT_GENERATIONS}"
log_body = {k: v for k, v in body.items()}
print(f"[o1key Grok Image] 请求 URL: {url}")
print(f"[o1key Grok Image] 请求体: {json.dumps(log_body, ensure_ascii=False)}")
results = []
for i in range(n):
images = await self._do_request_with_retry(url, body)
results.extend(images)
if n > 1:
print(f"[o1key Grok Image] 第 {i+1}/{n} 张完成")
return results
# ── 图生图(edits 接口)───────────────────────────────────────────────────
async def _edit_async(
self,
prompt: str,
model: str,
aspect_ratio: str,
resolution: str,
n: int,
image_list: List[torch.Tensor],
) -> List[Image.Image]:
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 = self._redact_edit_body(body)
print(f"[o1key Grok Image] 请求 URL: {url}")
print(f"[o1key Grok Image] 请求体: {json.dumps(log_body, ensure_ascii=False)}")
results = []
for i in range(n):
images = await self._do_request_with_retry(url, body)
results.extend(images)
if n > 1:
print(f"[o1key Grok Image] 第 {i+1}/{n} 张完成")
return results
# ── 带重试的请求 ────────────────────────────────────────────────────────
async def _do_request_with_retry(self, url: str, body: dict) -> List[Image.Image]:
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async def _do_request():
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
last_error = None
for attempt in range(1, _MAX_RETRIES + 1):
t0 = time.time()
async with session.post(url, json=body, headers=self._json_headers()) as resp:
elapsed = time.time() - t0
text = await resp.text()
if resp.status == 429 or resp.status in (502, 503, 504):
last_error = f"HTTP {resp.status}"
print(f"[o1key Grok Image] 重试 {attempt}/{_MAX_RETRIES}{last_error}")
await asyncio.sleep(_RETRY_DELAY * attempt)
continue
if resp.status == 400 and "high load" in text.lower():
last_error = "high load"
print(f"[o1key Grok Image] 重试 {attempt}/{_MAX_RETRIES}(服务繁忙)")
await asyncio.sleep(_RETRY_DELAY * attempt)
continue
if resp.status != 200:
try:
err_json = json.loads(text)
err_obj = err_json.get("error", {})
msg = (
err_obj.get("message") or err_obj.get("msg") or text
if isinstance(err_obj, dict) else str(err_obj) or text
)
except Exception:
msg = text
raise RuntimeError(f"请求失败 HTTP {resp.status}: {msg}")
try:
resp_json = json.loads(text)
except Exception:
raise RuntimeError(f"响应 JSON 解析失败,原始内容:{text[:500]}")
print(f"[o1key Grok Image] API 响应耗时 {elapsed:.1f}s")
return await self._parse_response(resp_json, session)
raise RuntimeError(f"重试 {_MAX_RETRIES} 次后仍失败: {last_error}")
return await self._run_with_interrupt(_do_request())
# ── 同步入口 ──────────────────────────────────────────────────────────────
def run_sync(
self,
prompt: str,
model: str,
aspect_ratio: str,
resolution: str,
n: int,
image_list: Optional[List[torch.Tensor]] = None,
) -> List[Image.Image]:
if image_list:
coro = self._edit_async(
prompt=prompt, model=model, aspect_ratio=aspect_ratio,
resolution=resolution, n=n, image_list=image_list,
)
else:
coro = self._generate_async(
prompt=prompt, model=model, aspect_ratio=aspect_ratio,
resolution=resolution, n=n,
)
def _run():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_run)
try:
return future.result(timeout=_REQUEST_TIMEOUT + 30)
except TimeoutError:
raise RuntimeError("Grok Image 请求超时,请检查网络或稍后重试")
# ── 余额查询 ──────────────────────────────────────────────────────────────
async def _query_balance_async(self) -> dict:
url = f"{self.base_url}/api/usage/token"
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
async with session.get(url, headers=self._auth_headers()) as resp:
if resp.status != 200:
raise RuntimeError(f"余额查询失败 HTTP {resp.status}")
return await resp.json()
def query_balance_sync(self) -> dict:
def _run():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(self._query_balance_async())
finally:
loop.close()
with ThreadPoolExecutor(max_workers=1) as executor:
return executor.submit(_run).result(timeout=15)
@staticmethod
def format_balance_info(balance_data: dict) -> str:
data = balance_data.get("data", {})
api_name = data.get("name", "未知")
total_available = data.get("total_available", 0)
balance_in_dollars = total_available / 500000
return f"当前余额:{balance_in_dollars:.2f} | API{api_name}"
-361
View File
@@ -1,361 +0,0 @@
"""Client for the complete O1Key Grok Imagine Video API."""
import asyncio
import json
import os
import re
import time
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import quote
import aiohttp
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,
interruptible_sleep,
run_with_interrupt,
)
class GrokVideoClient(BaseAPIClient):
"""Submit, poll, and download Grok video generation, edit, or extension tasks."""
ENDPOINTS = {
"generate": "/grok/v1/videos/generations",
"edit": "/grok/v1/videos/edits",
"extend": "/grok/v1/videos/extensions",
}
STATUS_ENDPOINT = "/grok/v1/videos/{request_id}"
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):
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, 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)
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,
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("参考图/音频生视频必须填写提示词。")
if resolution == "1080p":
raise ValueError("参考图/音频生视频不支持 1080p。")
elif not normal_image and not prompt:
raise ValueError("文生视频必须填写提示词。")
if resolution == "1080p" and model != cls.LATEST_MODEL:
raise ValueError("1080p 仅支持 grok-imagine-video-1.5 的文生或图生视频。")
body: Dict[str, Any] = {
"model": model,
"duration": duration,
"aspect_ratio": aspect_ratio,
"resolution": resolution,
}
if prompt:
body["prompt"] = prompt
if normal_image:
body["image"] = normal_image
if normal_references:
body["reference_images"] = normal_references
if normal_audios:
body["reference_audios"] = normal_audios
return body
if model not in cls.MODEL_OPTIONS:
raise ValueError(f"模型仅支持:{', '.join(cls.MODEL_OPTIONS)}")
if 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 _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 _safe_filename(request_id: str) -> str:
return re.sub(r"[^A-Za-z0-9_.-]+", "_", request_id).strip("._") or "grok_video"
@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(
self,
method: str,
endpoint: str,
session: aiohttp.ClientSession,
*,
json_body: Optional[Dict[str, Any]] = None,
timeout_seconds: int = 120,
request_id: Optional[str] = None,
) -> Dict[str, Any]:
url = f"{self.base_url}{endpoint}"
timeout = aiohttp.ClientTimeout(total=timeout_seconds, connect=30, sock_read=timeout_seconds)
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=self.get_headers(use_bearer_token=True), timeout=timeout,
)
)
text = await run_with_interrupt(response.text())
last_status, last_text = response.status, text
if 200 <= response.status < 300:
try:
return json.loads(text) if text.strip() else {}
except json.JSONDecodeError:
raise RuntimeError("Grok Video 响应不是有效 JSON。") from None
if response.status not in RETRYABLE_STATUS_CODES or attempt == 3:
break
delay = min(2 ** attempt, 8)
print(f"Grok 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)
finally:
if response is not None:
response.release()
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(
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(request_id=quote(request_id, safe=""))
started_at = time.monotonic()
while True:
await interruptible_sleep(poll_interval)
response = await self._request_json(
"GET", endpoint, session, timeout_seconds=60, request_id=request_id
)
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:
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(
f"Grok Video 轮询超时(request_id: {request_id},状态:{status or 'unknown'})。"
)
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_request() -> Dict[str, Any]:
async with self._make_session() as session:
endpoint = self.get_endpoint(operation)
body = self.build_video_body(
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,
)
print(f"Grok Video:正在提交{operation}任务…")
created = await self._request_json(
"POST", endpoint, session, json_body=body, timeout_seconds=180
)
request_id = self._extract_request_id(created)
if not request_id:
raise RuntimeError("Grok Video 创建响应中没有 request_id。")
print(f"Grok Video:任务已提交,request_id{request_id}")
completed = await self._poll(
request_id, session, poll_interval=max(1, int(poll_interval)),
timeout=timeout, progress_callback=progress_callback,
)
video_data = completed.get("video")
video_url = video_data.get("url") if isinstance(video_data, dict) else None
if not video_url:
raise RuntimeError(f"Grok Video 完成响应中没有 video.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 {
"request_id": request_id,
"video_path": video_path,
"duration": video_data.get("duration"),
"raw_json": {"create": created, "status": completed},
}
return self.run_async_in_thread(run_request())
-242
View File
@@ -1,242 +0,0 @@
"""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
-517
View File
@@ -1,517 +0,0 @@
"""
new-api Veo 3.1 video client.
Implements the OpenAI-compatible /v1/videos task flow:
submit, poll, and stream-download video content.
"""
import asyncio
import json
import os
import re
import time
from typing import Any, Callable, Dict, Optional
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):
CREATE_ENDPOINT = "/v1/videos"
STATUS_ENDPOINT = "/v1/videos/{task_id}"
CONTENT_ENDPOINT = "/v1/videos/{task_id}/content"
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,
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)
def get_endpoint(self, **kwargs) -> str:
return self.CREATE_ENDPOINT
def build_request_body(self, **kwargs) -> Dict[str, Any]:
return self._build_video_body(**kwargs)
def parse_response(self, response: Dict[str, Any]) -> Any:
return response
@staticmethod
def _build_video_body(
prompt: str,
model: str,
duration: int,
aspect_ratio: str,
resolution: str,
negative_prompt: str = "",
generate_audio: bool = True,
) -> Dict[str, Any]:
metadata: Dict[str, Any] = {
"aspectRatio": aspect_ratio,
"resolution": resolution,
"generateAudio": bool(generate_audio),
}
negative_prompt = (negative_prompt or "").strip()
if negative_prompt:
metadata["negativePrompt"] = negative_prompt
body: Dict[str, Any] = {
"model": model,
"prompt": prompt,
"duration": int(duration),
"metadata": metadata,
}
return body
@staticmethod
def _print_request_body(body: Dict[str, Any], image_bytes: Optional[bytes] = None) -> None:
log_body = dict(body)
if image_bytes is not None:
log_body["input_reference"] = f"<PNG bytes: {len(image_bytes)}>"
print(
"NewAPI Veo request body:\n"
f"{json.dumps(log_body, ensure_ascii=False, indent=2)}"
)
@staticmethod
def _safe_task_filename(task_id: str) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", task_id).strip("._")
return safe or "newapi_veo"
@staticmethod
def _extract_task_id(data: Dict[str, Any]) -> Optional[str]:
for key in ("id", "task_id", "video_id"):
value = data.get(key)
if value:
return str(value)
nested = data.get("data")
if isinstance(nested, dict):
for key in ("id", "task_id", "video_id"):
value = nested.get(key)
if value:
return str(value)
return None
@staticmethod
def _extract_status(data: Dict[str, Any]) -> str:
for key in ("status", "state", "task_status"):
value = data.get(key)
if value:
return str(value).lower()
nested = data.get("data")
if isinstance(nested, dict):
for key in ("status", "state", "task_status"):
value = nested.get(key)
if value:
return str(value).lower()
return "unknown"
@staticmethod
def _extract_progress(data: Dict[str, Any]) -> int:
progress = data.get("progress")
if progress is None and isinstance(data.get("data"), dict):
progress = data["data"].get("progress")
if isinstance(progress, str):
progress = progress.rstrip("%").strip()
try:
return int(float(progress))
except ValueError:
return 0
if isinstance(progress, (int, float)):
return int(progress)
return 0
@classmethod
def _format_http_error(
cls,
endpoint: str,
status: int,
error_text: str,
task_id: Optional[str] = None,
) -> str:
code = ""
message = error_text
try:
payload = json.loads(error_text)
error = payload.get("error", payload)
if isinstance(error, dict):
code = str(error.get("code") or error.get("type") or "")
message = str(error.get("message") or payload.get("message") or error_text)
elif error is not None:
message = str(error)
except Exception:
pass
message = (message or "").strip()
if len(message) > 1200:
message = message[:1200] + "...(truncated)"
if status in (401, 403):
hint = "凭证或分组权限问题,请检查 new-api token、模型分组或渠道权限。"
elif status == 429:
hint = "频率或额度限制,请稍后重试或检查 new-api 额度。"
elif status in (502, 503, 504):
hint = "上游服务暂时不可用或超时,请稍后用 task_id 继续查询。"
elif status == 400:
hint = "请求参数错误,请检查 model、duration、metadata 和图片输入。"
else:
hint = "new-api 视频请求失败。"
parts = [
hint,
f"endpoint: {endpoint}",
f"http_status: {status}",
]
if task_id:
parts.append(f"task_id: {task_id}")
if code:
parts.append(f"error_code: {code}")
if message:
parts.append(f"message: {message}")
return "\n".join(parts)
@classmethod
def _format_task_failure(cls, task_id: str, data: Dict[str, Any]) -> str:
error = data.get("error")
if error is None and isinstance(data.get("data"), dict):
error = data["data"].get("error")
if isinstance(error, dict):
code = error.get("code") or error.get("type") or ""
message = error.get("message") or json.dumps(error, ensure_ascii=False)
else:
code = ""
message = str(error or "未知错误")
return "\n".join(
[
"Veo 视频任务失败。",
f"endpoint: {cls.STATUS_ENDPOINT.format(task_id=task_id)}",
f"task_id: {task_id}",
f"error_code: {code}",
f"message: {message}",
]
)
async def create_video_async(
self,
prompt: str,
model: str,
duration: int,
aspect_ratio: str,
resolution: str,
negative_prompt: str = "",
generate_audio: bool = True,
image_bytes: Optional[bytes] = None,
session: Optional[aiohttp.ClientSession] = None,
) -> Dict[str, Any]:
body = self._build_video_body(
prompt=prompt,
model=model,
duration=duration,
aspect_ratio=aspect_ratio,
resolution=resolution,
negative_prompt=negative_prompt,
generate_audio=generate_audio,
)
url = f"{self.base_url}{self.CREATE_ENDPOINT}"
close_session = False
if session is None:
session = self._make_session()
close_session = True
try:
timeout = aiohttp.ClientTimeout(total=120, connect=30, sock_read=120)
headers = {"Authorization": f"Bearer {self.api_key}"}
if image_bytes is not None:
if len(image_bytes) > self.max_request_size:
raise ValueError(
f"输入图片过大,超过 {self.max_request_size / 1024 / 1024:.0f}MB 限制"
)
self._print_request_body(body, image_bytes=image_bytes)
form = aiohttp.FormData()
form.add_field("model", body["model"])
form.add_field("prompt", body["prompt"])
form.add_field("duration", str(body["duration"]))
form.add_field("metadata", json.dumps(body["metadata"], ensure_ascii=False))
form.add_field(
"input_reference",
image_bytes,
filename="input_reference.png",
content_type="image/png",
)
request_kwargs = {"data": form, "headers": headers}
print(
"NewAPI Veo: POST /v1/videos multipart "
f"| model={model} | duration={duration}s | {resolution} {aspect_ratio}"
)
else:
self._print_request_body(body)
headers["Content-Type"] = "application/json"
request_kwargs = {"json": body, "headers": headers}
print(
"NewAPI Veo: POST /v1/videos json "
f"| model={model} | duration={duration}s | {resolution} {aspect_ratio}"
)
async with session.post(url, timeout=timeout, **request_kwargs) as response:
if response.status >= 300:
error_text = await response.text()
raise RuntimeError(
self._format_http_error(
self.CREATE_ENDPOINT,
response.status,
error_text,
)
)
return await response.json()
finally:
if close_session:
await session.close()
async def _get_json_with_retry(
self,
endpoint: str,
session: aiohttp.ClientSession,
task_id: Optional[str] = None,
max_retries: int = 3,
) -> Dict[str, Any]:
url = f"{self.base_url}{endpoint}"
headers = self.get_headers(use_bearer_token=True)
timeout = aiohttp.ClientTimeout(total=60, connect=30, sock_read=60)
last_error = ""
last_status = 0
for attempt in range(max_retries + 1):
async with session.get(url, headers=headers, timeout=timeout) as response:
if response.status < 300:
return await response.json()
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(endpoint, last_status, last_error, task_id=task_id)
)
async def poll_video_status_async(
self,
task_id: str,
poll_interval: int = 5,
timeout: int = VIDEO_POLL_DEADLINE_SECONDS,
progress_callback: Optional[Callable[[int, str, float], None]] = None,
session: Optional[aiohttp.ClientSession] = None,
) -> Dict[str, Any]:
endpoint = self.STATUS_ENDPOINT.format(task_id=task_id)
close_session = False
if session is None:
session = self._make_session()
close_session = True
start = time.time()
try:
poll_interval = max(1, int(poll_interval))
await asyncio.sleep(poll_interval)
while True:
data = await self._get_json_with_retry(endpoint, session, task_id=task_id)
status = self._extract_status(data)
elapsed = time.time() - start
progress = self._extract_progress(data)
if status == "unknown":
print(
"NewAPI Veo status response did not include a recognized status field:\n"
f"{json.dumps(data, ensure_ascii=False, indent=2)[:1200]}"
)
if progress_callback:
progress_callback(progress, status, elapsed)
if status in self.COMPLETED_STATUSES:
return data
if status in self.FAILED_STATUSES:
raise RuntimeError(self._format_task_failure(task_id, data))
if elapsed >= timeout:
raise TimeoutError(
"Veo 视频任务轮询超时;任务未被标记为失败,可用 task_id 继续查询。\n"
f"endpoint: {endpoint}\n"
f"task_id: {task_id}\n"
f"status: {status}\n"
f"timeout: {timeout}s"
)
remaining = max(0.0, timeout - elapsed)
await asyncio.sleep(min(poll_interval, remaining))
finally:
if close_session:
await session.close()
async def _download_url_to_file(
self,
url: str,
save_path: str,
session: aiohttp.ClientSession,
max_retries: int = 3,
) -> None:
# 抗超时 / 断点续传 / 无限重试 / 可取消
await download_video_to_file(session, url, save_path, label="VEO 视频")
async def download_video_async(
self,
task_id: str,
save_path: str,
session: Optional[aiohttp.ClientSession] = None,
) -> 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=120, connect=30, sock_read=120)
close_session = False
if session is None:
session = self._make_session()
close_session = True
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:
content_type = response.headers.get("Content-Type", "")
if "application/json" in content_type.lower():
data = await response.json()
nested = data.get("data") if isinstance(data.get("data"), dict) else {}
download_url = (
data.get("url")
or data.get("download_url")
or nested.get("url")
or nested.get("download_url")
)
if not download_url:
raise RuntimeError(
"视频下载失败: content 响应为 JSON,但未包含 url/download_url。\n"
f"endpoint: {endpoint}\n"
f"task_id: {task_id}"
)
await self._download_url_to_file(download_url, save_path, session)
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:
raise RuntimeError(
self._format_http_error(endpoint, last_status, last_error, task_id=task_id)
)
await asyncio.sleep(min(2 ** attempt, 8))
# 抗超时 / 断点续传 / 无限重试 / 可取消
return await download_video_to_file(
session, url, save_path, headers=headers, label="VEO 视频",
)
finally:
if close_session:
await session.close()
def generate_video_sync(
self,
prompt: str,
model: str,
duration: int,
aspect_ratio: str,
resolution: str,
output_dir: str,
negative_prompt: str = "",
generate_audio: bool = True,
image_bytes: Optional[bytes] = None,
poll_interval: int = 5,
timeout: int = VIDEO_POLL_DEADLINE_SECONDS,
reuse_task_id: str = "",
progress_callback: Optional[Callable[[int, str, float], None]] = None,
) -> Dict[str, Any]:
async def _run():
async with self._make_session() as session:
create_response: Dict[str, Any] = {}
task_id = (reuse_task_id or "").strip()
if task_id:
print(f"NewAPI Veo: reuse task_id={task_id}")
else:
create_response = await self.create_video_async(
prompt=prompt,
model=model,
duration=duration,
aspect_ratio=aspect_ratio,
resolution=resolution,
negative_prompt=negative_prompt,
generate_audio=generate_audio,
image_bytes=image_bytes,
session=session,
)
task_id = self._extract_task_id(create_response) or ""
if not task_id:
raise RuntimeError(
"new-api 未返回视频任务 ID。\n"
f"endpoint: {self.CREATE_ENDPOINT}\n"
f"response: {json.dumps(create_response, ensure_ascii=False)[:1200]}"
)
status_response = await self.poll_video_status_async(
task_id=task_id,
poll_interval=poll_interval,
timeout=timeout,
progress_callback=progress_callback,
session=session,
)
status = self._extract_status(status_response)
os.makedirs(output_dir, exist_ok=True)
filename = f"{self._safe_task_filename(task_id)}.mp4"
save_path = os.path.join(output_dir, filename)
video_path = await self.download_video_async(
task_id=task_id,
save_path=save_path,
session=session,
)
return {
"task_id": task_id,
"status": status,
"video_path": video_path,
"raw_json": {
"create": create_response,
"status": status_response,
},
}
return self.run_async_in_thread(_run())
-358
View File
@@ -1,358 +0,0 @@
"""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}")
-216
View File
@@ -1,216 +0,0 @@
"""
Seedance 视频生成客户端
使用 new-api 原生格式:POST /v1/video/generations → GET /v1/video/generations/{task_id}
"""
import json
from typing import Any, Callable, Dict, Optional
import aiohttp
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,
interruptible_sleep,
is_failure_status,
is_success_status,
run_with_interrupt,
)
class SeedanceClient:
"""Seedance 视频生成客户端(new-api 原生三段式)
注意:新旧格式模型(seedance-2-0-260128-d 等)共用同一套端点,
区别仅在于请求体结构(顶层 content vs metadata.content),
由调用方(节点层)通过 use_new_format 控制请求体拼装方式。
"""
# 提交任务(新旧格式模型共用)
CREATE_ENDPOINT = "/v1/video/generations"
# 查询任务状态:{task_id} 占位(新旧格式模型共用)
STATUS_ENDPOINT = "/v1/video/generations/{task_id}"
POLL_INITIAL_INTERVAL = 4 # 首次轮询等待秒数
POLL_MAX_INTERVAL = 15 # 最大轮询间隔秒数
# new-api 返回的成功状态值
SUCCESS_STATUSES = {"succeeded", "success", "completed", "done", "finished"}
FAILURE_STATUSES = {"failed", "fail", "error", "expired"}
def __init__(self):
self.api_key = get_api_key_or_raise()
self.base_url = get_base_url_by_route()
def _headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
# ── 1. 提交任务 ────────────────────────────────────────────────────
async def submit_async(
self,
body: Dict[str, Any],
session: aiohttp.ClientSession,
use_new_format: bool = False,
) -> str:
"""提交视频生成任务,返回 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 提交: "
))
check_interrupt()
text = await resp.text()
data = json.loads(text)
# new-api 返回字段:id / task_id
task_id = data.get("id") or data.get("task_id")
if not task_id:
raise RuntimeError("API 未返回任务 ID")
return task_id
# ── 2. 轮询状态 ────────────────────────────────────────────────────
async def poll_async(
self,
task_id: str,
session: aiohttp.ClientSession,
on_progress: Optional[Callable[[int], None]] = None,
use_new_format: bool = False,
) -> str:
"""轮询任务状态,成功后返回视频 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()
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}")
result = json.loads(text)
# new-api 包装格式:真实数据在 result["data"] 里
inner = result.get("data") or result
status = extract_status(result)
# 解析进度
progress_pct = extract_progress(result)
print(f"[Seedance] 生成中 {progress_pct}%")
if on_progress:
on_progress(progress_pct)
if is_success_status(status):
# 响应结构:result["data"] = innerinner["data"] = platform_data
# 视频 URL 在 inner["result_url"] 或 inner["data"]["content"]["video_url"]
platform_data = inner.get("data") or {}
content = platform_data.get("content") or {}
video_url = (
inner.get("result_url")
or content.get("video_url")
or platform_data.get("video_url")
or inner.get("url")
)
if not video_url:
raise RuntimeError("任务成功但未找到视频 URL")
# 末帧图片 URL 在 inner["data"]["content"]["last_frame_url"]
last_frame_url = (
content.get("last_frame_url")
or platform_data.get("last_frame_url")
or inner.get("last_frame_url")
)
return video_url, last_frame_url
if is_failure_status(status, result):
reason = extract_error_message(result)
raise RuntimeError(f"视频生成失败:{reason}")
await interruptible_sleep(interval)
interval = min(interval * 1.5, self.POLL_MAX_INTERVAL)
# ── 3. 下载视频 ────────────────────────────────────────────────────
async def download_async(
self,
video_url: str,
save_path: str,
session: aiohttp.ClientSession,
) -> str:
"""下载视频到本地,返回本地路径"""
print(f"[Seedance] 下载视频...")
return await download_video_to_file(
session, video_url, save_path, label="Seedance",
)
# ── 全流程入口(供节点调用)────────────────────────────────────────
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,
use_new_format: bool = False,
) -> tuple:
"""提交 → 轮询 → 下载,返回 (本地视频路径, 末帧图片URL或None)"""
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
# 提交
check_interrupt()
if on_stage:
on_stage("submitting")
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, 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
@@ -1,320 +0,0 @@
"""
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
@@ -1,409 +0,0 @@
"""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",
]
-529
View File
@@ -1,529 +0,0 @@
"""
Sora 视频生成 API 客户端
提供视频创建、状态轮询、视频下载功能
"""
import asyncio
import base64
import json
import os
import time
from typing import Any, Callable, Dict, List, Optional
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:
"""将 API 返回的已知英文错误信息翻译为中文友好提示"""
if "people-in-user-uploads" in msg or (
"moderation" in msg and "inputs" in msg
):
return "上传的参考图片中包含了真实人物【官方风控】,请尝试使用其他办法绕开。"
return msg
class SoraClient(BaseAPIClient):
"""
Sora 视频生成客户端
工作流程:
1. create_video → POST /v1/videos (提交生成任务)
2. poll_status → GET /v1/videos/{id} (轮询直到完成/失败)
3. download_video→ GET /v1/videos/{id}/content (下载视频文件)
"""
CREATE_ENDPOINT = "/v1/videos"
STATUS_ENDPOINT = "/v1/videos/{video_id}"
CONTENT_ENDPOINT = "/v1/videos/{video_id}/content"
POLL_INITIAL_INTERVAL = 3
POLL_MAX_INTERVAL = 15
def __init__(self):
api_key = get_api_key_or_raise()
base_url = get_api_base_url()
super().__init__(base_url=base_url, api_key=api_key)
# ------------------------------------------------------------------
# BaseAPIClient 抽象方法实现(本客户端主要使用自定义方法)
# ------------------------------------------------------------------
def get_endpoint(self, **kwargs) -> str:
return self.CREATE_ENDPOINT
def build_request_body(self, **kwargs) -> Dict[str, Any]:
return {}
def parse_response(self, response: Dict[str, Any]) -> Any:
return response
# ------------------------------------------------------------------
# 核心异步方法
# ------------------------------------------------------------------
async def create_video_async(
self,
prompt: str,
model: str,
seconds: int = 4,
size: str = "720x1280",
input_reference_bytes: Optional[bytes] = None,
seed: Optional[int] = None,
session: Optional[aiohttp.ClientSession] = None,
) -> Dict[str, Any]:
"""
提交视频生成任务
格式策略(根据抓包确认):
- 无参考图片:application/json
- 有参考图片:multipart/form-datainput_reference 以 PNG 文件上传
注意:seed 不被上游 API 接受,仅在 ComfyUI 节点侧用于缓存刷新
Returns:
API 响应 JSON,包含 video id 和初始状态
"""
url = f"{self.base_url}{self.CREATE_ENDPOINT}"
headers = {"Authorization": f"Bearer {self.api_key}"}
# ============================================================
# ⚠️ 已验证可用的标准请求方案,请勿随意修改!(2026-02-28)
# ============================================================
# 经多轮调试确认:
# - 有图片:必须使用 multipart/form-datainput_reference 以 PNG 文件上传
# · filename="reference.png", content_type="image/png"(与抓包一致)
# · 不可改为 application/json + base64 → 400 "expected a file, got a string"
# · 不可改为 application/json + data URI → 500 upstream error
# · 不可改为 multipart + image/jpeg → 400 "Inpaint image must match..."(尺寸校验失败)
# - 无图片:使用 application/json,已验证成功
# ============================================================
if input_reference_bytes:
if len(input_reference_bytes) > self.max_request_size:
raise ValueError(
f"参考图片约 {len(input_reference_bytes) / 1024 / 1024:.1f}MB"
f"超过 {self.max_request_size / 1024 / 1024:.0f}MB 限制,请使用较小的图片"
)
# ⚠️ 有图片:multipart/form-data + PNG 文件上传(唯一验证成功的方案)
form = aiohttp.FormData()
form.add_field("prompt", prompt)
form.add_field("model", model)
form.add_field("seconds", str(seconds))
form.add_field("size", size)
form.add_field(
"input_reference",
input_reference_bytes,
filename="reference.png", # ⚠️ 不可改文件名/扩展名
content_type="image/png", # ⚠️ 不可改为 image/jpeg
)
send_kwargs: Dict[str, Any] = {"data": form, "headers": headers}
else:
# ⚠️ 无图片:application/json(已验证成功)
body: Dict[str, Any] = {
"model": model,
"prompt": prompt,
"seconds": str(seconds),
"size": size,
}
send_kwargs = {"json": body, "headers": headers}
close_session = False
if session is None:
session = self._make_session()
close_session = True
try:
async with session.post(url, **send_kwargs) as response:
if response.status != 200:
error_text = await response.text()
error_message = self._extract_error_message(error_text, response.status)
raise RuntimeError(error_message)
resp_json = await response.json()
return resp_json
finally:
if close_session:
await session.close()
async def poll_video_status_async(
self,
video_id: str,
progress_callback: Optional[Callable[[int, float], None]] = None,
session: Optional[aiohttp.ClientSession] = None,
) -> Dict[str, Any]:
"""
轮询视频生成状态,直到完成或失败
Args:
video_id: 视频任务 ID
progress_callback: 进度回调 (progress_percent, elapsed_seconds)
session: aiohttp 会话
Returns:
最终状态的 API 响应
Raises:
RuntimeError: 生成失败
"""
url = f"{self.base_url}{self.STATUS_ENDPOINT.format(video_id=video_id)}"
headers = self.get_headers(use_bearer_token=True)
close_session = False
if session is None:
session = self._make_session()
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()
error_message = self._extract_error_message(error_text, response.status)
raise RuntimeError(error_message)
data = await response.json()
# status 兼容大小写:queued / in_progress / IN_PROGRESS / completed / COMPLETED
status = data.get("status", "").lower()
# progress 兼容整数 (30) 和字符串 ("30%") 两种格式
progress_raw = data.get("progress", 0)
if isinstance(progress_raw, str):
try:
progress = int(progress_raw.rstrip("%").strip())
except ValueError:
progress = 0
else:
progress = int(progress_raw) if progress_raw else 0
if progress_callback:
progress_callback(progress)
if status == "completed":
return data
if status == "failed":
error_info = data.get("error", {})
error_msg = error_info.get("message", "未知错误") if isinstance(error_info, dict) else str(error_info)
error_msg = _translate_error_message(error_msg)
raise RuntimeError(f"视频生成失败: {error_msg}")
await asyncio.sleep(interval)
interval = min(interval * 1.5, self.POLL_MAX_INTERVAL)
finally:
if close_session:
await session.close()
async def download_video_async(
self,
video_id: str,
save_path: str,
session: Optional[aiohttp.ClientSession] = None,
) -> str:
"""
下载生成的视频文件
处理两种情况:
1. 响应为重定向或 JSON 含下载 URL → 跟随下载
2. 响应为二进制视频流 → 直接保存
Returns:
保存的文件路径
"""
url = f"{self.base_url}{self.CONTENT_ENDPOINT.format(video_id=video_id)}"
headers = self.get_headers(use_bearer_token=True)
close_session = False
if session is None:
session = self._make_session()
close_session = True
try:
async with session.get(url, headers=headers, allow_redirects=True) as response:
if response.status != 200:
error_text = await response.text()
error_message = self._extract_error_message(error_text, response.status)
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:
# content 端点直接返回视频流(幂等 GET,可安全重连续传)
await download_video_to_file(
session, url, save_path, headers=headers, label="Sora 视频",
)
return save_path
finally:
if close_session:
await session.close()
# ------------------------------------------------------------------
# 同步包装
# ------------------------------------------------------------------
def generate_video_sync(
self,
prompt: str,
model: str,
seconds: int,
size: str,
save_path: str,
input_reference_bytes: Optional[bytes] = None,
seed: Optional[int] = None,
progress_callback: Optional[Callable[[int, float], None]] = None,
on_stage: Optional[Callable[[str], None]] = None,
) -> str:
"""
同步执行完整的视频生成流程(创建 → 轮询 → 下载)
Args:
on_stage: 阶段回调,用于打印状态切换信息
Returns:
保存的视频文件路径
"""
async def _run():
connector = aiohttp.TCPConnector(ssl=False, limit=0)
async with aiohttp.ClientSession(connector=connector) as session:
# 1. 提交任务
if on_stage:
on_stage("submitting")
result = await self.create_video_async(
prompt=prompt,
model=model,
seconds=seconds,
size=size,
input_reference_bytes=input_reference_bytes,
seed=seed,
session=session,
)
video_id = result.get("id")
if not video_id:
raise RuntimeError("API 未返回视频任务 ID")
if on_stage:
on_stage(f"submitted:{video_id}")
# 2. 轮询状态
if on_stage:
on_stage("polling")
await self.poll_video_status_async(
video_id=video_id,
progress_callback=progress_callback,
session=session,
)
# 3. 下载视频
if on_stage:
on_stage("downloading")
path = await self.download_video_async(
video_id=video_id,
save_path=save_path,
session=session,
)
if on_stage:
on_stage("done")
return path
return self.run_async_in_thread(_run())
async def _generate_one_video_async(
self,
prompt: str,
model: str,
seconds: int,
size: str,
save_path: str,
input_reference_bytes: Optional[bytes] = None,
seed: Optional[int] = None,
session: Optional[aiohttp.ClientSession] = None,
) -> str:
"""
异步生成单个视频(创建 → 轮询 → 下载)
Returns:
保存的视频文件路径
"""
result = await self.create_video_async(
prompt=prompt,
model=model,
seconds=seconds,
size=size,
input_reference_bytes=input_reference_bytes,
seed=seed,
session=session,
)
video_id = result.get("id")
if not video_id:
raise RuntimeError("API 未返回视频任务 ID")
await self.poll_video_status_async(video_id=video_id, session=session)
path = await self.download_video_async(
video_id=video_id, save_path=save_path, session=session
)
return path
async def generate_batch_videos_async(
self,
prompt: str,
model: str,
seconds: int,
size: str,
save_paths: List[str],
input_reference_bytes: Optional[bytes] = None,
seed: Optional[int] = None,
progress_callback: Optional[Callable[[int, int, bool, Optional[str]], None]] = None,
) -> List[str]:
"""
并发生成多个视频
Args:
prompt: 提示词
model: 模型名称
seconds: 视频时长(秒)
size: 分辨率
save_paths: 各视频的保存路径列表,长度决定并发数量
input_reference_bytes: 参考图片字节(可选)
seed: 随机种子(仅节点侧使用)
progress_callback: 进度回调 (current, total, success, error_msg)
Returns:
成功生成的视频路径列表
"""
batch_size = len(save_paths)
connector = aiohttp.TCPConnector(ssl=False, limit=0)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [
self._generate_one_video_async(
prompt=prompt,
model=model,
seconds=seconds,
size=size,
save_path=save_paths[i],
input_reference_bytes=input_reference_bytes,
seed=seed,
session=session,
)
for i in range(batch_size)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
completed = 0
paths: List[str] = []
first_error = None
for i, result in enumerate(results):
if isinstance(result, Exception):
error_msg = str(result)
print(f"Sora: 第 {i + 1} 个视频生成失败")
print(f"原始错误详情:\n{error_msg}")
if first_error is None:
first_error = result
if progress_callback:
progress_callback(i + 1, batch_size, False, error_msg)
else:
completed += 1
paths.append(result)
if progress_callback:
progress_callback(completed, batch_size, True, None)
if not paths:
if first_error:
raise first_error
raise RuntimeError(f"批量视频生成失败,{batch_size} 个任务全部失败")
return paths
def generate_batch_videos_sync(
self,
prompt: str,
model: str,
seconds: int,
size: str,
save_paths: List[str],
input_reference_bytes: Optional[bytes] = None,
seed: Optional[int] = None,
progress_callback: Optional[Callable[[int, int, bool, Optional[str]], None]] = None,
) -> List[str]:
"""
同步并发生成多个视频(用于 ComfyUI 节点)
Args:
save_paths: 各视频的保存路径列表,长度决定并发数量
Returns:
成功生成的视频路径列表
"""
coro = self.generate_batch_videos_async(
prompt=prompt,
model=model,
seconds=seconds,
size=size,
save_paths=save_paths,
input_reference_bytes=input_reference_bytes,
seed=seed,
progress_callback=progress_callback,
)
return self.run_async_in_thread(coro)
# ------------------------------------------------------------------
# 内部辅助方法
# ------------------------------------------------------------------
async def _download_from_url(
self,
url: str,
save_path: str,
session: aiohttp.ClientSession,
) -> None:
"""从给定 URL 下载文件到本地路径"""
await download_video_to_file(session, url, save_path, label="Sora 视频")
@staticmethod
def _extract_error_message(error_text: str, status_code: int) -> str:
"""从错误响应中提取可读的错误信息"""
error_message = error_text
try:
error_json = json.loads(error_text)
if "error" in error_json:
if isinstance(error_json["error"], dict):
error_message = error_json["error"].get("message", error_text)
else:
error_message = str(error_json["error"])
elif "message" in error_json:
error_message = error_json["message"]
except (json.JSONDecodeError, KeyError):
pass
status_hints = {
400: "请求参数错误 (400)",
401: "认证失败 (401),请检查 API 密钥",
403: "权限不足 (403),请检查账户权限或余额",
429: "请求频率超限 (429),请稍后重试",
503: "服务暂时不可用 (503),请稍后重试",
504: "请求超时 (504),请稍后重试",
}
hint = status_hints.get(status_code, f"API 请求失败 (状态码: {status_code})")
return f"{hint}\nAPI 返回: {error_message}"
-509
View File
@@ -1,509 +0,0 @@
"""
Veo 视频生成 API 客户端
提供视频创建、状态轮询、视频下载功能
"""
import asyncio
import base64
import json
import os
import time
from typing import Any, Callable, Dict, List, Optional
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):
"""
Veo 视频生成客户端
工作流程:
1. create_video → POST /v1/videos (提交生成任务)
2. poll_status → GET /v1/videos/{id} (轮询直到完成/失败)
3. download_video→ GET /v1/videos/{id}/content (下载视频文件)
"""
CREATE_ENDPOINT = "/v1/videos"
STATUS_ENDPOINT = "/v1/videos/{video_id}"
CONTENT_ENDPOINT = "/v1/videos/{video_id}/content"
POLL_INITIAL_INTERVAL = 3
POLL_MAX_INTERVAL = 15
def __init__(self):
api_key = get_api_key_or_raise()
base_url = get_api_base_url()
super().__init__(base_url=base_url, api_key=api_key)
# ------------------------------------------------------------------
# BaseAPIClient 抽象方法实现
# ------------------------------------------------------------------
def get_endpoint(self, **kwargs) -> str:
return self.CREATE_ENDPOINT
def build_request_body(self, **kwargs) -> Dict[str, Any]:
return {}
def parse_response(self, response: Dict[str, Any]) -> Any:
return response
# ------------------------------------------------------------------
# 核心异步方法
# ------------------------------------------------------------------
async def create_video_async(
self,
prompt: str,
model: str,
seconds: int = 8,
size: str = "720x1280",
first_frame_bytes: Optional[bytes] = None,
last_frame_bytes: Optional[bytes] = None,
reference_bytes: Optional[bytes] = None,
seed: Optional[int] = None,
session: Optional[aiohttp.ClientSession] = None,
) -> Dict[str, Any]:
"""
提交视频生成任务
格式策略:
- 无参考图片:application/json
- 有参考图片:multipart/form-data,图片以 PNG 文件上传
Args:
prompt: 提示词
model: 模型名称
seconds: 视频时长(秒)
size: 分辨率
first_frame_bytes: 首帧图片字节
last_frame_bytes: 尾帧图片字节
reference_bytes: 参考图片字节
seed: 随机种子
session: aiohttp 会话
Returns:
API 响应 JSON,包含 video id 和初始状态
"""
url = f"{self.base_url}{self.CREATE_ENDPOINT}"
headers = {"Authorization": f"Bearer {self.api_key}"}
# 检查是否有图片
has_images = any([first_frame_bytes, last_frame_bytes, reference_bytes])
if has_images:
# 有图片:multipart/form-data + PNG 文件上传
if first_frame_bytes and len(first_frame_bytes) > self.max_request_size:
raise ValueError(f"首帧图片过大,超过 {self.max_request_size / 1024 / 1024:.0f}MB 限制")
if last_frame_bytes and len(last_frame_bytes) > self.max_request_size:
raise ValueError(f"尾帧图片过大,超过 {self.max_request_size / 1024 / 1024:.0f}MB 限制")
if reference_bytes and len(reference_bytes) > self.max_request_size:
raise ValueError(f"参考图片过大,超过 {self.max_request_size / 1024 / 1024:.0f}MB 限制")
form = aiohttp.FormData()
form.add_field("prompt", prompt)
form.add_field("model", model)
form.add_field("seconds", str(seconds))
form.add_field("size", size)
# 注意:seed 不被上游 API 接受,仅在 ComfyUI 节点侧用于缓存刷新
# if seed is not None:
# form.add_field("seed", str(seed))
# 使用 input_reference 字段(OpenAI兼容格式)
# 尝试支持多张图片:按顺序添加多个 input_reference 字段
if first_frame_bytes:
form.add_field(
"input_reference",
first_frame_bytes,
filename="first_frame.png",
content_type="image/png",
)
if last_frame_bytes:
form.add_field(
"input_reference",
last_frame_bytes,
filename="last_frame.png",
content_type="image/png",
)
if reference_bytes:
form.add_field(
"input_reference",
reference_bytes,
filename="reference.png",
content_type="image/png",
)
send_kwargs: Dict[str, Any] = {"data": form, "headers": headers}
else:
# 无图片:application/json
body: Dict[str, Any] = {
"model": model,
"prompt": prompt,
"seconds": str(seconds),
"size": size,
}
# 注意:seed 不被上游 API 接受,仅在 ComfyUI 节点侧用于缓存刷新
# if seed is not None:
# body["seed"] = str(seed)
send_kwargs = {"json": body, "headers": headers}
# 打印请求调试信息
import json
if has_images:
print(f"Veo: 使用 multipart/form-data 格式上传图片")
else:
print(f"Veo API 请求体: {json.dumps(body, ensure_ascii=False)}")
close_session = False
if session is None:
session = self._make_session()
close_session = True
try:
async with session.post(url, **send_kwargs) as response:
if response.status != 200:
error_text = await response.text()
error_message = self._extract_error_message(error_text, response.status)
raise RuntimeError(error_message)
resp_json = await response.json()
return resp_json
finally:
if close_session:
await session.close()
async def poll_video_status_async(
self,
video_id: str,
progress_callback: Optional[Callable[[int, float], None]] = None,
session: Optional[aiohttp.ClientSession] = None,
) -> Dict[str, Any]:
"""
轮询视频生成状态,直到完成或失败
Args:
video_id: 视频任务 ID
progress_callback: 进度回调 (progress_percent, elapsed_seconds)
session: aiohttp 会话
Returns:
最终状态的 API 响应
Raises:
RuntimeError: 生成失败
"""
url = f"{self.base_url}{self.STATUS_ENDPOINT.format(video_id=video_id)}"
headers = self.get_headers(use_bearer_token=True)
close_session = False
if session is None:
session = self._make_session()
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()
error_message = self._extract_error_message(error_text, response.status)
raise RuntimeError(error_message)
data = await response.json()
# status 兼容大小写
status = data.get("status", "").lower()
# progress 兼容整数和字符串
progress_raw = data.get("progress", 0)
if isinstance(progress_raw, str):
try:
progress = int(progress_raw.rstrip("%").strip())
except ValueError:
progress = 0
else:
progress = int(progress_raw) if progress_raw else 0
if progress_callback:
progress_callback(progress)
if status == "completed":
return data
if status == "failed":
error_info = data.get("error", {})
error_msg = error_info.get("message", "未知错误") if isinstance(error_info, dict) else str(error_info)
raise RuntimeError(f"视频生成失败: {error_msg}")
await asyncio.sleep(interval)
interval = min(interval * 1.5, self.POLL_MAX_INTERVAL)
finally:
if close_session:
await session.close()
async def download_video_async(
self,
video_id: str,
save_path: str,
session: Optional[aiohttp.ClientSession] = None,
) -> str:
"""
下载生成的视频文件
Returns:
保存的文件路径
"""
url = f"{self.base_url}{self.CONTENT_ENDPOINT.format(video_id=video_id)}"
headers = self.get_headers(use_bearer_token=True)
close_session = False
if session is None:
session = self._make_session()
close_session = True
try:
async with session.get(url, headers=headers, allow_redirects=True) as response:
if response.status != 200:
error_text = await response.text()
error_message = self._extract_error_message(error_text, response.status)
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:
# content 端点直接返回视频流(幂等 GET,可安全重连续传)
await download_video_to_file(
session, url, save_path, headers=headers, label="VEO 视频",
)
return save_path
finally:
if close_session:
await session.close()
# ------------------------------------------------------------------
# 同步包装
# ------------------------------------------------------------------
def generate_video_sync(
self,
prompt: str,
model: str,
seconds: int,
size: str,
save_path: str,
first_frame_bytes: Optional[bytes] = None,
last_frame_bytes: Optional[bytes] = None,
reference_bytes: Optional[bytes] = None,
seed: Optional[int] = None,
progress_callback: Optional[Callable[[int], None]] = None,
on_stage: Optional[Callable[[str], None]] = None,
) -> str:
"""
同步执行完整的视频生成流程(创建 → 轮询 → 下载)
"""
async def _run():
connector = aiohttp.TCPConnector(ssl=False, limit=0)
async with aiohttp.ClientSession(connector=connector) as session:
# 1. 提交任务
if on_stage:
on_stage("submitting")
result = await self.create_video_async(
prompt=prompt,
model=model,
seconds=seconds,
size=size,
first_frame_bytes=first_frame_bytes,
last_frame_bytes=last_frame_bytes,
reference_bytes=reference_bytes,
seed=seed,
session=session,
)
video_id = result.get("id")
if not video_id:
raise RuntimeError("API 未返回视频任务 ID")
if on_stage:
on_stage(f"submitted:{video_id}")
# 2. 轮询状态
if on_stage:
on_stage("polling")
await self.poll_video_status_async(
video_id=video_id,
progress_callback=progress_callback,
session=session,
)
# 3. 下载视频
if on_stage:
on_stage("downloading")
path = await self.download_video_async(
video_id=video_id,
save_path=save_path,
session=session,
)
if on_stage:
on_stage("done")
return path
return self.run_async_in_thread(_run())
def generate_batch_videos_sync(
self,
prompt: str,
model: str,
seconds: int,
size: str,
save_paths: List[str],
first_frame_bytes: Optional[bytes] = None,
last_frame_bytes: Optional[bytes] = None,
reference_bytes: Optional[bytes] = None,
seed: Optional[int] = None,
progress_callback: Optional[Callable[[int, int, bool, Optional[str]], None]] = None,
) -> List[str]:
"""
同步并发生成多个视频
"""
async def _run():
batch_size = len(save_paths)
connector = aiohttp.TCPConnector(ssl=False, limit=0)
async def generate_one(save_path: str):
return await self._generate_one_video_async(
prompt=prompt,
model=model,
seconds=seconds,
size=size,
save_path=save_path,
first_frame_bytes=first_frame_bytes,
last_frame_bytes=last_frame_bytes,
reference_bytes=reference_bytes,
seed=seed,
)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [generate_one(p) for p in save_paths]
results = await asyncio.gather(*tasks, return_exceptions=True)
completed = 0
paths: List[str] = []
first_error = None
for i, result in enumerate(results):
if isinstance(result, Exception):
error_msg = str(result)
print(f"Veo: 第 {i + 1} 个视频生成失败")
if first_error is None:
first_error = result
if progress_callback:
progress_callback(i + 1, batch_size, False, error_msg)
else:
completed += 1
paths.append(result)
if progress_callback:
progress_callback(completed, batch_size, True, None)
if not paths:
if first_error:
raise first_error
raise RuntimeError(f"批量视频生成失败,{batch_size} 个任务全部失败")
return paths
return self.run_async_in_thread(_run())
async def _generate_one_video_async(
self,
prompt: str,
model: str,
seconds: int,
size: str,
save_path: str,
first_frame_bytes: Optional[bytes] = None,
last_frame_bytes: Optional[bytes] = None,
reference_bytes: Optional[bytes] = None,
seed: Optional[int] = None,
session: Optional[aiohttp.ClientSession] = None,
) -> str:
"""异步生成单个视频"""
result = await self.create_video_async(
prompt=prompt,
model=model,
seconds=seconds,
size=size,
first_frame_bytes=first_frame_bytes,
last_frame_bytes=last_frame_bytes,
reference_bytes=reference_bytes,
seed=seed,
session=session,
)
video_id = result.get("id")
if not video_id:
raise RuntimeError("API 未返回视频任务 ID")
await self.poll_video_status_async(video_id=video_id, session=session)
path = await self.download_video_async(
video_id=video_id, save_path=save_path, session=session
)
return path
# ------------------------------------------------------------------
# 内部辅助方法
# ------------------------------------------------------------------
async def _download_from_url(
self,
url: str,
save_path: str,
session: aiohttp.ClientSession,
) -> None:
"""从给定 URL 下载文件到本地路径"""
await download_video_to_file(session, url, save_path, label="VEO 视频")
@staticmethod
def _extract_error_message(error_text: str, status_code: int) -> str:
"""从错误响应中提取可读的错误信息"""
error_message = error_text
try:
error_json = json.loads(error_text)
if "error" in error_json:
if isinstance(error_json["error"], dict):
error_message = error_json["error"].get("message", error_text)
else:
error_message = str(error_json["error"])
elif "message" in error_json:
error_message = error_json["message"]
except (json.JSONDecodeError, KeyError):
pass
status_hints = {
400: "请求参数错误 (400)",
401: "认证失败 (401),请检查 API 密钥",
403: "权限不足 (403),请检查账户权限或余额",
429: "请求频率超限 (429),请稍后重试",
503: "服务暂时不可用 (503),请稍后重试",
504: "请求超时 (504),请稍后重试",
}
hint = status_hints.get(status_code, f"API 请求失败 (状态码: {status_code})")
return f"{hint}\nAPI 返回: {error_message}"
-33
View File
@@ -1,33 +0,0 @@
# 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
@@ -1,264 +0,0 @@
# 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
@@ -1,59 +0,0 @@
# 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
@@ -1,25 +0,0 @@
# 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?
@@ -1,39 +0,0 @@
# 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.
@@ -1,37 +0,0 @@
# 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.
@@ -1,40 +0,0 @@
# 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.
@@ -1,36 +0,0 @@
# 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.
@@ -1,35 +0,0 @@
# 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.
@@ -1,46 +0,0 @@
# 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.
@@ -1,33 +0,0 @@
# 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.
@@ -1,30 +0,0 @@
# 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.
@@ -1,25 +0,0 @@
# 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.
@@ -1,23 +0,0 @@
# 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.
@@ -1,21 +0,0 @@
# 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.
@@ -1,21 +0,0 @@
# 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.
@@ -1,21 +0,0 @@
# 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.
@@ -1,25 +0,0 @@
# 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
@@ -1,28 +0,0 @@
# 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
@@ -1,108 +0,0 @@
# 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
@@ -1,74 +0,0 @@
# 文件清理与结构优化说明
清理日期: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
@@ -1,53 +0,0 @@
# 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.
+64 -520
View File
@@ -8,7 +8,7 @@
3. 重新启用模型: 将模型的 enabled 字段改回 True
模型类型:
- GEMINI_MODELS: Nano Banana 图像生成模型
- GEMINI_MODELS: Nano Banana Pro 图像生成模型
- GEMINI_FLASH_MODELS: Google Gemini Flash 文本生成模型
示例:
@@ -17,21 +17,14 @@
"id": "gemini-新模型名称",
"description": "模型说明和特点",
"enabled": True,
"endpoint_type": "standard",
"endpoint": "/v1beta/models/gemini-新模型名称:generateContent",
"thinking_config": {
"不思考": None,
"": "low",
"": None,
"": "high"
}
"endpoint_type": "standard" # 端点类型: "dynamic", "standard", "flatfee"
}
临时关闭模型:
将对应模型的 "enabled": True 改为 "enabled": False
"""
from typing import List, Dict, Optional, Tuple
from typing import List, Dict, Optional
# ============================================================
@@ -39,72 +32,45 @@ from typing import List, Dict, Optional, Tuple
# ============================================================
# ============================================================
# Nano Banana 图像生成模型
# Nano Banana Pro 图像生成模型
# ============================================================
GEMINI_MODELS = [
{
"id": "nano-banana-pro-次卡",
"description": "Nano Banana Pro 次卡,根据分辨率自动选择端点 (1K/2K/4K),高性能图像生成模型",
"id": "nano-banana-pro",
"description": "Nano Banana Pro,根据分辨率自动选择端点 (1K/2K/4K),高性能图像生成模型",
"enabled": True,
"provider": "gemini_async",
"endpoint_type": "dynamic",
"endpoint": None, # 动态端点,由代码根据分辨率选择
"supported_aspect_ratios": [
"1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"
],
"supported_resolutions": ["1K", "2K", "4K"]
"endpoint": None # 动态端点,由代码根据分辨率选择
},
{
"id": "nano-banana-pro-官方计费",
"description": "Nano Banana Pro 官方计费,按分辨率路由 (1K/2K/4K),使用官方计费通道",
"enabled": True,
"provider": "gemini_async",
"id": "gemini-3-pro-image-preview-url",
"description": "URL 模式,根据分辨率自动选择端点 (1K/2K/4K),推荐用于需要不同分辨率的场景",
"enabled": False,
"endpoint_type": "dynamic",
"endpoint": None, # 动态端点,由代码根据分辨率选择
"supported_aspect_ratios": [
"1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"
],
"supported_resolutions": ["1K", "2K", "4K"]
"endpoint": None # 动态端点,由代码根据分辨率选择
},
{
"id": "nano-banana-2-次卡",
"description": "Nano Banana 2 次卡,根据分辨率自动选择端点 (512/1K/2K/4K)图像生成模型",
"id": "gemini-3-pro-image-preview",
"description": "标准模式,固定端点,适用于常规图像生成",
"enabled": True,
"provider": "gemini_async",
"endpoint_type": "dynamic",
"endpoint": None, # 动态端点,由代码根据分辨率选择
"supported_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"
],
"supported_resolutions": ["512", "1K", "2K", "4K"]
},
{
"id": "nano-banana-2-官方计费",
"description": "Nano Banana 2 官方计费,按分辨率路由 (512/1K/2K/4K),使用官方计费通道",
"enabled": True,
"provider": "gemini_async",
"endpoint_type": "dynamic",
"endpoint": None, # 动态端点,由代码根据分辨率选择
"supported_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"
],
"supported_resolutions": ["512", "1K", "2K", "4K"]
},
{
"id": "nano-banana-次卡",
"description": "Nano Banana 次卡,固定端点,图像生成模型",
"enabled": True,
"provider": "gemini_async",
"endpoint_type": "standard",
"endpoint": "/v1beta/models/nano-banana:generateContent",
"supported_aspect_ratios": [
"1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"
],
"supported_resolutions": ["1K"]
"endpoint": "/v1beta/models/gemini-3-pro-image-preview:generateContent"
},
{
"id": "gemini-3-pro-image-preview-flatfee",
"description": "固定费用模式,固定端点,按固定价格计费 (暂时不可用-504错误)",
"enabled": False, # 暂时禁用:端点返回 504 错误
"endpoint_type": "flatfee",
"endpoint": "/v1beta/models/gemini-3-pro-image-preview-flatfee:generateContent"
},
{
"id": "nano-banana-2",
"description": "Nano Banana 2 模型,固定端点,适用于高质量图像生成",
"enabled": False,
"endpoint_type": "standard",
"endpoint": "/v1beta/models/nano-banana-2:generateContent"
}
]
@@ -114,29 +80,14 @@ GEMINI_MODELS = [
GEMINI_FLASH_MODELS = [
{
"id": "gemini-3.5-flash",
"description": "Gemini 3.5 Flash,快速多模态文本生成,通过 thinkingConfig 控制思考等级",
"id": "gemini-3-flash-preview",
"description": "Gemini 3 Flash,快速多模态文本生成,支持图片和视频输入",
"enabled": True,
"endpoint_type": "standard",
"endpoint": "/v1beta/models/gemini-3.5-flash:generateContent",
"thinking_config": {
"": "low",
"": "medium",
"": "high"
"endpoints": {
"不思考": "/v1beta/models/gemini-3-flash-preview-nothinking:generateContent",
"": "/v1beta/models/gemini-3-flash-preview-high:generateContent"
}
},
{
"id": "gemini-3.1-pro-preview",
"description": "Gemini 3.1 Pro,高性能多模态文本生成,通过 thinkingConfig 控制思考等级",
"enabled": True,
"endpoint_type": "standard",
"endpoint": "/v1beta/models/gemini-3.1-pro-preview:generateContent",
"thinking_config": {
"": "low",
"": "high"
}
},
}
]
@@ -233,123 +184,6 @@ def get_model_description(model_id: str) -> str:
return config.get("description", "")
def get_model_supported_aspect_ratios(model_id: str) -> List[str]:
"""
获取模型支持的宽高比列表
Args:
model_id: 模型 ID
Returns:
支持的宽高比字符串列表,如果未配置则返回空列表
Example:
>>> get_model_supported_aspect_ratios("gemini-3-pro-image-preview")
['1:1', '2:3', '3:2', ...]
"""
config = get_model_config(model_id)
if config is None:
return []
return config.get("supported_aspect_ratios", [])
def get_all_supported_aspect_ratios() -> List[str]:
"""
获取所有启用模型支持的宽高比(去重合并)
Returns:
所有启用模型支持的宽高比列表(保持顺序、去重)
Example:
>>> get_all_supported_aspect_ratios()
['1:1', '4:3', '3:4', '16:9', '9:16', '2:3', '3:2', '4:5', '5:4', '21:9', '1:4', '4:1', '1:8', '8:1']
"""
seen = set()
result = []
for model in GEMINI_MODELS:
if not model.get("enabled", False):
continue
for ratio in model.get("supported_aspect_ratios", []):
if ratio not in seen:
seen.add(ratio)
result.append(ratio)
return result
def get_model_supported_resolutions(model_id: str) -> List[str]:
"""
获取模型支持的分辨率列表
Args:
model_id: 模型 ID
Returns:
支持的分辨率字符串列表,如果未配置则返回空列表
Example:
>>> get_model_supported_resolutions("gemini-3.1-flash-image-preview")
['512', '1K', '2K', '4K']
>>> get_model_supported_resolutions("gemini-3-pro-image-preview")
['1K', '2K', '4K']
"""
config = get_model_config(model_id)
if config is None:
return []
return config.get("supported_resolutions", [])
def get_all_supported_resolutions() -> List[str]:
"""
获取所有启用模型支持的分辨率(去重合并,按从小到大固定顺序排列)
Returns:
所有启用模型支持的分辨率列表(按 512 → 1K → 2K → 4K 顺序)
Example:
>>> get_all_supported_resolutions()
['512', '1K', '2K', '4K']
"""
_ORDER = ["512", "1K", "2K", "4K"]
seen = set()
for model in GEMINI_MODELS:
if not model.get("enabled", False):
continue
for res in model.get("supported_resolutions", []):
seen.add(res)
return [res for res in _ORDER if res in seen]
def get_model_provider(model_id: str) -> Optional[str]:
"""
获取模型的异步 Provider 名称
Args:
model_id: 模型 ID
Returns:
Provider 名称(如 "gemini_async"),如果模型未配置 provider 则返回 None
"""
config = get_model_config(model_id)
if config is None:
return None
return config.get("provider")
def get_enabled_async_models() -> List[str]:
"""
获取所有启用的、支持异步模式的模型 ID 列表
Returns:
模型 ID 列表(仅包含配置了 provider 且 enabled 的模型)
"""
return [
model["id"] for model in GEMINI_MODELS
if model.get("enabled", False) and model.get("provider")
]
def get_endpoint_type(model_id: str) -> Optional[str]:
"""
获取模型的端点类型
@@ -392,233 +226,6 @@ def get_model_endpoint(model_id: str) -> Optional[str]:
return config.get("endpoint")
# ============================================================
# Gemini Flash 模型工具函数
# ============================================================
# ============================================================
# Sora 视频生成模型
# ============================================================
SORA_MODELS = [
{
"id": "sora-2",
"description": "Sora 2 官方模型,支持标准时长和分辨率",
"enabled": True,
"supported_seconds": [4, 8, 10, 12, 15],
"supported_sizes": ["720x1280", "1280x720"],
"seconds_category": "官方", # 用于界面显示标签
},
{
"id": "sora-2-pro",
"description": "Sora 2 Pro 增强模型,支持扩展时长和竖屏/横屏高清分辨率",
"enabled": True,
"supported_seconds": [4, 8, 12, 15, 25],
"supported_sizes": ["720x1280", "1280x720", "1024x1792", "1792x1024"],
"seconds_category": "扩展", # Pro 模型支持全部时长
},
]
# 秒数显示标签配置(用于界面下拉菜单)
# key: 实际秒数, value: 显示文本
SECONDS_DISPLAY_MAP = {
4: "4",
8: "8",
12: "12",
10: "10",
15: "15",
25: "25(pro)",
}
# 分辨率显示标签配置
# key: 实际分辨率, value: (显示P数, 显示方向)
RESOLUTION_DISPLAY_MAP = {
"720x1280": ("720P", "竖屏"),
"1280x720": ("720P", "横屏"),
"1024x1792": ("1080P", "竖屏"),
"1792x1024": ("1080P", "横屏"),
}
# ============================================================
# Sora 模型工具函数
# ============================================================
def get_enabled_sora_models() -> List[str]:
"""获取所有启用的 Sora 模型 ID 列表"""
return [model["id"] for model in SORA_MODELS if model.get("enabled", False)]
def get_sora_model_config(model_id: str) -> Optional[Dict]:
"""根据模型 ID 获取 Sora 模型的完整配置"""
for model in SORA_MODELS:
if model["id"] == model_id:
return model
return None
def get_sora_supported_seconds(model_id: str) -> List[int]:
"""获取 Sora 模型支持的视频时长列表(秒)"""
config = get_sora_model_config(model_id)
if config is None:
return []
return config.get("supported_seconds", [])
def get_sora_supported_sizes(model_id: str) -> List[str]:
"""获取 Sora 模型支持的分辨率列表"""
config = get_sora_model_config(model_id)
if config is None:
return []
return config.get("supported_sizes", [])
def get_all_sora_seconds() -> List[int]:
"""获取所有启用 Sora 模型支持的时长(去重、升序)"""
seen = set()
for model in SORA_MODELS:
if not model.get("enabled", False):
continue
for s in model.get("supported_seconds", []):
seen.add(s)
return sorted(seen)
def get_all_sora_sizes() -> List[str]:
"""获取所有启用 Sora 模型支持的分辨率(去重、保持顺序)"""
seen = set()
result = []
for model in SORA_MODELS:
if not model.get("enabled", False):
continue
for size in model.get("supported_sizes", []):
if size not in seen:
seen.add(size)
result.append(size)
return result
def get_sora_seconds_with_labels(model_id: str) -> List[Tuple[str, int]]:
"""
获取指定模型支持的秒数列表(带标签显示)
Returns:
列表项为 (显示文本, 实际秒数),如 [("4(官方)", 4), ("10(特殊)", 10)]
"""
config = get_sora_model_config(model_id)
if config is None:
return []
seconds_list = config.get("supported_seconds", [])
result = []
for s in seconds_list:
category = SECONDS_CATEGORIES.get(s, "")
label = f"{s}{category}" if category else str(s)
result.append((label, s))
return result
def get_sora_sizes_with_labels(model_id: str) -> List[Tuple[str, str]]:
"""
获取指定模型支持的分辨率列表(带独占标识)
Returns:
列表项为 (显示文本, 实际分辨率),如 [("720P 9:16 (720x1280)", "720x1280")]
"""
from math import gcd
config = get_sora_model_config(model_id)
if config is None:
return []
sizes = config.get("supported_sizes", [])
result = []
# 检查哪些分辨率是独占的(仅该模型支持)
all_sizes_count = {}
for m in SORA_MODELS:
if not m.get("enabled", False):
continue
for size in m.get("supported_sizes", []):
all_sizes_count[size] = all_sizes_count.get(size, 0) + 1
for size in sizes:
# 解析分辨率
parts = size.lower().split("x")
w, h = int(parts[0]), int(parts[1])
short_side = min(w, h)
# 分辨率等级
if short_side >= 1792:
res = "2K+"
elif short_side >= 1080:
res = "1K+"
elif short_side >= 720:
res = "720P"
else:
res = f"{short_side}P"
# 比例
g = gcd(w, h)
ratio = f"{w // g}:{h // g}"
# 检查是否独占
exclusive = all_sizes_count.get(size, 0) == 1
exclusive_tag = " [Pro独占]" if exclusive else ""
# 方向
orientation = "竖屏" if h > w else "横屏" if w > h else "方形"
label = f"{res} {ratio} {orientation}{exclusive_tag} ({size})"
result.append((label, size))
return result
# ============================================================
# Google Veo 视频生成模型
# ============================================================
VEO_MODELS = [
{
"id": "Veo3.1",
"description": "Google Veo 3.1 视频生成模型,支持文生视频和图生视频",
"enabled": True,
},
]
# Veo 分辨率映射表
# key: "分辨率_宽高比", value: 实际分辨率字符串
VEO_RESOLUTION_MAP = {
# 720p
"720p_9:16": "720x1280",
"720p_16:9": "1280x720",
# 1080p
"1080p_9:16": "1080x1920",
"1080p_16:9": "1920x1080",
# 4K
"4K_9:16": "2160x3840",
"4K_16:9": "3840x2160",
}
# ============================================================
# Veo 模型工具函数
# ============================================================
def get_enabled_veo_models() -> List[str]:
"""获取所有启用的 Veo 模型 ID 列表"""
return [model["id"] for model in VEO_MODELS if model.get("enabled", False)]
def get_veo_model_config(model_id: str) -> Optional[Dict]:
"""根据模型 ID 获取 Veo 模型的完整配置"""
for model in VEO_MODELS:
if model["id"] == model_id:
return model
return None
# ============================================================
# Gemini Flash 模型工具函数
# ============================================================
@@ -684,24 +291,29 @@ def is_flash_model_enabled(model_id: str) -> bool:
return config.get("enabled", False)
def get_flash_model_endpoint(model_id: str) -> Optional[str]:
def get_flash_model_endpoint(model_id: str, thinking_depth: str = "不思考") -> Optional[str]:
"""
获取 Flash 模型的 API 端点
Args:
model_id: 模型 ID
thinking_depth: 思考深度 ("不思考""")
Returns:
API 端点路径,如果未找到则返回 None
Example:
>>> get_flash_model_endpoint("gemini-3-flash-preview")
'/v1beta/models/gemini-3-flash-preview:generateContent'
>>> get_flash_model_endpoint("gemini-3-flash-preview", "不思考")
'/v1beta/models/gemini-3-flash-preview-nothinking:generateContent'
>>> get_flash_model_endpoint("gemini-3-flash-preview", "")
'/v1beta/models/gemini-3-flash-preview-high:generateContent'
"""
config = get_flash_model_config(model_id)
if config is None:
return None
return config.get("endpoint")
endpoints = config.get("endpoints", {})
return endpoints.get(thinking_depth)
def get_flash_model_description(model_id: str) -> str:
@@ -720,83 +332,6 @@ def get_flash_model_description(model_id: str) -> str:
return config.get("description", "")
def get_flash_model_thinking_level_value(model_id: str, thinking_level: str) -> Optional[str]:
"""
获取指定模型在给定思考等级下应传入请求体的 thinkingLevel 值。
仅对 endpoint_type="standard" 且配置了 thinking_config 的模型有效。
返回 None 表示该等级不受支持,请求体中不应包含 thinkingConfig。
Args:
model_id: 模型 ID
thinking_level: 思考等级中文名(不思考/低/中/高)
Returns:
API thinkingLevel 值(如 "low"/"medium"/"high"),或 None(不传参)
Example:
>>> get_flash_model_thinking_level_value("gemini-3-pro-preview", "")
'low'
>>> get_flash_model_thinking_level_value("gemini-3-pro-preview", "")
None # 不受支持,省略 thinkingConfig
"""
config = get_flash_model_config(model_id)
if config is None:
return None
thinking_config = config.get("thinking_config")
if not thinking_config:
return None
return thinking_config.get(thinking_level)
# 已弃用:动态端点模式下不再需要这些函数
# def get_flash_model_thinking_levels(model_id: str) -> List[str]:
# """
# 获取 Flash 模型支持的思考等级列表
#
# Args:
# model_id: 模型 ID
#
# Returns:
# 思考等级列表(中文),如果未找到则返回空列表
#
# Example:
# >>> get_flash_model_thinking_levels("gemini-3-flash-preview")
# ['默认', '最低', '低', '中', '高']
# """
# config = get_flash_model_config(model_id)
# if config is None:
# return []
#
# thinking_levels = config.get("thinking_levels", {})
# return list(thinking_levels.keys())
# def get_thinking_level_value(model_id: str, thinking_level: str) -> Optional[str]:
# """
# 获取思考等级对应的 API 参数值
#
# Args:
# model_id: 模型 ID
# thinking_level: 思考等级(中文)
#
# Returns:
# API 参数值(英文),如果未找到则返回 None
#
# Example:
# >>> get_thinking_level_value("gemini-3-flash-preview", "默认")
# 'high'
# >>> get_thinking_level_value("gemini-3-flash-preview", "最低")
# 'minimal'
# """
# config = get_flash_model_config(model_id)
# if config is None:
# return None
#
# thinking_levels = config.get("thinking_levels", {})
# return thinking_levels.get(thinking_level)
# ============================================================
# 向后兼容性检查
# ============================================================
@@ -857,8 +392,8 @@ def validate_flash_models_config() -> None:
验证 Flash 模型配置的完整性
检查:
- 每个模型必须有 id, description, enabled 字段
- 每个模型必须有 endpoint 字段且格式正确
- 每个模型必须有 id, description, enabled, endpoints 字段
- endpoints 必须包含所有思考深度选项
- 至少有一个模型是启用的
Raises:
@@ -867,7 +402,8 @@ def validate_flash_models_config() -> None:
if not GEMINI_FLASH_MODELS:
raise ValueError("GEMINI_FLASH_MODELS 列表不能为空")
required_fields = ["id", "description", "enabled"]
required_fields = ["id", "description", "enabled", "endpoints"]
required_thinking_depths = ["不思考", ""]
for i, model in enumerate(GEMINI_FLASH_MODELS):
# 检查必需字段
@@ -875,16 +411,24 @@ def validate_flash_models_config() -> None:
if field not in model:
raise ValueError(f"Flash 模型 #{i} 缺少必需字段: {field}")
# 检查端点配置
if "endpoint" not in model:
raise ValueError(f"Flash 模型 {model['id']} 缺少 'endpoint' 字段")
# 检查 endpoints 字典
endpoints = model.get("endpoints", {})
if not isinstance(endpoints, dict):
raise ValueError(f"Flash 模型 {model['id']} 的 endpoints 必须是字典")
endpoint = model.get("endpoint", "")
if not endpoint or not endpoint.startswith("/v1beta/models/"):
raise ValueError(
f"Flash 模型 {model['id']} 的 endpoint '{endpoint}' 格式不正确。"
f"应以 '/v1beta/models/' 开头"
)
# 检查所有思考深度选项都有对应端点
for depth in required_thinking_depths:
if depth not in endpoints:
raise ValueError(
f"Flash 模型 {model['id']} 的 endpoints 缺少 '{depth}' 思考深度"
)
endpoint = endpoints[depth]
if not endpoint or not endpoint.startswith("/v1beta/models/"):
raise ValueError(
f"Flash 模型 {model['id']} 的端点 '{endpoint}' 格式不正确。"
f"应以 '/v1beta/models/' 开头"
)
# 检查至少有一个启用的模型
if not get_enabled_flash_models():
-13
View File
@@ -1,13 +0,0 @@
# 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`.
-396
View File
@@ -1,396 +0,0 @@
"""
K3 动作控制节点
用参考视频驱动参考图中人物动作,生成视频。
支持的模型:
- 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 as _stdio
import json
import os
import struct
import tempfile
import aiohttp
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,
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
# ── 常量 ──────────────────────────────────────────────────────────────────────
# 官方标准模型名映射
_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
# ── 工具函数 ───────────────────────────────────────────────────────────────────
# ── 视频时长检测(纯标准库,跨平台) ──────────────────────────────────────────
def _parse_video_duration(data: bytes) -> float | None:
"""从 MP4/MOV 原始字节解析时长(秒)。读取 mvhd box。"""
idx = data.find(b"mvhd")
if idx == -1:
return None
box = data[idx + 4:]
if len(box) < 32:
return None
version = box[0]
try:
if version == 0:
timescale = struct.unpack(">I", box[12:16])[0]
duration = struct.unpack(">I", box[16:20])[0]
else: # version == 1
timescale = struct.unpack(">I", box[20:24])[0]
duration = struct.unpack(">Q", box[24:32])[0]
except struct.error:
return None
return (duration / timescale) if timescale > 0 else None
def _get_video_duration(reference_video) -> float | None:
"""从 ComfyUI VIDEO 对象获取视频时长(秒),失败返回 None。"""
try:
source = reference_video.get_stream_source()
if isinstance(source, str) and os.path.isfile(source):
with open(source, "rb") as f:
data = f.read()
elif isinstance(source, _stdio.BytesIO):
source.seek(0)
data = source.read()
else:
return None
return _parse_video_duration(data)
except Exception:
return None
def _validate_video_duration(reference_video, character_orientation: str):
"""校验视频时长,超限时抛出 ValueError。解析失败时静默跳过。"""
duration = _get_video_duration(reference_video)
if duration is None:
print("[K3 动作控制] 无法解析视频时长,跳过校验。")
return
limit = 10 if character_orientation == "image" else 30
print(f"[K3 动作控制] 检测到视频时长: {duration:.2f}s(限制: 3~{limit}s")
if not (3 <= duration <= limit):
orientation_label = "图片" if character_orientation == "image" else "视频"
raise ValueError(
f"参考视频时长 {duration:.1f}s 不符合要求。\n"
f"角色朝向为「{orientation_label}」时,时长须在 3~{limit}s 之间。"
)
# ── 模型 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(io.ComfyNode):
"""K3 动作控制 自研 —— 用参考视频驱动参考图人物动作"""
@classmethod
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,
)
@classmethod
async def execute(cls, 参考图片, 参考视频, 模型, 提示词, 模式, 角色朝向, 保留原声, seed, **_kwargs) -> io.NodeOutput:
api_key = get_api_key_or_raise()
# ── 渠道判定(模型为 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",
}
if len(prompt) > 2500:
raise ValueError(f"提示词长度({len(prompt)})超过上限 2500 个字符,请缩短后重试。")
# ── 进度条 ────────────────────────────────────────────────────
try:
from comfy.utils import ProgressBar
pbar = ProgressBar(100)
except Exception:
pbar = None
def _stage(s: str):
if s == "uploading":
print("[K3 动作控制] 上传图片/视频到 OSS...")
if pbar: pbar.update_absolute(0, 100)
elif s == "submitting":
print("[K3 动作控制] 提交任务...")
if pbar: pbar.update_absolute(10, 100)
elif s.startswith("submitted:"):
print(f"[K3 动作控制] 任务已提交 → {s.split(':', 1)[1]}")
if pbar: pbar.update_absolute(15, 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(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(
f"参考视频时长 {_dur:.1f}s 超过所选时长 {时长}s。\n"
f"请将时长调整为 ≥{_dur:.0f}s 的档位,或更换更短的参考视频。"
)
# ── 图片 & 视频上传 OSS → 获取公网 URL ────────────────────────
_stage("uploading")
check_interrupt()
pil_list = tensor_to_pil(参考图片)
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(参考视频, base_url=base_url)
# ── 构建请求体 ────────────────────────────────────────────────
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_")
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}{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"),
headers=headers, prefix="K3 动作控制提交: "
))
check_interrupt()
text = await resp.text()
create_resp = json.loads(text)
# task_id 兼容扁平结构和 data 嵌套结构
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}{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:
text = await resp.text()
if resp.status != 200:
try:
err = json.loads(text)
msg = err.get("message") or text
except Exception:
msg = text
raise RuntimeError(f"状态查询失败 ({resp.status}): {msg}")
sr = json.loads(text)
# 兼容扁平结构和 data 嵌套结构
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_result_url = extract_video_url(sr)
break
elif is_failure_status(status, sr):
err_msg = extract_error_message(sr)
raise RuntimeError(f"K3 动作控制生成失败:{err_msg}")
interval = min(interval * 1.3, _POLL_MAX)
if not video_result_url:
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
# 3. 下载视频(抗超时 / 断点续传 / 无限重试 / 可取消)
check_interrupt()
_stage("downloading")
os.close(tmp_fd)
await download_video_to_file(
session, video_result_url, save_path, label="K3 动作控制",
)
_stage("done")
if _FOLDER_PATHS_OK:
return io.NodeOutput(InputImpl.VideoFromFile(save_path))
return io.NodeOutput(save_path)
# ── 节点注册 ──────────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {
"K3MotionControl": K3MotionControl,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"K3MotionControl": "K 动作模仿",
}
-1091
View File
File diff suppressed because it is too large Load Diff
+3 -37
View File
@@ -3,42 +3,8 @@
包含所有 ComfyUI 自定义节点的实现
"""
from .stream_preview import StreamPreview
from .nano_banana import NanoBanana
NanoBananaPro = NanoBanana
from .batch_nano_banana import BatchNanoBananaPro
from .nano_banana_pro import NanoBananaPro
from .batch_nano_banana_pro 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 .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 SeedanceMultiModal
from .doubao_image import DoubaoImage
from .gpt_image import O1keyGPTImage
from .gpt_image_batch import O1keyGPTImageBatch
from .grok_image import O1keyGrokImage
from .grok_video import O1keyGrokVideo, O1keyGrokVideoEdit
from .K3_video import K3Video
from .K3_motion_control import K3MotionControl
from .save_image_format import SaveImageFormat
from .save_psd import O1keySavePSD
from .remove_bg import O1keyRemoveBackground
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__ = ['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']
__all__ = ['NanoBananaPro', 'BatchNanoBananaPro', 'GoogleGemini']
-289
View File
@@ -1,289 +0,0 @@
"""
自动红偏校正。
纯 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)
-80
View File
@@ -1,80 +0,0 @@
"""
批量图像(o1key)节点
复刻 ComfyUI 原生「批量图像」节点的动态输入行为:
- 默认显示 2 个图像输入端口(图1, 图2)
- 当最后一个端口连上图像后,自动追加新端口
- 断开连线后,多余的端口自动消失,最少保留 2 个
与原生节点的区别:
原生节点会把所有图像强制 resize 到第一张的分辨率再合并为单一 tensor。
本节点保留每张图的原始分辨率,以 list[Tensor] 形式输出(is_output_list)。
下游节点(如「多分辨率图像预览」)需开启 INPUT_IS_LIST 才能正确接收。
实现方式:使用 V3 API 的 io.Autogrow.TemplateNames
框架原生支持动态 slot 增减,无需编写任何 JS 扩展。
"""
import torch
from comfy_api.latest import io
# 预生成 50 个端口名:图1, 图2, ..., 图50
_SLOT_NAMES = [f"{i}" for i in range(1, 51)]
class BatchImagesO1key(io.ComfyNode):
"""
批量图像(o1key
- 动态输入端口(默认 2 个,最多 50 个),端口名为 图1、图2、图3...
- 连接最后一个端口时自动增加新端口
- 断开后自动减少,保持界面整洁
- 保留每张图的原始分辨率,不做任何 resize / 裁剪
- 输出为图像列表,可直接接入「多分辨率图像预览」节点
"""
@classmethod
def define_schema(cls):
autogrow_template = io.Autogrow.TemplateNames(
input=io.Image.Input("image"),
names=_SLOT_NAMES,
min=2,
)
return io.Schema(
node_id="BatchImagesO1key",
display_name="加载图像(批量)",
category="image",
description=(
"将多个独立图像收集为图像列表输出,保留每张图的原始分辨率。\n"
"• 默认显示 2 个输入端口(图1、图2),连接最后一个后自动追加新端口\n"
"• 断开连线后端口自动减少,最少保留 2 个\n"
"• 不做任何 resize / 裁剪,原图尺寸原样输出\n"
"• 输出为图像列表,可直接接入「多分辨率图像预览」节点"
),
search_aliases=["批量图像", "batch images", "合并图像", "图像合并", "stack images"],
inputs=[
io.Autogrow.Input("images", template=autogrow_template)
],
outputs=[
io.Image.Output(display_name="图像", is_output_list=True),
],
)
@classmethod
def execute(cls, images: io.Autogrow.Type) -> io.NodeOutput:
# images 是 dictkey 为 "图1", "图2", ... ;未连接的 slot 值为 None
tensors = [v for v in images.values() if v is not None]
if not tensors:
raise ValueError("批量图像(o1key):请至少连接一张图像")
for i, t in enumerate(tensors):
h, w = t.shape[1], t.shape[2]
print(f"批量图像(o1key):图{i + 1}{w}×{h}shape={list(t.shape)}")
print(f"批量图像(o1key):共收集 {len(tensors)} 张,原始分辨率原样输出")
# 以 list[Tensor] 形式返回,每张图保持自身分辨率
return io.NodeOutput(tensors)
File diff suppressed because it is too large Load Diff
+751
View File
@@ -0,0 +1,751 @@
"""
批量 Nano Banana Pro 节点
ComfyUI 自定义节点,用于批量处理图像生成任务
支持多文件夹加载、1:1/笛卡尔积配对、智能命名保存
"""
import time
import math
import random
import asyncio
import aiohttp
from concurrent.futures import ThreadPoolExecutor
from typing import Optional, Tuple, List
from PIL import Image
import torch
import numpy as np
from ..utils.image_utils import tensor_to_pil, pil_to_tensor
from ..utils.file_utils import (
ImageInfo,
load_images_from_folder,
pair_images_indexed,
pair_images_cartesian,
generate_output_filename,
save_image
)
from ..clients.gemini_client import GeminiAPIClient
from ..models_config import get_enabled_models
# 导入 ComfyUI 原生进度条
try:
from comfy.utils import ProgressBar
PROGRESS_BAR_AVAILABLE = True
except ImportError:
PROGRESS_BAR_AVAILABLE = False
print("⚠️ BatchNanoBananaPro: comfy.utils.ProgressBar 不可用,将只使用终端进度显示")
class BatchNanoBananaPro:
"""
批量 Nano Banana Pro 节点
功能:
- 从多个文件夹加载图片
- 支持三种配对模式:
* 1:1 - 索引配对(文件夹之间按位置配对)
* 1*N - 笛卡尔积配对(所有可能组合)
* 不配对 - 固定参考图模式(文件夹图片依次与所有参考图组合)
- 批量调用 API 生成图像
- 智能命名保存(保留原始文件名)
- 并发控制(默认最大 100)
注意:
- 「不配对」模式只支持单个文件夹
- 支持的模型列表从 models_config.py 动态加载
- 要添加/禁用模型,请编辑 models_config.py 文件
"""
# 支持的模型列表(从配置文件动态加载)
MODELS = None # 将在 INPUT_TYPES 中动态获取
# 支持的宽高比列表
ASPECT_RATIOS = [
"1:1", "4:3", "3:4", "16:9", "9:16",
"2:3", "3:2", "4:5", "5:4", "21:9"
]
# 支持的分辨率列表
RESOLUTIONS = ["1K", "2K", "4K"]
# 配对模式
PAIRING_MODES = ["1:1", "1*N", "不配对"]
def __init__(self):
"""初始化节点"""
self.client = None
def resize_to_megapixels(
self,
image: Image.Image,
target_megapixels: float
) -> Image.Image:
"""
将图像缩放到指定的总像素数,保持纵横比
Args:
image: PIL Image 对象
target_megapixels: 目标像素数(百万像素)
Returns:
缩放后的 PIL Image
Example:
>>> resized = self.resize_to_megapixels(img, 2.0) # 缩放到2百万像素
"""
# 计算当前像素数
current_pixels = image.width * image.height
target_pixels = int(target_megapixels * 1_000_000)
# 如果当前像素数已经接近目标,则不缩放
if abs(current_pixels - target_pixels) / target_pixels < 0.05:
return image
# 计算缩放比例
scale = (target_pixels / current_pixels) ** 0.5
# 计算新尺寸
new_width = int(image.width * scale)
new_height = int(image.height * scale)
# 确保至少为1像素
new_width = max(1, new_width)
new_height = max(1, new_height)
# 使用 Lanczos 重采样
resized_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
return resized_image
@classmethod
def INPUT_TYPES(cls):
"""
定义输入参数
ComfyUI 节点规范:
- required: 必选参数
- optional: 可选参数
"""
# 从配置文件动态获取启用的模型列表
enabled_models = get_enabled_models()
# 如果没有启用的模型,使用空列表(会导致节点不可用,提示用户配置)
if not enabled_models:
enabled_models = ["请在 models_config.py 中启用至少一个模型"]
# 创建9个独立的图像输入
optional_inputs = {}
for i in range(1, 10): # 1-9
optional_inputs[f"参考图{i}"] = ("IMAGE",)
return {
"required": {
"prompt": ("STRING", {
"default": "一个中国女子的OOTD",
"multiline": True
}),
"模型": (enabled_models, {
"default": enabled_models[0]
}),
"宽高比": (cls.ASPECT_RATIOS, {
"default": "1:1"
}),
"分辨率": (cls.RESOLUTIONS, {
"default": "2K"
}),
"像素缩放": ("BOOLEAN", {
"default": False
}),
"分辨率像素": ("FLOAT", {
"default": 1.0,
"min": 0.1,
"max": 100.0,
"step": 0.1,
"display": "number"
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 0xffffffffffffffff
}),
"文件夹1": ("STRING", {
"default": "",
"multiline": False
}),
"文件夹2": ("STRING", {
"default": "",
"multiline": False
}),
"文件夹3": ("STRING", {
"default": "",
"multiline": False
}),
"文件夹4": ("STRING", {
"default": "",
"multiline": False
}),
"保存路径": ("STRING", {
"default": "",
"multiline": False
}),
"图片配对模式": (cls.PAIRING_MODES, {
"default": "不配对"
})
},
"optional": optional_inputs
}
# 返回值类型
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("输出图像",)
# 执行函数名
FUNCTION = "process_batch"
# 节点分类
CATEGORY = "image/batch"
def _load_folders(
self,
folder1: str,
folder2: Optional[str],
folder3: Optional[str],
folder4: Optional[str],
enable_scaling: bool,
target_megapixels: float
) -> List[List[ImageInfo]]:
"""
加载所有文件夹中的图片
Args:
folder1-4: 文件夹路径
enable_scaling: 是否启用像素缩放
target_megapixels: 目标像素数(百万像素)
Returns:
图片列表的列表
"""
folders = [folder1, folder2, folder3, folder4]
all_images = []
for i, folder in enumerate(folders, 1):
if folder and folder.strip():
try:
images = load_images_from_folder(folder)
if images:
# 应用像素缩放
if enable_scaling:
scaled_images = []
for img_info in images:
scaled_img = self.resize_to_megapixels(
img_info.image,
target_megapixels
)
# 创建新的 ImageInfo,保留其他元数据
scaled_info = ImageInfo(
image=scaled_img,
filename=img_info.filename,
extension=img_info.extension,
source_path=img_info.source_path
)
scaled_images.append(scaled_info)
images = scaled_images
all_images.append(images)
print(f"BatchNanoBananaPro: 文件夹{i} 加载了 {len(images)} 张图片")
else:
print(f"BatchNanoBananaPro: 文件夹{i} 为空或没有有效图片")
except ValueError as e:
print(f"BatchNanoBananaPro: 文件夹{i} 加载失败 - {e}")
return all_images
def _create_pairs(
self,
image_lists: List[List[ImageInfo]],
pairing_mode: str,
manual_images: Optional[List[ImageInfo]] = None
) -> List[Tuple[ImageInfo, ...]]:
"""
根据配对模式创建图片组合
Args:
image_lists: 从文件夹加载的图片列表
pairing_mode: 配对模式 (1:1, 1*N, 不配对)
manual_images: 手动输入的参考图
Returns:
配对后的元组列表
Raises:
ValueError: 不配对模式下填入多个文件夹时
"""
# === 新模式:不配对 ===
if pairing_mode == "不配对":
# 验证:只支持单个文件夹
if len(image_lists) > 1:
raise ValueError("「不配对」模式只支持单个文件夹,请清空其他文件夹路径")
# 场景1:有文件夹 + 有参考图
if image_lists and manual_images:
folder_images = image_lists[0]
# 每张文件夹图片 + 所有参考图
pairs = []
for img in folder_images:
pair = (img,) + tuple(manual_images)
pairs.append(pair)
return pairs
# 场景2:有文件夹 + 无参考图
elif image_lists:
# 每张图片单独成组
return [(img,) for img in image_lists[0]]
# 场景3:无文件夹 + 有参考图
elif manual_images:
# 每张参考图单独成组
return [(img,) for img in manual_images]
else:
return []
# === 原有逻辑:1:1 和 1*N ===
# 如果有手动参考图,添加到列表中(所有参考图作为一个列表)
if manual_images:
image_lists.append(manual_images)
if not image_lists:
return []
# 如果只有一个列表,直接返回每个图片作为单元素元组
if len(image_lists) == 1:
return [(img,) for img in image_lists[0]]
# 根据配对模式选择配对函数
if pairing_mode == "1:1":
pairs = pair_images_indexed(*image_lists)
else: # 1*N
pairs = pair_images_cartesian(*image_lists)
return pairs
async def _generate_single_task(
self,
client: GeminiAPIClient,
session: aiohttp.ClientSession,
prompt: str,
model: str,
resolution: str,
aspect_ratio: str,
images: List[ImageInfo],
output_folder: str,
task_index: int
) -> dict:
"""
执行单个生成任务
Args:
client: API 客户端
session: aiohttp 会话
prompt: 提示词
model: 模型名称
resolution: 分辨率
aspect_ratio: 宽高比
images: 输入图片列表
output_folder: 输出文件夹
task_index: 任务索引
Returns:
包含结果信息的字典
"""
result = {
"task_index": task_index,
"success": False,
"generated_count": 0,
"saved_files": [],
"error": None
}
try:
# 准备输入图片
input_pil_images = [info.image for info in images]
# 调用 API 生成图片(固定生成1次)
generated_images = []
try:
gen_result = await client.generate_single_async(
prompt=prompt,
model=model,
resolution=resolution,
aspect_ratio=aspect_ratio,
images=input_pil_images,
session=session
)
if gen_result:
generated_images.extend(gen_result)
except Exception as e:
error_msg = str(e)
print(f"BatchNanoBananaPro: 任务 {task_index + 1} 生成失败 - {error_msg}")
result["error"] = error_msg
# 保存生成的图片
for i, gen_img in enumerate(generated_images):
# 使用任务索引作为唯一标识,确保并发安全
output_path = generate_output_filename(
source_images=list(images),
batch_index=i,
output_folder=output_folder,
extension=".png",
task_id=f"task{task_index}"
)
save_image(gen_img, output_path)
result["saved_files"].append(output_path)
# 只有生成了图片才标记为成功
if len(generated_images) > 0:
result["success"] = True
result["generated_count"] = len(generated_images)
except Exception as e:
result["error"] = str(e)
return result
async def _process_batch_async(
self,
pairs: List[Tuple[ImageInfo, ...]],
prompt: str,
model: str,
resolution: str,
aspect_ratio: str,
output_folder: str,
pbar=None
) -> List[dict]:
"""
异步批量处理所有任务
Args:
pairs: 配对后的图片组合
prompt: 提示词
model: 模型名称
resolution: 分辨率
aspect_ratio: 宽高比
output_folder: 输出文件夹
Returns:
所有任务的结果列表
"""
if self.client is None:
self.client = GeminiAPIClient()
# 固定最大并发数为 100
max_concurrent = 100
total_tasks = len(pairs)
all_results = []
completed = 0
success_count = 0
fail_count = 0
# 计算分批数量
num_batches = math.ceil(total_tasks / max_concurrent)
# 进度打印配置:任务数 >= 50 时,额外显示百分比里程碑
show_milestone = total_tasks >= 50
milestones = [0.2, 0.4, 0.6, 0.8, 1.0] # 20%, 40%, 60%, 80%, 100%
milestone_index = 0
if num_batches > 1:
print(f"BatchNanoBananaPro: 任务数 {total_tasks} 超过并发上限 {max_concurrent},将分 {num_batches} 批执行")
connector = aiohttp.TCPConnector(limit=0, limit_per_host=0)
async with aiohttp.ClientSession(connector=connector) as session:
for batch_idx in range(num_batches):
start_idx = batch_idx * max_concurrent
end_idx = min(start_idx + max_concurrent, total_tasks)
batch_pairs = pairs[start_idx:end_idx]
if num_batches > 1:
print(f"BatchNanoBananaPro: 执行第 {batch_idx + 1}/{num_batches} 批 ({start_idx + 1}-{end_idx})...")
# 创建当前批次的任务
tasks = []
for i, pair in enumerate(batch_pairs):
task = asyncio.create_task(
self._generate_single_task(
client=self.client,
session=session,
prompt=prompt,
model=model,
resolution=resolution,
aspect_ratio=aspect_ratio,
images=list(pair),
output_folder=output_folder,
task_index=start_idx + i
)
)
tasks.append(task)
# 使用 as_completed 实时获取完成的任务
for coro in asyncio.as_completed(tasks):
result_data = None
try:
result = await coro
if isinstance(result, Exception):
result_data = {
"success": False,
"error": str(result),
"generated_count": 0,
"saved_files": []
}
all_results.append(result_data)
else:
result_data = result
all_results.append(result)
except Exception as e:
result_data = {
"success": False,
"error": str(e),
"generated_count": 0,
"saved_files": []
}
all_results.append(result_data)
completed += 1
# 根据成功/失败状态打印不同信息
if result_data and result_data.get("success", False):
success_count += 1
print(f"BatchNanoBananaPro: 任务 {completed}/{total_tasks} 成功 ✓")
else:
fail_count += 1
# 提取错误信息的第一行
error_msg = result_data.get("error", "未知错误") if result_data else "未知错误"
# 截取第一行或前50个字符
if '\n' in error_msg:
error_msg = error_msg.split('\n')[0]
if len(error_msg) > 50:
error_msg = error_msg[:50] + "..."
print(f"BatchNanoBananaPro: 任务 {completed}/{total_tasks} 失败 ✗ - {error_msg}")
# 更新 ComfyUI 原生进度条
if pbar is not None:
pbar.update(1)
# 大任务额外显示百分比里程碑
if show_milestone and milestone_index < len(milestones):
progress = completed / total_tasks
if progress >= milestones[milestone_index]:
percentage = int(milestones[milestone_index] * 100)
print(f"BatchNanoBananaPro: >>> 进度 {percentage}% <<<")
milestone_index += 1
return all_results
def process_batch(
self,
prompt: str,
文件夹1: str,
文件夹2: str,
文件夹3: str,
文件夹4: str,
像素缩放: bool,
分辨率像素: float,
seed: int,
保存路径: str,
图片配对模式: str,
模型: str,
宽高比: str,
分辨率: str,
**kwargs
) -> Tuple[torch.Tensor]:
"""
批量处理图像生成任务
Args:
prompt: 提示词
文件夹1-4: 图片文件夹路径
像素缩放: 是否启用像素缩放
分辨率像素: 目标像素数(百万像素)
seed: 随机种子
保存路径: 输出保存路径
图片配对模式: 1:1 或 1*N
模型: 模型名称
宽高比: 输出宽高比
分辨率: 输出分辨率
**kwargs: 动态参考图输入 (参考图1-9)
Returns:
输出图像张量
"""
start_time = time.time()
try:
# 设置随机种子(用于本地随机操作)
random.seed(seed)
np.random.seed(seed % (2**32))
# 验证保存路径
if not 保存路径 or not 保存路径.strip():
raise ValueError("请提供保存路径")
# 加载文件夹图片
print("BatchNanoBananaPro: 开始加载图片...")
image_lists = self._load_folders(
文件夹1, 文件夹2, 文件夹3, 文件夹4,
像素缩放, 分辨率像素
)
# 处理独立的参考图输入
manual_images = []
for i in range(1, 10): # 1-9
key = f"参考图{i}"
if key in kwargs and kwargs[key] is not None:
pil_images = tensor_to_pil(kwargs[key])
for j, img in enumerate(pil_images):
# 如果启用像素缩放,也对参考图进行缩放
if 像素缩放:
img = self.resize_to_megapixels(img, 分辨率像素)
manual_images.append(
ImageInfo(
image=img,
filename=f"manual_{i}_{j}",
extension=".png",
source_path=""
)
)
if manual_images:
print(f"BatchNanoBananaPro: 加载了 {len(manual_images)} 张参考图")
# 验证是否有图片
total_folder_images = sum(len(lst) for lst in image_lists)
total_manual_images = len(manual_images)
if total_folder_images == 0 and total_manual_images == 0:
raise ValueError("未找到任何图片,请检查文件夹路径或提供参考图")
# 创建配对
print(f"BatchNanoBananaPro: 使用 {图片配对模式} 模式创建配对...")
pairs = self._create_pairs(image_lists, 图片配对模式, manual_images if manual_images else None)
if not pairs:
raise ValueError("配对结果为空,请检查输入")
total_tasks = len(pairs)
print(f"BatchNanoBananaPro: 共 {total_tasks} 组配对")
# 创建 ComfyUI 原生进度条
pbar = None
if PROGRESS_BAR_AVAILABLE:
pbar = ProgressBar(total_tasks)
# 初始化 API 客户端
if self.client is None:
try:
self.client = GeminiAPIClient()
except ValueError as e:
raise ValueError(f"初始化 API 客户端失败: {str(e)}")
# 执行批量生成
print("BatchNanoBananaPro: 开始批量生成...")
# 在新线程中运行异步代码,避免事件循环冲突
def run_async_in_thread():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(
self._process_batch_async(
pairs=pairs,
prompt=prompt,
model=模型,
resolution=分辨率,
aspect_ratio=宽高比,
output_folder=保存路径,
pbar=pbar
)
)
finally:
loop.close()
# 使用线程池在新线程中运行事件循环
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(run_async_in_thread)
results = future.result()
# 统计结果
success_count = sum(1 for r in results if r.get("success", False))
fail_count = len(results) - success_count
total_generated = sum(r.get("generated_count", 0) for r in results)
all_saved_files = []
for r in results:
all_saved_files.extend(r.get("saved_files", []))
elapsed = time.time() - start_time
# 精简统计信息
print("=" * 50)
print(f"BatchNanoBananaPro 处理完成 | 总耗时: {elapsed:.2f}s | 成功: {success_count}/{total_tasks} | 生成: {total_generated}")
print(f"保存路径: {保存路径}")
# 失败详情(如果有)
failed_results = [r for r in results if not r.get("success", False)]
if failed_results:
# 收集失败任务的索引
failed_indices = [str(r.get('task_index', '?') + 1) for r in failed_results[:5]]
failed_str = ",".join(failed_indices)
if len(failed_results) > 5:
failed_str += f"... (共{len(failed_results)}个)"
# 显示第一个失败原因作为示例
first_error = failed_results[0].get('error', '未知错误')
print(f"失败 {len(failed_results)}个: 任务{failed_str} - {first_error}")
# 收集所有生成的图片
output_images = []
for file_path in all_saved_files:
try:
img = Image.open(file_path)
output_images.append(img)
except Exception as e:
print(f"BatchNanoBananaPro: 无法加载图片 {file_path} - {e}")
# 如果没有生成成功的图片,创建一个占位图
if not output_images:
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
output_images = [placeholder]
# 转换为张量
output_tensor = pil_to_tensor(output_images)
return (output_tensor,)
except ValueError as e:
# 检测是否为授权错误
if str(e) == "未授权!":
print("请联系作者授权后方可使用!")
else:
print(f"BatchNanoBananaPro: 输入错误 - {str(e)}")
raise
except RuntimeError as e:
print(f"BatchNanoBananaPro: 运行时错误 - {str(e)}")
raise
except Exception as e:
print(f"BatchNanoBananaPro: 未知错误 - {str(e)}")
raise
finally:
# 无论成功或失败,都尝试查询余额
if self.client is not None:
try:
balance_data = self.client.query_balance_sync()
balance_info = self.client.format_balance_info(balance_data)
print(f"{balance_info}")
print("=" * 50)
except Exception as e:
print(f"⚠️ 余额查询失败 - {str(e)}")
print("=" * 50)
-420
View File
@@ -1,420 +0,0 @@
"""
豆包生图节点
后端通过 new-api 兼容层调用豆包官方 API
"""
import asyncio
import time
import numpy as np
import torch
from concurrent.futures import ThreadPoolExecutor
from PIL import Image
from typing import List, Optional
from ..clients.doubao_image_client import DoubaoImageClient
from ..utils.image_utils import tensor_to_pil
# ── 模型列表 ──────────────────────────────────────────────────────────────────
_MODELS = [
"doubao-seedream-5-0-260128",
"doubao-seedream-4-5-251128",
]
# ── 宽高比列表 ─────────────────────────────────────────────────────────────────
_ASPECT_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9"]
# ── 分辨率档位(每个模型支持的档位不同)──────────────────────────────────────
# 5.02K / 3K
# 4.52K / 4K
_RESOLUTIONS = ["2K", "3K", "4K"]
# ── 像素对照表 ─────────────────────────────────────────────────────────────────
# 结构:{ 模型版本key: { 分辨率: { 宽高比: (宽, 高) } } }
_SIZE_TABLE = {
"5-0": {
"2K": {
"1:1": (2048, 2048),
"4:3": (2304, 1728),
"3:4": (1728, 2304),
"16:9": (2848, 1600),
"9:16": (1600, 2848),
"3:2": (2496, 1664),
"2:3": (1664, 2496),
"21:9": (3136, 1344),
},
"3K": {
"1:1": (3072, 3072),
"4:3": (3456, 2592),
"3:4": (2592, 3456),
"16:9": (4096, 2304),
"9:16": (2304, 4096),
"3:2": (3744, 2496),
"2:3": (2496, 3744),
"21:9": (4704, 2016),
},
},
"4-5": {
"2K": {
"1:1": (2048, 2048),
"4:3": (2304, 1728),
"3:4": (1728, 2304),
"16:9": (2848, 1600),
"9:16": (1600, 2848),
"3:2": (2496, 1664),
"2:3": (1664, 2496),
"21:9": (3136, 1344),
},
"4K": {
"1:1": (4096, 4096),
"4:3": (4704, 3520),
"3:4": (3520, 4704),
"16:9": (5504, 3040),
"9:16": (3040, 5504),
"3:2": (4992, 3328),
"2:3": (3328, 4992),
"21:9": (6240, 2656),
},
},
}
# 每个模型版本支持的分辨率档位
_MODEL_RESOLUTIONS = {
"5-0": ["2K", "3K"],
"4-5": ["2K", "4K"],
}
# 并发请求超时(秒)
_CONCURRENT_TIMEOUT = 330
def _model_key(model: str) -> str:
"""从模型 ID 中提取版本 key'5-0''4-5')。"""
for key in _SIZE_TABLE:
if key in model:
return key
raise ValueError(f"无法识别模型版本:{model},支持的模型:{_MODELS}")
def _pil_list_to_tensor(images: List[Image.Image]) -> torch.Tensor:
"""
PIL Image 列表 → ComfyUI IMAGE tensor [B, H, W, C],值域 [0, 1]。
多张尺寸不同时,以最大尺寸为准,较小图像丢弃。
"""
if not images:
placeholder = Image.new("RGB", (512, 512), color=(128, 128, 128))
images = [placeholder]
base_size = max(images, key=lambda img: img.size[0] * img.size[1]).size
matched = [img for img in images if img.size == base_size]
skipped = len(images) - len(matched)
if skipped:
print(f"[豆包生图] 丢弃 {skipped} 张非最大尺寸图像,仅输出 {base_size[0]}×{base_size[1]}{len(matched)}")
tensors = []
for img in matched:
arr = np.array(img.convert("RGB")).astype(np.float32) / 255.0
tensors.append(torch.from_numpy(arr))
return torch.stack(tensors, dim=0) # [B, H, W, C]
class DoubaoImage:
"""豆包生图 —— 通过宽高比 + 分辨率档位选择尺寸,后端自动换算真实像素"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"模型": (
_MODELS,
{"default": _MODELS[0]},
),
"提示词": (
"STRING",
{
"multiline": True,
"default": "",
"tooltip": "用于创建或编辑图像的文本提示",
},
),
"宽高比": (
_ASPECT_RATIOS,
{
"default": "1:1",
"tooltip": "图像宽高比。所有分辨率档位均支持这些比例",
},
),
"分辨率": (
_RESOLUTIONS,
{
"default": "2K",
"tooltip": (
"图像分辨率档位。\n"
"• Seedream 5.0:支持 2K / 3K\n"
"• Seedream 4.5:支持 2K / 4K\n"
"3K 与 4.5 或 4K 与 5.0 搭配时将报错)"
),
},
),
"生图数量": (
"INT",
{
"default": 1,
"min": 1,
"max": 10,
"step": 1,
"tooltip": "生成图像的数量。2-10 张时自动并发请求,加快出图速度",
},
),
"种子": (
"INT",
{
"default": 0,
"min": 0,
"max": 2147483647,
"step": 1,
"control_after_generate": True,
"tooltip": "用于生成的随机种子",
},
),
"部分失败时停止": (
"BOOLEAN",
{
"default": True,
"tooltip": (
"启用时:任意一张失败即抛出错误并中止。\n"
"禁用时:返回已成功生成的图像,忽略失败项"
),
},
),
},
"optional": {
"图像": (
"IMAGE",
{
"tooltip": (
"用于图生图的输入图像。"
"单参考或多参考生成时,可输入1-10张图像列表"
),
},
),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("图像",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/豆包"
# ── 并发核心:在新 event loop 里 gather N 个 _generate_async ─────────────
async def _run_concurrent(
self,
client: DoubaoImageClient,
生图数量: int,
model: str,
prompt: str,
size: str,
seed: int,
image_tensor,
pbar,
) -> List[dict]:
"""
并发发起 生图数量 个独立请求,每完成一个推进一格进度条。
返回结果列表:[{"index": int, "images": [...], "error": str|None}]
"""
# 固定参数(顺序生成功能暂时隐藏)
seq = "disabled"
max_img = 1
async def _one(idx: int) -> dict:
try:
imgs = await client._generate_async(
model=model,
prompt=prompt,
size=size,
seed=seed,
sequential_image_generation=seq,
max_images=max_img,
image_tensor=image_tensor,
)
return {"index": idx, "images": imgs, "error": None}
except Exception as e:
return {"index": idx, "images": [], "error": str(e)}
# 用 as_completed 方式逐个推进进度条
tasks = [asyncio.create_task(_one(i)) for i in range(生图数量)]
results = [None] * 生图数量
completed = 0
for coro in asyncio.as_completed(tasks):
res = await coro
results[res["index"]] = res
completed += 1
status = "" if res["error"] is None else f"{res['error']}"
print(f"[豆包生图] [{completed}/{生图数量}] 第 {res['index'] + 1} 张 → {status}")
if pbar is not None:
pbar.update(1)
return results
# ── 节点主入口 ────────────────────────────────────────────────────────────
def generate(
self,
模型: str,
提示词: str,
宽高比: str,
分辨率: str,
生图数量: int,
种子: int,
部分失败时停止: bool,
图像=None,
):
start_time = time.time()
# 顺序图像生成功能暂时隐藏,固定使用默认值
顺序图像生成 = "disabled"
最大图片数 = 1
# ── 1. 校验提示词 ─────────────────────────────────────────────────────
if not 提示词.strip():
raise ValueError("提示词不能为空,请输入图像描述后重试。")
# ── 2. 解析模型版本并校验分辨率兼容性 ────────────────────────────────
try:
mkey = _model_key(模型)
except ValueError as e:
raise ValueError(str(e)) from None
supported = _MODEL_RESOLUTIONS[mkey]
if 分辨率 not in supported:
raise ValueError(
f"模型 {模型} 不支持 {分辨率} 分辨率。\n"
f"该模型支持:{' / '.join(supported)}"
)
# ── 3. 查表换算真实像素 ───────────────────────────────────────────────
w, h = _SIZE_TABLE[mkey][分辨率][宽高比]
size_str = f"{w}x{h}"
# ── 4. 打印概要 ───────────────────────────────────────────────────────
mode_str = "图生图" if 图像 is not None else "文生图"
print(
f"[豆包生图] {mode_str} | 模型={模型} | {分辨率} {宽高比}{size_str}"
f" | 数量={生图数量} | 种子={种子}"
)
# ── 5. 初始化客户端 ───────────────────────────────────────────────────
try:
client = DoubaoImageClient()
except ValueError as e:
raise ValueError(str(e)) from None
# ── 6. 进度条(按张数计)──────────────────────────────────────────────
try:
from comfy.utils import ProgressBar
pbar = ProgressBar(生图数量)
except Exception:
pbar = None
# ── 7. 单张 / 多张分支 ────────────────────────────────────────────────
if 生图数量 == 1:
# 单张:走原有同步路径
try:
pil_images: List[Image.Image] = client.generate_sync(
model=模型,
prompt=提示词,
size=size_str,
seed=种子,
sequential_image_generation=顺序图像生成,
max_images=最大图片数,
image_tensor=图像,
)
except RuntimeError as e:
raise RuntimeError(str(e)) from None
except Exception as e:
raise RuntimeError(f"豆包生图请求失败: {e}") from None
if pbar is not None:
pbar.update(1)
else:
# 多张:并发请求
def _run_in_thread():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(
self._run_concurrent(
client=client,
生图数量=生图数量,
model=模型,
prompt=提示词,
size=size_str,
seed=种子,
image_tensor=图像,
pbar=pbar,
)
)
finally:
loop.close()
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_run_in_thread)
try:
results = future.result(timeout=_CONCURRENT_TIMEOUT)
except TimeoutError:
raise RuntimeError(
f"并发生图超时(>{_CONCURRENT_TIMEOUT}s),请检查网络或减少生图数量"
)
# 统计成功 / 失败
success_results = [r for r in results if r and r["error"] is None]
failed_results = [r for r in results if r and r["error"] is not None]
if failed_results:
fail_info = "".join(
f"{r['index']+1}张: {r['error']}" for r in failed_results
)
if 部分失败时停止:
raise RuntimeError(
f"{len(failed_results)}/{生图数量} 张生成失败:{fail_info}\n"
"(可将【部分失败时停止】设为 False 以返回已成功的图像)"
)
else:
print(f"[豆包生图] 警告:{len(failed_results)}/{生图数量} 张失败,已忽略:{fail_info}")
if not success_results:
raise RuntimeError("所有图像均生成失败,请检查网络或 API 配置。")
# 按原始 index 排序,展平为 PIL 列表
success_results.sort(key=lambda r: r["index"])
pil_images = []
for r in success_results:
pil_images.extend(r["images"])
# ── 8. PIL → tensor ───────────────────────────────────────────────────
output_tensor = _pil_list_to_tensor(pil_images)
# ── 9. 完成日志 ───────────────────────────────────────────────────────
elapsed = time.time() - start_time
print(
f"[豆包生图] 完成!耗时 {elapsed:.1f}s"
f"输出 {output_tensor.shape[0]}"
f"{output_tensor.shape[2]}×{output_tensor.shape[1]}"
)
return (output_tensor,)
# ── 节点注册 ──────────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {
"DoubaoImage": DoubaoImage,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DoubaoImage": "豆包生图",
}
-174
View File
@@ -1,174 +0,0 @@
"""
Flux2 图像编辑节点
通过 api.o1key.cn 调用 Flux2 + SeedVR2 远程服务进行图像编辑和超分辨率
功能:
- 接收主图和参考图
- 上传到远程服务器执行图像编辑
- 轮询等待 SeedVR2 超分辨率结果
- 返回最终放大后的图像
"""
import time
from io import BytesIO
from typing import Tuple
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
class FluxImageEdit:
"""
Flux2 图像编辑节点
通过远程 API 将主图与参考图结合,按照提示词进行图像编辑,
并经 SeedVR2 超分辨率放大后返回最终结果。
"""
SIZES = ["2K", "4K"]
def __init__(self):
self.client = None
self._client_config_signature = None
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"主图": ("IMAGE",),
"参考图": ("IMAGE",),
"提示词": ("STRING", {
"default": "Replace the woman's underwear in Figure 1 with the strapless bra in Figure 2",
"multiline": True,
}),
"分辨率": (cls.SIZES, {
"default": "4K",
}),
"轮询间隔": ("INT", {
"default": 15,
"min": 5,
"max": 60,
"step": 5,
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 0xffffffffffffffff,
}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("输出图像",)
FUNCTION = "generate"
CATEGORY = "image/edit"
def _image_to_jpeg_bytes(self, image: Image.Image, quality: int = 92) -> bytes:
"""将 PIL Image 转为 JPEG 二进制"""
if image.mode in ("RGBA", "P", "LA"):
image = image.convert("RGB")
buf = BytesIO()
image.save(buf, format="JPEG", quality=quality)
return buf.getvalue()
def generate(
self,
主图: torch.Tensor,
参考图: torch.Tensor,
提示词: str,
分辨率: str,
轮询间隔: int,
seed: int,
) -> Tuple[torch.Tensor]:
"""
执行图像编辑
Args:
主图: 要编辑的原始图像 (ComfyUI tensor, [B, H, W, C])
参考图: 参考/风格图像 (ComfyUI tensor, [B, H, W, C])
提示词: 编辑指令
分辨率: 超分辨率目标 ("2K""4K",会自动映射为 2048/4096)
轮询间隔: 轮询秒数
seed: 随机种子
Returns:
输出图像 tensor (IMAGE,)
"""
start_time = time.time()
try:
# 初始化客户端
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(主图)
ref_pils = tensor_to_pil(参考图)
if not main_pils:
raise ValueError("主图不能为空")
if not ref_pils:
raise ValueError("参考图不能为空")
main_img = main_pils[0]
ref_img = ref_pils[0]
# PIL → JPEG bytes
main_bytes = self._image_to_jpeg_bytes(main_img)
ref_bytes = self._image_to_jpeg_bytes(ref_img)
print(f"Flux Edit: 开始处理 | 主图 {main_img.size} | 参考图 {ref_img.size} | 分辨率 {分辨率} | seed {seed}")
# 进度回调
def progress_callback(status_str: str):
print(f"Flux Edit: {status_str}")
# 提交任务并等待结果
result_bytes = self.client.submit_and_wait(
image_bytes=main_bytes,
mask_bytes=ref_bytes,
prompt=提示词,
size=分辨率,
poll_interval=轮询间隔,
progress_callback=progress_callback,
)
# 解码结果
result_img = Image.open(BytesIO(result_bytes))
if result_img.mode != "RGB":
result_img = result_img.convert("RGB")
print(f"Flux Edit: 结果图像尺寸 {result_img.size}")
# 转为 tensor
output_tensor = pil_to_tensor([result_img])
# 打印耗时
elapsed = time.time() - start_time
if elapsed < 60:
time_str = f"{elapsed:.1f}s"
else:
minutes = int(elapsed // 60)
seconds = elapsed % 60
time_str = f"{minutes}m {seconds:.0f}s"
print(f"Flux Edit: 完成!总耗时 {time_str}")
return (output_tensor,)
except ValueError as e:
if str(e) == "未授权!":
print("请联系作者授权后方可使用!")
raise ValueError("未授权!") from None
print(f"Flux Edit: ❌ {e}")
raise
except Exception as e:
error_msg = str(e)
print(f"Flux Edit: ❌ {error_msg}")
raise RuntimeError(error_msg) from None
+70 -421
View File
@@ -6,50 +6,29 @@ ComfyUI 自定义节点,用于调用 Gemini Flash 模型进行多模态文本生
import base64
import os
import time
import tempfile
from typing import Dict, List, Optional, Tuple
from io import BytesIO
import torch
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
# 文件大小限制(20MB
MAX_FILE_SIZE = 20 * 1024 * 1024
# 图片缩放后最大尺寸(1K分辨率 = 1024像素)
MAX_IMAGE_DIMENSION = 1024
# 视频压缩目标大小(1-10MB
TARGET_VIDEO_SIZE_MIN = 1 * 1024 * 1024
TARGET_VIDEO_SIZE_MAX = 10 * 1024 * 1024
# 支持的视频 MIME 类型映射
VIDEO_MIME_TYPES = {
".mp4": "video/mp4",
".mpeg": "video/mpeg",
".mpg": "video/mpg",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".mov": "video/mov",
".avi": "video/avi",
".flv": "video/x-flv",
".webm": "video/webm",
".wmv": "video/x-ms-wmv",
".wmv": "video/wmv",
".3gp": "video/3gpp",
".3gpp": "video/3gpp"
}
try:
import subprocess
FFMPEG_AVAILABLE = True
except ImportError:
FFMPEG_AVAILABLE = False
class GoogleGemini:
"""
@@ -57,18 +36,18 @@ class GoogleGemini:
功能:
- 支持多个 Gemini Flash 模型
- 支持图片视频和文件输入
- 支持不同思考等级(不思考/低/中/高)- 通过 thinkingConfig.thinkingLevel 控制
- 输出生成的文本内容(主要内容 + 思考内容
- 支持图片视频输入
- 支持系统指令
- 支持不同思考深度(不思考/高
- 输出生成的文本内容
"""
# 支持的思考等级选项
THINKING_LEVELS = ["不思考", "", "", ""]
# 支持的思考深度选项
THINKING_DEPTHS = ["不思考", ""]
def __init__(self):
"""初始化节点"""
self.client = None
self._client_config_signature = None
@classmethod
def INPUT_TYPES(cls):
@@ -88,20 +67,23 @@ class GoogleGemini:
"default": "",
"multiline": True
}),
"思考等级": (cls.THINKING_LEVELS, {
"思考深度": (cls.THINKING_DEPTHS, {
"default": "不思考"
})
},
"optional": {
"系统指令": ("STRING", {
"default": "",
"multiline": True
}),
"图片": ("IMAGE",),
"视频": ("VIDEO",),
"文件": ("FILE",)
"视频": ("VIDEO",)
}
}
# 返回值类型
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("主要内容",)
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("主要内容", "思考内容")
# 执行函数名
FUNCTION = "generate"
@@ -112,66 +94,6 @@ class GoogleGemini:
# 允许输出到 UI
OUTPUT_NODE = True
def _resize_image_if_needed(self, img: Image.Image) -> Image.Image:
"""
如果图片过大,缩放到1K分辨率
Args:
img: PIL Image 对象
Returns:
缩放后的 PIL Image
"""
width, height = img.size
max_dim = max(width, height)
if max_dim > MAX_IMAGE_DIMENSION:
# 计算缩放比例
scale = MAX_IMAGE_DIMENSION / max_dim
new_width = int(width * scale)
new_height = int(height * scale)
print(f"Google Gemini: 图片尺寸 {width}x{height} 超过限制,缩放至 {new_width}x{new_height}")
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
return img
def _check_and_compress_image(self, img: Image.Image) -> str:
"""
检查图片大小,如果超过20MB则进行压缩
Args:
img: PIL Image 对象
Returns:
base64 编码的字符串
"""
# 先进行尺寸缩放(如果需要)
img = self._resize_image_if_needed(img)
# 尝试不同的压缩质量
qualities = [95, 85, 75, 65, 55, 45]
for quality in qualities:
buffer = BytesIO()
# 转换为RGB模式(去除alpha通道)以减小体积
if img.mode in ('RGBA', 'P'):
img_rgb = img.convert('RGB')
else:
img_rgb = img
img_rgb.save(buffer, format='JPEG', quality=quality, optimize=True)
buffer.seek(0)
data = buffer.getvalue()
if len(data) <= MAX_FILE_SIZE:
print(f"Google Gemini: 图片压缩后大小 {len(data) / 1024 / 1024:.2f}MB (质量{quality})")
return base64.b64encode(data).decode('utf-8')
# 如果所有质量都无法满足,使用最低质量
print(f"Google Gemini: 警告 - 即使最低质量仍超过20MB,将使用最低质量发送")
return base64.b64encode(data).decode('utf-8')
def _prepare_image_data(
self,
images: Optional[torch.Tensor]
@@ -179,8 +101,6 @@ class GoogleGemini:
"""
准备图片数据
如果图片超过20MB,会自动进行缩放和压缩
Args:
images: ComfyUI 图片张量 [B, H, W, C]
@@ -190,183 +110,17 @@ class GoogleGemini:
if images is None:
return None
image_data = []
pil_images = tensor_to_pil(images)
if not pil_images:
return None
# 将所有图片转为 RGB PIL Image 并首次编码
processed = [] # [(pil_img_rgb, b64_data, mime_type)]
for img in pil_images:
buffer = BytesIO()
img.save(buffer, format='PNG')
original_size = buffer.tell()
buffer.close()
if original_size > MAX_FILE_SIZE:
print(f"Google Gemini: 检测到图片过大 ({original_size / 1024 / 1024:.2f}MB),正在进行压缩...")
img_rgb = img.convert('RGB') if img.mode != 'RGB' else img.copy()
b64_str = self._check_and_compress_image(img_rgb)
processed.append((img_rgb, b64_str, "image/jpeg"))
else:
b64_str = encode_image_to_base64(img)
processed.append((None, b64_str, "image/png"))
b64_str = encode_image_to_base64(img)
image_data.append({
"mime_type": "image/png",
"data": b64_str
})
# 多图总体积控制
def calc_total_bytes():
return sum(len(base64.b64decode(item[1])) for item in processed)
total = calc_total_bytes()
if total > MAX_FILE_SIZE and len(processed) > 1:
print(f"Google Gemini: 图片总体积 {total / 1024 / 1024:.2f}MB 超过 {MAX_FILE_SIZE // 1024 // 1024}MB 限制,正在压缩...")
# 降质量
for quality in range(70, 19, -10):
new_processed = []
for pil_img, _, _ in processed:
if pil_img is None:
# PNG 原图需要转 RGB
continue
buf = BytesIO()
pil_img.save(buf, format='JPEG', quality=quality, optimize=True)
data = buf.getvalue()
new_processed.append((pil_img, base64.b64encode(data).decode('utf-8'), "image/jpeg"))
if not new_processed:
break
processed = new_processed
total = calc_total_bytes()
if total <= MAX_FILE_SIZE:
print(f"Google Gemini: 图片压缩完成,总体积 {total / 1024 / 1024:.2f}MB ({len(processed)}张图片,质量{quality})")
break
# 降分辨率
if total > MAX_FILE_SIZE:
for scale in [0.75, 0.5, 0.35]:
new_processed = []
for pil_img, _, _ in processed:
if pil_img is None:
continue
w, h = pil_img.size
resized = pil_img.resize((int(w * scale), int(h * scale)), Image.Resampling.LANCZOS)
buf = BytesIO()
resized.save(buf, format='JPEG', quality=20, optimize=True)
data = buf.getvalue()
new_processed.append((resized, base64.b64encode(data).decode('utf-8'), "image/jpeg"))
if not new_processed:
break
processed = new_processed
total = calc_total_bytes()
if total <= MAX_FILE_SIZE:
print(f"Google Gemini: 图片压缩完成,总体积 {total / 1024 / 1024:.2f}MB ({len(processed)}张图片,缩放{int(scale*100)}%)")
break
if total > MAX_FILE_SIZE:
print(f"Google Gemini: 无法将 {len(processed)} 张图片压缩到 {MAX_FILE_SIZE // 1024 // 1024}MB 以内,请减少图片数量或降低分辨率")
raise ValueError(f"图片总体积 {total / 1024 / 1024:.2f}MB 超过限制,无法压缩到 {MAX_FILE_SIZE // 1024 // 1024}MB 以内")
image_data = [{"mime_type": mt, "data": b64} for _, b64, mt in processed]
return image_data
def _compress_video_with_ffmpeg(self, input_path: str, output_path: str, target_size: int) -> bool:
"""
使用 FFmpeg 压缩视频到目标大小
Args:
input_path: 输入视频路径
output_path: 输出视频路径
target_size: 目标文件大小(字节)
Returns:
是否压缩成功
"""
try:
# 获取视频时长(秒)
probe_cmd = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', input_path]
duration = float(subprocess.check_output(probe_cmd).decode().strip())
# 计算目标比特率(bit/s),预留一些余量
target_bitrate = int((target_size * 8) / duration * 0.9)
# 使用 FFmpeg 压缩视频
# -c:v libx264: 使用 H.264 编码器
# -b:v: 视频比特率
# -maxrate 和 -bufsize: 控制码率波动
# -c:a aac: 音频使用 AAC 编码
# -b:a 128k: 音频比特率 128k
# -movflags +faststart: 优化网络播放
cmd = [
'ffmpeg', '-y', '-i', input_path,
'-c:v', 'libx264',
'-b:v', f'{target_bitrate}',
'-maxrate', f'{int(target_bitrate * 1.5)}',
'-bufsize', f'{target_bitrate * 2}',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
'-preset', 'fast',
output_path
]
print(f"Google Gemini: 正在压缩视频到 {target_size / 1024 / 1024:.1f}MB...")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0 and os.path.exists(output_path):
final_size = os.path.getsize(output_path)
print(f"Google Gemini: 视频压缩完成,最终大小 {final_size / 1024 / 1024:.2f}MB")
return True
else:
print(f"Google Gemini: FFmpeg 压缩失败: {result.stderr}")
return False
except Exception as e:
print(f"Google Gemini: 视频压缩异常: {str(e)}")
return False
def _compress_video(self, video_path: str) -> str:
"""
压缩视频到 1-10MB 之间
Args:
video_path: 原视频路径
Returns:
压缩后的视频路径(临时文件)
"""
original_size = os.path.getsize(video_path)
print(f"Google Gemini: 视频文件过大 ({original_size / 1024 / 1024:.2f}MB),正在压缩...")
# 创建临时文件
temp_dir = tempfile.gettempdir()
_, ext = os.path.splitext(video_path)
output_path = os.path.join(temp_dir, f"compressed_{int(time.time())}{ext}")
# 确定目标大小(优先尝试 10MB,如果不行再降低)
target_sizes = [
TARGET_VIDEO_SIZE_MAX, # 10MB
int(TARGET_VIDEO_SIZE_MAX * 0.8), # 8MB
int(TARGET_VIDEO_SIZE_MAX * 0.6), # 6MB
int(TARGET_VIDEO_SIZE_MAX * 0.5), # 5MB
TARGET_VIDEO_SIZE_MIN * 5, # 5MB
TARGET_VIDEO_SIZE_MIN * 3, # 3MB
TARGET_VIDEO_SIZE_MIN * 2, # 2MB
]
# 优先尝试 FFmpeg
if FFMPEG_AVAILABLE:
for target_size in target_sizes:
if self._compress_video_with_ffmpeg(video_path, output_path, target_size):
# 检查最终大小
final_size = os.path.getsize(output_path)
if TARGET_VIDEO_SIZE_MIN <= final_size <= MAX_FILE_SIZE:
return output_path
# 如果仍然太大,继续降低目标
os.remove(output_path)
# 所有压缩方法都失败
raise ValueError(
f"视频文件过大 ({original_size / 1024 / 1024:.2f}MB) 且无法压缩到 20MB 以下。"
f"请安装 FFmpeg 以获得更好的压缩效果,或手动压缩视频。"
)
return image_data if image_data else None
def _prepare_video_data(
self,
@@ -377,7 +131,6 @@ class GoogleGemini:
ComfyUI VIDEO 类型包含视频文件路径信息。
读取视频文件并转换为 base64。
如果视频超过 20MB,会自动进行压缩。
Args:
video: ComfyUI VIDEO 类型数据
@@ -388,48 +141,17 @@ class GoogleGemini:
if video is None:
return None
# VIDEO 类型处理:支持多种格式
# VIDEO 类型通常是一个字典,包含 'video' 键指向文件路径
# 或者直接是文件路径字符串
video_path = None
temp_compressed_path = None
if isinstance(video, dict):
# 字典格式:尝试常见的键名
video_path = video.get("video") or video.get("path") or video.get("file") or video.get("filename")
# 如果还是找不到,遍历所有键找到有效路径
if not video_path:
for key, val in video.items():
if isinstance(val, str) and os.path.exists(val):
video_path = val
break
# 尝试获取视频路径
video_path = video.get("video") or video.get("path") or video.get("file")
elif isinstance(video, str):
# 字符串格式:直接作为路径
video_path = video
else:
# 对象格式:尝试常见属性
# 1. 尝试 __file 属性(VideoFromFile 对象)
if hasattr(video, "__file"):
video_path = video.__file
# 2. 尝试其他常见属性
elif hasattr(video, "video"):
video_path = video.video
elif hasattr(video, "path"):
video_path = video.path
elif hasattr(video, "filename"):
video_path = video.filename
# 3. 尝试从 __dict__ 中查找路径(支持私有属性如 _VideoFromFile__file
elif hasattr(video, "__dict__"):
for attr_name, attr_value in video.__dict__.items():
# 查找字符串类型的属性,且包含 file 或 path 关键字
if isinstance(attr_value, str):
if "file" in attr_name.lower() or "path" in attr_name.lower():
# 验证路径是否有效
if os.path.exists(attr_value):
video_path = attr_value
break
# 如果属性值本身看起来像文件路径,也尝试使用
elif os.path.exists(attr_value) and os.path.isfile(attr_value):
video_path = attr_value
break
elif hasattr(video, "video"):
video_path = video.video
if not video_path or not os.path.exists(video_path):
print(f"Google Gemini: 视频文件不存在或路径无效: {video_path}")
@@ -441,70 +163,30 @@ class GoogleGemini:
mime_type = VIDEO_MIME_TYPES.get(ext, "video/mp4")
# 检查文件大小(限制 20MB
file_size = os.path.getsize(video_path)
if file_size > 20 * 1024 * 1024:
raise ValueError(
f"视频文件过大 ({file_size / 1024 / 1024:.2f}MB)"
f"请使用不超过 20MB 的视频文件"
)
# 读取并编码视频
try:
# 检查文件大小
file_size = os.path.getsize(video_path)
# 如果超过 20MB,进行压缩
if file_size > MAX_FILE_SIZE:
video_path = self._compress_video(video_path)
temp_compressed_path = video_path
# 压缩后统一使用 mp4 格式
mime_type = "video/mp4"
# 读取并编码视频
with open(video_path, "rb") as f:
video_bytes = f.read()
b64_str = base64.b64encode(video_bytes).decode("utf-8")
# 清理临时文件
if temp_compressed_path and os.path.exists(temp_compressed_path):
try:
os.remove(temp_compressed_path)
print(f"Google Gemini: 临时压缩文件已清理")
except:
pass
return {
"mime_type": mime_type,
"data": b64_str
}
except Exception as e:
# 清理临时文件
if temp_compressed_path and os.path.exists(temp_compressed_path):
try:
os.remove(temp_compressed_path)
except:
pass
print(f"Google Gemini: 处理视频文件失败 - {str(e)}")
print(f"Google Gemini: 读取视频文件失败 - {str(e)}")
return None
def _prepare_file_data(
self,
file: Optional[FileData]
) -> Optional[Dict[str, str]]:
"""
准备文件数据
从 FILE 类型提取文件数据
Args:
file: FileData 对象(来自 LoadFile 节点)
Returns:
文件数据字典,包含 mime_type 和 data
"""
if file is None:
return None
return {
"mime_type": file.mime_type,
"data": file.data
}
def _parse_dual_output(self, raw_response: Dict) -> Tuple[str, str]:
"""
解析包含思考内容和主要内容的响应
@@ -532,16 +214,16 @@ class GoogleGemini:
# 主要内容
main_text = part.get("text", "")
return main_text
return (main_text, thought_text)
def generate(
self,
模型: str,
提示词: str,
思考等级: str,
思考深度: str,
系统指令: Optional[str] = None,
图片: Optional[torch.Tensor] = None,
视频=None,
文件: Optional[FileData] = None
视频=None
) -> Tuple[str]:
"""
生成文本
@@ -549,23 +231,21 @@ class GoogleGemini:
Args:
模型: 使用的模型名称
提示词: 用户提示词
思考等级: 思考等级选项
思考深度: 思考深度选项
系统指令: 系统级指令
图片: 输入图片
视频: 输入视频
文件: 输入文件(PDF/TXT)
Returns:
(主要内容, 思考内容)
生成的文本 (STRING,)
"""
start_time = time.time()
try:
# 初始化 API 客户端
config_signature = get_runtime_config_signature()
if self.client is None or config_signature != self._client_config_signature:
if self.client is None:
try:
self.client = GeminiFlashClient()
self._client_config_signature = config_signature
except ValueError as e:
raise ValueError(f"初始化失败: {str(e)}")
@@ -579,12 +259,6 @@ class GoogleGemini:
if video_data:
print(f"Google Gemini: 输入视频 ({video_data['mime_type']})")
# 准备文件数据
document_data = self._prepare_file_data(文件)
if document_data:
file_type = "PDF" if document_data['mime_type'] == "application/pdf" else "TXT"
print(f"Google Gemini: 输入文件 ({file_type})")
# 构建输入描述
input_desc = []
if 提示词:
@@ -593,32 +267,31 @@ class GoogleGemini:
input_desc.append(f"{len(image_data)}张图片")
if video_data:
input_desc.append("视频")
if document_data:
input_desc.append("文件")
print(f"Google Gemini: 模型 = {模型}")
print(f"Google Gemini: 多模态输入 ({', '.join(input_desc)})")
print(f"Google Gemini: 思考等级 = {思考等级}")
print(f"Google Gemini: 思考深度 = {思考深度}")
print(f"Google Gemini: 发送请求...")
# 获取端点和构建请求体
endpoint = self.client.get_endpoint(model=模型)
endpoint = self.client.get_endpoint(model=模型, thinking_depth=思考深度)
request_body = self.client.build_request_body(
prompt=提示词,
model=模型,
thinking_level=思考等级,
system_instruction=系统指令,
image_data=image_data,
video_data=video_data,
document_data=document_data
video_data=video_data
)
print(f"Google Gemini: 发送请求...")
# 根据是否有视频设置超时
timeout = 300 if video_data else 180
# 调用底层 API 获取原始响应
async def get_raw_response():
return await self.client.request_async(
endpoint,
request_body,
session=None
session=None,
timeout=timeout
)
# 在独立线程中执行异步请求
@@ -628,56 +301,32 @@ class GoogleGemini:
elapsed = time.time() - start_time
# 解析响应,分离主要内容和思考内容
main_text = self._parse_dual_output(raw_response)
# 打印响应 token 用量
usage = raw_response.get("usageMetadata", {})
prompt_tokens = usage.get("promptTokenCount", 0)
candidates_tokens = usage.get("candidatesTokenCount", 0)
thoughts_tokens = usage.get("thoughtsTokenCount", 0)
total_tokens = usage.get("totalTokenCount", 0)
finish_reason = ""
candidates = raw_response.get("candidates", [])
if candidates:
finish_reason = candidates[0].get("finishReason", "")
main_text, thought_text = self._parse_dual_output(raw_response)
# 输出信息
print(f"Google Gemini: 生成完成 (耗时: {elapsed:.2f}s)")
print(f"Google Gemini: finishReason = {finish_reason}")
print(f"Google Gemini: Token 用量 — 输入: {prompt_tokens}, 输出: {candidates_tokens}, 思考: {thoughts_tokens}, 合计: {total_tokens}")
print(f"Google Gemini: 主要内容长度: {len(main_text)} 字符")
print(f"Google Gemini: 思考内容长度: {len(thought_text)} 字符")
# 输出预览
if main_text:
preview = main_text[:100] + "..." if len(main_text) > 100 else main_text
print(f"Google Gemini: 主要内容预览: {preview}")
return (main_text,)
return (main_text, thought_text)
except ValueError as e:
# 检测是否为授权错误
if str(e) == "未授权!":
print("请联系作者授权后方可使用!")
raise ValueError("未授权!") from None
else:
# 用户输入错误 - 只显示简洁信息
error_msg = str(e).split('\n')[0] # 只取第一行
print(f"Google Gemini: ❌ {error_msg}")
raise ValueError(error_msg) from None
print(f"Google Gemini: 输入错误 - {str(e)}")
raise
except RuntimeError as e:
# 日志只打第一行;报错框展示完整多行
error_full = str(e)
print(f"Google Gemini: ❌ {error_full.split('\n')[0]}")
raise RuntimeError(error_full) from None
print(f"Google Gemini: API 错误 - {str(e)}")
raise
except Exception as e:
# 其他未知错误 - 只显示简洁信息
error_msg = str(e).split('\n')[0]
print(f"Google Gemini: ❌ {error_msg}")
raise type(e)(error_msg) from None
finally:
if self.client is not None:
try:
balance_data = self.client.query_balance_sync()
balance_info = self.client.format_balance_info(balance_data)
print(f"Google Gemini: {balance_info}")
except Exception:
pass
print(f"Google Gemini: 未知错误 - {str(e)}")
raise
-884
View File
@@ -1,884 +0,0 @@
"""
o1key GPT Image 节点
支持 GPT Image 2 / 2.5 系列的文生图、图生图和带蒙版图像编辑
"""
import os
import time
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 ..utils.image_utils import parse_batch_prompts, pil_to_tensor, tensor_to_pil
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,
load_images_from_folder,
pair_images_by_name,
pair_images_cartesian,
save_image,
)
try:
from comfy.model_management import processing_interrupted, InterruptProcessingException
_INTERRUPT_AVAILABLE = True
except ImportError:
_INTERRUPT_AVAILABLE = False
processing_interrupted = lambda: False
InterruptProcessingException = RuntimeError
try:
from comfy.utils import ProgressBar
_PROGRESS_BAR_AVAILABLE = True
except ImportError:
_PROGRESS_BAR_AVAILABLE = False
try:
import folder_paths
_FOLDER_PATHS_AVAILABLE = True
except ImportError:
_FOLDER_PATHS_AVAILABLE = False
def _make_node_progress_callback(progress_bar, task_index: int, total_tasks: int):
if progress_bar is None:
return None
total_units = max(1, total_tasks) * 100
base_units = max(0, task_index - 1) * 100
last_pct = {"value": -1}
def _callback(pct: int):
try:
pct_value = int(round(float(pct)))
except (TypeError, ValueError):
return
pct_value = max(0, min(100, pct_value))
if pct_value < last_pct["value"]:
return
last_pct["value"] = pct_value
progress_bar.update_absolute(
min(total_units, base_units + pct_value),
total_units,
)
return _callback
def _resolve_async_size(value: str) -> str:
return resolve_gpt_image_size(value)
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 节点
功能:
- 文生图:仅提供 prompt
- 图生图:提供 prompt + 图片(无遮罩)
- 图像编辑:提供 prompt + 图片 + 遮罩(白色区域将被替换)
- 批量模式:prompt 中用单独一行 --- 分隔多条提示词
参数:
- prompt : 文本提示词(多行;用 --- 独占一行分隔批量提示词)
- 模型 : GPT Image 主模型
- 模型线路 : 畅速、直连或专线
- 分辨率 : 图像尺寸(auto 让 API 自动决定)
- 生图数量 : 每条提示词生成数量 1-8
- 质量 : 生成质量
- seed : 随机种子(0 表示不指定)
- 图片 : 可选参考图(用于图生图或编辑)
- 遮罩 : 可选蒙版(白色区域将被替换)
"""
@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_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,
)
@classmethod
def execute(
cls,
prompt: str,
模型: str = "gpt-image-2.5-sunburst",
模型线路: str = "畅速",
分辨率: str = "智能",
质量: str = "自动",
输出格式: 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,
):
"""
生成图像(文生图 / 图生图 / 图像编辑 / 批量提示词)
路由逻辑:
- 无图片 → generations 接口(文生图)
- 有图片,无遮罩 → edits 接口(图生图)
- 有图片,有遮罩 → edits 接口(图像编辑 + 蒙版)
- prompt 含 --- → 批量模式,逐条调用上述接口
"""
start_time = time.time()
# ── 0. 收集多参考图输入 ────────────────────────────────────────────────
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 = resolve_gpt_image_model(模型, 模型线路)
# ── 2c. 解析质量显示值 → API 参数值 ───────────────────────────────────
quality = resolve_gpt_image_quality(模型, 质量)
# ── 3. 创建客户端 ─────────────────────────────────────────────────────
try:
client = GptImageClient()
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] 请联系作者授权后方可使用!")
raise ValueError("未授权!") from None
raise
try:
# ── 4. 解析批量提示词 ─────────────────────────────────────────────
batch_prompts = parse_batch_prompts(prompt)
# ── 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
if batch_prompts:
# 批量模式:逐条提示词调用
total = len(batch_prompts)
print(f"[o1key GPT Image] 批量模式 | {total} 条提示词 | 每条生成 {生图数量}")
for idx, p in enumerate(batch_prompts, 1):
if _INTERRUPT_AVAILABLE and processing_interrupted():
print("[o1key GPT Image] 用户取消,已中断批量生成")
raise InterruptProcessingException()
try:
pil_images = client.generate_image_async_sync(
prompt=p,
model=model,
quality=quality,
size=size,
n=生图数量,
seed=seed,
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)
except InterruptProcessingException:
raise
except Exception as e:
error_msg = str(e).split('\n')[0]
print(f"[o1key GPT Image] [{idx}/{total}] ❌ {error_msg}")
if progress_bar is not None:
progress_bar.update_absolute(idx * 100, total * 100)
else:
# 单提示词模式
if not prompt or not prompt.strip():
raise ValueError("提示词不能为空")
try:
pil_images = client.generate_image_async_sync(
prompt=prompt,
model=model,
quality=quality,
size=size,
n=生图数量,
seed=seed,
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:
raise
except Exception as e:
error_msg = str(e).split('\n')[0]
print(f"[o1key GPT Image] ❌ {error_msg}")
raise RuntimeError(error_msg) from None
# ── 6. 检查是否有可用图像 ─────────────────────────────────────────
if not all_pil_images:
raise RuntimeError("所有提示词均生成失败,无可用图像输出")
# ── 7. PIL → tensor ───────────────────────────────────────────────
output_tensor = GptImageClient._pil_list_to_tensor(all_pil_images)
# ── 8. 完成日志 ───────────────────────────────────────────────────
elapsed = time.time() - start_time
print(
f"[o1key GPT Image] 完成!耗时 {elapsed:.1f}s"
f"输出 {output_tensor.shape[0]}"
f"{output_tensor.shape[2]}×{output_tensor.shape[1]}"
)
return (output_tensor,)
finally:
cls._print_balance(client)
@staticmethod
def _print_balance(client):
try:
balance_data = client.query_balance_sync()
balance_info = client.format_balance_info(balance_data)
print(f"[o1key GPT Image] {balance_info}")
except Exception:
pass
class _LegacyO1keyGPTImageBatch:
"""
o1key GPT Image 批量节点
复用 BatchNanoBananaPro 的批量思路:
- 从文件夹批量加载图片
- 按文件名同名 / 1*N / 不配对 三种模式创建任务
- 可追加节点手动输入参考图
- prompt 支持用独占一行 --- 展开为多提示词任务
- 每个任务调用 GPT Image 客户端并保存到磁盘
"""
PAIRING_MODES = ["按相同图片命名", "1*N", "不配对"]
IMAGE_FORMATS = ["原始", "JPEG", "PNG", "WebP"]
MODEL_OPTIONS = GPT_IMAGE_ROUTE_OPTIONS
QUALITY_OPTIONS = ["", "", "", "自动"]
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",
]
@classmethod
def INPUT_TYPES(cls):
optional_inputs = {}
for image_index in range(1, 10):
optional_inputs[f"参考图{image_index}"] = ("IMAGE", {
"tooltip": "追加到每个批量任务末尾的固定参考图。",
})
optional_inputs["遮罩"] = ("MASK", {
"tooltip": "可选蒙版,会应用到每个任务的第一张参考图;请确保尺寸一致。",
})
optional_inputs["图片配对模式"] = (cls.PAIRING_MODES, {
"default": "不配对",
"tooltip": "文件夹图片的组合方式;手动参考图只追加,不参与配对。",
})
return {
"required": {
"prompt": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "提示词;可用独占一行的 --- 分隔多条批量提示词。",
}),
"模型线路": (cls.MODEL_OPTIONS, {
"default": "畅速",
}),
"分辨率": (cls.RESOLUTION_OPTIONS, {
"default": "智能",
}),
"生图数量": ("INT", {
"default": 1,
"min": 1,
"max": 8,
"step": 1,
"display": "number",
}),
"质量": (cls.QUALITY_OPTIONS, {
"default": "自动",
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 2**31 - 1,
"step": 1,
"display": "number",
"control_after_generate": True,
}),
"图片格式": (cls.IMAGE_FORMATS, {
"default": "原始",
}),
"文件夹1": ("STRING", {
"default": "",
"multiline": False,
}),
"文件夹2": ("STRING", {
"default": "",
"multiline": False,
}),
"文件夹3": ("STRING", {
"default": "",
"multiline": False,
}),
"文件夹4": ("STRING", {
"default": "",
"multiline": False,
}),
"文件夹5": ("STRING", {
"default": "",
"multiline": False,
}),
"保存路径": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "为空时优先使用 ComfyUI 默认 output 目录。",
}),
},
"optional": optional_inputs,
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("IMAGE",)
FUNCTION = "process_batch"
CATEGORY = "o1key/image"
OUTPUT_NODE = False
def _load_folders(self, folders: List[str]) -> List[List[ImageInfo]]:
image_lists = []
for folder_index, folder in enumerate(folders, 1):
if not folder or not folder.strip():
continue
try:
loaded_images = load_images_from_folder(folder)
if loaded_images:
image_lists.append(loaded_images)
except ValueError as error:
print(f"[o1key GPT Image Batch] 文件夹{folder_index} 加载失败 - {error}")
return image_lists
def _create_pairs(
self,
image_lists: List[List[ImageInfo]],
pairing_mode: str,
manual_images: Optional[List[ImageInfo]] = None,
) -> List[Tuple[ImageInfo, ...]]:
if pairing_mode == "不配对":
if len(image_lists) > 1:
raise ValueError("「不配对」模式只支持单个文件夹,请清空其他文件夹路径")
if image_lists and manual_images:
return [
(folder_image,) + tuple(manual_images)
for folder_image in image_lists[0]
]
if image_lists:
return [(folder_image,) for folder_image in image_lists[0]]
return []
if not image_lists:
return []
if len(image_lists) == 1:
base_pairs = [(folder_image,) for folder_image in image_lists[0]]
elif pairing_mode == "按相同图片命名":
base_pairs = list(pair_images_by_name(*image_lists))
else:
base_pairs = list(pair_images_cartesian(*image_lists))
if manual_images:
manual_tuple = tuple(manual_images)
base_pairs = [pair + manual_tuple for pair in base_pairs]
return base_pairs
def _collect_manual_images(self, kwargs) -> List[ImageInfo]:
manual_images = []
for image_index in range(1, 10):
key = f"参考图{image_index}"
if key not in kwargs or kwargs[key] is None:
continue
for tensor_index, image in enumerate(tensor_to_pil(kwargs[key])):
manual_images.append(ImageInfo(
image=image,
filename=f"manual_{image_index}_{tensor_index}",
extension=".png",
source_path="",
))
return manual_images
@staticmethod
def _pair_to_tensors(pair: Tuple[ImageInfo, ...]) -> List:
return [pil_to_tensor([image_info.image]) for image_info in pair]
@staticmethod
def _resolve_size(分辨率: str) -> str:
return _resolve_async_size(分辨率)
@staticmethod
def _resolve_model(模型线路: str) -> str:
# 直接返回,客户端会映射到 API 值
return 模型线路
@staticmethod
def _resolve_quality(质量: str) -> str:
quality_map = {"": "high", "": "medium", "": "low", "自动": "auto"}
return quality_map.get(质量, "auto")
@staticmethod
def _resolve_output_format(图片格式: str) -> str:
output_format_map = {
"JPEG": "jpeg",
"PNG": "png",
"WebP": "webp",
}
return output_format_map.get(图片格式, "png")
@staticmethod
def _ensure_output_folder(保存路径: str) -> str:
output_folder = (保存路径 or "").strip()
if not output_folder and _FOLDER_PATHS_AVAILABLE:
output_folder = folder_paths.get_output_directory()
print(f"[o1key GPT Image Batch] 未设置保存路径,使用 ComfyUI 默认 output 目录: {output_folder}")
if not output_folder:
raise ValueError("未设置保存路径,且当前环境无法获取 ComfyUI 默认 output 目录")
os.makedirs(output_folder, exist_ok=True)
test_path = os.path.join(output_folder, ".write_test")
with open(test_path, "w", encoding="utf-8") as test_file:
test_file.write("test")
os.remove(test_path)
return output_folder
@staticmethod
def _save_images(
images: List[Image.Image],
output_folder: str,
image_format: str,
base_filename: Optional[str] = None,
) -> List[str]:
format_ext_map = {"JPEG": ".jpg", "PNG": ".png", "WebP": ".webp"}
save_ext = format_ext_map.get(image_format, ".png")
saved_files = []
for image in images:
if base_filename:
counter = 0
while True:
suffix = "" if counter == 0 else f"+{counter}"
filename = f"{base_filename}{suffix}{save_ext}"
output_path = os.path.join(output_folder, filename)
if not os.path.exists(output_path):
break
counter += 1
else:
output_path = generate_timestamp_filename(
output_folder=output_folder,
extension=save_ext,
)
if image_format == "JPEG":
if image.mode != "RGB":
image = image.convert("RGB")
image.save(output_path, quality=100)
elif image_format == "WebP":
image.save(output_path, lossless=True)
else:
save_image(image, output_path)
saved_files.append(output_path)
return saved_files
def process_batch(
self,
prompt: str,
模型线路: str,
分辨率: str,
生图数量: int,
质量: str,
seed: int,
图片格式: str,
文件夹1: str,
文件夹2: str,
文件夹3: str,
文件夹4: str,
文件夹5: str,
保存路径: str = "",
图片配对模式: str = "不配对",
遮罩=None,
**kwargs,
):
start_time = time.time()
client = None
try:
if not prompt or not prompt.strip():
raise ValueError("提示词不能为空")
folders = [文件夹1, 文件夹2, 文件夹3, 文件夹4, 文件夹5]
if not any(folder and folder.strip() for folder in folders):
raise ValueError("请至少填写一个文件夹路径,该节点专为批量文件夹处理设计")
image_lists = self._load_folders(folders)
total_folder_images = sum(len(image_list) for image_list in image_lists)
if total_folder_images == 0:
raise ValueError("文件夹中未找到任何图片,请检查文件夹路径是否正确")
manual_images = self._collect_manual_images(kwargs)
pairs = self._create_pairs(
image_lists=image_lists,
pairing_mode=图片配对模式,
manual_images=manual_images if manual_images else None,
)
if not pairs:
raise ValueError("配对结果为空,请检查输入")
batch_prompts = parse_batch_prompts(prompt)
prompts_per_task = None
if batch_prompts:
expanded_pairs = []
expanded_prompts = []
for pair in pairs:
for batch_prompt in batch_prompts:
expanded_pairs.append(pair)
expanded_prompts.append(batch_prompt)
pairs = expanded_pairs
prompts_per_task = expanded_prompts
total_tasks = len(pairs)
if batch_prompts:
print(
f"[o1key GPT Image Batch] 批量任务 | {图片配对模式} × "
f"{len(batch_prompts)} 个提示词 | 共 {total_tasks} 任务"
)
else:
print(f"[o1key GPT Image Batch] 批量任务 | {图片配对模式} | 共 {total_tasks} 任务")
output_folder = self._ensure_output_folder(保存路径)
size = self._resolve_size(分辨率)
model = self._resolve_model(模型线路)
quality = self._resolve_quality(质量)
output_format = self._resolve_output_format(图片格式)
client = GptImageClient()
client.base_url = get_base_url_by_route()
progress_bar = ProgressBar(total_tasks * 100) if _PROGRESS_BAR_AVAILABLE else None
results = []
all_saved_files = []
for task_index, pair in enumerate(pairs, 1):
if _INTERRUPT_AVAILABLE and processing_interrupted():
print("[o1key GPT Image Batch] 用户取消,已中断批量生成")
raise InterruptProcessingException()
task_prompt = prompts_per_task[task_index - 1] if prompts_per_task else prompt
base_filename = pair[0].filename if pair else None
result = {
"task_index": task_index,
"success": False,
"generated_count": 0,
"saved_files": [],
"error": None,
}
try:
pil_images = client.generate_image_async_sync(
prompt=task_prompt,
model=model,
quality=quality,
size=size,
n=生图数量,
seed=seed,
image_tensor=self._pair_to_tensors(pair),
mask_tensor=遮罩,
output_format=output_format,
progress_callback=_make_node_progress_callback(progress_bar, task_index, total_tasks),
)
saved_files = self._save_images(
images=pil_images,
output_folder=output_folder,
image_format=图片格式,
base_filename=base_filename,
)
result["success"] = bool(pil_images)
result["generated_count"] = len(pil_images)
result["saved_files"] = saved_files
all_saved_files.extend(saved_files)
print(f"[o1key GPT Image Batch] [{task_index}/{total_tasks}] ✓ {base_filename or 'task'}")
except InterruptProcessingException:
raise
except Exception as error:
error_msg = str(error).split("\n")[0]
result["error"] = error_msg
print(f"[o1key GPT Image Batch] [{task_index}/{total_tasks}] ❌ {base_filename or 'task'}{error_msg}")
results.append(result)
if progress_bar is not None:
progress_bar.update_absolute(task_index * 100, total_tasks * 100)
success_count = sum(1 for result in results if result.get("success", False))
total_generated = sum(result.get("generated_count", 0) for result in results)
if success_count == 0:
raise RuntimeError("所有批量任务均生成失败,无可用图像输出")
output_images = []
for file_path in all_saved_files[-10:]:
try:
loaded_image = Image.open(file_path)
loaded_image.load()
output_images.append(loaded_image)
except Exception as error:
print(f"[o1key GPT Image Batch] 无法加载输出图片 {file_path} - {error}")
if not output_images:
output_images = [Image.new("RGBA", (512, 512), (128, 128, 128, 255))]
output_tensor = GptImageClient._pil_list_to_tensor(output_images)
elapsed = time.time() - start_time
print("=" * 60)
print(
f"[o1key GPT Image Batch] 完成!耗时 {elapsed:.1f}s | "
f"成功 {success_count}/{total_tasks} | 生成 {total_generated}"
)
print(f"[o1key GPT Image Batch] 保存路径: {output_folder}")
if all_saved_files:
print(f"[o1key GPT Image Batch] 最新保存文件: {all_saved_files[-1]}")
failed_results = [result for result in results if not result.get("success", False)]
if failed_results:
print(f"[o1key GPT Image Batch] 失败任务: {len(failed_results)}")
for failed_result in failed_results[:3]:
print(
f" - #{failed_result.get('task_index')}: "
f"{failed_result.get('error', '未知错误')}"
)
return (output_tensor,)
except ValueError as error:
if str(error) == "未授权!":
print("[o1key GPT Image Batch] 请联系作者授权后方可使用!")
raise ValueError("未授权!") from None
raise ValueError(str(error)) from None
except RuntimeError as error:
raise RuntimeError(str(error)) from None
finally:
if client is not None:
try:
balance_data = client.query_balance_sync()
balance_info = client.format_balance_info(balance_data)
print(f"[o1key GPT Image Batch] {balance_info}")
except Exception:
pass
-479
View File
@@ -1,479 +0,0 @@
"""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)
-428
View File
@@ -1,428 +0,0 @@
"""
Merged grid image splitter.
This node is designed for AI-generated contact sheets such as 3x3 or 2x3
grids. Auto mode scores common layouts by looking for strong seams or flat
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
import numpy as np
import torch
from PIL import Image
from ..utils.image_utils import pil_to_tensor, tensor_to_pil
_AUTO_LAYOUTS: Sequence[Tuple[int, int]] = (
(3, 3),
(2, 3),
(3, 2),
(2, 2),
(1, 2),
(2, 1),
(1, 3),
(3, 1),
(4, 4),
(3, 4),
(4, 3),
)
_LAYOUTS = [
"auto",
"1x2",
"2x1",
"1x3",
"3x1",
"2x2",
"2x3",
"3x2",
"3x3",
"3x4",
"4x3",
"4x4",
"custom",
]
@dataclass(frozen=True)
class _AxisCut:
seam: int
span_start: int
span_end: int
score: float
@dataclass(frozen=True)
class _AxisPlan:
intervals: List[Tuple[int, int]]
cuts: List[_AxisCut]
score: float
def _to_float_array(image: Image.Image) -> np.ndarray:
if image.mode != "RGB":
image = image.convert("RGB")
return np.asarray(image).astype(np.float32) / 255.0
def _axis_texture(arr: np.ndarray, axis: str) -> np.ndarray:
if axis == "x":
profile = arr.std(axis=(0, 2))
else:
profile = arr.std(axis=(1, 2))
high = np.percentile(profile, 95) + 1e-6
return np.clip(profile / high, 0.0, 1.0)
def _axis_edge(arr: np.ndarray, axis: str) -> np.ndarray:
if axis == "x":
diff = np.abs(np.diff(arr, axis=1)).mean(axis=(0, 2))
length = arr.shape[1]
else:
diff = np.abs(np.diff(arr, axis=0)).mean(axis=(1, 2))
length = arr.shape[0]
padded = np.zeros(length, dtype=np.float32)
if diff.size:
padded[1:] = diff
high = np.percentile(padded, 95) + 1e-6
return np.clip(padded / high, 0.0, 1.5)
def _smooth(profile: np.ndarray, radius: int = 2) -> np.ndarray:
if radius <= 0 or profile.size < radius * 2 + 1:
return profile
kernel = np.ones(radius * 2 + 1, dtype=np.float32) / float(radius * 2 + 1)
return np.convolve(profile, kernel, mode="same")
def _separator_span(
texture: np.ndarray,
seam: int,
search_px: int,
min_separator_px: int,
) -> Tuple[int, int]:
length = texture.size
if length <= 1:
return 0, length
limit = max(1, min(search_px, length // 8))
threshold = max(0.08, min(0.28, float(np.percentile(texture, 12)) * 1.8))
left = seam
while left > 0 and seam - left < limit and texture[left - 1] <= threshold:
left -= 1
right = seam
while right < length and right - seam < limit and texture[right] <= threshold:
right += 1
if right - left >= max(1, min_separator_px):
return left, right
return seam, seam
def _edge_trim(texture: np.ndarray, search_px: int, min_cell: int) -> Tuple[int, int]:
length = texture.size
if length <= 2:
return 0, length
max_trim = max(0, min(search_px * 2, min_cell // 3, length // 6))
if max_trim <= 0:
return 0, length
threshold = max(0.08, min(0.24, float(np.percentile(texture, 12)) * 1.6))
start = 0
while start < max_trim and texture[start] <= threshold:
start += 1
end = length
while length - end < max_trim and end > start + min_cell and texture[end - 1] <= threshold:
end -= 1
return start, end
def _axis_plan(
arr: np.ndarray,
cells: int,
axis: str,
search_px: int,
crop_separators: bool,
trim_outer: bool,
min_separator_px: int,
) -> _AxisPlan:
length = arr.shape[1] if axis == "x" else arr.shape[0]
if cells <= 1:
return _AxisPlan(intervals=[(0, length)], cuts=[], score=0.0)
raw_texture = _axis_texture(arr, axis)
raw_edge = _axis_edge(arr, axis)
texture = _smooth(raw_texture, radius=2)
edge = _smooth(raw_edge, radius=1)
evidence = np.maximum(edge, (1.0 - texture) * 0.75)
exact_evidence = np.maximum(raw_edge, (1.0 - raw_texture) * 0.75)
cuts: List[_AxisCut] = []
scores: List[float] = []
for idx in range(1, cells):
expected = round(length * idx / cells)
start = max(1, expected - search_px)
end = min(length - 1, expected + search_px)
if start >= end:
seam = expected
score = 0.0
else:
window = evidence[start:end + 1]
offset = int(window.argmax())
coarse = start + offset
fine_start = max(start, coarse - 2)
fine_end = min(end, coarse + 2)
fine_window = exact_evidence[fine_start:fine_end + 1]
seam = fine_start + int(fine_window.argmax())
score = float(window[offset])
span_start, span_end = _separator_span(
raw_texture,
seam,
search_px=search_px,
min_separator_px=min_separator_px,
)
cuts.append(_AxisCut(seam=seam, span_start=span_start, span_end=span_end, score=score))
scores.append(score)
min_cell = max(1, length // cells)
outer_start, outer_end = _edge_trim(raw_texture, search_px, min_cell) if trim_outer else (0, length)
intervals: List[Tuple[int, int]] = []
cursor = outer_start
for cut in cuts:
split_start = cut.span_start if crop_separators else cut.seam
split_end = cut.span_end if crop_separators else cut.seam
intervals.append((cursor, split_start))
cursor = split_end
intervals.append((cursor, outer_end))
cleaned: List[Tuple[int, int]] = []
for start, end in intervals:
start = max(0, min(length - 1, int(start)))
end = max(start + 1, min(length, int(end)))
cleaned.append((start, end))
return _AxisPlan(
intervals=cleaned,
cuts=cuts,
score=float(np.mean(scores)) if scores else 0.0,
)
def _parse_layout(layout: str, custom_rows: int, custom_cols: int) -> Tuple[int, int]:
if layout == "custom":
return max(1, int(custom_rows)), max(1, int(custom_cols))
rows_text, cols_text = layout.split("x", 1)
return int(rows_text), int(cols_text)
def _fallback_layout(width: int, height: int) -> Tuple[int, int]:
aspect = width / max(1, height)
if 0.82 <= aspect <= 1.22:
return 3, 3
if aspect > 1.22:
return 2, 3
return 3, 2
def _choose_auto_layout(
arr: np.ndarray,
search_px: int,
crop_separators: bool,
trim_outer: bool,
min_separator_px: int,
) -> Tuple[int, int, _AxisPlan, _AxisPlan, float, bool]:
height, width = arr.shape[:2]
best = None
for rows, cols in _AUTO_LAYOUTS:
x_plan = _axis_plan(arr, cols, "x", search_px, crop_separators, trim_outer, min_separator_px)
y_plan = _axis_plan(arr, rows, "y", search_px, crop_separators, trim_outer, min_separator_px)
score = (x_plan.score + y_plan.score) / 2.0
# Prefer common 3x3 / 2x3 / 3x2 layouts when the image gives weak signals.
if (rows, cols) in ((3, 3), (2, 3), (3, 2)):
score += 0.025
if best is None or score > best[0]:
best = (score, rows, cols, x_plan, y_plan)
assert best is not None
score, rows, cols, x_plan, y_plan = best
confident = score >= 0.22
if confident:
return rows, cols, x_plan, y_plan, score, True
rows, cols = _fallback_layout(width, height)
x_plan = _axis_plan(arr, cols, "x", search_px, crop_separators, trim_outer, min_separator_px)
y_plan = _axis_plan(arr, rows, "y", search_px, crop_separators, trim_outer, min_separator_px)
return rows, cols, x_plan, y_plan, score, False
def _normalize_sizes(crops: List[Image.Image]) -> List[Image.Image]:
min_w = min(crop.width for crop in crops)
min_h = min(crop.height for crop in crops)
normalized = []
for crop in crops:
left = max(0, (crop.width - min_w) // 2)
top = max(0, (crop.height - min_h) // 2)
normalized.append(crop.crop((left, top, left + min_w, top + min_h)))
return normalized
def _split_one(
image: Image.Image,
layout: str,
custom_rows: int,
custom_cols: int,
search_px: int,
crop_separators: bool,
trim_outer: bool,
min_separator_px: int,
) -> Tuple[List[Image.Image], str]:
arr = _to_float_array(image)
if layout == "auto":
rows, cols, x_plan, y_plan, confidence, confident = _choose_auto_layout(
arr,
search_px=search_px,
crop_separators=crop_separators,
trim_outer=trim_outer,
min_separator_px=min_separator_px,
)
mode_note = "auto" if confident else "auto-low-confidence-fallback"
else:
rows, cols = _parse_layout(layout, custom_rows, custom_cols)
x_plan = _axis_plan(arr, cols, "x", search_px, crop_separators, trim_outer, min_separator_px)
y_plan = _axis_plan(arr, rows, "y", search_px, crop_separators, trim_outer, min_separator_px)
confidence = (x_plan.score + y_plan.score) / 2.0
mode_note = "manual"
crops: List[Image.Image] = []
for y0, y1 in y_plan.intervals:
for x0, x1 in x_plan.intervals:
crops.append(image.crop((x0, y0, x1, y1)))
crops = _normalize_sizes(crops)
info = (
f"{mode_note}: {rows}x{cols}, cells={len(crops)}, "
f"confidence={confidence:.3f}, "
f"x={x_plan.intervals}, y={y_plan.intervals}"
)
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."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"图像": ("IMAGE",),
"布局": (_LAYOUTS, {"default": "auto"}),
"自定义行数": ("INT", {"default": 3, "min": 1, "max": 12, "step": 1}),
"自定义列数": ("INT", {"default": 3, "min": 1, "max": 12, "step": 1}),
"搜索范围px": ("INT", {"default": 32, "min": 0, "max": 256, "step": 1}),
"裁掉分隔线": ("BOOLEAN", {"default": True}),
"裁掉外边距": ("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}),
}
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("切割图像", "检测信息")
FUNCTION = "split_grid"
CATEGORY = "o1key/image"
DESCRIPTION = (
"智能切割 AI 生成的九宫格、六宫格等合并图。"
"自动模式会检测常见布局;没有明显分隔线时建议手动选择布局。"
)
def split_grid(
self,
图像: torch.Tensor,
布局: str = "auto",
自定义行数: int = 3,
自定义列数: int = 3,
搜索范围px: int = 32,
裁掉分隔线: bool = True,
裁掉外边距: 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] = []
for batch_index, image in enumerate(source_images, start=1):
crops, info = _split_one(
image=image,
layout=布局,
custom_rows=自定义行数,
custom_cols=自定义列数,
search_px=搜索范围px,
crop_separators=裁掉分隔线,
trim_outer=裁掉外边距,
min_separator_px=最小分隔线px,
)
if len(crops) > 最大输出张数:
raise ValueError(
f"合并图切割:检测到 {len(crops)} 张,超过最大输出张数 {最大输出张数}"
"请调大最大输出张数,或检查布局设置。"
)
all_crops.extend(crops)
info_lines.append(f"batch {batch_index}: {info}")
if not all_crops:
raise ValueError("合并图切割:没有生成任何切片。")
all_crops = _normalize_sizes(all_crops)
print("[o1key 合并图切割] " + " | ".join(info_lines))
return (pil_to_tensor(all_crops), "\n".join(info_lines))
-160
View File
@@ -1,160 +0,0 @@
"""
o1key Grok Image 节点
支持 Grok Image / Grok Image Pro 模型的文生图和图生图
"""
import time
from ..clients.grok_image_client import GrokImageClient
from ..utils.image_utils import parse_batch_prompts
try:
from comfy.model_management import processing_interrupted, InterruptProcessingException
_INTERRUPT_AVAILABLE = True
except ImportError:
_INTERRUPT_AVAILABLE = False
processing_interrupted = lambda: False
InterruptProcessingException = RuntimeError
_ASPECT_RATIOS = [
"auto", "1:1", "16:9", "9:16", "4:3", "3:4",
"3:2", "2:3", "2:1", "1:2", "19.5:9", "9:19.5", "20:9", "9:20",
]
class O1keyGrokImage:
@classmethod
def INPUT_TYPES(cls):
optional_inputs = {}
for i in range(1, 4):
optional_inputs[f"参考图{i}"] = ("IMAGE", {
"tooltip": f"Optional reference image {i}",
})
return {
"required": {
"prompt": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "文本提示词,用 --- 独占一行分隔批量提示词",
}),
},
"optional": {
"模型": (["Grok Image", "Grok Image Pro"], {
"default": "Grok Image Pro",
}),
"宽高比": (_ASPECT_RATIOS, {
"default": "auto",
}),
"分辨率": (["1k", "2k"], {
"default": "1k",
}),
"生图数量": ("INT", {
"default": 1,
"min": 1,
"max": 4,
"step": 1,
"display": "number",
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 2**31 - 1,
"step": 1,
"display": "number",
"control_after_generate": True,
}),
**optional_inputs,
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("IMAGE",)
FUNCTION = "generate"
CATEGORY = "o1key/image"
OUTPUT_NODE = False
def generate(
self,
prompt: str,
模型: str = "Grok Image Pro",
宽高比: str = "auto",
分辨率: str = "1k",
生图数量: int = 1,
seed: int = 0,
**kwargs,
):
start_time = time.time()
reference_tensors = []
for i in range(1, 4):
key = f"参考图{i}"
if key in kwargs and kwargs[key] is not None:
reference_tensors.append(kwargs[key])
image_list = reference_tensors if reference_tensors else None
try:
client = GrokImageClient()
except ValueError as e:
if str(e) == "未授权!":
print("[o1key Grok Image] 请联系作者授权后方可使用!")
raise ValueError("未授权!") from None
raise
try:
batch_prompts = parse_batch_prompts(prompt)
all_pil_images = []
if batch_prompts:
total = len(batch_prompts)
print(f"[o1key Grok Image] 批量模式 | {total} 条提示词 | 每条生成 {生图数量}")
for idx, p in enumerate(batch_prompts, 1):
if _INTERRUPT_AVAILABLE and processing_interrupted():
print("[o1key Grok Image] 用户取消")
raise InterruptProcessingException()
try:
pil_images = client.run_sync(
prompt=p, model=模型, aspect_ratio=宽高比,
resolution=分辨率, n=生图数量, image_list=image_list,
)
all_pil_images.extend(pil_images)
snippet = p[:30] + ("..." if len(p) >= 30 else "")
print(f"[o1key Grok Image] [{idx}/{total}] done: {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 Grok Image] [{idx}/{total}] fail: {snippet}{error_msg}")
else:
if not prompt or not prompt.strip():
raise ValueError("提示词不能为空")
pil_images = client.run_sync(
prompt=prompt, model=模型, aspect_ratio=宽高比,
resolution=分辨率, n=生图数量, image_list=image_list,
)
all_pil_images.extend(pil_images)
if not all_pil_images:
raise RuntimeError("所有提示词均生成失败,无可用图像输出")
output_tensor = GrokImageClient._pil_list_to_tensor(all_pil_images)
elapsed = time.time() - start_time
print(
f"[o1key Grok Image] 完成!耗时 {elapsed:.1f}s"
f"输出 {output_tensor.shape[0]}"
f"{output_tensor.shape[2]}x{output_tensor.shape[1]}"
)
return (output_tensor,)
finally:
self._print_balance(client)
def _print_balance(self, client):
try:
balance_data = client.query_balance_sync()
balance_info = client.format_balance_info(balance_data)
print(f"[o1key Grok Image] {balance_info}")
except Exception:
pass
-311
View File
@@ -1,311 +0,0 @@
"""Lean ComfyUI nodes for O1Key Grok Imagine Video."""
import asyncio
import math
import os
import re
from typing import Dict
from ..clients.grok_video_client import GrokVideoClient
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
except ImportError:
folder_paths = None
try:
from comfy.utils import ProgressBar
except ImportError:
ProgressBar = None
try:
from comfy_api.input_impl import VideoFromFile
except Exception:
try:
from comfy_api.latest import InputImpl
VideoFromFile = InputImpl.VideoFromFile
except Exception:
VideoFromFile = None
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 is not None:
base_dir = folder_paths.get_temp_directory()
else:
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
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 _single_pil_image(image_tensor, input_name: str):
if image_tensor is None:
return None
images = tensor_to_pil(image_tensor)
if len(images) != 1:
raise ValueError(f"{input_name} 只能连接 1 张图片,请拆分批次后再连接。")
return images[0].convert("RGB")
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 _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 _progress_callback():
progress_bar = ProgressBar(100) if ProgressBar is not None else None
progress_value = [0]
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
return progress_bar, progress_value, callback
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": {
"生成模式": (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"}),
"分辨率": (RESOLUTION_OPTIONS, {"default": "480p"}),
"参考音色ID(逗号分隔)": ("STRING", {"default": ""}),
},
"optional": {
**{input_name: ("IMAGE",) for input_name in IMAGE_INPUT_NAMES},
**{input_name: ("AUDIO",) for input_name in AUDIO_INPUT_NAMES},
},
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Video"
DESCRIPTION = (
"支持文生、图生和多参考素材生成。图生视频只连接图片 1;参考生视频最多使用 7 张图和 "
"3 个参考音频(AUDIO 或 voice_id 合计)。Grok 1.5 的文生/图生可选 1080p,多参考最高 720p。"
)
def generate(self, **kwargs):
if VideoFromFile is None:
raise RuntimeError("当前 ComfyUI 版本不支持 VideoFromFile,无法输出 VIDEO。")
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")
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 []
)
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=model,
duration=duration,
aspect_ratio=aspect_ratio,
resolution=resolution,
image=placeholder_image,
reference_images=placeholder_references,
reference_audios=placeholder_audios,
)
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)),
)
return list(image_urls), list(audio_urls)
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),
]
progress_bar, progress_value, callback = _progress_callback()
result = client.run_video_sync(
operation="generate",
prompt=prompt,
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(),
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 秒,输出总时长等于输入时长加续写时长。"
)
def generate(self, **kwargs):
if VideoFromFile is None:
raise RuntimeError("当前 ComfyUI 版本不支持 VideoFromFile,无法输出 VIDEO。")
video = kwargs.get("视频素材")
if video is None:
raise ValueError("请连接一个 VIDEO 类型的视频素材。")
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",
}
-241
View File
@@ -1,241 +0,0 @@
"""
高级图像拼接节点
支持最多 10 张图像按指定方向(上、下、左、右)依次拼接,
支持调整图像大小匹配和添加间隔。
"""
from typing import Optional, Tuple, List
import torch
from PIL import Image
from ..utils.image_utils import tensor_to_pil, pil_to_tensor
from ..utils.file_utils import load_images_from_folder
# 间隔颜色映射
SPACING_COLOR_MAP = {
"white": (255, 255, 255),
"black": (0, 0, 0),
"red": (255, 0, 0),
"green": (0, 255, 0),
"blue": (0, 0, 255),
}
def _resize_to_match(img: Image.Image, ref: Image.Image, direction: str) -> Image.Image:
"""
按拼接方向将 img 缩放,使其与 ref 在垂直于拼接轴的尺寸上一致。
- 水平拼接 (right/left):统一高度
- 垂直拼接 (down/up):统一宽度
"""
ref_w, ref_h = ref.size
img_w, img_h = img.size
if direction in ("right", "left"):
if img_h != ref_h:
scale = ref_h / img_h
new_w = max(1, int(img_w * scale))
img = img.resize((new_w, ref_h), Image.LANCZOS)
else:
if img_w != ref_w:
scale = ref_w / img_w
new_h = max(1, int(img_h * scale))
img = img.resize((ref_w, new_h), Image.LANCZOS)
return img
def _make_spacer(ref: Image.Image, spacing_width: int,
direction: str, color: Tuple[int, int, int]) -> Image.Image:
"""创建间隔色块"""
if direction in ("right", "left"):
return Image.new("RGB", (spacing_width, ref.size[1]), color)
else:
return Image.new("RGB", (ref.size[0], spacing_width), color)
def _stitch_two(img_a: Image.Image, img_b: Image.Image,
direction: str, match_size: bool,
spacing_width: int, spacing_color: Tuple[int, int, int]) -> Image.Image:
"""
将两张 PIL 图像按指定方向拼接。
img_a 为基准图像,img_b 拼接在 img_a 的指定方向侧。
direction="right" → img_b 在 img_a 右侧
direction="left" → img_b 在 img_a 左侧
direction="down" → img_b 在 img_a 下方
direction="up" → img_b 在 img_a 上方
"""
if img_a.mode != "RGB":
img_a = img_a.convert("RGB")
if img_b.mode != "RGB":
img_b = img_b.convert("RGB")
if match_size:
img_b = _resize_to_match(img_b, img_a, direction)
if direction == "right":
pieces = [img_a, img_b]
elif direction == "left":
pieces = [img_b, img_a]
elif direction == "down":
pieces = [img_a, img_b]
else: # up
pieces = [img_b, img_a]
if spacing_width > 0:
interleaved: List[Image.Image] = []
for idx, piece in enumerate(pieces):
interleaved.append(piece)
if idx < len(pieces) - 1:
interleaved.append(_make_spacer(piece, spacing_width, direction, spacing_color))
pieces = interleaved
if direction in ("right", "left"):
total_w = sum(p.size[0] for p in pieces)
max_h = max(p.size[1] for p in pieces)
canvas = Image.new("RGB", (total_w, max_h), spacing_color)
x = 0
for piece in pieces:
canvas.paste(piece, (x, 0))
x += piece.size[0]
else:
max_w = max(p.size[0] for p in pieces)
total_h = sum(p.size[1] for p in pieces)
canvas = Image.new("RGB", (max_w, total_h), spacing_color)
y = 0
for piece in pieces:
canvas.paste(piece, (0, y))
y += piece.size[1]
return canvas
def _natural_sort_key(filename: str):
"""按数字优先的文件名排序,使 1, 2, 3, 10 而非 1, 10, 2, 3"""
try:
return (0, int(filename))
except ValueError:
return (1, filename.lower())
class ImageStitchPro:
"""
高级图像拼接节点
在 ComfyUI 原生拼接节点基础上扩展,支持同时输入最多 10 张图像,
按指定方向依次拼接,并可在图像间添加任意颜色的间隔。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"方向": (["right", "down", "left", "up"], {"default": "down"}),
"匹配图像尺寸": ("BOOLEAN", {"default": True}),
"间距宽度": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 2}),
"间距颜色": (["white", "black", "red", "green", "blue"], {"default": "white"}),
},
"optional": {
"图1": ("IMAGE",),
"图2": ("IMAGE",),
"图3": ("IMAGE",),
"图4": ("IMAGE",),
"图5": ("IMAGE",),
"图6": ("IMAGE",),
"图7": ("IMAGE",),
"图8": ("IMAGE",),
"图9": ("IMAGE",),
"图10": ("IMAGE",),
"图11": ("IMAGE",),
"图12": ("IMAGE",),
"图片路径(可选)": ("STRING", {"default": "", "multiline": False}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("拼接图像",)
FUNCTION = "stitch"
CATEGORY = "image"
DESCRIPTION = (
"高级图像拼接节点,支持最多 12 张图像按指定方向(右/下/左/上)依次拼接。\n"
"可选择是否将后续图像缩放以匹配第一张图像的尺寸,并可在图像间添加彩色间隔。\n"
"可选填「图片路径」:仅处理该文件夹内图片,按文件名顺序依次拼接;与输入端图片不可同时使用。"
)
def stitch(
self,
方向: str = "down",
匹配图像尺寸: bool = True,
间距宽度: int = 0,
间距颜色: str = "white",
图1: Optional[torch.Tensor] = None,
图2: Optional[torch.Tensor] = None,
图3: Optional[torch.Tensor] = None,
图4: Optional[torch.Tensor] = None,
图5: Optional[torch.Tensor] = None,
图6: Optional[torch.Tensor] = None,
图7: Optional[torch.Tensor] = None,
图8: Optional[torch.Tensor] = None,
图9: Optional[torch.Tensor] = None,
图10: Optional[torch.Tensor] = None,
图11: Optional[torch.Tensor] = None,
图12: Optional[torch.Tensor] = None,
**kwargs: object,
) -> Tuple[torch.Tensor]:
color = SPACING_COLOR_MAP.get(间距颜色, (255, 255, 255))
raw_tensors = [图1, 图2, 图3, 图4, 图5, 图6, 图7, 图8, 图9, 图10, 图11, 图12]
tensors = [t for t in raw_tensors if t is not None]
has_input_images = len(tensors) > 0
image_folder = (kwargs.get("图片路径(可选)") or "").strip()
if image_folder and has_input_images:
raise ValueError("不可同时使用「图片路径(可选)」与输入端图片,请二选一。")
if image_folder:
infos = load_images_from_folder(image_folder)
if not infos:
raise ValueError(f"文件夹中未找到可用的图片,或路径无效: {image_folder}")
infos.sort(key=lambda x: _natural_sort_key(x.filename))
pil_list = [info.image for info in infos]
if len(pil_list) == 1:
return (pil_to_tensor(pil_list),)
base = pil_list[0]
for next_img in pil_list[1:]:
base = _stitch_two(
base, next_img,
direction=方向,
match_size=匹配图像尺寸,
spacing_width=间距宽度,
spacing_color=color,
)
return (pil_to_tensor([base]),)
else:
if not has_input_images:
raise ValueError("请至少接入一张图片,或填写「图片路径(可选)」中的文件夹路径。")
if len(tensors) == 1:
return (tensors[0],)
pil_batches: List[List[Image.Image]] = [tensor_to_pil(t) for t in tensors]
batch_size = min(len(b) for b in pil_batches)
result_images: List[Image.Image] = []
for i in range(batch_size):
frames = [batch[i] for batch in pil_batches]
base = frames[0]
for next_img in frames[1:]:
base = _stitch_two(
base, next_img,
direction=方向,
match_size=匹配图像尺寸,
spacing_width=间距宽度,
spacing_color=color,
)
result_images.append(base)
return (pil_to_tensor(result_images),)
-125
View File
@@ -1,125 +0,0 @@
"""
LoadFile 节点(增强版)
支持单文件路径和文件夹路径,输出 FILE_LIST 类型供全能LLM等节点使用
"""
import base64
import os
from pathlib import Path
from typing import Tuple, List
from ..utils.file_types import FileData, FileList, DOCUMENT_MIME_TYPES, FILE_SIZE_LIMIT, TOTAL_FILE_SIZE_LIMIT
class LoadFile:
"""
加载文件节点
- 单文件路径:加载指定文件
- 文件夹路径:加载文件夹内所有支持的文件(非递归)
- 两者可同时使用,结果合并输出
- 输出 FILE_LIST 类型,可直接连接到全能LLM对话助手
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
"单文件路径": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "文件完整路径,多个文件用英文逗号分隔",
}),
"文件夹路径": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "文件夹路径,自动读取其中所有支持的文件",
}),
},
}
RETURN_TYPES = ("FILE_LIST", "STRING")
RETURN_NAMES = ("文件列表", "文件信息")
FUNCTION = "load_file"
CATEGORY = "file/input"
def load_file(self, 单文件路径: str = "", 文件夹路径: str = "") -> Tuple[FileList, str]:
collected: List[Path] = []
# 1. 单文件路径(逗号分隔,支持多个)
if 单文件路径.strip():
for raw in 单文件路径.split(","):
p = Path(raw.strip().strip('"').strip("'"))
if not p.is_absolute():
p = Path.cwd() / p
if not p.exists():
raise ValueError(f"文件不存在: {p}")
if not p.is_file():
raise ValueError(f"路径不是文件: {p}")
collected.append(p)
# 2. 文件夹路径
if 文件夹路径.strip():
folder = Path(文件夹路径.strip().strip('"').strip("'"))
if not folder.is_absolute():
folder = Path.cwd() / folder
if not folder.exists():
raise ValueError(f"文件夹不存在: {folder}")
if not folder.is_dir():
raise ValueError(f"路径不是文件夹: {folder}")
for p in sorted(folder.iterdir()):
if p.is_file() and p.suffix.lower() in DOCUMENT_MIME_TYPES:
collected.append(p)
if not collected:
raise ValueError(f"文件夹中没有支持的文件: {folder}")
if not collected:
raise ValueError("请至少提供一个文件路径或文件夹路径")
# 去重(保持顺序)
seen = set()
unique: List[Path] = []
for p in collected:
key = str(p.resolve())
if key not in seen:
seen.add(key)
unique.append(p)
# 大小检查 & 读取
total_size = 0
file_list: FileList = []
info_lines = []
for p in unique:
ext = p.suffix.lower()
if ext not in DOCUMENT_MIME_TYPES:
print(f"LoadFile: 跳过不支持的文件类型 {p.name}")
continue
file_size = p.stat().st_size
if file_size > FILE_SIZE_LIMIT:
raise ValueError(
f"文件 {p.name} 大小 {file_size / 1024 / 1024:.1f}MB 超过单文件 50MB 限制"
)
total_size += file_size
if total_size > TOTAL_FILE_SIZE_LIMIT:
raise ValueError(f"所有文件总大小超过 50MB 限制")
mime = DOCUMENT_MIME_TYPES[ext]
with open(p, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
file_list.append(FileData(
path=str(p),
filename=p.stem,
extension=ext,
mime_type=mime,
data=b64,
size=file_size,
))
info_lines.append(f" {p.name} ({file_size / 1024:.1f}KB, {mime})")
print(f"LoadFile: 加载 {p.name} ({file_size / 1024:.1f}KB)")
info = f"{len(file_list)} 个文件,总大小 {total_size / 1024:.1f}KB\n" + "\n".join(info_lines)
return (file_list, info)
-136
View File
@@ -1,136 +0,0 @@
"""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
@@ -1,579 +0,0 @@
"""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 视频生成"}
-722
View File
@@ -1,722 +0,0 @@
"""
Nano Banana 节点 (V3)
ComfyUI 自定义节点,用于调用异步生图模型
使用 V3 DynamicCombo 实现模型-宽高比-分辨率动态联动
"""
import time
import math
import random
import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, List, Optional
import torch
import numpy as np
from PIL import Image
from comfy_api.latest import io
from ..utils.image_utils import tensor_to_pil, pil_to_tensor, parse_batch_prompts
from ..utils.config import (
get_base_url_by_route,
get_api_key_or_raise,
get_runtime_config_signature,
)
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
PROGRESS_BAR_AVAILABLE = True
except ImportError:
PROGRESS_BAR_AVAILABLE = False
try:
from comfy.model_management import processing_interrupted, InterruptProcessingException
INTERRUPT_AVAILABLE = True
except ImportError:
INTERRUPT_AVAILABLE = False
InterruptProcessingException = RuntimeError
processing_interrupted = lambda: 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, _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
async def _poll_interrupt():
while True:
await asyncio.sleep(_INTERRUPT_CHECK_INTERVAL)
if INTERRUPT_AVAILABLE and processing_interrupted():
return
async def _run_with_interrupt(coro):
if not INTERRUPT_AVAILABLE:
return await coro
request_task = asyncio.ensure_future(coro)
interrupt_task = asyncio.ensure_future(_poll_interrupt())
done, pending = await asyncio.wait(
[request_task, interrupt_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
if interrupt_task in done and request_task not in done:
raise InterruptProcessingException()
return request_task.result()
def _check_interrupt():
if INTERRUPT_AVAILABLE and processing_interrupted():
raise InterruptProcessingException()
def _make_progress_callback(pbar) -> Optional[Callable[[float], None]]:
if pbar is None:
return None
last_progress = [0.0]
def _on_progress(progress: float) -> None:
try:
progress = max(0.0, min(float(progress), 1.0))
except (TypeError, ValueError):
return
if progress <= last_progress[0]:
return
pbar.update(progress - last_progress[0])
last_progress[0] = progress
return _on_progress
def _images_to_tensor_safe(images: List[Image.Image], node_label: str) -> torch.Tensor:
if not images:
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
return pil_to_tensor([placeholder])
base_size = max(images, key=lambda img: img.size[0] * img.size[1]).size
matched = [img for img in images if img.size == base_size]
skipped = [img for img in images if img.size != base_size]
if skipped:
sizes_str = ", ".join(f"{img.size[0]}x{img.size[1]}" for img in skipped)
print(
f"{node_label}: 丢弃 {len(skipped)} 张较小尺寸的图 ({sizes_str})"
f"仅输出最大尺寸 {base_size[0]}x{base_size[1]}{len(matched)}"
)
return pil_to_tensor(matched)
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",
}
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: Any,
base_url: str,
api_key: str,
prompt: str,
model: str,
resolution: str,
aspect_ratio: str,
images: Optional[List[Image.Image]] = None,
image_urls: Optional[List[str]] = None,
thinking_level: Optional[str] = None,
progress_callback: Optional[Callable[[float], None]] = None,
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,
api_key=api_key,
prompt=prompt,
model=model,
resolution=resolution,
aspect_ratio=aspect_ratio,
images=images,
image_urls=image_urls,
upload_cache=upload_cache,
download_semaphore=download_semaphore,
thinking_level=thinking_level,
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
async def _generate_single_task(
session: Any,
base_url: str,
api_key: str,
prompt: str,
model: str,
resolution: str,
aspect_ratio: str,
images: Optional[List[Image.Image]],
image_urls: Optional[List[str]],
global_task_index: int,
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,
"prompt": prompt,
"success": False,
"generated_count": 0,
"output_images": [],
"error": None,
}
task_started = time.time()
try:
gen_images, timing = await _generate_single(
session=session,
base_url=base_url,
api_key=api_key,
prompt=prompt,
model=model,
resolution=resolution,
aspect_ratio=aspect_ratio,
images=images if images else None,
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,
)
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:
result["error"] = str(e)
return result
async def _process_batch_async(
base_url: str,
api_key: str,
prompts: List[str],
model: str,
resolution: str,
aspect_ratio: str,
images_per_prompt: int,
input_images: Optional[List[Image.Image]],
pbar=None,
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):
for sub_idx in range(images_per_prompt):
tasks_def.append((p_idx, sub_idx, prompt))
total_tasks = len(tasks_def)
max_concurrent = _MAX_GENERATION_CONCURRENCY
num_batches = math.ceil(total_tasks / max_concurrent)
all_results = []
completed = 0
success_count = 0
fail_count = 0
upload_cache = {}
download_semaphore = (
None
if unlimited_downloads
else asyncio.Semaphore(_MAX_DOWNLOAD_CONCURRENCY)
)
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
end_idx = min(start_idx + max_concurrent, total_tasks)
tasks = []
for i in range(start_idx, end_idx):
_check_interrupt()
_, _, prompt = tasks_def[i]
task = asyncio.create_task(
_generate_single_task(
session=session,
base_url=base_url,
api_key=api_key,
prompt=prompt,
model=model,
resolution=resolution,
aspect_ratio=aspect_ratio,
images=input_images,
image_urls=None,
global_task_index=i,
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)
batch_results = []
for coro in asyncio.as_completed(tasks):
_check_interrupt()
result_data = None
try:
result = await coro
if isinstance(result, Exception):
result_data = {"success": False, "error": str(result), "generated_count": 0, "output_images": [], "prompt": ""}
else:
result_data = result
except InterruptProcessingException:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise
except Exception as e:
result_data = {"success": False, "error": str(e), "generated_count": 0, "output_images": [], "prompt": ""}
batch_results.append(result_data)
completed += 1
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
else:
fail_count += 1
error_msg = result_data.get("error", "未知错误") if result_data else "未知错误"
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)
return all_results
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",
category="image/generation",
inputs=[
io.String.Input(
"prompt",
default="",
multiline=True,
),
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.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=["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=0,
思考等级="",
缩放图片="不缩放",
**kwargs,
) -> io.NodeOutput:
start_time = time.time()
was_interrupted = False
生图数量 = int(生图数量)
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, 宽高比, 分辨率)
if 思考等级 not in THINKING_LEVEL_MAP:
raise ValueError("思考等级无效,仅支持:低、高")
thinking_level = (
THINKING_LEVEL_MAP[思考等级]
if model_name == "Nano Banana 2"
else None
)
actual_model = _build_model_id(model_name, 分辨率, 模型线路)
api_key = get_api_key_or_raise("O1KEY_API_KEY")
base_url = get_base_url_by_route()
pbar = ProgressBar(生图数量) if PROGRESS_BAR_AVAILABLE else None
try:
random.seed(seed)
np.random.seed(seed % (2**32))
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
]
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)
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}")
else:
mode_str = f"图生图模式 (输入{len(input_images)}张)" if input_images else "文生图模式"
print(f"Nano Banana: {mode_str} | {分辨率} {宽高比} | {生图数量}")
if batch_prompts or 生图数量 > 1:
prompts = batch_prompts if batch_prompts else [prompt]
images_per_prompt = 生图数量
total_tasks = len(prompts) * images_per_prompt
if pbar is not None:
pbar = ProgressBar(total_tasks)
def run_async_in_thread():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(
_run_with_interrupt(_process_batch_async(
base_url=base_url,
api_key=api_key,
prompts=prompts,
model=actual_model,
resolution=分辨率,
aspect_ratio=宽高比,
images_per_prompt=images_per_prompt,
input_images=input_images,
pbar=pbar,
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:
future = executor.submit(run_async_in_thread)
try:
results = future.result(timeout=_REQUEST_TIMEOUT)
except TimeoutError:
raise RuntimeError(f"任务执行超时({_REQUEST_TIMEOUT}秒)")
success_count = sum(1 for r in results if r.get("success", False))
fail_count = len(results) - success_count
elapsed = time.time() - start_time
time_str = f"{elapsed:.3f}s" if elapsed < 1 else f"{elapsed:.2f}s"
print(f"完成!总耗时 {time_str} | 成功: {success_count}/{total_tasks} | 失败: {fail_count}")
output_images = []
for r in results:
output_images.extend(r.get("output_images", []))
if not output_images:
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
output_images = [placeholder]
output_tensor = _images_to_tensor_safe(output_images, _NODE)
import gc; gc.collect()
return io.NodeOutput(output_tensor)
else:
def run_single():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
async def _do():
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,
api_key=api_key,
prompt=prompt,
model=actual_model,
resolution=分辨率,
aspect_ratio=宽高比,
images=input_images if input_images else None,
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, 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"{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)
except InterruptProcessingException:
was_interrupted = True
print("Nano Banana: 用户取消")
raise
except ValueError as e:
if str(e) == "未授权!":
print("请联系作者授权后方可使用!")
raise ValueError("未授权!") from None
raise ValueError(str(e)) from None
except RuntimeError as e:
raise RuntimeError(str(e)) from None
except Exception as e:
raise RuntimeError(str(e)) from None
finally:
if not was_interrupted:
try:
client = _get_client()
client.base_url = base_url
balance_data = client.query_balance_sync()
balance_info = client.format_balance_info(balance_data)
print(f"Nano Banana: {balance_info}")
except Exception:
pass
import gc; gc.collect()
+381
View File
@@ -0,0 +1,381 @@
"""
Nano Banana Pro 节点
ComfyUI 自定义节点,用于调用 Gemini 3 Pro 模型生成图像
"""
import time
import random
from typing import Optional, Tuple
import torch
import numpy as np
from PIL import Image
from ..utils.image_utils import tensor_to_pil, pil_to_tensor, parse_batch_prompts
from ..clients.gemini_client import GeminiAPIClient
from ..models_config import get_enabled_models, get_model_description
# 导入 ComfyUI 原生进度条
try:
from comfy.utils import ProgressBar
PROGRESS_BAR_AVAILABLE = True
except ImportError:
PROGRESS_BAR_AVAILABLE = False
print("⚠️ NanoBananaPro: comfy.utils.ProgressBar 不可用,将只使用终端进度显示")
class NanoBananaPro:
"""
Nano Banana Pro 节点
功能:
- 文生图:基于提示词生成图像
- 图生图:基于输入图像和提示词生成新图像
- 批量生成:支持并发生成多张图像
注意:
- 支持的模型列表从 models_config.py 动态加载
- 要添加/禁用模型,请编辑 models_config.py 文件
"""
# 支持的模型列表(从配置文件动态加载)
MODELS = None # 将在 INPUT_TYPES 中动态获取
# 支持的宽高比列表
ASPECT_RATIOS = [
"1:1", "4:3", "3:4", "16:9", "9:16",
"2:3", "3:2", "4:5", "5:4", "21:9"
]
# 支持的分辨率列表
RESOLUTIONS = ["1K", "2K", "4K"]
def __init__(self):
"""初始化节点"""
self.client = None
@classmethod
def INPUT_TYPES(cls):
"""
定义输入参数
ComfyUI 节点规范:
- required: 必选参数
- optional: 可选参数
"""
# 从配置文件动态获取启用的模型列表
enabled_models = get_enabled_models()
# 如果没有启用的模型,使用空列表(会导致节点不可用,提示用户配置)
if not enabled_models:
enabled_models = ["请在 models_config.py 中启用至少一个模型"]
# 创建9个独立的图像输入
optional_inputs = {}
for i in range(1, 10): # 1-9
optional_inputs[f"参考图{i}"] = ("IMAGE",)
return {
"required": {
"prompt": ("STRING", {
"default": "一个中国女子的OOTD",
"multiline": True
}),
"模型": (enabled_models, {
"default": enabled_models[0]
}),
"宽高比": (cls.ASPECT_RATIOS, {
"default": "1:1"
}),
"分辨率": (cls.RESOLUTIONS, {
"default": "2K"
}),
"生图数量": ("INT", {
"default": 1,
"min": 1,
"max": 1000,
"step": 1
}),
"像素缩放": ("BOOLEAN", {
"default": False
}),
"分辨率像素": ("FLOAT", {
"default": 1.0,
"min": 0.1,
"max": 100.0,
"step": 0.1,
"display": "number"
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 0xffffffffffffffff
})
},
"optional": optional_inputs
}
# 返回值类型
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("输出图像",)
# 执行函数名
FUNCTION = "generate"
# 节点分类
CATEGORY = "image/generation"
def resize_to_megapixels(
self,
image: Image.Image,
target_megapixels: float
) -> Image.Image:
"""
将图像缩放到指定的总像素数,保持纵横比
Args:
image: PIL Image 对象
target_megapixels: 目标像素数(百万像素)
Returns:
缩放后的 PIL Image
Example:
>>> resized = self.resize_to_megapixels(img, 2.0) # 缩放到2百万像素
"""
# 计算当前像素数
current_pixels = image.width * image.height
target_pixels = int(target_megapixels * 1_000_000)
# 如果当前像素数已经接近目标,则不缩放
if abs(current_pixels - target_pixels) / target_pixels < 0.05:
return image
# 计算缩放比例
scale = (target_pixels / current_pixels) ** 0.5
# 计算新尺寸
new_width = int(image.width * scale)
new_height = int(image.height * scale)
# 确保至少为1像素
new_width = max(1, new_width)
new_height = max(1, new_height)
# 使用 Lanczos 重采样
resized_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
return resized_image
def validate_inputs(
self,
images: Optional[torch.Tensor],
batch_size: int
) -> None:
"""
验证输入参数
Args:
images: 输入图像张量(可选)
batch_size: 批次大小
Raises:
ValueError: 如果输入参数不合法
"""
# 检查图像数量
if images is not None:
num_images = images.shape[0]
if num_images > 14:
raise ValueError(
f"输入图像数量 {num_images} 超过限制 14 张,请减少输入图像数量"
)
# 检查批次大小
if batch_size < 1 or batch_size > 1000:
raise ValueError(
f"批次大小 {batch_size} 超出范围 [1, 1000]"
)
def generate(
self,
prompt: str,
模型: str,
宽高比: str,
分辨率: str,
生图数量: int,
像素缩放: bool,
分辨率像素: float,
seed: int,
**kwargs
) -> Tuple[torch.Tensor]:
"""
生成图像
Args:
prompt: 提示词
模型: 模型名称
宽高比: 宽高比
分辨率: 分辨率
生图数量: 批次大小
像素缩放: 是否启用像素缩放
分辨率像素: 目标像素数(百万像素)
seed: 随机种子
**kwargs: 动态参考图输入 (参考图1-9)
Returns:
生成的图像张量 (IMAGE,)
"""
start_time = time.time()
# 创建 ComfyUI 原生进度条
pbar = None
if PROGRESS_BAR_AVAILABLE:
pbar = ProgressBar(生图数量)
try:
# 设置随机种子(用于本地随机操作)
random.seed(seed)
np.random.seed(seed % (2**32))
# 初始化 API 客户端
if self.client is None:
try:
self.client = GeminiAPIClient()
except ValueError as e:
raise ValueError(f"初始化失败: {str(e)}")
# 收集独立输入的参考图
input_images = []
for i in range(1, 10): # 1-9
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)
# 验证输入图像数量
if input_images:
if len(input_images) > 14:
raise ValueError(
f"输入图像数量 {len(input_images)} 超过限制 14 张,请减少输入图像数量"
)
# 应用像素缩放(如果启用)
if input_images and 像素缩放:
scaled_images = []
for img in input_images:
scaled = self.resize_to_megapixels(img, 分辨率像素)
scaled_images.append(scaled)
input_images = scaled_images
print(f"Nano Banana Pro: 已缩放 {len(scaled_images)} 张图像到 {分辨率像素}M 像素")
# 转换为 API 所需的格式
if input_images:
print(f"Nano Banana Pro: 图生图模式 (输入 {len(input_images)} 张图像)")
# 解析批量提示词
batch_prompts = parse_batch_prompts(prompt)
# 统计变量
success_count = 0
fail_count = 0
# 进度回调 - 实时显示每个任务的完成状态,并更新 ComfyUI 进度条
def progress_callback(current, total, success, error_msg=None):
nonlocal success_count, fail_count
if success:
success_count += 1
print(f"Nano Banana Pro: ✓ [{current}/{total}] 第 {success_count} 张生成成功")
else:
fail_count += 1
error_brief = error_msg[:50] + "..." if error_msg and len(error_msg) > 50 else error_msg
print(f"Nano Banana Pro: ✗ [{current}/{total}] 生成失败 - {error_brief}")
# 更新 ComfyUI 原生进度条
if pbar is not None:
pbar.update(1)
# 根据是否有批量提示词选择生成模式
if batch_prompts:
# 批量提示词模式
num_prompts = len(batch_prompts)
total_images = num_prompts * 生图数量
print(f"Nano Banana Pro: 批量提示词模式 ({num_prompts} 个提示词 × {生图数量} 张/提示词 = {total_images} 张图)")
print(f"Nano Banana Pro: 发送请求")
print(f"Nano Banana Pro: 生图中...")
# 重新创建进度条以匹配实际总数
if pbar is not None:
pbar = ProgressBar(total_images)
generated_images = self.client.generate_multi_prompts_sync(
prompts=batch_prompts,
model=模型,
resolution=分辨率,
aspect_ratio=宽高比,
images_per_prompt=生图数量,
images=input_images,
progress_callback=progress_callback
)
if fail_count > 0:
print(f"Nano Banana Pro: 生图完成 (成功: {success_count}, 失败: {fail_count})")
else:
print(f"Nano Banana Pro: 全部生图成功!")
else:
# 单提示词模式
print(f"Nano Banana Pro: {'图生图' if input_images else '文生图'}模式")
print(f"Nano Banana Pro: 发送请求")
print(f"Nano Banana Pro: 生图中...")
generated_images = self.client.generate_sync(
prompt=prompt,
model=模型,
resolution=分辨率,
aspect_ratio=宽高比,
batch_size=生图数量,
images=input_images,
progress_callback=progress_callback
)
if fail_count > 0:
print(f"Nano Banana Pro: 生图完成 (成功: {success_count}, 失败: {fail_count})")
else:
print(f"Nano Banana Pro: 全部生图成功!")
# 转换输出图像
output_tensor = pil_to_tensor(generated_images)
# 计算耗时
elapsed = time.time() - start_time
print(f"Nano Banana Pro: 完成生图 (耗时: {elapsed:.2f}s, 成功生成 {len(generated_images)} 张图像)")
return (output_tensor,)
except ValueError as e:
# 检测是否为授权错误
if str(e) == "未授权!":
print("请联系作者授权后方可使用!")
else:
# 用户输入错误
print(f"Nano Banana Pro: 输入错误 - {str(e)}")
raise
except RuntimeError as e:
# API 或网络错误
print(f"Nano Banana Pro: API 错误 - {str(e)}")
raise
except Exception as e:
# 其他未知错误
print(f"Nano Banana Pro: 未知错误 - {str(e)}")
raise
finally:
# 无论成功或失败,都尝试查询余额
if self.client is not None:
try:
balance_data = self.client.query_balance_sync()
balance_info = self.client.format_balance_info(balance_data)
print(f"Nano Banana Pro: {balance_info}")
except Exception as e:
print(f"Nano Banana Pro: ⚠️ 余额查询失败 - {str(e)}")
-252
View File
@@ -1,252 +0,0 @@
"""
Single-node new-api Veo 3.1 generator.
The node submits a /v1/videos task, waits for completion, downloads the mp4,
and returns ComfyUI's native VIDEO object for the built-in Save Video node.
"""
import os
from io import BytesIO
from typing import Optional, Tuple
from ..clients.newapi_veo_client import NewAPIVeoClient
from ..utils.image_utils import tensor_to_pil
from ..utils.config import get_base_url_by_route
try:
import folder_paths
FOLDER_PATHS_AVAILABLE = True
except ImportError:
FOLDER_PATHS_AVAILABLE = False
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
except Exception:
VideoFromFile = None
MODEL_OPTIONS = [
"veo-3.1",
]
DURATION_OPTIONS = ["4", "6", "8"]
ASPECT_RATIO_OPTIONS = ["16:9", "9:16"]
RESOLUTION_OPTIONS = ["720p", "1080p"]
TARGET_SIZE_MAP = {
("720p", "16:9"): (1280, 720),
("720p", "9:16"): (720, 1280),
("1080p", "16:9"): (1920, 1080),
("1080p", "9:16"): (1080, 1920),
}
def _get_output_dir() -> str:
if FOLDER_PATHS_AVAILABLE:
return folder_paths.get_output_directory()
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
comfy_root = os.path.dirname(os.path.dirname(plugin_dir))
return os.path.join(comfy_root, "output")
def _get_download_dir() -> str:
output_dir = _get_output_dir()
video_dir = os.path.join(output_dir, "newapi_veo")
os.makedirs(video_dir, exist_ok=True)
return video_dir
def _fit_image_to_target(image, target_size: Tuple[int, int]):
from PIL import Image as PILImage
target_w, target_h = target_size
src_w, src_h = image.size
src_ratio = src_w / src_h
target_ratio = target_w / target_h
if src_w == target_w and src_h == target_h:
return image
resample = PILImage.Resampling.LANCZOS if hasattr(PILImage, "Resampling") else PILImage.LANCZOS
if src_ratio > target_ratio:
scale = target_h / src_h
new_w = round(src_w * scale)
image = image.resize((new_w, target_h), resample=resample)
left = max(0, (new_w - target_w) // 2)
image = image.crop((left, 0, left + target_w, target_h))
else:
scale = target_w / src_w
new_h = round(src_h * scale)
image = image.resize((target_w, new_h), resample=resample)
top = max(0, (new_h - target_h) // 2)
image = image.crop((0, top, target_w, top + target_h))
return image
def _image_to_png_bytes(image_tensor, resolution: str, aspect_ratio: str) -> Optional[bytes]:
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 != "RGB":
image = image.convert("RGB")
target_size = TARGET_SIZE_MAP.get((resolution, aspect_ratio))
if target_size is not None:
original_size = image.size
image = _fit_image_to_target(image, target_size)
if image.size != original_size:
print(
"NewAPI Veo: input image fitted "
f"{original_size[0]}x{original_size[1]} -> {image.size[0]}x{image.size[1]}"
)
buffer = BytesIO()
image.save(buffer, format="PNG")
image_bytes = buffer.getvalue()
print(
"NewAPI Veo: input_reference PNG "
f"{len(image_bytes) / 1024:.0f} KB ({image.size[0]}x{image.size[1]})"
)
return image_bytes
class Google31Video:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"提示词": (
"STRING",
{
"default": "A cinematic shot of a small robot walking through a rainy neon street.",
"multiline": True,
},
),
"负向提示词": ("STRING", {"default": "", "multiline": True}),
"模型": (MODEL_OPTIONS, {"default": MODEL_OPTIONS[0]}),
"时长": (DURATION_OPTIONS, {"default": "8"}),
"宽高比": (ASPECT_RATIO_OPTIONS, {"default": "16:9"}),
"分辨率": (RESOLUTION_OPTIONS, {"default": "1080p"}),
"生成音频": (["打开", "关闭"], {"default": "打开"}),
"seed": (
"INT",
{
"default": -1,
"min": -1,
"max": 0xFFFFFFFFFFFFFFFF,
"step": 1,
},
),
},
"optional": {
"参考图像": ("IMAGE",),
},
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Video"
DESCRIPTION = (
"Submit a new-api /v1/videos Veo 3.1 task, poll until complete, "
"download the mp4, and output native VIDEO for ComfyUI Save Video."
)
def generate(
self,
提示词: str,
负向提示词: str,
模型: str,
时长: str,
宽高比: str,
分辨率: str,
生成音频: str,
seed: int,
参考图像=None,
**_kwargs,
):
if VideoFromFile is None:
raise RuntimeError("当前 ComfyUI 版本不支持原生 VIDEO 输入实现 VideoFromFile。")
prompt = (提示词 or "").strip()
if not prompt:
raise ValueError("提示词不能为空。")
duration_value = int(时长)
if duration_value not in (4, 6, 8):
raise ValueError("时长仅支持 4、6、8。")
if 宽高比 not in ASPECT_RATIO_OPTIONS:
raise ValueError("宽高比仅支持 16:9 或 9:16。")
if 分辨率 not in RESOLUTION_OPTIONS:
raise ValueError("分辨率仅支持 720p 或 1080p。")
output_dir = _get_download_dir()
image_bytes = _image_to_png_bytes(参考图像, 分辨率, 宽高比)
pbar = ProgressBar(100) if PROGRESS_BAR_AVAILABLE else None
last_progress = [0]
last_status = [""]
def progress_callback(progress: int, status: str, elapsed: float):
if status != last_status[0]:
print(
"NewAPI Veo: polling "
f"status={status} | elapsed={elapsed:.0f}s"
)
last_status[0] = status
progress = max(0, min(100, int(progress or 0)))
if pbar is not None and progress > last_progress[0]:
pbar.update(progress - last_progress[0])
last_progress[0] = progress
client = NewAPIVeoClient(base_url=get_base_url_by_route())
result = client.generate_video_sync(
prompt=prompt,
model=模型,
duration=duration_value,
aspect_ratio=宽高比,
resolution=分辨率,
output_dir=output_dir,
negative_prompt=负向提示词,
generate_audio=(生成音频 == "打开"),
image_bytes=image_bytes,
poll_interval=10,
progress_callback=progress_callback,
)
video_path = result["video_path"]
video = VideoFromFile(video_path)
print(
"NewAPI Veo: completed "
f"| task_id={result['task_id']} | video={video_path}"
)
return (video,)
NODE_CLASS_MAPPINGS = {
"Google31Video": Google31Video,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Google31Video": "Google 3.1 Video",
}
File diff suppressed because it is too large Load Diff
-196
View File
@@ -1,196 +0,0 @@
"""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
@@ -1,182 +0,0 @@
"""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
@@ -1,198 +0,0 @@
"""
提示词(多功能)节点
支持以「第一套---第二套---第三套」格式填入多套提示词,并选择处理方式:
- 全部使用:保留 --- 分隔符输出全部套数,交给下游批量节点并发跑
- 随机抽取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"{功能}|{提示词}"
-36
View File
@@ -1,36 +0,0 @@
"""
o1key 去背景节点
基于 rembg 实现,支持 CPU 推理
"""
import numpy as np
import torch
class O1keyRemoveBackground:
"""
移除图像背景,输出 RGBA 透明图层
基于 rembg (ISNet-General-Use) 模型,支持 CPU 推理。
首次运行会自动下载模型(约 170MB)。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("RGBA图像",)
FUNCTION = "remove_bg"
CATEGORY = "o1key/image"
def remove_bg(self, image):
from ..utils.rembg_utils import remove_background_tensor
print("[o1key 去背景] 正在处理...")
result = remove_background_tensor(image)
print(f"[o1key 去背景] 完成,输出 {result.shape[0]} 张 RGBA")
return (result,)
-188
View File
@@ -1,188 +0,0 @@
"""
图像元数据去除节点
提供批量去除已有图片中元数据的功能
"""
import os
from PIL import Image
from PIL.PngImagePlugin import PngInfo
# 支持的图片格式
SUPPORTED_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.tif'}
def _save_image_clean(image: Image.Image, path: str, fmt: str = None, quality: int = 95) -> None:
"""
保存图像,不包含任何元数据
通过提取纯像素数据并重建全新的 Image 对象,确保没有任何元数据残留。
Args:
image: PIL Image 对象
path: 保存路径
fmt: 图像格式(PNG/JPEG/WEBP),为 None 时根据扩展名推断
quality: JPEG/WEBP 质量(1-100
"""
# 确保 RGB 模式
if image.mode != 'RGB':
image = image.convert('RGB')
# 提取纯像素数据,重建全新的 Image 对象
# 使用 tobytes() + frombytes() 确保只保留像素数据,彻底断开与原图像的关联
pixel_data = image.tobytes()
clean = Image.frombytes('RGB', image.size, pixel_data)
# 显式清空 info 字典,确保不会有任何残留元数据
clean.info = {}
# 推断格式
if fmt is None:
ext = os.path.splitext(path)[1].lower()
format_map = {
'.png': 'PNG',
'.jpg': 'JPEG',
'.jpeg': 'JPEG',
'.webp': 'WEBP',
'.bmp': 'BMP',
'.tiff': 'TIFF',
'.tif': 'TIFF',
}
fmt = format_map.get(ext, 'PNG')
# 构建保存参数(确保不写入任何元数据)
save_kwargs = {}
if fmt == 'PNG':
save_kwargs['pnginfo'] = PngInfo() # 空的 PngInfo,不包含任何文本块
elif fmt == 'JPEG':
save_kwargs['quality'] = quality
# 不传 exif 参数,自然不会写入 EXIF 数据
elif fmt == 'WEBP':
save_kwargs['quality'] = quality
save_kwargs['exif'] = b"" # 显式清空 EXIF
clean.save(path, format=fmt, **save_kwargs)
# ============================================================================
# 批量去除元数据
# ============================================================================
class BatchCleanMetadata:
"""
批量去除文件夹中图片元数据的节点
功能:
- 指定文件夹路径,批量处理其中所有图片
- 去除 EXIF、PNG tEXt 块、ComfyUI 工作流等所有元数据
- 支持保存到原目录(添加 _nometa 后缀)或覆盖原文件
- 支持 PNG/JPG/JPEG/WEBP/BMP/TIFF 格式
使用场景:
- 已经保存了一批含有 AI 元数据的图片,需要批量清理
- 批量处理指定文件夹中的所有图片
"""
@classmethod
def INPUT_TYPES(cls):
"""
定义输入参数
Returns:
输入参数配置字典
"""
return {
"required": {
"文件夹路径": ("STRING", {"default": ""}),
"覆盖原文件": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("处理结果",)
OUTPUT_NODE = True
FUNCTION = "batch_clean"
CATEGORY = "image"
DESCRIPTION = (
"批量去除文件夹中图片的元数据。\n"
"支持 PNG/JPG/JPEG/WEBP/BMP/TIFF 格式。\n"
"默认在原文件名后添加 _nometa 后缀保存,也可选择覆盖原文件。"
)
def batch_clean(
self,
文件夹路径: str,
覆盖原文件: bool = False,
) -> tuple:
"""
批量去除文件夹中图片的元数据
Args:
文件夹路径: 待处理图片所在的文件夹路径
覆盖原文件: 是否覆盖原文件(False 则添加 _nometa 后缀)
Returns:
处理结果字符串
Raises:
ValueError: 文件夹路径无效
"""
if not 文件夹路径 or not 文件夹路径.strip():
raise ValueError("请输入文件夹路径")
folder = 文件夹路径.strip()
if not os.path.isdir(folder):
raise ValueError(f"文件夹路径无效或不存在: {folder}")
# 扫描支持的图片文件
files = []
for f in sorted(os.listdir(folder)):
ext = os.path.splitext(f)[1].lower()
if ext in SUPPORTED_EXTENSIONS:
files.append(f)
if not files:
msg = f"文件夹中未找到支持的图片文件 ({', '.join(SUPPORTED_EXTENSIONS)})"
print(f"批量去除元数据: {msg}")
return (msg,)
print(f"批量去除元数据: 找到 {len(files)} 张图片,开始处理...")
success_count = 0
fail_count = 0
for f in files:
try:
src_path = os.path.join(folder, f)
img = Image.open(src_path)
if 覆盖原文件:
dst_path = src_path
else:
name, ext = os.path.splitext(f)
dst_path = os.path.join(folder, f"{name}_nometa{ext}")
_save_image_clean(img, dst_path)
success_count += 1
except Exception as e:
print(f"批量去除元数据: 处理 {f} 失败 - {str(e)}")
fail_count += 1
# 构建结果消息
if fail_count > 0:
msg = f"处理完成: 成功 {success_count} 张, 失败 {fail_count}"
else:
msg = f"处理完成: 全部 {success_count} 张成功"
if not 覆盖原文件:
msg += " (已添加 _nometa 后缀)"
else:
msg += " (已覆盖原文件)"
print(f"批量去除元数据: {msg}")
return (msg,)
-138
View File
@@ -1,138 +0,0 @@
"""保存图像节点 - 支持 PNG/JPEG/WebP 格式输出"""
import os
import json
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
import folder_paths
from comfy.cli_args import args
class SaveImageFormat:
"""保存图像,支持 PNG / JPEG / WebP 三种格式"""
FORMATS = ["PNG", "JPEG", "WebP"]
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
self.compress_level = 4
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"图像": ("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": ""}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ()
FUNCTION = "save_images"
OUTPUT_NODE = True
CATEGORY = "image"
DESCRIPTION = "保存图像,支持 PNG / JPEG / WebP 格式输出。"
_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",
质量=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,
images[0].shape[1], images[0].shape[0]
)
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))
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_{ext}"
filepath = os.path.join(full_output_folder, file)
if format == "PNG":
metadata = None
if not args.disable_metadata:
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
img.save(filepath, pnginfo=metadata,
compress_level=self.compress_level)
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,
"subfolder": subfolder,
"type": self.type,
})
counter += 1
return {"ui": {"images": results}}
-237
View File
@@ -1,237 +0,0 @@
"""
o1key SavePSD 节点
将多个 IMAGE 图层合成为分层 PSD 文件
手写 PSD 二进制格式,零外部依赖(仅 numpy + Pillow
"""
import os
import struct
import time
import numpy as np
import torch
from PIL import Image
import folder_paths
def _pad_even(data: bytes) -> bytes:
if len(data) % 2:
return data + b"\x00"
return data
def _pad4(data: bytes) -> bytes:
return data + (b"\x00" * ((4 - (len(data) % 4)) % 4))
def _pascal_name(name: str) -> bytes:
raw = name.encode("macroman", errors="replace")[:255]
data = bytes([len(raw)]) + raw
return _pad4(data)
def _unicode_name_block(name: str) -> bytes:
payload = struct.pack(">I", len(name)) + name.encode("utf-16be")
block = b"8BIM" + b"luni" + struct.pack(">I", len(payload)) + _pad_even(payload)
return block
def _layer_extra_data(name: str) -> bytes:
data = b""
data += struct.pack(">I", 0) # layer mask data length
data += struct.pack(">I", 0) # layer blending ranges length
data += _pascal_name(name)
data += _unicode_name_block(name)
return data
def _alpha_bbox(rgba_arr: np.ndarray):
"""找到 RGBA 数组中非透明区域的 bounding box。"""
alpha = rgba_arr[:, :, 3]
rows = np.any(alpha > 0, axis=1)
cols = np.any(alpha > 0, axis=0)
if not rows.any():
return None
top = int(np.argmax(rows))
bottom = int(len(rows) - np.argmax(rows[::-1]))
left = int(np.argmax(cols))
right = int(len(cols) - np.argmax(cols[::-1]))
return top, left, bottom, right
def write_psd(filepath: str, layers: list, canvas_w: int, canvas_h: int):
"""
写入 PSD 文件。
layers: [(name, rgba_array), ...] 从底到顶排列
rgba_array: numpy uint8 [H, W, 4]
"""
records = []
channel_data_blocks = []
layers_top_to_bottom = list(reversed(layers))
for name, rgba in layers_top_to_bottom:
bbox = _alpha_bbox(rgba)
if not bbox:
continue
top, left, bottom, right = bbox
cropped = rgba[top:bottom, left:right]
# PLACEHOLDER_CHANNELS
channels = [
(0, cropped[:, :, 0].tobytes(order="C")),
(1, cropped[:, :, 1].tobytes(order="C")),
(2, cropped[:, :, 2].tobytes(order="C")),
(-1, cropped[:, :, 3].tobytes(order="C")),
]
channel_info = b""
data_block = b""
for channel_id, data in channels:
channel_info += struct.pack(">hI", channel_id, 2 + len(data))
data_block += struct.pack(">H", 0) + data # raw compression
extra = _layer_extra_data(name)
record = b""
record += struct.pack(">iiii", top, left, bottom, right)
record += struct.pack(">H", len(channels))
record += channel_info
record += b"8BIM" + b"norm"
record += bytes([255, 0, 0, 0]) # opacity=255, clipping, flags, filler
record += struct.pack(">I", len(extra)) + extra
records.append(record)
channel_data_blocks.append(data_block)
if not records:
raise ValueError("所有图层均为空(完全透明),无法生成 PSD")
# Layer and Mask Information
layer_info = struct.pack(">h", len(records))
layer_info += b"".join(records) + b"".join(channel_data_blocks)
layer_info = _pad_even(layer_info)
layer_info_block = struct.pack(">I", len(layer_info)) + layer_info
global_mask = struct.pack(">I", 0)
layer_mask_payload = layer_info_block + global_mask
layer_and_mask = struct.pack(">I", len(layer_mask_payload)) + layer_mask_payload
# PLACEHOLDER_COMPOSITE
# Composite preview (flattened image for compatibility)
comp = Image.new("RGBA", (canvas_w, canvas_h), (255, 255, 255, 255))
for name, rgba in layers:
layer_img = Image.fromarray(rgba, "RGBA")
comp.alpha_composite(layer_img)
comp_rgb = np.asarray(comp.convert("RGB"), dtype=np.uint8)
composite_data = (
struct.pack(">H", 0)
+ comp_rgb[:, :, 0].tobytes(order="C")
+ comp_rgb[:, :, 1].tobytes(order="C")
+ comp_rgb[:, :, 2].tobytes(order="C")
)
# Write PSD file
with open(filepath, "wb") as f:
# Header
f.write(b"8BPS")
f.write(struct.pack(">H", 1)) # version
f.write(b"\x00" * 6) # reserved
f.write(struct.pack(">HIIHH", 3, canvas_h, canvas_w, 8, 3))
# Color Mode Data
f.write(struct.pack(">I", 0))
# Image Resources
f.write(struct.pack(">I", 0))
# Layer and Mask
f.write(layer_and_mask)
# Composite Image Data
f.write(composite_data)
# PLACEHOLDER_NODE
class O1keySavePSD:
"""
将多个 IMAGE 输入合成为分层 PSD 文件
每个输入作为独立图层,支持 RGBA 透明通道。
图层从下到上排列(图层1在最底部)。
使用 bbox 裁剪优化文件大小,包含合成预览层。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"批次图像": ("IMAGE", {
"tooltip": "批次图像输入,每张图自动作为独立图层(支持RGBA透明)",
}),
},
"optional": {
"图层名称": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "每行一个图层名称,与图层顺序对应。留空则自动命名。",
}),
"文件名前缀": ("STRING", {
"default": "o1key_layers",
"tooltip": "输出 PSD 文件名前缀",
}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("文件路径",)
FUNCTION = "save_psd"
CATEGORY = "o1key/image"
OUTPUT_NODE = True
def save_psd(self, 批次图像, 图层名称: str = "", 文件名前缀: str = "o1key_layers", **kwargs):
# 将批次 tensor [B, H, W, C] 拆为单张列表
if 批次图像.dim() == 3:
layer_tensors = [批次图像]
else:
layer_tensors = [批次图像[i] for i in range(批次图像.shape[0])]
names = [n.strip() for n in 图层名称.split("\n") if n.strip()]
# 确定画布尺寸
max_h, max_w = 0, 0
for t in layer_tensors:
h, w = t.shape[0], t.shape[1]
max_h = max(max_h, h)
max_w = max(max_w, w)
# 转换为 [(name, rgba_array), ...] 格式
layers = []
for idx, tensor in enumerate(layer_tensors):
arr = (tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
h, w = arr.shape[0], arr.shape[1]
channels = arr.shape[2] if arr.ndim == 3 else 1
if channels == 3:
rgba = np.zeros((max_h, max_w, 4), dtype=np.uint8)
rgba[:h, :w, :3] = arr
rgba[:h, :w, 3] = 255
elif channels == 4:
rgba = np.zeros((max_h, max_w, 4), dtype=np.uint8)
rgba[:h, :w] = arr
else:
rgba = np.zeros((max_h, max_w, 4), dtype=np.uint8)
rgba[:h, :w, 0] = rgba[:h, :w, 1] = rgba[:h, :w, 2] = arr[:, :, 0] if arr.ndim == 3 else arr
rgba[:h, :w, 3] = 255
name = names[idx] if idx < len(names) else f"图层 {idx + 1}"
layers.append((name, rgba))
print(f"[o1key SavePSD] 图层 '{name}': {w}×{h}")
# 写入 PSD
output_dir = folder_paths.get_output_directory()
timestamp = time.strftime("%Y%m%d_%H%M%S")
filename = f"{文件名前缀}_{timestamp}.psd"
filepath = os.path.join(output_dir, filename)
write_psd(filepath, layers, max_w, max_h)
size_kb = os.path.getsize(filepath) / 1024
print(f"[o1key SavePSD] 完成: {filepath} ({size_kb:.0f}KB, "
f"{len(layers)} 层, {max_w}×{max_h})")
return (filepath,)
-881
View File
@@ -1,881 +0,0 @@
"""
Seedance 2.0 / 2.5 自动过审节点(xinhankr/可美线路)
与 Seedance / SeedanceMultiModal 的差异:
- 模型名用 seedance-2.0 / seedance-2.0-fast / seedance-2.0-minifast、mini 仅支持 480p/720p
- 界面仅保留多模态、首尾帧两种生成模式
- 多模态无素材时自动作为文生视频;首尾帧根据尾帧是否连接自动选择首帧/首尾帧
- 参考素材可自动创建为素材,也可直接使用手动填写的 asset ID
- 首/尾帧和 asset:// 素材使用 content body
端点不变:POST /v1/video/generations、GET /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
@@ -1,859 +0,0 @@
"""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
@@ -1,127 +0,0 @@
"""
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 创建素材",
}
-629
View File
@@ -1,629 +0,0 @@
"""
Seedance 多模态参考生视频节点
"""
import io as py_io
import json
import tempfile
import aiohttp
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_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, io
# ── 模型列表 ──────────────────────────────────────────────────────────────────
_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())
# 多模态节点矩阵式模型配置(主模型 × 模型线路 → 实际模型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
# ── 模型能力判断 ──────────────────────────────────────────────────────────────
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",
]
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def _format_mb(size_bytes: int) -> str:
return f"{size_bytes / 1024 / 1024:.2f}MB"
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 = py_io.BytesIO()
image.save(buffered, format="PNG")
image_bytes = buffered.getvalue()
image_size = len(image_bytes)
if image_size > _MAX_IMAGE_BYTES:
raise ValueError(
f"Seedance {label}大小 {_format_mb(image_size)} 超过单张图片 "
f"{_format_mb(_MAX_IMAGE_BYTES)} 限制,请先压缩或缩小图片。"
)
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):
body_size = len(json.dumps(body, ensure_ascii=False).encode("utf-8"))
if body_size > _MAX_REQUEST_BODY_BYTES:
raise ValueError(
f"{tag} 请求体大小 {_format_mb(body_size)} 超过 "
f"{_format_mb(_MAX_REQUEST_BODY_BYTES)} 限制,请减少参考图片数量或降低图片尺寸。"
)
print(
f"[{tag}] 请求体大小: {_format_mb(body_size)} "
f"(限制 {_format_mb(_MAX_REQUEST_BODY_BYTES)})"
)
_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"""
try:
from PIL import Image
async with aiohttp.ClientSession() as session:
async with session.get(url, allow_redirects=True) as resp:
if resp.status != 200:
return None
data = await resp.read()
img = Image.open(py_io.BytesIO(data)).convert("RGB")
return pil_to_tensor([img])
except Exception as e:
print(f"[Seedance] 末帧图片下载失败: {e}")
return None
def _show_balance():
"""完成后打印余额(静默失败)"""
try:
client = GeminiAPIClient()
data = client.query_balance_sync()
print(f"Seedance: {client.format_balance_info(data)}")
except Exception:
pass
def _make_pbar():
try:
from comfy.utils import ProgressBar
return ProgressBar(100)
except Exception:
return None
def _make_callbacks(tag: str, pbar):
def on_stage(stage: str):
if stage == "submitting":
print(f"[{tag}] 提交中...")
if pbar: pbar.update_absolute(0, 100)
elif stage.startswith("submitted:"):
print(f"[{tag}] 已提交 → {stage.split(':', 1)[1]}")
if pbar: pbar.update_absolute(5, 100)
elif stage == "downloading":
print(f"[{tag}] 下载视频中...")
if pbar: pbar.update_absolute(99, 100)
elif stage == "done":
print(f"[{tag}] 完成")
if pbar: pbar.update_absolute(100, 100)
def on_progress(pct: int):
if pbar: pbar.update_absolute(5 + int(pct * 0.94), 100)
return on_stage, on_progress
# ── 多模态参考生视频节点 ──────────────────────────────────────────────────────
class SeedanceMultiModal(io.ComfyNode):
"""Seedance 2.0 / 2.5 多模态参考生视频。"""
@classmethod
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="末帧图片"),
],
)
@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]
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()
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_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)
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)]
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 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()
# 真人素材 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:
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 = await _tensor_to_uploaded_url(img_tensor, base_url, f"参考图片{idx}")
content.append({
"type": "image_url",
"image_url": {"url": url},
"role": "reference_image",
})
# 参考视频(2.5 最多 10 个)
for v in ref_videos:
url = await upload_video(v, base_url=base_url)
content.append({
"type": "video_url",
"video_url": {"url": url},
"role": "reference_video",
})
# 视频素材 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, 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})
if not content:
raise ValueError("content 为空,请至少提供参考图片、参考视频或提示词。")
# ── 构建请求体 ──────────────────────────────────────────────────────
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 not in ("智能", "adaptive"): # 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 web_search:
metadata["tools"] = [{"type": "web_search"}]
# 顶层 image:取第一张图的 URL(优先真人素材,其次参考图的上传 URL)
first_image_url = next(
(item["image_url"]["url"] for item in content if item["type"] == "image_url"),
None,
)
body = {
"model": model,
"prompt": prompt if prompt else " ",
"metadata": metadata,
}
if first_image_url:
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 = 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 io.NodeOutput(InputImpl.VideoFromFile(result_path), last_frame_tensor)
finally:
_show_balance()
# ── 节点注册 ──────────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {
"SeedanceMultiModal": SeedanceMultiModal,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SeedanceMultiModal": "Seedance 多模态参考生视频",
}
-530
View File
@@ -1,530 +0,0 @@
"""
Sora 视频生成节点
ComfyUI 自定义节点,调用 Sora API 生成视频
"""
import os
import re
import time
from math import gcd
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,
get_all_sora_seconds,
get_all_sora_sizes,
get_sora_supported_seconds,
get_sora_supported_sizes,
get_sora_seconds_with_labels,
get_sora_sizes_with_labels,
SORA_MODELS,
)
try:
import folder_paths
FOLDER_PATHS_AVAILABLE = True
except ImportError:
FOLDER_PATHS_AVAILABLE = False
try:
from comfy.utils import ProgressBar
PROGRESS_BAR_AVAILABLE = True
except ImportError:
PROGRESS_BAR_AVAILABLE = False
print("⚠️ SoraVideo: comfy.utils.ProgressBar 不可用,将只使用终端进度显示")
def _size_to_display(size: str) -> str:
"""
'WxH' 格式的分辨率转换为友好显示名。
例如:
"720x1280""720P 9:16"
"1280x720""720P 16:9"
"1024x1792""1K 4:7"
"1792x1024""1K 7:4"
Args:
size: 分辨率字符串,格式 "WxH"
Returns:
友好显示名字符串
"""
parts = size.lower().split("x")
w, h = int(parts[0]), int(parts[1])
short_side = min(w, h)
if short_side >= 3840:
res = "4K"
elif short_side >= 1920:
res = "2K"
elif short_side >= 1080:
res = "1K"
elif short_side >= 720:
res = "720P"
elif short_side >= 480:
res = "480P"
else:
res = f"{short_side}P"
g = gcd(w, h)
ratio = f"{w // g}:{h // g}"
return f"{res} {ratio} ({size})"
def _build_size_display_map(sizes: list) -> dict:
"""
构建 显示名 → 实际值 映射字典。
Args:
sizes: 实际分辨率列表,如 ["720x1280", "1280x720"]
Returns:
字典,key 为显示名,value 为实际分辨率字符串
"""
mapping = {}
for size in sizes:
display = _size_to_display(size)
if display in mapping:
# 极少数情况下防止重名
display = f"{display} ({size})"
mapping[display] = size
return mapping
def _get_video_output_dir() -> str:
"""获取视频输出目录: ComfyUI/output/video"""
if FOLDER_PATHS_AVAILABLE:
base = folder_paths.get_output_directory()
else:
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base = os.path.join(os.path.dirname(os.path.dirname(plugin_dir)), "output")
video_dir = os.path.join(base, "video")
os.makedirs(video_dir, exist_ok=True)
return video_dir
def _get_next_counter(directory: str, prefix: str) -> int:
"""扫描目录,获取下一个可用的文件计数器"""
if not os.path.exists(directory):
return 1
pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)")
max_counter = 0
for f in os.listdir(directory):
m = pattern.match(f)
if m:
max_counter = max(max_counter, int(m.group(1)))
return max_counter + 1
def _fit_image_to_target(image, target_size: str):
"""
将参考图片按 "等比缩放覆盖 + 居中裁剪" 策略适配到目标分辨率。
策略 (Cover Crop)
1. 比较图片宽高比和目标宽高比
2. 等比缩放,使图片最短边刚好覆盖目标对应边(图片完全覆盖目标区域)
3. 居中裁剪多余部分,得到精确目标尺寸
Args:
image: PIL Image 对象
target_size: 目标分辨率字符串,格式 "WxH"(如 "720x1280"
Returns:
适配后的 PIL Image 对象
"""
from PIL import Image as PILImage
# 解析目标尺寸
parts = target_size.lower().split("x")
target_w, target_h = int(parts[0]), int(parts[1])
src_w, src_h = image.size
src_ratio = src_w / src_h
target_ratio = target_w / target_h
# 宽高比一致且尺寸不超过目标,无需处理
if abs(src_ratio - target_ratio) < 0.01 and src_w <= target_w and src_h <= target_h:
return image
print(f"Sora: 参考图片 {src_w}x{src_h} (比例 {src_ratio:.2f}) → 目标 {target_w}x{target_h} (比例 {target_ratio:.2f})")
# 获取高质量重采样滤波器
resample = PILImage.Resampling.LANCZOS if hasattr(PILImage, "Resampling") else PILImage.LANCZOS
# Cover Crop: 缩放使图片完全覆盖目标区域,然后居中裁剪
if src_ratio > target_ratio:
# 图片更宽:以高度为基准缩放,裁左右
scale = target_h / src_h
new_w = round(src_w * scale)
new_h = target_h
image = image.resize((new_w, new_h), resample=resample)
# 居中裁剪宽度
left = (new_w - target_w) // 2
image = image.crop((left, 0, left + target_w, target_h))
else:
# 图片更高(或一样):以宽度为基准缩放,裁上下
scale = target_w / src_w
new_w = target_w
new_h = round(src_h * scale)
image = image.resize((new_w, new_h), resample=resample)
# 居中裁剪高度
top = (new_h - target_h) // 2
image = image.crop((0, top, target_w, top + target_h))
print(f"Sora: 参考图片已适配为 {image.size[0]}x{image.size[1]}")
return image
def _compress_image_for_upload(
image,
target_size: Optional[str] = None,
) -> bytes:
"""
将 PIL Image 适配目标分辨率并编码为 PNG 字节,用于上传。
============================================================
⚠️ 已验证可用的标准做法,请勿随意修改以下编码逻辑!
============================================================
经过多轮调试(2026-02-28),以下参数组合为唯一验证成功的方案:
1. 图片格式:PNGformat="PNG"
- 不可改为 JPEG —— API 会校验 Content-Type,抓包确认服务端使用 image/png
- 不可使用 base64 字符串 —— 会报 "expected a file, got a string"
- 不可使用 data URI —— 服务端不识别,返回 500
2. 图片尺寸:必须与视频分辨率完全一致(target_size
- 不可缩放降采样 —— 会报 "Inpaint image must match the requested width and height"
- 尺寸由 _fit_image_to_target() 保证(等比缩放 + 居中裁剪)
3. 上传方式:由调用方(sora_client.py)以 multipart/form-data 文件字段上传
- filename="reference.png", content_type="image/png"
- 不可改回 application/json —— 服务端校验 input_reference 必须为 file 类型
============================================================
Args:
image: PIL Image 对象
target_size: 目标分辨率字符串 "WxH"(如 "720x1280"
Returns:
PNG 格式的二进制字节
"""
from io import BytesIO
# 统一转换为 RGB(去除透明通道及其他模式)
if image.mode != "RGB":
image = image.convert("RGB")
# 适配到目标分辨率(等比缩放 + 居中裁剪)
# ⚠️ 必须保持此尺寸不变,API 强制要求参考图片与视频分辨率完全一致
if target_size:
image = _fit_image_to_target(image, target_size)
# ⚠️ 必须使用 PNG 格式,不可改为 JPEG 或其他格式
buffered = BytesIO()
image.save(buffered, format="PNG")
size_kb = buffered.tell() / 1024
print(f"Sora: 参考图片编码为 PNG{size_kb:.0f} KB ({image.size[0]}x{image.size[1]})")
return buffered.getvalue()
class SoraVideo:
"""
Sora 视频生成节点
功能:
- 文生视频:基于提示词生成视频
- 图生视频:基于参考图片和提示词生成视频
- 异步轮询:自动等待生成完成并下载
"""
def __init__(self):
self.client = None
self._client_config_signature = None
@classmethod
def INPUT_TYPES(cls):
from ..models_config import SECONDS_DISPLAY_MAP, RESOLUTION_DISPLAY_MAP
enabled_models = get_enabled_sora_models()
if not enabled_models:
enabled_models = ["请在 models_config.py 中启用至少一个 Sora 模型"]
# 构建秒数选项列表(按数字顺序排序)
# 格式: ["4", "8", "10", "12", "15", "25(pro)"]
all_seconds_display = []
seen_seconds = set()
for model_id in enabled_models:
supported = get_sora_supported_seconds(model_id)
for s in supported:
if s not in seen_seconds:
seen_seconds.add(s)
display = SECONDS_DISPLAY_MAP.get(s, str(s))
all_seconds_display.append((s, display))
# 按秒数数值排序
all_seconds_display = sorted(all_seconds_display, key=lambda x: x[0])
seconds_options = [d for _, d in all_seconds_display] if all_seconds_display else ["4", "8", "12"]
# 构建分辨率选项列表(去重)
# 格式: ["720P", "1080P"]
seen_resolutions = set()
for model_id in enabled_models:
supported = get_sora_supported_sizes(model_id)
for size in supported:
if size in RESOLUTION_DISPLAY_MAP:
res_name, _ = RESOLUTION_DISPLAY_MAP[size]
seen_resolutions.add(res_name)
resolution_options = sorted(list(seen_resolutions)) if seen_resolutions else ["720P"]
return {
"required": {
"prompt": ("STRING", {
"default": "A calico cat playing a piano on stage",
"multiline": True,
}),
"模型": (enabled_models, {
"default": enabled_models[0],
}),
"分辨率": (resolution_options, {
"default": resolution_options[0] if resolution_options else "720P",
}),
"宽高比": (["竖屏", "横屏"], {
"default": "竖屏",
}),
"视频时长": (seconds_options, {
"default": seconds_options[0] if seconds_options else "4",
}),
"生成数量": ("INT", {
"default": 1,
"min": 1,
"max": 10,
"step": 1,
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 0xffffffffffffffff
}),
},
"optional": {
"参考图片": ("IMAGE",),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("预览视频",)
FUNCTION = "generate_video"
CATEGORY = "video/generation"
DESCRIPTION = (
"Sora 视频生成节点。\n"
"支持文生视频和图生视频,自动轮询任务状态并下载视频。\n"
"视频保存到 ComfyUI/output/video/ 目录。\n\n"
"【模型说明】\n"
"• sora-2:官方模型,支持 4/8/12秒、720P 分辨率\n"
"• sora-2-pro:增强模型,支持全时长(含25秒)、1080P 分辨率\n\n"
"【时长说明】\n"
"• 25(pro):仅 sora-2-pro 支持的25秒时长\n\n"
"【分辨率说明】\n"
"• 720Psora-2 和 sora-2-pro 均支持\n"
"• 1080P:仅 sora-2-pro 支持的高清分辨率"
)
def generate_video(
self,
prompt: str,
模型: str,
**kwargs,
) -> Tuple[str]:
from ..models_config import SECONDS_DISPLAY_MAP, RESOLUTION_DISPLAY_MAP
视频时长_display = kwargs.pop("视频时长", "4")
分辨率_display = kwargs.pop("分辨率", "720P")
宽高比 = kwargs.pop("宽高比", "竖屏")
生成数量 = kwargs.pop("生成数量", 1)
seed = kwargs.pop("seed", 0)
start_time = time.time()
# 解析秒数显示值(如 "25(pro)" → 25
seconds = 4 # 默认
for actual, display in SECONDS_DISPLAY_MAP.items():
if display == 视频时长_display:
seconds = actual
break
# 如果找不到映射,尝试直接解析数字
if seconds == 4 and 视频时长_display != "4":
try:
seconds = int(视频时长_display.replace("(pro)", ""))
except ValueError:
seconds = 4
# 根据分辨率和宽高比确定实际分辨率值
分辨率 = "720x1280" # 默认
for actual, (res_name, orientation) in RESOLUTION_DISPLAY_MAP.items():
if res_name == 分辨率_display and orientation == 宽高比:
分辨率 = actual
break
# 检查参考图片
ref_image = kwargs.get("参考图片")
ref_image_bytes = None
if ref_image is not None:
pil_images = tensor_to_pil(ref_image)
if pil_images:
ref_image_bytes = _compress_image_for_upload(pil_images[0], target_size=分辨率)
mode_str = "图生视频 (含参考图)" if ref_image_bytes else "文生视频"
# 获取用户友好的显示值用于日志
seconds_display = SECONDS_DISPLAY_MAP.get(seconds, str(seconds))
res_display = f"{分辨率_display} {宽高比}"
if 生成数量 > 1:
print(f"Sora: {mode_str} | 并发{生成数量}个 | {模型} | {seconds_display} | {res_display}")
else:
print(f"Sora: {mode_str} | {模型} | {seconds_display} | {res_display}")
# 校验参数兼容性
supported_seconds = get_sora_supported_seconds(模型)
if supported_seconds and seconds not in supported_seconds:
# 构建带标签的支持时长列表
supported_labels = []
for s in supported_seconds:
display = SECONDS_DISPLAY_MAP.get(s, str(s))
supported_labels.append(display)
raise ValueError(
f"时长 {SECONDS_DISPLAY_MAP.get(seconds, str(seconds))} 与模型 \"{模型}\" 不兼容!\n"
f"该模型支持的时长: {', '.join(supported_labels)}"
)
supported_sizes = get_sora_supported_sizes(模型)
if supported_sizes and 分辨率 not in supported_sizes:
# 检查该分辨率是否为Pro独占
pro_only_sizes = ["1024x1792", "1792x1024"]
_, orientation = RESOLUTION_DISPLAY_MAP.get(分辨率, (分辨率, ""))
extra_hint = f"\n提示:1080P {orientation} 为 sora-2-pro 独占,请切换模型或选择720P。" if 分辨率 in pro_only_sizes else ""
raise ValueError(
f"分辨率 \"{分辨率_display} {宽高比}\" 与模型 \"{模型}\" 不兼容!"
f"支持的分辨率: {', '.join(supported_sizes)}" + extra_hint
)
# 准备保存路径
video_dir = _get_video_output_dir()
counter = _get_next_counter(video_dir, "sora")
# ProgressBar
pbar = None
if PROGRESS_BAR_AVAILABLE:
pbar = ProgressBar(生成数量 if 生成数量 > 1 else 100)
try:
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:
# ── 单个视频:保留详细进度(提交→轮询→下载)
save_path = os.path.join(video_dir, f"sora_{counter:05d}.mp4")
last_progress = [0]
def progress_callback(progress_pct: int):
print(
f"\rSora: 生成中... 进度: {progress_pct}%",
end="", flush=True
)
if pbar is not None and progress_pct > last_progress[0]:
pbar.update(progress_pct - last_progress[0])
last_progress[0] = progress_pct
def on_stage(stage: str):
if stage == "submitting":
print("Sora: 正在提交视频生成任务...")
elif stage.startswith("submitted:"):
vid = stage.split(":", 1)[1]
print(f"Sora: 视频任务已提交,ID: {vid}")
elif stage == "polling":
print("Sora: 等待视频生成...")
elif stage == "downloading":
print("") # 换行(结束 \r 行)
print("Sora: 视频生成完成,正在下载...")
result_path = self.client.generate_video_sync(
prompt=prompt,
model=模型,
seconds=seconds,
size=分辨率,
save_path=save_path,
input_reference_bytes=ref_image_bytes,
seed=seed,
progress_callback=progress_callback,
on_stage=on_stage,
)
result_paths = [result_path]
else:
# ── 批量并发:同时提交多个任务
save_paths = [
os.path.join(video_dir, f"sora_{counter + i:05d}.mp4")
for i in range(生成数量)
]
success_count = [0]
fail_count = [0]
def batch_progress_callback(current: int, total: int, success: bool, error_msg):
if success:
success_count[0] += 1
print(f"Sora: 第 {current}/{total} 个视频完成 ✓")
else:
fail_count[0] += 1
print(f"Sora: 第 {current}/{total} 个视频失败 ✗")
if error_msg:
print(f"原始错误详情:\n{error_msg}")
if pbar is not None:
pbar.update(1)
print(f"Sora: 正在并发提交 {生成数量} 个视频任务,请耐心等待...")
result_paths = self.client.generate_batch_videos_sync(
prompt=prompt,
model=模型,
seconds=seconds,
size=分辨率,
save_paths=save_paths,
input_reference_bytes=ref_image_bytes,
seed=seed,
progress_callback=batch_progress_callback,
)
elapsed = time.time() - start_time
time_str = f"{elapsed:.2f}s" if elapsed >= 1 else f"{elapsed:.3f}s"
print(f"Sora: 完成!总耗时 {time_str} | 已生成 {len(result_paths)} 个视频")
for p in result_paths:
print(f"{p}")
output_path = "\n".join(result_paths)
return (output_path,)
except ValueError as e:
error_msg = str(e)
print(f"\nSora: ❌ {error_msg}")
raise ValueError(error_msg) from None
except RuntimeError as e:
error_msg = str(e)
print(f"\nSora: ❌ {error_msg}")
raise RuntimeError(error_msg) from None
except Exception as e:
error_msg = str(e)
print(f"\nSora: ❌ {error_msg}")
raise type(e)(error_msg) from None
finally:
if self.client is not None:
try:
balance_data = self.client.query_balance_sync()
balance_info = self.client.format_balance_info(balance_data)
print(f"Sora: {balance_info}")
except Exception:
pass
-28
View File
@@ -1,28 +0,0 @@
"""
流式文本预览节点
接收文本输入,支持 markdown 渲染,通过 ComfyUI 事件系统实时推送内容
"""
class StreamPreview:
"""
流式 Markdown 预览节点
接收任意文本,在节点面板中实时渲染为 Markdown 格式
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"文本": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("文本",)
FUNCTION = "preview"
CATEGORY = "text/preview"
OUTPUT_NODE = True
def preview(self, 文本: str):
return {"ui": {"text": [文本]}, "result": (文本,)}
-628
View File
@@ -1,628 +0,0 @@
"""
提示词专家节点
ComfyUI 自定义节点,通过 OpenAI 兼容协议调用市面上主流的 AI 对话大模型
支持多模态(图片输入),单轮对话,非流式输出
API 密钥和地址通过插件统一配置(环境变量或 .config 文件),与 Google Gemini 节点一致
"""
import os
import time
import base64
import json
from io import BytesIO
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_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-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
# 图片最大文件大小(20MB
MAX_IMAGE_SIZE = 20 * 1024 * 1024
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):
"""
提示词专家
功能:
- 通过 OpenAI 兼容协议调用主流大模型
- 支持多模态(图片输入)
- 单轮对话,非流式输出
- API 密钥和地址继承插件统一配置
"""
@classmethod
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:
"""如果图片过长边超过限制,等比缩放"""
w, h = img.size
max_dim = max(w, h)
if max_dim > MAX_IMAGE_DIMENSION:
scale = MAX_IMAGE_DIMENSION / max_dim
new_w, new_h = int(w * scale), int(h * scale)
print(f"提示词专家: 图片缩放 {w}x{h} -> {new_w}x{new_h}")
return img.resize((new_w, new_h), Image.Resampling.LANCZOS)
return img
def _image_to_data_url(self, img: Image.Image) -> str:
"""将 PIL Image 转为 data URLJPEG base64"""
img = self._resize_image(img)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
for quality in [92, 82, 72, 60, 45]:
buf = BytesIO()
img.save(buf, format='JPEG', quality=quality, optimize=True)
data = buf.getvalue()
if len(data) <= MAX_IMAGE_SIZE:
b64 = base64.b64encode(data).decode('utf-8')
return f"data:image/jpeg;base64,{b64}"
b64 = base64.b64encode(data).decode('utf-8')
return f"data:image/jpeg;base64,{b64}"
# 文件大小限制
MAX_FILE_SIZE = 50 * 1024 * 1024 # 单文件 50MB
MAX_TOTAL_FILE_SIZE = 50 * 1024 * 1024 # 所有文件总计 50MB
# 常见 MIME 类型映射
MIME_MAP = {
".pdf": "application/pdf",
".txt": "text/plain",
".md": "text/markdown",
".csv": "text/csv",
".json": "application/json",
".py": "text/x-python",
".js": "text/javascript",
".html": "text/html",
".xml": "application/xml",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".zip": "application/zip",
}
# 纯文本类型,直接读取内容
TEXT_EXTS = {".txt", ".md", ".csv", ".json", ".py", ".js", ".ts", ".html",
".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".log",
".sh", ".bat", ".sql", ".css", ".scss", ".jsx", ".tsx"}
def _load_files(self, file_paths_str: str) -> List[dict]:
"""读取文件列表,返回 content part 数组"""
if not file_paths_str or not file_paths_str.strip():
return []
paths = [p.strip() for p in file_paths_str.split(",") if p.strip()]
parts = []
total_size = 0
for path in paths:
if not os.path.isfile(path):
raise ValueError(f"文件不存在: {path}")
file_size = os.path.getsize(path)
if file_size > self.MAX_FILE_SIZE:
raise ValueError(f"文件 {os.path.basename(path)} 大小 {file_size / 1024 / 1024:.1f}MB 超过单文件 50MB 限制")
total_size += file_size
if total_size > self.MAX_TOTAL_FILE_SIZE:
raise ValueError(f"所有文件总大小超过 50MB 限制")
ext = os.path.splitext(path)[1].lower()
mime = self.MIME_MAP.get(ext, "application/octet-stream")
filename = os.path.basename(path)
if ext in self.TEXT_EXTS:
# 文本文件直接读取内容
with open(path, "r", encoding="utf-8", errors="replace") as f:
text_content = f.read()
parts.append({
"type": "text",
"text": f"[文件: {filename}]\n```\n{text_content}\n```",
})
else:
# 二进制文件转 base64,使用 file 格式(OpenAI 兼容协议)
with open(path, "rb") as f:
file_data = base64.b64encode(f.read()).decode("utf-8")
parts.append({
"type": "file",
"file": {
"filename": filename,
"file_data": f"data:{mime};base64,{file_data}",
},
})
print(f"提示词专家: 加载文件 {filename} ({file_size / 1024:.1f}KB, {mime})")
return parts
def _build_input(
self,
prompt: str,
image_tensors: Optional[List[torch.Tensor]] = None,
file_paths: str = "",
file_list: Optional[FileList] = None,
video=None,
) -> list:
"""构建 chat/completions 格式的 messages 数组"""
image_data_urls = []
pil_images_cache = [] # 保留 PIL Image 用于总体积重新编码
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')
pil_images_cache.append(img_resized)
image_data_urls.append(self._image_to_data_url(img_resized))
# 多图总体积控制
if pil_images_cache and len(pil_images_cache) > 1:
total_bytes = sum(
len(base64.b64decode(url.split(',', 1)[1])) for url in image_data_urls
)
if total_bytes > MAX_IMAGE_SIZE:
print(f"提示词专家: 图片总体积 {total_bytes / 1024 / 1024:.2f}MB 超过 {MAX_IMAGE_SIZE // 1024 // 1024}MB 限制,正在压缩...")
# 降质量
compressed = False
for quality in [80, 70, 60, 50, 40, 30, 20]:
new_urls = []
for img in pil_images_cache:
buf = BytesIO()
img.save(buf, format='JPEG', quality=quality, optimize=True)
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
new_urls.append(f"data:image/jpeg;base64,{b64}")
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"提示词专家: 图片压缩完成,总体积 {total_bytes / 1024 / 1024:.2f}MB ({len(pil_images_cache)}张图片,质量{quality})")
compressed = True
break
# 降分辨率
if not compressed:
for scale in [0.75, 0.5, 0.35]:
new_urls = []
for img in pil_images_cache:
w, h = img.size
resized = img.resize((int(w * scale), int(h * scale)), Image.Resampling.LANCZOS)
buf = BytesIO()
resized.save(buf, format='JPEG', quality=20, optimize=True)
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
new_urls.append(f"data:image/jpeg;base64,{b64}")
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"提示词专家: 图片压缩完成,总体积 {total_bytes / 1024 / 1024:.2f}MB ({len(pil_images_cache)}张图片,缩放{int(scale*100)}%)")
compressed = True
break
if not compressed:
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 类型)
video_url_str = ""
if video is not None:
# 从 VIDEO 对象中提取文件路径
vp = None
if isinstance(video, dict):
vp = video.get("video") or video.get("path") or video.get("file") or video.get("filename")
if not vp:
for val in video.values():
if isinstance(val, str) and os.path.exists(val):
vp = val
break
elif isinstance(video, str):
vp = video
else:
for attr in ("video", "path", "filename"):
if hasattr(video, attr):
vp = getattr(video, attr)
break
if not vp and hasattr(video, "__dict__"):
for attr_val in video.__dict__.values():
if isinstance(attr_val, str) and os.path.isfile(attr_val):
vp = attr_val
break
if not vp or not os.path.isfile(vp):
raise ValueError(f"视频文件不存在或路径无效: {vp}")
mime_map = {
".mp4": "video/mp4", ".mpeg": "video/mpeg", ".mpg": "video/mpg",
".mov": "video/quicktime", ".avi": "video/x-msvideo",
".flv": "video/x-flv", ".webm": "video/webm",
".wmv": "video/x-ms-wmv", ".mkv": "video/x-matroska",
}
ext = os.path.splitext(vp)[1].lower()
mime = mime_map.get(ext, "video/mp4")
file_size = os.path.getsize(vp)
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}"
# 加载文件:优先使用 FILE_LIST,其次使用字符串路径
file_parts = []
if file_list:
for fd in file_list:
print(f"提示词专家: 使用文件 {fd.filename}{fd.extension} ({fd.size / 1024:.1f}KB)")
file_parts.append({
"type": "file",
"file": {
"filename": fd.filename + fd.extension,
"file_data": f"data:{fd.mime_type};base64,{fd.data}",
},
})
elif file_paths:
file_parts = self._load_files(file_paths)
# 纯文本,无图片无文件无视频
if not image_data_urls and not file_parts and not video_url_str:
return [{"role": "user", "content": prompt}]
content_parts = []
# 图片
for url in image_data_urls:
content_parts.append({
"type": "image_url",
"image_url": {"url": url},
})
# 视频:用 image_url 类型传 data URLGemini OpenAI 兼容层支持此格式)
# 同时保留 video_url 类型作为备用(其他支持 video_url 的模型)
if video_url_str:
content_parts.append({
"type": "image_url",
"image_url": {"url": video_url_str},
})
# 文件
for fp in file_parts:
content_parts.append(fp)
content_parts.append({
"type": "text",
"text": prompt,
})
return [{"role": "user", "content": content_parts}]
@staticmethod
def _send_stream_token(node_id, token, done=False):
"""通过 PromptServer 向前端推送流式 token"""
try:
from server import PromptServer
PromptServer.instance.send_sync(
"o1key.stream_token",
{"node_id": str(node_id), "token": token, "done": done},
)
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 = "",
seed: int = 0,
提示词: str = "",
视频=None,
文件: Optional[FileList] = None,
node_id: str = "",
**kwargs,
) -> Tuple[str]:
start_time = time.time()
try:
# 用户填写 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")
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(提示词, image_tensors, "", 文件, 视频)
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"提示词专家: 模型 = {模型}")
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 截断显示
def _truncate_for_log(obj):
if isinstance(obj, dict):
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:"):
return obj[:60] + f"...[{len(obj)}chars]"
return obj
print(f"提示词专家: 请求原始内容 = {json.dumps(_truncate_for_log(request_body), ensure_ascii=False)}")
# 发送请求(在独立线程中运行异步请求,避免与 ComfyUI 事件循环冲突)
import aiohttp
import asyncio
from concurrent.futures import ThreadPoolExecutor
async def _do_request():
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {effective_api_key}",
}
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:
status = resp.status
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])
except Exception:
err_msg = body[:200]
if status == 401:
raise ValueError(f"认证失败:API Key 无效或已过期")
elif status == 403:
raise ValueError(f"无权访问模型 {模型}")
elif status == 429:
raise ValueError(f"请求频率超限,请稍后重试")
elif status == 404:
raise ValueError(f"模型 {模型} 不存在或 API 地址错误")
else:
raise RuntimeError(f"API 错误 ({status}): {err_msg}")
# 流式读取,拼接 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
delta = choices[0].get("delta", {})
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)
reply = "".join(reply_parts)
if not reply:
raise RuntimeError("模型未返回有效文本内容")
return reply
def _run_in_thread():
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(_do_request())
finally:
loop.close()
with ThreadPoolExecutor(max_workers=1) as pool:
reply = pool.submit(_run_in_thread).result()
elapsed = time.time() - start_time
print(f"提示词专家: 生成完成 (耗时: {elapsed:.2f}s)")
if reply:
preview = reply[:100] + "..." if len(reply) > 100 else reply
print(f"提示词专家: 回复预览: {preview}")
return (reply,)
except ValueError as e:
if str(e) == "未授权!":
print("提示词专家: 请联系作者授权后方可使用!")
raise ValueError("未授权!") from None
error_msg = str(e).split('\n')[0]
print(f"提示词专家: ❌ {error_msg}")
raise
except Exception as e:
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
-426
View File
@@ -1,426 +0,0 @@
"""
Google Veo 视频生成节点
ComfyUI 自定义节点,调用 Veo API 生成视频
"""
import os
import re
import time
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,
VEO_MODELS,
VEO_RESOLUTION_MAP,
)
try:
import folder_paths
FOLDER_PATHS_AVAILABLE = True
except ImportError:
FOLDER_PATHS_AVAILABLE = False
try:
from comfy.utils import ProgressBar
PROGRESS_BAR_AVAILABLE = True
except ImportError:
PROGRESS_BAR_AVAILABLE = False
print("⚠️ GoogleVeo: comfy.utils.ProgressBar 不可用,将只使用终端进度显示")
def _get_video_output_dir() -> str:
"""获取视频输出目录: ComfyUI/output/video"""
if FOLDER_PATHS_AVAILABLE:
base = folder_paths.get_output_directory()
else:
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base = os.path.join(os.path.dirname(os.path.dirname(plugin_dir)), "output")
video_dir = os.path.join(base, "video")
os.makedirs(video_dir, exist_ok=True)
return video_dir
def _get_next_counter(directory: str, prefix: str) -> int:
"""扫描目录,获取下一个可用的文件计数器"""
if not os.path.exists(directory):
return 1
pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)")
max_counter = 0
for f in os.listdir(directory):
m = pattern.match(f)
if m:
max_counter = max(max_counter, int(m.group(1)))
return max_counter + 1
def _fit_image_to_target(image, target_size: str):
"""
将参考图片按 "等比缩放覆盖 + 居中裁剪" 策略适配到目标分辨率。
"""
from PIL import Image as PILImage
parts = target_size.lower().split("x")
target_w, target_h = int(parts[0]), int(parts[1])
src_w, src_h = image.size
src_ratio = src_w / src_h
target_ratio = target_w / target_h
if abs(src_ratio - target_ratio) < 0.01 and src_w <= target_w and src_h <= target_h:
return image
print(f"Veo: 参考图片 {src_w}x{src_h} (比例 {src_ratio:.2f}) → 目标 {target_w}x{target_h} (比例 {target_ratio:.2f})")
resample = PILImage.Resampling.LANCZOS if hasattr(PILImage, "Resampling") else PILImage.LANCZOS
if src_ratio > target_ratio:
scale = target_h / src_h
new_w = round(src_w * scale)
new_h = target_h
image = image.resize((new_w, new_h), resample=resample)
left = (new_w - target_w) // 2
image = image.crop((left, 0, left + target_w, target_h))
else:
scale = target_w / src_w
new_w = target_w
new_h = round(src_h * scale)
image = image.resize((new_w, new_h), resample=resample)
top = (new_h - target_h) // 2
image = image.crop((0, top, target_w, top + target_h))
print(f"Veo: 参考图片已适配为 {image.size[0]}x{image.size[1]}")
return image
def _compress_image_to_bytes(image, target_size: Optional[str] = None) -> bytes:
"""
将 PIL Image 适配目标分辨率并编码为 PNG 字节
"""
from io import BytesIO
if image.mode != "RGB":
image = image.convert("RGB")
if target_size:
image = _fit_image_to_target(image, target_size)
buffered = BytesIO()
image.save(buffered, format="PNG")
size_kb = buffered.tell() / 1024
print(f"Veo: 参考图片编码为 PNG{size_kb:.0f} KB ({image.size[0]}x{image.size[1]})")
return buffered.getvalue()
class GoogleVeo:
"""
Google Veo 视频生成节点
功能:
- 文生视频:基于提示词生成视频
- 图生视频:基于首帧/尾帧/参考图生成视频
- 异步轮询:自动等待生成完成并下载
"""
def __init__(self):
self.client = None
self._client_config_signature = None
@classmethod
def INPUT_TYPES(cls):
enabled_models = get_enabled_veo_models()
if not enabled_models:
enabled_models = ["请在 models_config.py 中启用 Veo 模型"]
# 分辨率选项
resolution_options = ["720p", "1080p", "4K"]
# 宽高比选项
aspect_ratio_options = ["16:9", "9:16"]
# 视频秒数选项
seconds_options = ["4", "6", "8"]
return {
"required": {
"prompt": ("STRING", {
"default": "A calico cat playing a piano on stage",
"multiline": True,
}),
"模型": (enabled_models, {
"default": enabled_models[0] if enabled_models else "Veo3.1",
}),
"分辨率": (resolution_options, {
"default": "720p",
}),
"宽高比": (aspect_ratio_options, {
"default": "9:16",
}),
"视频时长": (seconds_options, {
"default": "8",
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 0xffffffffffffffff,
}),
"生成数量": ("INT", {
"default": 1,
"min": 1,
"max": 10,
"step": 1,
}),
},
"optional": {
"首帧": ("IMAGE",),
"尾帧": ("IMAGE",),
"参考图": ("IMAGE",),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("预览视频",)
FUNCTION = "generate_video"
CATEGORY = "video/generation"
DESCRIPTION = (
"Google Veo 视频生成节点。\n"
"支持文生视频和图生视频(图生视频支持首帧、尾帧、参考图)。\n"
"视频保存到 ComfyUI/output/video/ 目录。\n\n"
"【模型说明】\n"
"• Veo3.1Google 最新视频生成模型\n\n"
"【分辨率说明】\n"
"• 720p:标清\n"
"• 1080p:高清\n"
"• 4K:超高清\n\n"
"【时长说明】\n"
"• 4秒:短视频\n"
"• 6秒:标准\n"
"• 8秒:长视频(默认)\n\n"
"【图生视频说明】\n"
"• 首帧:视频开始的第一帧图像\n"
"• 尾帧:视频结束时的最后一帧图像\n"
"• 参考图:参考图像(与首帧/尾帧配合使用)\n"
"• 至少需要提供首帧或参考图之一"
)
def generate_video(
self,
prompt: str,
模型: str,
**kwargs,
) -> Tuple[str]:
分辨率 = kwargs.pop("分辨率", "720p")
宽高比 = kwargs.pop("宽高比", "9:16")
视频时长 = kwargs.pop("视频时长", "8")
seed = kwargs.pop("seed", 0)
生成数量 = kwargs.pop("生成数量", 1)
start_time = time.time()
# 解析视频时长
seconds = int(视频时长)
# 解析分辨率和宽高比,映射到模型名称
size_key = f"{分辨率}_{宽高比}"
actual_size = VEO_RESOLUTION_MAP.get(size_key)
if not actual_size:
# 默认值
actual_size = "720x1280" # 720p 9:16
# 检查是否有参考图输入
首帧 = kwargs.get("首帧")
尾帧 = kwargs.get("尾帧")
参考图 = kwargs.get("参考图")
has_image = 首帧 is not None or 尾帧 is not None or 参考图 is not None
# 根据是否有图片选择模型前缀
if has_image:
model_prefix = "veo3.1"
else:
model_prefix = "veo3.1"
# 构建完整模型名称
# 格式: veo3.1-portrait / veo3.1-landscape / veo3.1-portrait-fl / veo3.1-landscape-fl 等
if 分辨率 == "720p":
res_suffix = ""
if 宽高比 == "9:16":
orientation = "portrait"
else:
orientation = "landscape"
elif 分辨率 == "1080p":
res_suffix = "-hd"
if 宽高比 == "9:16":
orientation = "portrait"
else:
orientation = "landscape"
else: # 4K
res_suffix = "-4k"
if 宽高比 == "9:16":
orientation = "portrait"
else:
orientation = "landscape"
# 图生视频添加 -fl 后缀
if has_image:
model_suffix = f"-{orientation}-fl{res_suffix}"
else:
model_suffix = f"-{orientation}{res_suffix}"
model = f"{model_prefix}{model_suffix}"
# 准备图片字节
first_frame_bytes = None
last_frame_bytes = None
reference_bytes = None
if 首帧 is not None:
pil_images = tensor_to_pil(首帧)
if pil_images:
first_frame_bytes = _compress_image_to_bytes(pil_images[0], target_size=actual_size)
if 尾帧 is not None:
pil_images = tensor_to_pil(尾帧)
if pil_images:
last_frame_bytes = _compress_image_to_bytes(pil_images[0], target_size=actual_size)
if 参考图 is not None:
pil_images = tensor_to_pil(参考图)
if pil_images:
reference_bytes = _compress_image_to_bytes(pil_images[0], target_size=actual_size)
mode_str = "图生视频" if has_image else "文生视频"
print(f"Veo: {mode_str} | 并发{生成数量}个 | 模型: {model} | {seconds}秒 | {分辨率} {宽高比}")
# 准备保存路径
video_dir = _get_video_output_dir()
counter = _get_next_counter(video_dir, "veo")
# ProgressBar
pbar = None
if PROGRESS_BAR_AVAILABLE:
pbar = ProgressBar(生成数量 if 生成数量 > 1 else 100)
try:
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")
last_progress = [0]
def progress_callback(progress_pct: int):
print(
f"\rVeo: 生成中... 进度: {progress_pct}%",
end="", flush=True
)
if pbar is not None and progress_pct > last_progress[0]:
pbar.update(progress_pct - last_progress[0])
last_progress[0] = progress_pct
def on_stage(stage: str):
if stage == "submitting":
print("Veo: 正在提交视频生成任务...")
elif stage.startswith("submitted:"):
vid = stage.split(":", 1)[1]
print(f"Veo: 视频任务已提交,ID: {vid}")
elif stage == "polling":
print("Veo: 等待视频生成...")
elif stage == "downloading":
print("")
print("Veo: 视频生成完成,正在下载...")
result_path = self.client.generate_video_sync(
prompt=prompt,
model=model,
seconds=seconds,
size=actual_size,
save_path=save_path,
first_frame_bytes=first_frame_bytes,
last_frame_bytes=last_frame_bytes,
reference_bytes=reference_bytes,
seed=seed,
progress_callback=progress_callback,
on_stage=on_stage,
)
result_paths = [result_path]
else:
save_paths = [
os.path.join(video_dir, f"veo_{counter + i:05d}.mp4")
for i in range(生成数量)
]
success_count = [0]
def batch_progress_callback(current: int, total: int, success: bool, error_msg):
if success:
success_count[0] += 1
print(f"Veo: 第 {current}/{total} 个视频完成 ✓")
else:
print(f"Veo: 第 {current}/{total} 个视频失败 ✗")
if error_msg:
print(f"原始错误详情:\n{error_msg}")
if pbar is not None:
pbar.update(1)
print(f"Veo: 正在并发提交 {生成数量} 个视频任务,请耐心等待...")
result_paths = self.client.generate_batch_videos_sync(
prompt=prompt,
model=model,
seconds=seconds,
size=actual_size,
save_paths=save_paths,
first_frame_bytes=first_frame_bytes,
last_frame_bytes=last_frame_bytes,
reference_bytes=reference_bytes,
seed=seed,
progress_callback=batch_progress_callback,
)
elapsed = time.time() - start_time
time_str = f"{elapsed:.2f}s" if elapsed >= 1 else f"{elapsed:.3f}s"
print(f"Veo: 完成!总耗时 {time_str} | 已生成 {len(result_paths)} 个视频")
for p in result_paths:
print(f"{p}")
output_path = "\n".join(result_paths)
return (output_path,)
except ValueError as e:
error_msg = str(e)
print(f"\nVeo: ❌ {error_msg}")
raise ValueError(error_msg) from None
except RuntimeError as e:
error_msg = str(e)
print(f"\nVeo: ❌ {error_msg}")
raise RuntimeError(error_msg) from None
except Exception as e:
error_msg = str(e)
print(f"\nVeo: ❌ {error_msg}")
raise type(e)(error_msg) from None
finally:
if self.client is not None:
try:
balance_data = self.client.query_balance_sync()
balance_info = self.client.format_balance_info(balance_data)
print(f"Veo: {balance_info}")
except Exception:
pass
NODE_CLASS_MAPPINGS = {
"GoogleVeo": GoogleVeo,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"GoogleVeo": "Google Veo - ab",
}
-90
View File
@@ -1,90 +0,0 @@
"""
视频预览节点
接收 VIDEO 类型,在前端内嵌播放器预览
"""
import os
import io
try:
import folder_paths
FOLDER_PATHS_AVAILABLE = True
except ImportError:
FOLDER_PATHS_AVAILABLE = False
def _get_output_dir() -> str:
if FOLDER_PATHS_AVAILABLE:
return folder_paths.get_output_directory()
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(os.path.dirname(os.path.dirname(plugin_dir)), "output")
class VideoPreview:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"视频": ("VIDEO",),
},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "preview"
CATEGORY = "comfyui_o1key/Utils"
def preview(self, 视频) -> dict:
# 用官方接口取文件路径
source = 视频.get_stream_source()
if isinstance(source, io.BytesIO):
# BytesIO 情况:写到 output/video/ 临时文件
output_dir = os.path.join(_get_output_dir(), "video")
os.makedirs(output_dir, exist_ok=True)
filename = "preview_tmp.mp4"
tmp_path = os.path.join(output_dir, filename)
source.seek(0)
with open(tmp_path, "wb") as f:
f.write(source.read())
subfolder = "video"
else:
video_path = source
output_dir = _get_output_dir()
abs_video = os.path.abspath(video_path)
abs_output = os.path.abspath(output_dir)
if abs_video.startswith(abs_output):
rel_path = os.path.relpath(abs_video, abs_output)
subfolder = os.path.dirname(rel_path).replace("\\", "/")
filename = os.path.basename(rel_path)
else:
# 文件在 output 目录外,复制一份
target_dir = os.path.join(output_dir, "video")
os.makedirs(target_dir, exist_ok=True)
filename = os.path.basename(abs_video)
target_path = os.path.join(target_dir, filename)
if not os.path.exists(target_path):
import shutil
shutil.copy2(abs_video, target_path)
subfolder = "video"
return {
"ui": {
"videos": [{
"filename": filename,
"subfolder": subfolder,
"type": "output",
}],
}
}
NODE_CLASS_MAPPINGS = {
"VideoPreview": VideoPreview,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"VideoPreview": "预览视频",
}
-226
View File
@@ -1,226 +0,0 @@
"""
视频裁剪节点
上传本地视频(或接入上游 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
@@ -1,24 +0,0 @@
"""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")
-3
View File
@@ -1,6 +1,3 @@
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
@@ -1,11 +0,0 @@
# 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
@@ -1,50 +0,0 @@
"""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
@@ -1,46 +0,0 @@
"""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
@@ -1,28 +0,0 @@
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
@@ -1,60 +0,0 @@
"""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
@@ -1,344 +0,0 @@
"""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
@@ -1,110 +0,0 @@
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"]);

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