diff --git a/__init__.py b/__init__.py index 044f699..d441f6c 100644 --- a/__init__.py +++ b/__init__.py @@ -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, 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 请求超时,请稍后重试或检查网络。" @@ -79,9 +79,7 @@ NODE_CLASS_MAPPINGS = { "AspectRatioPreset": AspectRatioPreset, "MultiResPreview": MultiResPreview, "BatchImagesO1key": BatchImagesO1key, - "SeedanceT2V": SeedanceT2V, - "SeedanceI2V": SeedanceI2V, - "SeedanceFlipFlop": SeedanceFlipFlop, + "Seedance": Seedance, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -104,9 +102,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "AspectRatioPreset": "图片宽高比预设", "MultiResPreview": "预览图像(v2)", "BatchImagesO1key": "加载图像(批量)", - "SeedanceT2V": "Seedance 文生视频", - "SeedanceI2V": "Seedance 图生视频", - "SeedanceFlipFlop": "Seedance 首尾帧生视频", + "Seedance": "Seedance 视频生成", } WEB_DIRECTORY = "./web" diff --git a/clients/seedance_client.py b/clients/seedance_client.py index 938c0c7..2edcba9 100644 --- a/clients/seedance_client.py +++ b/clients/seedance_client.py @@ -92,37 +92,47 @@ class SeedanceClient: raise RuntimeError(f"状态查询失败 ({resp.status}): {msg}") result = json.loads(text) - status = (result.get("status") or "").lower() + # new-api 包装格式:真实数据在 result["data"] 里 + inner = result.get("data") or result - # 调试:打印原始响应(排查状态字段问题后可删除) - print(f"[Seedance][DEBUG] 原始响应: {result}") + status = (inner.get("status") or "").lower() # 解析进度 - progress_raw = result.get("progress", "0") + progress_raw = inner.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})") + print(f"[Seedance] 生成中 {progress_pct}%") if on_progress: on_progress(progress_pct) 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 = ( - result.get("url") - or (result.get("output") or {}).get("video_url") - or (result.get("metadata") or {}).get("url") + inner.get("result_url") + or content.get("video_url") + or platform_data.get("video_url") + or inner.get("url") ) if not video_url: 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: reason = ( - result.get("fail_reason") - or (result.get("error") or {}).get("message") + inner.get("fail_reason") + or (inner.get("error") or {}).get("message") or "未知错误" ) raise RuntimeError(f"视频生成失败:{reason}") @@ -157,8 +167,8 @@ class SeedanceClient: save_path: str, on_stage: Optional[Callable[[str], None]] = None, on_progress: Optional[Callable[[int], None]] = None, - ) -> str: - """提交 → 轮询 → 下载,返回本地文件路径""" + ) -> tuple: + """提交 → 轮询 → 下载,返回 (本地视频路径, 末帧图片URL或None)""" connector = aiohttp.TCPConnector(force_close=True) async with aiohttp.ClientSession(connector=connector) as session: @@ -171,7 +181,7 @@ class SeedanceClient: 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: @@ -180,4 +190,4 @@ class SeedanceClient: if on_stage: on_stage("done") - return path + return path, last_frame_url diff --git a/nodes/__init__.py b/nodes/__init__.py index 595bf5b..da54a06 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -20,6 +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 +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'] diff --git a/nodes/seedance_video.py b/nodes/seedance_video.py index 564b87a..576cfe7 100644 --- a/nodes/seedance_video.py +++ b/nodes/seedance_video.py @@ -1,17 +1,19 @@ """ Seedance 视频生成节点 节点列表: - - SeedanceT2V: 文生视频 - - SeedanceI2V: 图生视频(首帧驱动) - - SeedanceFlipFlop: 首尾帧生视频 + - 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 +from ..utils.image_utils import tensor_to_pil, encode_image_to_base64, pil_to_tensor from comfy_api.latest import InputImpl @@ -24,53 +26,19 @@ except ImportError: # ── 模型列表 ────────────────────────────────────────────────────────────────── -_T2V_MODELS = [ +_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", -] +_RESOLUTIONS = ["720p", "480p"] # ── 模型能力判断 ────────────────────────────────────────────────────────────── -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) + """2.0 系列不支持固定镜头""" + return False # 当前仅 2.0 模型,均不支持 # ── 工具函数 ────────────────────────────────────────────────────────────────── @@ -105,6 +73,22 @@ def _tensor_to_base64_url(tensor) -> str: 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: @@ -124,7 +108,6 @@ def _make_pbar(): def _make_callbacks(tag: str, pbar): - """生成通用的 on_stage / on_progress 回调""" def on_stage(stage: str): if stage == "submitting": print(f"[{tag}] 提交中...") @@ -145,310 +128,160 @@ def _make_callbacks(tag: str, pbar): return on_stage, on_progress -# ── 节点 1:文生视频 ───────────────────────────────────────────────────────── +# ── 统一节点 ───────────────────────────────────────────────────────────────── +# +# 模式由图片输入自动判断: +# 首帧 = None → T2V 文生视频 (联网搜索生效) +# 首帧 = 图片,尾帧 = None → I2V 图生视频 (固定镜头生效,当前 2.0 不支持故忽略) +# 首帧 = 图片,尾帧 = 图片 → FlipFlop 首尾帧(联网搜索/固定镜头均忽略) -class SeedanceT2V: - """Seedance 文生视频""" +class Seedance: + """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"}), + "模型": (_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": "关闭"}), "联网搜索": (["关闭", "打开"], {"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": { + "optional": { "首帧图片": ("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 = ("视频",) + RETURN_TYPES = ("VIDEO", "IMAGE") + 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) + 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 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, - }, - ] + if duration == -1 and mode == "t2v": + pass # 2.0 均支持自动时长 + elif duration == -1 and mode != "t2v": + pass # 2.0 均支持自动时长 metadata: dict = { "resolution": resolution, - "watermark": watermark, - "content": content, + "watermark": False, } if ratio != "adaptive": metadata["ratio"] = ratio if duration != -1: metadata["duration"] = duration - if gen_audio and _supports_audio(model): + if gen_audio: 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, - } + # 模式专属参数 + 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, "seedance_flip") - save_path = os.path.join(video_dir, f"seedance_flip_{counter:05d}.mp4") + 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("Seedance首尾帧", pbar) + client = SeedanceClient() + pbar = _make_pbar() + on_stage, on_prog = _make_callbacks(tag, pbar) try: - result_path = await client.generate_async( + result_path, last_frame_url = await client.generate_async( body=body, save_path=save_path, 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: _show_balance() @@ -456,13 +289,9 @@ class SeedanceFlipFlop: # ── 节点注册 ────────────────────────────────────────────────────────────────── NODE_CLASS_MAPPINGS = { - "SeedanceT2V": SeedanceT2V, - "SeedanceI2V": SeedanceI2V, - "SeedanceFlipFlop": SeedanceFlipFlop, + "Seedance": Seedance, } NODE_DISPLAY_NAME_MAPPINGS = { - "SeedanceT2V": "Seedance 文生视频", - "SeedanceI2V": "Seedance 图生视频", - "SeedanceFlipFlop": "Seedance 首尾帧生视频", + "Seedance": "Seedance 视频生成", }