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")
```
### 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
@@ -150,6 +160,27 @@ result = api.generate_from_image(resource_url, title="My Model")
| Global config | GET | `/config` | 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

View File

@@ -131,6 +131,16 @@ api = Hunyuan3DAPIComplete()
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 端点
@@ -150,6 +160,27 @@ result = api.generate_from_image(resource_url, title="我的模型")
| 全局配置 | GET | `/config` | 查询参数 |
| 动画模板 | 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 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"
@@ -17,6 +19,24 @@ 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客户端
@@ -180,6 +200,66 @@ class Hunyuan3DAPI:
"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]:
"""

View File

@@ -4,10 +4,12 @@
支持所有生成模式图生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"
@@ -47,6 +49,24 @@ class Hunyuan3DAPI:
TOPO_MEDIUM = 18000
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):
self.session = requests.Session()
self.session.headers.update({
@@ -124,6 +144,66 @@ class Hunyuan3DAPI:
"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]:
"""取消生成任务"""
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()
# 用于存储结果
result = {"creationsId": None, "status": None, "modelUrl": None}
result = {"creationsId": None, "status": None, "urlResult": {}}
# 监听响应
def handle_response(response):
@@ -79,7 +79,7 @@ def generate_3d(image_path, wait_for_complete=False, timeout=300):
if "creationsId" in body:
result["creationsId"] = body["creationsId"]
print(f"✅ 生成已触发creationsId: {body['creationsId']}")
except:
except Exception:
pass
elif "creations/detail" in url and response.status == 200:
try:
@@ -87,9 +87,13 @@ def generate_3d(image_path, wait_for_complete=False, timeout=300):
result["status"] = body.get("status")
if "result" in body and isinstance(body["result"], list) and len(body["result"]) > 0:
model_data = body["result"][0]
if "modelUrl" in model_data:
result["modelUrl"] = model_data["modelUrl"]
except:
url_result = model_data.get("urlResult", {})
if url_result:
result["urlResult"] = {
k: v for k, v in url_result.items()
if v not in (None, "", {})
}
except Exception:
pass
page.on("response", handle_response)
@@ -147,12 +151,16 @@ def generate_3d(image_path, wait_for_complete=False, timeout=300):
last_status = status
if status == "success":
# 提取模型URL
# 提取所有可用格式URL
if "result" in status_result and len(status_result["result"]) > 0:
model_data = status_result["result"][0]
result["modelUrl"] = model_data.get("modelUrl")
result["previewUrl"] = model_data.get("previewUrl")
print(f"\n✅ 生成完成!")
url_result = model_data.get("urlResult", {})
if url_result:
result["urlResult"] = {
k: v for k, v in url_result.items()
if v not in (None, "", {})
}
print(f"\n✅ 生成完成!可用格式: {', '.join(result['urlResult'].keys())}")
break
elif status == "fail":
print(f"\n❌ 生成失败")

View File

@@ -31,6 +31,18 @@ def main():
status_parser.add_argument("creation_id", help="创作ID")
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()
if not args.command:
@@ -49,6 +61,16 @@ def main():
print(json.dumps(result, indent=2, ensure_ascii=False))
elif args.command == "status":
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__":