#!/usr/bin/env python3 """ 腾讯混元3D纯Python API客户端 无需浏览器,直接HTTP调用 """ import requests import json import time from typing import Optional, Dict, Any from .sign import sign, sign_with_custom_nonce BASE_URL = "https://3d.hunyuan.tencent.com" API_BASE = f"{BASE_URL}/api/3d" class Hunyuan3DAPI: """腾讯混元3D API客户端""" def __init__(self, cookies: Optional[str] = None): """ 初始化API客户端 Args: cookies: 浏览器Cookie字符串,包含hunyuan_user和hunyuan_token """ 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" }) if cookies: self.set_cookies(cookies) def set_cookies(self, cookies: str): """设置Cookie""" self.session.headers["Cookie"] = cookies def set_cookie_dict(self, cookie_dict: Dict[str, str]): """从字典设置Cookie""" cookie_str = "; ".join(f"{k}={v}" for k, v in cookie_dict.items()) self.session.headers["Cookie"] = cookie_str def _make_request(self, method: str, endpoint: str, params: Optional[Dict] = None, data: Optional[Dict] = None) -> Dict[str, Any]: """ 发送带签名的API请求 注意: 腾讯混元3D的签名只包含URL查询参数,不包含请求体数据 """ url = f"{API_BASE}{endpoint}" # 签名只针对查询参数,不包含请求体 sign_params = {} if params: sign_params.update(params) # 生成签名 signed_params = sign(sign_params) # 分离签名参数 timestamp = signed_params.pop("timestamp") nonce = signed_params.pop("nonce") sign_value = signed_params.pop("sign") # 构建最终URL查询参数(只包含签名相关) 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: # POST请求:签名在URL参数中,数据在body中 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 generate_3d(self, image_url: str, scene_type: str = "playGround3D-2.0", model_type: str = "image2ModelV3.1", title: str = "", enable_pbr: bool = True, enable_low_poly: bool = False, face_count: int = 1500000, count: int = 1) -> Dict[str, Any]: """ 生成3D模型 Args: image_url: 图片URL(需要先上传到腾讯云COS) scene_type: 场景类型 model_type: 模型类型 title: 标题 enable_pbr: 是否启用PBR材质 enable_low_poly: 是否启用低多边形 face_count: 面数 count: 生成数量 Returns: 包含creationsId的响应 """ data = { "sceneType": scene_type, "count": count, "modelType": model_type, "title": title, "style": "", "imageList": [image_url], "enable_pbr": enable_pbr, "enableLowPoly": enable_low_poly, "faceCount": face_count } return self._make_request("POST", "/creations/generations", data=data) def generate_text(self, prompt: str, scene_type: str = "playGround3D-2.0", 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: 文本描述/提示词 scene_type: 场景类型 title: 作品标题 style: 纹理风格 enable_pbr: 是否启用PBR材质 enable_low_poly: 是否低多边形 face_count: 面数 count: 生成数量,文生3D固定为4 Returns: 包含creationsId的响应 """ data = { "sceneType": scene_type, "count": count, "modelType": "text2ModelV3.1", "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) def get_generation_status(self, creation_id: str) -> Dict[str, Any]: """获取生成状态""" return self._make_request("GET", "/creations/detail", params={ "creationsId": creation_id }) 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: 轮询间隔(秒) Returns: 完成的创作信息 """ start_time = time.time() last_error = None while time.time() - start_time < timeout: try: status = self.get_generation_status(creation_id) except Exception as e: last_error = e print(f"查询状态失败: {e},将在 {poll_interval} 秒后重试...") time.sleep(poll_interval) continue # Handle both wrapped {code, data} and flat response formats if "code" in status: data = status.get("data", {}) else: data = status state = data.get("status") or data.get("state") if state == "success": return status elif state == "fail": raise Exception(f"Generation failed: {data.get('errorMsg', 'Unknown error')}") print(f"State: {state}, progress: {data.get('progress', 0)}%") time.sleep(poll_interval) if last_error: raise TimeoutError(f"Generation timeout after {timeout} seconds. Last error: {last_error}") 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 if len(sys.argv) > 1 and sys.argv[1] == "--cookie-string": cookies = sys.argv[2] else: 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))