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:
+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()
|
||||
|
||||
Reference in New Issue
Block a user