Files
hunyuan3dweb/hunyuan3dweb/browser/generator.py
Claude 734d53dafb fix: prevent browser process leaks and improve robustness
- fix(browser): wrap context lifecycle in try/finally to ensure browser
  closes on exceptions and KeyboardInterrupt (login, sniffer, generator)
- fix(browser): replace time.sleep with Playwright native waits
  (wait_for, wait_for_timeout) for more reliable element interaction
- fix(browser): use parameterized page.evaluate instead of f-string
  JS injection in generator polling
- fix(api): add retry logic in wait_for_completion to survive transient
  network errors
- fix(config): add prepare_profile_dir to copy profile to temp dir,
  preventing Chromium SingletonLock conflicts when tools run concurrently
- fix(sniffer): stream API logs to tempfile instead of unbounded memory
  list to avoid OOM
- fix(api): specify encoding='utf-8' when loading cookies from file

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 23:08:46 +08:00

241 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
腾讯混元3D 图生3D 完整自动化脚本
使用 cloakbrowser 在浏览器内完成所有操作(包括签名生成)
"""
import contextlib
import json
import os
import shutil
import sys
import tempfile
import time
from datetime import datetime
from cloakbrowser import launch_persistent_context
from ..config import get_profile_dir
PROFILE_DIR = str(get_profile_dir())
@contextlib.contextmanager
def _temp_persistent_context(headless=True):
"""
将标准 profile 复制到临时目录后启动持久化上下文,
避免与 login 进程发生 Chromium SingletonLock 冲突。
"""
standard = str(get_profile_dir())
if not os.path.exists(standard):
raise FileNotFoundError(f"未找到登录状态目录: {standard}")
temp_root = tempfile.mkdtemp(prefix="hunyuan3dweb-profile-")
temp_profile = os.path.join(temp_root, "profile")
shutil.copytree(standard, temp_profile, dirs_exist_ok=True)
context = None
try:
context = launch_persistent_context(temp_profile, headless=headless)
yield context
finally:
if context is not None:
try:
context.close()
except Exception:
pass
try:
shutil.rmtree(temp_root)
except Exception:
pass
def generate_3d(image_path, wait_for_complete=False, timeout=300):
"""
上传图片并触发生成3D模型
Args:
image_path: 图片路径
wait_for_complete: 是否等待生成完成
timeout: 最长等待时间(秒)
Returns:
dict: 包含 creationsId 和状态信息
"""
if not os.path.exists(image_path):
raise FileNotFoundError(f"图片不存在: {image_path}")
with _temp_persistent_context(headless=True) as context:
page = context.new_page()
# 用于存储结果
result = {"creationsId": None, "status": None, "modelUrl": None}
# 监听响应
def handle_response(response):
url = response.url
if "creations/generations" in url and response.status == 200:
try:
body = response.json()
if "creationsId" in body:
result["creationsId"] = body["creationsId"]
print(f"✅ 生成已触发creationsId: {body['creationsId']}")
except:
pass
elif "creations/detail" in url and response.status == 200:
try:
body = response.json()
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:
pass
page.on("response", handle_response)
print("[1/6] 打开首页...")
page.goto("https://3d.hunyuan.tencent.com/", timeout=60000)
page.wait_for_timeout(3000)
print("[2/6] 点击 AI创作...")
page.locator("button").filter(has_text="AI创作").first.click()
page.locator("text=图生3D").wait_for(state="visible", timeout=10000)
print("[3/6] 点击 图生3D...")
page.locator("text=图生3D").first.click()
page.locator("text=单张图片").wait_for(state="visible", timeout=10000)
print("[4/6] 点击 单张图片...")
page.locator("text=单张图片").first.click()
page.locator('input[type=file]').first.wait_for(state="visible", timeout=10000)
print("[5/6] 上传图片...")
page.locator('input[type=file]').first.set_input_files(image_path)
page.locator("text=立即生成").wait_for(state="visible", timeout=10000)
print("[6/6] 点击 立即生成...")
page.locator("text=立即生成").first.click()
# 等待生成触发
page.wait_for_timeout(5000)
if not result["creationsId"]:
print("⚠️ 未获取到 creationsId可能生成失败")
return result
if wait_for_complete:
print(f"\n⏳ 等待生成完成(最长 {timeout} 秒)...")
start = time.time()
last_status = None
while time.time() - start < timeout:
# 轮询状态
status_result = page.evaluate('''
async (creationsId) => {
const resp = await fetch(`https://3d.hunyuan.tencent.com/api/3d/creations/detail?creationsId=${creationsId}`, {
headers: {'X-Source': 'web', 'Referer': 'https://3d.hunyuan.tencent.com/'}
});
return await resp.json();
}
''', result["creationsId"])
status = status_result.get("status", "unknown")
progress = status_result.get("progress", 0)
if status != last_status:
print(f" 状态: {status} (进度: {progress}%)")
last_status = status
if status == "success":
# 提取模型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✅ 生成完成!")
break
elif status == "fail":
print(f"\n❌ 生成失败")
break
time.sleep(3)
else:
print(f"\n⏰ 超时,当前状态: {last_status}")
return result
def get_quota():
"""获取当前配额信息"""
with _temp_persistent_context(headless=True) as context:
page = context.new_page()
page.goto("https://3d.hunyuan.tencent.com/", timeout=60000)
page.wait_for_timeout(3000)
result = page.evaluate('''
async () => {
const resp = await fetch('https://3d.hunyuan.tencent.com/api/3d/quotainfo', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Source': 'web', 'Referer': 'https://3d.hunyuan.tencent.com/'},
body: '{"sceneType":"3dCreations"}'
});
return await resp.json();
}
''')
return result
def get_creations_list():
"""获取作品列表"""
with _temp_persistent_context(headless=True) as context:
page = context.new_page()
page.goto("https://3d.hunyuan.tencent.com/", timeout=60000)
page.wait_for_timeout(3000)
result = page.evaluate('''
async () => {
const resp = await fetch('https://3d.hunyuan.tencent.com/api/3d/creations/list', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Source': 'web', 'Referer': 'https://3d.hunyuan.tencent.com/'},
body: '{"page":1,"pageSize":20}'
});
return await resp.json();
}
''')
return result
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法:")
print(f" python {sys.argv[0]} quota # 查询配额")
print(f" python {sys.argv[0]} list # 查询作品列表")
print(f" python {sys.argv[0]} generate <图片路径> [wait] # 生成3D模型")
sys.exit(1)
cmd = sys.argv[1]
if cmd == "quota":
quota = get_quota()
print(json.dumps(quota, indent=2, ensure_ascii=False))
elif cmd == "list":
creations = get_creations_list()
print(json.dumps(creations, indent=2, ensure_ascii=False))
elif cmd == "generate":
if len(sys.argv) < 3:
print("请提供图片路径")
sys.exit(1)
image_path = sys.argv[2]
wait = len(sys.argv) > 3 and sys.argv[3] == "wait"
result = generate_3d(image_path, wait_for_complete=wait)
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print(f"未知命令: {cmd}")