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)
|
### 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
|
```bash
|
||||||
hunyuan3dweb-generate /path/to/image.png wait
|
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')}")
|
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)
|
### 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:
|
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.py` | Basic API client (image2model, text2model) |
|
||||||
| `hunyuan3dweb/api_complete.py` | Full API client (all generation modes) |
|
| `hunyuan3dweb/api_complete.py` | Full API client (all generation modes) |
|
||||||
| `hunyuan3dweb/sign.py` | Tencent Hunyuan 3D signing algorithm |
|
| `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/config.py` | User config path management |
|
||||||
| `hunyuan3dweb/cli.py` | CLI entry point |
|
| `hunyuan3dweb/cli.py` | CLI entry point |
|
||||||
| `hunyuan3dweb/browser/login.py` | Browser login tool |
|
| `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(本地文件)
|
### 2. 图生3D(本地文件)
|
||||||
|
|
||||||
本地图片文件需通过浏览器自动化工具上传。纯 API 客户端无法直接上传图片,因为腾讯 COS 临时凭证需要浏览器端解密。
|
本地图片文件可通过浏览器自动化工具上传,也可使用纯 Python 的 COS 上传模块先拿到 `resourceUrl` 再走 API。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hunyuan3dweb-generate /path/to/image.png wait
|
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')}")
|
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)
|
### 3. 图生3D(API,已有 resourceUrl)
|
||||||
|
|
||||||
如果你已经有 `resourceUrl`(例如之前通过浏览器上传过),可直接调用 API:
|
如果你已经有 `resourceUrl`(例如之前通过浏览器上传过),可直接调用 API:
|
||||||
@@ -283,6 +295,7 @@ hunyuan3dweb-sniffer
|
|||||||
| `hunyuan3dweb/api.py` | 基础 API 客户端(图生3D、文生3D) |
|
| `hunyuan3dweb/api.py` | 基础 API 客户端(图生3D、文生3D) |
|
||||||
| `hunyuan3dweb/api_complete.py` | 完整 API 客户端(所有生成模式) |
|
| `hunyuan3dweb/api_complete.py` | 完整 API 客户端(所有生成模式) |
|
||||||
| `hunyuan3dweb/sign.py` | 腾讯混元3D 签名算法 |
|
| `hunyuan3dweb/sign.py` | 腾讯混元3D 签名算法 |
|
||||||
|
| `hunyuan3dweb/cos_upload.py` | 纯 Python COS 上传工具 |
|
||||||
| `hunyuan3dweb/config.py` | 用户配置路径管理 |
|
| `hunyuan3dweb/config.py` | 用户配置路径管理 |
|
||||||
| `hunyuan3dweb/cli.py` | CLI 入口 |
|
| `hunyuan3dweb/cli.py` | CLI 入口 |
|
||||||
| `hunyuan3dweb/browser/login.py` | 浏览器登录工具 |
|
| `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"
|
"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:
|
try:
|
||||||
self.set_cookies(cookies)
|
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):
|
def set_cookies(self, cookies: str):
|
||||||
"""设置Cookie"""
|
"""设置Cookie"""
|
||||||
|
|||||||
@@ -57,8 +57,12 @@ class Hunyuan3DAPI:
|
|||||||
"Referer": f"{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"
|
"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:
|
try:
|
||||||
self.set_cookies(cookies)
|
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):
|
def set_cookies(self, cookies: str):
|
||||||
self.session.headers["Cookie"] = cookies
|
self.session.headers["Cookie"] = cookies
|
||||||
|
|||||||
@@ -56,9 +56,12 @@ def main():
|
|||||||
page.goto("https://3d.hunyuan.tencent.com/", timeout=60000)
|
page.goto("https://3d.hunyuan.tencent.com/", timeout=60000)
|
||||||
page.wait_for_timeout(2000)
|
page.wait_for_timeout(2000)
|
||||||
|
|
||||||
# 检查是否已经是登录状态(没有登录按钮说明已登录)
|
# 检查是否已经是登录状态:URL 不含 login 且页面上没有登录按钮
|
||||||
|
is_login_page = "login" in page.url
|
||||||
login_btn = page.locator("button").filter(has_text="登录").first
|
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("检测到已有登录状态,无需重新登录。")
|
||||||
print(f"当前页面: {page.url}")
|
print(f"当前页面: {page.url}")
|
||||||
_extract_and_save_cookies(context)
|
_extract_and_save_cookies(context)
|
||||||
@@ -70,6 +73,10 @@ def main():
|
|||||||
login_btn.click()
|
login_btn.click()
|
||||||
page.locator("text=邮箱").wait_for(state="visible", timeout=10000)
|
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
|
email_tab = page.locator("text=邮箱").first
|
||||||
if email_tab.count() > 0:
|
if email_tab.count() > 0:
|
||||||
@@ -78,10 +85,6 @@ def main():
|
|||||||
else:
|
else:
|
||||||
print("未找到邮箱登录选项")
|
print("未找到邮箱登录选项")
|
||||||
return
|
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")
|
send_code_btn = page.locator("a.hyc-email-login__send-code")
|
||||||
login_submit_btn = page.locator("button.hyc-email-login__btn")
|
login_submit_btn = page.locator("button.hyc-email-login__btn")
|
||||||
checkbox = page.locator(".t-checkbox__former")
|
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