Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
321 lines
12 KiB
Python
321 lines
12 KiB
Python
"""
|
||
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()
|