Files
comfyui_o1key/nodes/seedance_video.py
T
o1keyandClaude Sonnet 4.5 03d477648a feat: 接入 Seedance 视频节点并增强图像节点功能
- 新增 Seedance 文生视频、图生视频、首尾帧生视频三个节点
- 新增 seedance_client.py 客户端
- 为 NanoBananaPro、BatchNanoBananaPro、全能生图节点添加「跳过错误」开关,出错时返回占位图以继续队列
- 修复 nano-banana-2-限时特价 动态端点路由(按分辨率选择)
- 统一分辨率选项命名:512 → 512px

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-04-08 18:32:18 +08:00

469 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Seedance 视频生成节点
节点列表:
- SeedanceT2V: 文生视频
- SeedanceI2V: 图生视频(首帧驱动)
- SeedanceFlipFlop: 首尾帧生视频
"""
import os
import re
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 comfy_api.latest import InputImpl
try:
import folder_paths
FOLDER_PATHS_AVAILABLE = True
except ImportError:
FOLDER_PATHS_AVAILABLE = False
# ── 模型列表 ──────────────────────────────────────────────────────────────────
_T2V_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",
]
# ── 模型能力判断 ──────────────────────────────────────────────────────────────
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)
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def _get_video_output_dir() -> str:
if FOLDER_PATHS_AVAILABLE:
base = folder_paths.get_output_directory()
else:
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base = os.path.join(os.path.dirname(os.path.dirname(plugin_dir)), "output")
video_dir = os.path.join(base, "video")
os.makedirs(video_dir, exist_ok=True)
return video_dir
def _get_next_counter(directory: str, prefix: str) -> int:
if not os.path.exists(directory):
return 1
pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)")
max_counter = 0
for f in os.listdir(directory):
m = pattern.match(f)
if m:
max_counter = max(max_counter, int(m.group(1)))
return max_counter + 1
def _tensor_to_base64_url(tensor) -> str:
"""ComfyUI IMAGE tensor → data:image/png;base64,xxx"""
pil_images = tensor_to_pil(tensor)
b64 = encode_image_to_base64(pil_images[0], format="PNG")
return f"data:image/png;base64,{b64}"
def _show_balance():
"""完成后打印余额(静默失败)"""
try:
client = GeminiAPIClient()
data = client.query_balance_sync()
print(f"Seedance: {client.format_balance_info(data)}")
except Exception:
pass
def _make_pbar():
try:
from comfy.utils import ProgressBar
return ProgressBar(100)
except Exception:
return None
def _make_callbacks(tag: str, pbar):
"""生成通用的 on_stage / on_progress 回调"""
def on_stage(stage: str):
if stage == "submitting":
print(f"[{tag}] 提交中...")
if pbar: pbar.update_absolute(0, 100)
elif stage.startswith("submitted:"):
print(f"[{tag}] 已提交 → {stage.split(':', 1)[1]}")
if pbar: pbar.update_absolute(5, 100)
elif stage == "downloading":
print(f"[{tag}] 下载视频中...")
if pbar: pbar.update_absolute(99, 100)
elif stage == "done":
print(f"[{tag}] 完成")
if pbar: pbar.update_absolute(100, 100)
def on_progress(pct: int):
if pbar: pbar.update_absolute(5 + int(pct * 0.94), 100)
return on_stage, on_progress
# ── 节点 1:文生视频 ─────────────────────────────────────────────────────────
class SeedanceT2V:
"""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"}),
"宽高比": (["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": {
"首帧图片": ("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 = ("视频",)
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)
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,
},
]
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 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,
}
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")
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()
# ── 节点注册 ──────────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {
"SeedanceT2V": SeedanceT2V,
"SeedanceI2V": SeedanceI2V,
"SeedanceFlipFlop": SeedanceFlipFlop,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SeedanceT2V": "Seedance 文生视频",
"SeedanceI2V": "Seedance 图生视频",
"SeedanceFlipFlop": "Seedance 首尾帧生视频",
}