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

+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"
)