tripocli v2.0.0:Tripo AI 3D 生成与加工 CLI(面向 Agent 与开发者,纯 Python 标准库、零第三方依赖)
覆盖文生 3D、图生 3D、多视图、批量生成,以及重拓扑、绑定、动画、智能分割、 纹理、PBR、智能 UV、部件补全、格式导出与下载。 代码组织:一个功能一个模块(tripo/features/),入口薄壳 tripocli.py。
This commit is contained in:
commit
977f97995a
60 files changed
+5622
No files matched your search
@@ -0,0 +1,81 @@
|
||||
"""Feature registry.
|
||||
|
||||
Every Tripo capability is one module in this package. A module declares
|
||||
``NAME``, ``HELP``, ``add_arguments(parser)`` and ``run(ctx)``; ``cli.py`` wires
|
||||
them into the argparse tree. Adding a capability means adding one file and one
|
||||
line here.
|
||||
"""
|
||||
|
||||
from . import (
|
||||
account,
|
||||
account_mgmt,
|
||||
animate,
|
||||
assets,
|
||||
batch,
|
||||
cancel,
|
||||
config_cmd,
|
||||
download,
|
||||
export_model,
|
||||
fill_parts,
|
||||
image_assets,
|
||||
image_gen,
|
||||
image_multiview,
|
||||
image_to_3d,
|
||||
import_model,
|
||||
inspect_model,
|
||||
mesh_edit,
|
||||
multiview,
|
||||
motion,
|
||||
optimize_model,
|
||||
pbr,
|
||||
project,
|
||||
remesh,
|
||||
rename,
|
||||
rigging,
|
||||
segmentation,
|
||||
serve,
|
||||
stylize,
|
||||
task,
|
||||
text_to_3d,
|
||||
texture,
|
||||
texture_upscale,
|
||||
update,
|
||||
uv,
|
||||
)
|
||||
|
||||
MODULES = [
|
||||
account_mgmt, # account add/list/use/remove/show
|
||||
serve, # serve (local account UI)
|
||||
account, # balance / whoami / quota
|
||||
assets, # assets
|
||||
text_to_3d, # text
|
||||
image_gen, # img (text to image)
|
||||
image_assets, # imgs (list image assets)
|
||||
image_multiview, # imgmv (one image -> 4 views)
|
||||
image_to_3d, # image
|
||||
import_model, # import (upload own glb/obj/fbx/stl)
|
||||
mesh_edit, # edit (local_edit on Nexus mesh)
|
||||
multiview, # multiview
|
||||
batch, # batch
|
||||
cancel, # cancel (cancel a running op)
|
||||
remesh, # remesh
|
||||
rigging, # rig
|
||||
animate, # animate
|
||||
motion, # motion (text to motion)
|
||||
segmentation, # segment
|
||||
stylize, # stylize (3D 风格化)
|
||||
texture, # texture
|
||||
texture_upscale, # upscale
|
||||
pbr, # pbr
|
||||
uv, # uv
|
||||
fill_parts, # fill
|
||||
export_model, # export
|
||||
download, # download
|
||||
inspect_model, # inspect (local GLB game stats & diagnostics)
|
||||
optimize_model, # optimize (local decimate / draco / LODs)
|
||||
task, # task
|
||||
project, # project
|
||||
rename, # rename (update_name)
|
||||
config_cmd, # config
|
||||
update, # update (self-update from source tree)
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Shared argument groups and the submit -> wait -> download flow."""
|
||||
|
||||
import os
|
||||
|
||||
from ..errors import TripoAPIError, TripoTimeout
|
||||
from ..versions import default_for, resolve
|
||||
|
||||
|
||||
def add_generation_options(parser):
|
||||
"""Options shared by text / image / multiview / batch generation."""
|
||||
parser.add_argument("--smart-mesh", action="store_true", help="智能网格模式 (P2.0/P1.0,四边面低模)")
|
||||
parser.add_argument("-m", "--model", help="模型版本: P2.0 / P1.0 / H3.1 / v3.0 / v2.5(默认随模式选择)")
|
||||
parser.add_argument("--face-limit", type=int, help="面数上限(智能网格默认 25000,高精度默认 2000000;游戏场景建议 10000~25000)")
|
||||
parser.add_argument("--quad", action="store_true", help="四边面拓扑(智能网格默认开启)")
|
||||
parser.add_argument("--count", type=int, default=1, choices=[1, 2, 4], help="生成候选数量 1/2/4(默认 1)")
|
||||
parser.add_argument("--no-texture", action="store_true", help="不生成贴图")
|
||||
parser.add_argument("--no-pbr", action="store_true", help="不生成 PBR 材质")
|
||||
parser.add_argument("--no-delight", action="store_true", help="保留原图光影(不去光)")
|
||||
parser.add_argument("--texture-quality", default="detailed", choices=["standard", "detailed"], help="贴图质量")
|
||||
parser.add_argument("--geometry-quality", default="detailed", choices=["standard", "detailed"], help="几何精细度")
|
||||
parser.add_argument("--t-pose", action="store_true", help="人形角色输出 T-Pose")
|
||||
parser.add_argument("--visibility", default="shareable", choices=["shareable", "private"], help="模型可见性")
|
||||
|
||||
|
||||
def add_wait_options(parser, output_help="生成完成后保存到该路径"):
|
||||
parser.add_argument("-w", "--wait", action="store_true", help="阻塞等待任务完成")
|
||||
parser.add_argument("-o", "--output", help=output_help)
|
||||
|
||||
|
||||
def add_op_options(parser, output_help="完成后保存到该路径(可选)"):
|
||||
"""Options shared by post-process operators, which act on an existing project."""
|
||||
parser.add_argument("-p", "--project", required=True, help="项目 ID (project_id)")
|
||||
parser.add_argument("-m", "--model", help="覆盖 model_version(默认取项目自身的模型版本)")
|
||||
add_wait_options(parser, output_help)
|
||||
|
||||
|
||||
def generation_body(args):
|
||||
"""Turn generation CLI args into the request body fields."""
|
||||
smart = args.smart_mesh
|
||||
model_version = resolve(args.model, smart_mesh=smart)
|
||||
face_limit = args.face_limit or (25000 if smart else 2000000)
|
||||
quad = True if smart else args.quad
|
||||
return {
|
||||
"model_version": model_version,
|
||||
"face_limit": face_limit,
|
||||
"quad": quad,
|
||||
"texture": not args.no_texture,
|
||||
"pbr": not args.no_pbr,
|
||||
"delight": not args.no_delight,
|
||||
"texture_quality": args.texture_quality,
|
||||
"geometry_quality": args.geometry_quality,
|
||||
"t_pose": args.t_pose,
|
||||
"visibility": args.visibility,
|
||||
"smart_poly": smart,
|
||||
"model_count": args.count,
|
||||
}
|
||||
|
||||
|
||||
def wait_and_report(ctx, operator_id, timeout=900):
|
||||
"""Block on a task, printing progress; returns the finished task."""
|
||||
|
||||
def on_progress(status, progress, left_time, _task):
|
||||
ctx.out.progress(f"⏳ 进度 {progress}% (预估剩余 {left_time}s)")
|
||||
|
||||
task = ctx.client.wait_task(operator_id, timeout=timeout, on_progress=on_progress)
|
||||
ctx.out.progress_done()
|
||||
return task
|
||||
|
||||
|
||||
def fetch_model_url(ctx, project_id):
|
||||
detail = ctx.client.project_detail(project_id) or {}
|
||||
return detail.get("model_url"), detail
|
||||
|
||||
|
||||
def finish_single(ctx, task_data, output=None, wait=False, timeout=900, label="模型"):
|
||||
"""Standard flow for one generated asset: optionally wait and download."""
|
||||
project_id = task_data.get("project_id")
|
||||
operator_id = task_data.get("operator_id")
|
||||
|
||||
if not wait:
|
||||
ctx.out.data(
|
||||
task_data,
|
||||
human=lambda: (
|
||||
ctx.out.line("✅ 任务已提交"),
|
||||
ctx.out.line(f"project_id : {project_id}"),
|
||||
ctx.out.line(f"operator_id : {operator_id}"),
|
||||
ctx.out.line(f"查询进度 : tripocli task status {operator_id}"),
|
||||
ctx.out.line(f"等待并下载 : tripocli task wait {operator_id} -p {project_id} -o out.glb"),
|
||||
),
|
||||
)
|
||||
return task_data
|
||||
|
||||
task = wait_and_report(ctx, operator_id, timeout=timeout)
|
||||
model_url, detail = fetch_model_url(ctx, project_id)
|
||||
|
||||
output_path = output or f"{project_id}.glb"
|
||||
if model_url:
|
||||
ctx.client.download(model_url, output_path)
|
||||
output_path = os.path.abspath(output_path)
|
||||
|
||||
payload = {"task": task, "project": detail, "output_file": output_path if model_url else None}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"🎉 {label}完成"),
|
||||
ctx.out.line(f"模型地址: {model_url}"),
|
||||
ctx.out.line(f"已保存 : {output_path}") if model_url else ctx.out.line("⚠️ 未取到 model_url"),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def project_version(client, project_id, override=None):
|
||||
"""Model version to send with post-process ops.
|
||||
|
||||
The studio sends the project's own version for most operations, so we read it
|
||||
from the project rather than assuming a global default.
|
||||
"""
|
||||
if override:
|
||||
return resolve(override)
|
||||
return client.project_operator(project_id).get("model_version")
|
||||
|
||||
|
||||
def node_part_name(client, project_id):
|
||||
"""The ``part_name_list`` the studio expects for a project's current mesh.
|
||||
|
||||
Real names come from the mesh's nodes (only present inside the model file);
|
||||
when the model cannot be read we fall back to the single-node name
|
||||
``tripo_node_<operator_id>`` (verified live).
|
||||
"""
|
||||
names = client.part_names(project_id)
|
||||
if names:
|
||||
return names
|
||||
operator = client.project_operator(project_id)
|
||||
operator_id = operator.get("operator_id")
|
||||
if not operator_id:
|
||||
raise TripoAPIError(f"无法从项目 {project_id} 读取 operator_id")
|
||||
return [f"tripo_node_{operator_id}"]
|
||||
|
||||
|
||||
def run_operator(ctx, result, output=None, wait=False, timeout=900, label="处理", multiple=False):
|
||||
"""Shared flow for post-process operators (they return project_id/operator_id)."""
|
||||
if multiple:
|
||||
operator_ids = [item.get("operator_id") for item in result]
|
||||
project_id = result[0].get("project_id") if result else None
|
||||
else:
|
||||
project_id = result.get("project_id")
|
||||
operator_ids = [result.get("operator_id")]
|
||||
# Some endpoints answer without a task id; never wait on a missing one.
|
||||
operator_ids = [oid for oid in operator_ids if oid]
|
||||
|
||||
if not wait:
|
||||
ctx.out.data(
|
||||
result,
|
||||
human=lambda: (
|
||||
ctx.out.line("✅ 任务已提交"),
|
||||
*[ctx.out.line(f"operator_id : {oid}") for oid in operator_ids],
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
for operator_id in operator_ids:
|
||||
wait_and_report(ctx, operator_id, timeout=timeout)
|
||||
|
||||
model_url, detail = fetch_model_url(ctx, project_id) if project_id else (None, None)
|
||||
output_path = output
|
||||
if output_path and model_url:
|
||||
ctx.client.download(model_url, output_path)
|
||||
output_path = os.path.abspath(output_path)
|
||||
|
||||
payload = {"result": result, "project": detail, "model_url": model_url, "output_file": output_path}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"🎉 {label}完成"),
|
||||
ctx.out.line(f"模型地址: {model_url}"),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,75 @@
|
||||
"""账户信息:积分余额、身份、免费额度。
|
||||
|
||||
tripocli balance | tripocli whoami | tripocli quota
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from ..auth import decode_jwt_payload
|
||||
|
||||
|
||||
def _balance(ctx):
|
||||
data = ctx.client.payment_profile() or {}
|
||||
wallet = data.get("wallet", {})
|
||||
member = data.get("member", {})
|
||||
ctx.out.data(
|
||||
data,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"剩余积分 : {wallet.get('total_credit', 0)}"),
|
||||
ctx.out.line(f"即将过期 : {wallet.get('expiring_credit', 0)} (到期 {wallet.get('expiring_date', '无')})"),
|
||||
ctx.out.line(f"会员等级 : {member.get('type', 'free')} (有效至 {member.get('valid_until', '无')})"),
|
||||
),
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _whoami(ctx):
|
||||
client = ctx.client
|
||||
info = {
|
||||
"user_id": None,
|
||||
"token_expires_at": None,
|
||||
"account": client.account_name,
|
||||
"token_source": client.token_source or ("account" if client.cookie else None),
|
||||
}
|
||||
payload = decode_jwt_payload(client.token or "")
|
||||
info["user_id"] = payload.get("sub")
|
||||
if payload.get("exp"):
|
||||
info["token_expires_at"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(payload["exp"]))
|
||||
|
||||
profile = ctx.client.payment_profile() or {}
|
||||
info["member"] = profile.get("member", {})
|
||||
info["wallet"] = profile.get("wallet", {})
|
||||
|
||||
ctx.out.data(
|
||||
info,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"用户 ID : {info.get('user_id')}"),
|
||||
ctx.out.line(f"Token 到期 : {info.get('token_expires_at')} ({info['token_source']})"),
|
||||
ctx.out.line(f"积分余额 : {info.get('wallet', {}).get('total_credit', 0)}"),
|
||||
),
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def _quota(ctx):
|
||||
data = ctx.client.quota() or {}
|
||||
quotas = data.get("quota", {})
|
||||
|
||||
def human():
|
||||
for group, items in quotas.items():
|
||||
for name, value in items.items():
|
||||
ctx.out.line(f"{group}/{name}: 剩余 {value.get('remaining', 0)} / 共 {value.get('total_count', 0)}")
|
||||
|
||||
ctx.out.data(data, human=human)
|
||||
return data
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
parser = subparsers.add_parser("balance", help="查询积分余额与会员状态")
|
||||
parser.set_defaults(func=_balance)
|
||||
|
||||
parser = subparsers.add_parser("whoami", help="查询当前身份与 token 有效期")
|
||||
parser.set_defaults(func=_whoami)
|
||||
|
||||
parser = subparsers.add_parser("quota", help="查询特权/试用免费额度")
|
||||
parser.set_defaults(func=_quota)
|
||||
@@ -0,0 +1,189 @@
|
||||
"""账号管理 — add / list / use / remove / show.
|
||||
|
||||
给别人用时不需要浏览器:把浏览器的 cookie 粘贴进来一次,CLI 之后自己续期 JWT。
|
||||
|
||||
tripocli account add --name work --cookie "ory_kratos_session=...; csrf_token_...=..."
|
||||
tripocli account add # 交互式粘贴
|
||||
tripocli account list
|
||||
tripocli account use work
|
||||
tripocli account remove work
|
||||
"""
|
||||
|
||||
import getpass
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .. import auth
|
||||
from ..accounts import Accounts
|
||||
from ..config import Config
|
||||
|
||||
COOKIE_HINT = (
|
||||
"请粘贴 cookie(整条 Cookie 字符串,或仅 ory_kratos_session 的值),回车结束:\n"
|
||||
" 提示:浏览器 DevTools → Network → 任意 api.tripo3d.ai 请求 → Request Headers → Cookie 整条复制"
|
||||
)
|
||||
|
||||
|
||||
def _fmt(epoch):
|
||||
if not epoch:
|
||||
return None
|
||||
if isinstance(epoch, (int, float)):
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(epoch))
|
||||
return str(epoch)
|
||||
|
||||
|
||||
def _mask(value, keep=10):
|
||||
if not value:
|
||||
return None
|
||||
return f"{value[:keep]}...{value[-6:]}" if len(value) > keep + 8 else "***"
|
||||
|
||||
|
||||
def _read_cookie(args):
|
||||
if args.cookie_file:
|
||||
with open(args.cookie_file, "r", encoding="utf-8") as handle:
|
||||
return handle.read().strip()
|
||||
if args.cookie == "-":
|
||||
return sys.stdin.read().strip()
|
||||
if args.cookie:
|
||||
return args.cookie.strip()
|
||||
# Interactive paste; hidden input so the cookie does not land in scrollback.
|
||||
print(COOKIE_HINT)
|
||||
try:
|
||||
return getpass.getpass("cookie: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
raise SystemExit("已取消")
|
||||
|
||||
|
||||
def _identity(api_base, cookie, device_id):
|
||||
"""Best-effort identity lookup so accounts are recognisable in `account list`."""
|
||||
try:
|
||||
return auth.fetch_identity(api_base, cookie, device_id)
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _add(ctx):
|
||||
args = ctx.args
|
||||
cookie = auth.normalize_cookie(_read_cookie(args))
|
||||
|
||||
# Validate before storing: a bad cookie must fail here, not on the next command.
|
||||
token, session_expires = auth.mint_token(ctx.config.api_base, cookie, ctx.config.device_id)
|
||||
user_id, email = _identity(ctx.config.api_base, cookie, ctx.config.device_id)
|
||||
|
||||
name = args.name or email or user_id or "default"
|
||||
ctx.accounts.add(name, cookie, token=token, session_expires_at=session_expires,
|
||||
user_id=user_id, email=email)
|
||||
|
||||
payload = {
|
||||
"added": name,
|
||||
"active": ctx.accounts.active_name(),
|
||||
"email": email,
|
||||
"user_id": user_id,
|
||||
"session_expires_at": _fmt(session_expires),
|
||||
}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"✅ 账号已保存: {name}"),
|
||||
ctx.out.line(f"身份 : {email or user_id or '未知'}"),
|
||||
ctx.out.line(f"会话有效期: {_fmt(session_expires) or '未知'}"),
|
||||
ctx.out.line("之后无需浏览器,CLI 会自动用 cookie 续期 JWT"),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _list(ctx):
|
||||
accounts = ctx.accounts
|
||||
active = accounts.active_name()
|
||||
items = []
|
||||
for name in accounts.names():
|
||||
record = accounts.data["accounts"][name]
|
||||
items.append({
|
||||
"name": name,
|
||||
"active": name == active,
|
||||
"email": record.get("email"),
|
||||
"user_id": record.get("user_id"),
|
||||
"session_expires_at": _fmt(record.get("session_expires_at")),
|
||||
"cookie": _mask(record.get("cookie")),
|
||||
})
|
||||
|
||||
def human():
|
||||
if not items:
|
||||
ctx.out.line("暂无账号。用 `tripocli account add` 粘贴 cookie 添加。")
|
||||
return
|
||||
for item in items:
|
||||
mark = "*" if item["active"] else " "
|
||||
ctx.out.line(f"{mark} {item['name']:<20} {item['email'] or item['user_id'] or '-'}"
|
||||
f" (会话至 {item['session_expires_at'] or '未知'})")
|
||||
|
||||
ctx.out.data({"active": active, "accounts": items}, human=human)
|
||||
return items
|
||||
|
||||
|
||||
def _use(ctx):
|
||||
name = ctx.args.name
|
||||
try:
|
||||
ctx.accounts.use(name)
|
||||
except KeyError:
|
||||
raise ValueError(f"账号不存在: {name}(可用 `tripocli account list` 查看)")
|
||||
ctx.out.data({"active": name}, human=lambda: ctx.out.line(f"✅ 当前账号已切换为 {name}"))
|
||||
return {"active": name}
|
||||
|
||||
|
||||
def _remove(ctx):
|
||||
name = ctx.args.name
|
||||
try:
|
||||
ctx.accounts.remove(name)
|
||||
except KeyError:
|
||||
raise ValueError(f"账号不存在: {name}")
|
||||
payload = {"removed": name, "active": ctx.accounts.active_name()}
|
||||
ctx.out.data(payload, human=lambda: ctx.out.line(f"✅ 已删除账号 {name}"))
|
||||
return payload
|
||||
|
||||
|
||||
def _show(ctx):
|
||||
name = ctx.args.name
|
||||
resolved, record = ctx.accounts.get(name)
|
||||
if not record:
|
||||
raise ValueError(f"账号不存在: {name or '(无当前账号)'}")
|
||||
info = {
|
||||
"name": resolved,
|
||||
"active": resolved == ctx.accounts.active_name(),
|
||||
"email": record.get("email"),
|
||||
"user_id": record.get("user_id"),
|
||||
"cookie": _mask(record.get("cookie")),
|
||||
"session_expires_at": _fmt(record.get("session_expires_at")),
|
||||
"added_at": _fmt(record.get("added_at")),
|
||||
}
|
||||
ctx.out.data(info, human=lambda: (
|
||||
ctx.out.line(f"账号 : {resolved}{' (当前)' if info['active'] else ''}"),
|
||||
ctx.out.line(f"身份 : {record.get('email') or record.get('user_id') or '未知'}"),
|
||||
ctx.out.line(f"Cookie : {info['cookie']}"),
|
||||
ctx.out.line(f"会话有效期: {info['session_expires_at'] or '未知'}"),
|
||||
))
|
||||
return info
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
parser = subparsers.add_parser("account", help="账号管理(粘贴 cookie,多账号切换)")
|
||||
sub = parser.add_subparsers(dest="account_action", required=True)
|
||||
|
||||
add = sub.add_parser("add", help="添加账号(粘贴 cookie,无需浏览器)")
|
||||
add.add_argument("--name", help="账号名(默认取邮箱/用户 id)")
|
||||
add.add_argument("--cookie", help="cookie 值,或整条 Cookie 串;传 - 从 stdin 读取")
|
||||
add.add_argument("--cookie-file", help="从文件读取 cookie")
|
||||
add.set_defaults(func=_add)
|
||||
|
||||
sub.add_parser("list", help="列出所有账号").set_defaults(func=_list)
|
||||
|
||||
use = sub.add_parser("use", help="切换当前账号")
|
||||
use.add_argument("name")
|
||||
use.set_defaults(func=_use)
|
||||
|
||||
remove = sub.add_parser("remove", help="删除账号")
|
||||
remove.add_argument("name")
|
||||
remove.set_defaults(func=_remove)
|
||||
|
||||
show = sub.add_parser("show", help="查看账号详情")
|
||||
show.add_argument("name", nargs="?")
|
||||
show.set_defaults(func=_show)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""动画 — retarget a rigged model onto animation presets.
|
||||
|
||||
tripocli animate -p <project_id> --preset idle --wait -o anim_idle.glb
|
||||
tripocli animate --list
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import _common
|
||||
from .. import motions
|
||||
|
||||
NAME = "animate"
|
||||
HELP = "动画:为已绑定骨骼的模型套用动作预设"
|
||||
|
||||
PRESET_EXAMPLES = ["idle", "walk", "run", "slash", "cast_a_spell", "jump"]
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-p", "--project", help="项目 ID(--list 时可省略)")
|
||||
parser.add_argument("-m", "--model", help="覆盖 model_version(默认 default)")
|
||||
_common.add_wait_options(parser, "动画结果保存到该路径(可选)")
|
||||
parser.add_argument("--type", default="biped", help="骨骼类型,默认 biped")
|
||||
parser.add_argument(
|
||||
"--preset", action="append",
|
||||
help="动作预设名(如 idle / walk / run / slash),可重复;用 --list 查看全部",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--animation", action="append",
|
||||
help="完整动作标识(如 preset:biped:idle),可重复",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--motion-asset",
|
||||
help="套用「文字生成动作」产出的动作资产 ID(来自 tripocli motion)",
|
||||
)
|
||||
parser.add_argument("--list", action="store_true", help="列出全部可用动作预设(按骨骼类型)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
|
||||
if args.list:
|
||||
data = {
|
||||
rig: [
|
||||
{"name": item["name"], "action_type": item.get("action_type"),
|
||||
"category": item.get("category"), "id": motions.identifier(rig, item["name"])}
|
||||
for item in motions.catalog().get(rig, [])
|
||||
]
|
||||
for rig in motions.rig_types()
|
||||
}
|
||||
|
||||
def human():
|
||||
for rig, items in data.items():
|
||||
ctx.out.line(f"== {rig} ({len(items)}) ==")
|
||||
ctx.out.line(" " + ", ".join(item["name"] for item in items))
|
||||
|
||||
ctx.out.data(data, human=human)
|
||||
return data
|
||||
|
||||
animations = list(args.animation or [])
|
||||
for preset in (args.preset or []):
|
||||
animations.append(preset if ":" in preset else motions.identifier(args.type, preset))
|
||||
if not args.project:
|
||||
raise ValueError("需要 -p/--project 指定项目(用 --list 只查看动作清单)")
|
||||
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"rig_type": args.type,
|
||||
"model_version": args.model or "default",
|
||||
}
|
||||
if args.motion_asset:
|
||||
# 文字生成动作产出的资产:retarget_model 用 motion_asset_id(而不是 animations)
|
||||
body["motion_asset_id"] = args.motion_asset
|
||||
elif animations:
|
||||
body["animations"] = animations
|
||||
else:
|
||||
raise ValueError(
|
||||
"至少需要一个动作:用 --preset afraid、--animation preset:biped:afraid,"
|
||||
f"或 --motion-asset <id>(示例: {', '.join(PRESET_EXAMPLES)};用 --list 看全部)"
|
||||
)
|
||||
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/retarget_model", body=body)
|
||||
if not args.wait:
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=False, label="动画重定向")
|
||||
|
||||
operator_id = (data or {}).get("operator_id")
|
||||
project_id = (data or {}).get("project_id") or args.project
|
||||
if operator_id:
|
||||
_common.wait_and_report(ctx, operator_id, timeout=900)
|
||||
|
||||
# 动画结果不在 project.model_url(那是绑骨结果),而在 operator.retarget[] 里:
|
||||
# 每个元素 {operator_id, name:"preset:biped:xxx", status, model_url}(实测确认)。
|
||||
entry = _retarget_entry(ctx.client, project_id, operator_id)
|
||||
model_url = (entry or {}).get("model_url")
|
||||
output_path = args.output
|
||||
if output_path and model_url:
|
||||
ctx.client.download(model_url, output_path)
|
||||
output_path = os.path.abspath(output_path)
|
||||
|
||||
payload = {"result": data, "animation": (entry or {}).get("name"), "model_url": model_url,
|
||||
"output_file": output_path if model_url else None}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"🎉 动画重定向完成({(entry or {}).get('name')})"),
|
||||
ctx.out.line(f"模型地址: {model_url}"),
|
||||
ctx.out.line(f"已保存 : {output_path}") if model_url else ctx.out.line("⚠️ 未取到动画 model_url"),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _retarget_entry(client, project_id, operator_id=None):
|
||||
"""Pick the retarget result from the project's operator.retarget list."""
|
||||
detail = client.project_detail(project_id) or {}
|
||||
entries = (detail.get("operator") or {}).get("retarget") or []
|
||||
if operator_id:
|
||||
for item in entries:
|
||||
if item.get("operator_id") == operator_id:
|
||||
return item
|
||||
return entries[-1] if entries else None
|
||||
@@ -0,0 +1,36 @@
|
||||
"""资产列表 — list the account's generated assets.
|
||||
|
||||
tripocli assets --type model --page 1
|
||||
|
||||
The API takes two filters: ``asset_type`` (scope, verified value: ``mine``) and
|
||||
``type`` (the asset kind, e.g. ``model``).
|
||||
"""
|
||||
|
||||
NAME = "assets"
|
||||
HELP = "列出账号资产(模型/图片)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--type", default="model", help="资产种类,默认 model")
|
||||
parser.add_argument("--asset-type", default="mine", help="资产范围,默认 mine")
|
||||
parser.add_argument("--page", type=int, default=1, help="页码")
|
||||
parser.add_argument("--size", type=int, default=20, help="每页数量")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
data = ctx.client.assets(
|
||||
asset_type=args.asset_type, type=args.type, page=args.page, size=args.size
|
||||
) or {}
|
||||
projects = data.get("projects") or []
|
||||
|
||||
def show():
|
||||
ctx.out.line(f"共 {data.get('total', len(projects))} 个资产(当前显示 {len(projects)} 个)")
|
||||
for item in projects:
|
||||
pid = item.get("id") or item.get("project_id") or ""
|
||||
name = item.get("project_name") or (item.get("biz_info") or {}).get("short_description") or ""
|
||||
cat = item.get("category") or item.get("type") or ""
|
||||
ctx.out.line(f" {pid} [{cat}] {name}")
|
||||
|
||||
ctx.out.data(data, human=show)
|
||||
return data
|
||||
@@ -0,0 +1,76 @@
|
||||
"""批量图生 3D — batch image to model.
|
||||
|
||||
tripocli batch -i a.png,b.png,c.png --wait -o ./out
|
||||
|
||||
Verified against the live API: the batch endpoint takes ``image`` as an *array*
|
||||
(the field is singular, not ``images``) and answers with an array of
|
||||
``{project_id, operator_id}`` — one entry per submitted image.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "batch"
|
||||
HELP = "批量图生 3D(多张图片一次提交,返回多个任务)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-i", "--images", required=True, help="逗号分隔的本地图片路径")
|
||||
_common.add_generation_options(parser)
|
||||
_common.add_wait_options(parser, "输出目录(每个结果保存为 <project_id>.glb)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
paths = [p.strip() for p in args.images.split(",") if p.strip()]
|
||||
if not paths:
|
||||
raise ValueError("--images 至少需要一张图片")
|
||||
|
||||
images = []
|
||||
for path in paths:
|
||||
ctx.out.line(f"📤 上传并审核图片: {path}")
|
||||
images.append(ctx.client.upload_image(path))
|
||||
|
||||
body = _common.generation_body(args)
|
||||
results = ctx.client.get_data(
|
||||
"POST",
|
||||
"/v2/studio/operation/batch_image_to_model",
|
||||
body={
|
||||
"image": images,
|
||||
"model_version": body["model_version"],
|
||||
"face_limit": body["face_limit"],
|
||||
"quad": body["quad"],
|
||||
"texture": body["texture"],
|
||||
"visibility": body["visibility"],
|
||||
},
|
||||
)
|
||||
results = results or []
|
||||
|
||||
if not args.wait:
|
||||
ctx.out.data(
|
||||
results,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"✅ 已提交 {len(results)} 个任务"),
|
||||
*[ctx.out.line(f" project {r.get('project_id')} operator {r.get('operator_id')}") for r in results],
|
||||
),
|
||||
)
|
||||
return results
|
||||
|
||||
out_dir = args.output or "."
|
||||
payload = []
|
||||
for item in results:
|
||||
project_id = item.get("project_id")
|
||||
wait_task = _common.wait_and_report(ctx, item.get("operator_id"))
|
||||
model_url, _detail = _common.fetch_model_url(ctx, project_id)
|
||||
target = None
|
||||
if model_url:
|
||||
target = os.path.abspath(os.path.join(out_dir, f"{project_id}.glb"))
|
||||
ctx.client.download(model_url, target)
|
||||
payload.append({"task": wait_task, "project_id": project_id, "model_url": model_url, "output_file": target})
|
||||
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: [ctx.out.line(f"✨ {p['output_file'] or p['project_id']}") for p in payload],
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,42 @@
|
||||
"""取消任务 — cancel a running operation.
|
||||
|
||||
tripocli cancel --operator <operator_id> --type remesh
|
||||
|
||||
实测请求体 ``{operator_id, scene, operation_type}``;``scene`` ∈ {workspace, assets},
|
||||
``operation_type`` 的取值由服务端枚举决定(下面这份清单来自真实报错信息):
|
||||
|
||||
text_to_model image_to_model multiview_to_model ai_segmentation ai_completion
|
||||
mesh_fill modification skeleton_adjustment texture_generation pre_rig_check
|
||||
rigging retarget stylize export retexture apply_texture pbr remesh
|
||||
texture_upscaler image_edit_to_model
|
||||
|
||||
⚠️ 图片生成任务**不在**该枚举内,无法取消。
|
||||
"""
|
||||
|
||||
NAME = "cancel"
|
||||
HELP = "取消正在运行的任务(需指定操作类型)"
|
||||
|
||||
OPERATION_TYPES = (
|
||||
"text_to_model", "image_to_model", "multiview_to_model", "ai_segmentation",
|
||||
"ai_completion", "mesh_fill", "modification", "skeleton_adjustment",
|
||||
"texture_generation", "pre_rig_check", "rigging", "retarget", "stylize",
|
||||
"export", "retexture", "apply_texture", "pbr", "remesh", "texture_upscaler",
|
||||
"image_edit_to_model",
|
||||
)
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--operator", required=True, help="要取消的 operator_id")
|
||||
parser.add_argument("--type", required=True, choices=OPERATION_TYPES, help="要取消的任务类型")
|
||||
parser.add_argument("--scene", default="workspace", choices=["workspace", "assets"], help="场景(默认 workspace)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
data = ctx.client.get_data(
|
||||
"POST", "/v2/studio/operation/cancel",
|
||||
body={"operator_id": args.operator, "scene": args.scene, "operation_type": args.type},
|
||||
)
|
||||
payload = data or {"operator_id": args.operator, "cancelled": True}
|
||||
ctx.out.data(payload, human=lambda: ctx.out.line(f"✅ 已取消: {args.operator}"))
|
||||
return payload
|
||||
@@ -0,0 +1,48 @@
|
||||
"""全局设置(API 地址、device id)。
|
||||
|
||||
凭据(cookie / JWT)由 `tripocli account` 管理,这里只管非敏感设置。
|
||||
"""
|
||||
|
||||
from ..config import Config
|
||||
|
||||
|
||||
def _show(ctx):
|
||||
config = ctx.config
|
||||
info = {
|
||||
"config_path": str(config.path),
|
||||
"api_base": config.api_base,
|
||||
"device_id": config.device_id,
|
||||
"accounts_path": str(ctx.accounts.path),
|
||||
"active_account": ctx.accounts.active_name(),
|
||||
"accounts": ctx.accounts.names(),
|
||||
}
|
||||
ctx.out.data(
|
||||
info,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"配置文件 : {info['config_path']}"),
|
||||
ctx.out.line(f"API Base : {info['api_base']}"),
|
||||
ctx.out.line(f"Device ID : {info['device_id']}"),
|
||||
ctx.out.line(f"账号文件 : {info['accounts_path']}"),
|
||||
ctx.out.line(f"当前账号 : {info['active_account'] or '未设置'}"),
|
||||
ctx.out.line(f"账号列表 : {', '.join(info['accounts']) or '空'}"),
|
||||
),
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def _set(ctx):
|
||||
args = ctx.args
|
||||
ctx.config.set(device_id=args.device_id, api_base=args.api_base)
|
||||
ctx.out.data({"saved": True}, human=lambda: ctx.out.line("✅ 配置已保存"))
|
||||
return {"saved": True}
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
parser = subparsers.add_parser("config", help="全局设置(API 地址 / device id)")
|
||||
sub = parser.add_subparsers(dest="config_action", required=True)
|
||||
sub.add_parser("show", help="显示当前设置").set_defaults(func=_show)
|
||||
|
||||
setter = sub.add_parser("set", help="修改 API 地址 / device id")
|
||||
setter.add_argument("--device-id", help="x-tripo-device-id")
|
||||
setter.add_argument("--api-base", help="API 根地址")
|
||||
setter.set_defaults(func=_set)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""下载 — fetch a model by operator id through a signed URL.
|
||||
|
||||
tripocli download --operator <operator_id> -o model.glb
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
NAME = "download"
|
||||
HELP = "下载模型:按 operator_id 获取签名直链并保存"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--operator", required=True, help="operator_id")
|
||||
parser.add_argument("-o", "--output", default="model.glb", help="保存路径(默认 model.glb)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
filename = os.path.basename(args.output)
|
||||
url = ctx.client.download_url(args.operator, filename)
|
||||
if not url:
|
||||
raise ValueError(f"operator {args.operator} 未返回下载直链")
|
||||
|
||||
path = os.path.abspath(args.output)
|
||||
ctx.out.line(f"📥 下载中: {path}")
|
||||
ctx.client.download(url, path)
|
||||
|
||||
payload = {"operator_id": args.operator, "url": url, "output_file": path}
|
||||
ctx.out.data(payload, human=lambda: ctx.out.line(f"✨ 已保存: {path}"))
|
||||
return payload
|
||||
@@ -0,0 +1,70 @@
|
||||
"""格式转换导出 — convert a model to another format.
|
||||
|
||||
tripocli export -p <project_id> -f fbx --wait -o model.fbx
|
||||
|
||||
Export is asynchronous; once it succeeds the result is fetched through
|
||||
``download_with_name`` using the *export* operator id.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "export"
|
||||
HELP = "格式转换导出 (glb/fbx/obj/stl/usdz/3mf)"
|
||||
|
||||
FORMATS = ["glb", "fbx", "obj", "stl", "usdz", "3mf"]
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-p", "--project", required=True, help="项目 ID")
|
||||
parser.add_argument("-f", "--format", required=True, choices=FORMATS, help="目标格式")
|
||||
parser.add_argument("--operator", help="模型 operator_id(默认取项目当前 operator)")
|
||||
parser.add_argument("-n", "--name", help="导出文件名前缀")
|
||||
parser.add_argument("-m", "--model", help="覆盖 model_version(默认取项目自身版本)")
|
||||
_common.add_wait_options(parser, "导出文件保存路径")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
operator_id = args.operator
|
||||
if not operator_id:
|
||||
operator_id = ctx.client.project_operator(args.project).get("operator_id")
|
||||
if not operator_id:
|
||||
raise ValueError(f"无法确定项目 {args.project} 的 operator_id,请用 --operator 指定")
|
||||
|
||||
version = _common.project_version(ctx.client, args.project, args.model) or "default"
|
||||
result = ctx.client.export(
|
||||
args.project, operator_id, args.format, name=args.name or "model", model_version=version
|
||||
) or {}
|
||||
|
||||
export_operator = result.get("operator_id")
|
||||
if not args.wait:
|
||||
ctx.out.data(
|
||||
result,
|
||||
human=lambda: (
|
||||
ctx.out.line("✅ 导出任务已提交"),
|
||||
ctx.out.line(f"export operator_id : {export_operator}"),
|
||||
ctx.out.line(f"查询进度 : tripocli task status {export_operator}"),
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
_common.wait_and_report(ctx, export_operator)
|
||||
|
||||
filename = args.output or f"{args.name or 'model'}.{args.format}"
|
||||
url = ctx.client.download_url(export_operator, os.path.basename(filename))
|
||||
output_path = None
|
||||
if url:
|
||||
output_path = os.path.abspath(filename)
|
||||
ctx.client.download(url, output_path)
|
||||
|
||||
payload = {"export": result, "download_url": url, "output_file": output_path}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line("🎉 导出完成"),
|
||||
ctx.out.line(f"已保存: {output_path}") if output_path else ctx.out.line("⚠️ 未取到下载直链"),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,49 @@
|
||||
"""部件补全 — fill / complete parts of a segmented model.
|
||||
|
||||
tripocli fill -p <project_id> --parts all --wait
|
||||
tripocli fill -p <project_id> --parts tripo_part_0,tripo_part_3 --wait
|
||||
|
||||
实测:`mesh_fill`(界面「快速封口」)与 `ai_completion`(界面「AI 补全」)都要求
|
||||
`part_names`(数组)+ `model_version`;缺 `part_names` 报 `type mismatch for field "part_names"`。
|
||||
|
||||
部件标识 = **模型里的节点名**:未拆分模型是 `tripo_node_<operator_id>`,
|
||||
智能分割后是每个部件一个 `tripo_part_<n>`(实测 `tripo_part_0` 可用)。
|
||||
项目详情接口不返回这些名字,`tripocli` 直接解析模型 GLB 的节点名,故 `--parts all` 即可全选。
|
||||
"""
|
||||
|
||||
from ..errors import TripoAPIError
|
||||
from . import _common
|
||||
|
||||
NAME = "fill"
|
||||
HELP = "部件补全:补全拆分后的部件网格(快速封口 / AI 补全)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "补全结果保存到该路径(可选)")
|
||||
parser.add_argument("--parts", required=True, help="部件名(逗号分隔),或传 all 选择全部节点")
|
||||
parser.add_argument("--ai", action="store_true", help="使用 AI 补全(默认使用快速封口)")
|
||||
|
||||
|
||||
def resolve_parts(ctx, args):
|
||||
if args.parts.strip().lower() in ("all", "*"):
|
||||
names = ctx.client.part_names(args.project)
|
||||
if not names:
|
||||
raise TripoAPIError("无法从模型读取部件名,请用 --parts 显式指定")
|
||||
return names
|
||||
return [part.strip() for part in args.parts.split(",") if part.strip()]
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
part_names = resolve_parts(ctx, args)
|
||||
if not part_names:
|
||||
raise ValueError("--parts 不能为空")
|
||||
|
||||
endpoint = "ai_completion" if args.ai else "mesh_fill"
|
||||
body = {"project_id": args.project, "part_names": part_names}
|
||||
model_version = _common.project_version(ctx.client, args.project, args.model)
|
||||
if model_version:
|
||||
body["model_version"] = model_version
|
||||
|
||||
data = ctx.client.get_data("POST", f"/v2/studio/operation/{endpoint}", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="部件补全")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""图片资产列表 — list the image assets you have generated.
|
||||
|
||||
tripocli imgs --size 10
|
||||
|
||||
实测:`POST /v2/studio/image/image_assets` body `{page_num, page_size}`,
|
||||
返回 `{assets:[{asset_id, input:{prompt, prompt_image:[{bucket,key,url}]}, ...}]}`。
|
||||
"""
|
||||
|
||||
NAME = "imgs"
|
||||
HELP = "列出我的图片资产(生成过的参考图)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--page", type=int, default=1, help="页码(默认 1)")
|
||||
parser.add_argument("--size", type=int, default=8, help="每页数量(默认 8)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
data = ctx.client.get_data(
|
||||
"POST", "/v2/studio/image/image_assets",
|
||||
body={"page_num": args.page, "page_size": args.size},
|
||||
) or {}
|
||||
assets = data.get("assets") or []
|
||||
|
||||
def show():
|
||||
for item in assets:
|
||||
source = item.get("input") or {}
|
||||
prompt = source.get("prompt_text") or source.get("prompt") or ""
|
||||
ctx.out.line(f" {item.get('asset_id')} [{item.get('status', '')}] {prompt[:56]}")
|
||||
pictures = ((item.get("output") or {}).get("data")) or source.get("prompt_image") or []
|
||||
for picture in pictures[:2]:
|
||||
ctx.out.line(f" {picture.get('url', '')[:100]}")
|
||||
|
||||
ctx.out.data({"assets": assets}, human=show)
|
||||
return data
|
||||
@@ -0,0 +1,159 @@
|
||||
"""文生图 — text to image(游戏资产流程的第一步)。
|
||||
|
||||
tripocli img -p "a cute cartoon wooden barrel" -m nano-banana-2 --amount 1 --resolution 2K --wait -o ./refs
|
||||
|
||||
实测流程(页面上真操作抓到的请求):
|
||||
|
||||
POST /v2/studio/image/gen_image_v2
|
||||
{"if_upload":true,"sketch_to_render":false,"t_pose":false,"amount":4,
|
||||
"model_version":"gemini_3.1_flash_image_preview","prompt":"...","resolution":"4K","scale":"1:1"}
|
||||
→ {task_id, asset_id, remaining_free_count}
|
||||
|
||||
POST /v2/studio/image/get_image_asset {"asset_id": "..."}
|
||||
→ {status, type:"generate_image", output:{data:[{bucket,key,url}]}}
|
||||
|
||||
异步:先 gen_image_v2 拿 asset_id,再轮询 get_image_asset 直到 status=success,从 output.data 取图。
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..errors import TripoAPIError, TripoTimeout
|
||||
|
||||
NAME = "img"
|
||||
HELP = "文生图:用文本生成参考图(游戏资产流程的第一步)"
|
||||
|
||||
# 界面上的模型名 → API 的 model_version(逐个在页面上选中并抓包确认)
|
||||
IMAGE_MODELS = {
|
||||
"nano-banana-2": "gemini_3.1_flash_image_preview",
|
||||
"nano-banana-pro": "gemini_3_pro_image_preview",
|
||||
"gpt-image-2": "gpt_image_2",
|
||||
"gpt-image-2.5": "gpt_image_2.5_sunburst",
|
||||
"midjourney": "midjourney",
|
||||
}
|
||||
DEFAULT_MODEL = "nano-banana-2"
|
||||
|
||||
SCALES = ["1:1", "16:9", "9:16", "4:3", "3:4"]
|
||||
RESOLUTIONS = ["1K", "2K", "4K"]
|
||||
|
||||
|
||||
def resolve_model(name):
|
||||
"""Accept either a friendly alias or a raw model_version string."""
|
||||
if not name:
|
||||
return IMAGE_MODELS[DEFAULT_MODEL]
|
||||
key = name.strip().lower()
|
||||
return IMAGE_MODELS.get(key, name.strip())
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-p", "--prompt", help="图片描述提示词(用 --asset 取已有结果时可省略)")
|
||||
parser.add_argument("--asset", help="直接取已有 asset_id 的结果,不发起新生成")
|
||||
parser.add_argument("--templates", action="store_true", help="列出官方模板(角色拆分/T姿势/3D增强…)后退出")
|
||||
parser.add_argument("--amount", type=int, default=1, choices=[1, 2, 3, 4], help="生成数量 1-4(默认 1,省额度)")
|
||||
parser.add_argument("--scale", default="1:1", choices=SCALES, help="图片比例(默认 1:1)")
|
||||
parser.add_argument("--resolution", default="1K", choices=RESOLUTIONS, help="分辨率 1K/2K/4K(默认 1K)")
|
||||
parser.add_argument(
|
||||
"-m", "--model", default=DEFAULT_MODEL,
|
||||
help="图像模型: " + " / ".join(IMAGE_MODELS) + f"(默认 {DEFAULT_MODEL}),也可直接传 model_version",
|
||||
)
|
||||
parser.add_argument("--t-pose", action="store_true", help="人形角色输出 T 姿势(便于后续绑骨)")
|
||||
parser.add_argument("-w", "--wait", action="store_true", help="等待生成完成")
|
||||
parser.add_argument("-o", "--output", help="图片保存目录(配合 --wait)")
|
||||
parser.add_argument("--timeout", type=int, default=300, help="等待超时秒数(默认 300)")
|
||||
|
||||
|
||||
def _poll_asset(client, asset_id, timeout):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
data = client.get_data("POST", "/v2/studio/image/get_image_asset", body={"asset_id": asset_id}) or {}
|
||||
status = data.get("status")
|
||||
if status == "success":
|
||||
return data
|
||||
if status in ("failed", "banned"):
|
||||
raise TripoAPIError(f"图片生成失败: status={status}")
|
||||
time.sleep(3)
|
||||
raise TripoTimeout(f"图片生成在 {timeout}s 内未完成(asset_id={asset_id})")
|
||||
|
||||
|
||||
def _report(ctx, asset_id, asset, output_dir):
|
||||
images = ((asset.get("output") or {}).get("data")) or []
|
||||
|
||||
saved = []
|
||||
if output_dir and images:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
for index, item in enumerate(images):
|
||||
target = os.path.abspath(os.path.join(output_dir, f"image_{index}.png"))
|
||||
ctx.client.download(item["url"], target)
|
||||
saved.append(target)
|
||||
|
||||
payload = {"asset_id": asset_id, "status": asset.get("status"), "images": images, "saved": saved}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"🎉 生成完成(status={asset.get('status')}),共 {len(images)} 张"),
|
||||
*[ctx.out.line(f" {path}") for path in saved],
|
||||
*([] if saved else [ctx.out.line(f" {item['url'][:90]}") for item in images]),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
|
||||
if args.templates:
|
||||
# 模板是客户端的「提示词+参数」预设,接口只给元数据(实测:请求体里没有 template_id)。
|
||||
data = ctx.client.get_data("GET", "/v2/studio/image/available_templates") or {}
|
||||
templates = data.get("templates") or []
|
||||
|
||||
def show():
|
||||
for item in templates:
|
||||
tags = ", ".join(item.get("secondary_tag") or [])
|
||||
ctx.out.line(f" {item.get('title', ''):<22} [{item.get('category', '')}] {tags}")
|
||||
if item.get("description"):
|
||||
ctx.out.line(f" {item['description'].splitlines()[0]}")
|
||||
|
||||
ctx.out.data({"templates": templates}, human=show)
|
||||
return data
|
||||
|
||||
if args.asset:
|
||||
if args.wait:
|
||||
asset = _poll_asset(ctx.client, args.asset, args.timeout)
|
||||
else:
|
||||
asset = ctx.client.get_data(
|
||||
"POST", "/v2/studio/image/get_image_asset", body={"asset_id": args.asset}
|
||||
) or {}
|
||||
return _report(ctx, args.asset, asset, args.output)
|
||||
|
||||
if not args.prompt:
|
||||
raise ValueError("需要 -p/--prompt 发起生成,或用 --asset 取已有结果")
|
||||
|
||||
model_version = resolve_model(args.model)
|
||||
body = {
|
||||
"if_upload": True,
|
||||
"sketch_to_render": False,
|
||||
"t_pose": args.t_pose,
|
||||
"amount": args.amount,
|
||||
"model_version": model_version,
|
||||
"prompt": args.prompt,
|
||||
"resolution": args.resolution,
|
||||
"scale": args.scale,
|
||||
}
|
||||
ctx.out.line(f"🎨 生成图片: \"{args.prompt}\" (x{args.amount}, {args.scale}, {args.resolution}, {model_version})")
|
||||
data = ctx.client.get_data("POST", "/v2/studio/image/gen_image_v2", body=body) or {}
|
||||
asset_id = data.get("asset_id") or data.get("task_id")
|
||||
if not asset_id:
|
||||
raise TripoAPIError(f"gen_image_v2 未返回 asset_id: {data}")
|
||||
|
||||
if not args.wait:
|
||||
ctx.out.data(
|
||||
data,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"✅ 已提交 asset_id={asset_id}(免费剩余 {data.get('remaining_free_count')})"),
|
||||
ctx.out.line(f"取图: tripocli img --asset {asset_id} --wait -o ./refs"),
|
||||
),
|
||||
)
|
||||
return data
|
||||
|
||||
asset = _poll_asset(ctx.client, asset_id, args.timeout)
|
||||
return _report(ctx, asset_id, asset, args.output)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""单图生成多视角图 — one image to four consistent views.
|
||||
|
||||
tripocli imgmv -i ./ref.png --wait -o ./views
|
||||
|
||||
Turns one reference image into 4 views (front/left/right/back), which can then be
|
||||
fed to ``tripocli multiview --front/--left/--right/--back`` for a noticeably better
|
||||
model than plain single-image 3D. Verified body:
|
||||
``{image, model_version, amount, resolution, scale}``; the result asset has
|
||||
``type: "multiview_images"`` with 4 entries in ``output.data`` (实测出图 4 张).
|
||||
"""
|
||||
|
||||
from ..errors import TripoAPIError
|
||||
from . import image_gen
|
||||
|
||||
NAME = "imgmv"
|
||||
HELP = "单图生成多视角图(4 张,可喂给 multiview 提升 3D 质量)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-i", "--image", required=True, help="输入图片 (png/jpg/webp)")
|
||||
parser.add_argument("--amount", type=int, default=4, choices=[1, 2, 3, 4], help="生成数量(默认 4,多视角本身需要 4 张)")
|
||||
parser.add_argument("--scale", default="1:1", choices=image_gen.SCALES, help="图片比例(默认 1:1)")
|
||||
parser.add_argument("--resolution", default="1K", choices=image_gen.RESOLUTIONS, help="分辨率(默认 1K)")
|
||||
parser.add_argument("-m", "--model", default=image_gen.DEFAULT_MODEL,
|
||||
help="图像模型(默认 nano-banana-2),也可直接传 model_version")
|
||||
parser.add_argument("-w", "--wait", action="store_true", help="等待生成完成")
|
||||
parser.add_argument("-o", "--output", help="图片保存目录(配合 --wait)")
|
||||
parser.add_argument("--timeout", type=int, default=300, help="等待超时秒数(默认 300)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
ctx.out.line(f"📤 上传图片: {args.image}")
|
||||
image = ctx.client.upload_image(args.image)
|
||||
body = {
|
||||
"image": image,
|
||||
"model_version": image_gen.resolve_model(args.model),
|
||||
"amount": args.amount,
|
||||
"resolution": args.resolution,
|
||||
"scale": args.scale,
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/image/gen_multiview", body=body) or {}
|
||||
asset_id = data.get("asset_id") or data.get("task_id")
|
||||
if not asset_id:
|
||||
raise TripoAPIError(f"gen_multiview 未返回 asset_id: {data}")
|
||||
|
||||
if not args.wait:
|
||||
ctx.out.data(
|
||||
data,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"✅ 已提交 asset_id={asset_id}"),
|
||||
ctx.out.line(f"取图: tripocli img --asset {asset_id} --wait -o ./views"),
|
||||
),
|
||||
)
|
||||
return data
|
||||
|
||||
asset = image_gen._poll_asset(ctx.client, asset_id, args.timeout)
|
||||
return image_gen._report(ctx, asset_id, asset, args.output)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""单图生 3D — image to model.
|
||||
|
||||
tripocli image -i ./chair.png --wait -o chair.glb
|
||||
|
||||
The local file is uploaded straight to S3 with SigV4 and then audited; both steps
|
||||
are required before the generation endpoint accepts the image.
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "image"
|
||||
HELP = "单张图片生成 3D 模型(图生 3D)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-i", "--image", required=True, help="本地图片路径 (png/jpg/webp)")
|
||||
_common.add_generation_options(parser)
|
||||
_common.add_wait_options(parser, "生成完成后保存的 .glb 路径")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
ctx.out.line(f"📤 上传并审核图片: {args.image}")
|
||||
image = ctx.client.upload_image(args.image)
|
||||
body = _common.generation_body(args)
|
||||
data = ctx.client.get_data(
|
||||
"POST", "/v2/studio/operation/image_to_model", body={"image": image, **body}
|
||||
)
|
||||
return _common.finish_single(ctx, data, output=args.output, wait=args.wait)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""导入自己的模型 — register a local 3D model as a studio project.
|
||||
|
||||
tripocli import -i ./my_model.glb --wait -o my.glb
|
||||
|
||||
The file goes straight to S3, then ``import_user_model`` turns it into a project,
|
||||
after which every post-process operator (remesh / rig / animate / segment / ...)
|
||||
works on it. Verified body:
|
||||
``{format, model:{bucket,key}, name, transform_matrix, use_original_uv}``.
|
||||
|
||||
The UI lets you rotate the mesh before importing — that is what
|
||||
``transform_matrix`` carries; the CLI imports as-is with the identity matrix.
|
||||
Note the studio drops custom materials and deep node hierarchies on import.
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "import"
|
||||
HELP = "导入自己的 3D 模型(glb/obj/fbx/stl)为项目,之后可继续后处理"
|
||||
|
||||
IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-i", "--input", required=True, help="本地模型文件 (glb/obj/fbx/stl)")
|
||||
parser.add_argument("--name", help="项目显示名(默认用文件名)")
|
||||
parser.add_argument("--matrix", help="16 个数的 4x4 变换矩阵(默认单位矩阵),逗号分隔")
|
||||
parser.add_argument("--no-original-uv", action="store_true", help="不保留原始 UV(默认保留)")
|
||||
_common.add_wait_options(parser, "导入完成后保存到该路径(可选)")
|
||||
|
||||
|
||||
def parse_matrix(text):
|
||||
if not text:
|
||||
return IDENTITY
|
||||
values = [float(part) for part in text.replace(" ", "").split(",")]
|
||||
if len(values) != 16:
|
||||
raise ValueError("--matrix 需要恰好 16 个数字(4x4,逗号分隔)")
|
||||
return values
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
ctx.out.line(f"📤 上传模型: {args.input}")
|
||||
payload = ctx.client.upload_model(args.input)
|
||||
body = {
|
||||
**payload,
|
||||
"name": args.name or payload["name"],
|
||||
"transform_matrix": parse_matrix(args.matrix),
|
||||
"use_original_uv": not args.no_original_uv,
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/import_user_model", body=body)
|
||||
if isinstance(data, list):
|
||||
data = data[0] if data else {}
|
||||
return _common.finish_single(ctx, data or {}, output=args.output, wait=args.wait, label="导入")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""本地模型体检 — inspect local GLB/GLTF files for game-engine readiness.
|
||||
|
||||
tripocli inspect model.glb
|
||||
tripocli inspect ./assets/*.glb
|
||||
"""
|
||||
|
||||
from .. import glb
|
||||
|
||||
NAME = "inspect"
|
||||
HELP = "本地模型体检:查看 GLB 的三角形面数、包围盒尺寸、骨骼、动画名与性能告警(0 积分)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("files", nargs="+", help="一个或多个本地 .glb/.gltf 文件路径")
|
||||
|
||||
|
||||
def _fmt_size(num_bytes):
|
||||
if num_bytes >= 1024 * 1024:
|
||||
return f"{num_bytes / (1024 * 1024):.2f} MB"
|
||||
return f"{num_bytes / 1024:.1f} KB"
|
||||
|
||||
|
||||
def run(ctx):
|
||||
results = []
|
||||
for path in ctx.args.files:
|
||||
results.append(glb.inspect_file(path))
|
||||
|
||||
payload = results[0] if len(results) == 1 else {"models": results}
|
||||
|
||||
def human():
|
||||
for info in results:
|
||||
comp = "Draco" if info["draco_compressed"] else "未压缩"
|
||||
ctx.out.line(f"📦 {info['name']} ({_fmt_size(info['file_size'])}, {comp})")
|
||||
ctx.out.line(
|
||||
f" 网格面数 : {info['triangles']:,} 三角面 / {info['vertices']:,} 顶点 "
|
||||
f"({info['meshes']} mesh, {info['materials']} 材质, {info['textures']} 贴图"
|
||||
f"{', 含法线/PBR贴图' if info['has_pbr_maps'] else ''})"
|
||||
)
|
||||
if info["bbox"]:
|
||||
b = info["bbox"]
|
||||
sz = b["size"]
|
||||
ctx.out.line(
|
||||
f" 包围尺寸 : 宽 {sz[0]} × 高 {sz[1]} × 深 {sz[2]} "
|
||||
f"(minY={b['min'][1]}, 底部贴地需偏移 +{b['ground_offset_y']})"
|
||||
)
|
||||
if info["is_rigged"] or info["animations"]:
|
||||
anims = ", ".join(a["name"] for a in info["animations"]) or "无"
|
||||
ctx.out.line(f" 骨骼动画 : {info['joints']} 根骨骼 | 动作片段 ({len(info['animations'])}): {anims}")
|
||||
for w in info["warnings"]:
|
||||
ctx.out.line(f" ⚠️ {w}")
|
||||
|
||||
ctx.out.data(payload, human=human)
|
||||
return payload
|
||||
@@ -0,0 +1,51 @@
|
||||
"""网格编辑(局部重拓扑)— local_edit on a 智能网格 (Nexus) model.
|
||||
|
||||
tripocli edit -p <project_id> --bbox "-1,-1,-1,1,1,1" --face-limit 8000 --wait
|
||||
|
||||
The studio shows this as the 「编辑」 tab: pick a bounding box on a Nexus mesh and
|
||||
re-topologise just that region. Verified body:
|
||||
``{project_id, source_operator_id, bbox:[[min],[max]], face_limit}``.
|
||||
|
||||
⚠️ Only offered for **智能网格 (Nexus)** models that are not yet textured / rigged
|
||||
/ segmented / PBR (the studio hides the tab otherwise). ``bbox`` coordinates are
|
||||
normalised to the model's own space, ``-1..1`` per axis (the UI clamps them), so
|
||||
the default ``-1,-1,-1,1,1,1`` covers the whole model.
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "edit"
|
||||
HELP = "网格编辑:在包围盒内对智能网格(Nexus)模型做局部重拓扑"
|
||||
|
||||
FULL_BBOX = [-1.0, -1.0, -1.0, 1.0, 1.0, 1.0]
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "编辑完成后保存到该路径(可选)")
|
||||
parser.add_argument(
|
||||
"--bbox", default=",".join(str(v) for v in FULL_BBOX),
|
||||
help="包围盒 x1,y1,z1,x2,y2,z2(归一化 -1..1,默认整个模型)",
|
||||
)
|
||||
parser.add_argument("--face-limit", type=int, default=8000, help="该区域面数上限(默认 8000)")
|
||||
|
||||
|
||||
def parse_bbox(text):
|
||||
values = [float(part) for part in text.replace(" ", "").split(",")]
|
||||
if len(values) != 6:
|
||||
raise ValueError("--bbox 需要 6 个数字:x1,y1,z1,x2,y2,z2")
|
||||
lower = [min(1.0, max(-1.0, v)) for v in values[:3]]
|
||||
upper = [min(1.0, max(-1.0, v)) for v in values[3:]]
|
||||
return [lower, upper]
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
operator_id = ctx.client.project_operator(args.project).get("operator_id")
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"source_operator_id": operator_id,
|
||||
"bbox": parse_bbox(args.bbox),
|
||||
"face_limit": args.face_limit,
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/local_edit", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="网格编辑")
|
||||
@@ -0,0 +1,151 @@
|
||||
"""文字生成动作 — text to motion(AI 动画),并把动作应用到角色。
|
||||
|
||||
tripocli motion -p "让角色挥手打招呼" --duration 5 --wait # 生成动作,得到 motion_asset_id
|
||||
tripocli motion -p "让角色走一段" --project <pid> --wait # 生成并直接应用到角色
|
||||
tripocli motion --list # 列出我的动作资产
|
||||
tripocli motion -p "..." --segment "3:跳一下" --segment "2:落地" # 多阶段动作
|
||||
|
||||
实测流程(在动画页「AI 动画」标签里点生成抓到):
|
||||
|
||||
POST /v2/studio/motion/generate
|
||||
{"segments": [{"duration_seconds": 5, "prompt": "让角色挥手打招呼"}]}
|
||||
→ {task_id, status:"queued"}
|
||||
|
||||
POST /v2/studio/motion/get_task {"task_id": "..."}
|
||||
→ 轮询直到 status="success",取 asset_id
|
||||
|
||||
POST /v2/studio/operation/retarget_model
|
||||
{"motion_asset_id": "<asset_id>", "model_version":"default",
|
||||
"project_id": "...", "rig_type": "biped"} → 把动作套到模型上
|
||||
|
||||
其它已验证的 motion 接口:`/motion/list_assets`(我的动作资产)、`/motion/get_asset`、
|
||||
`/motion/list_active_tasks`(正在跑的任务)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from . import _common, animate
|
||||
from ..errors import TripoAPIError, TripoTimeout
|
||||
|
||||
NAME = "motion"
|
||||
HELP = "文字生成动作:用一句话生成动画并(可选)应用到已绑定的角色"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-p", "--prompt", help="动作描述,如「让角色挥手打招呼」")
|
||||
parser.add_argument("--duration", type=float, default=5, help="动作时长(秒,默认 5)")
|
||||
parser.add_argument(
|
||||
"--segment", action="append",
|
||||
help="多阶段动作:\"时长:描述\",可重复(如 --segment \"3:跳一下\" --segment \"2:落地\")",
|
||||
)
|
||||
parser.add_argument("--list", action="store_true", help="列出我的动作资产后退出")
|
||||
parser.add_argument("--project", help="生成后直接应用到该项目(需已绑定骨骼)")
|
||||
parser.add_argument("--type", default="biped", help="骨骼类型,默认 biped(配合 --project 使用)")
|
||||
parser.add_argument("-w", "--wait", action="store_true", help="等待动作生成完成")
|
||||
parser.add_argument("-o", "--output", help="应用动作后的模型保存路径(配合 --project)")
|
||||
parser.add_argument("--timeout", type=int, default=300, help="等待超时秒数(默认 300)")
|
||||
|
||||
|
||||
def _segments(args):
|
||||
if args.segment:
|
||||
out = []
|
||||
for raw in args.segment:
|
||||
if ":" in raw:
|
||||
duration, text = raw.split(":", 1)
|
||||
out.append({"duration_seconds": float(duration), "prompt": text.strip()})
|
||||
else:
|
||||
out.append({"duration_seconds": args.duration, "prompt": raw.strip()})
|
||||
return out
|
||||
if not args.prompt:
|
||||
return []
|
||||
return [{"duration_seconds": args.duration, "prompt": args.prompt}]
|
||||
|
||||
|
||||
def _poll_task(client, task_id, timeout):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
data = client.get_data("POST", "/v2/studio/motion/get_task", body={"task_id": task_id}) or {}
|
||||
status = data.get("status")
|
||||
if status == "success":
|
||||
return data
|
||||
if status in ("failed", "banned", "expired"):
|
||||
raise TripoAPIError(f"动作生成失败: status={status} error={data.get('error')}")
|
||||
time.sleep(3)
|
||||
raise TripoTimeout(f"动作生成在 {timeout}s 内未完成(task_id={task_id})")
|
||||
|
||||
|
||||
def _list_assets(ctx):
|
||||
data = ctx.client.get_data("POST", "/v2/studio/motion/list_assets", body={}) or {}
|
||||
assets = data.get("assets") or []
|
||||
ctx.out.data(
|
||||
data,
|
||||
human=lambda: [
|
||||
ctx.out.line(f"共 {len(assets)} 个动作资产"),
|
||||
*[ctx.out.line(
|
||||
f" {a.get('asset_id') or a.get('id')} [{a.get('action_type') or a.get('category') or ''}] "
|
||||
f"{a.get('title') or a.get('name') or a.get('prompt') or ''}"
|
||||
) for a in assets],
|
||||
],
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
|
||||
if args.list:
|
||||
return _list_assets(ctx)
|
||||
|
||||
segments = _segments(args)
|
||||
if not segments:
|
||||
raise ValueError("需要 -p/--prompt 描述动作(或用 --segment 组合多阶段动作,--list 查看已有资产)")
|
||||
|
||||
ctx.out.line("🎬 生成动作: " + " | ".join(f"{s['duration_seconds']}s {s['prompt']}" for s in segments))
|
||||
task = ctx.client.get_data("POST", "/v2/studio/motion/generate", body={"segments": segments}) or {}
|
||||
task_id = task.get("task_id")
|
||||
if not task_id:
|
||||
raise TripoAPIError(f"motion/generate 未返回 task_id: {task}")
|
||||
|
||||
if not args.wait:
|
||||
ctx.out.data(
|
||||
task,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"✅ 已提交 task_id={task_id}"),
|
||||
ctx.out.line(f"取结果: tripocli motion --wait ... 或轮询 /motion/get_task"),
|
||||
),
|
||||
)
|
||||
return task
|
||||
|
||||
result = _poll_task(ctx.client, task_id, args.timeout)
|
||||
asset_id = result.get("asset_id")
|
||||
|
||||
payload = {"task": result, "motion_asset_id": asset_id}
|
||||
if args.project and asset_id:
|
||||
ctx.out.line(f"🦴 应用到项目 {args.project} ...")
|
||||
body = {
|
||||
"motion_asset_id": asset_id,
|
||||
"model_version": "default",
|
||||
"project_id": args.project,
|
||||
"rig_type": args.type,
|
||||
}
|
||||
applied = ctx.client.get_data("POST", "/v2/studio/operation/retarget_model", body=body) or {}
|
||||
op_id = applied.get("operator_id")
|
||||
if op_id:
|
||||
_common.wait_and_report(ctx, op_id, timeout=900)
|
||||
entry = animate._retarget_entry(ctx.client, args.project, op_id) or {}
|
||||
model_url = entry.get("model_url")
|
||||
output_path = None
|
||||
if args.output and model_url:
|
||||
ctx.client.download(model_url, args.output)
|
||||
output_path = os.path.abspath(args.output)
|
||||
payload["applied"] = {"result": applied, "model_url": model_url, "output_file": output_path}
|
||||
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"🎉 动作已生成 motion_asset_id={asset_id}"),
|
||||
ctx.out.line(f" 应用到角色: tripocli animate -p <project_id> --motion-asset {asset_id} --wait"),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,51 @@
|
||||
"""多视角图生 3D — multi-view to model.
|
||||
|
||||
tripocli multiview --front f.png --left l.png --right r.png --back b.png --wait -o mv.glb
|
||||
|
||||
Verified against the live API: the ``image`` field must be a list of exactly four
|
||||
audited image objects; fewer than four is rejected with "Invalid parameter(s)".
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
from ..errors import TripoAPIError
|
||||
from ..versions import SMART_MESH_DEFAULT
|
||||
|
||||
NAME = "multiview"
|
||||
HELP = "多视角图片生成 3D 模型(前/左/右/后,必须 4 张)"
|
||||
|
||||
VIEWS = ("front", "left", "right", "back")
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--front", required=True, help="前视图本地图片")
|
||||
parser.add_argument("--left", required=True, help="左视图本地图片")
|
||||
parser.add_argument("--right", required=True, help="右视图本地图片")
|
||||
parser.add_argument("--back", required=True, help="后视图本地图片")
|
||||
_common.add_generation_options(parser)
|
||||
_common.add_wait_options(parser, "生成完成后保存的 .glb 路径")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
paths = [getattr(args, name) for name in VIEWS]
|
||||
|
||||
# Multi-view is a smart-mesh workflow by default; keep the low-poly topology
|
||||
# unless the caller explicitly asked for something else.
|
||||
if not args.smart_mesh and not args.model:
|
||||
args.smart_mesh = True
|
||||
if not args.model:
|
||||
args.model = SMART_MESH_DEFAULT
|
||||
|
||||
images = []
|
||||
for name, path in zip(VIEWS, paths):
|
||||
ctx.out.line(f"📤 上传 {name} 视图: {path}")
|
||||
images.append(ctx.client.upload_image(path))
|
||||
|
||||
if len(images) != 4:
|
||||
raise TripoAPIError("多视角生成需要恰好 4 张图片 (--front/--left/--right/--back)")
|
||||
|
||||
body = _common.generation_body(args)
|
||||
data = ctx.client.get_data(
|
||||
"POST", "/v2/studio/operation/multiview_to_model", body={"image": images, **body}
|
||||
)
|
||||
return _common.finish_single(ctx, data, output=args.output, wait=args.wait)
|
||||
@@ -0,0 +1,185 @@
|
||||
"""本地游戏资产优化 — decimate triangles, resize textures, Draco-compress, and build LODs.
|
||||
|
||||
tripocli optimize house.glb -o house_game.glb --faces 15000
|
||||
tripocli optimize hero.glb --in-place --faces 40000
|
||||
tripocli optimize house.glb --lods
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from .. import glb
|
||||
|
||||
NAME = "optimize"
|
||||
HELP = "本地模型优化:真减面、锁 UV、贴图缩放、Draco 压缩与一键生成多级 LOD(0 积分)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("input", help="输入的本地 .glb 文件路径")
|
||||
parser.add_argument("-o", "--output", help="输出 .glb 路径(默认 <原文件名>_opt.glb)")
|
||||
parser.add_argument("--in-place", action="store_true", help="直接原地覆盖输入文件")
|
||||
parser.add_argument(
|
||||
"--faces", type=int,
|
||||
help="目标三角形面数(如 15000;不传则按静态模型 18000 / 骨骼角色 45000 自动推算)",
|
||||
)
|
||||
parser.add_argument("--ratio", type=float, help="减面保留比例 0~1(优先于 --faces)")
|
||||
parser.add_argument(
|
||||
"--error", type=float,
|
||||
help="几何误差容忍度(默认静态模型 0.06,带骨骼角色 0.02 以保护五官与关节)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--texture-size", type=int, default=1024,
|
||||
help="限制最大贴图边长(默认 1024;传 0 保留原始尺寸)",
|
||||
)
|
||||
parser.add_argument("--no-draco", action="store_true", help="跳过 Draco 几何压缩")
|
||||
parser.add_argument(
|
||||
"--lods", action="store_true",
|
||||
help="一键生成 3 级游戏 LOD 文件(<名>_lod0.glb / _lod1.glb / _lod2.glb)",
|
||||
)
|
||||
|
||||
|
||||
def _runner():
|
||||
if shutil.which("gltf-transform"):
|
||||
return ["gltf-transform"]
|
||||
if shutil.which("npx"):
|
||||
return ["npx", "--yes", "@gltf-transform/cli"]
|
||||
raise RuntimeError("未找到 npx 或 gltf-transform,请先安装 Node.js")
|
||||
|
||||
|
||||
def _run_cmd(cmd):
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout or "").strip()[-600:]
|
||||
raise RuntimeError(f"执行失败 ({' '.join(cmd[:4])}): {detail}")
|
||||
|
||||
|
||||
def _optimize_one(src, dst, before_info, target_faces=None, ratio=None, error=None,
|
||||
texture_size=None, use_draco=True):
|
||||
cli = _runner()
|
||||
is_rigged = before_info["is_rigged"]
|
||||
orig_tris = max(before_info["triangles"], 1)
|
||||
|
||||
if ratio is None:
|
||||
faces = target_faces or (45000 if is_rigged else 18000)
|
||||
ratio = min(1.0, round(faces / orig_tris, 4))
|
||||
|
||||
err = error if error is not None else (0.02 if is_rigged else 0.06)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="tripo_opt_") as tmpdir:
|
||||
current = src
|
||||
if ratio < 0.999 and before_info["meshes"] > 0:
|
||||
step_weld = os.path.join(tmpdir, "welded.glb")
|
||||
_run_cmd(cli + ["weld", current, step_weld])
|
||||
step_simp = os.path.join(tmpdir, "simplified.glb")
|
||||
_run_cmd(cli + ["simplify", step_weld, step_simp, "--ratio", str(ratio), "--error", str(err)])
|
||||
current = step_simp
|
||||
|
||||
if texture_size and texture_size > 0 and before_info["textures"] > 0:
|
||||
step_tex = os.path.join(tmpdir, "resized.glb")
|
||||
_run_cmd(cli + [
|
||||
"resize", current, step_tex,
|
||||
"--width", str(texture_size), "--height", str(texture_size),
|
||||
])
|
||||
current = step_tex
|
||||
|
||||
if use_draco and before_info["meshes"] > 0:
|
||||
step_draco = os.path.join(tmpdir, "draco.glb")
|
||||
_run_cmd(cli + ["draco", current, step_draco])
|
||||
current = step_draco
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(dst)) or ".", exist_ok=True)
|
||||
shutil.copy2(current, dst)
|
||||
|
||||
after_info = glb.inspect_file(dst)
|
||||
tri_drop = round((1.0 - after_info["triangles"] / orig_tris) * 100, 1) if orig_tris else 0.0
|
||||
size_drop = round((1.0 - after_info["file_size"] / max(before_info["file_size"], 1)) * 100, 1)
|
||||
return {
|
||||
"output_file": os.path.abspath(dst),
|
||||
"before_triangles": before_info["triangles"],
|
||||
"after_triangles": after_info["triangles"],
|
||||
"triangle_reduction_pct": tri_drop,
|
||||
"before_size_mb": before_info["file_size_mb"],
|
||||
"after_size_mb": after_info["file_size_mb"],
|
||||
"size_reduction_pct": size_drop,
|
||||
"ratio_used": ratio,
|
||||
"error_used": err,
|
||||
}
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
src = os.path.abspath(args.input)
|
||||
if not os.path.exists(src):
|
||||
raise FileNotFoundError(f"文件不存在: {src}")
|
||||
|
||||
before = glb.inspect_file(src)
|
||||
stem, ext = os.path.splitext(src)
|
||||
use_draco = not args.no_draco
|
||||
|
||||
if args.lods:
|
||||
base_out = os.path.splitext(os.path.abspath(args.output))[0] if args.output else stem
|
||||
tiers = [
|
||||
("lod0", args.faces or (45000 if before["is_rigged"] else 20000), 0.03 if before["is_rigged"] else 0.05),
|
||||
("lod1", 12000 if before["is_rigged"] else 5000, 0.05 if before["is_rigged"] else 0.08),
|
||||
("lod2", 3500 if before["is_rigged"] else 1500, 0.10 if before["is_rigged"] else 0.15),
|
||||
]
|
||||
lods = []
|
||||
for tag, faces, err in tiers:
|
||||
target_path = f"{base_out}_{tag}{ext or '.glb'}"
|
||||
ctx.out.line(f"⚙️ 生成 {tag.upper()} (目标 ≤{faces:,} 面) -> {os.path.basename(target_path)}...")
|
||||
report = _optimize_one(
|
||||
src, target_path, before,
|
||||
target_faces=faces, error=err,
|
||||
texture_size=args.texture_size, use_draco=use_draco,
|
||||
)
|
||||
report["lod"] = tag
|
||||
lods.append(report)
|
||||
|
||||
payload = {"input": src, "before_triangles": before["triangles"], "lods": lods}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: [
|
||||
ctx.out.line(
|
||||
f"✅ {item['lod'].upper()}: {item['before_triangles']:,} -> {item['after_triangles']:,} 三角面 "
|
||||
f"(-{item['triangle_reduction_pct']}%), {item['before_size_mb']}MB -> {item['after_size_mb']}MB "
|
||||
f"({item['output_file']})"
|
||||
)
|
||||
for item in lods
|
||||
],
|
||||
)
|
||||
return payload
|
||||
|
||||
if args.in_place:
|
||||
dst = src
|
||||
elif args.output:
|
||||
dst = os.path.abspath(args.output)
|
||||
else:
|
||||
dst = f"{stem}_opt{ext or '.glb'}"
|
||||
|
||||
ctx.out.line(
|
||||
f"⚙️ 优化模型: {os.path.basename(src)} "
|
||||
f"(原始 {before['triangles']:,} 三角面, {before['file_size_mb']} MB"
|
||||
f"{', 带骨骼蒙皮' if before['is_rigged'] else ''})..."
|
||||
)
|
||||
report = _optimize_one(
|
||||
src, dst, before,
|
||||
target_faces=args.faces, ratio=args.ratio, error=args.error,
|
||||
texture_size=args.texture_size, use_draco=use_draco,
|
||||
)
|
||||
ctx.out.data(
|
||||
report,
|
||||
human=lambda: (
|
||||
ctx.out.line(
|
||||
f"🎉 优化完成: {report['before_triangles']:,} -> {report['after_triangles']:,} 三角面 "
|
||||
f"(-{report['triangle_reduction_pct']}%)"
|
||||
),
|
||||
ctx.out.line(
|
||||
f" 体积变化: {report['before_size_mb']} MB -> {report['after_size_mb']} MB "
|
||||
f"(-{report['size_reduction_pct']}%)"
|
||||
),
|
||||
ctx.out.line(f" 已保存 : {report['output_file']}"),
|
||||
),
|
||||
)
|
||||
return report
|
||||
@@ -0,0 +1,32 @@
|
||||
"""PBR — generate metallic/roughness/normal maps.
|
||||
|
||||
tripocli pbr -p <project_id> --wait -o pbr.glb
|
||||
|
||||
Endpoint ``/operation/pbr_generate``. Verified live: it needs the project's
|
||||
``model_version`` **and** ``part_name_list`` (``tripo_node_<operator_id>``) —
|
||||
without the node list the task is accepted but fails at 100%.
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "pbr"
|
||||
HELP = "PBR:生成金属度/粗糙度/法线等物理材质贴图"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "PBR 结果保存到该路径(可选)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
model_version = _common.project_version(ctx.client, args.project, args.model)
|
||||
if not model_version:
|
||||
raise ValueError(f"无法读取项目 {args.project} 的 model_version,请用 -m 显式指定")
|
||||
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"model_version": model_version,
|
||||
"part_name_list": _common.node_part_name(ctx.client, args.project),
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/pbr_generate", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="PBR 生成")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""项目详情 — inspect a project and its current asset.
|
||||
|
||||
tripocli project <project_id>
|
||||
"""
|
||||
|
||||
NAME = "project"
|
||||
HELP = "查看项目详情(模型地址、预览图、operator 状态)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("project_id", help="项目 ID")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
detail = ctx.client.project_detail(ctx.args.project_id) or {}
|
||||
operator = detail.get("operator") or {}
|
||||
retargets = operator.get("retarget") or []
|
||||
|
||||
def show():
|
||||
ctx.out.line(f"项目名 : {detail.get('project_name')}")
|
||||
ctx.out.line(f"项目 ID : {detail.get('id')}")
|
||||
ctx.out.line(f"operator : {operator.get('operator_id')} ({operator.get('status')})")
|
||||
ctx.out.line(f"模型版本 : {operator.get('model_version')}")
|
||||
ctx.out.line(f"模型地址 : {detail.get('model_url')}")
|
||||
if retargets:
|
||||
names = ", ".join(f"{r.get('name')}({r.get('status')})" for r in retargets)
|
||||
ctx.out.line(f"重定向动作 ({len(retargets)}): {names}")
|
||||
|
||||
ctx.out.data(detail, human=show)
|
||||
return detail
|
||||
@@ -0,0 +1,36 @@
|
||||
"""重拓扑 — remesh an existing project into clean quad/tri topology.
|
||||
|
||||
tripocli remesh -p <project_id> --face-limit 10000 --wait -o lowpoly.glb
|
||||
|
||||
Verified body: ``bake``, ``face_limit``, ``model_version`` ("default"), ``quad``,
|
||||
``smart_poly`` and ``part_name_list`` holding ``tripo_node_<operator_id>``.
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "remesh"
|
||||
HELP = "重拓扑:将模型重制为干净的四边形/三角面网格"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser)
|
||||
parser.add_argument("--face-limit", type=int, default=10000, help="目标面数(默认 10000)")
|
||||
parser.add_argument("--tri", action="store_true", help="输出三角面(默认四边面)")
|
||||
parser.add_argument("--no-bake", action="store_true", help="不烘焙原贴图")
|
||||
parser.add_argument("--part", action="append", help="只处理指定部件节点名,可重复;默认整个模型")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
parts = args.part or _common.node_part_name(ctx.client, args.project)
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"face_limit": args.face_limit,
|
||||
"quad": not args.tri,
|
||||
"smart_poly": False,
|
||||
"bake": not args.no_bake,
|
||||
"model_version": _common.project_version(ctx.client, args.project, args.model) or "default",
|
||||
"part_name_list": parts,
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/remesh", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="重拓扑")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""重命名项目 — rename a project.
|
||||
|
||||
tripocli rename -p <project_id> --name "My Hero"
|
||||
|
||||
实测请求体 ``{project_id, name}``,成功返回 ``null``。
|
||||
"""
|
||||
|
||||
NAME = "rename"
|
||||
HELP = "重命名项目"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-p", "--project", required=True, help="项目 ID (project_id)")
|
||||
parser.add_argument("--name", required=True, help="新的项目名")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
ctx.client.get_data(
|
||||
"POST", "/v2/studio/operation/update_name",
|
||||
body={"project_id": args.project, "name": args.name},
|
||||
)
|
||||
payload = {"project_id": args.project, "name": args.name}
|
||||
ctx.out.data(payload, human=lambda: ctx.out.line(f"✅ 已重命名为: {args.name}"))
|
||||
return payload
|
||||
@@ -0,0 +1,80 @@
|
||||
"""绑定 — auto rigging with UniRig.
|
||||
|
||||
tripocli rig -p <project_id> --type biped --spec mixamo --wait
|
||||
|
||||
A riggable check runs first (``pre_rig_check``); the model must be a humanoid in
|
||||
T/A pose for good results. Verified body of ``rigging_model``:
|
||||
``{project_id, model_version, rig_type, spec}``.
|
||||
|
||||
骨骼预设(界面 5 个选项 → API 的 spec 值,取自 studio 前端数据表,并用实测请求核对):
|
||||
``Mixamo → mixamo``、``ActorCore → actorcore``、``UE5 Mannequin → unreal``、
|
||||
``Unity Humanoid Compatible → unity``、``VRM 1.0 Humanoid → vrm``。
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "rig"
|
||||
HELP = "自动绑定骨骼(人形/其他 + 5 种骨骼预设)"
|
||||
|
||||
RIG_TYPES = ("biped", "others")
|
||||
|
||||
# 界面预设名 → API spec 值
|
||||
SPEC_ALIASES = {
|
||||
"mixamo": "mixamo",
|
||||
"actorcore": "actorcore",
|
||||
"actor_core": "actorcore",
|
||||
"unreal": "unreal",
|
||||
"ue5": "unreal",
|
||||
"ue5-mannequin": "unreal",
|
||||
"ue5_mannequin": "unreal",
|
||||
"unity": "unity",
|
||||
"unity-humanoid": "unity",
|
||||
"vrm": "vrm",
|
||||
"vrm1": "vrm",
|
||||
}
|
||||
DEFAULT_SPEC = "mixamo"
|
||||
|
||||
|
||||
def resolve_spec(name):
|
||||
if not name:
|
||||
return DEFAULT_SPEC
|
||||
return SPEC_ALIASES.get(name.strip().lower(), name.strip())
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "绑定完成后保存到该路径(可选)")
|
||||
parser.add_argument("--type", default="biped", choices=RIG_TYPES, help="绑定类型:biped=人形,others=其他")
|
||||
parser.add_argument(
|
||||
"--spec", default=DEFAULT_SPEC,
|
||||
help="骨骼预设: mixamo(默认) / actorcore / unreal(UE5 Mannequin) / unity / vrm",
|
||||
)
|
||||
parser.add_argument("--check-only", action="store_true", help="只做绑定前检查,不执行绑定")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
model_version = _common.project_version(ctx.client, args.project, args.model)
|
||||
spec = resolve_spec(args.spec)
|
||||
|
||||
check_body = {"project_id": args.project}
|
||||
if model_version:
|
||||
check_body["model_version"] = model_version
|
||||
check = ctx.client.get_data("POST", "/v2/studio/operation/pre_rig_check", body=check_body) or {}
|
||||
|
||||
if args.check_only:
|
||||
ctx.out.data(
|
||||
check,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"可绑定: {check.get('riggable')}"),
|
||||
ctx.out.line(f"绑定类型: {check.get('rig_type')}"),
|
||||
),
|
||||
)
|
||||
return check
|
||||
|
||||
ctx.out.line(f"🔎 绑定前检查: riggable={check.get('riggable')} type={check.get('rig_type')}")
|
||||
|
||||
body = {"project_id": args.project, "rig_type": args.type, "spec": spec}
|
||||
if model_version:
|
||||
body["model_version"] = model_version
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/rigging_model", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="绑定")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""智能分割 — split a model into editable semantic parts.
|
||||
|
||||
tripocli segment -p <project_id> --wait
|
||||
|
||||
实测(在页面上真操作抓到的请求):
|
||||
|
||||
POST /v2/studio/operation/ai_segmentation
|
||||
{"model_version": "v2.0-20260430", "project_id": "...", "segmentation_granularity": "balanced"}
|
||||
|
||||
⚠️ 关键点:`model_version` 用的是**分割模型自己的版本**(`v2.0-20260430`),
|
||||
不是项目自身的模型版本——传项目版本会得到 `1001 Fatal error on the server side`。
|
||||
|
||||
粒度 `segmentation_granularity`:`balanced`(界面「标准」)已实测可用;
|
||||
界面另有「粗略 / 精细」两档,但它们的枚举值未确认,需要时用 `--granularity` 传入。
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "segment"
|
||||
HELP = "智能分割:把模型拆成可编辑的语义部件"
|
||||
|
||||
# 分割模型自己的版本,实测确认
|
||||
SEGMENTATION_MODEL_VERSION = "v2.0-20260430"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "分割结果保存到该路径(可选)")
|
||||
parser.add_argument(
|
||||
"--granularity", default="balanced",
|
||||
help="分割粒度(默认 balanced)",
|
||||
)
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"model_version": args.model or SEGMENTATION_MODEL_VERSION,
|
||||
"segmentation_granularity": args.granularity,
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/ai_segmentation", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="智能分割")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""本地账号管理服务 — tripocli serve.
|
||||
|
||||
tripocli serve # 打开 http://127.0.0.1:8765/
|
||||
tripocli serve --port 9000 --open # 指定端口并自动打开浏览器
|
||||
|
||||
页面里可以粘贴/更新 cookie、查看会话有效期与积分额度、切换当前账号、删除账号。
|
||||
默认只监听 127.0.0.1:能访问到它的人就能以该账号身份调用接口。
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import webbrowser
|
||||
|
||||
from ..server import serve
|
||||
|
||||
NAME = "serve"
|
||||
HELP = "启动本地账号管理服务(网页里粘贴/更新 cookie、看有效期与积分)"
|
||||
|
||||
LOOPBACK = {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认 127.0.0.1)")
|
||||
parser.add_argument("--port", type=int, default=8765, help="监听端口(默认 8765,0 表示自动分配)")
|
||||
parser.add_argument("--open", action="store_true", help="启动后自动打开浏览器")
|
||||
parser.add_argument("--quiet", action="store_true", help="不打印请求日志")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
httpd, url = serve(host=args.host, port=args.port)
|
||||
|
||||
if args.quiet:
|
||||
httpd.RequestHandlerClass.log_message = lambda *a, **k: None
|
||||
|
||||
ctx.out.line(f"🖥 账号管理服务已启动: {url}")
|
||||
ctx.out.line(f" 账号文件: {ctx.accounts.path}")
|
||||
if args.host not in LOOPBACK:
|
||||
ctx.out.line(f" ⚠️ 正在监听 {args.host},局域网内任何人都能访问并操作该账号,请谨慎使用")
|
||||
ctx.out.line(" 按 Ctrl+C 停止")
|
||||
|
||||
if args.open:
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
try:
|
||||
subprocess.Popen(["open", url])
|
||||
except Exception:
|
||||
ctx.out.line(" (无法自动打开浏览器,请手动访问上面的地址)")
|
||||
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
ctx.out.line("\n已停止")
|
||||
finally:
|
||||
httpd.server_close()
|
||||
return {"url": url}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""3D 风格化 — stylize an existing model.
|
||||
|
||||
tripocli stylize -p <project_id> --style clay --wait
|
||||
|
||||
实测请求体 ``{project_id, model_version, style}``(``style:"clay"`` 被服务端接受)。
|
||||
⚠️ `style` 的**完整合法取值未确认**(该页面的 chunk 在生成页未加载,无法枚举);
|
||||
若传入不支持的风格,服务端会在任务阶段报错。
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "stylize"
|
||||
HELP = "3D 风格化:把已有模型转成某种风格(如 clay 黏土风)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "风格化结果保存到该路径(可选)")
|
||||
parser.add_argument("--style", default="clay", help="目标风格(默认 clay 黏土风)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
model_version = _common.project_version(ctx.client, args.project, args.model)
|
||||
body = {"project_id": args.project, "style": args.style}
|
||||
if model_version:
|
||||
body["model_version"] = model_version
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/stylize", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="风格化")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""任务进度 — poll or block on an operator.
|
||||
|
||||
tripocli task status <operator_id>
|
||||
tripocli task wait <operator_id> -p <project_id> -o out.glb
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import _common
|
||||
|
||||
TIMEOUT_DEFAULT = 900
|
||||
|
||||
|
||||
def _status(ctx):
|
||||
items = ctx.client.progress([ctx.args.operator_id])
|
||||
if not items:
|
||||
raise ValueError(f"未找到任务 {ctx.args.operator_id}")
|
||||
task = items[0]
|
||||
ctx.out.data(
|
||||
task,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"状态 : {task.get('status')}"),
|
||||
ctx.out.line(f"进度 : {task.get('progress')}%"),
|
||||
ctx.out.line(f"剩余 : {task.get('left_time')}s"),
|
||||
),
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
def _wait(ctx):
|
||||
args = ctx.args
|
||||
task = _common.wait_and_report(ctx, args.operator_id, timeout=args.timeout)
|
||||
|
||||
output_path = None
|
||||
model_url = None
|
||||
if args.project:
|
||||
model_url, _detail = _common.fetch_model_url(ctx, args.project)
|
||||
if args.output and model_url:
|
||||
ctx.client.download(model_url, args.output)
|
||||
output_path = os.path.abspath(args.output)
|
||||
|
||||
payload = {"task": task, "model_url": model_url, "output_file": output_path}
|
||||
ctx.out.data(
|
||||
payload,
|
||||
human=lambda: (
|
||||
ctx.out.line(f"✅ 任务状态: {task.get('status')}"),
|
||||
ctx.out.line(f"模型地址: {model_url}") if model_url else ctx.out.line(""),
|
||||
ctx.out.line(f"已保存: {output_path}") if output_path else ctx.out.line(""),
|
||||
),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
parser = subparsers.add_parser("task", help="任务进度查询与等待")
|
||||
sub = parser.add_subparsers(dest="task_action", required=True)
|
||||
|
||||
status = sub.add_parser("status", help="查询任务状态")
|
||||
status.add_argument("operator_id", help="operator_id / task_id")
|
||||
status.set_defaults(func=_status)
|
||||
|
||||
wait = sub.add_parser("wait", help="阻塞等待任务完成(可顺带下载)")
|
||||
wait.add_argument("operator_id", help="operator_id / task_id")
|
||||
wait.add_argument("-p", "--project", help="项目 ID,用于取最终模型地址")
|
||||
wait.add_argument("-o", "--output", help="下载保存路径")
|
||||
wait.add_argument("--timeout", type=int, default=TIMEOUT_DEFAULT, help=f"超时秒数(默认 {TIMEOUT_DEFAULT})")
|
||||
wait.set_defaults(func=_wait)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""文生 3D — text to model.
|
||||
|
||||
tripocli text -p "a low-poly wooden barrel" --smart-mesh --wait -o barrel.glb
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "text"
|
||||
HELP = "文本生成 3D 模型(文生 3D)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("-p", "--prompt", required=True, help="模型描述提示词(英文效果更佳)")
|
||||
_common.add_generation_options(parser)
|
||||
_common.add_wait_options(parser, "生成完成后保存的 .glb 路径")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
body = _common.generation_body(args)
|
||||
data = ctx.client.get_data(
|
||||
"POST", "/v2/studio/operation/text_to_model", body={"prompt": args.prompt, **body}
|
||||
)
|
||||
return _common.finish_single(ctx, data, output=args.output, wait=args.wait)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""纹理生成 — (re)texture a model.
|
||||
|
||||
tripocli texture -p <project_id> --resolution 2K --wait -o textured.glb
|
||||
|
||||
Endpoint ``/operation/texture_model``. Verified live on an *untextured* model:
|
||||
it needs ``texture_quality``, ``texture_alignment`` and ``part_name_list``.
|
||||
Note the studio refuses to re-texture a model that already has a texture — for
|
||||
those, use the retexture flow instead.
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "texture"
|
||||
HELP = "纹理生成:为模型生成/重绘贴图(2K/4K/8K)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "贴图后的模型保存到该路径(可选)")
|
||||
parser.add_argument("--resolution", default="2K", choices=["2K", "4K", "8K"], help="贴图分辨率")
|
||||
parser.add_argument("--quality", default="detailed", choices=["standard", "detailed"], help="贴图质量")
|
||||
parser.add_argument("--prompt", help="贴图风格提示词(可选)")
|
||||
parser.add_argument("--keep-light", action="store_true", help="保留原图光影(关闭去光处理)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"texture_quality": args.quality,
|
||||
"texture_alignment": "original_image",
|
||||
"texture_resolution": args.resolution,
|
||||
"delight": not args.keep_light,
|
||||
"part_name_list": _common.node_part_name(ctx.client, args.project),
|
||||
}
|
||||
model_version = _common.project_version(ctx.client, args.project, args.model)
|
||||
if model_version:
|
||||
body["model_version"] = model_version
|
||||
if args.prompt:
|
||||
body["prompt"] = args.prompt
|
||||
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/texture_model", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="纹理生成")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""纹理放大 — upscale an existing texture.
|
||||
|
||||
tripocli upscale -p <project_id> --wait
|
||||
|
||||
实测(页面上真操作抓到的请求):
|
||||
|
||||
POST /v2/studio/operation/texture_upscaler
|
||||
{"model_version": "v3.0-20250812", "project_id": "...", "texture_quality": "detailed"}
|
||||
|
||||
⚠️ 字段是 `texture_quality`(值为 `detailed`),不是 `texture_resolution`——
|
||||
写成后者会得到 `1001 Fatal error on the server side`。
|
||||
`model_version` 用项目当前的模型版本(从项目详情读取)。
|
||||
界面上的 4K / 8K 选择**没有**出现在请求体里(实测默认档 4K 的请求体只有上面三个字段)。
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "upscale"
|
||||
HELP = "纹理放大:提升贴图分辨率"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "放大后的模型保存到该路径(可选)")
|
||||
parser.add_argument("--quality", default="detailed", help="贴图质量(默认 detailed)")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
model_version = _common.project_version(ctx.client, args.project, args.model)
|
||||
if not model_version:
|
||||
raise ValueError(f"无法读取项目 {args.project} 的 model_version,请用 -m 显式指定")
|
||||
|
||||
body = {
|
||||
"project_id": args.project,
|
||||
"model_version": model_version,
|
||||
"texture_quality": args.quality,
|
||||
}
|
||||
data = ctx.client.get_data("POST", "/v2/studio/operation/texture_upscaler", body=body)
|
||||
return _common.run_operator(ctx, data, output=args.output, wait=args.wait, label="纹理放大")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""自我更新 — tripocli update.
|
||||
|
||||
tripocli update # 从记住的源码目录重装全局 CLI
|
||||
tripocli update --from ~/Documents/AI/github/Tripo2Api
|
||||
tripocli update --check # 只查看当前状态,不安装
|
||||
tripocli update --dry-run # 只打印将要执行的命令
|
||||
|
||||
改完源码后跑一次,全局 `tripocli` 就更新到最新;用 pyenv 时自动 rehash。
|
||||
"""
|
||||
|
||||
from .. import __version__, update as updater
|
||||
|
||||
NAME = "update"
|
||||
HELP = "更新全局 CLI 到项目源码的最新版本"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
parser.add_argument("--from", dest="source", help="指定项目源码目录(会记住,之后无需再传)")
|
||||
parser.add_argument("--check", action="store_true", help="只显示源码目录与已安装版本,不执行安装")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只打印将要执行的命令")
|
||||
parser.add_argument("--no-rehash", action="store_true", help="跳过 pyenv rehash")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
source = updater.detect_source(ctx.args.source, config=ctx.config)
|
||||
|
||||
if ctx.args.check or not source:
|
||||
installed = updater.installed_version()
|
||||
info = {
|
||||
"source": str(source) if source else None,
|
||||
"running_version": __version__,
|
||||
"installed_version": installed,
|
||||
"needs_install": bool(source) and installed != __version__,
|
||||
"pyenv": updater._has_pyenv(),
|
||||
}
|
||||
if not source:
|
||||
ctx.out.error(
|
||||
"未找到项目源码目录。请指定一次:tripocli update --from <项目目录>"
|
||||
)
|
||||
return info
|
||||
ctx.out.data(info, human=lambda: (
|
||||
ctx.out.line(f"源码目录 : {info['source']}"),
|
||||
ctx.out.line(f"当前运行版本: {info['running_version']}"),
|
||||
ctx.out.line(f"已安装版本 : {info['installed_version'] or '未安装'}"),
|
||||
ctx.out.line(f"需要更新 : {'是' if info['needs_install'] else '否(已一致)'}"),
|
||||
ctx.out.line(f"pyenv : {'是(安装后会自动 rehash)' if info['pyenv'] else '否'}"),
|
||||
))
|
||||
return info
|
||||
|
||||
# Remember the source so later runs are just `tripocli update`.
|
||||
ctx.config.set(**{updater.INSTALL_SOURCE_KEY: str(source)})
|
||||
|
||||
report = updater.update(source, rehash=not ctx.args.no_rehash, dry_run=ctx.args.dry_run)
|
||||
ctx.out.data(
|
||||
report,
|
||||
human=lambda: (
|
||||
ctx.out.line("✅ 已更新全局 tripocli" if not ctx.args.dry_run else "(dry-run)"),
|
||||
*[ctx.out.line(" " + line) for line in updater.report_lines(report)],
|
||||
ctx.out.line(" 提示: 在项目目录内执行时会用项目里的代码;其它目录才用刚装的这份"),
|
||||
),
|
||||
)
|
||||
return report
|
||||
@@ -0,0 +1,81 @@
|
||||
"""智能 UV — unfold UVs for a low-poly model.
|
||||
|
||||
tripocli uv -p <project_id> --wait
|
||||
|
||||
实测流程(在页面上真操作抓到的请求):
|
||||
|
||||
POST /v2/studio/operation/uv_edit/generate
|
||||
{project_id, current_operator_id, action:"generate"} → 生成 UV 候选(新 operator)
|
||||
POST /v2/studio/operation/uv_edit/apply
|
||||
{project_id, current_operator_id, candidate_operator_id} → 保存/应用候选
|
||||
POST /v2/studio/operation/uv_edit/context
|
||||
{project_id, current_operator_id} → 查询当前处于哪一步
|
||||
|
||||
所以这是**两步握手**:先 generate 出候选,再 apply 保存。只调 generate 不 apply,
|
||||
上下文会停在「待保存」,再调 generate 就会报 `2046 UV Edit action no longer matches the context`。
|
||||
|
||||
另外服务端要求网格是低模(约 ≤8 万三角面),否则需要先 `tripocli remesh`。
|
||||
"""
|
||||
|
||||
from . import _common
|
||||
|
||||
NAME = "uv"
|
||||
HELP = "智能 UV:为低模展开 UV(需 ≤8 万三角面)"
|
||||
|
||||
|
||||
def add_arguments(parser):
|
||||
_common.add_op_options(parser, "应用 UV 后的模型保存到该路径(可选)")
|
||||
parser.add_argument("--action", default="generate", help="UV 操作动作(默认 generate)")
|
||||
parser.add_argument("--generate-only", action="store_true", help="只生成候选,不自动保存应用")
|
||||
|
||||
|
||||
def run(ctx):
|
||||
args = ctx.args
|
||||
client = ctx.client
|
||||
|
||||
operator = client.project_operator(args.project)
|
||||
current_operator_id = operator.get("operator_id")
|
||||
if not current_operator_id:
|
||||
raise ValueError(f"无法读取项目 {args.project} 的 operator_id")
|
||||
|
||||
generate_body = {
|
||||
"project_id": args.project,
|
||||
"current_operator_id": current_operator_id,
|
||||
"action": args.action,
|
||||
}
|
||||
model_version = _common.project_version(client, args.project, args.model)
|
||||
if model_version:
|
||||
generate_body["model_version"] = model_version
|
||||
|
||||
ctx.out.line(f"🚀 生成 UV 候选 (current_operator_id={current_operator_id})...")
|
||||
generated = client.get_data("POST", "/v2/studio/operation/uv_edit/generate", body=generate_body) or {}
|
||||
candidate_id = generated.get("candidate_operator_id") or generated.get("operator_id")
|
||||
if not candidate_id:
|
||||
raise RuntimeError(f"generate 未返回候选 operator: {generated}")
|
||||
|
||||
if args.wait and candidate_id:
|
||||
# The generate step produces a candidate; wait for it, but never hang on it —
|
||||
# if it does not report a terminal state we still go on to apply.
|
||||
try:
|
||||
_common.wait_and_report(ctx, candidate_id, timeout=180)
|
||||
except Exception as error:
|
||||
ctx.out.line(f"(候选未在 180s 内就绪,继续尝试保存: {error})")
|
||||
|
||||
if args.generate_only:
|
||||
ctx.out.data(
|
||||
{"generate": generated, "candidate_operator_id": candidate_id},
|
||||
human=lambda: ctx.out.line(f"✅ 已生成候选(未保存): {candidate_id}"),
|
||||
)
|
||||
return {"generate": generated, "candidate_operator_id": candidate_id}
|
||||
|
||||
apply_body = {
|
||||
"project_id": args.project,
|
||||
"current_operator_id": current_operator_id,
|
||||
"candidate_operator_id": candidate_id,
|
||||
}
|
||||
ctx.out.line("💾 应用(保存)UV 候选...")
|
||||
applied = client.get_data("POST", "/v2/studio/operation/uv_edit/apply", body=apply_body) or {}
|
||||
|
||||
return _common.run_operator(
|
||||
ctx, applied, output=args.output, wait=args.wait, label="智能 UV"
|
||||
)
|
||||
Reference in new issue
Block a user