tripocli v2.0.0:Tripo AI 3D 生成与加工 CLI(面向 Agent 与开发者,纯 Python 标准库、零第三方依赖)

覆盖文生 3D、图生 3D、多视图、批量生成,以及重拓扑、绑定、动画、智能分割、
纹理、PBR、智能 UV、部件补全、格式导出与下载。

代码组织:一个功能一个模块(tripo/features/),入口薄壳 tripocli.py。
This commit is contained in:
Mrpzx committed 2026-10-13 19:22:54 +08:00
commit 977f97995a
60 files changed
+5622

No files matched your search

+9
View File
@@ -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"
+132
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+47
View File
@@ -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()
+524
View File
@@ -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"
}
]
}
+53
View File
@@ -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)
+81
View File
@@ -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)
]
+179
View File
@@ -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
+75
View File
@@ -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)
+189
View File
@@ -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)
+120
View File
@@ -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
+36
View File
@@ -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
+76
View File
@@ -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
+42
View File
@@ -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
+48
View File
@@ -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)
+30
View File
@@ -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
+70
View File
@@ -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
+49
View File
@@ -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="部件补全")
+36
View File
@@ -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
+159
View File
@@ -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)
+58
View File
@@ -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)
+29
View File
@@ -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)
+53
View File
@@ -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="导入")
+53
View File
@@ -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
+51
View File
@@ -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="网格编辑")
+151
View File
@@ -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
+51
View File
@@ -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)
+185
View File
@@ -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
+32
View File
@@ -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 生成")
+30
View File
@@ -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
+36
View File
@@ -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="重拓扑")
+25
View File
@@ -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
+80
View File
@@ -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="绑定")
+42
View File
@@ -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="智能分割")
+57
View File
@@ -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}
+28
View File
@@ -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="风格化")
+67
View File
@@ -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)
+24
View File
@@ -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)
+42
View File
@@ -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="纹理生成")
+39
View File
@@ -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="纹理放大")
+62
View File
@@ -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
+81
View File
@@ -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
View File
@@ -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,
}
+44
View File
@@ -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"
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+42
View File
@@ -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
+178
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>