Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Node implementation rules
|
||||
|
||||
These instructions apply to `nodes/` and override broader guidance where more specific.
|
||||
|
||||
- Preserve released node IDs, input IDs, output order, and widget ordering unless a tested workflow migration is included.
|
||||
- Root `NODE_CLASS_MAPPINGS` in `../__init__.py` is the canonical public registry. Keep `nodes/__init__.py` exports synchronized with it.
|
||||
- V1 and V3 nodes currently coexist. Follow the API style already used by the target node; do not migrate unrelated nodes opportunistically.
|
||||
- V3 `node_id` must match the root mapping key. Return `io.NodeOutput` from V3 execution methods.
|
||||
- Validate user inputs before uploads or paid API calls. Check interruption during polling, retries, and long downloads.
|
||||
- Do not store API keys or resolved authorization data in node attributes that can enter serialized workflows.
|
||||
- Put reusable HTTP behavior, retry logic, uploads, media validation, and response parsing in `clients/` or `utils/`.
|
||||
- If widget layout changes, update `../web/js/migrateWorkflow.js` and add a regression test for legacy `widgets_values`.
|
||||
- Add new node tests under `../tests/` and run them through `../tests/run_all.py`.
|
||||
+159
-127
@@ -1,11 +1,20 @@
|
||||
"""
|
||||
K3 动作控制 自研节点
|
||||
K3 动作控制节点
|
||||
|
||||
用参考视频驱动参考图中人物动作,生成视频。
|
||||
视频通过 R2 上传后传 URL,图片转 base64 直传。
|
||||
|
||||
支持的模型:
|
||||
- v3:标准动作控制,支持 5~30s 时长
|
||||
- v2-6:标准动作控制,支持 5~30s 时长
|
||||
- v3-t / v2-6-t:腾讯 Kling 网关渠道(保留兼容)
|
||||
|
||||
接口端点:
|
||||
- 动作控制:POST /kling/v1/videos/motion-control
|
||||
- 腾讯渠道:POST /v1/videos
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import io as _stdio
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
@@ -13,12 +22,16 @@ import tempfile
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||
from comfy_api.latest import io
|
||||
|
||||
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, get_base_url_by_route
|
||||
from ..utils.r2_uploader import upload_video, upload_image
|
||||
from ..utils.image_utils import tensor_to_pil
|
||||
from ..utils.http_error import async_request_with_retry
|
||||
from ..utils.video_task import (
|
||||
PollDeadline,
|
||||
check_interrupt,
|
||||
download_video_to_file,
|
||||
extract_error_message,
|
||||
extract_progress,
|
||||
extract_status,
|
||||
@@ -39,9 +52,26 @@ except Exception:
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 官方标准模型名映射
|
||||
_STANDARD_MODELS = {
|
||||
"v3": "kling-v3",
|
||||
"v2-6": "kling-v2-6",
|
||||
}
|
||||
|
||||
# 官方标准端点
|
||||
_ENDPOINT_CREATE = "/kling/v1/videos/motion-control"
|
||||
_ENDPOINT_STATUS = "/kling/v1/videos/motion-control/{task_id}"
|
||||
|
||||
# 腾讯 Kling 网关渠道(-t):保留兼容
|
||||
_ENDPOINT_T_CREATE = "/v1/videos"
|
||||
_ENDPOINT_T_STATUS = "/v1/videos/{task_id}"
|
||||
|
||||
# -t 渠道模型名映射(服务端已部署,需在「模型倍率」各配一行 =1)
|
||||
_MODEL_T_MAP = {
|
||||
"v3-t": "kling-v3-motion-t",
|
||||
"v2-6-t": "kling-v2-6-motion-t",
|
||||
}
|
||||
|
||||
_POLL_INIT = 5
|
||||
_POLL_MAX = 15
|
||||
|
||||
@@ -80,7 +110,7 @@ def _get_video_duration(reference_video) -> float | None:
|
||||
if isinstance(source, str) and os.path.isfile(source):
|
||||
with open(source, "rb") as f:
|
||||
data = f.read()
|
||||
elif isinstance(source, io.BytesIO):
|
||||
elif isinstance(source, _stdio.BytesIO):
|
||||
source.seek(0)
|
||||
data = source.read()
|
||||
else:
|
||||
@@ -106,51 +136,79 @@ def _validate_video_duration(reference_video, character_orientation: str):
|
||||
)
|
||||
|
||||
|
||||
# ── 模型 DynamicCombo 选项构建 ─────────────────────────────────────────────────
|
||||
|
||||
def _build_model_input():
|
||||
"""构建「模型」DynamicCombo。
|
||||
|
||||
支持的模型:
|
||||
- v3:标准动作控制
|
||||
- v2-6:标准动作控制
|
||||
- v3-t / v2-6-t:腾讯网关渠道(保留兼容)
|
||||
"""
|
||||
def _duration_input():
|
||||
return io.Combo.Input(
|
||||
"时长", options=[5, 10, 15, 20, 25, 30], default=5,
|
||||
tooltip="输出视频时长(秒)。须 ≥ 参考视频时长。",
|
||||
)
|
||||
return io.DynamicCombo.Input(
|
||||
"模型",
|
||||
options=[
|
||||
io.DynamicCombo.Option("v3", [_duration_input()]),
|
||||
io.DynamicCombo.Option("v2-6", [_duration_input()]),
|
||||
io.DynamicCombo.Option("v3-t", []), # 腾讯网关,无时长参数
|
||||
io.DynamicCombo.Option("v2-6-t", []), # 腾讯网关,无时长参数
|
||||
],
|
||||
tooltip="v3/v2-6:官方标准模型;v3-t/v2-6-t:腾讯网关渠道(兼容)。",
|
||||
)
|
||||
|
||||
|
||||
# ── 节点 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class K3MotionControl:
|
||||
class K3MotionControl(io.ComfyNode):
|
||||
"""K3 动作控制 自研 —— 用参考视频驱动参考图人物动作"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"参考图片": ("IMAGE",),
|
||||
"参考视频": ("VIDEO",),
|
||||
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
|
||||
"模型": (["v3", "v2-6"], {"default": "v3"}),
|
||||
"模式": (["720p", "1080p"], {"default": "1080p"}),
|
||||
"时长": ([5, 10, 15, 20, 25, 30], {"default": 5}),
|
||||
"角色朝向": (["图片", "视频"], {"default": "图片"}),
|
||||
"保留原声": (["打开", "关闭"], {"default": "打开"}),
|
||||
"seed": ("INT", {
|
||||
"default": 0, "min": 0, "max": 2147483647,
|
||||
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
|
||||
}),
|
||||
},
|
||||
}
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="K3MotionControl",
|
||||
display_name="K 动作模仿",
|
||||
category="comfyui_o1key/KVideo",
|
||||
inputs=[
|
||||
io.Image.Input("参考图片"),
|
||||
io.Video.Input("参考视频"),
|
||||
_build_model_input(),
|
||||
io.String.Input("提示词", multiline=True, default=""),
|
||||
io.Combo.Input("模式", options=["720p", "1080p"], default="1080p"),
|
||||
io.Combo.Input("角色朝向", options=["图片", "视频"], default="图片"),
|
||||
io.Combo.Input("保留原声", options=["打开", "关闭"], default="打开"),
|
||||
io.Int.Input("seed", default=0, min=0, max=2147483647,
|
||||
tooltip="seed 仅控制节点是否重新运行,结果本身不可复现。"),
|
||||
],
|
||||
outputs=[io.Video.Output(display_name="视频")],
|
||||
accept_all_inputs=True,
|
||||
)
|
||||
|
||||
RETURN_TYPES = ("VIDEO",)
|
||||
RETURN_NAMES = ("视频",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "comfyui_o1key/KVideo"
|
||||
|
||||
async def generate(self, 参考图片, 参考视频, 提示词, 保留原声, 角色朝向, 模式, 模型, 时长, 网络线路, seed, **kwargs):
|
||||
@classmethod
|
||||
async def execute(cls, 参考图片, 参考视频, 模型, 提示词, 模式, 角色朝向, 保留原声, seed, **_kwargs) -> io.NodeOutput:
|
||||
api_key = get_api_key_or_raise()
|
||||
base_url = get_base_url_by_route(网络线路)
|
||||
|
||||
# ── 渠道判定(模型为 DynamicCombo dict)─────────────────────────
|
||||
模型代号 = 模型["模型"]
|
||||
is_t_channel = 模型代号 in _MODEL_T_MAP
|
||||
时长 = int(模型.get("时长", 5)) # -t 渠道无此子输入
|
||||
mode_api = "std" if 模式 == "720p" else "pro"
|
||||
character_orientation = "image" if 角色朝向 == "图片" else "video"
|
||||
keep_sound = "yes" if 保留原声 == "打开" else "no"
|
||||
prompt = 提示词.strip()
|
||||
|
||||
base_url = get_base_url_by_route()
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ── 参数映射 ──────────────────────────────────────────────────
|
||||
mode_api = "std" if 模式 == "720p" else "pro"
|
||||
model_name = f"kling-{模型}-motion-{mode_api}-{时长}s"
|
||||
character_orientation = "image" if 角色朝向 == "图片" else "video"
|
||||
keep_sound = "yes" if 保留原声 == "打开" else "no"
|
||||
prompt = 提示词.strip()
|
||||
|
||||
if len(prompt) > 2500:
|
||||
raise ValueError(f"提示词长度({len(prompt)})超过上限 2500 个字符,请缩短后重试。")
|
||||
|
||||
@@ -163,7 +221,7 @@ class K3MotionControl:
|
||||
|
||||
def _stage(s: str):
|
||||
if s == "uploading":
|
||||
print("[K3 动作控制] 上传视频到 R2...")
|
||||
print("[K3 动作控制] 上传图片/视频到 OSS...")
|
||||
if pbar: pbar.update_absolute(0, 100)
|
||||
elif s == "submitting":
|
||||
print("[K3 动作控制] 提交任务...")
|
||||
@@ -182,36 +240,65 @@ class K3MotionControl:
|
||||
if pbar: pbar.update_absolute(15 + int(pct * 0.84), 100)
|
||||
|
||||
# ── 视频时长校验 ──────────────────────────────────────────────
|
||||
# 参考视频时长约束(image≤10s / video≤30s,下限 3s)两渠道通用。
|
||||
_validate_video_duration(参考视频, character_orientation)
|
||||
|
||||
# 参考视频时长不得超过所选时长(防止用长视频生成短计费)
|
||||
_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 的档位,或更换更短的参考视频。"
|
||||
)
|
||||
# 「参考视频不得超过所选时长」仅标准渠道有意义:-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 的档位,或更换更短的参考视频。"
|
||||
)
|
||||
|
||||
# ── 图片 & 视频上传 R2 → 获取公网 URL ────────────────────────
|
||||
# ── 图片 & 视频上传 OSS → 获取公网 URL ────────────────────────
|
||||
_stage("uploading")
|
||||
check_interrupt()
|
||||
pil_list = tensor_to_pil(参考图片)
|
||||
image_url = await upload_image(pil_list[0].convert("RGB"))
|
||||
img = pil_list[0]
|
||||
# 转换为 RGBA 以支持透明通道,PNG 格式上传
|
||||
if img.mode not in ("RGBA", "RGB"):
|
||||
img = img.convert("RGBA" if "A" in img.mode or img.mode == "LA" else "RGB")
|
||||
image_url = await upload_image(img, base_url=base_url)
|
||||
check_interrupt()
|
||||
video_url = await upload_video(参考视频)
|
||||
video_url = await upload_video(参考视频, base_url=base_url)
|
||||
|
||||
# ── 构建请求体 ────────────────────────────────────────────────
|
||||
body: dict = {
|
||||
"model_name": model_name,
|
||||
"model": model_name,
|
||||
"image_url": image_url,
|
||||
"video_url": video_url,
|
||||
"character_orientation": character_orientation,
|
||||
"mode": mode_api,
|
||||
"keep_original_sound": keep_sound,
|
||||
}
|
||||
if prompt:
|
||||
body["prompt"] = prompt
|
||||
if is_t_channel:
|
||||
# 腾讯 Kling 网关渠道:动作控制专有字段进 metadata 透传(PascalCase),
|
||||
# 顶层只放标准字段。无 duration / mode 由网关按模型处理。
|
||||
body = {
|
||||
"model": _MODEL_T_MAP[模型代号],
|
||||
"prompt": prompt or "动作与参考视频保持一致", # 网关强制非空
|
||||
"image": image_url,
|
||||
"metadata": {
|
||||
"Video": video_url,
|
||||
"CharacterOrientation": character_orientation,
|
||||
"KeepOriginalSound": keep_sound,
|
||||
"Mode": mode_api, # 720p→std / 1080p→pro
|
||||
},
|
||||
}
|
||||
create_path = _ENDPOINT_T_CREATE
|
||||
status_path = _ENDPOINT_T_STATUS
|
||||
else:
|
||||
# 标准渠道:使用官方标准接口,参数扁平传递
|
||||
# 获取实际的模型名(v3 → kling-v3)
|
||||
actual_model_name = _STANDARD_MODELS.get(模型代号, f"kling-{模型代号}")
|
||||
|
||||
body = {
|
||||
"model_name": actual_model_name,
|
||||
"image_url": image_url,
|
||||
"video_url": video_url,
|
||||
"character_orientation": character_orientation,
|
||||
"mode": mode_api,
|
||||
"keep_original_sound": keep_sound,
|
||||
"duration": str(时长),
|
||||
}
|
||||
if prompt:
|
||||
body["prompt"] = prompt
|
||||
create_path = _ENDPOINT_CREATE
|
||||
status_path = _ENDPOINT_STATUS
|
||||
|
||||
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
|
||||
tmp_fd, save_path = tempfile.mkstemp(suffix=".mp4", prefix="k3_motion_")
|
||||
@@ -222,7 +309,7 @@ class K3MotionControl:
|
||||
# 1. 提交任务
|
||||
check_interrupt()
|
||||
_stage("submitting")
|
||||
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
||||
create_url = f"{base_url}{create_path}"
|
||||
resp = await run_with_interrupt(async_request_with_retry(
|
||||
session, "POST", create_url,
|
||||
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
||||
@@ -243,11 +330,13 @@ class K3MotionControl:
|
||||
_stage(f"submitted:{task_id}")
|
||||
|
||||
# 2. 轮询
|
||||
status_url = f"{base_url}{_ENDPOINT_STATUS.format(task_id=task_id)}"
|
||||
status_url = f"{base_url}{status_path.format(task_id=task_id)}"
|
||||
interval = _POLL_INIT
|
||||
video_result_url = None
|
||||
deadline = PollDeadline(label="K3 动作控制")
|
||||
|
||||
while True:
|
||||
deadline.check()
|
||||
await interruptible_sleep(interval)
|
||||
check_interrupt()
|
||||
async with session.get(status_url, headers=headers) as resp:
|
||||
@@ -281,84 +370,27 @@ class K3MotionControl:
|
||||
if not video_result_url:
|
||||
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
||||
|
||||
# 3. 下载视频
|
||||
# 3. 下载视频(抗超时 / 断点续传 / 无限重试 / 可取消)
|
||||
check_interrupt()
|
||||
_stage("downloading")
|
||||
async with session.get(video_result_url, allow_redirects=True) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"视频下载失败 ({resp.status})")
|
||||
os.close(tmp_fd)
|
||||
with open(save_path, "wb") as f:
|
||||
async for chunk in resp.content.iter_chunked(8192):
|
||||
check_interrupt()
|
||||
f.write(chunk)
|
||||
os.close(tmp_fd)
|
||||
await download_video_to_file(
|
||||
session, video_result_url, save_path, label="K3 动作控制",
|
||||
)
|
||||
|
||||
_stage("done")
|
||||
|
||||
if _FOLDER_PATHS_OK:
|
||||
return (InputImpl.VideoFromFile(save_path),)
|
||||
return (save_path,)
|
||||
|
||||
|
||||
# ── 视频时长检测测试节点 ──────────────────────────────────────────────────────
|
||||
|
||||
class K3MotionVideoCheck:
|
||||
"""检测视频时长并校验是否满足动作控制的限制,不调用 API。"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"参考视频": ("VIDEO",),
|
||||
"角色朝向": (["图片", "视频"], {"default": "图片"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("检测结果",)
|
||||
FUNCTION = "check"
|
||||
CATEGORY = "comfyui_o1key/KVideo"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def check(self, 参考视频, 角色朝向):
|
||||
character_orientation = "image" if 角色朝向 == "图片" else "video"
|
||||
duration = _get_video_duration(参考视频)
|
||||
|
||||
if duration is None:
|
||||
result = "❌ 无法解析视频时长(格式不支持或文件损坏)"
|
||||
print(f"[K3 视频检测] {result}")
|
||||
return (result,)
|
||||
|
||||
limit = 10 if character_orientation == "image" else 30
|
||||
orientation_label = 角色朝向
|
||||
ok = 3 <= duration <= limit
|
||||
|
||||
if ok:
|
||||
result = (
|
||||
f"✅ 时长检测通过\n"
|
||||
f"视频时长: {duration:.2f}s\n"
|
||||
f"角色朝向: {orientation_label}(限制 3~{limit}s)"
|
||||
)
|
||||
else:
|
||||
result = (
|
||||
f"❌ 时长检测不通过\n"
|
||||
f"视频时长: {duration:.2f}s\n"
|
||||
f"角色朝向: {orientation_label}(限制 3~{limit}s)\n"
|
||||
f"请更换时长在 3~{limit}s 之间的视频。"
|
||||
)
|
||||
|
||||
print(f"[K3 视频检测] {result}")
|
||||
return (result,)
|
||||
return io.NodeOutput(InputImpl.VideoFromFile(save_path))
|
||||
return io.NodeOutput(save_path)
|
||||
|
||||
|
||||
# ── 节点注册 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"K3MotionControl": K3MotionControl,
|
||||
"K3MotionVideoCheck": K3MotionVideoCheck,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"K3MotionControl": "动作控制 K3 自研",
|
||||
"K3MotionVideoCheck": "视频时长检测 K3",
|
||||
"K3MotionControl": "K 动作模仿",
|
||||
}
|
||||
|
||||
+968
-221
File diff suppressed because it is too large
Load Diff
@@ -1,283 +0,0 @@
|
||||
"""
|
||||
首尾帧 K3 自研节点
|
||||
基于 K3 图生视频 自研,去掉分镜功能,新增尾帧可选输入。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||
from ..utils.http_error import async_request_with_retry
|
||||
from ..utils.video_task import (
|
||||
check_interrupt,
|
||||
extract_error_message,
|
||||
extract_progress,
|
||||
extract_status,
|
||||
extract_video_url,
|
||||
interruptible_sleep,
|
||||
is_failure_status,
|
||||
is_success_status,
|
||||
run_with_interrupt,
|
||||
)
|
||||
|
||||
try:
|
||||
from comfy_api.latest import InputImpl
|
||||
import folder_paths
|
||||
_FOLDER_PATHS_OK = True
|
||||
except Exception:
|
||||
_FOLDER_PATHS_OK = False
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_MODEL_BASE = "kling-v3"
|
||||
_MODES = ["720p", "1080p", "4K"]
|
||||
_MODE_MAP = {"720p": "std", "1080p": "pro", "4K": "4k"}
|
||||
|
||||
_ENDPOINT_CREATE = "/v1/video/generations"
|
||||
_ENDPOINT_STATUS = "/v1/video/generations/{task_id}"
|
||||
|
||||
_POLL_INIT = 3
|
||||
_POLL_MAX = 15
|
||||
|
||||
|
||||
# ── 工具函数 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _prepare_image_base64(tensor) -> str:
|
||||
"""转换并校验图片,不符合约束时自动等比缩放后返回 base64。"""
|
||||
import io
|
||||
import base64
|
||||
|
||||
pil_list = tensor_to_pil(tensor)
|
||||
img = pil_list[0].convert("RGB")
|
||||
w, h = img.size
|
||||
|
||||
# 1. 宽高比校验
|
||||
ratio = w / h
|
||||
if ratio < 1 / 2.5 or ratio > 2.5:
|
||||
raise RuntimeError(
|
||||
f"图片宽高比 {w}:{h}({ratio:.2f})超出允许范围 1:2.5 ~ 2.5:1,请裁剪后重试。"
|
||||
)
|
||||
|
||||
# 2. 最小尺寸:任意边 < 300px 时等比放大
|
||||
if w < 300 or h < 300:
|
||||
scale = max(300 / w, 300 / h)
|
||||
img = img.resize((int(w * scale), int(h * scale)), resample=1)
|
||||
|
||||
# 3. 文件大小:循环等比缩小直到 ≤ 10MB
|
||||
MAX_BYTES = 10 * 1024 * 1024
|
||||
for _ in range(20):
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
if buf.tell() <= MAX_BYTES:
|
||||
break
|
||||
scale = (MAX_BYTES / buf.tell()) ** 0.5 * 0.95
|
||||
new_w = int(img.width * scale)
|
||||
new_h = int(img.height * scale)
|
||||
if new_w < 300 or new_h < 300:
|
||||
raise RuntimeError(
|
||||
f"图片压缩至 10MB 以内后尺寸({new_w}x{new_h})低于最小限制 300px,无法同时满足两项约束。"
|
||||
)
|
||||
img = img.resize((new_w, new_h), resample=1)
|
||||
else:
|
||||
raise RuntimeError("图片经过 20 次缩放仍超过 10MB,请检查原始图片。")
|
||||
|
||||
buf.seek(0)
|
||||
return base64.b64encode(buf.read()).decode("utf-8")
|
||||
|
||||
|
||||
# ── 节点 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class K3VideoFirstLast:
|
||||
"""首尾帧 K3 自研"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"起始帧": ("IMAGE",),
|
||||
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"负向提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"时长": ([5, 10, 15], {"default": 5}),
|
||||
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
||||
"模式": (_MODES, {"default": "720p"}),
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
|
||||
"seed": ("INT", {
|
||||
"default": 0, "min": 0, "max": 2147483647,
|
||||
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"尾帧": ("IMAGE", {"tooltip": "可选。传入后将作为视频尾帧参考。"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO",)
|
||||
RETURN_NAMES = ("视频",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "comfyui_o1key/KVideo"
|
||||
|
||||
async def generate(self, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, 网络线路, seed, 尾帧=None):
|
||||
api_key = get_api_key_or_raise()
|
||||
base_url = get_base_url_by_route(网络线路)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
voice = "voice" if 生成音频 == "打开" else "novoice"
|
||||
mode_api = _MODE_MAP[模式]
|
||||
if mode_api == "4k":
|
||||
model_name = f"{_MODEL_BASE}-4k-{时长}s"
|
||||
else:
|
||||
model_name = f"{_MODEL_BASE}-{mode_api}-{时长}s-{voice}"
|
||||
|
||||
if not 提示词.strip():
|
||||
raise RuntimeError("提示词不能为空。")
|
||||
|
||||
# ── 构建请求体 ────────────────────────────────────────────────
|
||||
body: dict = {
|
||||
"model": model_name,
|
||||
"prompt": 提示词.strip(),
|
||||
"mode": mode_api,
|
||||
"duration": 时长,
|
||||
"image": _prepare_image_base64(起始帧),
|
||||
}
|
||||
|
||||
if 负向提示词.strip():
|
||||
body["negative_prompt"] = 负向提示词.strip()
|
||||
|
||||
# metadata:尾帧 + 音频
|
||||
metadata: dict = {}
|
||||
if 尾帧 is not None:
|
||||
metadata["image_tail"] = _prepare_image_base64(尾帧)
|
||||
if 生成音频 == "打开":
|
||||
metadata["sound"] = "on"
|
||||
if metadata:
|
||||
body["metadata"] = metadata
|
||||
|
||||
# generate_audio 字段(非 metadata 路径)
|
||||
if 生成音频 == "打开" and not metadata.get("sound"):
|
||||
body["generate_audio"] = True
|
||||
|
||||
# ── 进度条 ────────────────────────────────────────────────────
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
pbar = ProgressBar(100)
|
||||
except Exception:
|
||||
pbar = None
|
||||
|
||||
def _stage(s: str):
|
||||
if s == "submitting":
|
||||
print("[K3 首尾帧] 提交中...")
|
||||
if pbar: pbar.update_absolute(0, 100)
|
||||
elif s.startswith("submitted:"):
|
||||
print(f"[K3 首尾帧] 任务已提交 → {s.split(':', 1)[1]}")
|
||||
if pbar: pbar.update_absolute(5, 100)
|
||||
elif s == "downloading":
|
||||
print("[K3 首尾帧] 下载视频...")
|
||||
if pbar: pbar.update_absolute(99, 100)
|
||||
elif s == "done":
|
||||
print("[K3 首尾帧] 完成")
|
||||
if pbar: pbar.update_absolute(100, 100)
|
||||
|
||||
def _progress(pct: int):
|
||||
if pbar: pbar.update_absolute(5 + int(pct * 0.94), 100)
|
||||
|
||||
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
|
||||
tmp_fd, save_path = tempfile.mkstemp(suffix=".mp4", prefix="k3fl_")
|
||||
|
||||
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
|
||||
# 1. 提交
|
||||
check_interrupt()
|
||||
_stage("submitting")
|
||||
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
||||
resp = await run_with_interrupt(async_request_with_retry(
|
||||
session, "POST", create_url, json=body, headers=headers, prefix="K3 首尾帧提交: "
|
||||
))
|
||||
check_interrupt()
|
||||
text = await resp.text()
|
||||
create_resp = json.loads(text)
|
||||
|
||||
task_id = (
|
||||
create_resp.get("task_id")
|
||||
or create_resp.get("id")
|
||||
or create_resp.get("data", {}).get("task_id")
|
||||
)
|
||||
if not task_id:
|
||||
raise RuntimeError(f"API 未返回任务 ID,响应:{create_resp}")
|
||||
_stage(f"submitted:{task_id}")
|
||||
|
||||
# 2. 轮询
|
||||
status_url = f"{base_url}{_ENDPOINT_STATUS.format(task_id=task_id)}"
|
||||
interval = _POLL_INIT
|
||||
video_url = None
|
||||
|
||||
while True:
|
||||
check_interrupt()
|
||||
async with session.get(status_url, headers=headers) as resp:
|
||||
text = await resp.text()
|
||||
if resp.status != 200:
|
||||
try:
|
||||
err = json.loads(text)
|
||||
msg = err.get("error", {}).get("message") or err.get("message") or text
|
||||
except Exception:
|
||||
msg = text
|
||||
raise RuntimeError(f"状态查询失败 ({resp.status}): {msg}")
|
||||
sr = json.loads(text)
|
||||
|
||||
data = sr.get("data", sr)
|
||||
status = extract_status(sr)
|
||||
|
||||
pct = extract_progress(sr)
|
||||
print(f"[K3 首尾帧] 生成中 {pct}%")
|
||||
_progress(pct)
|
||||
|
||||
if is_success_status(status):
|
||||
video_url = extract_video_url(sr)
|
||||
break
|
||||
if is_failure_status(status, sr):
|
||||
err_msg = extract_error_message(sr)
|
||||
raise RuntimeError(f"K3 首尾帧生成失败:{err_msg}")
|
||||
|
||||
await interruptible_sleep(interval)
|
||||
interval = min(interval * 1.5, _POLL_MAX)
|
||||
|
||||
if not video_url:
|
||||
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
||||
|
||||
# 3. 下载
|
||||
check_interrupt()
|
||||
_stage("downloading")
|
||||
async with session.get(video_url, allow_redirects=True) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"视频下载失败 ({resp.status})")
|
||||
os.close(tmp_fd)
|
||||
with open(save_path, "wb") as f:
|
||||
async for chunk in resp.content.iter_chunked(8192):
|
||||
check_interrupt()
|
||||
f.write(chunk)
|
||||
|
||||
_stage("done")
|
||||
|
||||
if _FOLDER_PATHS_OK:
|
||||
return (InputImpl.VideoFromFile(save_path),)
|
||||
return (save_path,)
|
||||
|
||||
|
||||
# ── 节点注册 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"K3VideoFirstLast": K3VideoFirstLast,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"K3VideoFirstLast": "首尾帧 K3 自研",
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
"""
|
||||
K26 图生视频节点
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..utils.config import get_api_key_or_raise, get_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||
from ..utils.http_error import async_request_with_retry
|
||||
from ..utils.video_task import (
|
||||
check_interrupt,
|
||||
extract_error_message,
|
||||
extract_progress,
|
||||
extract_status,
|
||||
extract_video_url,
|
||||
interruptible_sleep,
|
||||
is_failure_status,
|
||||
is_success_status,
|
||||
run_with_interrupt,
|
||||
)
|
||||
|
||||
try:
|
||||
from comfy_api.latest import InputImpl
|
||||
import folder_paths
|
||||
_FOLDER_PATHS_OK = True
|
||||
except ImportError:
|
||||
_FOLDER_PATHS_OK = False
|
||||
|
||||
# 模型基础名,运行时动态拼接完整名称
|
||||
_MODEL_BASE = "kling-v2-6"
|
||||
|
||||
# API 端点
|
||||
_ENDPOINT_CREATE = "/v1/video/generations"
|
||||
_ENDPOINT_STATUS = "/v1/video/generations/{task_id}"
|
||||
|
||||
_POLL_INIT = 3
|
||||
_POLL_MAX = 15
|
||||
|
||||
|
||||
def _image_to_base64(tensor, scale=1.0) -> str:
|
||||
from PIL import Image
|
||||
pil = tensor_to_pil(tensor)
|
||||
img = pil[0]
|
||||
if scale < 1.0:
|
||||
w, h = img.size
|
||||
new_w = max(1, int(w * scale))
|
||||
new_h = max(1, int(h * scale))
|
||||
img = img.resize((new_w, new_h), Image.LANCZOS)
|
||||
return encode_image_to_base64(img, format="PNG")
|
||||
|
||||
|
||||
class KVideoFirstLast:
|
||||
"""K26 图生视频节点(首尾帧)"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"起始帧": ("IMAGE",),
|
||||
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"模式": (["1080p"],),
|
||||
"时长": ([5, 10],),
|
||||
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
|
||||
"seed": ("INT", {
|
||||
"default": 0, "min": 0, "max": 2147483647,
|
||||
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"尾帧": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO",)
|
||||
RETURN_NAMES = ("视频",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "comfyui_o1key/KVideo"
|
||||
|
||||
async def generate(self, 起始帧, 提示词, 模式, 时长, 生成音频="关闭", 网络线路="全球加速", 尾帧=None, seed=0):
|
||||
api_key = get_api_key_or_raise()
|
||||
base_url = get_base_url_by_route(网络线路)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ── 动态拼接模型名 ────────────────────────────────────────────
|
||||
mode_api = "pro" # 1080p 映射为 pro
|
||||
voice = "voice" if 生成音频 == "打开" else "novoice"
|
||||
model_name = f"{_MODEL_BASE}-{mode_api}-{时长}s-{voice}"
|
||||
|
||||
# ── 构建请求体(超过 10MB 自动缩放图片)────────────────────────
|
||||
MAX_BODY = 10 * 1024 * 1024
|
||||
scale = 1.0
|
||||
|
||||
print(f"[K26 图生视频] 请求体大小限制: 10MB,超出将自动缩放图片")
|
||||
|
||||
while True:
|
||||
body = {
|
||||
"model": model_name,
|
||||
"prompt": 提示词.strip(),
|
||||
"image": _image_to_base64(起始帧, scale),
|
||||
"mode": mode_api,
|
||||
"duration": 时长,
|
||||
}
|
||||
metadata = {}
|
||||
if 尾帧 is not None:
|
||||
metadata["image_tail"] = _image_to_base64(尾帧, scale)
|
||||
if 生成音频 == "打开":
|
||||
metadata["sound"] = "on"
|
||||
if metadata:
|
||||
body["metadata"] = metadata
|
||||
|
||||
body_str = json.dumps(body, ensure_ascii=False)
|
||||
body_size = len(body_str.encode("utf-8"))
|
||||
|
||||
if body_size <= MAX_BODY:
|
||||
print(f"[K26 图生视频] 请求体大小: {body_size / 1024 / 1024:.2f}MB"
|
||||
+ (f"(已缩放至 {scale:.1%})" if scale < 1.0 else ""))
|
||||
break
|
||||
|
||||
# 等比缩放:图片像素面积与 base64 长度近似线性
|
||||
target_ratio = MAX_BODY / body_size
|
||||
scale = scale * math.sqrt(target_ratio) * 0.95 # 5% 安全余量
|
||||
|
||||
if scale < 0.01:
|
||||
raise RuntimeError("图片缩放后仍超过10MB限制,请使用更小的参考图")
|
||||
|
||||
w, h = tensor_to_pil(起始帧)[0].size
|
||||
print(f"[K26 图生视频] 请求体 {body_size / 1024 / 1024:.2f}MB 超限,"
|
||||
f"自动缩放至 {scale:.1%}({int(w * scale)}x{int(h * scale)})")
|
||||
|
||||
# ── 进度条 ────────────────────────────────────────────────────
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
pbar = ProgressBar(100)
|
||||
except Exception:
|
||||
pbar = None
|
||||
|
||||
def _stage(s: str):
|
||||
if s == "submitting":
|
||||
print("[K26 图生视频] 提交中...")
|
||||
if pbar: pbar.update_absolute(0, 100)
|
||||
elif s.startswith("submitted:"):
|
||||
print(f"[K26 图生视频] 任务已提交 → {s.split(':', 1)[1]}")
|
||||
if pbar: pbar.update_absolute(5, 100)
|
||||
elif s == "downloading":
|
||||
print("[K26 图生视频] 下载视频...")
|
||||
if pbar: pbar.update_absolute(99, 100)
|
||||
elif s == "done":
|
||||
print("[K26 图生视频] 完成")
|
||||
if pbar: pbar.update_absolute(100, 100)
|
||||
|
||||
def _progress(pct: int):
|
||||
if pbar: pbar.update_absolute(5 + int(pct * 0.94), 100)
|
||||
|
||||
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
|
||||
tmp_fd, save_path = tempfile.mkstemp(suffix=".mp4", prefix="k26_")
|
||||
|
||||
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
|
||||
# 1. 提交
|
||||
check_interrupt()
|
||||
_stage("submitting")
|
||||
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
||||
resp = await run_with_interrupt(async_request_with_retry(
|
||||
session, "POST", create_url, json=body, headers=headers, prefix="K26 提交: "
|
||||
))
|
||||
check_interrupt()
|
||||
text = await resp.text()
|
||||
create_resp = json.loads(text)
|
||||
|
||||
task_id = (
|
||||
create_resp.get("task_id")
|
||||
or create_resp.get("id")
|
||||
or create_resp.get("data", {}).get("task_id")
|
||||
)
|
||||
if not task_id:
|
||||
raise RuntimeError(f"API 未返回任务 ID,响应:{create_resp}")
|
||||
_stage(f"submitted:{task_id}")
|
||||
|
||||
# 2. 轮询
|
||||
status_url = f"{base_url}{_ENDPOINT_STATUS.format(task_id=task_id)}"
|
||||
interval = _POLL_INIT
|
||||
video_url = None
|
||||
|
||||
while True:
|
||||
check_interrupt()
|
||||
async with session.get(status_url, headers=headers) as resp:
|
||||
text = await resp.text()
|
||||
if resp.status != 200:
|
||||
try:
|
||||
err = json.loads(text)
|
||||
msg = err.get("error", {}).get("message") or err.get("message") or text
|
||||
except Exception:
|
||||
msg = text
|
||||
raise RuntimeError(f"状态查询失败 ({resp.status}): {msg}")
|
||||
sr = json.loads(text)
|
||||
|
||||
data = sr.get("data", sr)
|
||||
status = extract_status(sr)
|
||||
|
||||
pct = extract_progress(sr)
|
||||
print(f"[K26 图生视频] 生成中 {pct}%")
|
||||
_progress(pct)
|
||||
|
||||
if is_success_status(status):
|
||||
# 提取视频 URL
|
||||
video_url = extract_video_url(sr)
|
||||
break
|
||||
if is_failure_status(status, sr):
|
||||
err_msg = extract_error_message(sr)
|
||||
raise RuntimeError(f"K26 生成失败:{err_msg}")
|
||||
|
||||
await interruptible_sleep(interval)
|
||||
interval = min(interval * 1.5, _POLL_MAX)
|
||||
|
||||
if not video_url:
|
||||
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
||||
|
||||
# 3. 下载
|
||||
check_interrupt()
|
||||
_stage("downloading")
|
||||
async with session.get(video_url, allow_redirects=True) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"视频下载失败 ({resp.status})")
|
||||
os.close(tmp_fd)
|
||||
with open(save_path, "wb") as f:
|
||||
async for chunk in resp.content.iter_chunked(8192):
|
||||
check_interrupt()
|
||||
f.write(chunk)
|
||||
|
||||
_stage("done")
|
||||
|
||||
if _FOLDER_PATHS_OK:
|
||||
return (InputImpl.VideoFromFile(save_path),)
|
||||
return (save_path,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"KVideoFirstLast": KVideoFirstLast,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"KVideoFirstLast": "K26 图生视频(首尾帧)",
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
"""
|
||||
K26 图生视频节点
|
||||
支持 720p 和 1080p 模式
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..utils.config import get_api_key_or_raise, get_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||
from ..utils.http_error import async_request_with_retry
|
||||
from ..utils.video_task import (
|
||||
check_interrupt,
|
||||
extract_error_message,
|
||||
extract_progress,
|
||||
extract_status,
|
||||
extract_video_url,
|
||||
interruptible_sleep,
|
||||
is_failure_status,
|
||||
is_success_status,
|
||||
run_with_interrupt,
|
||||
)
|
||||
|
||||
try:
|
||||
from comfy_api.latest import InputImpl
|
||||
import folder_paths
|
||||
_FOLDER_PATHS_OK = True
|
||||
except ImportError:
|
||||
_FOLDER_PATHS_OK = False
|
||||
|
||||
# 模型基础名,运行时动态拼接完整名称
|
||||
_MODEL_BASE = "kling-v2-6"
|
||||
|
||||
# API 端点
|
||||
_ENDPOINT_CREATE = "/v1/video/generations"
|
||||
_ENDPOINT_STATUS = "/v1/video/generations/{task_id}"
|
||||
|
||||
_POLL_INIT = 3
|
||||
_POLL_MAX = 15
|
||||
|
||||
|
||||
def _image_to_base64(tensor, scale=1.0) -> str:
|
||||
from PIL import Image
|
||||
pil = tensor_to_pil(tensor)
|
||||
img = pil[0]
|
||||
if scale < 1.0:
|
||||
w, h = img.size
|
||||
new_w = max(1, int(w * scale))
|
||||
new_h = max(1, int(h * scale))
|
||||
img = img.resize((new_w, new_h), Image.LANCZOS)
|
||||
return encode_image_to_base64(img, format="PNG")
|
||||
|
||||
|
||||
class KVideoImage2Video:
|
||||
"""K26 图生视频节点"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"起始帧": ("IMAGE",),
|
||||
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"模式": (["720p", "1080p"], {"default": "720p"}),
|
||||
"时长": ([5, 10], {"default": 5}),
|
||||
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
|
||||
"seed": ("INT", {
|
||||
"default": 0, "min": 0, "max": 2147483647,
|
||||
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO",)
|
||||
RETURN_NAMES = ("视频",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "comfyui_o1key/KVideo"
|
||||
|
||||
async def generate(self, 起始帧, 提示词, 模式, 时长, 生成音频="关闭", 网络线路="全球加速", seed=0):
|
||||
if 模式 == "720p" and 生成音频 == "打开":
|
||||
raise RuntimeError("K26 仅1080p支持音频,请将模式切换为1080p或关闭生成音频。")
|
||||
|
||||
api_key = get_api_key_or_raise()
|
||||
base_url = get_base_url_by_route(网络线路)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ── 动态拼接模型名 ────────────────────────────────────────────
|
||||
mode_api = "std" if 模式 == "720p" else "pro"
|
||||
voice = "voice" if 生成音频 == "打开" else "novoice"
|
||||
model_name = f"{_MODEL_BASE}-{mode_api}-{时长}s-{voice}"
|
||||
|
||||
# ── 构建请求体(超过 10MB 自动缩放图片)────────────────────────
|
||||
MAX_BODY = 10 * 1024 * 1024
|
||||
scale = 1.0
|
||||
|
||||
print(f"[K26 图生视频] 请求体大小限制: 10MB,超出将自动缩放图片")
|
||||
|
||||
while True:
|
||||
body = {
|
||||
"model": model_name,
|
||||
"prompt": 提示词.strip(),
|
||||
"image": _image_to_base64(起始帧, scale),
|
||||
"mode": mode_api,
|
||||
"duration": 时长,
|
||||
}
|
||||
if 生成音频 == "打开":
|
||||
body["metadata"] = {"sound": "on"}
|
||||
|
||||
body_str = json.dumps(body, ensure_ascii=False)
|
||||
body_size = len(body_str.encode("utf-8"))
|
||||
|
||||
if body_size <= MAX_BODY:
|
||||
print(f"[K26 图生视频] 请求体大小: {body_size / 1024 / 1024:.2f}MB"
|
||||
+ (f"(已缩放至 {scale:.1%})" if scale < 1.0 else ""))
|
||||
break
|
||||
|
||||
# 等比缩放:图片像素面积与 base64 长度近似线性
|
||||
target_ratio = MAX_BODY / body_size
|
||||
scale = scale * math.sqrt(target_ratio) * 0.95 # 5% 安全余量
|
||||
|
||||
if scale < 0.01:
|
||||
raise RuntimeError("图片缩放后仍超过10MB限制,请使用更小的参考图")
|
||||
|
||||
w, h = tensor_to_pil(起始帧)[0].size
|
||||
print(f"[K26 图生视频] 请求体 {body_size / 1024 / 1024:.2f}MB 超限,"
|
||||
f"自动缩放至 {scale:.1%}({int(w * scale)}x{int(h * scale)})")
|
||||
|
||||
# ── 进度条 ────────────────────────────────────────────────────
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
pbar = ProgressBar(100)
|
||||
except Exception:
|
||||
pbar = None
|
||||
|
||||
def _stage(s: str):
|
||||
if s == "submitting":
|
||||
print("[K26 图生视频] 提交中...")
|
||||
if pbar: pbar.update_absolute(0, 100)
|
||||
elif s.startswith("submitted:"):
|
||||
print(f"[K26 图生视频] 任务已提交 → {s.split(':', 1)[1]}")
|
||||
if pbar: pbar.update_absolute(5, 100)
|
||||
elif s == "downloading":
|
||||
print("[K26 图生视频] 下载视频...")
|
||||
if pbar: pbar.update_absolute(99, 100)
|
||||
elif s == "done":
|
||||
print("[K26 图生视频] 完成")
|
||||
if pbar: pbar.update_absolute(100, 100)
|
||||
|
||||
def _progress(pct: int):
|
||||
if pbar: pbar.update_absolute(5 + int(pct * 0.94), 100)
|
||||
|
||||
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
|
||||
tmp_fd, save_path = tempfile.mkstemp(suffix=".mp4", prefix="k26_")
|
||||
|
||||
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
|
||||
# 1. 提交
|
||||
check_interrupt()
|
||||
_stage("submitting")
|
||||
create_url = f"{base_url}{_ENDPOINT_CREATE}"
|
||||
resp = await run_with_interrupt(async_request_with_retry(
|
||||
session, "POST", create_url, json=body, headers=headers, prefix="K26 图生视频提交: "
|
||||
))
|
||||
check_interrupt()
|
||||
sr = await resp.json()
|
||||
|
||||
task_id = sr.get("task_id") or sr.get("id")
|
||||
if not task_id:
|
||||
raise RuntimeError(f"API 未返回 task_id,响应:{sr}")
|
||||
|
||||
_stage(f"submitted:{task_id}")
|
||||
|
||||
# 2. 轮询
|
||||
status_url = f"{base_url}{_ENDPOINT_STATUS.format(task_id=task_id)}"
|
||||
interval = _POLL_INIT
|
||||
video_url = None
|
||||
|
||||
while True:
|
||||
await interruptible_sleep(interval)
|
||||
|
||||
check_interrupt()
|
||||
async with session.get(status_url, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
err_text = await resp.text()
|
||||
raise RuntimeError(f"查询失败 ({resp.status}): {err_text}")
|
||||
sr = await resp.json()
|
||||
|
||||
data = sr.get("data", {}) or {}
|
||||
status = extract_status(sr)
|
||||
|
||||
pct = extract_progress(sr)
|
||||
print(f"[K26 图生视频] 生成中 {pct}%")
|
||||
_progress(pct)
|
||||
|
||||
if is_success_status(status):
|
||||
# 提取视频 URL
|
||||
video_url = extract_video_url(sr)
|
||||
break
|
||||
if is_failure_status(status, sr):
|
||||
err_msg = extract_error_message(sr)
|
||||
raise RuntimeError(f"K26 生成失败:{err_msg}")
|
||||
|
||||
interval = min(interval * 1.5, _POLL_MAX)
|
||||
|
||||
if not video_url:
|
||||
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
|
||||
|
||||
# 3. 下载
|
||||
check_interrupt()
|
||||
_stage("downloading")
|
||||
async with session.get(video_url, allow_redirects=True) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"视频下载失败 ({resp.status})")
|
||||
os.close(tmp_fd)
|
||||
with open(save_path, "wb") as f:
|
||||
async for chunk in resp.content.iter_chunked(8192):
|
||||
check_interrupt()
|
||||
f.write(chunk)
|
||||
|
||||
_stage("done")
|
||||
|
||||
if _FOLDER_PATHS_OK:
|
||||
return (InputImpl.VideoFromFile(save_path),)
|
||||
return (save_path,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"KVideoImage2Video": KVideoImage2Video,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"KVideoImage2Video": "K26 图生视频",
|
||||
}
|
||||
+17
-11
@@ -9,30 +9,36 @@ NanoBananaPro = NanoBanana
|
||||
from .batch_nano_banana import BatchNanoBananaPro
|
||||
from .google_gemini import GoogleGemini
|
||||
from .load_file import LoadFile
|
||||
from .load_images_from_folder import LoadImagesFromFolder
|
||||
from .image_stitch_pro import ImageStitchPro
|
||||
from .remove_metadata import BatchCleanMetadata
|
||||
from .video_preview import VideoPreview
|
||||
from .kling_video import KlingVideo, KlingFirstLastFrame, KlingMotionControlTest, AspectRatioPreset
|
||||
from .veo_video import GoogleVeo
|
||||
from .newapi_veo_video import Google31Video
|
||||
from .minimax_h3_video import MiniMaxH3Video
|
||||
from .flux_edit import FluxImageEdit
|
||||
from .universal_llm import UniversalLLMChat
|
||||
from .batch_images_o1key import BatchImagesO1key
|
||||
from .seedance_video import Seedance, SeedanceMultiModal
|
||||
from .nano_banana_v2 import NanoBananaV2, NanoBananaV2Batch, AsyncImageGenerator, BatchAsyncImageGenerator
|
||||
from .seedance_video import SeedanceMultiModal
|
||||
from .doubao_image import DoubaoImage
|
||||
from .gpt_image import O1keyGPTImage, O1keyGPTImageBatch
|
||||
from .gpt_image import O1keyGPTImage
|
||||
from .gpt_image_batch import O1keyGPTImageBatch
|
||||
from .grok_image import O1keyGrokImage
|
||||
from .grok_video import O1keyGrokVideo
|
||||
from .K_video_firstlast import KVideoFirstLast
|
||||
from .K_video_image2video import KVideoImage2Video
|
||||
from .grok_video import O1keyGrokVideo, O1keyGrokVideoEdit
|
||||
from .K3_video import K3Video
|
||||
from .K3_video_firstlast import K3VideoFirstLast
|
||||
from .K3_motion_control import K3MotionControl, K3MotionVideoCheck
|
||||
from .K3_motion_control import K3MotionControl
|
||||
from .save_image_format import SaveImageFormat
|
||||
from .save_psd import O1keySavePSD
|
||||
from .remove_bg import O1keyRemoveBackground
|
||||
from .color_remove_bg import O1keyColorRemoveBG
|
||||
from .grid_splitter import O1keyGridSplitter
|
||||
from .auto_red_cast import O1keyAutoRedCast
|
||||
from .prompt_multi_function import O1keyPromptMultiFunction
|
||||
from .video_trim import O1keyVideoTrim
|
||||
from .seedance_element import SeedanceElementCreate
|
||||
from .seedance_autopass import SeedanceAutoPass
|
||||
from .seedance_autopass_batch import SeedanceAutoPassBatch
|
||||
from .o1key_image_generator import O1keyImageGenerator, O1keyImageSave
|
||||
from .o1key_video_generator import O1keyVideoGenerator, O1keyVideoResult
|
||||
from .omni_flash_video import O1keyOmniFlashVideo
|
||||
|
||||
__all__ = ['NanoBananaV2', 'NanoBananaV2Batch', 'NanoBanana', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'Google31Video', 'FluxImageEdit', 'UniversalLLMChat', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'O1keyGPTImageBatch', 'O1keyGrokImage', 'O1keyGrokVideo', 'KVideoFirstLast', 'KVideoImage2Video', 'K3Video', 'K3VideoFirstLast', 'K3MotionControl', 'K3MotionVideoCheck', 'AsyncImageGenerator', 'BatchAsyncImageGenerator', 'SaveImageFormat', 'O1keySavePSD', 'O1keyRemoveBackground', 'O1keyColorRemoveBG', 'O1keyGridSplitter']
|
||||
__all__ = ['NanoBanana', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'LoadImagesFromFolder', 'ImageStitchPro', 'BatchCleanMetadata', 'VideoPreview', 'GoogleVeo', 'Google31Video', 'MiniMaxH3Video', 'FluxImageEdit', 'UniversalLLMChat', 'BatchImagesO1key', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'O1keyGPTImageBatch', 'O1keyGrokImage', 'O1keyGrokVideo', 'O1keyGrokVideoEdit', 'K3Video', 'K3MotionControl', 'SaveImageFormat', 'O1keySavePSD', 'O1keyRemoveBackground', 'O1keyGridSplitter', 'O1keyAutoRedCast', 'O1keyPromptMultiFunction', 'O1keyVideoTrim', 'SeedanceElementCreate', 'SeedanceAutoPass', 'SeedanceAutoPassBatch', 'O1keyImageGenerator', 'O1keyImageSave', 'O1keyVideoGenerator', 'O1keyVideoResult', 'O1keyOmniFlashVideo']
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
自动红偏校正。
|
||||
|
||||
纯 torch 实现的确定性白平衡:把图像转到 CIE Lab,自动挑选高亮低饱和区域
|
||||
当作灰卡,测出红轴(a 通道)偏移量后只做减法校正。不调用模型或网络 API,
|
||||
CPU / GPU 都能跑。支持单张(连接图像端口)和批量(填写文件夹路径)两种模式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from ..utils.image_utils import pil_to_tensor
|
||||
|
||||
|
||||
D65_WHITE = (0.95047, 1.0, 1.08883)
|
||||
LAB_EPSILON = 216.0 / 24389.0
|
||||
LAB_KAPPA = 24389.0 / 27.0
|
||||
|
||||
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tiff", ".tif")
|
||||
|
||||
|
||||
def _load_folder_images(folder: str) -> List[Image.Image]:
|
||||
"""从文件夹加载所有图片,返回 PIL Image 列表(RGB)。"""
|
||||
if not os.path.isdir(folder):
|
||||
raise ValueError(f"自动红偏校正:路径不是有效的文件夹:{folder}")
|
||||
|
||||
names = sorted(
|
||||
n for n in os.listdir(folder)
|
||||
if n.lower().endswith(_IMAGE_EXTS) and os.path.isfile(os.path.join(folder, n))
|
||||
)
|
||||
if not names:
|
||||
raise ValueError(f"自动红偏校正:文件夹中没有可读取的图片:{folder}")
|
||||
|
||||
images: List[Image.Image] = []
|
||||
for name in names:
|
||||
path = os.path.join(folder, name)
|
||||
with Image.open(path) as img:
|
||||
images.append(img.convert("RGB"))
|
||||
return images
|
||||
|
||||
|
||||
def _srgb_to_lab(image: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
rgb = image[..., :3].clamp(0.0, 1.0)
|
||||
linear = torch.where(
|
||||
rgb <= 0.04045,
|
||||
rgb / 12.92,
|
||||
((rgb + 0.055) / 1.055).pow(2.4),
|
||||
)
|
||||
|
||||
red, green, blue = linear.unbind(dim=-1)
|
||||
x = (0.4124564 * red + 0.3575761 * green + 0.1804375 * blue) / D65_WHITE[0]
|
||||
y = 0.2126729 * red + 0.7151522 * green + 0.0721750 * blue
|
||||
z = (0.0193339 * red + 0.1191920 * green + 0.9503041 * blue) / D65_WHITE[2]
|
||||
|
||||
def pivot(value: torch.Tensor) -> torch.Tensor:
|
||||
return torch.where(
|
||||
value > LAB_EPSILON,
|
||||
value.clamp_min(0.0).pow(1.0 / 3.0),
|
||||
(LAB_KAPPA * value + 16.0) / 116.0,
|
||||
)
|
||||
|
||||
fx, fy, fz = pivot(x), pivot(y), pivot(z)
|
||||
lightness = 116.0 * fy - 16.0
|
||||
a = 500.0 * (fx - fy)
|
||||
b = 200.0 * (fy - fz)
|
||||
return lightness, a, b
|
||||
|
||||
|
||||
def _lab_to_srgb(lightness: torch.Tensor, a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
|
||||
fy = (lightness + 16.0) / 116.0
|
||||
fx = fy + a / 500.0
|
||||
fz = fy - b / 200.0
|
||||
|
||||
def inverse_pivot(value: torch.Tensor) -> torch.Tensor:
|
||||
cubed = value.pow(3.0)
|
||||
return torch.where(cubed > LAB_EPSILON, cubed, (116.0 * value - 16.0) / LAB_KAPPA)
|
||||
|
||||
x = D65_WHITE[0] * inverse_pivot(fx)
|
||||
y = inverse_pivot(fy)
|
||||
z = D65_WHITE[2] * inverse_pivot(fz)
|
||||
|
||||
red = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
|
||||
green = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z
|
||||
blue = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
|
||||
linear = torch.stack((red, green, blue), dim=-1)
|
||||
|
||||
positive = linear.clamp_min(0.0)
|
||||
srgb = torch.where(
|
||||
linear <= 0.0031308,
|
||||
12.92 * linear,
|
||||
1.055 * positive.pow(1.0 / 2.4) - 0.055,
|
||||
)
|
||||
return srgb.clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _smoothstep(value: torch.Tensor) -> torch.Tensor:
|
||||
value = value.clamp(0.0, 1.0)
|
||||
return value * value * (3.0 - 2.0 * value)
|
||||
|
||||
|
||||
class O1keyAutoRedCast:
|
||||
"""自动检测并移除商品图红偏,零 API 成本。支持单张和批量文件夹两种模式。"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"强度": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 1.5,
|
||||
"step": 0.05,
|
||||
"tooltip": "1.0 为自动测得的完整校正量。",
|
||||
},
|
||||
),
|
||||
"最大校正量": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 8.0,
|
||||
"min": 0.0,
|
||||
"max": 20.0,
|
||||
"step": 0.5,
|
||||
"tooltip": "限制 Lab 红轴最大校正量,防止极端图片过度校色。",
|
||||
},
|
||||
),
|
||||
"高饱和保护": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.1,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.05,
|
||||
"tooltip": "保护橙色、蓝色等高饱和区域;0 为统一白平衡,1 为最大保护。",
|
||||
},
|
||||
),
|
||||
"图片路径": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"tooltip": (
|
||||
"批量模式:填写文件夹路径后将处理其中所有图片,忽略上方图像输入。"
|
||||
"文件夹内图片必须尺寸一致。留空则使用图像输入端口。"
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"图像": (
|
||||
"IMAGE",
|
||||
{
|
||||
"tooltip": "单张模式输入;填写图片路径进入批量模式后可不连接。",
|
||||
},
|
||||
),
|
||||
"灰卡最低亮度": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 58.0,
|
||||
"min": 20.0,
|
||||
"max": 95.0,
|
||||
"step": 1.0,
|
||||
"tooltip": "灰卡候选区域的最低 Lab 亮度。",
|
||||
},
|
||||
),
|
||||
"灰卡最大色度": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 18.0,
|
||||
"min": 3.0,
|
||||
"max": 40.0,
|
||||
"step": 1.0,
|
||||
"tooltip": "灰卡候选区域允许的最大色度。",
|
||||
},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFFFFFFFFFF,
|
||||
"step": 1,
|
||||
"control_after_generate": True,
|
||||
"tooltip": "ComfyUI 原生随机种子;改变 seed 可重新运行节点,校色结果由图像和校色参数决定。",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
|
||||
RETURN_NAMES = ("校正图像", "取样遮罩", "检测报告")
|
||||
FUNCTION = "correct"
|
||||
CATEGORY = "o1key/image"
|
||||
DESCRIPTION = (
|
||||
"零成本自动检测并移除商品图红偏,不调用模型或网络 API。"
|
||||
"填写图片路径可批量处理整个文件夹;留空则处理连接的图像输入。"
|
||||
)
|
||||
|
||||
@torch.inference_mode()
|
||||
def correct(
|
||||
self,
|
||||
图像: Optional[torch.Tensor] = None,
|
||||
强度: float = 1.0,
|
||||
最大校正量: float = 8.0,
|
||||
高饱和保护: float = 0.1,
|
||||
图片路径: str = "",
|
||||
seed: int = 0,
|
||||
灰卡最低亮度: float = 58.0,
|
||||
灰卡最大色度: float = 18.0,
|
||||
):
|
||||
# --- 数据来源:文件夹 or 图像输入 ---
|
||||
if 图片路径 and 图片路径.strip():
|
||||
pil_images = _load_folder_images(图片路径.strip())
|
||||
|
||||
# 校验所有图片尺寸一致(不同尺寸无法合并为批次张量)
|
||||
sizes = {img.size for img in pil_images}
|
||||
if len(sizes) > 1:
|
||||
raise ValueError(
|
||||
f"自动红偏校正:文件夹中的图片尺寸不统一 {sizes},"
|
||||
"请确保所有图片宽高相同,或分批放入不同文件夹。"
|
||||
)
|
||||
|
||||
source = pil_to_tensor(pil_images) # (B, H, W, 3), float32, [0,1]
|
||||
print(f"[o1key 自动红偏校正] 批量模式:加载 {len(pil_images)} 张图片,尺寸 {pil_images[0].size}")
|
||||
else:
|
||||
if 图像 is None:
|
||||
raise ValueError(
|
||||
"自动红偏校正:请连接图像输入,或填写批量图片文件夹路径。"
|
||||
)
|
||||
source = 图像
|
||||
|
||||
source_float = source.float()
|
||||
lightness, a, b = _srgb_to_lab(source_float)
|
||||
chroma = torch.hypot(a, b)
|
||||
corrected_a = a.clone()
|
||||
masks = []
|
||||
report_lines = []
|
||||
|
||||
for index in range(source_float.shape[0]):
|
||||
sample_mask = (lightness[index] >= 灰卡最低亮度) & (chroma[index] <= 灰卡最大色度)
|
||||
minimum_pixels = max(1024, int(sample_mask.numel() * 0.001))
|
||||
|
||||
# 中性像素太少时放宽一档,避免深色背景图直接放弃校正
|
||||
if int(sample_mask.sum().item()) < minimum_pixels:
|
||||
sample_mask = (lightness[index] >= max(40.0, 灰卡最低亮度 - 12.0)) & (
|
||||
chroma[index] <= 灰卡最大色度 + 8.0
|
||||
)
|
||||
|
||||
sample_count = int(sample_mask.sum().item())
|
||||
masks.append(sample_mask.float())
|
||||
if sample_count < minimum_pixels:
|
||||
report_lines.append(f"第 {index + 1} 张:中性取样不足,保持原图")
|
||||
continue
|
||||
|
||||
measured_a = float(a[index][sample_mask].mean().item())
|
||||
correction = max(0.0, min(float(最大校正量), measured_a + 0.2))
|
||||
applied = correction * float(强度)
|
||||
|
||||
saturation = _smoothstep((chroma[index] - 18.0) / 36.0)
|
||||
protection = 1.0 - float(高饱和保护) * saturation
|
||||
corrected_a[index] = a[index] - applied * protection
|
||||
|
||||
sample_ratio = 100.0 * sample_count / sample_mask.numel()
|
||||
if applied > 0.01:
|
||||
report_lines.append(
|
||||
f"第 {index + 1} 张:检测红轴 {measured_a:+.2f},"
|
||||
f"校正 {-applied:.2f},取样 {sample_ratio:.1f}%"
|
||||
)
|
||||
else:
|
||||
report_lines.append(
|
||||
f"第 {index + 1} 张:未检测到红偏,保持原图,取样 {sample_ratio:.1f}%"
|
||||
)
|
||||
|
||||
corrected_rgb = _lab_to_srgb(lightness, corrected_a, b)
|
||||
# 保留原始 alpha 通道(如有)
|
||||
if source_float.shape[-1] > 3:
|
||||
corrected = torch.cat((corrected_rgb, source_float[..., 3:]), dim=-1)
|
||||
else:
|
||||
corrected = corrected_rgb
|
||||
|
||||
report = "\n".join(report_lines)
|
||||
print("[o1key 自动红偏校正] " + " | ".join(report_lines))
|
||||
return (corrected.to(dtype=source.dtype), torch.stack(masks), report)
|
||||
+800
-513
File diff suppressed because it is too large
Load Diff
@@ -1,93 +0,0 @@
|
||||
"""
|
||||
o1key 颜色去背景节点
|
||||
基于颜色距离计算,精确可控,不依赖 AI 模型
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class O1keyColorRemoveBG:
|
||||
"""
|
||||
颜色去背景 - 精确移除纯色背景
|
||||
|
||||
模式说明:
|
||||
- 白色(white): 移除白色背景,适合大多数场景
|
||||
- 白色保护(white-preserve): 移除白底但保护浅色前景物体
|
||||
- 自动检测(corner): 自动采样四角颜色作为背景色
|
||||
- 指定颜色(color): 手动指定要移除的背景颜色
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"模式": (["白色", "白色保护", "自动检测", "指定颜色"], {
|
||||
"default": "白色",
|
||||
}),
|
||||
"容差": ("FLOAT", {
|
||||
"default": 8.0,
|
||||
"min": 0.0,
|
||||
"max": 100.0,
|
||||
"step": 1.0,
|
||||
"tooltip": "颜色距离阈值,越大去除范围越广",
|
||||
}),
|
||||
"羽化": ("FLOAT", {
|
||||
"default": 45.0,
|
||||
"min": 0.0,
|
||||
"max": 200.0,
|
||||
"step": 1.0,
|
||||
"tooltip": "边缘过渡范围,越大边缘越柔和",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"背景色R": ("INT", {"default": 255, "min": 0, "max": 255}),
|
||||
"背景色G": ("INT", {"default": 255, "min": 0, "max": 255}),
|
||||
"背景色B": ("INT", {"default": 255, "min": 0, "max": 255}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("RGBA图像",)
|
||||
FUNCTION = "remove_bg"
|
||||
CATEGORY = "o1key/image"
|
||||
|
||||
_MODE_MAP = {
|
||||
"白色": "white",
|
||||
"白色保护": "white-preserve",
|
||||
"自动检测": "corner",
|
||||
"指定颜色": "color",
|
||||
}
|
||||
|
||||
def remove_bg(self, image, 模式, 容差, 羽化, 背景色R=255, 背景色G=255, 背景色B=255):
|
||||
from ..utils.color_key import remove_background
|
||||
|
||||
mode = self._MODE_MAP.get(模式, "white")
|
||||
bg_color = (背景色R, 背景色G, 背景色B)
|
||||
|
||||
batch_size = image.shape[0]
|
||||
results = []
|
||||
|
||||
for i in range(batch_size):
|
||||
frame = image[i] # [H, W, C]
|
||||
arr = (frame.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
|
||||
|
||||
if arr.shape[2] == 4:
|
||||
pil_img = Image.fromarray(arr, mode="RGBA")
|
||||
else:
|
||||
pil_img = Image.fromarray(arr, mode="RGB")
|
||||
|
||||
result = remove_background(
|
||||
pil_img, mode=mode, bg_color=bg_color,
|
||||
tolerance=容差, feather=羽化,
|
||||
)
|
||||
|
||||
result_arr = np.array(result.convert("RGBA")).astype(np.float32) / 255.0
|
||||
results.append(torch.from_numpy(result_arr))
|
||||
|
||||
output = torch.stack(results, dim=0)
|
||||
print(f"[o1key 颜色去背景] 模式={模式}, 容差={容差}, 羽化={羽化}, "
|
||||
f"处理 {batch_size} 张")
|
||||
return (output,)
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Flux2 图像编辑节点
|
||||
通过 vip.o1key.com 调用 Flux2 + SeedVR2 远程服务进行图像编辑和超分辨率
|
||||
通过 api.o1key.cn 调用 Flux2 + SeedVR2 远程服务进行图像编辑和超分辨率
|
||||
|
||||
功能:
|
||||
- 接收主图和参考图
|
||||
@@ -17,6 +17,7 @@ import torch
|
||||
from PIL import Image
|
||||
|
||||
from ..utils.image_utils import tensor_to_pil, pil_to_tensor
|
||||
from ..utils.config import get_runtime_config_signature
|
||||
from ..clients.flux_edit_client import FluxEditClient
|
||||
|
||||
|
||||
@@ -32,6 +33,7 @@ class FluxImageEdit:
|
||||
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self._client_config_signature = None
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -100,8 +102,10 @@ class FluxImageEdit:
|
||||
|
||||
try:
|
||||
# 初始化客户端
|
||||
if self.client is None:
|
||||
config_signature = get_runtime_config_signature()
|
||||
if self.client is None or config_signature != self._client_config_signature:
|
||||
self.client = FluxEditClient()
|
||||
self._client_config_signature = config_signature
|
||||
|
||||
# Tensor → PIL(取第一张)
|
||||
main_pils = tensor_to_pil(主图)
|
||||
|
||||
@@ -15,6 +15,7 @@ from PIL import Image
|
||||
|
||||
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||
from ..utils.file_types import FileData
|
||||
from ..utils.config import get_runtime_config_signature
|
||||
from ..clients.gemini_flash_client import GeminiFlashClient
|
||||
from ..models_config import get_enabled_flash_models
|
||||
|
||||
@@ -67,6 +68,7 @@ class GoogleGemini:
|
||||
def __init__(self):
|
||||
"""初始化节点"""
|
||||
self.client = None
|
||||
self._client_config_signature = None
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -559,9 +561,11 @@ class GoogleGemini:
|
||||
|
||||
try:
|
||||
# 初始化 API 客户端
|
||||
if self.client is None:
|
||||
config_signature = get_runtime_config_signature()
|
||||
if self.client is None or config_signature != self._client_config_signature:
|
||||
try:
|
||||
self.client = GeminiFlashClient()
|
||||
self._client_config_signature = config_signature
|
||||
except ValueError as e:
|
||||
raise ValueError(f"初始化失败: {str(e)}")
|
||||
|
||||
|
||||
+220
-160
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
o1key GPT Image 节点
|
||||
支持 gpt-image-1 / gpt-image-1.5 模型的文生图、图生图、图像编辑(带蒙版)
|
||||
支持 GPT Image 2 / 2.5 系列的文生图、图生图和带蒙版图像编辑
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -8,10 +8,24 @@ import time
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
from comfy_api.latest import io
|
||||
|
||||
from ..clients.gpt_image_client import GptImageClient
|
||||
from ..clients.gpt_image_client import (
|
||||
GPT_IMAGE_MODEL_OPTIONS,
|
||||
GPT_IMAGE_ROUTE_OPTIONS,
|
||||
GptImageClient,
|
||||
resolve_gpt_image_model,
|
||||
)
|
||||
from ..utils.image_utils import parse_batch_prompts, pil_to_tensor, tensor_to_pil
|
||||
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||
from ..utils.config import get_base_url_by_route
|
||||
from ..utils.o1key_image_catalog import (
|
||||
GPT_IMAGE_BACKGROUND_OPTIONS,
|
||||
GPT_IMAGE_EXACT_SIZE_OPTIONS,
|
||||
GPT_IMAGE_OUTPUT_FORMAT_OPTIONS,
|
||||
GPT_IMAGE_25_QUALITY_OPTIONS,
|
||||
resolve_gpt_image_quality,
|
||||
resolve_gpt_image_size,
|
||||
)
|
||||
from ..utils.file_utils import (
|
||||
ImageInfo,
|
||||
generate_timestamp_filename,
|
||||
@@ -68,31 +82,20 @@ def _make_node_progress_callback(progress_bar, task_index: int, total_tasks: int
|
||||
|
||||
|
||||
def _resolve_async_size(value: str) -> str:
|
||||
value = (value or "").strip()
|
||||
if not value or value == "智能" or value.lower() == "auto":
|
||||
return "auto"
|
||||
|
||||
first_part = value.split("(")[0].strip()
|
||||
normalized_size = first_part.lower().replace("*", "x").replace("×", "x")
|
||||
size_parts = [part.strip() for part in normalized_size.split("x")]
|
||||
if len(size_parts) == 2 and all(part.isdigit() for part in size_parts):
|
||||
return f"{int(size_parts[0])}x{int(size_parts[1])}"
|
||||
|
||||
allowed = {"auto", "1024x1024", "1K", "2K", "4K"}
|
||||
if first_part in allowed:
|
||||
return first_part
|
||||
|
||||
if "4K" in value:
|
||||
return "4K"
|
||||
if "2K" in value:
|
||||
return "2K"
|
||||
if "1K" in value:
|
||||
return "1K"
|
||||
|
||||
return "auto"
|
||||
return resolve_gpt_image_size(value)
|
||||
|
||||
|
||||
class O1keyGPTImage:
|
||||
MAX_GPT_IMAGE_REFERENCES = 9
|
||||
def _collect_autogrow_inputs(value) -> list:
|
||||
"""收集已连接的 Autogrow 输入,并兼容单个旧值。"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
return [item for item in value.values() if item is not None]
|
||||
return [value]
|
||||
|
||||
|
||||
class O1keyGPTImage(io.ComfyNode):
|
||||
"""
|
||||
o1key GPT Image 节点
|
||||
|
||||
@@ -104,7 +107,8 @@ class O1keyGPTImage:
|
||||
|
||||
参数:
|
||||
- prompt : 文本提示词(多行;用 --- 独占一行分隔批量提示词)
|
||||
- 模型 : 模型选择
|
||||
- 模型 : GPT Image 主模型
|
||||
- 模型线路 : 畅速、直连或专线
|
||||
- 分辨率 : 图像尺寸(auto 让 API 自动决定)
|
||||
- 生图数量 : 每条提示词生成数量 1-8
|
||||
- 质量 : 生成质量
|
||||
@@ -114,110 +118,144 @@ class O1keyGPTImage:
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# 创建9个独立的参考图输入
|
||||
optional_inputs = {}
|
||||
for i in range(1, 10):
|
||||
optional_inputs[f"参考图{i}"] = ("IMAGE", {
|
||||
"tooltip": f"Optional reference image {i} for image editing.",
|
||||
})
|
||||
def define_schema(cls):
|
||||
reference_images = io.Autogrow.Input(
|
||||
"参考图组",
|
||||
template=io.Autogrow.TemplateNames(
|
||||
input=io.Image.Input("参考图"),
|
||||
names=[f"参考图{i}" for i in range(1, MAX_GPT_IMAGE_REFERENCES + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip=f"连接后自动增加输入端口,合计最多 {MAX_GPT_IMAGE_REFERENCES} 张参考图。",
|
||||
)
|
||||
return io.Schema(
|
||||
node_id="O1keyGPTImage",
|
||||
display_name="gpt image",
|
||||
category="o1key/image",
|
||||
inputs=[
|
||||
io.String.Input(
|
||||
"prompt",
|
||||
default="",
|
||||
multiline=True,
|
||||
tooltip="Text prompt for GPT Image. Use --- on its own line to separate batch prompts.",
|
||||
),
|
||||
io.Combo.Input(
|
||||
"模型",
|
||||
options=GPT_IMAGE_MODEL_OPTIONS,
|
||||
default="gpt-image-2.5-sunburst",
|
||||
),
|
||||
io.Combo.Input(
|
||||
"模型线路",
|
||||
options=GPT_IMAGE_ROUTE_OPTIONS,
|
||||
default="畅速",
|
||||
),
|
||||
io.Combo.Input(
|
||||
"分辨率",
|
||||
options=GPT_IMAGE_EXACT_SIZE_OPTIONS,
|
||||
default="智能",
|
||||
tooltip="Image size (智能 = API decides)",
|
||||
),
|
||||
io.Int.Input(
|
||||
"生图数量",
|
||||
default=1,
|
||||
min=1,
|
||||
max=8,
|
||||
step=1,
|
||||
display_mode=io.NumberDisplay.number,
|
||||
tooltip="How many images to generate per prompt",
|
||||
),
|
||||
io.Combo.Input(
|
||||
"质量",
|
||||
options=GPT_IMAGE_25_QUALITY_OPTIONS,
|
||||
default="自动",
|
||||
tooltip="GPT Image 2 支持高/中/低/自动;GPT Image 2.5 另支持超高=xhigh、最高=max。",
|
||||
),
|
||||
io.Combo.Input(
|
||||
"输出格式",
|
||||
options=["png", "jpeg", "webp"],
|
||||
default="png",
|
||||
tooltip="Generated image output format",
|
||||
),
|
||||
io.Combo.Input(
|
||||
"背景",
|
||||
options=list(GPT_IMAGE_BACKGROUND_OPTIONS),
|
||||
default="auto",
|
||||
tooltip="透明背景仅支持 PNG 或 WebP 输出格式。",
|
||||
),
|
||||
io.Mask.Input(
|
||||
"遮罩",
|
||||
optional=True,
|
||||
tooltip="Optional mask for inpainting (white areas will be replaced)",
|
||||
),
|
||||
reference_images,
|
||||
io.Combo.Input(
|
||||
"缩放图片",
|
||||
options=["不缩放", "智能缩放"],
|
||||
default="智能缩放",
|
||||
tooltip="请求体超过 18 MiB 时,智能缩放会等比缩小占用最大的参考图。",
|
||||
),
|
||||
io.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2**31 - 1,
|
||||
step=1,
|
||||
display_mode=io.NumberDisplay.number,
|
||||
control_after_generate=io.ControlAfterGenerate.randomize,
|
||||
tooltip="Random seed (0 = not specified)",
|
||||
),
|
||||
],
|
||||
outputs=[io.Image.Output(display_name="IMAGE")],
|
||||
# 兼容 Autogrow 改造前保存的参考图1~参考图9端口。
|
||||
accept_all_inputs=True,
|
||||
)
|
||||
|
||||
optional_inputs["模型"] = ([
|
||||
"gpt-image-2-按量",
|
||||
"gpt-image-2-次卡",
|
||||
], {
|
||||
"default": "gpt-image-2-次卡",
|
||||
})
|
||||
optional_inputs["网络"] = (NETWORK_ROUTE_OPTIONS, {
|
||||
"default": "全球加速",
|
||||
})
|
||||
optional_inputs["分辨率"] = ([
|
||||
"智能",
|
||||
# ── 1K ──
|
||||
"1024x1024(1K 正方形 1:1)",
|
||||
"1536x1024(1K 横版 3:2)",
|
||||
"1024x1536(1K 竖版 2:3)",
|
||||
"1360x1024(1K 横版 4:3)",
|
||||
"1024x1360(1K 竖版 3:4)",
|
||||
"1824x1024(1K 横版 16:9)",
|
||||
"1024x1824(1K 竖版 9:16)",
|
||||
# ── 2K ──
|
||||
"2048x2048(2K 正方形 1:1)",
|
||||
"3072x2048(2K 横版 3:2)",
|
||||
"2048x3072(2K 竖版 2:3)",
|
||||
"2736x2048(2K 横版 4:3)",
|
||||
"2048x2736(2K 竖版 3:4)",
|
||||
"3648x2048(2K 横版 16:9)",
|
||||
"2048x3648(2K 竖版 9:16)",
|
||||
# ── 4K ──
|
||||
"2880x2880(4K 正方形 1:1)",
|
||||
"3504x2336(4K 横版 3:2)",
|
||||
"2336x3504(4K 竖版 2:3)",
|
||||
"3264x2448(4K 横版 4:3)",
|
||||
"2448x3264(4K 竖版 3:4)",
|
||||
"3840x2160(4K 横版 16:9)",
|
||||
"2160x3840(4K 竖版 9:16)",
|
||||
], {
|
||||
"default": "智能",
|
||||
"tooltip": "Image size (智能 = API decides)",
|
||||
})
|
||||
optional_inputs["生图数量"] = ("INT", {
|
||||
"default": 1,
|
||||
"min": 1,
|
||||
"max": 8,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
"tooltip": "How many images to generate per prompt",
|
||||
})
|
||||
optional_inputs["质量"] = (["高", "中", "低", "自动"], {
|
||||
"default": "自动",
|
||||
"tooltip": "Image quality: 高=high, 中=medium, 低=low, 自动=auto",
|
||||
})
|
||||
optional_inputs["输出格式"] = (["png", "jpeg", "webp"], {
|
||||
"default": "jpeg",
|
||||
"tooltip": "Generated image output format",
|
||||
})
|
||||
optional_inputs["seed"] = ("INT", {
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 2**31 - 1,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
"control_after_generate": True,
|
||||
"tooltip": "Random seed (0 = not specified)",
|
||||
})
|
||||
optional_inputs["遮罩"] = ("MASK", {
|
||||
"tooltip": "Optional mask for inpainting (white areas will be replaced)",
|
||||
})
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": "Text prompt for GPT Image. Use --- on its own line to separate batch prompts.",
|
||||
}),
|
||||
},
|
||||
"optional": optional_inputs,
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("IMAGE",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "o1key/image"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def generate(
|
||||
self,
|
||||
@classmethod
|
||||
def execute(
|
||||
cls,
|
||||
prompt: str,
|
||||
模型: str = "gpt-image-2-次卡",
|
||||
网络: str = "全球加速",
|
||||
分辨率: str = "auto",
|
||||
模型: str = "gpt-image-2.5-sunburst",
|
||||
模型线路: str = "畅速",
|
||||
分辨率: str = "智能",
|
||||
质量: str = "自动",
|
||||
输出格式: str = "jpeg",
|
||||
输出格式: str = "png",
|
||||
生图数量: int = 1,
|
||||
seed: int = 0,
|
||||
遮罩=None,
|
||||
缩放图片: str = "智能缩放",
|
||||
背景: str = "auto",
|
||||
**kwargs,
|
||||
) -> io.NodeOutput:
|
||||
result = cls.generate(
|
||||
prompt=prompt,
|
||||
模型=模型,
|
||||
模型线路=模型线路,
|
||||
分辨率=分辨率,
|
||||
质量=质量,
|
||||
输出格式=输出格式,
|
||||
生图数量=生图数量,
|
||||
seed=seed,
|
||||
遮罩=遮罩,
|
||||
缩放图片=缩放图片,
|
||||
背景=背景,
|
||||
**kwargs,
|
||||
)
|
||||
return io.NodeOutput(*result)
|
||||
|
||||
@classmethod
|
||||
def generate(
|
||||
cls,
|
||||
prompt: str,
|
||||
模型: str = "gpt-image-2.5-sunburst",
|
||||
模型线路: str = "畅速",
|
||||
分辨率: str = "智能",
|
||||
质量: str = "自动",
|
||||
输出格式: str = "png",
|
||||
生图数量: int = 1,
|
||||
seed: int = 0,
|
||||
遮罩=None,
|
||||
缩放图片: str = "智能缩放",
|
||||
背景: str = "auto",
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -230,35 +268,45 @@ class O1keyGPTImage:
|
||||
- prompt 含 --- → 批量模式,逐条调用上述接口
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# ── 0. 收集多参考图输入 ────────────────────────────────────────────────
|
||||
reference_tensors = []
|
||||
for i in range(1, 10):
|
||||
key = f"参考图{i}"
|
||||
if key in kwargs and kwargs[key] is not None:
|
||||
reference_tensors.append(kwargs[key])
|
||||
reference_tensors = _collect_autogrow_inputs(kwargs.get("参考图组"))
|
||||
if not reference_tensors:
|
||||
# 兼容 Autogrow 改造前保存的固定参考图端口。
|
||||
reference_tensors = [
|
||||
kwargs[f"参考图{i}"]
|
||||
for i in range(1, MAX_GPT_IMAGE_REFERENCES + 1)
|
||||
if kwargs.get(f"参考图{i}") is not None
|
||||
]
|
||||
|
||||
图片 = reference_tensors if reference_tensors else None
|
||||
|
||||
# ── 1. 参数校验 ───────────────────────────────────────────────────────
|
||||
if 遮罩 is not None and 图片 is None:
|
||||
raise ValueError("提供了遮罩但未提供图片,请同时提供图片和遮罩")
|
||||
if 缩放图片 not in {"不缩放", "智能缩放"}:
|
||||
raise ValueError("缩放图片参数无效")
|
||||
if 输出格式 not in GPT_IMAGE_OUTPUT_FORMAT_OPTIONS:
|
||||
raise ValueError("GPT Image 输出格式无效")
|
||||
if 背景 not in GPT_IMAGE_BACKGROUND_OPTIONS:
|
||||
raise ValueError("GPT Image 背景参数无效")
|
||||
if 背景 == "transparent" and 输出格式 == "jpeg":
|
||||
raise ValueError("GPT Image 透明背景仅支持 PNG 或 WebP 输出格式")
|
||||
|
||||
# ── 2. 解析分辨率显示值 → API 参数值 ──────────────────────────────────
|
||||
size = _resolve_async_size(分辨率)
|
||||
|
||||
# ── 2b. 解析模型显示值 → API 参数值 ───────────────────────────────────
|
||||
_model_map = {"gpt-image-2-次卡": "gpt-image-2-c", "gpt-image-2-按量": "gpt-image-2"}
|
||||
model = _model_map.get(模型, 模型)
|
||||
# ── 2b. 主模型与线路共同解析为 API 模型名 ───────────────────────────
|
||||
model = resolve_gpt_image_model(模型, 模型线路)
|
||||
|
||||
# ── 2c. 解析质量显示值 → API 参数值 ───────────────────────────────────
|
||||
_quality_map = {"高": "high", "中": "medium", "低": "low", "自动": "auto"}
|
||||
quality = _quality_map.get(质量, "auto")
|
||||
quality = resolve_gpt_image_quality(模型, 质量)
|
||||
|
||||
# ── 3. 创建客户端 ─────────────────────────────────────────────────────
|
||||
try:
|
||||
client = GptImageClient()
|
||||
client.base_url = get_base_url_by_route(网络)
|
||||
client.base_url = get_base_url_by_route()
|
||||
client.response_log_enabled = False
|
||||
client.poll_log_enabled = False
|
||||
except ValueError as e:
|
||||
if str(e) == "未授权!":
|
||||
print("[o1key GPT Image] 请联系作者授权后方可使用!")
|
||||
@@ -271,6 +319,12 @@ class O1keyGPTImage:
|
||||
|
||||
# ── 5. 调用 API ───────────────────────────────────────────────────
|
||||
all_pil_images = []
|
||||
def _submitted(task_id, status, elapsed):
|
||||
print(f"[o1key GPT Image] 已提交 | task_id={task_id} | 状态={status} | 耗时={elapsed:.1f}s")
|
||||
|
||||
def _completed(task_id, image_count, elapsed, urls):
|
||||
del urls
|
||||
print(f"[o1key GPT Image] 完成 ✓ | task_id={task_id} | 生成={image_count} 张 | 耗时={elapsed:.1f}s")
|
||||
progress_total = len(batch_prompts) if batch_prompts else 1
|
||||
progress_bar = ProgressBar(progress_total * 100) if _PROGRESS_BAR_AVAILABLE else None
|
||||
|
||||
@@ -293,17 +347,22 @@ class O1keyGPTImage:
|
||||
image_tensor=图片,
|
||||
mask_tensor=遮罩,
|
||||
output_format=输出格式,
|
||||
background=背景,
|
||||
progress_callback=_make_node_progress_callback(progress_bar, idx, total),
|
||||
special_price_parallel=True,
|
||||
task_submitted_callback=_submitted,
|
||||
task_completed_callback=_completed,
|
||||
log_request_start=False,
|
||||
log_downloads=True,
|
||||
log_prefix=f"[o1key GPT Image] [{idx}/{total}]",
|
||||
resize_mode=缩放图片,
|
||||
)
|
||||
all_pil_images.extend(pil_images)
|
||||
snippet = p[:30] + ("..." if len(p) >= 30 else "")
|
||||
print(f"[o1key GPT Image] [{idx}/{total}] ✓ {snippet}")
|
||||
except InterruptProcessingException:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = str(e).split('\n')[0]
|
||||
snippet = p[:30] + ("..." if len(p) >= 30 else "")
|
||||
print(f"[o1key GPT Image] [{idx}/{total}] ❌ {snippet} → {error_msg}")
|
||||
print(f"[o1key GPT Image] [{idx}/{total}] ❌ {error_msg}")
|
||||
if progress_bar is not None:
|
||||
progress_bar.update_absolute(idx * 100, total * 100)
|
||||
else:
|
||||
@@ -321,7 +380,14 @@ class O1keyGPTImage:
|
||||
image_tensor=图片,
|
||||
mask_tensor=遮罩,
|
||||
output_format=输出格式,
|
||||
background=背景,
|
||||
progress_callback=_make_node_progress_callback(progress_bar, 1, 1),
|
||||
special_price_parallel=True,
|
||||
task_submitted_callback=_submitted,
|
||||
task_completed_callback=_completed,
|
||||
log_request_start=False,
|
||||
log_downloads=True,
|
||||
resize_mode=缩放图片,
|
||||
)
|
||||
all_pil_images.extend(pil_images)
|
||||
except InterruptProcessingException:
|
||||
@@ -349,9 +415,10 @@ class O1keyGPTImage:
|
||||
return (output_tensor,)
|
||||
|
||||
finally:
|
||||
self._print_balance(client)
|
||||
cls._print_balance(client)
|
||||
|
||||
def _print_balance(self, client):
|
||||
@staticmethod
|
||||
def _print_balance(client):
|
||||
try:
|
||||
balance_data = client.query_balance_sync()
|
||||
balance_info = client.format_balance_info(balance_data)
|
||||
@@ -360,7 +427,7 @@ class O1keyGPTImage:
|
||||
pass
|
||||
|
||||
|
||||
class O1keyGPTImageBatch:
|
||||
class _LegacyO1keyGPTImageBatch:
|
||||
"""
|
||||
o1key GPT Image 批量节点
|
||||
|
||||
@@ -374,7 +441,7 @@ class O1keyGPTImageBatch:
|
||||
|
||||
PAIRING_MODES = ["按相同图片命名", "1*N", "不配对"]
|
||||
IMAGE_FORMATS = ["原始", "JPEG", "PNG", "WebP"]
|
||||
MODEL_OPTIONS = ["gpt-image-2-按量", "gpt-image-2-次卡"]
|
||||
MODEL_OPTIONS = GPT_IMAGE_ROUTE_OPTIONS
|
||||
QUALITY_OPTIONS = ["高", "中", "低", "自动"]
|
||||
RESOLUTION_OPTIONS = [
|
||||
"智能",
|
||||
@@ -424,11 +491,8 @@ class O1keyGPTImageBatch:
|
||||
"multiline": True,
|
||||
"tooltip": "提示词;可用独占一行的 --- 分隔多条批量提示词。",
|
||||
}),
|
||||
"模型": (cls.MODEL_OPTIONS, {
|
||||
"default": "gpt-image-2-次卡",
|
||||
}),
|
||||
"网络": (NETWORK_ROUTE_OPTIONS, {
|
||||
"default": "全球加速",
|
||||
"模型线路": (cls.MODEL_OPTIONS, {
|
||||
"default": "畅速",
|
||||
}),
|
||||
"分辨率": (cls.RESOLUTION_OPTIONS, {
|
||||
"default": "智能",
|
||||
@@ -561,12 +625,9 @@ class O1keyGPTImageBatch:
|
||||
return _resolve_async_size(分辨率)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_model(模型: str) -> str:
|
||||
model_map = {
|
||||
"gpt-image-2-次卡": "gpt-image-2-c",
|
||||
"gpt-image-2-按量": "gpt-image-2",
|
||||
}
|
||||
return model_map.get(模型, 模型)
|
||||
def _resolve_model(模型线路: str) -> str:
|
||||
# 直接返回,客户端会映射到 API 值
|
||||
return 模型线路
|
||||
|
||||
@staticmethod
|
||||
def _resolve_quality(质量: str) -> str:
|
||||
@@ -642,8 +703,7 @@ class O1keyGPTImageBatch:
|
||||
def process_batch(
|
||||
self,
|
||||
prompt: str,
|
||||
模型: str,
|
||||
网络: str,
|
||||
模型线路: str,
|
||||
分辨率: str,
|
||||
生图数量: int,
|
||||
质量: str,
|
||||
@@ -707,12 +767,12 @@ class O1keyGPTImageBatch:
|
||||
|
||||
output_folder = self._ensure_output_folder(保存路径)
|
||||
size = self._resolve_size(分辨率)
|
||||
model = self._resolve_model(模型)
|
||||
model = self._resolve_model(模型线路)
|
||||
quality = self._resolve_quality(质量)
|
||||
output_format = self._resolve_output_format(图片格式)
|
||||
|
||||
client = GptImageClient()
|
||||
client.base_url = get_base_url_by_route(网络)
|
||||
client.base_url = get_base_url_by_route()
|
||||
|
||||
progress_bar = ProgressBar(total_tasks * 100) if _PROGRESS_BAR_AVAILABLE else None
|
||||
results = []
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"""GPT Image V3 批量跑图节点。"""
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
from comfy_api.latest import io
|
||||
|
||||
from ..clients.gpt_image_client import (
|
||||
GPT_IMAGE_MODEL_OPTIONS,
|
||||
GPT_IMAGE_ROUTE_OPTIONS,
|
||||
GptImageClient,
|
||||
resolve_gpt_image_model,
|
||||
)
|
||||
from .gpt_image import GPT_IMAGE_25_QUALITY_OPTIONS, resolve_gpt_image_quality
|
||||
from ..utils.config import get_base_url_by_route
|
||||
from ..utils.file_utils import (
|
||||
ImageInfo,
|
||||
load_images_from_folder,
|
||||
pair_images_by_name,
|
||||
pair_images_cartesian,
|
||||
pair_images_indexed,
|
||||
save_image,
|
||||
)
|
||||
from ..utils.image_utils import parse_batch_prompts, pil_to_tensor, tensor_to_pil
|
||||
from ..utils.o1key_image_catalog import (
|
||||
GPT_IMAGE_BACKGROUND_OPTIONS,
|
||||
GPT_IMAGE_OUTPUT_FORMAT_OPTIONS,
|
||||
)
|
||||
|
||||
try:
|
||||
from comfy.model_management import InterruptProcessingException
|
||||
except ImportError:
|
||||
InterruptProcessingException = RuntimeError
|
||||
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
_PROGRESS_AVAILABLE = True
|
||||
except ImportError:
|
||||
_PROGRESS_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
_FOLDER_PATHS_AVAILABLE = True
|
||||
except ImportError:
|
||||
_FOLDER_PATHS_AVAILABLE = False
|
||||
|
||||
|
||||
_MAX_PATHS = 5
|
||||
_MAX_REFERENCES = 9
|
||||
_PAIRING_MODES = ["不配对", "相同文件名", "同序号", "全匹配"]
|
||||
_RESOLUTION_OPTIONS = [
|
||||
"智能",
|
||||
"1024x1024(1K 正方形 1:1)", "1536x1024(1K 横版 3:2)",
|
||||
"1024x1536(1K 竖版 2:3)", "1360x1024(1K 横版 4:3)",
|
||||
"1024x1360(1K 竖版 3:4)", "1824x1024(1K 横版 16:9)",
|
||||
"1024x1824(1K 竖版 9:16)", "2048x2048(2K 正方形 1:1)",
|
||||
"3072x2048(2K 横版 3:2)", "2048x3072(2K 竖版 2:3)",
|
||||
"2736x2048(2K 横版 4:3)", "2048x2736(2K 竖版 3:4)",
|
||||
"3648x2048(2K 横版 16:9)", "2048x3648(2K 竖版 9:16)",
|
||||
"2880x2880(4K 正方形 1:1)", "3504x2336(4K 横版 3:2)",
|
||||
"2336x3504(4K 竖版 2:3)", "3264x2448(4K 横版 4:3)",
|
||||
"2448x3264(4K 竖版 3:4)", "3840x2160(4K 横版 16:9)",
|
||||
"2160x3840(4K 竖版 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="按需显示 1~5 个图片文件夹路径。",
|
||||
),
|
||||
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)
|
||||
+31
-1
@@ -8,6 +8,7 @@ separator bands near the expected grid lines, then crops each cell.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Sequence, Tuple
|
||||
|
||||
@@ -327,6 +328,30 @@ def _split_one(
|
||||
return crops, info
|
||||
|
||||
|
||||
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tiff", ".tif", ".gif")
|
||||
|
||||
|
||||
def _load_folder_images(folder: str) -> List[Image.Image]:
|
||||
if not os.path.isdir(folder):
|
||||
raise ValueError(f"合并图切割:图片路径不是有效的文件夹:{folder}")
|
||||
|
||||
names = sorted(
|
||||
name for name in os.listdir(folder)
|
||||
if name.lower().endswith(_IMAGE_EXTS)
|
||||
)
|
||||
images: List[Image.Image] = []
|
||||
for name in names:
|
||||
path = os.path.join(folder, name)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
with Image.open(path) as opened:
|
||||
images.append(opened.convert("RGB"))
|
||||
|
||||
if not images:
|
||||
raise ValueError(f"合并图切割:文件夹中没有可读取的图片:{folder}")
|
||||
return images
|
||||
|
||||
|
||||
class O1keyGridSplitter:
|
||||
"""Split AI-generated grid/contact-sheet images into individual cells."""
|
||||
|
||||
@@ -343,6 +368,7 @@ class O1keyGridSplitter:
|
||||
"裁掉外边距": ("BOOLEAN", {"default": True}),
|
||||
"最小分隔线px": ("INT", {"default": 2, "min": 0, "max": 64, "step": 1}),
|
||||
"最大输出张数": ("INT", {"default": 16, "min": 1, "max": 144, "step": 1}),
|
||||
"图片路径": ("STRING", {"default": "", "multiline": False}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,8 +392,12 @@ class O1keyGridSplitter:
|
||||
裁掉外边距: bool = True,
|
||||
最小分隔线px: int = 2,
|
||||
最大输出张数: int = 16,
|
||||
图片路径: str = "",
|
||||
):
|
||||
source_images = tensor_to_pil(图像)
|
||||
if 图片路径 and 图片路径.strip():
|
||||
source_images = _load_folder_images(图片路径.strip())
|
||||
else:
|
||||
source_images = tensor_to_pil(图像)
|
||||
all_crops: List[Image.Image] = []
|
||||
info_lines: List[str] = []
|
||||
|
||||
|
||||
+1
-6
@@ -6,7 +6,6 @@ o1key Grok Image 节点
|
||||
import time
|
||||
from ..clients.grok_image_client import GrokImageClient
|
||||
from ..utils.image_utils import parse_batch_prompts
|
||||
from ..utils.config import NETWORK_ROUTE_OPTIONS
|
||||
|
||||
try:
|
||||
from comfy.model_management import processing_interrupted, InterruptProcessingException
|
||||
@@ -57,9 +56,6 @@ class O1keyGrokImage:
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
}),
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {
|
||||
"default": NETWORK_ROUTE_OPTIONS[0],
|
||||
}),
|
||||
"seed": ("INT", {
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
@@ -85,7 +81,6 @@ class O1keyGrokImage:
|
||||
宽高比: str = "auto",
|
||||
分辨率: str = "1k",
|
||||
生图数量: int = 1,
|
||||
网络线路: str = "全球加速",
|
||||
seed: int = 0,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -99,7 +94,7 @@ class O1keyGrokImage:
|
||||
image_list = reference_tensors if reference_tensors else None
|
||||
|
||||
try:
|
||||
client = GrokImageClient(route=网络线路)
|
||||
client = GrokImageClient()
|
||||
except ValueError as e:
|
||||
if str(e) == "未授权!":
|
||||
print("[o1key Grok Image] 请联系作者授权后方可使用!")
|
||||
|
||||
+227
-199
@@ -1,30 +1,25 @@
|
||||
"""
|
||||
Grok Video node.
|
||||
"""Lean ComfyUI nodes for O1Key Grok Imagine Video."""
|
||||
|
||||
Submits a /v1/videos task, polls until completion, downloads the mp4,
|
||||
and returns ComfyUI's native VIDEO object.
|
||||
"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
from typing import List, Optional
|
||||
import re
|
||||
from typing import Dict
|
||||
|
||||
from ..clients.grok_video_client import GrokVideoClient
|
||||
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||
from ..utils.image_utils import encode_images_for_request_body_limit, tensor_to_pil
|
||||
from ..utils.config import get_base_url_by_route
|
||||
from ..utils.image_utils import tensor_to_pil
|
||||
from ..utils.r2_uploader import upload_audio, upload_image, upload_video
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
FOLDER_PATHS_AVAILABLE = True
|
||||
except ImportError:
|
||||
FOLDER_PATHS_AVAILABLE = False
|
||||
folder_paths = None
|
||||
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
PROGRESS_BAR_AVAILABLE = True
|
||||
except ImportError:
|
||||
ProgressBar = None
|
||||
PROGRESS_BAR_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from comfy_api.input_impl import VideoFromFile
|
||||
@@ -36,135 +31,94 @@ except Exception:
|
||||
VideoFromFile = None
|
||||
|
||||
|
||||
MODEL_OPTIONS = ["grok-imagine-video-1.5-preview", "grok-imagine-1.0-video"]
|
||||
ASPECT_RATIO_OPTIONS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]
|
||||
QUALITY_OPTIONS = ["720p"]
|
||||
QUALITY_VALUE_MAP = {
|
||||
"720p": "high",
|
||||
}
|
||||
MODEL_SECONDS_OPTIONS = {
|
||||
"grok-imagine-1.0-video": [6, 10, 12, 16, 20],
|
||||
}
|
||||
|
||||
MAX_REFERENCE_IMAGES = 3
|
||||
MAX_REQUEST_BODY_BYTES = 20 * 1024 * 1024
|
||||
MODEL_OPTIONS = list(GrokVideoClient.MODEL_OPTIONS)
|
||||
ASPECT_RATIO_OPTIONS = list(GrokVideoClient.ASPECT_RATIO_OPTIONS)
|
||||
RESOLUTION_OPTIONS = list(GrokVideoClient.RESOLUTION_OPTIONS)
|
||||
GENERATION_MODE_OPTIONS = ["文生视频", "图生视频", "参考生视频"]
|
||||
EDIT_MODE_OPTIONS = ["编辑视频", "续写视频"]
|
||||
IMAGE_INPUT_NAMES = [f"图片{i}" for i in range(1, 8)]
|
||||
AUDIO_INPUT_NAMES = ["音频素材", "音频素材2", "音频素材3"]
|
||||
|
||||
|
||||
def _get_output_dir() -> str:
|
||||
if FOLDER_PATHS_AVAILABLE:
|
||||
base = folder_paths.get_output_directory()
|
||||
if folder_paths is not None:
|
||||
base_dir = folder_paths.get_temp_directory()
|
||||
else:
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
comfy_root = os.path.dirname(os.path.dirname(plugin_dir))
|
||||
base = os.path.join(comfy_root, "output")
|
||||
|
||||
output_dir = os.path.join(base, "grok_video")
|
||||
base_dir = os.path.join(os.path.dirname(os.path.dirname(plugin_dir)), "temp")
|
||||
output_dir = os.path.join(base_dir, "grok_video")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
return output_dir
|
||||
|
||||
|
||||
def _format_mb(size_bytes: int) -> str:
|
||||
return f"{size_bytes / 1024 / 1024:.2f}MB"
|
||||
|
||||
|
||||
def _image_tensor_to_first_pil(image_tensor):
|
||||
def _single_pil_image(image_tensor, input_name: str):
|
||||
if image_tensor is None:
|
||||
return None
|
||||
|
||||
pil_images = tensor_to_pil(image_tensor)
|
||||
if not pil_images:
|
||||
return None
|
||||
|
||||
image = pil_images[0]
|
||||
if image.mode not in ("RGB", "L"):
|
||||
image = image.convert("RGB")
|
||||
return image
|
||||
images = tensor_to_pil(image_tensor)
|
||||
if len(images) != 1:
|
||||
raise ValueError(f"{input_name} 只能连接 1 张图片,请拆分批次后再连接。")
|
||||
return images[0].convert("RGB")
|
||||
|
||||
|
||||
def _collect_reference_images(**kwargs) -> List[object]:
|
||||
images = []
|
||||
for i in range(1, MAX_REFERENCE_IMAGES + 1):
|
||||
image = _image_tensor_to_first_pil(kwargs.get(f"参考图{i}"))
|
||||
if image is not None:
|
||||
images.append(image)
|
||||
return images
|
||||
def _parse_voice_ids(value: object) -> list[str]:
|
||||
voice_ids = [item.strip() for item in re.split(r"[,,\n]", str(value or "")) if item.strip()]
|
||||
if len(voice_ids) > 3:
|
||||
raise ValueError("参考音色 ID 最多填写 3 个。")
|
||||
return voice_ids
|
||||
|
||||
|
||||
def _to_data_urls(encoded_images) -> List[str]:
|
||||
return [f"data:{mime};base64,{b64}" for mime, b64 in encoded_images]
|
||||
def _video_duration_seconds(video) -> float:
|
||||
getter = getattr(video, "get_duration", None)
|
||||
if not callable(getter):
|
||||
raise ValueError("无法读取输入视频时长;请连接 ComfyUI 原生 VIDEO 输出。")
|
||||
try:
|
||||
duration = float(getter())
|
||||
except Exception as exc:
|
||||
raise ValueError("无法读取输入视频时长;请确认视频文件可以正常解码。") from exc
|
||||
if not math.isfinite(duration) or duration <= 0:
|
||||
raise ValueError("输入视频时长无效;请确认视频文件可以正常解码。")
|
||||
return duration
|
||||
|
||||
|
||||
def _encode_image_data_urls(
|
||||
images: List[object],
|
||||
prompt: str,
|
||||
model: str,
|
||||
aspect_ratio: str,
|
||||
seconds: int,
|
||||
quality: str,
|
||||
) -> Optional[List[str]]:
|
||||
if not images:
|
||||
return None
|
||||
def _progress_callback():
|
||||
progress_bar = ProgressBar(100) if ProgressBar is not None else None
|
||||
progress_value = [0]
|
||||
|
||||
def build_body(encoded_images):
|
||||
return GrokVideoClient.build_video_body(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
aspect_ratio=aspect_ratio,
|
||||
seconds=seconds,
|
||||
quality=quality,
|
||||
images=_to_data_urls(encoded_images),
|
||||
)
|
||||
def callback(progress: int, _status: str, _elapsed: float) -> None:
|
||||
current = max(0, min(100, int(progress or 0)))
|
||||
if progress_bar is not None and current > progress_value[0]:
|
||||
progress_bar.update(current - progress_value[0])
|
||||
progress_value[0] = current
|
||||
|
||||
encoded = encode_images_for_request_body_limit(
|
||||
images,
|
||||
build_body=build_body,
|
||||
max_body_bytes=MAX_REQUEST_BODY_BYTES,
|
||||
)
|
||||
data_urls = _to_data_urls(encoded)
|
||||
|
||||
return data_urls
|
||||
return progress_bar, progress_value, callback
|
||||
|
||||
|
||||
def _validate_request_body_size(body: dict) -> None:
|
||||
body_size = len(json.dumps(body, ensure_ascii=False).encode("utf-8"))
|
||||
if body_size > MAX_REQUEST_BODY_BYTES:
|
||||
raise ValueError(
|
||||
f"Grok Video 请求体大小 {_format_mb(body_size)} 超过 "
|
||||
f"{_format_mb(MAX_REQUEST_BODY_BYTES)} 限制,请减少参考图片或降低图片尺寸。"
|
||||
)
|
||||
def _finish_video(result: Dict[str, object], progress_bar, progress_value):
|
||||
if progress_bar is not None and progress_value[0] < 100:
|
||||
progress_bar.update(100 - progress_value[0])
|
||||
video_path = result["video_path"]
|
||||
print(f"Grok Video:下载完成:{video_path}")
|
||||
return (VideoFromFile(video_path),)
|
||||
|
||||
|
||||
class O1keyGrokVideo:
|
||||
"""文生、图生或参考素材生 Grok 视频。素材会自动上传为 URL。"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"提示词": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
},
|
||||
),
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": NETWORK_ROUTE_OPTIONS[0]}),
|
||||
"模型": (MODEL_OPTIONS, {"default": MODEL_OPTIONS[0]}),
|
||||
"生成模式": (GENERATION_MODE_OPTIONS, {"default": "文生视频"}),
|
||||
"提示词": ("STRING", {"default": "", "multiline": True}),
|
||||
"模型": (MODEL_OPTIONS, {"default": GrokVideoClient.DEFAULT_MODEL}),
|
||||
"时长(秒)": ("INT", {"default": 8, "min": 1, "max": 15, "step": 1}),
|
||||
"宽高比": (ASPECT_RATIO_OPTIONS, {"default": "16:9"}),
|
||||
"秒数(按模型限制)": (
|
||||
"INT",
|
||||
{
|
||||
"default": 5,
|
||||
"min": 5,
|
||||
"max": 20,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
"画质": (QUALITY_OPTIONS, {"default": "720p"}),
|
||||
"分辨率": (RESOLUTION_OPTIONS, {"default": "480p"}),
|
||||
"参考音色ID(逗号分隔)": ("STRING", {"default": ""}),
|
||||
},
|
||||
"optional": {
|
||||
"参考图1": ("IMAGE",),
|
||||
"参考图2": ("IMAGE",),
|
||||
"参考图3": ("IMAGE",),
|
||||
**{input_name: ("IMAGE",) for input_name in IMAGE_INPUT_NAMES},
|
||||
**{input_name: ("AUDIO",) for input_name in AUDIO_INPUT_NAMES},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -172,112 +126,186 @@ class O1keyGrokVideo:
|
||||
RETURN_NAMES = ("视频",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "comfyui_o1key/Video"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Grok Video /v1/videos task node. Supports prompt plus up to "
|
||||
"three image references, multiple aspect ratios, model-specific seconds, 720p output."
|
||||
"支持文生、图生和多参考素材生成。图生视频只连接图片 1;参考生视频最多使用 7 张图和 "
|
||||
"3 个参考音频(AUDIO 或 voice_id 合计)。Grok 1.5 的文生/图生可选 1080p,多参考最高 720p。"
|
||||
)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
**kwargs,
|
||||
):
|
||||
def generate(self, **kwargs):
|
||||
if VideoFromFile is None:
|
||||
raise RuntimeError("当前 ComfyUI 版本不支持原生 VIDEO 输入实现 VideoFromFile。")
|
||||
raise RuntimeError("当前 ComfyUI 版本不支持 VideoFromFile,无法输出 VIDEO。")
|
||||
|
||||
提示词 = kwargs.get("提示词", "")
|
||||
网络线路 = kwargs.get("网络线路", NETWORK_ROUTE_OPTIONS[0])
|
||||
模型 = kwargs.get("模型", MODEL_OPTIONS[0])
|
||||
宽高比 = kwargs.get("宽高比", "16:9")
|
||||
秒数 = kwargs.get("秒数(按模型限制)", kwargs.get("秒数(≤15s)", kwargs.get("秒数", 5)))
|
||||
画质 = kwargs.get("画质", "720p")
|
||||
mode = kwargs.get("生成模式", "文生视频")
|
||||
model = kwargs.get("模型", GrokVideoClient.DEFAULT_MODEL)
|
||||
prompt = (kwargs.get("提示词") or "").strip()
|
||||
connected_images = [
|
||||
(input_name, image)
|
||||
for input_name in IMAGE_INPUT_NAMES
|
||||
if (image := _single_pil_image(kwargs.get(input_name), input_name)) is not None
|
||||
]
|
||||
images = [image for _, image in connected_images]
|
||||
audios = [kwargs.get(input_name) for input_name in AUDIO_INPUT_NAMES if kwargs.get(input_name) is not None]
|
||||
voice_ids = _parse_voice_ids(kwargs.get("参考音色ID(逗号分隔)"))
|
||||
duration = kwargs.get("时长(秒)", 8)
|
||||
aspect_ratio = kwargs.get("宽高比", "16:9")
|
||||
resolution = kwargs.get("分辨率", "480p")
|
||||
|
||||
prompt = (提示词 or "").strip()
|
||||
if not prompt:
|
||||
raise ValueError("提示词不能为空。")
|
||||
if 模型 not in MODEL_OPTIONS:
|
||||
raise ValueError(f"模型仅支持: {', '.join(MODEL_OPTIONS)}")
|
||||
if 宽高比 not in ASPECT_RATIO_OPTIONS:
|
||||
raise ValueError(f"宽高比仅支持: {', '.join(ASPECT_RATIO_OPTIONS)}。")
|
||||
seconds = int(秒数)
|
||||
allowed_seconds = MODEL_SECONDS_OPTIONS.get(模型)
|
||||
if allowed_seconds is not None:
|
||||
if seconds not in allowed_seconds:
|
||||
raise ValueError(
|
||||
f"模型 {模型} 仅支持秒数: "
|
||||
f"{', '.join(str(s) for s in allowed_seconds)}。"
|
||||
"请修改为正确的秒数后再发起请求。"
|
||||
)
|
||||
elif seconds < 5 or seconds > 15:
|
||||
raise ValueError("秒数仅支持 5 到 15。")
|
||||
if 画质 not in QUALITY_OPTIONS:
|
||||
raise ValueError("画质仅支持 720p。")
|
||||
if mode not in GENERATION_MODE_OPTIONS:
|
||||
raise ValueError(f"不支持的生成模式:{mode}。")
|
||||
if len(audios) + len(voice_ids) > 3:
|
||||
raise ValueError("参考音频与参考音色 ID 合计最多 3 个。")
|
||||
|
||||
quality = QUALITY_VALUE_MAP[画质]
|
||||
reference_images = _collect_reference_images(**kwargs)
|
||||
image_data_urls = _encode_image_data_urls(
|
||||
reference_images,
|
||||
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=模型,
|
||||
aspect_ratio=宽高比,
|
||||
seconds=seconds,
|
||||
quality=quality,
|
||||
model=model,
|
||||
duration=duration,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
image=placeholder_image,
|
||||
reference_images=placeholder_references,
|
||||
reference_audios=placeholder_audios,
|
||||
)
|
||||
|
||||
request_body = GrokVideoClient.build_video_body(
|
||||
prompt=prompt,
|
||||
model=模型,
|
||||
aspect_ratio=宽高比,
|
||||
seconds=seconds,
|
||||
quality=quality,
|
||||
images=image_data_urls,
|
||||
)
|
||||
_validate_request_body_size(request_body)
|
||||
base_url = get_base_url_by_route()
|
||||
client = GrokVideoClient(base_url=base_url)
|
||||
|
||||
pbar = ProgressBar(100) if PROGRESS_BAR_AVAILABLE else None
|
||||
last_progress = [0]
|
||||
|
||||
def progress_callback(progress: int, status: str, elapsed: float):
|
||||
progress_value = max(0, min(100, int(progress or 0)))
|
||||
if pbar is not None and progress_value > last_progress[0]:
|
||||
pbar.update(progress_value - last_progress[0])
|
||||
last_progress[0] = progress_value
|
||||
|
||||
client = GrokVideoClient(base_url=get_base_url_by_route(网络线路))
|
||||
|
||||
try:
|
||||
result = client.generate_video_sync(
|
||||
prompt=prompt,
|
||||
model=模型,
|
||||
aspect_ratio=宽高比,
|
||||
seconds=seconds,
|
||||
quality=quality,
|
||||
output_dir=_get_output_dir(),
|
||||
images=image_data_urls,
|
||||
poll_interval=5,
|
||||
timeout=1200,
|
||||
progress_callback=progress_callback,
|
||||
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)
|
||||
|
||||
if pbar is not None and last_progress[0] < 100:
|
||||
pbar.update(100 - last_progress[0])
|
||||
image_urls, audio_urls = client.run_async_in_thread(upload_materials())
|
||||
if mode == "文生视频":
|
||||
image = None
|
||||
reference_images = []
|
||||
reference_audios = []
|
||||
elif mode == "图生视频":
|
||||
image = {"url": image_urls[0]}
|
||||
reference_images = []
|
||||
reference_audios = []
|
||||
else:
|
||||
image = None
|
||||
reference_images = [{"url": url} for url in image_urls]
|
||||
reference_audios = [
|
||||
*({"url": url} for url in audio_urls),
|
||||
*({"voice_id": voice_id} for voice_id in voice_ids),
|
||||
]
|
||||
|
||||
video_path = result["video_path"]
|
||||
print(f"Grok Video:下载完成:{video_path}")
|
||||
return (VideoFromFile(video_path),)
|
||||
finally:
|
||||
try:
|
||||
balance_data = client.query_balance_sync()
|
||||
balance_info = client.format_balance_info(balance_data)
|
||||
print(f"Grok Video:{balance_info}")
|
||||
except Exception:
|
||||
pass
|
||||
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",
|
||||
}
|
||||
|
||||
@@ -1,738 +0,0 @@
|
||||
"""
|
||||
Kling 3.0 Video Nodes
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from ..clients.kling_client import KlingClient
|
||||
from ..clients.gemini_client import GeminiAPIClient
|
||||
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||
|
||||
from comfy_api.latest import InputImpl
|
||||
|
||||
|
||||
def _tensor_to_base64(tensor) -> str:
|
||||
"""ComfyUI IMAGE tensor → base64 PNG 字符串"""
|
||||
pil_images = tensor_to_pil(tensor)
|
||||
return encode_image_to_base64(pil_images[0], format="PNG")
|
||||
|
||||
|
||||
def _validate_prompt(prompt: str, *, required: bool = True) -> None:
|
||||
"""校验单条提示词。
|
||||
|
||||
Args:
|
||||
prompt: 提示词字符串。
|
||||
required: 为 True 时不允许为空(多镜头关闭或 shot_type 为 intelligence 时适用)。
|
||||
"""
|
||||
if required and not prompt.strip():
|
||||
raise ValueError("提示词不能为空(非多镜头模式下必填)。")
|
||||
if len(prompt) > 2500:
|
||||
raise ValueError(
|
||||
f"提示词长度 ({len(prompt)}) 超过上限 2500 个字符,请缩短后重试。"
|
||||
)
|
||||
|
||||
|
||||
def _validate_multi_prompt(multi_prompt_list: list, total_duration: int) -> None:
|
||||
"""校验多镜头分镜列表。
|
||||
|
||||
规则:
|
||||
- 分镜数量:1 ~ 6;
|
||||
- 每个分镜提示词不超过 512 个字符;
|
||||
- 每个分镜时长 ≥ 1 且 ≤ total_duration;
|
||||
- 所有分镜时长之和必须等于 total_duration。
|
||||
"""
|
||||
count = len(multi_prompt_list)
|
||||
if count < 1 or count > 6:
|
||||
raise ValueError(
|
||||
f"多镜头分镜数量须在 1~6 之间,当前为 {count}。"
|
||||
)
|
||||
|
||||
duration_sum = 0
|
||||
for entry in multi_prompt_list:
|
||||
idx = entry["index"]
|
||||
p = entry.get("prompt", "")
|
||||
dur = entry.get("duration", 0)
|
||||
|
||||
if len(p) > 512:
|
||||
raise ValueError(
|
||||
f"镜头 {idx} 提示词长度 ({len(p)}) 超过上限 512 个字符。"
|
||||
)
|
||||
if dur < 1:
|
||||
raise ValueError(
|
||||
f"镜头 {idx} 时长 ({dur}s) 不能小于 1 秒。"
|
||||
)
|
||||
if dur > total_duration:
|
||||
raise ValueError(
|
||||
f"镜头 {idx} 时长 ({dur}s) 超过任务总时长 ({total_duration}s)。"
|
||||
)
|
||||
duration_sum += dur
|
||||
|
||||
if duration_sum != total_duration:
|
||||
raise ValueError(
|
||||
f"所有分镜时长之和 ({duration_sum}s) 必须等于任务总时长 ({total_duration}s)。"
|
||||
)
|
||||
|
||||
|
||||
def _validate_image(tensor, label: str = "图片") -> None:
|
||||
"""校验图片张量。
|
||||
|
||||
规则:
|
||||
- 文件大小(PNG)不超过 10MB;
|
||||
- 宽、高均不小于 300px;
|
||||
- 宽高比介于 1:2.5 ~ 2.5:1 之间(即 ratio ∈ [0.4, 2.5])。
|
||||
"""
|
||||
import io
|
||||
|
||||
pil_images = tensor_to_pil(tensor)
|
||||
img = pil_images[0]
|
||||
w, h = img.size
|
||||
|
||||
# ── 最小尺寸 ──────────────────────────────────────────────────────
|
||||
if w < 300 or h < 300:
|
||||
raise ValueError(
|
||||
f"{label} 宽高不得小于 300px,当前为 {w}×{h}px。"
|
||||
)
|
||||
|
||||
# ── 宽高比 ────────────────────────────────────────────────────────
|
||||
ratio = w / h
|
||||
if ratio < 1 / 2.5 or ratio > 2.5:
|
||||
raise ValueError(
|
||||
f"{label} 宽高比须在 1:2.5 ~ 2.5:1 之间,"
|
||||
f"当前为 {w}:{h}(比值 {ratio:.2f})。"
|
||||
)
|
||||
|
||||
# ── 文件大小 ──────────────────────────────────────────────────────
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
size_mb = buf.tell() / (1024 * 1024)
|
||||
if size_mb > 10:
|
||||
raise ValueError(
|
||||
f"{label} PNG 大小 ({size_mb:.1f}MB) 超过上限 10MB。"
|
||||
)
|
||||
|
||||
|
||||
class KlingVideo:
|
||||
"""Kling 视频生成节点(支持多镜头)"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"反向提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
|
||||
"模型版本": (["v3", "v2-6"], {"default": "v3"}),
|
||||
"时长": ([5, 10, 15],),
|
||||
"分辨率": (["1080p", "720p"],),
|
||||
"宽高比": (["智能", "16:9", "9:16", "1:1"], {"default": "智能"}),
|
||||
"生成音频": (["打开", "关闭"], {"default": "打开"}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
|
||||
},
|
||||
"optional": {
|
||||
"起始帧": ("IMAGE",),
|
||||
"镜头1_提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"镜头1_时长": ("STRING", {"default": "5"}),
|
||||
"镜头2_提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"镜头2_时长": ("STRING", {"default": "5"}),
|
||||
"镜头3_提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"镜头3_时长": ("STRING", {"default": "5"}),
|
||||
"镜头4_提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"镜头4_时长": ("STRING", {"default": "5"}),
|
||||
"镜头5_提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"镜头5_时长": ("STRING", {"default": "5"}),
|
||||
"镜头6_提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"镜头6_时长": ("STRING", {"default": "5"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO",)
|
||||
RETURN_NAMES = ("视频",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "comfyui_o1key/Kling"
|
||||
|
||||
async def generate(self, **kwargs):
|
||||
"""生成视频(支持多镜头)"""
|
||||
prompt = kwargs["提示词"]
|
||||
negative_prompt = kwargs["反向提示词"]
|
||||
model_ver = kwargs.get("模型版本", "v3")
|
||||
duration = kwargs["时长"]
|
||||
resolution = kwargs["分辨率"]
|
||||
aspect_ratio = kwargs["宽高比"]
|
||||
generate_audio = kwargs["生成音频"]
|
||||
start_frame = kwargs.get("起始帧", None)
|
||||
seed = kwargs.get("seed", 0) # noqa: F841 — 触发 ComfyUI 缓存刷新
|
||||
|
||||
mode = "pro" if resolution == "1080p" else "std"
|
||||
voice = "voice" if generate_audio == "打开" else "novoice"
|
||||
|
||||
# ── v2-6 模型约束校验 ──────────────────────────────────────────
|
||||
if model_ver == "v2-6":
|
||||
if duration == 15:
|
||||
raise ValueError(
|
||||
"v2-6 模型不支持 15s 时长,请选择 5s 或 10s。"
|
||||
)
|
||||
if mode == "std" and voice == "voice":
|
||||
raise ValueError(
|
||||
"v2-6 模型的标准画质(720p)不支持生成音频,请关闭生成音频或切换至 1080p。"
|
||||
)
|
||||
|
||||
# ── 多镜头检测 ────────────────────────────────────────────────
|
||||
multi_prompt_list = []
|
||||
for i in range(1, 7):
|
||||
sb_prompt = kwargs.get(f"镜头{i}_提示词", "").strip()
|
||||
if sb_prompt:
|
||||
raw_dur = kwargs.get(f"镜头{i}_时长", "5")
|
||||
try:
|
||||
sb_duration = int(str(raw_dur).strip()) if str(raw_dur).strip() else 5
|
||||
except ValueError:
|
||||
sb_duration = 5
|
||||
multi_prompt_list.append({
|
||||
"index": i,
|
||||
"prompt": sb_prompt,
|
||||
"duration": sb_duration,
|
||||
})
|
||||
|
||||
multi_shot_enabled = len(multi_prompt_list) > 0
|
||||
|
||||
if multi_shot_enabled:
|
||||
total_duration = sum(e["duration"] for e in multi_prompt_list)
|
||||
if total_duration < 3 or total_duration > 15:
|
||||
raise ValueError(
|
||||
f"多镜头总时长 ({total_duration}s) 必须在 3~15 秒之间。"
|
||||
)
|
||||
_validate_multi_prompt(multi_prompt_list, total_duration)
|
||||
duration = total_duration
|
||||
else:
|
||||
_validate_prompt(prompt, required=True)
|
||||
|
||||
# ── 构建模型名 & 请求体 ───────────────────────────────────────
|
||||
import json, base64, copy
|
||||
model_name = f"kling-{model_ver}-{mode}-{duration}s-{voice}"
|
||||
|
||||
body = {
|
||||
"model": model_name,
|
||||
"mode": mode,
|
||||
"duration": duration,
|
||||
}
|
||||
|
||||
sound = "on" if generate_audio == "打开" else "off"
|
||||
|
||||
if multi_shot_enabled or sound == "on":
|
||||
ms_payload = {}
|
||||
ms_payload["prompt"] = prompt
|
||||
|
||||
if sound == "on":
|
||||
ms_payload["sound"] = "on"
|
||||
|
||||
if multi_shot_enabled:
|
||||
ms_payload["multi_shot"] = True
|
||||
ms_payload["shot_type"] = "customize"
|
||||
ms_payload["multi_prompt"] = multi_prompt_list
|
||||
|
||||
encoded = base64.b64encode(
|
||||
json.dumps(ms_payload, ensure_ascii=False).encode("utf-8")
|
||||
).decode("utf-8")
|
||||
body["prompt"] = f"__MS__:{encoded}"
|
||||
else:
|
||||
body["prompt"] = prompt
|
||||
|
||||
if negative_prompt.strip():
|
||||
body["negative_prompt"] = negative_prompt
|
||||
|
||||
if start_frame is not None:
|
||||
_validate_image(start_frame, "起始帧")
|
||||
body["image"] = _tensor_to_base64(start_frame)
|
||||
endpoint_type = "image2video"
|
||||
else:
|
||||
if aspect_ratio and aspect_ratio != "智能":
|
||||
body["metadata"] = {"aspect_ratio": aspect_ratio}
|
||||
endpoint_type = "text2video"
|
||||
|
||||
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
|
||||
_, save_path = tempfile.mkstemp(suffix=".mp4", prefix="kling_")
|
||||
|
||||
client = KlingClient()
|
||||
client.base_url = get_base_url_by_route(kwargs.get("网络线路", "全球加速"))
|
||||
|
||||
# ── 进度条 ────────────────────────────────────────────────────
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
pbar = ProgressBar(100)
|
||||
except Exception:
|
||||
pbar = None
|
||||
|
||||
def on_stage(stage: str):
|
||||
if stage == "submitting":
|
||||
print("[视频生成] 提交中...")
|
||||
if pbar: pbar.update_absolute(0, 100)
|
||||
elif stage.startswith("submitted:"):
|
||||
print(f"[视频生成] 任务已提交 → {stage.split(':',1)[1]}")
|
||||
if pbar: pbar.update_absolute(5, 100)
|
||||
elif stage == "downloading":
|
||||
print("[视频生成] 下载视频...")
|
||||
if pbar: pbar.update_absolute(99, 100)
|
||||
elif stage == "done":
|
||||
print("[视频生成] 完成")
|
||||
if pbar: pbar.update_absolute(100, 100)
|
||||
|
||||
def on_progress(pct: int):
|
||||
mapped = 5 + int(pct * 0.94)
|
||||
if pbar: pbar.update_absolute(mapped, 100)
|
||||
|
||||
try:
|
||||
result_path = await client.generate_async(
|
||||
endpoint_type=endpoint_type,
|
||||
body=body,
|
||||
save_path=save_path,
|
||||
on_stage=on_stage,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
return (InputImpl.VideoFromFile(result_path),)
|
||||
finally:
|
||||
# 查询余额
|
||||
try:
|
||||
_balance_client = GeminiAPIClient()
|
||||
balance_data = _balance_client.query_balance_sync()
|
||||
balance_info = _balance_client.format_balance_info(balance_data)
|
||||
print(f"自研视频模型: {balance_info}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class KlingFirstLastFrame:
|
||||
"""Kling 首尾帧到视频节点"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"首帧": ("IMAGE",),
|
||||
"尾帧": ("IMAGE",),
|
||||
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
|
||||
"模型": (["v3", "v2-6"], {"default": "v3"}),
|
||||
"分辨率": (["1080p", "720p"],),
|
||||
"时长": ([5, 10, 15],),
|
||||
"生成音频": (["打开", "关闭"], {"default": "打开"}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO",)
|
||||
RETURN_NAMES = ("视频",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "comfyui_o1key/Kling"
|
||||
|
||||
async def generate(self, **kwargs):
|
||||
first_frame = kwargs["首帧"]
|
||||
end_frame = kwargs["尾帧"]
|
||||
prompt = kwargs["提示词"]
|
||||
duration = kwargs["时长"]
|
||||
generate_audio = kwargs["生成音频"]
|
||||
model_base = kwargs["模型"]
|
||||
model_base = "kling-" + model_base # v3/v2-6 → kling-v3/kling-v2-6(后端值还原)
|
||||
resolution = kwargs["分辨率"]
|
||||
seed = kwargs.get("seed", 0) # noqa: F841 — 触发 ComfyUI 缓存刷新
|
||||
|
||||
_validate_prompt(prompt, required=True)
|
||||
|
||||
# 时长校验
|
||||
if duration not in (5, 10, 15):
|
||||
raise ValueError(f"时长仅支持 5、10、15 秒,当前值为 {duration},请重新选择。")
|
||||
|
||||
# 拼接模型名:kling-{ver}-{mode}-{dur}s-{voice}
|
||||
mode = "pro" if resolution == "1080p" else "std"
|
||||
voice = "voice" if generate_audio == "打开" else "novoice"
|
||||
|
||||
# ── v2-6 模型约束校验 ──────────────────────────────────────────
|
||||
model_ver = kwargs["模型"] # "v3" or "v2-6"
|
||||
if model_ver == "v2-6":
|
||||
if duration == 15:
|
||||
raise ValueError(
|
||||
"v2-6 模型不支持 15s 时长,请选择 5s 或 10s。"
|
||||
)
|
||||
if mode == "std" and voice == "voice":
|
||||
raise ValueError(
|
||||
"v2-6 模型的标准画质(720p)不支持生成音频,请关闭生成音频或切换至 1080p。"
|
||||
)
|
||||
|
||||
model_name = f"{model_base}-{mode}-{duration}s-{voice}"
|
||||
|
||||
# 图片校验 & 转 base64
|
||||
_validate_image(first_frame, "首帧")
|
||||
_validate_image(end_frame, "尾帧")
|
||||
image_b64 = _tensor_to_base64(first_frame)
|
||||
image_tail_b64 = _tensor_to_base64(end_frame)
|
||||
|
||||
# ── 按规范编码 prompt 和 sound ──────────────────────────
|
||||
import json, base64
|
||||
sound = "on" if generate_audio == "打开" else "off"
|
||||
|
||||
body = {
|
||||
"model": model_name,
|
||||
"image": image_b64,
|
||||
"mode": mode,
|
||||
"duration": duration,
|
||||
"metadata": {
|
||||
"image_tail": image_tail_b64,
|
||||
},
|
||||
}
|
||||
|
||||
if sound == "on":
|
||||
ms_payload = {
|
||||
"prompt": prompt,
|
||||
"sound": "on",
|
||||
}
|
||||
encoded = base64.b64encode(
|
||||
json.dumps(ms_payload, ensure_ascii=False).encode("utf-8")
|
||||
).decode("utf-8")
|
||||
body["prompt"] = f"__MS__:{encoded}"
|
||||
else:
|
||||
body["prompt"] = prompt
|
||||
|
||||
# 保存路径(临时文件,避免与下游保存节点重复落盘)
|
||||
_, save_path = tempfile.mkstemp(suffix=".mp4", prefix="kling_")
|
||||
|
||||
client = KlingClient()
|
||||
client.base_url = get_base_url_by_route(kwargs.get("网络线路", "全球加速"))
|
||||
|
||||
# 进度条:0~100 步
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
pbar = ProgressBar(100)
|
||||
except Exception:
|
||||
pbar = None
|
||||
|
||||
def on_stage(stage: str):
|
||||
if stage == "submitting":
|
||||
print("[视频生成] 提交中...")
|
||||
if pbar:
|
||||
pbar.update_absolute(0, 100)
|
||||
elif stage.startswith("submitted:"):
|
||||
print(f"[视频生成] 任务已提交 → {stage.split(':',1)[1]}")
|
||||
if pbar:
|
||||
pbar.update_absolute(5, 100)
|
||||
elif stage == "downloading":
|
||||
print("[视频生成] 下载视频...")
|
||||
if pbar:
|
||||
pbar.update_absolute(99, 100)
|
||||
elif stage == "done":
|
||||
print("[视频生成] 完成")
|
||||
if pbar:
|
||||
pbar.update_absolute(100, 100)
|
||||
|
||||
def on_progress(pct: int):
|
||||
# pct 来自 API progress 字段,如 50 表示 50%
|
||||
# 生成阶段占 5~99 区间
|
||||
mapped = 5 + int(pct * 0.94)
|
||||
if pbar:
|
||||
pbar.update_absolute(mapped, 100)
|
||||
|
||||
try:
|
||||
result_path = await client.generate_async(
|
||||
endpoint_type="image2video",
|
||||
body=body,
|
||||
save_path=save_path,
|
||||
on_stage=on_stage,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
return (InputImpl.VideoFromFile(result_path),)
|
||||
finally:
|
||||
# 查询余额
|
||||
try:
|
||||
_balance_client = GeminiAPIClient()
|
||||
balance_data = _balance_client.query_balance_sync()
|
||||
balance_info = _balance_client.format_balance_info(balance_data)
|
||||
print(f"自研视频模型: {balance_info}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class KlingMotionControlTest:
|
||||
"""Kling 动作控制(测试)节点 —— reference_video 接受 VIDEO 类型输入"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"参考图片": ("IMAGE",),
|
||||
"参考视频": ("VIDEO",),
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
|
||||
},
|
||||
"optional": {
|
||||
"模型": (["v3", "v2-6"], {"default": "v3"}),
|
||||
"分辨率": (["1080p", "720p"],),
|
||||
"时长": ([5, 10, 15], {"default": 5}),
|
||||
"人物朝向": (["video", "image"],),
|
||||
"保留原声": (["打开", "关闭"], {"default": "打开"}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO",)
|
||||
RETURN_NAMES = ("视频",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "comfyui_o1key/Kling"
|
||||
|
||||
async def generate(self, **kwargs):
|
||||
"""动作控制:VIDEO 类型参考视频 + 图片人物动作迁移(走 new API 三段式)"""
|
||||
import base64
|
||||
|
||||
prompt = kwargs["提示词"]
|
||||
reference_image = kwargs["参考图片"]
|
||||
reference_video = kwargs["参考视频"]
|
||||
keep_original_sound = kwargs.get("保留原声", "打开")
|
||||
character_orientation = kwargs.get("人物朝向", "video")
|
||||
mode = kwargs.get("分辨率", "1080p")
|
||||
duration = kwargs.get("时长", 5)
|
||||
mode_api = "pro" if mode == "1080p" else "std" # 映射为 API 参数值
|
||||
model = kwargs.get("模型", "v3")
|
||||
model_name = f"kling-{model}-motion-{mode_api}-{duration}s"
|
||||
seed = kwargs.get("seed", 0) # noqa: F841 — 触发 ComfyUI 缓存刷新
|
||||
|
||||
# ── 校验提示词 ────────────────────────────────────────────────
|
||||
_validate_prompt(prompt, required=True)
|
||||
|
||||
# ── 校验参考图片 ──────────────────────────────────────────────
|
||||
_validate_image(reference_image, "参考图片")
|
||||
image_b64 = _tensor_to_base64(reference_image)
|
||||
|
||||
# ── 从 VIDEO 对象获取本地文件路径并读取 ───────────────────────
|
||||
video_path = None
|
||||
if hasattr(reference_video, "source_path"):
|
||||
video_path = reference_video.source_path
|
||||
elif hasattr(reference_video, "path"):
|
||||
video_path = reference_video.path
|
||||
elif isinstance(reference_video, str):
|
||||
video_path = reference_video.strip()
|
||||
|
||||
if not video_path or not os.path.isfile(video_path):
|
||||
raise ValueError(
|
||||
f"无法获取参考视频文件路径,请确保连接的是本地视频文件。"
|
||||
f"(当前路径:{video_path})"
|
||||
)
|
||||
|
||||
# ── 校验视频时长约束 ──────────────────────────────────────────
|
||||
# 人物朝向="video" → 3~30 秒;人物朝向="image" → 3~10 秒
|
||||
try:
|
||||
import subprocess, json as _json
|
||||
ffprobe_cmd = [
|
||||
"ffprobe", "-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
video_path,
|
||||
]
|
||||
result_proc = subprocess.run(ffprobe_cmd, capture_output=True, text=True, timeout=30)
|
||||
if result_proc.returncode == 0:
|
||||
info = _json.loads(result_proc.stdout)
|
||||
duration_sec = float(info.get("format", {}).get("duration", 0))
|
||||
if character_orientation == "video":
|
||||
if not (3 <= duration_sec <= 30):
|
||||
raise ValueError(
|
||||
f"当人物朝向为 'video' 时,"
|
||||
f"参考视频时长须在 3~30 秒之间,当前为 {duration_sec:.1f}s。"
|
||||
)
|
||||
else: # "image"
|
||||
if not (3 <= duration_sec <= 10):
|
||||
raise ValueError(
|
||||
f"当人物朝向为 'image' 时,"
|
||||
f"参考视频时长须在 3~10 秒之间,当前为 {duration_sec:.1f}s。"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print("[动作控制] 警告:ffprobe 未找到,跳过视频时长校验。")
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[动作控制] 时长校验异常(已跳过):{e}")
|
||||
|
||||
# ── 视频转 base64 ─────────────────────────────────────────────
|
||||
with open(video_path, "rb") as f:
|
||||
video_b64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
# ── 构建请求体(new API 格式)─────────────────────────────────
|
||||
body = {
|
||||
"model": model_name,
|
||||
"prompt": prompt,
|
||||
"image_url": image_b64,
|
||||
"video_url": video_b64,
|
||||
"character_orientation": character_orientation,
|
||||
"mode": mode_api,
|
||||
"keep_original_sound": "yes" if keep_original_sound == "打开" else "no",
|
||||
}
|
||||
|
||||
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
|
||||
_, save_path = tempfile.mkstemp(suffix=".mp4", prefix="kling_motion_")
|
||||
|
||||
client = KlingClient()
|
||||
client.base_url = get_base_url_by_route(kwargs.get("网络线路", "全球加速"))
|
||||
|
||||
# ── 进度条 ────────────────────────────────────────────────────
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
pbar = ProgressBar(100)
|
||||
except Exception:
|
||||
pbar = None
|
||||
|
||||
def on_stage(stage: str):
|
||||
if stage == "submitting":
|
||||
print("[动作控制] 提交中...")
|
||||
if pbar: pbar.update_absolute(0, 100)
|
||||
elif stage.startswith("submitted:"):
|
||||
print(f"[动作控制] 任务已提交 → {stage.split(':',1)[1]}")
|
||||
if pbar: pbar.update_absolute(5, 100)
|
||||
elif stage == "downloading":
|
||||
print("[动作控制] 下载视频...")
|
||||
if pbar: pbar.update_absolute(99, 100)
|
||||
elif stage == "done":
|
||||
print("[动作控制] 完成")
|
||||
if pbar: pbar.update_absolute(100, 100)
|
||||
|
||||
def on_progress(pct: int):
|
||||
mapped = 5 + int(pct * 0.94)
|
||||
if pbar: pbar.update_absolute(mapped, 100)
|
||||
|
||||
try:
|
||||
result_path = await client.motion_control_async(
|
||||
body=body,
|
||||
save_path=save_path,
|
||||
on_stage=on_stage,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
return (InputImpl.VideoFromFile(result_path),)
|
||||
finally:
|
||||
# 查询余额
|
||||
try:
|
||||
_balance_client = GeminiAPIClient()
|
||||
balance_data = _balance_client.query_balance_sync()
|
||||
balance_info = _balance_client.format_balance_info(balance_data)
|
||||
print(f"自研视频模型: {balance_info}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class AspectRatioPreset:
|
||||
"""图片宽高比预设节点"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"图像": ("IMAGE",),
|
||||
"宽高比": (["智能", "16:9", "9:16", "4:3", "3:4", "1:1"], {"default": "智能"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("图像",)
|
||||
FUNCTION = "resize"
|
||||
CATEGORY = "comfyui_o1key/Utils"
|
||||
|
||||
def resize(self, 图像, 宽高比):
|
||||
import torch
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
pil_images = tensor_to_pil(图像)
|
||||
img = pil_images[0]
|
||||
w, h = img.size
|
||||
img_ratio = w / h
|
||||
|
||||
# 确定原图所属的宽高比家族
|
||||
ratios = {"16:9": 16/9, "9:16": 9/16, "4:3": 4/3, "3:4": 3/4, "1:1": 1.0}
|
||||
closest_ratio = min(ratios.keys(), key=lambda k: abs(ratios[k] - img_ratio))
|
||||
|
||||
# 智能模式:使用最接近的比例
|
||||
if 宽高比 == "智能":
|
||||
宽高比 = closest_ratio
|
||||
|
||||
# 解析目标比例
|
||||
target_w, target_h = map(int, 宽高比.split(":"))
|
||||
target_ratio = target_w / target_h
|
||||
|
||||
# 确定分辨率级别(1K/2K)
|
||||
max_dim = max(w, h)
|
||||
if max_dim <= 1080:
|
||||
base = 1080
|
||||
elif max_dim <= 2160:
|
||||
base = 2160
|
||||
else:
|
||||
base = 2160
|
||||
|
||||
# 计算目标尺寸
|
||||
if target_ratio >= 1:
|
||||
target_width = base
|
||||
target_height = int(base / target_ratio)
|
||||
else:
|
||||
target_height = base
|
||||
target_width = int(base * target_ratio)
|
||||
|
||||
# 判断是否同家族(横向家族:16:9, 4:3;纵向家族:9:16, 3:4;正方形:1:1)
|
||||
horizontal_family = ["16:9", "4:3"]
|
||||
vertical_family = ["9:16", "3:4"]
|
||||
|
||||
same_family = False
|
||||
if closest_ratio in horizontal_family and 宽高比 in horizontal_family:
|
||||
same_family = True
|
||||
elif closest_ratio in vertical_family and 宽高比 in vertical_family:
|
||||
same_family = True
|
||||
elif closest_ratio == "1:1" and 宽高比 == "1:1":
|
||||
same_family = True
|
||||
|
||||
# 同家族:直接缩放或裁剪(无白底)
|
||||
if same_family:
|
||||
if img_ratio > target_ratio:
|
||||
# 图像更宽,以高度为准缩放后裁剪
|
||||
scale = target_height / h
|
||||
scaled_w = int(w * scale)
|
||||
scaled_h = target_height
|
||||
scaled = img.resize((scaled_w, scaled_h), Image.LANCZOS)
|
||||
left = (scaled_w - target_width) // 2
|
||||
result = scaled.crop((left, 0, left + target_width, target_height))
|
||||
else:
|
||||
# 图像更高,以宽度为准缩放后裁剪
|
||||
scale = target_width / w
|
||||
scaled_w = target_width
|
||||
scaled_h = int(h * scale)
|
||||
scaled = img.resize((scaled_w, scaled_h), Image.LANCZOS)
|
||||
top = (scaled_h - target_height) // 2
|
||||
result = scaled.crop((0, top, target_width, top + target_height))
|
||||
|
||||
# 不同家族:保持宽高比 + 白底填充
|
||||
else:
|
||||
if img_ratio > target_ratio:
|
||||
scaled_w = target_width
|
||||
scaled_h = int(target_width / img_ratio)
|
||||
else:
|
||||
scaled_h = target_height
|
||||
scaled_w = int(target_height * img_ratio)
|
||||
|
||||
scaled = img.resize((scaled_w, scaled_h), Image.LANCZOS)
|
||||
canvas = Image.new("RGB", (target_width, target_height), (255, 255, 255))
|
||||
paste_x = (target_width - scaled_w) // 2
|
||||
paste_y = (target_height - scaled_h) // 2
|
||||
canvas.paste(scaled, (paste_x, paste_y))
|
||||
result = canvas
|
||||
|
||||
# 转回 tensor
|
||||
arr = np.array(result).astype(np.float32) / 255.0
|
||||
tensor = torch.from_numpy(arr).unsqueeze(0)
|
||||
|
||||
return (tensor,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"KlingVideo": KlingVideo,
|
||||
"KlingFirstLastFrame": KlingFirstLastFrame,
|
||||
"KlingMotionControlTest": KlingMotionControlTest,
|
||||
"AspectRatioPreset": AspectRatioPreset,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"KlingVideo": "文/图生视频 自研模型",
|
||||
"KlingFirstLastFrame": "首尾帧生视频 自研模型",
|
||||
"KlingMotionControlTest": "动作控制 自研模型",
|
||||
"AspectRatioPreset": "图片宽高比预设",
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Load every image in a local folder and emit them one by one."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from comfy_api.latest import io
|
||||
|
||||
|
||||
def _resolve_folder(folder_path: str) -> Path:
|
||||
raw_path = str(folder_path or "").strip().strip('"').strip("'")
|
||||
if not raw_path:
|
||||
raise ValueError("加载图像(文件夹):请输入文件夹路径")
|
||||
|
||||
expanded = os.path.expandvars(os.path.expanduser(raw_path))
|
||||
folder = Path(expanded)
|
||||
if not folder.is_absolute():
|
||||
folder = Path.cwd() / folder
|
||||
folder = folder.resolve()
|
||||
|
||||
if not folder.exists():
|
||||
raise ValueError(f"加载图像(文件夹):文件夹不存在:{folder}")
|
||||
if not folder.is_dir():
|
||||
raise ValueError(f"加载图像(文件夹):路径不是文件夹:{folder}")
|
||||
return folder
|
||||
|
||||
|
||||
def _natural_sort_key(path: Path):
|
||||
"""Sort image2 before image10 while remaining case-insensitive."""
|
||||
return tuple(
|
||||
int(part) if part.isdigit() else part.casefold()
|
||||
for part in re.split(r"(\d+)", path.name)
|
||||
)
|
||||
|
||||
|
||||
def _list_image_files(folder: Path) -> list[Path]:
|
||||
# Pillow's registry reflects the formats supported by the current runtime,
|
||||
# including optional formats supplied by installed Pillow plugins.
|
||||
Image.init()
|
||||
supported_extensions = {suffix.casefold() for suffix in Image.registered_extensions()}
|
||||
image_files = sorted(
|
||||
(
|
||||
path
|
||||
for path in folder.iterdir()
|
||||
if path.is_file() and path.suffix.casefold() in supported_extensions
|
||||
),
|
||||
key=_natural_sort_key,
|
||||
)
|
||||
if not image_files:
|
||||
raise ValueError(f"加载图像(文件夹):文件夹中没有可读取的图片:{folder}")
|
||||
return image_files
|
||||
|
||||
|
||||
def _load_image_tensor(path: Path) -> torch.Tensor:
|
||||
try:
|
||||
with Image.open(path) as opened:
|
||||
image = ImageOps.exif_transpose(opened)
|
||||
image.seek(0)
|
||||
if image.mode == "I":
|
||||
image = image.point(lambda value: value * (1 / 255))
|
||||
rgb_image = image.convert("RGB")
|
||||
array = np.asarray(rgb_image, dtype=np.float32) / 255.0
|
||||
except (OSError, ValueError, UnidentifiedImageError) as exc:
|
||||
raise ValueError(f"加载图像(文件夹):无法读取图片 {path.name}:{exc}") from exc
|
||||
|
||||
# ComfyUI IMAGE tensors use [batch, height, width, channels]. Each list
|
||||
# item is kept as a separate batch of one so original dimensions survive.
|
||||
return torch.from_numpy(array).unsqueeze(0)
|
||||
|
||||
|
||||
class LoadImagesFromFolder(io.ComfyNode):
|
||||
"""Load local images in natural filename order as a ComfyUI output list."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="O1keyLoadImagesFromFolder",
|
||||
display_name="加载图像(文件夹)",
|
||||
category="image",
|
||||
description=(
|
||||
"读取本地文件夹第一层中的所有图片,按文件名自然顺序逐张输出。"
|
||||
"每张图片保留原始分辨率,可直接连接普通图像处理节点。"
|
||||
),
|
||||
search_aliases=[
|
||||
"文件夹图片",
|
||||
"批量加载图片",
|
||||
"folder images",
|
||||
"load images from folder",
|
||||
],
|
||||
inputs=[
|
||||
io.String.Input(
|
||||
"文件夹路径",
|
||||
default="",
|
||||
placeholder=r"例如:D:\images",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
io.Image.Output(display_name="图像", is_output_list=True),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def fingerprint_inputs(cls, 文件夹路径: str):
|
||||
"""Invalidate ComfyUI's cache when the folder's image set changes."""
|
||||
try:
|
||||
folder = _resolve_folder(文件夹路径)
|
||||
return tuple(
|
||||
(path.name, path.stat().st_size, path.stat().st_mtime_ns)
|
||||
for path in _list_image_files(folder)
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
# Execution will provide the user-facing validation error.
|
||||
return str(文件夹路径 or "")
|
||||
|
||||
@classmethod
|
||||
def execute(cls, 文件夹路径: str) -> io.NodeOutput:
|
||||
folder = _resolve_folder(文件夹路径)
|
||||
image_files = _list_image_files(folder)
|
||||
images = []
|
||||
|
||||
for index, path in enumerate(image_files, start=1):
|
||||
tensor = _load_image_tensor(path)
|
||||
images.append(tensor)
|
||||
height, width = tensor.shape[1:3]
|
||||
print(
|
||||
f"加载图像(文件夹):{index}/{len(image_files)} "
|
||||
f"{path.name} ({width}×{height})"
|
||||
)
|
||||
|
||||
print(f"加载图像(文件夹):已从 {folder} 加载 {len(images)} 张图片")
|
||||
return io.NodeOutput(images)
|
||||
@@ -0,0 +1,579 @@
|
||||
"""MiniMax-H3 video generation through a New API gateway."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
import folder_paths
|
||||
from comfy_api.latest import InputImpl, io
|
||||
|
||||
from ..clients.minimax_h3_client import MiniMaxH3Client
|
||||
from ..utils.config import get_base_url_by_route
|
||||
from ..utils.image_utils import tensor_to_pil
|
||||
from ..utils.minimax_h3_media import (
|
||||
MAX_REFERENCE_AUDIOS,
|
||||
MAX_REFERENCE_IMAGES,
|
||||
MAX_REFERENCE_VIDEOS,
|
||||
validate_image,
|
||||
validate_reference_audios,
|
||||
validate_reference_videos,
|
||||
)
|
||||
from ..utils.r2_uploader import upload_audio, upload_image, upload_video
|
||||
|
||||
|
||||
MODEL_ID = "MiniMax-H3"
|
||||
MODEL_MAX_ID = "MiniMax-H3-MAX"
|
||||
MODEL_OPTIONS = [MODEL_ID, MODEL_MAX_ID]
|
||||
MODE_TEXT = "文生视频"
|
||||
MODE_FIRST = "首帧图生视频"
|
||||
MODE_LAST = "尾帧图生视频"
|
||||
MODE_FIRST_LAST = "首尾帧生视频"
|
||||
MODE_REFERENCE = "参考素材生视频"
|
||||
|
||||
RESOLUTION_OPTIONS = ["2K", "768P", "480P"]
|
||||
MODEL_RESOLUTIONS = {
|
||||
MODEL_ID: {"768P", "2K"},
|
||||
MODEL_MAX_ID: {"480P", "768P"},
|
||||
}
|
||||
MODEL_DURATION_RANGES = {
|
||||
MODEL_ID: (4, 15),
|
||||
MODEL_MAX_ID: (5, 15),
|
||||
}
|
||||
RATIO_OPTIONS = ["16:9", "21:9", "4:3", "1:1", "3:4", "9:16"]
|
||||
REFERENCE_RATIO_OPTIONS = ["adaptive", *RATIO_OPTIONS]
|
||||
MAX_PROMPT_CHARS = 7000
|
||||
MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024
|
||||
MAX_REFERENCE_MATERIALS = 12
|
||||
MAX_SEED = 2**31 - 1
|
||||
|
||||
|
||||
def _validate_prompt(prompt: str) -> str:
|
||||
prompt = (prompt or "").strip()
|
||||
if not prompt:
|
||||
raise ValueError("MiniMax H3 提示词不能为空。")
|
||||
if len(prompt) > MAX_PROMPT_CHARS:
|
||||
raise ValueError(
|
||||
f"MiniMax H3 提示词最多 {MAX_PROMPT_CHARS} 字符,当前为 {len(prompt)} 字符。"
|
||||
)
|
||||
return prompt
|
||||
|
||||
|
||||
def _validate_seed(seed: int) -> int:
|
||||
if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed <= MAX_SEED:
|
||||
raise ValueError(f"MiniMax H3 seed 必须是 0~{MAX_SEED} 的整数。")
|
||||
return seed
|
||||
|
||||
|
||||
def _validate_generation_options(
|
||||
model: str,
|
||||
resolution: str,
|
||||
duration: int,
|
||||
mode: Optional[str] = None,
|
||||
) -> None:
|
||||
if model not in MODEL_OPTIONS:
|
||||
raise ValueError(f"不支持的 MiniMax 模型:{model}")
|
||||
allowed_resolutions = MODEL_RESOLUTIONS[model]
|
||||
if resolution not in allowed_resolutions:
|
||||
allowed_text = "、".join(
|
||||
value for value in RESOLUTION_OPTIONS if value in allowed_resolutions
|
||||
)
|
||||
raise ValueError(f"{model} 分辨率仅支持 {allowed_text}。")
|
||||
minimum_duration, maximum_duration = MODEL_DURATION_RANGES[model]
|
||||
if (
|
||||
isinstance(duration, bool)
|
||||
or not isinstance(duration, int)
|
||||
or not minimum_duration <= duration <= maximum_duration
|
||||
):
|
||||
raise ValueError(
|
||||
f"{model} 时长必须是 {minimum_duration}~{maximum_duration} 的整数。"
|
||||
)
|
||||
if model == MODEL_MAX_ID and mode == MODE_REFERENCE:
|
||||
raise ValueError("MiniMax-H3-MAX 不支持图片、视频或音频参考素材模式。")
|
||||
|
||||
|
||||
def _validate_reference_counts(
|
||||
reference_images,
|
||||
reference_videos,
|
||||
reference_audios,
|
||||
) -> None:
|
||||
if len(reference_images) > MAX_REFERENCE_IMAGES:
|
||||
raise ValueError(f"参考图片最多 {MAX_REFERENCE_IMAGES} 张。")
|
||||
if len(reference_videos) > MAX_REFERENCE_VIDEOS:
|
||||
raise ValueError(f"参考视频最多 {MAX_REFERENCE_VIDEOS} 个。")
|
||||
if len(reference_audios) > MAX_REFERENCE_AUDIOS:
|
||||
raise ValueError(f"参考音频最多 {MAX_REFERENCE_AUDIOS} 个。")
|
||||
total = len(reference_images) + len(reference_videos) + len(reference_audios)
|
||||
if total > MAX_REFERENCE_MATERIALS:
|
||||
raise ValueError(
|
||||
f"参考图片、视频和音频合计最多 {MAX_REFERENCE_MATERIALS} 个,当前为 {total} 个。"
|
||||
)
|
||||
|
||||
|
||||
def _validate_image_tensor(image, label: str):
|
||||
if image is None:
|
||||
raise ValueError(f"{label}不能为空。")
|
||||
ndim = getattr(image, "dim", lambda: None)()
|
||||
if ndim == 4 and int(image.shape[0]) != 1:
|
||||
raise ValueError(f"{label}仅支持单张图片,当前批次包含 {int(image.shape[0])} 张。")
|
||||
|
||||
|
||||
def _image_to_pil(image, label: str):
|
||||
_validate_image_tensor(image, label)
|
||||
images = tensor_to_pil(image)
|
||||
if not images:
|
||||
raise ValueError(f"无法读取{label}。")
|
||||
result = images[0]
|
||||
if result.mode not in ("RGB", "RGBA"):
|
||||
result = result.convert("RGB")
|
||||
validate_image(result, label)
|
||||
return result
|
||||
|
||||
|
||||
def _validate_body_size(body: dict) -> None:
|
||||
body_size = len(
|
||||
json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
if body_size > MAX_REQUEST_BODY_BYTES:
|
||||
raise ValueError(
|
||||
f"MiniMax H3 请求体大小 {body_size / 1024 / 1024:.2f} MB 超过 64 MB 限制。"
|
||||
)
|
||||
|
||||
|
||||
def build_request_body(
|
||||
*,
|
||||
prompt: str,
|
||||
resolution: str,
|
||||
duration: int,
|
||||
mode: str,
|
||||
model: str = MODEL_ID,
|
||||
seed: int = 0,
|
||||
ratio: Optional[str] = None,
|
||||
first_url: Optional[str] = None,
|
||||
last_url: Optional[str] = None,
|
||||
reference_image_urls: Optional[list[str]] = None,
|
||||
reference_video_urls: Optional[list[str]] = None,
|
||||
reference_audio_urls: Optional[list[str]] = None,
|
||||
) -> dict:
|
||||
"""Build and validate the documented MiniMax H3 / H3 Max request body."""
|
||||
prompt = _validate_prompt(prompt)
|
||||
seed = _validate_seed(seed)
|
||||
_validate_generation_options(model, resolution, duration, mode)
|
||||
|
||||
reference_image_urls = [url for url in (reference_image_urls or []) if url]
|
||||
reference_video_urls = [url for url in (reference_video_urls or []) if url]
|
||||
reference_audio_urls = [url for url in (reference_audio_urls or []) if url]
|
||||
_validate_reference_counts(
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
)
|
||||
|
||||
has_first_last_material = bool(first_url or last_url)
|
||||
has_reference_material = bool(
|
||||
reference_image_urls or reference_video_urls or reference_audio_urls
|
||||
)
|
||||
|
||||
content = [{"type": "text", "text": prompt}]
|
||||
|
||||
if mode == MODE_TEXT:
|
||||
if has_first_last_material or has_reference_material:
|
||||
raise ValueError("文生视频不能同时使用首尾帧或参考素材。")
|
||||
if ratio not in RATIO_OPTIONS:
|
||||
raise ValueError("MiniMax H3 文生视频必须选择具体宽高比,不能使用 adaptive。")
|
||||
request_ratio = ratio
|
||||
elif mode == MODE_FIRST:
|
||||
if last_url or has_reference_material:
|
||||
raise ValueError("首帧图生视频不能同时使用尾帧或参考素材。")
|
||||
if not first_url:
|
||||
raise ValueError("首帧图生视频必须连接首帧图片。")
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": first_url},
|
||||
"role": "first_frame",
|
||||
})
|
||||
request_ratio = "adaptive"
|
||||
elif mode == MODE_LAST:
|
||||
if first_url or has_reference_material:
|
||||
raise ValueError("尾帧图生视频不能同时使用首帧或参考素材。")
|
||||
if not last_url:
|
||||
raise ValueError("尾帧图生视频必须连接尾帧图片。")
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": last_url},
|
||||
"role": "last_frame",
|
||||
})
|
||||
request_ratio = "adaptive"
|
||||
elif mode == MODE_FIRST_LAST:
|
||||
if has_reference_material:
|
||||
raise ValueError("首尾帧模式和参考素材模式不能混用。")
|
||||
if not first_url or not last_url:
|
||||
raise ValueError("首尾帧生视频必须同时连接首帧和尾帧图片。")
|
||||
content.extend([
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": first_url},
|
||||
"role": "first_frame",
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": last_url},
|
||||
"role": "last_frame",
|
||||
},
|
||||
])
|
||||
request_ratio = "adaptive"
|
||||
elif mode == MODE_REFERENCE:
|
||||
if has_first_last_material:
|
||||
raise ValueError("参考素材模式和首尾帧模式不能混用。")
|
||||
if not has_reference_material:
|
||||
raise ValueError("参考素材生视频至少需要一张图片、一个视频或一段音频。")
|
||||
content.extend(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": url},
|
||||
"role": "reference_image",
|
||||
}
|
||||
for url in reference_image_urls
|
||||
)
|
||||
content.extend(
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": url},
|
||||
"role": "reference_video",
|
||||
}
|
||||
for url in reference_video_urls
|
||||
)
|
||||
content.extend(
|
||||
{
|
||||
"type": "audio_url",
|
||||
"audio_url": {"url": url},
|
||||
"role": "reference_audio",
|
||||
}
|
||||
for url in reference_audio_urls
|
||||
)
|
||||
request_ratio = ratio or "adaptive"
|
||||
if request_ratio not in REFERENCE_RATIO_OPTIONS:
|
||||
raise ValueError(f"MiniMax H3 参考素材模式不支持宽高比:{request_ratio}")
|
||||
else:
|
||||
raise ValueError(f"不支持的 MiniMax H3 生成模式:{mode}")
|
||||
|
||||
body = {
|
||||
"model": model,
|
||||
"content": content,
|
||||
"resolution": resolution,
|
||||
"duration": duration,
|
||||
"ratio": request_ratio,
|
||||
"seed": seed,
|
||||
}
|
||||
_validate_body_size(body)
|
||||
return body
|
||||
|
||||
|
||||
def _build_mode_input():
|
||||
reference_images = io.Autogrow.Input(
|
||||
"参考图片组",
|
||||
template=io.Autogrow.TemplateNames(
|
||||
input=io.Image.Input("参考图片"),
|
||||
names=[f"参考图片{i}" for i in range(1, MAX_REFERENCE_IMAGES + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip="可动态连接,最多 9 张。",
|
||||
)
|
||||
reference_videos = io.Autogrow.Input(
|
||||
"参考视频组",
|
||||
template=io.Autogrow.TemplateNames(
|
||||
input=io.Video.Input("参考视频"),
|
||||
names=[f"参考视频{i}" for i in range(1, MAX_REFERENCE_VIDEOS + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip="可动态连接,最多 3 个;每段 2~15 秒,总时长不超过 15 秒。",
|
||||
)
|
||||
reference_audios = io.Autogrow.Input(
|
||||
"参考音频组",
|
||||
template=io.Autogrow.TemplateNames(
|
||||
input=io.Audio.Input("参考音频"),
|
||||
names=[f"参考音频{i}" for i in range(1, MAX_REFERENCE_AUDIOS + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip="可动态连接,最多 3 段;每段 2~15 秒,总时长不超过 15 秒。",
|
||||
)
|
||||
return io.DynamicCombo.Input(
|
||||
"生成模式",
|
||||
options=[
|
||||
io.DynamicCombo.Option(
|
||||
MODE_TEXT,
|
||||
[
|
||||
io.Combo.Input(
|
||||
"宽高比",
|
||||
options=RATIO_OPTIONS,
|
||||
default="16:9",
|
||||
tooltip="文生视频必须使用具体比例,不能使用 adaptive。",
|
||||
)
|
||||
],
|
||||
),
|
||||
io.DynamicCombo.Option(
|
||||
MODE_FIRST,
|
||||
[io.Image.Input("首帧图片", tooltip="输入图片决定视频比例。")],
|
||||
),
|
||||
io.DynamicCombo.Option(
|
||||
MODE_LAST,
|
||||
[io.Image.Input("尾帧图片", tooltip="输入图片决定视频比例。")],
|
||||
),
|
||||
io.DynamicCombo.Option(
|
||||
MODE_FIRST_LAST,
|
||||
[
|
||||
io.Image.Input("首帧图片"),
|
||||
io.Image.Input("尾帧图片"),
|
||||
],
|
||||
),
|
||||
io.DynamicCombo.Option(
|
||||
MODE_REFERENCE,
|
||||
[
|
||||
reference_images,
|
||||
reference_videos,
|
||||
reference_audios,
|
||||
io.Combo.Input(
|
||||
"宽高比",
|
||||
options=REFERENCE_RATIO_OPTIONS,
|
||||
default="adaptive",
|
||||
tooltip="参考素材模式默认 adaptive,也可指定具体比例。",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
tooltip="首尾帧模式与参考素材模式互斥。",
|
||||
)
|
||||
|
||||
|
||||
def _collect_autogrow(value) -> list:
|
||||
"""Collect connected values while tolerating a legacy single input value."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
return [item for item in value.values() if item is not None]
|
||||
return [value]
|
||||
|
||||
|
||||
def _make_progress_callbacks():
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
|
||||
pbar = ProgressBar(100)
|
||||
except Exception:
|
||||
pbar = None
|
||||
|
||||
last_progress = -1
|
||||
|
||||
def set_progress(value: int):
|
||||
nonlocal last_progress
|
||||
value = max(0, min(100, int(value)))
|
||||
if value <= last_progress:
|
||||
return
|
||||
last_progress = value
|
||||
if pbar:
|
||||
pbar.update_absolute(value, 100)
|
||||
|
||||
def on_stage(stage: str):
|
||||
if stage == "submitting":
|
||||
print("[MiniMax H3] 正在创建任务...")
|
||||
set_progress(0)
|
||||
elif stage.startswith("submitted:"):
|
||||
print(f"[MiniMax H3] 任务已进入队列:{stage.split(':', 1)[1]}")
|
||||
elif stage == "downloading":
|
||||
print("[MiniMax H3] 生成成功,正在立即下载临时 CDN 视频...")
|
||||
elif stage == "done":
|
||||
set_progress(100)
|
||||
|
||||
def on_progress(progress: int):
|
||||
# New API returns the authoritative task percentage in data.progress.
|
||||
# Mirror it directly in ComfyUI while preventing stale poll responses
|
||||
# from moving the node progress bar backwards.
|
||||
set_progress(progress)
|
||||
|
||||
return on_stage, on_progress
|
||||
|
||||
|
||||
class MiniMaxH3Video(io.ComfyNode):
|
||||
"""MiniMax H3 / H3 Max text, frame, and reference video generation."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="MiniMaxH3Video",
|
||||
display_name="MiniMax H3 / H3 Max 视频生成",
|
||||
category="comfyui_o1key/Video",
|
||||
description=(
|
||||
"通过 New API 网关调用 MiniMax-H3 或 MiniMax-H3-MAX;H3 支持多模态参考,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 支持 4~15 秒;H3 Max 支持 5~15 秒。",
|
||||
),
|
||||
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 视频生成"}
|
||||
+281
-134
@@ -8,9 +8,8 @@ import time
|
||||
import math
|
||||
import random
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Callable, List, Optional
|
||||
from typing import Any, Callable, List, Optional
|
||||
|
||||
import torch
|
||||
import numpy as np
|
||||
@@ -20,12 +19,21 @@ from comfy_api.latest import io
|
||||
|
||||
from ..utils.image_utils import tensor_to_pil, pil_to_tensor, parse_batch_prompts
|
||||
from ..utils.config import (
|
||||
NETWORK_ROUTE_OPTIONS,
|
||||
get_base_url_by_route,
|
||||
get_api_key_or_raise,
|
||||
get_runtime_config_signature,
|
||||
)
|
||||
from ..utils.nano_banana_async import generate_nano_banana_async
|
||||
from ..utils.nano_banana_async import (
|
||||
generate_nano_banana_async,
|
||||
VERBOSE_LOG_ENABLED,
|
||||
)
|
||||
from ..utils.http2_client import create_http_client
|
||||
from ..clients.gemini_client import GeminiAPIClient
|
||||
from ..utils.nano_banana_models import (
|
||||
NANO_BANANA_MODEL_OPTIONS,
|
||||
NANO_BANANA_ROUTE_OPTIONS,
|
||||
resolve_nano_banana_model,
|
||||
)
|
||||
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
@@ -41,19 +49,28 @@ except ImportError:
|
||||
InterruptProcessingException = RuntimeError
|
||||
processing_interrupted = lambda: False
|
||||
|
||||
REQUEST_LOG_ENABLED = False
|
||||
# 完整原始报文仅在显式开启详细日志时打印。
|
||||
REQUEST_LOG_ENABLED = VERBOSE_LOG_ENABLED
|
||||
|
||||
_NODE = "Nano Banana"
|
||||
_REQUEST_TIMEOUT = 900
|
||||
_INTERRUPT_CHECK_INTERVAL = 0.2
|
||||
MAX_REFERENCE_IMAGES = 14
|
||||
_MAX_GENERATION_CONCURRENCY = 12
|
||||
_MAX_DOWNLOAD_CONCURRENCY = 6
|
||||
_HTTP_MAX_CONNECTIONS = 32
|
||||
_HTTP_MAX_KEEPALIVE_CONNECTIONS = 16
|
||||
|
||||
_client_instance = None
|
||||
_client_config_signature = None
|
||||
|
||||
|
||||
def _get_client():
|
||||
global _client_instance
|
||||
if _client_instance is None:
|
||||
global _client_instance, _client_config_signature
|
||||
config_signature = get_runtime_config_signature()
|
||||
if _client_instance is None or config_signature != _client_config_signature:
|
||||
_client_instance = GeminiAPIClient()
|
||||
_client_config_signature = config_signature
|
||||
return _client_instance
|
||||
|
||||
|
||||
@@ -132,47 +149,29 @@ def _images_to_tensor_safe(images: List[Image.Image], node_label: str) -> torch.
|
||||
return pil_to_tensor(matched)
|
||||
|
||||
|
||||
MODEL_ID_MAP = {
|
||||
"Nano Banana Pro": "nano-banana-pro",
|
||||
"Nano Banana 2": "nano-banana-2",
|
||||
"Nano Banana": "nano-banana",
|
||||
def _collect_autogrow_inputs(value) -> list:
|
||||
"""Collect connected Autogrow slots while tolerating a single legacy value."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
return [item for item in value.values() if item is not None]
|
||||
return [value]
|
||||
|
||||
|
||||
THINKING_LEVEL_MAP = {
|
||||
"低": "minimal",
|
||||
"高": "high",
|
||||
}
|
||||
RESOLUTION_KEY_MAP = {
|
||||
"512px": "0.5k",
|
||||
"1K": "1k",
|
||||
"2K": "2k",
|
||||
"4K": "4k",
|
||||
}
|
||||
BILLING_SPECIAL_ONLY = {"nano-banana"}
|
||||
|
||||
|
||||
def _build_model_id(model_name: str, resolution: str, billing: str) -> str:
|
||||
base = MODEL_ID_MAP.get(model_name, "nano-banana-pro")
|
||||
|
||||
if base == "nano-banana":
|
||||
if billing == "官方":
|
||||
raise ValueError(f"模型 \"{model_name}\" 仅支持特价计费")
|
||||
return "nano-banana"
|
||||
|
||||
res_key = RESOLUTION_KEY_MAP.get(resolution, "2k")
|
||||
is_official = (billing == "官方")
|
||||
|
||||
if base == "nano-banana-pro" and res_key == "1k" and not is_official:
|
||||
return "nano-banana-pro"
|
||||
|
||||
if base == "nano-banana-2" and res_key == "0.5k":
|
||||
if is_official:
|
||||
raise ValueError("Nano Banana 2 的 512px 分辨率仅支持特价计费")
|
||||
return "nano-banana-2-0.5k"
|
||||
|
||||
model_id = f"{base}-{res_key}"
|
||||
if is_official:
|
||||
model_id += "-official"
|
||||
return model_id
|
||||
def _build_model_id(model_name: str, resolution: str, route: str) -> str:
|
||||
"""兼容旧调用签名;新模型名只由主模型和线路决定。"""
|
||||
del resolution
|
||||
return resolve_nano_banana_model(model_name, route)
|
||||
|
||||
|
||||
async def _generate_single(
|
||||
session: aiohttp.ClientSession,
|
||||
session: Any,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
prompt: str,
|
||||
@@ -180,10 +179,17 @@ async def _generate_single(
|
||||
resolution: str,
|
||||
aspect_ratio: str,
|
||||
images: Optional[List[Image.Image]] = None,
|
||||
enable_grounding: bool = False,
|
||||
image_urls: Optional[List[str]] = None,
|
||||
thinking_level: Optional[str] = None,
|
||||
progress_callback: Optional[Callable[[float], None]] = None,
|
||||
) -> List[Image.Image]:
|
||||
node_label: str = "Nano Banana",
|
||||
result_url_callback: Optional[Callable[[str], None]] = None,
|
||||
log_task_success: bool = True,
|
||||
upload_cache: Optional[dict] = None,
|
||||
download_semaphore: Optional[asyncio.Semaphore] = None,
|
||||
resize_mode: str = "不缩放",
|
||||
google_search: bool = False,
|
||||
) -> tuple[List[Image.Image], dict]:
|
||||
result_images, timing = await generate_nano_banana_async(
|
||||
session=session,
|
||||
base_url=base_url,
|
||||
@@ -193,18 +199,24 @@ async def _generate_single(
|
||||
resolution=resolution,
|
||||
aspect_ratio=aspect_ratio,
|
||||
images=images,
|
||||
enable_grounding=enable_grounding,
|
||||
image_urls=image_urls,
|
||||
upload_cache=upload_cache,
|
||||
download_semaphore=download_semaphore,
|
||||
thinking_level=thinking_level,
|
||||
node_label="Nano Banana",
|
||||
google_search=google_search,
|
||||
node_label=node_label,
|
||||
request_log_enabled=REQUEST_LOG_ENABLED,
|
||||
check_interrupt=_check_interrupt,
|
||||
progress_callback=progress_callback,
|
||||
result_url_callback=result_url_callback,
|
||||
log_task_success=log_task_success,
|
||||
resize_mode=resize_mode,
|
||||
)
|
||||
return result_images, timing["task_ms"], timing["parse_ms"]
|
||||
return result_images, timing
|
||||
|
||||
|
||||
async def _generate_single_task(
|
||||
session: aiohttp.ClientSession,
|
||||
session: Any,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
prompt: str,
|
||||
@@ -212,10 +224,14 @@ async def _generate_single_task(
|
||||
resolution: str,
|
||||
aspect_ratio: str,
|
||||
images: Optional[List[Image.Image]],
|
||||
image_urls: Optional[List[str]],
|
||||
global_task_index: int,
|
||||
enable_grounding: bool = False,
|
||||
thinking_level: Optional[str] = None,
|
||||
progress_callback: Optional[Callable[[float], None]] = None,
|
||||
upload_cache: Optional[dict] = None,
|
||||
download_semaphore: Optional[asyncio.Semaphore] = None,
|
||||
resize_mode: str = "不缩放",
|
||||
google_search: bool = False,
|
||||
) -> dict:
|
||||
result = {
|
||||
"global_task_index": global_task_index,
|
||||
@@ -225,8 +241,9 @@ async def _generate_single_task(
|
||||
"output_images": [],
|
||||
"error": None,
|
||||
}
|
||||
task_started = time.time()
|
||||
try:
|
||||
gen_images, task_ms, parse_ms = await _generate_single(
|
||||
gen_images, timing = await _generate_single(
|
||||
session=session,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
@@ -235,14 +252,22 @@ async def _generate_single_task(
|
||||
resolution=resolution,
|
||||
aspect_ratio=aspect_ratio,
|
||||
images=images if images else None,
|
||||
enable_grounding=enable_grounding,
|
||||
image_urls=image_urls if image_urls else None,
|
||||
thinking_level=thinking_level,
|
||||
google_search=google_search,
|
||||
progress_callback=progress_callback,
|
||||
node_label=f"Nano Banana#{global_task_index + 1}",
|
||||
upload_cache=upload_cache,
|
||||
download_semaphore=download_semaphore,
|
||||
resize_mode=resize_mode,
|
||||
)
|
||||
del task_ms, parse_ms
|
||||
result["output_images"] = gen_images
|
||||
result["success"] = True
|
||||
result["generated_count"] = len(gen_images)
|
||||
print(
|
||||
f"Nano Banana#{global_task_index + 1}: 完成 ✓ | "
|
||||
f"生成={len(gen_images)} 张 | 耗时={time.time() - task_started:.1f}s"
|
||||
)
|
||||
except InterruptProcessingException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -260,8 +285,10 @@ async def _process_batch_async(
|
||||
images_per_prompt: int,
|
||||
input_images: Optional[List[Image.Image]],
|
||||
pbar=None,
|
||||
enable_grounding: bool = False,
|
||||
thinking_level: Optional[str] = None,
|
||||
resize_mode: str = "不缩放",
|
||||
unlimited_downloads: bool = False,
|
||||
google_search: bool = False,
|
||||
) -> List[dict]:
|
||||
tasks_def = []
|
||||
for p_idx, prompt in enumerate(prompts):
|
||||
@@ -269,7 +296,7 @@ async def _process_batch_async(
|
||||
tasks_def.append((p_idx, sub_idx, prompt))
|
||||
|
||||
total_tasks = len(tasks_def)
|
||||
max_concurrent = 50
|
||||
max_concurrent = _MAX_GENERATION_CONCURRENCY
|
||||
num_batches = math.ceil(total_tasks / max_concurrent)
|
||||
|
||||
all_results = []
|
||||
@@ -277,9 +304,18 @@ async def _process_batch_async(
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
connector = aiohttp.TCPConnector(ssl=False, limit=0, limit_per_host=0)
|
||||
upload_cache = {}
|
||||
download_semaphore = (
|
||||
None
|
||||
if unlimited_downloads
|
||||
else asyncio.Semaphore(_MAX_DOWNLOAD_CONCURRENCY)
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
async with create_http_client(
|
||||
http2=True,
|
||||
max_connections=_HTTP_MAX_CONNECTIONS,
|
||||
max_keepalive_connections=_HTTP_MAX_KEEPALIVE_CONNECTIONS,
|
||||
) as session:
|
||||
for batch_idx in range(num_batches):
|
||||
_check_interrupt()
|
||||
start_idx = batch_idx * max_concurrent
|
||||
@@ -299,10 +335,14 @@ async def _process_batch_async(
|
||||
resolution=resolution,
|
||||
aspect_ratio=aspect_ratio,
|
||||
images=input_images,
|
||||
image_urls=None,
|
||||
global_task_index=i,
|
||||
enable_grounding=enable_grounding,
|
||||
thinking_level=thinking_level,
|
||||
google_search=google_search,
|
||||
progress_callback=_make_progress_callback(pbar),
|
||||
upload_cache=upload_cache,
|
||||
download_semaphore=download_semaphore,
|
||||
resize_mode=resize_mode,
|
||||
)
|
||||
)
|
||||
tasks.append(task)
|
||||
@@ -327,18 +367,23 @@ async def _process_batch_async(
|
||||
|
||||
batch_results.append(result_data)
|
||||
completed += 1
|
||||
prompt_snippet = (result_data.get("prompt", "") or "")[:30]
|
||||
task_num = result_data.get("global_task_index", "?")
|
||||
if isinstance(task_num, int):
|
||||
task_num += 1
|
||||
|
||||
if result_data and result_data.get("success", False):
|
||||
success_count += 1
|
||||
count = result_data.get("generated_count", 1)
|
||||
print(f"Nano Banana: [{completed}/{total_tasks}] {prompt_snippet}{'...' if len(prompt_snippet) >= 30 else ''} → ✓成功({count}张)")
|
||||
else:
|
||||
fail_count += 1
|
||||
error_msg = result_data.get("error", "未知错误") if result_data else "未知错误"
|
||||
print(f"Nano Banana: [{completed}/{total_tasks}] {prompt_snippet}{'...' if len(prompt_snippet) >= 30 else ''} → ✗失败: {error_msg}")
|
||||
print(f"Nano Banana#{task_num}: 失败 | error={error_msg}")
|
||||
|
||||
all_results.extend(batch_results)
|
||||
print(
|
||||
f"Nano Banana: 批次 {batch_idx + 1}/{num_batches} 完成 "
|
||||
f"| 成功={success_count} | 失败={fail_count} "
|
||||
f"| 总进度={completed}/{total_tasks}"
|
||||
)
|
||||
import gc; gc.collect()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@@ -347,8 +392,62 @@ async def _process_batch_async(
|
||||
|
||||
class NanoBanana(io.ComfyNode):
|
||||
|
||||
@staticmethod
|
||||
def _validate_model_config(model_name: str, aspect_ratio: str, resolution: str):
|
||||
"""验证模型配置是否合法"""
|
||||
# 验证模型名称
|
||||
valid_models = set(NANO_BANANA_MODEL_OPTIONS)
|
||||
if model_name not in valid_models:
|
||||
raise ValueError(
|
||||
f"模型 '{model_name}' 无效,支持的模型:{', '.join(sorted(valid_models))}"
|
||||
)
|
||||
|
||||
# 验证宽高比
|
||||
valid_aspect_ratios = {
|
||||
"智能", "1:1", "1:4", "1:8", "2:3", "3:2", "3:4",
|
||||
"4:1", "4:3", "4:5", "5:4", "8:1",
|
||||
"9:16", "16:9", "21:9",
|
||||
}
|
||||
if aspect_ratio not in valid_aspect_ratios:
|
||||
raise ValueError(
|
||||
f"宽高比 '{aspect_ratio}' 无效,支持的宽高比:{', '.join(sorted(valid_aspect_ratios))}"
|
||||
)
|
||||
|
||||
# 验证分辨率
|
||||
# “智能”仅由统一生图节点传入;独立节点仍保持原有下拉选项。
|
||||
valid_resolutions = {"智能", "1K", "2K", "4K"}
|
||||
if resolution not in valid_resolutions:
|
||||
raise ValueError(
|
||||
f"分辨率 '{resolution}' 无效,支持的分辨率:{', '.join(sorted(valid_resolutions))}"
|
||||
)
|
||||
|
||||
# Nano Banana 2 系列特有的宽高比
|
||||
nano_2_exclusive_ratios = {"1:4", "1:8", "4:1", "8:1"}
|
||||
# 其他模型使用了 Nano Banana 2 系列专属宽高比
|
||||
if model_name not in {"Nano Banana 2", "Nano Banana 2 Lite"} and aspect_ratio in nano_2_exclusive_ratios:
|
||||
raise ValueError(
|
||||
f"宽高比 {aspect_ratio} 仅支持 Nano Banana 2 系列模型,"
|
||||
f"当前模型 {model_name} 不支持此宽高比"
|
||||
)
|
||||
|
||||
# Nano Banana 只支持 1K
|
||||
if model_name == "Nano Banana" and resolution not in {"智能", "1K"}:
|
||||
raise ValueError(
|
||||
f"Nano Banana 模型仅支持 1K 分辨率,"
|
||||
f"当前选择的 {resolution} 不支持"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
reference_images = io.Autogrow.Input(
|
||||
"参考图组",
|
||||
template=io.Autogrow.TemplateNames(
|
||||
input=io.Image.Input("参考图"),
|
||||
names=[f"参考图{i}" for i in range(1, MAX_REFERENCE_IMAGES + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip=f"连接后自动增加输入端口,合计最多 {MAX_REFERENCE_IMAGES} 张参考图。",
|
||||
)
|
||||
return io.Schema(
|
||||
node_id="NanoBanana",
|
||||
display_name="Nano Banana",
|
||||
@@ -356,74 +455,97 @@ class NanoBanana(io.ComfyNode):
|
||||
inputs=[
|
||||
io.String.Input(
|
||||
"prompt",
|
||||
default="一个中国女子的OOTD",
|
||||
default="",
|
||||
multiline=True,
|
||||
),
|
||||
io.DynamicCombo.Input("模型", options=[
|
||||
io.DynamicCombo.Option("Nano Banana Pro", [
|
||||
io.Combo.Input("宽高比", options=[
|
||||
"智能", "1:1", "2:3", "3:2", "3:4", "4:3",
|
||||
"4:5", "5:4", "9:16", "16:9", "21:9",
|
||||
], default="智能"),
|
||||
io.Combo.Input("分辨率", options=["1K", "2K", "4K"], default="2K"),
|
||||
]),
|
||||
io.DynamicCombo.Option("Nano Banana 2", [
|
||||
io.Combo.Input("宽高比", options=[
|
||||
"智能", "1:1", "1:4", "1:8", "2:3", "3:2", "3:4",
|
||||
"4:1", "4:3", "4:5", "5:4", "8:1",
|
||||
"9:16", "16:9", "21:9",
|
||||
], default="智能"),
|
||||
io.Combo.Input("分辨率", options=["512px", "1K", "2K", "4K"], default="2K"),
|
||||
io.Combo.Input("思考深度", options=["高", "低"], default="高"),
|
||||
]),
|
||||
io.DynamicCombo.Option("Nano Banana", [
|
||||
io.Combo.Input("宽高比", options=[
|
||||
"智能", "1:1", "2:3", "3:2", "3:4", "4:3",
|
||||
"4:5", "5:4", "9:16", "16:9", "21:9",
|
||||
], default="智能"),
|
||||
io.Combo.Input("分辨率", options=["1K"], default="1K"),
|
||||
]),
|
||||
]),
|
||||
io.Int.Input("生图数量", default=1, min=1, max=1000, step=1),
|
||||
io.Combo.Input("网络", options=NETWORK_ROUTE_OPTIONS, default="全球加速"),
|
||||
io.Combo.Input("计费", options=["特价", "官方"], default="特价"),
|
||||
io.Combo.Input("谷歌搜索", options=["关闭", "打开"], default="关闭"),
|
||||
io.Int.Input("seed", default=0, min=0, max=0xFFFFFFFFFFFFFFFF),
|
||||
io.Image.Input("参考图1", optional=True),
|
||||
io.Image.Input("参考图2", optional=True),
|
||||
io.Image.Input("参考图3", optional=True),
|
||||
io.Image.Input("参考图4", optional=True),
|
||||
io.Image.Input("参考图5", optional=True),
|
||||
io.Image.Input("参考图6", optional=True),
|
||||
io.Image.Input("参考图7", optional=True),
|
||||
io.Image.Input("参考图8", optional=True),
|
||||
io.Image.Input("参考图9", optional=True),
|
||||
io.Combo.Input("模型", options=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, **kwargs) -> io.NodeOutput:
|
||||
def execute(
|
||||
cls,
|
||||
prompt,
|
||||
模型,
|
||||
分辨率,
|
||||
宽高比,
|
||||
生图数量,
|
||||
模型线路="畅速",
|
||||
seed=0,
|
||||
思考等级="高",
|
||||
缩放图片="不缩放",
|
||||
**kwargs,
|
||||
) -> io.NodeOutput:
|
||||
start_time = time.time()
|
||||
was_interrupted = False
|
||||
生图数量 = int(生图数量)
|
||||
|
||||
model_name = 模型["模型"]
|
||||
宽高比 = 模型["宽高比"]
|
||||
分辨率 = 模型["分辨率"]
|
||||
思考深度 = 模型.get("思考深度")
|
||||
model_name = 模型
|
||||
# 兼容旧工作流/外部调用传入的“计费”字段。
|
||||
模型线路 = kwargs.pop("计费", 模型线路)
|
||||
unlimited_downloads = kwargs.pop("_o1key_unlimited_downloads", False) is True
|
||||
requested_google_search = kwargs.pop("_o1key_google_search", False) is True
|
||||
google_search = model_name == "Nano Banana 2" and requested_google_search
|
||||
resize_mode = str(缩放图片)
|
||||
if resize_mode not in {"不缩放", "智能缩放"}:
|
||||
raise ValueError("缩放图片参数无效")
|
||||
# 验证模型和宽高比、分辨率的组合是否合法
|
||||
cls._validate_model_config(model_name, 宽高比, 分辨率)
|
||||
|
||||
enable_grounding = (谷歌搜索 == "打开")
|
||||
if 思考等级 not in THINKING_LEVEL_MAP:
|
||||
raise ValueError("思考等级无效,仅支持:低、高")
|
||||
thinking_level = (
|
||||
THINKING_LEVEL_MAP[思考等级]
|
||||
if model_name == "Nano Banana 2"
|
||||
else None
|
||||
)
|
||||
|
||||
thinking_level = None
|
||||
if model_name == "Nano Banana 2" and 思考深度:
|
||||
thinking_level = "High" if 思考深度 == "高" else "Low"
|
||||
|
||||
actual_model = _build_model_id(model_name, 分辨率, 计费)
|
||||
actual_model = _build_model_id(model_name, 分辨率, 模型线路)
|
||||
|
||||
api_key = get_api_key_or_raise("O1KEY_API_KEY")
|
||||
base_url = get_base_url_by_route(网络)
|
||||
base_url = get_base_url_by_route()
|
||||
|
||||
pbar = ProgressBar(生图数量) if PROGRESS_BAR_AVAILABLE else None
|
||||
|
||||
@@ -431,30 +553,37 @@ class NanoBanana(io.ComfyNode):
|
||||
random.seed(seed)
|
||||
np.random.seed(seed % (2**32))
|
||||
|
||||
input_images = []
|
||||
for i in range(1, 10):
|
||||
key = f"参考图{i}"
|
||||
if key in kwargs and kwargs[key] is not None:
|
||||
pil_imgs = tensor_to_pil(kwargs[key])
|
||||
input_images.extend(pil_imgs)
|
||||
reference_inputs = _collect_autogrow_inputs(kwargs.get("参考图组"))
|
||||
if not reference_inputs:
|
||||
# Keep execution compatibility with workflows created before
|
||||
# the Autogrow input replaced the nine fixed image sockets.
|
||||
reference_inputs = [
|
||||
kwargs[f"参考图{i}"]
|
||||
for i in range(1, 10)
|
||||
if kwargs.get(f"参考图{i}") is not None
|
||||
]
|
||||
|
||||
if len(input_images) > 14:
|
||||
raise ValueError(f"输入图像数量 {len(input_images)} 超过限制 14 张")
|
||||
input_images = []
|
||||
for image_input in reference_inputs:
|
||||
input_images.extend(tensor_to_pil(image_input))
|
||||
|
||||
if len(input_images) > MAX_REFERENCE_IMAGES:
|
||||
raise ValueError(
|
||||
f"输入图像数量 {len(input_images)} 超过限制 {MAX_REFERENCE_IMAGES} 张"
|
||||
)
|
||||
|
||||
batch_prompts = parse_batch_prompts(prompt)
|
||||
|
||||
grounding_str = " | 谷歌搜索接地" if enable_grounding else ""
|
||||
thinking_str = f" | 思考:{thinking_level}" if thinking_level else ""
|
||||
if batch_prompts:
|
||||
num_prompts = len(batch_prompts)
|
||||
total_images = num_prompts * 生图数量
|
||||
mode_str = f"批量提示词模式 ({num_prompts}个提示词)"
|
||||
if input_images:
|
||||
mode_str += f" (输入{len(input_images)}张)"
|
||||
print(f"Nano Banana: {mode_str} | {分辨率} {宽高比} | 共{total_images}张{grounding_str}{thinking_str}")
|
||||
print(f"Nano Banana: {mode_str} | {分辨率} {宽高比} | 共{total_images}张")
|
||||
else:
|
||||
mode_str = f"图生图模式 (输入{len(input_images)}张)" if input_images else "文生图模式"
|
||||
print(f"Nano Banana: {mode_str} | {分辨率} {宽高比} | {生图数量}张{grounding_str}{thinking_str}")
|
||||
print(f"Nano Banana: {mode_str} | {分辨率} {宽高比} | {生图数量}张")
|
||||
|
||||
if batch_prompts or 生图数量 > 1:
|
||||
prompts = batch_prompts if batch_prompts else [prompt]
|
||||
@@ -479,11 +608,14 @@ class NanoBanana(io.ComfyNode):
|
||||
images_per_prompt=images_per_prompt,
|
||||
input_images=input_images,
|
||||
pbar=pbar,
|
||||
enable_grounding=enable_grounding,
|
||||
thinking_level=thinking_level,
|
||||
google_search=google_search,
|
||||
resize_mode=resize_mode,
|
||||
unlimited_downloads=unlimited_downloads,
|
||||
))
|
||||
)
|
||||
finally:
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
@@ -516,8 +648,11 @@ class NanoBanana(io.ComfyNode):
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
async def _do():
|
||||
connector = aiohttp.TCPConnector(ssl=False)
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
async with create_http_client(
|
||||
http2=True,
|
||||
max_connections=_HTTP_MAX_CONNECTIONS,
|
||||
max_keepalive_connections=_HTTP_MAX_KEEPALIVE_CONNECTIONS,
|
||||
) as session:
|
||||
return await _generate_single(
|
||||
session=session,
|
||||
base_url=base_url,
|
||||
@@ -527,25 +662,37 @@ class NanoBanana(io.ComfyNode):
|
||||
resolution=分辨率,
|
||||
aspect_ratio=宽高比,
|
||||
images=input_images if input_images else None,
|
||||
enable_grounding=enable_grounding,
|
||||
thinking_level=thinking_level,
|
||||
google_search=google_search,
|
||||
download_semaphore=(
|
||||
None
|
||||
if unlimited_downloads
|
||||
else asyncio.Semaphore(_MAX_DOWNLOAD_CONCURRENCY)
|
||||
),
|
||||
progress_callback=_make_progress_callback(pbar),
|
||||
resize_mode=resize_mode,
|
||||
)
|
||||
return loop.run_until_complete(_run_with_interrupt(_do()))
|
||||
finally:
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(run_single)
|
||||
generated_images, task_ms, parse_ms = future.result(timeout=_REQUEST_TIMEOUT)
|
||||
generated_images, timing = future.result(timeout=_REQUEST_TIMEOUT)
|
||||
|
||||
output_tensor = _images_to_tensor_safe(generated_images, _NODE)
|
||||
elapsed = time.time() - start_time
|
||||
time_str = f"{elapsed:.3f}s" if elapsed < 1 else f"{elapsed:.2f}s"
|
||||
task_str = f"{task_ms/1000:.2f}s"
|
||||
parse_str = f"{parse_ms/1000:.2f}s"
|
||||
print(f"完成!总耗时 {time_str} | 异步任务 {task_str} | 解析 {parse_str} | 成功 {len(generated_images)}张")
|
||||
|
||||
task_str = f"{timing['task_ms']/1000:.2f}s"
|
||||
download_str = f"{timing['download_ms']/1000:.2f}s"
|
||||
parse_str = f"{max(0, timing['parse_ms'] - timing['download_ms'])/1000:.2f}s"
|
||||
inline_suffix = f" | 内联={timing['inline_images']}张" if timing['inline_images'] else ""
|
||||
print(
|
||||
f"Nano Banana: 完成 ✓ | task_id={timing['task_id']} | "
|
||||
f"生成={len(generated_images)} 张 | 耗时={time_str} "
|
||||
f"(生成 {task_str} | 下载 {download_str} | 解析 {parse_str}{inline_suffix})"
|
||||
)
|
||||
import gc; gc.collect()
|
||||
return io.NodeOutput(output_tensor)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ from typing import Optional, Tuple
|
||||
|
||||
from ..clients.newapi_veo_client import NewAPIVeoClient
|
||||
from ..utils.image_utils import tensor_to_pil
|
||||
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||
from ..utils.config import get_base_url_by_route
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
@@ -138,7 +138,6 @@ class Google31Video:
|
||||
},
|
||||
),
|
||||
"负向提示词": ("STRING", {"default": "", "multiline": True}),
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
|
||||
"模型": (MODEL_OPTIONS, {"default": MODEL_OPTIONS[0]}),
|
||||
"时长": (DURATION_OPTIONS, {"default": "8"}),
|
||||
"宽高比": (ASPECT_RATIO_OPTIONS, {"default": "16:9"}),
|
||||
@@ -173,7 +172,6 @@ class Google31Video:
|
||||
self,
|
||||
提示词: str,
|
||||
负向提示词: str,
|
||||
网络线路: str,
|
||||
模型: str,
|
||||
时长: str,
|
||||
宽高比: str,
|
||||
@@ -181,6 +179,7 @@ class Google31Video:
|
||||
生成音频: str,
|
||||
seed: int,
|
||||
参考图像=None,
|
||||
**_kwargs,
|
||||
):
|
||||
if VideoFromFile is None:
|
||||
raise RuntimeError("当前 ComfyUI 版本不支持原生 VIDEO 输入实现 VideoFromFile。")
|
||||
@@ -217,7 +216,7 @@ class Google31Video:
|
||||
pbar.update(progress - last_progress[0])
|
||||
last_progress[0] = progress
|
||||
|
||||
client = NewAPIVeoClient(base_url=get_base_url_by_route(网络线路))
|
||||
client = NewAPIVeoClient(base_url=get_base_url_by_route())
|
||||
|
||||
result = client.generate_video_sync(
|
||||
prompt=prompt,
|
||||
@@ -230,7 +229,6 @@ class Google31Video:
|
||||
generate_audio=(生成音频 == "打开"),
|
||||
image_bytes=image_bytes,
|
||||
poll_interval=10,
|
||||
timeout=900,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
"""Panel-driven parallel video generation and durable result nodes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import folder_paths
|
||||
from PIL import Image
|
||||
from comfy_api.latest import InputImpl, io, ui
|
||||
|
||||
from ..utils.image_utils import pil_to_tensor
|
||||
from ..utils.o1key_video_catalog import (
|
||||
SEEDANCE_ASSET_CREATION_MODE_OPTIONS,
|
||||
SEEDANCE_MODEL_OPTIONS,
|
||||
SEEDANCE_ROUTE_OPTIONS,
|
||||
VIDEO_ASPECT_RATIO_OPTIONS,
|
||||
VIDEO_DURATION_OPTIONS,
|
||||
VIDEO_GENERATION_MODE_OPTIONS,
|
||||
VIDEO_PROVIDER_OPTIONS,
|
||||
VIDEO_RESOLUTION_OPTIONS,
|
||||
)
|
||||
|
||||
|
||||
def _parse_result_descriptor(value: str | dict[str, Any] | None) -> dict[str, str] | None:
|
||||
if value in (None, "", "{}"):
|
||||
return None
|
||||
try:
|
||||
item = json.loads(value) if isinstance(value, str) else value
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("视频结果描述符不是有效 JSON") from None
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("视频结果描述符必须是对象")
|
||||
filename = os.path.basename(str(item.get("filename") or "").strip())
|
||||
subfolder = str(item.get("subfolder") or "").strip().replace("\\", "/")
|
||||
folder_type = str(item.get("type") or "output").strip()
|
||||
if not filename or folder_type not in {"output", "temp"}:
|
||||
raise ValueError("视频结果描述符无效")
|
||||
if subfolder.startswith("/") or any(part == ".." for part in subfolder.split("/")):
|
||||
raise ValueError("视频结果子目录无效")
|
||||
return {"filename": filename, "subfolder": subfolder, "type": folder_type}
|
||||
|
||||
|
||||
def _resolve_result_path(descriptor: dict[str, str]) -> str:
|
||||
root = (
|
||||
folder_paths.get_output_directory()
|
||||
if descriptor["type"] == "output"
|
||||
else folder_paths.get_temp_directory()
|
||||
)
|
||||
root = os.path.realpath(os.path.abspath(root))
|
||||
candidate = os.path.realpath(
|
||||
os.path.abspath(os.path.join(root, descriptor["subfolder"], descriptor["filename"]))
|
||||
)
|
||||
try:
|
||||
inside = os.path.commonpath((root, candidate)) == root
|
||||
except ValueError:
|
||||
inside = False
|
||||
if not inside or not os.path.isfile(candidate):
|
||||
raise ValueError("视频结果文件不存在或已超出允许目录")
|
||||
return candidate
|
||||
|
||||
|
||||
def _result_values(
|
||||
video_manifest: str | dict[str, Any] | None,
|
||||
last_frame_manifest: str | dict[str, Any] | None,
|
||||
) -> tuple[Any, Any]:
|
||||
video_descriptor = _parse_result_descriptor(video_manifest)
|
||||
if video_descriptor is None:
|
||||
return None, None
|
||||
video_path = _resolve_result_path(video_descriptor)
|
||||
|
||||
last_frame_tensor = None
|
||||
last_frame_descriptor = _parse_result_descriptor(last_frame_manifest)
|
||||
if last_frame_descriptor is not None:
|
||||
last_frame_path = _resolve_result_path(last_frame_descriptor)
|
||||
with Image.open(last_frame_path) as image:
|
||||
image.load()
|
||||
last_frame_tensor = pil_to_tensor([image.convert("RGB")])
|
||||
return InputImpl.VideoFromFile(video_path), last_frame_tensor
|
||||
|
||||
|
||||
class O1keyVideoGenerator(io.ComfyNode):
|
||||
"""A frontend-operated generator; each click creates an independent job."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="O1keyVideoGenerator",
|
||||
display_name="o1key 视频生成",
|
||||
category="o1key/video",
|
||||
description="点击节点内按钮提交独立后台视频任务,并自动连接原生保存节点。",
|
||||
inputs=[
|
||||
io.String.Input("prompt", default="", multiline=True, socketless=True),
|
||||
io.Combo.Input("provider", options=VIDEO_PROVIDER_OPTIONS, default="seedance", socketless=True),
|
||||
io.Combo.Input("model", options=SEEDANCE_MODEL_OPTIONS, default="seedance-2.0", socketless=True),
|
||||
io.Combo.Input("route", options=SEEDANCE_ROUTE_OPTIONS, default="domestic", socketless=True),
|
||||
io.Combo.Input("generation_mode", options=VIDEO_GENERATION_MODE_OPTIONS, default="multimodal", socketless=True),
|
||||
io.Combo.Input("resolution", options=VIDEO_RESOLUTION_OPTIONS, default="720p", socketless=True),
|
||||
io.Combo.Input("aspect_ratio", options=VIDEO_ASPECT_RATIO_OPTIONS, default="auto", socketless=True),
|
||||
io.Combo.Input("duration", options=VIDEO_DURATION_OPTIONS, default="5", socketless=True),
|
||||
io.Boolean.Input("generate_audio", default=False, socketless=True),
|
||||
io.Boolean.Input("return_last_frame", default=False, socketless=True),
|
||||
io.Int.Input("seed", default=0, min=0, max=0xFFFFFFFFFFFFFFFF, socketless=True),
|
||||
io.String.Input("media_manifest", default="{}", multiline=True, socketless=True),
|
||||
io.String.Input("asset_manifest", default="{}", multiline=True, socketless=True),
|
||||
io.String.Input("provider_options", default="{}", multiline=True, socketless=True),
|
||||
io.String.Input("filename_prefix", default="o1key_video", socketless=True),
|
||||
io.String.Input("save_location", default="video", socketless=True),
|
||||
# Append-only: keep every released widgets_values position stable.
|
||||
io.Combo.Input(
|
||||
"asset_creation_mode",
|
||||
options=SEEDANCE_ASSET_CREATION_MODE_OPTIONS,
|
||||
default="auto",
|
||||
socketless=True,
|
||||
),
|
||||
io.String.Input("video_manifest", default="{}", multiline=True, socketless=True),
|
||||
io.String.Input("last_frame_manifest", default="{}", multiline=True, socketless=True),
|
||||
],
|
||||
outputs=[
|
||||
io.Video.Output("VIDEO", display_name="VIDEO"),
|
||||
io.Image.Output("LAST_FRAME", display_name="LAST_FRAME"),
|
||||
],
|
||||
not_idempotent=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(
|
||||
cls,
|
||||
video_manifest: str = "{}",
|
||||
last_frame_manifest: str = "{}",
|
||||
**_kwargs,
|
||||
) -> io.NodeOutput:
|
||||
# Generation remains owned by /o1key/video/jobs. Native execution only
|
||||
# resolves the latest completed local descriptors and never spends again.
|
||||
video, last_frame = _result_values(video_manifest, last_frame_manifest)
|
||||
if video is None:
|
||||
return io.NodeOutput(block_execution="请先在 o1key 视频生成节点中完成一次生成")
|
||||
return io.NodeOutput(video, last_frame)
|
||||
|
||||
|
||||
class O1keyVideoResult(io.ComfyNode):
|
||||
"""A completed background result that can later feed native VIDEO workflows."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="O1keyVideoResult",
|
||||
display_name="o1key 视频结果",
|
||||
category="o1key/video",
|
||||
description="显示独立后台任务状态;完成后可向下游输出原生 VIDEO。",
|
||||
is_deprecated=True,
|
||||
inputs=[
|
||||
io.String.Input("batch_id", default="", socketless=True),
|
||||
io.String.Input("video_manifest", default="{}", multiline=True, socketless=True),
|
||||
io.String.Input("last_frame_manifest", default="{}", multiline=True, socketless=True),
|
||||
],
|
||||
outputs=[
|
||||
io.Video.Output("VIDEO", display_name="VIDEO"),
|
||||
io.Image.Output("LAST_FRAME", display_name="LAST_FRAME"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(
|
||||
cls,
|
||||
batch_id: str = "",
|
||||
video_manifest: str = "{}",
|
||||
last_frame_manifest: str = "{}",
|
||||
) -> io.NodeOutput:
|
||||
del batch_id
|
||||
video_descriptor = _parse_result_descriptor(video_manifest)
|
||||
if video_descriptor is None:
|
||||
raise ValueError("视频任务尚未完成,没有可输出的视频")
|
||||
video, last_frame_tensor = _result_values(video_manifest, last_frame_manifest)
|
||||
|
||||
preview = ui.PreviewVideo([video_descriptor])
|
||||
return io.NodeOutput(
|
||||
video,
|
||||
last_frame_tensor,
|
||||
ui=preview,
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"O1keyVideoGenerator": O1keyVideoGenerator,
|
||||
"O1keyVideoResult": O1keyVideoResult,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"O1keyVideoGenerator": "o1key 视频生成",
|
||||
"O1keyVideoResult": "o1key 视频结果",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["O1keyVideoGenerator", "O1keyVideoResult"]
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Omni Flash video generation through a normal ComfyUI execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io as py_io
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import folder_paths
|
||||
from comfy_api.latest import InputImpl, io
|
||||
|
||||
from ..clients.omni_flash_client import OmniFlashClient, build_video_body
|
||||
from ..utils.config import get_api_key_or_raise, get_base_url_by_route
|
||||
from ..utils.image_utils import tensor_to_pil
|
||||
from ..utils.r2_uploader import upload_image, upload_video
|
||||
from ..utils.video_task import check_interrupt
|
||||
|
||||
|
||||
_MODES = {
|
||||
"文生视频": "text",
|
||||
"参考图视频": "reference",
|
||||
"首尾帧": "first_last_frame",
|
||||
"视频编辑": "edit",
|
||||
}
|
||||
_MAX_EDIT_VIDEO_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
def _make_progress_callback():
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
bar = ProgressBar(100)
|
||||
except ImportError:
|
||||
bar = None
|
||||
last_progress = -1
|
||||
|
||||
def update(stage: str, value: int, _task_id: str) -> None:
|
||||
nonlocal last_progress
|
||||
# The provider percentage maps directly to the node bar. Reserve the
|
||||
# final point for the local video download and save.
|
||||
current = max(0, min(100, int(value)))
|
||||
if stage != "done":
|
||||
current = min(current, 99)
|
||||
if current <= last_progress:
|
||||
return
|
||||
last_progress = current
|
||||
if bar is not None:
|
||||
bar.update_absolute(current, 100)
|
||||
|
||||
return update
|
||||
|
||||
|
||||
class O1keyOmniFlashVideo(io.ComfyNode):
|
||||
"""One graph node owns validation, submission, polling, and VIDEO output."""
|
||||
|
||||
@classmethod
|
||||
def fingerprint_inputs(cls, **kwargs):
|
||||
"""Do not reuse a completed generation when this node is queued again."""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="O1keyOmniFlashVideo",
|
||||
display_name="Omni Flash 视频生成",
|
||||
description="连接图片或视频后生成;开始生成按钮运行当前节点。",
|
||||
category="comfyui_o1key/视频",
|
||||
is_output_node=True,
|
||||
not_idempotent=True,
|
||||
inputs=[
|
||||
io.String.Input("提示词", multiline=True, default=""),
|
||||
io.Combo.Input("生成模式", options=list(_MODES), default="文生视频"),
|
||||
io.Combo.Input("分辨率", options=["720p", "1080p"], default="720p"),
|
||||
io.Combo.Input("宽高比", options=["16:9", "9:16"], default="16:9"),
|
||||
io.Autogrow.Input(
|
||||
"参考图片",
|
||||
optional=True,
|
||||
template=io.Autogrow.TemplateNames(
|
||||
input=io.Image.Input("参考图片"),
|
||||
names=[f"参考图片{i}" for i in range(1, 6)],
|
||||
min=0,
|
||||
),
|
||||
),
|
||||
io.Image.Input("首帧图片", optional=True),
|
||||
io.Image.Input("尾帧图片", optional=True, tooltip="可不连接;只连接首帧也能生成。"),
|
||||
io.Video.Input("源视频", optional=True),
|
||||
],
|
||||
outputs=[io.Video.Output("VIDEO", display_name="视频")],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _reference_images(kwargs):
|
||||
group = kwargs.get("参考图片")
|
||||
if isinstance(group, dict):
|
||||
return [value for value in group.values() if value is not None]
|
||||
return [kwargs[f"参考图片{index}"] for index in range(1, 6)
|
||||
if kwargs.get(f"参考图片{index}") is not None]
|
||||
|
||||
@staticmethod
|
||||
def _one_image(value, label):
|
||||
images = tensor_to_pil(value)
|
||||
if len(images) != 1:
|
||||
raise ValueError(f"{label}必须恰好包含 1 张图片")
|
||||
return images[0].convert("RGB")
|
||||
|
||||
@staticmethod
|
||||
def _check_source_video(video):
|
||||
source = video.get_stream_source() if hasattr(video, "get_stream_source") else None
|
||||
if isinstance(source, py_io.BytesIO):
|
||||
size = source.getbuffer().nbytes
|
||||
elif isinstance(source, str) and os.path.isfile(source):
|
||||
size = os.path.getsize(source)
|
||||
if Path(source).suffix.lower() not in {".mp4", ".mov"}:
|
||||
raise ValueError("源视频须为 MP4 或 MOV")
|
||||
else:
|
||||
raise ValueError("无法读取源视频文件")
|
||||
if size > _MAX_EDIT_VIDEO_BYTES:
|
||||
raise ValueError("源视频不能超过 20 MB")
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, **kwargs):
|
||||
mode = _MODES.get(kwargs.get("生成模式"))
|
||||
if mode is None:
|
||||
raise ValueError("生成模式无效")
|
||||
images = cls._reference_images(kwargs)
|
||||
first = kwargs.get("首帧图片")
|
||||
last = kwargs.get("尾帧图片")
|
||||
source = kwargs.get("源视频")
|
||||
if mode == "text" and (images or first is not None or last is not None or source is not None):
|
||||
raise ValueError("文生视频模式不接受媒体输入")
|
||||
if mode == "reference" and (not images or first is not None or last is not None or source is not None):
|
||||
raise ValueError("参考图视频模式只接受参考图片")
|
||||
if mode == "first_last_frame" and (images or first is None or source is not None):
|
||||
raise ValueError("首尾帧模式须连接首帧图片;尾帧图片可选")
|
||||
if mode == "edit" and (source is None or first is not None or last is not None):
|
||||
raise ValueError("视频编辑模式须连接源视频,不能连接首尾帧")
|
||||
if mode == "edit" and len(images) > 5:
|
||||
raise ValueError("视频编辑最多支持 5 张参考图")
|
||||
|
||||
model = "omni_flash_abra_edit" if mode == "edit" else "omni_flash_10s"
|
||||
prompt = kwargs["提示词"]
|
||||
resolution = kwargs["分辨率"]
|
||||
ratio = kwargs["宽高比"]
|
||||
# Validate the scalar contract before any upload or paid request.
|
||||
upload_images = ([first] + ([last] if last is not None else [])) if mode == "first_last_frame" else images
|
||||
build_video_body(model=model, prompt=prompt, resolution=resolution,
|
||||
aspect_ratio=ratio, mode=mode,
|
||||
references=["https://example.invalid/media"] * len(upload_images),
|
||||
source_video_url="https://example.invalid/video" if mode == "edit" else "")
|
||||
pil_images = [cls._one_image(value, "输入") for value in upload_images]
|
||||
if source is not None:
|
||||
cls._check_source_video(source)
|
||||
|
||||
api_key = get_api_key_or_raise("O1KEY_API_KEY")
|
||||
base_url = get_base_url_by_route()
|
||||
urls = []
|
||||
for image in pil_images:
|
||||
check_interrupt()
|
||||
urls.append(await upload_image(image, base_url=base_url))
|
||||
source_url = await upload_video(source, base_url=base_url) if source is not None else ""
|
||||
body = build_video_body(model=model, prompt=prompt, resolution=resolution,
|
||||
aspect_ratio=ratio, mode=mode, references=urls,
|
||||
source_video_url=source_url)
|
||||
|
||||
output_dir = Path(folder_paths.get_output_directory()) / "omni_flash"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex}.mp4"
|
||||
target = output_dir / filename
|
||||
partial = output_dir / f"{filename}.part"
|
||||
report_progress = _make_progress_callback()
|
||||
report_progress("polling", 0, "")
|
||||
try:
|
||||
await OmniFlashClient(base_url=base_url, api_key=api_key).generate(
|
||||
body, str(partial), progress=report_progress,
|
||||
)
|
||||
check_interrupt()
|
||||
os.replace(partial, target)
|
||||
report_progress("done", 100, "")
|
||||
except BaseException:
|
||||
partial.unlink(missing_ok=True)
|
||||
raise
|
||||
return io.NodeOutput(InputImpl.VideoFromFile(str(target)))
|
||||
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
提示词(多功能)节点
|
||||
支持以「第一套---第二套---第三套」格式填入多套提示词,并选择处理方式:
|
||||
- 全部使用:保留 --- 分隔符输出全部套数,交给下游批量节点并发跑
|
||||
- 随机抽取n套:按指定数量不重复抽取,按原始顺序输出;数量为 1 时只抽 1 套
|
||||
- 指定序号:按填写顺序输出一套或多套提示词
|
||||
|
||||
下游需连接支持批量提示词(按单独行 --- 分割并发执行)的节点,
|
||||
如「Nano Banana 批量跑图」等。
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
from ..utils.image_utils import parse_batch_prompts
|
||||
|
||||
|
||||
_MODE_ALL = "全部使用"
|
||||
_MODE_RANDOM = "随机抽取n套"
|
||||
_MODE_SELECTED = "指定序号"
|
||||
_LEGACY_MODE_RANDOM_ONE = "随机抽取1套"
|
||||
_LEGACY_MODE_RANDOM_MANY = "随机抽取多套"
|
||||
_RANDOM_MODES = {
|
||||
_MODE_RANDOM,
|
||||
_LEGACY_MODE_RANDOM_ONE,
|
||||
_LEGACY_MODE_RANDOM_MANY,
|
||||
}
|
||||
_MODES = [_MODE_ALL, _MODE_RANDOM, _MODE_SELECTED]
|
||||
_DEFAULT_SAMPLE_COUNT = 3
|
||||
_DEFAULT_SELECTED_INDICES = "1,2,3"
|
||||
_INDEX_SEPARATOR_RE = re.compile(r"[\s,,;;]+")
|
||||
_INDEX_RANGE_RE = re.compile(r"^(\d+)[-~~—–](\d+)$")
|
||||
|
||||
|
||||
def _split_prompt_sets(text: str) -> List[str]:
|
||||
"""按单独行 --- 切分多套提示词;无分隔符时整段视为 1 套。"""
|
||||
stripped = (text or "").strip()
|
||||
if not stripped:
|
||||
return []
|
||||
sets = parse_batch_prompts(text)
|
||||
if not sets:
|
||||
return [stripped]
|
||||
return sets
|
||||
|
||||
|
||||
def _parse_prompt_indices(value: str, total: int) -> List[int]:
|
||||
"""解析从 1 开始的序号列表,拒绝重复并保留填写顺序。"""
|
||||
raw_value = str(value or "").strip()
|
||||
if not raw_value:
|
||||
raise ValueError("提示词(多功能):指定序号为空,请填写如 1,3,5。")
|
||||
|
||||
tokens = [token for token in _INDEX_SEPARATOR_RE.split(raw_value) if token]
|
||||
selected: List[int] = []
|
||||
seen = set()
|
||||
|
||||
def append_index(index: int) -> None:
|
||||
if index < 1 or index > total:
|
||||
raise ValueError(
|
||||
f"提示词(多功能):序号 {index} 超出范围,当前共有 {total} 套提示词。"
|
||||
)
|
||||
if index in seen:
|
||||
raise ValueError(f"提示词(多功能):序号 {index} 重复,请勿重复填写。")
|
||||
seen.add(index)
|
||||
selected.append(index)
|
||||
|
||||
for token in tokens:
|
||||
if token.isdigit():
|
||||
append_index(int(token))
|
||||
continue
|
||||
|
||||
match = _INDEX_RANGE_RE.fullmatch(token)
|
||||
if match:
|
||||
start, end = (int(part) for part in match.groups())
|
||||
if end < start:
|
||||
raise ValueError(
|
||||
f"提示词(多功能):区间 {token} 必须从小到大填写。"
|
||||
)
|
||||
if start < 1 or start > total:
|
||||
append_index(start)
|
||||
if end < 1 or end > total:
|
||||
append_index(end)
|
||||
for index in range(start, end + 1):
|
||||
append_index(index)
|
||||
continue
|
||||
|
||||
raise ValueError(
|
||||
f"提示词(多功能):无法识别序号“{token}”,请填写如 1,3,5 或 2-4。"
|
||||
)
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
class O1keyPromptMultiFunction:
|
||||
"""多功能提示词节点:全部使用、随机抽取或按序号选择。"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"提示词": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "第一套提示词\n---\n第二套提示词\n---\n第三套提示词",
|
||||
"placeholder": "多套提示词请用单独一行的 --- 分隔",
|
||||
}),
|
||||
"功能": (_MODES, {"default": _MODE_ALL}),
|
||||
"抽取数量": ("INT", {
|
||||
"default": _DEFAULT_SAMPLE_COUNT,
|
||||
"min": 1,
|
||||
"max": 1000,
|
||||
"step": 1,
|
||||
"tooltip": "仅“随机抽取n套”生效;从全部提示词中不重复抽取。",
|
||||
}),
|
||||
"指定序号": ("STRING", {
|
||||
"default": _DEFAULT_SELECTED_INDICES,
|
||||
"placeholder": "例如:1,3,5 或 2-4",
|
||||
"tooltip": "仅“指定序号”生效;序号从 1 开始,按填写顺序输出。",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("提示词",)
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "o1key/prompt"
|
||||
DESCRIPTION = (
|
||||
"多套提示词用单独一行的 --- 分隔。\n"
|
||||
"全部使用:保留 --- 输出全部,交给下游批量节点并发跑每一套。\n"
|
||||
"随机抽取n套:按“抽取数量”不重复随机选择,并按原始顺序输出。\n"
|
||||
"指定序号:支持 1,3,5、中文逗号、空格和 2-4 区间,按填写顺序输出。"
|
||||
)
|
||||
|
||||
def process(
|
||||
self,
|
||||
提示词: str,
|
||||
功能: str = _MODE_ALL,
|
||||
抽取数量: int = _DEFAULT_SAMPLE_COUNT,
|
||||
指定序号: str = _DEFAULT_SELECTED_INDICES,
|
||||
):
|
||||
sets = _split_prompt_sets(提示词)
|
||||
if not sets:
|
||||
raise ValueError("提示词(多功能):提示词为空,请至少填写 1 套。")
|
||||
|
||||
# 旧 API 工作流可能绕过前端迁移直接提交原模式值,继续保持只抽 1 套。
|
||||
if 功能 == _LEGACY_MODE_RANDOM_ONE:
|
||||
chosen = random.choice(sets)
|
||||
print(f"[o1key 提示词多功能] 随机抽取 1/{len(sets)} 套")
|
||||
return (chosen,)
|
||||
|
||||
if 功能 in {_MODE_RANDOM, _LEGACY_MODE_RANDOM_MANY}:
|
||||
try:
|
||||
sample_count = int(抽取数量)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("提示词(多功能):抽取数量必须是整数。") from exc
|
||||
if sample_count < 1:
|
||||
raise ValueError("提示词(多功能):抽取数量必须至少为 1。")
|
||||
if sample_count > len(sets):
|
||||
raise ValueError(
|
||||
f"提示词(多功能):抽取数量 {sample_count} 超过当前提示词总数 {len(sets)}。"
|
||||
)
|
||||
chosen_indices = sorted(random.sample(range(len(sets)), sample_count))
|
||||
chosen = [sets[index] for index in chosen_indices]
|
||||
display_indices = ",".join(str(index + 1) for index in chosen_indices)
|
||||
print(
|
||||
f"[o1key 提示词多功能] 随机抽取 {sample_count}/{len(sets)} 套,"
|
||||
f"序号:{display_indices}"
|
||||
)
|
||||
return ("\n---\n".join(chosen),)
|
||||
|
||||
if 功能 == _MODE_SELECTED:
|
||||
selected_indices = _parse_prompt_indices(指定序号, len(sets))
|
||||
chosen = [sets[index - 1] for index in selected_indices]
|
||||
display_indices = ",".join(str(index) for index in selected_indices)
|
||||
print(
|
||||
f"[o1key 提示词多功能] 指定使用 {len(chosen)}/{len(sets)} 套,"
|
||||
f"序号:{display_indices}"
|
||||
)
|
||||
return ("\n---\n".join(chosen),)
|
||||
|
||||
# 全部使用:保留单独行 --- 分隔符,下游批量节点可并发分割执行
|
||||
joined = "\n---\n".join(sets)
|
||||
print(f"[o1key 提示词多功能] 全部使用,共 {len(sets)} 套")
|
||||
return (joined,)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
提示词,
|
||||
功能=_MODE_ALL,
|
||||
抽取数量=_DEFAULT_SAMPLE_COUNT,
|
||||
指定序号=_DEFAULT_SELECTED_INDICES,
|
||||
):
|
||||
# 随机模式每次都重新抽取
|
||||
if 功能 in _RANDOM_MODES:
|
||||
return float("nan")
|
||||
if 功能 == _MODE_SELECTED:
|
||||
return f"{功能}|{指定序号}|{提示词}"
|
||||
return f"{功能}|{提示词}"
|
||||
@@ -27,8 +27,15 @@ class SaveImageFormat:
|
||||
"图像": ("IMAGE",),
|
||||
"文件名前缀": ("STRING", {"default": "ComfyUI"}),
|
||||
"输出格式": (cls.FORMATS, {"default": "PNG"}),
|
||||
"质量": ("INT", {
|
||||
"default": 100, "min": 1, "max": 100, "step": 1,
|
||||
"tooltip": "图片质量。100=不压缩(JPEG 最高质量 / WebP 无损);"
|
||||
"小于 100 时按该数值压缩(如 90),仅对 JPEG / WebP 生效。",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"保存路径": ("STRING", {"default": ""}),
|
||||
},
|
||||
"optional": {},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
@@ -43,20 +50,51 @@ class SaveImageFormat:
|
||||
|
||||
_EXT_MAP = {"PNG": ".png", "JPEG": ".jpg", "WebP": ".webp"}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, 图像=None, **kwargs):
|
||||
"""保存节点属于有副作用的输出节点,不能复用上次的缓存结果。"""
|
||||
return float("nan")
|
||||
|
||||
def save_images(self, 图像=None, 文件名前缀="ComfyUI", 输出格式="PNG",
|
||||
prompt=None, extra_pnginfo=None):
|
||||
质量=100, 保存路径="", prompt=None, extra_pnginfo=None):
|
||||
images = 图像
|
||||
filename_prefix = 文件名前缀
|
||||
format = 输出格式
|
||||
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]
|
||||
)
|
||||
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))
|
||||
@@ -80,9 +118,15 @@ class SaveImageFormat:
|
||||
elif format == "JPEG":
|
||||
if img.mode == "RGBA":
|
||||
img = img.convert("RGB")
|
||||
img.save(filepath, quality=100, optimize=True)
|
||||
if quality >= 100:
|
||||
img.save(filepath, quality=100, optimize=True)
|
||||
else:
|
||||
img.save(filepath, quality=quality, optimize=True)
|
||||
elif format == "WebP":
|
||||
img.save(filepath, lossless=True)
|
||||
if quality >= 100:
|
||||
img.save(filepath, lossless=True)
|
||||
else:
|
||||
img.save(filepath, quality=quality, method=6)
|
||||
|
||||
results.append({
|
||||
"filename": file,
|
||||
|
||||
@@ -0,0 +1,881 @@
|
||||
"""
|
||||
Seedance 2.0 / 2.5 自动过审节点(xinhankr/可美线路)
|
||||
与 Seedance / SeedanceMultiModal 的差异:
|
||||
- 模型名用 seedance-2.0 / seedance-2.0-fast / seedance-2.0-mini(fast、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 全能生成视频",
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
"""Seedance 全能生成视频(批量)。
|
||||
|
||||
注册节点使用文件下方的 V3 实现:功能参数与单节点一致,媒体端口替换为
|
||||
图片、视频和音频文件夹路径,并保留分批并发与输出目录控制。
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
from comfy_api.latest import io
|
||||
|
||||
from ..utils.config import (
|
||||
get_api_key_or_raise,
|
||||
get_base_url_by_route,
|
||||
)
|
||||
from ..utils.r2_uploader import upload_image, upload_video
|
||||
from ..utils.file_utils import load_images_from_folder
|
||||
from ..utils.image_utils import parse_batch_prompts
|
||||
from ..utils.video_task import (
|
||||
PollDeadline,
|
||||
check_interrupt,
|
||||
download_video_to_file,
|
||||
interruptible_sleep,
|
||||
run_with_interrupt,
|
||||
InterruptProcessingException,
|
||||
)
|
||||
from ..utils.http_error import async_request_with_retry
|
||||
from .seedance_autopass import (
|
||||
SeedanceAutoPass,
|
||||
_BASE_MODELS,
|
||||
_SUCCESS_STATUSES,
|
||||
_FAILURE_STATUSES,
|
||||
_RATIOS,
|
||||
_DURATIONS,
|
||||
_RESOLUTIONS,
|
||||
_MODEL_ROUTES,
|
||||
_GENERATION_MODES,
|
||||
_MODE_MULTIMODAL,
|
||||
_MODE_FIRST_FRAME,
|
||||
_MODE_FIRST_LAST,
|
||||
_MODE_TEXT,
|
||||
_FAST_RESOLUTIONS,
|
||||
_LIMITED_RESOLUTION_MODELS,
|
||||
_normalize_model_route,
|
||||
_resolve_model_matrix,
|
||||
_resolve_asset_creation_mode,
|
||||
)
|
||||
from .seedance_video import (
|
||||
_MM_MODELS,
|
||||
_MM_RESOLUTIONS,
|
||||
_resolve_model,
|
||||
_is_new_format_model,
|
||||
_check_fast_resolution,
|
||||
)
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
FOLDER_PATHS_AVAILABLE = True
|
||||
except ImportError:
|
||||
FOLDER_PATHS_AVAILABLE = False
|
||||
print("⚠️ SeedanceAutoPassBatch: folder_paths 不可用,将无法定位 output 目录")
|
||||
|
||||
|
||||
_LABEL = "Seedance全能生成视频(批量)"
|
||||
_MAX_BATCH = 10 # 每批最多并发提交数(用户要求硬上限 10)
|
||||
_VIDEO_EXTENSIONS = {".mp4", ".mov"}
|
||||
_AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg"}
|
||||
_MM_DEFAULT_MODEL = _MM_MODELS[0]
|
||||
|
||||
|
||||
def load_video_paths_from_folder(folder_path: str):
|
||||
"""从文件夹按文件名升序收集 mp4/mov 视频路径。"""
|
||||
folder_path = (folder_path or "").strip()
|
||||
if not folder_path:
|
||||
return []
|
||||
path = Path(folder_path)
|
||||
if not path.exists():
|
||||
raise ValueError(f"视频文件夹不存在: {folder_path}")
|
||||
if not path.is_dir():
|
||||
raise ValueError(f"视频路径不是文件夹: {folder_path}")
|
||||
files = [
|
||||
f for f in path.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in _VIDEO_EXTENSIONS
|
||||
]
|
||||
files.sort(key=lambda x: x.name.lower())
|
||||
return [str(f) for f in files]
|
||||
|
||||
|
||||
def load_audio_paths_from_folder(folder_path: str):
|
||||
"""从文件夹按文件名升序收集常见音频文件路径。"""
|
||||
folder_path = (folder_path or "").strip()
|
||||
if not folder_path:
|
||||
return []
|
||||
path = Path(folder_path)
|
||||
if not path.exists():
|
||||
raise ValueError(f"音频文件夹不存在: {folder_path}")
|
||||
if not path.is_dir():
|
||||
raise ValueError(f"音频路径不是文件夹: {folder_path}")
|
||||
files = [
|
||||
f for f in path.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in _AUDIO_EXTENSIONS
|
||||
]
|
||||
files.sort(key=lambda x: x.name.lower())
|
||||
return [str(f) for f in files]
|
||||
|
||||
|
||||
def _unique_output_path(out_dir: str, stem: str, ext: str = ".mp4") -> str:
|
||||
"""在 out_dir 下生成不覆盖已有文件的目标路径。"""
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
candidate = os.path.join(out_dir, f"{stem}{ext}")
|
||||
if not os.path.exists(candidate):
|
||||
return candidate
|
||||
counter = 1
|
||||
while True:
|
||||
candidate = os.path.join(out_dir, f"{stem}_{counter}{ext}")
|
||||
if not os.path.exists(candidate):
|
||||
return candidate
|
||||
counter += 1
|
||||
|
||||
|
||||
def _build_mm_body(model_id, prompt, resolution, ratio, duration_s,
|
||||
gen_audio, web_search, seed,
|
||||
ref_url, kind):
|
||||
"""
|
||||
按 SeedanceMultiModal 的规则构建请求体。
|
||||
kind: "image" | "video"
|
||||
"""
|
||||
use_new_format = _is_new_format_model(model_id)
|
||||
|
||||
# 构建 content 列表
|
||||
content = []
|
||||
if kind == "image":
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": ref_url},
|
||||
"role": "reference_image",
|
||||
})
|
||||
else:
|
||||
content.append({
|
||||
"type": "video_url",
|
||||
"video_url": {"url": ref_url},
|
||||
"role": "reference_video",
|
||||
})
|
||||
if prompt:
|
||||
content.append({"type": "text", "text": prompt})
|
||||
|
||||
duration = int(duration_s.replace("秒", "")) if duration_s != "自动" else -1
|
||||
|
||||
if use_new_format:
|
||||
# 新格式:顶层 content,文本放最前面
|
||||
ordered = [item for item in content if item.get("type") == "text"]
|
||||
ordered += [item for item in content if item.get("type") != "text"]
|
||||
body = {
|
||||
"model": model_id,
|
||||
"content": ordered,
|
||||
"duration": duration if duration != -1 else 5,
|
||||
"resolution": resolution,
|
||||
"ratio": ratio if ratio not in ("智能",) else "16:9",
|
||||
"generate_audio": gen_audio,
|
||||
"watermark": False,
|
||||
"return_last_frame": False,
|
||||
}
|
||||
if seed != 0:
|
||||
body["seed"] = seed
|
||||
else:
|
||||
# 旧格式:metadata.content
|
||||
metadata: dict = {
|
||||
"resolution": resolution,
|
||||
"watermark": False,
|
||||
"content": content,
|
||||
}
|
||||
if ratio != "智能":
|
||||
metadata["ratio"] = ratio
|
||||
if duration != -1:
|
||||
metadata["duration"] = duration
|
||||
if gen_audio:
|
||||
metadata["generate_audio"] = True
|
||||
if web_search:
|
||||
metadata["tools"] = [{"type": "web_search"}]
|
||||
if seed != 0:
|
||||
metadata["seed"] = seed
|
||||
|
||||
body = {
|
||||
"model": model_id,
|
||||
"prompt": prompt if prompt else " ",
|
||||
"metadata": metadata,
|
||||
}
|
||||
if kind == "image":
|
||||
body["image"] = ref_url
|
||||
|
||||
return body
|
||||
|
||||
|
||||
class _LegacySeedanceAutoPassBatch:
|
||||
"""Seedance 2.0 自动过审 · 批量(文件夹 → 并发生成 → 落地 output)"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"图片文件夹": ("STRING", {"default": "", "multiline": False}),
|
||||
"视频文件夹": ("STRING", {"default": "", "multiline": False}),
|
||||
"模型": (_MM_MODELS, {"default": _MM_DEFAULT_MODEL}),
|
||||
"分辨率": (_MM_RESOLUTIONS, {"default": "720p"}),
|
||||
"宽高比": (_RATIOS, {"default": "智能"}),
|
||||
"时长": (_DURATIONS, {"default": "5秒"}),
|
||||
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
||||
"联网搜索": (["关闭", "打开"], {"default": "关闭"}),
|
||||
"每批并发数": ("INT", {"default": _MAX_BATCH, "min": 1, "max": _MAX_BATCH}),
|
||||
"输出子目录": ("STRING", {"default": "", "multiline": False}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("结果汇总",)
|
||||
FUNCTION = "generate"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "comfyui_o1key/Seedance"
|
||||
|
||||
async def generate(self, **kwargs):
|
||||
prompt = (kwargs["提示词"] or "").strip()
|
||||
image_dir = (kwargs.get("图片文件夹") or "").strip()
|
||||
video_dir = (kwargs.get("视频文件夹") or "").strip()
|
||||
model_label = kwargs["模型"]
|
||||
resolution = kwargs["分辨率"]
|
||||
ratio = kwargs["宽高比"]
|
||||
duration_s = kwargs["时长"]
|
||||
gen_audio = kwargs["生成音频"] == "打开"
|
||||
web_search = kwargs["联网搜索"] == "打开"
|
||||
batch_size = max(1, min(int(kwargs.get("每批并发数", _MAX_BATCH)), _MAX_BATCH))
|
||||
sub_dir = (kwargs.get("输出子目录") or "").strip()
|
||||
seed = int(kwargs.get("seed", 0))
|
||||
|
||||
# 解析真实模型 ID 并做分辨率校验
|
||||
model_id = _resolve_model(model_label)
|
||||
_check_fast_resolution(model_id, resolution)
|
||||
|
||||
if not prompt:
|
||||
raise ValueError("提示词不能为空")
|
||||
if not image_dir and not video_dir:
|
||||
raise ValueError("请至少填写「图片文件夹」或「视频文件夹」其中一个路径")
|
||||
|
||||
# ── 收集任务清单(每个文件一个任务)─────────────────────────────
|
||||
tasks_meta = [] # [(kind, source, stem)]
|
||||
if image_dir:
|
||||
images = load_images_from_folder(image_dir)
|
||||
if not images:
|
||||
print(f"[{_LABEL}] 图片文件夹无可用图片: {image_dir}")
|
||||
for info in images:
|
||||
pil = info.image
|
||||
if pil.mode == "RGBA":
|
||||
pil = pil.convert("RGB")
|
||||
tasks_meta.append(("image", pil, info.filename))
|
||||
if video_dir:
|
||||
videos = load_video_paths_from_folder(video_dir)
|
||||
if not videos:
|
||||
print(f"[{_LABEL}] 视频文件夹无可用视频(mp4/mov): {video_dir}")
|
||||
for vpath in videos:
|
||||
stem = os.path.splitext(os.path.basename(vpath))[0]
|
||||
tasks_meta.append(("video", vpath, stem))
|
||||
|
||||
if not tasks_meta:
|
||||
raise ValueError("两个文件夹中都没有可用素材,无法生成")
|
||||
|
||||
# ── 输出目录 ──────────────────────────────────────────────────
|
||||
if not FOLDER_PATHS_AVAILABLE:
|
||||
raise RuntimeError("folder_paths 不可用,无法定位 ComfyUI output 目录")
|
||||
out_dir = os.path.abspath(folder_paths.get_output_directory())
|
||||
if sub_dir:
|
||||
out_dir = os.path.join(out_dir, sub_dir)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
base_url = get_base_url_by_route()
|
||||
api_key = get_api_key_or_raise()
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
|
||||
total = len(tasks_meta)
|
||||
num_batches = (total + batch_size - 1) // batch_size
|
||||
print(f"[{_LABEL}] 共 {total} 个任务,按每批 {batch_size} 个并发,分 {num_batches} 批提交")
|
||||
|
||||
results = []
|
||||
connector = aiohttp.TCPConnector(ssl=False, limit=0, limit_per_host=0)
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
for batch_idx in range(num_batches):
|
||||
check_interrupt()
|
||||
start = batch_idx * batch_size
|
||||
batch = tasks_meta[start:start + batch_size]
|
||||
print(f"[{_LABEL}] 执行第 {batch_idx + 1}/{num_batches} 批 "
|
||||
f"({start + 1}-{start + len(batch)})...")
|
||||
|
||||
coros = [
|
||||
self._run_one(
|
||||
session, base_url, headers, out_dir,
|
||||
model_id, prompt, resolution, ratio, duration_s,
|
||||
gen_audio, web_search, seed,
|
||||
kind, source, stem, start + i + 1, total,
|
||||
)
|
||||
for i, (kind, source, stem) in enumerate(batch)
|
||||
]
|
||||
# return_exceptions=True:单个任务异常不影响同批其它任务
|
||||
batch_results = await asyncio.gather(*coros, return_exceptions=True)
|
||||
|
||||
for r in batch_results:
|
||||
if isinstance(r, InterruptProcessingException):
|
||||
raise r # 用户主动取消,立即中止整批流程
|
||||
if isinstance(r, Exception):
|
||||
results.append({"success": False, "error": str(r), "source": "?"})
|
||||
else:
|
||||
results.append(r)
|
||||
|
||||
# ── 汇总 ──────────────────────────────────────────────────────
|
||||
success = [r for r in results if r.get("success")]
|
||||
failed = [r for r in results if not r.get("success")]
|
||||
lines = [
|
||||
f"任务总数: {total}",
|
||||
f"成功: {len(success)}",
|
||||
f"失败: {len(failed)}",
|
||||
f"输出目录: {out_dir}",
|
||||
]
|
||||
if success:
|
||||
lines.append("")
|
||||
lines.append("成功文件:")
|
||||
lines.extend(f" ✓ {os.path.basename(r['path'])}" for r in success)
|
||||
if failed:
|
||||
lines.append("")
|
||||
lines.append("失败项:")
|
||||
lines.extend(f" ✗ {os.path.basename(str(r.get('source', '?')))} - {r.get('error')}"
|
||||
for r in failed)
|
||||
summary = "\n".join(lines)
|
||||
print(f"[{_LABEL}] 全部完成 — 成功 {len(success)} / 失败 {len(failed)}")
|
||||
return (summary,)
|
||||
|
||||
async def _run_one(
|
||||
self, session, base_url, headers, out_dir,
|
||||
model_id, prompt, resolution, ratio, duration_s,
|
||||
gen_audio, web_search, seed,
|
||||
kind, source, stem, task_no, total,
|
||||
) -> dict:
|
||||
"""提交 → 轮询 → 下载单个任务;异常收敛为 result dict(中断异常除外)。"""
|
||||
try:
|
||||
# 1) 参考素材 → 公开 URL
|
||||
if kind == "image":
|
||||
ref_url = await upload_image(source, base_url=base_url)
|
||||
else:
|
||||
ref_url = await upload_video(source, base_url=base_url)
|
||||
|
||||
body = _build_mm_body(
|
||||
model_id, prompt, resolution, ratio, duration_s,
|
||||
gen_audio, web_search, seed,
|
||||
ref_url, kind,
|
||||
)
|
||||
|
||||
# 2) 提交
|
||||
submit_url = f"{base_url}/v1/video/generations"
|
||||
check_interrupt()
|
||||
resp = await run_with_interrupt(async_request_with_retry(
|
||||
session, "POST", submit_url, json=body, headers=headers,
|
||||
prefix=f"{_LABEL} 提交[{task_no}/{total}]: ",
|
||||
))
|
||||
text = await resp.text()
|
||||
data = json.loads(text)
|
||||
task_id = data.get("task_id") or data.get("id")
|
||||
if not task_id:
|
||||
raise RuntimeError(f"未返回 task_id,响应:{text[:300]}")
|
||||
print(f"[{_LABEL}] 任务 {task_no}/{total} 已提交,task_id={task_id}")
|
||||
|
||||
# 3) 轮询
|
||||
status_url = f"{base_url}/v1/video/generations/{task_id}"
|
||||
deadline = PollDeadline(label=f"{_LABEL}#{task_no}")
|
||||
interval = 4
|
||||
video_url = None
|
||||
download_headers = None
|
||||
while True:
|
||||
deadline.check()
|
||||
check_interrupt()
|
||||
async with session.get(status_url, headers=headers) as sresp:
|
||||
stext = await sresp.text()
|
||||
if sresp.status != 200:
|
||||
raise RuntimeError(f"状态查询失败 ({sresp.status}): {stext[:300]}")
|
||||
sdata = json.loads(stext)
|
||||
|
||||
status = (sdata.get("status")
|
||||
or (sdata.get("data") or {}).get("status")
|
||||
or "").lower()
|
||||
|
||||
if status in _SUCCESS_STATUSES:
|
||||
video_url = SeedanceAutoPass._extract_video_url(sdata)
|
||||
if not video_url:
|
||||
video_url = f"{base_url}/v1/videos/{task_id}/content"
|
||||
# 平台自有域名(含 content 代理)需带鉴权;第三方 CDN 直链绝不带 Bearer
|
||||
if video_url.startswith(base_url):
|
||||
download_headers = headers
|
||||
break
|
||||
if status in _FAILURE_STATUSES:
|
||||
raise RuntimeError(f"生成失败,响应:{stext[:300]}")
|
||||
|
||||
await interruptible_sleep(interval)
|
||||
interval = min(interval * 1.5, 15)
|
||||
|
||||
# 4) 下载到 output 目录
|
||||
out_path = _unique_output_path(out_dir, stem)
|
||||
await download_video_to_file(
|
||||
session, video_url, out_path,
|
||||
headers=download_headers, label=f"{_LABEL}#{task_no}",
|
||||
)
|
||||
print(f"[{_LABEL}] 任务 {task_no}/{total} 成功 ✓ → {out_path}")
|
||||
return {"success": True, "path": out_path, "source": source}
|
||||
|
||||
except InterruptProcessingException:
|
||||
raise
|
||||
except Exception as e:
|
||||
src_name = source if kind == "video" else stem
|
||||
print(f"[{_LABEL}] 任务 {task_no}/{total} 失败 ✗ - {e}")
|
||||
return {"success": False, "error": str(e), "source": src_name}
|
||||
|
||||
|
||||
# V3 批量节点。保留上方旧实现只用于读取该版本文件时的历史语义说明;
|
||||
# 注册映射使用下面这个同名类,节点 ID 不变,因此旧工作流仍能识别节点。
|
||||
class SeedanceAutoPassBatch(io.ComfyNode):
|
||||
"""Seedance 全能生成视频的文件夹批量版本。"""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
web_search = lambda: io.Combo.Input(
|
||||
"联网搜索",
|
||||
options=["关闭", "打开"],
|
||||
default="关闭",
|
||||
advanced=True,
|
||||
)
|
||||
return io.Schema(
|
||||
node_id="SeedanceAutoPassBatch",
|
||||
display_name="Seedance 全能生成视频(批量)",
|
||||
description=(
|
||||
"参数与 Seedance 全能生成视频一致,媒体改为文件夹路径。"
|
||||
"多模态素材按文件名排序后按序号组成任务;首尾帧按序号一一配对。"
|
||||
"提示词支持用单独一行的 --- 分隔多条,与素材做笛卡尔组合。"
|
||||
),
|
||||
category="comfyui_o1key/Seedance",
|
||||
inputs=[
|
||||
io.String.Input(
|
||||
"提示词",
|
||||
multiline=True,
|
||||
default="",
|
||||
tooltip=(
|
||||
"支持批量提示词:用单独一行的 --- 分隔多个提示词,"
|
||||
"每个素材会与每个提示词组合成一个任务(素材数 × 提示词数)。"
|
||||
"--- 不单独占一行时按单个提示词处理。"
|
||||
),
|
||||
),
|
||||
io.DynamicCombo.Input(
|
||||
"生成模式",
|
||||
options=[
|
||||
io.DynamicCombo.Option(
|
||||
_MODE_MULTIMODAL,
|
||||
[
|
||||
web_search(),
|
||||
io.String.Input(
|
||||
"图片文件夹",
|
||||
default="",
|
||||
tooltip="图片按文件名升序,每张参与一条任务。",
|
||||
),
|
||||
io.String.Input(
|
||||
"视频文件夹",
|
||||
default="",
|
||||
tooltip="支持 mp4/mov,与图片和音频按排序后的序号配对。",
|
||||
),
|
||||
io.String.Input(
|
||||
"音频文件夹",
|
||||
default="",
|
||||
tooltip="支持 wav/mp3/m4a/aac/flac/ogg,按序号配对。",
|
||||
),
|
||||
],
|
||||
),
|
||||
io.DynamicCombo.Option(
|
||||
_MODE_FIRST_FRAME,
|
||||
[
|
||||
web_search(),
|
||||
io.String.Input(
|
||||
"首帧图片文件夹",
|
||||
default="",
|
||||
tooltip="文件夹内每张图片分别生成一个视频。",
|
||||
),
|
||||
],
|
||||
),
|
||||
io.DynamicCombo.Option(
|
||||
_MODE_FIRST_LAST,
|
||||
[
|
||||
web_search(),
|
||||
io.String.Input(
|
||||
"首帧图片文件夹",
|
||||
default="",
|
||||
tooltip="按文件名升序与尾帧图片一一配对。",
|
||||
),
|
||||
io.String.Input(
|
||||
"尾帧图片文件夹",
|
||||
default="",
|
||||
tooltip="图片数量必须与首帧文件夹一致。",
|
||||
),
|
||||
],
|
||||
),
|
||||
io.DynamicCombo.Option(
|
||||
_MODE_TEXT,
|
||||
[
|
||||
web_search(),
|
||||
io.Int.Input(
|
||||
"生成数量",
|
||||
default=1,
|
||||
min=1,
|
||||
max=100,
|
||||
tooltip=(
|
||||
"使用相同参数批量提交的文生视频任务数。"
|
||||
"批量提示词模式下总任务数为本数量 × 提示词数。"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
tooltip="切换后仅显示当前模式需要的文件夹输入。",
|
||||
),
|
||||
io.Combo.Input("主模型", options=_BASE_MODELS, default="seedance 2.0"),
|
||||
io.Combo.Input("模型线路", options=_MODEL_ROUTES, default="国内"),
|
||||
io.Combo.Input("分辨率", options=_RESOLUTIONS, default="720p"),
|
||||
io.Combo.Input("宽高比", options=_RATIOS, default="智能"),
|
||||
io.Combo.Input("时长", options=_DURATIONS, default="5秒"),
|
||||
io.Combo.Input("生成音频", options=["关闭", "打开"], default="关闭"),
|
||||
io.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=0xffffffffffffffff,
|
||||
advanced=True,
|
||||
),
|
||||
io.Int.Input(
|
||||
"每批并发数",
|
||||
default=_MAX_BATCH,
|
||||
min=1,
|
||||
max=_MAX_BATCH,
|
||||
advanced=True,
|
||||
),
|
||||
io.String.Input(
|
||||
"输出子目录",
|
||||
default="",
|
||||
advanced=True,
|
||||
tooltip="留空时直接保存到 ComfyUI output 目录。",
|
||||
),
|
||||
],
|
||||
outputs=[io.String.Output(display_name="结果汇总")],
|
||||
is_output_node=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _mode_inputs(kwargs):
|
||||
mode_inputs = kwargs.get("生成模式")
|
||||
if isinstance(mode_inputs, dict):
|
||||
return mode_inputs.get("生成模式", _MODE_MULTIMODAL), mode_inputs
|
||||
if isinstance(mode_inputs, str):
|
||||
return mode_inputs, kwargs
|
||||
return _MODE_MULTIMODAL, kwargs
|
||||
|
||||
@classmethod
|
||||
def _build_tasks(cls, generation_mode, mode_inputs, prompt):
|
||||
"""构造最终任务列表:媒体任务 × 提示词。
|
||||
|
||||
提示词用单独一行的 --- 分隔时进入批量提示词模式,每个媒体任务与每个
|
||||
提示词组合成一条任务;否则所有任务共用同一个提示词。
|
||||
"""
|
||||
batch_prompts = parse_batch_prompts(prompt)
|
||||
media_tasks = cls._build_media_tasks(generation_mode, mode_inputs, prompt)
|
||||
|
||||
if not batch_prompts:
|
||||
for task in media_tasks:
|
||||
task["prompt"] = prompt
|
||||
return media_tasks
|
||||
|
||||
width = len(str(len(batch_prompts)))
|
||||
tasks = []
|
||||
for media_task in media_tasks:
|
||||
for prompt_index, task_prompt in enumerate(batch_prompts, start=1):
|
||||
task = dict(media_task)
|
||||
task["prompt"] = task_prompt
|
||||
task["stem"] = f"{media_task['stem']}_p{prompt_index:0{width}d}"
|
||||
task["source"] = f"{media_task['source']} [提示词{prompt_index}]"
|
||||
tasks.append(task)
|
||||
return tasks
|
||||
|
||||
@classmethod
|
||||
def _build_media_tasks(cls, generation_mode, mode_inputs, prompt):
|
||||
"""读取文件夹并按当前模式构造媒体任务(不含提示词)。"""
|
||||
if generation_mode not in _GENERATION_MODES:
|
||||
raise ValueError(f"不支持的生成模式:{generation_mode}")
|
||||
|
||||
if generation_mode == _MODE_TEXT:
|
||||
if not prompt:
|
||||
raise ValueError("文生视频模式下提示词不能为空")
|
||||
count = int(mode_inputs.get("生成数量", 1))
|
||||
return [
|
||||
{
|
||||
"images": [], "videos": [], "audios": [],
|
||||
"stem": f"seedance_text_{index:03d}",
|
||||
"source": f"文生视频任务{index}",
|
||||
}
|
||||
for index in range(1, count + 1)
|
||||
]
|
||||
|
||||
if generation_mode == _MODE_FIRST_FRAME:
|
||||
images = load_images_from_folder(mode_inputs.get("首帧图片文件夹", ""))
|
||||
if not images:
|
||||
raise ValueError("首帧图片文件夹中没有可用图片")
|
||||
return [
|
||||
{
|
||||
"images": [item.image], "videos": [], "audios": [],
|
||||
"stem": item.filename, "source": item.source_path,
|
||||
}
|
||||
for item in images
|
||||
]
|
||||
|
||||
if generation_mode == _MODE_FIRST_LAST:
|
||||
first_images = load_images_from_folder(mode_inputs.get("首帧图片文件夹", ""))
|
||||
last_images = load_images_from_folder(mode_inputs.get("尾帧图片文件夹", ""))
|
||||
if not first_images or not last_images:
|
||||
raise ValueError("首帧和尾帧图片文件夹都必须包含可用图片")
|
||||
if len(first_images) != len(last_images):
|
||||
raise ValueError(
|
||||
"首帧与尾帧图片数量必须一致:"
|
||||
f"当前首帧 {len(first_images)} 张,尾帧 {len(last_images)} 张"
|
||||
)
|
||||
return [
|
||||
{
|
||||
"images": [first.image, last.image],
|
||||
"videos": [], "audios": [],
|
||||
"stem": first.filename,
|
||||
"source": f"{first.source_path} + {last.source_path}",
|
||||
}
|
||||
for first, last in zip(first_images, last_images)
|
||||
]
|
||||
|
||||
images = load_images_from_folder(mode_inputs.get("图片文件夹", ""))
|
||||
videos = load_video_paths_from_folder(mode_inputs.get("视频文件夹", ""))
|
||||
audios = load_audio_paths_from_folder(mode_inputs.get("音频文件夹", ""))
|
||||
task_count = max(len(images), len(videos), len(audios), 1 if prompt else 0)
|
||||
if task_count == 0:
|
||||
raise ValueError("请至少填写一个包含可用素材的文件夹,或提供提示词")
|
||||
|
||||
tasks = []
|
||||
for index in range(task_count):
|
||||
image = images[index] if index < len(images) else None
|
||||
video = videos[index] if index < len(videos) else None
|
||||
audio = audios[index] if index < len(audios) else None
|
||||
sources = [item for item in (image, video, audio) if item is not None]
|
||||
if sources:
|
||||
first = sources[0]
|
||||
stem = first.filename if hasattr(first, "filename") else Path(first).stem
|
||||
source = first.source_path if hasattr(first, "source_path") else str(first)
|
||||
else:
|
||||
stem = f"seedance_{index + 1:03d}"
|
||||
source = f"多模态任务{index + 1}"
|
||||
tasks.append({
|
||||
"images": [image.image] if image is not None else [],
|
||||
"videos": [video] if video is not None else [],
|
||||
"audios": [audio] if audio is not None else [],
|
||||
"stem": stem,
|
||||
"source": source,
|
||||
})
|
||||
return tasks
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, **kwargs):
|
||||
generation_mode, mode_inputs = cls._mode_inputs(kwargs)
|
||||
prompt = (kwargs.get("提示词", "") or "").strip()
|
||||
base_model = kwargs["主模型"]
|
||||
model_route = _normalize_model_route(kwargs["模型线路"])
|
||||
model = _resolve_model_matrix(base_model, model_route)
|
||||
resolution = kwargs["分辨率"]
|
||||
ratio = kwargs["宽高比"]
|
||||
duration_s = kwargs["时长"]
|
||||
gen_audio = kwargs["生成音频"] == "打开"
|
||||
web_search = mode_inputs.get("联网搜索", "关闭") == "打开"
|
||||
create_mode = _resolve_asset_creation_mode(model_route)
|
||||
seed = int(kwargs.get("seed", 0))
|
||||
batch_size = max(1, min(int(kwargs.get("每批并发数", _MAX_BATCH)), _MAX_BATCH))
|
||||
sub_dir = (kwargs.get("输出子目录") or "").strip()
|
||||
|
||||
if model in _LIMITED_RESOLUTION_MODELS and resolution not in _FAST_RESOLUTIONS:
|
||||
raise ValueError(f"{model} 仅支持 {'/'.join(sorted(_FAST_RESOLUTIONS))}")
|
||||
|
||||
tasks = cls._build_tasks(generation_mode, mode_inputs, prompt)
|
||||
for task in tasks:
|
||||
SeedanceAutoPass._validate_mode_inputs(
|
||||
generation_mode, base_model, task["prompt"],
|
||||
task["images"], task["videos"], task["audios"],
|
||||
)
|
||||
SeedanceAutoPass._validate_dynamic_parameters(
|
||||
base_model, model_route, duration_s,
|
||||
task["images"], task["videos"], task["audios"],
|
||||
)
|
||||
SeedanceAutoPass._validate_reference_media(
|
||||
task["images"], task["videos"], task["audios"],
|
||||
)
|
||||
|
||||
if not FOLDER_PATHS_AVAILABLE:
|
||||
raise RuntimeError("folder_paths 不可用,无法定位 ComfyUI output 目录")
|
||||
out_dir = os.path.abspath(folder_paths.get_output_directory())
|
||||
if sub_dir:
|
||||
out_dir = os.path.join(out_dir, sub_dir)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
base_url = get_base_url_by_route()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {get_api_key_or_raise()}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
total = len(tasks)
|
||||
num_batches = (total + batch_size - 1) // batch_size
|
||||
batch_prompt_count = len(parse_batch_prompts(prompt))
|
||||
if batch_prompt_count:
|
||||
print(
|
||||
f"[{_LABEL}] 批量提示词模式:{total // batch_prompt_count} 个素材 × "
|
||||
f"{batch_prompt_count} 个提示词"
|
||||
)
|
||||
print(f"[{_LABEL}] 共 {total} 个任务,每批最多 {batch_size} 个并发,共 {num_batches} 批")
|
||||
|
||||
results = []
|
||||
connector = aiohttp.TCPConnector(ssl=False, limit=0, limit_per_host=0)
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
for batch_index in range(num_batches):
|
||||
check_interrupt()
|
||||
start = batch_index * batch_size
|
||||
batch = tasks[start:start + batch_size]
|
||||
coroutines = [
|
||||
cls._run_one(
|
||||
session, base_url, headers, out_dir,
|
||||
model, resolution, ratio, duration_s,
|
||||
gen_audio, web_search, seed, create_mode,
|
||||
generation_mode, task, start + offset + 1, total,
|
||||
)
|
||||
for offset, task in enumerate(batch)
|
||||
]
|
||||
batch_results = await asyncio.gather(*coroutines, return_exceptions=True)
|
||||
for result in batch_results:
|
||||
if isinstance(result, InterruptProcessingException):
|
||||
raise result
|
||||
if isinstance(result, Exception):
|
||||
results.append({"success": False, "error": str(result), "source": "?"})
|
||||
else:
|
||||
results.append(result)
|
||||
|
||||
succeeded = [item for item in results if item.get("success")]
|
||||
failed = [item for item in results if not item.get("success")]
|
||||
lines = [
|
||||
f"任务总数: {total}",
|
||||
f"成功: {len(succeeded)}",
|
||||
f"失败: {len(failed)}",
|
||||
f"输出目录: {out_dir}",
|
||||
]
|
||||
if succeeded:
|
||||
lines.extend(["", "成功文件:"])
|
||||
lines.extend(f" ✓ {os.path.basename(item['path'])}" for item in succeeded)
|
||||
if failed:
|
||||
lines.extend(["", "失败项:"])
|
||||
lines.extend(
|
||||
f" ✗ {os.path.basename(str(item.get('source', '?')))} - {item.get('error')}"
|
||||
for item in failed
|
||||
)
|
||||
summary = "\n".join(lines)
|
||||
print(f"[{_LABEL}] 全部完成 — 成功 {len(succeeded)} / 失败 {len(failed)}")
|
||||
return io.NodeOutput(summary)
|
||||
|
||||
@classmethod
|
||||
async def _run_one(
|
||||
cls, session, base_url, headers, out_dir,
|
||||
model, resolution, ratio, duration_s,
|
||||
gen_audio, web_search, seed, create_mode,
|
||||
generation_mode, task, task_no, total,
|
||||
):
|
||||
prompt = task["prompt"]
|
||||
try:
|
||||
image_urls, video_urls, audio_urls = await SeedanceAutoPass._create_assets(
|
||||
task["images"], task["videos"], task["audios"], base_url, create_mode
|
||||
)
|
||||
body = SeedanceAutoPass._build_body(
|
||||
model, prompt, resolution, ratio, duration_s,
|
||||
gen_audio, web_search, seed,
|
||||
image_urls, video_urls, audio_urls,
|
||||
use_asset_protocol=True,
|
||||
generation_mode=generation_mode,
|
||||
)
|
||||
|
||||
response = await run_with_interrupt(async_request_with_retry(
|
||||
session,
|
||||
"POST",
|
||||
f"{base_url}/v1/video/generations",
|
||||
json=body,
|
||||
headers=headers,
|
||||
prefix=f"{_LABEL} 提交[{task_no}/{total}]: ",
|
||||
))
|
||||
response_text = await response.text()
|
||||
data = json.loads(response_text)
|
||||
task_id = data.get("task_id") or data.get("id")
|
||||
if not task_id:
|
||||
raise RuntimeError(f"未返回 task_id,响应:{response_text[:300]}")
|
||||
|
||||
status_url = f"{base_url}/v1/video/generations/{task_id}"
|
||||
deadline = PollDeadline(label=f"{_LABEL}#{task_no}")
|
||||
interval = 4
|
||||
download_headers = None
|
||||
while True:
|
||||
deadline.check()
|
||||
check_interrupt()
|
||||
async with session.get(status_url, headers=headers) as status_response:
|
||||
status_text = await status_response.text()
|
||||
if status_response.status != 200:
|
||||
raise RuntimeError(
|
||||
f"状态查询失败 ({status_response.status}): {status_text[:300]}"
|
||||
)
|
||||
status_data = json.loads(status_text)
|
||||
status = (
|
||||
status_data.get("status")
|
||||
or (status_data.get("data") or {}).get("status")
|
||||
or ""
|
||||
).lower()
|
||||
if status in _SUCCESS_STATUSES:
|
||||
video_url = SeedanceAutoPass._extract_video_url(status_data)
|
||||
if not video_url:
|
||||
video_url = f"{base_url}/v1/videos/{task_id}/content"
|
||||
if video_url.startswith(base_url):
|
||||
download_headers = headers
|
||||
break
|
||||
if status in _FAILURE_STATUSES:
|
||||
raise RuntimeError(f"生成失败,响应:{status_text[:300]}")
|
||||
await interruptible_sleep(interval)
|
||||
interval = min(interval * 1.5, 15)
|
||||
|
||||
out_path = _unique_output_path(out_dir, task["stem"])
|
||||
await download_video_to_file(
|
||||
session, video_url, out_path,
|
||||
headers=download_headers,
|
||||
label=f"{_LABEL}#{task_no}",
|
||||
)
|
||||
print(f"[{_LABEL}] 任务 {task_no}/{total} 成功 ✓ → {out_path}")
|
||||
return {"success": True, "path": out_path, "source": task["source"]}
|
||||
except InterruptProcessingException:
|
||||
raise
|
||||
except Exception as error:
|
||||
print(f"[{_LABEL}] 任务 {task_no}/{total} 失败 ✗ - {error}")
|
||||
return {"success": False, "error": str(error), "source": task["source"]}
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SeedanceAutoPassBatch": SeedanceAutoPassBatch,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SeedanceAutoPassBatch": "Seedance 全能生成视频(批量)",
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Seedance 素材节点
|
||||
节点列表:
|
||||
- SeedanceElementCreate: 创建图片、视频或音频素材
|
||||
"""
|
||||
|
||||
import json
|
||||
from ..clients.seedance_element_client import SeedanceElementClient
|
||||
from ..utils.image_utils import tensor_to_pil
|
||||
from ..utils.r2_uploader import upload_audio, upload_image, upload_video
|
||||
from ..utils.config import get_base_url_by_route
|
||||
from ..utils.seedance_assets import SeedanceAssetService
|
||||
|
||||
|
||||
class SeedanceElementCreate:
|
||||
"""Seedance 创建素材"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"素材名称": ("STRING", {"default": ""}),
|
||||
"请求模式": (["HC", "Doubao"], {"default": "HC"}),
|
||||
},
|
||||
"optional": {
|
||||
"照片": ("IMAGE",),
|
||||
"视频": ("VIDEO",),
|
||||
"音频": ("AUDIO",),
|
||||
"素材描述": ("STRING", {"multiline": True, "default": ""}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("查询信息", "提取ID")
|
||||
FUNCTION = "create_element"
|
||||
CATEGORY = "comfyui_o1key/Seedance"
|
||||
|
||||
async def create_element(self, **kwargs):
|
||||
name = kwargs["素材名称"].strip()
|
||||
request_mode = kwargs.get("请求模式", "HC")
|
||||
# 旧名称保留为执行期别名,兼容未经过前端迁移的 API 工作流。
|
||||
image_tensor = kwargs.get("照片")
|
||||
video = kwargs.get("视频")
|
||||
audio = kwargs.get("音频")
|
||||
if image_tensor is None:
|
||||
image_tensor = kwargs.get("真人照片")
|
||||
if video is None:
|
||||
video = kwargs.get("真人视频")
|
||||
if audio is None:
|
||||
audio = kwargs.get("真人音频")
|
||||
if request_mode in {"标准", "高并发"}:
|
||||
request_mode = "HC"
|
||||
request_types = {"HC": "hc", "Doubao": "doubao"}
|
||||
if request_mode not in request_types:
|
||||
raise ValueError(f"不支持的请求模式:{request_mode}")
|
||||
request_type = request_types[request_mode]
|
||||
|
||||
base_url = get_base_url_by_route()
|
||||
|
||||
sources = []
|
||||
if image_tensor is not None:
|
||||
sources.append(("Image", image_tensor))
|
||||
if video is not None:
|
||||
sources.append(("Video", video))
|
||||
if audio is not None:
|
||||
sources.append(("Audio", audio))
|
||||
|
||||
if len(sources) != 1:
|
||||
raise ValueError(
|
||||
f"{request_mode} 模式必须在照片、视频、音频中恰好提供一种素材"
|
||||
)
|
||||
|
||||
asset_type, source = sources[0]
|
||||
if asset_type == "Image":
|
||||
pil_images = tensor_to_pil(source)
|
||||
if not pil_images:
|
||||
raise ValueError("无法读取图片")
|
||||
pil_image = pil_images[0]
|
||||
if pil_image.mode == "RGBA":
|
||||
pil_image = pil_image.convert("RGB")
|
||||
print(f"[Seedance素材][{request_mode}] 上传图片中...")
|
||||
asset_url = await upload_image(pil_image, base_url=base_url)
|
||||
elif asset_type == "Video":
|
||||
print(f"[Seedance素材][{request_mode}] 上传视频中...")
|
||||
asset_url = await upload_video(source, base_url=base_url)
|
||||
else:
|
||||
print(f"[Seedance素材][{request_mode}] 上传音频中...")
|
||||
asset_url = await upload_audio(source, base_url=base_url)
|
||||
|
||||
if not asset_url.startswith("https://"):
|
||||
raise ValueError(f"{request_mode} 素材上传后未获得 HTTPS 公网地址")
|
||||
|
||||
print(
|
||||
f"[Seedance素材][{request_mode}] 创建素材: "
|
||||
f"Name={name or '(空)'}, AssetType={asset_type}, URL=<临时地址已折叠>"
|
||||
)
|
||||
service = SeedanceAssetService(
|
||||
client=SeedanceElementClient(base_url=base_url),
|
||||
)
|
||||
result = await service.create_from_url(
|
||||
name=name,
|
||||
asset_url=asset_url,
|
||||
asset_type=asset_type,
|
||||
request_type=request_type,
|
||||
)
|
||||
|
||||
element_id = result.get("Id")
|
||||
if not element_id:
|
||||
raise RuntimeError(f"{request_mode} 素材已激活但未返回 Id,响应:{result}")
|
||||
|
||||
create_response = result.get("_create_response", {})
|
||||
response_json = json.dumps(create_response, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"[Seedance素材] 创建成功 → {element_id}")
|
||||
|
||||
return (response_json, element_id)
|
||||
|
||||
|
||||
# ── 节点注册 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SeedanceElementCreate": SeedanceElementCreate,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SeedanceElementCreate": "Seedance 创建素材",
|
||||
}
|
||||
+422
-283
@@ -1,13 +1,9 @@
|
||||
"""
|
||||
Seedance 视频生成节点
|
||||
节点列表:
|
||||
- Seedance: 文生视频 / 图生视频 / 首尾帧生视频(根据图片输入自动切换模式)
|
||||
Seedance 多模态参考生视频节点
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import io as py_io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import aiohttp
|
||||
@@ -16,19 +12,78 @@ import torch
|
||||
from ..clients.seedance_client import SeedanceClient
|
||||
from ..clients.gemini_client import GeminiAPIClient
|
||||
from ..utils.image_utils import tensor_to_pil, pil_to_tensor
|
||||
from ..utils.r2_uploader import upload_video, upload_audio
|
||||
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||
from ..utils.r2_uploader import upload_image, upload_video, upload_audio
|
||||
from ..utils.config import get_base_url_by_route
|
||||
from .seedance_autopass import (
|
||||
SeedanceAutoPass,
|
||||
_BASE_MODELS as _LATEST_BASE_MODELS,
|
||||
_MODEL_CAPABILITIES as _LATEST_MODEL_CAPABILITIES,
|
||||
_MODEL_ROUTES as _LATEST_MODEL_ROUTES,
|
||||
_RATIOS as _LATEST_RATIOS,
|
||||
_resolve_model_matrix as _resolve_latest_model_matrix,
|
||||
)
|
||||
from ..utils.video_task import format_seedance_generation_error
|
||||
|
||||
from comfy_api.latest import InputImpl
|
||||
from comfy_api.latest import InputImpl, io
|
||||
|
||||
|
||||
# ── 模型列表 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_MODELS = [
|
||||
"doubao-seedance-2-0-260128",
|
||||
]
|
||||
_MM_MODEL_LABEL_TO_ID = {
|
||||
"seedance 2.0 海外版(高并发)": "dreamina-seedance-2-0-hc",
|
||||
"seedance 2.0 fast 海外版(高并发)": "dreamina-seedance-2-0-fast-hc",
|
||||
"seedance 2.0 mini 海外版(高并发)": "dreamina-seedance-2-0-mini-hc",
|
||||
"seedance 2.0 海外版": "seedance-2-0-260128-d",
|
||||
"seedance 2.0 海外版(破限)": "seedance-2-0-260128-d-ep",
|
||||
"seedance 2.0 fast 海外版": "seedance-2-0-fast-260128-d",
|
||||
"seedance 2.0 fast 海外版(破限)": "seedance-2-0-fast-d-ep",
|
||||
"seedance 2.0 mini 海外版": "seedance-2-0-mini-260615-d",
|
||||
"seedance 2.0 mini 海外版(破限)": "seedance-2-0-mini-260615-d-ep",
|
||||
}
|
||||
_MM_MODELS = list(_MM_MODEL_LABEL_TO_ID.keys())
|
||||
|
||||
_RESOLUTIONS = ["720p", "1080p", "480p"]
|
||||
# 多模态节点矩阵式模型配置(主模型 × 模型线路 → 实际模型ID)
|
||||
_MM_BASE_MODELS = list(_LATEST_BASE_MODELS)
|
||||
_MM_ROUTES = list(dict.fromkeys([*_LATEST_MODEL_ROUTES, "国内"]))
|
||||
_MM_MODEL_MATRIX = {
|
||||
("seedance 2.0", "国内"): "doubao-seedance-2-0-260128-max",
|
||||
("seedance 2.0 fast", "国内"): "doubao-seedance-2-0-fast-260128-max",
|
||||
("seedance 2.0 mini", "国内"): "doubao-seedance-2-0-mini-260615-max",
|
||||
("seedance 2.5", "国内"): "doubao-seedance-2-5-260628-max",
|
||||
}
|
||||
_MM_RATIOS = list(_LATEST_RATIOS)
|
||||
_MM_MAX_CAPABILITIES = _LATEST_MODEL_CAPABILITIES["seedance 2.5"]
|
||||
_MM_IMAGE_LIMIT = _MM_MAX_CAPABILITIES["images"]
|
||||
_MM_VIDEO_LIMIT = _MM_MAX_CAPABILITIES["videos"]
|
||||
_MM_AUDIO_LIMIT = _MM_MAX_CAPABILITIES["audios"]
|
||||
|
||||
|
||||
def _resolve_model_matrix(base_model: str, route: str) -> str:
|
||||
"""矩阵式解析:主模型 + 模型线路 → 实际模型ID"""
|
||||
model = _MM_MODEL_MATRIX.get((base_model, route))
|
||||
if model is not None:
|
||||
return model
|
||||
return _resolve_latest_model_matrix(base_model, route)
|
||||
|
||||
|
||||
def _resolve_model(model: str) -> str:
|
||||
"""兼容批量节点保存的模型展示名;真实模型 ID 原样返回。"""
|
||||
return _MM_MODEL_LABEL_TO_ID.get(model, model)
|
||||
|
||||
|
||||
# 多模态参考生视频支持 4k
|
||||
_MM_RESOLUTIONS = ["720p", "1080p", "4k", "480p"]
|
||||
# 受限模型仅支持 480p / 720p(前端做选择时报错提示)
|
||||
_LIMITED_RESOLUTION_MODELS = {
|
||||
"doubao-seedance-2-0-fast-260128",
|
||||
"doubao-seedance-2-0-fast-260128-max",
|
||||
"doubao-seedance-2-0-mini-260615-max",
|
||||
"dreamina-seedance-2-0-fast-hc",
|
||||
"seedance-2-0-fast-260128-d",
|
||||
"seedance-2-0-fast-d-ep",
|
||||
"dreamina-seedance-2-0-mini-hc",
|
||||
}
|
||||
_FAST_UNSUPPORTED_RESOLUTIONS = ["1080p", "4k"]
|
||||
|
||||
_MAX_IMAGE_BYTES = 30 * 1024 * 1024
|
||||
_MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024
|
||||
@@ -36,9 +91,37 @@ _MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024
|
||||
|
||||
# ── 模型能力判断 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _supports_camera_fixed(model: str) -> bool:
|
||||
"""2.0 系列不支持固定镜头"""
|
||||
return False # 当前仅 2.0 模型,均不支持
|
||||
def _check_fast_resolution(model: str, resolution: str):
|
||||
"""受限模型不支持 1080p / 4k,提交前拦截(保留旧函数名兼容调用方)。"""
|
||||
if model in _LIMITED_RESOLUTION_MODELS and resolution in _FAST_UNSUPPORTED_RESOLUTIONS:
|
||||
raise ValueError(
|
||||
f"{model} 不支持 {resolution} 分辨率,"
|
||||
f"请改用 {_FAST_UNSUPPORTED_RESOLUTIONS} 以外的分辨率(如 720p)。"
|
||||
)
|
||||
|
||||
|
||||
def _is_new_format_model(model: str) -> bool:
|
||||
"""判断是否使用新请求体格式的模型(顶层 content,role 用 subject)
|
||||
|
||||
端点与老格式模型相同(均为 /v1/video/generations),
|
||||
仅请求体结构不同,由此函数控制节点侧如何拼装 body。
|
||||
"""
|
||||
return model in [
|
||||
"dreamina-seedance-2-0-hc",
|
||||
"dreamina-seedance-2-0-fast-hc",
|
||||
"dreamina-seedance-2-0-mini-hc",
|
||||
"dreamina-seedance-2-5-hc",
|
||||
"seedance-2-0-260128-d",
|
||||
"seedance-2-0-260128-d-ep",
|
||||
"seedance-2-0-fast-260128-d",
|
||||
"seedance-2-0-fast-d-ep",
|
||||
"seedance-2-0-mini-260615-d",
|
||||
"seedance-2-0-mini-260615-d-ep",
|
||||
"doubao-seedance-2-0-260128-max",
|
||||
"doubao-seedance-2-0-fast-260128-max",
|
||||
"doubao-seedance-2-0-mini-260615-max",
|
||||
"doubao-seedance-2-5-260628-max",
|
||||
]
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
@@ -47,14 +130,14 @@ def _format_mb(size_bytes: int) -> str:
|
||||
return f"{size_bytes / 1024 / 1024:.2f}MB"
|
||||
|
||||
|
||||
def _tensor_to_base64_url(tensor, label: str = "图片") -> str:
|
||||
"""ComfyUI IMAGE tensor → data:image/png;base64,xxx"""
|
||||
def _tensor_to_png(tensor, label: str = "图片"):
|
||||
"""ComfyUI IMAGE tensor → (PIL.Image RGB, png_bytes),并做单图大小校验。"""
|
||||
pil_images = tensor_to_pil(tensor)
|
||||
image = pil_images[0]
|
||||
if image.mode == "RGBA":
|
||||
image = image.convert("RGB")
|
||||
|
||||
buffered = io.BytesIO()
|
||||
buffered = py_io.BytesIO()
|
||||
image.save(buffered, format="PNG")
|
||||
image_bytes = buffered.getvalue()
|
||||
image_size = len(image_bytes)
|
||||
@@ -65,8 +148,17 @@ def _tensor_to_base64_url(tensor, label: str = "图片") -> str:
|
||||
f"{_format_mb(_MAX_IMAGE_BYTES)} 限制,请先压缩或缩小图片。"
|
||||
)
|
||||
|
||||
b64 = base64.b64encode(image_bytes).decode("utf-8")
|
||||
return f"data:image/png;base64,{b64}"
|
||||
return image, image_bytes
|
||||
|
||||
|
||||
async def _tensor_to_uploaded_url(tensor, base_url: str, label: str = "图片") -> str:
|
||||
"""ComfyUI IMAGE tensor → 上传到 R2 并返回公网 URL(保留单图大小校验)。
|
||||
|
||||
与参考视频/音频一致,图片改走上传 URL 而非内联 base64,
|
||||
以显著缩小请求体、避免触碰 64MB 请求体上限。
|
||||
"""
|
||||
image, _ = _tensor_to_png(tensor, label)
|
||||
return await upload_image(image, base_url=base_url)
|
||||
|
||||
|
||||
def _validate_request_body_size(body: dict, tag: str):
|
||||
@@ -82,6 +174,57 @@ def _validate_request_body_size(body: dict, tag: str):
|
||||
)
|
||||
|
||||
|
||||
_REQUEST_LOG_SECRET_FIELDS = {
|
||||
"authorization",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"api-key",
|
||||
"access_token",
|
||||
"token",
|
||||
}
|
||||
_REQUEST_LOG_BASE64_FIELDS = {"data", "b64_json", "base64", "image_base64"}
|
||||
_REQUEST_LOG_URL_FIELDS = {"url", "image", "image_url", "video_url", "audio_url"}
|
||||
|
||||
|
||||
def _sanitize_request_body_for_log(value, field_name: str = ""):
|
||||
"""Copy a request body for logging without exposing credentials or media URLs."""
|
||||
normalized_field = field_name.lower()
|
||||
if normalized_field in _REQUEST_LOG_SECRET_FIELDS:
|
||||
return "<redacted>"
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _sanitize_request_body_for_log(item, str(key))
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_sanitize_request_body_for_log(item, field_name) for item in value]
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return f"<binary data, {len(value)} bytes>"
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
if normalized_field in _REQUEST_LOG_BASE64_FIELDS:
|
||||
return f"<base64 data, {len(value)} chars>"
|
||||
|
||||
header, separator, data = value.partition(",")
|
||||
if separator and header.lower().startswith("data:") and ";base64" in header.lower():
|
||||
return f"{header},<base64 data, {len(data)} chars>"
|
||||
|
||||
if normalized_field in _REQUEST_LOG_URL_FIELDS and value.lower().startswith(("http://", "https://")):
|
||||
return "<temporary URL omitted>"
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _log_original_request_body(body: dict):
|
||||
safe_body = _sanitize_request_body_for_log(body)
|
||||
print(
|
||||
"[SeedanceMultiModal] 原始请求体(临时 URL 与媒体数据已折叠):\n"
|
||||
f"{json.dumps(safe_body, ensure_ascii=False, indent=2)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
async def _url_to_tensor(url: str) -> torch.Tensor:
|
||||
"""从 URL 下载图片并转为 ComfyUI IMAGE tensor,失败时返回 None"""
|
||||
@@ -92,7 +235,7 @@ async def _url_to_tensor(url: str) -> torch.Tensor:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
data = await resp.read()
|
||||
img = Image.open(io.BytesIO(data)).convert("RGB")
|
||||
img = Image.open(py_io.BytesIO(data)).convert("RGB")
|
||||
return pil_to_tensor([img])
|
||||
except Exception as e:
|
||||
print(f"[Seedance] 末帧图片下载失败: {e}")
|
||||
@@ -139,280 +282,241 @@ def _make_callbacks(tag: str, pbar):
|
||||
|
||||
|
||||
|
||||
# ── 统一节点 ─────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# 模式由图片输入自动判断:
|
||||
# 首帧 = None → T2V 文生视频 (联网搜索生效)
|
||||
# 首帧 = 图片,尾帧 = None → I2V 图生视频 (固定镜头生效,当前 2.0 不支持故忽略)
|
||||
# 首帧 = 图片,尾帧 = 图片 → FlipFlop 首尾帧(联网搜索/固定镜头均忽略)
|
||||
|
||||
class Seedance:
|
||||
"""Seedance 视频生成(文生视频 / 图生视频 / 首尾帧,自动判断模式)"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
|
||||
"模型": (_MODELS, {"default": "doubao-seedance-2-0-260128"}),
|
||||
"分辨率": (_RESOLUTIONS, {"default": "720p"}),
|
||||
"宽高比": (["16:9", "adaptive", "9:16", "1:1", "4:3", "3:4", "21:9"],
|
||||
{"default": "16:9"}),
|
||||
"时长秒(-1=自动)": ("INT", {"default": 5, "min": -1, "max": 30, "step": 1}),
|
||||
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
||||
"联网搜索": (["关闭", "打开"], {"default": "关闭"}),
|
||||
"返回末帧图片": (["关闭", "打开"], {"default": "关闭"}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
|
||||
},
|
||||
"optional": {
|
||||
"首帧图片": ("IMAGE",),
|
||||
"尾帧图片": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO", "IMAGE")
|
||||
RETURN_NAMES = ("视频", "末帧图片")
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "comfyui_o1key/Seedance"
|
||||
|
||||
async def generate(self, **kwargs):
|
||||
prompt = kwargs["提示词"].strip()
|
||||
model = kwargs["模型"]
|
||||
resolution = kwargs["分辨率"]
|
||||
ratio = kwargs["宽高比"]
|
||||
duration = kwargs["时长秒(-1=自动)"]
|
||||
gen_audio = kwargs["生成音频"] == "打开"
|
||||
web_search = kwargs["联网搜索"] == "打开"
|
||||
return_last = kwargs["返回末帧图片"] == "打开"
|
||||
seed = kwargs.get("seed", 0)
|
||||
first_image = kwargs.get("首帧图片", None)
|
||||
last_image = kwargs.get("尾帧图片", None)
|
||||
|
||||
# 模式判断
|
||||
if first_image is None and last_image is not None:
|
||||
raise ValueError("请同时接入首帧图片,或仅接入首帧图片。")
|
||||
if first_image is None:
|
||||
mode = "t2v"
|
||||
tag = "Seedance文生视频"
|
||||
file_prefix = "seedance_t2v"
|
||||
elif last_image is None:
|
||||
mode = "i2v"
|
||||
tag = "Seedance图生视频"
|
||||
file_prefix = "seedance_i2v"
|
||||
else:
|
||||
mode = "flipflop"
|
||||
tag = "Seedance首尾帧"
|
||||
file_prefix = "seedance_flip"
|
||||
|
||||
if not prompt:
|
||||
raise ValueError("提示词不能为空。")
|
||||
if duration == -1 and mode == "t2v":
|
||||
pass # 2.0 均支持自动时长
|
||||
elif duration == -1 and mode != "t2v":
|
||||
pass # 2.0 均支持自动时长
|
||||
|
||||
metadata: dict = {
|
||||
"resolution": resolution,
|
||||
"watermark": False,
|
||||
}
|
||||
if ratio != "adaptive":
|
||||
metadata["ratio"] = ratio
|
||||
if duration != -1:
|
||||
metadata["duration"] = duration
|
||||
if gen_audio:
|
||||
metadata["generate_audio"] = True
|
||||
if return_last:
|
||||
metadata["return_last_frame"] = True
|
||||
if seed != 0:
|
||||
metadata["seed"] = seed
|
||||
|
||||
# 模式专属参数
|
||||
if mode == "t2v":
|
||||
if web_search:
|
||||
metadata["tools"] = [{"type": "web_search"}]
|
||||
body = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
elif mode == "i2v":
|
||||
first_url = _tensor_to_base64_url(first_image, "首帧图片")
|
||||
metadata["content"] = [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": first_url},
|
||||
"role": "first_frame",
|
||||
},
|
||||
{"type": "text", "text": prompt},
|
||||
]
|
||||
body = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"images": [first_url],
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
else: # flipflop
|
||||
first_url = _tensor_to_base64_url(first_image, "首帧图片")
|
||||
last_url = _tensor_to_base64_url(last_image, "尾帧图片")
|
||||
metadata["content"] = [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": first_url},
|
||||
"role": "first_frame",
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": last_url},
|
||||
"role": "last_frame",
|
||||
},
|
||||
{"type": "text", "text": prompt},
|
||||
]
|
||||
body = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"images": [first_url],
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
_validate_request_body_size(body, tag)
|
||||
|
||||
# 保存路径(临时文件,避免与下游保存节点重复落盘)
|
||||
_, save_path = tempfile.mkstemp(suffix=".mp4", prefix=f"{file_prefix}_")
|
||||
|
||||
client = SeedanceClient()
|
||||
client.base_url = get_base_url_by_route(kwargs.get("网络线路", "全球加速"))
|
||||
pbar = _make_pbar()
|
||||
on_stage, on_prog = _make_callbacks(tag, pbar)
|
||||
|
||||
try:
|
||||
result_path, last_frame_url = await client.generate_async(
|
||||
body=body, save_path=save_path,
|
||||
on_stage=on_stage, on_progress=on_prog,
|
||||
)
|
||||
last_frame_tensor = None
|
||||
if return_last and last_frame_url:
|
||||
last_frame_tensor = await _url_to_tensor(last_frame_url)
|
||||
return (InputImpl.VideoFromFile(result_path), last_frame_tensor)
|
||||
finally:
|
||||
_show_balance()
|
||||
|
||||
|
||||
# ── 多模态参考生视频节点 ──────────────────────────────────────────────────────
|
||||
|
||||
class SeedanceMultiModal:
|
||||
"""Seedance 2.0 多模态参考生视频(参考图片 + 参考视频 + 参考音频 + 文本)"""
|
||||
class SeedanceMultiModal(io.ComfyNode):
|
||||
"""Seedance 2.0 / 2.5 多模态参考生视频。"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
|
||||
"模型": (_MODELS, {"default": "doubao-seedance-2-0-260128"}),
|
||||
"分辨率": (_RESOLUTIONS, {"default": "720p"}),
|
||||
"宽高比": (["adaptive", "16:9", "9:16", "1:1", "4:3", "3:4", "21:9"],
|
||||
{"default": "adaptive"}),
|
||||
"时长秒(-1=自动)": ("INT", {"default": 5, "min": -1, "max": 15, "step": 1}),
|
||||
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
||||
"联网搜索": (["关闭", "打开"], {"default": "关闭"}),
|
||||
"返回末帧图片": (["关闭", "打开"], {"default": "关闭"}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
|
||||
},
|
||||
"optional": {
|
||||
"参考图片": ("IMAGE",),
|
||||
"参考视频1": ("VIDEO",),
|
||||
"参考视频2": ("VIDEO",),
|
||||
"参考视频3": ("VIDEO",),
|
||||
"参考音频1": ("AUDIO",),
|
||||
"参考音频2": ("AUDIO",),
|
||||
"参考音频3": ("AUDIO",),
|
||||
},
|
||||
}
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SeedanceMultiModal",
|
||||
display_name="Seedance 多模态参考生视频",
|
||||
description="支持动态增加参考图片、视频、音频,以及渐进填写素材 ID。",
|
||||
category="comfyui_o1key/Seedance",
|
||||
inputs=[
|
||||
io.String.Input("提示词", multiline=True, default=""),
|
||||
io.Combo.Input("主模型", options=_MM_BASE_MODELS, default="seedance 2.0"),
|
||||
io.Combo.Input("模型线路", options=_MM_ROUTES, default="国内"),
|
||||
io.Combo.Input("分辨率", options=_MM_RESOLUTIONS, default="720p"),
|
||||
io.Combo.Input(
|
||||
"宽高比",
|
||||
options=_MM_RATIOS,
|
||||
default="智能",
|
||||
),
|
||||
io.Combo.Input(
|
||||
"时长",
|
||||
options=["自动"] + [f"{i}秒" for i in range(4, 31)],
|
||||
default="5秒",
|
||||
),
|
||||
io.Combo.Input("生成音频", options=["关闭", "打开"], default="关闭"),
|
||||
io.Combo.Input("联网搜索", options=["关闭", "打开"], default="关闭"),
|
||||
io.Combo.Input("返回末帧图片", options=["关闭", "打开"], default="关闭"),
|
||||
io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff),
|
||||
io.Autogrow.Input(
|
||||
"参考图片",
|
||||
optional=True,
|
||||
template=io.Autogrow.TemplateNames(
|
||||
input=io.Image.Input("参考图片"),
|
||||
names=[f"参考图片{i}" for i in range(1, _MM_IMAGE_LIMIT + 1)],
|
||||
min=0,
|
||||
),
|
||||
),
|
||||
io.Autogrow.Input(
|
||||
"参考视频",
|
||||
optional=True,
|
||||
template=io.Autogrow.TemplateNames(
|
||||
input=io.Video.Input("参考视频"),
|
||||
names=[f"参考视频{i}" for i in range(1, _MM_VIDEO_LIMIT + 1)],
|
||||
min=0,
|
||||
),
|
||||
),
|
||||
io.Autogrow.Input(
|
||||
"参考音频",
|
||||
optional=True,
|
||||
template=io.Autogrow.TemplateNames(
|
||||
input=io.Audio.Input("参考音频"),
|
||||
names=[f"参考音频{i}" for i in range(1, _MM_AUDIO_LIMIT + 1)],
|
||||
min=0,
|
||||
),
|
||||
),
|
||||
# 保留已发布的 9/3/3 widget 顺序;只更新图片素材的显示名称,
|
||||
# 2.5 的新增素材 ID 仍仅追加,避免 widgets_values 发生位置漂移。
|
||||
*[io.String.Input(f"图片素材ID{i}", default="") for i in range(1, 10)],
|
||||
*[io.String.Input(f"视频素材ID{i}", default="") for i in range(1, 4)],
|
||||
*[io.String.Input(f"音频素材ID{i}", default="") for i in range(1, 4)],
|
||||
*[io.String.Input(f"图片素材ID{i}", default="") for i in range(10, _MM_IMAGE_LIMIT + 1)],
|
||||
*[io.String.Input(f"视频素材ID{i}", default="") for i in range(4, _MM_VIDEO_LIMIT + 1)],
|
||||
*[io.String.Input(f"音频素材ID{i}", default="") for i in range(4, _MM_AUDIO_LIMIT + 1)],
|
||||
],
|
||||
outputs=[
|
||||
io.Video.Output(display_name="视频"),
|
||||
io.Image.Output(display_name="末帧图片"),
|
||||
],
|
||||
)
|
||||
|
||||
RETURN_TYPES = ("VIDEO", "IMAGE")
|
||||
RETURN_NAMES = ("视频", "末帧图片")
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "comfyui_o1key/Seedance"
|
||||
INPUT_IS_LIST = True
|
||||
@staticmethod
|
||||
def _autogrow_values(kwargs, group_name, legacy_prefix, legacy_max):
|
||||
"""读取 V3 动态输入,同时兼容旧工作流的编号端口直接调用。"""
|
||||
group = kwargs.get(group_name)
|
||||
if isinstance(group, dict):
|
||||
return [value for value in group.values() if value is not None]
|
||||
|
||||
async def generate(self, **kwargs):
|
||||
# INPUT_IS_LIST=True 时所有参数都是列表,取第一个元素
|
||||
def _first(value):
|
||||
if isinstance(value, list):
|
||||
return value[0] if value else None
|
||||
return value
|
||||
|
||||
return [
|
||||
value
|
||||
for index in range(1, legacy_max + 1)
|
||||
if (value := _first(kwargs.get(f"{legacy_prefix}{index}"))) is not None
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, **kwargs):
|
||||
return await cls.generate(**kwargs)
|
||||
|
||||
@classmethod
|
||||
async def generate(cls, **kwargs):
|
||||
# V3 传入标量;保留列表兼容旧版 INPUT_IS_LIST 的直接调用。
|
||||
def _first(v, default=None):
|
||||
if isinstance(v, list):
|
||||
return v[0] if v else default
|
||||
return v if v is not None else default
|
||||
|
||||
prompt = _first(kwargs.get("提示词"), "").strip()
|
||||
model = _first(kwargs.get("模型"))
|
||||
base_model = _first(kwargs.get("主模型"), "seedance 2.0")
|
||||
route = _first(kwargs.get("模型线路"), "国内")
|
||||
model = _resolve_model_matrix(base_model, route)
|
||||
resolution = _first(kwargs.get("分辨率"))
|
||||
ratio = _first(kwargs.get("宽高比"))
|
||||
duration = _first(kwargs.get("时长秒(-1=自动)"), 5)
|
||||
duration_str = _first(kwargs.get("时长"), "5秒")
|
||||
# 解析时长:自动 → -1,其他提取数字
|
||||
if duration_str == "自动":
|
||||
duration = -1
|
||||
else:
|
||||
duration = int(duration_str.replace("秒", ""))
|
||||
gen_audio = _first(kwargs.get("生成音频"), "关闭") == "打开"
|
||||
web_search = _first(kwargs.get("联网搜索"), "关闭") == "打开"
|
||||
return_last = _first(kwargs.get("返回末帧图片"), "关闭") == "打开"
|
||||
seed = _first(kwargs.get("seed"), 0)
|
||||
network_route = _first(kwargs.get("网络线路"), "全球加速")
|
||||
|
||||
# 参考图片:INPUT_IS_LIST 时是 [tensor, tensor, ...] 列表,直接保留
|
||||
raw_images = kwargs.get("参考图片", None)
|
||||
ref_images = [img for img in raw_images if img is not None] if raw_images else None
|
||||
ref_images = cls._autogrow_values(kwargs, "参考图片", "参考图片", _MM_IMAGE_LIMIT)
|
||||
ref_videos = cls._autogrow_values(kwargs, "参考视频", "参考视频", _MM_VIDEO_LIMIT)
|
||||
ref_audios = cls._autogrow_values(kwargs, "参考音频", "参考音频", _MM_AUDIO_LIMIT)
|
||||
# 新名称优先;旧名称兼容未经过浏览器迁移的 API 工作流。
|
||||
element_ids = []
|
||||
for i in range(1, _MM_IMAGE_LIMIT + 1):
|
||||
current_id = _first(kwargs.get(f"图片素材ID{i}"), "").strip()
|
||||
legacy_id = _first(kwargs.get(f"真人素材ID{i}"), "").strip()
|
||||
element_ids.append(current_id or legacy_id)
|
||||
video_ids = [_first(kwargs.get(f"视频素材ID{i}"), "").strip() for i in range(1, _MM_VIDEO_LIMIT + 1)]
|
||||
audio_ids = [_first(kwargs.get(f"音频素材ID{i}"), "").strip() for i in range(1, _MM_AUDIO_LIMIT + 1)]
|
||||
|
||||
ref_videos = [_first(kwargs.get(f"参考视频{i}")) for i in range(1, 4)]
|
||||
ref_audios = [_first(kwargs.get(f"参考音频{i}")) for i in range(1, 4)]
|
||||
|
||||
ref_videos = [v for v in ref_videos if v is not None]
|
||||
ref_audios = [a for a in ref_audios if a is not None]
|
||||
element_ids = [eid for eid in element_ids if eid]
|
||||
video_ids = [vid for vid in video_ids if vid]
|
||||
audio_ids = [aid for aid in audio_ids if aid]
|
||||
|
||||
# ── 校验 ──────────────────────────────────────────────────────────
|
||||
has_image = bool(ref_images)
|
||||
has_video = len(ref_videos) > 0
|
||||
has_audio = len(ref_audios) > 0
|
||||
has_image = bool(ref_images)
|
||||
has_video = len(ref_videos) > 0
|
||||
has_audio = len(ref_audios) > 0
|
||||
has_element = len(element_ids) > 0
|
||||
has_video_id = len(video_ids) > 0
|
||||
has_audio_id = len(audio_ids) > 0
|
||||
|
||||
if not has_image and not has_video and not has_audio and not prompt:
|
||||
raise ValueError("至少需要提供参考图片、参考视频或提示词之一。")
|
||||
if has_audio and not has_image and not has_video:
|
||||
raise ValueError("不可单独输入音频,请至少连接一张参考图片或一个参考视频。")
|
||||
if not has_image and not has_video and not has_audio and not has_element and not has_video_id and not has_audio_id and not prompt:
|
||||
raise ValueError("至少需要提供参考图片、参考视频、图片素材ID、视频素材ID或提示词之一。")
|
||||
if base_model != "seedance 2.5" and (has_audio or has_audio_id) and not has_image and not has_video and not has_element and not has_video_id:
|
||||
raise ValueError("不可单独输入音频,请至少连接一张参考图片、一个参考视频或提供图片/视频素材ID。")
|
||||
# 国内线路仅替换实际模型 ID,能力限制与对应主模型保持一致。
|
||||
SeedanceAutoPass._validate_dynamic_parameters(
|
||||
base_model,
|
||||
route,
|
||||
duration_str,
|
||||
[*ref_images, *element_ids],
|
||||
[*ref_videos, *video_ids],
|
||||
[*ref_audios, *audio_ids],
|
||||
)
|
||||
_check_fast_resolution(model, resolution)
|
||||
|
||||
# 判断是否使用新格式
|
||||
use_new_format = _is_new_format_model(model)
|
||||
|
||||
# ── 构建 content 列表 ─────────────────────────────────────────────
|
||||
content = []
|
||||
base_url = get_base_url_by_route()
|
||||
|
||||
# 参考图片(批次,最多9张)
|
||||
# 真人素材 ID(与参考图片共用当前模型的图片额度,优先级最高)
|
||||
if has_element:
|
||||
for idx, eid in enumerate(element_ids, start=1):
|
||||
# 确保 element_id 格式正确
|
||||
asset_url = eid if eid.startswith("asset://") else f"asset://{eid}"
|
||||
# 第一个素材ID作为主体(subject),其余作为参考图片(reference_image)
|
||||
if idx == 1:
|
||||
role = "subject" if use_new_format else "reference_image"
|
||||
else:
|
||||
role = "reference_image"
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": asset_url},
|
||||
"role": role,
|
||||
})
|
||||
role_label = "主体" if role == "subject" else "参考"
|
||||
print(f"[SeedanceMultiModal] 使用图片素材ID{idx}({role_label}) → {asset_url}")
|
||||
|
||||
# 参考图片(2.5 最多 30 个独立槽位,用户自行选择连接哪几个)
|
||||
if has_image:
|
||||
imgs = ref_images[:9]
|
||||
if len(ref_images) > 9:
|
||||
print(f"[SeedanceMultiModal] 参考图片超过9张,仅取前9张(共{len(ref_images)}张)")
|
||||
for idx, img_tensor in enumerate(imgs, start=1):
|
||||
for idx, img_tensor in enumerate(ref_images, start=1):
|
||||
# 每个 tensor 可能是 [1,H,W,C] 或 [H,W,C],统一确保有 batch 维
|
||||
if img_tensor.dim() == 3:
|
||||
img_tensor = img_tensor.unsqueeze(0)
|
||||
url = _tensor_to_base64_url(img_tensor, f"参考图片{idx}")
|
||||
url = await _tensor_to_uploaded_url(img_tensor, base_url, f"参考图片{idx}")
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": url},
|
||||
"role": "reference_image",
|
||||
})
|
||||
|
||||
# 参考视频(最多3个)
|
||||
# 参考视频(2.5 最多 10 个)
|
||||
for v in ref_videos:
|
||||
url = await upload_video(v)
|
||||
url = await upload_video(v, base_url=base_url)
|
||||
content.append({
|
||||
"type": "video_url",
|
||||
"video_url": {"url": url},
|
||||
"role": "reference_video",
|
||||
})
|
||||
|
||||
# 参考音频(最多3段)
|
||||
# 视频素材 ID(与参考视频共用当前模型的视频额度)
|
||||
for idx, vid in enumerate(video_ids, start=1):
|
||||
asset_url = vid if vid.startswith("asset://") else f"asset://{vid}"
|
||||
content.append({
|
||||
"type": "video_url",
|
||||
"video_url": {"url": asset_url},
|
||||
"role": "reference_video",
|
||||
})
|
||||
print(f"[SeedanceMultiModal] 使用视频素材ID{idx} → {asset_url}")
|
||||
|
||||
# 参考音频(2.5 最多 10 段)
|
||||
for a in ref_audios:
|
||||
url = await upload_audio(a)
|
||||
url = await upload_audio(a, base_url=base_url)
|
||||
content.append({
|
||||
"type": "audio_url",
|
||||
"audio_url": {"url": url},
|
||||
"role": "reference_audio",
|
||||
})
|
||||
|
||||
# 音频素材 ID(与参考音频共用当前模型的音频额度)
|
||||
for idx, aid in enumerate(audio_ids, start=1):
|
||||
asset_url = aid if aid.startswith("asset://") else f"asset://{aid}"
|
||||
content.append({
|
||||
"type": "audio_url",
|
||||
"audio_url": {"url": asset_url},
|
||||
"role": "reference_audio",
|
||||
})
|
||||
print(f"[SeedanceMultiModal] 使用音频素材ID{idx} → {asset_url}")
|
||||
|
||||
# 文本提示词(放最后)
|
||||
if prompt:
|
||||
content.append({"type": "text", "text": prompt})
|
||||
@@ -420,59 +524,96 @@ class SeedanceMultiModal:
|
||||
if not content:
|
||||
raise ValueError("content 为空,请至少提供参考图片、参考视频或提示词。")
|
||||
|
||||
# ── 构建请求体(new-api 兼容格式)──────────────────────────────────
|
||||
metadata: dict = {
|
||||
"resolution": resolution,
|
||||
"watermark": False,
|
||||
"content": content,
|
||||
}
|
||||
# ── 构建请求体 ──────────────────────────────────────────────────────
|
||||
if use_new_format:
|
||||
# 新格式:顶层 content
|
||||
# 注意:文本提示词应该放在最前面
|
||||
ordered_content = []
|
||||
|
||||
if ratio != "adaptive":
|
||||
metadata["ratio"] = ratio
|
||||
if duration != -1:
|
||||
metadata["duration"] = duration
|
||||
if gen_audio:
|
||||
metadata["generate_audio"] = True
|
||||
if return_last:
|
||||
metadata["return_last_frame"] = True
|
||||
if seed != 0:
|
||||
metadata["seed"] = seed
|
||||
if web_search:
|
||||
metadata["tools"] = [{"type": "web_search"}]
|
||||
# 先添加文本
|
||||
text_items = [item for item in content if item.get("type") == "text"]
|
||||
ordered_content.extend(text_items)
|
||||
|
||||
# 顶层 image:取第一张参考图的 base64(new-api 单图字段)
|
||||
first_image_url = next(
|
||||
(item["image_url"]["url"] for item in content if item["type"] == "image_url"),
|
||||
None,
|
||||
)
|
||||
# 再添加其他内容(图片、视频、音频)
|
||||
non_text_items = [item for item in content if item.get("type") != "text"]
|
||||
ordered_content.extend(non_text_items)
|
||||
|
||||
body = {
|
||||
"model": model,
|
||||
"prompt": prompt if prompt else " ",
|
||||
"metadata": metadata,
|
||||
}
|
||||
if first_image_url:
|
||||
body["image"] = first_image_url
|
||||
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 = get_base_url_by_route(network_route)
|
||||
client.base_url = base_url
|
||||
pbar = _make_pbar()
|
||||
on_stage, on_prog = _make_callbacks("Seedance多模态", pbar)
|
||||
|
||||
try:
|
||||
result_path, last_frame_url = await client.generate_async(
|
||||
body=body, save_path=save_path,
|
||||
on_stage=on_stage, on_progress=on_prog,
|
||||
)
|
||||
try:
|
||||
result_path, last_frame_url = await client.generate_async(
|
||||
body=body, save_path=save_path,
|
||||
on_stage=on_stage, on_progress=on_prog,
|
||||
use_new_format=use_new_format,
|
||||
)
|
||||
except Exception as exc:
|
||||
message = format_seedance_generation_error(exc)
|
||||
if message == str(exc):
|
||||
raise
|
||||
raise RuntimeError(message) from None
|
||||
last_frame_tensor = None
|
||||
if return_last and last_frame_url:
|
||||
last_frame_tensor = await _url_to_tensor(last_frame_url)
|
||||
return (InputImpl.VideoFromFile(result_path), last_frame_tensor)
|
||||
return io.NodeOutput(InputImpl.VideoFromFile(result_path), last_frame_tensor)
|
||||
finally:
|
||||
_show_balance()
|
||||
|
||||
@@ -480,11 +621,9 @@ class SeedanceMultiModal:
|
||||
# ── 节点注册 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"Seedance": Seedance,
|
||||
"SeedanceMultiModal": SeedanceMultiModal,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"Seedance": "Seedance 视频生成",
|
||||
"SeedanceMultiModal": "Seedance 多模态参考生视频",
|
||||
}
|
||||
|
||||
+5
-1
@@ -12,6 +12,7 @@ from typing import Optional, Tuple
|
||||
import torch
|
||||
|
||||
from ..utils.image_utils import tensor_to_pil
|
||||
from ..utils.config import get_runtime_config_signature
|
||||
from ..clients.sora_client import SoraClient
|
||||
from ..models_config import (
|
||||
get_enabled_sora_models,
|
||||
@@ -242,6 +243,7 @@ class SoraVideo:
|
||||
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self._client_config_signature = None
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -417,8 +419,10 @@ class SoraVideo:
|
||||
pbar = ProgressBar(生成数量 if 生成数量 > 1 else 100)
|
||||
|
||||
try:
|
||||
if self.client is None:
|
||||
config_signature = get_runtime_config_signature()
|
||||
if self.client is None or config_signature != self._client_config_signature:
|
||||
self.client = SoraClient()
|
||||
self._client_config_signature = config_signature
|
||||
|
||||
if 生成数量 == 1:
|
||||
# ── 单个视频:保留详细进度(提交→轮询→下载)
|
||||
|
||||
+209
-94
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
全能LLM对话助手节点
|
||||
提示词专家节点
|
||||
ComfyUI 自定义节点,通过 OpenAI 兼容协议调用市面上主流的 AI 对话大模型
|
||||
支持多模态(图片输入),单轮对话,非流式输出
|
||||
|
||||
@@ -15,25 +15,44 @@ from typing import Optional, Tuple, List
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
from comfy_api.latest import io
|
||||
|
||||
from ..utils.image_utils import tensor_to_pil
|
||||
from ..utils.config import get_api_key_or_raise, get_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
|
||||
from ..utils.config import get_api_key_or_raise, get_base_url_by_route
|
||||
from ..utils.file_types import FileList
|
||||
|
||||
# ============================================================================
|
||||
# 模型配置
|
||||
# ============================================================================
|
||||
|
||||
DEFAULT_MODEL = "gpt-6-sol"
|
||||
|
||||
SUPPORTED_MODELS = [
|
||||
DEFAULT_MODEL,
|
||||
"gpt-6-astra",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.5",
|
||||
"gemini-3.1-pro-preview",
|
||||
"deepseek-v4-pro",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
"gemini-3.5-flash",
|
||||
"claude-opus-5",
|
||||
"doubao-seed-2.0-pro",
|
||||
]
|
||||
|
||||
MAX_IMAGE_INPUTS = 9
|
||||
|
||||
REASONING_DEPTH_OPTIONS = ["低", "中", "高"]
|
||||
REASONING_DEPTH_VALUE_MAP = {
|
||||
"低": "low",
|
||||
"中": "medium",
|
||||
"高": "high",
|
||||
"low": "low",
|
||||
"medium": "medium",
|
||||
"high": "high",
|
||||
}
|
||||
|
||||
# 节点内实时 token 预览开关。设为 True 即可恢复原打字机效果。
|
||||
ENABLE_NODE_TYPEWRITER_PREVIEW = False
|
||||
|
||||
# 图片缩放最大尺寸
|
||||
MAX_IMAGE_DIMENSION = 1568
|
||||
|
||||
@@ -41,9 +60,18 @@ MAX_IMAGE_DIMENSION = 1568
|
||||
MAX_IMAGE_SIZE = 20 * 1024 * 1024
|
||||
|
||||
|
||||
class UniversalLLMChat:
|
||||
def _collect_autogrow_inputs(value) -> list:
|
||||
"""收集已连接的 Autogrow 输入,并兼容单个旧值。"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
return [item for item in value.values() if item is not None]
|
||||
return [value]
|
||||
|
||||
|
||||
class UniversalLLMChat(io.ComfyNode):
|
||||
"""
|
||||
全能LLM对话助手
|
||||
提示词专家
|
||||
|
||||
功能:
|
||||
- 通过 OpenAI 兼容协议调用主流大模型
|
||||
@@ -52,51 +80,64 @@ class UniversalLLMChat:
|
||||
- API 密钥和地址继承插件统一配置
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._api_key = None
|
||||
self._base_url = None
|
||||
|
||||
def _ensure_config(self):
|
||||
"""延迟加载配置,首次调用时初始化"""
|
||||
if self._api_key is None:
|
||||
self._api_key = get_api_key_or_raise("O1KEY_API_KEY")
|
||||
self._base_url = get_api_base_url()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"网络线路": (NETWORK_ROUTE_OPTIONS, {
|
||||
"default": "全球加速"
|
||||
}),
|
||||
"模型": (SUPPORTED_MODELS, {
|
||||
"default": SUPPORTED_MODELS[0]
|
||||
}),
|
||||
"提示词": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"图片": ("IMAGE",),
|
||||
"视频": ("VIDEO",),
|
||||
"文件": ("FILE_LIST",),
|
||||
"令牌": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"placeholder": "留空则使用默认 API Key",
|
||||
}),
|
||||
},
|
||||
"hidden": {
|
||||
"node_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("回复",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "text/generation"
|
||||
OUTPUT_NODE = True
|
||||
def define_schema(cls):
|
||||
image_inputs = io.Autogrow.Input(
|
||||
"图片组",
|
||||
template=io.Autogrow.TemplateNames(
|
||||
input=io.Image.Input("图片"),
|
||||
names=[f"图片{i}" for i in range(1, MAX_IMAGE_INPUTS + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip=f"连接后自动增加输入端口,合计最多 {MAX_IMAGE_INPUTS} 张图片。",
|
||||
)
|
||||
return io.Schema(
|
||||
node_id="UniversalLLMChat",
|
||||
display_name="提示词专家",
|
||||
category="text/generation",
|
||||
inputs=[
|
||||
io.Combo.Input(
|
||||
"模型",
|
||||
options=SUPPORTED_MODELS,
|
||||
default=DEFAULT_MODEL,
|
||||
),
|
||||
io.Combo.Input(
|
||||
"思考深度",
|
||||
options=REASONING_DEPTH_OPTIONS,
|
||||
default="高",
|
||||
),
|
||||
io.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2**31 - 1,
|
||||
step=1,
|
||||
display_mode=io.NumberDisplay.number,
|
||||
control_after_generate=io.ControlAfterGenerate.randomize,
|
||||
),
|
||||
io.String.Input(
|
||||
"api(可选)",
|
||||
default="",
|
||||
multiline=False,
|
||||
placeholder="留空则使用默认 API Key",
|
||||
),
|
||||
io.String.Input(
|
||||
"提示词",
|
||||
default="",
|
||||
multiline=True,
|
||||
),
|
||||
io.Video.Input("视频", optional=True),
|
||||
io.Custom("FILE_LIST").Input("文件", optional=True),
|
||||
image_inputs,
|
||||
],
|
||||
outputs=[
|
||||
io.String.Output(display_name="回复"),
|
||||
],
|
||||
hidden=[io.Hidden.unique_id],
|
||||
is_output_node=True,
|
||||
# 接收旧版固定图片端口及旧令牌字段,避免旧工作流直接失效。
|
||||
accept_all_inputs=True,
|
||||
)
|
||||
|
||||
def _resize_image(self, img: Image.Image) -> Image.Image:
|
||||
"""如果图片过长边超过限制,等比缩放"""
|
||||
@@ -105,7 +146,7 @@ class UniversalLLMChat:
|
||||
if max_dim > MAX_IMAGE_DIMENSION:
|
||||
scale = MAX_IMAGE_DIMENSION / max_dim
|
||||
new_w, new_h = int(w * scale), int(h * scale)
|
||||
print(f"全能LLM: 图片缩放 {w}x{h} -> {new_w}x{new_h}")
|
||||
print(f"提示词专家: 图片缩放 {w}x{h} -> {new_w}x{new_h}")
|
||||
return img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
return img
|
||||
|
||||
@@ -197,14 +238,14 @@ class UniversalLLMChat:
|
||||
},
|
||||
})
|
||||
|
||||
print(f"全能LLM: 加载文件 {filename} ({file_size / 1024:.1f}KB, {mime})")
|
||||
print(f"提示词专家: 加载文件 {filename} ({file_size / 1024:.1f}KB, {mime})")
|
||||
|
||||
return parts
|
||||
|
||||
def _build_input(
|
||||
self,
|
||||
prompt: str,
|
||||
images: Optional[torch.Tensor] = None,
|
||||
image_tensors: Optional[List[torch.Tensor]] = None,
|
||||
file_paths: str = "",
|
||||
file_list: Optional[FileList] = None,
|
||||
video=None,
|
||||
@@ -212,15 +253,17 @@ class UniversalLLMChat:
|
||||
"""构建 chat/completions 格式的 messages 数组"""
|
||||
image_data_urls = []
|
||||
pil_images_cache = [] # 保留 PIL Image 用于总体积重新编码
|
||||
|
||||
if images is not None:
|
||||
pil_images = tensor_to_pil(images)
|
||||
for img in pil_images:
|
||||
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 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:
|
||||
@@ -228,7 +271,7 @@ class UniversalLLMChat:
|
||||
len(base64.b64decode(url.split(',', 1)[1])) for url in image_data_urls
|
||||
)
|
||||
if total_bytes > MAX_IMAGE_SIZE:
|
||||
print(f"全能LLM: 图片总体积 {total_bytes / 1024 / 1024:.2f}MB 超过 {MAX_IMAGE_SIZE // 1024 // 1024}MB 限制,正在压缩...")
|
||||
print(f"提示词专家: 图片总体积 {total_bytes / 1024 / 1024:.2f}MB 超过 {MAX_IMAGE_SIZE // 1024 // 1024}MB 限制,正在压缩...")
|
||||
|
||||
# 降质量
|
||||
compressed = False
|
||||
@@ -242,7 +285,7 @@ class UniversalLLMChat:
|
||||
total_bytes = sum(len(base64.b64decode(u.split(',', 1)[1])) for u in new_urls)
|
||||
if total_bytes <= MAX_IMAGE_SIZE:
|
||||
image_data_urls = new_urls
|
||||
print(f"全能LLM: 图片压缩完成,总体积 {total_bytes / 1024 / 1024:.2f}MB ({len(pil_images_cache)}张图片,质量{quality})")
|
||||
print(f"提示词专家: 图片压缩完成,总体积 {total_bytes / 1024 / 1024:.2f}MB ({len(pil_images_cache)}张图片,质量{quality})")
|
||||
compressed = True
|
||||
break
|
||||
|
||||
@@ -260,12 +303,12 @@ class UniversalLLMChat:
|
||||
total_bytes = sum(len(base64.b64decode(u.split(',', 1)[1])) for u in new_urls)
|
||||
if total_bytes <= MAX_IMAGE_SIZE:
|
||||
image_data_urls = new_urls
|
||||
print(f"全能LLM: 图片压缩完成,总体积 {total_bytes / 1024 / 1024:.2f}MB ({len(pil_images_cache)}张图片,缩放{int(scale*100)}%)")
|
||||
print(f"提示词专家: 图片压缩完成,总体积 {total_bytes / 1024 / 1024:.2f}MB ({len(pil_images_cache)}张图片,缩放{int(scale*100)}%)")
|
||||
compressed = True
|
||||
break
|
||||
|
||||
if not compressed:
|
||||
print(f"全能LLM: 无法将 {len(pil_images_cache)} 张图片压缩到 {MAX_IMAGE_SIZE // 1024 // 1024}MB 以内,请减少图片数量或降低分辨率")
|
||||
print(f"提示词专家: 无法将 {len(pil_images_cache)} 张图片压缩到 {MAX_IMAGE_SIZE // 1024 // 1024}MB 以内,请减少图片数量或降低分辨率")
|
||||
raise ValueError(f"图片总体积 {total_bytes / 1024 / 1024:.2f}MB 超过限制,无法压缩到 {MAX_IMAGE_SIZE // 1024 // 1024}MB 以内")
|
||||
|
||||
# 处理视频输入(ComfyUI VIDEO 类型)
|
||||
@@ -305,7 +348,7 @@ class UniversalLLMChat:
|
||||
ext = os.path.splitext(vp)[1].lower()
|
||||
mime = mime_map.get(ext, "video/mp4")
|
||||
file_size = os.path.getsize(vp)
|
||||
print(f"全能LLM: 加载视频 {os.path.basename(vp)} ({file_size / 1024 / 1024:.1f}MB, {mime})")
|
||||
print(f"提示词专家: 加载视频 {os.path.basename(vp)} ({file_size / 1024 / 1024:.1f}MB, {mime})")
|
||||
with open(vp, "rb") as f:
|
||||
b64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
video_url_str = f"data:{mime};base64,{b64}"
|
||||
@@ -314,7 +357,7 @@ class UniversalLLMChat:
|
||||
file_parts = []
|
||||
if file_list:
|
||||
for fd in file_list:
|
||||
print(f"全能LLM: 使用文件 {fd.filename}{fd.extension} ({fd.size / 1024:.1f}KB)")
|
||||
print(f"提示词专家: 使用文件 {fd.filename}{fd.extension} ({fd.size / 1024:.1f}KB)")
|
||||
file_parts.append({
|
||||
"type": "file",
|
||||
"file": {
|
||||
@@ -369,44 +412,89 @@ class UniversalLLMChat:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def execute(
|
||||
cls,
|
||||
模型: str,
|
||||
思考深度: str = "高",
|
||||
seed: int = 0,
|
||||
提示词: str = "",
|
||||
视频=None,
|
||||
文件: Optional[FileList] = None,
|
||||
**kwargs,
|
||||
) -> io.NodeOutput:
|
||||
worker = cls()
|
||||
result = worker.generate(
|
||||
模型=模型,
|
||||
思考深度=思考深度,
|
||||
seed=seed,
|
||||
提示词=提示词,
|
||||
视频=视频,
|
||||
文件=文件,
|
||||
node_id=str(cls.hidden.unique_id or ""),
|
||||
**kwargs,
|
||||
)
|
||||
return io.NodeOutput(*result)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
模型: str,
|
||||
提示词: str,
|
||||
网络线路: str = "全球加速",
|
||||
图片: Optional[torch.Tensor] = None,
|
||||
思考深度: str = "高",
|
||||
seed: int = 0,
|
||||
提示词: str = "",
|
||||
视频=None,
|
||||
文件: Optional[FileList] = None,
|
||||
令牌: str = "",
|
||||
node_id: str = "",
|
||||
**kwargs,
|
||||
) -> Tuple[str]:
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
self._ensure_config()
|
||||
self._base_url = get_base_url_by_route(网络线路)
|
||||
# 用户填写 api 时覆盖默认 API Key;保留旧字段名兼容旧工作流。
|
||||
api_value = kwargs.get(
|
||||
"api(可选)",
|
||||
kwargs.get("分组令牌(可留空)", kwargs.get("令牌", "")),
|
||||
)
|
||||
effective_api_key = str(api_value).strip() if api_value else ""
|
||||
if not effective_api_key:
|
||||
effective_api_key = get_api_key_or_raise("O1KEY_API_KEY")
|
||||
base_url = get_base_url_by_route()
|
||||
reasoning_effort = REASONING_DEPTH_VALUE_MAP.get(思考深度, "medium")
|
||||
|
||||
# 如果用户传入了自定义令牌,则覆盖默认 API Key
|
||||
effective_api_key = 令牌.strip() if 令牌 and 令牌.strip() else self._api_key
|
||||
image_tensors = _collect_autogrow_inputs(kwargs.get("图片组"))
|
||||
if not image_tensors:
|
||||
# 兼容 Autogrow 改造前的「图片」及「图片1~图片9」端口。
|
||||
旧图片 = kwargs.get("图片")
|
||||
if 旧图片 is not None:
|
||||
image_tensors.append(旧图片)
|
||||
image_tensors.extend(
|
||||
kwargs[f"图片{i}"]
|
||||
for i in range(1, MAX_IMAGE_INPUTS + 1)
|
||||
if kwargs.get(f"图片{i}") is not None
|
||||
)
|
||||
|
||||
# 构建 input
|
||||
input_data = self._build_input(提示词, 图片, "", 文件, 视频)
|
||||
input_data = self._build_input(提示词, image_tensors, "", 文件, 视频)
|
||||
|
||||
img_count = len(tensor_to_pil(图片)) if 图片 is not None else 0
|
||||
img_count = sum(len(tensor_to_pil(t)) for t in image_tensors)
|
||||
file_count = len(文件) if 文件 else 0
|
||||
input_desc = "文本"
|
||||
if img_count: input_desc += f" + {img_count}张图片"
|
||||
if 视频 is not None: input_desc += " + 视频"
|
||||
if file_count: input_desc += f" + {file_count}个文件"
|
||||
|
||||
print(f"全能LLM: 模型 = {模型}")
|
||||
print(f"全能LLM: 输入 = {input_desc}")
|
||||
print(f"提示词专家: 模型 = {模型}")
|
||||
print(f"提示词专家: 思考深度 = {思考深度} ({reasoning_effort})")
|
||||
print(f"提示词专家: seed = {seed}")
|
||||
print(f"提示词专家: 输入 = {input_desc}")
|
||||
|
||||
# 构建请求体(chat/completions 格式)
|
||||
request_body = {
|
||||
"model": 模型,
|
||||
"messages": input_data,
|
||||
"stream": True,
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"seed": seed,
|
||||
}
|
||||
|
||||
# 打印请求体,base64 截断显示
|
||||
@@ -415,10 +503,10 @@ class UniversalLLMChat:
|
||||
return {k: _truncate_for_log(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_truncate_for_log(i) for i in obj]
|
||||
if isinstance(obj, str) and (obj.startswith("data:image") or obj.startswith("data:application") or obj.startswith("data:text")):
|
||||
if isinstance(obj, str) and obj.startswith("data:"):
|
||||
return obj[:60] + f"...[{len(obj)}chars]"
|
||||
return obj
|
||||
print(f"全能LLM: 请求原始内容 = {json.dumps(_truncate_for_log(request_body), ensure_ascii=False)}")
|
||||
print(f"提示词专家: 请求原始内容 = {json.dumps(_truncate_for_log(request_body), ensure_ascii=False)}")
|
||||
|
||||
# 发送请求(在独立线程中运行异步请求,避免与 ComfyUI 事件循环冲突)
|
||||
import aiohttp
|
||||
@@ -430,8 +518,10 @@ class UniversalLLMChat:
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {effective_api_key}",
|
||||
}
|
||||
url = f"{self._base_url}/v1/chat/completions"
|
||||
timeout = aiohttp.ClientTimeout(total=120)
|
||||
url = f"{base_url}/v1/chat/completions"
|
||||
# 流式接口不设整体 total 上限(否则会掐断高思考深度的长生成),
|
||||
# 改用连接超时 + 单次读取超时:只要在 sock_read 间隔内有数据返回就不超时。
|
||||
timeout = aiohttp.ClientTimeout(total=None, sock_connect=30, sock_read=300)
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, headers=headers, json=request_body) as resp:
|
||||
@@ -439,6 +529,7 @@ class UniversalLLMChat:
|
||||
|
||||
if status != 200:
|
||||
body = await resp.text()
|
||||
print(f"提示词专家: 响应体 = {body}")
|
||||
try:
|
||||
err_data = json.loads(body)
|
||||
err_msg = err_data.get("error", {}).get("message", body[:200])
|
||||
@@ -458,17 +549,30 @@ class UniversalLLMChat:
|
||||
|
||||
# 流式读取,拼接 delta content
|
||||
reply_parts = []
|
||||
response_body_parts = []
|
||||
async for raw_line in resp.content:
|
||||
line = raw_line.decode("utf-8").strip()
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
data_str = line[len("data:"):].strip()
|
||||
response_body_parts.append(f"data: {data_str}")
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
except Exception:
|
||||
continue
|
||||
stream_error = chunk.get("error")
|
||||
if stream_error:
|
||||
response_body = "\n".join(response_body_parts)
|
||||
print(f"提示词专家: 响应体 = {response_body}")
|
||||
if isinstance(stream_error, dict):
|
||||
error_message = stream_error.get("message", "上游服务暂时不可用")
|
||||
error_type = stream_error.get("type", "upstream_error")
|
||||
else:
|
||||
error_message = str(stream_error)
|
||||
error_type = "upstream_error"
|
||||
raise RuntimeError(f"上游服务错误 ({error_type}): {error_message}")
|
||||
choices = chunk.get("choices")
|
||||
if not choices:
|
||||
continue
|
||||
@@ -476,10 +580,17 @@ class UniversalLLMChat:
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
reply_parts.append(content)
|
||||
UniversalLLMChat._send_stream_token(node_id, content)
|
||||
if ENABLE_NODE_TYPEWRITER_PREVIEW:
|
||||
UniversalLLMChat._send_stream_token(node_id, content)
|
||||
|
||||
UniversalLLMChat._send_stream_token(node_id, "", done=True)
|
||||
return "".join(reply_parts)
|
||||
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()
|
||||
@@ -492,22 +603,26 @@ class UniversalLLMChat:
|
||||
reply = pool.submit(_run_in_thread).result()
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"全能LLM: 生成完成 (耗时: {elapsed:.2f}s)")
|
||||
print(f"提示词专家: 生成完成 (耗时: {elapsed:.2f}s)")
|
||||
if reply:
|
||||
preview = reply[:100] + "..." if len(reply) > 100 else reply
|
||||
print(f"全能LLM: 回复预览: {preview}")
|
||||
print(f"提示词专家: 回复预览: {preview}")
|
||||
|
||||
return (reply,)
|
||||
|
||||
except ValueError as e:
|
||||
if str(e) == "未授权!":
|
||||
print("全能LLM: 请联系作者授权后方可使用!")
|
||||
print("提示词专家: 请联系作者授权后方可使用!")
|
||||
raise ValueError("未授权!") from None
|
||||
error_msg = str(e).split('\n')[0]
|
||||
print(f"全能LLM: ❌ {error_msg}")
|
||||
print(f"提示词专家: ❌ {error_msg}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).split('\n')[0]
|
||||
print(f"全能LLM: ❌ {error_msg}")
|
||||
import asyncio as _asyncio
|
||||
if isinstance(e, _asyncio.TimeoutError):
|
||||
error_msg = "请求超时:服务端长时间未返回数据(可能是模型思考过久或网络不稳定),请重试或降低思考深度/图片数量"
|
||||
else:
|
||||
error_msg = str(e).split('\n')[0] or f"未知错误({type(e).__name__})"
|
||||
print(f"提示词专家: ❌ {error_msg}")
|
||||
raise RuntimeError(error_msg) from None
|
||||
|
||||
+5
-1
@@ -11,6 +11,7 @@ from typing import Optional, Tuple
|
||||
import torch
|
||||
|
||||
from ..utils.image_utils import tensor_to_pil
|
||||
from ..utils.config import get_runtime_config_signature
|
||||
from ..clients.veo_client import VeoClient
|
||||
from ..models_config import (
|
||||
get_enabled_veo_models,
|
||||
@@ -127,6 +128,7 @@ class GoogleVeo:
|
||||
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self._client_config_signature = None
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -304,8 +306,10 @@ class GoogleVeo:
|
||||
pbar = ProgressBar(生成数量 if 生成数量 > 1 else 100)
|
||||
|
||||
try:
|
||||
if self.client is None:
|
||||
config_signature = get_runtime_config_signature()
|
||||
if self.client is None or config_signature != self._client_config_signature:
|
||||
self.client = VeoClient()
|
||||
self._client_config_signature = config_signature
|
||||
|
||||
if 生成数量 == 1:
|
||||
save_path = os.path.join(video_dir, f"veo_{counter:05d}.mp4")
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
视频裁剪节点
|
||||
上传本地视频(或接入上游 VIDEO),用前端时间轴选段,按 [开始, 结束] 物理裁剪。
|
||||
|
||||
为什么物理裁剪而不用 VideoFromFile 的惰性 trim:
|
||||
r2_uploader.upload_video / 各生视频节点读取的是 get_stream_source()(整段原文件),
|
||||
惰性 trim 窗口在上传时会被忽略。这里用 ffmpeg 真正切出一段独立 mp4,
|
||||
保证预览、上传、保存三条路径都拿到裁剪后的内容。
|
||||
|
||||
ffmpeg 解析顺序:系统 PATH → imageio-ffmpeg 自带二进制(随整合包分发,
|
||||
无需用户单独安装 ffmpeg)。
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
from comfy_api.latest import InputImpl
|
||||
_VIDEO_OK = True
|
||||
except Exception:
|
||||
_VIDEO_OK = False
|
||||
|
||||
|
||||
# ── ffmpeg / 时长解析 ───────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_ffmpeg() -> str:
|
||||
exe = shutil.which("ffmpeg")
|
||||
if exe:
|
||||
return exe
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(
|
||||
"未找到 ffmpeg。请在便携 Python 中执行:"
|
||||
"python_embeded\\python.exe -m pip install imageio-ffmpeg"
|
||||
)
|
||||
|
||||
|
||||
_DUR_RE = re.compile(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)")
|
||||
|
||||
|
||||
def _probe_duration(src: str) -> float | None:
|
||||
"""取视频时长(秒)。优先 ffprobe;缺失时解析 `ffmpeg -i` 的 stderr。"""
|
||||
ffprobe = shutil.which("ffprobe")
|
||||
if ffprobe:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[ffprobe, "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=nw=1:nk=1", src],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
val = (out.stdout or "").strip()
|
||||
if val:
|
||||
return float(val)
|
||||
except Exception:
|
||||
pass
|
||||
# 退化:imageio-ffmpeg 只带 ffmpeg,没有 ffprobe → 解析 ffmpeg -i 输出
|
||||
try:
|
||||
ffmpeg = _resolve_ffmpeg()
|
||||
out = subprocess.run([ffmpeg, "-i", src], capture_output=True, text=True, timeout=30)
|
||||
m = _DUR_RE.search(out.stderr or "")
|
||||
if m:
|
||||
h, mi, s = int(m.group(1)), int(m.group(2)), float(m.group(3))
|
||||
return h * 3600 + mi * 60 + s
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ── 源文件解析 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_source(video_obj, video_path: str):
|
||||
"""返回 (源文件路径, 是否为临时文件)。临时文件用完需删除。"""
|
||||
if video_obj is not None and hasattr(video_obj, "get_stream_source"):
|
||||
source = video_obj.get_stream_source()
|
||||
if isinstance(source, io.BytesIO):
|
||||
fd, tmp = tempfile.mkstemp(suffix=".mp4", prefix="o1key_trim_src_")
|
||||
os.close(fd)
|
||||
source.seek(0)
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(source.read())
|
||||
return tmp, True
|
||||
return source, False
|
||||
|
||||
p = (video_path or "").strip().strip('"').strip("'")
|
||||
if not p:
|
||||
raise ValueError("请先点节点上的「上传视频」按钮,或连接一个「视频」输入。")
|
||||
if not os.path.isfile(p):
|
||||
raise ValueError(f"视频文件不存在:{p}")
|
||||
return p, False
|
||||
|
||||
|
||||
class O1keyVideoTrim:
|
||||
"""
|
||||
视频裁剪:上传本地视频 → 时间轴拖拽选段 → 输出裁剪后的 VIDEO。
|
||||
|
||||
- 「视频路径」由前端「上传视频」按钮自动填入(也可手动粘贴绝对路径)。
|
||||
- 「开始时间」由时间轴拖拽同步,单位秒。
|
||||
- 「固定时长」> 0 时:裁剪区间长度固定为该值,前端可整体拖动这个窗口(定时快剪)。
|
||||
- 「固定时长」= 0 时:用「结束时间」;结束时间为 0 表示到片尾(自由两端拖拽)。
|
||||
- 可选「视频」输入:连接上游 VIDEO 时优先裁剪它,忽略「视频路径」。
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"视频路径": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"placeholder": "点节点上的「上传视频」按钮,或粘贴视频绝对路径",
|
||||
}),
|
||||
"开始时间": ("FLOAT", {
|
||||
"default": 0.0, "min": 0.0, "max": 86400.0, "step": 0.1,
|
||||
"tooltip": "裁剪起点(秒)",
|
||||
}),
|
||||
"结束时间": ("FLOAT", {
|
||||
"default": 0.0, "min": 0.0, "max": 86400.0, "step": 0.1,
|
||||
"tooltip": "裁剪终点(秒);0 表示到片尾。固定时长 > 0 时忽略此项",
|
||||
}),
|
||||
"固定时长": ("FLOAT", {
|
||||
"default": 0.0, "min": 0.0, "max": 86400.0, "step": 0.5,
|
||||
"tooltip": "> 0 时裁剪区间长度固定为该值(定时快剪);0 表示用结束时间",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"视频": ("VIDEO",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO", "FLOAT")
|
||||
RETURN_NAMES = ("视频", "时长")
|
||||
FUNCTION = "trim"
|
||||
CATEGORY = "comfyui_o1key/Utils"
|
||||
|
||||
def trim(self, 视频路径: str = "", 开始时间: float = 0.0, 固定时长: float = 0.0,
|
||||
结束时间: float = 0.0, 视频=None):
|
||||
if not _VIDEO_OK:
|
||||
raise RuntimeError("当前环境缺少 comfy_api,无法输出 VIDEO 类型。")
|
||||
|
||||
src, is_tmp = _resolve_source(视频, 视频路径)
|
||||
try:
|
||||
duration = _probe_duration(src)
|
||||
|
||||
start = max(0.0, float(开始时间))
|
||||
fixed = max(0.0, float(固定时长))
|
||||
|
||||
# 计算结束时间
|
||||
if fixed > 0.0:
|
||||
# 固定时长模式:窗口整体不超过片尾
|
||||
if duration and fixed >= duration:
|
||||
start, end = 0.0, duration
|
||||
else:
|
||||
if duration:
|
||||
start = min(start, max(duration - fixed, 0.0))
|
||||
end = start + fixed
|
||||
if duration:
|
||||
end = min(end, duration)
|
||||
else:
|
||||
end = float(结束时间)
|
||||
if end <= 0.0:
|
||||
end = duration if duration else 0.0 # 0 → 到片尾
|
||||
if duration:
|
||||
end = min(end, duration)
|
||||
start = min(start, max(duration - 0.05, 0.0))
|
||||
|
||||
if end > 0.0 and end <= start:
|
||||
raise ValueError(
|
||||
f"结束时间({end:.2f}s)必须大于开始时间({start:.2f}s)。"
|
||||
)
|
||||
|
||||
# 整段未裁剪且源为磁盘文件:直接透传,避免无谓重编码
|
||||
full_range = (
|
||||
duration is not None and start <= 0.01 and end >= duration - 0.05
|
||||
)
|
||||
if full_range and not is_tmp:
|
||||
return (InputImpl.VideoFromFile(src), float(duration))
|
||||
|
||||
seg_dur = (end - start) if end > 0.0 else (
|
||||
(duration - start) if duration else 0.0
|
||||
)
|
||||
if seg_dur <= 0.0:
|
||||
raise ValueError("裁剪区间长度为 0,请调整开始/结束时间或固定时长。")
|
||||
|
||||
fd, out_path = tempfile.mkstemp(suffix=".mp4", prefix="o1key_trim_")
|
||||
os.close(fd)
|
||||
|
||||
ffmpeg = _resolve_ffmpeg()
|
||||
cmd = [
|
||||
ffmpeg, "-y",
|
||||
"-ss", f"{start:.3f}",
|
||||
"-i", src,
|
||||
"-t", f"{seg_dur:.3f}",
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-movflags", "+faststart",
|
||||
out_path,
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode != 0 or not os.path.isfile(out_path) or os.path.getsize(out_path) == 0:
|
||||
tail = (proc.stderr or "").strip().splitlines()[-8:]
|
||||
raise RuntimeError("ffmpeg 裁剪失败:\n" + "\n".join(tail))
|
||||
|
||||
return (InputImpl.VideoFromFile(out_path), float(seg_dur))
|
||||
finally:
|
||||
if is_tmp:
|
||||
try:
|
||||
os.remove(src)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"O1keyVideoTrim": O1keyVideoTrim,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"O1keyVideoTrim": "视频裁剪",
|
||||
}
|
||||
Reference in New Issue
Block a user