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:
@@ -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
|
||||||
|
|||||||
@@ -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` | 空气墙(碰撞体) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 技术细节
|
## 技术细节
|
||||||
|
|||||||
@@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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❌ 生成失败")
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user