feat(seedance): 火山方舟视频生成客户端与错误分类(58 测试,全离线)
CI / ai-gates (push) Successful in 31s

按用户提供的三份官方文档适配 Seedance 视频生成:提交
(POST /contents/generations/tasks)与查询(GET .../tasks/{id})。
取证记录见 patbond-doc docs/development/providers/volcengine-seedance.md。

**开工前问的五项,两项与原假设不同:**

1. **不支持幂等键**。请求参数全集里没有任何幂等字段。故 ADR-023 §4 设想的
   「把我方 idempotency_key 透传为厂商幂等头」对本供应商不成立,「提交超时后
   不知道有没有扣费」只能由 max_attempts 兜住——如实登记,不假装解决。
   两点减轻:提交是异步的(只入队即返回,超时概率低);且厂商支持 callback_url,
   孤儿任务会通过回调把 ID 送回来,故回调在此供应商上还是**孤儿回收通道**,
   不只是延迟优化——这是 ADR-023 §4「回调仅预留」之外的新论据,值得复评。
2. **产物是有效期 24 小时的 URL**(2.5 另有 100 次下载上限)。因此厂商 URL
   **不能当存储用**:存进 media.assets.external_url 会在 24h 后变死链。
   **这对挂起中的存储决策是硬约束**——无论选本地还是云,都必须在 24 小时内
   把文件复制到我们自己的存储。

其余三项:查询接口只保留 7 天记录;无明确频率数值,但官方 SDK 示例用 10s 轮询
(我们的 3s/10s/30s 阶梯与之兼容)、客户端截止 30 分钟(建议据此把 ADR-023 §10
的视频客户端超时从 15~20 分钟对齐到 30 分钟);execution_expires_after 范围
[3600, 259200],实现取下限 1 小时(厂商默认 48h 对交互式应用过长,且无法更短)。

**两个文档自身的问题,实现按宽的那份:**

- **状态枚举不一致**:查询文档列 5 个(queued/running/cancelled/succeeded/
  failed),创建文档的回调一节额外列 **expired**。只按查询文档实现会让超时
  任务掉进未知分支。按 6 个实现,expired 视为**可重试**失败(是超时不是内容
  问题),且厂商 expired 时不一定给 error.code 故单独分类。
- **QuotaExceeded 一码两义**:同一 code 既表示「排队任务数超限」(等几秒)
  又表示「额度耗尽」(等到明天或充值)。按 code 一刀切必然有一半场景的文案
  和出口是错的;只能按 message 判别,两条用例都在测试里钉住。

**顺带拿到两个免费收益**:last_frame_url 可直接作视频封面(iteration-4/03 R13
把缩略图列为视频成本项,实际免费);查询返回 ratio/resolution/framespersecond/
duration,**Worker 写产物时维度与时长都在手里、无需解码**——widthPx/heightPx
债在 AI 产物上可零成本修掉。

一处精度陷阱已处理:返回的 duration 是「帧数/24 向下取整」的约数(133 帧实际
5.54s 返回 5),且 duration 与 frames 只返回其一。故优先按 frames/fps 精确换算,
只有 duration 时退回整数秒并标注为约数,缺失时返回 None 而非编 0。

取消语义已查明:厂商**只支持取消 queued 状态**的任务,故我方取消已 running
的任务无法向厂商传导,**仍会计费**。ADR-023 §6 关于取消的说明据此可定稿。

测试 58 项(错误分类 32 + 客户端 26),全部离线用 httpx MockTransport,
夹具响应体逐字取自官方文档示例以免字段名对不上。

过程中防泄漏钩子拦下了测试夹具里的 `api_key="test-key"`——规则是对的(这正是
真实密钥最常见的泄漏形态),故改用 ALLOW 清单认可的占位词,未放宽规则。

