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
This commit is contained in:
KawasakiAkasei
2026-05-27 11:59:48 +08:00
parent 58ab0d6655
commit ad3c86b8ba
6 changed files with 261 additions and 9 deletions

View File

@@ -131,6 +131,16 @@ api = Hunyuan3DAPIComplete()
result = api.generate_from_image(resource_url, title="My Model") result = api.generate_from_image(resource_url, title="My Model")
``` ```
### 4. CLI Download
```bash
# List available formats for a creation
hunyuan3dweb formats <creation_id>
# Download a specific format (default: glb)
hunyuan3dweb download <creation_id> --format glb -o model.glb
```
--- ---
## API Endpoints ## API Endpoints
@@ -150,6 +160,27 @@ result = api.generate_from_image(resource_url, title="My Model")
| Global config | GET | `/config` | Query params | | Global config | GET | `/config` | Query params |
| Animation templates | GET | `/workflow/action/templates` | Query params | | Animation templates | GET | `/workflow/action/templates` | Query params |
### Model Download Formats
The `urlResult` field in `/creations/detail` contains up to 14 format keys:
| Key | Description |
|-----|-------------|
| `glb` | GLB binary model with PBR textures |
| `obj` | OBJ model (usually zipped) |
| `mtl` | MTL material file |
| `obj_url` | Standalone OBJ file URL |
| `geometryGlb` | Geometry-only GLB (no materials) |
| `textureGlb` | GLB texture pack |
| `textureObj` | OBJ texture pack |
| `image_url` | Preview / thumbnail image |
| `pbrImage` | Combined PBR texture map |
| `pbrMetallicImage` | PBR metallic map |
| `pbrRoughnessImage` | PBR roughness map |
| `pbrNormalImage` | PBR normal map |
| `invisible_wall` | Invisible collision wall |
| `air_wall` | Air wall (collision body) |
--- ---
## Technical Details ## Technical Details

View File

@@ -131,6 +131,16 @@ api = Hunyuan3DAPIComplete()
result = api.generate_from_image(resource_url, title="我的模型") result = api.generate_from_image(resource_url, title="我的模型")
``` ```
### 4. CLI 下载模型
```bash
# 列出某个创作所有可用的下载格式
hunyuan3dweb formats <creation_id>
# 下载指定格式(默认 glb
hunyuan3dweb download <creation_id> --format glb -o model.glb
```
--- ---
## API 端点 ## API 端点
@@ -150,6 +160,27 @@ result = api.generate_from_image(resource_url, title="我的模型")
| 全局配置 | GET | `/config` | 查询参数 | | 全局配置 | GET | `/config` | 查询参数 |
| 动画模板 | GET | `/workflow/action/templates` | 查询参数 | | 动画模板 | GET | `/workflow/action/templates` | 查询参数 |
### 模型下载格式
`/creations/detail` 返回的 `urlResult` 字段最多包含 14 种格式键:
| 键名 | 说明 |
|-----|------|
| `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` | 空气墙(碰撞体) |
--- ---
## 技术细节 ## 技术细节

View File

