Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 14:21:07 +08:00
co-authored by Cursor
commit f3f66ab063
931 changed files with 112366 additions and 0 deletions
+646
View File
@@ -0,0 +1,646 @@
"""Generate and download a base mesh from the Hunyuan 3D website."""
from __future__ import annotations
import json
import re
import time
import zipfile
from pathlib import Path
from typing import Callable, Optional
from .auth import HUNYUAN_URL, is_logged_in, storage_state_path
FACE_LABEL = "50k"
FACE_LABELS = ("50k", "500k", "1m")
GENERATION_TIMEOUT_SEC = 1200
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"
UpdateFn = Callable[..., None]
def _browser_channel() -> str:
import os
return os.environ.get("HUNYUAN_BROWSER_CHANNEL", "msedge").strip() or "msedge"
def _click_visible_text(page, labels, exact=False, timeout=5000):
for label in labels:
locator = page.get_by_text(label, exact=exact)
try:
total = locator.count()
except Exception:
total = 0
for index in range(total):
target = locator.nth(index)
try:
if not target.is_visible():
continue
target.scroll_into_view_if_needed(timeout=2000)
target.click(timeout=timeout)
page.wait_for_timeout(400)
return True
except Exception:
continue
return False
def _dismiss_popups(page, log=None):
"""Close common confirm/agreement toasts that block generate submit."""
for label in ("我知道了", "知道了", "同意并继续", "同意", "确认", "继续", "关闭"):
if _click_visible_text(page, (label,), exact=False, timeout=1200):
if log:
log.write(f"dismissed popup via 「{label}」\n")
page.wait_for_timeout(400)
def _click_generate(page, log=None):
"""Click the primary studio generate button (not nav text)."""
_dismiss_popups(page, log)
locators = (
page.get_by_role("button", name=re.compile(r"立即生成")),
page.locator('button.t-button--theme-primary:has-text("立即生成")'),
page.locator('button:has-text("立即生成")'),
page.get_by_text("立即生成", exact=False),
)
for locator in locators:
try:
total = locator.count()
except Exception:
continue
for index in range(total):
target = locator.nth(index)
try:
if not target.is_visible():
continue
try:
if target.is_disabled():
continue
except Exception:
pass
box = target.bounding_box()
if box and (box["width"] < 80 or box["height"] < 28):
continue
target.scroll_into_view_if_needed(timeout=2000)
target.click(timeout=5000)
page.wait_for_timeout(600)
if log:
log.write("clicked generate\n")
return True
except Exception as exc:
if log:
log.write(f"generate click skipped: {exc}\n")
continue
for index in range(page.get_by_text("立即生成", exact=False).count()):
target = page.get_by_text("立即生成", exact=False).nth(index)
try:
if not target.is_visible():
continue
target.click(timeout=4000, force=True)
if log:
log.write("clicked generate (force)\n")
return True
except Exception:
continue
return False
def _wait_generations_api(generations_bodies, page, log, get_creations_id=None, timeout_sec=25):
deadline = time.monotonic() + timeout_sec
while time.monotonic() < deadline:
if generations_bodies or (get_creations_id and get_creations_id()):
return True
_dismiss_popups(page, log)
page.wait_for_timeout(800)
return bool(generations_bodies or (get_creations_id and get_creations_id()))
def _in_studio(page) -> bool:
try:
return bool(
page.get_by_text("模型面数", exact=True).count()
and page.get_by_text("立即生成", exact=False).count()
)
except Exception:
return False
def _enter_studio(page, log):
if _in_studio(page):
log.write("already in studio\n")
return
if _click_visible_text(page, ("立即开始",), exact=False):
page.wait_for_timeout(2500)
if not _in_studio(page):
_click_visible_text(page, ("图/文生3D",), exact=False)
page.wait_for_timeout(800)
_click_visible_text(page, ("立即开始", "开始"), exact=False)
page.wait_for_timeout(2500)
deadline = time.monotonic() + 30
while time.monotonic() < deadline and not _in_studio(page):
page.wait_for_timeout(500)
if not _in_studio(page):
raise RuntimeError("未能进入混元「图/文生3D」创作页,请确认登录态后重试。")
log.write("entered studio\n")
def _select_face_count(page, label=FACE_LABEL, log=None):
aliases = {
"50k": ("50k", "50K"),
"500k": ("500k", "500K"),
"1m": ("1m", "1M"),
}
candidates = aliases.get(label, (label,))
for candidate in candidates:
pattern = re.compile(rf"^\s*{re.escape(candidate)}\s*$", re.I)
locator = page.get_by_text(pattern)
try:
total = locator.count()
except Exception:
total = 0
for index in range(total):
target = locator.nth(index)
try:
if not target.is_visible():
continue
box = target.bounding_box()
if not box or box["width"] > 120 or box["height"] > 80:
continue
target.scroll_into_view_if_needed(timeout=2000)
target.click(timeout=4000)
page.wait_for_timeout(400)
if log:
log.write(f"selected face count {candidate}\n")
return True
except Exception as exc:
if log:
log.write(f"face click skipped: {exc}\n")
continue
if log:
log.write(f"visible face chip {label} not clicked\n")
return False
def _dump_debug(page, directory: Path, log, network_hits):
try:
page.screenshot(path=str(directory / "hunyuan_debug.png"), full_page=True)
log.write("saved hunyuan_debug.png\n")
except Exception as exc:
log.write(f"screenshot failed: {exc}\n")
try:
(directory / "hunyuan_debug.html").write_text(page.content(), encoding="utf-8")
log.write("saved hunyuan_debug.html\n")
except Exception as exc:
log.write(f"html dump failed: {exc}\n")
(directory / "hunyuan_network.json").write_text(
json.dumps(network_hits[-120:], ensure_ascii=False, indent=2), encoding="utf-8"
)
def _api_json(request_context, method, url, **kwargs):
response = getattr(request_context, method)(url, **kwargs)
if response.status >= 400:
if response.status in (401, 403):
raise PermissionError(f"混元接口鉴权失败 {response.status}: {url}")
raise RuntimeError(f"混元接口失败 {response.status}: {url}")
try:
return response.json()
except Exception as exc:
raise RuntimeError(f"混元接口返回非 JSON:{url}") from exc
def _list_creations(request_context):
return _api_json(
request_context,
"post",
API_LIST,
data=json.dumps({}),
headers={"content-type": "application/json"},
)
def _detail_creation(request_context, creations_id):
return _api_json(
request_context, "get", f"{API_DETAIL}?creationsId={creations_id}"
)
def _creation_progress(creation: dict, page=None):
"""Return (stage_text, progress_0_100, done, failed, glb_url)."""
status = str(creation.get("status") or "").lower()
result = (creation.get("result") or [{}])[0] if creation.get("result") else {}
result_status = str(result.get("status") or status).lower()
urls = result.get("urlResult") or {}
glb = urls.get("glb") or urls.get("textureGlb")
intermediate = result.get("intermediate_outputs") or {}
has_geometry = bool(
(intermediate.get("geometry") or {}).get("glb_url") or urls.get("geometryGlb")
)
ui_percent = None
ui_stage = None
if page is not None:
try:
body = page.inner_text("body")
match = re.search(
r"(几何生成中|纹理生成中|PBR生成中|生成中)[^\d%]{0,8}(\d{1,3})\s*%", body
)
if match:
ui_stage, ui_percent = match.group(1), int(match.group(2))
except Exception:
pass
if result_status in {"fail", "failed", "error"} or status in {
"fail",
"failed",
"error",
}:
return "混元生成失败", 100, False, True, None
if result_status in {"success", "done", "finished"} and glb:
return "混元生成完成", 95, True, False, glb
if ui_percent is not None:
stage = f"资产页 · {ui_stage} {ui_percent}%"
if ui_stage and ("纹理" in ui_stage or "PBR" in ui_stage):
progress = 80 + min(14, ui_percent * 14 // 100)
else:
progress = 55 + min(24, ui_percent * 24 // 100)
return stage, progress, False, False, None
if has_geometry:
return "资产页 · 纹理/PBR 生成中", 82, False, False, None
if status in {"wait", "waiting", "queue", "queued"}:
return "资产页 · 排队中", 58, False, False, None
return "资产页 · 几何生成中", 65, False, False, None
def _extract_creations_id(payload):
if payload is None:
return None
if isinstance(payload, dict):
for key in ("creationsId", "creationId", "CreationsId", "id"):
value = payload.get(key)
if isinstance(value, str) and len(value) >= 8:
return value
for key in ("data", "result", "creation", "creations"):
nested = payload.get(key)
found = _extract_creations_id(nested)
if found:
return found
if isinstance(payload.get("list"), list):
found = _extract_creations_id(payload.get("list"))
if found:
return found
if isinstance(payload, list):
for item in payload:
found = _extract_creations_id(item)
if found:
return found
return None
def _find_new_creation(listing, before_ids):
creations = listing.get("creations") or []
for item in creations:
item_id = item.get("id")
if item_id and item_id not in before_ids:
return item
return None
def _wait_for_new_creations_id(
request_context, before_ids, captured_id, update, log, timeout_sec=120
):
if captured_id and captured_id not in before_ids:
log.write(f"creationsId from generations response: {captured_id}\n")
return captured_id
if captured_id and captured_id in before_ids:
log.write(f"ignored stale creationsId from response: {captured_id}\n")
deadline = time.monotonic() + timeout_sec
while time.monotonic() < deadline:
waited = int(timeout_sec - (deadline - time.monotonic()))
update(
event="stage",
stage=f"等待资产页出现新任务({waited}s / {timeout_sec}s)",
progress=54,
)
listing = _list_creations(request_context)
newest = _find_new_creation(listing, before_ids)
if newest and newest.get("id"):
log.write(
f"creationsId from list delta: {newest['id']} status={newest.get('status')} "
f"total={listing.get('totalCount')}\n"
)
return newest["id"]
time.sleep(POLL_INTERVAL_SEC)
raise RuntimeError(
"提交后资产列表未出现新任务。可能未真正提交成功,请查看官网资产页后重试。"
)
def _download_url(request_context, url, destination: Path, log):
response = request_context.get(url)
if response.status >= 400:
raise RuntimeError(f"下载模型失败 HTTP {response.status}")
destination.write_bytes(response.body())
log.write(f"downloaded bytes={destination.stat().st_size} -> {destination}\n")
return destination
def _extract_mesh(archive_or_file: Path, directory: Path) -> Path:
if archive_or_file.suffix.lower() == ".glb":
destination = directory / "generated.glb"
if archive_or_file.resolve() != destination.resolve():
destination.write_bytes(archive_or_file.read_bytes())
return destination
if archive_or_file.suffix.lower() == ".zip" or zipfile.is_zipfile(archive_or_file):
extract_to = directory / "hunyuan_extract"
extract_to.mkdir(exist_ok=True)
with zipfile.ZipFile(archive_or_file, "r") as archive:
archive.extractall(extract_to)
glbs = sorted(extract_to.rglob("*.glb"))
if not glbs:
raise RuntimeError("混元下载包中未找到 GLB 模型。")
destination = directory / "generated.glb"
destination.write_bytes(glbs[0].read_bytes())
return destination
raise RuntimeError(f"不支持的混元下载格式:{archive_or_file.suffix}")
def _poll_until_ready(
request_context, page, creations_id, update, log, network_hits, directory
):
deadline = time.monotonic() + GENERATION_TIMEOUT_SEC
last_stage = ""
while time.monotonic() < deadline:
waited = int(GENERATION_TIMEOUT_SEC - (deadline - time.monotonic()))
try:
detail = _detail_creation(request_context, creations_id)
except PermissionError:
raise
except Exception:
listing = _list_creations(request_context)
detail = next(
(
c
for c in (listing.get("creations") or [])
if c.get("id") == creations_id
),
None,
)
if detail is None:
detail = {"status": "waiting", "result": []}
(directory / "hunyuan_creation.json").write_text(
json.dumps(detail, ensure_ascii=False, indent=2), encoding="utf-8"
)
stage, progress, done, failed, glb = _creation_progress(detail, page=page)
message = f"{stage}(已等待 {waited}s / 最长 {GENERATION_TIMEOUT_SEC}s)"
if message != last_stage:
log.write(message + "\n")
last_stage = message
update(event="stage", stage=message, progress=progress)
if failed:
raise RuntimeError("混元资产页显示生成失败。")
if done and glb:
return glb
try:
if page.get_by_text("几何生成中", exact=False).count() == 0:
_click_visible_text(page, ("资产",), exact=True)
except Exception:
pass
page.wait_for_timeout(POLL_INTERVAL_SEC * 1000)
_dump_debug(page, directory, log, network_hits)
raise TimeoutError(f"混元资产生成超时(已等待 {GENERATION_TIMEOUT_SEC}s)。")
def _page_looks_logged_out(page) -> bool:
try:
if page.get_by_text("登录", exact=True).count():
login = page.get_by_text("登录", exact=True).first
if login.is_visible(timeout=500):
return True
except Exception:
pass
return False
def generate_from_image(
image_path: Path,
directory: Path,
update: UpdateFn,
face_label: str = FACE_LABEL,
) -> Path:
"""图生3D,返回 directory/generated.glb。鉴权失败抛 PermissionError。"""
if not is_logged_in():
raise PermissionError("尚未微信登录混元3D,请先登录。")
if face_label not in FACE_LABELS:
raise ValueError(f"面数须为 {FACE_LABELS} 之一,收到: {face_label}")
from playwright.sync_api import sync_playwright
image_path = Path(image_path).expanduser().resolve()
if not image_path.is_file():
raise FileNotFoundError(f"参考图不存在: {image_path}")
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
log_path = directory / "generation.log"
network_hits = []
api_bodies = {}
generations_bodies = []
creations_id = None
state_path = storage_state_path()
def on_response(response):
nonlocal creations_id
url = response.url
if "/api/3d/" not in url:
return
entry = {"url": url, "status": response.status}
network_hits.append(entry)
path = url.split("?", 1)[0]
if any(
token in path
for token in (
"/creations/generations",
"/creations/detail",
"/creations/list",
"/resource/download",
)
):
try:
body = response.json()
entry["body_keys"] = (
list(body)[:20] if isinstance(body, dict) else type(body).__name__
)
api_bodies.setdefault(path, body)
if "/creations/generations" in path and isinstance(body, dict):
generations_bodies.append(body)
found = _extract_creations_id(body)
if found:
creations_id = found
entry["creationsId"] = found
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
)
context = browser.new_context(
storage_state=str(state_path),
locale="zh-CN",
accept_downloads=True,
viewport={"width": 1600, "height": 1200},
)
page = context.new_page()
page.on("response", on_response)
try:
page.goto(HUNYUAN_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(2500)
log.write("opened hunyuan home\n")
if _page_looks_logged_out(page):
raise PermissionError("混元登录凭证已失效,需要重新微信登录。")
update(event="stage", stage="进入图/文生3D", progress=26)
_enter_studio(page, log)
before = _list_creations(context.request)
before_ids = {
item.get("id")
for item in (before.get("creations") or [])
if item.get("id")
}
before_count = before.get("totalCount")
log.write(
f"before_submit totalCount={before_count} known_ids={len(before_ids)}\n"
)
update(event="stage", stage="切换到图生3D", progress=30)
_click_visible_text(page, ("图生3D",), exact=True)
_click_visible_text(page, ("单张图片",), exact=True)
update(event="stage", stage="上传参考图片", progress=36)
file_input = page.locator('input[type="file"]').first
if not file_input.count():
_click_visible_text(page, ("上传图片",), exact=False)
file_input = page.locator('input[type="file"]').first
if not file_input.count():
_dump_debug(page, directory, log, network_hits)
raise RuntimeError("未找到图片上传控件,页面结构可能已变化。")
file_input.set_input_files(str(image_path))
page.wait_for_timeout(3500)
log.write(f"uploaded {image_path}\n")
upload_deadline = time.monotonic() + 30
while time.monotonic() < upload_deadline:
if any("/resource/review" in (e.get("url") or "") for e in network_hits):
break
page.wait_for_timeout(500)
page.wait_for_timeout(1500)
update(event="stage", stage=f"选择 {face_label} 原始面数", progress=44)
if not _select_face_count(page, face_label, log=log):
_dump_debug(page, directory, log, network_hits)
raise RuntimeError(f"未能点选模型面数 {face_label}。")
update(event="stage", stage="提交混元生成", progress=52)
submitted = False
for attempt in range(1, 4):
if not _click_generate(page, log=log):
_dump_debug(page, directory, log, network_hits)
raise RuntimeError("未找到「立即生成」按钮,页面结构可能已变化。")
log.write(f"generate attempt {attempt}/3\n")
if _wait_generations_api(
generations_bodies,
page,
log,
get_creations_id=lambda: creations_id,
timeout_sec=20,
):
submitted = True
log.write("generations API observed\n")
break
log.write("no /creations/generations yet; will retry click\n")
page.wait_for_timeout(1200)
if not submitted:
_dump_debug(page, directory, log, network_hits)
raise RuntimeError(
"点击「立即生成」后官网未发起生成请求(无 /creations/generations)。"
"请打开混元官网确认额度/协议弹窗后重试。"
)
(directory / "hunyuan_generations.json").write_text(
json.dumps(generations_bodies, ensure_ascii=False, indent=2)[:200000],
encoding="utf-8",
)
(directory / "hunyuan_api_bodies.json").write_text(
json.dumps(
{
k: (
v
if not str(k).rstrip("/").endswith("list")
else {
"totalCount": v.get("totalCount"),
"first_id": (v.get("creations") or [{}])[0].get("id"),
}
)
for k, v in api_bodies.items()
},
ensure_ascii=False,
indent=2,
)[:200000],
encoding="utf-8",
)
creations_id = _wait_for_new_creations_id(
context.request, before_ids, creations_id, update, log, timeout_sec=120
)
if creations_id in before_ids:
raise RuntimeError(f"拒绝绑定旧资产 {creations_id},请重试提交。")
log.write(f"bound creationsId={creations_id}\n")
(directory / "hunyuan_binding.json").write_text(
json.dumps(
{
"creationsId": creations_id,
"before_count": before_count,
"before_ids_sample": sorted(before_ids)[:5],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
update(event="stage", stage="打开资产页并等待完成", progress=55)
_click_visible_text(page, ("资产",), exact=True)
page.wait_for_timeout(1500)
glb_url = _poll_until_ready(
context.request, page, creations_id, update, log, network_hits, directory
)
update(event="stage", stage="下载混元 GLB", progress=96)
saved = directory / "hunyuan_raw.glb"
_download_url(context.request, glb_url, saved, log)
mesh = _extract_mesh(saved, directory)
log.write(f"extracted {mesh}\n")
(directory / "hunyuan_network.json").write_text(
json.dumps(network_hits[-120:], ensure_ascii=False, indent=2),
encoding="utf-8",
)
context.storage_state(path=str(state_path))
return mesh
except Exception as exc:
log.write(f"ERROR: {exc}\n")
try:
_dump_debug(page, directory, log, network_hits)
except Exception:
pass
raise
finally:
browser.close()