Files
comfyui_o1key/clients/seedance_element_client.py
Jony ba920f2b66 Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
2026-09-24 19:56:48 +08:00

321 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Seedance 2.0 真人素材(ElementAPI 客户端
封装标准素材接口与高并发素材接口
"""
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: 渠道ID0表示自动选择
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()