- 将 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]>
298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""
|
|
Seedance 视频生成节点
|
|
节点列表:
|
|
- 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, pil_to_tensor
|
|
|
|
from comfy_api.latest import InputImpl
|
|
|
|
try:
|
|
import folder_paths
|
|
FOLDER_PATHS_AVAILABLE = True
|
|
except ImportError:
|
|
FOLDER_PATHS_AVAILABLE = False
|
|
|
|
|
|
# ── 模型列表 ──────────────────────────────────────────────────────────────────
|
|
|
|
_MODELS = [
|
|
"doubao-seedance-2-0-260128",
|
|
"doubao-seedance-2-0-fast-260128",
|
|
]
|
|
|
|
_RESOLUTIONS = ["720p", "480p"]
|
|
|
|
|
|
# ── 模型能力判断 ──────────────────────────────────────────────────────────────
|
|
|
|
def _supports_camera_fixed(model: str) -> bool:
|
|
"""2.0 系列不支持固定镜头"""
|
|
return False # 当前仅 2.0 模型,均不支持
|
|
|
|
|
|
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
|
|
|
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}"
|
|
|
|
|
|
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:
|
|
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):
|
|
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
|
|
|
|
|
|
# ── 统一节点 ─────────────────────────────────────────────────────────────────
|
|
#
|
|
# 模式由图片输入自动判断:
|
|
# 首帧 = None → T2V 文生视频 (联网搜索生效)
|
|
# 首帧 = 图片,尾帧 = None → I2V 图生视频 (固定镜头生效,当前 2.0 不支持故忽略)
|
|
# 首帧 = 图片,尾帧 = 图片 → FlipFlop 首尾帧(联网搜索/固定镜头均忽略)
|
|
|
|
class Seedance:
|
|
"""Seedance 视频生成(文生视频 / 图生视频 / 首尾帧,自动判断模式)"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"提示词": ("STRING", {"multiline": True, "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,
|
|
}
|
|
|
|
video_dir = _get_video_output_dir()
|
|
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(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()
|
|
|
|
|
|
# ── 节点注册 ──────────────────────────────────────────────────────────────────
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"Seedance": Seedance,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"Seedance": "Seedance 视频生成",
|
|
}
|