From 8f1d1bc5f335a721bf93307648581dfbd3fd2bc8 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Sun, 20 Sep 2026 15:12:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(seedance):=20=E4=B8=A4=E4=B8=AA=E5=B7=B2?= =?UTF-8?q?=E5=BC=80=E9=80=9A=E6=A8=A1=E5=9E=8B=E7=9A=84=E9=99=90=E5=88=B6?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=E6=8F=90=E4=BA=A4=E5=89=8D=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E9=A2=84=E6=A0=A1=E9=AA=8C=EF=BC=8823=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 账号实际接入 doubao-seedance-2-5-260628 与 doubao-seedance-2-0-260128,两者限制 差异不小,且同一组参数在一个上合法、在另一个上不合法: | | 2.5 | 2.0 | | 时长 | [4, 30] | [4, 15] | | 分辨率 | 480p/720p/1080p | 480p/720p/1080p/4k | | 图生视频 ratio | **只能 adaptive** | 可指定 | | output_format | mp4/mov | 仅 mp4 | 超范围的请求会被厂商 400 拒绝——那要花一次往返、计入 RPM,并表现为一个失败的 任务。故在 capabilities.py 落一张限制表并在提交前本地预校验,错误信息直接指出 是哪个模型的哪条限制。 两处刻意的设计: - **按模型族前缀匹配**而非完整 ID:尾部是日期版本(260628/260128),会随厂商 小版本更新而变,按前缀匹配使一次升级不必改表。 - **未知模型一律放行**(只记日志):账号新开通一个模型时不该因为这张表没更新 就用不了,否则「开通即可用」会退化成「开通还得改代码发版」。代价只是失去 本地预校验,厂商仍会兜底。 测试 100 → 123:限制表 23 项逐条对应官方文档的一条限制,另在适配器层加 4 项 端到端断言(超范围请求的 handler 直接 assert 不该被调用,确认请求根本没发出)。 Co-Authored-By: Claude Opus 5 (1M context) --- app/providers/volcengine/capabilities.py | 122 ++++++++++++++++ app/providers/volcengine/video.py | 12 ++ .../providers/volcengine/test_capabilities.py | 130 ++++++++++++++++++ tests/providers/volcengine/test_video.py | 60 ++++++++ 4 files changed, 324 insertions(+) create mode 100644 app/providers/volcengine/capabilities.py create mode 100644 tests/providers/volcengine/test_capabilities.py diff --git a/app/providers/volcengine/capabilities.py b/app/providers/volcengine/capabilities.py new file mode 100644 index 0000000..e56cc3b --- /dev/null +++ b/app/providers/volcengine/capabilities.py @@ -0,0 +1,122 @@ +"""各 Seedance 模型的参数限制。 + +**为什么需要它**:已接入的两个模型限制不同——2.5 时长上限 30 秒但没有 4k, +2.0 有 4k 但时长上限 15 秒;2.5 在图生视频时 `ratio` 只能是 `adaptive`。超范围的 +请求会被厂商以 400 拒绝,那要花一次往返、计入 RPM,并表现为一个失败的任务。 +在本地先拦下来,错误信息还能具体指出是哪个模型的哪条限制。 + +**未知模型一律放行**(只记一条日志):账号新开通一个模型时,不应该因为这张表 +没更新就用不了——那会让「开通即可用」变成「开通还得改代码发版」。代价只是失去 +本地预校验,厂商仍会兜底。 + +限制取自官方文档,取证见 patbond-doc +`docs/development/providers/volcengine-seedance.md` §5。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ModelLimits: + """一个模型族的参数限制。 + + :param ratio_must_be_adaptive_with_first_frame: 2.5 在图生视频(首帧/首尾帧) + 与视频编辑/延长任务中**只支持 `adaptive`**——模型自动保持输出宽高比与 + 首帧图片一致,不接受指定值。2.0 系列则可指定。 + :param supports_output_format: `output_format`(mp4/mov)仅 2.5 支持。 + """ + + resolutions: frozenset[str] + min_duration_sec: int + max_duration_sec: int + ratio_must_be_adaptive_with_first_frame: bool + supports_output_format: bool + + +#: Seedance 2.5:时长可到 30 秒,但没有 4k;图生视频时宽高比锁 adaptive。 +#: 1080p 输出为 10bit + H.265/HEVC,少数播放环境可能不兼容。 +SEEDANCE_2_5 = ModelLimits( + resolutions=frozenset({"480p", "720p", "1080p"}), + min_duration_sec=4, + max_duration_sec=30, + ratio_must_be_adaptive_with_first_frame=True, + supports_output_format=True, +) + +#: Seedance 2.0 系列:有 4k,但时长上限 15 秒。4k 输出为 10bit + H.265/HEVC。 +SEEDANCE_2_0 = ModelLimits( + resolutions=frozenset({"480p", "720p", "1080p", "4k"}), + min_duration_sec=4, + max_duration_sec=15, + ratio_must_be_adaptive_with_first_frame=False, + supports_output_format=False, +) + +#: 按模型族前缀匹配,而非精确匹配完整 model ID。 +#: 完整 ID 形如 `doubao-seedance-2-5-260628`,尾部的日期版本会随厂商更新而变; +#: 按前缀匹配使一次模型小版本升级不需要改这张表。 +_BY_PREFIX: tuple[tuple[str, ModelLimits], ...] = ( + ("doubao-seedance-2-5", SEEDANCE_2_5), + ("doubao-seedance-2-0", SEEDANCE_2_0), +) + +#: `duration` 的特殊值:交给模型在有效范围内自选。不参与范围校验。 +AUTO_DURATION = -1 + + +def limits_for(model: str) -> ModelLimits | None: + """查模型限制;未知模型返回 None(表示不做本地预校验)。""" + for prefix, limits in _BY_PREFIX: + if model.startswith(prefix): + return limits + return None + + +def validate( + model: str, + *, + resolution: str | None = None, + duration_sec: int | None = None, + ratio: str | None = None, + has_first_frame: bool = False, +) -> None: + """按模型限制校验参数,不合法则抛 `ValueError`。 + + 未知模型直接放行——见模块 docstring。 + + :raises ValueError: 参数超出该模型的限制,消息里指明模型与具体限制 + """ + limits = limits_for(model) + if limits is None: + log.info("模型 %s 无本地限制表,跳过预校验,由厂商兜底", model) + return + + if resolution and resolution not in limits.resolutions: + raise ValueError( + f"模型 {model} 不支持分辨率 {resolution}," + f"可选:{sorted(limits.resolutions)}" + ) + + if duration_sec is not None and duration_sec != AUTO_DURATION: + if not limits.min_duration_sec <= duration_sec <= limits.max_duration_sec: + raise ValueError( + f"模型 {model} 的时长需在 " + f"[{limits.min_duration_sec}, {limits.max_duration_sec}] 秒内" + f"(或 {AUTO_DURATION} 表示由模型自选),当前 {duration_sec}" + ) + + if ( + has_first_frame + and limits.ratio_must_be_adaptive_with_first_frame + and ratio + and ratio != "adaptive" + ): + raise ValueError( + f"模型 {model} 在图生视频时只支持 ratio=adaptive" + f"(输出宽高比自动与首帧图片一致),当前 {ratio}" + ) diff --git a/app/providers/volcengine/video.py b/app/providers/volcengine/video.py index 426521a..24a5cb4 100644 --- a/app/providers/volcengine/video.py +++ b/app/providers/volcengine/video.py @@ -24,6 +24,7 @@ from app.domain.generation import ( ) from app.providers.base import GenerationProvider +from .capabilities import validate as validate_limits from .client import ArkClient from .models import ( ASSET_URL_TTL_SEC, @@ -88,6 +89,17 @@ class SeedanceVideoProvider(GenerationProvider): model = request.model or self._default_model if not model: raise ValueError("未指定模型:请求未携带 model 且 ARK_VIDEO_MODEL 未配置") + + # 本地预校验:已接入的两个模型限制不同(2.5 时长到 30 秒但无 4k,2.0 有 4k + # 但时长上限 15 秒;2.5 图生视频时 ratio 锁 adaptive)。超范围的请求会被厂商 + # 400 拒绝——那要花一次往返、计入 RPM,并表现为一个失败的任务。未知模型放行。 + validate_limits( + model, + resolution=request.resolution, + duration_sec=request.duration_sec, + ratio=request.ratio, + has_first_frame=bool(request.input_url), + ) return SeedanceSubmission( model=model, prompt=request.prompt, diff --git a/tests/providers/volcengine/test_capabilities.py b/tests/providers/volcengine/test_capabilities.py new file mode 100644 index 0000000..fcff24a --- /dev/null +++ b/tests/providers/volcengine/test_capabilities.py @@ -0,0 +1,130 @@ +"""模型限制表的测试。 + +每个用例对应官方文档里两个已接入模型的一条具体限制。这些限制若搞错,表现是 +用户提交后拿到一个失败任务(厂商 400),而本地测试全绿——所以值得逐条钉住。 +""" + +from __future__ import annotations + +import pytest + +from app.providers.volcengine.capabilities import ( + AUTO_DURATION, + SEEDANCE_2_0, + SEEDANCE_2_5, + limits_for, + validate, +) + +#: 账号实际接入的两个模型 ID。 +MODEL_2_5 = "doubao-seedance-2-5-260628" +MODEL_2_0 = "doubao-seedance-2-0-260128" + + +class TestFamilyDetection: + def test_both_activated_models_are_recognised(self): + assert limits_for(MODEL_2_5) is SEEDANCE_2_5 + assert limits_for(MODEL_2_0) is SEEDANCE_2_0 + + def test_matching_is_by_family_prefix_not_exact_id(self): + """尾部日期版本会随厂商更新而变。 + + 按前缀匹配使一次模型小版本升级(如 2-5-261231)不需要改这张表。 + """ + assert limits_for("doubao-seedance-2-5-999999") is SEEDANCE_2_5 + + def test_unknown_model_has_no_limits(self): + assert limits_for("some-future-model") is None + + +class TestUnknownModelsArePassedThrough: + """账号新开通一个模型时不该因为这张表没更新就用不了。 + + 否则「开通即可用」会变成「开通还得改代码发版」。代价只是失去本地预校验。 + """ + + def test_anything_goes_for_an_unknown_model(self): + validate( + "brand-new-model", + resolution="8k", + duration_sec=999, + ratio="16:9", + has_first_frame=True, + ) + + +class TestResolutionLimits: + def test_2_5_has_no_4k(self): + # 官方:2.5 可选 480p / 720p / 1080p。 + with pytest.raises(ValueError, match="不支持分辨率 4k"): + validate(MODEL_2_5, resolution="4k") + + def test_2_0_does_have_4k(self): + validate(MODEL_2_0, resolution="4k") + + def test_shared_resolutions_work_on_both(self): + for model in (MODEL_2_5, MODEL_2_0): + for resolution in ("480p", "720p", "1080p"): + validate(model, resolution=resolution) + + def test_error_names_the_model_and_lists_the_options(self): + with pytest.raises(ValueError) as exc: + validate(MODEL_2_5, resolution="4k") + assert MODEL_2_5 in str(exc.value) + assert "1080p" in str(exc.value) + + +class TestDurationLimits: + def test_2_5_allows_up_to_30_seconds(self): + validate(MODEL_2_5, duration_sec=30) + + def test_2_0_caps_at_15_seconds(self): + # 官方:2.0 系列取值范围 [4, 15]。 + validate(MODEL_2_0, duration_sec=15) + with pytest.raises(ValueError, match=r"\[4, 15\]"): + validate(MODEL_2_0, duration_sec=20) + + def test_the_same_20_seconds_is_fine_on_2_5(self): + """同一个时长在两个模型上一个合法一个不合法——这正是需要这张表的理由。""" + validate(MODEL_2_5, duration_sec=20) + with pytest.raises(ValueError): + validate(MODEL_2_0, duration_sec=20) + + def test_below_minimum_is_rejected_on_both(self): + for model in (MODEL_2_5, MODEL_2_0): + with pytest.raises(ValueError): + validate(model, duration_sec=2) + + def test_auto_duration_bypasses_the_range_check(self): + # -1 表示交给模型在有效范围内自选,不该被范围校验拦下。 + validate(MODEL_2_5, duration_sec=AUTO_DURATION) + validate(MODEL_2_0, duration_sec=AUTO_DURATION) + + def test_omitted_duration_is_not_checked(self): + validate(MODEL_2_5) + + +class TestRatioLockedToAdaptiveOnImageToVideo: + """2.5 在图生视频时输出宽高比自动与首帧图片一致,不接受指定值。""" + + def test_2_5_rejects_a_specific_ratio_when_a_first_frame_is_given(self): + with pytest.raises(ValueError, match="只支持 ratio=adaptive"): + validate(MODEL_2_5, ratio="16:9", has_first_frame=True) + + def test_2_5_accepts_adaptive_with_a_first_frame(self): + validate(MODEL_2_5, ratio="adaptive", has_first_frame=True) + + def test_2_5_accepts_a_specific_ratio_for_text_to_video(self): + # 限制只在图生视频场景成立;文生视频可指定宽高比。 + validate(MODEL_2_5, ratio="16:9", has_first_frame=False) + + def test_2_0_accepts_a_specific_ratio_even_with_a_first_frame(self): + validate(MODEL_2_0, ratio="16:9", has_first_frame=True) + + +class TestOutputFormatSupport: + def test_only_2_5_supports_mov(self): + # 我们当前不发送 output_format(默认 mp4),但这张表记录了差异, + # 以便将来开放该参数时不必重读文档。 + assert SEEDANCE_2_5.supports_output_format is True + assert SEEDANCE_2_0.supports_output_format is False diff --git a/tests/providers/volcengine/test_video.py b/tests/providers/volcengine/test_video.py index 439a756..029d9ba 100644 --- a/tests/providers/volcengine/test_video.py +++ b/tests/providers/volcengine/test_video.py @@ -396,3 +396,63 @@ class TestPixelDimensionsAreLeftBlankOnPurpose: assert result.height_px is None # 但原始信息要透出,供上层或后续实现换算。 assert (result.ratio, result.resolution) == ("16:9", "720p") + + +class TestPerModelLimitsAreEnforcedBeforeCallingTheVendor: + """超范围的请求本地就拦下,不花一次往返去换一个厂商 400。 + + 两个已接入模型的限制不同,同一组参数在一个上合法、在另一个上不合法—— + 这正是需要本地预校验的理由。 + """ + + def test_duration_beyond_the_2_0_cap_is_rejected_locally(self): + def handler(request): + raise AssertionError("不应发出请求:本地预校验就该拦下") + + with pytest.raises(ValueError, match=r"\[4, 15\]"): + provider_with(handler).submit( + video_request(model="doubao-seedance-2-0-260128", duration_sec=20) + ) + + def test_the_same_duration_passes_on_2_5(self): + def handler(request): + return httpx.Response(200, json={"id": "cgt-1"}) + + assert ( + provider_with(handler).submit( + video_request(model="doubao-seedance-2-5-260628", duration_sec=20) + ) + == "cgt-1" + ) + + def test_4k_is_rejected_on_2_5_but_allowed_on_2_0(self): + def rejecting(request): + raise AssertionError("不应发出请求") + + with pytest.raises(ValueError, match="不支持分辨率 4k"): + provider_with(rejecting).submit( + video_request(model="doubao-seedance-2-5-260628", resolution="4k") + ) + + def accepting(request): + return httpx.Response(200, json={"id": "cgt-2"}) + + assert ( + provider_with(accepting).submit( + video_request(model="doubao-seedance-2-0-260128", resolution="4k") + ) + == "cgt-2" + ) + + def test_2_5_image_to_video_rejects_a_specific_ratio(self): + def handler(request): + raise AssertionError("不应发出请求") + + with pytest.raises(ValueError, match="只支持 ratio=adaptive"): + provider_with(handler).submit( + video_request( + model="doubao-seedance-2-5-260628", + input_url="https://x/a.png", + ratio="16:9", + ) + )