tripocli v2.0.0:Tripo AI 3D 生成与加工 CLI(面向 Agent 与开发者,纯 Python 标准库、零第三方依赖)
覆盖文生 3D、图生 3D、多视图、批量生成,以及重拓扑、绑定、动画、智能分割、 纹理、PBR、智能 UV、部件补全、格式导出与下载。 代码组织:一个功能一个模块(tripo/features/),入口薄壳 tripocli.py。
This commit is contained in:
commit
977f97995a
60 files changed
+5622
No files matched your search
@@ -0,0 +1,9 @@
|
||||
"""tripocli - Tripo AI 3D generation toolkit for agents and developers.
|
||||
|
||||
The package is split by feature: every Tripo capability lives in its own module
|
||||
under ``tripo/features`` so the CLI can keep growing without becoming one giant
|
||||
file. ``tripo/client.py`` holds the HTTP plumbing, ``tripo/config.py`` the
|
||||
credentials, and ``tripo/cli.py`` wires the feature modules into an argparse CLI.
|
||||
"""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Multi-account credential store.
|
||||
|
||||
A user hands the CLI a session cookie once (paste it, or pass ``TRIPO_COOKIE``);
|
||||
from then on the CLI mints short-lived JWTs on demand over plain HTTP — no
|
||||
browser, no repeated login.
|
||||
|
||||
~/.tripo/accounts.json
|
||||
{
|
||||
"active": "main",
|
||||
"accounts": {
|
||||
"main": {"cookie": "...", "token": "...", "session_expires_at": "...",
|
||||
"user_id": "...", "email": "...", "added_at": 1791800000}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_ACCOUNTS_PATH = Path.home() / ".tripo" / "accounts.json"
|
||||
|
||||
|
||||
class Accounts:
|
||||
def __init__(self, path=None):
|
||||
self.path = Path(path) if path else DEFAULT_ACCOUNTS_PATH
|
||||
self.data = {"active": None, "accounts": {}}
|
||||
self.load()
|
||||
|
||||
# --- persistence ---
|
||||
|
||||
def load(self):
|
||||
if self.path.exists():
|
||||
try:
|
||||
loaded = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
self.data = {
|
||||
"active": loaded.get("active"),
|
||||
"accounts": loaded.get("accounts", {}),
|
||||
}
|
||||
except Exception:
|
||||
self.data = {"active": None, "accounts": {}}
|
||||
|
||||
def save(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(json.dumps(self.data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
self.path.chmod(0o600) # holds a session cookie: keep it private
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# --- queries ---
|
||||
|
||||
def names(self):
|
||||
return sorted(self.data["accounts"])
|
||||
|
||||
def rotation(self, start=None):
|
||||
"""Account names starting at ``start`` (default: active), wrapping around.
|
||||
|
||||
Used by the optional auto-fallback so a command can try the next account
|
||||
when the current one is out of credits.
|
||||
"""
|
||||
names = self.names()
|
||||
if not names:
|
||||
return []
|
||||
start = start or self.data.get("active")
|
||||
if start not in names:
|
||||
return names
|
||||
index = names.index(start)
|
||||
return names[index:] + names[:index]
|
||||
|
||||
def exists(self, name):
|
||||
return name in self.data["accounts"]
|
||||
|
||||
def active_name(self):
|
||||
return self.data.get("active")
|
||||
|
||||
def get(self, name=None):
|
||||
"""Return ``(name, record)`` for ``name`` or the active account."""
|
||||
name = name or self.data.get("active")
|
||||
if not name or name not in self.data["accounts"]:
|
||||
return None, None
|
||||
return name, self.data["accounts"][name]
|
||||
|
||||
# --- mutations ---
|
||||
|
||||
def add(self, name, cookie, token=None, session_expires_at=None, user_id=None, email=None):
|
||||
record = self.data["accounts"].get(name, {})
|
||||
record.update({
|
||||
"cookie": cookie,
|
||||
"token": token,
|
||||
"session_expires_at": session_expires_at,
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"added_at": record.get("added_at") or int(time.time()),
|
||||
"updated_at": int(time.time()),
|
||||
})
|
||||
self.data["accounts"][name] = record
|
||||
if not self.data.get("active"):
|
||||
self.data["active"] = name
|
||||
self.save()
|
||||
return name
|
||||
|
||||
def use(self, name):
|
||||
if name not in self.data["accounts"]:
|
||||
raise KeyError(name)
|
||||
self.data["active"] = name
|
||||
self.save()
|
||||
|
||||
def remove(self, name):
|
||||
if name not in self.data["accounts"]:
|
||||
raise KeyError(name)
|
||||
del self.data["accounts"][name]
|
||||
if self.data.get("active") == name:
|
||||
self.data["active"] = self.names()[0] if self.data["accounts"] else None
|
||||
self.save()
|
||||
|
||||
def update_token(self, name, token, session_expires_at=None):
|
||||
record = self.data["accounts"].get(name)
|
||||
if record is None:
|
||||
return
|
||||
record["token"] = token
|
||||
record["updated_at"] = int(time.time())
|
||||
if session_expires_at:
|
||||
record["session_expires_at"] = session_expires_at
|
||||
self.save()
|
||||
|
||||
def update_cookie(self, name, cookie):
|
||||
record = self.data["accounts"].get(name)
|
||||
if record is None:
|
||||
return
|
||||
record["cookie"] = cookie
|
||||
self.save()
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
"""Mint the short-lived JWT that the API needs, from a session cookie.
|
||||
|
||||
The web session cookie (``ory_kratos_session``, ~30 days) authenticates
|
||||
``GET /v2/studio/studio/whoami?tokenizeAs=default_jwt`` over plain HTTP and
|
||||
returns a ``tokenized`` JWT valid for ~15 minutes. Verified live: a cookie alone
|
||||
is enough — no browser, no device id.
|
||||
|
||||
The endpoint occasionally answers a transient ``401`` under rapid repeated calls,
|
||||
so minting retries a few times with a short backoff before giving up.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .errors import TripoAPIError
|
||||
|
||||
ORIGIN = "https://studio.tripo3d.ai"
|
||||
COOKIE_NAME = "ory_kratos_session"
|
||||
WHOAMI = "/v2/studio/studio/whoami?tokenizeAs=default_jwt"
|
||||
|
||||
RETRY_ATTEMPTS = 3
|
||||
RETRY_BACKOFF = 1.5
|
||||
|
||||
|
||||
def _request(api_base, cookie, device_id=None, timeout=30):
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Origin": ORIGIN,
|
||||
"Referer": ORIGIN + "/",
|
||||
"User-Agent": "tripocli/2.0.0",
|
||||
"Cookie": cookie,
|
||||
}
|
||||
if device_id:
|
||||
headers["x-tripo-device-id"] = device_id
|
||||
|
||||
request = urllib.request.Request(f"{api_base}{WHOAMI}", headers=headers, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", "replace")
|
||||
raise TripoAPIError(
|
||||
f"会话换取 JWT 失败 (HTTP {error.code}): {body[:200]}", code=error.code
|
||||
) from None
|
||||
except urllib.error.URLError as error:
|
||||
raise TripoAPIError(f"网络错误: {error.reason}") from None
|
||||
|
||||
|
||||
def decode_jwt_payload(token):
|
||||
"""Return the JWT payload, or ``{}`` when it cannot be decoded."""
|
||||
try:
|
||||
parts = (token or "").split(".")
|
||||
if len(parts) >= 2:
|
||||
payload = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(payload).decode("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def normalize_cookie(cookie):
|
||||
"""Accept either a bare cookie value or a full ``name=value; name2=value2`` string."""
|
||||
cookie = (cookie or "").strip().strip('"').strip("'")
|
||||
if not cookie:
|
||||
raise ValueError("cookie 为空")
|
||||
if "=" not in cookie:
|
||||
cookie = f"{COOKIE_NAME}={cookie}"
|
||||
return cookie
|
||||
|
||||
|
||||
def fetch_identity(api_base, cookie, device_id=None):
|
||||
"""Read ``(user_id, email)`` for a session cookie.
|
||||
|
||||
Note ``/studio/whoami`` authenticates with the *cookie*, not the Bearer JWT
|
||||
(a JWT-only call answers 401), so identity lookup goes through the same
|
||||
cookie path as minting.
|
||||
"""
|
||||
cookie = normalize_cookie(cookie)
|
||||
data = _request(api_base, cookie, device_id)
|
||||
traits = (data.get("identity") or {}).get("traits") or {}
|
||||
user_id = data.get("id") or decode_jwt_payload(data.get("tokenized") or "").get("sub")
|
||||
return user_id, traits.get("email")
|
||||
|
||||
|
||||
def mint_token(api_base, cookie, device_id=None):
|
||||
"""Exchange a session cookie for ``(token, session_expires_at)``."""
|
||||
cookie = normalize_cookie(cookie)
|
||||
|
||||
last_error = None
|
||||
for attempt in range(1, RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
data = _request(api_base, cookie, device_id)
|
||||
except TripoAPIError as error:
|
||||
last_error = error
|
||||
# 401 here is usually a transient throttle; 5xx/network also worth retrying.
|
||||
if error.code in (401, 403, 429, 500, 502, 503, 504, None) and attempt < RETRY_ATTEMPTS:
|
||||
time.sleep(RETRY_BACKOFF * attempt)
|
||||
continue
|
||||
raise
|
||||
|
||||
token = data.get("tokenized")
|
||||
if not token:
|
||||
raise TripoAPIError("会话有效但未返回 tokenized,cookie 可能已失效", raw=data)
|
||||
return token, data.get("expires_at")
|
||||
|
||||
raise last_error
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
"""argparse wiring and dispatch.
|
||||
|
||||
``tripocli`` is a thin shell: this module builds the command tree from the
|
||||
feature modules, resolves credentials (re-minting the JWT from the account's
|
||||
session cookie when needed) and hands control to the selected feature.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from . import __version__
|
||||
from .accounts import Accounts
|
||||
from .client import TripoClient
|
||||
from .config import Config
|
||||
from .errors import TripoAPIError, TripoTimeout, is_insufficient_credits
|
||||
from .features import MODULES
|
||||
from .output import Out
|
||||
|
||||
EPILOG = """\
|
||||
常用示例:
|
||||
tripocli account add --cookie "<粘贴浏览器 cookie>" # 一次性添加账号,之后免浏览器
|
||||
tripocli account list # 多账号查看/切换
|
||||
tripocli serve --open # 打开网页版账号管理(看积分/有效期)
|
||||
tripocli update # 改完源码后更新全局 CLI
|
||||
tripocli balance # 查看积分
|
||||
tripocli text -p "a wooden barrel" --wait -o a.glb # 文生 3D
|
||||
tripocli image -i ./photo.png --wait -o b.glb # 单图生 3D
|
||||
tripocli multiview --front f.png --left l.png --right r.png --back b.png --wait -o c.glb
|
||||
tripocli batch -i a.png,b.png --wait -o ./out # 批量图生 3D
|
||||
tripocli remesh -p <project_id> --face-limit 8000 --wait -o low.glb
|
||||
tripocli rig -p <project_id> --type biped --wait # 绑定骨骼
|
||||
tripocli animate -p <project_id> --preset idle --wait -o anim.glb
|
||||
tripocli segment -p <project_id> --wait # 智能分割
|
||||
tripocli texture -p <project_id> --resolution 2K --wait
|
||||
tripocli pbr -p <project_id> --wait # PBR 材质
|
||||
tripocli upscale -p <project_id> --wait # 纹理放大
|
||||
tripocli export -p <project_id> -f fbx --wait -o m.fbx
|
||||
|
||||
凭据:只需提供一次浏览器 cookie,CLI 会自行续期 JWT(cookie 约 30 天有效)。
|
||||
也可用环境变量 TRIPO_COOKIE 提供;多账号用 --account <名字> 指定。
|
||||
加 --json 可输出机器可读结果(Agent 调用推荐)。
|
||||
"""
|
||||
|
||||
# Commands that must run without any credentials.
|
||||
NO_CREDENTIAL_COMMANDS = {"account", "config", "serve", "update", "inspect", "optimize"}
|
||||
|
||||
|
||||
class Context:
|
||||
"""Everything a feature needs: client, config, accounts, output sink and args."""
|
||||
|
||||
def __init__(self, client, config, accounts, out, args):
|
||||
self.client = client
|
||||
self.config = config
|
||||
self.accounts = accounts
|
||||
self.out = out
|
||||
self.args = args
|
||||
|
||||
|
||||
def _add_json_flag_everywhere(parser):
|
||||
"""Allow ``--json`` after the subcommand as well as before it.
|
||||
|
||||
``argparse`` only accepts a parent parser's options before the subcommand, but
|
||||
agents habitually append ``--json`` at the end; SUPPRESS keeps the subcommand
|
||||
copy from overwriting a value given on the main parser.
|
||||
"""
|
||||
for action in parser._actions:
|
||||
if isinstance(action, argparse._SubParsersAction):
|
||||
for subparser in action.choices.values():
|
||||
if "--json" not in subparser._option_string_actions:
|
||||
subparser.add_argument(
|
||||
"--json", action="store_true", default=argparse.SUPPRESS,
|
||||
help="输出结构化 JSON(Agent 调用推荐)",
|
||||
)
|
||||
_add_json_flag_everywhere(subparser)
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="tripocli",
|
||||
description="Tripo AI 3D 生成 CLI(面向 Agent 与开发者)",
|
||||
epilog=EPILOG,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="输出结构化 JSON(Agent 调用推荐)")
|
||||
parser.add_argument("--account", help="使用指定账号(默认用当前账号)")
|
||||
parser.add_argument(
|
||||
"--no-fallback", action="store_true",
|
||||
help="关闭「额度不足自动切换下一个账号」(默认开启;设 TRIPO_AUTO_FALLBACK=0 也可关闭)",
|
||||
)
|
||||
parser.add_argument("--token", help="临时覆盖 Bearer JWT")
|
||||
parser.add_argument("--device-id", help="临时覆盖 x-tripo-device-id")
|
||||
parser.add_argument("--api-base", help="临时覆盖 API 根地址")
|
||||
parser.add_argument("-V", "--version", action="version", version=f"tripocli {__version__}")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", metavar="<命令>")
|
||||
for module in MODULES:
|
||||
if hasattr(module, "register"):
|
||||
module.register(subparsers)
|
||||
else:
|
||||
command = subparsers.add_parser(module.NAME, help=module.HELP)
|
||||
module.add_arguments(command)
|
||||
command.set_defaults(func=module.run)
|
||||
_add_json_flag_everywhere(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
handler = getattr(args, "func", None)
|
||||
if handler is None:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
out = Out(as_json=args.json)
|
||||
config = Config()
|
||||
accounts = Accounts()
|
||||
|
||||
needs_credentials = args.command not in NO_CREDENTIAL_COMMANDS
|
||||
# 默认开启账号回退;显式 --no-fallback 或 TRIPO_AUTO_FALLBACK=0 可关闭。
|
||||
env_fallback = os.environ.get("TRIPO_AUTO_FALLBACK", "").strip().lower()
|
||||
fallback = not args.no_fallback and env_fallback not in ("0", "false", "no")
|
||||
if needs_credentials and fallback and len(accounts.names()) > 1:
|
||||
order = accounts.rotation(args.account)
|
||||
else:
|
||||
order = [args.account]
|
||||
extra_codes = config.data.get("insufficient_credit_codes") or []
|
||||
|
||||
last_error = None
|
||||
for index, account in enumerate(order):
|
||||
client = TripoClient(
|
||||
token=args.token, device_id=args.device_id, api_base=args.api_base,
|
||||
account=account, accounts=accounts, config=config,
|
||||
)
|
||||
ctx = Context(client=client, config=config, accounts=accounts, out=out, args=args)
|
||||
try:
|
||||
if needs_credentials:
|
||||
client.ensure_credentials()
|
||||
handler(ctx)
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
out.error("已中断")
|
||||
return 130
|
||||
except (TripoAPIError, TripoTimeout, ValueError, FileNotFoundError, RuntimeError) as error:
|
||||
can_retry = (
|
||||
needs_credentials
|
||||
and fallback
|
||||
and index + 1 < len(order)
|
||||
and is_insufficient_credits(error, extra_codes)
|
||||
)
|
||||
if can_retry:
|
||||
out.notice(f"⚠️ 账号 {account or accounts.active_name()} 额度不足,切换到下一个账号重试…")
|
||||
last_error = error
|
||||
continue
|
||||
out.error(error)
|
||||
return 1
|
||||
except Exception as error: # unexpected: keep stdout parseable for agents
|
||||
out.error(f"运行错误: {error}")
|
||||
return 1
|
||||
|
||||
out.error(last_error or "没有可用账号")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
"""HTTP plumbing shared by every feature module.
|
||||
|
||||
Holds the transport concerns only: credentials resolution, headers, retries for
|
||||
transient failures, automatic JWT refresh from the account's session cookie,
|
||||
progress polling and file download. Feature specific request bodies live in
|
||||
``tripo/features/*``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from . import auth, glb, s3
|
||||
from .accounts import Accounts
|
||||
from .config import Config
|
||||
from .errors import TripoAPIError, TripoTimeout
|
||||
|
||||
# Transient transport/server failures worth retrying (the studio occasionally
|
||||
# returns a bare 502 mid-generation, which must not abort a running poll).
|
||||
TRANSIENT_STATUS = {429, 500, 502, 503, 504}
|
||||
RETRY_ATTEMPTS = 4
|
||||
RETRY_BACKOFF = 1.5
|
||||
|
||||
ORIGIN = "https://studio.tripo3d.ai"
|
||||
USER_AGENT = "tripocli/2.0.0 (Mozilla/5.0; AI Agent Client)"
|
||||
|
||||
# Model formats the studio's "上传 3D 模型" importer accepts (UI: OBJ/FBX/STL/GLB ≤150MB).
|
||||
MODEL_FORMATS = ("glb", "obj", "fbx", "stl")
|
||||
|
||||
NO_CREDENTIALS = (
|
||||
"未找到可用凭据。请先添加账号(粘贴浏览器 cookie 即可,之后 CLI 自动续期 JWT):\n"
|
||||
" tripocli account add --cookie \"<整条 Cookie 或 ory_kratos_session 的值>\"\n"
|
||||
"也可用环境变量 TRIPO_COOKIE 提供。"
|
||||
)
|
||||
|
||||
|
||||
def _jwt_expiry(token):
|
||||
import base64
|
||||
try:
|
||||
payload = token.split(".")[1]
|
||||
payload += "=" * (-len(payload) % 4)
|
||||
return int(json.loads(base64.urlsafe_b64decode(payload).decode()).get("exp") or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
class TripoClient:
|
||||
def __init__(self, token=None, device_id=None, api_base=None, account=None,
|
||||
auto_refresh=True, accounts=None, config=None):
|
||||
self.config = config or Config()
|
||||
self.accounts = accounts or Accounts()
|
||||
|
||||
env_token = os.environ.get("TRIPO_TOKEN") or os.environ.get("TRIPO_API_KEY")
|
||||
env_cookie = os.environ.get("TRIPO_COOKIE")
|
||||
self.account_name, record = self.accounts.get(account or os.environ.get("TRIPO_ACCOUNT"))
|
||||
record = record or {}
|
||||
|
||||
self.token = token or env_token or record.get("token")
|
||||
self.cookie = env_cookie or record.get("cookie")
|
||||
self.device_id = device_id or self.config.device_id
|
||||
self.api_base = (api_base or self.config.api_base).rstrip("/")
|
||||
self.auto_refresh = auto_refresh and not token and not env_token
|
||||
self.token_source = "env" if env_token else ("account" if record.get("token") else None)
|
||||
|
||||
# --- credentials ---
|
||||
|
||||
def token_expiring(self):
|
||||
exp = _jwt_expiry(self.token or "")
|
||||
return not exp or exp - time.time() < 120
|
||||
|
||||
def refresh_token(self):
|
||||
"""Mint a fresh JWT from the session cookie (pure HTTP, no browser)."""
|
||||
if not self.cookie:
|
||||
raise TripoAPIError(NO_CREDENTIALS, code="no-credentials")
|
||||
token, session_expires = auth.mint_token(self.api_base, self.cookie, self.device_id)
|
||||
self.token = token
|
||||
if self.account_name:
|
||||
self.accounts.update_token(self.account_name, token, session_expires)
|
||||
return True
|
||||
|
||||
def ensure_credentials(self):
|
||||
if self.token and not self.token_expiring():
|
||||
return
|
||||
if self.cookie:
|
||||
self.refresh_token()
|
||||
return
|
||||
if not self.token:
|
||||
raise TripoAPIError(NO_CREDENTIALS, code="no-credentials")
|
||||
|
||||
# --- transport ---
|
||||
|
||||
def _headers(self, extra=None):
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Origin": ORIGIN,
|
||||
"Referer": ORIGIN + "/",
|
||||
"User-Agent": USER_AGENT,
|
||||
"x-tripo-device-id": self.device_id,
|
||||
}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
if extra:
|
||||
headers.update(extra)
|
||||
return headers
|
||||
|
||||
def _raw_request(self, method, endpoint, params=None, body=None, headers=None, timeout=30):
|
||||
url = f"{self.api_base}{endpoint}"
|
||||
if params:
|
||||
query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
|
||||
payload = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
request = urllib.request.Request(
|
||||
url, data=payload, headers=self._headers(headers), method=method.upper()
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
text = response.read().decode("utf-8")
|
||||
if not text:
|
||||
return None
|
||||
return json.loads(text)
|
||||
except urllib.error.HTTPError as error:
|
||||
err_body = error.read().decode("utf-8", "replace")
|
||||
try:
|
||||
err_json = json.loads(err_body)
|
||||
except json.JSONDecodeError:
|
||||
raise TripoAPIError(f"HTTP {error.code}: {error.reason}", code=error.code) from None
|
||||
raise TripoAPIError(
|
||||
err_json.get("message", f"HTTP {error.code}"),
|
||||
code=err_json.get("code", error.code),
|
||||
suggestion=err_json.get("suggestion"),
|
||||
raw=err_json,
|
||||
) from None
|
||||
except urllib.error.URLError as error:
|
||||
raise TripoAPIError(f"Network error: {error.reason}") from None
|
||||
|
||||
def request(self, method, endpoint, params=None, body=None, headers=None, timeout=30):
|
||||
"""Perform an API call, retrying transient failures and expired tokens."""
|
||||
attempt = 0
|
||||
refreshed = False
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
result = self._raw_request(method, endpoint, params, body, headers, timeout)
|
||||
if isinstance(result, dict) and result.get("code") not in (None, 0):
|
||||
raise TripoAPIError(
|
||||
result.get("message", "API request failed"),
|
||||
code=result.get("code"),
|
||||
suggestion=result.get("suggestion"),
|
||||
raw=result,
|
||||
)
|
||||
return result
|
||||
except TripoAPIError as error:
|
||||
if error.is_auth_error and self.auto_refresh and not refreshed:
|
||||
refreshed = True
|
||||
try:
|
||||
if self.refresh_token():
|
||||
continue
|
||||
except TripoAPIError:
|
||||
pass # fall through and surface the original error
|
||||
transient = error.code in TRANSIENT_STATUS or error.code is None
|
||||
if transient and attempt < RETRY_ATTEMPTS:
|
||||
time.sleep(RETRY_BACKOFF * attempt)
|
||||
continue
|
||||
raise
|
||||
|
||||
# --- shared API calls ---
|
||||
|
||||
def get_data(self, method, endpoint, **kwargs):
|
||||
result = self.request(method, endpoint, **kwargs)
|
||||
return result.get("data") if result else None
|
||||
|
||||
def progress(self, operator_ids):
|
||||
if isinstance(operator_ids, str):
|
||||
operator_ids = [operator_ids]
|
||||
return self.get_data("POST", "/v2/studio/progress", body={"ids": operator_ids}) or []
|
||||
|
||||
def project_detail(self, project_id, locale="zh-CN"):
|
||||
return self.get_data("GET", f"/v2/studio/project/detail/v3/{project_id}", params={"locale": locale})
|
||||
|
||||
def project_operator(self, project_id):
|
||||
"""Current operator block of a project (carries operator_id + model_version)."""
|
||||
detail = self.project_detail(project_id) or {}
|
||||
return detail.get("operator") or {}
|
||||
|
||||
def part_names(self, project_id):
|
||||
"""Node names inside the project's current mesh (the ``part_name_list``).
|
||||
|
||||
Read from the model file itself, since the detail API does not expose
|
||||
them. Returns an empty list when the model cannot be read.
|
||||
"""
|
||||
detail = self.project_detail(project_id) or {}
|
||||
url = (detail.get("operator") or {}).get("model_url")
|
||||
if not url:
|
||||
return []
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
data = response.read()
|
||||
return glb.node_names(data)
|
||||
|
||||
def payment_profile(self):
|
||||
return self.get_data("GET", "/v2/studio/user/profile/payment")
|
||||
|
||||
def whoami(self):
|
||||
return self.get_data("GET", "/v2/studio/studio/whoami", params={"tokenizeAs": "default_jwt"})
|
||||
|
||||
def quota(self):
|
||||
return self.get_data("POST", "/v2/studio/operation/quota", body={})
|
||||
|
||||
def assets(self, asset_type="mine", type="model", page=1, size=20, offset=0, locale="zh-CN"):
|
||||
return self.get_data(
|
||||
"GET",
|
||||
"/v2/studio/assets/v2",
|
||||
params={
|
||||
"asset_type": asset_type,
|
||||
"type": type,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"offset": offset,
|
||||
"locale": locale,
|
||||
},
|
||||
)
|
||||
|
||||
def download_url(self, operator_id, file_name):
|
||||
data = self.get_data(
|
||||
"POST", "/v2/studio/operation/download_with_name",
|
||||
body={"operator_id": operator_id, "file_name": file_name},
|
||||
)
|
||||
return (data or {}).get("model_url")
|
||||
|
||||
def export(self, project_id, operator_id, export_format, name="model", model_version="default"):
|
||||
fmt = {"glb": "gltf", "gltf": "gltf"}.get(export_format.lower(), export_format.lower())
|
||||
return self.get_data(
|
||||
"POST", "/v2/studio/operation/export",
|
||||
body={
|
||||
"project_id": project_id,
|
||||
"operator_id": operator_id,
|
||||
"format": fmt,
|
||||
"name": name,
|
||||
"model_version": model_version,
|
||||
},
|
||||
)
|
||||
|
||||
# --- images ---
|
||||
|
||||
def upload_image(self, path):
|
||||
"""Upload a local image and return an image object ready to be submitted.
|
||||
|
||||
Verified flow: temporary_token -> SigV4 PUT -> ``/audit/image`` -> the
|
||||
``image`` object must carry ``image_audit_result`` and ``image_source``,
|
||||
otherwise every generation endpoint answers 11002.
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"图片文件不存在: {path}")
|
||||
|
||||
fmt = path.suffix.lower().lstrip(".") or "png"
|
||||
if fmt == "jpeg":
|
||||
fmt = "jpg"
|
||||
if fmt not in ("png", "jpg", "webp"):
|
||||
raise ValueError(f"不支持的图片格式: .{fmt} (支持 png/jpg/webp)")
|
||||
content_type = "image/jpeg" if fmt == "jpg" else f"image/{fmt}"
|
||||
|
||||
sts = self.get_data(
|
||||
"POST", "/v2/studio/storage/temporary_token", body={"client": "aws", "format": fmt}
|
||||
)
|
||||
s3.upload_bytes(sts, path.read_bytes(), content_type=content_type)
|
||||
|
||||
image = {"bucket": sts["resource_bucket"], "key": sts["resource_uri"]}
|
||||
audit = self.get_data("POST", "/v2/studio/audit/image", body={"image": image}) or {}
|
||||
image["image_audit_result"] = audit.get("result", "pass")
|
||||
image["image_source"] = "upload"
|
||||
return image
|
||||
|
||||
# --- models ---
|
||||
|
||||
def upload_model(self, path):
|
||||
"""Upload a local 3D model and return the ``import_user_model`` payload.
|
||||
|
||||
Verified flow: ``temporary_token`` (format = file extension) -> SigV4 PUT
|
||||
-> the import body is ``{format, model:{bucket,key}, name,
|
||||
transform_matrix, use_original_uv}``. The studio UI rotates the mesh
|
||||
before importing (that is what ``transform_matrix`` carries); a CLI
|
||||
import uses the identity matrix.
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"模型文件不存在: {path}")
|
||||
|
||||
fmt = path.suffix.lower().lstrip(".")
|
||||
if fmt not in MODEL_FORMATS:
|
||||
raise ValueError(f"不支持的模型格式: .{fmt} (支持 glb/obj/fbx/stl)")
|
||||
|
||||
sts = self.get_data(
|
||||
"POST", "/v2/studio/storage/temporary_token", body={"client": "aws", "format": fmt}
|
||||
)
|
||||
s3.upload_bytes(sts, path.read_bytes(), content_type="application/octet-stream")
|
||||
return {
|
||||
"format": fmt,
|
||||
"model": {"bucket": sts["resource_bucket"], "key": sts["resource_uri"]},
|
||||
"name": path.name,
|
||||
}
|
||||
|
||||
# --- waiting & downloading ---
|
||||
|
||||
def wait_task(self, operator_id, timeout=900, poll_interval=3, on_progress=None):
|
||||
"""Poll until the operator succeeds, fails or the timeout expires."""
|
||||
if not operator_id:
|
||||
# Guard: polling a missing id would otherwise spin until the timeout.
|
||||
raise TripoAPIError("没有可等待的 operator_id(接口未返回任务 id)", code="no-operator")
|
||||
deadline = time.time() + timeout
|
||||
last_progress = -1
|
||||
while time.time() < deadline:
|
||||
items = self.progress([operator_id])
|
||||
if items:
|
||||
task = items[0]
|
||||
status = task.get("status")
|
||||
progress = task.get("progress", 0)
|
||||
if progress != last_progress:
|
||||
last_progress = progress
|
||||
if on_progress:
|
||||
on_progress(status, progress, task.get("left_time", -1), task)
|
||||
if status == "success":
|
||||
return task
|
||||
if status in ("failed", "banned", "expired"):
|
||||
raise TripoAPIError(f"任务 {operator_id} 失败: {json.dumps(task, ensure_ascii=False)}")
|
||||
time.sleep(poll_interval)
|
||||
raise TripoTimeout(f"任务 {operator_id} 在 {timeout}s 内未完成")
|
||||
|
||||
def download(self, url, output_path):
|
||||
output_path = Path(output_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(request, timeout=300) as response, open(output_path, "wb") as handle:
|
||||
while True:
|
||||
chunk = response.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
handle.write(chunk)
|
||||
return output_path
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Global settings (API base, device id).
|
||||
|
||||
Credentials live in ``tripo/accounts.py``; this module only holds the non-secret
|
||||
settings, in ``~/.tripo/config.json``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path.home() / ".tripo" / "config.json"
|
||||
DEFAULT_API_BASE = "https://api.tripo3d.ai"
|
||||
DEFAULT_DEVICE_ID = "2c3443d027e6d5d268e9e9af3bdbf3ec"
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, path=None):
|
||||
self.path = Path(path) if path else DEFAULT_CONFIG_PATH
|
||||
self.data = {}
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
if self.path.exists():
|
||||
try:
|
||||
self.data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
self.data = {}
|
||||
else:
|
||||
self.data = {}
|
||||
|
||||
def save(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(json.dumps(self.data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
@property
|
||||
def api_base(self):
|
||||
return os.environ.get("TRIPO_API_BASE") or self.data.get("api_base") or DEFAULT_API_BASE
|
||||
|
||||
@property
|
||||
def device_id(self):
|
||||
return os.environ.get("TRIPO_DEVICE_ID") or self.data.get("device_id") or DEFAULT_DEVICE_ID
|
||||
|
||||
def set(self, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
if value is not None:
|
||||
self.data[key] = value
|
||||
self.save()
|
||||
@@ -0,0 +1,524 @@
|
||||
{
|
||||
"aquatic": [
|
||||
{
|
||||
"action_type": "Walk",
|
||||
"category": "basic",
|
||||
"name": "march"
|
||||
}
|
||||
],
|
||||
"biped": [
|
||||
{
|
||||
"action_type": "Fear",
|
||||
"category": "emotional",
|
||||
"name": "afraid"
|
||||
},
|
||||
{
|
||||
"action_type": "Social",
|
||||
"category": "interactive",
|
||||
"name": "agree"
|
||||
},
|
||||
{
|
||||
"action_type": "Anger",
|
||||
"category": "emotional",
|
||||
"name": "angry_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Anger",
|
||||
"category": "emotional",
|
||||
"name": "angry_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Anger",
|
||||
"category": "emotional",
|
||||
"name": "angry_03"
|
||||
},
|
||||
{
|
||||
"action_type": "Ball Sports",
|
||||
"category": "scene_specific",
|
||||
"name": "basketball_shot"
|
||||
},
|
||||
{
|
||||
"action_type": "Social",
|
||||
"category": "interactive",
|
||||
"name": "bow"
|
||||
},
|
||||
{
|
||||
"action_type": "Strike",
|
||||
"category": "combat",
|
||||
"name": "box_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Strike",
|
||||
"category": "combat",
|
||||
"name": "box_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Strike",
|
||||
"category": "combat",
|
||||
"name": "box_03"
|
||||
},
|
||||
{
|
||||
"action_type": "Magic",
|
||||
"category": "scene_specific",
|
||||
"name": "cast_a_spell"
|
||||
},
|
||||
{
|
||||
"action_type": "Joy",
|
||||
"category": "emotional",
|
||||
"name": "cheer"
|
||||
},
|
||||
{
|
||||
"action_type": "Work Activities",
|
||||
"category": "scene_specific",
|
||||
"name": "chop"
|
||||
},
|
||||
{
|
||||
"action_type": "Joy",
|
||||
"category": "emotional",
|
||||
"name": "clap"
|
||||
},
|
||||
{
|
||||
"action_type": "Climb & Ride",
|
||||
"category": "scene_specific",
|
||||
"name": "climb"
|
||||
},
|
||||
{
|
||||
"action_type": "Anger",
|
||||
"category": "emotional",
|
||||
"name": "complain_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Anger",
|
||||
"category": "emotional",
|
||||
"name": "complain_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Ball Sports",
|
||||
"category": "scene_specific",
|
||||
"name": "crossover_dribble"
|
||||
},
|
||||
{
|
||||
"action_type": "Sadness",
|
||||
"category": "emotional",
|
||||
"name": "cry"
|
||||
},
|
||||
{
|
||||
"action_type": "Dance",
|
||||
"category": "scene_specific",
|
||||
"name": "dance_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Dance",
|
||||
"category": "scene_specific",
|
||||
"name": "dance_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Dance",
|
||||
"category": "scene_specific",
|
||||
"name": "dance_03"
|
||||
},
|
||||
{
|
||||
"action_type": "Dance",
|
||||
"category": "scene_specific",
|
||||
"name": "dance_04"
|
||||
},
|
||||
{
|
||||
"action_type": "Dance",
|
||||
"category": "scene_specific",
|
||||
"name": "dance_05"
|
||||
},
|
||||
{
|
||||
"action_type": "Dance",
|
||||
"category": "scene_specific",
|
||||
"name": "dance_06"
|
||||
},
|
||||
{
|
||||
"action_type": "Hit Reactions",
|
||||
"category": "combat",
|
||||
"name": "defeat_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Hit Reactions",
|
||||
"category": "combat",
|
||||
"name": "defeat_03"
|
||||
},
|
||||
{
|
||||
"action_type": "Sadness",
|
||||
"category": "emotional",
|
||||
"name": "depressed"
|
||||
},
|
||||
{
|
||||
"action_type": "Work Activities",
|
||||
"category": "scene_specific",
|
||||
"name": "dig"
|
||||
},
|
||||
{
|
||||
"action_type": "Acrobatics",
|
||||
"category": "scene_specific",
|
||||
"name": "dive"
|
||||
},
|
||||
{
|
||||
"action_type": "Ball Sports",
|
||||
"category": "scene_specific",
|
||||
"name": "dribble"
|
||||
},
|
||||
{
|
||||
"action_type": "Accident",
|
||||
"category": "scene_specific",
|
||||
"name": "fall"
|
||||
},
|
||||
{
|
||||
"action_type": "Ranged",
|
||||
"category": "combat",
|
||||
"name": "fire"
|
||||
},
|
||||
{
|
||||
"action_type": "Fear",
|
||||
"category": "emotional",
|
||||
"name": "flee_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Fear",
|
||||
"category": "emotional",
|
||||
"name": "flee_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Acrobatics",
|
||||
"category": "scene_specific",
|
||||
"name": "flip"
|
||||
},
|
||||
{
|
||||
"action_type": "Idle",
|
||||
"category": "basic",
|
||||
"name": "fold_arms"
|
||||
},
|
||||
{
|
||||
"action_type": "Ball Sports",
|
||||
"category": "scene_specific",
|
||||
"name": "football_catch"
|
||||
},
|
||||
{
|
||||
"action_type": "Ball Sports",
|
||||
"category": "scene_specific",
|
||||
"name": "football_save"
|
||||
},
|
||||
{
|
||||
"action_type": "Ball Sports",
|
||||
"category": "scene_specific",
|
||||
"name": "football_pass"
|
||||
},
|
||||
{
|
||||
"action_type": "Body Movement",
|
||||
"category": "basic",
|
||||
"name": "freaky"
|
||||
},
|
||||
{
|
||||
"action_type": "Fear",
|
||||
"category": "emotional",
|
||||
"name": "frightened"
|
||||
},
|
||||
{
|
||||
"action_type": "Strike",
|
||||
"category": "combat",
|
||||
"name": "front_kick_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Strike",
|
||||
"category": "combat",
|
||||
"name": "front_kick_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Anger",
|
||||
"category": "emotional",
|
||||
"name": "frustrated_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Anger",
|
||||
"category": "emotional",
|
||||
"name": "frustrated_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Social",
|
||||
"category": "interactive",
|
||||
"name": "greet_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Social",
|
||||
"category": "interactive",
|
||||
"name": "greet_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Social",
|
||||
"category": "interactive",
|
||||
"name": "greet_03"
|
||||
},
|
||||
{
|
||||
"action_type": "Social",
|
||||
"category": "interactive",
|
||||
"name": "greet_04"
|
||||
},
|
||||
{
|
||||
"action_type": "Social",
|
||||
"category": "interactive",
|
||||
"name": "heart_pose"
|
||||
},
|
||||
{
|
||||
"action_type": "Hit Reactions",
|
||||
"category": "combat",
|
||||
"name": "hit_to_body_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Hit Reactions",
|
||||
"category": "combat",
|
||||
"name": "hit_to_body_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Hit Reactions",
|
||||
"category": "combat",
|
||||
"name": "hit_to_head"
|
||||
},
|
||||
{
|
||||
"action_type": "Hit Reactions",
|
||||
"category": "combat",
|
||||
"name": "hit_to_side"
|
||||
},
|
||||
{
|
||||
"action_type": "Hit Reactions",
|
||||
"category": "combat",
|
||||
"name": "hit_to_stomach"
|
||||
},
|
||||
{
|
||||
"action_type": "Social",
|
||||
"category": "interactive",
|
||||
"name": "hug"
|
||||
},
|
||||
{
|
||||
"action_type": "Idle",
|
||||
"category": "basic",
|
||||
"name": "idle"
|
||||
},
|
||||
{
|
||||
"action_type": "Jump",
|
||||
"category": "basic",
|
||||
"name": "jump_down"
|
||||
},
|
||||
{
|
||||
"action_type": "Jump",
|
||||
"category": "basic",
|
||||
"name": "jump"
|
||||
},
|
||||
{
|
||||
"action_type": "Fitness",
|
||||
"category": "scene_specific",
|
||||
"name": "jump_rope_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Fitness",
|
||||
"category": "scene_specific",
|
||||
"name": "jump_rope_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Joy",
|
||||
"category": "emotional",
|
||||
"name": "laugh_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Joy",
|
||||
"category": "emotional",
|
||||
"name": "laugh_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Object Interaction",
|
||||
"category": "interactive",
|
||||
"name": "lift_heavy"
|
||||
},
|
||||
{
|
||||
"action_type": "Body Movement",
|
||||
"category": "basic",
|
||||
"name": "look_around"
|
||||
},
|
||||
{
|
||||
"action_type": "Social",
|
||||
"category": "interactive",
|
||||
"name": "make_a_call_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Social",
|
||||
"category": "interactive",
|
||||
"name": "make_a_call_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Ball Sports",
|
||||
"category": "scene_specific",
|
||||
"name": "pitch_baseball"
|
||||
},
|
||||
{
|
||||
"action_type": "Leisure",
|
||||
"category": "interactive",
|
||||
"name": "play_mobile_game"
|
||||
},
|
||||
{
|
||||
"action_type": "Leisure",
|
||||
"category": "interactive",
|
||||
"name": "play_video_game"
|
||||
},
|
||||
{
|
||||
"action_type": "Fitness",
|
||||
"category": "scene_specific",
|
||||
"name": "press-up"
|
||||
},
|
||||
{
|
||||
"action_type": "Run",
|
||||
"category": "basic",
|
||||
"name": "run_upstairs"
|
||||
},
|
||||
{
|
||||
"action_type": "Run",
|
||||
"category": "basic",
|
||||
"name": "run"
|
||||
},
|
||||
{
|
||||
"action_type": "Fear",
|
||||
"category": "emotional",
|
||||
"name": "scared_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Fear",
|
||||
"category": "emotional",
|
||||
"name": "scared_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Body Movement",
|
||||
"category": "basic",
|
||||
"name": "scratch"
|
||||
},
|
||||
{
|
||||
"action_type": "Ranged",
|
||||
"category": "combat",
|
||||
"name": "shoot"
|
||||
},
|
||||
{
|
||||
"action_type": "Work Activities",
|
||||
"category": "scene_specific",
|
||||
"name": "shovel"
|
||||
},
|
||||
{
|
||||
"action_type": "Play Music",
|
||||
"category": "scene_specific",
|
||||
"name": "sing_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Play Music",
|
||||
"category": "scene_specific",
|
||||
"name": "sing_02"
|
||||
},
|
||||
{
|
||||
"action_type": "Play Music",
|
||||
"category": "scene_specific",
|
||||
"name": "sing_03"
|
||||
},
|
||||
{
|
||||
"action_type": "Play Music",
|
||||
"category": "scene_specific",
|
||||
"name": "sing_04"
|
||||
},
|
||||
{
|
||||
"action_type": "Sit & Lie",
|
||||
"category": "basic",
|
||||
"name": "sit"
|
||||
},
|
||||
{
|
||||
"action_type": "Work Activities",
|
||||
"category": "scene_specific",
|
||||
"name": "slash"
|
||||
},
|
||||
{
|
||||
"action_type": "Sadness",
|
||||
"category": "emotional",
|
||||
"name": "sob"
|
||||
},
|
||||
{
|
||||
"action_type": "Idle",
|
||||
"category": "basic",
|
||||
"name": "standing_relax"
|
||||
},
|
||||
{
|
||||
"action_type": "Water Activities",
|
||||
"category": "scene_specific",
|
||||
"name": "surf"
|
||||
},
|
||||
{
|
||||
"action_type": "Attitude",
|
||||
"category": "emotional",
|
||||
"name": "swagger"
|
||||
},
|
||||
{
|
||||
"action_type": "Water Activities",
|
||||
"category": "scene_specific",
|
||||
"name": "swim"
|
||||
},
|
||||
{
|
||||
"action_type": "Turn",
|
||||
"category": "basic",
|
||||
"name": "turn"
|
||||
},
|
||||
{
|
||||
"action_type": "Ball Sports",
|
||||
"category": "scene_specific",
|
||||
"name": "volleyball"
|
||||
},
|
||||
{
|
||||
"action_type": "Idle",
|
||||
"category": "basic",
|
||||
"name": "wait"
|
||||
},
|
||||
{
|
||||
"action_type": "Walk",
|
||||
"category": "basic",
|
||||
"name": "walk"
|
||||
},
|
||||
{
|
||||
"action_type": "Fitness",
|
||||
"category": "scene_specific",
|
||||
"name": "warm_up"
|
||||
},
|
||||
{
|
||||
"action_type": "Social",
|
||||
"category": "interactive",
|
||||
"name": "wave_goodbye_01"
|
||||
},
|
||||
{
|
||||
"action_type": "Social",
|
||||
"category": "interactive",
|
||||
"name": "wave_goodbye_02"
|
||||
}
|
||||
],
|
||||
"hexapod": [
|
||||
{
|
||||
"action_type": "Walk",
|
||||
"category": "basic",
|
||||
"name": "walk"
|
||||
}
|
||||
],
|
||||
"octopod": [
|
||||
{
|
||||
"action_type": "Walk",
|
||||
"category": "basic",
|
||||
"name": "walk"
|
||||
}
|
||||
],
|
||||
"quadruped": [
|
||||
{
|
||||
"action_type": "Walk",
|
||||
"category": "basic",
|
||||
"name": "walk"
|
||||
}
|
||||
],
|
||||
"serpentine": [
|
||||
{
|
||||
"action_type": "Walk",
|
||||
"category": "basic",
|
||||
"name": "march"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Error types shared across the package."""
|
||||
|
||||
|
||||
class TripoAPIError(Exception):
|
||||
"""An error returned by the Tripo API (or a transport failure talking to it)."""
|
||||
|
||||
def __init__(self, message, code=None, suggestion=None, raw=None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.suggestion = suggestion
|
||||
self.raw = raw
|
||||
|
||||
def __str__(self):
|
||||
msg = f"[Tripo Error {self.code}] {super().__str__()}"
|
||||
if self.suggestion:
|
||||
msg += f" (Suggestion: {self.suggestion})"
|
||||
return msg
|
||||
|
||||
@property
|
||||
def is_auth_error(self):
|
||||
"""True when the JWT is missing/expired and a re-mint from the cookie could help."""
|
||||
return self.code in (401, 403, 1002)
|
||||
|
||||
|
||||
class TripoTimeout(Exception):
|
||||
"""A task did not reach a terminal state within the allotted time."""
|
||||
|
||||
|
||||
# Signals that an account is out of credits. The exact server code is not
|
||||
# confirmed, so the message text is the practical trigger; extra codes can be
|
||||
# added without touching code via config ``insufficient_credit_codes``.
|
||||
INSUFFICIENT_KEYWORDS = (
|
||||
"insufficient",
|
||||
"not enough",
|
||||
"no enough",
|
||||
"balance",
|
||||
"credit",
|
||||
"quota",
|
||||
"recharge",
|
||||
"积分",
|
||||
"余额",
|
||||
"额度不足",
|
||||
)
|
||||
|
||||
|
||||
def is_insufficient_credits(error, extra_codes=()):
|
||||
"""True when ``error`` looks like "this account has run out of credits"."""
|
||||
if not isinstance(error, TripoAPIError):
|
||||
return False
|
||||
if error.code is not None and error.code in set(extra_codes):
|
||||
return True
|
||||
text = str(error).lower()
|
||||
return any(keyword in text for keyword in INSUFFICIENT_KEYWORDS)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Feature registry.
|
||||
|
||||
Every Tripo capability is one module in this package. A module declares
|
||||
``NAME``, ``HELP``, ``add_arguments(parser)`` and ``run(ctx)``; ``cli.py`` wires
|
||||
them into the argparse tree. Adding a capability means adding one file and one
|
||||
line here.
|
||||
"""
|
||||
|
||||
from . import (
|
||||
account,
|
||||
account_mgmt,
|
||||
animate,
|
||||
assets,
|
||||
batch,
|
||||
cancel,
|
||||
config_cmd,
|
||||
download,
|
||||
export_model,
|
||||
fill_parts,
|
||||
image_assets,
|
||||
image_gen,
|
||||
image_multiview,
|
||||
image_to_3d,
|
||||
import_model,
|
||||
inspect_model,
|
||||
mesh_edit,
|
||||
multiview,
|
||||
motion,
|
||||
optimize_model,
|
||||
pbr,
|
||||
project,
|
||||
remesh,
|
||||
rename,
|
||||
rigging,
|
||||
segmentation,
|
||||
serve,
|
||||
stylize,
|
||||
task,
|
||||
text_to_3d,
|
||||
texture,
|
||||
texture_upscale,
|
||||
update,
|
||||
uv,
|
||||
)
|
||||
|
||||
MODULES = [
|
||||
account_mgmt, # account add/list/use/remove/show
|
||||
serve, # serve (local account UI)
|
||||
account, # balance / whoami / quota
|
||||
assets, # assets
|
||||
text_to_3d, # text
|
||||
image_gen, # img (text to image)
|
||||
image_assets, # imgs (list image assets)
|
||||
image_multiview, # imgmv (one image -> 4 views)
|
||||
image_to_3d, # image
|
||||
import_model, # import (upload own glb/obj/fbx/stl)
|
||||
mesh_edit, # edit (local_edit on Nexus mesh)
|
||||
multiview, # multiview
|
||||
batch, # batch
|
||||
cancel, # cancel (cancel a running op)
|
||||
remesh, # remesh
|
||||
rigging, # rig
|
||||
animate, # animate
|
||||
motion, # motion (text to motion)
|
||||
segmentation, # segment
|
||||
stylize, # stylize (3D 风格化)
|
||||
texture, # texture
|
||||
texture_upscale, # upscale
|
||||
pbr, # pbr
|
||||
uv, # uv
|
||||
fill_parts, # fill
|
||||
export_model, # export
|
||||
download, # download
|
||||
inspect_model, # inspect (local GLB game stats & diagnostics)
|
||||
optimize_model, # optimize (local decimate / draco / LODs)
|
||||
task, # task
|
||||
project, # project
|
||||
rename, # rename (update_name)
|
||||
config_cmd, # config
|
||||
update, # update (self-update from source tree)
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Shared argument groups and the submit -> wait -> download flow."""
|
||||
|
||||
import os
|
||||
|
||||
from ..errors import TripoAPIError, TripoTimeout
|
||||
from ..versions import default_for, resolve
|
||||
|
||||
|
||||
def add_generation_options(parser):
|
||||
"""Options shared by text / image / multiview / batch generation."""
|
||||
parser.add_argument("--smart-mesh", action="store_true", help="智能网格模式 (P2.0/P1.0,四边面低模)")
|
||||
parser.add_argument("-m", "--model", help="模型版本: P2.0 / P1.0 / H3.1 / v3.0 / v2.5(默认随模式选择)")
|
||||
parser.add_argument("--face-limit", type=int, help="面数上限(智能网格默认 25000,高精度默认 2000000;游戏场景建议 10000~25000)")
|
||||
parser.add_argument("--quad", action="store_true", help="四边面拓扑(智能网格默认开启)")
|
||||
parser.add_argument("--count", type=int, default=1, choices=[1, 2, 4], help="生成候选数量 1/2/4(默认 1)")
|
||||
parser.add_argument("--no-texture", action="store_true", help="不生成贴图")
|
||||
parser.add_argument("--no-pbr", action="store_true", help="不生成 PBR 材质")
|
||||
parser.add_argument("--no-delight", action="store_true", help="保留原图光影(不去光)")
|
||||
parser.add_argument("--texture-quality", default="detailed", choices=["standard", "detailed"], help="贴图质量")
|
||||
parser.add_argument("--geometry-quality", default="detailed", choices=["standard", "detailed"], help="几何精细度")
|
||||
parser.add_argument("--t-pose", action="store_true", help="人形角色输出 T-Pose")
|
||||
parser.add_argument("--visibility", default="shareable", choices=["shareable", "private"], help="模型可见性")
|
||||
|
||||
|
||||
def add_wait_options(parser, output_help="生成完成后保存到该路径"):
|
||||
parser.add_argument("-w", "--wait", action="store_true", help="阻塞等待任务完成")
|
||||
parser.add_argument("-o", "--output", help=output_help)
|
||||
|
||||
|
||||
def add_op_options(parser, output_help="完成后保存到该路径(可选)"):
|
||||
"""Options shared by post-process operators, which act on an existing project."""
|
||||
parser.add_argument("-p", "--project", required=True, help="项目 ID (project_id)")
|
||||
parser.add_argument("-m", "--model", help="覆盖 model_version(默认取项目自身的模型版本)")
|
||||
add_wait_options(parser, output_help)
|
||||
|
||||
|
||||
def generation_body(args):
|
||||
"""Turn generation CLI args into the request body fields."""
|
||||
smart = args.smart_mesh
|
||||
model_version = resolve(args.model, smart_mesh=smart)
|
||||
face_limit = args.face_limit or (25000 if smart else 2000000)
|
||||
quad = True if smart else args.quad
|
||||
return {
|
||||
"model_version": model_version,
|
||||
"face_limit": face_limit,
|
||||
"quad": quad,
|
||||
"texture": not args.no_texture,
|
||||
"pbr": not args.no_pbr,
|
||||
"delight": not args.no_delight,
|
||||
"texture_quality": args.texture_quality,
|
||||
"geometry_quality": args.geometry_quality,
|
||||
"t_pose": args.t_pose,
|
||||
"visibility": args.visibility,
|
||||
"smart_poly": smart,
|
||||
"model_count": args.count,
|
||||
}
|
||||
|
||||
|
||||
def wait_and_report(ctx, operator_id, timeout=900):
|
||||
"""Block on a task, printing progress; returns the finished task."""
|
||||
|
||||
def on_progress(status, progress, left_time, _task):
|
||||
ctx.out.progress(f"⏳ 进度 {progress}% (预估剩余 {left_time}s)")
|
||||
|
||||
task = ctx.client.wait_task(operator_id, timeout=timeout, on_progress=on_progress)
|
||||
ctx.out.progress_done()
|
||||
return task
|
||||
|
||||
|
||||
def fetch_model_url(ctx, project_id):
|
||||
detail = ctx.client.project_detail(project_id) or {}
|
||||
return detail.get("model_url"), detail
|
||||
|
||||
|
||||
def finish_single(ctx, task_data, output=None, wait=False, timeout=900, label="模型"):
|
||||
"""Standard flow for one generated asset: optionally wait and download."""
|
||||
project_id = task_data.get("project_id")
|
||||
operator_id = task_data.get("operator_id")
|
||||
|
||||
if not wait:
|
||||
ctx.out.data(
|
||||
task_data,
|
||||
human=lambda: (
|
||||
ctx.out.line("✅ 任务已提交"),
|
||||
ctx.out.line(f"project_id : {project_id}"),
|
||||
ctx.out.line(f"operator_id : {operator_id}"),
|
||||
ctx.out.line(f"查询进度 : tripocli task status {operator_id}"),
|
||||
ctx.out.line(f"等待并下载 : tripocli task wait {operator_id} -p {project_id} -o out.glb"),
|
||||
),
|
||||
)
|
||||
return task_data
|
||||
|
||||
task = wait_and_report(ctx, operator_id, timeout=timeout)
|
||||
model_url, detail = fetch_model_url(ctx, project_id)
|
||||
|
||||
output_path = output or f"{project_id}.glb"
|
||||
if model_url:
|
||||
ctx.client.download(model_url, output_path)
|
||||
output_path = os.path.abspath(output_path)
|
||||
|
||||
payload = {"task": task, "project": detail, "output_file": output_path if model_url else None}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"🎉 {label}完成"),
|
||||
ctx.out.line(f"模型地址: {model_url}"),
|
||||
ctx.out.line(f"已保存 : {output_path}") if model_url else ctx.out.line("⚠️ 未取到 model_url"),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def project_version(client, project_id, override=None):
|
||||
"""Model version to send with post-process ops.
|
||||
|
||||
The studio sends the project's own version for most operations, so we read it
|
||||
from the project rather than assuming a global default.
|
||||
"""
|
||||
if override:
|
||||
return resolve(override)
|
||||
return client.project_operator(project_id).get("model_version")
|
||||
|
||||
|
||||
def node_part_name(client, project_id):
|
||||
"""The ``part_name_list`` the studio expects for a project's current mesh.
|
||||
|
||||
Real names come from the mesh's nodes (only present inside the model file);
|
||||
when the model cannot be read we fall back to the single-node name
|
||||
``tripo_node_<operator_id>`` (verified live).
|
||||
"""
|
||||
names = client.part_names(project_id)
|
||||
if names:
|
||||
return names
|
||||
operator = client.project_operator(project_id)
|
||||
operator_id = operator.get("operator_id")
|
||||
if not operator_id:
|
||||
raise TripoAPIError(f"无法从项目 {project_id} 读取 operator_id")
|
||||
return [f"tripo_node_{operator_id}"]
|
||||
|
||||
|
||||
def run_operator(ctx, result, output=None, wait=False, timeout=900, label="处理", multiple=False):
|
||||
"""Shared flow for post-process operators (they return project_id/operator_id)."""
|
||||
if multiple:
|
||||
operator_ids = [item.get("operator_id") for item in result]
|
||||
project_id = result[0].get("project_id") if result else None
|
||||
else:
|
||||
project_id = result.get("project_id")
|
||||
operator_ids = [result.get("operator_id")]
|
||||
# Some endpoints answer without a task id; never wait on a missing one.
|
||||
operator_ids = [oid for oid in operator_ids if oid]
|
||||
|
||||
if not wait:
|
||||
ctx.out.data(
|
||||
result,
|
||||
human=lambda: (
|
||||
ctx.out.line("✅ 任务已提交"),
|
||||
*[ctx.out.line(f"operator_id : {oid}") for oid in operator_ids],
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
for operator_id in operator_ids:
|
||||
wait_and_report(ctx, operator_id, timeout=timeout)
|
||||
|
||||
model_url, detail = fetch_model_url(ctx, project_id) if project_id else (None, None)
|
||||
output_path = output
|
||||
if output_path and model_url:
|
||||
ctx.client.download(model_url, output_path)
|
||||
output_path = os.path.abspath(output_path)
|
||||
|
||||
payload = {"result": result, "project": detail, "model_url": model_url, "output_file": output_path}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"🎉 {label}完成"),
|
||||
ctx.out.line(f"模型地址: {model_url}"),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,75 @@
|
||||
"""账户信息:积分余额、身份、免费额度。
|
||||
|
||||
tripocli balance | tripocli whoami | tripocli quota
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from ..auth import decode_jwt_payload
|
||||
|
||||
|
||||
def _balance(ctx):
|
||||
data = ctx.client.payment_profile() or {}
|
||||
wallet = data.get("wallet", {})
|
||||
member = data.get("member", {})
|
||||
ctx.out.data(
|
||||
data,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"剩余积分 : {wallet.get('total_credit', 0)}"),
|
||||
ctx.out.line(f"即将过期 : {wallet.get('expiring_credit', 0)} (到期 {wallet.get('expiring_date', '无')})"),
|
||||
ctx.out.line(f"会员等级 : {member.get('type', 'free')} (有效至 {member.get('valid_until', '无')})"),
|
||||
),
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _whoami(ctx):
|
||||
client = ctx.client
|
||||
info = {
|
||||
"user_id": None,
|
||||
"token_expires_at": None,
|
||||
"account": client.account_name,
|
||||
"token_source": client.token_source or ("account" if client.cookie else None),
|
||||
}
|
||||
payload = decode_jwt_payload(client.token or "")
|
||||
info["user_id"] = payload.get("sub")
|
||||
if payload.get("exp"):
|
||||
info["token_expires_at"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(payload["exp"]))
|
||||
|
||||
profile = ctx.client.payment_profile() or {}
|
||||
info["member"] = profile.get("member", {})
|
||||
info["wallet"] = profile.get("wallet", {})
|
||||
|
||||
ctx.out.data(
|
||||
info,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"用户 ID : {info.get('user_id')}"),
|
||||
ctx.out.line(f"Token 到期 : {info.get('token_expires_at')} ({info['token_source']})"),
|
||||
ctx.out.line(f"积分余额 : {info.get('wallet', {}).get('total_credit', 0)}"),
|
||||
),
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def _quota(ctx):
|
||||
data = ctx.client.quota() or {}
|
||||
quotas = data.get("quota", {})
|
||||
|
||||
def human():
|
||||
for group, items in quotas.items():
|
||||
for name, value in items.items():
|
||||
ctx.out.line(f"{group}/{name}: 剩余 {value.get('remaining', 0)} / 共 {value.get('total_count', 0)}")
|
||||
|
||||
ctx.out.data(data, human=human)
|
||||
return data
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
parser = subparsers.add_parser("balance", help="查询积分余额与会员状态")
|
||||
parser.set_defaults(func=_balance)
|
||||
|
||||
parser = subparsers.add_parser("whoami", help="查询当前身份与 token 有效期")
|
||||
parser.set_defaults(func=_whoami)
|
||||
|
||||
parser = subparsers.add_parser("quota", help="查询特权/试用免费额度")
|
||||
parser.set_defaults(func=_quota)
|
||||
@@ -0,0 +1,189 @@
|
||||
"""账号管理 — add / list / use / remove / show.
|
||||
|
||||
给别人用时不需要浏览器:把浏览器的 cookie 粘贴进来一次,CLI 之后自己续期 JWT。
|
||||
|
||||
tripocli account add --name work --cookie "ory_kratos_session=...; csrf_token_...=..."
|
||||
tripocli account add # 交互式粘贴
|
||||
tripocli account list
|
||||
tripocli account use work
|
||||
tripocli account remove work
|
||||
"""
|
||||
|
||||
import getpass
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .. import auth
|
||||
from ..accounts import Accounts
|
||||
from ..config import Config
|
||||
|
||||
COOKIE_HINT = (
|
||||
"请粘贴 cookie(整条 Cookie 字符串,或仅 ory_kratos_session 的值),回车结束:\n"
|
||||
" 提示:浏览器 DevTools → Network → 任意 api.tripo3d.ai 请求 → Request Headers → Cookie 整条复制"
|
||||
)
|
||||
|
||||
|
||||
def _fmt(epoch):
|
||||
if not epoch:
|
||||
return None
|
||||
if isinstance(epoch, (int, float)):
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(epoch))
|
||||
return str(epoch)
|
||||
|
||||
|
||||
def _mask(value, keep=10):
|
||||
if not value:
|
||||
return None
|
||||
return f"{value[:keep]}...{value[-6:]}" if len(value) > keep + 8 else "***"
|
||||
|
||||
|
||||
def _read_cookie(args):
|
||||
if args.cookie_file:
|
||||
with open(args.cookie_file, "r", encoding="utf-8") as handle:
|
||||
return handle.read().strip()
|
||||
if args.cookie == "-":
|
||||
return sys.stdin.read().strip()
|
||||
if args.cookie:
|
||||
return args.cookie.strip()
|
||||
# Interactive paste; hidden input so the cookie does not land in scrollback.
|
||||
print(COOKIE_HINT)
|
||||
try:
|
||||
return getpass.getpass("cookie: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
raise SystemExit("已取消")
|
||||
|
||||
|
||||
def _identity(api_base, cookie, device_id):
|
||||
"""Best-effort identity lookup so accounts are recognisable in `account list`."""
|
||||
try:
|
||||
return auth.fetch_identity(api_base, cookie, device_id)
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _add(ctx):
|
||||
args = ctx.args
|
||||
cookie = auth.normalize_cookie(_read_cookie(args))
|
||||
|
||||
# Validate before storing: a bad cookie must fail here, not on the next command.
|
||||
token, session_expires = auth.mint_token(ctx.config.api_base, cookie, ctx.config.device_id)
|
||||
user_id, email = _identity(ctx.config.api_base, cookie, ctx.config.device_id)
|
||||
|
||||
name = args.name or email or user_id or "default"
|
||||
ctx.accounts.add(name, cookie, token=token, session_expires_at=session_expires,
|
||||
user_id=user_id, email=email)
|
||||
|
||||
payload = {
|
||||
"added": name,
|
||||
"active": ctx.accounts.active_name(),
|
||||
"email": email,
|
||||
"user_id": user_id,
|
||||
"session_expires_at": _fmt(session_expires),
|
||||
}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"✅ 账号已保存: {name}"),
|
||||
ctx.out.line(f"身份 : {email or user_id or '未知'}"),
|
||||
ctx.out.line(f"会话有效期: {_fmt(session_expires) or '未知'}"),
|
||||
ctx.out.line("之后无需浏览器,CLI 会自动用 cookie 续期 JWT"),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _list(ctx):
|
||||
accounts = ctx.accounts
|
||||
active = accounts.active_name()
|
||||
items = []
|
||||
for name in accounts.names():
|
||||
record = accounts.data["accounts"][name]
|
||||
items.append({
|
||||
"name": name,
|
||||
"active": name == active,
|
||||
"email": record.get("email"),
|
||||
"user_id": record.get("user_id"),
|
||||
"session_expires_at": _fmt(record.get("session_expires_at")),
|
||||
"cookie": _mask(record.get("cookie")),
|
||||
})
|
||||
|
||||
def human():
|
||||
if not items:
|
||||
ctx.out.line("暂无账号。用 `tripocli account add` 粘贴 cookie 添加。")
|
||||
return
|
||||
for item in items:
|
||||
mark = "*" if item["active"] else " "
|
||||
ctx.out.line(f"{mark} {item['name']:<20} {item['email'] or item['user_id'] or '-'}"
|
||||
f" (会话至 {item['session_expires_at'] or '未知'})")
|
||||
|
||||
ctx.out.data({"active": active, "accounts": items}, human=human)
|
||||
return items
|
||||
|
||||
|
||||
def _use(ctx):
|
||||
name = ctx.args.name
|
||||
try:
|
||||
ctx.accounts.use(name)
|
||||
except KeyError:
|
||||
raise ValueError(f"账号不存在: {name}(可用 `tripocli account list` 查看)")
|
||||
ctx.out.data({"active": name}, human=lambda: ctx.out.line(f"✅ 当前账号已切换为 {name}"))
|
||||
return {"active": name}
|
||||
|
||||
|
||||
def _remove(ctx):
|
||||
name = ctx.args.name
|
||||
try:
|
||||
ctx.accounts.remove(name)
|
||||
except KeyError:
|
||||
raise ValueError(f"账号不存在: {name}")
|
||||
payload = {"removed": name, "active": ctx.accounts.active_name()}
|
||||
ctx.out.data(payload, human=lambda: ctx.out.line(f"✅ 已删除账号 {name}"))
|
||||
return payload
|
||||
|
||||
|
||||
def _show(ctx):
|
||||
name = ctx.args.name
|
||||
resolved, record = ctx.accounts.get(name)
|
||||
if not record:
|
||||
raise ValueError(f"账号不存在: {name or '(无当前账号)'}")
|
||||
info = {
|
||||
"name": resolved,
|
||||
"active": resolved == ctx.accounts.active_name(),
|
||||
"email": record.get("email"),
|
||||
"user_id": record.get("user_id"),
|
||||
"cookie": _mask(record.get("cookie")),
|
||||
"session_expires_at": _fmt(record.get("session_expires_at")),
|
||||
"added_at": _fmt(record.get("added_at")),
|
||||
}
|
||||
ctx.out.data(info, human=lambda: (
|
||||
ctx.out.line(f"账号 : {resolved}{' (当前)' if info['active'] else ''}"),
|
||||
ctx.out.line(f"身份 : {record.get('email') or record.get('user_id') or '未知'}"),
|
||||
ctx.out.line(f"Cookie : {info['cookie']}"),
|
||||
ctx.out.line(f"会话有效期: {info['session_expires_at'] or '未知'}"),
|
||||
))
|
||||
return info
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
parser = subparsers.add_parser("account", help="账号管理(粘贴 cookie,多账号切换)")
|
||||
sub = parser.add_subparsers(dest="account_action", required=True)
|
||||
|
||||
add = sub.add_parser("add", help="添加账号(粘贴 cookie,无需浏览器)")
|
||||
add.add_argument("--name", help="账号名(默认取邮箱/用户 id)")
|
||||
add.add_argument("--cookie", help="cookie 值,或整条 Cookie 串;传 - 从 stdin 读取")
|
||||
add.add_argument("--cookie-file", help="从文件读取 cookie")
|
||||
add.set_defaults(func=_add)
|
||||
|
||||
sub.add_parser("list", help="列出所有账号").set_defaults(func=_list)
|
||||
|
||||
use = sub.add_parser("use", help="切换当前账号")
|
||||
use.add_argument("name")
|
||||
use.set_defaults(func=_use)
|
||||
|
||||
remove = sub.add_parser("remove", help="删除账号")
|
||||
remove.add_argument("name")
|
||||
remove.set_defaults(func=_remove)
|
||||
|
||||
show = sub.add_parser("show", help="查看账号详情")
|
||||
show.add_argument("name", nargs="?")
|
||||
show.set_defaults(func=_show)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""动画 — retarget a rigged model onto animation presets.
|
||||
|
||||
tripocli animate -p <project_id> --preset idle --wait -o anim_idle.glb
|
||||
tripocli animate --list
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import _common
|
||||
from .. import motions
|
||||
|
||||
NAME = "animate"
|
||||
HELP = "动画:为已绑定骨骼的模型套用动作预设"
|
||||
|
||||
PRESET_EXAMPLES = ["idle", "walk", "run", "slash", "cast_a_spell", "jump"]
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-p", "--project", help="项目 ID(--list 时可省略)")
|
||||
parser.add_argument("-m", "--model", help="覆盖 model_version(默认 default)")
|
||||
_common.add_wait_options(parser, "动画结果保存到该路径(可选)")
|
||||
parser.add_argument("--type", default="biped", help="骨骼类型,默认 biped")
|
||||
parser.add_argument(
|
||||
"--preset", action="append",
|
||||
help="动作预设名(如 idle / walk / run / slash),可重复;用 --list 查看全部",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--animation", action="append",
|
||||
help="完整动作标识(如 preset:biped:idle),可重复",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--motion-asset",
|
||||
help="套用「文字生成动作」产出的动作资产 ID(来自 tripocli motion)",
|
||||
)
|
||||
parser.add_argument("--list", action="store_true", help="列出全部可用动作预设(按骨骼类型)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
|
||||
if args.list:
|
||||
data = {
|
||||
rig: [
|
||||
{"name": item["name"], "action_type": item.get("action_type"),
|
||||
"category": item.get("category"), "id": motions.identifier(rig, item["name"])}
|
||||
for item in motions.catalog().get(rig, [])
|
||||
]
|
||||
for rig in motions.rig_types()
|
||||
}
|
||||
|
||||
def human():
|
||||
for rig, items in data.items():
|
||||
ctx.out.line(f"== {rig} ({len(items)}) ==")
|
||||
ctx.out.line(" " + ", ".join(item["name"] for item in items))
|
||||
|
||||
ctx.out.data(data, human=human)
|
||||
return data
|
||||
|
||||
animations = list(args.animation or [])
|
||||
for preset in (args.preset or []):
|
||||
animations.append(preset if ":" in preset else motions.identifier(args.type, preset))
|
||||
if not args.project:
|
||||
raise ValueError("需要 -p/--project 指定项目(用 --list 只查看动作清单)")
|
||||
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"rig_type": args.type,
|
||||
"model_version": args.model or "default",
|
||||
}
|
||||
if args.motion_asset:
|
||||
# 文字生成动作产出的资产:retarget_model 用 motion_asset_id(而不是 animations)
|
||||
body["motion_asset_id"] = args.motion_asset
|
||||
elif animations:
|
||||
body["animations"] = animations
|
||||
else:
|
||||
raise ValueError(
|
||||
"至少需要一个动作:用 --preset afraid、--animation preset:biped:afraid,"
|
||||
f"或 --motion-asset <id>(示例: {', '.join(PRESET_EXAMPLES)};用 --list 看全部)"
|
||||
)
|
||||
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/retarget_model", body=body)
|
||||
if not args.wait:
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=False, label="动画重定向")
|
||||
|
||||
operator_id = (data or {}).get("operator_id")
|
||||
project_id = (data or {}).get("project_id") or args.project
|
||||
if operator_id:
|
||||
_common.wait_and_report(ctx, operator_id, timeout=900)
|
||||
|
||||
# 动画结果不在 project.model_url(那是绑骨结果),而在 operator.retarget[] 里:
|
||||
# 每个元素 {operator_id, name:"preset:biped:xxx", status, model_url}(实测确认)。
|
||||
entry = _retarget_entry(ctx.client, project_id, operator_id)
|
||||
model_url = (entry or {}).get("model_url")
|
||||
output_path = args.output
|
||||
if output_path and model_url:
|
||||
ctx.client.download(model_url, output_path)
|
||||
output_path = os.path.abspath(output_path)
|
||||
|
||||
payload = {"result": data, "animation": (entry or {}).get("name"), "model_url": model_url,
|
||||
"output_file": output_path if model_url else None}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"🎉 动画重定向完成({(entry or {}).get('name')})"),
|
||||
ctx.out.line(f"模型地址: {model_url}"),
|
||||
ctx.out.line(f"已保存 : {output_path}") if model_url else ctx.out.line("⚠️ 未取到动画 model_url"),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _retarget_entry(client, project_id, operator_id=None):
|
||||
"""Pick the retarget result from the project's operator.retarget list."""
|
||||
detail = client.project_detail(project_id) or {}
|
||||
entries = (detail.get("operator") or {}).get("retarget") or []
|
||||
if operator_id:
|
||||
for item in entries:
|
||||
if item.get("operator_id") == operator_id:
|
||||
return item
|
||||
return entries[-1] if entries else None
|
||||
@@ -0,0 +1,36 @@
|
||||
"""资产列表 — list the account's generated assets.
|
||||
|
||||
tripocli assets --type model --page 1
|
||||
|
||||
The API takes two filters: ``asset_type`` (scope, verified value: ``mine``) and
|
||||
``type`` (the asset kind, e.g. ``model``).
|
||||
"""
|
||||
|
||||
NAME = "assets"
|
||||
HELP = "列出账号资产(模型/图片)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--type", default="model", help="资产种类,默认 model")
|
||||
parser.add_argument("--asset-type", default="mine", help="资产范围,默认 mine")
|
||||
parser.add_argument("--page", type=int, default=1, help="页码")
|
||||
parser.add_argument("--size", type=int, default=20, help="每页数量")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
data = ctx.client.assets(
|
||||
asset_type=args.asset_type, type=args.type, page=args.page, size=args.size
|
||||
) or {}
|
||||
projects = data.get("projects") or []
|
||||
|
||||
def show():
|
||||
ctx.out.line(f"共 {data.get('total', len(projects))} 个资产(当前显示 {len(projects)} 个)")
|
||||
for item in projects:
|
||||
pid = item.get("id") or item.get("project_id") or ""
|
||||
name = item.get("project_name") or (item.get("biz_info") or {}).get("short_description") or ""
|
||||
cat = item.get("category") or item.get("type") or ""
|
||||
ctx.out.line(f" {pid} [{cat}] {name}")
|
||||
|
||||
ctx.out.data(data, human=show)
|
||||
return data
|
||||
@@ -0,0 +1,76 @@
|
||||
"""批量图生 3D — batch image to model.
|
||||
|
||||
tripocli batch -i a.png,b.png,c.png --wait -o ./out
|
||||
|
||||
Verified against the live API: the batch endpoint takes ``image`` as an *array*
|
||||
(the field is singular, not ``images``) and answers with an array of
|
||||
``{project_id, operator_id}`` — one entry per submitted image.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "batch"
|
||||
HELP = "批量图生 3D(多张图片一次提交,返回多个任务)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-i", "--images", required=True, help="逗号分隔的本地图片路径")
|
||||
_common.add_generation_options(parser)
|
||||
_common.add_wait_options(parser, "输出目录(每个结果保存为 <project_id>.glb)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
paths = [p.strip() for p in args.images.split(",") if p.strip()]
|
||||
if not paths:
|
||||
raise ValueError("--images 至少需要一张图片")
|
||||
|
||||
images = []
|
||||
for path in paths:
|
||||
ctx.out.line(f"📤 上传并审核图片: {path}")
|
||||
images.append(ctx.client.upload_image(path))
|
||||
|
||||
body = _common.generation_body(args)
|
||||
results = ctx.client.get_data(
|
||||
"POST",
|
||||
"/v2/studio/operation/batch_image_to_model",
|
||||
body={
|
||||
"image": images,
|
||||
"model_version": body["model_version"],
|
||||
"face_limit": body["face_limit"],
|
||||
"quad": body["quad"],
|
||||
"texture": body["texture"],
|
||||
"visibility": body["visibility"],
|
||||
},
|
||||
)
|
||||
results = results or []
|
||||
|
||||
if not args.wait:
|
||||
ctx.out.data(
|
||||
results,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"✅ 已提交 {len(results)} 个任务"),
|
||||
*[ctx.out.line(f" project {r.get('project_id')} operator {r.get('operator_id')}") for r in results],
|
||||
),
|
||||
)
|
||||
return results
|
||||
|
||||
out_dir = args.output or "."
|
||||
payload = []
|
||||
for item in results:
|
||||
project_id = item.get("project_id")
|
||||
wait_task = _common.wait_and_report(ctx, item.get("operator_id"))
|
||||
model_url, _detail = _common.fetch_model_url(ctx, project_id)
|
||||
target = None
|
||||
if model_url:
|
||||
target = os.path.abspath(os.path.join(out_dir, f"{project_id}.glb"))
|
||||
ctx.client.download(model_url, target)
|
||||
payload.append({"task": wait_task, "project_id": project_id, "model_url": model_url, "output_file": target})
|
||||
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: [ctx.out.line(f"✨ {p['output_file'] or p['project_id']}") for p in payload],
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,42 @@
|
||||
"""取消任务 — cancel a running operation.
|
||||
|
||||
tripocli cancel --operator <operator_id> --type remesh
|
||||
|
||||
实测请求体 ``{operator_id, scene, operation_type}``;``scene`` ∈ {workspace, assets},
|
||||
``operation_type`` 的取值由服务端枚举决定(下面这份清单来自真实报错信息):
|
||||
|
||||
text_to_model image_to_model multiview_to_model ai_segmentation ai_completion
|
||||
mesh_fill modification skeleton_adjustment texture_generation pre_rig_check
|
||||
rigging retarget stylize export retexture apply_texture pbr remesh
|
||||
texture_upscaler image_edit_to_model
|
||||
|
||||
⚠️ 图片生成任务**不在**该枚举内,无法取消。
|
||||
"""
|
||||
|
||||
NAME = "cancel"
|
||||
HELP = "取消正在运行的任务(需指定操作类型)"
|
||||
|
||||
OPERATION_TYPES = (
|
||||
"text_to_model", "image_to_model", "multiview_to_model", "ai_segmentation",
|
||||
"ai_completion", "mesh_fill", "modification", "skeleton_adjustment",
|
||||
"texture_generation", "pre_rig_check", "rigging", "retarget", "stylize",
|
||||
"export", "retexture", "apply_texture", "pbr", "remesh", "texture_upscaler",
|
||||
"image_edit_to_model",
|
||||
)
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--operator", required=True, help="要取消的 operator_id")
|
||||
parser.add_argument("--type", required=True, choices=OPERATION_TYPES, help="要取消的任务类型")
|
||||
parser.add_argument("--scene", default="workspace", choices=["workspace", "assets"], help="场景(默认 workspace)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
data = ctx.client.get_data(
|
||||
"POST", "/v2/studio/operation/cancel",
|
||||
body={"operator_id": args.operator, "scene": args.scene, "operation_type": args.type},
|
||||
)
|
||||
payload = data or {"operator_id": args.operator, "cancelled": True}
|
||||
ctx.out.data(payload, human=lambda: ctx.out.line(f"✅ 已取消: {args.operator}"))
|
||||
return payload
|
||||
@@ -0,0 +1,48 @@
|
||||
"""全局设置(API 地址、device id)。
|
||||
|
||||
凭据(cookie / JWT)由 `tripocli account` 管理,这里只管非敏感设置。
|
||||
"""
|
||||
|
||||
from ..config import Config
|
||||
|
||||
|
||||
def _show(ctx):
|
||||
config = ctx.config
|
||||
info = {
|
||||
"config_path": str(config.path),
|
||||
"api_base": config.api_base,
|
||||
"device_id": config.device_id,
|
||||
"accounts_path": str(ctx.accounts.path),
|
||||
"active_account": ctx.accounts.active_name(),
|
||||
"accounts": ctx.accounts.names(),
|
||||
}
|
||||
ctx.out.data(
|
||||
info,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"配置文件 : {info['config_path']}"),
|
||||
ctx.out.line(f"API Base : {info['api_base']}"),
|
||||
ctx.out.line(f"Device ID : {info['device_id']}"),
|
||||
ctx.out.line(f"账号文件 : {info['accounts_path']}"),
|
||||
ctx.out.line(f"当前账号 : {info['active_account'] or '未设置'}"),
|
||||
ctx.out.line(f"账号列表 : {', '.join(info['accounts']) or '空'}"),
|
||||
),
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def _set(ctx):
|
||||
args = ctx.args
|
||||
ctx.config.set(device_id=args.device_id, api_base=args.api_base)
|
||||
ctx.out.data({"saved": True}, human=lambda: ctx.out.line("✅ 配置已保存"))
|
||||
return {"saved": True}
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
parser = subparsers.add_parser("config", help="全局设置(API 地址 / device id)")
|
||||
sub = parser.add_subparsers(dest="config_action", required=True)
|
||||
sub.add_parser("show", help="显示当前设置").set_defaults(func=_show)
|
||||
|
||||
setter = sub.add_parser("set", help="修改 API 地址 / device id")
|
||||
setter.add_argument("--device-id", help="x-tripo-device-id")
|
||||
setter.add_argument("--api-base", help="API 根地址")
|
||||
setter.set_defaults(func=_set)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""下载 — fetch a model by operator id through a signed URL.
|
||||
|
||||
tripocli download --operator <operator_id> -o model.glb
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
NAME = "download"
|
||||
HELP = "下载模型:按 operator_id 获取签名直链并保存"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--operator", required=True, help="operator_id")
|
||||
parser.add_argument("-o", "--output", default="model.glb", help="保存路径(默认 model.glb)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
filename = os.path.basename(args.output)
|
||||
url = ctx.client.download_url(args.operator, filename)
|
||||
if not url:
|
||||
raise ValueError(f"operator {args.operator} 未返回下载直链")
|
||||
|
||||
path = os.path.abspath(args.output)
|
||||
ctx.out.line(f"📥 下载中: {path}")
|
||||
ctx.client.download(url, path)
|
||||
|
||||
payload = {"operator_id": args.operator, "url": url, "output_file": path}
|
||||
ctx.out.data(payload, human=lambda: ctx.out.line(f"✨ 已保存: {path}"))
|
||||
return payload
|
||||
@@ -0,0 +1,70 @@
|
||||
"""格式转换导出 — convert a model to another format.
|
||||
|
||||
tripocli export -p <project_id> -f fbx --wait -o model.fbx
|
||||
|
||||
Export is asynchronous; once it succeeds the result is fetched through
|
||||
``download_with_name`` using the *export* operator id.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "export"
|
||||
HELP = "格式转换导出 (glb/fbx/obj/stl/usdz/3mf)"
|
||||
|
||||
FORMATS = ["glb", "fbx", "obj", "stl", "usdz", "3mf"]
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-p", "--project", required=True, help="项目 ID")
|
||||
parser.add_argument("-f", "--format", required=True, choices=FORMATS, help="目标格式")
|
||||
parser.add_argument("--operator", help="模型 operator_id(默认取项目当前 operator)")
|
||||
parser.add_argument("-n", "--name", help="导出文件名前缀")
|
||||
parser.add_argument("-m", "--model", help="覆盖 model_version(默认取项目自身版本)")
|
||||
_common.add_wait_options(parser, "导出文件保存路径")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
operator_id = args.operator
|
||||
if not operator_id:
|
||||
operator_id = ctx.client.project_operator(args.project).get("operator_id")
|
||||
if not operator_id:
|
||||
raise ValueError(f"无法确定项目 {args.project} 的 operator_id,请用 --operator 指定")
|
||||
|
||||
version = _common.project_version(ctx.client, args.project, args.model) or "default"
|
||||
result = ctx.client.export(
|
||||
args.project, operator_id, args.format, name=args.name or "model", model_version=version
|
||||
) or {}
|
||||
|
||||
export_operator = result.get("operator_id")
|
||||
if not args.wait:
|
||||
ctx.out.data(
|
||||
result,
|
||||
human=lambda: (
|
||||
ctx.out.line("✅ 导出任务已提交"),
|
||||
ctx.out.line(f"export operator_id : {export_operator}"),
|
||||
ctx.out.line(f"查询进度 : tripocli task status {export_operator}"),
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
_common.wait_and_report(ctx, export_operator)
|
||||
|
||||
filename = args.output or f"{args.name or 'model'}.{args.format}"
|
||||
url = ctx.client.download_url(export_operator, os.path.basename(filename))
|
||||
output_path = None
|
||||
if url:
|
||||
output_path = os.path.abspath(filename)
|
||||
ctx.client.download(url, output_path)
|
||||
|
||||
payload = {"export": result, "download_url": url, "output_file": output_path}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line("🎉 导出完成"),
|
||||
ctx.out.line(f"已保存: {output_path}") if output_path else ctx.out.line("⚠️ 未取到下载直链"),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,49 @@
|
||||
"""部件补全 — fill / complete parts of a segmented model.
|
||||
|
||||
tripocli fill -p <project_id> --parts all --wait
|
||||
tripocli fill -p <project_id> --parts tripo_part_0,tripo_part_3 --wait
|
||||
|
||||
实测:`mesh_fill`(界面「快速封口」)与 `ai_completion`(界面「AI 补全」)都要求
|
||||
`part_names`(数组)+ `model_version`;缺 `part_names` 报 `type mismatch for field "part_names"`。
|
||||
|
||||
部件标识 = **模型里的节点名**:未拆分模型是 `tripo_node_<operator_id>`,
|
||||
智能分割后是每个部件一个 `tripo_part_<n>`(实测 `tripo_part_0` 可用)。
|
||||
项目详情接口不返回这些名字,`tripocli` 直接解析模型 GLB 的节点名,故 `--parts all` 即可全选。
|
||||
"""
|
||||
|
||||
from ..errors import TripoAPIError
|
||||
from . import _common
|
||||
|
||||
NAME = "fill"
|
||||
HELP = "部件补全:补全拆分后的部件网格(快速封口 / AI 补全)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "补全结果保存到该路径(可选)")
|
||||
parser.add_argument("--parts", required=True, help="部件名(逗号分隔),或传 all 选择全部节点")
|
||||
parser.add_argument("--ai", action="store_true", help="使用 AI 补全(默认使用快速封口)")
|
||||
|
||||
|
||||
def resolve_parts(ctx, args):
|
||||
if args.parts.strip().lower() in ("all", "*"):
|
||||
names = ctx.client.part_names(args.project)
|
||||
if not names:
|
||||
raise TripoAPIError("无法从模型读取部件名,请用 --parts 显式指定")
|
||||
return names
|
||||
return [part.strip() for part in args.parts.split(",") if part.strip()]
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
part_names = resolve_parts(ctx, args)
|
||||
if not part_names:
|
||||
raise ValueError("--parts 不能为空")
|
||||
|
||||
endpoint = "ai_completion" if args.ai else "mesh_fill"
|
||||
body = {"project_id": args.project, "part_names": part_names}
|
||||
model_version = _common.project_version(ctx.client, args.project, args.model)
|
||||
if model_version:
|
||||
body["model_version"] = model_version
|
||||
|
||||
data = ctx.client.get_data("POST", f"/v2/studio/operation/{endpoint}", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="部件补全")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""图片资产列表 — list the image assets you have generated.
|
||||
|
||||
tripocli imgs --size 10
|
||||
|
||||
实测:`POST /v2/studio/image/image_assets` body `{page_num, page_size}`,
|
||||
返回 `{assets:[{asset_id, input:{prompt, prompt_image:[{bucket,key,url}]}, ...}]}`。
|
||||
"""
|
||||
|
||||
NAME = "imgs"
|
||||
HELP = "列出我的图片资产(生成过的参考图)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--page", type=int, default=1, help="页码(默认 1)")
|
||||
parser.add_argument("--size", type=int, default=8, help="每页数量(默认 8)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
data = ctx.client.get_data(
|
||||
"POST", "/v2/studio/image/image_assets",
|
||||
body={"page_num": args.page, "page_size": args.size},
|
||||
) or {}
|
||||
assets = data.get("assets") or []
|
||||
|
||||
def show():
|
||||
for item in assets:
|
||||
source = item.get("input") or {}
|
||||
prompt = source.get("prompt_text") or source.get("prompt") or ""
|
||||
ctx.out.line(f" {item.get('asset_id')} [{item.get('status', '')}] {prompt[:56]}")
|
||||
pictures = ((item.get("output") or {}).get("data")) or source.get("prompt_image") or []
|
||||
for picture in pictures[:2]:
|
||||
ctx.out.line(f" {picture.get('url', '')[:100]}")
|
||||
|
||||
ctx.out.data({"assets": assets}, human=show)
|
||||
return data
|
||||
@@ -0,0 +1,159 @@
|
||||
"""文生图 — text to image(游戏资产流程的第一步)。
|
||||
|
||||
tripocli img -p "a cute cartoon wooden barrel" -m nano-banana-2 --amount 1 --resolution 2K --wait -o ./refs
|
||||
|
||||
实测流程(页面上真操作抓到的请求):
|
||||
|
||||
POST /v2/studio/image/gen_image_v2
|
||||
{"if_upload":true,"sketch_to_render":false,"t_pose":false,"amount":4,
|
||||
"model_version":"gemini_3.1_flash_image_preview","prompt":"...","resolution":"4K","scale":"1:1"}
|
||||
→ {task_id, asset_id, remaining_free_count}
|
||||
|
||||
POST /v2/studio/image/get_image_asset {"asset_id": "..."}
|
||||
→ {status, type:"generate_image", output:{data:[{bucket,key,url}]}}
|
||||
|
||||
异步:先 gen_image_v2 拿 asset_id,再轮询 get_image_asset 直到 status=success,从 output.data 取图。
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..errors import TripoAPIError, TripoTimeout
|
||||
|
||||
NAME = "img"
|
||||
HELP = "文生图:用文本生成参考图(游戏资产流程的第一步)"
|
||||
|
||||
# 界面上的模型名 → API 的 model_version(逐个在页面上选中并抓包确认)
|
||||
IMAGE_MODELS = {
|
||||
"nano-banana-2": "gemini_3.1_flash_image_preview",
|
||||
"nano-banana-pro": "gemini_3_pro_image_preview",
|
||||
"gpt-image-2": "gpt_image_2",
|
||||
"gpt-image-2.5": "gpt_image_2.5_sunburst",
|
||||
"midjourney": "midjourney",
|
||||
}
|
||||
DEFAULT_MODEL = "nano-banana-2"
|
||||
|
||||
SCALES = ["1:1", "16:9", "9:16", "4:3", "3:4"]
|
||||
RESOLUTIONS = ["1K", "2K", "4K"]
|
||||
|
||||
|
||||
def resolve_model(name):
|
||||
"""Accept either a friendly alias or a raw model_version string."""
|
||||
if not name:
|
||||
return IMAGE_MODELS[DEFAULT_MODEL]
|
||||
key = name.strip().lower()
|
||||
return IMAGE_MODELS.get(key, name.strip())
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-p", "--prompt", help="图片描述提示词(用 --asset 取已有结果时可省略)")
|
||||
parser.add_argument("--asset", help="直接取已有 asset_id 的结果,不发起新生成")
|
||||
parser.add_argument("--templates", action="store_true", help="列出官方模板(角色拆分/T姿势/3D增强…)后退出")
|
||||
parser.add_argument("--amount", type=int, default=1, choices=[1, 2, 3, 4], help="生成数量 1-4(默认 1,省额度)")
|
||||
parser.add_argument("--scale", default="1:1", choices=SCALES, help="图片比例(默认 1:1)")
|
||||
parser.add_argument("--resolution", default="1K", choices=RESOLUTIONS, help="分辨率 1K/2K/4K(默认 1K)")
|
||||
parser.add_argument(
|
||||
"-m", "--model", default=DEFAULT_MODEL,
|
||||
help="图像模型: " + " / ".join(IMAGE_MODELS) + f"(默认 {DEFAULT_MODEL}),也可直接传 model_version",
|
||||
)
|
||||
parser.add_argument("--t-pose", action="store_true", help="人形角色输出 T 姿势(便于后续绑骨)")
|
||||
parser.add_argument("-w", "--wait", action="store_true", help="等待生成完成")
|
||||
parser.add_argument("-o", "--output", help="图片保存目录(配合 --wait)")
|
||||
parser.add_argument("--timeout", type=int, default=300, help="等待超时秒数(默认 300)")
|
||||
|
||||
|
||||
def _poll_asset(client, asset_id, timeout):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
data = client.get_data("POST", "/v2/studio/image/get_image_asset", body={"asset_id": asset_id}) or {}
|
||||
status = data.get("status")
|
||||
if status == "success":
|
||||
return data
|
||||
if status in ("failed", "banned"):
|
||||
raise TripoAPIError(f"图片生成失败: status={status}")
|
||||
time.sleep(3)
|
||||
raise TripoTimeout(f"图片生成在 {timeout}s 内未完成(asset_id={asset_id})")
|
||||
|
||||
|
||||
def _report(ctx, asset_id, asset, output_dir):
|
||||
images = ((asset.get("output") or {}).get("data")) or []
|
||||
|
||||
saved = []
|
||||
if output_dir and images:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
for index, item in enumerate(images):
|
||||
target = os.path.abspath(os.path.join(output_dir, f"image_{index}.png"))
|
||||
ctx.client.download(item["url"], target)
|
||||
saved.append(target)
|
||||
|
||||
payload = {"asset_id": asset_id, "status": asset.get("status"), "images": images, "saved": saved}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"🎉 生成完成(status={asset.get('status')}),共 {len(images)} 张"),
|
||||
*[ctx.out.line(f" {path}") for path in saved],
|
||||
*([] if saved else [ctx.out.line(f" {item['url'][:90]}") for item in images]),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
|
||||
if args.templates:
|
||||
# 模板是客户端的「提示词+参数」预设,接口只给元数据(实测:请求体里没有 template_id)。
|
||||
data = ctx.client.get_data("GET", "/v2/studio/image/available_templates") or {}
|
||||
templates = data.get("templates") or []
|
||||
|
||||
def show():
|
||||
for item in templates:
|
||||
tags = ", ".join(item.get("secondary_tag") or [])
|
||||
ctx.out.line(f" {item.get('title', ''):<22} [{item.get('category', '')}] {tags}")
|
||||
if item.get("description"):
|
||||
ctx.out.line(f" {item['description'].splitlines()[0]}")
|
||||
|
||||
ctx.out.data({"templates": templates}, human=show)
|
||||
return data
|
||||
|
||||
if args.asset:
|
||||
if args.wait:
|
||||
asset = _poll_asset(ctx.client, args.asset, args.timeout)
|
||||
else:
|
||||
asset = ctx.client.get_data(
|
||||
"POST", "/v2/studio/image/get_image_asset", body={"asset_id": args.asset}
|
||||
) or {}
|
||||
return _report(ctx, args.asset, asset, args.output)
|
||||
|
||||
if not args.prompt:
|
||||
raise ValueError("需要 -p/--prompt 发起生成,或用 --asset 取已有结果")
|
||||
|
||||
model_version = resolve_model(args.model)
|
||||
body = {
|
||||
"if_upload": True,
|
||||
"sketch_to_render": False,
|
||||
"t_pose": args.t_pose,
|
||||
"amount": args.amount,
|
||||
"model_version": model_version,
|
||||
"prompt": args.prompt,
|
||||
"resolution": args.resolution,
|
||||
"scale": args.scale,
|
||||
}
|
||||
ctx.out.line(f"🎨 生成图片: \"{args.prompt}\" (x{args.amount}, {args.scale}, {args.resolution}, {model_version})")
|
||||
data = ctx.client.get_data("POST", "/v2/studio/image/gen_image_v2", body=body) or {}
|
||||
asset_id = data.get("asset_id") or data.get("task_id")
|
||||
if not asset_id:
|
||||
raise TripoAPIError(f"gen_image_v2 未返回 asset_id: {data}")
|
||||
|
||||
if not args.wait:
|
||||
ctx.out.data(
|
||||
data,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"✅ 已提交 asset_id={asset_id}(免费剩余 {data.get('remaining_free_count')})"),
|
||||
ctx.out.line(f"取图: tripocli img --asset {asset_id} --wait -o ./refs"),
|
||||
),
|
||||
)
|
||||
return data
|
||||
|
||||
asset = _poll_asset(ctx.client, asset_id, args.timeout)
|
||||
return _report(ctx, asset_id, asset, args.output)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""单图生成多视角图 — one image to four consistent views.
|
||||
|
||||
tripocli imgmv -i ./ref.png --wait -o ./views
|
||||
|
||||
Turns one reference image into 4 views (front/left/right/back), which can then be
|
||||
fed to ``tripocli multiview --front/--left/--right/--back`` for a noticeably better
|
||||
model than plain single-image 3D. Verified body:
|
||||
``{image, model_version, amount, resolution, scale}``; the result asset has
|
||||
``type: "multiview_images"`` with 4 entries in ``output.data`` (实测出图 4 张).
|
||||
"""
|
||||
|
||||
from ..errors import TripoAPIError
|
||||
from . import image_gen
|
||||
|
||||
NAME = "imgmv"
|
||||
HELP = "单图生成多视角图(4 张,可喂给 multiview 提升 3D 质量)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-i", "--image", required=True, help="输入图片 (png/jpg/webp)")
|
||||
parser.add_argument("--amount", type=int, default=4, choices=[1, 2, 3, 4], help="生成数量(默认 4,多视角本身需要 4 张)")
|
||||
parser.add_argument("--scale", default="1:1", choices=image_gen.SCALES, help="图片比例(默认 1:1)")
|
||||
parser.add_argument("--resolution", default="1K", choices=image_gen.RESOLUTIONS, help="分辨率(默认 1K)")
|
||||
parser.add_argument("-m", "--model", default=image_gen.DEFAULT_MODEL,
|
||||
help="图像模型(默认 nano-banana-2),也可直接传 model_version")
|
||||
parser.add_argument("-w", "--wait", action="store_true", help="等待生成完成")
|
||||
parser.add_argument("-o", "--output", help="图片保存目录(配合 --wait)")
|
||||
parser.add_argument("--timeout", type=int, default=300, help="等待超时秒数(默认 300)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
ctx.out.line(f"📤 上传图片: {args.image}")
|
||||
image = ctx.client.upload_image(args.image)
|
||||
body = {
|
||||
"image": image,
|
||||
"model_version": image_gen.resolve_model(args.model),
|
||||
"amount": args.amount,
|
||||
"resolution": args.resolution,
|
||||
"scale": args.scale,
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/image/gen_multiview", body=body) or {}
|
||||
asset_id = data.get("asset_id") or data.get("task_id")
|
||||
if not asset_id:
|
||||
raise TripoAPIError(f"gen_multiview 未返回 asset_id: {data}")
|
||||
|
||||
if not args.wait:
|
||||
ctx.out.data(
|
||||
data,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"✅ 已提交 asset_id={asset_id}"),
|
||||
ctx.out.line(f"取图: tripocli img --asset {asset_id} --wait -o ./views"),
|
||||
),
|
||||
)
|
||||
return data
|
||||
|
||||
asset = image_gen._poll_asset(ctx.client, asset_id, args.timeout)
|
||||
return image_gen._report(ctx, asset_id, asset, args.output)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""单图生 3D — image to model.
|
||||
|
||||
tripocli image -i ./chair.png --wait -o chair.glb
|
||||
|
||||
The local file is uploaded straight to S3 with SigV4 and then audited; both steps
|
||||
are required before the generation endpoint accepts the image.
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "image"
|
||||
HELP = "单张图片生成 3D 模型(图生 3D)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-i", "--image", required=True, help="本地图片路径 (png/jpg/webp)")
|
||||
_common.add_generation_options(parser)
|
||||
_common.add_wait_options(parser, "生成完成后保存的 .glb 路径")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
ctx.out.line(f"📤 上传并审核图片: {args.image}")
|
||||
image = ctx.client.upload_image(args.image)
|
||||
body = _common.generation_body(args)
|
||||
data = ctx.client.get_data(
|
||||
"POST", "/v2/studio/operation/image_to_model", body={"image": image, **body}
|
||||
)
|
||||
return _common.finish_single(ctx, data, output=args.output, wait=args.wait)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""导入自己的模型 — register a local 3D model as a studio project.
|
||||
|
||||
tripocli import -i ./my_model.glb --wait -o my.glb
|
||||
|
||||
The file goes straight to S3, then ``import_user_model`` turns it into a project,
|
||||
after which every post-process operator (remesh / rig / animate / segment / ...)
|
||||
works on it. Verified body:
|
||||
``{format, model:{bucket,key}, name, transform_matrix, use_original_uv}``.
|
||||
|
||||
The UI lets you rotate the mesh before importing — that is what
|
||||
``transform_matrix`` carries; the CLI imports as-is with the identity matrix.
|
||||
Note the studio drops custom materials and deep node hierarchies on import.
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "import"
|
||||
HELP = "导入自己的 3D 模型(glb/obj/fbx/stl)为项目,之后可继续后处理"
|
||||
|
||||
IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-i", "--input", required=True, help="本地模型文件 (glb/obj/fbx/stl)")
|
||||
parser.add_argument("--name", help="项目显示名(默认用文件名)")
|
||||
parser.add_argument("--matrix", help="16 个数的 4x4 变换矩阵(默认单位矩阵),逗号分隔")
|
||||
parser.add_argument("--no-original-uv", action="store_true", help="不保留原始 UV(默认保留)")
|
||||
_common.add_wait_options(parser, "导入完成后保存到该路径(可选)")
|
||||
|
||||
|
||||
def parse_matrix(text):
|
||||
if not text:
|
||||
return IDENTITY
|
||||
values = [float(part) for part in text.replace(" ", "").split(",")]
|
||||
if len(values) != 16:
|
||||
raise ValueError("--matrix 需要恰好 16 个数字(4x4,逗号分隔)")
|
||||
return values
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
ctx.out.line(f"📤 上传模型: {args.input}")
|
||||
payload = ctx.client.upload_model(args.input)
|
||||
body = {
|
||||
**payload,
|
||||
"name": args.name or payload["name"],
|
||||
"transform_matrix": parse_matrix(args.matrix),
|
||||
"use_original_uv": not args.no_original_uv,
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/import_user_model", body=body)
|
||||
if isinstance(data, list):
|
||||
data = data[0] if data else {}
|
||||
return _common.finish_single(ctx, data or {}, output=args.output, wait=args.wait, label="导入")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""本地模型体检 — inspect local GLB/GLTF files for game-engine readiness.
|
||||
|
||||
tripocli inspect model.glb
|
||||
tripocli inspect ./assets/*.glb
|
||||
"""
|
||||
|
||||
from .. import glb
|
||||
|
||||
NAME = "inspect"
|
||||
HELP = "本地模型体检:查看 GLB 的三角形面数、包围盒尺寸、骨骼、动画名与性能告警(0 积分)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("files", nargs="+", help="一个或多个本地 .glb/.gltf 文件路径")
|
||||
|
||||
|
||||
def _fmt_size(num_bytes):
|
||||
if num_bytes >= 1024 * 1024:
|
||||
return f"{num_bytes / (1024 * 1024):.2f} MB"
|
||||
return f"{num_bytes / 1024:.1f} KB"
|
||||
|
||||
|
||||
def run(ctx):
|
||||
results = []
|
||||
for path in ctx.args.files:
|
||||
results.append(glb.inspect_file(path))
|
||||
|
||||
payload = results[0] if len(results) == 1 else {"models": results}
|
||||
|
||||
def human():
|
||||
for info in results:
|
||||
comp = "Draco" if info["draco_compressed"] else "未压缩"
|
||||
ctx.out.line(f"📦 {info['name']} ({_fmt_size(info['file_size'])}, {comp})")
|
||||
ctx.out.line(
|
||||
f" 网格面数 : {info['triangles']:,} 三角面 / {info['vertices']:,} 顶点 "
|
||||
f"({info['meshes']} mesh, {info['materials']} 材质, {info['textures']} 贴图"
|
||||
f"{', 含法线/PBR贴图' if info['has_pbr_maps'] else ''})"
|
||||
)
|
||||
if info["bbox"]:
|
||||
b = info["bbox"]
|
||||
sz = b["size"]
|
||||
ctx.out.line(
|
||||
f" 包围尺寸 : 宽 {sz[0]} × 高 {sz[1]} × 深 {sz[2]} "
|
||||
f"(minY={b['min'][1]}, 底部贴地需偏移 +{b['ground_offset_y']})"
|
||||
)
|
||||
if info["is_rigged"] or info["animations"]:
|
||||
anims = ", ".join(a["name"] for a in info["animations"]) or "无"
|
||||
ctx.out.line(f" 骨骼动画 : {info['joints']} 根骨骼 | 动作片段 ({len(info['animations'])}): {anims}")
|
||||
for w in info["warnings"]:
|
||||
ctx.out.line(f" ⚠️ {w}")
|
||||
|
||||
ctx.out.data(payload, human=human)
|
||||
return payload
|
||||
@@ -0,0 +1,51 @@
|
||||
"""网格编辑(局部重拓扑)— local_edit on a 智能网格 (Nexus) model.
|
||||
|
||||
tripocli edit -p <project_id> --bbox "-1,-1,-1,1,1,1" --face-limit 8000 --wait
|
||||
|
||||
The studio shows this as the 「编辑」 tab: pick a bounding box on a Nexus mesh and
|
||||
re-topologise just that region. Verified body:
|
||||
``{project_id, source_operator_id, bbox:[[min],[max]], face_limit}``.
|
||||
|
||||
⚠️ Only offered for **智能网格 (Nexus)** models that are not yet textured / rigged
|
||||
/ segmented / PBR (the studio hides the tab otherwise). ``bbox`` coordinates are
|
||||
normalised to the model's own space, ``-1..1`` per axis (the UI clamps them), so
|
||||
the default ``-1,-1,-1,1,1,1`` covers the whole model.
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "edit"
|
||||
HELP = "网格编辑:在包围盒内对智能网格(Nexus)模型做局部重拓扑"
|
||||
|
||||
FULL_BBOX = [-1.0, -1.0, -1.0, 1.0, 1.0, 1.0]
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "编辑完成后保存到该路径(可选)")
|
||||
parser.add_argument(
|
||||
"--bbox", default=",".join(str(v) for v in FULL_BBOX),
|
||||
help="包围盒 x1,y1,z1,x2,y2,z2(归一化 -1..1,默认整个模型)",
|
||||
)
|
||||
parser.add_argument("--face-limit", type=int, default=8000, help="该区域面数上限(默认 8000)")
|
||||
|
||||
|
||||
def parse_bbox(text):
|
||||
values = [float(part) for part in text.replace(" ", "").split(",")]
|
||||
if len(values) != 6:
|
||||
raise ValueError("--bbox 需要 6 个数字:x1,y1,z1,x2,y2,z2")
|
||||
lower = [min(1.0, max(-1.0, v)) for v in values[:3]]
|
||||
upper = [min(1.0, max(-1.0, v)) for v in values[3:]]
|
||||
return [lower, upper]
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
operator_id = ctx.client.project_operator(args.project).get("operator_id")
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"source_operator_id": operator_id,
|
||||
"bbox": parse_bbox(args.bbox),
|
||||
"face_limit": args.face_limit,
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/local_edit", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="网格编辑")
|
||||
@@ -0,0 +1,151 @@
|
||||
"""文字生成动作 — text to motion(AI 动画),并把动作应用到角色。
|
||||
|
||||
tripocli motion -p "让角色挥手打招呼" --duration 5 --wait # 生成动作,得到 motion_asset_id
|
||||
tripocli motion -p "让角色走一段" --project <pid> --wait # 生成并直接应用到角色
|
||||
tripocli motion --list # 列出我的动作资产
|
||||
tripocli motion -p "..." --segment "3:跳一下" --segment "2:落地" # 多阶段动作
|
||||
|
||||
实测流程(在动画页「AI 动画」标签里点生成抓到):
|
||||
|
||||
POST /v2/studio/motion/generate
|
||||
{"segments": [{"duration_seconds": 5, "prompt": "让角色挥手打招呼"}]}
|
||||
→ {task_id, status:"queued"}
|
||||
|
||||
POST /v2/studio/motion/get_task {"task_id": "..."}
|
||||
→ 轮询直到 status="success",取 asset_id
|
||||
|
||||
POST /v2/studio/operation/retarget_model
|
||||
{"motion_asset_id": "<asset_id>", "model_version":"default",
|
||||
"project_id": "...", "rig_type": "biped"} → 把动作套到模型上
|
||||
|
||||
其它已验证的 motion 接口:`/motion/list_assets`(我的动作资产)、`/motion/get_asset`、
|
||||
`/motion/list_active_tasks`(正在跑的任务)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from . import _common, animate
|
||||
from ..errors import TripoAPIError, TripoTimeout
|
||||
|
||||
NAME = "motion"
|
||||
HELP = "文字生成动作:用一句话生成动画并(可选)应用到已绑定的角色"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-p", "--prompt", help="动作描述,如「让角色挥手打招呼」")
|
||||
parser.add_argument("--duration", type=float, default=5, help="动作时长(秒,默认 5)")
|
||||
parser.add_argument(
|
||||
"--segment", action="append",
|
||||
help="多阶段动作:\"时长:描述\",可重复(如 --segment \"3:跳一下\" --segment \"2:落地\")",
|
||||
)
|
||||
parser.add_argument("--list", action="store_true", help="列出我的动作资产后退出")
|
||||
parser.add_argument("--project", help="生成后直接应用到该项目(需已绑定骨骼)")
|
||||
parser.add_argument("--type", default="biped", help="骨骼类型,默认 biped(配合 --project 使用)")
|
||||
parser.add_argument("-w", "--wait", action="store_true", help="等待动作生成完成")
|
||||
parser.add_argument("-o", "--output", help="应用动作后的模型保存路径(配合 --project)")
|
||||
parser.add_argument("--timeout", type=int, default=300, help="等待超时秒数(默认 300)")
|
||||
|
||||
|
||||
def _segments(args):
|
||||
if args.segment:
|
||||
out = []
|
||||
for raw in args.segment:
|
||||
if ":" in raw:
|
||||
duration, text = raw.split(":", 1)
|
||||
out.append({"duration_seconds": float(duration), "prompt": text.strip()})
|
||||
else:
|
||||
out.append({"duration_seconds": args.duration, "prompt": raw.strip()})
|
||||
return out
|
||||
if not args.prompt:
|
||||
return []
|
||||
return [{"duration_seconds": args.duration, "prompt": args.prompt}]
|
||||
|
||||
|
||||
def _poll_task(client, task_id, timeout):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
data = client.get_data("POST", "/v2/studio/motion/get_task", body={"task_id": task_id}) or {}
|
||||
status = data.get("status")
|
||||
if status == "success":
|
||||
return data
|
||||
if status in ("failed", "banned", "expired"):
|
||||
raise TripoAPIError(f"动作生成失败: status={status} error={data.get('error')}")
|
||||
time.sleep(3)
|
||||
raise TripoTimeout(f"动作生成在 {timeout}s 内未完成(task_id={task_id})")
|
||||
|
||||
|
||||
def _list_assets(ctx):
|
||||
data = ctx.client.get_data("POST", "/v2/studio/motion/list_assets", body={}) or {}
|
||||
assets = data.get("assets") or []
|
||||
ctx.out.data(
|
||||
data,
|
||||
human=lambda: [
|
||||
ctx.out.line(f"共 {len(assets)} 个动作资产"),
|
||||
*[ctx.out.line(
|
||||
f" {a.get('asset_id') or a.get('id')} [{a.get('action_type') or a.get('category') or ''}] "
|
||||
f"{a.get('title') or a.get('name') or a.get('prompt') or ''}"
|
||||
) for a in assets],
|
||||
],
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
|
||||
if args.list:
|
||||
return _list_assets(ctx)
|
||||
|
||||
segments = _segments(args)
|
||||
if not segments:
|
||||
raise ValueError("需要 -p/--prompt 描述动作(或用 --segment 组合多阶段动作,--list 查看已有资产)")
|
||||
|
||||
ctx.out.line("🎬 生成动作: " + " | ".join(f"{s['duration_seconds']}s {s['prompt']}" for s in segments))
|
||||
task = ctx.client.get_data("POST", "/v2/studio/motion/generate", body={"segments": segments}) or {}
|
||||
task_id = task.get("task_id")
|
||||
if not task_id:
|
||||
raise TripoAPIError(f"motion/generate 未返回 task_id: {task}")
|
||||
|
||||
if not args.wait:
|
||||
ctx.out.data(
|
||||
task,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"✅ 已提交 task_id={task_id}"),
|
||||
ctx.out.line(f"取结果: tripocli motion --wait ... 或轮询 /motion/get_task"),
|
||||
),
|
||||
)
|
||||
return task
|
||||
|
||||
result = _poll_task(ctx.client, task_id, args.timeout)
|
||||
asset_id = result.get("asset_id")
|
||||
|
||||
payload = {"task": result, "motion_asset_id": asset_id}
|
||||
if args.project and asset_id:
|
||||
ctx.out.line(f"🦴 应用到项目 {args.project} ...")
|
||||
body = {
|
||||
"motion_asset_id": asset_id,
|
||||
"model_version": "default",
|
||||
"project_id": args.project,
|
||||
"rig_type": args.type,
|
||||
}
|
||||
applied = ctx.client.get_data("POST", "/v2/studio/operation/retarget_model", body=body) or {}
|
||||
op_id = applied.get("operator_id")
|
||||
if op_id:
|
||||
_common.wait_and_report(ctx, op_id, timeout=900)
|
||||
entry = animate._retarget_entry(ctx.client, args.project, op_id) or {}
|
||||
model_url = entry.get("model_url")
|
||||
output_path = None
|
||||
if args.output and model_url:
|
||||
ctx.client.download(model_url, args.output)
|
||||
output_path = os.path.abspath(args.output)
|
||||
payload["applied"] = {"result": applied, "model_url": model_url, "output_file": output_path}
|
||||
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"🎉 动作已生成 motion_asset_id={asset_id}"),
|
||||
ctx.out.line(f" 应用到角色: tripocli animate -p <project_id> --motion-asset {asset_id} --wait"),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,51 @@
|
||||
"""多视角图生 3D — multi-view to model.
|
||||
|
||||
tripocli multiview --front f.png --left l.png --right r.png --back b.png --wait -o mv.glb
|
||||
|
||||
Verified against the live API: the ``image`` field must be a list of exactly four
|
||||
audited image objects; fewer than four is rejected with "Invalid parameter(s)".
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
from ..errors import TripoAPIError
|
||||
from ..versions import SMART_MESH_DEFAULT
|
||||
|
||||
NAME = "multiview"
|
||||
HELP = "多视角图片生成 3D 模型(前/左/右/后,必须 4 张)"
|
||||
|
||||
VIEWS = ("front", "left", "right", "back")
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--front", required=True, help="前视图本地图片")
|
||||
parser.add_argument("--left", required=True, help="左视图本地图片")
|
||||
parser.add_argument("--right", required=True, help="右视图本地图片")
|
||||
parser.add_argument("--back", required=True, help="后视图本地图片")
|
||||
_common.add_generation_options(parser)
|
||||
_common.add_wait_options(parser, "生成完成后保存的 .glb 路径")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
paths = [getattr(args, name) for name in VIEWS]
|
||||
|
||||
# Multi-view is a smart-mesh workflow by default; keep the low-poly topology
|
||||
# unless the caller explicitly asked for something else.
|
||||
if not args.smart_mesh and not args.model:
|
||||
args.smart_mesh = True
|
||||
if not args.model:
|
||||
args.model = SMART_MESH_DEFAULT
|
||||
|
||||
images = []
|
||||
for name, path in zip(VIEWS, paths):
|
||||
ctx.out.line(f"📤 上传 {name} 视图: {path}")
|
||||
images.append(ctx.client.upload_image(path))
|
||||
|
||||
if len(images) != 4:
|
||||
raise TripoAPIError("多视角生成需要恰好 4 张图片 (--front/--left/--right/--back)")
|
||||
|
||||
body = _common.generation_body(args)
|
||||
data = ctx.client.get_data(
|
||||
"POST", "/v2/studio/operation/multiview_to_model", body={"image": images, **body}
|
||||
)
|
||||
return _common.finish_single(ctx, data, output=args.output, wait=args.wait)
|
||||
@@ -0,0 +1,185 @@
|
||||
"""本地游戏资产优化 — decimate triangles, resize textures, Draco-compress, and build LODs.
|
||||
|
||||
tripocli optimize house.glb -o house_game.glb --faces 15000
|
||||
tripocli optimize hero.glb --in-place --faces 40000
|
||||
tripocli optimize house.glb --lods
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from .. import glb
|
||||
|
||||
NAME = "optimize"
|
||||
HELP = "本地模型优化:真减面、锁 UV、贴图缩放、Draco 压缩与一键生成多级 LOD(0 积分)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("input", help="输入的本地 .glb 文件路径")
|
||||
parser.add_argument("-o", "--output", help="输出 .glb 路径(默认 <原文件名>_opt.glb)")
|
||||
parser.add_argument("--in-place", action="store_true", help="直接原地覆盖输入文件")
|
||||
parser.add_argument(
|
||||
"--faces", type=int,
|
||||
help="目标三角形面数(如 15000;不传则按静态模型 18000 / 骨骼角色 45000 自动推算)",
|
||||
)
|
||||
parser.add_argument("--ratio", type=float, help="减面保留比例 0~1(优先于 --faces)")
|
||||
parser.add_argument(
|
||||
"--error", type=float,
|
||||
help="几何误差容忍度(默认静态模型 0.06,带骨骼角色 0.02 以保护五官与关节)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--texture-size", type=int, default=1024,
|
||||
help="限制最大贴图边长(默认 1024;传 0 保留原始尺寸)",
|
||||
)
|
||||
parser.add_argument("--no-draco", action="store_true", help="跳过 Draco 几何压缩")
|
||||
parser.add_argument(
|
||||
"--lods", action="store_true",
|
||||
help="一键生成 3 级游戏 LOD 文件(<名>_lod0.glb / _lod1.glb / _lod2.glb)",
|
||||
)
|
||||
|
||||
|
||||
def _runner():
|
||||
if shutil.which("gltf-transform"):
|
||||
return ["gltf-transform"]
|
||||
if shutil.which("npx"):
|
||||
return ["npx", "--yes", "@gltf-transform/cli"]
|
||||
raise RuntimeError("未找到 npx 或 gltf-transform,请先安装 Node.js")
|
||||
|
||||
|
||||
def _run_cmd(cmd):
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout or "").strip()[-600:]
|
||||
raise RuntimeError(f"执行失败 ({' '.join(cmd[:4])}): {detail}")
|
||||
|
||||
|
||||
def _optimize_one(src, dst, before_info, target_faces=None, ratio=None, error=None,
|
||||
texture_size=None, use_draco=True):
|
||||
cli = _runner()
|
||||
is_rigged = before_info["is_rigged"]
|
||||
orig_tris = max(before_info["triangles"], 1)
|
||||
|
||||
if ratio is None:
|
||||
faces = target_faces or (45000 if is_rigged else 18000)
|
||||
ratio = min(1.0, round(faces / orig_tris, 4))
|
||||
|
||||
err = error if error is not None else (0.02 if is_rigged else 0.06)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="tripo_opt_") as tmpdir:
|
||||
current = src
|
||||
if ratio < 0.999 and before_info["meshes"] > 0:
|
||||
step_weld = os.path.join(tmpdir, "welded.glb")
|
||||
_run_cmd(cli + ["weld", current, step_weld])
|
||||
step_simp = os.path.join(tmpdir, "simplified.glb")
|
||||
_run_cmd(cli + ["simplify", step_weld, step_simp, "--ratio", str(ratio), "--error", str(err)])
|
||||
current = step_simp
|
||||
|
||||
if texture_size and texture_size > 0 and before_info["textures"] > 0:
|
||||
step_tex = os.path.join(tmpdir, "resized.glb")
|
||||
_run_cmd(cli + [
|
||||
"resize", current, step_tex,
|
||||
"--width", str(texture_size), "--height", str(texture_size),
|
||||
])
|
||||
current = step_tex
|
||||
|
||||
if use_draco and before_info["meshes"] > 0:
|
||||
step_draco = os.path.join(tmpdir, "draco.glb")
|
||||
_run_cmd(cli + ["draco", current, step_draco])
|
||||
current = step_draco
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(dst)) or ".", exist_ok=True)
|
||||
shutil.copy2(current, dst)
|
||||
|
||||
after_info = glb.inspect_file(dst)
|
||||
tri_drop = round((1.0 - after_info["triangles"] / orig_tris) * 100, 1) if orig_tris else 0.0
|
||||
size_drop = round((1.0 - after_info["file_size"] / max(before_info["file_size"], 1)) * 100, 1)
|
||||
return {
|
||||
"output_file": os.path.abspath(dst),
|
||||
"before_triangles": before_info["triangles"],
|
||||
"after_triangles": after_info["triangles"],
|
||||
"triangle_reduction_pct": tri_drop,
|
||||
"before_size_mb": before_info["file_size_mb"],
|
||||
"after_size_mb": after_info["file_size_mb"],
|
||||
"size_reduction_pct": size_drop,
|
||||
"ratio_used": ratio,
|
||||
"error_used": err,
|
||||
}
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
src = os.path.abspath(args.input)
|
||||
if not os.path.exists(src):
|
||||
raise FileNotFoundError(f"文件不存在: {src}")
|
||||
|
||||
before = glb.inspect_file(src)
|
||||
stem, ext = os.path.splitext(src)
|
||||
use_draco = not args.no_draco
|
||||
|
||||
if args.lods:
|
||||
base_out = os.path.splitext(os.path.abspath(args.output))[0] if args.output else stem
|
||||
tiers = [
|
||||
("lod0", args.faces or (45000 if before["is_rigged"] else 20000), 0.03 if before["is_rigged"] else 0.05),
|
||||
("lod1", 12000 if before["is_rigged"] else 5000, 0.05 if before["is_rigged"] else 0.08),
|
||||
("lod2", 3500 if before["is_rigged"] else 1500, 0.10 if before["is_rigged"] else 0.15),
|
||||
]
|
||||
lods = []
|
||||
for tag, faces, err in tiers:
|
||||
target_path = f"{base_out}_{tag}{ext or '.glb'}"
|
||||
ctx.out.line(f"⚙️ 生成 {tag.upper()} (目标 ≤{faces:,} 面) -> {os.path.basename(target_path)}...")
|
||||
report = _optimize_one(
|
||||
src, target_path, before,
|
||||
target_faces=faces, error=err,
|
||||
texture_size=args.texture_size, use_draco=use_draco,
|
||||
)
|
||||
report["lod"] = tag
|
||||
lods.append(report)
|
||||
|
||||
payload = {"input": src, "before_triangles": before["triangles"], "lods": lods}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: [
|
||||
ctx.out.line(
|
||||
f"✅ {item['lod'].upper()}: {item['before_triangles']:,} -> {item['after_triangles']:,} 三角面 "
|
||||
f"(-{item['triangle_reduction_pct']}%), {item['before_size_mb']}MB -> {item['after_size_mb']}MB "
|
||||
f"({item['output_file']})"
|
||||
)
|
||||
for item in lods
|
||||
],
|
||||
)
|
||||
return payload
|
||||
|
||||
if args.in_place:
|
||||
dst = src
|
||||
elif args.output:
|
||||
dst = os.path.abspath(args.output)
|
||||
else:
|
||||
dst = f"{stem}_opt{ext or '.glb'}"
|
||||
|
||||
ctx.out.line(
|
||||
f"⚙️ 优化模型: {os.path.basename(src)} "
|
||||
f"(原始 {before['triangles']:,} 三角面, {before['file_size_mb']} MB"
|
||||
f"{', 带骨骼蒙皮' if before['is_rigged'] else ''})..."
|
||||
)
|
||||
report = _optimize_one(
|
||||
src, dst, before,
|
||||
target_faces=args.faces, ratio=args.ratio, error=args.error,
|
||||
texture_size=args.texture_size, use_draco=use_draco,
|
||||
)
|
||||
ctx.out.data(
|
||||
report,
|
||||
human=lambda: (
|
||||
ctx.out.line(
|
||||
f"🎉 优化完成: {report['before_triangles']:,} -> {report['after_triangles']:,} 三角面 "
|
||||
f"(-{report['triangle_reduction_pct']}%)"
|
||||
),
|
||||
ctx.out.line(
|
||||
f" 体积变化: {report['before_size_mb']} MB -> {report['after_size_mb']} MB "
|
||||
f"(-{report['size_reduction_pct']}%)"
|
||||
),
|
||||
ctx.out.line(f" 已保存 : {report['output_file']}"),
|
||||
),
|
||||
)
|
||||
return report
|
||||
@@ -0,0 +1,32 @@
|
||||
"""PBR — generate metallic/roughness/normal maps.
|
||||
|
||||
tripocli pbr -p <project_id> --wait -o pbr.glb
|
||||
|
||||
Endpoint ``/operation/pbr_generate``. Verified live: it needs the project's
|
||||
``model_version`` **and** ``part_name_list`` (``tripo_node_<operator_id>``) —
|
||||
without the node list the task is accepted but fails at 100%.
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "pbr"
|
||||
HELP = "PBR:生成金属度/粗糙度/法线等物理材质贴图"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "PBR 结果保存到该路径(可选)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
model_version = _common.project_version(ctx.client, args.project, args.model)
|
||||
if not model_version:
|
||||
raise ValueError(f"无法读取项目 {args.project} 的 model_version,请用 -m 显式指定")
|
||||
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"model_version": model_version,
|
||||
"part_name_list": _common.node_part_name(ctx.client, args.project),
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/pbr_generate", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="PBR 生成")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""项目详情 — inspect a project and its current asset.
|
||||
|
||||
tripocli project <project_id>
|
||||
"""
|
||||
|
||||
NAME = "project"
|
||||
HELP = "查看项目详情(模型地址、预览图、operator 状态)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("project_id", help="项目 ID")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
detail = ctx.client.project_detail(ctx.args.project_id) or {}
|
||||
operator = detail.get("operator") or {}
|
||||
retargets = operator.get("retarget") or []
|
||||
|
||||
def show():
|
||||
ctx.out.line(f"项目名 : {detail.get('project_name')}")
|
||||
ctx.out.line(f"项目 ID : {detail.get('id')}")
|
||||
ctx.out.line(f"operator : {operator.get('operator_id')} ({operator.get('status')})")
|
||||
ctx.out.line(f"模型版本 : {operator.get('model_version')}")
|
||||
ctx.out.line(f"模型地址 : {detail.get('model_url')}")
|
||||
if retargets:
|
||||
names = ", ".join(f"{r.get('name')}({r.get('status')})" for r in retargets)
|
||||
ctx.out.line(f"重定向动作 ({len(retargets)}): {names}")
|
||||
|
||||
ctx.out.data(detail, human=show)
|
||||
return detail
|
||||
@@ -0,0 +1,36 @@
|
||||
"""重拓扑 — remesh an existing project into clean quad/tri topology.
|
||||
|
||||
tripocli remesh -p <project_id> --face-limit 10000 --wait -o lowpoly.glb
|
||||
|
||||
Verified body: ``bake``, ``face_limit``, ``model_version`` ("default"), ``quad``,
|
||||
``smart_poly`` and ``part_name_list`` holding ``tripo_node_<operator_id>``.
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "remesh"
|
||||
HELP = "重拓扑:将模型重制为干净的四边形/三角面网格"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser)
|
||||
parser.add_argument("--face-limit", type=int, default=10000, help="目标面数(默认 10000)")
|
||||
parser.add_argument("--tri", action="store_true", help="输出三角面(默认四边面)")
|
||||
parser.add_argument("--no-bake", action="store_true", help="不烘焙原贴图")
|
||||
parser.add_argument("--part", action="append", help="只处理指定部件节点名,可重复;默认整个模型")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
parts = args.part or _common.node_part_name(ctx.client, args.project)
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"face_limit": args.face_limit,
|
||||
"quad": not args.tri,
|
||||
"smart_poly": False,
|
||||
"bake": not args.no_bake,
|
||||
"model_version": _common.project_version(ctx.client, args.project, args.model) or "default",
|
||||
"part_name_list": parts,
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/remesh", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="重拓扑")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""重命名项目 — rename a project.
|
||||
|
||||
tripocli rename -p <project_id> --name "My Hero"
|
||||
|
||||
实测请求体 ``{project_id, name}``,成功返回 ``null``。
|
||||
"""
|
||||
|
||||
NAME = "rename"
|
||||
HELP = "重命名项目"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-p", "--project", required=True, help="项目 ID (project_id)")
|
||||
parser.add_argument("--name", required=True, help="新的项目名")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
ctx.client.get_data(
|
||||
"POST", "/v2/studio/operation/update_name",
|
||||
body={"project_id": args.project, "name": args.name},
|
||||
)
|
||||
payload = {"project_id": args.project, "name": args.name}
|
||||
ctx.out.data(payload, human=lambda: ctx.out.line(f"✅ 已重命名为: {args.name}"))
|
||||
return payload
|
||||
@@ -0,0 +1,80 @@
|
||||
"""绑定 — auto rigging with UniRig.
|
||||
|
||||
tripocli rig -p <project_id> --type biped --spec mixamo --wait
|
||||
|
||||
A riggable check runs first (``pre_rig_check``); the model must be a humanoid in
|
||||
T/A pose for good results. Verified body of ``rigging_model``:
|
||||
``{project_id, model_version, rig_type, spec}``.
|
||||
|
||||
骨骼预设(界面 5 个选项 → API 的 spec 值,取自 studio 前端数据表,并用实测请求核对):
|
||||
``Mixamo → mixamo``、``ActorCore → actorcore``、``UE5 Mannequin → unreal``、
|
||||
``Unity Humanoid Compatible → unity``、``VRM 1.0 Humanoid → vrm``。
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "rig"
|
||||
HELP = "自动绑定骨骼(人形/其他 + 5 种骨骼预设)"
|
||||
|
||||
RIG_TYPES = ("biped", "others")
|
||||
|
||||
# 界面预设名 → API spec 值
|
||||
SPEC_ALIASES = {
|
||||
"mixamo": "mixamo",
|
||||
"actorcore": "actorcore",
|
||||
"actor_core": "actorcore",
|
||||
"unreal": "unreal",
|
||||
"ue5": "unreal",
|
||||
"ue5-mannequin": "unreal",
|
||||
"ue5_mannequin": "unreal",
|
||||
"unity": "unity",
|
||||
"unity-humanoid": "unity",
|
||||
"vrm": "vrm",
|
||||
"vrm1": "vrm",
|
||||
}
|
||||
DEFAULT_SPEC = "mixamo"
|
||||
|
||||
|
||||
def resolve_spec(name):
|
||||
if not name:
|
||||
return DEFAULT_SPEC
|
||||
return SPEC_ALIASES.get(name.strip().lower(), name.strip())
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "绑定完成后保存到该路径(可选)")
|
||||
parser.add_argument("--type", default="biped", choices=RIG_TYPES, help="绑定类型:biped=人形,others=其他")
|
||||
parser.add_argument(
|
||||
"--spec", default=DEFAULT_SPEC,
|
||||
help="骨骼预设: mixamo(默认) / actorcore / unreal(UE5 Mannequin) / unity / vrm",
|
||||
)
|
||||
parser.add_argument("--check-only", action="store_true", help="只做绑定前检查,不执行绑定")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
model_version = _common.project_version(ctx.client, args.project, args.model)
|
||||
spec = resolve_spec(args.spec)
|
||||
|
||||
check_body = {"project_id": args.project}
|
||||
if model_version:
|
||||
check_body["model_version"] = model_version
|
||||
check = ctx.client.get_data("POST", "/v2/studio/operation/pre_rig_check", body=check_body) or {}
|
||||
|
||||
if args.check_only:
|
||||
ctx.out.data(
|
||||
check,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"可绑定: {check.get('riggable')}"),
|
||||
ctx.out.line(f"绑定类型: {check.get('rig_type')}"),
|
||||
),
|
||||
)
|
||||
return check
|
||||
|
||||
ctx.out.line(f"🔎 绑定前检查: riggable={check.get('riggable')} type={check.get('rig_type')}")
|
||||
|
||||
body = {"project_id": args.project, "rig_type": args.type, "spec": spec}
|
||||
if model_version:
|
||||
body["model_version"] = model_version
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/rigging_model", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="绑定")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""智能分割 — split a model into editable semantic parts.
|
||||
|
||||
tripocli segment -p <project_id> --wait
|
||||
|
||||
实测(在页面上真操作抓到的请求):
|
||||
|
||||
POST /v2/studio/operation/ai_segmentation
|
||||
{"model_version": "v2.0-20260430", "project_id": "...", "segmentation_granularity": "balanced"}
|
||||
|
||||
⚠️ 关键点:`model_version` 用的是**分割模型自己的版本**(`v2.0-20260430`),
|
||||
不是项目自身的模型版本——传项目版本会得到 `1001 Fatal error on the server side`。
|
||||
|
||||
粒度 `segmentation_granularity`:`balanced`(界面「标准」)已实测可用;
|
||||
界面另有「粗略 / 精细」两档,但它们的枚举值未确认,需要时用 `--granularity` 传入。
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "segment"
|
||||
HELP = "智能分割:把模型拆成可编辑的语义部件"
|
||||
|
||||
# 分割模型自己的版本,实测确认
|
||||
SEGMENTATION_MODEL_VERSION = "v2.0-20260430"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "分割结果保存到该路径(可选)")
|
||||
parser.add_argument(
|
||||
"--granularity", default="balanced",
|
||||
help="分割粒度(默认 balanced)",
|
||||
)
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"model_version": args.model or SEGMENTATION_MODEL_VERSION,
|
||||
"segmentation_granularity": args.granularity,
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/ai_segmentation", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="智能分割")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""本地账号管理服务 — tripocli serve.
|
||||
|
||||
tripocli serve # 打开 http://127.0.0.1:8765/
|
||||
tripocli serve --port 9000 --open # 指定端口并自动打开浏览器
|
||||
|
||||
页面里可以粘贴/更新 cookie、查看会话有效期与积分额度、切换当前账号、删除账号。
|
||||
默认只监听 127.0.0.1:能访问到它的人就能以该账号身份调用接口。
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import webbrowser
|
||||
|
||||
from ..server import serve
|
||||
|
||||
NAME = "serve"
|
||||
HELP = "启动本地账号管理服务(网页里粘贴/更新 cookie、看有效期与积分)"
|
||||
|
||||
LOOPBACK = {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认 127.0.0.1)")
|
||||
parser.add_argument("--port", type=int, default=8765, help="监听端口(默认 8765,0 表示自动分配)")
|
||||
parser.add_argument("--open", action="store_true", help="启动后自动打开浏览器")
|
||||
parser.add_argument("--quiet", action="store_true", help="不打印请求日志")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
httpd, url = serve(host=args.host, port=args.port)
|
||||
|
||||
if args.quiet:
|
||||
httpd.RequestHandlerClass.log_message = lambda *a, **k: None
|
||||
|
||||
ctx.out.line(f"🖥 账号管理服务已启动: {url}")
|
||||
ctx.out.line(f" 账号文件: {ctx.accounts.path}")
|
||||
if args.host not in LOOPBACK:
|
||||
ctx.out.line(f" ⚠️ 正在监听 {args.host},局域网内任何人都能访问并操作该账号,请谨慎使用")
|
||||
ctx.out.line(" 按 Ctrl+C 停止")
|
||||
|
||||
if args.open:
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
try:
|
||||
subprocess.Popen(["open", url])
|
||||
except Exception:
|
||||
ctx.out.line(" (无法自动打开浏览器,请手动访问上面的地址)")
|
||||
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
ctx.out.line("\n已停止")
|
||||
finally:
|
||||
httpd.server_close()
|
||||
return {"url": url}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""3D 风格化 — stylize an existing model.
|
||||
|
||||
tripocli stylize -p <project_id> --style clay --wait
|
||||
|
||||
实测请求体 ``{project_id, model_version, style}``(``style:"clay"`` 被服务端接受)。
|
||||
⚠️ `style` 的**完整合法取值未确认**(该页面的 chunk 在生成页未加载,无法枚举);
|
||||
若传入不支持的风格,服务端会在任务阶段报错。
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "stylize"
|
||||
HELP = "3D 风格化:把已有模型转成某种风格(如 clay 黏土风)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "风格化结果保存到该路径(可选)")
|
||||
parser.add_argument("--style", default="clay", help="目标风格(默认 clay 黏土风)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
model_version = _common.project_version(ctx.client, args.project, args.model)
|
||||
body = {"project_id": args.project, "style": args.style}
|
||||
if model_version:
|
||||
body["model_version"] = model_version
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/stylize", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="风格化")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""任务进度 — poll or block on an operator.
|
||||
|
||||
tripocli task status <operator_id>
|
||||
tripocli task wait <operator_id> -p <project_id> -o out.glb
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import _common
|
||||
|
||||
TIMEOUT_DEFAULT = 900
|
||||
|
||||
|
||||
def _status(ctx):
|
||||
items = ctx.client.progress([ctx.args.operator_id])
|
||||
if not items:
|
||||
raise ValueError(f"未找到任务 {ctx.args.operator_id}")
|
||||
task = items[0]
|
||||
ctx.out.data(
|
||||
task,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"状态 : {task.get('status')}"),
|
||||
ctx.out.line(f"进度 : {task.get('progress')}%"),
|
||||
ctx.out.line(f"剩余 : {task.get('left_time')}s"),
|
||||
),
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
def _wait(ctx):
|
||||
args = ctx.args
|
||||
task = _common.wait_and_report(ctx, args.operator_id, timeout=args.timeout)
|
||||
|
||||
output_path = None
|
||||
model_url = None
|
||||
if args.project:
|
||||
model_url, _detail = _common.fetch_model_url(ctx, args.project)
|
||||
if args.output and model_url:
|
||||
ctx.client.download(model_url, args.output)
|
||||
output_path = os.path.abspath(args.output)
|
||||
|
||||
payload = {"task": task, "model_url": model_url, "output_file": output_path}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"✅ 任务状态: {task.get('status')}"),
|
||||
ctx.out.line(f"模型地址: {model_url}") if model_url else ctx.out.line(""),
|
||||
ctx.out.line(f"已保存: {output_path}") if output_path else ctx.out.line(""),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
parser = subparsers.add_parser("task", help="任务进度查询与等待")
|
||||
sub = parser.add_subparsers(dest="task_action", required=True)
|
||||
|
||||
status = sub.add_parser("status", help="查询任务状态")
|
||||
status.add_argument("operator_id", help="operator_id / task_id")
|
||||
status.set_defaults(func=_status)
|
||||
|
||||
wait = sub.add_parser("wait", help="阻塞等待任务完成(可顺带下载)")
|
||||
wait.add_argument("operator_id", help="operator_id / task_id")
|
||||
wait.add_argument("-p", "--project", help="项目 ID,用于取最终模型地址")
|
||||
wait.add_argument("-o", "--output", help="下载保存路径")
|
||||
wait.add_argument("--timeout", type=int, default=TIMEOUT_DEFAULT, help=f"超时秒数(默认 {TIMEOUT_DEFAULT})")
|
||||
wait.set_defaults(func=_wait)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""文生 3D — text to model.
|
||||
|
||||
tripocli text -p "a low-poly wooden barrel" --smart-mesh --wait -o barrel.glb
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "text"
|
||||
HELP = "文本生成 3D 模型(文生 3D)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-p", "--prompt", required=True, help="模型描述提示词(英文效果更佳)")
|
||||
_common.add_generation_options(parser)
|
||||
_common.add_wait_options(parser, "生成完成后保存的 .glb 路径")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
body = _common.generation_body(args)
|
||||
data = ctx.client.get_data(
|
||||
"POST", "/v2/studio/operation/text_to_model", body={"prompt": args.prompt, **body}
|
||||
)
|
||||
return _common.finish_single(ctx, data, output=args.output, wait=args.wait)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""纹理生成 — (re)texture a model.
|
||||
|
||||
tripocli texture -p <project_id> --resolution 2K --wait -o textured.glb
|
||||
|
||||
Endpoint ``/operation/texture_model``. Verified live on an *untextured* model:
|
||||
it needs ``texture_quality``, ``texture_alignment`` and ``part_name_list``.
|
||||
Note the studio refuses to re-texture a model that already has a texture — for
|
||||
those, use the retexture flow instead.
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "texture"
|
||||
HELP = "纹理生成:为模型生成/重绘贴图(2K/4K/8K)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "贴图后的模型保存到该路径(可选)")
|
||||
parser.add_argument("--resolution", default="2K", choices=["2K", "4K", "8K"], help="贴图分辨率")
|
||||
parser.add_argument("--quality", default="detailed", choices=["standard", "detailed"], help="贴图质量")
|
||||
parser.add_argument("--prompt", help="贴图风格提示词(可选)")
|
||||
parser.add_argument("--keep-light", action="store_true", help="保留原图光影(关闭去光处理)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"texture_quality": args.quality,
|
||||
"texture_alignment": "original_image",
|
||||
"texture_resolution": args.resolution,
|
||||
"delight": not args.keep_light,
|
||||
"part_name_list": _common.node_part_name(ctx.client, args.project),
|
||||
}
|
||||
model_version = _common.project_version(ctx.client, args.project, args.model)
|
||||
if model_version:
|
||||
body["model_version"] = model_version
|
||||
if args.prompt:
|
||||
body["prompt"] = args.prompt
|
||||
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/texture_model", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="纹理生成")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""纹理放大 — upscale an existing texture.
|
||||
|
||||
tripocli upscale -p <project_id> --wait
|
||||
|
||||
实测(页面上真操作抓到的请求):
|
||||
|
||||
POST /v2/studio/operation/texture_upscaler
|
||||
{"model_version": "v3.0-20250812", "project_id": "...", "texture_quality": "detailed"}
|
||||
|
||||
⚠️ 字段是 `texture_quality`(值为 `detailed`),不是 `texture_resolution`——
|
||||
写成后者会得到 `1001 Fatal error on the server side`。
|
||||
`model_version` 用项目当前的模型版本(从项目详情读取)。
|
||||
界面上的 4K / 8K 选择**没有**出现在请求体里(实测默认档 4K 的请求体只有上面三个字段)。
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "upscale"
|
||||
HELP = "纹理放大:提升贴图分辨率"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "放大后的模型保存到该路径(可选)")
|
||||
parser.add_argument("--quality", default="detailed", help="贴图质量(默认 detailed)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
model_version = _common.project_version(ctx.client, args.project, args.model)
|
||||
if not model_version:
|
||||
raise ValueError(f"无法读取项目 {args.project} 的 model_version,请用 -m 显式指定")
|
||||
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"model_version": model_version,
|
||||
"texture_quality": args.quality,
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/texture_upscaler", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="纹理放大")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""自我更新 — tripocli update.
|
||||
|
||||
tripocli update # 从记住的源码目录重装全局 CLI
|
||||
tripocli update --from ~/Documents/AI/github/Tripo2Api
|
||||
tripocli update --check # 只查看当前状态,不安装
|
||||
tripocli update --dry-run # 只打印将要执行的命令
|
||||
|
||||
改完源码后跑一次,全局 `tripocli` 就更新到最新;用 pyenv 时自动 rehash。
|
||||
"""
|
||||
|
||||
from .. import __version__, update as updater
|
||||
|
||||
NAME = "update"
|
||||
HELP = "更新全局 CLI 到项目源码的最新版本"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--from", dest="source", help="指定项目源码目录(会记住,之后无需再传)")
|
||||
parser.add_argument("--check", action="store_true", help="只显示源码目录与已安装版本,不执行安装")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只打印将要执行的命令")
|
||||
parser.add_argument("--no-rehash", action="store_true", help="跳过 pyenv rehash")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
source = updater.detect_source(ctx.args.source, config=ctx.config)
|
||||
|
||||
if ctx.args.check or not source:
|
||||
installed = updater.installed_version()
|
||||
info = {
|
||||
"source": str(source) if source else None,
|
||||
"running_version": __version__,
|
||||
"installed_version": installed,
|
||||
"needs_install": bool(source) and installed != __version__,
|
||||
"pyenv": updater._has_pyenv(),
|
||||
}
|
||||
if not source:
|
||||
ctx.out.error(
|
||||
"未找到项目源码目录。请指定一次:tripocli update --from <项目目录>"
|
||||
)
|
||||
return info
|
||||
ctx.out.data(info, human=lambda: (
|
||||
ctx.out.line(f"源码目录 : {info['source']}"),
|
||||
ctx.out.line(f"当前运行版本: {info['running_version']}"),
|
||||
ctx.out.line(f"已安装版本 : {info['installed_version'] or '未安装'}"),
|
||||
ctx.out.line(f"需要更新 : {'是' if info['needs_install'] else '否(已一致)'}"),
|
||||
ctx.out.line(f"pyenv : {'是(安装后会自动 rehash)' if info['pyenv'] else '否'}"),
|
||||
))
|
||||
return info
|
||||
|
||||
# Remember the source so later runs are just `tripocli update`.
|
||||
ctx.config.set(**{updater.INSTALL_SOURCE_KEY: str(source)})
|
||||
|
||||
report = updater.update(source, rehash=not ctx.args.no_rehash, dry_run=ctx.args.dry_run)
|
||||
ctx.out.data(
|
||||
report,
|
||||
human=lambda: (
|
||||
ctx.out.line("✅ 已更新全局 tripocli" if not ctx.args.dry_run else "(dry-run)"),
|
||||
*[ctx.out.line(" " + line) for line in updater.report_lines(report)],
|
||||
ctx.out.line(" 提示: 在项目目录内执行时会用项目里的代码;其它目录才用刚装的这份"),
|
||||
),
|
||||
)
|
||||
return report
|
||||
@@ -0,0 +1,81 @@
|
||||
"""智能 UV — unfold UVs for a low-poly model.
|
||||
|
||||
tripocli uv -p <project_id> --wait
|
||||
|
||||
实测流程(在页面上真操作抓到的请求):
|
||||
|
||||
POST /v2/studio/operation/uv_edit/generate
|
||||
{project_id, current_operator_id, action:"generate"} → 生成 UV 候选(新 operator)
|
||||
POST /v2/studio/operation/uv_edit/apply
|
||||
{project_id, current_operator_id, candidate_operator_id} → 保存/应用候选
|
||||
POST /v2/studio/operation/uv_edit/context
|
||||
{project_id, current_operator_id} → 查询当前处于哪一步
|
||||
|
||||
所以这是**两步握手**:先 generate 出候选,再 apply 保存。只调 generate 不 apply,
|
||||
上下文会停在「待保存」,再调 generate 就会报 `2046 UV Edit action no longer matches the context`。
|
||||
|
||||
另外服务端要求网格是低模(约 ≤8 万三角面),否则需要先 `tripocli remesh`。
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "uv"
|
||||
HELP = "智能 UV:为低模展开 UV(需 ≤8 万三角面)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "应用 UV 后的模型保存到该路径(可选)")
|
||||
parser.add_argument("--action", default="generate", help="UV 操作动作(默认 generate)")
|
||||
parser.add_argument("--generate-only", action="store_true", help="只生成候选,不自动保存应用")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
client = ctx.client
|
||||
|
||||
operator = client.project_operator(args.project)
|
||||
current_operator_id = operator.get("operator_id")
|
||||
if not current_operator_id:
|
||||
raise ValueError(f"无法读取项目 {args.project} 的 operator_id")
|
||||
|
||||
generate_body = {
|
||||
"project_id": args.project,
|
||||
"current_operator_id": current_operator_id,
|
||||
"action": args.action,
|
||||
}
|
||||
model_version = _common.project_version(client, args.project, args.model)
|
||||
if model_version:
|
||||
generate_body["model_version"] = model_version
|
||||
|
||||
ctx.out.line(f"🚀 生成 UV 候选 (current_operator_id={current_operator_id})...")
|
||||
generated = client.get_data("POST", "/v2/studio/operation/uv_edit/generate", body=generate_body) or {}
|
||||
candidate_id = generated.get("candidate_operator_id") or generated.get("operator_id")
|
||||
if not candidate_id:
|
||||
raise RuntimeError(f"generate 未返回候选 operator: {generated}")
|
||||
|
||||
if args.wait and candidate_id:
|
||||
# The generate step produces a candidate; wait for it, but never hang on it —
|
||||
# if it does not report a terminal state we still go on to apply.
|
||||
try:
|
||||
_common.wait_and_report(ctx, candidate_id, timeout=180)
|
||||
except Exception as error:
|
||||
ctx.out.line(f"(候选未在 180s 内就绪,继续尝试保存: {error})")
|
||||
|
||||
if args.generate_only:
|
||||
ctx.out.data(
|
||||
{"generate": generated, "candidate_operator_id": candidate_id},
|
||||
human=lambda: ctx.out.line(f"✅ 已生成候选(未保存): {candidate_id}"),
|
||||
)
|
||||
return {"generate": generated, "candidate_operator_id": candidate_id}
|
||||
|
||||
apply_body = {
|
||||
"project_id": args.project,
|
||||
"current_operator_id": current_operator_id,
|
||||
"candidate_operator_id": candidate_id,
|
||||
}
|
||||
ctx.out.line("💾 应用(保存)UV 候选...")
|
||||
applied = client.get_data("POST", "/v2/studio/operation/uv_edit/apply", body=apply_body) or {}
|
||||
|
||||
return _common.run_operator(
|
||||
ctx, applied, output=args.output, wait=args.wait, label="智能 UV"
|
||||
)
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
"""Read metadata and node names out of a GLB/GLTF file, using only the standard library.
|
||||
|
||||
The studio builds ``part_name_list`` from the mesh's *node names*, which live
|
||||
inside the model file — the project detail API never returns them. Unsegmented
|
||||
models carry a single ``tripo_node_<operator_id>`` node; a segmented model
|
||||
carries one ``tripo_part_<n>`` node per part.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
|
||||
GLB_MAGIC = b"glTF"
|
||||
JSON_CHUNK = 0x4E4F534A
|
||||
|
||||
|
||||
def gltf_document(data):
|
||||
"""Return the glTF JSON document from a ``.glb`` or ``.gltf`` byte string."""
|
||||
if data[:4] == GLB_MAGIC:
|
||||
offset = 12
|
||||
while offset < len(data):
|
||||
length, kind = struct.unpack_from("<II", data, offset)
|
||||
offset += 8
|
||||
chunk = data[offset:offset + length]
|
||||
offset += length
|
||||
if kind == JSON_CHUNK:
|
||||
return json.loads(chunk.decode("utf-8"))
|
||||
raise ValueError("GLB 缺少 JSON chunk")
|
||||
if data[:18] == b"Kaydara FBX Binary":
|
||||
raise ValueError("该文件实际是 FBX 二进制格式(而非 GLB),可用 `tripocli export -p <pid> -f glb --wait` 转为 GLB")
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
|
||||
def read_gltf_file(path):
|
||||
"""Read just the JSON header chunk from a local ``.glb`` or ``.gltf`` file."""
|
||||
with open(path, "rb") as handle:
|
||||
head = handle.read(20)
|
||||
if head[:4] == GLB_MAGIC and len(head) >= 20:
|
||||
length, kind = struct.unpack_from("<II", head, 12)
|
||||
if kind == JSON_CHUNK:
|
||||
chunk = handle.read(length)
|
||||
return json.loads(chunk.decode("utf-8"))
|
||||
handle.seek(0)
|
||||
return gltf_document(handle.read())
|
||||
|
||||
|
||||
def node_names(data):
|
||||
"""Named nodes of the model, in file order."""
|
||||
document = gltf_document(data)
|
||||
return [node.get("name") for node in (document.get("nodes") or []) if node.get("name")]
|
||||
|
||||
|
||||
def inspect_file(path):
|
||||
"""Extract game-relevant stats (triangles, bbox, textures, joints, animations) from a GLB/GLTF."""
|
||||
abspath = os.path.abspath(path)
|
||||
file_size = os.path.getsize(abspath)
|
||||
doc = read_gltf_file(abspath)
|
||||
|
||||
accessors = doc.get("accessors") or []
|
||||
meshes = doc.get("meshes") or []
|
||||
nodes = doc.get("nodes") or []
|
||||
materials = doc.get("materials") or []
|
||||
textures = doc.get("textures") or []
|
||||
images = doc.get("images") or []
|
||||
skins = doc.get("skins") or []
|
||||
animations = doc.get("animations") or []
|
||||
extensions = doc.get("extensionsUsed") or []
|
||||
|
||||
vertices = 0
|
||||
triangles = 0
|
||||
primitives_count = 0
|
||||
min_pt = [float("inf"), float("inf"), float("inf")]
|
||||
max_pt = [float("-inf"), float("-inf"), float("-inf")]
|
||||
has_bbox = False
|
||||
|
||||
for mesh in meshes:
|
||||
for prim in mesh.get("primitives") or []:
|
||||
primitives_count += 1
|
||||
attrs = prim.get("attributes") or {}
|
||||
pos_idx = attrs.get("POSITION")
|
||||
pos_count = 0
|
||||
if pos_idx is not None and 0 <= pos_idx < len(accessors):
|
||||
pos_acc = accessors[pos_idx]
|
||||
pos_count = pos_acc.get("count") or 0
|
||||
vertices += pos_count
|
||||
amin = pos_acc.get("min")
|
||||
amax = pos_acc.get("max")
|
||||
if isinstance(amin, list) and isinstance(amax, list) and len(amin) >= 3 and len(amax) >= 3:
|
||||
has_bbox = True
|
||||
for axis in range(3):
|
||||
min_pt[axis] = min(min_pt[axis], float(amin[axis]))
|
||||
max_pt[axis] = max(max_pt[axis], float(amax[axis]))
|
||||
|
||||
mode = prim.get("mode", 4) # 4 = TRIANGLES
|
||||
idx_acc_id = prim.get("indices")
|
||||
if idx_acc_id is not None and 0 <= idx_acc_id < len(accessors):
|
||||
idx_count = accessors[idx_acc_id].get("count") or 0
|
||||
if mode == 4:
|
||||
triangles += idx_count // 3
|
||||
elif mode in (5, 6):
|
||||
triangles += max(0, idx_count - 2)
|
||||
elif mode == 4 and pos_count:
|
||||
triangles += pos_count // 3
|
||||
|
||||
bbox = None
|
||||
if has_bbox:
|
||||
size = [round(max_pt[i] - min_pt[i], 4) for i in range(3)]
|
||||
bbox = {
|
||||
"min": [round(v, 4) for v in min_pt],
|
||||
"max": [round(v, 4) for v in max_pt],
|
||||
"size": size,
|
||||
"ground_offset_y": round(-min_pt[1], 4),
|
||||
}
|
||||
|
||||
has_pbr = False
|
||||
for mat in materials:
|
||||
pbr = mat.get("pbrMetallicRoughness") or {}
|
||||
if pbr.get("metallicRoughnessTexture") or mat.get("normalTexture"):
|
||||
has_pbr = True
|
||||
break
|
||||
|
||||
joint_indices = set()
|
||||
for skin in skins:
|
||||
joint_indices.update(skin.get("joints") or [])
|
||||
|
||||
mesh_nodes = [
|
||||
n.get("name") for i, n in enumerate(nodes)
|
||||
if n.get("name") and ("mesh" in n or i not in joint_indices)
|
||||
]
|
||||
anim_list = [
|
||||
{"name": a.get("name") or f"clip_{i}", "channels": len(a.get("channels") or [])}
|
||||
for i, a in enumerate(animations)
|
||||
]
|
||||
|
||||
warnings = []
|
||||
if not meshes and anim_list:
|
||||
warnings.append("纯骨骼动画轨道文件(无网格),在引擎中需挂载到 rig 导出的角色本体网格上播放")
|
||||
if triangles > 100000:
|
||||
warnings.append(
|
||||
f"面数较高 ({triangles:,} 三角面),放入实时游戏场景建议先运行: "
|
||||
f"tripocli optimize {os.path.basename(path)}"
|
||||
)
|
||||
if meshes and not textures:
|
||||
warnings.append("无贴图白模(materials 未包含纹理贴图)")
|
||||
|
||||
return {
|
||||
"file": abspath,
|
||||
"name": os.path.basename(abspath),
|
||||
"file_size": file_size,
|
||||
"file_size_mb": round(file_size / (1024 * 1024), 2),
|
||||
"meshes": len(meshes),
|
||||
"primitives": primitives_count,
|
||||
"vertices": vertices,
|
||||
"triangles": triangles,
|
||||
"bbox": bbox,
|
||||
"materials": len(materials),
|
||||
"textures": len(textures),
|
||||
"images": len(images),
|
||||
"has_pbr_maps": has_pbr,
|
||||
"is_rigged": bool(skins),
|
||||
"joints": len(joint_indices),
|
||||
"animations": anim_list,
|
||||
"draco_compressed": "KHR_draco_mesh_compression" in extensions,
|
||||
"extensions": extensions,
|
||||
"mesh_nodes": mesh_nodes[:20],
|
||||
"warnings": warnings,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""动作预设目录。
|
||||
|
||||
数据来自 studio 前端的静态表(页面加载的 chunk 里的 ``biped:[{action_type,category,name}...]``),
|
||||
因为服务端**没有**提供预设清单接口(实测:``/motion/list_assets`` 返回的是用户自己的动画资产,
|
||||
``/operation/get_user_operation_context`` 只返回「最近用过」的几个)。
|
||||
|
||||
动作标识格式:``preset:<rig_type>:<name>``,例如 ``preset:biped:afraid``。
|
||||
每个动作的预览图:``https://tripo-public.tripo3d.ai/studio_riging_data/<rig_type>/<name>.avif``
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
DATA_PATH = Path(__file__).parent / "data" / "motions.json"
|
||||
PREVIEW_BASE = "https://tripo-public.tripo3d.ai/studio_riging_data"
|
||||
|
||||
_cache = None
|
||||
|
||||
|
||||
def catalog():
|
||||
"""``{rig_type: [{action_type, category, name}, ...]}``"""
|
||||
global _cache
|
||||
if _cache is None:
|
||||
try:
|
||||
_cache = json.loads(DATA_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
_cache = {}
|
||||
return _cache
|
||||
|
||||
|
||||
def rig_types():
|
||||
return sorted(catalog())
|
||||
|
||||
|
||||
def names(rig_type="biped"):
|
||||
return [item["name"] for item in catalog().get(rig_type, [])]
|
||||
|
||||
|
||||
def identifier(rig_type, name):
|
||||
return f"preset:{rig_type}:{name}"
|
||||
|
||||
|
||||
def preview_url(rig_type, name):
|
||||
return f"{PREVIEW_BASE}/{rig_type}/{name}.avif"
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Terminal output helpers.
|
||||
|
||||
Every command prints either a human readable block or, with ``--json``, a single
|
||||
JSON document on stdout so an agent can parse it. Errors go to stderr (and to a
|
||||
JSON object on stdout when ``--json`` is active) so stdout stays machine safe.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
class Out:
|
||||
def __init__(self, as_json=False):
|
||||
self.as_json = as_json
|
||||
|
||||
def json(self, payload):
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
|
||||
def data(self, payload, human=None):
|
||||
"""Print ``payload`` as JSON, or ``human()`` when not in JSON mode."""
|
||||
if self.as_json:
|
||||
self.json(payload)
|
||||
elif human:
|
||||
human()
|
||||
|
||||
def line(self, text=""):
|
||||
if not self.as_json:
|
||||
print(text)
|
||||
|
||||
def notice(self, text):
|
||||
"""Progress/warning for humans; goes to stderr so stdout stays parseable."""
|
||||
print(text, file=sys.stderr)
|
||||
|
||||
def progress(self, text):
|
||||
"""In-place progress line; suppressed in JSON mode."""
|
||||
if not self.as_json:
|
||||
print(text, end="\r", flush=True)
|
||||
|
||||
def progress_done(self):
|
||||
if not self.as_json:
|
||||
print()
|
||||
|
||||
def error(self, message):
|
||||
if self.as_json:
|
||||
self.json({"error": str(message)})
|
||||
print(f"❌ {message}", file=sys.stderr)
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
"""Direct-to-S3 upload with AWS SigV4, using only the standard library.
|
||||
|
||||
The studio hands out temporary AWS credentials via
|
||||
``POST /v2/studio/storage/temporary_token``; the browser then PUTs the raw bytes
|
||||
straight to S3 (``https://{bucket}.{host}/{key}``). No boto3 required.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import urllib.request
|
||||
|
||||
REGION = "us-west-2"
|
||||
SERVICE = "s3"
|
||||
|
||||
|
||||
def _sign(key, message):
|
||||
return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest()
|
||||
|
||||
|
||||
def upload_bytes(sts, data, content_type="application/octet-stream", timeout=120):
|
||||
"""PUT ``data`` to the S3 object described by the STS response.
|
||||
|
||||
``sts`` is the ``data`` block of ``/v2/studio/storage/temporary_token``:
|
||||
``sts_ak``, ``sts_sk``, ``session_token``, ``host``, ``resource_bucket``,
|
||||
``resource_uri``.
|
||||
"""
|
||||
access_key = sts["sts_ak"]
|
||||
secret_key = sts["sts_sk"]
|
||||
session_token = sts["session_token"]
|
||||
host = sts.get("host", "s3.us-west-2.amazonaws.com")
|
||||
bucket = sts["resource_bucket"]
|
||||
key = sts["resource_uri"]
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
date_stamp = now.strftime("%Y%m%d")
|
||||
|
||||
host_header = f"{bucket}.{host}"
|
||||
payload_hash = hashlib.sha256(data).hexdigest()
|
||||
|
||||
canonical_headers = (
|
||||
f"host:{host_header}\n"
|
||||
f"x-amz-content-sha256:{payload_hash}\n"
|
||||
f"x-amz-date:{amz_date}\n"
|
||||
f"x-amz-security-token:{session_token}\n"
|
||||
)
|
||||
signed_headers = "host;x-amz-content-sha256;x-amz-date;x-amz-security-token"
|
||||
canonical_request = f"PUT\n/{key}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
||||
|
||||
scope = f"{date_stamp}/{REGION}/{SERVICE}/aws4_request"
|
||||
string_to_sign = (
|
||||
f"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n"
|
||||
f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
|
||||
)
|
||||
|
||||
k_date = _sign(("AWS4" + secret_key).encode("utf-8"), date_stamp)
|
||||
k_region = _sign(k_date, REGION)
|
||||
k_service = _sign(k_region, SERVICE)
|
||||
k_signing = _sign(k_service, "aws4_request")
|
||||
signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
authorization = (
|
||||
f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, "
|
||||
f"SignedHeaders={signed_headers}, Signature={signature}"
|
||||
)
|
||||
|
||||
request = urllib.request.Request(
|
||||
f"https://{host_header}/{key}",
|
||||
data=data,
|
||||
method="PUT",
|
||||
headers={
|
||||
"Host": host_header,
|
||||
"x-amz-date": amz_date,
|
||||
"x-amz-content-sha256": payload_hash,
|
||||
"x-amz-security-token": session_token,
|
||||
"Authorization": authorization,
|
||||
"Content-Type": content_type,
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.status in (200, 204)
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
"""Local account-management service (``tripocli serve``).
|
||||
|
||||
Serves a small web page plus a JSON API so cookies can be added, updated and
|
||||
inspected from a browser instead of the terminal:
|
||||
|
||||
GET / the page
|
||||
GET /api/accounts list accounts (identity, session expiry, credits)
|
||||
POST /api/accounts add/update an account {name?, cookie}
|
||||
POST /api/accounts/<name>/activate make it the active account
|
||||
POST /api/accounts/<name>/refresh re-validate the cookie and re-mint the JWT
|
||||
DELETE /api/accounts/<name> remove an account
|
||||
|
||||
The page never receives a full cookie — only a masked form. The server binds to
|
||||
loopback by default because whoever can reach it can act as the signed-in user.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from . import auth
|
||||
from .accounts import Accounts
|
||||
from .client import TripoClient
|
||||
from .config import Config
|
||||
from .errors import TripoAPIError
|
||||
|
||||
PAGE = Path(__file__).parent / "web" / "accounts.html"
|
||||
CREDIT_CACHE_SECONDS = 30
|
||||
|
||||
|
||||
def _mask(value, keep=8):
|
||||
if not value:
|
||||
return None
|
||||
return f"{value[:keep]}…{value[-4:]}" if len(value) > keep + 6 else "***"
|
||||
|
||||
|
||||
class AccountService:
|
||||
"""Account store plus a short-lived credits cache for the list view."""
|
||||
|
||||
def __init__(self, accounts=None, config=None):
|
||||
self.accounts = accounts or Accounts()
|
||||
self.config = config or Config()
|
||||
self._lock = threading.Lock()
|
||||
self._cache = {} # name -> (timestamp, payload)
|
||||
|
||||
# --- helpers ---
|
||||
|
||||
def _client(self, name=None):
|
||||
return TripoClient(account=name, accounts=self.accounts, config=self.config)
|
||||
|
||||
def _live_status(self, name):
|
||||
"""Credits/membership for one account, cached briefly to keep the page snappy."""
|
||||
cached = self._cache.get(name)
|
||||
if cached and time.time() - cached[0] < CREDIT_CACHE_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
status = {"total_credit": None, "expiring_credit": None, "expiring_date": None,
|
||||
"member_type": None, "member_valid_until": None, "token_expires_at": None,
|
||||
"error": None}
|
||||
try:
|
||||
client = self._client(name)
|
||||
client.ensure_credentials()
|
||||
profile = client.payment_profile() or {}
|
||||
wallet = profile.get("wallet") or {}
|
||||
member = profile.get("member") or {}
|
||||
status.update({
|
||||
"total_credit": wallet.get("total_credit"),
|
||||
"expiring_credit": wallet.get("expiring_credit"),
|
||||
"expiring_date": wallet.get("expiring_date"),
|
||||
"member_type": member.get("type"),
|
||||
"member_valid_until": member.get("valid_until"),
|
||||
"token_expires_at": _iso(client.token),
|
||||
})
|
||||
except Exception as error:
|
||||
status["error"] = str(error)[:200]
|
||||
|
||||
self._cache[name] = (time.time(), status)
|
||||
return status
|
||||
|
||||
def list_accounts(self):
|
||||
active = self.accounts.active_name()
|
||||
result = []
|
||||
for name in self.accounts.names():
|
||||
record = self.accounts.data["accounts"][name]
|
||||
item = {
|
||||
"name": name,
|
||||
"active": name == active,
|
||||
"email": record.get("email"),
|
||||
"user_id": record.get("user_id"),
|
||||
"session_expires_at": record.get("session_expires_at"),
|
||||
"cookie": _mask(record.get("cookie")),
|
||||
}
|
||||
item.update(self._live_status(name))
|
||||
result.append(item)
|
||||
return {"active": active, "accounts": result}
|
||||
|
||||
def add_account(self, name, cookie):
|
||||
if not cookie:
|
||||
raise ValueError("cookie 不能为空")
|
||||
token, session_expires = auth.mint_token(self.config.api_base, cookie, self.config.device_id)
|
||||
try:
|
||||
user_id, email = auth.fetch_identity(self.config.api_base, cookie, self.config.device_id)
|
||||
except Exception:
|
||||
user_id, email = None, None
|
||||
|
||||
name = (name or email or user_id or "default").strip()
|
||||
with self._lock:
|
||||
self.accounts.add(name, auth.normalize_cookie(cookie), token=token,
|
||||
session_expires_at=session_expires, user_id=user_id, email=email)
|
||||
self._cache.pop(name, None)
|
||||
return {"name": name, "email": email, "user_id": user_id, "session_expires_at": session_expires}
|
||||
|
||||
def activate(self, name):
|
||||
with self._lock:
|
||||
if not self.accounts.exists(name):
|
||||
raise KeyError(name)
|
||||
self.accounts.use(name)
|
||||
return {"active": name}
|
||||
|
||||
def remove(self, name):
|
||||
with self._lock:
|
||||
if not self.accounts.exists(name):
|
||||
raise KeyError(name)
|
||||
self.accounts.remove(name)
|
||||
self._cache.pop(name, None)
|
||||
return {"removed": name, "active": self.accounts.active_name()}
|
||||
|
||||
def refresh(self, name):
|
||||
with self._lock:
|
||||
_, record = self.accounts.get(name)
|
||||
if not record:
|
||||
raise KeyError(name)
|
||||
cookie = record.get("cookie")
|
||||
if not cookie:
|
||||
raise ValueError(f"账号 {name} 没有 cookie")
|
||||
token, session_expires = auth.mint_token(self.config.api_base, cookie, self.config.device_id)
|
||||
with self._lock:
|
||||
self.accounts.update_token(name, token, session_expires)
|
||||
self._cache.pop(name, None)
|
||||
return {"name": name, "token_expires_at": _iso(token), "session_expires_at": session_expires}
|
||||
|
||||
|
||||
def _iso(token):
|
||||
exp = auth.decode_jwt_payload(token or "").get("exp")
|
||||
if not exp:
|
||||
return None
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(int(exp)))
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "tripocli"
|
||||
service: AccountService = None
|
||||
|
||||
# --- plumbing ---
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"[serve] {self.address_string()} {fmt % args}")
|
||||
|
||||
def _send(self, status, payload, content_type="application/json; charset=utf-8"):
|
||||
body = payload if isinstance(payload, bytes) else json.dumps(payload, ensure_ascii=False).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def _error(self, status, message):
|
||||
self._send(status, {"error": str(message)})
|
||||
|
||||
def _read_json(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
if not length:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("请求体不是合法 JSON")
|
||||
|
||||
# --- routing ---
|
||||
|
||||
def _segments(self):
|
||||
path = urlparse(self.path).path
|
||||
return [unquote(part) for part in path.strip("/").split("/") if part]
|
||||
|
||||
def do_GET(self):
|
||||
parts = self._segments()
|
||||
if not parts:
|
||||
try:
|
||||
self._send(200, PAGE.read_bytes(), "text/html; charset=utf-8")
|
||||
except OSError as error:
|
||||
self._error(500, f"页面文件读取失败: {error}")
|
||||
return
|
||||
if parts == ["api", "accounts"]:
|
||||
self._send(200, self.service.list_accounts())
|
||||
return
|
||||
if parts == ["api", "health"]:
|
||||
self._send(200, {"ok": True})
|
||||
return
|
||||
self._error(404, "not found")
|
||||
|
||||
def do_POST(self):
|
||||
parts = self._segments()
|
||||
try:
|
||||
if parts == ["api", "accounts"]:
|
||||
body = self._read_json()
|
||||
self._send(200, self.service.add_account(body.get("name", ""), body.get("cookie", "")))
|
||||
return
|
||||
if len(parts) == 4 and parts[:2] == ["api", "accounts"]:
|
||||
name, action = parts[2], parts[3]
|
||||
if action == "activate":
|
||||
self._send(200, self.service.activate(name))
|
||||
return
|
||||
if action == "refresh":
|
||||
self._send(200, self.service.refresh(name))
|
||||
return
|
||||
except KeyError as error:
|
||||
self._error(404, f"账号不存在: {error.args[0]}")
|
||||
return
|
||||
except (ValueError, TripoAPIError) as error:
|
||||
self._error(400, error)
|
||||
return
|
||||
except Exception as error:
|
||||
self._error(500, error)
|
||||
return
|
||||
self._error(404, "not found")
|
||||
|
||||
def do_DELETE(self):
|
||||
parts = self._segments()
|
||||
if len(parts) == 3 and parts[:2] == ["api", "accounts"]:
|
||||
try:
|
||||
self._send(200, self.service.remove(parts[2]))
|
||||
except KeyError as error:
|
||||
self._error(404, f"账号不存在: {error.args[0]}")
|
||||
except Exception as error:
|
||||
self._error(500, error)
|
||||
return
|
||||
self._error(404, "not found")
|
||||
|
||||
|
||||
def create_server(host="127.0.0.1", port=8765, service=None):
|
||||
handler = type("BoundHandler", (Handler,), {"service": service or AccountService()})
|
||||
return ThreadingHTTPServer((host, port), handler)
|
||||
|
||||
|
||||
def serve(host="127.0.0.1", port=8765):
|
||||
httpd = create_server(host, port)
|
||||
actual_port = httpd.server_address[1]
|
||||
return httpd, f"http://{host}:{actual_port}/"
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
"""Self-update: refresh the installed CLI from the project source tree.
|
||||
|
||||
The project is distributed as a source directory (no PyPI release yet), so
|
||||
"updating" means: find the source tree, re-install it with the *same*
|
||||
interpreter that is running us, and rehash pyenv shims if pyenv is in use.
|
||||
|
||||
The source directory is remembered in ``~/.tripo/config.json`` (``install_source``)
|
||||
the first time it is discovered, so later runs only need ``tripocli update``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import __version__
|
||||
|
||||
INSTALL_SOURCE_KEY = "install_source"
|
||||
|
||||
|
||||
def _running_package_dir():
|
||||
"""Directory of the ``tripo`` package that is currently imported."""
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _is_site_packages(path):
|
||||
parts = {p.lower() for p in Path(path).parts}
|
||||
return bool(parts & {"site-packages", "dist-packages"})
|
||||
|
||||
|
||||
def detect_source(explicit=None, config=None):
|
||||
"""Locate the source tree to install from.
|
||||
|
||||
Order: ``--from`` > remembered ``install_source`` > the tree we were imported
|
||||
from (only when that is not site-packages).
|
||||
"""
|
||||
if explicit:
|
||||
candidate = Path(explicit).expanduser().resolve()
|
||||
if not (candidate / "pyproject.toml").exists():
|
||||
raise ValueError(f"{candidate} 看起来不是项目目录(缺少 pyproject.toml)")
|
||||
return candidate
|
||||
|
||||
if config:
|
||||
remembered = config.data.get(INSTALL_SOURCE_KEY)
|
||||
if remembered and (Path(remembered) / "pyproject.toml").exists():
|
||||
return Path(remembered)
|
||||
|
||||
package_dir = _running_package_dir()
|
||||
if not _is_site_packages(package_dir):
|
||||
root = package_dir.parent
|
||||
if (root / "pyproject.toml").exists():
|
||||
return root
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def installed_version():
|
||||
"""Version of the copy in site-packages (what other directories will run)."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "show", "tripocli"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
for line in out.stdout.splitlines():
|
||||
if line.lower().startswith("version:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _has_pyenv():
|
||||
return bool(shutil.which("pyenv") or os.environ.get("PYENV_ROOT"))
|
||||
|
||||
|
||||
def update(source, rehash=True, dry_run=False):
|
||||
"""Re-install ``source`` with the running interpreter. Returns a report dict."""
|
||||
source = Path(source)
|
||||
steps = []
|
||||
command = [
|
||||
sys.executable, "-m", "pip", "install",
|
||||
"--no-build-isolation", "--no-deps", "--upgrade", "--force-reinstall", str(source),
|
||||
]
|
||||
|
||||
if dry_run:
|
||||
planned = [" ".join(command)]
|
||||
if rehash and _has_pyenv():
|
||||
planned.append("pyenv rehash")
|
||||
return {"source": str(source), "command": command, "steps": planned,
|
||||
"rehash": False, "dry_run": True, "version": __version__}
|
||||
|
||||
proc = subprocess.run(command, capture_output=True, text=True)
|
||||
steps.append(" ".join(command))
|
||||
tail = "\n".join((proc.stdout or "").strip().splitlines()[-6:])
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"pip 安装失败(退出码 {proc.returncode}):\n{(proc.stderr or proc.stdout or '')[-800:]}"
|
||||
)
|
||||
steps.append(tail)
|
||||
|
||||
rehashed = False
|
||||
if rehash and _has_pyenv():
|
||||
subprocess.run(["pyenv", "rehash"], capture_output=True, text=True)
|
||||
steps.append("pyenv rehash")
|
||||
rehashed = True
|
||||
|
||||
return {
|
||||
"source": str(source),
|
||||
"command": command,
|
||||
"steps": steps,
|
||||
"rehash": rehashed,
|
||||
"version": __version__,
|
||||
"installed_version": installed_version(),
|
||||
}
|
||||
|
||||
|
||||
def report_lines(report):
|
||||
prefix = "将执行" if report.get("dry_run") else "已执行"
|
||||
lines = [
|
||||
f"源码目录 : {report['source']}",
|
||||
f"版本 : {report.get('installed_version') or report['version']}",
|
||||
f"{prefix} : {'; '.join(report['steps'])}",
|
||||
]
|
||||
if report.get("rehash"):
|
||||
lines.append("已执行 : pyenv rehash")
|
||||
return lines
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Model version aliases.
|
||||
|
||||
The web studio exposes friendly names (``P2.0``, ``H3.1`` ...) that map onto the
|
||||
internal ``model_version`` strings the API expects. Verified against the live
|
||||
API: ``Nexus-v2.0-20260801`` and ``v3.1-20260211`` both generate successfully.
|
||||
"""
|
||||
|
||||
SMART_MESH_DEFAULT = "Nexus-v2.0-20260801"
|
||||
HIGH_DETAIL_DEFAULT = "v3.1-20260211"
|
||||
|
||||
# Aliases are matched case-insensitively.
|
||||
ALIASES = {
|
||||
# 智能网格 Smart Mesh
|
||||
"p2.0": SMART_MESH_DEFAULT,
|
||||
"p2": SMART_MESH_DEFAULT,
|
||||
"nexus-v2": SMART_MESH_DEFAULT,
|
||||
"p1.0": "Nexus-v1.0-20260214",
|
||||
"p1": "Nexus-v1.0-20260214",
|
||||
"nexus-v1": "Nexus-v1.0-20260214",
|
||||
# 高精度模型 High-Detail
|
||||
"h3.1": HIGH_DETAIL_DEFAULT,
|
||||
"v3.1": HIGH_DETAIL_DEFAULT,
|
||||
"h3.0": "v3.0-20250812",
|
||||
"v3.0": "v3.0-20250812",
|
||||
"h2.5": "v2.5-20250123",
|
||||
"v2.5": "v2.5-20250123",
|
||||
}
|
||||
|
||||
|
||||
def resolve(name, smart_mesh=False):
|
||||
"""Map a user supplied alias to an internal model version string.
|
||||
|
||||
Unknown values pass through untouched so a brand new server-side version can
|
||||
still be used without a client release.
|
||||
"""
|
||||
if not name:
|
||||
return SMART_MESH_DEFAULT if smart_mesh else HIGH_DETAIL_DEFAULT
|
||||
return ALIASES.get(name.strip().lower(), name.strip())
|
||||
|
||||
|
||||
def default_for(smart_mesh=False):
|
||||
return SMART_MESH_DEFAULT if smart_mesh else HIGH_DETAIL_DEFAULT
|
||||
@@ -0,0 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>tripocli · 账号管理</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg:#0f1115; --panel:#171a21; --line:#262b35; --fg:#e6e8ee;
|
||||
--muted:#8b93a7; --accent:#4f8cff; --ok:#3ddc84; --warn:#ffb020; --err:#ff5c5c; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,
|
||||
"PingFang SC", "Microsoft YaHei", system-ui, sans-serif; }
|
||||
header { padding:22px 26px 14px; border-bottom:1px solid var(--line); }
|
||||
h1 { margin:0 0 4px; font-size:19px; }
|
||||
header p { margin:0; color:var(--muted); font-size:13px; }
|
||||
main { padding:22px 26px 60px; max-width:1080px; }
|
||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:18px 20px; margin-bottom:20px; }
|
||||
.card h2 { margin:0 0 14px; font-size:15px; font-weight:600; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th, td { text-align:left; padding:10px 8px; border-bottom:1px solid var(--line); vertical-align:middle; }
|
||||
th { color:var(--muted); font-weight:500; font-size:12.5px; }
|
||||
tr:last-child td { border-bottom:none; }
|
||||
.name { font-weight:600; }
|
||||
.badge { display:inline-block; padding:1px 8px; border-radius:20px; font-size:11.5px;
|
||||
border:1px solid var(--line); color:var(--muted); }
|
||||
.badge.active { color:#0b1220; background:var(--ok); border-color:var(--ok); font-weight:600; }
|
||||
.ok { color:var(--ok); } .warn { color:var(--warn); } .err { color:var(--err); } .muted { color:var(--muted); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:12.5px; }
|
||||
button { font:inherit; padding:6px 12px; border-radius:7px; border:1px solid var(--line);
|
||||
background:#1e2330; color:var(--fg); cursor:pointer; }
|
||||
button:hover { border-color:var(--accent); }
|
||||
button.primary { background:var(--accent); border-color:var(--accent); color:#fff; font-weight:600; }
|
||||
button.danger:hover { border-color:var(--err); color:var(--err); }
|
||||
input, textarea { width:100%; background:#12151c; color:var(--fg); border:1px solid var(--line);
|
||||
border-radius:8px; padding:9px 11px; font:inherit; }
|
||||
textarea { min-height:88px; resize:vertical; font-family: ui-monospace, Menlo, monospace; font-size:12.5px; }
|
||||
.row { display:flex; gap:12px; align-items:flex-end; }
|
||||
.row > div { flex:1; }
|
||||
label { display:block; margin:0 0 6px; color:var(--muted); font-size:12.5px; }
|
||||
.msg { margin-top:12px; font-size:13px; min-height:18px; }
|
||||
.hint { color:var(--muted); font-size:12.5px; margin:10px 0 0; }
|
||||
.actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
code { background:#12151c; padding:1px 5px; border-radius:5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>tripocli · 账号管理</h1>
|
||||
<p>粘贴 cookie 添加账号;CLI 会用 cookie 自动续期 JWT,无需浏览器常驻。</p>
|
||||
</header>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h2>添加 / 更新账号</h2>
|
||||
<div class="row">
|
||||
<div style="max-width:220px">
|
||||
<label for="name">账号名(留空自动取邮箱)</label>
|
||||
<input id="name" placeholder="work">
|
||||
</div>
|
||||
<div>
|
||||
<label for="cookie">Cookie(整条,或 ory_kratos_session 的值)</label>
|
||||
<textarea id="cookie" placeholder="ory_kratos_session=...; csrf_token_...=..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<button class="primary" onclick="addAccount()">保存并校验</button>
|
||||
<button onclick="load()">刷新列表</button>
|
||||
</div>
|
||||
<p class="hint">获取 cookie:DevTools → Network → 任意 <code>api.tripo3d.ai</code> 请求 → Request Headers → 复制整条 <code>Cookie</code>。</p>
|
||||
<div id="addMsg" class="msg"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>账号列表</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>账号</th><th>身份</th><th>会话有效期</th><th>积分</th><th>会员</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="rows"><tr><td colspan="6" class="muted">加载中…</td></tr></tbody>
|
||||
</table>
|
||||
<div id="listMsg" class="msg"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => (s == null ? "" : String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])));
|
||||
|
||||
async function api(path, options) {
|
||||
const res = await fetch(path, options);
|
||||
let body = null;
|
||||
try { body = await res.json(); } catch (e) { body = { error: res.statusText }; }
|
||||
if (!res.ok) throw new Error(body.error || res.statusText);
|
||||
return body;
|
||||
}
|
||||
|
||||
function expiryCell(iso) {
|
||||
if (!iso) return '<span class="muted">未知</span>';
|
||||
const t = Date.parse(iso);
|
||||
if (isNaN(t)) return `<span class="mono">${esc(iso)}</span>`;
|
||||
const days = (t - Date.now()) / 86400000;
|
||||
const cls = days < 0 ? "err" : days < 5 ? "warn" : "ok";
|
||||
const d = new Date(t).toLocaleString();
|
||||
const left = days < 0 ? "已过期" : `剩 ${Math.floor(days)} 天`;
|
||||
return `<span class="${cls}">${esc(d)}</span><br><span class="muted">${left}</span>`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const rows = $("rows");
|
||||
try {
|
||||
const data = await api("/api/accounts");
|
||||
if (!data.accounts.length) {
|
||||
rows.innerHTML = '<tr><td colspan="6" class="muted">暂无账号,粘贴 cookie 添加。</td></tr>';
|
||||
return;
|
||||
}
|
||||
rows.innerHTML = data.accounts.map(a => `
|
||||
<tr>
|
||||
<td><span class="name">${esc(a.name)}</span> ${a.active ? '<span class="badge active">当前</span>' : ''}
|
||||
${a.error ? `<div class="err">${esc(a.error)}</div>` : ''}</td>
|
||||
<td>${esc(a.email || a.user_id || "-")}</td>
|
||||
<td>${expiryCell(a.session_expires_at)}</td>
|
||||
<td>${a.total_credit == null ? '<span class="muted">-</span>' : a.total_credit}
|
||||
${a.expiring_credit ? `<div class="muted">将过期 ${a.expiring_credit} (${esc(a.expiring_date||"-")})</div>` : ''}</td>
|
||||
<td>${esc(a.member_type || "-")}${a.member_valid_until ? `<div class="muted">至 ${esc(a.member_valid_until)}</div>` : ''}</td>
|
||||
<td><div class="actions">
|
||||
${a.active ? '' : `<button onclick="activate('${esc(a.name)}')">设为当前</button>`}
|
||||
<button onclick="refresh('${esc(a.name)}')">重新校验</button>
|
||||
<button class="danger" onclick="remove('${esc(a.name)}')">删除</button>
|
||||
</div></td>
|
||||
</tr>`).join("");
|
||||
} catch (e) {
|
||||
rows.innerHTML = `<tr><td colspan="6" class="err">${esc(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function addAccount() {
|
||||
const msg = $("addMsg");
|
||||
msg.textContent = "校验中…"; msg.className = "msg muted";
|
||||
try {
|
||||
const data = await api("/api/accounts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: $("name").value.trim(), cookie: $("cookie").value.trim() })
|
||||
});
|
||||
msg.className = "msg ok";
|
||||
msg.textContent = `✅ 已保存 ${data.name}${data.email ? " (" + data.email + ")" : ""}`;
|
||||
$("cookie").value = "";
|
||||
load();
|
||||
} catch (e) {
|
||||
msg.className = "msg err";
|
||||
msg.textContent = "❌ " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(name) {
|
||||
try { await api(`/api/accounts/${encodeURIComponent(name)}/activate`, { method: "POST" }); load(); }
|
||||
catch (e) { $("listMsg").className = "msg err"; $("listMsg").textContent = "❌ " + e.message; }
|
||||
}
|
||||
|
||||
async function refresh(name) {
|
||||
$("listMsg").className = "msg muted"; $("listMsg").textContent = `正在重新校验 ${name}…`;
|
||||
try {
|
||||
const d = await api(`/api/accounts/${encodeURIComponent(name)}/refresh`, { method: "POST" });
|
||||
$("listMsg").className = "msg ok";
|
||||
$("listMsg").textContent = `✅ ${name} 校验通过,JWT 到期 ${d.token_expires_at || "未知"}`;
|
||||
load();
|
||||
} catch (e) { $("listMsg").className = "msg err"; $("listMsg").textContent = "❌ " + e.message; }
|
||||
}
|
||||
|
||||
async function remove(name) {
|
||||
if (!confirm(`确定删除账号 ${name}?`)) return;
|
||||
try { await api(`/api/accounts/${encodeURIComponent(name)}`, { method: "DELETE" }); load(); }
|
||||
catch (e) { $("listMsg").className = "msg err"; $("listMsg").textContent = "❌ " + e.message; }
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user