diff --git a/__init__.py b/__init__.py index 2ca20f8..044f699 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 +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" diff --git a/clients/gemini_client.py b/clients/gemini_client.py index 35e4ba0..5d703ad 100644 --- a/clients/gemini_client.py +++ b/clients/gemini_client.py @@ -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" diff --git a/clients/seedance_client.py b/clients/seedance_client.py new file mode 100644 index 0000000..938c0c7 --- /dev/null +++ b/clients/seedance_client.py @@ -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: + # 取视频 URL:url / 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 diff --git a/models_config.py b/models_config.py index 5f12f8a..ad9f5dc 100644 --- a/models_config.py +++ b/models_config.py @@ -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: diff --git a/nodes/__init__.py b/nodes/__init__.py index 4d6535b..595bf5b 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -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'] diff --git a/nodes/batch_nano_banana_pro.py b/nodes/batch_nano_banana_pro.py index 65bc01f..1aa5165 100644 --- a/nodes/batch_nano_banana_pro.py +++ b/nodes/batch_nano_banana_pro.py @@ -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: diff --git a/nodes/nano_banana_pro.py b/nodes/nano_banana_pro.py index 996172b..7b204b2 100644 --- a/nodes/nano_banana_pro.py +++ b/nodes/nano_banana_pro.py @@ -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: diff --git a/nodes/quan_neng_sheng_tu.py b/nodes/quan_neng_sheng_tu.py index dc1e190..8d91cb6 100644 --- a/nodes/quan_neng_sheng_tu.py +++ b/nodes/quan_neng_sheng_tu.py @@ -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: diff --git a/nodes/seedance_video.py b/nodes/seedance_video.py new file mode 100644 index 0000000..564b87a --- /dev/null +++ b/nodes/seedance_video.py @@ -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 首尾帧生视频", +}