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>
This commit is contained in:
15
README.md
15
README.md
@@ -87,7 +87,7 @@ if result["status"] == "success":
|
||||
|
||||
### 2. Image-to-3D (Local File)
|
||||
|
||||
For local image files, use the browser automation tool. The pure API client cannot upload images directly because Tencent COS requires browser-side decryption of temporary credentials.
|
||||
For local image files, you can use either the browser automation tool or the pure Python COS uploader to obtain a `resourceUrl` and then call the API.
|
||||
|
||||
```bash
|
||||
hunyuan3dweb-generate /path/to/image.png wait
|
||||
@@ -102,6 +102,18 @@ result = generate_3d("/path/to/image.png", wait_for_complete=True)
|
||||
print(f"Model URL: {result.get('modelUrl')}")
|
||||
```
|
||||
|
||||
Or via pure Python upload:
|
||||
|
||||
```python
|
||||
from hunyuan3dweb.cos_upload import upload_image
|
||||
from hunyuan3dweb import Hunyuan3DAPIComplete
|
||||
|
||||
resource_url = upload_image("/path/to/image.png")
|
||||
|
||||
api = Hunyuan3DAPIComplete()
|
||||
result = api.generate_from_image(resource_url, title="My Model")
|
||||
```
|
||||
|
||||
### 3. Image-to-3D (API with Existing resourceUrl)
|
||||
|
||||
If you already have a `resourceUrl` (e.g. from a previous browser upload), use the pure API client:
|
||||
@@ -283,6 +295,7 @@ hunyuan3dweb-sniffer
|
||||
| `hunyuan3dweb/api.py` | Basic API client (image2model, text2model) |
|
||||
| `hunyuan3dweb/api_complete.py` | Full API client (all generation modes) |
|
||||
| `hunyuan3dweb/sign.py` | Tencent Hunyuan 3D signing algorithm |
|
||||
| `hunyuan3dweb/cos_upload.py` | Pure Python COS upload helper |
|
||||
| `hunyuan3dweb/config.py` | User config path management |
|
||||
| `hunyuan3dweb/cli.py` | CLI entry point |
|
||||
| `hunyuan3dweb/browser/login.py` | Browser login tool |
|
||||
|
||||
15
README_CN.md
15
README_CN.md
@@ -87,7 +87,7 @@ if result["status"] == "success":
|
||||
|
||||
### 2. 图生3D(本地文件)
|
||||
|
||||
本地图片文件需通过浏览器自动化工具上传。纯 API 客户端无法直接上传图片,因为腾讯 COS 临时凭证需要浏览器端解密。
|
||||
本地图片文件可通过浏览器自动化工具上传,也可使用纯 Python 的 COS 上传模块先拿到 `resourceUrl` 再走 API。
|
||||
|
||||
```bash
|
||||
hunyuan3dweb-generate /path/to/image.png wait
|
||||
@@ -102,6 +102,18 @@ result = generate_3d("/path/to/image.png", wait_for_complete=True)
|
||||
print(f"模型地址: {result.get('modelUrl')}")
|
||||
```
|
||||
|
||||
或通过纯 Python 上传:
|
||||
|
||||
```python
|
||||
from hunyuan3dweb.cos_upload import upload_image
|
||||
from hunyuan3dweb import Hunyuan3DAPIComplete
|
||||
|
||||
resource_url = upload_image("/path/to/image.png")
|
||||
|
||||
api = Hunyuan3DAPIComplete()
|
||||
result = api.generate_from_image(resource_url, title="我的模型")
|
||||
```
|
||||
|
||||
### 3. 图生3D(API,已有 resourceUrl)
|
||||
|
||||
如果你已经有 `resourceUrl`(例如之前通过浏览器上传过),可直接调用 API:
|
||||
@@ -283,6 +295,7 @@ hunyuan3dweb-sniffer
|
||||
| `hunyuan3dweb/api.py` | 基础 API 客户端(图生3D、文生3D) |
|
||||
| `hunyuan3dweb/api_complete.py` | 完整 API 客户端(所有生成模式) |
|
||||
| `hunyuan3dweb/sign.py` | 腾讯混元3D 签名算法 |
|
||||
| `hunyuan3dweb/cos_upload.py` | 纯 Python COS 上传工具 |
|
||||
| `hunyuan3dweb/config.py` | 用户配置路径管理 |
|
||||
| `hunyuan3dweb/cli.py` | CLI 入口 |
|
||||
| `hunyuan3dweb/browser/login.py` | 浏览器登录工具 |
|
||||
|
||||
@@ -34,8 +34,12 @@ class Hunyuan3DAPI:
|
||||
"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)
|
||||
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"""
|
||||
|
||||
@@ -57,8 +57,12 @@ class Hunyuan3DAPI:
|
||||
"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)
|
||||
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):
|
||||
self.session.headers["Cookie"] = cookies
|
||||
|
||||
@@ -56,9 +56,12 @@ def main():
|
||||
page.goto("https://3d.hunyuan.tencent.com/", timeout=60000)
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# 检查是否已经是登录状态(没有登录按钮说明已登录)
|
||||
# 检查是否已经是登录状态:URL 不含 login 且页面上没有登录按钮
|
||||
is_login_page = "login" in page.url
|
||||
login_btn = page.locator("button").filter(has_text="登录").first
|
||||
if login_btn.count() == 0:
|
||||
has_login_btn = login_btn.count() > 0
|
||||
|
||||
if not is_login_page and not has_login_btn:
|
||||
print("检测到已有登录状态,无需重新登录。")
|
||||
print(f"当前页面: {page.url}")
|
||||
_extract_and_save_cookies(context)
|
||||
@@ -70,6 +73,10 @@ def main():
|
||||
login_btn.click()
|
||||
page.locator("text=邮箱").wait_for(state="visible", timeout=10000)
|
||||
|
||||
# 定位元素
|
||||
email_input = page.locator('input[placeholder="请输入邮箱地址"]')
|
||||
code_input = page.locator('input[type="number"][placeholder="请输入邮箱验证码"]')
|
||||
|
||||
# 切换到邮箱登录
|
||||
email_tab = page.locator("text=邮箱").first
|
||||
if email_tab.count() > 0:
|
||||
@@ -78,10 +85,6 @@ def main():
|
||||
else:
|
||||
print("未找到邮箱登录选项")
|
||||
return
|
||||
|
||||
# 定位元素
|
||||
email_input = page.locator('input[placeholder="请输入邮箱地址"]')
|
||||
code_input = page.locator('input[type="number"][placeholder="请输入邮箱验证码"]')
|
||||
send_code_btn = page.locator("a.hyc-email-login__send-code")
|
||||
login_submit_btn = page.locator("button.hyc-email-login__btn")
|
||||
checkbox = page.locator(".t-checkbox__former")
|
||||
|
||||
157
hunyuan3dweb/cos_upload.py
Normal file
157
hunyuan3dweb/cos_upload.py
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pure Python COS upload for Tencent Hunyuan 3D.
|
||||
Uses the API to get temporary credentials, then directly uploads to COS.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
from . import Hunyuan3DAPIComplete
|
||||
from .api import load_cookies_from_file
|
||||
|
||||
|
||||
def _cam_safe_url_encode(s):
|
||||
return quote(str(s), safe="")
|
||||
|
||||
|
||||
def _get_object_keys(obj):
|
||||
return sorted(obj.keys(), key=lambda x: x.lower())
|
||||
|
||||
|
||||
def _obj2str(obj, lower_case_key=False):
|
||||
items = []
|
||||
for key in _get_object_keys(obj):
|
||||
val = obj[key]
|
||||
if val is None:
|
||||
val = ""
|
||||
else:
|
||||
val = str(val)
|
||||
encoded_key = _cam_safe_url_encode(key).lower() if lower_case_key else _cam_safe_url_encode(key)
|
||||
encoded_val = _cam_safe_url_encode(val) or ""
|
||||
items.append(f"{encoded_key}={encoded_val}")
|
||||
return "&".join(items)
|
||||
|
||||
|
||||
def _cos_v1_auth(method, pathname, query_params, headers, secret_id, secret_key, key_time):
|
||||
"""Generate COS V1 authorization header."""
|
||||
sign_key = hmac.new(secret_key.encode("utf-8"), key_time.encode("utf-8"), hashlib.sha1).hexdigest()
|
||||
|
||||
query_str = _obj2str(query_params, True)
|
||||
headers_str = _obj2str(headers, True)
|
||||
format_string = f"{method}\n{pathname}\n{query_str}\n{headers_str}\n"
|
||||
|
||||
format_string_sha1 = hashlib.sha1(format_string.encode("utf-8")).hexdigest()
|
||||
string_to_sign = f"sha1\n{key_time}\n{format_string_sha1}\n"
|
||||
|
||||
signature = hmac.new(sign_key.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha1).hexdigest()
|
||||
|
||||
q_header_list = ";".join(_get_object_keys(headers)).lower()
|
||||
q_url_param_list = ";".join(_get_object_keys(query_params)).lower()
|
||||
|
||||
return "&".join([
|
||||
"q-sign-algorithm=sha1",
|
||||
f"q-ak={secret_id}",
|
||||
f"q-sign-time={key_time}",
|
||||
f"q-key-time={key_time}",
|
||||
f"q-header-list={q_header_list}",
|
||||
f"q-url-param-list={q_url_param_list}",
|
||||
f"q-signature={signature}",
|
||||
])
|
||||
|
||||
|
||||
def _guess_content_type(image_path):
|
||||
content_type, _ = mimetypes.guess_type(str(image_path))
|
||||
return content_type or "application/octet-stream"
|
||||
|
||||
|
||||
def upload_file_to_cos(image_path, upload_info, use_accelerate=True):
|
||||
"""
|
||||
Upload a local file to Tencent COS using temporary credentials from get_upload_info.
|
||||
|
||||
Args:
|
||||
image_path: Local file path
|
||||
upload_info: Response from get_upload_info API
|
||||
use_accelerate: Use COS global accelerate endpoint
|
||||
|
||||
Returns:
|
||||
requests.Response
|
||||
"""
|
||||
bucket = upload_info["bucketName"]
|
||||
region = upload_info["region"]
|
||||
location = upload_info["location"]
|
||||
secret_id = upload_info["encryptTmpSecretId"]
|
||||
secret_key = upload_info["encryptTmpSecretKey"]
|
||||
token = upload_info["encryptToken"]
|
||||
start_time = upload_info["startTime"]
|
||||
expired_time = upload_info["expiredTime"]
|
||||
|
||||
host = f"{bucket}.cos.accelerate.myqcloud.com" if use_accelerate else f"{bucket}.cos.{region}.myqcloud.com"
|
||||
key_time = f"{start_time};{expired_time}"
|
||||
file_size = Path(image_path).stat().st_size
|
||||
content_type = _guess_content_type(image_path)
|
||||
|
||||
headers_for_sign = {
|
||||
"content-length": file_size,
|
||||
"content-type": content_type,
|
||||
"host": host,
|
||||
}
|
||||
|
||||
auth = _cos_v1_auth(
|
||||
method="put",
|
||||
pathname=f"/{location}",
|
||||
query_params={},
|
||||
headers=headers_for_sign,
|
||||
secret_id=secret_id,
|
||||
secret_key=secret_key,
|
||||
key_time=key_time,
|
||||
)
|
||||
|
||||
upload_url = f"https://{host}/{location}"
|
||||
|
||||
with open(image_path, "rb") as f:
|
||||
resp = requests.put(
|
||||
upload_url,
|
||||
data=f,
|
||||
headers={
|
||||
"Authorization": auth,
|
||||
"x-cos-security-token": token,
|
||||
"Content-Type": content_type,
|
||||
"Content-Length": str(file_size),
|
||||
"Host": host,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
def upload_image(image_path, use_accelerate=True):
|
||||
"""
|
||||
Full pipeline: get upload credentials from Hunyuan3D API -> upload to COS.
|
||||
|
||||
Args:
|
||||
image_path: Local image file path
|
||||
use_accelerate: Use COS global accelerate endpoint
|
||||
|
||||
Returns:
|
||||
str: resourceUrl for subsequent 3D generation
|
||||
"""
|
||||
try:
|
||||
cookies = load_cookies_from_file()
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError("Cookies file not found. Please run 'hunyuan3dweb-login' first.") from exc
|
||||
api = Hunyuan3DAPIComplete(cookies=cookies)
|
||||
|
||||
filename = Path(image_path).name
|
||||
upload_info = api.get_upload_info(filename)
|
||||
|
||||
resp = upload_file_to_cos(image_path, upload_info, use_accelerate)
|
||||
resp.raise_for_status()
|
||||
|
||||
return upload_info["resourceUrl"]
|
||||
Reference in New Issue
Block a user