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:
co-authored by
Claude Sonnet 4.5
parent
9abd175316
commit
03d477648a
+7
-1
@@ -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
|
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 请求超时,请稍后重试或检查网络。"
|
_MSG_TIMEOUT = "API 请求超时,请稍后重试或检查网络。"
|
||||||
@@ -79,6 +79,9 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
"AspectRatioPreset": AspectRatioPreset,
|
"AspectRatioPreset": AspectRatioPreset,
|
||||||
"MultiResPreview": MultiResPreview,
|
"MultiResPreview": MultiResPreview,
|
||||||
"BatchImagesO1key": BatchImagesO1key,
|
"BatchImagesO1key": BatchImagesO1key,
|
||||||
|
"SeedanceT2V": SeedanceT2V,
|
||||||
|
"SeedanceI2V": SeedanceI2V,
|
||||||
|
"SeedanceFlipFlop": SeedanceFlipFlop,
|
||||||
}
|
}
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
@@ -101,6 +104,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"AspectRatioPreset": "图片宽高比预设",
|
"AspectRatioPreset": "图片宽高比预设",
|
||||||
"MultiResPreview": "预览图像(v2)",
|
"MultiResPreview": "预览图像(v2)",
|
||||||
"BatchImagesO1key": "加载图像(批量)",
|
"BatchImagesO1key": "加载图像(批量)",
|
||||||
|
"SeedanceT2V": "Seedance 文生视频",
|
||||||
|
"SeedanceI2V": "Seedance 图生视频",
|
||||||
|
"SeedanceFlipFlop": "Seedance 首尾帧生视频",
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web"
|
WEB_DIRECTORY = "./web"
|
||||||
|
|||||||
@@ -62,8 +62,20 @@ class GeminiAPIClient(BaseAPIClient):
|
|||||||
else:
|
else:
|
||||||
return "/v1beta/models/nano-banana-pro-2k:generateContent"
|
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-官方计费":
|
elif model == "nano-banana-2-官方计费":
|
||||||
if resolution == "512":
|
if resolution == "512px":
|
||||||
return "/v1beta/models/nano-banana-2-0.5k-official:generateContent"
|
return "/v1beta/models/nano-banana-2-0.5k-official:generateContent"
|
||||||
elif resolution == "1K":
|
elif resolution == "1K":
|
||||||
return "/v1beta/models/nano-banana-2-1k-official:generateContent"
|
return "/v1beta/models/nano-banana-2-1k-official:generateContent"
|
||||||
|
|||||||
@@ -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
|
||||||
+7
-7
@@ -67,15 +67,15 @@ GEMINI_MODELS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "nano-banana-2-限时特价",
|
"id": "nano-banana-2-限时特价",
|
||||||
"description": "Nano Banana 2 限时特价,固定端点,图像生成模型",
|
"description": "Nano Banana 2 限时特价,根据分辨率自动选择端点 (512px/1K/2K/4K),图像生成模型",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"endpoint_type": "standard",
|
"endpoint_type": "dynamic",
|
||||||
"endpoint": "/v1beta/models/nano-banana-2:generateContent",
|
"endpoint": None, # 动态端点,由代码根据分辨率选择
|
||||||
"supported_aspect_ratios": [
|
"supported_aspect_ratios": [
|
||||||
"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4",
|
"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"
|
"8:1", "9:16", "16:9", "21:9"
|
||||||
],
|
],
|
||||||
"supported_resolutions": ["512", "1K", "2K", "4K"]
|
"supported_resolutions": ["512px", "1K", "2K", "4K"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "nano-banana-2-官方计费",
|
"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",
|
"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"
|
"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",
|
"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",
|
"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"
|
"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()
|
>>> get_all_supported_resolutions()
|
||||||
['512', '1K', '2K', '4K']
|
['512', '1K', '2K', '4K']
|
||||||
"""
|
"""
|
||||||
_ORDER = ["512", "1K", "2K", "4K"]
|
_ORDER = ["512px", "1K", "2K", "4K"]
|
||||||
|
|
||||||
seen = set()
|
seen = set()
|
||||||
for model in GEMINI_MODELS:
|
for model in GEMINI_MODELS:
|
||||||
|
|||||||
+2
-1
@@ -20,5 +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
|
||||||
|
|
||||||
__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']
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class BatchNanoBananaPro:
|
|||||||
]
|
]
|
||||||
|
|
||||||
# 支持的分辨率列表(全量兜底,实际由 get_all_supported_resolutions() 动态生成)
|
# 支持的分辨率列表(全量兜底,实际由 get_all_supported_resolutions() 动态生成)
|
||||||
RESOLUTIONS = ["512", "1K", "2K", "4K"]
|
RESOLUTIONS = ["512px", "1K", "2K", "4K"]
|
||||||
|
|
||||||
# 配对模式
|
# 配对模式
|
||||||
PAIRING_MODES = ["按相同图片命名", "1*N", "不配对"]
|
PAIRING_MODES = ["按相同图片命名", "1*N", "不配对"]
|
||||||
@@ -298,6 +298,11 @@ class BatchNanoBananaPro:
|
|||||||
"保存路径": ("STRING", {
|
"保存路径": ("STRING", {
|
||||||
"default": "",
|
"default": "",
|
||||||
"multiline": False
|
"multiline": False
|
||||||
|
}),
|
||||||
|
"跳过错误": ("BOOLEAN", {
|
||||||
|
"default": False,
|
||||||
|
"label_on": "打开",
|
||||||
|
"label_off": "关闭"
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"optional": optional_inputs
|
"optional": optional_inputs
|
||||||
@@ -775,6 +780,7 @@ class BatchNanoBananaPro:
|
|||||||
模型: str,
|
模型: str,
|
||||||
宽高比: str,
|
宽高比: str,
|
||||||
分辨率: str,
|
分辨率: str,
|
||||||
|
跳过错误: bool = False,
|
||||||
保存路径: str = "",
|
保存路径: str = "",
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> Tuple[torch.Tensor]:
|
) -> Tuple[torch.Tensor]:
|
||||||
@@ -1089,19 +1095,30 @@ class BatchNanoBananaPro:
|
|||||||
# 用户输入错误 - 打印完整错误信息
|
# 用户输入错误 - 打印完整错误信息
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
print(f"BatchNanoBananaPro: ❌ {error_msg}")
|
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
|
raise ValueError(error_msg) from None
|
||||||
|
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
# 打印完整错误信息
|
# 打印完整错误信息
|
||||||
error_full = str(e)
|
error_full = str(e)
|
||||||
print(f"BatchNanoBananaPro: ❌ {error_full}")
|
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
|
raise RuntimeError(error_full) from None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 其他未知错误 - 打印完整错误信息
|
# 其他未知错误 - 打印完整错误信息
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
print(f"BatchNanoBananaPro: ❌ {error_msg}")
|
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
|
raise type(e)(error_msg) from None
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ except ImportError:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 是否启用调试日志(打印完整的 API 响应内容)
|
# 是否启用调试日志(打印完整的 API 响应内容)
|
||||||
# 设置为 True 以启用调试日志,False 以禁用
|
# 设置为 True 以启用调试日志,False 以禁用
|
||||||
DEBUG_LOG_ENABLED = False
|
DEBUG_LOG_ENABLED = True
|
||||||
# 是否启用请求体日志(打印发送给 API 的请求体,base64 图片数据将自动截断)
|
# 是否启用请求体日志(打印发送给 API 的请求体,base64 图片数据将自动截断)
|
||||||
# 设置为 True 以启用请求体日志,False 以禁用
|
# 设置为 True 以启用请求体日志,False 以禁用
|
||||||
REQUEST_LOG_ENABLED = False
|
REQUEST_LOG_ENABLED = True
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
_NODE = "Nano Banana Pro"
|
_NODE = "Nano Banana Pro"
|
||||||
@@ -119,7 +119,7 @@ class NanoBananaPro:
|
|||||||
]
|
]
|
||||||
|
|
||||||
# 支持的分辨率列表(全量兜底,实际由 get_all_supported_resolutions() 动态生成)
|
# 支持的分辨率列表(全量兜底,实际由 get_all_supported_resolutions() 动态生成)
|
||||||
RESOLUTIONS = ["512", "1K", "2K", "4K"]
|
RESOLUTIONS = ["512px", "1K", "2K", "4K"]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""初始化节点"""
|
"""初始化节点"""
|
||||||
@@ -199,6 +199,11 @@ class NanoBananaPro:
|
|||||||
"default": 0,
|
"default": 0,
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"max": 0xffffffffffffffff
|
"max": 0xffffffffffffffff
|
||||||
|
}),
|
||||||
|
"跳过错误": ("BOOLEAN", {
|
||||||
|
"default": False,
|
||||||
|
"label_on": "打开",
|
||||||
|
"label_off": "关闭"
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"optional": optional_inputs
|
"optional": optional_inputs
|
||||||
@@ -452,6 +457,7 @@ class NanoBananaPro:
|
|||||||
像素缩放: bool,
|
像素缩放: bool,
|
||||||
分辨率像素: float,
|
分辨率像素: float,
|
||||||
seed: int,
|
seed: int,
|
||||||
|
跳过错误: bool = False,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> Tuple[torch.Tensor]:
|
) -> Tuple[torch.Tensor]:
|
||||||
"""
|
"""
|
||||||
@@ -873,18 +879,30 @@ class NanoBananaPro:
|
|||||||
# 用户输入错误 - 打印完整错误信息
|
# 用户输入错误 - 打印完整错误信息
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
print(f"Nano Banana Pro: ❌ {error_msg}")
|
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
|
raise ValueError(error_msg) from None
|
||||||
|
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
# 打印完整错误信息
|
# 打印完整错误信息
|
||||||
error_full = str(e)
|
error_full = str(e)
|
||||||
print(f"Nano Banana Pro: ❌ {error_full}")
|
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
|
raise RuntimeError(error_full) from None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 其他未知错误 - 打印完整错误信息
|
# 其他未知错误 - 打印完整错误信息
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
print(f"Nano Banana Pro: ❌ {error_msg}")
|
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
|
raise type(e)(error_msg) from None
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ class QuanNengShengTu:
|
|||||||
"default": 0,
|
"default": 0,
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"max": 0xffffffffffffffff
|
"max": 0xffffffffffffffff
|
||||||
|
}),
|
||||||
|
"跳过错误": ("BOOLEAN", {
|
||||||
|
"default": False,
|
||||||
|
"label_on": "打开",
|
||||||
|
"label_off": "关闭"
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"optional": optional_inputs
|
"optional": optional_inputs
|
||||||
@@ -405,6 +410,7 @@ class QuanNengShengTu:
|
|||||||
像素缩放: bool,
|
像素缩放: bool,
|
||||||
分辨率像素: float,
|
分辨率像素: float,
|
||||||
seed: int,
|
seed: int,
|
||||||
|
跳过错误: bool = False,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> Tuple[torch.Tensor]:
|
) -> Tuple[torch.Tensor]:
|
||||||
"""
|
"""
|
||||||
@@ -798,16 +804,28 @@ class QuanNengShengTu:
|
|||||||
else:
|
else:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
print(f"全能生图: ❌ {error_msg}")
|
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
|
raise ValueError(error_msg) from None
|
||||||
|
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
error_full = str(e)
|
error_full = str(e)
|
||||||
print(f"全能生图: ❌ {error_full}")
|
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
|
raise RuntimeError(error_full) from None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
print(f"全能生图: ❌ {error_msg}")
|
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
|
raise type(e)(error_msg) from None
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -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 首尾帧生视频",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user