Files
comfyui_o1key/nodes/seedance_autopass_batch.py
Jony ba920f2b66 Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
2026-09-24 19:56:48 +08:00

860 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Seedance 全能生成视频(批量)。
注册节点使用文件下方的 V3 实现:功能参数与单节点一致,媒体端口替换为
图片、视频和音频文件夹路径,并保留分批并发与输出目录控制。
"""
import os
import json
import asyncio
from pathlib import Path
import aiohttp
from comfy_api.latest import io
from ..utils.config import (
get_api_key_or_raise,
get_base_url_by_route,
)
from ..utils.r2_uploader import upload_image, upload_video
from ..utils.file_utils import load_images_from_folder
from ..utils.image_utils import parse_batch_prompts
from ..utils.video_task import (
PollDeadline,
check_interrupt,
download_video_to_file,
interruptible_sleep,
run_with_interrupt,
InterruptProcessingException,
)
from ..utils.http_error import async_request_with_retry
from .seedance_autopass import (
SeedanceAutoPass,
_BASE_MODELS,
_SUCCESS_STATUSES,
_FAILURE_STATUSES,
_RATIOS,
_DURATIONS,
_RESOLUTIONS,
_MODEL_ROUTES,
_GENERATION_MODES,
_MODE_MULTIMODAL,
_MODE_FIRST_FRAME,
_MODE_FIRST_LAST,
_MODE_TEXT,
_FAST_RESOLUTIONS,
_LIMITED_RESOLUTION_MODELS,
_normalize_model_route,
_resolve_model_matrix,
_resolve_asset_creation_mode,
)
from .seedance_video import (
_MM_MODELS,
_MM_RESOLUTIONS,
_resolve_model,
_is_new_format_model,
_check_fast_resolution,
)
try:
import folder_paths
FOLDER_PATHS_AVAILABLE = True
except ImportError:
FOLDER_PATHS_AVAILABLE = False
print("⚠️ SeedanceAutoPassBatch: folder_paths 不可用,将无法定位 output 目录")
_LABEL = "Seedance全能生成视频(批量)"
_MAX_BATCH = 10 # 每批最多并发提交数(用户要求硬上限 10)
_VIDEO_EXTENSIONS = {".mp4", ".mov"}
_AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg"}
_MM_DEFAULT_MODEL = _MM_MODELS[0]
def load_video_paths_from_folder(folder_path: str):
"""从文件夹按文件名升序收集 mp4/mov 视频路径。"""
folder_path = (folder_path or "").strip()
if not folder_path:
return []
path = Path(folder_path)
if not path.exists():
raise ValueError(f"视频文件夹不存在: {folder_path}")
if not path.is_dir():
raise ValueError(f"视频路径不是文件夹: {folder_path}")
files = [
f for f in path.iterdir()
if f.is_file() and f.suffix.lower() in _VIDEO_EXTENSIONS
]
files.sort(key=lambda x: x.name.lower())
return [str(f) for f in files]
def load_audio_paths_from_folder(folder_path: str):
"""从文件夹按文件名升序收集常见音频文件路径。"""
folder_path = (folder_path or "").strip()
if not folder_path:
return []
path = Path(folder_path)
if not path.exists():
raise ValueError(f"音频文件夹不存在: {folder_path}")
if not path.is_dir():
raise ValueError(f"音频路径不是文件夹: {folder_path}")
files = [
f for f in path.iterdir()
if f.is_file() and f.suffix.lower() in _AUDIO_EXTENSIONS
]
files.sort(key=lambda x: x.name.lower())
return [str(f) for f in files]
def _unique_output_path(out_dir: str, stem: str, ext: str = ".mp4") -> str:
"""在 out_dir 下生成不覆盖已有文件的目标路径。"""
os.makedirs(out_dir, exist_ok=True)
candidate = os.path.join(out_dir, f"{stem}{ext}")
if not os.path.exists(candidate):
return candidate
counter = 1
while True:
candidate = os.path.join(out_dir, f"{stem}_{counter}{ext}")
if not os.path.exists(candidate):
return candidate
counter += 1
def _build_mm_body(model_id, prompt, resolution, ratio, duration_s,
gen_audio, web_search, seed,
ref_url, kind):
"""
按 SeedanceMultiModal 的规则构建请求体。
kind: "image" | "video"
"""
use_new_format = _is_new_format_model(model_id)
# 构建 content 列表
content = []
if kind == "image":
content.append({
"type": "image_url",
"image_url": {"url": ref_url},
"role": "reference_image",
})
else:
content.append({
"type": "video_url",
"video_url": {"url": ref_url},
"role": "reference_video",
})
if prompt:
content.append({"type": "text", "text": prompt})
duration = int(duration_s.replace("秒", "")) if duration_s != "自动" else -1
if use_new_format:
# 新格式:顶层 content,文本放最前面
ordered = [item for item in content if item.get("type") == "text"]
ordered += [item for item in content if item.get("type") != "text"]
body = {
"model": model_id,
"content": ordered,
"duration": duration if duration != -1 else 5,
"resolution": resolution,
"ratio": ratio if ratio not in ("智能",) else "16:9",
"generate_audio": gen_audio,
"watermark": False,
"return_last_frame": False,
}
if seed != 0:
body["seed"] = seed
else:
# 旧格式:metadata.content
metadata: dict = {
"resolution": resolution,
"watermark": False,
"content": content,
}
if ratio != "智能":
metadata["ratio"] = ratio
if duration != -1:
metadata["duration"] = duration
if gen_audio:
metadata["generate_audio"] = True
if web_search:
metadata["tools"] = [{"type": "web_search"}]
if seed != 0:
metadata["seed"] = seed
body = {
"model": model_id,
"prompt": prompt if prompt else " ",
"metadata": metadata,
}
if kind == "image":
body["image"] = ref_url
return body
class _LegacySeedanceAutoPassBatch:
"""Seedance 2.0 自动过审 · 批量(文件夹 → 并发生成 → 落地 output"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"提示词": ("STRING", {"multiline": True, "default": ""}),
"图片文件夹": ("STRING", {"default": "", "multiline": False}),
"视频文件夹": ("STRING", {"default": "", "multiline": False}),
"模型": (_MM_MODELS, {"default": _MM_DEFAULT_MODEL}),
"分辨率": (_MM_RESOLUTIONS, {"default": "720p"}),
"宽高比": (_RATIOS, {"default": "智能"}),
"时长": (_DURATIONS, {"default": "5秒"}),
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
"联网搜索": (["关闭", "打开"], {"default": "关闭"}),
"每批并发数": ("INT", {"default": _MAX_BATCH, "min": 1, "max": _MAX_BATCH}),
"输出子目录": ("STRING", {"default": "", "multiline": False}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("结果汇总",)
FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = "comfyui_o1key/Seedance"
async def generate(self, **kwargs):
prompt = (kwargs["提示词"] or "").strip()
image_dir = (kwargs.get("图片文件夹") or "").strip()
video_dir = (kwargs.get("视频文件夹") or "").strip()
model_label = kwargs["模型"]
resolution = kwargs["分辨率"]
ratio = kwargs["宽高比"]
duration_s = kwargs["时长"]
gen_audio = kwargs["生成音频"] == "打开"
web_search = kwargs["联网搜索"] == "打开"
batch_size = max(1, min(int(kwargs.get("每批并发数", _MAX_BATCH)), _MAX_BATCH))
sub_dir = (kwargs.get("输出子目录") or "").strip()
seed = int(kwargs.get("seed", 0))
# 解析真实模型 ID 并做分辨率校验
model_id = _resolve_model(model_label)
_check_fast_resolution(model_id, resolution)
if not prompt:
raise ValueError("提示词不能为空")
if not image_dir and not video_dir:
raise ValueError("请至少填写「图片文件夹」或「视频文件夹」其中一个路径")
# ── 收集任务清单(每个文件一个任务)─────────────────────────────
tasks_meta = [] # [(kind, source, stem)]
if image_dir:
images = load_images_from_folder(image_dir)
if not images:
print(f"[{_LABEL}] 图片文件夹无可用图片: {image_dir}")
for info in images:
pil = info.image
if pil.mode == "RGBA":
pil = pil.convert("RGB")
tasks_meta.append(("image", pil, info.filename))
if video_dir:
videos = load_video_paths_from_folder(video_dir)
if not videos:
print(f"[{_LABEL}] 视频文件夹无可用视频(mp4/mov): {video_dir}")
for vpath in videos:
stem = os.path.splitext(os.path.basename(vpath))[0]
tasks_meta.append(("video", vpath, stem))
if not tasks_meta:
raise ValueError("两个文件夹中都没有可用素材,无法生成")
# ── 输出目录 ──────────────────────────────────────────────────
if not FOLDER_PATHS_AVAILABLE:
raise RuntimeError("folder_paths 不可用,无法定位 ComfyUI output 目录")
out_dir = os.path.abspath(folder_paths.get_output_directory())
if sub_dir:
out_dir = os.path.join(out_dir, sub_dir)
os.makedirs(out_dir, exist_ok=True)
base_url = get_base_url_by_route()
api_key = get_api_key_or_raise()
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
total = len(tasks_meta)
num_batches = (total + batch_size - 1) // batch_size
print(f"[{_LABEL}] 共 {total} 个任务,按每批 {batch_size} 个并发,分 {num_batches} 批提交")
results = []
connector = aiohttp.TCPConnector(ssl=False, limit=0, limit_per_host=0)
async with aiohttp.ClientSession(connector=connector) as session:
for batch_idx in range(num_batches):
check_interrupt()
start = batch_idx * batch_size
batch = tasks_meta[start:start + batch_size]
print(f"[{_LABEL}] 执行第 {batch_idx + 1}/{num_batches} 批 "
f"{start + 1}-{start + len(batch)}...")
coros = [
self._run_one(
session, base_url, headers, out_dir,
model_id, prompt, resolution, ratio, duration_s,
gen_audio, web_search, seed,
kind, source, stem, start + i + 1, total,
)
for i, (kind, source, stem) in enumerate(batch)
]
# return_exceptions=True:单个任务异常不影响同批其它任务
batch_results = await asyncio.gather(*coros, return_exceptions=True)
for r in batch_results:
if isinstance(r, InterruptProcessingException):
raise r # 用户主动取消,立即中止整批流程
if isinstance(r, Exception):
results.append({"success": False, "error": str(r), "source": "?"})
else:
results.append(r)
# ── 汇总 ──────────────────────────────────────────────────────
success = [r for r in results if r.get("success")]
failed = [r for r in results if not r.get("success")]
lines = [
f"任务总数: {total}",
f"成功: {len(success)}",
f"失败: {len(failed)}",
f"输出目录: {out_dir}",
]
if success:
lines.append("")
lines.append("成功文件:")
lines.extend(f" ✓ {os.path.basename(r['path'])}" for r in success)
if failed:
lines.append("")
lines.append("失败项:")
lines.extend(f" ✗ {os.path.basename(str(r.get('source', '?')))} - {r.get('error')}"
for r in failed)
summary = "\n".join(lines)
print(f"[{_LABEL}] 全部完成 — 成功 {len(success)} / 失败 {len(failed)}")
return (summary,)
async def _run_one(
self, session, base_url, headers, out_dir,
model_id, prompt, resolution, ratio, duration_s,
gen_audio, web_search, seed,
kind, source, stem, task_no, total,
) -> dict:
"""提交 → 轮询 → 下载单个任务;异常收敛为 result dict(中断异常除外)。"""
try:
# 1) 参考素材 → 公开 URL
if kind == "image":
ref_url = await upload_image(source, base_url=base_url)
else:
ref_url = await upload_video(source, base_url=base_url)
body = _build_mm_body(
model_id, prompt, resolution, ratio, duration_s,
gen_audio, web_search, seed,
ref_url, kind,
)
# 2) 提交
submit_url = f"{base_url}/v1/video/generations"
check_interrupt()
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", submit_url, json=body, headers=headers,
prefix=f"{_LABEL} 提交[{task_no}/{total}]: ",
))
text = await resp.text()
data = json.loads(text)
task_id = data.get("task_id") or data.get("id")
if not task_id:
raise RuntimeError(f"未返回 task_id,响应:{text[:300]}")
print(f"[{_LABEL}] 任务 {task_no}/{total} 已提交,task_id={task_id}")
# 3) 轮询
status_url = f"{base_url}/v1/video/generations/{task_id}"
deadline = PollDeadline(label=f"{_LABEL}#{task_no}")
interval = 4
video_url = None
download_headers = None
while True:
deadline.check()
check_interrupt()
async with session.get(status_url, headers=headers) as sresp:
stext = await sresp.text()
if sresp.status != 200:
raise RuntimeError(f"状态查询失败 ({sresp.status}): {stext[:300]}")
sdata = json.loads(stext)
status = (sdata.get("status")
or (sdata.get("data") or {}).get("status")
or "").lower()
if status in _SUCCESS_STATUSES:
video_url = SeedanceAutoPass._extract_video_url(sdata)
if not video_url:
video_url = f"{base_url}/v1/videos/{task_id}/content"
# 平台自有域名(含 content 代理)需带鉴权;第三方 CDN 直链绝不带 Bearer
if video_url.startswith(base_url):
download_headers = headers
break
if status in _FAILURE_STATUSES:
raise RuntimeError(f"生成失败,响应:{stext[:300]}")
await interruptible_sleep(interval)
interval = min(interval * 1.5, 15)
# 4) 下载到 output 目录
out_path = _unique_output_path(out_dir, stem)
await download_video_to_file(
session, video_url, out_path,
headers=download_headers, label=f"{_LABEL}#{task_no}",
)
print(f"[{_LABEL}] 任务 {task_no}/{total} 成功 ✓ → {out_path}")
return {"success": True, "path": out_path, "source": source}
except InterruptProcessingException:
raise
except Exception as e:
src_name = source if kind == "video" else stem
print(f"[{_LABEL}] 任务 {task_no}/{total} 失败 ✗ - {e}")
return {"success": False, "error": str(e), "source": src_name}
# V3 批量节点。保留上方旧实现只用于读取该版本文件时的历史语义说明;
# 注册映射使用下面这个同名类,节点 ID 不变,因此旧工作流仍能识别节点。
class SeedanceAutoPassBatch(io.ComfyNode):
"""Seedance 全能生成视频的文件夹批量版本。"""
@classmethod
def define_schema(cls):
web_search = lambda: io.Combo.Input(
"联网搜索",
options=["关闭", "打开"],
default="关闭",
advanced=True,
)
return io.Schema(
node_id="SeedanceAutoPassBatch",
display_name="Seedance 全能生成视频(批量)",
description=(
"参数与 Seedance 全能生成视频一致,媒体改为文件夹路径。"
"多模态素材按文件名排序后按序号组成任务;首尾帧按序号一一配对。"
"提示词支持用单独一行的 --- 分隔多条,与素材做笛卡尔组合。"
),
category="comfyui_o1key/Seedance",
inputs=[
io.String.Input(
"提示词",
multiline=True,
default="",
tooltip=(
"支持批量提示词:用单独一行的 --- 分隔多个提示词,"
"每个素材会与每个提示词组合成一个任务(素材数 × 提示词数)。"
"--- 不单独占一行时按单个提示词处理。"
),
),
io.DynamicCombo.Input(
"生成模式",
options=[
io.DynamicCombo.Option(
_MODE_MULTIMODAL,
[
web_search(),
io.String.Input(
"图片文件夹",
default="",
tooltip="图片按文件名升序,每张参与一条任务。",
),
io.String.Input(
"视频文件夹",
default="",
tooltip="支持 mp4/mov,与图片和音频按排序后的序号配对。",
),
io.String.Input(
"音频文件夹",
default="",
tooltip="支持 wav/mp3/m4a/aac/flac/ogg,按序号配对。",
),
],
),
io.DynamicCombo.Option(
_MODE_FIRST_FRAME,
[
web_search(),
io.String.Input(
"首帧图片文件夹",
default="",
tooltip="文件夹内每张图片分别生成一个视频。",
),
],
),
io.DynamicCombo.Option(
_MODE_FIRST_LAST,
[
web_search(),
io.String.Input(
"首帧图片文件夹",
default="",
tooltip="按文件名升序与尾帧图片一一配对。",
),
io.String.Input(
"尾帧图片文件夹",
default="",
tooltip="图片数量必须与首帧文件夹一致。",
),
],
),
io.DynamicCombo.Option(
_MODE_TEXT,
[
web_search(),
io.Int.Input(
"生成数量",
default=1,
min=1,
max=100,
tooltip=(
"使用相同参数批量提交的文生视频任务数。"
"批量提示词模式下总任务数为本数量 × 提示词数。"
),
),
],
),
],
tooltip="切换后仅显示当前模式需要的文件夹输入。",
),
io.Combo.Input("主模型", options=_BASE_MODELS, default="seedance 2.0"),
io.Combo.Input("模型线路", options=_MODEL_ROUTES, default="国内"),
io.Combo.Input("分辨率", options=_RESOLUTIONS, default="720p"),
io.Combo.Input("宽高比", options=_RATIOS, default="智能"),
io.Combo.Input("时长", options=_DURATIONS, default="5秒"),
io.Combo.Input("生成音频", options=["关闭", "打开"], default="关闭"),
io.Int.Input(
"seed",
default=0,
min=0,
max=0xffffffffffffffff,
advanced=True,
),
io.Int.Input(
"每批并发数",
default=_MAX_BATCH,
min=1,
max=_MAX_BATCH,
advanced=True,
),
io.String.Input(
"输出子目录",
default="",
advanced=True,
tooltip="留空时直接保存到 ComfyUI output 目录。",
),
],
outputs=[io.String.Output(display_name="结果汇总")],
is_output_node=True,
)
@staticmethod
def _mode_inputs(kwargs):
mode_inputs = kwargs.get("生成模式")
if isinstance(mode_inputs, dict):
return mode_inputs.get("生成模式", _MODE_MULTIMODAL), mode_inputs
if isinstance(mode_inputs, str):
return mode_inputs, kwargs
return _MODE_MULTIMODAL, kwargs
@classmethod
def _build_tasks(cls, generation_mode, mode_inputs, prompt):
"""构造最终任务列表:媒体任务 × 提示词。
提示词用单独一行的 --- 分隔时进入批量提示词模式,每个媒体任务与每个
提示词组合成一条任务;否则所有任务共用同一个提示词。
"""
batch_prompts = parse_batch_prompts(prompt)
media_tasks = cls._build_media_tasks(generation_mode, mode_inputs, prompt)
if not batch_prompts:
for task in media_tasks:
task["prompt"] = prompt
return media_tasks
width = len(str(len(batch_prompts)))
tasks = []
for media_task in media_tasks:
for prompt_index, task_prompt in enumerate(batch_prompts, start=1):
task = dict(media_task)
task["prompt"] = task_prompt
task["stem"] = f"{media_task['stem']}_p{prompt_index:0{width}d}"
task["source"] = f"{media_task['source']} [提示词{prompt_index}]"
tasks.append(task)
return tasks
@classmethod
def _build_media_tasks(cls, generation_mode, mode_inputs, prompt):
"""读取文件夹并按当前模式构造媒体任务(不含提示词)。"""
if generation_mode not in _GENERATION_MODES:
raise ValueError(f"不支持的生成模式:{generation_mode}")
if generation_mode == _MODE_TEXT:
if not prompt:
raise ValueError("文生视频模式下提示词不能为空")
count = int(mode_inputs.get("生成数量", 1))
return [
{
"images": [], "videos": [], "audios": [],
"stem": f"seedance_text_{index:03d}",
"source": f"文生视频任务{index}",
}
for index in range(1, count + 1)
]
if generation_mode == _MODE_FIRST_FRAME:
images = load_images_from_folder(mode_inputs.get("首帧图片文件夹", ""))
if not images:
raise ValueError("首帧图片文件夹中没有可用图片")
return [
{
"images": [item.image], "videos": [], "audios": [],
"stem": item.filename, "source": item.source_path,
}
for item in images
]
if generation_mode == _MODE_FIRST_LAST:
first_images = load_images_from_folder(mode_inputs.get("首帧图片文件夹", ""))
last_images = load_images_from_folder(mode_inputs.get("尾帧图片文件夹", ""))
if not first_images or not last_images:
raise ValueError("首帧和尾帧图片文件夹都必须包含可用图片")
if len(first_images) != len(last_images):
raise ValueError(
"首帧与尾帧图片数量必须一致:"
f"当前首帧 {len(first_images)} 张,尾帧 {len(last_images)} 张"
)
return [
{
"images": [first.image, last.image],
"videos": [], "audios": [],
"stem": first.filename,
"source": f"{first.source_path} + {last.source_path}",
}
for first, last in zip(first_images, last_images)
]
images = load_images_from_folder(mode_inputs.get("图片文件夹", ""))
videos = load_video_paths_from_folder(mode_inputs.get("视频文件夹", ""))
audios = load_audio_paths_from_folder(mode_inputs.get("音频文件夹", ""))
task_count = max(len(images), len(videos), len(audios), 1 if prompt else 0)
if task_count == 0:
raise ValueError("请至少填写一个包含可用素材的文件夹,或提供提示词")
tasks = []
for index in range(task_count):
image = images[index] if index < len(images) else None
video = videos[index] if index < len(videos) else None
audio = audios[index] if index < len(audios) else None
sources = [item for item in (image, video, audio) if item is not None]
if sources:
first = sources[0]
stem = first.filename if hasattr(first, "filename") else Path(first).stem
source = first.source_path if hasattr(first, "source_path") else str(first)
else:
stem = f"seedance_{index + 1:03d}"
source = f"多模态任务{index + 1}"
tasks.append({
"images": [image.image] if image is not None else [],
"videos": [video] if video is not None else [],
"audios": [audio] if audio is not None else [],
"stem": stem,
"source": source,
})
return tasks
@classmethod
async def execute(cls, **kwargs):
generation_mode, mode_inputs = cls._mode_inputs(kwargs)
prompt = (kwargs.get("提示词", "") or "").strip()
base_model = kwargs["主模型"]
model_route = _normalize_model_route(kwargs["模型线路"])
model = _resolve_model_matrix(base_model, model_route)
resolution = kwargs["分辨率"]
ratio = kwargs["宽高比"]
duration_s = kwargs["时长"]
gen_audio = kwargs["生成音频"] == "打开"
web_search = mode_inputs.get("联网搜索", "关闭") == "打开"
create_mode = _resolve_asset_creation_mode(model_route)
seed = int(kwargs.get("seed", 0))
batch_size = max(1, min(int(kwargs.get("每批并发数", _MAX_BATCH)), _MAX_BATCH))
sub_dir = (kwargs.get("输出子目录") or "").strip()
if model in _LIMITED_RESOLUTION_MODELS and resolution not in _FAST_RESOLUTIONS:
raise ValueError(f"{model} 仅支持 {'/'.join(sorted(_FAST_RESOLUTIONS))}")
tasks = cls._build_tasks(generation_mode, mode_inputs, prompt)
for task in tasks:
SeedanceAutoPass._validate_mode_inputs(
generation_mode, base_model, task["prompt"],
task["images"], task["videos"], task["audios"],
)
SeedanceAutoPass._validate_dynamic_parameters(
base_model, model_route, duration_s,
task["images"], task["videos"], task["audios"],
)
SeedanceAutoPass._validate_reference_media(
task["images"], task["videos"], task["audios"],
)
if not FOLDER_PATHS_AVAILABLE:
raise RuntimeError("folder_paths 不可用,无法定位 ComfyUI output 目录")
out_dir = os.path.abspath(folder_paths.get_output_directory())
if sub_dir:
out_dir = os.path.join(out_dir, sub_dir)
os.makedirs(out_dir, exist_ok=True)
base_url = get_base_url_by_route()
headers = {
"Authorization": f"Bearer {get_api_key_or_raise()}",
"Content-Type": "application/json",
}
total = len(tasks)
num_batches = (total + batch_size - 1) // batch_size
batch_prompt_count = len(parse_batch_prompts(prompt))
if batch_prompt_count:
print(
f"[{_LABEL}] 批量提示词模式:{total // batch_prompt_count} 个素材 × "
f"{batch_prompt_count} 个提示词"
)
print(f"[{_LABEL}] 共 {total} 个任务,每批最多 {batch_size} 个并发,共 {num_batches} 批")
results = []
connector = aiohttp.TCPConnector(ssl=False, limit=0, limit_per_host=0)
async with aiohttp.ClientSession(connector=connector) as session:
for batch_index in range(num_batches):
check_interrupt()
start = batch_index * batch_size
batch = tasks[start:start + batch_size]
coroutines = [
cls._run_one(
session, base_url, headers, out_dir,
model, resolution, ratio, duration_s,
gen_audio, web_search, seed, create_mode,
generation_mode, task, start + offset + 1, total,
)
for offset, task in enumerate(batch)
]
batch_results = await asyncio.gather(*coroutines, return_exceptions=True)
for result in batch_results:
if isinstance(result, InterruptProcessingException):
raise result
if isinstance(result, Exception):
results.append({"success": False, "error": str(result), "source": "?"})
else:
results.append(result)
succeeded = [item for item in results if item.get("success")]
failed = [item for item in results if not item.get("success")]
lines = [
f"任务总数: {total}",
f"成功: {len(succeeded)}",
f"失败: {len(failed)}",
f"输出目录: {out_dir}",
]
if succeeded:
lines.extend(["", "成功文件:"])
lines.extend(f" ✓ {os.path.basename(item['path'])}" for item in succeeded)
if failed:
lines.extend(["", "失败项:"])
lines.extend(
f" ✗ {os.path.basename(str(item.get('source', '?')))} - {item.get('error')}"
for item in failed
)
summary = "\n".join(lines)
print(f"[{_LABEL}] 全部完成 — 成功 {len(succeeded)} / 失败 {len(failed)}")
return io.NodeOutput(summary)
@classmethod
async def _run_one(
cls, session, base_url, headers, out_dir,
model, resolution, ratio, duration_s,
gen_audio, web_search, seed, create_mode,
generation_mode, task, task_no, total,
):
prompt = task["prompt"]
try:
image_urls, video_urls, audio_urls = await SeedanceAutoPass._create_assets(
task["images"], task["videos"], task["audios"], base_url, create_mode
)
body = SeedanceAutoPass._build_body(
model, prompt, resolution, ratio, duration_s,
gen_audio, web_search, seed,
image_urls, video_urls, audio_urls,
use_asset_protocol=True,
generation_mode=generation_mode,
)
response = await run_with_interrupt(async_request_with_retry(
session,
"POST",
f"{base_url}/v1/video/generations",
json=body,
headers=headers,
prefix=f"{_LABEL} 提交[{task_no}/{total}]: ",
))
response_text = await response.text()
data = json.loads(response_text)
task_id = data.get("task_id") or data.get("id")
if not task_id:
raise RuntimeError(f"未返回 task_id,响应:{response_text[:300]}")
status_url = f"{base_url}/v1/video/generations/{task_id}"
deadline = PollDeadline(label=f"{_LABEL}#{task_no}")
interval = 4
download_headers = None
while True:
deadline.check()
check_interrupt()
async with session.get(status_url, headers=headers) as status_response:
status_text = await status_response.text()
if status_response.status != 200:
raise RuntimeError(
f"状态查询失败 ({status_response.status}): {status_text[:300]}"
)
status_data = json.loads(status_text)
status = (
status_data.get("status")
or (status_data.get("data") or {}).get("status")
or ""
).lower()
if status in _SUCCESS_STATUSES:
video_url = SeedanceAutoPass._extract_video_url(status_data)
if not video_url:
video_url = f"{base_url}/v1/videos/{task_id}/content"
if video_url.startswith(base_url):
download_headers = headers
break
if status in _FAILURE_STATUSES:
raise RuntimeError(f"生成失败,响应:{status_text[:300]}")
await interruptible_sleep(interval)
interval = min(interval * 1.5, 15)
out_path = _unique_output_path(out_dir, task["stem"])
await download_video_to_file(
session, video_url, out_path,
headers=download_headers,
label=f"{_LABEL}#{task_no}",
)
print(f"[{_LABEL}] 任务 {task_no}/{total} 成功 ✓ → {out_path}")
return {"success": True, "path": out_path, "source": task["source"]}
except InterruptProcessingException:
raise
except Exception as error:
print(f"[{_LABEL}] 任务 {task_no}/{total} 失败 ✗ - {error}")
return {"success": False, "error": str(error), "source": task["source"]}
NODE_CLASS_MAPPINGS = {
"SeedanceAutoPassBatch": SeedanceAutoPassBatch,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SeedanceAutoPassBatch": "Seedance 全能生成视频(批量)",
}