Files
hunyuan3dweb/hunyuan3dweb/api.py
KawasakiAkasei ad3c86b8ba feat: full format compatibility adaptation for model downloads
- 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
2026-05-27 11:59:48 +08:00

332 lines
11 KiB
Python
Raw 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.
#!/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))