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:
Jony
2026-09-24 19:56:48 +08:00
parent 3e337722ab
commit ba920f2b66
183 changed files with 49496 additions and 9934 deletions
+422 -283
View File
@@ -1,13 +1,9 @@
"""
Seedance 视频生成节点
节点列表:
- Seedance: 文生视频 / 图生视频 / 首尾帧生视频(根据图片输入自动切换模式)
Seedance 多模态参考生视频节点
"""
import base64
import io
import io as py_io
import json
import os
import tempfile
import aiohttp
@@ -16,19 +12,78 @@ import torch
from ..clients.seedance_client import SeedanceClient
from ..clients.gemini_client import GeminiAPIClient
from ..utils.image_utils import tensor_to_pil, pil_to_tensor
from ..utils.r2_uploader import upload_video, upload_audio
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.r2_uploader import upload_image, upload_video, upload_audio
from ..utils.config import get_base_url_by_route
from .seedance_autopass import (
SeedanceAutoPass,
_BASE_MODELS as _LATEST_BASE_MODELS,
_MODEL_CAPABILITIES as _LATEST_MODEL_CAPABILITIES,
_MODEL_ROUTES as _LATEST_MODEL_ROUTES,
_RATIOS as _LATEST_RATIOS,
_resolve_model_matrix as _resolve_latest_model_matrix,
)
from ..utils.video_task import format_seedance_generation_error
from comfy_api.latest import InputImpl
from comfy_api.latest import InputImpl, io
# ── 模型列表 ──────────────────────────────────────────────────────────────────
_MODELS = [
"doubao-seedance-2-0-260128",
]
_MM_MODEL_LABEL_TO_ID = {
"seedance 2.0 海外版(高并发)": "dreamina-seedance-2-0-hc",
"seedance 2.0 fast 海外版(高并发)": "dreamina-seedance-2-0-fast-hc",
"seedance 2.0 mini 海外版(高并发)": "dreamina-seedance-2-0-mini-hc",
"seedance 2.0 海外版": "seedance-2-0-260128-d",
"seedance 2.0 海外版(破限)": "seedance-2-0-260128-d-ep",
"seedance 2.0 fast 海外版": "seedance-2-0-fast-260128-d",
"seedance 2.0 fast 海外版(破限)": "seedance-2-0-fast-d-ep",
"seedance 2.0 mini 海外版": "seedance-2-0-mini-260615-d",
"seedance 2.0 mini 海外版(破限)": "seedance-2-0-mini-260615-d-ep",
}
_MM_MODELS = list(_MM_MODEL_LABEL_TO_ID.keys())
_RESOLUTIONS = ["720p", "1080p", "480p"]
# 多模态节点矩阵式模型配置(主模型 × 模型线路 → 实际模型ID)
_MM_BASE_MODELS = list(_LATEST_BASE_MODELS)
_MM_ROUTES = list(dict.fromkeys([*_LATEST_MODEL_ROUTES, "国内"]))
_MM_MODEL_MATRIX = {
("seedance 2.0", "国内"): "doubao-seedance-2-0-260128-max",
("seedance 2.0 fast", "国内"): "doubao-seedance-2-0-fast-260128-max",
("seedance 2.0 mini", "国内"): "doubao-seedance-2-0-mini-260615-max",
("seedance 2.5", "国内"): "doubao-seedance-2-5-260628-max",
}
_MM_RATIOS = list(_LATEST_RATIOS)
_MM_MAX_CAPABILITIES = _LATEST_MODEL_CAPABILITIES["seedance 2.5"]
_MM_IMAGE_LIMIT = _MM_MAX_CAPABILITIES["images"]
_MM_VIDEO_LIMIT = _MM_MAX_CAPABILITIES["videos"]
_MM_AUDIO_LIMIT = _MM_MAX_CAPABILITIES["audios"]
def _resolve_model_matrix(base_model: str, route: str) -> str:
"""矩阵式解析:主模型 + 模型线路 → 实际模型ID"""
model = _MM_MODEL_MATRIX.get((base_model, route))
if model is not None:
return model
return _resolve_latest_model_matrix(base_model, route)
def _resolve_model(model: str) -> str:
"""兼容批量节点保存的模型展示名;真实模型 ID 原样返回。"""
return _MM_MODEL_LABEL_TO_ID.get(model, model)
# 多模态参考生视频支持 4k
_MM_RESOLUTIONS = ["720p", "1080p", "4k", "480p"]
# 受限模型仅支持 480p / 720p(前端做选择时报错提示)
_LIMITED_RESOLUTION_MODELS = {
"doubao-seedance-2-0-fast-260128",
"doubao-seedance-2-0-fast-260128-max",
"doubao-seedance-2-0-mini-260615-max",
"dreamina-seedance-2-0-fast-hc",
"seedance-2-0-fast-260128-d",
"seedance-2-0-fast-d-ep",
"dreamina-seedance-2-0-mini-hc",
}
_FAST_UNSUPPORTED_RESOLUTIONS = ["1080p", "4k"]
_MAX_IMAGE_BYTES = 30 * 1024 * 1024
_MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024
@@ -36,9 +91,37 @@ _MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024
# ── 模型能力判断 ──────────────────────────────────────────────────────────────
def _supports_camera_fixed(model: str) -> bool:
"""2.0 系列不支持固定镜头"""
return False # 当前仅 2.0 模型,均不支持
def _check_fast_resolution(model: str, resolution: str):
"""受限模型不支持 1080p / 4k,提交前拦截(保留旧函数名兼容调用方)。"""
if model in _LIMITED_RESOLUTION_MODELS and resolution in _FAST_UNSUPPORTED_RESOLUTIONS:
raise ValueError(
f"{model} 不支持 {resolution} 分辨率,"
f"请改用 {_FAST_UNSUPPORTED_RESOLUTIONS} 以外的分辨率(如 720p)。"
)
def _is_new_format_model(model: str) -> bool:
"""判断是否使用新请求体格式的模型(顶层 contentrole 用 subject
端点与老格式模型相同(均为 /v1/video/generations),
仅请求体结构不同,由此函数控制节点侧如何拼装 body。
"""
return model in [
"dreamina-seedance-2-0-hc",
"dreamina-seedance-2-0-fast-hc",
"dreamina-seedance-2-0-mini-hc",
"dreamina-seedance-2-5-hc",
"seedance-2-0-260128-d",
"seedance-2-0-260128-d-ep",
"seedance-2-0-fast-260128-d",
"seedance-2-0-fast-d-ep",
"seedance-2-0-mini-260615-d",
"seedance-2-0-mini-260615-d-ep",
"doubao-seedance-2-0-260128-max",
"doubao-seedance-2-0-fast-260128-max",
"doubao-seedance-2-0-mini-260615-max",
"doubao-seedance-2-5-260628-max",
]
# ── 工具函数 ──────────────────────────────────────────────────────────────────
@@ -47,14 +130,14 @@ def _format_mb(size_bytes: int) -> str:
return f"{size_bytes / 1024 / 1024:.2f}MB"
def _tensor_to_base64_url(tensor, label: str = "图片") -> str:
"""ComfyUI IMAGE tensor → data:image/png;base64,xxx"""
def _tensor_to_png(tensor, label: str = "图片"):
"""ComfyUI IMAGE tensor → (PIL.Image RGB, png_bytes),并做单图大小校验。"""
pil_images = tensor_to_pil(tensor)
image = pil_images[0]
if image.mode == "RGBA":
image = image.convert("RGB")
buffered = io.BytesIO()
buffered = py_io.BytesIO()
image.save(buffered, format="PNG")
image_bytes = buffered.getvalue()
image_size = len(image_bytes)
@@ -65,8 +148,17 @@ def _tensor_to_base64_url(tensor, label: str = "图片") -> str:
f"{_format_mb(_MAX_IMAGE_BYTES)} 限制,请先压缩或缩小图片。"
)
b64 = base64.b64encode(image_bytes).decode("utf-8")
return f"data:image/png;base64,{b64}"
return image, image_bytes
async def _tensor_to_uploaded_url(tensor, base_url: str, label: str = "图片") -> str:
"""ComfyUI IMAGE tensor → 上传到 R2 并返回公网 URL(保留单图大小校验)。
与参考视频/音频一致,图片改走上传 URL 而非内联 base64,
以显著缩小请求体、避免触碰 64MB 请求体上限。
"""
image, _ = _tensor_to_png(tensor, label)
return await upload_image(image, base_url=base_url)
def _validate_request_body_size(body: dict, tag: str):
@@ -82,6 +174,57 @@ def _validate_request_body_size(body: dict, tag: str):
)
_REQUEST_LOG_SECRET_FIELDS = {
"authorization",
"api_key",
"apikey",
"api-key",
"access_token",
"token",
}
_REQUEST_LOG_BASE64_FIELDS = {"data", "b64_json", "base64", "image_base64"}
_REQUEST_LOG_URL_FIELDS = {"url", "image", "image_url", "video_url", "audio_url"}
def _sanitize_request_body_for_log(value, field_name: str = ""):
"""Copy a request body for logging without exposing credentials or media URLs."""
normalized_field = field_name.lower()
if normalized_field in _REQUEST_LOG_SECRET_FIELDS:
return "<redacted>"
if isinstance(value, dict):
return {
key: _sanitize_request_body_for_log(item, str(key))
for key, item in value.items()
}
if isinstance(value, list):
return [_sanitize_request_body_for_log(item, field_name) for item in value]
if isinstance(value, (bytes, bytearray)):
return f"<binary data, {len(value)} bytes>"
if not isinstance(value, str):
return value
if normalized_field in _REQUEST_LOG_BASE64_FIELDS:
return f"<base64 data, {len(value)} chars>"
header, separator, data = value.partition(",")
if separator and header.lower().startswith("data:") and ";base64" in header.lower():
return f"{header},<base64 data, {len(data)} chars>"
if normalized_field in _REQUEST_LOG_URL_FIELDS and value.lower().startswith(("http://", "https://")):
return "<temporary URL omitted>"
return value
def _log_original_request_body(body: dict):
safe_body = _sanitize_request_body_for_log(body)
print(
"[SeedanceMultiModal] 原始请求体(临时 URL 与媒体数据已折叠):\n"
f"{json.dumps(safe_body, ensure_ascii=False, indent=2)}"
)
async def _url_to_tensor(url: str) -> torch.Tensor:
"""从 URL 下载图片并转为 ComfyUI IMAGE tensor,失败时返回 None"""
@@ -92,7 +235,7 @@ async def _url_to_tensor(url: str) -> torch.Tensor:
if resp.status != 200:
return None
data = await resp.read()
img = Image.open(io.BytesIO(data)).convert("RGB")
img = Image.open(py_io.BytesIO(data)).convert("RGB")
return pil_to_tensor([img])
except Exception as e:
print(f"[Seedance] 末帧图片下载失败: {e}")
@@ -139,280 +282,241 @@ def _make_callbacks(tag: str, pbar):
# ── 统一节点 ─────────────────────────────────────────────────────────────────
#
# 模式由图片输入自动判断:
# 首帧 = None → T2V 文生视频 (联网搜索生效)
# 首帧 = 图片,尾帧 = None → I2V 图生视频 (固定镜头生效,当前 2.0 不支持故忽略)
# 首帧 = 图片,尾帧 = 图片 → FlipFlop 首尾帧(联网搜索/固定镜头均忽略)
class Seedance:
"""Seedance 视频生成(文生视频 / 图生视频 / 首尾帧,自动判断模式)"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"提示词": ("STRING", {"multiline": True, "default": ""}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
"模型": (_MODELS, {"default": "doubao-seedance-2-0-260128"}),
"分辨率": (_RESOLUTIONS, {"default": "720p"}),
"宽高比": (["16:9", "adaptive", "9:16", "1:1", "4:3", "3:4", "21:9"],
{"default": "16:9"}),
"时长秒(-1=自动)": ("INT", {"default": 5, "min": -1, "max": 30, "step": 1}),
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
"联网搜索": (["关闭", "打开"], {"default": "关闭"}),
"返回末帧图片": (["关闭", "打开"], {"default": "关闭"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
},
"optional": {
"首帧图片": ("IMAGE",),
"尾帧图片": ("IMAGE",),
},
}
RETURN_TYPES = ("VIDEO", "IMAGE")
RETURN_NAMES = ("视频", "末帧图片")
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Seedance"
async def generate(self, **kwargs):
prompt = kwargs["提示词"].strip()
model = kwargs["模型"]
resolution = kwargs["分辨率"]
ratio = kwargs["宽高比"]
duration = kwargs["时长秒(-1=自动)"]
gen_audio = kwargs["生成音频"] == "打开"
web_search = kwargs["联网搜索"] == "打开"
return_last = kwargs["返回末帧图片"] == "打开"
seed = kwargs.get("seed", 0)
first_image = kwargs.get("首帧图片", None)
last_image = kwargs.get("尾帧图片", None)
# 模式判断
if first_image is None and last_image is not None:
raise ValueError("请同时接入首帧图片,或仅接入首帧图片。")
if first_image is None:
mode = "t2v"
tag = "Seedance文生视频"
file_prefix = "seedance_t2v"
elif last_image is None:
mode = "i2v"
tag = "Seedance图生视频"
file_prefix = "seedance_i2v"
else:
mode = "flipflop"
tag = "Seedance首尾帧"
file_prefix = "seedance_flip"
if not prompt:
raise ValueError("提示词不能为空。")
if duration == -1 and mode == "t2v":
pass # 2.0 均支持自动时长
elif duration == -1 and mode != "t2v":
pass # 2.0 均支持自动时长
metadata: dict = {
"resolution": resolution,
"watermark": False,
}
if ratio != "adaptive":
metadata["ratio"] = ratio
if duration != -1:
metadata["duration"] = duration
if gen_audio:
metadata["generate_audio"] = True
if return_last:
metadata["return_last_frame"] = True
if seed != 0:
metadata["seed"] = seed
# 模式专属参数
if mode == "t2v":
if web_search:
metadata["tools"] = [{"type": "web_search"}]
body = {
"model": model,
"prompt": prompt,
"metadata": metadata,
}
elif mode == "i2v":
first_url = _tensor_to_base64_url(first_image, "首帧图片")
metadata["content"] = [
{
"type": "image_url",
"image_url": {"url": first_url},
"role": "first_frame",
},
{"type": "text", "text": prompt},
]
body = {
"model": model,
"prompt": prompt,
"images": [first_url],
"metadata": metadata,
}
else: # flipflop
first_url = _tensor_to_base64_url(first_image, "首帧图片")
last_url = _tensor_to_base64_url(last_image, "尾帧图片")
metadata["content"] = [
{
"type": "image_url",
"image_url": {"url": first_url},
"role": "first_frame",
},
{
"type": "image_url",
"image_url": {"url": last_url},
"role": "last_frame",
},
{"type": "text", "text": prompt},
]
body = {
"model": model,
"prompt": prompt,
"images": [first_url],
"metadata": metadata,
}
_validate_request_body_size(body, tag)
# 保存路径(临时文件,避免与下游保存节点重复落盘)
_, save_path = tempfile.mkstemp(suffix=".mp4", prefix=f"{file_prefix}_")
client = SeedanceClient()
client.base_url = get_base_url_by_route(kwargs.get("网络线路", "全球加速"))
pbar = _make_pbar()
on_stage, on_prog = _make_callbacks(tag, pbar)
try:
result_path, last_frame_url = await client.generate_async(
body=body, save_path=save_path,
on_stage=on_stage, on_progress=on_prog,
)
last_frame_tensor = None
if return_last and last_frame_url:
last_frame_tensor = await _url_to_tensor(last_frame_url)
return (InputImpl.VideoFromFile(result_path), last_frame_tensor)
finally:
_show_balance()
# ── 多模态参考生视频节点 ──────────────────────────────────────────────────────
class SeedanceMultiModal:
"""Seedance 2.0 多模态参考生视频(参考图片 + 参考视频 + 参考音频 + 文本)"""
class SeedanceMultiModal(io.ComfyNode):
"""Seedance 2.0 / 2.5 多模态参考生视频"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"提示词": ("STRING", {"multiline": True, "default": ""}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
"模型": (_MODELS, {"default": "doubao-seedance-2-0-260128"}),
"分辨率": (_RESOLUTIONS, {"default": "720p"}),
"宽高比": (["adaptive", "16:9", "9:16", "1:1", "4:3", "3:4", "21:9"],
{"default": "adaptive"}),
"时长秒(-1=自动)": ("INT", {"default": 5, "min": -1, "max": 15, "step": 1}),
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
"联网搜索": (["关闭", "打开"], {"default": "关闭"}),
"返回末帧图片": (["关闭", "打开"], {"default": "关闭"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
},
"optional": {
"参考图片": ("IMAGE",),
"参考视频1": ("VIDEO",),
"参考视频2": ("VIDEO",),
"参考视频3": ("VIDEO",),
"参考音频1": ("AUDIO",),
"参考音频2": ("AUDIO",),
"参考音频3": ("AUDIO",),
},
}
def define_schema(cls):
return io.Schema(
node_id="SeedanceMultiModal",
display_name="Seedance 多模态参考生视频",
description="支持动态增加参考图片、视频、音频,以及渐进填写素材 ID。",
category="comfyui_o1key/Seedance",
inputs=[
io.String.Input("提示词", multiline=True, default=""),
io.Combo.Input("主模型", options=_MM_BASE_MODELS, default="seedance 2.0"),
io.Combo.Input("模型线路", options=_MM_ROUTES, default="国内"),
io.Combo.Input("分辨率", options=_MM_RESOLUTIONS, default="720p"),
io.Combo.Input(
"宽高比",
options=_MM_RATIOS,
default="智能",
),
io.Combo.Input(
"时长",
options=["自动"] + [f"{i}" for i in range(4, 31)],
default="5秒",
),
io.Combo.Input("生成音频", options=["关闭", "打开"], default="关闭"),
io.Combo.Input("联网搜索", options=["关闭", "打开"], default="关闭"),
io.Combo.Input("返回末帧图片", options=["关闭", "打开"], default="关闭"),
io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff),
io.Autogrow.Input(
"参考图片",
optional=True,
template=io.Autogrow.TemplateNames(
input=io.Image.Input("参考图片"),
names=[f"参考图片{i}" for i in range(1, _MM_IMAGE_LIMIT + 1)],
min=0,
),
),
io.Autogrow.Input(
"参考视频",
optional=True,
template=io.Autogrow.TemplateNames(
input=io.Video.Input("参考视频"),
names=[f"参考视频{i}" for i in range(1, _MM_VIDEO_LIMIT + 1)],
min=0,
),
),
io.Autogrow.Input(
"参考音频",
optional=True,
template=io.Autogrow.TemplateNames(
input=io.Audio.Input("参考音频"),
names=[f"参考音频{i}" for i in range(1, _MM_AUDIO_LIMIT + 1)],
min=0,
),
),
# 保留已发布的 9/3/3 widget 顺序;只更新图片素材的显示名称,
# 2.5 的新增素材 ID 仍仅追加,避免 widgets_values 发生位置漂移。
*[io.String.Input(f"图片素材ID{i}", default="") for i in range(1, 10)],
*[io.String.Input(f"视频素材ID{i}", default="") for i in range(1, 4)],
*[io.String.Input(f"音频素材ID{i}", default="") for i in range(1, 4)],
*[io.String.Input(f"图片素材ID{i}", default="") for i in range(10, _MM_IMAGE_LIMIT + 1)],
*[io.String.Input(f"视频素材ID{i}", default="") for i in range(4, _MM_VIDEO_LIMIT + 1)],
*[io.String.Input(f"音频素材ID{i}", default="") for i in range(4, _MM_AUDIO_LIMIT + 1)],
],
outputs=[
io.Video.Output(display_name="视频"),
io.Image.Output(display_name="末帧图片"),
],
)
RETURN_TYPES = ("VIDEO", "IMAGE")
RETURN_NAMES = ("视频", "末帧图片")
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Seedance"
INPUT_IS_LIST = True
@staticmethod
def _autogrow_values(kwargs, group_name, legacy_prefix, legacy_max):
"""读取 V3 动态输入,同时兼容旧工作流的编号端口直接调用。"""
group = kwargs.get(group_name)
if isinstance(group, dict):
return [value for value in group.values() if value is not None]
async def generate(self, **kwargs):
# INPUT_IS_LIST=True 时所有参数都是列表,取第一个元素
def _first(value):
if isinstance(value, list):
return value[0] if value else None
return value
return [
value
for index in range(1, legacy_max + 1)
if (value := _first(kwargs.get(f"{legacy_prefix}{index}"))) is not None
]
@classmethod
async def execute(cls, **kwargs):
return await cls.generate(**kwargs)
@classmethod
async def generate(cls, **kwargs):
# V3 传入标量;保留列表兼容旧版 INPUT_IS_LIST 的直接调用。
def _first(v, default=None):
if isinstance(v, list):
return v[0] if v else default
return v if v is not None else default
prompt = _first(kwargs.get("提示词"), "").strip()
model = _first(kwargs.get("模型"))
base_model = _first(kwargs.get("模型"), "seedance 2.0")
route = _first(kwargs.get("模型线路"), "国内")
model = _resolve_model_matrix(base_model, route)
resolution = _first(kwargs.get("分辨率"))
ratio = _first(kwargs.get("宽高比"))
duration = _first(kwargs.get("时长秒(-1=自动)"), 5)
duration_str = _first(kwargs.get("时长"), "5秒")
# 解析时长:自动 → -1,其他提取数字
if duration_str == "自动":
duration = -1
else:
duration = int(duration_str.replace("", ""))
gen_audio = _first(kwargs.get("生成音频"), "关闭") == "打开"
web_search = _first(kwargs.get("联网搜索"), "关闭") == "打开"
return_last = _first(kwargs.get("返回末帧图片"), "关闭") == "打开"
seed = _first(kwargs.get("seed"), 0)
network_route = _first(kwargs.get("网络线路"), "全球加速")
# 参考图片:INPUT_IS_LIST 时是 [tensor, tensor, ...] 列表,直接保留
raw_images = kwargs.get("参考图片", None)
ref_images = [img for img in raw_images if img is not None] if raw_images else None
ref_images = cls._autogrow_values(kwargs, "参考图片", "参考图片", _MM_IMAGE_LIMIT)
ref_videos = cls._autogrow_values(kwargs, "参考视频", "参考视频", _MM_VIDEO_LIMIT)
ref_audios = cls._autogrow_values(kwargs, "参考音频", "参考音频", _MM_AUDIO_LIMIT)
# 新名称优先;旧名称兼容未经过浏览器迁移的 API 工作流。
element_ids = []
for i in range(1, _MM_IMAGE_LIMIT + 1):
current_id = _first(kwargs.get(f"图片素材ID{i}"), "").strip()
legacy_id = _first(kwargs.get(f"真人素材ID{i}"), "").strip()
element_ids.append(current_id or legacy_id)
video_ids = [_first(kwargs.get(f"视频素材ID{i}"), "").strip() for i in range(1, _MM_VIDEO_LIMIT + 1)]
audio_ids = [_first(kwargs.get(f"音频素材ID{i}"), "").strip() for i in range(1, _MM_AUDIO_LIMIT + 1)]
ref_videos = [_first(kwargs.get(f"参考视频{i}")) for i in range(1, 4)]
ref_audios = [_first(kwargs.get(f"参考音频{i}")) for i in range(1, 4)]
ref_videos = [v for v in ref_videos if v is not None]
ref_audios = [a for a in ref_audios if a is not None]
element_ids = [eid for eid in element_ids if eid]
video_ids = [vid for vid in video_ids if vid]
audio_ids = [aid for aid in audio_ids if aid]
# ── 校验 ──────────────────────────────────────────────────────────
has_image = bool(ref_images)
has_video = len(ref_videos) > 0
has_audio = len(ref_audios) > 0
has_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:取第一张参考图的 base64new-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 多模态参考生视频",
}