""" Seedance 2.0 真人素材(Element)API 客户端 封装标准素材接口与高并发素材接口 """ import json import aiohttp from typing import Optional from urllib.parse import quote from ..utils.config import get_api_key_or_raise, get_base_url_by_route from ..utils.video_task import PollDeadline, check_interrupt, interruptible_sleep class SeedanceElementClient: """Seedance 2.0 真人素材客户端""" _ASSET_REQUEST_TYPES = {"hc", "doubao"} def __init__(self, base_url: str = None, api_key: str = None): self.api_key = api_key or get_api_key_or_raise() self.base_url = base_url or get_base_url_by_route() def _headers(self) -> dict: return { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", } def _hc_headers(self) -> dict: return { **self._headers(), "Accept": "application/json", } @classmethod def _normalize_asset_request_type(cls, request_type: str) -> str: normalized = str(request_type).strip().lower() if normalized not in cls._ASSET_REQUEST_TYPES: raise ValueError(f"不支持的素材请求类型:{request_type}") return normalized @staticmethod def _hc_error_message(payload: dict, default: str) -> str: error = payload.get("error") if isinstance(error, dict): error = error.get("message") or error.get("detail") data = payload.get("data") base_resp = data.get("base_resp", {}) if isinstance(data, dict) else {} return str( error or payload.get("message") or base_resp.get("status_msg") or default ) @staticmethod async def _read_json_response(resp: aiohttp.ClientResponse) -> dict: text = await resp.text() try: payload = json.loads(text) except json.JSONDecodeError: raise RuntimeError("素材接口返回了无效 JSON") from None if not isinstance(payload, dict): raise RuntimeError("素材接口返回格式错误") return payload async def create_element( self, name: str, image_url: str, description: Optional[str] = None, channel_id: int = 0, session: aiohttp.ClientSession = None, ) -> dict: """ 创建素材 Args: name: 素材名称 image_url: 图片 URL(必须是 http/https) description: 素材描述(可选) channel_id: 渠道ID,0表示自动选择 session: aiohttp会话,如果为None则创建临时会话 Returns: { "id": 123, "name": "我的数字人", "description": "真人形象描述", "frontal_image": "https://xxx.jpg", "element_id": "asset-abc123xyz", # 重要!上游Asset ID "job_id": "group-xyz789", "status": "succeed", "created_at": 1719734400 } """ url = f"{self.base_url}/api/element/seedance" body = { "name": name, "image_url": image_url, "channel_id": channel_id, } if description: body["description"] = description should_close = session is None if session is None: connector = aiohttp.TCPConnector(ssl=False, force_close=True) session = aiohttp.ClientSession(connector=connector) try: async with session.post(url, json=body, headers=self._headers()) as resp: result = await resp.json() print( "[Seedance素材][标准] 创建响应体:\n" + json.dumps( {"http_status": resp.status, "success": bool(result.get("success"))}, ensure_ascii=False, ) ) if resp.status != 200: error_msg = result.get("message", result.get("error", str(result))) raise RuntimeError(f"创建素材失败 ({resp.status}): {error_msg}") if not result.get("success", False): error_msg = result.get("message", "创建素材失败") raise RuntimeError(error_msg) data = result.get("data", result) if isinstance(data, dict): data = dict(data) data["_create_response"] = result return data finally: if should_close: await session.close() async def create_hc_asset( self, name: str, asset_url: str, asset_type: str, session: aiohttp.ClientSession = None, request_type: str = "hc", ) -> dict: """通过统一 Seedance 素材接口创建 HC 或 Doubao 素材。""" normalized_asset_type = str(asset_type).strip().lower() if normalized_asset_type not in {"image", "video", "audio"}: raise ValueError(f"不支持的素材类型:{asset_type}") normalized_request_type = self._normalize_asset_request_type(request_type) request_label = "HC" if normalized_request_type == "hc" else "Doubao" url = f"{self.base_url}/v1/seedance/assets" body = { "type": normalized_request_type, "url": asset_url, "asset_type": normalized_asset_type, } if name: body["name"] = name should_close = session is None if session is None: connector = aiohttp.TCPConnector(ssl=False, force_close=True) session = aiohttp.ClientSession(connector=connector) try: check_interrupt() async with session.post(url, json=body, headers=self._hc_headers()) as resp: result = await self._read_json_response(resp) print( f"[Seedance素材][{request_label}] 创建响应体:\n" + json.dumps( {"http_status": resp.status, "success": bool(result.get("success"))}, ensure_ascii=False, ) ) if resp.status < 200 or resp.status >= 300: message = self._hc_error_message(result, "创建素材失败") raise RuntimeError(f"创建 {request_label} 素材失败 ({resp.status}): {message}") if not result.get("success", False): raise RuntimeError(self._hc_error_message(result, f"创建 {request_label} 素材失败")) data = result.get("data") if not isinstance(data, dict) or not data.get("Id"): raise RuntimeError(f"创建 {request_label} 素材成功但未返回 data.Id") data = dict(data) data["_create_response"] = result return data finally: if should_close: await session.close() async def get_hc_asset( self, asset_id: str, session: aiohttp.ClientSession = None, request_type: str = "hc", ) -> dict: """通过统一 Seedance 素材接口查询 HC 或 Doubao 素材状态。""" normalized_request_type = self._normalize_asset_request_type(request_type) request_label = "HC" if normalized_request_type == "hc" else "Doubao" encoded_id = quote(asset_id, safe="") url = f"{self.base_url}/v1/seedance/assets/{encoded_id}" should_close = session is None if session is None: connector = aiohttp.TCPConnector(ssl=False, force_close=True) session = aiohttp.ClientSession(connector=connector) try: check_interrupt() async with session.get( url, params={"type": normalized_request_type}, headers=self._hc_headers(), ) as resp: result = await self._read_json_response(resp) if resp.status < 200 or resp.status >= 300: message = self._hc_error_message(result, "查询素材状态失败") raise RuntimeError(f"查询 {request_label} 素材失败 ({resp.status}): {message}") if not result.get("success", False): raise RuntimeError(self._hc_error_message(result, f"查询 {request_label} 素材失败")) data = result.get("data") if not isinstance(data, dict): raise RuntimeError(f"查询 {request_label} 素材未返回 data") return data finally: if should_close: await session.close() async def create_hc_asset_and_wait( self, name: str, asset_url: str, asset_type: str, poll_interval: float = 3.0, request_type: str = "hc", ) -> dict: """创建 HC 或 Doubao 素材并等待其进入 Active 状态。""" normalized_request_type = self._normalize_asset_request_type(request_type) request_label = "HC" if normalized_request_type == "hc" else "Doubao" connector = aiohttp.TCPConnector(ssl=False, force_close=True) async with aiohttp.ClientSession(connector=connector) as session: created = await self.create_hc_asset( name=name, asset_url=asset_url, asset_type=asset_type, session=session, request_type=normalized_request_type, ) asset_id = str(created["Id"]) deadline = PollDeadline(label=f"Seedance {request_label} 素材") print(f"[Seedance素材][{request_label}] 已创建 {asset_id},等待素材可用...") while True: deadline.check() check_interrupt() asset = await self.get_hc_asset( asset_id, session=session, request_type=normalized_request_type, ) status = str(asset.get("Status", "")).strip() normalized_status = status.lower() print(f"[Seedance素材][{request_label}] {asset_id} 状态: {status or '未知'}") if normalized_status == "active": asset = dict(asset) asset["_create_response"] = created.get("_create_response", {}) return asset if normalized_status == "failed": message = self._hc_error_message({"data": asset}, "素材处理失败") raise RuntimeError(f"{request_label} 素材处理失败:{message}") if normalized_status != "processing": raise RuntimeError(f"{request_label} 素材返回未知状态:{status or '空状态'}") await interruptible_sleep(poll_interval) async def delete_element( self, element_internal_id: int, session: aiohttp.ClientSession = None, ) -> dict: """ 删除素材记录(仅删除平台记录,不删除上游Asset) Args: element_internal_id: 平台内部记录ID(非element_id) session: aiohttp会话 Returns: {"message": "删除成功"} """ url = f"{self.base_url}/api/element/seedance/{element_internal_id}" should_close = session is None if session is None: connector = aiohttp.TCPConnector(ssl=False, force_close=True) session = aiohttp.ClientSession(connector=connector) try: async with session.delete(url, headers=self._headers()) as resp: result = await resp.json() if resp.status != 200: error_msg = result.get("message", result.get("error", str(result))) raise RuntimeError(f"删除素材失败 ({resp.status}): {error_msg}") if not result.get("success", False): error_msg = result.get("message", "删除素材失败") raise RuntimeError(error_msg) return result.get("data", result) finally: if should_close: await session.close()