diff --git a/docs/development/providers/volcengine-seedance.md b/docs/development/providers/volcengine-seedance.md new file mode 100644 index 0000000..529c9c6 --- /dev/null +++ b/docs/development/providers/volcengine-seedance.md @@ -0,0 +1,201 @@ +# 火山方舟 Seedance 视频生成:接口事实与映射 + +> 取证日期:2026-09-20 来源:用户提供的三份官方文档(`doc_temp/火山视频请求.md`、 +> `doc_temp/火山错误码.md`、`doc_temp/火山查询.md`) +> 实现位置:`patbond-ai` 的 `app/providers/seedance.py` 与 `app/providers/errors.py` + +本页记录**从官方文档实际读到的事实**,供实现与排查引用。凡本页与代码不一致, +以代码为准并回改本页;凡本页与厂商文档不一致,以厂商文档为准并回改两处。 + +> **文档文件名与内容对不上**:用户提供的 `火山错误码.md` 里装的是**查询 API** +> 文档,`火山查询.md` 里装的是**错误码表**。按文件名取用会读错,已按实际内容归档。 + +## 1. 三个端点 + +| 用途 | 方法与路径 | +| --- | --- | +| 提交任务 | `POST https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks` | +| 查询任务 | `GET https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/{id}` | +| 鉴权 | `Authorization: Bearer $ARK_API_KEY` | + +**提交是异步接口**:响应只有 `{"id": ...}`,状态靠查询接口拿。这与 ADR-023 §4 +「Java↔Python 边界一律异步」天然吻合,无需在 `patbond-ai` 内部做后台任务承载。 + +## 2. 开工前问的五项,逐条答案 + +### ① 是否支持幂等键:**不支持** + +请求参数全集为 `model`、`content`、`omni_reference_task_type`、`resolution`、 +`ratio`、`duration`、`generate_audio`、`watermark`、`output_format`、 +`return_last_frame`、`callback_url`、`execution_expires_after`、`priority`、 +`safety_identifier`。**没有任何幂等相关字段。** + +**后果**:ADR-023 §4 设想的「把我方 `idempotency_key` 透传为厂商幂等头」这条 +缓解手段**对本供应商不成立**。「提交超时后不知道有没有扣费」的窗口只能由 +`max_attempts` 兜住。**如实登记,不假装已解决。** + +**两点减轻**(非消除): + +1. 提交是异步的,只入队即返回,**耗时短、超时概率低**——与同步长连接型供应商 + 的风险量级不同。 +2. 厂商支持 `callback_url`,任务状态变化时 POST 推送(请求体与查询接口返回体 + 一致)。**一个「提交超时但实际已创建」的孤儿任务会通过回调把 ID 送回来**, + 因此回调在此供应商上不只是延迟优化,还是**孤儿任务回收通道**。这是 ADR-023 + §4「M4 只做轮询、回调仅预留字段」之外的一个新论据,值得复评。 + +### ② 查询接口形态与频率限制 + +- 形态:`GET .../tasks/{id}`,返回体见 §3。 +- **任务记录只保留最近 7 天**(区间 `[T-7天, T)`)。更早的任务查询不到。 +- 频率限制**未给具体数值**,但错误码表里有多个 429 分支(见 §4),说明确实有限。 +- **官方 SDK 示例用 10 秒轮询 + 30 分钟截止**。我们的 3s/10s/30s 退避阶梯与之 + 兼容,且厂商自己的示例也用 10 秒——说明这个量级是预期用法。 + +### ③ 单任务最长时长与超时行为 + +- `execution_expires_after`:默认 **172800 秒(48 小时)**,取值范围 + **`[3600, 259200]`**(1~72 小时),从 `created_at` 起算。 +- 超时任务被自动终止并标记为 **`expired`**。 +- **实现取下限 3600**:48 小时对交互式应用过长,被放弃的任务会在厂商侧挂两天。 + 注意**无法设得更短**——1 小时是厂商下限。 +- 官方 SDK 示例的客户端截止时间是 **30 分钟**。ADR-023 §10 原先给视频客户端 + 硬超时排的是 15~20 分钟,**建议按 30 分钟对齐厂商示例**。 + +### ④ 产物返回形式:**有效期 24 小时的 URL** + +- `content.video_url`:生成视频 URL,**有效期 24 小时**;Seedance 2.5 另有 + **下载次数上限 100 次**。 +- `content.last_frame_url`:尾帧图(png、尺寸与视频一致、**无水印**),仅在提交 + 时 `return_last_frame: true` 才返回。 + +**这一条对挂起中的存储决策是硬约束**:厂商 URL **不能**当存储用。把它存进 +`media.assets.external_url` 会在 24 小时后变成死链,因此**无论最终选本地存储 +还是云存储,都必须在 24 小时内把文件复制到我们自己的存储**。「直接存厂商 URL」 +不是一个可选项。 + +**两个顺带的好处**: + +- `last_frame_url` 可**直接用作视频封面图**,省掉我们自己抽帧。`iteration-4/03` + R13 把「缩略图」列为视频进 M4 的成本项之一,这一项实际是免费的。 +- 查询返回 `ratio`、`resolution`、`framespersecond`、`duration`/`frames`, + **Worker 写产物时维度与时长都在手里、无需解码**。这正是 ADR-023 §11 所说 + 「AI 输出路径不需要图片解码」的具体依据,`widthPx`/`heightPx` 债在 AI 产物 + 上可零成本修掉。 + +**一个精度陷阱**:查询返回的 `duration` 是**向下取整的约数**——官方公式为 +`duration = 实际总帧数 / 24` 向下取整,例如 133 帧实际 5.54 秒会返回 5。且 +**`duration` 与 `frames` 只会返回其中一个**(提交时指定了 `frames` 才返回 +`frames`)。故实现优先按 `frames / framespersecond` 精确换算,仅在只有 +`duration` 时退回整数秒并**明确标注为约数**。 + +### ⑤ 限流与配额响应形态 + +见 §4 的完整映射。核心结论:**两族必须分开**,且 `QuotaExceeded` 一码两义。 + +## 3. 查询返回体的关键字段 + +```json +{ + "id": "cgt-2026****-****", + "model": "doubao-seedance-2-0-260128", + "status": "succeeded", + "content": { "video_url": "https://...", "last_frame_url": "https://...png" }, + "usage": { "completion_tokens": 108900, "total_tokens": 108900 }, + "created_at": 1779348818, + "updated_at": 1779348874, + "resolution": "720p", "ratio": "16:9", + "duration": 5, "framespersecond": 24, + "execution_expires_after": 172800, + "error": null +} +``` + +`usage.completion_tokens` 可作**计费对账依据**(视频生成不统计输入 token,故 +`total_tokens == completion_tokens`)。Seedance 2.0 系列有**最低 token 用量**, +实际低于下限时按下限计费。 + +### ⚠️ 状态枚举在两份文档里不一致,必须按宽的实现 + +| 来源 | 列出的状态 | +| --- | --- | +| 查询接口文档 | `queued` / `running` / `cancelled` / `succeeded` / `failed` | +| 创建接口的 `callback_url` 一节 | `queued` / `running` / `succeeded` / `failed` / **`expired`** | + +**只按查询文档实现,线上遇到超时任务会掉进未知分支。** 实现按**六个**状态处理, +并把 `expired` 当作**可重试的失败**(是超时而非内容或参数问题);另注意厂商在 +`expired` 时不一定给 `error.code`,故单独分类而非走通用路径。 + +另:`cancelled` **只支持取消 `queued` 状态的任务**,且取消状态 24 小时自动删除。 +这意味着我方「取消正在 running 的任务」无法向厂商传导——**已 running 的任务取消 +后仍会计费**。ADR-023 §6 关于取消的说明据此可以定稿:取消只改我方任务记录。 + +## 4. 错误码映射 + +实现在 `app/providers/errors.py`,测试在 `tests/test_errors.py`(32 项)。 + +### 🔴 `QuotaExceeded` 一码两义 + +官方错误码表里有**两行 code 完全相同**的 `QuotaExceeded`: + +| message | 含义 | 处置 | +| --- | --- | --- | +| `The request has exceeded the quota.` | 当前账号**排队中任务数**超限 | **等几秒**,退避重试 | +| `...exhausted its free trial quota...` / `...exceeded the 5-hour/weekly/monthly usage quota...` | 额度耗尽 | **等到明天或充值**,重试无用 | + +**按 code 一刀切必然有一半场景的文案和出口是错的。** 实现只能按 message 判别 +(没有别的字段可依据),判别式与两条用例都在测试里钉住。 + +### 分族 + +| 族 | 代表码 | 重试 | 用户侧 | +| --- | --- | --- | --- | +| **内容被拒** | `*SensitiveContentDetected*`(输入/输出 × 文本/图片/视频/音频 × PolicyViolation / PrivacyInformation / DeepFake 等子类)、`*RiskDetection` | **NEVER** | 换提示词或换图 | +| **限流** | `RateLimitExceeded.Endpoint{RPM,TPM,FlexTPM}Exceeded`、`ModelAccount{Rpm,Tpm,FlexTpm,Ipm}RateLimitExceeded`、`APIAccountRpmRateLimitExceeded`、`AccountRateLimitExceeded`、`ServerOverloaded`、`RequestBurstTooFast` | BACKOFF | 稍后重试(SnackBar) | +| **配额/计费** | `SetLimitExceeded`、`InflightBatchsizeExceeded`、`AccountOverdueError`、`OperationDenied.Service{Overdue,NotOpen}`、`ModelNotOpen` | QUOTA | 去充值/开通(sheet) | +| **我方故障** | `AuthenticationError`、`InvalidEndpointOrModel.NotFound`、`InvalidParameter*`、`MissingParameter*`、`InvalidParameter.TaskType{Constraint,Mismatch}` | **NEVER** | 通用失败 | +| **上游内部** | `InternalServiceError` | BACKOFF | 稍后重试 | + +两处容易搞错的地方,实现里各有测试钉住: + +- **`ContentSecurityDetectionError` 不属于内容被拒**——它是风险识别产品自身请求 + 失败,不是内容真的违规,**可重试**。 +- **内容安全族用前缀匹配而非穷举**:该族的码在持续增加(Input/Output × 媒体类型 + × 子原因),穷举必然漏,而整族处置方式完全一致。 +- **未知码一律按可退避重试处理**,不判死:厂商在持续新增码,把未知码当终态失败 + 会让一次可恢复的抖动变成用户看到的失败;重试预算本身有上限,最坏只多试两次。 + +**403 的欠费/未开通归入配额族**:HTTP 状态码不同(403 而非 429),但用户要做的 +事一样——去充值。按状态码分族会把它们错分。 + +## 5. 模型与能力矩阵(供 V7 目录种子取用) + +| 模型 | 文生视频 | 图生视频-首帧 | 首尾帧 | 全模态参考 | 有声 | +| --- | --- | --- | --- | --- | --- | +| Seedance 2.5 | ✓ | ✓ | ✓ | ✓(图 0-30 / 视频 0-10 / 音频 0-10,可仅音频) | ✓ | +| Seedance 2.0 系列 | ✓ | ✓ | ✓ | ✓(图 0-9 / 视频 0-3 / 音频 0-3,不可仅音频) | ✓ | +| Seedance 1.5 pro | ✓ | ✓ | ✓ | — | ✓ | +| Seedance 1.0 pro | ✓ | ✓ | ✓ | — | — | +| Seedance 1.0 pro fast | ✓ | ✓ | — | — | — | + +分辨率与宽高比的可选值**随模型不同**(2.5 无 4k,2.0 fast/mini 只到 720p; +2.5 在首帧/首尾帧、视频编辑、视频延长任务中 **`ratio` 只支持 `adaptive`**)。 +时长范围:2.5 为 `[4, 30]`,2.0 系列为 `[4, 15]`,均支持 `-1` 智能选择。 + +文档出现过的 model ID 示例:`doubao-seedance-2-5-260628`、 +`doubao-seedance-2-0-260128`、`doubao-seedance-1-0-pro-250528`、 +`doubao-seedance-1-0-lite-i2v-250428`。 + +> **V7 种子待定项**:实际可用的 model ID 取决于账号开通与推理接入点配置, +> 文档示例值不能直接当种子。需确认账号实际开通的模型后再写 V7,否则种子里 +> 的 `provider_model_name` 会指向一个不存在或无权访问的模型 +> (对应错误码 `InvalidEndpointOrModel.NotFound` / `ModelNotOpen`)。 + +## 6. 实现状态 + +| 项 | 状态 | +| --- | --- | +| 错误分类(`errors.py`) | ✅ 32 测试 | +| 提交与查询客户端(`seedance.py`) | ✅ 26 测试,全部离线(MockTransport) | +| 产物转存 | ⏸ 阻塞于存储决策(ADR-023 §7)。注意 §2-④ 的 24 小时硬约束 | +| V7 目录种子 | ⏸ 待确认账号实际开通的 model ID | +| Java 侧 provider 适配 | ⏸ 待本服务的 HTTP 端点与 `openapi-internal.yaml` 落地 | diff --git a/mkdocs.yml b/mkdocs.yml index d0e20d1..46f785c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,6 +11,8 @@ nav: - 发布记录: development/releases.md - 服务器暴露面清单: development/server-exposure.md - CI Runner 部署手册: development/ci-runner-setup.md + - 服务商接口: + - 火山方舟 Seedance(视频): development/providers/volcengine-seedance.md - 第一迭代: - 进展看板: development/iterations/iteration-1/index.md - 01 任务分解: development/iterations/iteration-1/01-pm-task-breakdown.md