优化多图输入处理并添加视频上传缓存
This commit is contained in:
@@ -1,154 +1,166 @@
|
||||
<div align="center">
|
||||
<img src="https://registry.npmmirror.com/@lobehub/icons-static-png/1.74.0/files/dark/jimeng-color.png" width="120" />
|
||||
</div>
|
||||
|
||||
# ComfyUI 即梦 API 节点
|
||||
|
||||
本项目为 [ComfyUI](https://github.com/comfyanonymous/ComfyUI) 提供了火山方舟的视觉模型(即梦/豆包) API 节点。用户可以通过这些节点在 ComfyUI 中使用多种图像生成和视频生成功能。
|
||||
|
||||
* Seedance 2.0 面向企业用户开放公测,项目已支持 `Seedance 2.0` 与 `Seedance 2.0 fast` 视频生成节点,可用于文生视频、多模态参考、视频编辑与视频延长场景。
|
||||
|
||||
## ⚠️ 模型下线与节点弃用说明
|
||||
|
||||
火山方舟平台会随着模型迭代逐步下线旧版本模型。根据官方公告,`doubao-seedream-3-0-t2i-250415` 与 `doubao-seedance-1-0-lite-i2v-250428 / doubao-seedance-1-0-lite-t2v-250428` 已进入下线流程,平台将逐步下调配额并在到期后完成服务下线与替换。
|
||||
|
||||
- **已标记为即将弃用**:
|
||||
- `图像生成(Seedream 3)`(对应模型:doubao-seedream-3-0-t2i-250415)
|
||||
- `视频生成(参考图生视频)`(对应模型:doubao-seedance-1-0-lite-i2v-250428 / doubao-seedance-1-0-lite-t2v-250428)
|
||||
- **建议使用新节点**:
|
||||
- `图像生成(Seedream 3)` → `图像生成(Seedream 5)`
|
||||
- `视频生成(参考图生视频)` → `视频生成(Seedance 2.0)`
|
||||
|
||||
|
||||
* 如在使用过程中遇到问题,请通过 [ISSUES](https://github.com/fkxianzhou/ComfyUI-Jimeng-API/issues) 反馈。
|
||||
|
||||
* 暂不支持 ComfyUI 的 Nodes 2.0 新版 UI,如遇到显示异常:
|
||||
1. 点击左侧/顶部 ComfyUI 图标 打开菜单。
|
||||
2. 找到并关闭 Nodes 2.0 选项,切换回传统界面。
|
||||
|
||||
## ✨ 项目特性
|
||||
|
||||
- **多 Key 管理**:支持在配置文件中设置多个 API Key,并在节点中灵活切换,方便管理不同账户或配额。
|
||||
- **异步与并发**:所有核心节点均支持任务的异步提交和并发生成,无需阻塞队列,大幅提升批量生成效率。
|
||||
- **友好交互**:提供清晰的控制台进度提示和完善的异常处理机制,报错信息直观,便于快速排查问题。
|
||||
|
||||
## 📦 安装
|
||||
|
||||
方式1: **克隆仓库**:
|
||||
打开终端,`cd` 到 ComfyUI 的 `custom_nodes` 目录,运行:
|
||||
```bash
|
||||
</div>
|
||||
|
||||
# ComfyUI 即梦 API 节点
|
||||
|
||||
本项目为 [ComfyUI](https://github.com/comfyanonymous/ComfyUI) 提供了火山方舟的视觉模型(即梦/豆包) API 节点。用户可以通过这些节点在 ComfyUI 中使用多种图像生成和视频生成功能。
|
||||
|
||||
- Seedance 2.0 面向企业用户开放公测,项目已支持 `Seedance 2.0` 与 `Seedance 2.0 fast` 视频生成节点,可用于文生视频、多模态参考、视频编辑与视频延长场景。
|
||||
|
||||
## ⚠️ 模型下线与节点弃用说明
|
||||
|
||||
火山方舟平台会随着模型迭代逐步下线旧版本模型。目前`doubao-seedream-3-0` 与 `doubao-seedance-1-0-lite` 已进入下线流程,平台将逐步下调配额并在到期后完成服务下线与替换。
|
||||
|
||||
- **已标记为即将弃用**:
|
||||
- `图像生成(Seedream 3)`(对应模型:doubao-seedream-3-0)
|
||||
- `视频生成(参考图生视频)`(对应模型:doubao-seedance-1-0-lite)
|
||||
- **建议使用新节点**:
|
||||
- `图像生成(Seedream 3)` → `图像生成(Seedream 5)`
|
||||
- `视频生成(参考图生视频)` → `视频生成(Seedance 2.0)`
|
||||
|
||||
* 如在使用过程中遇到问题,请通过 [ISSUES](https://github.com/fkxianzhou/ComfyUI-Jimeng-API/issues) 反馈。
|
||||
* 暂不支持 ComfyUI 的 Nodes 2.0 新版 UI,如遇到显示异常:
|
||||
1. 点击左侧/顶部 ComfyUI 图标 打开菜单。
|
||||
2. 找到并关闭 Nodes 2.0 选项,切换回传统界面。
|
||||
|
||||
## ✨ 项目特性
|
||||
|
||||
- **多 Key 管理**:支持在配置文件中设置多个 API Key,并在节点中灵活切换,方便管理不同账户或配额。
|
||||
- **异步与并发**:所有核心节点均支持任务的异步提交和并发生成,无需阻塞队列,大幅提升批量生成效率。
|
||||
- **友好交互**:提供清晰的控制台进度提示和完善的异常处理机制,报错信息直观,便于快速排查问题。
|
||||
|
||||
## 📦 安装
|
||||
|
||||
方式1: **克隆仓库**:
|
||||
打开终端,`cd` 到 ComfyUI 的 `custom_nodes` 目录,运行:
|
||||
`bash
|
||||
git clone https://github.com/fkxianzhou/ComfyUI-Jimeng-API
|
||||
```
|
||||
|
||||
方式2: **使用ComfyUI Manager下载**。
|
||||
|
||||
## ⚙️ 设置:配置 API 密钥
|
||||
|
||||
### 方式 1:手动配置
|
||||
|
||||
1. 在插件根目录中找到 `api_keys.json.example` 文件。
|
||||
2. 将其**重命名**为 `api_keys.json`。
|
||||
3. 打开文件并填入您的密钥信息([从此获取 Key](https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey))。
|
||||
|
||||
### 方式 2:节点内配置
|
||||
|
||||
1. 在 ComfyUI 中添加 **Jimeng API Client** 节点。
|
||||
2. 在 `key_name` 下拉框中选择 **Custom**。
|
||||
3. 在弹出的输入框中填入您的 API Key。
|
||||
4. (可选)在 `new_key_name` 中填入一个名称(如 "MyKey"),运行一次后该 Key 将被自动保存。
|
||||
* *注意:保存后需刷新浏览器页面,新密钥才会显示在下拉列表中。*
|
||||
|
||||
## 📖 功能节点列表
|
||||
|
||||
(所有节点均位于 `JimengAI` 菜单下)
|
||||
|
||||
- **基础设置**:
|
||||
- `火山方舟 API 客户端`: **(必须)** 用于加载并创建一个可供其他节点使用的客户端实例。
|
||||
- `Jimeng 配额设置`: 用于设置图像和视频生成的配额限制,防止意外消耗过多资源。
|
||||
- **图像生成**:
|
||||
- `图像生成(Seedream 3)`: 支持**文生图**和**图生图**模式的基础生成节点。
|
||||
- `图像生成(Seedream 4)`: 高级图像生成节点,支持多图输入、**组图生成**以及 4.5 模型。
|
||||
- `图像生成(Seedream 5)`: 最新一代图像生成节点,支持多图输入、组图生成以及**联网搜索**。
|
||||
- **视频生成**:
|
||||
- `视频生成(Seedance 1.0)`: 核心视频生成节点,支持文生视频、图生视频(首/尾帧)。
|
||||
- `视频生成(Seedance 1.5 Pro)`: 支持**音频生成**和**智能时长**的高级视频生成节点。
|
||||
- `视频生成(Seedance 2.0)`: 支持**图片/视频/音频多模态参考**、**视频编辑/延长**与**联网搜索增强**。
|
||||
- `视频生成(参考图生视频)`: 根据 1-4 张**参考图像**生成视频。
|
||||
- `视频生成任务列表查询`: 用于查询和管理在 API 上运行的任务历史。
|
||||
- **视觉理解**:
|
||||
- `视觉理解(Visual Understanding)`: 使用 Seed 2.0 模型进行视觉理解对话,支持图片/视频输入和多轮对话。
|
||||
|
||||
## 📑 节点详解
|
||||
|
||||
### `火山方舟 API 客户端 (Jimeng API Client)`
|
||||
加载 `api_keys.json` 中的密钥配置。这是所有工作流的起点。
|
||||
- **输入**: `密钥名称` (在 JSON 中配置的 customName)。
|
||||
- **输出**: `客户端` 实例。
|
||||
|
||||
### `Jimeng 配额设置 (Jimeng Quota Settings)`
|
||||
允许为当前客户端设置图像(张数)和视频(Tokens)的使用上限。
|
||||
- **特性**: 当达到限额时自动停止任务并抛出提示,防止额度超支。
|
||||
- **示例工作流**:
|
||||

|
||||
|
||||
---
|
||||
|
||||
### `图像生成(Seedream 3)`
|
||||
- **模式**: 不连接 `输入图像` 为文生图,连接则为图生图。
|
||||
|
||||
**示例工作流**:
|
||||

|
||||
|
||||
---
|
||||
|
||||
### `图像生成(Seedream 4)`
|
||||
支持 `doubao-seedream-4.5`。
|
||||
- **输入图像**: 支持单张或多张(Batch)图像作为参考。
|
||||
- **启用组图生成**: 开启后可一次性生成多张内容关联的图片。
|
||||
|
||||
**示例工作流**:
|
||||

|
||||
|
||||
---
|
||||
|
||||
### `图像生成(Seedream 5)`
|
||||
支持最新的 `doubao-seedream-5.0-lite`。
|
||||
- **输入图像**: 支持单张或多张(Batch)图像作为参考。
|
||||
- **启用组图生成**: 开启后可一次性生成多张内容关联的图片。
|
||||
- **联网搜索**: 开启后模型可自主搜索互联网内容(如实时信息、商品等)辅助生成。
|
||||
|
||||
**示例工作流**:
|
||||

|
||||
|
||||
---
|
||||
|
||||
### `视频生成(Seedance 1.0 / 1.5 Pro / 2.0)`
|
||||
- **1.0 特性**: 支持首/尾帧视频生成,支持非阻塞异步模式。
|
||||
- **1.5 Pro 特性**: 支持**音效生成**和**智能时长**控制。
|
||||
- **2.0 特性**: 支持最多 **9 张参考图 + 3 段参考视频 + 3 段参考音频**,可覆盖文生视频、多模态参考生视频、视频编辑、视频延长与联网搜索增强。
|
||||
|
||||
### `视频生成(参考图生视频)`
|
||||
支持上传 1-4 张图像作为风格或内容参考生成视频。
|
||||
|
||||
### `视频生成任务列表查询`
|
||||
支持按状态、模型版本或任务 ID 过滤查询任务历史。
|
||||
|
||||
**示例工作流**:
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
### `视觉理解(Visual Understanding)`
|
||||
支持 `doubao-seed-2.0` 系列模型。
|
||||
- **多模态输入**: 支持上传图片或视频进行理解和问答。
|
||||
- **多轮对话**: 支持开启多轮对话模式,保持上下文。
|
||||
- **深度思考**: 支持开启深度思考模式,提升复杂问题的推理能力。
|
||||
|
||||
**示例工作流**:
|
||||

|
||||
|
||||
## 📓 示例工作流
|
||||
|
||||
|
||||
您可以在 `example_workflows` 目录中找到所有节点的示例工作流。
|
||||
`
|
||||
   
|
||||
|
||||
方式2: **使用ComfyUI Manager下载**。
|
||||
|
||||
## ⚙️ 设置:配置 API 密钥
|
||||
|
||||
### 方式 1:手动配置
|
||||
|
||||
1. 在插件根目录中找到 `api_keys.json.example` 文件。
|
||||
2. 将其**重命名**为 `api_keys.json`。
|
||||
3. 打开文件并填入您的密钥信息([从此获取 Key](https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey))。
|
||||
|
||||
### 方式 2:节点内配置
|
||||
|
||||
1. 在 ComfyUI 中添加 **Jimeng API Client** 节点。
|
||||
2. 在 `key_name` 下拉框中选择 **Custom**。
|
||||
3. 在弹出的输入框中填入您的 API Key。
|
||||
4. (可选)在 `new_key_name` 中填入一个名称(如 "MyKey"),运行一次后该 Key 将被自动保存。
|
||||
- *注意:保存后需刷新浏览器页面,新密钥才会显示在下拉列表中。*
|
||||
|
||||
## 📖 功能节点列表
|
||||
|
||||
(所有节点均位于 `JimengAI` 菜单下)
|
||||
|
||||
- **基础设置**:
|
||||
- `火山方舟 API 客户端`: **(必须)** 用于加载并创建一个可供其他节点使用的客户端实例。
|
||||
- `Jimeng 配额设置`: 用于设置图像和视频生成的配额限制,防止意外消耗过多资源。
|
||||
- **图像生成**:
|
||||
- `图像生成(Seedream 3)`: 支持**文生图**和**图生图**模式的基础生成节点。
|
||||
- `图像生成(Seedream 4)`: 高级图像生成节点,支持多图输入、**组图生成**以及 4.5 模型。
|
||||
- `图像生成(Seedream 5)`: 最新一代图像生成节点,支持多图输入、组图生成以及**联网搜索**。
|
||||
- **视频生成**:
|
||||
- `视频生成(Seedance 1.0)`: 核心视频生成节点,支持文生视频、图生视频(首/尾帧)。
|
||||
- `视频生成(Seedance 1.5 Pro)`: 支持**音频生成**和**智能时长**的高级视频生成节点。
|
||||
- `视频生成(Seedance 2.0)`: 支持**图片/视频/音频多模态参考**、**视频编辑/延长**与**联网搜索增强**。
|
||||
- `视频生成(参考图生视频)`: 根据 1-4 张**参考图像**生成视频。
|
||||
- `视频生成任务列表查询`: 用于查询和管理在 API 上运行的任务历史。
|
||||
- **视觉理解**:
|
||||
- `视觉理解(Visual Understanding)`: 使用 Seed 2.0 模型进行视觉理解对话,支持图片/视频输入和多轮对话。
|
||||
|
||||
## 📑 节点详解
|
||||
|
||||
### `火山方舟 API 客户端 (Jimeng API Client)`
|
||||
|
||||
加载 `api_keys.json` 中的密钥配置。这是所有工作流的起点。
|
||||
|
||||
- **输入**: `密钥名称` (在 JSON 中配置的 customName)。
|
||||
- **输出**: `客户端` 实例。
|
||||
|
||||
### `Jimeng 配额设置 (Jimeng Quota Settings)`
|
||||
|
||||
允许为当前客户端设置图像(张数)和视频(Tokens)的使用上限。
|
||||
|
||||
- **特性**: 当达到限额时自动停止任务并抛出提示,防止额度超支。
|
||||
- **示例工作流**:
|
||||
!\[Quota Settings Workflow]\(example\_workflows/QuotaSettings.jpg null)
|
||||
|
||||
***
|
||||
|
||||
### `图像生成(Seedream 3)`
|
||||
|
||||
- **模式**: 不连接 `输入图像` 为文生图,连接则为图生图。
|
||||
|
||||
**示例工作流**:
|
||||
!\[Seedream 3 Workflow]\(example\_workflows/Seedream%203.jpg null)
|
||||
|
||||
***
|
||||
|
||||
### `图像生成(Seedream 4)`
|
||||
|
||||
支持 `doubao-seedream-4.5`。
|
||||
|
||||
- **输入图像**: 支持单张或多张(Batch)图像作为参考。
|
||||
- **启用组图生成**: 开启后可一次性生成多张内容关联的图片。
|
||||
|
||||
**示例工作流**:
|
||||
!\[Seedream 4 Workflow]\(example\_workflows/Seedream%204.jpg null)
|
||||
|
||||
***
|
||||
|
||||
### `图像生成(Seedream 5)`
|
||||
|
||||
支持最新的 `doubao-seedream-5.0-lite`。
|
||||
|
||||
- **输入图像**: 支持单张或多张(Batch)图像作为参考。
|
||||
- **启用组图生成**: 开启后可一次性生成多张内容关联的图片。
|
||||
- **联网搜索**: 开启后模型可自主搜索互联网内容(如实时信息、商品等)辅助生成。
|
||||
|
||||
**示例工作流**:
|
||||
!\[Seedream 5 Workflow]\(example\_workflows/Seedream%205.jpg null)
|
||||
|
||||
***
|
||||
|
||||
### `视频生成(Seedance 1.0 / 1.5 Pro / 2.0)`
|
||||
|
||||
- **1.0 特性**: 支持首/尾帧视频生成。
|
||||
- **1.5 Pro 特性**: 支持**音效生成**和**智能时长**控制。
|
||||
- **2.0 特性**: 支持最多 **9 张参考图 + 3 段参考视频 + 3 段参考音频**,可覆盖文生视频、多模态参考生视频、视频编辑、视频延长与联网搜索增强。
|
||||
|
||||
### `视频生成(参考图生视频)`
|
||||
|
||||
支持上传 1-4 张图像作为风格或内容参考生成视频。
|
||||
|
||||
### `视频生成任务列表查询`
|
||||
|
||||
支持按状态、模型版本或任务 ID 过滤查询任务历史。
|
||||
|
||||
**示例工作流**:
|
||||
!\[Seedance 1 Workflow]\(example\_workflows/Seedance%201.jpg null)
|
||||
!\[Seedance 2 Workflow]\(example\_workflows/Seedance%202.jpg null)
|
||||
|
||||
***
|
||||
|
||||
### `视觉理解(Seed 2.0)`
|
||||
|
||||
支持 `doubao-seed-2.0` 系列模型。
|
||||
|
||||
- **多模态输入**: 支持上传图片或视频进行理解和问答。
|
||||
- **多轮对话**: 支持开启多轮对话模式,保持上下文。
|
||||
- **深度思考**: 支持开启深度思考模式,提升复杂问题的推理能力。
|
||||
|
||||
**示例工作流**:
|
||||
!\[Visual Understanding Workflow]\(example\_workflows/VisualUnderstanding.jpg null)
|
||||
|
||||
## 📓 示例工作流
|
||||
|
||||
您可以在 `example_workflows` 目录中找到所有节点的示例工作流。
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+27
-4
@@ -24,6 +24,25 @@ VIDEO_MAX_SEED = 4294967295
|
||||
VIDEO_DEFAULT_TIMEOUT = 172800
|
||||
VIDEO_MIN_TIMEOUT = 3600
|
||||
VIDEO_MAX_TIMEOUT = 259200
|
||||
IMAGE_MIN_EDGE = 300
|
||||
IMAGE_MAX_EDGE = 6000
|
||||
IMAGE_MIN_RATIO = 0.4
|
||||
IMAGE_MAX_RATIO = 2.5
|
||||
REF_IMAGE_MAX_SIZE_MB = 30.0
|
||||
REF_IMAGE_MAX_TOTAL_REQUEST_MB = 64.0
|
||||
REF_MEDIA_MIN_DURATION = 1.8
|
||||
REF_MEDIA_MAX_DURATION = 15.2
|
||||
REF_VIDEO_MIN_DURATION = REF_MEDIA_MIN_DURATION
|
||||
REF_VIDEO_MAX_DURATION = REF_MEDIA_MAX_DURATION
|
||||
REF_VIDEO_MAX_TOTAL_DURATION = REF_MEDIA_MAX_DURATION
|
||||
REF_VIDEO_MAX_SIZE_MB = 50.0
|
||||
REF_VIDEO_MIN_PIXELS = 409600
|
||||
REF_VIDEO_MAX_PIXELS = 927408
|
||||
REF_AUDIO_MIN_DURATION = REF_MEDIA_MIN_DURATION
|
||||
REF_AUDIO_MAX_DURATION = REF_MEDIA_MAX_DURATION
|
||||
REF_AUDIO_MAX_TOTAL_DURATION = REF_MEDIA_MAX_DURATION
|
||||
REF_AUDIO_MAX_SIZE_MB = 15.0
|
||||
REF_AUDIO_MAX_TOTAL_REQUEST_MB = 64.0
|
||||
DEFAULT_VISUAL_SYSTEM_PROMPT = "你叫豆包,是由字节跳动开发的AI。"
|
||||
VIDEO_FRAME_RATE = 24.0
|
||||
VIDEO_MIN_FRAMES = 29
|
||||
@@ -66,8 +85,8 @@ LOG_TRANSLATIONS = {
|
||||
"batch_failed_reason": " - {msg}: {count} 次",
|
||||
"debug_create_request_params": "创建任务请求参数: {params}",
|
||||
"log_raw_api_response": "原始 API 响应: {raw}",
|
||||
"upload_ref_video_start": "开始上传参考视频 ({index}/{total})...",
|
||||
"upload_ref_video_done": "参考视频上传完成 ({index}/{total})。",
|
||||
"upload_ref_video_start": "开始上传参考视频(已完成: {done},待上传: {pending})...",
|
||||
"upload_ref_video_done": "参考视频上传完成(已完成: {done},待上传: {pending})。",
|
||||
|
||||
# 轮询提示
|
||||
"polling_single": "任务 {task_id}: 运行中... 已耗时 {elapsed}s / 预估 {max}s",
|
||||
@@ -186,6 +205,7 @@ LOG_TRANSLATIONS = {
|
||||
"OutputTextSensitiveContentDetected": "生成的文字可能包含敏感信息,请您更换输入内容后重试。",
|
||||
"OutputImageSensitiveContentDetected": "生成的图像可能包含敏感信息,请您更换输入内容后重试。",
|
||||
"OutputVideoSensitiveContentDetected": "生成的视频可能包含敏感信息,请您更换输入内容后重试。",
|
||||
"InputMediaMayContainRealPerson": "输入的图片或视频可能包含真人内容,请更换素材后重试。",
|
||||
"InvalidImageURL": "无法解析或处理图片,可能是 Base64 格式不正确、图片数据损坏或格式不支持。",
|
||||
"InvalidImageDetail": "image_url 中的 detail 参数值无效,只接受 'auto', 'high', 'low'。",
|
||||
"MissingParameter": "请求缺少必要参数,请查阅 API 文档。",
|
||||
@@ -218,8 +238,8 @@ LOG_TRANSLATIONS = {
|
||||
"batch_failed_reason": " - {msg}: {count} times",
|
||||
"debug_create_request_params": "Create request params: {params}",
|
||||
"log_raw_api_response": "Raw API response: {raw}",
|
||||
"upload_ref_video_start": "Uploading reference video ({index}/{total})...",
|
||||
"upload_ref_video_done": "Reference video upload completed ({index}/{total}).",
|
||||
"upload_ref_video_start": "Uploading reference video (done: {done}, pending: {pending})...",
|
||||
"upload_ref_video_done": "Reference video upload completed (done: {done}, pending: {pending}).",
|
||||
|
||||
# Updated
|
||||
"polling_single": "Task {task_id}: Running... {elapsed}s / {max}s elapsed",
|
||||
@@ -328,6 +348,7 @@ LOG_TRANSLATIONS = {
|
||||
"OutputTextSensitiveContentDetected": "Generated text contains sensitive content (400).",
|
||||
"OutputImageSensitiveContentDetected": "Generated image contains sensitive content (400).",
|
||||
"OutputVideoSensitiveContentDetected": "Generated video contains sensitive content (400).",
|
||||
"InputMediaMayContainRealPerson": "Request failed: input image or video may contain a real person.",
|
||||
"InvalidImageURL": "Invalid Image URL (400).",
|
||||
"InvalidImageDetail": "Invalid Image Detail parameter (400).",
|
||||
"MissingParameter": "Missing Parameter (400).",
|
||||
@@ -343,6 +364,8 @@ ERROR_TEXT_MATCH_RULES = {
|
||||
"output image may contain sensitive information": "OutputImageSensitiveContentDetected",
|
||||
"input text may contain sensitive information": "InputTextSensitiveContentDetected",
|
||||
"input image may contain sensitive information": "InputImageSensitiveContentDetected",
|
||||
"the request failed because the input video may contain real person": "InputMediaMayContainRealPerson",
|
||||
"the request failed because the input image may contain real person": "InputMediaMayContainRealPerson",
|
||||
"output video may contain sensitive information": "OutputVideoSensitiveContentDetected",
|
||||
"generated text contains sensitive content": "OutputTextSensitiveContentDetected",
|
||||
"API key or AK/SK in the request is missing or invalid": "AuthenticationError",
|
||||
|
||||
+63
-62
@@ -74,6 +74,65 @@ def validate_custom_size(width, height, min_pixels, max_pixels):
|
||||
return f"{width}x{height}"
|
||||
|
||||
|
||||
def _get_dynamic_input_order(name):
|
||||
match = None
|
||||
try:
|
||||
import re
|
||||
match = re.search(r"(\d+)$", str(name))
|
||||
except Exception:
|
||||
match = None
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return 999
|
||||
|
||||
|
||||
def _create_seedream_autogrow_input():
|
||||
return comfy_io.Autogrow.Input(
|
||||
"images",
|
||||
template=comfy_io.Autogrow.TemplateNames(
|
||||
input=comfy_io.Image.Input("image", optional=True),
|
||||
names=[f"image_{idx}" for idx in range(1, 15)],
|
||||
min=1,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _prepare_multi_image_inputs(images=None, **kwargs):
|
||||
input_image_tensors = []
|
||||
|
||||
if isinstance(images, dict):
|
||||
sorted_items = sorted(images.items(), key=lambda item: _get_dynamic_input_order(item[0]))
|
||||
for _, image_tensor in sorted_items:
|
||||
if isinstance(image_tensor, torch.Tensor):
|
||||
input_image_tensors.append(image_tensor)
|
||||
elif isinstance(images, torch.Tensor):
|
||||
input_image_tensors.append(images)
|
||||
|
||||
for key in sorted([k for k in kwargs.keys() if k.startswith("image_")], key=_get_dynamic_input_order):
|
||||
image_tensor = kwargs[key]
|
||||
if isinstance(image_tensor, torch.Tensor):
|
||||
input_image_tensors.append(image_tensor)
|
||||
|
||||
n_input_images = 0
|
||||
image_b64_list = []
|
||||
for tensor in input_image_tensors:
|
||||
batch_size = tensor.shape[0]
|
||||
n_input_images += batch_size
|
||||
for i in range(batch_size):
|
||||
image_b64_list.append(_image_to_base64(tensor[i : i + 1]))
|
||||
|
||||
image_param = None
|
||||
if n_input_images > 0:
|
||||
if n_input_images == 1:
|
||||
image_param = f"data:image/jpeg;base64,{image_b64_list[0]}"
|
||||
else:
|
||||
image_param = [
|
||||
f"data:image/jpeg;base64,{image_b64}" for image_b64 in image_b64_list
|
||||
]
|
||||
|
||||
return n_input_images, image_param
|
||||
|
||||
|
||||
class JimengSeedream3(comfy_io.ComfyNode):
|
||||
"""
|
||||
Jimeng Seedream 3 图像生成节点。
|
||||
@@ -239,7 +298,7 @@ class JimengSeedream4(comfy_io.ComfyNode):
|
||||
enable_group_generation=True,
|
||||
)
|
||||
+ [
|
||||
comfy_io.Image.Input("images", optional=True),
|
||||
_create_seedream_autogrow_input(),
|
||||
],
|
||||
hidden=[comfy_io.Hidden.unique_id, comfy_io.Hidden.prompt],
|
||||
outputs=[
|
||||
@@ -273,27 +332,7 @@ class JimengSeedream4(comfy_io.ComfyNode):
|
||||
model_id = list(SEEDREAM_4_MODEL_MAP.values())[0]
|
||||
|
||||
sequential_param = "auto" if enable_group_generation else "disabled"
|
||||
|
||||
input_image_tensors = []
|
||||
if images is not None:
|
||||
input_image_tensors.append(images)
|
||||
|
||||
sorted_keys = sorted([k for k in kwargs.keys() if k.startswith("image_")],
|
||||
key=lambda x: int(x.split("_")[-1]) if x.split("_")[-1].isdigit() else 999)
|
||||
|
||||
for k in sorted_keys:
|
||||
img = kwargs[k]
|
||||
if isinstance(img, torch.Tensor):
|
||||
input_image_tensors.append(img)
|
||||
|
||||
n_input_images = 0
|
||||
image_b64_list = []
|
||||
|
||||
for tensor in input_image_tensors:
|
||||
batch_size = tensor.shape[0]
|
||||
n_input_images += batch_size
|
||||
for i in range(batch_size):
|
||||
image_b64_list.append(_image_to_base64(tensor[i : i + 1]))
|
||||
n_input_images, image_param = _prepare_multi_image_inputs(images, **kwargs)
|
||||
|
||||
if sequential_param == "auto":
|
||||
total_count = n_input_images + max_images
|
||||
@@ -319,15 +358,6 @@ class JimengSeedream4(comfy_io.ComfyNode):
|
||||
else:
|
||||
size_str = size.split(" ")[0]
|
||||
|
||||
image_param = None
|
||||
if n_input_images > 0:
|
||||
if n_input_images == 1:
|
||||
image_param = f"data:image/jpeg;base64,{image_b64_list[0]}"
|
||||
else:
|
||||
image_param = [
|
||||
f"data:image/jpeg;base64,{b64}" for b64 in image_b64_list
|
||||
]
|
||||
|
||||
seq_options = None
|
||||
if sequential_param == "auto":
|
||||
seq_options = SequentialImageGenerationOptions(max_images=max_images)
|
||||
@@ -410,7 +440,7 @@ class JimengSeedream5(comfy_io.ComfyNode):
|
||||
enable_web_search=True,
|
||||
)
|
||||
+ [
|
||||
comfy_io.Image.Input("images", optional=True),
|
||||
_create_seedream_autogrow_input(),
|
||||
],
|
||||
hidden=[comfy_io.Hidden.unique_id, comfy_io.Hidden.prompt],
|
||||
outputs=[
|
||||
@@ -445,27 +475,7 @@ class JimengSeedream5(comfy_io.ComfyNode):
|
||||
model_id = list(SEEDREAM_5_MODEL_MAP.values())[0]
|
||||
|
||||
sequential_param = "auto" if enable_group_generation else "disabled"
|
||||
|
||||
input_image_tensors = []
|
||||
if images is not None:
|
||||
input_image_tensors.append(images)
|
||||
|
||||
sorted_keys = sorted([k for k in kwargs.keys() if k.startswith("image_")],
|
||||
key=lambda x: int(x.split("_")[-1]) if x.split("_")[-1].isdigit() else 999)
|
||||
|
||||
for k in sorted_keys:
|
||||
img = kwargs[k]
|
||||
if isinstance(img, torch.Tensor):
|
||||
input_image_tensors.append(img)
|
||||
|
||||
n_input_images = 0
|
||||
image_b64_list = []
|
||||
|
||||
for tensor in input_image_tensors:
|
||||
batch_size = tensor.shape[0]
|
||||
n_input_images += batch_size
|
||||
for i in range(batch_size):
|
||||
image_b64_list.append(_image_to_base64(tensor[i : i + 1]))
|
||||
n_input_images, image_param = _prepare_multi_image_inputs(images, **kwargs)
|
||||
|
||||
if sequential_param == "auto":
|
||||
total_count = n_input_images + max_images
|
||||
@@ -488,15 +498,6 @@ class JimengSeedream5(comfy_io.ComfyNode):
|
||||
else:
|
||||
size_str = size.split(" ")[0]
|
||||
|
||||
image_param = None
|
||||
if n_input_images > 0:
|
||||
if n_input_images == 1:
|
||||
image_param = f"data:image/jpeg;base64,{image_b64_list[0]}"
|
||||
else:
|
||||
image_param = [
|
||||
f"data:image/jpeg;base64,{b64}" for b64 in image_b64_list
|
||||
]
|
||||
|
||||
seq_options = None
|
||||
if sequential_param == "auto":
|
||||
seq_options = SequentialImageGenerationOptions(max_images=max_images)
|
||||
|
||||
+194
-110
@@ -14,6 +14,7 @@ import torch.nn.functional as F
|
||||
import requests
|
||||
import cv2
|
||||
import asyncio
|
||||
import threading
|
||||
from volcenginesdkarkruntime import Ark
|
||||
|
||||
from comfy_api.latest import io as comfy_io
|
||||
@@ -64,9 +65,6 @@ jimeng_api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
API_KEYS_FILE = os.path.join(jimeng_api_dir, "api_keys.json")
|
||||
FILES_UPLOAD_CACHE_FILE = os.path.join(jimeng_api_dir, "files_upload_cache.json")
|
||||
|
||||
API_KEYS_CONFIG = []
|
||||
CURRENT_LANG = "en"
|
||||
|
||||
JimengClientType = comfy_io.Custom("JIMENG_CLIENT")
|
||||
|
||||
|
||||
@@ -83,12 +81,104 @@ def detect_system_language():
|
||||
return "en"
|
||||
|
||||
|
||||
class LocalizationState:
|
||||
def __init__(self, default_lang="en"):
|
||||
self._lock = threading.RLock()
|
||||
self._lang = "en"
|
||||
self.set_language(default_lang)
|
||||
|
||||
def set_language(self, lang):
|
||||
normalized_lang = lang if lang in LOG_TRANSLATIONS else "en"
|
||||
with self._lock:
|
||||
self._lang = normalized_lang
|
||||
|
||||
def refresh_from_system(self):
|
||||
self.set_language(detect_system_language())
|
||||
|
||||
def get_language(self):
|
||||
with self._lock:
|
||||
return self._lang
|
||||
|
||||
def get_mapping(self):
|
||||
return LOG_TRANSLATIONS.get(self.get_language(), LOG_TRANSLATIONS["en"])
|
||||
|
||||
|
||||
LOCALIZATION_STATE = LocalizationState(detect_system_language())
|
||||
|
||||
|
||||
class ApiKeyStore:
|
||||
def __init__(self, config_file):
|
||||
self.config_file = config_file
|
||||
self._lock = threading.RLock()
|
||||
self._items = []
|
||||
|
||||
def load(self):
|
||||
loaded_items = []
|
||||
LOCALIZATION_STATE.refresh_from_system()
|
||||
|
||||
try:
|
||||
if os.path.exists(self.config_file):
|
||||
with open(self.config_file, "r", encoding="utf-8") as f:
|
||||
keys_data = json.load(f)
|
||||
if isinstance(keys_data, list):
|
||||
for item in keys_data:
|
||||
if "customName" in item and "apiKey" in item:
|
||||
loaded_items.append(item)
|
||||
except Exception as e:
|
||||
log_msg("api_load_error", e=e)
|
||||
|
||||
with self._lock:
|
||||
self._items = loaded_items
|
||||
|
||||
def save(self):
|
||||
with self._lock:
|
||||
serializable_items = list(self._items)
|
||||
try:
|
||||
with open(self.config_file, "w", encoding="utf-8") as f:
|
||||
json.dump(serializable_items, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save API key: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def upsert(self, name, key):
|
||||
with self._lock:
|
||||
updated = False
|
||||
for item in self._items:
|
||||
if item["customName"] == name:
|
||||
item["apiKey"] = key
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
self._items.append({"customName": name, "apiKey": key})
|
||||
|
||||
return self.save()
|
||||
|
||||
def get_items(self):
|
||||
with self._lock:
|
||||
return [dict(item) for item in self._items]
|
||||
|
||||
def get_key_names(self):
|
||||
with self._lock:
|
||||
return [item["customName"] for item in self._items]
|
||||
|
||||
def find_api_key(self, key_name):
|
||||
with self._lock:
|
||||
for item in self._items:
|
||||
if item["customName"] == key_name:
|
||||
return item["apiKey"]
|
||||
return None
|
||||
|
||||
|
||||
API_KEY_STORE = ApiKeyStore(API_KEYS_FILE)
|
||||
|
||||
|
||||
def get_text(key, **kwargs):
|
||||
"""
|
||||
获取指定 key 的本地化文本。
|
||||
"""
|
||||
global CURRENT_LANG
|
||||
mapping = LOG_TRANSLATIONS.get(CURRENT_LANG, LOG_TRANSLATIONS["en"])
|
||||
mapping = LOCALIZATION_STATE.get_mapping()
|
||||
msg = mapping.get(key, LOG_TRANSLATIONS["en"].get(key, key))
|
||||
if kwargs:
|
||||
try:
|
||||
@@ -102,8 +192,7 @@ def log_msg(key, default_msg="", **kwargs):
|
||||
"""
|
||||
记录本地化日志信息。
|
||||
"""
|
||||
global CURRENT_LANG
|
||||
mapping = LOG_TRANSLATIONS.get(CURRENT_LANG, LOG_TRANSLATIONS["en"])
|
||||
mapping = LOCALIZATION_STATE.get_mapping()
|
||||
msg = mapping.get(key, None)
|
||||
if not msg:
|
||||
msg = LOG_TRANSLATIONS["en"].get(key, default_msg)
|
||||
@@ -150,8 +239,7 @@ def format_api_error(e):
|
||||
格式化 API 错误信息。
|
||||
尝试解析错误代码并返回对应的本地化错误描述。
|
||||
"""
|
||||
global CURRENT_LANG
|
||||
mapping = LOG_TRANSLATIONS.get(CURRENT_LANG, LOG_TRANSLATIONS["en"])
|
||||
mapping = LOCALIZATION_STATE.get_mapping()
|
||||
error_map = mapping.get("api_errors", {})
|
||||
fallback_map = LOG_TRANSLATIONS["en"].get("api_errors", {})
|
||||
|
||||
@@ -206,47 +294,15 @@ def load_api_keys():
|
||||
"""
|
||||
加载 API 密钥配置文件 (api_keys.json)。
|
||||
"""
|
||||
global API_KEYS_CONFIG, CURRENT_LANG
|
||||
API_KEYS_CONFIG = []
|
||||
CURRENT_LANG = detect_system_language()
|
||||
|
||||
if not os.path.exists(API_KEYS_FILE):
|
||||
pass
|
||||
|
||||
try:
|
||||
if os.path.exists(API_KEYS_FILE):
|
||||
with open(API_KEYS_FILE, "r", encoding="utf-8") as f:
|
||||
keys_data = json.load(f)
|
||||
if isinstance(keys_data, list):
|
||||
for item in keys_data:
|
||||
if "customName" in item and "apiKey" in item:
|
||||
API_KEYS_CONFIG.append(item)
|
||||
except Exception as e:
|
||||
log_msg("api_load_error", e=e)
|
||||
API_KEY_STORE.load()
|
||||
|
||||
|
||||
def save_api_key(name, key):
|
||||
"""
|
||||
保存新的 API Key 到配置文件。
|
||||
"""
|
||||
global API_KEYS_CONFIG
|
||||
|
||||
updated = False
|
||||
for item in API_KEYS_CONFIG:
|
||||
if item["customName"] == name:
|
||||
item["apiKey"] = key
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
API_KEYS_CONFIG.append({"customName": name, "apiKey": key})
|
||||
|
||||
try:
|
||||
with open(API_KEYS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(API_KEYS_CONFIG, f, indent=2, ensure_ascii=False)
|
||||
if API_KEY_STORE.upsert(name, key):
|
||||
logger.info(f"Saved API Key: {name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save API key: {e}")
|
||||
|
||||
|
||||
def validate_api_key(api_key: str) -> bool:
|
||||
@@ -272,9 +328,6 @@ def validate_api_key(api_key: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
FILES_UPLOAD_CACHE = {}
|
||||
|
||||
|
||||
def _normalize_expire_seconds(expire_seconds):
|
||||
expire_seconds = int(expire_seconds if expire_seconds is not None else 604800)
|
||||
return max(86400, min(expire_seconds, 2592000))
|
||||
@@ -317,51 +370,87 @@ def _compute_file_sha256(file_path):
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def load_files_upload_cache():
|
||||
global FILES_UPLOAD_CACHE
|
||||
FILES_UPLOAD_CACHE = {}
|
||||
if not os.path.exists(FILES_UPLOAD_CACHE_FILE):
|
||||
return
|
||||
try:
|
||||
with open(FILES_UPLOAD_CACHE_FILE, "r", encoding="utf-8") as f:
|
||||
cache_data = json.load(f)
|
||||
if not isinstance(cache_data, dict):
|
||||
return
|
||||
class UploadCacheStore:
|
||||
def __init__(self, cache_file):
|
||||
self.cache_file = cache_file
|
||||
self._lock = threading.RLock()
|
||||
self._data = {}
|
||||
|
||||
def load(self):
|
||||
loaded_data = {}
|
||||
if os.path.exists(self.cache_file):
|
||||
try:
|
||||
with open(self.cache_file, "r", encoding="utf-8") as f:
|
||||
cache_data = json.load(f)
|
||||
if isinstance(cache_data, dict):
|
||||
now_ts = int(time.time())
|
||||
for raw_key, entry in cache_data.items():
|
||||
cache_key = _normalize_cache_key(raw_key)
|
||||
if cache_key is None or not isinstance(entry, dict):
|
||||
continue
|
||||
file_id = entry.get("file_id")
|
||||
expire_at = int(entry.get("expire_at", 0) or 0)
|
||||
if file_id and expire_at > now_ts:
|
||||
loaded_data[cache_key] = {
|
||||
"file_id": file_id,
|
||||
"expire_at": expire_at
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load upload cache: {e}")
|
||||
with self._lock:
|
||||
self._data = loaded_data
|
||||
|
||||
def save(self):
|
||||
now_ts = int(time.time())
|
||||
for raw_key, entry in cache_data.items():
|
||||
cache_key = _normalize_cache_key(raw_key)
|
||||
if cache_key is None or not isinstance(entry, dict):
|
||||
continue
|
||||
file_id = entry.get("file_id")
|
||||
expire_at = int(entry.get("expire_at", 0) or 0)
|
||||
if file_id and expire_at > now_ts:
|
||||
FILES_UPLOAD_CACHE[cache_key] = {
|
||||
"file_id": file_id,
|
||||
"expire_at": expire_at
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load upload cache: {e}")
|
||||
serializable_data = {}
|
||||
with self._lock:
|
||||
for raw_key, entry in self._data.items():
|
||||
cache_key = _normalize_cache_key(raw_key)
|
||||
if cache_key is None or not isinstance(entry, dict):
|
||||
continue
|
||||
file_id = entry.get("file_id")
|
||||
expire_at = int(entry.get("expire_at", 0) or 0)
|
||||
if file_id and expire_at > now_ts:
|
||||
serializable_data[_serialize_cache_key(cache_key)] = {
|
||||
"file_id": file_id,
|
||||
"expire_at": expire_at
|
||||
}
|
||||
try:
|
||||
with open(self.cache_file, "w", encoding="utf-8") as f:
|
||||
json.dump(serializable_data, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save upload cache: {e}")
|
||||
|
||||
def get(self, cache_key):
|
||||
with self._lock:
|
||||
return self._data.get(cache_key)
|
||||
|
||||
def set(self, cache_key, entry):
|
||||
with self._lock:
|
||||
self._data[cache_key] = entry
|
||||
|
||||
def pop(self, cache_key, default=None):
|
||||
with self._lock:
|
||||
return self._data.pop(cache_key, default)
|
||||
|
||||
def contains(self, cache_key):
|
||||
with self._lock:
|
||||
return cache_key in self._data
|
||||
|
||||
def keys(self):
|
||||
with self._lock:
|
||||
return list(self._data.keys())
|
||||
|
||||
|
||||
UPLOAD_CACHE_STORE = UploadCacheStore(FILES_UPLOAD_CACHE_FILE)
|
||||
|
||||
|
||||
def load_files_upload_cache():
|
||||
UPLOAD_CACHE_STORE.load()
|
||||
|
||||
|
||||
def save_files_upload_cache():
|
||||
now_ts = int(time.time())
|
||||
serializable_data = {}
|
||||
for raw_key, entry in FILES_UPLOAD_CACHE.items():
|
||||
cache_key = _normalize_cache_key(raw_key)
|
||||
if cache_key is None or not isinstance(entry, dict):
|
||||
continue
|
||||
file_id = entry.get("file_id")
|
||||
expire_at = int(entry.get("expire_at", 0) or 0)
|
||||
if file_id and expire_at > now_ts:
|
||||
serializable_data[_serialize_cache_key(cache_key)] = {
|
||||
"file_id": file_id,
|
||||
"expire_at": expire_at
|
||||
}
|
||||
try:
|
||||
with open(FILES_UPLOAD_CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(serializable_data, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save upload cache: {e}")
|
||||
UPLOAD_CACHE_STORE.save()
|
||||
|
||||
|
||||
load_files_upload_cache()
|
||||
@@ -371,12 +460,10 @@ async def upload_file_to_ark(client, file_path, fps=None, expire_seconds=604800,
|
||||
"""
|
||||
使用 client.ark.files.create 上传文件。
|
||||
"""
|
||||
global FILES_UPLOAD_CACHE
|
||||
|
||||
expire_seconds = _normalize_expire_seconds(expire_seconds)
|
||||
if fps is None:
|
||||
try:
|
||||
file_identity = f"sha256:{_compute_file_sha256(file_path)}"
|
||||
file_identity = f"sha256:{await asyncio.to_thread(_compute_file_sha256, file_path)}"
|
||||
except Exception:
|
||||
file_identity = file_path
|
||||
else:
|
||||
@@ -386,12 +473,12 @@ async def upload_file_to_ark(client, file_path, fps=None, expire_seconds=604800,
|
||||
now_ts = int(time.time())
|
||||
expire_at = now_ts + expire_seconds
|
||||
|
||||
cached_entry = FILES_UPLOAD_CACHE.get(cache_key)
|
||||
cached_entry = UPLOAD_CACHE_STORE.get(cache_key)
|
||||
legacy_path_key = None
|
||||
matched_cache_key = cache_key
|
||||
if cached_entry is None and fps is None and file_identity != file_path:
|
||||
legacy_path_key = (file_path, None, expire_seconds)
|
||||
cached_entry = FILES_UPLOAD_CACHE.get(legacy_path_key)
|
||||
cached_entry = UPLOAD_CACHE_STORE.get(legacy_path_key)
|
||||
if cached_entry is not None:
|
||||
matched_cache_key = legacy_path_key
|
||||
if isinstance(cached_entry, dict):
|
||||
@@ -407,9 +494,9 @@ async def upload_file_to_ark(client, file_path, fps=None, expire_seconds=604800,
|
||||
remote_expire_at = int(getattr(remote_file_info, "expire_at", 0) or 0)
|
||||
if remote_status == "active" and remote_expire_at > now_ts:
|
||||
cached_entry["expire_at"] = remote_expire_at
|
||||
if legacy_path_key is not None and legacy_path_key in FILES_UPLOAD_CACHE:
|
||||
FILES_UPLOAD_CACHE.pop(legacy_path_key, None)
|
||||
FILES_UPLOAD_CACHE[cache_key] = cached_entry
|
||||
if legacy_path_key is not None and UPLOAD_CACHE_STORE.contains(legacy_path_key):
|
||||
UPLOAD_CACHE_STORE.pop(legacy_path_key, None)
|
||||
UPLOAD_CACHE_STORE.set(cache_key, cached_entry)
|
||||
save_files_upload_cache()
|
||||
log_msg("visual_found_file", path=file_path)
|
||||
if return_meta:
|
||||
@@ -420,9 +507,9 @@ async def upload_file_to_ark(client, file_path, fps=None, expire_seconds=604800,
|
||||
return cached_file_id
|
||||
except Exception:
|
||||
if cached_expire_at > now_ts:
|
||||
if legacy_path_key is not None and legacy_path_key in FILES_UPLOAD_CACHE:
|
||||
FILES_UPLOAD_CACHE.pop(legacy_path_key, None)
|
||||
FILES_UPLOAD_CACHE[cache_key] = cached_entry
|
||||
if legacy_path_key is not None and UPLOAD_CACHE_STORE.contains(legacy_path_key):
|
||||
UPLOAD_CACHE_STORE.pop(legacy_path_key, None)
|
||||
UPLOAD_CACHE_STORE.set(cache_key, cached_entry)
|
||||
save_files_upload_cache()
|
||||
log_msg("visual_found_file", path=file_path)
|
||||
if return_meta:
|
||||
@@ -431,17 +518,17 @@ async def upload_file_to_ark(client, file_path, fps=None, expire_seconds=604800,
|
||||
"expire_at": cached_expire_at
|
||||
}
|
||||
return cached_file_id
|
||||
FILES_UPLOAD_CACHE.pop(matched_cache_key, None)
|
||||
UPLOAD_CACHE_STORE.pop(matched_cache_key, None)
|
||||
save_files_upload_cache()
|
||||
elif cached_entry is not None:
|
||||
FILES_UPLOAD_CACHE.pop(cache_key, None)
|
||||
UPLOAD_CACHE_STORE.pop(cache_key, None)
|
||||
save_files_upload_cache()
|
||||
|
||||
identity_candidates = {file_identity}
|
||||
if fps is None and file_identity != file_path:
|
||||
identity_candidates.add(file_path)
|
||||
stale_keys = []
|
||||
for existing_key in list(FILES_UPLOAD_CACHE.keys()):
|
||||
for existing_key in UPLOAD_CACHE_STORE.keys():
|
||||
normalized_existing_key = _normalize_cache_key(existing_key)
|
||||
if normalized_existing_key is None:
|
||||
continue
|
||||
@@ -456,7 +543,7 @@ async def upload_file_to_ark(client, file_path, fps=None, expire_seconds=604800,
|
||||
if stale_keys and hasattr(client.ark, "files"):
|
||||
cache_changed = False
|
||||
for stale_key in stale_keys:
|
||||
stale_entry = FILES_UPLOAD_CACHE.get(stale_key)
|
||||
stale_entry = UPLOAD_CACHE_STORE.get(stale_key)
|
||||
if isinstance(stale_entry, dict):
|
||||
stale_file_id = stale_entry.get("file_id")
|
||||
if stale_file_id:
|
||||
@@ -467,7 +554,7 @@ async def upload_file_to_ark(client, file_path, fps=None, expire_seconds=604800,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Delete stale file failed for {stale_file_id}: {e}")
|
||||
FILES_UPLOAD_CACHE.pop(stale_key, None)
|
||||
UPLOAD_CACHE_STORE.pop(stale_key, None)
|
||||
cache_changed = True
|
||||
if cache_changed:
|
||||
save_files_upload_cache()
|
||||
@@ -502,10 +589,10 @@ async def upload_file_to_ark(client, file_path, fps=None, expire_seconds=604800,
|
||||
|
||||
await wait_for_file_active(client, file_id)
|
||||
|
||||
FILES_UPLOAD_CACHE[cache_key] = {
|
||||
UPLOAD_CACHE_STORE.set(cache_key, {
|
||||
"file_id": file_id,
|
||||
"expire_at": expire_at
|
||||
}
|
||||
})
|
||||
save_files_upload_cache()
|
||||
if return_meta:
|
||||
return {
|
||||
@@ -692,7 +779,7 @@ class JimengAPIClient(comfy_io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls) -> comfy_io.Schema:
|
||||
load_api_keys()
|
||||
key_names = [key["customName"] for key in API_KEYS_CONFIG]
|
||||
key_names = API_KEY_STORE.get_key_names()
|
||||
key_names.append("Custom")
|
||||
|
||||
return comfy_io.Schema(
|
||||
@@ -727,10 +814,7 @@ class JimengAPIClient(comfy_io.ComfyNode):
|
||||
print(get_text("info_new_key_saved", name=new_key_name.strip()))
|
||||
|
||||
else:
|
||||
for key_info in API_KEYS_CONFIG:
|
||||
if key_info["customName"] == key_name:
|
||||
api_key = key_info["apiKey"]
|
||||
break
|
||||
api_key = API_KEY_STORE.find_api_key(key_name)
|
||||
|
||||
if not api_key:
|
||||
log_msg("api_key_not_found", key_name=key_name)
|
||||
|
||||
+245
-112
@@ -10,6 +10,7 @@ import logging
|
||||
import base64
|
||||
import io
|
||||
import wave
|
||||
import hashlib
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import folder_paths
|
||||
@@ -69,6 +70,9 @@ logging.getLogger("httpx").setLevel(logging.ERROR)
|
||||
|
||||
NON_BLOCKING_TASK_CACHE = {}
|
||||
LAST_SEEDANCE_1_5_DRAFT_TASK_ID = {}
|
||||
COMFY_VIDEO_UPLOAD_CACHE = {}
|
||||
COMFY_VIDEO_UPLOAD_CACHE_TTL_SECONDS = 86400
|
||||
COMFY_VIDEO_UPLOAD_CACHE_MAX_ENTRIES = 256
|
||||
|
||||
|
||||
def _raise_if_text_params(prompt: str, text_params: list[str]) -> None:
|
||||
@@ -77,27 +81,73 @@ def _raise_if_text_params(prompt: str, text_params: list[str]) -> None:
|
||||
raise JimengException(get_text("popup_param_not_allowed").format(param=i))
|
||||
|
||||
|
||||
from .constants import VIDEO_MAX_SEED, VIDEO_DEFAULT_TIMEOUT
|
||||
def _get_dynamic_input_order(name: str) -> int:
|
||||
parts = str(name).rsplit("_", 1)
|
||||
if len(parts) == 2 and parts[1].isdigit():
|
||||
return int(parts[1])
|
||||
return 999
|
||||
|
||||
IMAGE_MIN_EDGE = 300
|
||||
IMAGE_MAX_EDGE = 6000
|
||||
IMAGE_MIN_RATIO = 0.4
|
||||
IMAGE_MAX_RATIO = 2.5
|
||||
REF_IMAGE_MAX_SIZE_MB = 30.0
|
||||
REF_IMAGE_MAX_TOTAL_REQUEST_MB = 64.0
|
||||
|
||||
REF_VIDEO_MIN_DURATION = 2.0
|
||||
REF_VIDEO_MAX_DURATION = 15.0
|
||||
REF_VIDEO_MAX_TOTAL_DURATION = 15.0
|
||||
REF_VIDEO_MAX_SIZE_MB = 50.0
|
||||
REF_VIDEO_MIN_PIXELS = 409600
|
||||
REF_VIDEO_MAX_PIXELS = 927408
|
||||
def _create_autogrow_input(name, input_template, prefix, min_slots, max_slots):
|
||||
return comfy_io.Autogrow.Input(
|
||||
name,
|
||||
template=comfy_io.Autogrow.TemplatePrefix(
|
||||
input=input_template,
|
||||
prefix=prefix,
|
||||
min=min_slots,
|
||||
max=max_slots,
|
||||
),
|
||||
)
|
||||
|
||||
REF_AUDIO_MIN_DURATION = 2.0
|
||||
REF_AUDIO_MAX_DURATION = 15.0
|
||||
REF_AUDIO_MAX_TOTAL_DURATION = 15.0
|
||||
REF_AUDIO_MAX_SIZE_MB = 15.0
|
||||
REF_AUDIO_MAX_TOTAL_REQUEST_MB = 64.0
|
||||
|
||||
def _create_named_autogrow_input(name, input_template, names, min_slots):
|
||||
return comfy_io.Autogrow.Input(
|
||||
name,
|
||||
template=comfy_io.Autogrow.TemplateNames(
|
||||
input=input_template,
|
||||
names=names,
|
||||
min=min_slots,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _collect_dynamic_inputs(values=None, kwargs=None, prefix=None):
|
||||
collected = []
|
||||
|
||||
if isinstance(values, dict):
|
||||
sorted_items = sorted(values.items(), key=lambda item: _get_dynamic_input_order(item[0]))
|
||||
collected.extend([value for _, value in sorted_items])
|
||||
elif values is not None:
|
||||
collected.append(values)
|
||||
|
||||
if kwargs and prefix:
|
||||
sorted_keys = sorted([key for key in kwargs.keys() if key.startswith(prefix)], key=_get_dynamic_input_order)
|
||||
collected.extend([kwargs[key] for key in sorted_keys])
|
||||
|
||||
return [value for value in collected if value is not None]
|
||||
|
||||
|
||||
from .constants import (
|
||||
VIDEO_MAX_SEED,
|
||||
VIDEO_DEFAULT_TIMEOUT,
|
||||
IMAGE_MIN_EDGE,
|
||||
IMAGE_MAX_EDGE,
|
||||
IMAGE_MIN_RATIO,
|
||||
IMAGE_MAX_RATIO,
|
||||
REF_IMAGE_MAX_SIZE_MB,
|
||||
REF_IMAGE_MAX_TOTAL_REQUEST_MB,
|
||||
REF_VIDEO_MIN_DURATION,
|
||||
REF_VIDEO_MAX_DURATION,
|
||||
REF_VIDEO_MAX_TOTAL_DURATION,
|
||||
REF_VIDEO_MAX_SIZE_MB,
|
||||
REF_VIDEO_MIN_PIXELS,
|
||||
REF_VIDEO_MAX_PIXELS,
|
||||
REF_AUDIO_MIN_DURATION,
|
||||
REF_AUDIO_MAX_DURATION,
|
||||
REF_AUDIO_MAX_TOTAL_DURATION,
|
||||
REF_AUDIO_MAX_SIZE_MB,
|
||||
REF_AUDIO_MAX_TOTAL_REQUEST_MB,
|
||||
)
|
||||
|
||||
class JimengVideoBase:
|
||||
"""
|
||||
@@ -212,12 +262,139 @@ class JimengVideoBase:
|
||||
return len(stream_source.getvalue())
|
||||
return 0
|
||||
|
||||
def _build_comfy_video_upload_cache_key(self, video):
|
||||
try:
|
||||
stream_source = video.get_stream_source()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if isinstance(stream_source, str):
|
||||
path = os.path.abspath(stream_source)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
st = os.stat(path)
|
||||
return f"path:{path}|{int(st.st_size)}|{int(st.st_mtime_ns)}"
|
||||
|
||||
def _hash_buffer(size, reader):
|
||||
hasher = hashlib.sha256()
|
||||
sample_size = min(size, 1024 * 1024)
|
||||
head = reader(0, sample_size)
|
||||
hasher.update(head)
|
||||
if size > sample_size:
|
||||
tail = reader(size - sample_size, sample_size)
|
||||
hasher.update(tail)
|
||||
hasher.update(str(int(size)).encode("utf-8"))
|
||||
return hasher.hexdigest()
|
||||
|
||||
if hasattr(stream_source, "getbuffer"):
|
||||
buffer_view = stream_source.getbuffer()
|
||||
size = int(buffer_view.nbytes)
|
||||
digest = _hash_buffer(
|
||||
size,
|
||||
lambda start, length: bytes(buffer_view[start : start + length]),
|
||||
)
|
||||
return f"buffer:{size}|{digest}"
|
||||
|
||||
if hasattr(stream_source, "getvalue"):
|
||||
raw = stream_source.getvalue()
|
||||
size = len(raw)
|
||||
digest = _hash_buffer(size, lambda start, length: raw[start : start + length])
|
||||
return f"bytes:{size}|{digest}"
|
||||
|
||||
return None
|
||||
|
||||
def _prune_comfy_video_upload_cache(self):
|
||||
now_ts = time.time()
|
||||
expired_keys = [
|
||||
key
|
||||
for key, entry in COMFY_VIDEO_UPLOAD_CACHE.items()
|
||||
if not isinstance(entry, dict)
|
||||
or not entry.get("url")
|
||||
or float(entry.get("expire_at", 0.0) or 0.0) <= now_ts
|
||||
]
|
||||
for key in expired_keys:
|
||||
COMFY_VIDEO_UPLOAD_CACHE.pop(key, None)
|
||||
|
||||
if len(COMFY_VIDEO_UPLOAD_CACHE) <= COMFY_VIDEO_UPLOAD_CACHE_MAX_ENTRIES:
|
||||
return
|
||||
|
||||
sorted_items = sorted(
|
||||
COMFY_VIDEO_UPLOAD_CACHE.items(),
|
||||
key=lambda kv: float(kv[1].get("saved_at", 0.0) or 0.0),
|
||||
)
|
||||
remove_count = len(COMFY_VIDEO_UPLOAD_CACHE) - COMFY_VIDEO_UPLOAD_CACHE_MAX_ENTRIES
|
||||
for key, _ in sorted_items[:remove_count]:
|
||||
COMFY_VIDEO_UPLOAD_CACHE.pop(key, None)
|
||||
|
||||
def _get_cached_comfy_video_url(self, cache_key):
|
||||
if not cache_key:
|
||||
return None
|
||||
self._prune_comfy_video_upload_cache()
|
||||
entry = COMFY_VIDEO_UPLOAD_CACHE.get(cache_key)
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
return (entry.get("url") or "").strip() or None
|
||||
|
||||
def _save_cached_comfy_video_url(self, cache_key, video_url):
|
||||
if not cache_key:
|
||||
return
|
||||
normalized_url = (video_url or "").strip()
|
||||
if not normalized_url:
|
||||
return
|
||||
now_ts = time.time()
|
||||
COMFY_VIDEO_UPLOAD_CACHE[cache_key] = {
|
||||
"url": normalized_url,
|
||||
"saved_at": now_ts,
|
||||
"expire_at": now_ts + COMFY_VIDEO_UPLOAD_CACHE_TTL_SECONDS,
|
||||
}
|
||||
self._prune_comfy_video_upload_cache()
|
||||
|
||||
def _get_video_duration_seconds(self, video, stream_source):
|
||||
duration_fallback = None
|
||||
try:
|
||||
duration_fallback = float(video.get_duration())
|
||||
except Exception:
|
||||
duration_fallback = None
|
||||
|
||||
def _safe_numeric_from_video(method_name):
|
||||
getter = getattr(video, method_name, None)
|
||||
if not callable(getter):
|
||||
return None
|
||||
try:
|
||||
val = float(getter())
|
||||
if val > 0:
|
||||
return val
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
fps = _safe_numeric_from_video("get_fps") or _safe_numeric_from_video("get_frame_rate")
|
||||
frame_count = _safe_numeric_from_video("get_frame_count")
|
||||
if fps and frame_count:
|
||||
return frame_count / fps
|
||||
|
||||
if isinstance(stream_source, str) and os.path.exists(stream_source):
|
||||
cap = cv2.VideoCapture(stream_source)
|
||||
try:
|
||||
if cap.isOpened():
|
||||
cap_fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0)
|
||||
cap_frames = float(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0)
|
||||
if cap_fps > 0 and cap_frames > 0:
|
||||
return cap_frames / cap_fps
|
||||
finally:
|
||||
cap.release()
|
||||
|
||||
if duration_fallback is not None and duration_fallback > 0:
|
||||
return duration_fallback
|
||||
|
||||
raise JimengException(get_text("popup_ref_video_invalid"))
|
||||
|
||||
def _validate_single_reference_video(self, video):
|
||||
try:
|
||||
container_format = str(video.get_container_format() or "").lower()
|
||||
width, height = video.get_dimensions()
|
||||
duration = float(video.get_duration())
|
||||
stream_source = video.get_stream_source()
|
||||
duration = self._get_video_duration_seconds(video, stream_source)
|
||||
size_bytes = self._get_video_stream_size_bytes(stream_source)
|
||||
except Exception as e:
|
||||
raise JimengException(get_text("popup_ref_video_invalid").format(msg=str(e)))
|
||||
@@ -1050,9 +1227,24 @@ class JimengSeedance2(JimengVideoBase, comfy_io.ComfyNode):
|
||||
+ [
|
||||
comfy_io.Image.Input("first_frame_image", optional=True),
|
||||
comfy_io.Image.Input("last_frame_image", optional=True),
|
||||
comfy_io.Image.Input("ref_image_1", optional=True),
|
||||
comfy_io.Video.Input("ref_video_1", optional=True),
|
||||
comfy_io.Audio.Input("ref_audio_1", optional=True),
|
||||
_create_named_autogrow_input(
|
||||
"ref_images",
|
||||
comfy_io.Image.Input("ref_image", optional=True),
|
||||
[f"ref_image_{idx}" for idx in range(1, 10)],
|
||||
1,
|
||||
),
|
||||
_create_named_autogrow_input(
|
||||
"ref_videos",
|
||||
comfy_io.Video.Input("ref_video", optional=True),
|
||||
[f"ref_video_{idx}" for idx in range(1, 4)],
|
||||
1,
|
||||
),
|
||||
_create_named_autogrow_input(
|
||||
"ref_audios",
|
||||
comfy_io.Audio.Input("ref_audio", optional=True),
|
||||
[f"ref_audio_{idx}" for idx in range(1, 4)],
|
||||
1,
|
||||
),
|
||||
],
|
||||
hidden=[
|
||||
comfy_io.Hidden.auth_token_comfy_org,
|
||||
@@ -1087,21 +1279,10 @@ class JimengSeedance2(JimengVideoBase, comfy_io.ComfyNode):
|
||||
non_blocking,
|
||||
first_frame_image=None,
|
||||
last_frame_image=None,
|
||||
ref_image_1=None,
|
||||
ref_image_2=None,
|
||||
ref_image_3=None,
|
||||
ref_image_4=None,
|
||||
ref_image_5=None,
|
||||
ref_image_6=None,
|
||||
ref_image_7=None,
|
||||
ref_image_8=None,
|
||||
ref_image_9=None,
|
||||
ref_video_1=None,
|
||||
ref_video_2=None,
|
||||
ref_video_3=None,
|
||||
ref_audio_1=None,
|
||||
ref_audio_2=None,
|
||||
ref_audio_3=None,
|
||||
ref_images=None,
|
||||
ref_videos=None,
|
||||
ref_audios=None,
|
||||
**kwargs,
|
||||
) -> comfy_io.NodeOutput:
|
||||
node_id = cls.hidden.unique_id
|
||||
|
||||
@@ -1110,20 +1291,11 @@ class JimengSeedance2(JimengVideoBase, comfy_io.ComfyNode):
|
||||
|
||||
content = []
|
||||
total_image_request_bytes = 0
|
||||
ref_images = _collect_dynamic_inputs(ref_images, kwargs, "ref_image_")
|
||||
ref_videos = _collect_dynamic_inputs(ref_videos, kwargs, "ref_video_")
|
||||
ref_audios = _collect_dynamic_inputs(ref_audios, kwargs, "ref_audio_")
|
||||
|
||||
for img in [
|
||||
first_frame_image,
|
||||
last_frame_image,
|
||||
ref_image_1,
|
||||
ref_image_2,
|
||||
ref_image_3,
|
||||
ref_image_4,
|
||||
ref_image_5,
|
||||
ref_image_6,
|
||||
ref_image_7,
|
||||
ref_image_8,
|
||||
ref_image_9,
|
||||
]:
|
||||
for img in [first_frame_image, last_frame_image] + ref_images:
|
||||
helper._validate_reference_image_constraints(img)
|
||||
|
||||
total_image_request_bytes += helper._append_image_content(
|
||||
@@ -1136,41 +1308,14 @@ class JimengSeedance2(JimengVideoBase, comfy_io.ComfyNode):
|
||||
content, last_frame_image, "last_frame"
|
||||
)
|
||||
|
||||
has_any_reference_inputs = any(
|
||||
img is not None
|
||||
for img in [
|
||||
ref_image_1,
|
||||
ref_image_2,
|
||||
ref_image_3,
|
||||
ref_image_4,
|
||||
ref_image_5,
|
||||
ref_image_6,
|
||||
ref_image_7,
|
||||
ref_image_8,
|
||||
ref_image_9,
|
||||
]
|
||||
) or any(v is not None for v in [ref_video_1, ref_video_2, ref_video_3]) or any(
|
||||
audio is not None for audio in [ref_audio_1, ref_audio_2, ref_audio_3]
|
||||
)
|
||||
has_any_reference_inputs = bool(ref_images or ref_videos or ref_audios)
|
||||
|
||||
if (first_frame_image is not None or last_frame_image is not None) and has_any_reference_inputs:
|
||||
raise JimengException(get_text("popup_first_last_conflict_with_refs"))
|
||||
|
||||
helper._validate_reference_videos_constraints(
|
||||
[ref_video_1, ref_video_2, ref_video_3],
|
||||
)
|
||||
helper._validate_reference_videos_constraints(ref_videos)
|
||||
|
||||
for img in [
|
||||
ref_image_1,
|
||||
ref_image_2,
|
||||
ref_image_3,
|
||||
ref_image_4,
|
||||
ref_image_5,
|
||||
ref_image_6,
|
||||
ref_image_7,
|
||||
ref_image_8,
|
||||
ref_image_9,
|
||||
]:
|
||||
for img in ref_images:
|
||||
total_image_request_bytes += helper._append_image_content(
|
||||
content, img, "reference_image"
|
||||
)
|
||||
@@ -1183,35 +1328,39 @@ class JimengSeedance2(JimengVideoBase, comfy_io.ComfyNode):
|
||||
)
|
||||
)
|
||||
|
||||
uploaded_video_urls: list[str | None] = [None, None, None]
|
||||
ref_videos = [ref_video_1, ref_video_2, ref_video_3]
|
||||
if any(v is not None for v in ref_videos):
|
||||
uploaded_video_urls = []
|
||||
if ref_videos:
|
||||
try:
|
||||
from comfy_api_nodes.util import upload_video_to_comfyapi
|
||||
except Exception as e:
|
||||
raise JimengException(get_text("popup_req_failed").format(msg=str(e)))
|
||||
for idx, v in enumerate(ref_videos):
|
||||
if v is None:
|
||||
cache_key = helper._build_comfy_video_upload_cache_key(v)
|
||||
cached_video_url = helper._get_cached_comfy_video_url(cache_key)
|
||||
if cached_video_url:
|
||||
uploaded_video_urls.append(cached_video_url)
|
||||
continue
|
||||
log_msg("upload_ref_video_start", index=idx + 1, total=3)
|
||||
uploaded_video_urls[idx] = await upload_video_to_comfyapi(
|
||||
done_before = len(uploaded_video_urls)
|
||||
pending_before = max(0, len(ref_videos) - done_before)
|
||||
log_msg("upload_ref_video_start", done=done_before, pending=pending_before)
|
||||
uploaded_video_url = await upload_video_to_comfyapi(
|
||||
cls,
|
||||
v,
|
||||
wait_label=None,
|
||||
)
|
||||
log_msg("upload_ref_video_done", index=idx + 1, total=3)
|
||||
helper._save_cached_comfy_video_url(cache_key, uploaded_video_url)
|
||||
uploaded_video_urls.append(uploaded_video_url)
|
||||
done_after = len(uploaded_video_urls)
|
||||
pending_after = max(0, len(ref_videos) - done_after)
|
||||
log_msg("upload_ref_video_done", done=done_after, pending=pending_after)
|
||||
|
||||
final_video_urls = [
|
||||
(uploaded_video_urls[0] or "").strip(),
|
||||
(uploaded_video_urls[1] or "").strip(),
|
||||
(uploaded_video_urls[2] or "").strip(),
|
||||
]
|
||||
final_video_urls = [(uploaded_video_url or "").strip() for uploaded_video_url in uploaded_video_urls]
|
||||
for video_url in final_video_urls:
|
||||
helper._append_media_url_content(content, video_url, "video_url", "reference_video")
|
||||
|
||||
total_audio_duration = 0.0
|
||||
total_audio_request_bytes = 0
|
||||
for audio in [ref_audio_1, ref_audio_2, ref_audio_3]:
|
||||
for audio in ref_audios:
|
||||
appended = helper._append_audio_content(content, audio, "reference_audio")
|
||||
if appended is None:
|
||||
continue
|
||||
@@ -1236,29 +1385,13 @@ class JimengSeedance2(JimengVideoBase, comfy_io.ComfyNode):
|
||||
)
|
||||
|
||||
has_image_reference = any(
|
||||
img is not None
|
||||
for img in [
|
||||
first_frame_image,
|
||||
last_frame_image,
|
||||
ref_image_1,
|
||||
ref_image_2,
|
||||
ref_image_3,
|
||||
ref_image_4,
|
||||
ref_image_5,
|
||||
ref_image_6,
|
||||
ref_image_7,
|
||||
ref_image_8,
|
||||
ref_image_9,
|
||||
]
|
||||
)
|
||||
img is not None for img in [first_frame_image, last_frame_image]
|
||||
) or bool(ref_images)
|
||||
has_video_reference = any(
|
||||
(url or "").strip()
|
||||
for url in final_video_urls
|
||||
)
|
||||
has_audio_reference = any(
|
||||
audio is not None
|
||||
for audio in [ref_audio_1, ref_audio_2, ref_audio_3]
|
||||
)
|
||||
has_audio_reference = bool(ref_audios)
|
||||
prompt = (prompt or "").strip()
|
||||
|
||||
if not prompt and not content:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "ComfyUI-Jimeng-API"
|
||||
description = "在 ComfyUI 中使用火山方舟API提供的即梦(豆包)Seedream 和 Seedance 模型。 / Using the Volcano Ark API to utilize the Seedream and Seedance models of Jimeng (Doubao) in ComfyUI."
|
||||
version = "2.3.0"
|
||||
version = "2.3.1"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["volcengine-python-sdk[ark]>=5.0.12"]
|
||||
|
||||
|
||||
@@ -156,6 +156,75 @@ function applyBottomPadding(node) {
|
||||
return changed;
|
||||
}
|
||||
|
||||
const AUTOGROW_LABEL_RULES = {
|
||||
JimengSeedream4: [
|
||||
{ prefix: "image_", zhLabel: "图像", enLabel: "Image" },
|
||||
],
|
||||
JimengSeedream5: [
|
||||
{ prefix: "image_", zhLabel: "图像", enLabel: "Image" },
|
||||
],
|
||||
JimengSeedance2: [
|
||||
{ prefix: "ref_image_", zhLabel: "参考图片", enLabel: "Ref Image" },
|
||||
{ prefix: "ref_video_", zhLabel: "参考视频", enLabel: "Ref Video" },
|
||||
{ prefix: "ref_audio_", zhLabel: "参考音频", enLabel: "Ref Audio" },
|
||||
],
|
||||
};
|
||||
|
||||
function getAutogrowLabelRules(node) {
|
||||
if (!node || !node.comfyClass) return [];
|
||||
return AUTOGROW_LABEL_RULES[node.comfyClass] || [];
|
||||
}
|
||||
|
||||
function escapeRegex(text) {
|
||||
return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function setInputDisplayText(input, text) {
|
||||
const changed =
|
||||
input.label !== text ||
|
||||
input.localized_name !== text ||
|
||||
input.display_name !== text;
|
||||
input.label = text;
|
||||
input.localized_name = text;
|
||||
input.display_name = text;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function applyAutogrowInputLabels(node) {
|
||||
if (!node.inputs || node.inputs.length === 0) return false;
|
||||
const rules = getAutogrowLabelRules(node);
|
||||
if (!rules || rules.length === 0) return false;
|
||||
|
||||
const isZh = navigator.language.startsWith("zh");
|
||||
let changed = false;
|
||||
|
||||
for (const input of node.inputs) {
|
||||
if (!input || !input.name) continue;
|
||||
const inputName = String(input.name);
|
||||
for (const rule of rules) {
|
||||
// 兼容 Autogrow 生成的命名形式
|
||||
const pattern = new RegExp(`(?:^|\\.)${escapeRegex(rule.prefix)}(\\d+)$`);
|
||||
const matched = inputName.match(pattern);
|
||||
if (!matched) continue;
|
||||
const suffixNum = parseInt(matched[1], 10);
|
||||
if (!Number.isFinite(suffixNum)) continue;
|
||||
const displayLabel = isZh ? rule.zhLabel : rule.enLabel;
|
||||
const expectedLabel = `${displayLabel} ${suffixNum}`;
|
||||
if (setInputDisplayText(input, expectedLabel)) {
|
||||
changed = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function refreshAutogrowInputLabels(node) {
|
||||
if (applyAutogrowInputLabels(node)) {
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行组件联动逻辑
|
||||
* @param {object} node - 节点实例
|
||||
@@ -329,236 +398,19 @@ app.registerExtension({
|
||||
return r;
|
||||
};
|
||||
|
||||
// 动态控制 Seedream4/5 节点的图像输入接口
|
||||
if (node.comfyClass === "JimengSeedream4" || node.comfyClass === "JimengSeedream5") {
|
||||
const onConnectionsChange = node.onConnectionsChange;
|
||||
node.onConnectionsChange = function (type, index, connected, link_info, slot) {
|
||||
const r = onConnectionsChange ? onConnectionsChange.apply(this, arguments) : undefined;
|
||||
const onConnectionsChange = node.onConnectionsChange;
|
||||
node.onConnectionsChange = function (type, index, connected, link_info, slot) {
|
||||
const r = onConnectionsChange ? onConnectionsChange.apply(this, arguments) : undefined;
|
||||
if (!this._isConfiguring) {
|
||||
refreshAutogrowInputLabels(this);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
if (this._isConfiguring) return r;
|
||||
|
||||
if (type !== 1) return r;
|
||||
|
||||
if (this._isUpdatingInputs) return r;
|
||||
this._isUpdatingInputs = true;
|
||||
|
||||
try {
|
||||
let extraHeight = 0;
|
||||
if (this.size && this.computeSize && !this.flags?.collapsed) {
|
||||
const currentMinHeight = this.computeSize()[1];
|
||||
const currentActualHeight = this.size[1];
|
||||
extraHeight = Math.max(0, currentActualHeight - currentMinHeight);
|
||||
}
|
||||
|
||||
const prefix = "image_";
|
||||
const MAX_INPUTS = 14;
|
||||
|
||||
const allInputs = this.inputs || [];
|
||||
const dynamicInputs = [];
|
||||
let baseInputIndex = -1;
|
||||
|
||||
for (let i = 0; i < allInputs.length; i++) {
|
||||
const name = allInputs[i].name;
|
||||
if (name === 'images') {
|
||||
baseInputIndex = i;
|
||||
dynamicInputs.push({ index: i, name: name, input: allInputs[i] });
|
||||
} else if (name.startsWith(prefix)) {
|
||||
dynamicInputs.push({ index: i, name: name, input: allInputs[i] });
|
||||
}
|
||||
}
|
||||
|
||||
if (baseInputIndex === -1) return r;
|
||||
|
||||
const activeLinks = [];
|
||||
|
||||
for (const item of dynamicInputs) {
|
||||
if (item.input.link !== null) {
|
||||
const link = app.graph.links[item.input.link];
|
||||
if (link) {
|
||||
if (link.target_id !== this.id) continue;
|
||||
let slotNum = 1;
|
||||
if (item.name !== 'images') {
|
||||
const parsed = parseInt(item.name.slice(prefix.length), 10);
|
||||
if (!Number.isNaN(parsed)) slotNum = parsed;
|
||||
}
|
||||
activeLinks.push({
|
||||
slotNum,
|
||||
origin_id: link.origin_id,
|
||||
origin_slot: link.origin_slot,
|
||||
type: link.type
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeLinks.sort((a, b) => a.slotNum - b.slotNum);
|
||||
|
||||
const neededSlots = activeLinks.length + 1;
|
||||
const finalSlots = Math.min(neededSlots, MAX_INPUTS);
|
||||
|
||||
const lastDynamicIndex = dynamicInputs[dynamicInputs.length - 1].index;
|
||||
const firstDynamicIndex = dynamicInputs[0].index; // This is 'images'
|
||||
|
||||
for (let i = lastDynamicIndex; i > firstDynamicIndex; i--) {
|
||||
if (this.inputs[i].name.startsWith("image_")) {
|
||||
this.removeInput(i);
|
||||
}
|
||||
}
|
||||
|
||||
for (let slot = 2; slot <= finalSlots; slot++) {
|
||||
const name = `image_${slot}`;
|
||||
this.addInput(name, "IMAGE");
|
||||
const input = this.inputs[this.inputs.length - 1];
|
||||
if (input) {
|
||||
const isZh = navigator.language.startsWith("zh");
|
||||
input.label = isZh ? `图像 ${slot}` : `Image ${slot}`;
|
||||
}
|
||||
}
|
||||
|
||||
const nameToIndex = {};
|
||||
for (let i = 0; i < this.inputs.length; i++) {
|
||||
const n = this.inputs[i].name;
|
||||
if (n === 'images' || n.startsWith(prefix)) nameToIndex[n] = i;
|
||||
}
|
||||
|
||||
for (let k = 0; k < activeLinks.length; k++) {
|
||||
const linkInfo = activeLinks[k];
|
||||
const targetName = (k === 0) ? 'images' : `image_${k + 1}`;
|
||||
const targetSlotIndex = nameToIndex[targetName];
|
||||
if (targetSlotIndex === undefined) continue;
|
||||
|
||||
const sourceNode = app.graph.getNodeById(linkInfo.origin_id);
|
||||
if (sourceNode) {
|
||||
sourceNode.connect(linkInfo.origin_slot, this, targetSlotIndex);
|
||||
}
|
||||
}
|
||||
|
||||
updateNodeHeight(this, extraHeight);
|
||||
|
||||
} catch (e) {
|
||||
console.error("[Jimeng] Error updating dynamic inputs:", e);
|
||||
} finally {
|
||||
this._isUpdatingInputs = false;
|
||||
}
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
|
||||
if (node.comfyClass === "JimengSeedance2") {
|
||||
const onConnectionsChange = node.onConnectionsChange;
|
||||
node.onConnectionsChange = function (type, index, connected, link_info, slot) {
|
||||
const r = onConnectionsChange ? onConnectionsChange.apply(this, arguments) : undefined;
|
||||
|
||||
if (this._isConfiguring) return r;
|
||||
if (type !== 1) return r;
|
||||
if (this._isUpdatingInputs) return r;
|
||||
this._isUpdatingInputs = true;
|
||||
|
||||
try {
|
||||
let extraHeight = 0;
|
||||
if (this.size && this.computeSize && !this.flags?.collapsed) {
|
||||
const currentMinHeight = this.computeSize()[1];
|
||||
const currentActualHeight = this.size[1];
|
||||
extraHeight = Math.max(0, currentActualHeight - currentMinHeight);
|
||||
}
|
||||
|
||||
const isZh = navigator.language.startsWith("zh");
|
||||
|
||||
const updateGroup = (prefix, baseName, maxSlots, inputType, zhLabel, enLabel) => {
|
||||
const allInputs = this.inputs || [];
|
||||
let baseIndex = -1;
|
||||
const groupInputs = [];
|
||||
|
||||
for (let i = 0; i < allInputs.length; i++) {
|
||||
const name = allInputs[i].name;
|
||||
if (name === baseName) {
|
||||
baseIndex = i;
|
||||
groupInputs.push({ index: i, name, input: allInputs[i] });
|
||||
} else if (name.startsWith(prefix)) {
|
||||
groupInputs.push({ index: i, name, input: allInputs[i] });
|
||||
}
|
||||
}
|
||||
|
||||
if (baseIndex === -1) return;
|
||||
|
||||
const activeLinks = [];
|
||||
for (const item of groupInputs) {
|
||||
if (item.input.link !== null) {
|
||||
const link = app.graph.links[item.input.link];
|
||||
if (link) {
|
||||
if (link.target_id !== this.id) continue;
|
||||
let slotNum = 1;
|
||||
if (item.name !== baseName) {
|
||||
const parsed = parseInt(item.name.slice(prefix.length), 10);
|
||||
if (!Number.isNaN(parsed)) slotNum = parsed;
|
||||
}
|
||||
activeLinks.push({
|
||||
slotNum,
|
||||
origin_id: link.origin_id,
|
||||
origin_slot: link.origin_slot,
|
||||
type: link.type
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeLinks.sort((a, b) => a.slotNum - b.slotNum);
|
||||
const finalSlots = Math.min(activeLinks.length + 1, maxSlots);
|
||||
|
||||
for (let i = this.inputs.length - 1; i >= 0; i--) {
|
||||
const n = this.inputs[i].name;
|
||||
if (n !== baseName && n.startsWith(prefix)) {
|
||||
this.removeInput(i);
|
||||
}
|
||||
}
|
||||
|
||||
baseIndex = (this.inputs || []).findIndex(inp => inp.name === baseName);
|
||||
if (baseIndex === -1) return;
|
||||
|
||||
for (let slotNum = 2; slotNum <= finalSlots; slotNum++) {
|
||||
const name = `${prefix}${slotNum}`;
|
||||
this.addInput(name, inputType);
|
||||
const input = this.inputs[this.inputs.length - 1];
|
||||
if (input) {
|
||||
input.label = isZh ? `${zhLabel} ${slotNum}` : `${enLabel} ${slotNum}`;
|
||||
}
|
||||
const moved = this.inputs.pop();
|
||||
this.inputs.splice(baseIndex + (slotNum - 1), 0, moved);
|
||||
}
|
||||
|
||||
const nameToIndex = {};
|
||||
for (let i = 0; i < this.inputs.length; i++) {
|
||||
const n = this.inputs[i].name;
|
||||
if (n === baseName || n.startsWith(prefix)) nameToIndex[n] = i;
|
||||
}
|
||||
|
||||
for (let k = 0; k < activeLinks.length && k < maxSlots; k++) {
|
||||
const linkInfo = activeLinks[k];
|
||||
const targetName = (k === 0) ? baseName : `${prefix}${k + 1}`;
|
||||
const targetSlotIndex = nameToIndex[targetName];
|
||||
if (targetSlotIndex === undefined) continue;
|
||||
const sourceNode = app.graph.getNodeById(linkInfo.origin_id);
|
||||
if (sourceNode) {
|
||||
sourceNode.connect(linkInfo.origin_slot, this, targetSlotIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateGroup("ref_image_", "ref_image_1", 9, "IMAGE", "参考图片", "Ref Image");
|
||||
updateGroup("ref_video_", "ref_video_1", 3, "VIDEO", "参考视频", "Ref Video");
|
||||
updateGroup("ref_audio_", "ref_audio_1", 3, "AUDIO", "参考音频", "Ref Audio");
|
||||
|
||||
updateNodeHeight(this, extraHeight);
|
||||
} catch (e) {
|
||||
console.error("[Jimeng] Error updating dynamic inputs:", e);
|
||||
} finally {
|
||||
this._isUpdatingInputs = false;
|
||||
}
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
refreshAutogrowInputLabels(node);
|
||||
setTimeout(() => refreshAutogrowInputLabels(node), 0);
|
||||
setTimeout(() => refreshAutogrowInputLabels(node), 120);
|
||||
setTimeout(() => refreshAutogrowInputLabels(node), 360);
|
||||
|
||||
// 筛选需监听的组件
|
||||
const widgetsToWatch = node.widgets?.filter(w => TARGET_WIDGETS.includes(w.name));
|
||||
|
||||
Reference in New Issue
Block a user