按用户拍板的四点定型 Java↔Python 的 JSON 契约。 **① 字段命名 snake_case**(与对客户端的 camelCase 契约不同,有意为之):本服务是 Python,snake_case 使 JSON 与领域 dataclass 字段逐一对应、无需转换层;Java 侧只需 在这一个 Feign 客户端上配置命名策略。用 camelCase 反而要在两端都加转换。 **② 两类失败分开**,判别字段是 `error.kind` 而非 HTTP 状态码: - `service`(Java↔Python):unauthenticated / invalid_request / task_not_found / provider_unavailable / internal_error。属我方 bug 或配置问题。 - `provider`(Python↔服务商):非 2xx + 完整分类(retry / user_facing / provider_code / message),字段与 Failure 一致,故 Java 侧可复用同一段处置 逻辑,不必为「提交时被限流」和「任务失败于限流」写两套分支。 - **任务失败是第三类,走 200 + state=failed + failure**:查询一个失败的任务, HTTP 调用本身是成功的。做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。 **③ 像素对照表已实现**——此处需更正一个理解:该表**不需要查询任何东西、也不依赖 存储方案**。它是纯静态数据(官方文档「不同宽高比对应的宽高像素值」一节),厂商 查询响应已返回 ratio 与 resolution,查表即可,无需下载产物或解码。故 widthPx/heightPx 在 AI 视频产物上可零成本填真值(ADR-023 §11)。 实现上一个关键观察:**2.5 与 2.0 的 24 格里只有两格不同**(480p 的 16:9 与 9:16),其余完全一致。故写成「共享表 + 两格覆盖」,而非抄两份 24 行——后者每次 改动要改两处,迟早只改一处。另 4k 六格仅 2.0 有。 查不到时返回 None 而不猜(ratio=adaptive、未知模型族、该模型不支持的组合三种 情形):一个错的尺寸会让客户端按错误比例渲染,且看不出是错的。 连带给 SeedanceTask 补解析响应里的 model 字段——同一适配器实例可能同时处理两个 模型族的任务,按响应里的实际模型定族才对。 **④ 路径参数用服务商任务 ID**,并在契约里写明原因:本服务不持有状态,唯一能用来 问服务商的就是这个 ID;用 job_id 就得存一份映射,「无状态」不再成立,且会多出 一个「Java 有 ID 但查不到任何东西」的中间态。你指出的那点完全对——submit 会阻塞 一次到服务商的短往返才能拿到 ID,但服务商的提交接口本身是异步的(只入队即返回), 所以这一跳很快。契约里也如实登记了那个**无法消除的窗口**:该调用超时时 Java 拿 不到 ID 而服务商可能已创建任务,无幂等键可用,只能由 max_attempts 兜住。 测试 123 → 127:旧的「像素刻意留空」断言已作废,换成断言真实换算,并覆盖两族 差异的那两格、4k 仅 2.0、adaptive 与未知模型族的留空回退。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""`resolution` × `ratio` → 像素宽高的对照表。
|
||||
|
||||
**这是纯静态数据,不需要查询任何东西、也不依赖存储方案。** 厂商的查询接口会返回
|
||||
`ratio` 与 `resolution`(但不返回像素值),查本表即可换算——无需下载产物、无需
|
||||
探测文件。数值逐格取自官方文档「不同宽高比对应的宽高像素值」一节。
|
||||
|
||||
有了它,`widthPx`/`heightPx` 在 AI 视频产物上就能填真实值,而不必等到把文件
|
||||
下载下来解码——这也正是 ADR-023 §11 所说「AI 输出路径不需要解码」的落地方式。
|
||||
|
||||
**实现上的一个关键观察**:Seedance 2.5 与 2.0 系列的 24 格里**只有两格不同**
|
||||
(480p 的 `16:9` 与 `9:16`),其余完全一致。故实现为「共享表 + 两格覆盖」,
|
||||
而不是抄两份 24 行——后者每次改动都要改两处,迟早只改一处。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .capabilities import SEEDANCE_2_0, SEEDANCE_2_5, ModelLimits
|
||||
|
||||
#: (resolution, ratio) → (width, height)。两个模型族共用的 22 格。
|
||||
_SHARED: dict[tuple[str, str], tuple[int, int]] = {
|
||||
("480p", "4:3"): (752, 560),
|
||||
("480p", "1:1"): (640, 640),
|
||||
("480p", "3:4"): (560, 752),
|
||||
("480p", "21:9"): (992, 432),
|
||||
("720p", "16:9"): (1280, 720),
|
||||
("720p", "4:3"): (1112, 834),
|
||||
("720p", "1:1"): (960, 960),
|
||||
("720p", "3:4"): (834, 1112),
|
||||
("720p", "9:16"): (720, 1280),
|
||||
("720p", "21:9"): (1470, 630),
|
||||
("1080p", "16:9"): (1920, 1080),
|
||||
("1080p", "4:3"): (1664, 1248),
|
||||
("1080p", "1:1"): (1440, 1440),
|
||||
("1080p", "3:4"): (1248, 1664),
|
||||
("1080p", "9:16"): (1080, 1920),
|
||||
("1080p", "21:9"): (2206, 946),
|
||||
}
|
||||
|
||||
#: 2.5 与 2.0 系列**唯一**不同的两格:480p 的 16:9 与 9:16。
|
||||
_OVERRIDES_2_5: dict[tuple[str, str], tuple[int, int]] = {
|
||||
("480p", "16:9"): (854, 480),
|
||||
("480p", "9:16"): (480, 854),
|
||||
}
|
||||
|
||||
_OVERRIDES_2_0: dict[tuple[str, str], tuple[int, int]] = {
|
||||
("480p", "16:9"): (864, 496),
|
||||
("480p", "9:16"): (496, 864),
|
||||
# 4k 仅 2.0 系列支持。
|
||||
("4k", "16:9"): (3840, 2160),
|
||||
("4k", "4:3"): (3326, 2494),
|
||||
("4k", "1:1"): (2880, 2880),
|
||||
("4k", "3:4"): (2494, 3326),
|
||||
("4k", "9:16"): (2160, 3840),
|
||||
("4k", "21:9"): (4398, 1886),
|
||||
}
|
||||
|
||||
_BY_FAMILY: dict[int, dict[tuple[str, str], tuple[int, int]]] = {
|
||||
id(SEEDANCE_2_5): {**_SHARED, **_OVERRIDES_2_5},
|
||||
id(SEEDANCE_2_0): {**_SHARED, **_OVERRIDES_2_0},
|
||||
}
|
||||
|
||||
|
||||
def pixels_for(
|
||||
limits: ModelLimits | None, resolution: str | None, ratio: str | None
|
||||
) -> tuple[int | None, int | None]:
|
||||
"""查像素宽高;查不到返回 `(None, None)`。
|
||||
|
||||
查不到的三种情形都**返回 None 而不猜**——一个错的尺寸比留空更糟,它会让
|
||||
客户端按错误的比例渲染,而且看不出是错的:
|
||||
|
||||
1. **`ratio` 为 `adaptive`**:厂商在图生视频时自动适配首帧图片的比例。查询
|
||||
响应是否把它解析成具体比例、抑或原样返回 `adaptive`,文档未明确说明;
|
||||
原样返回时本表无从查起。
|
||||
2. **未知模型族**:能力表放行了未知模型(见 `capabilities.py`),但像素值
|
||||
无法凭空推断。
|
||||
3. **该模型不支持的组合**:如 2.5 的 4k——本不该发生(提交前已预校验),
|
||||
出现即说明厂商行为与文档不符,留空并让上层看到缺值。
|
||||
"""
|
||||
if limits is None or not resolution or not ratio:
|
||||
return (None, None)
|
||||
table = _BY_FAMILY.get(id(limits))
|
||||
if table is None:
|
||||
return (None, None)
|
||||
found = table.get((resolution, ratio))
|
||||
return found if found else (None, None)
|
||||
@@ -132,6 +132,9 @@ class SeedanceTask:
|
||||
id: str
|
||||
status: SeedanceStatus | None
|
||||
raw_status: str | None
|
||||
#: 任务实际使用的模型(形如 `doubao-seedance-2-0-260128`)。用于按模型族
|
||||
#: 换算像素宽高——同一适配器实例可能同时处理两个模型族的任务。
|
||||
model: str | None = None
|
||||
video_url: str | None = None
|
||||
last_frame_url: str | None = None
|
||||
duration_sec: int | None = None
|
||||
@@ -169,6 +172,7 @@ class SeedanceTask:
|
||||
id=str(payload.get("id") or ""),
|
||||
status=SeedanceStatus.parse(raw_status),
|
||||
raw_status=raw_status,
|
||||
model=payload.get("model"),
|
||||
video_url=content.get("video_url"),
|
||||
last_frame_url=content.get("last_frame_url"),
|
||||
duration_sec=payload.get("duration"),
|
||||
|
||||
@@ -24,8 +24,10 @@ from app.domain.generation import (
|
||||
)
|
||||
from app.providers.base import GenerationProvider
|
||||
|
||||
from .capabilities import limits_for
|
||||
from .capabilities import validate as validate_limits
|
||||
from .client import ArkClient
|
||||
from .dimensions import pixels_for
|
||||
from .models import (
|
||||
ASSET_URL_TTL_SEC,
|
||||
TASKS_PATH,
|
||||
@@ -149,6 +151,14 @@ class SeedanceVideoProvider(GenerationProvider):
|
||||
),
|
||||
)
|
||||
|
||||
# 像素宽高由 (模型族, resolution, ratio) 查静态对照表得到——厂商只返回后两者,
|
||||
# 不返回像素值。查表不需要下载产物也不需要解码,故 widthPx/heightPx 在 AI
|
||||
# 视频产物上可零成本填真值(ADR-023 §11)。查不到时留空而不猜:一个错的
|
||||
# 尺寸会让客户端按错误比例渲染,且看不出是错的。
|
||||
width_px, height_px = pixels_for(
|
||||
limits_for(self._model_of(task)), task.resolution, task.ratio
|
||||
)
|
||||
|
||||
return GenerationResult(
|
||||
provider_request_id=request_id,
|
||||
state=state,
|
||||
@@ -160,14 +170,23 @@ class SeedanceVideoProvider(GenerationProvider):
|
||||
cover_url=task.last_frame_url,
|
||||
ratio=task.ratio,
|
||||
resolution=task.resolution,
|
||||
# 像素尺寸留空:火山只返回 ratio 与 resolution,换算需要一张随模型族
|
||||
# 变化的对照表。在 V7 目录种子定下模型族之前不编造尺寸。
|
||||
width_px=width_px,
|
||||
height_px=height_px,
|
||||
duration_ms=task.duration_ms,
|
||||
output_format=task.output_format,
|
||||
failure=failure,
|
||||
billing_tokens=task.completion_tokens,
|
||||
)
|
||||
|
||||
def _model_of(self, task: SeedanceTask) -> str:
|
||||
"""任务所用的模型。
|
||||
|
||||
查询响应带 `model` 字段(形如 `doubao-seedance-2-0-260128`),优先用它——
|
||||
这样同一个适配器实例处理不同模型的任务时,像素换算也能各按其族。缺失时
|
||||
才回落到配置的默认模型。
|
||||
"""
|
||||
return task.model or self._default_model
|
||||
|
||||
@staticmethod
|
||||
def _map_state(task: SeedanceTask) -> TaskState:
|
||||
match task.status:
|
||||
|
||||
Reference in New Issue
Block a user