"""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 全能生成视频(批量)", }