Files
hy3d/hunyuan3dweb/api_complete.py
T

568 lines
19 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 完整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())}")