按用户拍板的四点定型 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
|
id: str
|
||||||
status: SeedanceStatus | None
|
status: SeedanceStatus | None
|
||||||
raw_status: str | None
|
raw_status: str | None
|
||||||
|
#: 任务实际使用的模型(形如 `doubao-seedance-2-0-260128`)。用于按模型族
|
||||||
|
#: 换算像素宽高——同一适配器实例可能同时处理两个模型族的任务。
|
||||||
|
model: str | None = None
|
||||||
video_url: str | None = None
|
video_url: str | None = None
|
||||||
last_frame_url: str | None = None
|
last_frame_url: str | None = None
|
||||||
duration_sec: int | None = None
|
duration_sec: int | None = None
|
||||||
@@ -169,6 +172,7 @@ class SeedanceTask:
|
|||||||
id=str(payload.get("id") or ""),
|
id=str(payload.get("id") or ""),
|
||||||
status=SeedanceStatus.parse(raw_status),
|
status=SeedanceStatus.parse(raw_status),
|
||||||
raw_status=raw_status,
|
raw_status=raw_status,
|
||||||
|
model=payload.get("model"),
|
||||||
video_url=content.get("video_url"),
|
video_url=content.get("video_url"),
|
||||||
last_frame_url=content.get("last_frame_url"),
|
last_frame_url=content.get("last_frame_url"),
|
||||||
duration_sec=payload.get("duration"),
|
duration_sec=payload.get("duration"),
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ from app.domain.generation import (
|
|||||||
)
|
)
|
||||||
from app.providers.base import GenerationProvider
|
from app.providers.base import GenerationProvider
|
||||||
|
|
||||||
|
from .capabilities import limits_for
|
||||||
from .capabilities import validate as validate_limits
|
from .capabilities import validate as validate_limits
|
||||||
from .client import ArkClient
|
from .client import ArkClient
|
||||||
|
from .dimensions import pixels_for
|
||||||
from .models import (
|
from .models import (
|
||||||
ASSET_URL_TTL_SEC,
|
ASSET_URL_TTL_SEC,
|
||||||
TASKS_PATH,
|
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(
|
return GenerationResult(
|
||||||
provider_request_id=request_id,
|
provider_request_id=request_id,
|
||||||
state=state,
|
state=state,
|
||||||
@@ -160,14 +170,23 @@ class SeedanceVideoProvider(GenerationProvider):
|
|||||||
cover_url=task.last_frame_url,
|
cover_url=task.last_frame_url,
|
||||||
ratio=task.ratio,
|
ratio=task.ratio,
|
||||||
resolution=task.resolution,
|
resolution=task.resolution,
|
||||||
# 像素尺寸留空:火山只返回 ratio 与 resolution,换算需要一张随模型族
|
width_px=width_px,
|
||||||
# 变化的对照表。在 V7 目录种子定下模型族之前不编造尺寸。
|
height_px=height_px,
|
||||||
duration_ms=task.duration_ms,
|
duration_ms=task.duration_ms,
|
||||||
output_format=task.output_format,
|
output_format=task.output_format,
|
||||||
failure=failure,
|
failure=failure,
|
||||||
billing_tokens=task.completion_tokens,
|
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
|
@staticmethod
|
||||||
def _map_state(task: SeedanceTask) -> TaskState:
|
def _map_state(task: SeedanceTask) -> TaskState:
|
||||||
match task.status:
|
match task.status:
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
|
||||||
|
info:
|
||||||
|
title: patbond-ai 内部接口
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
`patbond-creation`(Java)调用 `patbond-ai`(Python)执行 AI 生成的内部契约。
|
||||||
|
|
||||||
|
**这份契约不对客户端暴露**,与 `patbond-doc/docs/api/openapi.yaml` 是两份独立
|
||||||
|
文档、独立版本号(ADR-023 §4)。刻意不合并:内部适配端点若进入公开契约,会污染
|
||||||
|
其路径/操作计数,也会让「对客户端的承诺」与「对内部实现的约定」混为一谈。
|
||||||
|
本文档也**不进** patbond-api 的四模块快照锁。
|
||||||
|
|
||||||
|
## 字段命名为 snake_case
|
||||||
|
|
||||||
|
与项目对客户端的 camelCase 契约不同。这是有意的:本服务是 Python,snake_case
|
||||||
|
使 JSON 与领域 dataclass 字段逐一对应、无需转换层;Java 侧只需在这一个 Feign
|
||||||
|
客户端上配置命名策略。**用 camelCase 反而要在两端都加转换。**
|
||||||
|
|
||||||
|
## 两类失败必须分开(本契约的核心设计)
|
||||||
|
|
||||||
|
| 类别 | 含义 | 形态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **service** | Java↔Python 之间的问题:鉴权失败、请求体非法、任务不存在、本服务内部错误、适配器未装配 | 非 2xx + `error.kind = "service"` |
|
||||||
|
| **provider** | Python↔AI 服务商之间的问题:限流、配额耗尽、内容被拒、厂商内部错误 | 非 2xx + `error.kind = "provider"`,**并携带完整分类** |
|
||||||
|
| **任务失败** | HTTP 调用本身成功,但生成任务最终失败 | **200** + `state = "failed"` + `failure` |
|
||||||
|
|
||||||
|
第三类刻意不走错误通道:查询一个失败的任务,**HTTP 请求是成功的**,失败的是
|
||||||
|
任务。把它做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。
|
||||||
|
|
||||||
|
判别字段是 `error.kind`,**不是 HTTP 状态码**。Java 侧据此分流:
|
||||||
|
`service` 类属我方 bug 或配置问题(除 `internal_error`/`provider_unavailable`
|
||||||
|
外均不可重试);`provider` 类一律以 `retry` 字段为准,不要自己解读厂商码。
|
||||||
|
|
||||||
|
## 鉴权
|
||||||
|
|
||||||
|
所有端点要求服务身份 JWT:`Authorization: Bearer <token>`。本服务强制校验
|
||||||
|
`aud = patbond-ai` 与 `typ = service`(ADR-023 §5)。用户 token 一律拒绝——
|
||||||
|
该密钥对与用户 token 的密钥对是独立的两套。
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://127.0.0.1:8000
|
||||||
|
description: 本机开发
|
||||||
|
- url: http://patbond-ai:8000
|
||||||
|
description: 容器网络内
|
||||||
|
|
||||||
|
security:
|
||||||
|
- serviceJwt: []
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/internal/generation/tasks:
|
||||||
|
post:
|
||||||
|
summary: 提交生成任务
|
||||||
|
operationId: submitGenerationTask
|
||||||
|
description: |
|
||||||
|
把任务交给上游服务商,返回**服务商的任务 ID**。
|
||||||
|
|
||||||
|
**不阻塞等待生成完成。** 但会阻塞一次到服务商的短往返——服务商的提交接口
|
||||||
|
本身是异步的(只入队即返回任务 ID),所以这一跳很快。
|
||||||
|
|
||||||
|
调用方**必须在上游产生任何计费动作之前**把返回的 `provider_request_id`
|
||||||
|
落库:火山不支持幂等键,这是提交超时后唯一能判断「是否已提交」的依据。
|
||||||
|
|
||||||
|
**一个无法消除的窗口**:若本服务到服务商的调用超时,调用方会收到
|
||||||
|
`provider` 类错误且拿不到 ID,而服务商**可能已经创建了任务**。无幂等键可用,
|
||||||
|
该窗口只能由 `max_attempts` 兜住(ADR-023 §4 已如实登记)。服务商支持
|
||||||
|
回调,将来启用回调可作为孤儿任务的回收通道。
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SubmitRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 已提交
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SubmitResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/ServiceError'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/ServiceError'
|
||||||
|
'502':
|
||||||
|
$ref: '#/components/responses/ProviderError'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceError'
|
||||||
|
|
||||||
|
/internal/generation/tasks/{provider_request_id}:
|
||||||
|
get:
|
||||||
|
summary: 查询生成任务
|
||||||
|
operationId: getGenerationTask
|
||||||
|
description: |
|
||||||
|
路径参数是**服务商的任务 ID**,不是我方 `job_id`。
|
||||||
|
|
||||||
|
这是刻意的:本服务不持有任何状态,唯一能用来问服务商的就是这个 ID。若用
|
||||||
|
`job_id`,本服务就得存一份 `job_id → 厂商 ID` 的映射,「无状态」不再成立,
|
||||||
|
且会多出一个「Java 有 ID 但查不到任何东西」的中间态。
|
||||||
|
|
||||||
|
注意火山**只保留最近 7 天**的任务记录,更早的任务会以 `provider` 类错误
|
||||||
|
返回。
|
||||||
|
parameters:
|
||||||
|
- name: provider_request_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: cgt-20260920-abc123
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: |
|
||||||
|
任务当前状态。**任务失败也是 200** —— HTTP 调用成功了,失败的是任务。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TaskResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/ServiceError'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/ServiceError'
|
||||||
|
'502':
|
||||||
|
$ref: '#/components/responses/ProviderError'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceError'
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
serviceJwt:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: |
|
||||||
|
由 `patbond-creation` 用**独立于用户 token 的**密钥对签发。claim 约定:
|
||||||
|
`iss=patbond-api`、`aud=patbond-ai`、`sub=service:creation-worker`、
|
||||||
|
`typ=service`、短 `exp`(5 分钟)、带 `jti`。
|
||||||
|
|
||||||
|
responses:
|
||||||
|
ServiceError:
|
||||||
|
description: Java↔Python 之间的问题
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ServiceErrorEnvelope'
|
||||||
|
ProviderError:
|
||||||
|
description: Python↔服务商之间的问题,携带完整分类
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderErrorEnvelope'
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
SubmitRequest:
|
||||||
|
type: object
|
||||||
|
required: [job_id, media_kind, model]
|
||||||
|
description: |
|
||||||
|
**刻意不含 `user_id`**(数据最小化,ADR-023 §5):本服务干活只需要任务参数。
|
||||||
|
需要给服务商做滥用检测时传的是 `safety_identifier`——一个哈希,不是明文 ID。
|
||||||
|
properties:
|
||||||
|
job_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: |
|
||||||
|
我方任务 ID。**本服务不用它查任何东西**,仅用于日志关联,使一条报错能
|
||||||
|
对应回 `creation.generation_jobs` 的行。
|
||||||
|
media_kind:
|
||||||
|
type: string
|
||||||
|
enum: [image, video]
|
||||||
|
description: 决定路由到哪个适配器。
|
||||||
|
model:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
服务商侧的模型标识,取自任务行冻结的 `provider_model_snapshot`。
|
||||||
|
**本服务不自行选模型**:选型是目录的职责,且任务行里的快照必须与实际
|
||||||
|
调用的模型一致,否则事后无法对账。
|
||||||
|
example: doubao-seedance-2-5-260628
|
||||||
|
prompt:
|
||||||
|
type: [string, 'null']
|
||||||
|
maxLength: 10000
|
||||||
|
description: 与 `input_url` **至少要有一个**,都为空则本地即拒。
|
||||||
|
input_url:
|
||||||
|
type: [string, 'null']
|
||||||
|
description: |
|
||||||
|
参考图/首帧的**公网可达** URL。给了即图生视频,不给即文生视频
|
||||||
|
(参考图选填,决策 A2)。
|
||||||
|
ratio:
|
||||||
|
type: [string, 'null']
|
||||||
|
description: |
|
||||||
|
缺省 `adaptive`。**会按模型限制本地预校验**:Seedance 2.5 在图生视频时
|
||||||
|
只支持 `adaptive`,指定具体比例会被本服务直接拒(省掉一次往返)。
|
||||||
|
example: adaptive
|
||||||
|
resolution:
|
||||||
|
type: [string, 'null']
|
||||||
|
description: |
|
||||||
|
缺省 `720p`。按模型预校验:2.5 无 4k,2.0 系列有。
|
||||||
|
example: 720p
|
||||||
|
duration_sec:
|
||||||
|
type: [integer, 'null']
|
||||||
|
description: |
|
||||||
|
缺省 5。按模型预校验:2.5 为 `[4,30]`,2.0 系列为 `[4,15]`;
|
||||||
|
`-1` 表示由模型自选,不参与范围校验。
|
||||||
|
generate_audio:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
watermark:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
safety_identifier:
|
||||||
|
type: [string, 'null']
|
||||||
|
maxLength: 64
|
||||||
|
description: |
|
||||||
|
终端用户标识,供服务商做滥用检测。**必须是哈希值,不可传明文用户 ID**。
|
||||||
|
callback_url:
|
||||||
|
type: [string, 'null']
|
||||||
|
description: |
|
||||||
|
预留,当前不启用(ADR-023 §4:轮询为保证,回调为优化)。留此字段使
|
||||||
|
将来启用无需改契约。
|
||||||
|
upload_target:
|
||||||
|
type: [string, 'null']
|
||||||
|
description: |
|
||||||
|
产物的预签名 PUT 地址。**当前不启用**——转存阻塞于对象存储决策
|
||||||
|
(ADR-023 §7)。启用后本服务会把产物直传该地址,从而无需持有任何
|
||||||
|
对象存储凭证。
|
||||||
|
|
||||||
|
SubmitResponse:
|
||||||
|
type: object
|
||||||
|
required: [provider_request_id]
|
||||||
|
properties:
|
||||||
|
provider_request_id:
|
||||||
|
type: string
|
||||||
|
description: 服务商任务 ID。调用方须在上游计费前落库。
|
||||||
|
example: cgt-20260920-abc123
|
||||||
|
|
||||||
|
TaskResponse:
|
||||||
|
type: object
|
||||||
|
required: [provider_request_id, state, progress]
|
||||||
|
properties:
|
||||||
|
provider_request_id:
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
enum: [pending, running, succeeded, failed, cancelled]
|
||||||
|
description: |
|
||||||
|
**刻意比服务商的状态集更小。** 火山有 queued/running/cancelled/
|
||||||
|
succeeded/failed/expired 六个:`queued` 映射为 `pending`,`expired`
|
||||||
|
归入 `failed`(它是超时,属失败的一种,重试策略由 `failure` 表达,
|
||||||
|
不需要独立状态)。
|
||||||
|
progress:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 100
|
||||||
|
description: |
|
||||||
|
火山不返回百分比,故成功前恒 0、成功即 100。**这不是缺陷**:客户端
|
||||||
|
契约本就不承诺平滑进度(ADR-023 §10),一个诚实的 0 不该被读成「卡住」。
|
||||||
|
asset_url:
|
||||||
|
type: [string, 'null']
|
||||||
|
description: |
|
||||||
|
产物 URL。**可能是有时效的服务商地址**——火山为 24 小时。调用方必须在
|
||||||
|
时效内转存,**不可长期保存此值**(存进 `media.assets.external_url`
|
||||||
|
会在 24 小时后变死链)。
|
||||||
|
asset_url_expires_in_sec:
|
||||||
|
type: [integer, 'null']
|
||||||
|
description: |
|
||||||
|
`asset_url` 的剩余有效期。与 URL 一起给出,使调用方没有理由把它当
|
||||||
|
永久地址。火山为 86400。
|
||||||
|
cover_url:
|
||||||
|
type: [string, 'null']
|
||||||
|
description: |
|
||||||
|
封面图 URL。火山的尾帧图(png、尺寸与视频一致、无水印)可免费充当此
|
||||||
|
用途,省掉抽帧。
|
||||||
|
ratio:
|
||||||
|
type: [string, 'null']
|
||||||
|
resolution:
|
||||||
|
type: [string, 'null']
|
||||||
|
width_px:
|
||||||
|
type: [integer, 'null']
|
||||||
|
description: |
|
||||||
|
由 (模型族, resolution, ratio) 查静态对照表得到,**不需要下载产物或
|
||||||
|
解码**。`ratio` 为 `adaptive` 或模型族未知时为 `null`——留空而不猜,
|
||||||
|
一个错的尺寸会让客户端按错误比例渲染且看不出是错的。
|
||||||
|
height_px:
|
||||||
|
type: [integer, 'null']
|
||||||
|
duration_ms:
|
||||||
|
type: [integer, 'null']
|
||||||
|
description: |
|
||||||
|
有 `frames`+`fps` 时按帧精确换算;只有服务商的整数秒时退回它,而那是
|
||||||
|
「帧数/24 向下取整」的**约数**(最多可能短 1 秒)。两者都缺时为 null,
|
||||||
|
不编 0。
|
||||||
|
output_format:
|
||||||
|
type: [string, 'null']
|
||||||
|
example: mp4
|
||||||
|
billing_tokens:
|
||||||
|
type: [integer, 'null']
|
||||||
|
description: 计费对账依据(火山为 `usage.completion_tokens`)。
|
||||||
|
failure:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/Failure'
|
||||||
|
- type: 'null'
|
||||||
|
description: |
|
||||||
|
仅 `state = failed` 时非空。**`failed` 必然携带它**——否则调用方无法
|
||||||
|
决定重试策略,只能盲目重试或盲目放弃,两者都错。
|
||||||
|
|
||||||
|
Failure:
|
||||||
|
type: object
|
||||||
|
required: [retry, user_facing, provider_code]
|
||||||
|
properties:
|
||||||
|
retry:
|
||||||
|
type: string
|
||||||
|
enum: [never, backoff, quota]
|
||||||
|
description: |
|
||||||
|
**Worker 据此决定重试,不必解读服务商码。**
|
||||||
|
`never`:同样输入必然同样结果(参数错、内容被拒、鉴权失败)。
|
||||||
|
`backoff`:限流、突发流量保护、服务过载,退避几秒到几十秒。
|
||||||
|
`quota`:额度耗尽,要等到下一个计费周期或充值——不该把重试预算花在
|
||||||
|
这上面,应直接终态失败并让用户看到出口。
|
||||||
|
user_facing:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
[quota_exhausted, rate_limited, content_rejected, server_error, upstream_unavailable]
|
||||||
|
description: |
|
||||||
|
映射到客户端呈现。`quota_exhausted` 与 `rate_limited` **必须分开**:
|
||||||
|
前者等的是明天或充值(弹 sheet 给出口),后者等的是几秒(弹 SnackBar
|
||||||
|
可自动重试)。合成一类必然有一半场景的文案和出口是错的(ADR-023 §9)。
|
||||||
|
provider_code:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
原始服务商错误码,**原样保留、绝不丢弃**——排查时往往是唯一线索。
|
||||||
|
上层不应据此分支判断(那是适配层的职责),只记录与展示给开发者。
|
||||||
|
example: OutputVideoSensitiveContentDetected
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 服务商原始错误描述,仅供排查与开发者可见的日志。
|
||||||
|
|
||||||
|
ServiceErrorEnvelope:
|
||||||
|
type: object
|
||||||
|
required: [error]
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: object
|
||||||
|
required: [kind, code, message]
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
const: service
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- unauthenticated # 服务票缺失/过期/aud 或 typ 不符
|
||||||
|
- invalid_request # 请求体不合契约,或参数超出模型限制
|
||||||
|
- task_not_found # 路径里的服务商任务 ID 不存在
|
||||||
|
- provider_unavailable # 适配器未装配(如缺凭证)或 provider_code 未知
|
||||||
|
- internal_error # 本服务 bug
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
ProviderErrorEnvelope:
|
||||||
|
type: object
|
||||||
|
required: [error]
|
||||||
|
description: |
|
||||||
|
服务商调用失败。**与任务失败(200 + state=failed)是两回事**:这里是调用
|
||||||
|
没成功,那里是调用成功但任务最终失败。
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: object
|
||||||
|
required: [kind, retry, user_facing, provider_code, message]
|
||||||
|
description: |
|
||||||
|
字段与 `Failure` 一致,故 Java 侧可复用同一段分类处置逻辑,无需为
|
||||||
|
「提交时被限流」和「任务失败于限流」写两套分支。
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
const: provider
|
||||||
|
retry:
|
||||||
|
type: string
|
||||||
|
enum: [never, backoff, quota]
|
||||||
|
user_facing:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
[quota_exhausted, rate_limited, content_rejected, server_error, upstream_unavailable]
|
||||||
|
provider_code:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
@@ -382,21 +382,105 @@ class TestCoverImage:
|
|||||||
assert provider_with(handler).fetch("x").cover_url == "https://f.png"
|
assert provider_with(handler).fetch("x").cover_url == "https://f.png"
|
||||||
|
|
||||||
|
|
||||||
class TestPixelDimensionsAreLeftBlankOnPurpose:
|
class TestPixelDimensions:
|
||||||
def test_no_invented_dimensions(self):
|
"""厂商只返回 ratio 与 resolution,像素值由静态对照表换算。
|
||||||
"""火山只返回 ratio 与 resolution,不返回像素值。
|
|
||||||
|
|
||||||
换算需要一张随模型族变化的对照表;在 V7 目录种子定下模型族之前,
|
不需要下载产物、不需要解码、不依赖存储方案——这是 widthPx/heightPx 在 AI
|
||||||
留空比编一个可能错的尺寸好。
|
视频产物上能零成本填真值的依据(ADR-023 §11)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def test_derived_from_ratio_and_resolution(self):
|
||||||
|
# 官方表:2.0 系列 720p + 16:9 = 1280×720。
|
||||||
result = provider_with(
|
result = provider_with(
|
||||||
lambda r: httpx.Response(200, json=OFFICIAL_SUCCESS_PAYLOAD)
|
lambda r: httpx.Response(200, json=OFFICIAL_SUCCESS_PAYLOAD)
|
||||||
).fetch("x")
|
).fetch("x")
|
||||||
assert result.width_px is None
|
assert (result.width_px, result.height_px) == (1280, 720)
|
||||||
assert result.height_px is None
|
# 原始信息同时透出,便于排查与将来复核。
|
||||||
# 但原始信息要透出,供上层或后续实现换算。
|
|
||||||
assert (result.ratio, result.resolution) == ("16:9", "720p")
|
assert (result.ratio, result.resolution) == ("16:9", "720p")
|
||||||
|
|
||||||
|
def test_the_two_cells_where_the_families_differ(self):
|
||||||
|
"""2.5 与 2.0 的 24 格里只有两格不同:480p 的 16:9 与 9:16。
|
||||||
|
|
||||||
|
这两格若搞错,客户端会按错误比例渲染 480p 视频且看不出是错的。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def payload(model: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": "x",
|
||||||
|
"model": model,
|
||||||
|
"status": "succeeded",
|
||||||
|
"resolution": "480p",
|
||||||
|
"ratio": "16:9",
|
||||||
|
"content": {"video_url": "https://v"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result_2_5 = provider_with(
|
||||||
|
lambda r: httpx.Response(200, json=payload("doubao-seedance-2-5-260628"))
|
||||||
|
).fetch("x")
|
||||||
|
result_2_0 = provider_with(
|
||||||
|
lambda r: httpx.Response(200, json=payload("doubao-seedance-2-0-260128"))
|
||||||
|
).fetch("x")
|
||||||
|
assert (result_2_5.width_px, result_2_5.height_px) == (854, 480)
|
||||||
|
assert (result_2_0.width_px, result_2_0.height_px) == (864, 496)
|
||||||
|
|
||||||
|
def test_4k_resolves_only_on_2_0(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": "x",
|
||||||
|
"model": "doubao-seedance-2-0-260128",
|
||||||
|
"status": "succeeded",
|
||||||
|
"resolution": "4k",
|
||||||
|
"ratio": "16:9",
|
||||||
|
"content": {"video_url": "https://v"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = provider_with(handler).fetch("x")
|
||||||
|
assert (result.width_px, result.height_px) == (3840, 2160)
|
||||||
|
|
||||||
|
def test_adaptive_ratio_yields_none_rather_than_a_guess(self):
|
||||||
|
"""`adaptive` 时厂商自动适配首帧比例,本表无从查起。
|
||||||
|
|
||||||
|
留空而不猜:一个错的尺寸会让客户端按错误比例渲染,而且看不出是错的。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": "x",
|
||||||
|
"model": "doubao-seedance-2-5-260628",
|
||||||
|
"status": "succeeded",
|
||||||
|
"resolution": "720p",
|
||||||
|
"ratio": "adaptive",
|
||||||
|
"content": {"video_url": "https://v"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = provider_with(handler).fetch("x")
|
||||||
|
assert (result.width_px, result.height_px) == (None, None)
|
||||||
|
# 但原始值仍透出,上层至少知道厂商说了什么。
|
||||||
|
assert result.ratio == "adaptive"
|
||||||
|
|
||||||
|
def test_unknown_model_family_yields_none(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": "x",
|
||||||
|
"model": "some-future-model",
|
||||||
|
"status": "succeeded",
|
||||||
|
"resolution": "720p",
|
||||||
|
"ratio": "16:9",
|
||||||
|
"content": {"video_url": "https://v"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = provider_with(handler).fetch("x")
|
||||||
|
assert (result.width_px, result.height_px) == (None, None)
|
||||||
|
|
||||||
|
|
||||||
class TestPerModelLimitsAreEnforcedBeforeCallingTheVendor:
|
class TestPerModelLimitsAreEnforcedBeforeCallingTheVendor:
|
||||||
"""超范围的请求本地就拦下,不花一次往返去换一个厂商 400。
|
"""超范围的请求本地就拦下,不花一次往返去换一个厂商 400。
|
||||||
|
|||||||
Reference in New Issue
Block a user