Add AIServer, character art pipeline, and Art package boot integration.
Bring in MaleExplorer assets/shaders, model tooling and Hunyuan updates, plus boot/YooAsset Art package support. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from .auth import ensure_login, is_logged_in, logout, start_wechat_login_blocking, status
|
||||
from .client import FACE_LABELS, generate_from_image
|
||||
from .errors import HunyuanQuotaExceeded, HunyuanReloginNeeded
|
||||
|
||||
__all__ = [
|
||||
"ensure_login",
|
||||
@@ -11,4 +12,6 @@ __all__ = [
|
||||
"status",
|
||||
"FACE_LABELS",
|
||||
"generate_from_image",
|
||||
"HunyuanQuotaExceeded",
|
||||
"HunyuanReloginNeeded",
|
||||
]
|
||||
|
||||
+98
-13
@@ -1,6 +1,7 @@
|
||||
"""WeChat login session for https://3d.hunyuan.tencent.com via Playwright.
|
||||
|
||||
CLI 友好:同步阻塞扫码登录(非后台线程)。
|
||||
未登录 / 凭证失效 / 额度满换号 时弹出有头 Edge 供用户扫码。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,7 +12,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
HUNYUAN_URL = "https://3d.hunyuan.tencent.com/"
|
||||
LOGIN_TIMEOUT_SEC = 180
|
||||
LOGIN_TIMEOUT_SEC = 300
|
||||
|
||||
UpdateFn = Callable[..., None]
|
||||
|
||||
@@ -140,32 +141,87 @@ def _looks_logged_in(page) -> bool:
|
||||
has_generate = bool(page.get_by_text("立即生成").count())
|
||||
except Exception:
|
||||
pass
|
||||
return bool((interesting or storage_hit) and has_generate)
|
||||
# 有生成入口,且 cookie/storage 任一命中,或页面上已无「登录」
|
||||
if has_generate and (interesting or storage_hit):
|
||||
return True
|
||||
if has_generate and not _page_shows_login_cta(page):
|
||||
return True
|
||||
return bool(interesting and storage_hit)
|
||||
|
||||
|
||||
def _page_shows_login_cta(page) -> bool:
|
||||
try:
|
||||
if page.get_by_text("登录", exact=True).count():
|
||||
return page.get_by_text("登录", exact=True).first.is_visible(timeout=300)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _try_site_logout(page) -> None:
|
||||
"""尽量在官网侧退出,方便额度满后换号扫码。"""
|
||||
for label in ("退出登录", "退出", "登出", "注销"):
|
||||
if _click_first(page, (label,), exact=False):
|
||||
page.wait_for_timeout(800)
|
||||
break
|
||||
# 常见头像/用户菜单再点一次退出
|
||||
try:
|
||||
avatar = page.locator(
|
||||
'[class*="avatar"], [class*="user-info"], [class*="UserInfo"]'
|
||||
).first
|
||||
if avatar.count() and avatar.is_visible(timeout=500):
|
||||
avatar.click(timeout=2000)
|
||||
page.wait_for_timeout(400)
|
||||
_click_first(page, ("退出登录", "退出", "登出"), exact=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def start_wechat_login_blocking(
|
||||
timeout_sec: int = LOGIN_TIMEOUT_SEC,
|
||||
*,
|
||||
update: Optional[UpdateFn] = None,
|
||||
reason: str = "auth",
|
||||
clear_existing: bool = False,
|
||||
) -> dict:
|
||||
"""打开有头浏览器供微信扫码,阻塞直到成功 / 超时 / 失败。"""
|
||||
"""打开有头浏览器供微信扫码,阻塞直到成功 / 超时 / 失败。
|
||||
|
||||
reason:
|
||||
- auth: 未登录或凭证失效
|
||||
- quota: 当前账号额度满,需换号
|
||||
clear_existing: 先清本地会话;quota 场景会再尝试官网退出。
|
||||
"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
def _emit(event: str, **payload: Any) -> None:
|
||||
if update:
|
||||
update(event=event, **payload)
|
||||
|
||||
if clear_existing or reason == "quota":
|
||||
logout()
|
||||
|
||||
if reason == "quota":
|
||||
message = (
|
||||
"当前混元账号额度/次数已满,请在弹出的浏览器中退出旧号并用微信扫码登录其他账号"
|
||||
)
|
||||
else:
|
||||
message = "请在弹出的浏览器窗口中用微信扫码登录混元3D"
|
||||
|
||||
_write_status(
|
||||
logged_in=False,
|
||||
state="waiting_scan",
|
||||
message="请在弹出的浏览器窗口中用微信扫码登录",
|
||||
message=message,
|
||||
reason=reason,
|
||||
)
|
||||
_emit(
|
||||
"login_waiting",
|
||||
stage="请在弹出的浏览器窗口中用微信扫码登录",
|
||||
stage=message,
|
||||
progress=5,
|
||||
timeout_sec=timeout_sec,
|
||||
reason=reason,
|
||||
)
|
||||
# 同步打到 stderr,方便 Agent/终端注意弹窗
|
||||
print(f"[hunyuan] {message}(最长等待 {timeout_sec}s)", flush=True)
|
||||
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
@@ -177,8 +233,15 @@ def start_wechat_login_blocking(
|
||||
)
|
||||
page = context.new_page()
|
||||
page.goto(HUNYUAN_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(2500)
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
if reason == "quota":
|
||||
_try_site_logout(page)
|
||||
page.goto(HUNYUAN_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
_click_first(page, ("登录", "立即登录"), exact=True)
|
||||
page.wait_for_timeout(800)
|
||||
_click_first(page, ("微信", "微信登录", "微信扫码"), exact=False)
|
||||
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
@@ -187,15 +250,22 @@ def start_wechat_login_blocking(
|
||||
session_dir().mkdir(parents=True, exist_ok=True)
|
||||
context.storage_state(path=str(storage_state_path()))
|
||||
result = _write_status(
|
||||
logged_in=True, state="ready", message="微信登录成功"
|
||||
logged_in=True,
|
||||
state="ready",
|
||||
message="微信登录成功",
|
||||
reason=reason,
|
||||
)
|
||||
browser.close()
|
||||
_emit("login_ok", stage="微信登录成功", progress=15, **result)
|
||||
print("[hunyuan] 微信登录成功,会话已保存", flush=True)
|
||||
return result
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
result = _write_status(
|
||||
logged_in=False, state="timeout", message="微信登录超时,请重试"
|
||||
logged_in=False,
|
||||
state="timeout",
|
||||
message="微信登录超时,请重试",
|
||||
reason=reason,
|
||||
)
|
||||
browser.close()
|
||||
_emit("login_failed", stage="微信登录超时", progress=0, **result)
|
||||
@@ -204,7 +274,7 @@ def start_wechat_login_blocking(
|
||||
raise
|
||||
except Exception as exc:
|
||||
result = _write_status(
|
||||
logged_in=False, state="error", message=f"登录失败:{exc}"
|
||||
logged_in=False, state="error", message=f"登录失败:{exc}", reason=reason
|
||||
)
|
||||
_emit("login_failed", stage=str(exc), progress=0, **result)
|
||||
raise RuntimeError(f"登录失败:{exc}") from exc
|
||||
@@ -215,9 +285,14 @@ def ensure_login(
|
||||
interactive: bool = True,
|
||||
timeout_sec: int = LOGIN_TIMEOUT_SEC,
|
||||
update: Optional[UpdateFn] = None,
|
||||
force: bool = False,
|
||||
reason: str = "auth",
|
||||
) -> dict:
|
||||
"""若无会话则弹微信登录;interactive=False 且未登录则抛错。"""
|
||||
if is_logged_in():
|
||||
"""若无会话则弹微信登录;interactive=False 且未登录则抛错。
|
||||
|
||||
force=True 时忽略本地凭证,强制打开登录页(换号 / 重登)。
|
||||
"""
|
||||
if is_logged_in() and not force:
|
||||
current = status()
|
||||
if update:
|
||||
update(
|
||||
@@ -234,7 +309,17 @@ def ensure_login(
|
||||
if update:
|
||||
update(
|
||||
event="login_required",
|
||||
stage="需要微信登录混元3D",
|
||||
stage=(
|
||||
"当前账号额度已满,需要换号登录"
|
||||
if reason == "quota"
|
||||
else "需要微信登录混元3D"
|
||||
),
|
||||
progress=2,
|
||||
reason=reason,
|
||||
)
|
||||
return start_wechat_login_blocking(timeout_sec=timeout_sec, update=update)
|
||||
return start_wechat_login_blocking(
|
||||
timeout_sec=timeout_sec,
|
||||
update=update,
|
||||
reason=reason,
|
||||
clear_existing=force or reason == "quota",
|
||||
)
|
||||
|
||||
+201
-7
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .auth import HUNYUAN_URL, is_logged_in, storage_state_path
|
||||
from .errors import HunyuanQuotaExceeded, HunyuanReloginNeeded
|
||||
|
||||
FACE_LABEL = "50k"
|
||||
FACE_LABELS = ("50k", "500k", "1m")
|
||||
@@ -17,6 +18,28 @@ POLL_INTERVAL_SEC = 5
|
||||
API_LIST = "https://3d.hunyuan.tencent.com/api/3d/creations/list"
|
||||
API_DETAIL = "https://3d.hunyuan.tencent.com/api/3d/creations/detail"
|
||||
|
||||
# 额度/次数用尽常见文案(页面 toast / 弹窗 / API message)
|
||||
_QUOTA_PATTERNS = (
|
||||
r"额度不足",
|
||||
r"额度已用完",
|
||||
r"额度已满",
|
||||
r"额度已经用完",
|
||||
r"次数不足",
|
||||
r"次数已用完",
|
||||
r"次数已达上限",
|
||||
r"今日生成次数",
|
||||
r"今日次数已用完",
|
||||
r"已达上限",
|
||||
r"积分不足",
|
||||
r"免费次数.*?用完",
|
||||
r"体验次数.*?用完",
|
||||
r"生成次数不足",
|
||||
r"没有剩余.*?次数",
|
||||
r"剩余次数\s*[::]?\s*0",
|
||||
r"insufficient\s*(quota|credit|balance)",
|
||||
)
|
||||
_QUOTA_RE = re.compile("|".join(_QUOTA_PATTERNS), re.I)
|
||||
|
||||
UpdateFn = Callable[..., None]
|
||||
|
||||
|
||||
@@ -47,6 +70,123 @@ def _click_visible_text(page, labels, exact=False, timeout=5000):
|
||||
return False
|
||||
|
||||
|
||||
def _text_indicates_quota(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
return bool(_QUOTA_RE.search(text))
|
||||
|
||||
|
||||
def _payload_indicates_quota(payload) -> Optional[str]:
|
||||
"""从 generations / 其它 API JSON 中提取额度相关错误文案。"""
|
||||
if payload is None:
|
||||
return None
|
||||
if isinstance(payload, str):
|
||||
return payload if _text_indicates_quota(payload) else None
|
||||
if isinstance(payload, dict):
|
||||
for key in (
|
||||
"message",
|
||||
"msg",
|
||||
"error",
|
||||
"errorMsg",
|
||||
"errorMessage",
|
||||
"errMsg",
|
||||
"reason",
|
||||
"detail",
|
||||
"tips",
|
||||
"tip",
|
||||
):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and _text_indicates_quota(value):
|
||||
return value
|
||||
if isinstance(value, (dict, list)):
|
||||
found = _payload_indicates_quota(value)
|
||||
if found:
|
||||
return found
|
||||
# code 类字段旁带 message
|
||||
code = payload.get("code") or payload.get("ret") or payload.get("errorCode")
|
||||
msg = payload.get("message") or payload.get("msg") or ""
|
||||
if code not in (None, 0, "0", 200, "200", "Success", "success") and _text_indicates_quota(
|
||||
str(msg)
|
||||
):
|
||||
return str(msg)
|
||||
for key in ("data", "result", "error", "errors"):
|
||||
found = _payload_indicates_quota(payload.get(key))
|
||||
if found:
|
||||
return found
|
||||
if isinstance(payload, list):
|
||||
for item in payload:
|
||||
found = _payload_indicates_quota(item)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _page_quota_message(page) -> Optional[str]:
|
||||
"""优先从弹窗/toast 取额度提示,避免整页营销文案误判。"""
|
||||
selectors = (
|
||||
".t-dialog",
|
||||
".t-message",
|
||||
".t-notification",
|
||||
"[class*='dialog']",
|
||||
"[class*='toast']",
|
||||
"[class*='message']",
|
||||
"[role='dialog']",
|
||||
"[role='alert']",
|
||||
)
|
||||
chunks: list[str] = []
|
||||
for sel in selectors:
|
||||
try:
|
||||
locator = page.locator(sel)
|
||||
total = min(locator.count(), 8)
|
||||
except Exception:
|
||||
continue
|
||||
for index in range(total):
|
||||
try:
|
||||
node = locator.nth(index)
|
||||
if not node.is_visible(timeout=200):
|
||||
continue
|
||||
text = (node.inner_text(timeout=500) or "").strip()
|
||||
if text:
|
||||
chunks.append(text)
|
||||
except Exception:
|
||||
continue
|
||||
for text in chunks:
|
||||
if _text_indicates_quota(text):
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line and _text_indicates_quota(line):
|
||||
return line[:200]
|
||||
return text[:200]
|
||||
try:
|
||||
body = page.inner_text("body")
|
||||
except Exception:
|
||||
return None
|
||||
# 整页仅在出现非常明确的「用完/不足」短句时判定
|
||||
for line in body.splitlines():
|
||||
line = line.strip()
|
||||
if 4 <= len(line) <= 40 and _text_indicates_quota(line):
|
||||
return line[:200]
|
||||
return None
|
||||
|
||||
|
||||
def _raise_if_quota(page=None, payloads=None, fallback: Optional[str] = None):
|
||||
if payloads:
|
||||
for payload in payloads:
|
||||
found = _payload_indicates_quota(payload)
|
||||
if found:
|
||||
raise HunyuanQuotaExceeded(
|
||||
f"混元账号额度不足:{found}。请换号登录后重试。"
|
||||
)
|
||||
if page is not None:
|
||||
found = _page_quota_message(page)
|
||||
if found:
|
||||
raise HunyuanQuotaExceeded(
|
||||
f"混元账号额度不足:{found}。请换号登录后重试。"
|
||||
)
|
||||
if fallback and _text_indicates_quota(fallback):
|
||||
raise HunyuanQuotaExceeded(fallback)
|
||||
|
||||
|
||||
def _dismiss_popups(page, log=None):
|
||||
"""Close common confirm/agreement toasts that block generate submit."""
|
||||
for label in ("我知道了", "知道了", "同意并继续", "同意", "确认", "继续", "关闭"):
|
||||
@@ -54,6 +194,12 @@ def _dismiss_popups(page, log=None):
|
||||
if log:
|
||||
log.write(f"dismissed popup via 「{label}」\n")
|
||||
page.wait_for_timeout(400)
|
||||
# 协议点完后若仍是额度弹窗,再抛换号
|
||||
quota_msg = _page_quota_message(page)
|
||||
if quota_msg:
|
||||
raise HunyuanQuotaExceeded(
|
||||
f"混元账号额度不足:{quota_msg}。请换号登录后重试。"
|
||||
)
|
||||
|
||||
|
||||
def _click_generate(page, log=None):
|
||||
@@ -202,8 +348,17 @@ def _dump_debug(page, directory: Path, log, network_hits):
|
||||
def _api_json(request_context, method, url, **kwargs):
|
||||
response = getattr(request_context, method)(url, **kwargs)
|
||||
if response.status >= 400:
|
||||
body_text = ""
|
||||
try:
|
||||
body_text = response.text()
|
||||
except Exception:
|
||||
pass
|
||||
if response.status in (401, 403):
|
||||
raise PermissionError(f"混元接口鉴权失败 {response.status}: {url}")
|
||||
raise HunyuanReloginNeeded(f"混元接口鉴权失败 {response.status}: {url}")
|
||||
if _text_indicates_quota(body_text):
|
||||
raise HunyuanQuotaExceeded(
|
||||
f"混元账号额度不足(HTTP {response.status}):{body_text[:200]}"
|
||||
)
|
||||
raise RuntimeError(f"混元接口失败 {response.status}: {url}")
|
||||
try:
|
||||
return response.json()
|
||||
@@ -379,7 +534,7 @@ def _poll_until_ready(
|
||||
waited = int(GENERATION_TIMEOUT_SEC - (deadline - time.monotonic()))
|
||||
try:
|
||||
detail = _detail_creation(request_context, creations_id)
|
||||
except PermissionError:
|
||||
except HunyuanReloginNeeded:
|
||||
raise
|
||||
except Exception:
|
||||
listing = _list_creations(request_context)
|
||||
@@ -432,10 +587,15 @@ def generate_from_image(
|
||||
directory: Path,
|
||||
update: UpdateFn,
|
||||
face_label: str = FACE_LABEL,
|
||||
*,
|
||||
headed: bool = False,
|
||||
) -> Path:
|
||||
"""图生3D,返回 directory/generated.glb。鉴权失败抛 PermissionError。"""
|
||||
"""图生3D,返回 directory/generated.glb。
|
||||
|
||||
鉴权失败抛 HunyuanReloginNeeded;额度满抛 HunyuanQuotaExceeded。
|
||||
"""
|
||||
if not is_logged_in():
|
||||
raise PermissionError("尚未微信登录混元3D,请先登录。")
|
||||
raise HunyuanReloginNeeded("尚未微信登录混元3D,请先登录。")
|
||||
if face_label not in FACE_LABELS:
|
||||
raise ValueError(f"面数须为 {FACE_LABELS} 之一,收到: {face_label}")
|
||||
|
||||
@@ -483,13 +643,16 @@ def generate_from_image(
|
||||
if found:
|
||||
creations_id = found
|
||||
entry["creationsId"] = found
|
||||
quota = _payload_indicates_quota(body)
|
||||
if quota:
|
||||
entry["quota"] = quota
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
update(event="stage", stage="打开混元3D 网页", progress=20)
|
||||
with log_path.open("w", encoding="utf-8") as log, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(
|
||||
channel=_browser_channel(), headless=True
|
||||
channel=_browser_channel(), headless=not headed
|
||||
)
|
||||
context = browser.new_context(
|
||||
storage_state=str(state_path),
|
||||
@@ -505,7 +668,7 @@ def generate_from_image(
|
||||
log.write("opened hunyuan home\n")
|
||||
|
||||
if _page_looks_logged_out(page):
|
||||
raise PermissionError("混元登录凭证已失效,需要重新微信登录。")
|
||||
raise HunyuanReloginNeeded("混元登录凭证已失效,需要重新微信登录。")
|
||||
|
||||
update(event="stage", stage="进入图/文生3D", progress=26)
|
||||
_enter_studio(page, log)
|
||||
@@ -552,6 +715,7 @@ def generate_from_image(
|
||||
submitted = False
|
||||
for attempt in range(1, 4):
|
||||
if not _click_generate(page, log=log):
|
||||
_raise_if_quota(page=page, payloads=generations_bodies)
|
||||
_dump_debug(page, directory, log, network_hits)
|
||||
raise RuntimeError("未找到「立即生成」按钮,页面结构可能已变化。")
|
||||
log.write(f"generate attempt {attempt}/3\n")
|
||||
@@ -565,15 +729,21 @@ def generate_from_image(
|
||||
submitted = True
|
||||
log.write("generations API observed\n")
|
||||
break
|
||||
# 提交无响应时优先识别额度弹窗
|
||||
_raise_if_quota(page=page, payloads=generations_bodies)
|
||||
log.write("no /creations/generations yet; will retry click\n")
|
||||
page.wait_for_timeout(1200)
|
||||
if not submitted:
|
||||
_raise_if_quota(page=page, payloads=generations_bodies)
|
||||
_dump_debug(page, directory, log, network_hits)
|
||||
raise RuntimeError(
|
||||
"点击「立即生成」后官网未发起生成请求(无 /creations/generations)。"
|
||||
"请打开混元官网确认额度/协议弹窗后重试。"
|
||||
"可能是额度不足或协议弹窗未处理;脚本将尝试打开登录页换号。"
|
||||
)
|
||||
|
||||
# generations 返回了但可能是额度错误
|
||||
_raise_if_quota(page=page, payloads=generations_bodies)
|
||||
|
||||
(directory / "hunyuan_generations.json").write_text(
|
||||
json.dumps(generations_bodies, ensure_ascii=False, indent=2)[:200000],
|
||||
encoding="utf-8",
|
||||
@@ -635,12 +805,36 @@ def generate_from_image(
|
||||
)
|
||||
context.storage_state(path=str(state_path))
|
||||
return mesh
|
||||
except HunyuanQuotaExceeded:
|
||||
log.write("ERROR: quota exceeded\n")
|
||||
try:
|
||||
_dump_debug(page, directory, log, network_hits)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
except HunyuanReloginNeeded:
|
||||
log.write("ERROR: re-login needed\n")
|
||||
try:
|
||||
_dump_debug(page, directory, log, network_hits)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.write(f"ERROR: {exc}\n")
|
||||
try:
|
||||
_dump_debug(page, directory, log, network_hits)
|
||||
except Exception:
|
||||
pass
|
||||
# 提交失败且页面有额度提示时,升级为换号登录
|
||||
if "未发起生成请求" in str(exc) or "立即生成" in str(exc):
|
||||
try:
|
||||
_raise_if_quota(page=page, payloads=generations_bodies)
|
||||
except HunyuanQuotaExceeded:
|
||||
raise
|
||||
# 无法确认额度时也当作需换号,方便用户打开登录页处理
|
||||
raise HunyuanQuotaExceeded(
|
||||
f"{exc}(已按额度/账号问题处理:将打开登录页换号)"
|
||||
) from exc
|
||||
raise
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""混元 3D 流程异常:触发交互式重新登录 / 换号。"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class HunyuanReloginNeeded(PermissionError):
|
||||
"""凭证失效或未登录,需要打开登录页重新微信扫码。"""
|
||||
|
||||
reason = "auth"
|
||||
|
||||
|
||||
class HunyuanQuotaExceeded(HunyuanReloginNeeded):
|
||||
"""当前账号额度/次数用尽,需要换号登录后再生成。"""
|
||||
|
||||
reason = "quota"
|
||||
Reference in New Issue
Block a user