"""Seedance 全能生成视频 V3 / 批量节点 tests.""" import asyncio import io as py_io import os import sys import tempfile import unittest from pathlib import Path from unittest.mock import AsyncMock, patch from PIL import Image PACKAGE_DIR = Path(__file__).resolve().parents[1] sys.path.insert(0, str(PACKAGE_DIR.parent)) from comfy_api.latest import InputImpl, io from comfy_api.latest._io import build_nested_inputs, get_finalized_class_inputs import comfyui_o1key from comfyui_o1key.nodes import seedance_video from comfyui_o1key.nodes.seedance_autopass import ( SeedanceAutoPass, _resolve_asset_creation_mode, _resolve_model_matrix, ) from comfyui_o1key.nodes.seedance_autopass_batch import SeedanceAutoPassBatch from comfyui_o1key.utils.r2_uploader import upload_audio from comfyui_o1key.utils.video_task import format_seedance_generation_error from comfyui_o1key.utils.o1key_video_jobs import build_seedance_video_body class SeedanceAutoPassV3Tests(unittest.TestCase): def test_legacy_seedance_video_node_is_removed(self): self.assertNotIn("Seedance", comfyui_o1key.NODE_CLASS_MAPPINGS) self.assertNotIn("Seedance", seedance_video.NODE_CLASS_MAPPINGS) self.assertFalse(hasattr(seedance_video, "Seedance")) self.assertIn("SeedanceMultiModal", comfyui_o1key.NODE_CLASS_MAPPINGS) def test_schema_uses_stable_mode_combo_with_expected_inputs(self): self.assertTrue(issubclass(SeedanceAutoPass, io.ComfyNode)) schema = SeedanceAutoPass.GET_SCHEMA() inputs = {input_def.id: input_def for input_def in schema.inputs} self.assertNotIn("固定镜头", inputs) self.assertNotIn("图片链接", inputs) self.assertIn("提示词", inputs) self.assertIn("参考图片", inputs) self.assertIn("生成模式", inputs) self.assertEqual( [input_def.id for input_def in schema.inputs[:3]], ["提示词", "生成模式", "主模型"], ) mode_input = inputs["生成模式"] self.assertEqual(mode_input.io_type, "COMBO") self.assertEqual(mode_input.options, ["多模态", "首尾帧"]) self.assertEqual(mode_input.default, "多模态") expected = { "参考图片": (30, [f"参考图片{i}" for i in range(1, 31)]), "参考视频": (10, [f"参考视频{i}" for i in range(1, 11)]), "参考音频": (10, [f"参考音频{i}" for i in range(1, 11)]), } for group_name, (maximum, names) in expected.items(): input_def = inputs[group_name] self.assertEqual(input_def.io_type, "COMFY_AUTOGROW_V3") self.assertTrue(input_def.optional) self.assertEqual(input_def.template.min, 0) self.assertEqual(len(input_def.template.names), maximum) self.assertEqual(input_def.template.names, names) self.assertEqual( [input_def.id for input_def in schema.inputs if input_def.id in {"首帧图片", "尾帧图片"}], ["首帧图片", "尾帧图片"], ) self.assertTrue(inputs["首帧图片"].optional) self.assertTrue(inputs["尾帧图片"].optional) self.assertNotIn("自动创建素材", inputs) self.assertNotIn("素材创建", inputs) self.assertEqual( [input_def.id for input_def in schema.inputs[-3:]], ["联网搜索", "seed", "返回末帧图片"], ) for prefix, maximum in (("图片素材ID", 30), ("视频素材ID", 10), ("音频素材ID", 10)): self.assertNotIn(prefix, inputs) for index in range(1, maximum + 1): self.assertFalse(inputs[f"{prefix}{index}"].multiline) self.assertEqual(inputs[f"{prefix}{index}"].default, "") for former_advanced_name in ("联网搜索", "seed", "返回末帧图片"): self.assertFalse(inputs[former_advanced_name].advanced) self.assertEqual(inputs["素材创建模式"].io_type, "COMBO") self.assertEqual(inputs["素材创建模式"].options, ["关闭", "打开"]) self.assertEqual(inputs["素材创建模式"].default, "关闭") self.assertEqual(inputs["返回末帧图片"].default, "关闭") self.assertEqual( [output.display_name for output in schema.outputs], ["视频", "末帧图片"], ) self.assertEqual(schema.display_name, "Seedance 全能生成视频") self.assertEqual( comfyui_o1key.NODE_DISPLAY_NAME_MAPPINGS["SeedanceAutoPass"], "Seedance 全能生成视频", ) def test_autogrow_values_keep_slot_order_and_drop_empty_slots(self): first = object() third = object() kwargs = {"参考图片": {"参考图片1": first, "参考图片2": None, "参考图片3": third}} values = SeedanceAutoPass._autogrow_values(kwargs, "参考图片", "参考图片", 9) self.assertEqual(values, [first, third]) def test_numbered_asset_ids_preserve_order_and_legacy_alias(self): self.assertEqual(SeedanceAutoPass._manual_asset_ids({ "图片素材ID1": "image-1", "图片素材ID3": "asset://image-3", }, "图片素材ID", 30), ["image-1", "asset://image-3"]) self.assertEqual(SeedanceAutoPass._manual_asset_ids({ "图片素材ID": "image-1,image-2\nimage-3", }, "图片素材ID", 30), ["image-1", "image-2", "image-3"]) def test_asset_mode_toggle_and_legacy_values(self): for inputs, expected in [ ({}, "关闭"), ({"素材创建模式": "关闭"}, "关闭"), ({"素材创建模式": "打开"}, "打开"), ({"素材创建": "自动创建"}, "关闭"), ({"素材创建": "手动"}, "打开"), ({"素材创建": {"素材创建": "手动"}}, "打开"), ]: self.assertEqual(SeedanceAutoPass._asset_creation_inputs(inputs)[0], expected) def test_comfyui_builds_expected_stable_combo_values(self): live_inputs = { "提示词": "prompt", "生成模式": "多模态", "参考图片.参考图片1": "image-1", "参考音频.参考音频1": "audio-1", "联网搜索": "打开", } _, _, v3_data = get_finalized_class_inputs( SeedanceAutoPass.INPUT_TYPES(), live_inputs ) nested = build_nested_inputs(live_inputs, v3_data) self.assertEqual(nested["生成模式"], "多模态") self.assertEqual(nested["参考图片"], {"参考图片1": "image-1"}) self.assertEqual(nested["参考音频"], {"参考音频1": "audio-1"}) self.assertEqual(nested["提示词"], "prompt") self.assertEqual(nested["联网搜索"], "打开") def test_legacy_numbered_values_are_still_accepted_by_execute_helper(self): first = object() third = object() kwargs = {"参考视频1": first, "参考视频2": None, "参考视频3": third} values = SeedanceAutoPass._autogrow_values(kwargs, "参考视频", "参考视频", 3) self.assertEqual(values, [first, third]) def test_v3_async_execution_adapter_is_active(self): self.assertEqual(SeedanceAutoPass.FUNCTION, "EXECUTE_NORMALIZED_ASYNC") self.assertEqual(SeedanceAutoPass.RETURN_TYPES, ["VIDEO", "IMAGE"]) self.assertEqual(SeedanceAutoPass.RETURN_NAMES, ["视频", "末帧图片"]) def test_model_defaults_and_seedance_25_capabilities_are_exposed(self): schema = SeedanceAutoPass.GET_SCHEMA() inputs = {input_def.id: input_def for input_def in schema.inputs} self.assertIn("seedance 2.5", inputs["主模型"].options) self.assertEqual(inputs["主模型"].default, "seedance 2.0") self.assertEqual(inputs["模型线路"].default, "国内") self.assertEqual(inputs["模型线路"].options, ["海外", "国内"]) self.assertEqual(inputs["分辨率"].default, "720p") self.assertIn("30秒", inputs["时长"].options) self.assertEqual(_resolve_asset_creation_mode("海外HC"), "HC") self.assertEqual(_resolve_asset_creation_mode("海外"), "HC") self.assertEqual(_resolve_asset_creation_mode("国内"), "Doubao") self.assertEqual(_resolve_asset_creation_mode("海外破限高并发"), "HC") self.assertEqual( _resolve_model_matrix("seedance 2.5", "海外HC"), "dreamina-seedance-2-5-hc", ) self.assertEqual( _resolve_model_matrix("seedance 2.5", "国内"), "doubao-seedance-2-5-260628-max", ) self.assertEqual( _resolve_model_matrix("seedance 2.5", "海外破限标准"), "dreamina-seedance-2-5-hc", ) def test_seedance_25_accepts_its_maximum_dynamic_parameters(self): SeedanceAutoPass._validate_dynamic_parameters( "seedance 2.5", "海外HC", "30秒", [object()] * 30, [object()] * 10, [object()] * 10, ) def test_seedance_25_rejects_unsupported_route_and_excess_inputs(self): with self.assertRaisesRegex(ValueError, "仅支持模型线路"): SeedanceAutoPass._validate_dynamic_parameters( "seedance 2.5", "未知线路", "5秒", [], [], [] ) with self.assertRaisesRegex(ValueError, "时长仅支持 4-30 秒"): SeedanceAutoPass._validate_dynamic_parameters( "seedance 2.5", "海外HC", "31秒", [], [], [] ) with self.assertRaisesRegex(ValueError, "最多支持 30 个参考图片"): SeedanceAutoPass._validate_dynamic_parameters( "seedance 2.5", "海外HC", "30秒", [object()] * 31, [], [], ) def test_seedance_20_keeps_previous_limits(self): SeedanceAutoPass._validate_dynamic_parameters( "seedance 2.0", "海外HC", "15秒", [object()] * 9, [object()] * 3, [object()] * 3, ) with self.assertRaisesRegex(ValueError, "时长仅支持 4-15 秒"): SeedanceAutoPass._validate_dynamic_parameters( "seedance 2.0", "海外HC", "16秒", [], [], [] ) with self.assertRaisesRegex(ValueError, "最多支持 3 个参考视频"): SeedanceAutoPass._validate_dynamic_parameters( "seedance 2.0", "海外HC", "15秒", [], [object()] * 4, [], ) def test_mode_validation_matches_seedance_20_and_25_rules(self): audio = object() SeedanceAutoPass._validate_mode_inputs( "多模态参考生视频", "seedance 2.5", "", [], [], [audio] ) with self.assertRaisesRegex(ValueError, "不可单独输入参考音频"): SeedanceAutoPass._validate_mode_inputs( "多模态参考生视频", "seedance 2.0", "", [], [], [audio] ) with self.assertRaisesRegex(ValueError, "提示词不能为空"): SeedanceAutoPass._validate_mode_inputs( "文生视频", "seedance 2.5", "", [], [], [] ) SeedanceAutoPass._validate_mode_inputs( "图生视频-首帧", "seedance 2.5", "", [object()], [], [] ) with self.assertRaisesRegex(ValueError, "同时提供首帧图片和尾帧图片"): SeedanceAutoPass._validate_mode_inputs( "图生视频-首尾帧", "seedance 2.5", "", [object()], [], [] ) def test_simplified_modes_resolve_to_provider_modes_from_connected_media(self): self.assertEqual( SeedanceAutoPass._validate_mode_inputs( "多模态", "seedance 2.5", "prompt", [], [], [] ), "text", ) self.assertEqual( SeedanceAutoPass._validate_mode_inputs( "多模态", "seedance 2.5", "", [object()], [], [] ), "multimodal", ) self.assertEqual( SeedanceAutoPass._validate_mode_inputs( "首尾帧", "seedance 2.5", "", [object()], [], [] ), "first_frame", ) self.assertEqual( SeedanceAutoPass._validate_mode_inputs( "首尾帧", "seedance 2.5", "", [object(), object()], [], [] ), "first_last_frame", ) def test_frame_modes_use_first_and_last_frame_roles(self): body = SeedanceAutoPass._build_body( "dreamina-seedance-2-5-hc", "", "720p", "智能", "5秒", True, False, 0, ["https://example.com/first.png", "https://example.com/last.png"], [], [], use_asset_protocol=False, generation_mode="首尾帧", ) self.assertNotIn("prompt", body) self.assertNotIn("images", body) self.assertEqual( [item["role"] for item in body["content"]], ["first_frame", "last_frame"], ) def test_first_frame_mode_omits_image_role(self): body = SeedanceAutoPass._build_body( "dreamina-seedance-2-5-hc", "", "720p", "智能", "5秒", True, False, 0, ["https://ark-project.tos-cn-beijing.volces.com/doc_image/i2v_foxrgirl.png"], [], [], use_asset_protocol=False, generation_mode="首尾帧", ) self.assertEqual(body["content"][0], { "type": "image_url", "image_url": { "url": "https://ark-project.tos-cn-beijing.volces.com/doc_image/i2v_foxrgirl.png" }, }) def test_request_body_matches_o1key_video_builder(self): actual = SeedanceAutoPass._build_body( "doubao-seedance-2-5-260628-max", "prompt", "1080p", "智能", "30秒", True, True, 42, ["asset://image-1"], ["asset://video-1"], ["asset://audio-1"], use_asset_protocol=True, generation_mode="多模态参考生视频", return_last_frame=True, ) expected = build_seedance_video_body({ "actual_model": "doubao-seedance-2-5-260628-max", "prompt": "prompt", "generation_mode": "multimodal", "asset_creation_mode": "auto", "assets": {"images": [], "videos": [], "audios": []}, "duration": 30, "resolution": "1080p", "aspect_ratio": "auto", "generate_audio": True, "return_last_frame": True, "seed": 42, }, { "first_frame": None, "last_frame": None, "reference_images": ["asset://image-1"], "reference_videos": ["asset://video-1"], "reference_audios": ["asset://audio-1"], }) self.assertEqual(actual, expected) self.assertNotIn("web_search", actual) def test_manual_asset_creation_uses_existing_asset_ids(self): body = SeedanceAutoPass._build_body( "dreamina-seedance-2-5-hc", "prompt", "720p", "智能", "5秒", False, False, 0, ["image-1"], ["asset://video-1"], ["audio-1"], generation_mode="multimodal", asset_creation_mode="manual", ) self.assertEqual( [item["image_url"]["url"] for item in body["content"] if item["type"] == "image_url"], ["asset://image-1"], ) self.assertEqual( [item["video_url"]["url"] for item in body["content"] if item["type"] == "video_url"], ["asset://video-1"], ) self.assertEqual( [item["audio_url"]["url"] for item in body["content"] if item["type"] == "audio_url"], ["asset://audio-1"], ) def test_reference_media_limits_are_enforced_before_upload(self): valid_image = Image.new("RGB", (300, 300)) class Video: def __init__(self): self.source = py_io.BytesIO(b"video") def get_stream_source(self): return self.source def get_dimensions(self): return 854, 480 with tempfile.TemporaryDirectory() as root: audio_path = Path(root) / "reference.mp3" audio_path.write_bytes(b"audio") SeedanceAutoPass._validate_reference_media( [valid_image], [Video()], [str(audio_path)], ) with self.assertRaisesRegex(ValueError, "宽高必须分别在 300~6000px"): SeedanceAutoPass._validate_reference_media( [Image.new("RGB", (299, 300))], [], [], ) class SeedanceAutoPassExecuteTests(unittest.IsolatedAsyncioTestCase): async def test_automatic_frame_mode_rejects_last_frame_without_first_frame(self): with self.assertRaisesRegex(ValueError, "必须提供首帧图片"): await SeedanceAutoPass.execute(**{ "提示词": "prompt", "生成模式": { "生成模式": "首尾帧", "尾帧图片": object(), }, "主模型": "seedance 2.5", "模型线路": "国内", "分辨率": "720p", "宽高比": "智能", "时长": "5秒", "生成音频": "关闭", "联网搜索": "关闭", "seed": 0, "返回末帧图片": "关闭", "素材创建": {"素材创建": "自动创建"}, }) async def test_manual_assets_skip_upload_and_asset_creation(self): create = AsyncMock() validate = unittest.mock.Mock() submit = AsyncMock(return_value=("result.mp4", None)) video_output = object() with ( patch.object(SeedanceAutoPass, "_create_assets", create), patch.object(SeedanceAutoPass, "_validate_reference_media", validate), patch.object(SeedanceAutoPass, "_submit_poll_download", submit), patch.object(InputImpl, "VideoFromFile", return_value=video_output), patch( "comfyui_o1key.nodes.seedance_autopass.get_base_url_by_route", return_value="https://api.example.com", ), patch("builtins.print"), ): output = await SeedanceAutoPass.execute(**{ "提示词": "prompt", "生成模式": "多模态", "主模型": "seedance 2.5", "模型线路": "海外", "分辨率": "720p", "宽高比": "智能", "时长": "5秒", "生成音频": "关闭", "联网搜索": "关闭", "seed": 0, "返回末帧图片": "关闭", "素材创建模式": "打开", "图片素材ID1": "image-1", "图片素材ID2": "asset://image-2", "视频素材ID1": "video-1", "音频素材ID1": "audio-1", }) create.assert_not_awaited() validate.assert_not_called() content = submit.await_args.args[0]["content"] self.assertEqual( [item["image_url"]["url"] for item in content if item["type"] == "image_url"], ["asset://image-1", "asset://image-2"], ) self.assertIs(output.result[0], video_output) async def test_domestic_asset_creation_uses_doubao_with_bounded_order(self): active = 0 maximum = 0 calls = [] class FakeElementClient: async def create_hc_asset_and_wait(self, **kwargs): nonlocal active, maximum active += 1 maximum = max(maximum, active) calls.append(kwargs) await asyncio.sleep(0.002) active -= 1 return {"Id": f"asset-{kwargs['name']}"} images = [Image.new("RGB", (300, 300)) for _ in range(5)] with ( patch( "comfyui_o1key.nodes.seedance_autopass.SeedanceElementClient", return_value=FakeElementClient(), ), patch( "comfyui_o1key.nodes.seedance_autopass.upload_image", new=AsyncMock(return_value="https://upload.example.com/image.png"), ), ): image_urls, video_urls, audio_urls = await SeedanceAutoPass._create_assets( images, [], [], "https://api.example.com", "Doubao", ) self.assertLessEqual(maximum, 3) self.assertEqual([call["request_type"] for call in calls], ["doubao"] * 5) self.assertEqual( image_urls, [f"asset://asset-参考图片{index}" for index in range(1, 6)], ) self.assertEqual(video_urls, []) self.assertEqual(audio_urls, []) async def test_submit_uses_shared_seedance_client(self): fake_client = type("FakeSeedanceClient", (), {})() fake_client.base_url = "" fake_client.generate_async = AsyncMock(return_value=("result.mp4", "last.png")) with tempfile.TemporaryDirectory() as root: save_path = str(Path(root) / "result.mp4") handle = os.open(save_path, os.O_CREAT | os.O_RDWR) with ( patch( "comfyui_o1key.nodes.seedance_autopass.SeedanceClient", return_value=fake_client, ), patch( "comfyui_o1key.nodes.seedance_autopass.tempfile.mkstemp", return_value=(handle, save_path), ), ): result = await SeedanceAutoPass._submit_poll_download( {"model": "model", "content": []}, "https://api.example.com", ) self.assertEqual(result, ("result.mp4", "last.png")) self.assertEqual(fake_client.base_url, "https://api.example.com") fake_client.generate_async.assert_awaited_once_with( body={"model": "model", "content": []}, save_path=save_path, use_new_format=True, ) async def test_multimodal_logs_original_request_body_without_exposing_temporary_url(self): signed_url = "https://example.invalid/private/reference.png?signature=secret" image = type("FakeImageTensor", (), {"dim": lambda self: 4})() video_output = object() fake_client = type("FakeSeedanceClient", (), {})() fake_client.base_url = "" fake_client.generate_async = AsyncMock(return_value=("result.mp4", None)) with ( patch.object(seedance_video, "SeedanceClient", return_value=fake_client), patch.object( seedance_video, "_tensor_to_uploaded_url", new=AsyncMock(return_value=signed_url), ), patch.object(seedance_video, "_show_balance"), patch.object(seedance_video.tempfile, "mkstemp", return_value=(0, "unused.mp4")), patch.object(InputImpl, "VideoFromFile", return_value=video_output), patch( "comfyui_o1key.nodes.seedance_video.get_base_url_by_route", return_value="https://api.example.com", ), patch("builtins.print") as print_mock, ): output = await seedance_video.SeedanceMultiModal.generate(**{ "提示词": "test prompt", "主模型": "seedance 2.5", "模型线路": "海外HC", "分辨率": "1080p", "宽高比": "16:9", "时长": "5秒", "生成音频": "关闭", "联网搜索": "关闭", "返回末帧图片": "关闭", "seed": 0, "参考图片": {"参考图片1": image}, }) rendered_log = "\n".join( " ".join(str(arg) for arg in call.args) for call in print_mock.call_args_list ) submitted_body = fake_client.generate_async.await_args.kwargs["body"] self.assertIn("[SeedanceMultiModal] 原始请求体", rendered_log) self.assertIn('"model": "dreamina-seedance-2-5-hc"', rendered_log) self.assertIn('"text": "test prompt"', rendered_log) self.assertIn('"url": ""', rendered_log) self.assertNotIn(signed_url, rendered_log) self.assertEqual(submitted_body["content"][1]["image_url"]["url"], signed_url) self.assertIs(output.result[0], video_output) def test_multimodal_request_log_sanitizer_folds_secrets_and_base64(self): safe_body = seedance_video._sanitize_request_body_for_log({ "authorization": "Bearer secret", "image": "data:image/png;base64,QUJDREVGRw==", "content": [{"image_url": {"url": "asset://person-1"}}], }) self.assertEqual(safe_body["authorization"], "") self.assertEqual( safe_body["image"], "data:image/png;base64,", ) self.assertEqual( safe_body["content"][0]["image_url"]["url"], "asset://person-1", ) async def test_copyright_error_is_wrapped_by_both_seedance_nodes(self): raw = ( "The request failed because the output video may be related to " "copyright restriction" ) policy_raw = ( "OutputVideoSensitiveContentDetected.PolicyViolation: The request failed " "because the output video may be related to copyright restrictions" ) expected = "输出视频触发版权审查被拒绝生成!" self.assertEqual(format_seedance_generation_error(raw), expected) self.assertEqual(format_seedance_generation_error(policy_raw), expected) with ( patch.object( SeedanceAutoPass, "_create_assets", new=AsyncMock(return_value=([], [], [])), ), patch.object( SeedanceAutoPass, "_submit_poll_download", new=AsyncMock(side_effect=RuntimeError(f"生成失败,响应:{raw}")), ), patch( "comfyui_o1key.nodes.seedance_autopass.get_base_url_by_route", return_value="https://api.example.com", ), ): with self.assertRaisesRegex(RuntimeError, expected): await SeedanceAutoPass.execute(**{ "提示词": "test prompt", "生成模式": { "生成模式": "文生视频", "联网搜索": "关闭", }, "主模型": "seedance 2.0", "模型线路": "海外HC", "分辨率": "720p", "宽高比": "智能", "时长": "5秒", "生成音频": "关闭", "联网搜索": "关闭", "seed": 0, }) fake_client = type("FakeSeedanceClient", (), {})() fake_client.base_url = "" fake_client.generate_async = AsyncMock( side_effect=RuntimeError(f"视频生成失败:{policy_raw}") ) with ( patch.object(seedance_video, "SeedanceClient", return_value=fake_client), patch.object(seedance_video, "_show_balance"), patch.object(seedance_video.tempfile, "mkstemp", return_value=(0, "unused.mp4")), ): with self.assertRaisesRegex(RuntimeError, expected): await seedance_video.SeedanceMultiModal().generate(**{ "提示词": ["test prompt"], "主模型": ["seedance 2.0"], "模型线路": ["海外HC"], "分辨率": ["720p"], "宽高比": ["16:9"], "时长": ["5秒"], "生成音频": ["关闭"], "联网搜索": ["关闭"], "返回末帧图片": ["关闭"], "seed": [0], }) async def test_seedance_25_supports_1080p_and_4k(self): for resolution in ("1080p", "4k"): with self.subTest(resolution=resolution): create = AsyncMock(return_value=([], [], [])) submit = AsyncMock(return_value=("result.mp4", "done")) with ( patch.object(SeedanceAutoPass, "_create_assets", create), patch.object(SeedanceAutoPass, "_submit_poll_download", submit), patch.object(InputImpl, "VideoFromFile", return_value=object()), patch( "comfyui_o1key.nodes.seedance_autopass.get_base_url_by_route", return_value="https://api.example.com", ), ): await SeedanceAutoPass.execute(**{ "提示词": "test prompt", "生成模式": { "生成模式": "文生视频", "联网搜索": "关闭", }, "主模型": "seedance 2.5", "模型线路": "海外HC", "分辨率": resolution, "宽高比": "智能", "时长": "30秒", "生成音频": "关闭", "seed": 0, }) self.assertEqual(submit.await_args.args[0]["resolution"], resolution) async def test_seedance_fast_still_rejects_1080p_before_upload(self): with self.assertRaisesRegex(ValueError, "仅支持 480p / 720p"): await SeedanceAutoPass.execute(**{ "提示词": "test prompt", "生成模式": { "生成模式": "文生视频", "联网搜索": "关闭", }, "主模型": "seedance 2.0 fast", "模型线路": "海外HC", "分辨率": "1080p", "宽高比": "智能", "时长": "5秒", "生成音频": "关闭", "seed": 0, }) async def test_execute_consumes_autogrow_groups(self): image_1 = object() image_3 = object() video_1 = object() audio_1 = object() video_output = object() create = AsyncMock(return_value=( ["asset://image-1", "asset://image-3"], ["asset://video-1"], ["asset://audio-1"], )) submit = AsyncMock(return_value=("result.mp4", "done")) with ( patch.object(SeedanceAutoPass, "_create_assets", create), patch.object(SeedanceAutoPass, "_validate_reference_media"), patch.object(SeedanceAutoPass, "_submit_poll_download", submit), patch.object(InputImpl, "VideoFromFile", return_value=video_output), patch("comfyui_o1key.nodes.seedance_autopass.get_base_url_by_route", return_value="https://api.example.com"), ): output = await SeedanceAutoPass.execute(**{ "提示词": "test prompt", "生成模式": { "生成模式": "多模态参考生视频", "联网搜索": "打开", "参考图片": {"参考图片1": image_1, "参考图片2": None, "参考图片3": image_3}, "参考视频": {"参考视频1": video_1}, "参考音频": {"参考音频1": audio_1}, }, "主模型": "seedance 2.0", "模型线路": "海外HC", "分辨率": "1080p", "宽高比": "智能", "时长": "5秒", "生成音频": "关闭", "联网搜索": "关闭", # 旧工作流参数即使仍存在,也必须被新的自动匹配规则忽略。 "自动创建素材": "关闭", "素材创建模式": "标准", "seed": 0, }) create.assert_awaited_once_with( [image_1, image_3], [video_1], [audio_1], "https://api.example.com", "HC" ) body = submit.await_args.args[0] self.assertEqual( [item["image_url"]["url"] for item in body["content"] if item["type"] == "image_url"], ["asset://image-1", "asset://image-3"], ) self.assertNotIn("web_search", body) self.assertNotIn("camera_fixed", body) self.assertIs(output.result[0], video_output) self.assertIsNone(output.result[1]) self.assertEqual(len(output.result), 2) class SeedanceAutoPassBatchV3Tests(unittest.TestCase): def test_batch_schema_matches_single_common_parameters(self): self.assertTrue(issubclass(SeedanceAutoPassBatch, io.ComfyNode)) batch_schema = SeedanceAutoPassBatch.GET_SCHEMA() single_schema = SeedanceAutoPass.GET_SCHEMA() batch_inputs = {item.id: item for item in batch_schema.inputs} single_inputs = {item.id: item for item in single_schema.inputs} self.assertEqual(batch_schema.display_name, "Seedance 全能生成视频(批量)") self.assertEqual( comfyui_o1key.NODE_DISPLAY_NAME_MAPPINGS["SeedanceAutoPassBatch"], "Seedance 全能生成视频(批量)", ) self.assertEqual( [item.id for item in batch_schema.inputs[:3]], ["提示词", "生成模式", "主模型"], ) for name in ( "主模型", "模型线路", "分辨率", "宽高比", "时长", "生成音频", "seed", ): self.assertEqual(batch_inputs[name].default, single_inputs[name].default) for name in ("主模型", "模型线路", "分辨率", "宽高比", "时长", "生成音频"): self.assertEqual(batch_inputs[name].options, single_inputs[name].options) self.assertNotIn("自动创建素材", batch_inputs) self.assertNotIn("素材创建模式", batch_inputs) options = { option.key: [item.id for item in option.inputs] for option in batch_inputs["生成模式"].options } self.assertEqual(options["多模态参考生视频"], [ "联网搜索", "图片文件夹", "视频文件夹", "音频文件夹", ]) self.assertEqual(options["图生视频-首帧"], [ "联网搜索", "首帧图片文件夹", ]) self.assertEqual(options["图生视频-首尾帧"], [ "联网搜索", "首帧图片文件夹", "尾帧图片文件夹", ]) self.assertEqual(options["文生视频"], ["联网搜索", "生成数量"]) self.assertEqual(SeedanceAutoPassBatch.RETURN_TYPES, ["STRING"]) def test_multimodal_folders_are_paired_by_sorted_index(self): with tempfile.TemporaryDirectory() as root: root = Path(root) image_dir = root / "images" video_dir = root / "videos" audio_dir = root / "audios" image_dir.mkdir() video_dir.mkdir() audio_dir.mkdir() Image.new("RGB", (4, 4), "red").save(image_dir / "01.png") Image.new("RGB", (4, 4), "blue").save(image_dir / "02.png") (video_dir / "01.mp4").write_bytes(b"video") (audio_dir / "01.mp3").write_bytes(b"audio") tasks = SeedanceAutoPassBatch._build_tasks( "多模态参考生视频", { "图片文件夹": str(image_dir), "视频文件夹": str(video_dir), "音频文件夹": str(audio_dir), }, "prompt", ) self.assertEqual(len(tasks), 2) self.assertEqual( (len(tasks[0]["images"]), len(tasks[0]["videos"]), len(tasks[0]["audios"])), (1, 1, 1), ) self.assertEqual( (len(tasks[1]["images"]), len(tasks[1]["videos"]), len(tasks[1]["audios"])), (1, 0, 0), ) def test_single_prompt_is_attached_to_every_task(self): with tempfile.TemporaryDirectory() as root: image_dir = Path(root) / "images" image_dir.mkdir() Image.new("RGB", (4, 4), "red").save(image_dir / "01.png") Image.new("RGB", (4, 4), "blue").save(image_dir / "02.png") tasks = SeedanceAutoPassBatch._build_tasks( "图生视频-首帧", {"首帧图片文件夹": str(image_dir)}, "a woman --- a man", ) self.assertEqual(len(tasks), 2) self.assertEqual([task["prompt"] for task in tasks], ["a woman --- a man"] * 2) self.assertEqual([task["stem"] for task in tasks], ["01", "02"]) def test_batch_prompts_expand_media_tasks_cartesian(self): with tempfile.TemporaryDirectory() as root: image_dir = Path(root) / "images" image_dir.mkdir() Image.new("RGB", (4, 4), "red").save(image_dir / "01.png") Image.new("RGB", (4, 4), "blue").save(image_dir / "02.png") tasks = SeedanceAutoPassBatch._build_tasks( "图生视频-首帧", {"首帧图片文件夹": str(image_dir)}, "a woman\n---\na man\n---\na cat", ) self.assertEqual(len(tasks), 6) self.assertEqual( [task["prompt"] for task in tasks], ["a woman", "a man", "a cat"] * 2, ) self.assertEqual( [task["stem"] for task in tasks], ["01_p1", "01_p2", "01_p3", "02_p1", "02_p2", "02_p3"], ) self.assertEqual(len(tasks[0]["images"]), 1) self.assertIn("[提示词1]", tasks[0]["source"]) def test_batch_prompts_multiply_text_mode_count(self): tasks = SeedanceAutoPassBatch._build_tasks( "文生视频", {"生成数量": 2}, "first shot\n---\nsecond shot", ) self.assertEqual(len(tasks), 4) self.assertEqual( [task["prompt"] for task in tasks], ["first shot", "second shot", "first shot", "second shot"], ) self.assertEqual( [task["stem"] for task in tasks], [ "seedance_text_001_p1", "seedance_text_001_p2", "seedance_text_002_p1", "seedance_text_002_p2", ], ) def test_batch_prompts_pair_with_first_last_frames(self): with tempfile.TemporaryDirectory() as root: root = Path(root) first_dir = root / "first" last_dir = root / "last" first_dir.mkdir() last_dir.mkdir() Image.new("RGB", (4, 4)).save(first_dir / "01.png") Image.new("RGB", (4, 4)).save(last_dir / "01.png") tasks = SeedanceAutoPassBatch._build_tasks( "图生视频-首尾帧", { "首帧图片文件夹": str(first_dir), "尾帧图片文件夹": str(last_dir), }, "zoom in\n---\nzoom out", ) self.assertEqual(len(tasks), 2) self.assertEqual([task["prompt"] for task in tasks], ["zoom in", "zoom out"]) for task in tasks: self.assertEqual(len(task["images"]), 2) def test_batch_prompts_reject_all_empty_sections(self): with self.assertRaisesRegex(ValueError, "所有提示词都为空"): SeedanceAutoPassBatch._build_tasks("文生视频", {"生成数量": 1}, "---\n---") def test_first_last_folders_must_have_equal_counts(self): with tempfile.TemporaryDirectory() as root: root = Path(root) first_dir = root / "first" last_dir = root / "last" first_dir.mkdir() last_dir.mkdir() Image.new("RGB", (4, 4)).save(first_dir / "01.png") Image.new("RGB", (4, 4)).save(first_dir / "02.png") Image.new("RGB", (4, 4)).save(last_dir / "01.png") with self.assertRaisesRegex(ValueError, "数量必须一致"): SeedanceAutoPassBatch._build_tasks( "图生视频-首尾帧", { "首帧图片文件夹": str(first_dir), "尾帧图片文件夹": str(last_dir), }, "", ) class AudioPathUploadTests(unittest.IsolatedAsyncioTestCase): async def test_upload_audio_accepts_file_path_without_reencoding(self): with tempfile.TemporaryDirectory() as root: audio_path = Path(root) / "reference.mp3" audio_path.write_bytes(b"audio-bytes") public_url = "https://example.com/reference.mp3" with patch( "comfyui_o1key.utils.r2_uploader._upload_file", AsyncMock(return_value=public_url), ) as upload_file: result = await upload_audio(str(audio_path), base_url="https://api.example.com") self.assertEqual(result, public_url) upload_args = upload_file.await_args.args self.assertEqual(upload_args[0], b"audio-bytes") self.assertTrue(upload_args[1].endswith(".mp3")) self.assertEqual(upload_args[2], "audio/mpeg") self.assertEqual(upload_file.await_args.kwargs["base_url"], "https://api.example.com") if __name__ == "__main__": unittest.main()