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:
co-authored by
Claude Sonnet 4.5
parent
03d477648a
commit
0ddc571f20
+3
-7
@@ -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
@@ -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:
|
||||||
# 取视频 URL:url / metadata.url / output.video_url
|
# 响应结构:result["data"] = inner,inner["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
@@ -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']
|
||||||
|
|||||||
+134
-305
@@ -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,310 +128,160 @@ 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["首帧图片"]
|
prompt = kwargs["提示词"].strip()
|
||||||
last_image = kwargs["尾帧图片"]
|
model = kwargs["模型"]
|
||||||
prompt = kwargs["提示词"].strip()
|
resolution = kwargs["分辨率"]
|
||||||
model = kwargs["模型"]
|
ratio = kwargs["宽高比"]
|
||||||
resolution = kwargs["分辨率"]
|
duration = kwargs["时长秒(-1=自动)"]
|
||||||
ratio = kwargs["宽高比"]
|
gen_audio = kwargs["生成音频"] == "打开"
|
||||||
duration = kwargs["时长秒(-1=自动)"]
|
web_search = kwargs["联网搜索"] == "打开"
|
||||||
gen_audio = kwargs["生成音频"] == "打开"
|
return_last = kwargs["返回末帧图片"] == "打开"
|
||||||
watermark = kwargs["水印"] == "打开"
|
seed = kwargs.get("seed", 0)
|
||||||
return_last = kwargs["返回末帧图片"] == "打开"
|
first_image = kwargs.get("首帧图片", None)
|
||||||
service_tier = kwargs["服务等级"]
|
last_image = kwargs.get("尾帧图片", None)
|
||||||
seed = kwargs.get("seed", 0)
|
|
||||||
|
# 模式判断
|
||||||
|
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":
|
||||||
first_url = _tensor_to_base64_url(first_image)
|
pass # 2.0 均支持自动时长
|
||||||
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 = {
|
metadata: dict = {
|
||||||
"resolution": resolution,
|
"resolution": resolution,
|
||||||
"watermark": watermark,
|
"watermark": False,
|
||||||
"content": content,
|
|
||||||
}
|
}
|
||||||
if ratio != "adaptive":
|
if ratio != "adaptive":
|
||||||
metadata["ratio"] = ratio
|
metadata["ratio"] = ratio
|
||||||
if duration != -1:
|
if duration != -1:
|
||||||
metadata["duration"] = duration
|
metadata["duration"] = duration
|
||||||
if gen_audio and _supports_audio(model):
|
if gen_audio:
|
||||||
metadata["generate_audio"] = True
|
metadata["generate_audio"] = True
|
||||||
if return_last:
|
if return_last:
|
||||||
metadata["return_last_frame"] = True
|
metadata["return_last_frame"] = True
|
||||||
if seed != 0:
|
if seed != 0:
|
||||||
metadata["seed"] = seed
|
metadata["seed"] = seed
|
||||||
|
|
||||||
body = {
|
# 模式专属参数
|
||||||
"model": model,
|
if mode == "t2v":
|
||||||
"prompt": prompt,
|
if web_search:
|
||||||
"images": [first_url], # 供 new-api HasImage() 识别
|
metadata["tools"] = [{"type": "web_search"}]
|
||||||
"metadata": metadata,
|
body = {
|
||||||
"service_tier": service_tier,
|
"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()
|
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 首尾帧生视频",
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user