Files
patbond-ai/app/providers/volcengine/video.py
T
lixi fd23dfc0b9
CI / ai-gates (push) Successful in 33s
feat(ai): 内部契约 openapi-internal.yaml + 像素对照表(127 测试)
按用户拍板的四点定型 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>
2026-09-20 16:26:49 +08:00

225 lines
9.2 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 视频生成:领域请求 ↔ 火山请求体的翻译。
本模块只管**视频生成这一个业务**:怎么拼 `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