From ad3c86b8ba8a6039769ef5ded19f04450d735b57 Mon Sep 17 00:00:00 2001 From: KawasakiAkasei Date: Wed, 27 May 2026 11:59:48 +0800 Subject: [PATCH] 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 --- README_REVERSE_ENGINEERING.md | 31 ++++++++++++ README_REVERSE_ENGINEERING_CN.md | 31 ++++++++++++ hunyuan3dweb/api.py | 80 +++++++++++++++++++++++++++++++ hunyuan3dweb/api_complete.py | 80 +++++++++++++++++++++++++++++++ hunyuan3dweb/browser/generator.py | 26 ++++++---- hunyuan3dweb/cli.py | 22 +++++++++ 6 files changed, 261 insertions(+), 9 deletions(-) diff --git a/README_REVERSE_ENGINEERING.md b/README_REVERSE_ENGINEERING.md index d424f2e..936e0dc 100644 --- a/README_REVERSE_ENGINEERING.md +++ b/README_REVERSE_ENGINEERING.md @@ -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 + +# Download a specific format (default: glb) +hunyuan3dweb download --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 diff --git a/README_REVERSE_ENGINEERING_CN.md b/README_REVERSE_ENGINEERING_CN.md index f494563..ded3a42 100644 --- a/README_REVERSE_ENGINEERING_CN.md +++ b/README_REVERSE_ENGINEERING_CN.md @@ -131,6 +131,16 @@ api = Hunyuan3DAPIComplete() result = api.generate_from_image(resource_url, title="我的模型") ``` +### 4. CLI 下载模型 + +```bash +# 列出某个创作所有可用的下载格式 +hunyuan3dweb formats + +# 下载指定格式(默认 glb) +hunyuan3dweb download --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` | 空气墙(碰撞体) | + --- ## 技术细节 diff --git a/hunyuan3dweb/api.py b/hunyuan3dweb/api.py index 952232b..a995818 100644 --- a/hunyuan3dweb/api.py +++ b/hunyuan3dweb/api.py @@ -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]: """ diff --git a/hunyuan3dweb/api_complete.py b/hunyuan3dweb/api_complete.py index 68b7aa3..b1d6e8b 100644 --- a/hunyuan3dweb/api_complete.py +++ b/hunyuan3dweb/api_complete.py @@ -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={ diff --git a/hunyuan3dweb/browser/generator.py b/hunyuan3dweb/browser/generator.py index fe7e97b..a48cfb2 100644 --- a/hunyuan3dweb/browser/generator.py +++ b/hunyuan3dweb/browser/generator.py @@ -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❌ 生成失败") diff --git a/hunyuan3dweb/cli.py b/hunyuan3dweb/cli.py index e712c14..248e76f 100644 --- a/hunyuan3dweb/cli.py +++ b/hunyuan3dweb/cli.py @@ -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__":