@@ -7,7 +7,9 @@
import requests import requests
import json import json
import time import time
import os
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from urllib.parse import urlparse, unquote
from .sign import sign, sign_with_custom_nonce from .sign import sign, sign_with_custom_nonce
BASE_URL = "https://3d.hunyuan.tencent.com" BASE_URL = "https://3d.hunyuan.tencent.com"
@@ -17,6 +19,24 @@ API_BASE = f"{BASE_URL}/api/3d"
class Hunyuan3DAPI: class Hunyuan3DAPI:
"""腾讯混元3D API客户端""" """腾讯混元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): def __init__(self, cookies: Optional[str] = None):
""" """
初始化API客户端 初始化API客户端
@@ -180,6 +200,66 @@ class Hunyuan3DAPI:
"creationsId": creation_id "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, def wait_for_completion(self, creation_id: str, timeout: int = 600,
poll_interval: int = 5) -> Dict[str, Any]: poll_interval: int = 5) -> Dict[str, Any]:
""" """

View File

@@ -4,10 +4,12 @@
支持所有生成模式图生3D、文生3D、草图生3D、动画生成、纹理生成、智能拓扑 支持所有生成模式图生3D、文生3D、草图生3D、动画生成、纹理生成、智能拓扑
""" """
import os
import requests import requests
import json import json
import time import time
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from urllib.parse import urlparse, unquote
from .sign import sign from .sign import sign
BASE_URL = "https://3d.hunyuan.tencent.com" BASE_URL = "https://3d.hunyuan.tencent.com"
@@ -47,6 +49,24 @@ class Hunyuan3DAPI:
TOPO_MEDIUM = 18000 TOPO_MEDIUM = 18000
TOPO_HIGH = 30000 TOPO_HIGH = 30000
# 模型下载格式键名与腾讯混元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): def __init__(self, cookies: Optional[str] = None):
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({ self.session.headers.update({
@@ -124,6 +144,66 @@ class Hunyuan3DAPI:
"creationsId": creation_id "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 cancel_generation(self, creation_id: str) -> Dict[str, Any]: def cancel_generation(self, creation_id: str) -> Dict[str, Any]:
"""取消生成任务""" """取消生成任务"""
return self._make_request("POST", "/creations/cancel", data={ return self._make_request("POST", "/creations/cancel", data={

View File

@@ -68,7 +68,7 @@ def generate_3d(image_path, wait_for_complete=False, timeout=300):
page = context.new_page() page = context.new_page()
# 用于存储结果 # 用于存储结果
result = {"creationsId": None, "status": None, "modelUrl": None} result = {"creationsId": None, "status": None, "urlResult": {}}
# 监听响应 # 监听响应
def handle_response(response): def handle_response(response):
@@ -79,7 +79,7 @@ def generate_3d(image_path, wait_for_complete=False, timeout=300):
if "creationsId" in body: if "creationsId" in body:
result["creationsId"] = body["creationsId"] result["creationsId"] = body["creationsId"]
print(f"✅ 生成已触发creationsId: {body['creationsId']}") print(f"✅ 生成已触发creationsId: {body['creationsId']}")
except: except Exception:
pass pass
elif "creations/detail" in url and response.status == 200: elif "creations/detail" in url and response.status == 200:
try: try:
@@ -87,9 +87,13 @@ def generate_3d(image_path, wait_for_complete=False, timeout=300):
result["status"] = body.get("status") result["status"] = body.get("status")
if "result" in body and isinstance(body["result"], list) and len(body["result"]) > 0: if "result" in body and isinstance(body["result"], list) and len(body["result"]) > 0:
model_data = body["result"][0] model_data = body["result"][0]
if "modelUrl" in model_data: url_result = model_data.get("urlResult", {})
result["modelUrl"] = model_data["modelUrl"] if url_result:
except: result["urlResult"] = {
k: v for k, v in url_result.items()
if v not in (None, "", {})
}
except Exception:
pass pass
page.on("response", handle_response) page.on("response", handle_response)
@@ -147,12 +151,16 @@ def generate_3d(image_path, wait_for_complete=False, timeout=300):
last_status = status last_status = status
if status == "success": if status == "success":
# 提取模型URL # 提取所有可用格式URL
if "result" in status_result and len(status_result["result"]) > 0: if "result" in status_result and len(status_result["result"]) > 0:
model_data = status_result["result"][0] model_data = status_result["result"][0]
result["modelUrl"] = model_data.get("modelUrl") url_result = model_data.get("urlResult", {})
result["previewUrl"] = model_data.get("previewUrl") if url_result:
print(f"\n✅ 生成完成!") result["urlResult"] = {
k: v for k, v in url_result.items()
if v not in (None, "", {})
}
print(f"\n✅ 生成完成!可用格式: {', '.join(result['urlResult'].keys())}")
break break
elif status == "fail": elif status == "fail":
print(f"\n❌ 生成失败") print(f"\n❌ 生成失败")

View File

@@ -31,6 +31,18 @@ def main():
status_parser.add_argument("creation_id", help="创作ID") status_parser.add_argument("creation_id", help="创作ID")
status_parser.add_argument("--cookies", "-c", default=_DEFAULT_COOKIE, help="Cookie文件路径") status_parser.add_argument("--cookies", "-c", default=_DEFAULT_COOKIE, help="Cookie文件路径")
formats_parser = subparsers.add_parser("formats", help="列出创作可用下载格式")
formats_parser.add_argument("creation_id", help="创作ID")
formats_parser.add_argument("--cookies", "-c", default=_DEFAULT_COOKIE, help="Cookie文件路径")
download_parser = subparsers.add_parser("download", help="下载指定格式模型")
download_parser.add_argument("creation_id", help="创作ID")
download_parser.add_argument("--format", "-f", default="glb",
help="格式键名 (默认: glb)")
download_parser.add_argument("--output", "-o", default=None,
help="本地保存路径 (默认自动推断)")
download_parser.add_argument("--cookies", "-c", default=_DEFAULT_COOKIE, help="Cookie文件路径")
args = parser.parse_args() args = parser.parse_args()
if not args.command: if not args.command:
@@ -49,6 +61,16 @@ def main():
print(json.dumps(result, indent=2, ensure_ascii=False)) print(json.dumps(result, indent=2, ensure_ascii=False))
elif args.command == "status": elif args.command == "status":
print(json.dumps(api.get_generation_status(args.creation_id), indent=2, ensure_ascii=False)) print(json.dumps(api.get_generation_status(args.creation_id), indent=2, ensure_ascii=False))
elif args.command == "formats":
urls = api.get_model_urls(args.creation_id)
if not urls:
print("暂无可用格式,可能生成未完成或失败。", file=sys.stderr)
sys.exit(1)
for key, url in urls.items():
print(f"{key}: {url}")
elif args.command == "download":
path = api.download_model(args.creation_id, args.format, args.output)
print(f"已下载: {path}")
if __name__ == "__main__": if __name__ == "__main__":