Files
comfyui_o1key/tests/test_seedance_autopass_v3.py
T
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

1038 lines
42 KiB
Python
Raw 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 / 批量节点 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": "<temporary URL omitted>"', 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"], "<redacted>")
self.assertEqual(
safe_body["image"],
"data:image/png;base64,<base64 data, 12 chars>",
)
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()