- Add get_model_urls() and download_model() to api.py and api_complete.py supporting all 14 discovered urlResult format keys (glb, obj, pbr maps, etc.) - Update generator.py to extract full urlResult dict instead of just modelUrl - Add CLI subcommands: formats (list available formats) and download (fetch by key) - Update reverse engineering docs with complete format key table and CLI examples
332 lines
11 KiB
Python
332 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
腾讯混元3D纯Python API客户端
|
|
无需浏览器,直接HTTP调用
|
|
"""
|
|
|
|
import requests
|
|
import json
|
|
import time
|
|
import os
|
|
from typing import Optional, Dict, Any
|
|
from urllib.parse import urlparse, unquote
|
|
from .sign import sign, sign_with_custom_nonce
|
|
|
|
BASE_URL = "https://3d.hunyuan.tencent.com"
|
|
API_BASE = f"{BASE_URL}/api/3d"
|
|
|
|
|
|
class Hunyuan3DAPI:
|
|
"""腾讯混元3D API客户端"""
|
|
|
|
# 模型下载格式键名(与腾讯混元3D API返回的 urlResult 字段对应)
|
|
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", # 空气墙(碰撞体)
|
|
]
|
|
|
|
def __init__(self, cookies: Optional[str] = None):
|
|
"""
|
|
初始化API客户端
|
|
|
|
Args:
|
|
cookies: 浏览器Cookie字符串,包含hunyuan_user和hunyuan_token
|
|
"""
|
|
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):
|
|
"""设置Cookie"""
|
|
self.session.headers["Cookie"] = cookies
|
|
|
|
def set_cookie_dict(self, cookie_dict: Dict[str, str]):
|
|
"""从字典设置Cookie"""
|
|
cookie_str = "; ".join(f"{k}={v}" for k, v in cookie_dict.items())
|
|
self.session.headers["Cookie"] = cookie_str
|
|
|
|
def _make_request(self, method: str, endpoint: str, params: Optional[Dict] = None,
|
|
data: Optional[Dict] = None) -> Dict[str, Any]:
|
|
"""
|
|
发送带签名的API请求
|
|
|
|
注意: 腾讯混元3D的签名只包含URL查询参数,不包含请求体数据
|
|
"""
|
|
url = f"{API_BASE}{endpoint}"
|
|
|
|
# 签名只针对查询参数,不包含请求体
|
|
sign_params = {}
|
|
if params:
|
|
sign_params.update(params)
|
|
|
|
# 生成签名
|
|
signed_params = sign(sign_params)
|
|
|
|
# 分离签名参数
|
|
timestamp = signed_params.pop("timestamp")
|
|
nonce = signed_params.pop("nonce")
|
|
sign_value = signed_params.pop("sign")
|
|
|
|
# 构建最终URL查询参数(只包含签名相关)
|
|
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:
|
|
# POST请求:签名在URL参数中,数据在body中
|
|
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 generate_3d(self, image_url: str, scene_type: str = "playGround3D-2.0",
|
|
model_type: str = "image2ModelV3.1", title: str = "",
|
|
enable_pbr: bool = True, enable_low_poly: bool = False,
|
|
face_count: int = 1500000, count: int = 1) -> Dict[str, Any]:
|
|
"""
|
|
生成3D模型
|
|
|
|
Args:
|
|
image_url: 图片URL(需要先上传到腾讯云COS)
|
|
scene_type: 场景类型
|
|
model_type: 模型类型
|
|
title: 标题
|
|
enable_pbr: 是否启用PBR材质
|
|
enable_low_poly: 是否启用低多边形
|
|
face_count: 面数
|
|
count: 生成数量
|
|
|
|
Returns:
|
|
包含creationsId的响应
|
|
"""
|
|
data = {
|
|
"sceneType": scene_type,
|
|
"count": count,
|
|
"modelType": model_type,
|
|
"title": title,
|
|
"style": "",
|
|
"imageList": [image_url],
|
|
"enable_pbr": enable_pbr,
|
|
"enableLowPoly": enable_low_poly,
|
|
"faceCount": face_count
|
|
}
|
|
|
|
return self._make_request("POST", "/creations/generations", data=data)
|
|
|
|
def generate_text(self, prompt: str, scene_type: str = "playGround3D-2.0",
|
|
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: 文本描述/提示词
|
|
scene_type: 场景类型
|
|
title: 作品标题
|
|
style: 纹理风格
|
|
enable_pbr: 是否启用PBR材质
|
|
enable_low_poly: 是否低多边形
|
|
face_count: 面数
|
|
count: 生成数量,文生3D固定为4
|
|
|
|
Returns:
|
|
包含creationsId的响应
|
|
"""
|
|
data = {
|
|
"sceneType": scene_type,
|
|
"count": count,
|
|
"modelType": "text2ModelV3.1",
|
|
"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)
|
|
|
|
def get_generation_status(self, creation_id: str) -> Dict[str, Any]:
|
|
"""获取生成状态"""
|
|
return self._make_request("GET", "/creations/detail", params={
|
|
"creationsId": creation_id
|
|
})
|
|
|
|
def get_model_urls(self, creation_id: str) -> Dict[str, Optional[str]]:
|
|
"""
|
|
获取指定创作所有可用格式的下载URL
|
|
|
|
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", {})
|
|
return {
|
|
key: val
|
|
for key in self.MODEL_FORMAT_KEYS
|
|
if (val := url_result.get(key)) and val not in (None, "", {})
|
|
}
|
|
|
|
def download_model(self, creation_id: str, format_key: str = "glb",
|
|
output_path: Optional[str] = None) -> str:
|
|
"""
|
|
下载指定格式的模型文件
|
|
|
|
Args:
|
|
creation_id: 创作ID
|
|
format_key: 格式键名,如 'glb' / 'obj' / 'pbrNormalImage' 等
|
|
output_path: 本地保存路径,为空时自动从URL推断文件名
|
|
|
|
Returns:
|
|
实际保存的本地文件路径
|
|
"""
|
|
urls = self.get_model_urls(creation_id)
|
|
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 wait_for_completion(self, creation_id: str, timeout: int = 600,
|
|
poll_interval: int = 5) -> Dict[str, Any]:
|
|
"""
|
|
等待生成完成
|
|
|
|
Args:
|
|
creation_id: 创作ID
|
|
timeout: 超时时间(秒)
|
|
poll_interval: 轮询间隔(秒)
|
|
|
|
Returns:
|
|
完成的创作信息
|
|
"""
|
|
start_time = time.time()
|
|
last_error = None
|
|
while time.time() - start_time < timeout:
|
|
try:
|
|
status = self.get_generation_status(creation_id)
|
|
except Exception as e:
|
|
last_error = e
|
|
print(f"查询状态失败: {e},将在 {poll_interval} 秒后重试...")
|
|
time.sleep(poll_interval)
|
|
continue
|
|
|
|
# Handle both wrapped {code, data} and flat response formats
|
|
if "code" in status:
|
|
data = status.get("data", {})
|
|
else:
|
|
data = status
|
|
state = data.get("status") or data.get("state")
|
|
|
|
if state == "success":
|
|
return status
|
|
elif state == "fail":
|
|
raise Exception(f"Generation failed: {data.get('errorMsg', 'Unknown error')}")
|
|
|
|
print(f"State: {state}, progress: {data.get('progress', 0)}%")
|
|
|
|
time.sleep(poll_interval)
|
|
|
|
if last_error:
|
|
raise TimeoutError(f"Generation timeout after {timeout} seconds. Last error: {last_error}")
|
|
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
|
|
|
|
if len(sys.argv) > 1 and sys.argv[1] == "--cookie-string":
|
|
cookies = sys.argv[2]
|
|
else:
|
|
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))
|