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
+3 -7
View File
@@ -21,7 +21,7 @@ except Exception:
import ssl import ssl
from .nodes import NanoBananaPro, BatchNanoBananaPro, GoogleGemini, LoadFile, ImageStitchPro, SaveCleanImage, BatchCleanMetadata, VideoPreview, GoogleVeo, FluxImageEdit, UniversalLLMChat, KlingVideo, KlingFirstLastFrame, KlingMotionControlTest, QuanNengShengTu, BatchQuanNengShengTu, AspectRatioPreset, MultiResPreview, BatchImagesO1key, SeedanceT2V, SeedanceI2V, SeedanceFlipFlop from .nodes import NanoBananaPro, BatchNanoBananaPro, GoogleGemini, LoadFile, ImageStitchPro, SaveCleanImage, BatchCleanMetadata, VideoPreview, GoogleVeo, FluxImageEdit, UniversalLLMChat, KlingVideo, KlingFirstLastFrame, KlingMotionControlTest, QuanNengShengTu, BatchQuanNengShengTu, AspectRatioPreset, MultiResPreview, BatchImagesO1key, Seedance
# 报错弹框友好文案(不修改原节点代码,仅在外层统一处理) # 报错弹框友好文案(不修改原节点代码,仅在外层统一处理)
_MSG_TIMEOUT = "API 请求超时,请稍后重试或检查网络。" _MSG_TIMEOUT = "API 请求超时,请稍后重试或检查网络。"
@@ -79,9 +79,7 @@ NODE_CLASS_MAPPINGS = {
"AspectRatioPreset": AspectRatioPreset, "AspectRatioPreset": AspectRatioPreset,
"MultiResPreview": MultiResPreview, "MultiResPreview": MultiResPreview,
"BatchImagesO1key": BatchImagesO1key, "BatchImagesO1key": BatchImagesO1key,
"SeedanceT2V": SeedanceT2V, "Seedance": Seedance,
"SeedanceI2V": SeedanceI2V,
"SeedanceFlipFlop": SeedanceFlipFlop,
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
@@ -104,9 +102,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"AspectRatioPreset": "图片宽高比预设", "AspectRatioPreset": "图片宽高比预设",
"MultiResPreview": "预览图像(v2", "MultiResPreview": "预览图像(v2",
"BatchImagesO1key": "加载图像(批量)", "BatchImagesO1key": "加载图像(批量)",
"SeedanceT2V": "Seedance 文生视频", "Seedance": "Seedance 视频生成",
"SeedanceI2V": "Seedance 图生视频",
"SeedanceFlipFlop": "Seedance 首尾帧生视频",
} }
WEB_DIRECTORY = "./web" WEB_DIRECTORY = "./web"
+26 -16
View File
@@ -92,37 +92,47 @@ class SeedanceClient:
raise RuntimeError(f"状态查询失败 ({resp.status}): {msg}") raise RuntimeError(f"状态查询失败 ({resp.status}): {msg}")
result = json.loads(text) result = json.loads(text)
status = (result.get("status") or "").lower() # new-api 包装格式:真实数据在 result["data"] 里
inner = result.get("data") or result
# 调试:打印原始响应(排查状态字段问题后可删除) status = (inner.get("status") or "").lower()
print(f"[Seedance][DEBUG] 原始响应: {result}")
# 解析进度 # 解析进度
progress_raw = result.get("progress", "0") progress_raw = inner.get("progress", "0")
try: try:
progress_pct = int(str(progress_raw).rstrip("%").strip()) progress_pct = int(str(progress_raw).rstrip("%").strip())
except (ValueError, AttributeError): except (ValueError, AttributeError):
progress_pct = 0 progress_pct = 0
print(f"[Seedance] 生成中 {progress_pct}% (status={status})") print(f"[Seedance] 生成中 {progress_pct}%")
if on_progress: if on_progress:
on_progress(progress_pct) on_progress(progress_pct)
if status in self.SUCCESS_STATUSES: if status in self.SUCCESS_STATUSES:
# 取视频 URLurl / metadata.url / output.video_url # 响应结构:result["data"] = innerinner["data"] = platform_data
# 视频 URL 在 inner["result_url"] 或 inner["data"]["content"]["video_url"]
platform_data = inner.get("data") or {}
content = platform_data.get("content") or {}
video_url = ( video_url = (
result.get("url") inner.get("result_url")
or (result.get("output") or {}).get("video_url") or content.get("video_url")
or (result.get("metadata") or {}).get("url") or platform_data.get("video_url")
or inner.get("url")
) )
if not video_url: if not video_url:
raise RuntimeError(f"任务成功但未找到视频 URL,响应:{result}") raise RuntimeError(f"任务成功但未找到视频 URL,响应:{result}")
return video_url # 末帧图片 URL 在 inner["data"]["content"]["last_frame_url"]
last_frame_url = (
content.get("last_frame_url")
or platform_data.get("last_frame_url")
or inner.get("last_frame_url")
)
return video_url, last_frame_url
if status in self.FAILURE_STATUSES: if status in self.FAILURE_STATUSES:
reason = ( reason = (
result.get("fail_reason") inner.get("fail_reason")
or (result.get("error") or {}).get("message") or (inner.get("error") or {}).get("message")
or "未知错误" or "未知错误"
) )
raise RuntimeError(f"视频生成失败:{reason}") raise RuntimeError(f"视频生成失败:{reason}")
@@ -157,8 +167,8 @@ class SeedanceClient:
save_path: str, save_path: str,
on_stage: Optional[Callable[[str], None]] = None, on_stage: Optional[Callable[[str], None]] = None,
on_progress: Optional[Callable[[int], None]] = None, on_progress: Optional[Callable[[int], None]] = None,
) -> str: ) -> tuple:
"""提交 → 轮询 → 下载,返回本地文件路径""" """提交 → 轮询 → 下载,返回 (本地视频路径, 末帧图片URL或None)"""
connector = aiohttp.TCPConnector(force_close=True) connector = aiohttp.TCPConnector(force_close=True)
async with aiohttp.ClientSession(connector=connector) as session: async with aiohttp.ClientSession(connector=connector) as session:
@@ -171,7 +181,7 @@ class SeedanceClient:
on_stage(f"submitted:{task_id}") on_stage(f"submitted:{task_id}")
# 轮询 # 轮询
video_url = await self.poll_async(task_id, session, on_progress=on_progress) video_url, last_frame_url = await self.poll_async(task_id, session, on_progress=on_progress)
# 下载 # 下载
if on_stage: if on_stage:
@@ -180,4 +190,4 @@ class SeedanceClient:
if on_stage: if on_stage:
on_stage("done") on_stage("done")
return path return path, last_frame_url
+2 -2
View File
@@ -20,6 +20,6 @@ from .multi_res_preview import MultiResPreview
from .batch_images_o1key import BatchImagesO1key from .batch_images_o1key import BatchImagesO1key
from .nano_banana_v2 import NanaBananaV2 from .nano_banana_v2 import NanaBananaV2
from .batch_nano_banana_v2 import BatchNanaBananaV2 from .batch_nano_banana_v2 import BatchNanaBananaV2
from .seedance_video import SeedanceT2V, SeedanceI2V, SeedanceFlipFlop from .seedance_video import Seedance
__all__ = ['NanoBananaPro', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'SaveCleanImage', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'QuanNengShengTu', 'BatchQuanNengShengTu', 'MultiResPreview', 'BatchImagesO1key', 'NanaBananaV2', 'BatchNanaBananaV2', 'SeedanceT2V', 'SeedanceI2V', 'SeedanceFlipFlop'] __all__ = ['NanoBananaPro', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'SaveCleanImage', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'QuanNengShengTu', 'BatchQuanNengShengTu', 'MultiResPreview', 'BatchImagesO1key', 'NanaBananaV2', 'BatchNanaBananaV2', 'Seedance']
+119 -290
View File
@@ -1,17 +1,19 @@
""" """
Seedance 视频生成节点 Seedance 视频生成节点
节点列表: 节点列表:
- SeedanceT2V: 文生视频 - Seedance: 文生视频 / 图生视频 / 首尾帧生视频(根据图片输入自动切换模式)
- SeedanceI2V: 图生视频(首帧驱动)
- SeedanceFlipFlop: 首尾帧生视频
""" """
import io
import os import os
import re import re
import aiohttp
import torch
from ..clients.seedance_client import SeedanceClient from ..clients.seedance_client import SeedanceClient
from ..clients.gemini_client import GeminiAPIClient 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 from comfy_api.latest import InputImpl
@@ -24,53 +26,19 @@ except ImportError:
# ── 模型列表 ────────────────────────────────────────────────────────────────── # ── 模型列表 ──────────────────────────────────────────────────────────────────
_T2V_MODELS = [ _MODELS = [
"doubao-seedance-2-0-260128", "doubao-seedance-2-0-260128",
"doubao-seedance-2-0-fast-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 = [ _RESOLUTIONS = ["720p", "480p"]
"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: def _supports_camera_fixed(model: str) -> bool:
"""仅非 2.0 模型支持固定镜头2.0 已不支持)""" """2.0 系列不支持固定镜头"""
return not _is_v2(model) return False # 当前仅 2.0 模型,均不支持
def _supports_web_search(model: str) -> bool:
"""仅 2.0 系列支持联网搜索"""
return _is_v2(model)
# ── 工具函数 ────────────────────────────────────────────────────────────────── # ── 工具函数 ──────────────────────────────────────────────────────────────────
@@ -105,6 +73,22 @@ def _tensor_to_base64_url(tensor) -> str:
return f"data:image/png;base64,{b64}" 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(): def _show_balance():
"""完成后打印余额(静默失败)""" """完成后打印余额(静默失败)"""
try: try:
@@ -124,7 +108,6 @@ def _make_pbar():
def _make_callbacks(tag: str, pbar): def _make_callbacks(tag: str, pbar):
"""生成通用的 on_stage / on_progress 回调"""
def on_stage(stage: str): def on_stage(stage: str):
if stage == "submitting": if stage == "submitting":
print(f"[{tag}] 提交中...") print(f"[{tag}] 提交中...")
@@ -145,256 +128,124 @@ def _make_callbacks(tag: str, pbar):
return on_stage, on_progress return on_stage, on_progress
# ── 节点 1:文生视频 ───────────────────────────────────────────────────────── # ── 统一节点 ─────────────────────────────────────────────────────────────────
#
# 模式由图片输入自动判断:
# 首帧 = None → T2V 文生视频 (联网搜索生效)
# 首帧 = 图片,尾帧 = None → I2V 图生视频 (固定镜头生效,当前 2.0 不支持故忽略)
# 首帧 = 图片,尾帧 = 图片 → FlipFlop 首尾帧(联网搜索/固定镜头均忽略)
class SeedanceT2V: class Seedance:
"""Seedance 文生视频""" """Seedance 视频生成(文生视频 / 图生视频 / 首尾帧,自动判断模式)"""
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"提示词": ("STRING", {"multiline": True, "default": ""}), "提示词": ("STRING", {"multiline": True, "default": ""}),
"模型": (_T2V_MODELS, {"default": "doubao-seedance-2-0-260128"}), "模型": (_MODELS, {"default": "doubao-seedance-2-0-260128"}),
"分辨率": (["720p", "1080p", "480p"], {"default": "720p"}), "分辨率": (_RESOLUTIONS, {"default": "720p"}),
"宽高比": (["16:9", "adaptive", "9:16", "1:1", "4:3", "3:4", "21:9"], "宽高比": (["16:9", "adaptive", "9:16", "1:1", "4:3", "3:4", "21:9"],
{"default": "16:9"}), {"default": "16:9"}),
"时长秒(-1=自动)": ("INT", {"default": 5, "min": -1, "max": 30, "step": 1}), "时长秒(-1=自动)": ("INT", {"default": 5, "min": -1, "max": 30, "step": 1}),
"生成音频": (["关闭", "打开"], {"default": "关闭"}), "生成音频": (["关闭", "打开"], {"default": "关闭"}),
"水印": (["关闭", "打开"], {"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": "关闭"}),
"服务等级": (["default", "flex"], {"default": "default"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}), "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",
}, },
{ "optional": {
"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",),
"尾帧图片": ("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_TYPES = ("VIDEO", "IMAGE")
RETURN_NAMES = ("视频",) RETURN_NAMES = ("视频", "末帧图片")
FUNCTION = "generate" FUNCTION = "generate"
CATEGORY = "comfyui_o1key/Seedance" CATEGORY = "comfyui_o1key/Seedance"
async def generate(self, **kwargs): async def generate(self, **kwargs):
first_image = kwargs["首帧图片"]
last_image = kwargs["尾帧图片"]
prompt = kwargs["提示词"].strip() prompt = kwargs["提示词"].strip()
model = kwargs["模型"] model = kwargs["模型"]
resolution = kwargs["分辨率"] resolution = kwargs["分辨率"]
ratio = kwargs["宽高比"] ratio = kwargs["宽高比"]
duration = kwargs["时长秒(-1=自动)"] duration = kwargs["时长秒(-1=自动)"]
gen_audio = kwargs["生成音频"] == "打开" gen_audio = kwargs["生成音频"] == "打开"
watermark = kwargs["水印"] == "打开" web_search = kwargs["联网搜索"] == "打开"
return_last = kwargs["返回末帧图片"] == "打开" return_last = kwargs["返回末帧图片"] == "打开"
service_tier = kwargs["服务等级"]
seed = kwargs.get("seed", 0) 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: if not prompt:
raise ValueError("提示词不能为空。") raise ValueError("提示词不能为空。")
if duration == -1 and not _supports_auto_duration(model): if duration == -1 and mode == "t2v":
raise ValueError(f"模型 {model} 不支持自动时长(-1),请改用 2.0 或 1.5-pro 模型。") 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) first_url = _tensor_to_base64_url(first_image)
last_url = _tensor_to_base64_url(last_image) last_url = _tensor_to_base64_url(last_image)
metadata["content"] = [
content = [
{ {
"type": "image_url", "type": "image_url",
"image_url": {"url": first_url}, "image_url": {"url": first_url},
@@ -405,50 +256,32 @@ class SeedanceFlipFlop:
"image_url": {"url": last_url}, "image_url": {"url": last_url},
"role": "last_frame", "role": "last_frame",
}, },
{ {"type": "text", "text": prompt},
"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 = { body = {
"model": model, "model": model,
"prompt": prompt, "prompt": prompt,
"images": [first_url], # 供 new-api HasImage() 识别 "images": [first_url],
"metadata": metadata, "metadata": metadata,
"service_tier": service_tier,
} }
video_dir = _get_video_output_dir() video_dir = _get_video_output_dir()
counter = _get_next_counter(video_dir, "seedance_flip") counter = _get_next_counter(video_dir, file_prefix)
save_path = os.path.join(video_dir, f"seedance_flip_{counter:05d}.mp4") save_path = os.path.join(video_dir, f"{file_prefix}_{counter:05d}.mp4")
client = SeedanceClient() client = SeedanceClient()
pbar = _make_pbar() pbar = _make_pbar()
on_stage, on_prog = _make_callbacks("Seedance首尾帧", pbar) on_stage, on_prog = _make_callbacks(tag, pbar)
try: try:
result_path = await client.generate_async( result_path, last_frame_url = await client.generate_async(
body=body, save_path=save_path, body=body, save_path=save_path,
on_stage=on_stage, on_progress=on_prog, 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: finally:
_show_balance() _show_balance()
@@ -456,13 +289,9 @@ class SeedanceFlipFlop:
# ── 节点注册 ────────────────────────────────────────────────────────────────── # ── 节点注册 ──────────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"SeedanceT2V": SeedanceT2V, "Seedance": Seedance,
"SeedanceI2V": SeedanceI2V,
"SeedanceFlipFlop": SeedanceFlipFlop,
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"SeedanceT2V": "Seedance 文生视频", "Seedance": "Seedance 视频生成",
"SeedanceI2V": "Seedance 图生视频",
"SeedanceFlipFlop": "Seedance 首尾帧生视频",
} }