refactor: 重构 Seedance 节点,三合一并修复轮询 Bug

- 将 SeedanceT2V / SeedanceI2V / SeedanceFlipFlop 合并为单一 Seedance 节点
- 通过图片输入自动判断模式:无图=文生视频,首帧=图生视频,首尾帧=首尾帧模式
- 修复轮询状态字段取值路径错误导致的无限循环问题
- 修复视频 URL / 末帧 URL 取值路径(result.data.content.video_url)
- 新增末帧图片 IMAGE 输出端,支持 return_last_frame 功能
- 删除水印、服务等级前端参数,移除 1.0/1.5 旧模型,去掉 1080p 分辨率
- 关闭 DEBUG 原始响应日志,仅保留用户可见进度日志

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
This commit is contained in:
Jony
2026-04-09 01:34:17 +08:00
co-authored by Claude Sonnet 4.5
parent 03d477648a
commit 0ddc571f20
4 changed files with 165 additions and 330 deletions
+134 -305
View File
@@ -1,17 +1,19 @@
"""
Seedance 视频生成节点
节点列表:
- SeedanceT2V: 文生视频
- SeedanceI2V: 图生视频(首帧驱动)
- SeedanceFlipFlop: 首尾帧生视频
- Seedance: 文生视频 / 图生视频 / 首尾帧生视频(根据图片输入自动切换模式)
"""
import io
import os
import re
import aiohttp
import torch
from ..clients.seedance_client import SeedanceClient
from ..clients.gemini_client import GeminiAPIClient
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64, pil_to_tensor
from comfy_api.latest import InputImpl
@@ -24,53 +26,19 @@ except ImportError:
# ── 模型列表 ──────────────────────────────────────────────────────────────────
_T2V_MODELS = [
_MODELS = [
"doubao-seedance-2-0-260128",
"doubao-seedance-2-0-fast-260128",
"doubao-seedance-1-5-pro-251215",
"doubao-seedance-1-0-pro-250528",
"doubao-seedance-1-0-lite-t2v",
]
_I2V_MODELS = [
"doubao-seedance-2-0-260128",
"doubao-seedance-2-0-fast-260128",
"doubao-seedance-1-5-pro-251215",
"doubao-seedance-1-0-pro-250528",
"doubao-seedance-1-0-lite-i2v",
]
_FLIPFLOP_MODELS = [
"doubao-seedance-2-0-260128",
"doubao-seedance-2-0-fast-260128",
"doubao-seedance-1-5-pro-251215",
"doubao-seedance-1-0-pro-250528",
]
_RESOLUTIONS = ["720p", "480p"]
# ── 模型能力判断 ──────────────────────────────────────────────────────────────
def _is_v2(model: str) -> bool:
return "seedance-2-0" in model
def _is_v15_pro(model: str) -> bool:
return "seedance-1-5-pro" in model
def _supports_audio(model: str) -> bool:
"""2.0、2.0-fast、1.5-pro 支持生成音频"""
return _is_v2(model) or _is_v15_pro(model)
def _supports_auto_duration(model: str) -> bool:
"""2.0 和 1.5-pro 支持自动时长(duration 不传或传 -1"""
return _is_v2(model) or _is_v15_pro(model)
def _supports_camera_fixed(model: str) -> bool:
"""仅非 2.0 模型支持固定镜头2.0 已不支持)"""
return not _is_v2(model)
def _supports_web_search(model: str) -> bool:
"""仅 2.0 系列支持联网搜索"""
return _is_v2(model)
"""2.0 系列不支持固定镜头"""
return False # 当前仅 2.0 模型,均不支持
# ── 工具函数 ──────────────────────────────────────────────────────────────────
@@ -105,6 +73,22 @@ def _tensor_to_base64_url(tensor) -> str:
return f"data:image/png;base64,{b64}"
async def _url_to_tensor(url: str) -> torch.Tensor:
"""从 URL 下载图片并转为 ComfyUI IMAGE tensor,失败时返回 None"""
try:
from PIL import Image
async with aiohttp.ClientSession() as session:
async with session.get(url, allow_redirects=True) as resp:
if resp.status != 200:
return None
data = await resp.read()
img = Image.open(io.BytesIO(data)).convert("RGB")
return pil_to_tensor([img])
except Exception as e:
print(f"[Seedance] 末帧图片下载失败: {e}")
return None
def _show_balance():
"""完成后打印余额(静默失败)"""
try:
@@ -124,7 +108,6 @@ def _make_pbar():
def _make_callbacks(tag: str, pbar):
"""生成通用的 on_stage / on_progress 回调"""
def on_stage(stage: str):
if stage == "submitting":
print(f"[{tag}] 提交中...")
@@ -145,310 +128,160 @@ def _make_callbacks(tag: str, pbar):
return on_stage, on_progress
# ── 节点 1:文生视频 ─────────────────────────────────────────────────────────
# ── 统一节点 ─────────────────────────────────────────────────────────────────
#
# 模式由图片输入自动判断:
# 首帧 = None → T2V 文生视频 (联网搜索生效)
# 首帧 = 图片,尾帧 = None → I2V 图生视频 (固定镜头生效,当前 2.0 不支持故忽略)
# 首帧 = 图片,尾帧 = 图片 → FlipFlop 首尾帧(联网搜索/固定镜头均忽略)
class SeedanceT2V:
"""Seedance 文生视频"""
class Seedance:
"""Seedance 视频生成(文生视频 / 图生视频 / 首尾帧,自动判断模式)"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"提示词": ("STRING", {"multiline": True, "default": ""}),
"模型": (_T2V_MODELS, {"default": "doubao-seedance-2-0-260128"}),
"分辨率": (["720p", "1080p", "480p"], {"default": "720p"}),
"模型": (_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": "关闭"}),
"联网搜索": (["关闭", "打开"], {"default": "关闭"}),
"服务等级": (["default", "flex"], {"default": "default"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
}
}
RETURN_TYPES = ("VIDEO",)
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["生成音频"] == "打开"
watermark = kwargs["水印"] == "打开"
return_last = kwargs["返回末帧图片"] == "打开"
web_search = kwargs["联网搜索"] == "打开"
service_tier = kwargs["服务等级"]
seed = kwargs.get("seed", 0)
if not prompt:
raise ValueError("提示词不能为空。")
if duration == -1 and not _supports_auto_duration(model):
raise ValueError(f"模型 {model} 不支持自动时长(-1),请改用 2.0 或 1.5-pro 模型。")
metadata: dict = {
"resolution": resolution,
"watermark": watermark,
}
if ratio != "adaptive":
metadata["ratio"] = ratio
if duration != -1:
metadata["duration"] = duration
if gen_audio and _supports_audio(model):
metadata["generate_audio"] = True
if return_last:
metadata["return_last_frame"] = True
if web_search and _supports_web_search(model):
metadata["tools"] = [{"type": "web_search"}]
if seed != 0:
metadata["seed"] = seed
body = {
"model": model,
"prompt": prompt,
"metadata": metadata,
"service_tier": service_tier,
}
video_dir = _get_video_output_dir()
counter = _get_next_counter(video_dir, "seedance_t2v")
save_path = os.path.join(video_dir, f"seedance_t2v_{counter:05d}.mp4")
client = SeedanceClient()
pbar = _make_pbar()
on_stage, on_prog = _make_callbacks("Seedance文生视频", pbar)
try:
result_path = await client.generate_async(
body=body, save_path=save_path,
on_stage=on_stage, on_progress=on_prog,
)
return (InputImpl.VideoFromFile(result_path),)
finally:
_show_balance()
# ── 节点 2:图生视频(首帧驱动) ──────────────────────────────────────────────
class SeedanceI2V:
"""Seedance 图生视频(首帧驱动)"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"首帧图片": ("IMAGE",),
"提示词": ("STRING", {"multiline": True, "default": ""}),
"模型": (_I2V_MODELS, {"default": "doubao-seedance-2-0-260128"}),
"分辨率": (["720p", "1080p", "480p"], {"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": "关闭"}),
"返回末帧图片": (["关闭", "打开"], {"default": "关闭"}),
"服务等级": (["default", "flex"], {"default": "default"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
}
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Seedance"
async def generate(self, **kwargs):
image = kwargs["首帧图片"]
prompt = kwargs["提示词"].strip()
model = kwargs["模型"]
resolution = kwargs["分辨率"]
ratio = kwargs["宽高比"]
duration = kwargs["时长秒(-1=自动)"]
gen_audio = kwargs["生成音频"] == "打开"
watermark = kwargs["水印"] == "打开"
cam_fixed = kwargs["固定镜头"] == "打开"
return_last = kwargs["返回末帧图片"] == "打开"
service_tier = kwargs["服务等级"]
seed = kwargs.get("seed", 0)
if not prompt:
raise ValueError("提示词不能为空。")
if duration == -1 and not _supports_auto_duration(model):
raise ValueError(f"模型 {model} 不支持自动时长(-1),请改用 2.0 或 1.5-pro 模型。")
image_url = _tensor_to_base64_url(image)
# 使用 metadata.content 携带带 role 的图片(会覆盖 new-api 从 images 字段构建的 content
content = [
{
"type": "image_url",
"image_url": {"url": image_url},
"role": "first_frame",
},
{
"type": "text",
"text": prompt,
},
]
metadata: dict = {
"resolution": resolution,
"watermark": watermark,
"content": content,
}
if ratio != "adaptive":
metadata["ratio"] = ratio
if duration != -1:
metadata["duration"] = duration
if gen_audio and _supports_audio(model):
metadata["generate_audio"] = True
if cam_fixed and _supports_camera_fixed(model):
metadata["camera_fixed"] = True
if return_last:
metadata["return_last_frame"] = True
if seed != 0:
metadata["seed"] = seed
body = {
"model": model,
"prompt": prompt,
"images": [image_url], # 供 new-api HasImage() 识别,触发正确计费路径
"metadata": metadata,
"service_tier": service_tier,
}
video_dir = _get_video_output_dir()
counter = _get_next_counter(video_dir, "seedance_i2v")
save_path = os.path.join(video_dir, f"seedance_i2v_{counter:05d}.mp4")
client = SeedanceClient()
pbar = _make_pbar()
on_stage, on_prog = _make_callbacks("Seedance图生视频", pbar)
try:
result_path = await client.generate_async(
body=body, save_path=save_path,
on_stage=on_stage, on_progress=on_prog,
)
return (InputImpl.VideoFromFile(result_path),)
finally:
_show_balance()
# ── 节点 3:首尾帧生视频 ─────────────────────────────────────────────────────
class SeedanceFlipFlop:
"""Seedance 首尾帧生视频(同时指定起始帧与结束帧)"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"optional": {
"首帧图片": ("IMAGE",),
"尾帧图片": ("IMAGE",),
"提示词": ("STRING", {"multiline": True, "default": ""}),
"模型": (_FLIPFLOP_MODELS, {"default": "doubao-seedance-2-0-260128"}),
"分辨率": (["720p", "1080p", "480p"], {"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": "关闭"}),
"服务等级": (["default", "flex"], {"default": "default"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
}
},
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
RETURN_TYPES = ("VIDEO", "IMAGE")
RETURN_NAMES = ("视频", "末帧图片")
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Seedance"
async def generate(self, **kwargs):
first_image = kwargs["首帧图片"]
last_image = kwargs["尾帧图片"]
prompt = kwargs["提示词"].strip()
model = kwargs["模型"]
resolution = kwargs["分辨率"]
ratio = kwargs["宽高比"]
duration = kwargs["时长秒(-1=自动)"]
gen_audio = kwargs["生成音频"] == "打开"
watermark = kwargs["水印"] == "打开"
return_last = kwargs["返回末帧图片"] == "打开"
service_tier = kwargs["服务等级"]
seed = kwargs.get("seed", 0)
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 not _supports_auto_duration(model):
raise ValueError(f"模型 {model} 不支持自动时长(-1),请改用 2.0 或 1.5-pro 模型。")
first_url = _tensor_to_base64_url(first_image)
last_url = _tensor_to_base64_url(last_image)
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,
},
]
if duration == -1 and mode == "t2v":
pass # 2.0 均支持自动时长
elif duration == -1 and mode != "t2v":
pass # 2.0 均支持自动时长
metadata: dict = {
"resolution": resolution,
"watermark": watermark,
"content": content,
"watermark": False,
}
if ratio != "adaptive":
metadata["ratio"] = ratio
if duration != -1:
metadata["duration"] = duration
if gen_audio and _supports_audio(model):
if gen_audio:
metadata["generate_audio"] = True
if return_last:
metadata["return_last_frame"] = True
if seed != 0:
metadata["seed"] = seed
body = {
"model": model,
"prompt": prompt,
"images": [first_url], # 供 new-api HasImage() 识别
"metadata": metadata,
"service_tier": service_tier,
}
# 模式专属参数
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,
}
video_dir = _get_video_output_dir()
counter = _get_next_counter(video_dir, "seedance_flip")
save_path = os.path.join(video_dir, f"seedance_flip_{counter:05d}.mp4")
counter = _get_next_counter(video_dir, file_prefix)
save_path = os.path.join(video_dir, f"{file_prefix}_{counter:05d}.mp4")
client = SeedanceClient()
pbar = _make_pbar()
on_stage, on_prog = _make_callbacks("Seedance首尾帧", pbar)
client = SeedanceClient()
pbar = _make_pbar()
on_stage, on_prog = _make_callbacks(tag, pbar)
try:
result_path = await client.generate_async(
result_path, last_frame_url = await client.generate_async(
body=body, save_path=save_path,
on_stage=on_stage, on_progress=on_prog,
)
return (InputImpl.VideoFromFile(result_path),)
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()
@@ -456,13 +289,9 @@ class SeedanceFlipFlop:
# ── 节点注册 ──────────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {
"SeedanceT2V": SeedanceT2V,
"SeedanceI2V": SeedanceI2V,
"SeedanceFlipFlop": SeedanceFlipFlop,
"Seedance": Seedance,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SeedanceT2V": "Seedance 文生视频",
"SeedanceI2V": "Seedance 图生视频",
"SeedanceFlipFlop": "Seedance 首尾帧生视频",
"Seedance": "Seedance 视频生成",
}