"""火山方舟 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()