Files
hy3d/hunyuan3dweb/api.py
T
Claude bf4e1e5755 feat: add pure Python COS upload and improve login detection
- feat(cos): add cos_upload.py for direct file upload without browser
  - implements COS V1 signature algorithm with temporary credentials
  - upload_image() pipeline: get_upload_info → sign → PUT to COS
- feat(api): auto-load cookies from file when cookies arg is omitted
  - both Hunyuan3DAPI and Hunyuan3DAPIComplete now fall back to
    ~/.config/hunyuan3dweb/cookies.txt automatically
- fix(login): strengthen login-state detection using both URL and DOM
  - checks "login" not in page.url AND no login button on page
- docs: update README / README_CN with COS upload examples

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:47:11 +08:00

252 lines
8.2 KiB
Python

#!/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"
})
try:
cookie_value = cookies if cookies is not None else load_cookies_from_file()
except FileNotFoundError:
cookie_value = None
if cookie_value:
self.set_cookies(cookie_value)
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))