Files
hy3d/hy3d/cli.py
T
KawasakiAkasei 3e85d6f987 refactor: move config dir to ~/.config/hy3d, replace skill with run-hy3d
- 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)
2026-08-15 22:44:56 +08:00

471 lines
17 KiB
Python
Raw 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
"""
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 换取 resourceUrlURL 原样返回。"""
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())