Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4213a6ece | |||
| 8f1d1bc5f3 |
@@ -0,0 +1,125 @@
|
|||||||
|
"""各 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
|
||||||
|
and 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}"
|
||||||
|
)
|
||||||
@@ -24,6 +24,7 @@ from app.domain.generation import (
|
|||||||
)
|
)
|
||||||
from app.providers.base import GenerationProvider
|
from app.providers.base import GenerationProvider
|
||||||
|
|
||||||
|
from .capabilities import validate as validate_limits
|
||||||
from .client import ArkClient
|
from .client import ArkClient
|
||||||
from .models import (
|
from .models import (
|
||||||
ASSET_URL_TTL_SEC,
|
ASSET_URL_TTL_SEC,
|
||||||
@@ -88,6 +89,17 @@ class SeedanceVideoProvider(GenerationProvider):
|
|||||||
model = request.model or self._default_model
|
model = request.model or self._default_model
|
||||||
if not model:
|
if not model:
|
||||||
raise ValueError("未指定模型:请求未携带 model 且 ARK_VIDEO_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(
|
return SeedanceSubmission(
|
||||||
model=model,
|
model=model,
|
||||||
prompt=request.prompt,
|
prompt=request.prompt,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -396,3 +396,63 @@ class TestPixelDimensionsAreLeftBlankOnPurpose:
|
|||||||
assert result.height_px is None
|
assert result.height_px is None
|
||||||
# 但原始信息要透出,供上层或后续实现换算。
|
# 但原始信息要透出,供上层或后续实现换算。
|
||||||
assert (result.ratio, result.resolution) == ("16:9", "720p")
|
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",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user