未实现:产物转存(阻塞于存储决策)、HTTP 服务端点与 openapi-internal.yaml。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-20 11:51:37 +08:00
parent 6990709124
commit c6e8dddc8d
9 changed files with 1091 additions and 0 deletions
View File
View File
+198
View File
@@ -0,0 +1,198 @@
"""火山方舟(Volcengine Ark)错误分类。
把厂商的错误码翻译成三个对 Worker 有意义的维度:**是否可重试**、**等多久**、
**给用户看什么**。分类依据是官方错误码表(见 patbond-doc
`docs/development/providers/volcengine-seedance.md` 的取证记录)。
为什么值得单独成一个模块而不是散在客户端里:
1. **`QuotaExceeded` 一码两义。** 同一个 code 既表示「免费额度/周期限额耗尽」
(要等到明天或去充值),也表示「当前账号排队中任务数超限」(等几秒就好)。
官方文档里这两条是相邻的两行、code 完全相同,只有 message 不同。若按 code
一刀切,必然有一半场景的文案和出口是错的。
2. **`expired` 状态没写在查询接口的状态枚举里。** 查询文档列的是 queued /
running / cancelled / succeeded / failed 五个,但回调文档额外列了 `expired`
(任务超过 `execution_expires_after` 被自动终止)。只按查询文档实现,线上
遇到 expired 会掉进未知分支。
3. 敏感内容类错误**重试毫无意义**(同样的输入必然同样被拒),必须与限流类
区分开,否则会白烧三次重试额度再失败。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
class Retry(Enum):
"""重试策略。"""
#: 不可重试:同样的输入必然得到同样的结果(参数错、内容被拒、鉴权失败)。
NEVER = "never"
#: 可重试且应尽快:限流、突发流量保护、服务过载。退避几秒到几十秒。
BACKOFF = "backoff"
#: 可重试但要等很久:额度/配额耗尽,通常要等到下一个计费周期或充值之后。
#: Worker 不应把重试额度浪费在这上面,应直接终态失败并让用户看到出口。
QUOTA = "quota"
class UserFacing(Enum):
"""给终端用户的呈现类别,对应客户端的两个 429 分支(ADR-023 §9)。"""
#: 配额耗尽:等的是「明天」或「充值」。客户端弹 sheet 并给出出口。
QUOTA_EXHAUSTED = "quota_exhausted"
#: 频率过快:等的是「几秒」。客户端弹 SnackBar,可自动重试。
RATE_LIMITED = "rate_limited"
#: 内容被拒:换提示词或换图。不可重试。
CONTENT_REJECTED = "content_rejected"
#: 服务端问题(我们的 bug 或配置错误)。用户无从处置,只报通用失败。
SERVER_ERROR = "server_error"
#: 上游暂时不可用。可重试。
UPSTREAM_UNAVAILABLE = "upstream_unavailable"
@dataclass(frozen=True)
class Classification:
retry: Retry
user_facing: UserFacing
#: 原始厂商错误码,原样透传便于排查——绝不丢弃。
provider_code: str
#: 内容安全类:**重试无意义**。覆盖输入/输出的文本、图片、视频、音频,以及
#: 版权、真人、伪造证件等子类,另含风险识别产品(*RiskDetection)的四个码。
#: 用前缀匹配而非穷举:官方这一族的码在持续增加(Input/Output × 媒体类型 ×
#: 子原因),穷举必然漏,而这一族的处置方式完全一致。
_CONTENT_PREFIXES = (
"SensitiveContentDetected",
"InputTextSensitiveContentDetected",
"InputImageSensitiveContentDetected",
"InputVideoSensitiveContentDetected",
"InputAudioSensitiveContentDetected",
"OutputTextSensitiveContentDetected",
"OutputImageSensitiveContentDetected",
"OutputVideoSensitiveContentDetected",
"OutputAudioSensitiveContentDetected",
"InputTextRiskDetection",
"InputImageRiskDetection",
"OutputTextRiskDetection",
"OutputImageRiskDetection",
)
#: 限流类:退避后重试。等的是秒级。
_RATE_LIMIT_CODES = frozenset(
{
"RateLimitExceeded.EndpointRPMExceeded",
"RateLimitExceeded.EndpointTPMExceeded",
"RateLimitExceeded.EndpointFlexTPMExceeded",
"ModelAccountRpmRateLimitExceeded",
"ModelAccountTpmRateLimitExceeded",
"ModelAccountFlexTpmRateLimitExceeded",
"ModelAccountIpmRateLimitExceeded",
"APIAccountRpmRateLimitExceeded",
"AccountRateLimitExceeded",
"ServerOverloaded",
"RequestBurstTooFast",
}
)
#: 配额/计费类:等的是「明天」或「充值」。重试解决不了。
#: 注意 403 的欠费与未开通也归此类——HTTP 状态码不同,但用户要做的事一样。
_QUOTA_CODES = frozenset(
{
"SetLimitExceeded",
"InflightBatchsizeExceeded",
"QuotaExceeded.AgentPlanQuotaExceeded",
"QuotaExceeded.DoubaoSearchFreeQuotaExceeded",
"AccountOverdueError",
"OperationDenied.ServiceOverdue",
"OperationDenied.FileQuotaExceeded",
"ModelNotOpen",
"OperationDenied.ServiceNotOpen",
}
)
#: 我方配置或代码错误:重试无意义,需要人来改。
_SERVER_FAULT_CODES = frozenset(
{
"AuthenticationError",
"InvalidAccountStatus",
"AccessDenied",
"InvalidEndpointOrModel.NotFound",
"InvalidEndpointOrModel.ModelIDAccessDisabled",
"InvalidEndpoint.ClosedEndpoint",
"OperationDenied.PermissionDenied",
"OperationDenied.ArkAccessRoleNotFound",
"OperationDenied.TosAccessDenied",
"PathNotFound",
}
)
#: 上游内部错误:可重试。
_UPSTREAM_CODES = frozenset({"InternalServiceError", "UpstreamUnavailable"})
#: `QuotaExceeded` 一码两义的判别式。命中即为「排队任务数超限」,属秒级等待;
#: 否则按额度耗尽处理。取自官方两行 message 的区别性措辞。
#: 判别放在 message 上是不得已——code 相同,没有别的字段可依据。
_QUEUE_BACKLOG_MESSAGE = re.compile(
r"exceeded the quota|排队中状态的任务数", re.IGNORECASE
)
def classify(provider_code: str | None, message: str | None = None) -> Classification:
"""把厂商错误码分类。
未知码一律按**可退避重试的上游问题**处理而不是直接判死:厂商在持续新增错误
码,把未知码当终态失败会让一次可恢复的抖动变成用户看到的失败;而重试预算
本身有上限(`max_attempts`),最坏情况只是多试两次。
"""
code = (provider_code or "").strip()
text = message or ""
if not code:
return Classification(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, "")
if code.startswith(_CONTENT_PREFIXES):
return Classification(Retry.NEVER, UserFacing.CONTENT_REJECTED, code)
if code == "ContentSecurityDetectionError":
# 风险识别产品自身请求失败,不是内容真的违规 —— 可重试。
return Classification(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code)
if code == "QuotaExceeded":
# 一码两义,见模块 docstring。
if _QUEUE_BACKLOG_MESSAGE.search(text):
return Classification(Retry.BACKOFF, UserFacing.RATE_LIMITED, code)
return Classification(Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, code)
if code in _RATE_LIMIT_CODES:
return Classification(Retry.BACKOFF, UserFacing.RATE_LIMITED, code)
if code in _QUOTA_CODES:
return Classification(Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, code)
if code in _SERVER_FAULT_CODES:
return Classification(Retry.NEVER, UserFacing.SERVER_ERROR, code)
if code in _UPSTREAM_CODES:
return Classification(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code)
# 参数类:我方拼错了请求体,重试同样的请求体不会变好。
# 含 InvalidParameter.TaskTypeConstraint / TaskTypeMismatch —— 那两个是
# Seedance 2.5 全模态任务的参数与模型判定的任务类型不兼容,属我方参数问题。
if code.startswith(
(
"InvalidParameter",
"MissingParameter",
"InvalidArgumentError",
"InvalidImageURL",
"InvalidPayload",
"RequestTooLarge",
"RequestBodyTooLarge",
"OutofContextError",
)
):
return Classification(Retry.NEVER, UserFacing.SERVER_ERROR, code)
return Classification(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code)
+370
View File
@@ -0,0 +1,370 @@
"""火山方舟 Seedance 视频生成客户端。
只做两件事:**提交任务**(`POST /contents/generations/tasks`)与**查询任务**
(`GET /contents/generations/tasks/{id}`)。产物的转存不在这里——见
`SeedanceTask.video_url` 的说明。
## 厂商接口的五个关键事实(逐条取自官方文档,详见 patbond-doc
`docs/development/providers/volcengine-seedance.md`)
1. **没有幂等键。** 请求参数里没有任何幂等相关字段(有的是 model / content /
ratio / duration / generate_audio / watermark / output_format /
return_last_frame / callback_url / execution_expires_after / priority /
safety_identifier)。因此「提交超时后不知道有没有扣费」这个窗口**无法靠厂商
的幂等头消除**,只能由 `max_attempts` 兜住。这与原先的乐观假设不同,已如实
登记而非假装解决。
缓解手段有一个:本接口是**异步**的(提交只入队,立即返回任务 ID),所以提交
本身很快、超时概率低;且厂商支持 `callback_url`,一个提交超时但实际已创建的
任务会通过回调把 ID 送回来,可作为孤儿任务的回收通道。
2. **提交是异步的**:返回 `{"id": ...}`,随后靠查询接口拿状态。这正好与
「Java↔Python 边界一律异步」的既定设计吻合,无需在本服务内部做后台任务承载。
3. **产物是有效期 24 小时的 URL**(Seedance 2.5 另有 100 次下载上限)。
**因此产物必须转存**——把厂商 URL 直接存进 `media.assets.external_url`
会在 24 小时后变成死链。这一条对「本地存储还是云存储」的决策是硬约束:
无论选哪个,都必须在 24 小时内把文件复制到我们自己的存储。
4. **任务记录只保留 7 天**,`execution_expires_after` 取值范围 `[3600, 259200]`
秒(默认 48 小时)。超时任务被标记为 `expired`。
5. **限流与配额是两族不同的 429**,映射见 `errors.py`。
## 一个文档自身的矛盾,必须按宽的那份实现
查询接口文档列的状态是 queued / running / cancelled / succeeded / failed 五个,
但创建接口的 `callback_url` 一节额外列了 **`expired`**。只按查询文档实现,线上
遇到超时任务会掉进未知分支。这里按六个状态实现,并把 `expired` 当作**可重试的
失败**(是超时而非内容问题)。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Any
import httpx
from .errors import Classification, Retry, UserFacing, classify
log = logging.getLogger(__name__)
#: 官方 base URL(北京区)。
DEFAULT_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
_TASKS_PATH = "/contents/generations/tasks"
class TaskStatus(Enum):
"""厂商任务状态。
六个取值:查询文档给了五个,`expired` 来自创建文档的回调说明一节。
"""
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
#: 任务在排队或运行中超过 `execution_expires_after` 被自动终止。
#: **查询接口文档未列出此值**,仅见于创建接口的回调状态说明。
EXPIRED = "expired"
@classmethod
def parse(cls, value: str | None) -> TaskStatus | None:
"""未知状态返回 None 而不是抛异常。
厂商新增状态时,抛异常会让所有在途任务一起失败;返回 None 让调用方能把
它当成「还没结束」继续轮询,最坏是等到我们自己的超时。
"""
for status in cls:
if status.value == value:
return status
return None
@property
def is_terminal(self) -> bool:
return self in {
TaskStatus.SUCCEEDED,
TaskStatus.FAILED,
TaskStatus.CANCELLED,
TaskStatus.EXPIRED,
}
@dataclass(frozen=True)
class SeedanceTask:
"""查询接口返回的任务。
:param video_url: 生成视频的 URL,**有效期仅 24 小时**。本服务不负责转存,
但调用方必须在有效期内完成转存——把它长期存为 `external_url` 会变死链。
:param last_frame_url: 尾帧图像(png,尺寸与视频一致,无水印),仅在提交时
设置 `return_last_frame=true` 才返回。可直接用作**视频封面图**,省掉
我们自己抽帧。
:param duration_sec: 厂商返回的时长**是向下取整的约数**:官方给的公式是
`duration = 实际总帧数 / 24` 向下取整,例如 133 帧实际 5.54 秒会返回 5。
因此据此换算出的 `duration_ms` 最多可能短 1 秒左右,不可当精确值用。
:param frames: 与 `duration_sec` **只会返回其中一个**(提交时指定了 frames
才返回 frames)。两者都在时以 frames 为准。
"""
id: str
status: TaskStatus | None
raw_status: str | None
video_url: str | None = None
last_frame_url: str | None = None
duration_sec: int | None = None
frames: int | None = None
fps: int | None = None
ratio: str | None = None
resolution: str | None = None
output_format: str | None = None
error_code: str | None = None
error_message: str | None = None
completion_tokens: int | None = None
created_at: int | None = None
updated_at: int | None = None
@property
def duration_ms(self) -> int | None:
"""尽可能精确的时长毫秒数。
有 `frames` + `fps` 时按帧算(精确);否则退回厂商返回的整数秒
(**向下取整的约数**,见类 docstring)。两者都缺则返回 None,而不是
编一个 0 出来。
"""
if self.frames is not None and self.fps:
return round(self.frames / self.fps * 1000)
if self.duration_sec is not None:
return self.duration_sec * 1000
return None
@property
def failure(self) -> Classification | None:
"""失败任务的分类;非失败返回 None。
`expired` 单独处理:它是超时,不是内容或参数问题,因此可重试——而厂商
在 expired 时不一定给 `error.code`,若走通用分类会被当成「无码」处理。
"""
if self.status is TaskStatus.EXPIRED:
return Classification(
Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, "TaskExpired"
)
if self.status is TaskStatus.FAILED:
return classify(self.error_code, self.error_message)
return None
@dataclass
class SeedanceSubmission:
"""一次提交所需的全部参数。
刻意不把厂商的全部参数都暴露出来——只暴露 Patbond 会用到的。多暴露一个参数
就多一处需要在契约、客户端、文档三处同步维护的地方。
"""
model: str
prompt: str | None = None
#: 首帧图片 URL。给了即为图生视频,不给即为文生视频(参考图选填,决策 A2)。
first_frame_url: str | None = None
#: 宽高比。`adaptive` 让模型自适应;图生视频时 Seedance 2.5 只支持 adaptive。
ratio: str = "adaptive"
resolution: str = "720p"
#: 时长(秒)。`-1` 交给模型在有效范围内自选。
duration: int = 5
generate_audio: bool = True
watermark: bool = False
#: 返回尾帧图,用作视频封面。默认开启——成本为零,省掉我们自己抽帧。
return_last_frame: bool = True
#: 任务超时阈值(秒)。取值范围 [3600, 259200]。**默认 48 小时对交互式应用
#: 过长**,故这里取下限 1 小时,避免被放弃的任务在厂商侧挂两天。
execution_expires_after: int = 3600
#: 终端用户哈希标识,助厂商做滥用检测。**必须是哈希值,不可传明文用户 ID**。
safety_identifier: str | None = None
callback_url: str | None = None
def to_body(self) -> dict[str, Any]:
content: list[dict[str, Any]] = []
if self.prompt:
content.append({"type": "text", "text": self.prompt})
if self.first_frame_url:
# role 省略时厂商按 first_frame 处理;显式写出以免依赖默认行为。
content.append(
{
"type": "image_url",
"image_url": {"url": self.first_frame_url},
"role": "first_frame",
}
)
if not content:
raise ValueError("提交内容为空:prompt 与 first_frame_url 至少要有一个")
body: dict[str, Any] = {
"model": self.model,
"content": content,
"ratio": self.ratio,
"resolution": self.resolution,
"duration": self.duration,
"generate_audio": self.generate_audio,
"watermark": self.watermark,
"return_last_frame": self.return_last_frame,
"execution_expires_after": self.execution_expires_after,
}
if self.safety_identifier:
body["safety_identifier"] = self.safety_identifier
if self.callback_url:
body["callback_url"] = self.callback_url
return body
class SeedanceError(RuntimeError):
"""厂商返回的错误,带分类。"""
def __init__(
self,
classification: Classification,
message: str,
http_status: int | None = None,
):
super().__init__(message)
self.classification = classification
self.http_status = http_status
class SeedanceClient:
"""Seedance 的同步 HTTP 客户端。
**超时设置的含义**:这里的 timeout 是「提交/查询这一次 HTTP 往返」的超时,
**不是生成耗时**。提交接口是异步的(只入队),所以几秒足够;把它调大成
「等出图」是错的,会重新引入长连接占用与计费歧义。
"""
def __init__(
self,
api_key: str,
base_url: str = DEFAULT_BASE_URL,
timeout: float = 10.0,
client: httpx.Client | None = None,
):
if not api_key:
raise ValueError("ARK_API_KEY 未配置")
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._client = client or httpx.Client(timeout=timeout)
def submit(self, submission: SeedanceSubmission) -> str:
"""提交任务,返回厂商任务 ID。
调用方必须在**上游产生任何计费动作之前**把这个 ID 落库——那是超时后
唯一能判断「是否已提交」的依据。本接口异步,返回即代表已入队。
"""
response = self._post(_TASKS_PATH, submission.to_body())
task_id = response.get("id")
if not task_id:
raise SeedanceError(
classify(None),
f"提交成功但响应缺少任务 ID:{response!r}",
)
return str(task_id)
def get(self, task_id: str) -> SeedanceTask:
"""查询任务。
注意厂商只保留最近 7 天的任务记录;更早的任务查询会 404。
"""
payload = self._get(f"{_TASKS_PATH}/{task_id}")
return self._parse_task(payload)
# ---------------------------------------------------------------- 内部
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
try:
response = self._client.post(
f"{self._base_url}{path}", json=body, headers=self._headers()
)
except httpx.HTTPError as exc:
# 网络层失败:可重试。注意提交超时属于「不知道有没有扣费」的窗口,
# 调用方须依赖已落库的 provider_request_id 或回调来判断,不可盲目重发。
raise SeedanceError(classify(None), f"请求火山方舟失败:{exc}") from exc
return self._unwrap(response)
def _get(self, path: str) -> dict[str, Any]:
try:
response = self._client.get(
f"{self._base_url}{path}", headers=self._headers()
)
except httpx.HTTPError as exc:
raise SeedanceError(classify(None), f"查询火山方舟失败:{exc}") from exc
return self._unwrap(response)
@staticmethod
def _unwrap(response: httpx.Response) -> dict[str, Any]:
"""把 HTTP 响应转成 dict,非 2xx 转成带分类的 SeedanceError。
错误体形态取自官方错误码表:`{"error": {"code": ..., "message": ...}}`。
响应体解析失败时不静默——那通常意味着我们打到了错误的地址(比如把
网关的 HTML 错误页当成了 JSON)。
"""
if response.is_success:
try:
return response.json()
except ValueError as exc:
raise SeedanceError(
classify(None),
f"响应不是合法 JSON(HTTP {response.status_code}):{response.text[:200]}",
response.status_code,
) from exc
code: str | None = None
message = response.text[:500]
try:
error = response.json().get("error") or {}
code = error.get("code")
message = error.get("message") or message
except ValueError:
pass
classification = classify(code, message)
log.warning(
"火山方舟返回错误 http=%s code=%s retry=%s",
response.status_code,
code,
classification.retry.value,
)
raise SeedanceError(classification, message, response.status_code)
@staticmethod
def _parse_task(payload: dict[str, Any]) -> SeedanceTask:
content = payload.get("content") or {}
error = payload.get("error") or {}
usage = payload.get("usage") or {}
raw_status = payload.get("status")
status = TaskStatus.parse(raw_status)
if status is None and raw_status is not None:
# 不抛异常:新状态让调用方按「未结束」继续轮询,最坏等到自己的超时。
log.warning("火山方舟返回未知任务状态:%r", raw_status)
return SeedanceTask(
id=str(payload.get("id") or ""),
status=status,
raw_status=raw_status,
video_url=content.get("video_url"),
last_frame_url=content.get("last_frame_url"),
duration_sec=payload.get("duration"),
frames=payload.get("frames"),
fps=payload.get("framespersecond"),
ratio=payload.get("ratio"),
resolution=payload.get("resolution"),
output_format=payload.get("output_format"),
error_code=error.get("code"),
error_message=error.get("message"),
completion_tokens=usage.get("completion_tokens"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
)
def close(self) -> None:
self._client.close()