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
+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()