- config dir renamed hunyuan3dweb -> hy3d (cookies/profile moved intact, auth re-verified: quota 20/20) - cli.py/login.py/READMEs/doc paths updated to ~/.config/hy3d - skill: delete run-hunyuan3dweb, add run-hy3d pointing at bin/hy3d - zero stale old-name references remain (one historical note in skill)
471 lines
17 KiB
Python
471 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
hy3d — 腾讯混元 3D 非官方 CLI
|
||
|
||
用法: hy3d [--json] [--cookies PATH] <command> [args...]
|
||
|
||
全局选项(必须放在命令前):
|
||
--json 机器可读输出(默认 human 格式)
|
||
--cookies PATH 指定 cookie 文件(默认 ~/.config/hy3d/cookies.txt,可用环境变量 HY3D_COOKIES 覆盖)
|
||
|
||
退出码:
|
||
0 成功
|
||
1 环境/网络/参数错误
|
||
2 认证失败(token 无效/过期 → 需重新登录)
|
||
3 cookie 文件缺失
|
||
|
||
示例:
|
||
hy3d quota
|
||
hy3d text "a red teapot" --style cyberpunk --wait
|
||
hy3d image ./photo.png --wait
|
||
hy3d formats <creationsId>
|
||
hy3d download <creationsId> --format glb -o model.glb
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
from typing import Callable, Optional
|
||
|
||
import requests
|
||
|
||
from .config import get_cookie_path
|
||
|
||
DEFAULT_COOKIE = str(get_cookie_path())
|
||
|
||
# ---------- 常量(与 api_complete.Hunyuan3DAPI 对齐) ----------
|
||
|
||
MOTIONS = {
|
||
"capoeira": 9,
|
||
"falling": 10,
|
||
"jumping": 11,
|
||
"kicking": 12,
|
||
"sword": 13,
|
||
"running": 15,
|
||
"dancing": 16,
|
||
}
|
||
STYLES = ["", "sculpture", "qinghuaci", "china_style", "cartoon", "cyberpunk"]
|
||
FORMAT_KEYS = [
|
||
"glb", "obj", "mtl", "obj_url", "geometryGlb", "textureGlb", "textureObj",
|
||
"image_url", "pbrImage", "pbrMetallicImage", "pbrRoughnessImage",
|
||
"pbrNormalImage", "invisible_wall", "air_wall",
|
||
"fbx", "stl", "usdz", "mp4", "gif",
|
||
]
|
||
|
||
|
||
# ---------- 输出与认证 ----------
|
||
|
||
def out(obj, args):
|
||
"""按 --json/human 输出结果。args 可为 None(默认 human)。"""
|
||
if getattr(args, "json", False):
|
||
print(json.dumps(obj, ensure_ascii=False, indent=2))
|
||
else:
|
||
print(obj)
|
||
|
||
|
||
def make_api(args):
|
||
"""构造 API 客户端,加载 cookie(--cookies PATH 或 HY3D_COOKIES 覆盖默认)。"""
|
||
from .api_complete import Hunyuan3DAPI, load_cookies_from_file
|
||
path = args.cookies or os.environ.get("HY3D_COOKIES") or DEFAULT_COOKIE
|
||
try:
|
||
cookies = load_cookies_from_file(path)
|
||
except FileNotFoundError:
|
||
print(f"ERR3: cookie 文件不存在: {path}", file=sys.stderr)
|
||
print(" 首次使用需登录(真实终端,勿用管道):", file=sys.stderr)
|
||
print(" python -m hy3d.browser.login", file=sys.stderr)
|
||
sys.exit(3)
|
||
return Hunyuan3DAPI(cookies)
|
||
|
||
|
||
def classify_http_error(e: requests.HTTPError) -> Optional[int]:
|
||
"""把 HTTP 错误分类成退出码;返回 None 表示无法分类(调用方按 1 处理)。"""
|
||
resp = getattr(e, "response", None)
|
||
if resp is None:
|
||
return None
|
||
try:
|
||
body = resp.json()
|
||
except Exception:
|
||
return None
|
||
code = (body.get("error") or {}).get("code")
|
||
if resp.status_code == 401:
|
||
if code == "20001":
|
||
print("ERR2: token 无效/过期 → 需重新登录", file=sys.stderr)
|
||
print(" python -m hy3d.browser.login (在真实终端运行,按提示输邮箱+验证码)", file=sys.stderr)
|
||
elif code == "999":
|
||
print("ERR2: cookie 中没有用户 → 检查 cookie 或重新登录", file=sys.stderr)
|
||
else:
|
||
print(f"ERR2: 认证失败 (HTTP 401, code={code})", file=sys.stderr)
|
||
return 2
|
||
if resp.status_code == 400:
|
||
print(f"ERR1: 请求被拒 (HTTP 400, code={code}) — creationsId 可能不存在或参数无效", file=sys.stderr)
|
||
return 1
|
||
return None
|
||
|
||
|
||
def run_checked(fn: Callable, *args, **kw) -> int:
|
||
"""执行 API 调用并统一处理退出码。成功返回 0。"""
|
||
try:
|
||
fn(*args, **kw)
|
||
except requests.HTTPError as e:
|
||
code = classify_http_error(e)
|
||
sys.exit(code if code is not None else 1)
|
||
except (requests.ConnectionError, requests.Timeout) as e:
|
||
print(f"ERR1: 网络错误: {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
except Exception as e:
|
||
print(f"ERR1: {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
return 0
|
||
|
||
|
||
def submit_and_maybe_wait(api, args, submit: Callable[[], dict]):
|
||
"""提交生成任务;--wait 时轮询到完成并输出最终结果。"""
|
||
result = submit()
|
||
cid = result.get("creationsId")
|
||
if not cid:
|
||
out(result, args)
|
||
return
|
||
print(f"提交成功: creationsId={cid}")
|
||
if not args.wait:
|
||
print("用 `hy3d status <id>` 查询进度,或 `hy3d status <id> --wait` 等待完成")
|
||
print("用 `hy3d formats <id>` / `hy3d download <id> --format glb` 取模型")
|
||
return
|
||
# --wait: 轮询(进度走 stderr,保持 stdout 干净)
|
||
deadline = time.time() + args.timeout
|
||
while time.time() < deadline:
|
||
detail = api.get_generation_status(cid)
|
||
data = detail.get("data", detail)
|
||
state = data.get("status") or data.get("state")
|
||
if state == "success":
|
||
print(f"\n完成 ✓ (耗时 {args.timeout - (deadline - time.time()):.0f}s)" if args.json else "\n完成 ✓")
|
||
if args.json:
|
||
print(json.dumps(detail, ensure_ascii=False, indent=2))
|
||
else:
|
||
urls = api.get_model_urls(cid)
|
||
for k, u in urls.items():
|
||
print(f" {k}: {u}")
|
||
return
|
||
if state == "fail":
|
||
print(f"ERR1: 生成失败: {data}", file=sys.stderr)
|
||
sys.exit(1)
|
||
print(f" 状态: {state}, 进度: {data.get('progress', 0)}%", file=sys.stderr)
|
||
time.sleep(5)
|
||
print(f"ERR1: 等待超时({args.timeout}s),任务仍在后台运行", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
def parse_local_or_url(value: str, uploader) -> str:
|
||
"""本地文件自动上传 COS 换取 resourceUrl;URL 原样返回。"""
|
||
if os.path.isfile(value):
|
||
return uploader(value)
|
||
return value
|
||
|
||
|
||
# ---------- 各命令 ----------
|
||
|
||
def cmd_quota(api, args):
|
||
q = api.get_quota_info()
|
||
if args.json:
|
||
out(q, args)
|
||
else:
|
||
out(f"剩余 {q.get('remainQuota', '?')} / 总 {q.get('totalQuota', '?')}"
|
||
f"(已消耗 {q.get('consumeQuota', 0)})", args)
|
||
|
||
|
||
def cmd_count(api, args):
|
||
out(api.get_creation_count(), args)
|
||
|
||
|
||
def cmd_list(api, args):
|
||
data = api.get_creation_list(args.page, args.page_size)
|
||
if args.json:
|
||
out(data, args)
|
||
else:
|
||
items = data.get("creations", [])
|
||
print(f"共 {data.get('totalCount', len(items))} 个作品:")
|
||
for it in items:
|
||
print(f" {it.get('creationsId', it.get('id'))} {it.get('status', ''):10s} "
|
||
f"{str(it.get('title'))[:40]}")
|
||
if not items:
|
||
print(" (空)")
|
||
|
||
|
||
def cmd_status(api, args):
|
||
if args.wait:
|
||
deadline = time.time() + args.timeout
|
||
while time.time() < deadline:
|
||
data = api.get_generation_status(args.creation_id)
|
||
d = data.get("data", data)
|
||
state = d.get("status") or d.get("state")
|
||
if state == "success":
|
||
out(d, args)
|
||
return
|
||
if state == "fail":
|
||
print(f"ERR1: 生成失败: {d}", file=sys.stderr)
|
||
sys.exit(1)
|
||
print(f" 状态: {state}, 进度: {d.get('progress', 0)}%", file=sys.stderr)
|
||
time.sleep(5)
|
||
print(f"ERR1: 等待超时({args.timeout}s),任务仍在后台运行", file=sys.stderr)
|
||
sys.exit(1)
|
||
data = api.get_generation_status(args.creation_id)
|
||
if args.json:
|
||
out(data, args)
|
||
else:
|
||
d = data.get("data", data)
|
||
state = d.get("status") or d.get("state")
|
||
print(f"状态: {state} 进度: {d.get('progress', 0)}%")
|
||
if state == "success":
|
||
for k, u in api.get_model_urls(args.creation_id).items():
|
||
print(f" {k}: {u}")
|
||
|
||
|
||
def cmd_formats(api, args):
|
||
urls = api.get_model_urls(args.creation_id, include_converted=args.converted)
|
||
if not urls:
|
||
print("暂无可用格式(生成未完成或失败)", file=sys.stderr)
|
||
sys.exit(1)
|
||
if args.json:
|
||
out(urls, args)
|
||
else:
|
||
for k, u in urls.items():
|
||
print(f" {k}: {u}")
|
||
|
||
|
||
def cmd_download(api, args):
|
||
path = api.download_model(args.creation_id, args.format, args.output,
|
||
include_converted=args.converted)
|
||
print(f"已下载: {path}")
|
||
|
||
|
||
def cmd_cancel(api, args):
|
||
out(api.cancel_generation(args.creation_id), args)
|
||
|
||
|
||
def cmd_share(api, args):
|
||
out(api.create_share(args.creation_id, args.platform), args)
|
||
|
||
|
||
def cmd_user(api, args):
|
||
out(api.get_user_info(), args)
|
||
|
||
|
||
def cmd_auth(api, args):
|
||
"""认证体检:cookie 存在性 + 配额接口连通性。"""
|
||
if args.sub == "login":
|
||
print("登录需人工在真实终端完成(Claude 的 ! 前缀 stdin 非交互,input() 会 EOF):")
|
||
print(" python -m hy3d.browser.login")
|
||
print("流程: 输邮箱 → 收验证码 → 输验证码 → cookie 自动存回 "
|
||
f"{DEFAULT_COOKIE}")
|
||
return
|
||
# auth status
|
||
import os as _os
|
||
p = args.cookies or _os.environ.get("HY3D_COOKIES") or DEFAULT_COOKIE
|
||
if not _os.path.isfile(p):
|
||
print(f"ERR3: cookie 文件不存在: {p}", file=sys.stderr)
|
||
sys.exit(3)
|
||
q = api.get_quota_info()
|
||
print(f"登录有效 ✓ cookie: {p}")
|
||
print(f"剩余配额: {q.get('remainQuota')} / {q.get('totalQuota')}")
|
||
sys.exit(0)
|
||
|
||
|
||
def cmd_config(api, args):
|
||
from .config import get_config_dir, get_profile_dir
|
||
print(f"配置目录: {get_config_dir()}")
|
||
print(f"cookie 文件: {get_cookie_path()}")
|
||
print(f"浏览器配置: {get_profile_dir()}")
|
||
print("环境变量: HY3D_COOKIES(覆盖 cookie 路径)、HY3D_JSON(等同 --json)")
|
||
|
||
|
||
# ---------- 生成命令 ----------
|
||
|
||
def cmd_text(api, args):
|
||
submit_and_maybe_wait(api, args, lambda: api.generate_from_text(
|
||
args.prompt, title=args.title, style=args.style, count=args.count))
|
||
|
||
|
||
def cmd_image(api, args):
|
||
from .cos_upload import upload_image
|
||
def submit():
|
||
resource = parse_local_or_url(args.image, upload_image)
|
||
return api.generate_from_image(resource, title=args.title, style=args.style)
|
||
submit_and_maybe_wait(api, args, submit)
|
||
|
||
|
||
def cmd_multi_view(api, args):
|
||
from .cos_upload import upload_image
|
||
def submit():
|
||
urls = [parse_local_or_url(v, upload_image) for v in args.images]
|
||
return api.generate_from_multi_view(urls, title=args.title, style=args.style)
|
||
submit_and_maybe_wait(api, args, submit)
|
||
|
||
|
||
def cmd_sketch(api, args):
|
||
from .cos_upload import upload_image
|
||
def submit():
|
||
resource = parse_local_or_url(args.sketch, upload_image)
|
||
return api.generate_from_sketch(resource, prompt=args.prompt,
|
||
title=args.title, style=args.style)
|
||
submit_and_maybe_wait(api, args, submit)
|
||
|
||
|
||
def cmd_animate(api, args):
|
||
from .cos_upload import upload_image
|
||
motion = MOTIONS.get(args.motion)
|
||
if motion is None:
|
||
print(f"ERR1: 未知动作 '{args.motion}',可选: {', '.join(MOTIONS)}", file=sys.stderr)
|
||
sys.exit(1)
|
||
def submit():
|
||
resource = parse_local_or_url(args.model, upload_image)
|
||
return api.generate_animation(resource, motion_type=motion, title=args.title)
|
||
submit_and_maybe_wait(api, args, submit)
|
||
|
||
|
||
def cmd_texture(api, args):
|
||
from .cos_upload import upload_image
|
||
def submit():
|
||
resource = parse_local_or_url(args.model, upload_image)
|
||
return api.generate_texture(resource, prompt=args.prompt, title=args.title)
|
||
submit_and_maybe_wait(api, args, submit)
|
||
|
||
|
||
def cmd_topo(api, args):
|
||
from .cos_upload import upload_image
|
||
def submit():
|
||
resource = parse_local_or_url(args.model, upload_image)
|
||
return api.generate_lowpoly(resource, face_count=args.faces,
|
||
topology_format=args.format, title=args.title)
|
||
submit_and_maybe_wait(api, args, submit)
|
||
|
||
|
||
# ---------- 主入口 ----------
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
p = argparse.ArgumentParser(
|
||
prog="hy3d",
|
||
description="腾讯混元 3D 非官方 CLI(需要登录 cookie,见 hy3d auth login)")
|
||
p.add_argument("--json", action="store_true", help="机器可读 JSON 输出")
|
||
p.add_argument("--cookies", default=None, help="cookie 文件路径(默认 ~/.config/hy3d/cookies.txt)")
|
||
sub = p.add_subparsers(dest="command", required=True)
|
||
|
||
sub.add_parser("quota", help="查询配额")
|
||
sub.add_parser("count", help="作品数量统计")
|
||
sub.add_parser("user", help="用户信息")
|
||
sub.add_parser("config", help="显示配置路径与环境变量")
|
||
|
||
sp = sub.add_parser("list", help="作品列表")
|
||
sp.add_argument("--page", type=int, default=1)
|
||
sp.add_argument("--page-size", dest="page_size", type=int, default=20)
|
||
|
||
sp = sub.add_parser("status", help="查询生成状态/详情")
|
||
sp.add_argument("creation_id")
|
||
sp.add_argument("--wait", action="store_true", help="轮询等待完成")
|
||
sp.add_argument("--timeout", type=int, default=600)
|
||
|
||
sp = sub.add_parser("formats", help="列出可用下载格式")
|
||
sp.add_argument("creation_id")
|
||
sp.add_argument("--converted", action="store_true", help="包含转换格式 fbx/stl/usdz/mp4/gif")
|
||
|
||
sp = sub.add_parser("download", help="下载模型")
|
||
sp.add_argument("creation_id")
|
||
sp.add_argument("--format", default="glb", choices=FORMAT_KEYS, help="格式键名(默认 glb)")
|
||
sp.add_argument("-o", "--output", default=None, help="保存路径(默认 URL 推断)")
|
||
sp.add_argument("--converted", action="store_true", help="允许调用转换接口获取目标格式")
|
||
|
||
sp = sub.add_parser("cancel", help="取消生成任务")
|
||
sp.add_argument("creation_id")
|
||
|
||
sp = sub.add_parser("share", help="生成分享链接")
|
||
sp.add_argument("creation_id")
|
||
sp.add_argument("--platform", default="3dPlayground")
|
||
|
||
# --- 生成命令(均支持 --wait) ---
|
||
def generation_flags(sp):
|
||
sp.add_argument("--wait", action="store_true", help="提交后轮询到完成")
|
||
sp.add_argument("--timeout", type=int, default=600, help="--wait 超时秒数")
|
||
sp.add_argument("--title", default="", help="作品标题")
|
||
sp.add_argument("--style", default="", choices=STYLES,
|
||
help=f"纹理风格: {', '.join(s or 'default' for s in STYLES[1:])}")
|
||
|
||
sp = sub.add_parser("text", help="文生 3D(消耗 4 次配额,出 4 个模型)")
|
||
sp.add_argument("prompt")
|
||
sp.add_argument("--count", type=int, default=4, help="生成数量(服务端固定 4)")
|
||
generation_flags(sp)
|
||
|
||
sp = sub.add_parser("image", help="图生 3D(本地图片自动上传 COS)")
|
||
sp.add_argument("image", help="本地图片路径或 resourceUrl")
|
||
generation_flags(sp)
|
||
|
||
sp = sub.add_parser("multi-view", help="多视角图生 3D(≥2 张)")
|
||
sp.add_argument("images", nargs="+", help="多张图片路径或 URL(不同角度)")
|
||
generation_flags(sp)
|
||
|
||
sp = sub.add_parser("sketch", help="草图生 3D")
|
||
sp.add_argument("sketch", help="草图图片路径或 URL")
|
||
sp.add_argument("--prompt", required=True, help="草图描述提示词")
|
||
generation_flags(sp)
|
||
|
||
sp = sub.add_parser("animate", help="3D 动画生成")
|
||
sp.add_argument("model", help="3D 模型图片(路径或 URL)")
|
||
sp.add_argument("--motion", required=True, choices=list(MOTIONS),
|
||
help=f"动作: {', '.join(MOTIONS)}")
|
||
generation_flags(sp)
|
||
|
||
sp = sub.add_parser("texture", help="3D 纹理生成(白模上色)")
|
||
sp.add_argument("model", help="白模图片(路径或 URL)")
|
||
sp.add_argument("--prompt", required=True, help="纹理描述")
|
||
generation_flags(sp)
|
||
|
||
sp = sub.add_parser("topo", help="3D 智能拓扑(减面)")
|
||
sp.add_argument("model", help="模型图片(路径或 URL)")
|
||
sp.add_argument("--faces", type=int, default=5000, choices=[5000, 18000, 30000])
|
||
sp.add_argument("--format", default="glb", choices=["glb", "obj"])
|
||
generation_flags(sp)
|
||
|
||
# --- 认证 ---
|
||
sp = sub.add_parser("auth", help="认证体检/登录指引")
|
||
sp.add_argument("sub", nargs="?", default="status", choices=["status", "login"])
|
||
|
||
return p
|
||
|
||
|
||
def main(argv=None):
|
||
args = build_parser().parse_args(argv)
|
||
if os.environ.get("HY3D_JSON") == "1":
|
||
args.json = True
|
||
|
||
# auth login 不需要 API 实例
|
||
if args.command == "auth" and args.sub == "login":
|
||
cmd_auth(None, args)
|
||
return 0
|
||
if args.command == "config":
|
||
cmd_config(None, args)
|
||
return 0
|
||
|
||
api = make_api(args)
|
||
dispatch = {
|
||
"quota": lambda: cmd_quota(api, args),
|
||
"count": lambda: cmd_count(api, args),
|
||
"user": lambda: cmd_user(api, args),
|
||
"list": lambda: cmd_list(api, args),
|
||
"status": lambda: cmd_status(api, args),
|
||
"formats": lambda: cmd_formats(api, args),
|
||
"download": lambda: cmd_download(api, args),
|
||
"cancel": lambda: cmd_cancel(api, args),
|
||
"share": lambda: cmd_share(api, args),
|
||
"auth": lambda: cmd_auth(api, args),
|
||
"text": lambda: cmd_text(api, args),
|
||
"image": lambda: cmd_image(api, args),
|
||
"multi-view": lambda: cmd_multi_view(api, args),
|
||
"sketch": lambda: cmd_sketch(api, args),
|
||
"animate": lambda: cmd_animate(api, args),
|
||
"texture": lambda: cmd_texture(api, args),
|
||
"topo": lambda: cmd_topo(api, args),
|
||
}
|
||
return run_checked(dispatch.get(args.command, lambda: None))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main()) |