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:
udx
2026-09-21 17:06:59 +08:00
co-authored by Cursor
parent b7a0bbcb89
commit 6550b8df4b
121 changed files with 313260 additions and 113 deletions
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+14
View File
@@ -0,0 +1,14 @@
"""混元 3D 流程异常:触发交互式重新登录 / 换号。"""
from __future__ import annotations
class HunyuanReloginNeeded(PermissionError):
"""凭证失效或未登录,需要打开登录页重新微信扫码。"""
reason = "auth"
class HunyuanQuotaExceeded(HunyuanReloginNeeded):
"""当前账号额度/次数用尽,需要换号登录后再生成。"""
reason = "quota"