Files
AIGame/Tools/hunyuan_3d_generate.py
udxandCursor 6550b8df4b 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>
2026-09-21 17:06:59 +08:00

331 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""腾讯混元3D 图生3D 工具。
支持:
- AI / Agent CLI 调用(参数或 JSON)
- 流程回调(HTTP webhook / 状态文件 / NDJSON 事件流)
- 未登录时弹出 Edge 微信扫码,取得凭证后继续生成
依赖:playwright + 本机 Microsoft Edge。见 Tools/requirements-hunyuan.txt
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
import uuid
from pathlib import Path
from typing import Any, Dict, Optional, Sequence
# 允许直接 `python Tools/hunyuan_3d_generate.py` 运行
_TOOLS_DIR = Path(__file__).resolve().parent
if str(_TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(_TOOLS_DIR))
from hunyuan_3d.auth import ( # noqa: E402
ensure_login,
is_logged_in,
jobs_dir,
logout,
start_wechat_login_blocking,
status,
)
from hunyuan_3d.client import FACE_LABEL, FACE_LABELS, generate_from_image # noqa: E402
from hunyuan_3d.errors import HunyuanQuotaExceeded, HunyuanReloginNeeded # noqa: E402
from hunyuan_3d.events import CallbackHub # noqa: E402
class GenerateError(RuntimeError):
"""生成失败。"""
def _load_json_arg(raw: Optional[str]) -> Dict[str, Any]:
if not raw:
return {}
if raw == "-":
return json.load(sys.stdin)
path = Path(raw)
if path.is_file():
return json.loads(path.read_text(encoding="utf-8"))
return json.loads(raw)
def _normalize_faces(value: Optional[str]) -> str:
text = (value or FACE_LABEL).strip().lower()
aliases = {
"50k": "50k",
"50": "50k",
"50000": "50k",
"500k": "500k",
"500000": "500k",
"1m": "1m",
"1000000": "1m",
"1": "1m",
}
label = aliases.get(text, text)
if label not in FACE_LABELS:
raise GenerateError(f"面数须为 {list(FACE_LABELS)} 之一,收到: {value}")
return label
def _make_update(callbacks: CallbackHub):
def update(**payload: Any) -> None:
event = payload.pop("event", "stage")
callbacks.emit(event, **payload)
return update
def run_generate(
*,
image: str,
output: str,
faces: str = FACE_LABEL,
interactive_login: bool = True,
login_timeout: int = 300,
callback_url: Optional[str] = None,
callback_file: Optional[str] = None,
quiet: bool = False,
max_login_retries: int = 3,
) -> Dict[str, Any]:
callbacks = CallbackHub(
callback_url=callback_url,
callback_file=callback_file,
quiet=quiet,
)
update = _make_update(callbacks)
image_path = Path(image).expanduser().resolve()
if not image_path.is_file():
raise GenerateError(f"参考图不存在: {image_path}")
output_path = Path(output).expanduser().resolve()
if output_path.suffix.lower() != ".glb":
if output_path.is_dir() or not output_path.suffix:
output_path = output_path / f"{image_path.stem}.glb"
else:
raise GenerateError("输出路径须为 .glb 文件或目录")
output_path.parent.mkdir(parents=True, exist_ok=True)
face_label = _normalize_faces(faces)
job_id = uuid.uuid4().hex
work_dir = jobs_dir() / job_id
work_dir.mkdir(parents=True, exist_ok=True)
update(
event="started",
stage="开始混元图生3D",
progress=1,
job_id=job_id,
image=str(image_path),
output=str(output_path),
faces=face_label,
)
try:
ensure_login(
interactive=interactive_login,
timeout_sec=login_timeout,
update=update,
)
attempts = 0
while True:
attempts += 1
try:
mesh = generate_from_image(
image_path,
work_dir,
update,
face_label=face_label,
headed=False,
)
break
except HunyuanReloginNeeded as exc:
reason = getattr(exc, "reason", "auth") or "auth"
if isinstance(exc, HunyuanQuotaExceeded):
reason = "quota"
if not interactive_login or attempts > max_login_retries:
raise GenerateError(str(exc)) from exc
update(
event="login_required",
stage=(
"当前账号额度已满,打开登录页换号"
if reason == "quota"
else "凭证失效,重新微信登录"
),
progress=3,
error=str(exc),
reason=reason,
attempt=attempts,
)
print(
f"[hunyuan] {exc}\n"
f"[hunyuan] 即将打开浏览器登录页(原因={reason},第 {attempts}/{max_login_retries} 次)",
flush=True,
)
if is_logged_in():
logout()
start_wechat_login_blocking(
timeout_sec=login_timeout,
update=update,
reason=reason,
clear_existing=True,
)
shutil.copy2(mesh, output_path)
result = {
"ok": True,
"job_id": job_id,
"status": "succeeded",
"progress": 100,
"faces": face_label,
"image": str(image_path),
"output": str(output_path),
"work_dir": str(work_dir),
"bytes": output_path.stat().st_size,
"hint": "生成完成后请在 Doc/ResDesc_Model.md 登记该资源",
}
callbacks.emit("succeeded", **result)
return result
except GenerateError:
raise
except Exception as exc:
err = {"ok": False, "error": str(exc), "job_id": job_id, "work_dir": str(work_dir)}
callbacks.emit("failed", **err)
raise GenerateError(str(exc)) from exc
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="hunyuan_3d_generate",
description="腾讯混元3D 图生3D(微信登录 + Playwright + GLB 下载)",
)
p.add_argument("--image", "-i", help="参考图本地路径")
p.add_argument("--output", "-o", help="输出 GLB 路径(文件或目录)")
p.add_argument(
"--faces",
default=FACE_LABEL,
help=f"原始面数:{'/'.join(FACE_LABELS)},默认 {FACE_LABEL}",
)
p.add_argument(
"--login",
action="store_true",
help="仅弹出微信扫码登录并保存凭证",
)
p.add_argument("--logout", action="store_true", help="清除本地混元登录凭证")
p.add_argument("--status", action="store_true", help="打印当前登录状态 JSON")
p.add_argument(
"--no-interactive-login",
action="store_true",
help="未登录时不弹窗,直接失败(适合 CI)",
)
p.add_argument(
"--login-timeout",
type=int,
default=300,
help="微信扫码等待秒数,默认 300",
)
p.add_argument(
"--force-login",
action="store_true",
help="忽略本地凭证,强制弹出登录页(换号)",
)
p.add_argument("--callback-url", default=None, help="流程回调 URL:每个事件 POST JSON")
p.add_argument(
"--callback-file",
default=None,
help="流程回调状态文件(持续覆盖写入 latest/history)",
)
p.add_argument(
"--json",
dest="json_input",
default=None,
help='JSON 参数(对象字符串 / 文件路径 / "-" 读 stdin)',
)
p.add_argument(
"--result-only",
action="store_true",
help="只输出最终结果 JSON(抑制中间 NDJSON 事件到 stdout,仍写回调)",
)
p.add_argument(
"--quiet",
action="store_true",
help="不向 stdout 打印事件流(最终结果仍打印)",
)
return p
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
payload = _load_json_arg(args.json_input)
quiet_events = bool(args.quiet or args.result_only or payload.get("quiet"))
if args.logout or payload.get("logout"):
print(json.dumps(logout(), ensure_ascii=False), flush=True)
return 0
if args.status or payload.get("status"):
print(json.dumps(status(), ensure_ascii=False), flush=True)
return 0
if args.login or payload.get("login"):
callbacks = CallbackHub(
callback_url=payload.get("callback_url") or args.callback_url,
callback_file=payload.get("callback_file") or args.callback_file,
quiet=quiet_events,
)
update = _make_update(callbacks)
force = bool(args.force_login or payload.get("force_login"))
reason = str(payload.get("reason") or ("quota" if force else "auth"))
try:
result = start_wechat_login_blocking(
timeout_sec=int(payload.get("login_timeout") or args.login_timeout),
update=update,
reason=reason,
clear_existing=force or reason == "quota",
)
except Exception as exc: # noqa: BLE001
err = {"ok": False, "error": str(exc)}
print(json.dumps(err, ensure_ascii=False), flush=True)
return 1
print(json.dumps({"ok": True, **result}, ensure_ascii=False), flush=True)
return 0
image = payload.get("image") or args.image
output = payload.get("output") or args.output
if not image or not output:
parser.error("生成模式必须提供 --image/-i 与 --output/-o(或 JSON 同名字段)")
try:
result = run_generate(
image=image,
output=output,
faces=payload.get("faces") or args.faces,
interactive_login=not (
args.no_interactive_login or payload.get("no_interactive_login")
),
login_timeout=int(payload.get("login_timeout") or args.login_timeout),
callback_url=payload.get("callback_url") or args.callback_url,
callback_file=payload.get("callback_file") or args.callback_file,
quiet=quiet_events,
)
except GenerateError as exc:
err = {"ok": False, "error": str(exc)}
print(json.dumps(err, ensure_ascii=False), flush=True)
return 1
except Exception as exc: # noqa: BLE001
err = {"ok": False, "error": str(exc)}
print(json.dumps(err, ensure_ascii=False), flush=True)
return 2
print(json.dumps(result, ensure_ascii=False), flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())