feat: 接入 Seedance 视频节点并增强图像节点功能

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

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
This commit is contained in:
o1key
2026-04-08 18:32:18 +08:00
co-authored by Claude Sonnet 4.5
parent 9abd175316
commit 03d477648a
9 changed files with 742 additions and 19 deletions
+7 -1
View File
@@ -21,7 +21,7 @@ except Exception:
import ssl
from .nodes import NanoBananaPro, BatchNanoBananaPro, GoogleGemini, LoadFile, ImageStitchPro, SaveCleanImage, BatchCleanMetadata, VideoPreview, GoogleVeo, FluxImageEdit, UniversalLLMChat, KlingVideo, KlingFirstLastFrame, KlingMotionControlTest, QuanNengShengTu, BatchQuanNengShengTu, AspectRatioPreset, MultiResPreview, BatchImagesO1key
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
# 报错弹框友好文案(不修改原节点代码,仅在外层统一处理)
_MSG_TIMEOUT = "API 请求超时,请稍后重试或检查网络。"
@@ -79,6 +79,9 @@ NODE_CLASS_MAPPINGS = {
"AspectRatioPreset": AspectRatioPreset,
"MultiResPreview": MultiResPreview,
"BatchImagesO1key": BatchImagesO1key,
"SeedanceT2V": SeedanceT2V,
"SeedanceI2V": SeedanceI2V,
"SeedanceFlipFlop": SeedanceFlipFlop,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -101,6 +104,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"AspectRatioPreset": "图片宽高比预设",
"MultiResPreview": "预览图像(v2",
"BatchImagesO1key": "加载图像(批量)",
"SeedanceT2V": "Seedance 文生视频",
"SeedanceI2V": "Seedance 图生视频",
"SeedanceFlipFlop": "Seedance 首尾帧生视频",
}
WEB_DIRECTORY = "./web"
+13 -1
View File
@@ -62,8 +62,20 @@ class GeminiAPIClient(BaseAPIClient):
else:
return "/v1beta/models/nano-banana-pro-2k:generateContent"
elif model == "nano-banana-2-限时特价":
if resolution == "512px":
return "/v1beta/models/nano-banana-2-0.5k:generateContent"
elif resolution == "1K":
return "/v1beta/models/nano-banana-2-1k:generateContent"
elif resolution == "2K":
return "/v1beta/models/nano-banana-2-2k:generateContent"
elif resolution == "4K":
return "/v1beta/models/nano-banana-2-4k:generateContent"
else:
return "/v1beta/models/nano-banana-2-2k:generateContent"
elif model == "nano-banana-2-官方计费":
if resolution == "512":
if resolution == "512px":
return "/v1beta/models/nano-banana-2-0.5k-official:generateContent"
elif resolution == "1K":
return "/v1beta/models/nano-banana-2-1k-official:generateContent"
+183
View File
@@ -0,0 +1,183 @@
"""
Seedance 视频生成客户端
使用 new-api 原生格式:POST /v1/video/generations → GET /v1/video/generations/{task_id}
"""
import asyncio
import json
import os
from typing import Any, Callable, Dict, Optional
import aiohttp
from ..utils.config import get_api_key_or_raise, get_api_base_url
class SeedanceClient:
"""Seedance 视频生成客户端(new-api 原生三段式)"""
# 提交任务
CREATE_ENDPOINT = "/v1/video/generations"
# 查询任务状态:{task_id} 占位
STATUS_ENDPOINT = "/v1/video/generations/{task_id}"
POLL_INITIAL_INTERVAL = 4 # 首次轮询等待秒数
POLL_MAX_INTERVAL = 15 # 最大轮询间隔秒数
# new-api 返回的成功状态值
SUCCESS_STATUSES = {"succeeded", "success", "completed", "done", "finished"}
FAILURE_STATUSES = {"failed", "fail", "error", "expired"}
def __init__(self):
self.api_key = get_api_key_or_raise()
self.base_url = get_api_base_url()
def _headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
# ── 1. 提交任务 ────────────────────────────────────────────────────
async def submit_async(
self,
body: Dict[str, Any],
session: aiohttp.ClientSession,
) -> str:
"""提交视频生成任务,返回 task_id"""
url = f"{self.base_url}{self.CREATE_ENDPOINT}"
async with session.post(url, json=body, headers=self._headers()) as resp:
text = await resp.text()
if resp.status != 200:
try:
err = json.loads(text)
msg = (err.get("error", {}).get("message")
or err.get("message")
or text)
except Exception:
msg = text
raise RuntimeError(f"提交失败 ({resp.status}): {msg}")
data = json.loads(text)
# new-api 返回字段:id / task_id
task_id = data.get("id") or data.get("task_id")
if not task_id:
raise RuntimeError(f"API 未返回任务 ID,响应:{data}")
return task_id
# ── 2. 轮询状态 ────────────────────────────────────────────────────
async def poll_async(
self,
task_id: str,
session: aiohttp.ClientSession,
on_progress: Optional[Callable[[int], None]] = None,
) -> str:
"""轮询任务状态,成功后返回视频 URL"""
url = f"{self.base_url}{self.STATUS_ENDPOINT.format(task_id=task_id)}"
interval = self.POLL_INITIAL_INTERVAL
while True:
async with session.get(url, headers=self._headers()) as resp:
text = await resp.text()
if resp.status != 200:
try:
err = json.loads(text)
msg = (err.get("error", {}).get("message")
or err.get("message")
or text)
except Exception:
msg = text
raise RuntimeError(f"状态查询失败 ({resp.status}): {msg}")
result = json.loads(text)
status = (result.get("status") or "").lower()
# 调试:打印原始响应(排查状态字段问题后可删除)
print(f"[Seedance][DEBUG] 原始响应: {result}")
# 解析进度
progress_raw = result.get("progress", "0")
try:
progress_pct = int(str(progress_raw).rstrip("%").strip())
except (ValueError, AttributeError):
progress_pct = 0
print(f"[Seedance] 生成中 {progress_pct}% (status={status})")
if on_progress:
on_progress(progress_pct)
if status in self.SUCCESS_STATUSES:
# 取视频 URLurl / metadata.url / output.video_url
video_url = (
result.get("url")
or (result.get("output") or {}).get("video_url")
or (result.get("metadata") or {}).get("url")
)
if not video_url:
raise RuntimeError(f"任务成功但未找到视频 URL,响应:{result}")
return video_url
if status in self.FAILURE_STATUSES:
reason = (
result.get("fail_reason")
or (result.get("error") or {}).get("message")
or "未知错误"
)
raise RuntimeError(f"视频生成失败:{reason}")
await asyncio.sleep(interval)
interval = min(interval * 1.5, self.POLL_MAX_INTERVAL)
# ── 3. 下载视频 ────────────────────────────────────────────────────
async def download_async(
self,
video_url: str,
save_path: str,
session: aiohttp.ClientSession,
) -> str:
"""下载视频到本地,返回本地路径"""
print(f"[Seedance] 下载视频...")
async with session.get(video_url, allow_redirects=True) as resp:
if resp.status != 200:
raise RuntimeError(f"视频下载失败 ({resp.status})")
os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
f.write(chunk)
return save_path
# ── 全流程入口(供节点调用)────────────────────────────────────────
async def generate_async(
self,
body: Dict[str, Any],
save_path: str,
on_stage: Optional[Callable[[str], None]] = None,
on_progress: Optional[Callable[[int], None]] = None,
) -> str:
"""提交 → 轮询 → 下载,返回本地文件路径"""
connector = aiohttp.TCPConnector(force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
# 提交
if on_stage:
on_stage("submitting")
task_id = await self.submit_async(body, session)
print(f"[Seedance] 任务已提交 → {task_id}")
if on_stage:
on_stage(f"submitted:{task_id}")
# 轮询
video_url = await self.poll_async(task_id, session, on_progress=on_progress)
# 下载
if on_stage:
on_stage("downloading")
path = await self.download_async(video_url, save_path, session)
if on_stage:
on_stage("done")
return path
+7 -7
View File
@@ -67,15 +67,15 @@ GEMINI_MODELS = [
},
{
"id": "nano-banana-2-限时特价",
"description": "Nano Banana 2 限时特价,固定端点,图像生成模型",
"description": "Nano Banana 2 限时特价,根据分辨率自动选择端点 (512px/1K/2K/4K),图像生成模型",
"enabled": True,
"endpoint_type": "standard",
"endpoint": "/v1beta/models/nano-banana-2:generateContent",
"endpoint_type": "dynamic",
"endpoint": None, # 动态端点,由代码根据分辨率选择
"supported_aspect_ratios": [
"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4",
"8:1", "9:16", "16:9", "21:9"
],
"supported_resolutions": ["512", "1K", "2K", "4K"]
"supported_resolutions": ["512px", "1K", "2K", "4K"]
},
{
"id": "nano-banana-2-官方计费",
@@ -87,7 +87,7 @@ GEMINI_MODELS = [
"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4",
"8:1", "9:16", "16:9", "21:9"
],
"supported_resolutions": ["512", "1K", "2K", "4K"]
"supported_resolutions": ["512px", "1K", "2K", "4K"]
},
{
"id": "gemini-3-pro-image-preview",
@@ -110,7 +110,7 @@ GEMINI_MODELS = [
"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4",
"8:1", "9:16", "16:9", "21:9"
],
"supported_resolutions": ["512", "1K", "2K", "4K"]
"supported_resolutions": ["512px", "1K", "2K", "4K"]
}
]
@@ -329,7 +329,7 @@ def get_all_supported_resolutions() -> List[str]:
>>> get_all_supported_resolutions()
['512', '1K', '2K', '4K']
"""
_ORDER = ["512", "1K", "2K", "4K"]
_ORDER = ["512px", "1K", "2K", "4K"]
seen = set()
for model in GEMINI_MODELS:
+2 -1
View File
@@ -20,5 +20,6 @@ from .multi_res_preview import MultiResPreview
from .batch_images_o1key import BatchImagesO1key
from .nano_banana_v2 import NanaBananaV2
from .batch_nano_banana_v2 import BatchNanaBananaV2
from .seedance_video import 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']
__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']
+21 -4
View File
@@ -134,7 +134,7 @@ class BatchNanoBananaPro:
]
# 支持的分辨率列表(全量兜底,实际由 get_all_supported_resolutions() 动态生成)
RESOLUTIONS = ["512", "1K", "2K", "4K"]
RESOLUTIONS = ["512px", "1K", "2K", "4K"]
# 配对模式
PAIRING_MODES = ["按相同图片命名", "1*N", "不配对"]
@@ -298,6 +298,11 @@ class BatchNanoBananaPro:
"保存路径": ("STRING", {
"default": "",
"multiline": False
}),
"跳过错误": ("BOOLEAN", {
"default": False,
"label_on": "打开",
"label_off": "关闭"
})
},
"optional": optional_inputs
@@ -775,6 +780,7 @@ class BatchNanoBananaPro:
模型: str,
宽高比: str,
分辨率: str,
跳过错误: bool = False,
保存路径: str = "",
**kwargs
) -> Tuple[torch.Tensor]:
@@ -1089,19 +1095,30 @@ class BatchNanoBananaPro:
# 用户输入错误 - 打印完整错误信息
error_msg = str(e)
print(f"BatchNanoBananaPro: ❌ {error_msg}")
if 跳过错误:
print("BatchNanoBananaPro: ⚠️ 跳过错误已开启,返回占位图继续执行队列")
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
return (pil_to_tensor([placeholder]),)
raise ValueError(error_msg) from None
except RuntimeError as e:
# 打印完整错误信息
error_full = str(e)
print(f"BatchNanoBananaPro: ❌ {error_full}")
if 跳过错误:
print("BatchNanoBananaPro: ⚠️ 跳过错误已开启,返回占位图继续执行队列")
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
return (pil_to_tensor([placeholder]),)
raise RuntimeError(error_full) from None
except Exception as e:
# 其他未知错误 - 打印完整错误信息
error_msg = str(e)
print(f"BatchNanoBananaPro: ❌ {error_msg}")
if 跳过错误:
print("BatchNanoBananaPro: ⚠️ 跳过错误已开启,返回占位图继续执行队列")
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
return (pil_to_tensor([placeholder]),)
raise type(e)(error_msg) from None
finally:
+23 -5
View File
@@ -52,10 +52,10 @@ except ImportError:
# ============================================================================
# 是否启用调试日志(打印完整的 API 响应内容)
# 设置为 True 以启用调试日志,False 以禁用
DEBUG_LOG_ENABLED = False
DEBUG_LOG_ENABLED = True
# 是否启用请求体日志(打印发送给 API 的请求体,base64 图片数据将自动截断)
# 设置为 True 以启用请求体日志,False 以禁用
REQUEST_LOG_ENABLED = False
REQUEST_LOG_ENABLED = True
# ============================================================================
_NODE = "Nano Banana Pro"
@@ -119,7 +119,7 @@ class NanoBananaPro:
]
# 支持的分辨率列表(全量兜底,实际由 get_all_supported_resolutions() 动态生成)
RESOLUTIONS = ["512", "1K", "2K", "4K"]
RESOLUTIONS = ["512px", "1K", "2K", "4K"]
def __init__(self):
"""初始化节点"""
@@ -199,6 +199,11 @@ class NanoBananaPro:
"default": 0,
"min": 0,
"max": 0xffffffffffffffff
}),
"跳过错误": ("BOOLEAN", {
"default": False,
"label_on": "打开",
"label_off": "关闭"
})
},
"optional": optional_inputs
@@ -452,6 +457,7 @@ class NanoBananaPro:
像素缩放: bool,
分辨率像素: float,
seed: int,
跳过错误: bool = False,
**kwargs
) -> Tuple[torch.Tensor]:
"""
@@ -873,18 +879,30 @@ class NanoBananaPro:
# 用户输入错误 - 打印完整错误信息
error_msg = str(e)
print(f"Nano Banana Pro: ❌ {error_msg}")
if 跳过错误:
print("Nano Banana Pro: ⚠️ 跳过错误已开启,返回占位图继续执行队列")
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
return (pil_to_tensor([placeholder]),)
raise ValueError(error_msg) from None
except RuntimeError as e:
# 打印完整错误信息
error_full = str(e)
print(f"Nano Banana Pro: ❌ {error_full}")
if 跳过错误:
print("Nano Banana Pro: ⚠️ 跳过错误已开启,返回占位图继续执行队列")
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
return (pil_to_tensor([placeholder]),)
raise RuntimeError(error_full) from None
except Exception as e:
# 其他未知错误 - 打印完整错误信息
error_msg = str(e)
print(f"Nano Banana Pro: ❌ {error_msg}")
if 跳过错误:
print("Nano Banana Pro: ⚠️ 跳过错误已开启,返回占位图继续执行队列")
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
return (pil_to_tensor([placeholder]),)
raise type(e)(error_msg) from None
finally:
+18
View File
@@ -161,6 +161,11 @@ class QuanNengShengTu:
"default": 0,
"min": 0,
"max": 0xffffffffffffffff
}),
"跳过错误": ("BOOLEAN", {
"default": False,
"label_on": "打开",
"label_off": "关闭"
})
},
"optional": optional_inputs
@@ -405,6 +410,7 @@ class QuanNengShengTu:
像素缩放: bool,
分辨率像素: float,
seed: int,
跳过错误: bool = False,
**kwargs
) -> Tuple[torch.Tensor]:
"""
@@ -798,16 +804,28 @@ class QuanNengShengTu:
else:
error_msg = str(e)
print(f"全能生图: ❌ {error_msg}")
if 跳过错误:
print("全能生图: ⚠️ 跳过错误已开启,返回占位图继续执行队列")
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
return (pil_to_tensor([placeholder]),)
raise ValueError(error_msg) from None
except RuntimeError as e:
error_full = str(e)
print(f"全能生图: ❌ {error_full}")
if 跳过错误:
print("全能生图: ⚠️ 跳过错误已开启,返回占位图继续执行队列")
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
return (pil_to_tensor([placeholder]),)
raise RuntimeError(error_full) from None
except Exception as e:
error_msg = str(e)
print(f"全能生图: ❌ {error_msg}")
if 跳过错误:
print("全能生图: ⚠️ 跳过错误已开启,返回占位图继续执行队列")
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
return (pil_to_tensor([placeholder]),)
raise type(e)(error_msg) from None
finally:
+468
View File
@@ -0,0 +1,468 @@
"""
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 首尾帧生视频",
}