fd23dfc0b9
CI / ai-gates (push) Successful in 33s
按用户拍板的四点定型 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>
225 lines
9.2 KiB
Python
225 lines
9.2 KiB
Python
"""Seedance 视频生成:领域请求 ↔ 火山请求体的翻译。
|
||
|
||
本模块只管**视频生成这一个业务**:怎么拼 `content` 数组、状态怎么映射到领域状态、
|
||
失败怎么分类。HTTP 传输与鉴权在 `client.py`,厂商 DTO 在 `models.py`,错误码映射
|
||
在 `errors.py`。
|
||
|
||
以后接火山的**图片生成**时,应新建一个兄弟模块(如 `image.py`)复用同一个
|
||
`ArkClient` 与 `errors.py`,而不是在本文件里加分支——视频与图片的参数集、状态机、
|
||
产物形态都不同,混在一处会让两边都难改。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
from app.domain.generation import (
|
||
Failure,
|
||
GenerationRequest,
|
||
GenerationResult,
|
||
MediaKind,
|
||
Retry,
|
||
TaskState,
|
||
UserFacing,
|
||
)
|
||
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,
|
||
SeedanceStatus,
|
||
SeedanceSubmission,
|
||
SeedanceTask,
|
||
)
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
#: `creation.generation_models.provider_code` 里对应本适配器的值。
|
||
PROVIDER_CODE = "volcengine-seedance"
|
||
|
||
|
||
class SeedanceVideoProvider(GenerationProvider):
|
||
"""火山方舟 Seedance 的视频生成适配器。"""
|
||
|
||
def __init__(
|
||
self,
|
||
client: ArkClient,
|
||
default_model: str = "",
|
||
task_expiry_sec: int = 3600,
|
||
):
|
||
self._client = client
|
||
self._default_model = default_model
|
||
self._task_expiry_sec = task_expiry_sec
|
||
|
||
@property
|
||
def provider_code(self) -> str:
|
||
return PROVIDER_CODE
|
||
|
||
@property
|
||
def media_kind(self) -> MediaKind:
|
||
return MediaKind.VIDEO
|
||
|
||
# ------------------------------------------------------------- submit
|
||
|
||
def submit(self, request: GenerationRequest) -> str:
|
||
if request.media_kind is not MediaKind.VIDEO:
|
||
# 路由错了。与其把一个图片请求塞给视频接口让厂商拒,不如就地说清楚。
|
||
raise ValueError(
|
||
f"{PROVIDER_CODE} 只处理视频生成,收到 {request.media_kind.value}"
|
||
)
|
||
|
||
submission = self._to_submission(request)
|
||
payload = self._client.post(TASKS_PATH, submission.to_body())
|
||
task_id = payload.get("id")
|
||
if not task_id:
|
||
# 2xx 但没有 ID:我们再也查不到这个任务,而它可能已经在计费。
|
||
# 绝不能当成功放过。
|
||
raise ValueError(f"火山方舟提交成功但响应缺少任务 ID:{payload!r}")
|
||
return str(task_id)
|
||
|
||
def _to_submission(self, request: GenerationRequest) -> SeedanceSubmission:
|
||
"""领域请求 → 厂商请求体。
|
||
|
||
模型取值优先用请求里的(来自任务行冻结的快照),仅在缺失时回落到配置的
|
||
默认值。**适配层不自行选模型**:选型是目录的职责,且任务行里的快照必须
|
||
与实际调用的模型一致,否则事后无法对账。
|
||
"""
|
||
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,
|
||
first_frame_url=request.input_url,
|
||
ratio=request.ratio or SeedanceSubmission.ratio,
|
||
resolution=request.resolution or SeedanceSubmission.resolution,
|
||
duration=(
|
||
request.duration_sec
|
||
if request.duration_sec is not None
|
||
else SeedanceSubmission.duration
|
||
),
|
||
generate_audio=request.generate_audio,
|
||
watermark=request.watermark,
|
||
execution_expires_after=self._task_expiry_sec,
|
||
safety_identifier=request.safety_identifier,
|
||
callback_url=request.callback_url,
|
||
)
|
||
|
||
# -------------------------------------------------------------- fetch
|
||
|
||
def fetch(self, provider_request_id: str) -> GenerationResult:
|
||
payload = self._client.get(f"{TASKS_PATH}/{provider_request_id}")
|
||
task = SeedanceTask.from_payload(payload)
|
||
return self._to_result(task, provider_request_id)
|
||
|
||
def _to_result(self, task: SeedanceTask, request_id: str) -> GenerationResult:
|
||
"""厂商任务 → 领域结果。
|
||
|
||
状态映射见下。注意领域状态集比厂商的**更小**:queued/running 都还没结束,
|
||
expired 是失败的一种(重试策略由 `Failure` 表达,不需要一个独立状态)。
|
||
"""
|
||
state = self._map_state(task)
|
||
failure = self._map_failure(task)
|
||
|
||
if state is TaskState.SUCCEEDED and not task.video_url:
|
||
# 厂商说成功却没给 URL:这不是成功。当成可重试的上游异常,
|
||
# 而不是把一个没有产物的「成功」放给上层去空指针。
|
||
log.error("火山方舟任务 %s 状态为 succeeded 但缺少 video_url", task.id)
|
||
return GenerationResult(
|
||
provider_request_id=request_id,
|
||
state=TaskState.FAILED,
|
||
failure=Failure(
|
||
Retry.BACKOFF,
|
||
UserFacing.UPSTREAM_UNAVAILABLE,
|
||
"MissingVideoUrl",
|
||
"厂商返回 succeeded 但 content.video_url 为空",
|
||
),
|
||
)
|
||
|
||
# 像素宽高由 (模型族, 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,
|
||
# 火山不返回百分比进度。这不是缺陷而是事实:客户端契约本就不承诺
|
||
# 平滑进度(ADR-023 §10),成功前一律 0、成功即 100。
|
||
progress=100 if state is TaskState.SUCCEEDED else 0,
|
||
asset_url=task.video_url,
|
||
asset_url_expires_in_sec=(ASSET_URL_TTL_SEC if task.video_url else None),
|
||
cover_url=task.last_frame_url,
|
||
ratio=task.ratio,
|
||
resolution=task.resolution,
|
||
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:
|
||
case SeedanceStatus.QUEUED:
|
||
return TaskState.PENDING
|
||
case SeedanceStatus.RUNNING:
|
||
return TaskState.RUNNING
|
||
case SeedanceStatus.SUCCEEDED:
|
||
return TaskState.SUCCEEDED
|
||
case SeedanceStatus.CANCELLED:
|
||
return TaskState.CANCELLED
|
||
case SeedanceStatus.FAILED | SeedanceStatus.EXPIRED:
|
||
return TaskState.FAILED
|
||
case _:
|
||
# 未知状态当作「还在跑」:厂商新增状态时把在途任务判死更糟,
|
||
# 最坏是等到我们自己的超时。
|
||
log.warning("火山方舟返回未知任务状态:%r", task.raw_status)
|
||
return TaskState.RUNNING
|
||
|
||
@staticmethod
|
||
def _map_failure(task: SeedanceTask) -> Failure | None:
|
||
from .errors import classify
|
||
|
||
if task.status is SeedanceStatus.EXPIRED:
|
||
# 超时不是内容或参数问题,可重试。单独处理是因为厂商在 expired 时
|
||
# 不一定给 error.code,走通用分类会被当成「无码」。
|
||
return Failure(
|
||
Retry.BACKOFF,
|
||
UserFacing.UPSTREAM_UNAVAILABLE,
|
||
"TaskExpired",
|
||
"任务超过 execution_expires_after 被厂商终止",
|
||
)
|
||
if task.status is SeedanceStatus.FAILED:
|
||
return classify(task.error_code, task.error_message)
|
||
return None
|