568 lines
19 KiB
Python
568 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
腾讯混元3D 完整API客户端
|
||
支持所有生成模式:图生3D、文生3D、草图生3D、动画生成、纹理生成、智能拓扑
|
||
"""
|
||
|
||
import os
|
||
import requests
|
||
import json
|
||
import time
|
||
from typing import Optional, Dict, Any, List
|
||
from urllib.parse import urlparse, unquote
|
||
from .sign import sign
|
||
|
||
BASE_URL = "https://3d.hunyuan.tencent.com"
|
||
API_BASE = f"{BASE_URL}/api/3d"
|
||
|
||
|
||
class Hunyuan3DAPI:
|
||
"""腾讯混元3D 完整API客户端"""
|
||
|
||
# 生成模式常量
|
||
MODEL_TYPE_IMAGE = "image2ModelV3.1"
|
||
MODEL_TYPE_TEXT = "text2ModelV3.1"
|
||
MODEL_TYPE_SKETCH = "sketch2ModelV3.1"
|
||
MODEL_TYPE_ANIMATION = "animation3dV2"
|
||
MODEL_TYPE_TEXTURE = "textureTo3DV2"
|
||
MODEL_TYPE_LOWPOLY = "lowpolyV2"
|
||
|
||
# 动作类型
|
||
MOTION_CAPOEIRA = 9
|
||
MOTION_FALLING = 10
|
||
MOTION_JUMPING = 11
|
||
MOTION_KICKING = 12
|
||
MOTION_SWORD = 13
|
||
MOTION_RUNNING = 15
|
||
MOTION_DANCING = 16
|
||
|
||
# 纹理风格
|
||
STYLE_DEFAULT = ""
|
||
STYLE_SCULPTURE = "sculpture"
|
||
STYLE_QINGHUA = "qinghuaci"
|
||
STYLE_CHINA = "china_style"
|
||
STYLE_CARTOON = "cartoon"
|
||
STYLE_CYBERPUNK = "cyberpunk"
|
||
|
||
# 拓扑面数
|
||
TOPO_LOW = 5000
|
||
TOPO_MEDIUM = 18000
|
||
TOPO_HIGH = 30000
|
||
|
||
# 模型下载格式键名
|
||
# 原生格式:直接来自 API urlResult
|
||
# 转换格式:通过 /creations/resourceConvert 从 OBJ(zip) 转换得到
|
||
MODEL_FORMAT_KEYS = [
|
||
"glb", # 带PBR贴图的GLB二进制模型
|
||
"obj", # OBJ模型(通常打包在zip中)
|
||
"mtl", # MTL材质文件
|
||
"obj_url", # 独立OBJ文件URL
|
||
"geometryGlb", # 纯几何GLB(无材质)
|
||
"textureGlb", # GLB纹理
|
||
"textureObj", # OBJ纹理包
|
||
"image_url", # 预览图/缩略图
|
||
"pbrImage", # PBR综合贴图
|
||
"pbrMetallicImage", # PBR金属度贴图
|
||
"pbrRoughnessImage", # PBR粗糙度贴图
|
||
"pbrNormalImage", # PBR法线贴图
|
||
"invisible_wall", # 不可见碰撞墙
|
||
"air_wall", # 空气墙(碰撞体)
|
||
"fbx", # FBX 格式(转换)
|
||
"stl", # STL 格式(转换)
|
||
"usdz", # USDZ 格式(转换,用于 iOS AR)
|
||
"mp4", # MP4 视频格式(转换)
|
||
"gif", # GIF 动图格式(转换)
|
||
]
|
||
|
||
def __init__(self, cookies: Optional[str] = None):
|
||
self.session = requests.Session()
|
||
self.session.headers.update({
|
||
"Content-Type": "application/json",
|
||
"x-source": "web",
|
||
"x-product": "hunyuan3d",
|
||
"Origin": BASE_URL,
|
||
"Referer": f"{BASE_URL}/",
|
||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||
})
|
||
try:
|
||
cookie_value = cookies if cookies is not None else load_cookies_from_file()
|
||
except FileNotFoundError:
|
||
cookie_value = None
|
||
if cookie_value:
|
||
self.set_cookies(cookie_value)
|
||
|
||
def set_cookies(self, cookies: str):
|
||
self.session.headers["Cookie"] = cookies
|
||
|
||
def _make_request(self, method: str, endpoint: str, params: Optional[Dict] = None,
|
||
data: Optional[Dict] = None) -> Dict[str, Any]:
|
||
url = f"{API_BASE}{endpoint}"
|
||
sign_params = dict(params) if params else {}
|
||
signed_params = sign(sign_params)
|
||
|
||
timestamp = signed_params.pop("timestamp")
|
||
nonce = signed_params.pop("nonce")
|
||
sign_value = signed_params.pop("sign")
|
||
|
||
query_params = {}
|
||
if params:
|
||
query_params.update(params)
|
||
query_params.update({
|
||
"timestamp": timestamp,
|
||
"nonce": nonce,
|
||
"sign": sign_value
|
||
})
|
||
|
||
if method.upper() == "GET":
|
||
response = self.session.get(url, params=query_params, timeout=30)
|
||
else:
|
||
response = self.session.post(url, params=query_params, json=data, timeout=30)
|
||
|
||
response.raise_for_status()
|
||
return response.json()
|
||
|
||
# ========== 用户接口 ==========
|
||
|
||
def get_user_info(self) -> Dict[str, Any]:
|
||
"""获取用户信息"""
|
||
return self._make_request("GET", "/getuserinfo")
|
||
|
||
def get_quota_info(self, scene_type: str = "3dCreations") -> Dict[str, Any]:
|
||
"""获取配额信息"""
|
||
return self._make_request("POST", "/quotainfo", data={"sceneType": scene_type})
|
||
|
||
# ========== 作品管理 ==========
|
||
|
||
def get_creation_list(self, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
||
"""获取作品列表"""
|
||
return self._make_request("POST", "/creations/list", data={
|
||
"page": page,
|
||
"pageSize": page_size
|
||
})
|
||
|
||
def get_creation_count(self, status_list: Optional[List[str]] = None) -> Dict[str, Any]:
|
||
"""获取作品数量"""
|
||
data = {"statusList": status_list or ["wait", "processing", "success", "fail"]}
|
||
return self._make_request("POST", "/creations/count", data=data)
|
||
|
||
def get_generation_status(self, creation_id: str) -> Dict[str, Any]:
|
||
"""获取生成状态"""
|
||
return self._make_request("GET", "/creations/detail", params={
|
||
"creationsId": creation_id
|
||
})
|
||
|
||
def resource_convert(self, source_url: str, target_formats: List[str]) -> Dict[str, str]:
|
||
"""
|
||
调用资源转换接口将 OBJ(zip) 转换为其他格式
|
||
|
||
Args:
|
||
source_url: OBJ zip 文件的 URL(来自 urlResult['obj'])
|
||
target_formats: 目标格式列表,如 ["usdz", "fbx"]
|
||
|
||
Returns:
|
||
格式键名 -> 转换后下载URL 的字典
|
||
"""
|
||
data = {
|
||
"sourceResource": [
|
||
{"format": "zip", "url": source_url}
|
||
],
|
||
"targetFormatList": target_formats
|
||
}
|
||
resp = self._make_request("POST", "/creations/resourceConvert", data=data)
|
||
result = {}
|
||
for item in resp.get("convertResult", []):
|
||
fmt = item.get("format")
|
||
url = item.get("url")
|
||
if fmt and url:
|
||
result[fmt] = url
|
||
return result
|
||
|
||
def get_model_urls(self, creation_id: str, include_converted: bool = False) -> Dict[str, Optional[str]]:
|
||
"""
|
||
获取指定创作所有可用格式的下载URL
|
||
|
||
Args:
|
||
creation_id: 创作ID
|
||
include_converted: 是否包含转换格式(fbx/stl/usdz/mp4/gif),
|
||
启用时会调用 resourceConvert 接口
|
||
|
||
Returns:
|
||
格式键名 -> 下载URL 的字典,空值已被过滤
|
||
"""
|
||
status = self.get_generation_status(creation_id)
|
||
|
||
# 兼容两种响应包装格式
|
||
if "code" in status:
|
||
data = status.get("data", {})
|
||
else:
|
||
data = status
|
||
|
||
result_list = data.get("result", [])
|
||
if not result_list:
|
||
return {}
|
||
|
||
url_result = result_list[0].get("urlResult", {})
|
||
urls = {
|
||
key: val
|
||
for key in self.MODEL_FORMAT_KEYS
|
||
if (val := url_result.get(key)) and val not in (None, "", {})
|
||
}
|
||
|
||
if include_converted:
|
||
obj_url = urls.get("obj")
|
||
if obj_url:
|
||
converted = self.resource_convert(
|
||
obj_url,
|
||
["fbx", "stl", "usdz", "mp4", "gif"]
|
||
)
|
||
urls.update(converted)
|
||
|
||
return urls
|
||
|
||
def download_model(self, creation_id: str, format_key: str = "glb",
|
||
output_path: Optional[str] = None,
|
||
include_converted: bool = False) -> str:
|
||
"""
|
||
下载指定格式的模型文件
|
||
|
||
Args:
|
||
creation_id: 创作ID
|
||
format_key: 格式键名,如 'glb' / 'obj' / 'usdz' / 'pbrNormalImage' 等
|
||
output_path: 本地保存路径,为空时自动从URL推断文件名
|
||
include_converted: 是否包含转换格式(fbx/stl/usdz/mp4/gif)
|
||
|
||
Returns:
|
||
实际保存的本地文件路径
|
||
"""
|
||
urls = self.get_model_urls(creation_id, include_converted=include_converted)
|
||
if format_key not in urls:
|
||
available = ", ".join(urls.keys())
|
||
raise ValueError(
|
||
f"格式 '{format_key}' 不可用。可用格式: {available}"
|
||
)
|
||
|
||
url = urls[format_key]
|
||
resp = self.session.get(url, timeout=120)
|
||
resp.raise_for_status()
|
||
|
||
if output_path is None:
|
||
parsed = urlparse(url)
|
||
filename = unquote(os.path.basename(parsed.path)) or f"{creation_id}_{format_key}"
|
||
output_path = filename
|
||
|
||
with open(output_path, "wb") as f:
|
||
f.write(resp.content)
|
||
|
||
return os.path.abspath(output_path)
|
||
|
||
def cancel_generation(self, creation_id: str) -> Dict[str, Any]:
|
||
"""取消生成任务"""
|
||
return self._make_request("POST", "/creations/cancel", data={
|
||
"creationsId": creation_id
|
||
})
|
||
|
||
# ========== 核心生成接口 ==========
|
||
|
||
def _generate(self, model_type: str, scene_type: str = "playGround3D-2.0",
|
||
title: str = "", style: str = "", text: str = "",
|
||
image_list: Optional[List[str]] = None,
|
||
enable_pbr: bool = True, enable_low_poly: bool = False,
|
||
face_count: int = 1500000, motion_type: Optional[int] = None,
|
||
topology_format: Optional[str] = None) -> Dict[str, Any]:
|
||
"""通用生成接口"""
|
||
data = {
|
||
"sceneType": scene_type,
|
||
"count": 1,
|
||
"modelType": model_type,
|
||
"title": title,
|
||
"style": style,
|
||
"enable_pbr": enable_pbr,
|
||
"enableLowPoly": enable_low_poly,
|
||
"faceCount": face_count
|
||
}
|
||
|
||
if text:
|
||
data["prompt"] = text
|
||
if image_list:
|
||
data["imageList"] = image_list
|
||
if motion_type is not None:
|
||
data["motionType"] = motion_type
|
||
if topology_format:
|
||
data["topology_format"] = topology_format
|
||
|
||
return self._make_request("POST", "/creations/generations", data=data)
|
||
|
||
# ========== 图生3D ==========
|
||
|
||
def generate_from_image(self, image_url: str, title: str = "",
|
||
style: str = "", enable_pbr: bool = True,
|
||
enable_low_poly: bool = False,
|
||
face_count: int = 1500000) -> Dict[str, Any]:
|
||
"""
|
||
图生3D
|
||
|
||
Args:
|
||
image_url: 图片URL(需先上传获取resourceId)
|
||
title: 作品标题
|
||
style: 纹理风格
|
||
enable_pbr: 是否启用PBR材质
|
||
enable_low_poly: 是否低多边形
|
||
face_count: 面数
|
||
"""
|
||
return self._generate(
|
||
model_type=self.MODEL_TYPE_IMAGE,
|
||
title=title,
|
||
style=style,
|
||
image_list=[image_url],
|
||
enable_pbr=enable_pbr,
|
||
enable_low_poly=enable_low_poly,
|
||
face_count=face_count
|
||
)
|
||
|
||
def generate_from_multi_view(self, image_urls: List[str], title: str = "",
|
||
style: str = "", enable_pbr: bool = True,
|
||
enable_low_poly: bool = False,
|
||
face_count: int = 1500000) -> Dict[str, Any]:
|
||
"""
|
||
多图视角生3D(多视图)
|
||
|
||
Args:
|
||
image_urls: 多角度图片URL列表(通常2-4张不同视角)
|
||
title: 作品标题
|
||
style: 纹理风格
|
||
enable_pbr: 是否启用PBR材质
|
||
enable_low_poly: 是否低多边形
|
||
face_count: 面数
|
||
"""
|
||
return self._generate(
|
||
model_type=self.MODEL_TYPE_IMAGE,
|
||
title=title,
|
||
style=style,
|
||
image_list=image_urls,
|
||
enable_pbr=enable_pbr,
|
||
enable_low_poly=enable_low_poly,
|
||
face_count=face_count
|
||
)
|
||
|
||
# ========== 文生3D ==========
|
||
|
||
def generate_from_text(self, prompt: str, title: str = "",
|
||
style: str = "", enable_pbr: bool = True,
|
||
enable_low_poly: bool = False,
|
||
face_count: int = 1500000, count: int = 4) -> Dict[str, Any]:
|
||
"""
|
||
文生3D
|
||
|
||
Args:
|
||
prompt: 文本描述/提示词
|
||
title: 作品标题
|
||
style: 纹理风格
|
||
enable_pbr: 是否启用PBR材质
|
||
enable_low_poly: 是否低多边形
|
||
face_count: 面数
|
||
count: 生成数量,文生3D固定为4
|
||
"""
|
||
data = {
|
||
"sceneType": "playGround3D-2.0",
|
||
"count": count,
|
||
"modelType": self.MODEL_TYPE_TEXT,
|
||
"title": title or prompt,
|
||
"style": style,
|
||
"prompt": prompt,
|
||
"enable_pbr": enable_pbr,
|
||
"enableLowPoly": enable_low_poly,
|
||
"faceCount": face_count
|
||
}
|
||
return self._make_request("POST", "/creations/generations", data=data)
|
||
|
||
# ========== 草图生3D ==========
|
||
|
||
def generate_from_sketch(self, sketch_url: str, prompt: str,
|
||
title: str = "", style: str = "",
|
||
enable_pbr: bool = True,
|
||
enable_low_poly: bool = False,
|
||
face_count: int = 1500000) -> Dict[str, Any]:
|
||
"""
|
||
草图生3D
|
||
|
||
Args:
|
||
sketch_url: 草图图片URL
|
||
prompt: 草图描述提示词
|
||
title: 作品标题
|
||
style: 纹理风格
|
||
enable_pbr: 是否启用PBR材质
|
||
enable_low_poly: 是否低多边形
|
||
face_count: 面数
|
||
"""
|
||
return self._generate(
|
||
model_type=self.MODEL_TYPE_SKETCH,
|
||
title=title,
|
||
style=style,
|
||
text=prompt,
|
||
image_list=[sketch_url],
|
||
enable_pbr=enable_pbr,
|
||
enable_low_poly=enable_low_poly,
|
||
face_count=face_count
|
||
)
|
||
|
||
# ========== 3D动画生成 ==========
|
||
|
||
def generate_animation(self, model_image_url: str, motion_type: int,
|
||
title: str = "") -> Dict[str, Any]:
|
||
"""
|
||
3D动画生成
|
||
|
||
Args:
|
||
model_image_url: 3D模型图片URL
|
||
motion_type: 动作类型ID
|
||
title: 作品标题
|
||
"""
|
||
return self._generate(
|
||
model_type=self.MODEL_TYPE_ANIMATION,
|
||
title=title,
|
||
image_list=[model_image_url],
|
||
motion_type=motion_type
|
||
)
|
||
|
||
# ========== 3D纹理生成 ==========
|
||
|
||
def generate_texture(self, white_model_url: str, prompt: str,
|
||
title: str = "") -> Dict[str, Any]:
|
||
"""
|
||
3D纹理生成
|
||
|
||
Args:
|
||
white_model_url: 白模图片URL
|
||
prompt: 纹理描述
|
||
title: 作品标题
|
||
"""
|
||
return self._generate(
|
||
model_type=self.MODEL_TYPE_TEXTURE,
|
||
title=title,
|
||
text=prompt,
|
||
image_list=[white_model_url]
|
||
)
|
||
|
||
# ========== 3D智能拓扑 ==========
|
||
|
||
def generate_lowpoly(self, model_url: str, face_count: int = 5000,
|
||
topology_format: str = "glb",
|
||
title: str = "") -> Dict[str, Any]:
|
||
"""
|
||
3D智能拓扑(减面)
|
||
|
||
Args:
|
||
model_url: 模型图片URL
|
||
face_count: 目标面数(5000/18000/30000)
|
||
topology_format: 输出格式(glb/obj)
|
||
title: 作品标题
|
||
"""
|
||
return self._generate(
|
||
model_type=self.MODEL_TYPE_LOWPOLY,
|
||
title=title,
|
||
image_list=[model_url],
|
||
enable_low_poly=True,
|
||
face_count=face_count,
|
||
topology_format=topology_format
|
||
)
|
||
|
||
# ========== 资源上传 ==========
|
||
|
||
def get_upload_info(self, filename: str) -> Dict[str, Any]:
|
||
"""获取上传凭证"""
|
||
return self._make_request("POST", "/resource/genUploadInfo", data={
|
||
"fileName": filename
|
||
})
|
||
|
||
def review_resource(self, resource_url: str, scene_type: str = "playGround3D-2.0",
|
||
resource_type: str = "image", text: str = "") -> Dict[str, Any]:
|
||
"""资源审核"""
|
||
return self._make_request("POST", "/resource/review", data={
|
||
"sceneType": scene_type,
|
||
"text": text,
|
||
"resourceType": resource_type,
|
||
"resourceUrl": resource_url
|
||
})
|
||
|
||
# ========== 分享 ==========
|
||
|
||
def create_share(self, creation_id: str, platform: str = "3dPlayground") -> Dict[str, Any]:
|
||
"""创建分享"""
|
||
return self._make_request("POST", "/share", data={
|
||
"contentType": "creation",
|
||
"contentId": creation_id,
|
||
"sharedContent": "",
|
||
"platform": platform
|
||
})
|
||
|
||
# ========== 配置 ==========
|
||
|
||
def get_config(self) -> Dict[str, Any]:
|
||
"""获取全局配置"""
|
||
return self._make_request("GET", "/config")
|
||
|
||
def get_action_templates(self) -> Dict[str, Any]:
|
||
"""获取动画动作模板"""
|
||
return self._make_request("GET", "/workflow/action/templates")
|
||
|
||
# ========== 轮询等待 ==========
|
||
|
||
def wait_for_completion(self, creation_id: str, timeout: int = 600,
|
||
poll_interval: int = 5) -> Dict[str, Any]:
|
||
"""
|
||
等待生成完成
|
||
|
||
Args:
|
||
creation_id: 创作ID
|
||
timeout: 超时时间(秒)
|
||
poll_interval: 轮询间隔(秒)
|
||
"""
|
||
start_time = time.time()
|
||
while time.time() - start_time < timeout:
|
||
status = self.get_generation_status(creation_id)
|
||
|
||
# Handle both wrapped {code, data} and flat response formats
|
||
if "code" in status:
|
||
data = status.get("data", {})
|
||
else:
|
||
data = status
|
||
state = data.get("status")
|
||
|
||
if state == "success":
|
||
return status
|
||
elif state == "fail":
|
||
raise Exception(f"Generation failed: {data}")
|
||
|
||
progress = data.get("progress", 0)
|
||
print(f"State: {state}, progress: {progress}%")
|
||
|
||
time.sleep(poll_interval)
|
||
|
||
raise TimeoutError(f"Generation timeout after {timeout} seconds")
|
||
|
||
|
||
from .config import get_cookie_path
|
||
|
||
def load_cookies_from_file(filepath: Optional[str] = None) -> str:
|
||
"""从文件加载Cookie,默认读取用户配置目录的 cookies.txt"""
|
||
path = filepath or str(get_cookie_path())
|
||
with open(path, 'r', encoding='utf-8') as f:
|
||
return f.read().strip()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
cookies = load_cookies_from_file(sys.argv[1] if len(sys.argv) > 1 else None)
|
||
api = Hunyuan3DAPI(cookies)
|
||
|
||
# 测试配额查询
|
||
print("查询配额...")
|
||
quota = api.get_quota_info()
|
||
print(json.dumps(quota, indent=2, ensure_ascii=False))
|
||
|
||
# 测试获取配置
|
||
print("\n获取配置...")
|
||
config = api.get_config()
|
||
print(f"Config keys: {list(config.keys())}")
|