From 977f97995a758b36fef9aa9ca55cdfa1edf27205 Mon Sep 17 00:00:00 2001 From: Mrpzx Date: Tue, 13 Oct 2026 19:22:54 +0800 Subject: [PATCH] =?UTF-8?q?tripocli=20v2.0.0=EF=BC=9ATripo=20AI=203D=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=B8=8E=E5=8A=A0=E5=B7=A5=20CLI=EF=BC=88?= =?UTF-8?q?=E9=9D=A2=E5=90=91=20Agent=20=E4=B8=8E=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E8=80=85=EF=BC=8C=E7=BA=AF=20Python=20=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=BA=93=E3=80=81=E9=9B=B6=E7=AC=AC=E4=B8=89=E6=96=B9=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖文生 3D、图生 3D、多视图、批量生成,以及重拓扑、绑定、动画、智能分割、 纹理、PBR、智能 UV、部件补全、格式导出与下载。 代码组织:一个功能一个模块(tripo/features/),入口薄壳 tripocli.py。 --- .gitignore | 21 ++ AGENTS.md | 135 ++++++++ README.md | 206 ++++++++++++ SKILL.md | 144 ++++++++ TRIPO_API_SPEC.md | 280 ++++++++++++++++ pyproject.toml | 23 ++ tripo/__init__.py | 9 + tripo/accounts.py | 132 ++++++++ tripo/auth.py | 109 +++++++ tripo/cli.py | 168 ++++++++++ tripo/client.py | 347 ++++++++++++++++++++ tripo/config.py | 47 +++ tripo/data/motions.json | 524 ++++++++++++++++++++++++++++++ tripo/errors.py | 53 +++ tripo/features/__init__.py | 81 +++++ tripo/features/_common.py | 179 ++++++++++ tripo/features/account.py | 75 +++++ tripo/features/account_mgmt.py | 189 +++++++++++ tripo/features/animate.py | 120 +++++++ tripo/features/assets.py | 36 ++ tripo/features/batch.py | 76 +++++ tripo/features/cancel.py | 42 +++ tripo/features/config_cmd.py | 48 +++ tripo/features/download.py | 30 ++ tripo/features/export_model.py | 70 ++++ tripo/features/fill_parts.py | 49 +++ tripo/features/image_assets.py | 36 ++ tripo/features/image_gen.py | 159 +++++++++ tripo/features/image_multiview.py | 58 ++++ tripo/features/image_to_3d.py | 29 ++ tripo/features/import_model.py | 53 +++ tripo/features/inspect_model.py | 53 +++ tripo/features/mesh_edit.py | 51 +++ tripo/features/motion.py | 151 +++++++++ tripo/features/multiview.py | 51 +++ tripo/features/optimize_model.py | 185 +++++++++++ tripo/features/pbr.py | 32 ++ tripo/features/project.py | 30 ++ tripo/features/remesh.py | 36 ++ tripo/features/rename.py | 25 ++ tripo/features/rigging.py | 80 +++++ tripo/features/segmentation.py | 42 +++ tripo/features/serve.py | 57 ++++ tripo/features/stylize.py | 28 ++ tripo/features/task.py | 67 ++++ tripo/features/text_to_3d.py | 24 ++ tripo/features/texture.py | 42 +++ tripo/features/texture_upscale.py | 39 +++ tripo/features/update.py | 62 ++++ tripo/features/uv.py | 81 +++++ tripo/glb.py | 167 ++++++++++ tripo/motions.py | 44 +++ tripo/output.py | 46 +++ tripo/s3.py | 82 +++++ tripo/server.py | 253 +++++++++++++++ tripo/update.py | 128 ++++++++ tripo/versions.py | 42 +++ tripo/web/accounts.html | 178 ++++++++++ tripocli | 1 + tripocli.py | 17 + 60 files changed, 5622 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 TRIPO_API_SPEC.md create mode 100644 pyproject.toml create mode 100644 tripo/__init__.py create mode 100644 tripo/accounts.py create mode 100644 tripo/auth.py create mode 100644 tripo/cli.py create mode 100644 tripo/client.py create mode 100644 tripo/config.py create mode 100644 tripo/data/motions.json create mode 100644 tripo/errors.py create mode 100644 tripo/features/__init__.py create mode 100644 tripo/features/_common.py create mode 100644 tripo/features/account.py create mode 100644 tripo/features/account_mgmt.py create mode 100644 tripo/features/animate.py create mode 100644 tripo/features/assets.py create mode 100644 tripo/features/batch.py create mode 100644 tripo/features/cancel.py create mode 100644 tripo/features/config_cmd.py create mode 100644 tripo/features/download.py create mode 100644 tripo/features/export_model.py create mode 100644 tripo/features/fill_parts.py create mode 100644 tripo/features/image_assets.py create mode 100644 tripo/features/image_gen.py create mode 100644 tripo/features/image_multiview.py create mode 100644 tripo/features/image_to_3d.py create mode 100644 tripo/features/import_model.py create mode 100644 tripo/features/inspect_model.py create mode 100644 tripo/features/mesh_edit.py create mode 100644 tripo/features/motion.py create mode 100644 tripo/features/multiview.py create mode 100644 tripo/features/optimize_model.py create mode 100644 tripo/features/pbr.py create mode 100644 tripo/features/project.py create mode 100644 tripo/features/remesh.py create mode 100644 tripo/features/rename.py create mode 100644 tripo/features/rigging.py create mode 100644 tripo/features/segmentation.py create mode 100644 tripo/features/serve.py create mode 100644 tripo/features/stylize.py create mode 100644 tripo/features/task.py create mode 100644 tripo/features/text_to_3d.py create mode 100644 tripo/features/texture.py create mode 100644 tripo/features/texture_upscale.py create mode 100644 tripo/features/update.py create mode 100644 tripo/features/uv.py create mode 100644 tripo/glb.py create mode 100644 tripo/motions.py create mode 100644 tripo/output.py create mode 100644 tripo/s3.py create mode 100644 tripo/server.py create mode 100644 tripo/update.py create mode 100644 tripo/versions.py create mode 100644 tripo/web/accounts.html create mode 120000 tripocli create mode 100755 tripocli.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a66378 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +venv/ + +# 生成的 3D 资产(体积大,按需重新生成) +*.glb +*.fbx +*.obj +*.stl +*.usdz +*.3mf + +# 本地凭据/设置(正常不存在于项目内,双保险) +.tripo/ +config.json +accounts.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..53653e9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,135 @@ +# AGENTS.md + +给接手这个项目的 AI Agent / 开发者看的说明。**要做任何事之前先读完本文件。** + +--- + +## 1. 这是什么 + +`tripocli` —— 把 Tripo AI 的 3D 生成与加工能力封装成命令行工具,供 Agent 与开发者调用。 +纯 Python 3 标准库,**零第三方依赖**。 + +覆盖能力:文生 3D、图生 3D、多视图、批量生成,以及重拓扑、绑定、动画、智能分割、纹理、PBR、 +智能 UV、部件补全、格式导出与下载。 + +## 2. 代码结构(重要约定) + +**一个功能 = 一个文件。** 新增能力不要往单个大文件里塞。 + +``` +tripocli.py # 入口薄壳(只有十几行) +tripo/ +├── cli.py # argparse 装配 + 分发 + 错误处理 +├── client.py # HTTP:请求头、重试、凭据解析、轮询、下载 +├── auth.py # cookie → JWT(纯 HTTP) +├── accounts.py # 多账号凭据存储 +├── config.py # 全局设置(API 地址 / device id) +├── s3.py # SigV4 直传 +├── server.py + web/ # tripocli serve(本地账号管理页) +├── update.py # 自我更新(tripocli update) +└── features/ # 每个功能一个模块 +``` + +新增一个命令的标准做法: + +1. 在 `tripo/features/` 下新建 `xxx.py`; +2. 模块里定义 `NAME`、`HELP`、`add_arguments(parser)`、`run(ctx)`(多子命令的模块改用 `register(subparsers)`); +3. 在 `tripo/features/__init__.py` 的 import 和 `MODULES` 列表里各加一行; +4. 用真实请求验证后再交付。 + +## 3. 安装 + +```bash +cd <项目目录> +pip install . # 装成全局命令 +pyenv rehash # ← pyenv 用户必须执行,否则报 tripocli: command not found +``` + +不想安装也可以直接用项目里的入口:`<项目目录>/tripocli --version`。 + +> 若 `pip install .` 报 `invalid command 'bdist_wheel'`,说明环境缺打包后端,执行 `pip install wheel` 后重试。 +> (`tripocli` 本体零依赖,这只影响安装过程。) + +## 4. 更新(本节是「更新怎么做」的唯一依据) + +**首选:一条命令。** + +```bash +tripocli update # 从记住的源码目录重装全局 CLI(pyenv 会自动 rehash) +tripocli update --check # 只看状态:源码目录 / 已安装版本 / 是否需要更新 +tripocli update --dry-run # 只打印将执行的命令 +tripocli update --from <项目目录> # 首次指定源码目录(会记住,之后不用再传) +``` + +改完源码后跑一次 `tripocli update`,全局命令就更新到最新。 +源码目录首次自动识别(运行 `tripocli` 时所处的项目目录),并记录在 +`~/.tripo/config.json` 的 `install_source`。 + +### 4.1 手工方式(等价,update 不可用时) + +```bash +cd <项目目录> +git pull # 若有 git 仓库 +pip install . && pyenv rehash # 重新安装 +``` + +只拿到新目录/压缩包(未用 git)时:用新目录**整体覆盖**旧目录,再执行上面的安装命令; +如果一直是「直接用项目里的入口」的方式,覆盖完即生效,无需安装。 + +### 4.2 更新后必须做的验证 + +```bash +tripocli --version # 版本号是否为预期 +tripocli account list # 凭据是否仍在(在 ~/.tripo/accounts.json,更新代码不会动它) +tripocli balance # 能否正常调通接口 +``` + +### 4.3 两个容易踩的坑 + +- **副本优先级**:在项目目录内执行 `tripocli` 会优先用**项目里的代码**(cwd 优先); + 在其它目录执行才用 `pip` 装的那份。所以「更新后行为没变」多半是在项目目录里跑到了旧副本,或没重新安装。 +- **pyenv rehash**:装/卸命令后不 `rehash`,pyenv 的 shim 找不到新命令(`tripocli update` 会自动做)。 + +## 5. 凭据 + +凭据在 `~/.tripo/accounts.json`(权限 600),与代码分离,**更新代码不会丢失**。 +只需一次浏览器 cookie,CLI 自行续期 15 分钟的 JWT(cookie 约 30 天有效)。 + +```bash +tripocli account add --cookie "ory_kratos_session=..." +tripocli account list +tripocli serve --open # 网页版账号管理 +``` + +多账号时默认会在「当前账号额度不足」时自动切换下一个账号重试;如需关闭,传 `--no-fallback`(或设 `TRIPO_AUTO_FALLBACK=0`)。 + +**永远不要把 cookie / token 写进代码、文档或提交记录。** + +## 6. 验证改动(真实调用,不要只做静态检查) + +```bash +tripocli --json balance # 最快连通性检查 +tripocli --json text -p "a wooden crate" --smart-mesh --wait -o /tmp/t.glb +tripocli --json project # 拿 model_url +``` + +注意:后处理算子(remesh/rig/pbr/texture/uv…)会为项目新建 operator, +**同一项目必须等上一个算子 `success`** 才能提交下一个,否则报 `2021`。 + +## 7. 构建游戏场景与 3D 应用的性能与工程红线(必读) + +当用户要求用 `tripocli` 生成资产并搭建游戏场景(Web Three.js / Godot / Unity)时,必须遵守以下已验证规范: + +1. **严禁把未减面的 200 万面默认高模直接批量塞进场景**: + - 生成场景建筑/树木/道具时优先加 `--face-limit 15000` 或 `--smart-mesh` 从源头控制面数; + - 对已下载的高模做本地减面时,**不要只跑 `gltf-transform optimize`**(其默认 `--simplify-error 0.0001` 过严,几乎不减面,只做 Draco 压缩,解压后显存里仍是七八十万面/个);必须显式执行 `npx gltf-transform simplify in.glb tmp.glb --ratio 0.025 --error 0.06 && npx gltf-transform draco tmp.glb out.glb`(角色蒙皮网格用 `--ratio 0.12 --error 0.02`)。 +2. **严禁在 `normalize` 后用 `position.set(x, 0, z)` 覆盖 `position.y`**: + - Tripo 模型原点在几何中心,必须用外层 `Group` 包装后再设 `(x, groundY, z)`,否则所有建筑和树木会半截埋入地下。 +3. **骨骼动画**:`animate` 产出的 GLB 在 `project.operator.retarget[]` 中,且只含骨骼轨道不含 Mesh,需与 `rig` 本体模型合并播放;测量 `SkinnedMesh` 高度时必须用 `isBone` 的世界坐标而非默认 `Box3.setFromObject`。 +4. **文生图额度保护**:`tripocli img` 默认 `--amount 1`(一次一张),除非用户明确要求多张,否则不要调高。 + +## 8. 建议的下一步(尚未做,需项目所有者决定) + +1. `git init` + 首次提交,推到私有仓库 → 更新变成 `git pull`; +2. 需要对外分发时发布到 PyPI → 别人可用 `pip install -U tripocli`(当前该名字在 PyPI 上未被占用); +3. 接口契约见 `TRIPO_API_SPEC.md`;命令说明见 `README.md`。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..61f4155 --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# tripocli + +面向 AI Agent 与开发者的 Tripo AI 3D 生成命令行工具。纯 Python 3 标准库实现,零第三方依赖。 + +支持:文生图、文生 3D、单图/多视角/批量图生 3D、模型导入,以及重拓扑、骨骼绑定、动作预设重定向、文字生成动作、智能分割、3D 风格化、纹理生成与放大、PBR、智能 UV、部件补全、网格编辑、格式转换导出与多账号管理。 + +--- + +## 一、安装与更新 + +### 1. 安装为全局命令(推荐) + +```bash +cd <项目目录> +pip install . +pyenv rehash # 使用 pyenv 时执行一次以刷新命令路径 +tripocli --version +``` + +### 2. 免安装直接运行 + +无需 `pip`,直接运行仓库内的入口脚本: + +```bash +<项目目录>/tripocli --version +``` + +### 3. 更新 + +修改或拉取新源码后,一条命令重装全局 CLI: + +```bash +tripocli update # 从项目源码更新全局命令 +tripocli update --check # 查看当前源码路径与版本状态 +``` + +--- + +## 二、账号配置 + +只需提供一次浏览器 Cookie(`ory_kratos_session` 约 30 天有效),CLI 会在每次调用时自动换取并续期 JWT,日常运行无需打开浏览器。 + +Cookie 获取方式:浏览器登录 `studio.tripo3d.ai` → DevTools (`F12`) → Network → 任意 `api.tripo3d.ai` 请求 → 复制 Request Headers 中的 `Cookie`。 + +```bash +# 添加账号(交互式隐藏粘贴或命令行直接传入) +tripocli account add +tripocli account add --name work --cookie "ory_kratos_session=..." +tripocli account add --cookie-file cookie.txt + +# 多账号查看与切换 +tripocli account list +tripocli account use work +tripocli account show work +tripocli account remove work + +# 单条命令指定账号(多账号默认在额度不足时自动切换,可用 --no-fallback 关闭) +tripocli --account work balance +tripocli --no-fallback text -p "a wooden crate" --wait -o crate.glb + +# 本地网页版账号管理(可视化添加/校验 Cookie、查看实时积分与到期时间) +tripocli serve --open # 默认监听 http://127.0.0.1:8765/ + +# 无配置文件场景(服务器 / CI 环境变量) +export TRIPO_COOKIE="ory_kratos_session=..." +``` + +> 账号凭据保存在 `~/.tripo/accounts.json`(权限 `600`),更新代码不会影响已配置的账号。 + +--- + +## 三、命令速查 + +所有命令均支持 `--json`(可放在子命令前或后)输出标准 JSON,便于脚本与 Agent 解析。 + +### 1. 游戏角色与资产标准流程 + +```bash +# 1) 文生图:生成参考图(默认 --amount 1 每次 1 张;人形角色建议加 --t-pose) +tripocli img -p "full-body T-pose anime female knight, white background" --t-pose --wait -o ./refs + +# 2) 图生 3D(建议加 --face-limit 控制面数,或加 --smart-mesh 生成四边面低模) +tripocli image -i ./refs/image_0.png --face-limit 25000 --wait -o hero_mesh.glb + +# 3) 绑定骨骼(支持 mixamo / actorcore / unreal / unity / vrm 五种预设) +tripocli rig -p --type biped --spec mixamo --wait -o hero_rigged.glb + +# 4) 动作预设重定向(97 种人形动作预设) +tripocli animate --list +tripocli animate -p --preset idle --wait -o anim_idle.glb +tripocli animate -p --preset walk --wait -o anim_walk.glb +tripocli animate -p --preset run --wait -o anim_run.glb +tripocli animate -p --preset slash --wait -o anim_slash.glb + +# 5) 文字生成动作(AI 动画):一句话生成自定义动作并套用到角色 +tripocli motion -p "让角色挥手打招呼" --duration 5 --project --wait -o anim_wave.glb +``` + +### 2. 账户与资产查询 + +```bash +tripocli balance # 积分余额与会员状态 +tripocli whoami # 当前身份与 Token 有效期 +tripocli quota # 免费试用额度 +tripocli assets --size 10 # 列出已生成的 3D 模型项目 +tripocli imgs --size 10 # 列出已生成的参考图资产 +tripocli project # 查看项目详情、模型直链与已重定向的动作列表 +``` + +### 3. 生成命令 + +```bash +# 文生图(支持模型: nano-banana-2 / nano-banana-pro / gpt-image-2 / gpt-image-2.5 / midjourney) +tripocli img -p "a stylized treasure chest, game asset" --amount 1 --scale 1:1 --resolution 2K --wait -o ./refs +tripocli img --templates # 查看官方提示词模板 + +# 单图转 4 视角参考图 +tripocli imgmv -i ./refs/image_0.png --wait -o ./views + +# 文生 3D +tripocli text -p "medieval house" --face-limit 15000 --wait -o house.glb +tripocli text -p "wooden barrel" --smart-mesh --wait -o barrel.glb + +# 单图生 3D / 多视角生 3D / 批量图生 3D +tripocli image -i ./photo.png --face-limit 15000 --wait -o model.glb +tripocli multiview --front f.png --left l.png --right r.png --back b.png --wait -o mv.glb +tripocli batch -i a.png,b.png,c.png --face-limit 15000 --wait -o ./out + +# 导入本地模型(glb/obj/fbx/stl ≤150MB)转为云端项目以便继续绑定/后处理 +tripocli import -i ./my_model.glb --wait -o imported.glb +``` + +**生成类通用参数**: +- `-m/--model`:`P2.0` / `P1.0`(智能网格)、`H3.1` / `v3.0` / `v2.5`(高精度) +- `--smart-mesh`:开启智能网格模式(四边面低模,默认 25,000 面) +- `--face-limit `:面数上限(高精度默认 2,000,000;**用于实时游戏场景时建议设为 `10000`~`25000`**) +- `--quad`:输出四边面拓扑 +- `--count 1|2|4`:一次生成候选数量(默认 1) +- `--t-pose`:人形角色输出标准 T-Pose +- `--no-texture` / `--no-pbr` / `--no-delight`:关闭贴图、PBR 或去光处理 +- `--texture-quality standard|detailed`、`--geometry-quality standard|detailed` + +### 4. 后处理、导出与任务管理 + +```bash +# 拓扑与网格处理 +tripocli remesh -p --face-limit 10000 --wait -o low.glb +tripocli segment -p --wait -o segmented.glb +tripocli fill -p --parts all --wait -o filled.glb +tripocli edit -p --bbox "-1,-1,-1,1,1,1" --face-limit 8000 --wait +tripocli uv -p --wait -o uv.glb + +# 材质与风格化 +tripocli texture -p --resolution 2K --wait -o tex.glb +tripocli upscale -p --wait -o upscaled.glb +tripocli pbr -p --wait -o pbr.glb +tripocli stylize -p --style clay --wait -o clay.glb + +# 骨骼与动画 +tripocli rig -p --type biped --spec mixamo --wait -o rigged.glb +tripocli rig -p --check-only +tripocli animate -p --preset walk --wait -o anim_walk.glb +tripocli motion --list + +# 格式转换导出(支持 glb / fbx / obj / stl / usdz / 3mf)与下载 +tripocli export -p -f glb --wait -o model.glb +tripocli download --operator -o model.glb + +# 本地游戏资产体检与减面压缩(纯本地运行,0 积分) +tripocli inspect ./assets/*.glb # 查看面数、包围盒、骨骼、动画名与告警 +tripocli optimize house.glb -o house_low.glb --faces 15000 # 保 UV 真减面 + Draco 压缩 +tripocli optimize hero.glb --in-place --faces 45000 # 带骨骼角色原地减面(自动保护五官与关节) +tripocli optimize house.glb --lods # 一键生成 lod0 / lod1 / lod2 三级细节模型 + +# 任务进度、取消与项目重命名 +tripocli task status +tripocli task wait -p -o out.glb +tripocli cancel --operator --type remesh +tripocli rename -p --name "My Hero" +``` + +--- + +## 四、使用须知与游戏场景建议 + +1. **同一项目的后处理需串行执行**: + 后处理算子(`remesh` / `rig` / `pbr` / `texture` / `uv` 等)必须等该项目上一个任务状态变为 `success` 后才能提交下一个,否则服务端会返回 `2021`。建议直接加上 `--wait`。 +2. **游戏场景务必控制面数(防卡顿)**: + - 高精度模式默认上限为 200 万面(单个建筑/树木可达 70 万~90 万三角面),直接在游戏场景中批量摆放会导致严重卡顿。 + - **生成时控面**:生成建筑、树木、道具时建议直接传 `--face-limit 15000` 或 `--smart-mesh`。 + - **已下载高模本地减面与压缩(0 积分)**:直接运行内置的 `tripocli inspect` 与 `tripocli optimize`(底层自动为静态模型与骨骼角色匹配最佳误差容忍度并执行 Draco 压缩): + ```bash + tripocli inspect ./assets/*.glb + tripocli optimize in.glb -o out.glb --faces 15000 + tripocli optimize in.glb --lods + ``` +3. **角色本体与动作文件合并加载**: + - `tripocli rig` 导出的 GLB 包含完整角色网格与骨骼(`SkinnedMesh`); + - `tripocli animate` 导出的 GLB **仅包含 65 根骨骼的动画关键帧轨道(约 100~500KB),不含网格**。在 Three.js / Godot / Unity 中加载 1 份 `rig` 本体模型,再将多个 `animate` 导出的动画轨道挂载到同一角色的动画控制器上即可切换动作。 +4. **模型原点与格式转换**: + - Tripo 导出的 GLB 原点位于模型**几何中心**(而非底部),在引擎中摆放并缩放后,需按包围盒最低点(`-box.min.y`)用外层容器对齐地面,避免在设置 `(x, 0, z)` 坐标时把模型下半截埋入地下。 + - 部分旧项目或经过特定后处理的项目默认直链为 `.fbx`,如需在网页或引擎中按 `.glb` 加载,执行 `tripocli export -p -f glb --wait -o out.glb` 即可。 + +--- + +底层 HTTP 接口协议与字段契约详见 [TRIPO_API_SPEC.md](./TRIPO_API_SPEC.md),代码结构与维护说明详见 [AGENTS.md](./AGENTS.md)。 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..ea5b319 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,144 @@ +--- +name: tripo-3d +description: 用 Tripo AI 生成与加工 3D 模型资产:文生3D、图生3D、多视图、批量生成,以及重拓扑、绑定、动画、分割、纹理、PBR、UV、格式导出与下载。适用于需要 3D 模型、角色、道具、建筑或格式转换的场景。 +--- + +# Tripo 3D 生成与加工技能 + +当用户需要生成 3D 模型、角色、道具、建筑,或对已有模型做重拓扑/绑定/动画/贴图/导出时使用本技能。 + +## 调用方式 + +本技能对应命令行工具 `tripocli`。按环境选择其一: + +```bash +tripocli --version # 1) 已安装到 PATH(推荐) +<项目目录>/tripocli --version # 2) 直接用仓库里的入口(零依赖,无需安装) +``` + +若尚未安装,进到项目目录后安装(纯标准库,无第三方依赖): + +```bash +cd <项目目录> +pip install . # 用 pyenv 的话,装完执行一次 pyenv rehash +``` + +所有命令都支持 `--json`(放命令前或后都可以),Agent 调用时建议加上。 + +## 凭据(只需一次) + +CLI 需要 Tripo 的 session cookie 才能换 JWT。**不需要浏览器常驻**:cookie 约 30 天有效, +CLI 每次调用会用 cookie 自动续期 15 分钟的 JWT。 + +```bash +tripocli account add --cookie "ory_kratos_session=..." # 添加账号(粘贴浏览器 cookie) +tripocli account list # 多账号查看/切换 +tripocli serve --open # 网页版账号管理(看积分/有效期) +# 也可用环境变量:export TRIPO_COOKIE="ory_kratos_session=..." +``` + +cookie 获取:浏览器 DevTools → Network → 任意 `api.tripo3d.ai` 请求 → Request Headers → 整条 `Cookie`。 +若命令报 `no-credentials`,按上面的 `account add` 提供 cookie 即可。 + +## 常用命令 + +```bash +# 账户 +tripocli --json balance +tripocli --json quota +tripocli --json assets --size 10 + +# 文生图(参考图生成,默认 --amount 1 每次只出 1 张以节省额度;人形角色建议加 --t-pose) +tripocli --json img -p "<提示词>" --amount 1 --t-pose --wait -o /abs/path/refs +tripocli --json imgs --size 5 +tripocli --json imgmv -i /abs/path/ref.png --wait -o /abs/path/views + +# 文生 3D(⚠️ 游戏场景务必加 --face-limit 控制面数或用 --smart-mesh,避免默认 200 万面撑爆显存!) +tripocli --json text -p "<提示词>" --face-limit 15000 --wait -o /abs/path/out.glb +tripocli --json text -p "<提示词>" --smart-mesh --face-limit 20000 --wait -o /abs/path/out.glb + +# 图生 3D / 导入模型 +tripocli --json image -i /abs/path/photo.png --face-limit 25000 --wait -o /abs/path/out.glb +tripocli --json import -i /abs/path/my_model.glb --wait -o /abs/path/normalized.glb + +# 多视角(必须 4 张:前/左/右/后) +tripocli --json multiview --front f.png --left l.png --right r.png --back b.png --wait -o /abs/path/mv.glb + +# 批量图生 3D(多结果,-o 传目录) +tripocli --json batch -i a.png,b.png --wait -o /abs/path/outdir + +# 后处理(作用于已有项目,需要 project_id) +tripocli --json remesh -p --face-limit 10000 --wait -o low.glb +tripocli --json rig -p --type biped --spec mixamo --wait -o hero_rigged.glb +tripocli --json animate --list +tripocli --json animate -p --preset idle --wait -o anim_idle.glb +tripocli --json motion -p "让角色挥手打招呼" --duration 5 --project --wait -o anim_wave.glb +tripocli --json segment -p --wait +tripocli --json stylize -p --style clay --wait +tripocli --json texture -p --resolution 2K --wait +tripocli --json pbr -p --wait +tripocli --json upscale -p --wait +tripocli --json uv -p --wait +tripocli --json fill -p --parts all --wait +tripocli --json edit -p --bbox "-1,-1,-1,1,1,1" --face-limit 8000 --wait + +# 导出 / 下载 / 本地体检与减面 / 查询 / 取消 / 重命名 +tripocli --json export -p -f glb --wait -o model.glb +tripocli --json download --operator -o model.glb +tripocli --json inspect /abs/path/model.glb +tripocli --json optimize /abs/path/in.glb -o /abs/path/out.glb --faces 15000 +tripocli --json optimize /abs/path/in.glb --lods +tripocli --json task status +tripocli --json project +tripocli --json cancel --operator --type remesh +tripocli --json rename -p --name "New Name" +``` + +## 生成类通用参数 + +- `-m/--model`:`P2.0` / `P1.0`(智能网格)、`H3.1` / `v3.0` / `v2.5`(高精度) +- `--face-limit`:面数上限(智能网格默认 25000,高精度默认 2000000) +- `--quad`:四边面(智能网格默认开启) +- `--count 1|2|4`:候选数量 +- `--no-texture` / `--no-pbr` / `--no-delight` +- `--texture-quality standard|detailed`、`--geometry-quality standard|detailed` +- `--t-pose`、`--visibility shareable|private` + +## 重要约束 + +1. **后处理算子会新建 operator**:同一项目必须等上一个算子 `success` 后才能做下一个,否则报 + `2021 the status of task is not success`。用 `--wait`,或换项目。 +2. **图片自动走「上传 S3 + 审核」**:`image` / `multiview` / `batch` 内部已处理,无需手工调用审核接口。 +3. **智能 UV 需要低模**:模型须先 `remesh` 到 ≤8 万三角面才能使用。 +4. 生成的模型 URL 带签名且有时效,需要长期保存时用 `--wait -o` 落盘。 + +## 游戏与 3D 大场景构建必读规范(防卡顿 / 防陷地 / 骨骼动画) + +当用 `tripocli` 生成多个资产组装游戏场景(Three.js / Godot / Unity / Unreal)时,**必须遵守以下 5 条铁律**: + +1. **严控面数(防止 6000 万面撑爆显存卡顿)**: + - 高精度模式(`v3.1`)默认 `face_limit=2,000,000`,单个建筑/树木可达 **70 万~95 万三角面**!若在主城里克隆几十份,总面数会突破 **6,000 万面** 导致严重掉帧。 + - **源头控面(首选,省积分)**:生成建筑/树木/道具时直接传 `--face-limit 15000`(或加 `--smart-mesh` 走 P2.0 智能网格四边面低模)。 + - **本地真减面(已有高模救火 / 生成多级 LOD,0 积分秒级完成)**: + 入场前先用 `tripocli inspect *.glb` 检查面数,再用内置的 `tripocli optimize` 一键减面压缩(内部已自动区分静态模型 `--error 0.06` 与骨骼角色 `--error 0.02`,避免直接调 `gltf-transform optimize` 时默认 `--simplify-error 0.0001` 导致只压缩不减面的坑): + ```bash + tripocli inspect /abs/path/assets/*.glb + tripocli optimize in.glb -o out.glb --faces 15000 + tripocli optimize in.glb --lods + ``` +2. **模型接地封装(防止建筑/树木半截埋入地下)**: + - Tripo 导出的 GLB 模型原点位于**几何中心**(`y ∈ [-H/2, +H/2]`),不在底部。 + - 若直接修改模型自身的 `obj.position.y -= box.min.y` 后又在外部调用 `obj.position.set(x, 0, z)`,会把 `y` 重置回 `0`,导致所有房屋、城门、树木**整整下半截 50% 埋进地里**! + - **正确做法**:将缩放并偏移好底部高度(`rawObj.position.y = -box.min.y`)的模型包裹进一个外层 `const wrap = new THREE.Group()`,使 `wrap` 的 `(0,0,0)` 严格位于模型底部中心,外部只移动 `wrap.position.set(x, groundY, z)`。 +3. **骨骼动画加载(本体 Mesh + 纯骨骼轨道分离架构)**: + - `tripocli rig` 产出的 GLB 含完整网格与 65 根骨骼(`SkinnedMesh`); + - `tripocli animate`(`retarget_model`)产出的 GLB **仅含 65 根骨骼的动画轨道(约 100~500KB),不含任何 Mesh**! + - 在引擎中应以 `rig` 导出的 `hero.glb` 作为渲染网格,再加载各个 `anim_*.glb` 提取其中的 `gltf.animations` 挂到同一个 `AnimationMixer(heroModel)` 上播放。 +4. **带骨骼角色(SkinnedMesh)的包围盒测量**: + - Three.js 的 `Box3.setFromObject()` 对 `SkinnedMesh` 默认按绑定初始网格算,不含骨骼根变换,会导致算出的角色高度翻倍或悬空。应遍历 `o.isBone` 的骨骼世界坐标 `o.getWorldPosition()` 计算角色真实高度与脚底 `min.y`。 +5. **渲染管线与阴影减负**: + - 场景中有上百个建筑/道具时,只让主要建筑和角色开启 `castShadow = true`,**关闭复杂模型之间的 `receiveShadow`**(仅地面、广场、台阶开启 `receiveShadow = true`),并将高分屏像素比限制在 `Math.min(devicePixelRatio, 1.5)`,可让 WebGL 稳定跑满 **120 FPS**。 + +## 详细文档 + +命令全集与接口契约见 [README.md](./README.md) 与 [TRIPO_API_SPEC.md](./TRIPO_API_SPEC.md)。 diff --git a/TRIPO_API_SPEC.md b/TRIPO_API_SPEC.md new file mode 100644 index 0000000..370f4e6 --- /dev/null +++ b/TRIPO_API_SPEC.md @@ -0,0 +1,280 @@ +# Tripo API 规范(实测契约) + +本文档记录 `tripocli` 在真实生产环境(studio.tripo3d.ai / api.tripo3d.ai)**实测验证过**的接口契约, +供接入方与其它 Agent 参考。带 ⚠️ 的都是踩过的坑,按实测结果书写,不是推测。 + +--- + +## 一、鉴权 + +### 1. 请求头(业务接口必带) +```http +Accept: application/json +Content-Type: application/json +Origin: https://studio.tripo3d.ai +Referer: https://studio.tripo3d.ai/ +Authorization: Bearer +x-tripo-device-id: +``` +⚠️ **缺少 `Origin`/`Referer` 会返回 `403 code=1017 Access denied: invalid domain`**(实测)。 + +### 2. Cookie 换 JWT +``` +GET /v2/studio/studio/whoami?tokenizeAs=default_jwt +``` +- 鉴权:**必须用 Cookie**(`ory_kratos_session`)。⚠️ 实测**用 Bearer JWT 调这个接口会 401**。 +- 响应关键字段:`tokenized`(即后续使用的 JWT,**有效期约 15 分钟**)、`expires_at`(会话到期)、`id`、`identity.traits.email`。 +- Cookie `ory_kratos_session` 有效期约 **30 天**。 +- ⚠️ 短时间内高频调用该接口偶发瞬时 `401`,需重试。 + +--- + +## 二、账户 + +| 功能 | 方法与路径 | 说明 | +|---|---|---| +| 积分/会员 | `GET /v2/studio/user/profile/payment` | `data.wallet.total_credit`、`expiring_credit`、`expiring_date`;`data.member.type`、`valid_until` | +| 试用额度 | `POST /v2/studio/operation/quota`(body `{}`) | `data.quota.generation.*`、`data.quota.uv_edit.*` | +| 资产列表 | `GET /v2/studio/assets/v2` | ⚠️ 需要 `asset_type=mine`(实测 `model`/`personal`/`team` 等都报 `invalid asset type`)+ `type=model` | + +--- + +## 三、生成接口 + +三者返回结构一致:`data.{task_id, project_id, operator_id, is_nsfw}`。 + +### 1. 文生 3D +``` +POST /v2/studio/operation/text_to_model +``` +| 字段 | 类型 | 说明 | +|---|---|---| +| `prompt` | string | 必填 | +| `model_version` | string | 高精度 `v3.1-20260211` / `v3.0-20250812` / `v2.5-20250123`;智能网格 `Nexus-v2.0-20260801`(P2.0,实测可用)/ `Nexus-v1.0-20260214` | +| `face_limit` | int | 高精度默认 2000000;智能网格常用 25000 | +| `quad` | bool | 智能网格一般 true | +| `texture` / `pbr` / `delight` | bool | 默认 true | +| `texture_quality` | string | `standard` / `detailed` | +| `texture_alignment` | string | `original_image` | +| `geometry_quality` | string | `detailed` | +| `smart_poly` | bool | 智能网格模式 true | +| `t_pose` | bool | 人形角色 T-Pose | +| `visibility` | string | `shareable` / `private` | +| `model_count` | int | 1 / 2 / 4(⚠️ 多候选时 `project.model_url` 只给一个) | + +### 2. 图生 3D(⚠️ 必须带审核结果) +``` +POST /v2/studio/storage/temporary_token # body: {"client":"aws","format":"png|jpg|webp"} + → sts_ak / sts_sk / session_token / host / resource_bucket / resource_uri +PUT https://{bucket}.{host}/{resource_uri} # AWS SigV4 直传(region us-west-2) +POST /v2/studio/audit/image # body: {"image": {"bucket","key"}} → {"data":{"result":"pass"}} +POST /v2/studio/operation/image_to_model +``` +`image` 对象**必须**包含审核结果与来源,否则一律返回 `11002 Image audit result not found`: +```json +{ + "image": { + "bucket": "tripo-data", + "key": "tripo-studio///input.webp", + "image_audit_result": "pass", + "image_source": "upload" + }, + "model_version": "v3.1-20260211", + "face_limit": 2000000, + "quad": false, + "texture": true, + "pbr": true, + "delight": true, + "texture_alignment": "original_image", + "texture_quality": "detailed", + "geometry_quality": "detailed", + "smart_poly": false, + "visibility": "shareable" +} +``` + +### 3. 多视角图生 3D +``` +POST /v2/studio/operation/multiview_to_model +``` +⚠️ `image` 必须是**恰好 4 个**已审核图片对象的数组(前/左/右/后);少于 4 个返回 `1004 Invalid parameter(s)`,未审核返回 `11002`。 + +### 4. 导入自己的模型(上传 glb/obj/fbx/stl) + +``` +POST /v2/studio/storage/temporary_token # body: {"client":"aws","format":"glb|obj|fbx|stl"} +PUT https://{bucket}.{host}/{resource_uri} # AWS SigV4 直传(同图片) +POST /v2/studio/operation/import_user_model +``` +⚠️ body 是**嵌套**的,`model` 里放 S3 对象(实测): +```json +{ + "format": "glb", + "model": {"bucket": "tripo-data", "key": "tripo-studio///input.glb"}, + "name": "my_model.glb", + "transform_matrix": [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1], + "use_original_uv": true +} +``` +→ `data.{project_id, operator_id}`(随后可用 progress 轮询;实测导入成功后可下载)。 + +⚠️ **`transform_matrix`** 是 4x4 矩阵(前端在导入对话框里让用户转模型用的「找到最佳角度」),CLI 用单位矩阵即可。 +⚠️ 导入会**丢弃自定义材质与多层节点层级**(界面明确提示);个别模型(如 Tripo 自己导出的带材质 glb)服务端处理会失败(`9104` / `Internal algorithm error`),换标准 obj/glb 即可。 + +### 5. 部件名(`part_name_list`)来自模型文件,不在接口里 + +`remesh` / `pbr` / `texture_model` 的 `part_name_list` 取的是**模型里的节点名**: +未拆分为单个 `tripo_node_`,智能分割后为每个部件一个 `tripo_part_`。 +⚠️ 项目详情接口**不返回**部件名,前端是**下载 GLB 后解析 `nodes[].name`** 得到的; +`tripocli` 用同样办法(纯标准库解析 GLB 的 JSON chunk),读不到时回退到 `tripo_node_`。 + +### 6. 批量图生 3D + +``` +POST /v2/studio/operation/batch_image_to_model +``` +⚠️ 字段名是 **`image`**(数组,不是 `images`,写错返回 `type mismatch for field "image"`);响应 `data` 是**数组**,每项 `{project_id, operator_id}`。 + +--- + +## 四、任务与项目 + +| 功能 | 方法与路径 | +|---|---| +| 进度轮询 | `POST /v2/studio/progress`,body `{"ids":[""]}` → `[{status, progress, left_time, point_cloud_model_url}]`,`status ∈ running/success/failed` | +| 项目详情 | `GET /v2/studio/project/detail/v3/?locale=zh-CN` → `data.model_url`、`cover_image`、`operator.{operator_id, status, model_version, is_textured, ...}` | + +⚠️ 轮询偶发裸 `502`,需重试而非中断。 + +--- + +## 四、文生图(参考图生成) + +``` +POST /v2/studio/image/gen_image_v2 +{ + "if_upload": true, "sketch_to_render": false, "t_pose": false, + "amount": 4, // 1-4 + "model_version": "gemini_3.1_flash_image_preview", + "prompt": "a cute cartoon wooden barrel", + "resolution": "1K", // 实测 1K/2K/4K 有效,8K 报 400 + "scale": "1:1" // 1:1 16:9 9:16 4:3 3:4 +} + → {"task_id": ..., "asset_id": ..., "remaining_free_count": 96} + +POST /v2/studio/image/get_image_asset {"asset_id": "..."} + → {status, type:"generate_image", output:{data:[{bucket,key,url}]}} +``` +图片模型 `model_version`(在页面逐个选中并抓包确认): + +| 界面名 | model_version | +|---|---| +| 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 v8.2 | `midjourney` | + +> 异步:先 `gen_image_v2` 拿 `asset_id`,再轮询 `get_image_asset` 直到 `status=success`。 + +### 图片侧其它接口(实测) + +| 功能 | 方法与路径 | 说明 | +|---|---|---| +| 模板清单 | `GET /v2/studio/image/available_templates` | 返回 `{templates:[{template_id,title,description,category,secondary_tag,tag}]}`。**模板是客户端的提示词+参数预设**,请求体里没有 `template_id`(实测),CLI 只用来列清单 | +| 单图→多视角图 | `POST /v2/studio/image/gen_multiview` | body `{image, model_version, amount, resolution, scale}`(`image` 需先上传+审核)。结果 `get_image_asset` 的 `type="multiview_images"`,`output.data` 为 4 张(实测出图 4 张) | +| 图片资产 | `POST /v2/studio/image/image_assets` | body `{page_num,page_size}` | +| 图片放大 | `POST /v2/studio/image/upscale` | 需 `asset_id`(实测:只给 image 报 `field "asset_id" is not set`) | +| 图片拆分 | `POST /v2/studio/image/split` | — | +| 重新生成 | `POST /v2/studio/image/re_gen_image` / `re_gen_multiview` | — | +| 批量删除 | `POST /v2/studio/image/batch_delete` | — | + +--- + +## 五、后处理算子 +⚠️ **通用规则**:每个算子都会为项目新建一个 operator;**必须等上一个算子 `success` 之后**才能对同一项目再提交, +否则返回 `2021 the status of task is not success`。 + +⚠️ **`part_name_list`**:`remesh` / `pbr_generate` / `texture_model` 都需要它,值为 +`["tripo_node_<项目当前 operator_id>"]`。缺它时请求会被接受,但任务在 **100% 处失败**(实测)。 + +| 功能 | 路径 | 关键字段 | +|---|---|---| +| 重拓扑 | `POST /v2/studio/operation/remesh` | `bake:true`、`face_limit`、`quad`、`smart_poly:false`、`model_version:"default"`、`part_name_list` | +| 绑定前检查 | `POST /v2/studio/operation/pre_rig_check` | `project_id`(`model_version` 可选)→ `{check_success, riggable, rig_type}` | +| 骨骼绑定 | `POST /v2/studio/operation/rigging_model` | `project_id`、`rig_type:"biped"`、`spec`、`model_version`。⚠️ **spec 有 5 个值**(取自前端数据表 + 实测核对):`mixamo` / `actorcore`(界面对应 ActorCore)/ `unreal`(UE5 Mannequin)/ `unity`(Unity Humanoid Compatible)/ `vrm`(VRM 1.0 Humanoid) | +| 动画重定向 | `POST /v2/studio/operation/retarget_model` | ⚠️ 动作用 `animations` **数组**:`["preset:biped:afraid"]`;+ `project_id`、`rig_type:"biped"`、`model_version:"default"`。⚠️ **动画结果不在顶层 `project.model_url`**(那是 `rigging` 算子的带网格绑骨模型),而在 `project.operator.retarget[]` 数组中(按 `operator_id` 匹配取 `model_url`);且该 GLB 仅含 65 根骨骼动画轨道(约 100–500KB,不含 Mesh),在 Three.js / Godot 中需与 `rigging` 本体 SkinnedMesh 配合播放 | +| 3D 风格化 | `POST /v2/studio/operation/stylize` | `project_id`、`model_version`、`style`(实测 `"clay"` 黏土风可用) | +| 取消任务 | `POST /v2/studio/operation/cancel` | `operator_id`、`scene`(仅允许 `"workspace"` 或 `"assets"`)、`operation_type`(20 种枚举:`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`) | +| 重命名项目 | `POST /v2/studio/operation/update_name` | `project_id`、`name`(成功返回 `null`) | +| 图文生 3D(已废弃) | `POST /v2/studio/operation/image-prompt-model` | ⚠️ 服务端已下线,固定返回 `code=2040 This endpoint is no longer accepting requests` | +| 智能分割 | `POST /v2/studio/operation/ai_segmentation` | ⚠️ `model_version` 用**分割模型自己的版本** `v2.0-20260430`(传项目版本会 `1001`);`segmentation_granularity:"balanced"`(界面「标准」;粗略/精细取值未确认) | +| 纹理生成 | `POST /v2/studio/operation/texture_model` | `texture_quality`、`texture_alignment`、`part_name_list`(⚠️ 已贴图模型会失败) | +| 重贴图(魔法笔刷) | `POST /v2/studio/operation/retexture_generate` / `apply_retexture` / `get_retexture` / `get_retexture_images` | ⚠️ **纯交互式**:body 需在 3D 画布上涂抹出的遮罩快照(`snapshot()` → `matrix` + 涂抹区域)+ `magicBrushPrompt:{text,weight}`,且要求模型**已贴图**、未分割、未超清。CLI 未封装 | +| 纹理放大 | `POST /v2/studio/operation/texture_upscaler` | ⚠️ 字段是 `texture_quality:"detailed"`(不是 `texture_resolution`,写错会 `1001`);+ `model_version` | +| PBR | `POST /v2/studio/operation/pbr_generate` | `project_id`、`model_version`、`part_name_list` | +| 智能 UV | 见下方三步 | 上下文驱动 | +| 部件补全 | `POST /v2/studio/operation/mesh_fill` / `ai_completion` | `part_names`(数组)、`model_version`。部件名 = 模型 GLB 的节点名(`tripo_part_`),实测可用 | +| 网格编辑 | `POST /v2/studio/operation/local_edit` | `project_id`、`source_operator_id`、`bbox:[[min],[max]]`(归一化 -1..1)、`face_limit`。⚠️ 仅**智能网格(Nexus)** 且未贴图/未绑定/未分割/未 PBR 的模型可用(界面据此隐藏「编辑」页) | + +### 智能 UV(三步,实测) + +``` +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` 报 +`2046 UV Edit action no longer matches the context`。另外要求网格为低模(约 ≤8 万三角面)。 + +> 说明:`ai_segmentation` / `texture_upscaler` 等对**不同模型**可能服务端失败,属模型相关行为,与请求字段无关。 + +### 动作预设清单(无接口,来自前端静态表) + +服务端**没有**提供预设目录接口,实测: +- `POST /v2/studio/motion/list_assets`(body `{}`)→ 返回的是**用户自己的动画资产**(`{active_tasks, assets}`),不是预设目录; +- `POST /v2/studio/operation/get_user_operation_context` `{"record_type":"recent_retarget_motions"}` + → 只返回**最近用过**的:`{preset:[...], generated:[...]}`。 + +完整目录在 studio 前端 chunk(`BLST4mg6.js`)的静态表里,已提取到 `tripo/data/motions.json`: +- 格式 `preset::`,`rig_type ∈ {biped, aquatic, quadruped, hexapod, octopod, serpentine}` +- **biped 97 个**,其余体型各 1 个(`walk`/`march`) +- 预览图:`https://tripo-public.tripo3d.ai/studio_riging_data//.avif` + +用 `tripocli animate --list` 查看全部。 + +### 文字生成动作(AI 动画,实测三段式) + +``` +1) POST /v2/studio/motion/generate + {"segments": [{"duration_seconds": 5, "prompt": "让角色挥手打招呼"}]} + → {task_id, status:"queued"} + (segments 是数组 → 支持多阶段动作) + +2) POST /v2/studio/motion/get_task {"task_id": "..."} + → 轮询直到 status="success",取 asset_id(即 motion_asset_id) + +3) POST /v2/studio/operation/retarget_model + {"motion_asset_id": "", "model_version":"default", + "project_id": "...", "rig_type": "biped"} → 把动作套到模型上 +``` + +其它已验证接口:`POST /v2/studio/motion/list_assets`(我的动作资产)、`/motion/get_asset`、 +`/motion/list_active_tasks`(正在跑的任务)。 + +--- + +## 六、导出与下载 + +``` +POST /v2/studio/operation/export + body: {project_id, operator_id, format:"fbx|gltf|obj|stl|usdz|3mf", name, model_version} + → data.{operator_id, operation_id} # 异步,用 progress 轮询 + +POST /v2/studio/operation/download_with_name + body: {operator_id, file_name} + → data.model_url # 带 Content-Disposition 的签名直链 +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ab034fd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "tripocli" +version = "2.0.0" +description = "Tripo AI 3D 生成与加工 CLI(面向 Agent 与开发者,纯标准库零依赖)" +readme = "README.md" +requires-python = ">=3.8" +license = { text = "MIT" } +keywords = ["tripo", "3d", "cli", "agent", "glb", "fbx"] +dependencies = [] + +[project.scripts] +tripocli = "tripo.cli:main" + +[tool.setuptools] +packages = ["tripo", "tripo.features"] +include-package-data = true + +[tool.setuptools.package-data] +tripo = ["web/*.html", "data/*.json"] diff --git a/tripo/__init__.py b/tripo/__init__.py new file mode 100644 index 0000000..7ef4aef --- /dev/null +++ b/tripo/__init__.py @@ -0,0 +1,9 @@ +"""tripocli - Tripo AI 3D generation toolkit for agents and developers. + +The package is split by feature: every Tripo capability lives in its own module +under ``tripo/features`` so the CLI can keep growing without becoming one giant +file. ``tripo/client.py`` holds the HTTP plumbing, ``tripo/config.py`` the +credentials, and ``tripo/cli.py`` wires the feature modules into an argparse CLI. +""" + +__version__ = "2.0.0" diff --git a/tripo/accounts.py b/tripo/accounts.py new file mode 100644 index 0000000..0b246e2 --- /dev/null +++ b/tripo/accounts.py @@ -0,0 +1,132 @@ +"""Multi-account credential store. + +A user hands the CLI a session cookie once (paste it, or pass ``TRIPO_COOKIE``); +from then on the CLI mints short-lived JWTs on demand over plain HTTP — no +browser, no repeated login. + + ~/.tripo/accounts.json + { + "active": "main", + "accounts": { + "main": {"cookie": "...", "token": "...", "session_expires_at": "...", + "user_id": "...", "email": "...", "added_at": 1791800000} + } + } +""" + +import json +import time +from pathlib import Path + +DEFAULT_ACCOUNTS_PATH = Path.home() / ".tripo" / "accounts.json" + + +class Accounts: + def __init__(self, path=None): + self.path = Path(path) if path else DEFAULT_ACCOUNTS_PATH + self.data = {"active": None, "accounts": {}} + self.load() + + # --- persistence --- + + def load(self): + if self.path.exists(): + try: + loaded = json.loads(self.path.read_text(encoding="utf-8")) + self.data = { + "active": loaded.get("active"), + "accounts": loaded.get("accounts", {}), + } + except Exception: + self.data = {"active": None, "accounts": {}} + + def save(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(self.data, indent=2, ensure_ascii=False), encoding="utf-8") + try: + self.path.chmod(0o600) # holds a session cookie: keep it private + except OSError: + pass + + # --- queries --- + + def names(self): + return sorted(self.data["accounts"]) + + def rotation(self, start=None): + """Account names starting at ``start`` (default: active), wrapping around. + + Used by the optional auto-fallback so a command can try the next account + when the current one is out of credits. + """ + names = self.names() + if not names: + return [] + start = start or self.data.get("active") + if start not in names: + return names + index = names.index(start) + return names[index:] + names[:index] + + def exists(self, name): + return name in self.data["accounts"] + + def active_name(self): + return self.data.get("active") + + def get(self, name=None): + """Return ``(name, record)`` for ``name`` or the active account.""" + name = name or self.data.get("active") + if not name or name not in self.data["accounts"]: + return None, None + return name, self.data["accounts"][name] + + # --- mutations --- + + def add(self, name, cookie, token=None, session_expires_at=None, user_id=None, email=None): + record = self.data["accounts"].get(name, {}) + record.update({ + "cookie": cookie, + "token": token, + "session_expires_at": session_expires_at, + "user_id": user_id, + "email": email, + "added_at": record.get("added_at") or int(time.time()), + "updated_at": int(time.time()), + }) + self.data["accounts"][name] = record + if not self.data.get("active"): + self.data["active"] = name + self.save() + return name + + def use(self, name): + if name not in self.data["accounts"]: + raise KeyError(name) + self.data["active"] = name + self.save() + + def remove(self, name): + if name not in self.data["accounts"]: + raise KeyError(name) + del self.data["accounts"][name] + if self.data.get("active") == name: + self.data["active"] = self.names()[0] if self.data["accounts"] else None + self.save() + + def update_token(self, name, token, session_expires_at=None): + record = self.data["accounts"].get(name) + if record is None: + return + record["token"] = token + record["updated_at"] = int(time.time()) + if session_expires_at: + record["session_expires_at"] = session_expires_at + self.save() + + def update_cookie(self, name, cookie): + record = self.data["accounts"].get(name) + if record is None: + return + record["cookie"] = cookie + self.save() diff --git a/tripo/auth.py b/tripo/auth.py new file mode 100644 index 0000000..55923ad --- /dev/null +++ b/tripo/auth.py @@ -0,0 +1,109 @@ +"""Mint the short-lived JWT that the API needs, from a session cookie. + +The web session cookie (``ory_kratos_session``, ~30 days) authenticates +``GET /v2/studio/studio/whoami?tokenizeAs=default_jwt`` over plain HTTP and +returns a ``tokenized`` JWT valid for ~15 minutes. Verified live: a cookie alone +is enough — no browser, no device id. + +The endpoint occasionally answers a transient ``401`` under rapid repeated calls, +so minting retries a few times with a short backoff before giving up. +""" + +import base64 +import json +import time +import urllib.error +import urllib.request + +from .errors import TripoAPIError + +ORIGIN = "https://studio.tripo3d.ai" +COOKIE_NAME = "ory_kratos_session" +WHOAMI = "/v2/studio/studio/whoami?tokenizeAs=default_jwt" + +RETRY_ATTEMPTS = 3 +RETRY_BACKOFF = 1.5 + + +def _request(api_base, cookie, device_id=None, timeout=30): + headers = { + "Accept": "application/json", + "Origin": ORIGIN, + "Referer": ORIGIN + "/", + "User-Agent": "tripocli/2.0.0", + "Cookie": cookie, + } + if device_id: + headers["x-tripo-device-id"] = device_id + + request = urllib.request.Request(f"{api_base}{WHOAMI}", headers=headers, method="GET") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", "replace") + raise TripoAPIError( + f"会话换取 JWT 失败 (HTTP {error.code}): {body[:200]}", code=error.code + ) from None + except urllib.error.URLError as error: + raise TripoAPIError(f"网络错误: {error.reason}") from None + + +def decode_jwt_payload(token): + """Return the JWT payload, or ``{}`` when it cannot be decoded.""" + try: + parts = (token or "").split(".") + if len(parts) >= 2: + payload = parts[1] + "=" * (-len(parts[1]) % 4) + return json.loads(base64.urlsafe_b64decode(payload).decode("utf-8")) + except Exception: + pass + return {} + + +def normalize_cookie(cookie): + """Accept either a bare cookie value or a full ``name=value; name2=value2`` string.""" + cookie = (cookie or "").strip().strip('"').strip("'") + if not cookie: + raise ValueError("cookie 为空") + if "=" not in cookie: + cookie = f"{COOKIE_NAME}={cookie}" + return cookie + + +def fetch_identity(api_base, cookie, device_id=None): + """Read ``(user_id, email)`` for a session cookie. + + Note ``/studio/whoami`` authenticates with the *cookie*, not the Bearer JWT + (a JWT-only call answers 401), so identity lookup goes through the same + cookie path as minting. + """ + cookie = normalize_cookie(cookie) + data = _request(api_base, cookie, device_id) + traits = (data.get("identity") or {}).get("traits") or {} + user_id = data.get("id") or decode_jwt_payload(data.get("tokenized") or "").get("sub") + return user_id, traits.get("email") + + +def mint_token(api_base, cookie, device_id=None): + """Exchange a session cookie for ``(token, session_expires_at)``.""" + cookie = normalize_cookie(cookie) + + last_error = None + for attempt in range(1, RETRY_ATTEMPTS + 1): + try: + data = _request(api_base, cookie, device_id) + except TripoAPIError as error: + last_error = error + # 401 here is usually a transient throttle; 5xx/network also worth retrying. + if error.code in (401, 403, 429, 500, 502, 503, 504, None) and attempt < RETRY_ATTEMPTS: + time.sleep(RETRY_BACKOFF * attempt) + continue + raise + + token = data.get("tokenized") + if not token: + raise TripoAPIError("会话有效但未返回 tokenized,cookie 可能已失效", raw=data) + return token, data.get("expires_at") + + raise last_error diff --git a/tripo/cli.py b/tripo/cli.py new file mode 100644 index 0000000..2584a0d --- /dev/null +++ b/tripo/cli.py @@ -0,0 +1,168 @@ +"""argparse wiring and dispatch. + +``tripocli`` is a thin shell: this module builds the command tree from the +feature modules, resolves credentials (re-minting the JWT from the account's +session cookie when needed) and hands control to the selected feature. +""" + +import argparse +import os +import sys + +from . import __version__ +from .accounts import Accounts +from .client import TripoClient +from .config import Config +from .errors import TripoAPIError, TripoTimeout, is_insufficient_credits +from .features import MODULES +from .output import Out + +EPILOG = """\ +常用示例: + tripocli account add --cookie "<粘贴浏览器 cookie>" # 一次性添加账号,之后免浏览器 + tripocli account list # 多账号查看/切换 + tripocli serve --open # 打开网页版账号管理(看积分/有效期) + tripocli update # 改完源码后更新全局 CLI + tripocli balance # 查看积分 + tripocli text -p "a wooden barrel" --wait -o a.glb # 文生 3D + tripocli image -i ./photo.png --wait -o b.glb # 单图生 3D + tripocli multiview --front f.png --left l.png --right r.png --back b.png --wait -o c.glb + tripocli batch -i a.png,b.png --wait -o ./out # 批量图生 3D + tripocli remesh -p --face-limit 8000 --wait -o low.glb + tripocli rig -p --type biped --wait # 绑定骨骼 + tripocli animate -p --preset idle --wait -o anim.glb + tripocli segment -p --wait # 智能分割 + tripocli texture -p --resolution 2K --wait + tripocli pbr -p --wait # PBR 材质 + tripocli upscale -p --wait # 纹理放大 + tripocli export -p -f fbx --wait -o m.fbx + +凭据:只需提供一次浏览器 cookie,CLI 会自行续期 JWT(cookie 约 30 天有效)。 +也可用环境变量 TRIPO_COOKIE 提供;多账号用 --account <名字> 指定。 +加 --json 可输出机器可读结果(Agent 调用推荐)。 +""" + +# Commands that must run without any credentials. +NO_CREDENTIAL_COMMANDS = {"account", "config", "serve", "update", "inspect", "optimize"} + + +class Context: + """Everything a feature needs: client, config, accounts, output sink and args.""" + + def __init__(self, client, config, accounts, out, args): + self.client = client + self.config = config + self.accounts = accounts + self.out = out + self.args = args + + +def _add_json_flag_everywhere(parser): + """Allow ``--json`` after the subcommand as well as before it. + + ``argparse`` only accepts a parent parser's options before the subcommand, but + agents habitually append ``--json`` at the end; SUPPRESS keeps the subcommand + copy from overwriting a value given on the main parser. + """ + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + for subparser in action.choices.values(): + if "--json" not in subparser._option_string_actions: + subparser.add_argument( + "--json", action="store_true", default=argparse.SUPPRESS, + help="输出结构化 JSON(Agent 调用推荐)", + ) + _add_json_flag_everywhere(subparser) + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="tripocli", + description="Tripo AI 3D 生成 CLI(面向 Agent 与开发者)", + epilog=EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--json", action="store_true", help="输出结构化 JSON(Agent 调用推荐)") + parser.add_argument("--account", help="使用指定账号(默认用当前账号)") + parser.add_argument( + "--no-fallback", action="store_true", + help="关闭「额度不足自动切换下一个账号」(默认开启;设 TRIPO_AUTO_FALLBACK=0 也可关闭)", + ) + parser.add_argument("--token", help="临时覆盖 Bearer JWT") + parser.add_argument("--device-id", help="临时覆盖 x-tripo-device-id") + parser.add_argument("--api-base", help="临时覆盖 API 根地址") + parser.add_argument("-V", "--version", action="version", version=f"tripocli {__version__}") + + subparsers = parser.add_subparsers(dest="command", metavar="<命令>") + for module in MODULES: + if hasattr(module, "register"): + module.register(subparsers) + else: + command = subparsers.add_parser(module.NAME, help=module.HELP) + module.add_arguments(command) + command.set_defaults(func=module.run) + _add_json_flag_everywhere(parser) + return parser + + +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + + handler = getattr(args, "func", None) + if handler is None: + parser.print_help() + return 0 + + out = Out(as_json=args.json) + config = Config() + accounts = Accounts() + + needs_credentials = args.command not in NO_CREDENTIAL_COMMANDS + # 默认开启账号回退;显式 --no-fallback 或 TRIPO_AUTO_FALLBACK=0 可关闭。 + env_fallback = os.environ.get("TRIPO_AUTO_FALLBACK", "").strip().lower() + fallback = not args.no_fallback and env_fallback not in ("0", "false", "no") + if needs_credentials and fallback and len(accounts.names()) > 1: + order = accounts.rotation(args.account) + else: + order = [args.account] + extra_codes = config.data.get("insufficient_credit_codes") or [] + + last_error = None + for index, account in enumerate(order): + client = TripoClient( + token=args.token, device_id=args.device_id, api_base=args.api_base, + account=account, accounts=accounts, config=config, + ) + ctx = Context(client=client, config=config, accounts=accounts, out=out, args=args) + try: + if needs_credentials: + client.ensure_credentials() + handler(ctx) + return 0 + except KeyboardInterrupt: + out.error("已中断") + return 130 + except (TripoAPIError, TripoTimeout, ValueError, FileNotFoundError, RuntimeError) as error: + can_retry = ( + needs_credentials + and fallback + and index + 1 < len(order) + and is_insufficient_credits(error, extra_codes) + ) + if can_retry: + out.notice(f"⚠️ 账号 {account or accounts.active_name()} 额度不足,切换到下一个账号重试…") + last_error = error + continue + out.error(error) + return 1 + except Exception as error: # unexpected: keep stdout parseable for agents + out.error(f"运行错误: {error}") + return 1 + + out.error(last_error or "没有可用账号") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tripo/client.py b/tripo/client.py new file mode 100644 index 0000000..41a14b8 --- /dev/null +++ b/tripo/client.py @@ -0,0 +1,347 @@ +"""HTTP plumbing shared by every feature module. + +Holds the transport concerns only: credentials resolution, headers, retries for +transient failures, automatic JWT refresh from the account's session cookie, +progress polling and file download. Feature specific request bodies live in +``tripo/features/*``. +""" + +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from . import auth, glb, s3 +from .accounts import Accounts +from .config import Config +from .errors import TripoAPIError, TripoTimeout + +# Transient transport/server failures worth retrying (the studio occasionally +# returns a bare 502 mid-generation, which must not abort a running poll). +TRANSIENT_STATUS = {429, 500, 502, 503, 504} +RETRY_ATTEMPTS = 4 +RETRY_BACKOFF = 1.5 + +ORIGIN = "https://studio.tripo3d.ai" +USER_AGENT = "tripocli/2.0.0 (Mozilla/5.0; AI Agent Client)" + +# Model formats the studio's "上传 3D 模型" importer accepts (UI: OBJ/FBX/STL/GLB ≤150MB). +MODEL_FORMATS = ("glb", "obj", "fbx", "stl") + +NO_CREDENTIALS = ( + "未找到可用凭据。请先添加账号(粘贴浏览器 cookie 即可,之后 CLI 自动续期 JWT):\n" + " tripocli account add --cookie \"<整条 Cookie 或 ory_kratos_session 的值>\"\n" + "也可用环境变量 TRIPO_COOKIE 提供。" +) + + +def _jwt_expiry(token): + import base64 + try: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + return int(json.loads(base64.urlsafe_b64decode(payload).decode()).get("exp") or 0) + except Exception: + return 0 + + +class TripoClient: + def __init__(self, token=None, device_id=None, api_base=None, account=None, + auto_refresh=True, accounts=None, config=None): + self.config = config or Config() + self.accounts = accounts or Accounts() + + env_token = os.environ.get("TRIPO_TOKEN") or os.environ.get("TRIPO_API_KEY") + env_cookie = os.environ.get("TRIPO_COOKIE") + self.account_name, record = self.accounts.get(account or os.environ.get("TRIPO_ACCOUNT")) + record = record or {} + + self.token = token or env_token or record.get("token") + self.cookie = env_cookie or record.get("cookie") + self.device_id = device_id or self.config.device_id + self.api_base = (api_base or self.config.api_base).rstrip("/") + self.auto_refresh = auto_refresh and not token and not env_token + self.token_source = "env" if env_token else ("account" if record.get("token") else None) + + # --- credentials --- + + def token_expiring(self): + exp = _jwt_expiry(self.token or "") + return not exp or exp - time.time() < 120 + + def refresh_token(self): + """Mint a fresh JWT from the session cookie (pure HTTP, no browser).""" + if not self.cookie: + raise TripoAPIError(NO_CREDENTIALS, code="no-credentials") + token, session_expires = auth.mint_token(self.api_base, self.cookie, self.device_id) + self.token = token + if self.account_name: + self.accounts.update_token(self.account_name, token, session_expires) + return True + + def ensure_credentials(self): + if self.token and not self.token_expiring(): + return + if self.cookie: + self.refresh_token() + return + if not self.token: + raise TripoAPIError(NO_CREDENTIALS, code="no-credentials") + + # --- transport --- + + def _headers(self, extra=None): + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "Origin": ORIGIN, + "Referer": ORIGIN + "/", + "User-Agent": USER_AGENT, + "x-tripo-device-id": self.device_id, + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + if extra: + headers.update(extra) + return headers + + def _raw_request(self, method, endpoint, params=None, body=None, headers=None, timeout=30): + url = f"{self.api_base}{endpoint}" + if params: + query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) + if query: + url = f"{url}?{query}" + + payload = json.dumps(body).encode("utf-8") if body is not None else None + request = urllib.request.Request( + url, data=payload, headers=self._headers(headers), method=method.upper() + ) + + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + text = response.read().decode("utf-8") + if not text: + return None + return json.loads(text) + except urllib.error.HTTPError as error: + err_body = error.read().decode("utf-8", "replace") + try: + err_json = json.loads(err_body) + except json.JSONDecodeError: + raise TripoAPIError(f"HTTP {error.code}: {error.reason}", code=error.code) from None + raise TripoAPIError( + err_json.get("message", f"HTTP {error.code}"), + code=err_json.get("code", error.code), + suggestion=err_json.get("suggestion"), + raw=err_json, + ) from None + except urllib.error.URLError as error: + raise TripoAPIError(f"Network error: {error.reason}") from None + + def request(self, method, endpoint, params=None, body=None, headers=None, timeout=30): + """Perform an API call, retrying transient failures and expired tokens.""" + attempt = 0 + refreshed = False + while True: + attempt += 1 + try: + result = self._raw_request(method, endpoint, params, body, headers, timeout) + if isinstance(result, dict) and result.get("code") not in (None, 0): + raise TripoAPIError( + result.get("message", "API request failed"), + code=result.get("code"), + suggestion=result.get("suggestion"), + raw=result, + ) + return result + except TripoAPIError as error: + if error.is_auth_error and self.auto_refresh and not refreshed: + refreshed = True + try: + if self.refresh_token(): + continue + except TripoAPIError: + pass # fall through and surface the original error + transient = error.code in TRANSIENT_STATUS or error.code is None + if transient and attempt < RETRY_ATTEMPTS: + time.sleep(RETRY_BACKOFF * attempt) + continue + raise + + # --- shared API calls --- + + def get_data(self, method, endpoint, **kwargs): + result = self.request(method, endpoint, **kwargs) + return result.get("data") if result else None + + def progress(self, operator_ids): + if isinstance(operator_ids, str): + operator_ids = [operator_ids] + return self.get_data("POST", "/v2/studio/progress", body={"ids": operator_ids}) or [] + + def project_detail(self, project_id, locale="zh-CN"): + return self.get_data("GET", f"/v2/studio/project/detail/v3/{project_id}", params={"locale": locale}) + + def project_operator(self, project_id): + """Current operator block of a project (carries operator_id + model_version).""" + detail = self.project_detail(project_id) or {} + return detail.get("operator") or {} + + def part_names(self, project_id): + """Node names inside the project's current mesh (the ``part_name_list``). + + Read from the model file itself, since the detail API does not expose + them. Returns an empty list when the model cannot be read. + """ + detail = self.project_detail(project_id) or {} + url = (detail.get("operator") or {}).get("model_url") + if not url: + return [] + request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(request, timeout=120) as response: + data = response.read() + return glb.node_names(data) + + def payment_profile(self): + return self.get_data("GET", "/v2/studio/user/profile/payment") + + def whoami(self): + return self.get_data("GET", "/v2/studio/studio/whoami", params={"tokenizeAs": "default_jwt"}) + + def quota(self): + return self.get_data("POST", "/v2/studio/operation/quota", body={}) + + def assets(self, asset_type="mine", type="model", page=1, size=20, offset=0, locale="zh-CN"): + return self.get_data( + "GET", + "/v2/studio/assets/v2", + params={ + "asset_type": asset_type, + "type": type, + "page": page, + "size": size, + "offset": offset, + "locale": locale, + }, + ) + + def download_url(self, operator_id, file_name): + data = self.get_data( + "POST", "/v2/studio/operation/download_with_name", + body={"operator_id": operator_id, "file_name": file_name}, + ) + return (data or {}).get("model_url") + + def export(self, project_id, operator_id, export_format, name="model", model_version="default"): + fmt = {"glb": "gltf", "gltf": "gltf"}.get(export_format.lower(), export_format.lower()) + return self.get_data( + "POST", "/v2/studio/operation/export", + body={ + "project_id": project_id, + "operator_id": operator_id, + "format": fmt, + "name": name, + "model_version": model_version, + }, + ) + + # --- images --- + + def upload_image(self, path): + """Upload a local image and return an image object ready to be submitted. + + Verified flow: temporary_token -> SigV4 PUT -> ``/audit/image`` -> the + ``image`` object must carry ``image_audit_result`` and ``image_source``, + otherwise every generation endpoint answers 11002. + """ + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"图片文件不存在: {path}") + + fmt = path.suffix.lower().lstrip(".") or "png" + if fmt == "jpeg": + fmt = "jpg" + if fmt not in ("png", "jpg", "webp"): + raise ValueError(f"不支持的图片格式: .{fmt} (支持 png/jpg/webp)") + content_type = "image/jpeg" if fmt == "jpg" else f"image/{fmt}" + + sts = self.get_data( + "POST", "/v2/studio/storage/temporary_token", body={"client": "aws", "format": fmt} + ) + s3.upload_bytes(sts, path.read_bytes(), content_type=content_type) + + image = {"bucket": sts["resource_bucket"], "key": sts["resource_uri"]} + audit = self.get_data("POST", "/v2/studio/audit/image", body={"image": image}) or {} + image["image_audit_result"] = audit.get("result", "pass") + image["image_source"] = "upload" + return image + + # --- models --- + + def upload_model(self, path): + """Upload a local 3D model and return the ``import_user_model`` payload. + + Verified flow: ``temporary_token`` (format = file extension) -> SigV4 PUT + -> the import body is ``{format, model:{bucket,key}, name, + transform_matrix, use_original_uv}``. The studio UI rotates the mesh + before importing (that is what ``transform_matrix`` carries); a CLI + import uses the identity matrix. + """ + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"模型文件不存在: {path}") + + fmt = path.suffix.lower().lstrip(".") + if fmt not in MODEL_FORMATS: + raise ValueError(f"不支持的模型格式: .{fmt} (支持 glb/obj/fbx/stl)") + + sts = self.get_data( + "POST", "/v2/studio/storage/temporary_token", body={"client": "aws", "format": fmt} + ) + s3.upload_bytes(sts, path.read_bytes(), content_type="application/octet-stream") + return { + "format": fmt, + "model": {"bucket": sts["resource_bucket"], "key": sts["resource_uri"]}, + "name": path.name, + } + + # --- waiting & downloading --- + + def wait_task(self, operator_id, timeout=900, poll_interval=3, on_progress=None): + """Poll until the operator succeeds, fails or the timeout expires.""" + if not operator_id: + # Guard: polling a missing id would otherwise spin until the timeout. + raise TripoAPIError("没有可等待的 operator_id(接口未返回任务 id)", code="no-operator") + deadline = time.time() + timeout + last_progress = -1 + while time.time() < deadline: + items = self.progress([operator_id]) + if items: + task = items[0] + status = task.get("status") + progress = task.get("progress", 0) + if progress != last_progress: + last_progress = progress + if on_progress: + on_progress(status, progress, task.get("left_time", -1), task) + if status == "success": + return task + if status in ("failed", "banned", "expired"): + raise TripoAPIError(f"任务 {operator_id} 失败: {json.dumps(task, ensure_ascii=False)}") + time.sleep(poll_interval) + raise TripoTimeout(f"任务 {operator_id} 在 {timeout}s 内未完成") + + def download(self, url, output_path): + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(request, timeout=300) as response, open(output_path, "wb") as handle: + while True: + chunk = response.read(64 * 1024) + if not chunk: + break + handle.write(chunk) + return output_path diff --git a/tripo/config.py b/tripo/config.py new file mode 100644 index 0000000..5dd8871 --- /dev/null +++ b/tripo/config.py @@ -0,0 +1,47 @@ +"""Global settings (API base, device id). + +Credentials live in ``tripo/accounts.py``; this module only holds the non-secret +settings, in ``~/.tripo/config.json``. +""" + +import json +import os +from pathlib import Path + +DEFAULT_CONFIG_PATH = Path.home() / ".tripo" / "config.json" +DEFAULT_API_BASE = "https://api.tripo3d.ai" +DEFAULT_DEVICE_ID = "2c3443d027e6d5d268e9e9af3bdbf3ec" + + +class Config: + def __init__(self, path=None): + self.path = Path(path) if path else DEFAULT_CONFIG_PATH + self.data = {} + self.load() + + def load(self): + if self.path.exists(): + try: + self.data = json.loads(self.path.read_text(encoding="utf-8")) + except Exception: + self.data = {} + else: + self.data = {} + + def save(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(self.data, indent=2, ensure_ascii=False), encoding="utf-8") + + @property + def api_base(self): + return os.environ.get("TRIPO_API_BASE") or self.data.get("api_base") or DEFAULT_API_BASE + + @property + def device_id(self): + return os.environ.get("TRIPO_DEVICE_ID") or self.data.get("device_id") or DEFAULT_DEVICE_ID + + def set(self, **kwargs): + for key, value in kwargs.items(): + if value is not None: + self.data[key] = value + self.save() diff --git a/tripo/data/motions.json b/tripo/data/motions.json new file mode 100644 index 0000000..e0ef16b --- /dev/null +++ b/tripo/data/motions.json @@ -0,0 +1,524 @@ +{ + "aquatic": [ + { + "action_type": "Walk", + "category": "basic", + "name": "march" + } + ], + "biped": [ + { + "action_type": "Fear", + "category": "emotional", + "name": "afraid" + }, + { + "action_type": "Social", + "category": "interactive", + "name": "agree" + }, + { + "action_type": "Anger", + "category": "emotional", + "name": "angry_01" + }, + { + "action_type": "Anger", + "category": "emotional", + "name": "angry_02" + }, + { + "action_type": "Anger", + "category": "emotional", + "name": "angry_03" + }, + { + "action_type": "Ball Sports", + "category": "scene_specific", + "name": "basketball_shot" + }, + { + "action_type": "Social", + "category": "interactive", + "name": "bow" + }, + { + "action_type": "Strike", + "category": "combat", + "name": "box_01" + }, + { + "action_type": "Strike", + "category": "combat", + "name": "box_02" + }, + { + "action_type": "Strike", + "category": "combat", + "name": "box_03" + }, + { + "action_type": "Magic", + "category": "scene_specific", + "name": "cast_a_spell" + }, + { + "action_type": "Joy", + "category": "emotional", + "name": "cheer" + }, + { + "action_type": "Work Activities", + "category": "scene_specific", + "name": "chop" + }, + { + "action_type": "Joy", + "category": "emotional", + "name": "clap" + }, + { + "action_type": "Climb & Ride", + "category": "scene_specific", + "name": "climb" + }, + { + "action_type": "Anger", + "category": "emotional", + "name": "complain_01" + }, + { + "action_type": "Anger", + "category": "emotional", + "name": "complain_02" + }, + { + "action_type": "Ball Sports", + "category": "scene_specific", + "name": "crossover_dribble" + }, + { + "action_type": "Sadness", + "category": "emotional", + "name": "cry" + }, + { + "action_type": "Dance", + "category": "scene_specific", + "name": "dance_01" + }, + { + "action_type": "Dance", + "category": "scene_specific", + "name": "dance_02" + }, + { + "action_type": "Dance", + "category": "scene_specific", + "name": "dance_03" + }, + { + "action_type": "Dance", + "category": "scene_specific", + "name": "dance_04" + }, + { + "action_type": "Dance", + "category": "scene_specific", + "name": "dance_05" + }, + { + "action_type": "Dance", + "category": "scene_specific", + "name": "dance_06" + }, + { + "action_type": "Hit Reactions", + "category": "combat", + "name": "defeat_02" + }, + { + "action_type": "Hit Reactions", + "category": "combat", + "name": "defeat_03" + }, + { + "action_type": "Sadness", + "category": "emotional", + "name": "depressed" + }, + { + "action_type": "Work Activities", + "category": "scene_specific", + "name": "dig" + }, + { + "action_type": "Acrobatics", + "category": "scene_specific", + "name": "dive" + }, + { + "action_type": "Ball Sports", + "category": "scene_specific", + "name": "dribble" + }, + { + "action_type": "Accident", + "category": "scene_specific", + "name": "fall" + }, + { + "action_type": "Ranged", + "category": "combat", + "name": "fire" + }, + { + "action_type": "Fear", + "category": "emotional", + "name": "flee_01" + }, + { + "action_type": "Fear", + "category": "emotional", + "name": "flee_02" + }, + { + "action_type": "Acrobatics", + "category": "scene_specific", + "name": "flip" + }, + { + "action_type": "Idle", + "category": "basic", + "name": "fold_arms" + }, + { + "action_type": "Ball Sports", + "category": "scene_specific", + "name": "football_catch" + }, + { + "action_type": "Ball Sports", + "category": "scene_specific", + "name": "football_save" + }, + { + "action_type": "Ball Sports", + "category": "scene_specific", + "name": "football_pass" + }, + { + "action_type": "Body Movement", + "category": "basic", + "name": "freaky" + }, + { + "action_type": "Fear", + "category": "emotional", + "name": "frightened" + }, + { + "action_type": "Strike", + "category": "combat", + "name": "front_kick_01" + }, + { + "action_type": "Strike", + "category": "combat", + "name": "front_kick_02" + }, + { + "action_type": "Anger", + "category": "emotional", + "name": "frustrated_01" + }, + { + "action_type": "Anger", + "category": "emotional", + "name": "frustrated_02" + }, + { + "action_type": "Social", + "category": "interactive", + "name": "greet_01" + }, + { + "action_type": "Social", + "category": "interactive", + "name": "greet_02" + }, + { + "action_type": "Social", + "category": "interactive", + "name": "greet_03" + }, + { + "action_type": "Social", + "category": "interactive", + "name": "greet_04" + }, + { + "action_type": "Social", + "category": "interactive", + "name": "heart_pose" + }, + { + "action_type": "Hit Reactions", + "category": "combat", + "name": "hit_to_body_01" + }, + { + "action_type": "Hit Reactions", + "category": "combat", + "name": "hit_to_body_02" + }, + { + "action_type": "Hit Reactions", + "category": "combat", + "name": "hit_to_head" + }, + { + "action_type": "Hit Reactions", + "category": "combat", + "name": "hit_to_side" + }, + { + "action_type": "Hit Reactions", + "category": "combat", + "name": "hit_to_stomach" + }, + { + "action_type": "Social", + "category": "interactive", + "name": "hug" + }, + { + "action_type": "Idle", + "category": "basic", + "name": "idle" + }, + { + "action_type": "Jump", + "category": "basic", + "name": "jump_down" + }, + { + "action_type": "Jump", + "category": "basic", + "name": "jump" + }, + { + "action_type": "Fitness", + "category": "scene_specific", + "name": "jump_rope_01" + }, + { + "action_type": "Fitness", + "category": "scene_specific", + "name": "jump_rope_02" + }, + { + "action_type": "Joy", + "category": "emotional", + "name": "laugh_01" + }, + { + "action_type": "Joy", + "category": "emotional", + "name": "laugh_02" + }, + { + "action_type": "Object Interaction", + "category": "interactive", + "name": "lift_heavy" + }, + { + "action_type": "Body Movement", + "category": "basic", + "name": "look_around" + }, + { + "action_type": "Social", + "category": "interactive", + "name": "make_a_call_01" + }, + { + "action_type": "Social", + "category": "interactive", + "name": "make_a_call_02" + }, + { + "action_type": "Ball Sports", + "category": "scene_specific", + "name": "pitch_baseball" + }, + { + "action_type": "Leisure", + "category": "interactive", + "name": "play_mobile_game" + }, + { + "action_type": "Leisure", + "category": "interactive", + "name": "play_video_game" + }, + { + "action_type": "Fitness", + "category": "scene_specific", + "name": "press-up" + }, + { + "action_type": "Run", + "category": "basic", + "name": "run_upstairs" + }, + { + "action_type": "Run", + "category": "basic", + "name": "run" + }, + { + "action_type": "Fear", + "category": "emotional", + "name": "scared_01" + }, + { + "action_type": "Fear", + "category": "emotional", + "name": "scared_02" + }, + { + "action_type": "Body Movement", + "category": "basic", + "name": "scratch" + }, + { + "action_type": "Ranged", + "category": "combat", + "name": "shoot" + }, + { + "action_type": "Work Activities", + "category": "scene_specific", + "name": "shovel" + }, + { + "action_type": "Play Music", + "category": "scene_specific", + "name": "sing_01" + }, + { + "action_type": "Play Music", + "category": "scene_specific", + "name": "sing_02" + }, + { + "action_type": "Play Music", + "category": "scene_specific", + "name": "sing_03" + }, + { + "action_type": "Play Music", + "category": "scene_specific", + "name": "sing_04" + }, + { + "action_type": "Sit & Lie", + "category": "basic", + "name": "sit" + }, + { + "action_type": "Work Activities", + "category": "scene_specific", + "name": "slash" + }, + { + "action_type": "Sadness", + "category": "emotional", + "name": "sob" + }, + { + "action_type": "Idle", + "category": "basic", + "name": "standing_relax" + }, + { + "action_type": "Water Activities", + "category": "scene_specific", + "name": "surf" + }, + { + "action_type": "Attitude", + "category": "emotional", + "name": "swagger" + }, + { + "action_type": "Water Activities", + "category": "scene_specific", + "name": "swim" + }, + { + "action_type": "Turn", + "category": "basic", + "name": "turn" + }, + { + "action_type": "Ball Sports", + "category": "scene_specific", + "name": "volleyball" + }, + { + "action_type": "Idle", + "category": "basic", + "name": "wait" + }, + { + "action_type": "Walk", + "category": "basic", + "name": "walk" + }, + { + "action_type": "Fitness", + "category": "scene_specific", + "name": "warm_up" + }, + { + "action_type": "Social", + "category": "interactive", + "name": "wave_goodbye_01" + }, + { + "action_type": "Social", + "category": "interactive", + "name": "wave_goodbye_02" + } + ], + "hexapod": [ + { + "action_type": "Walk", + "category": "basic", + "name": "walk" + } + ], + "octopod": [ + { + "action_type": "Walk", + "category": "basic", + "name": "walk" + } + ], + "quadruped": [ + { + "action_type": "Walk", + "category": "basic", + "name": "walk" + } + ], + "serpentine": [ + { + "action_type": "Walk", + "category": "basic", + "name": "march" + } + ] +} \ No newline at end of file diff --git a/tripo/errors.py b/tripo/errors.py new file mode 100644 index 0000000..6ec8905 --- /dev/null +++ b/tripo/errors.py @@ -0,0 +1,53 @@ +"""Error types shared across the package.""" + + +class TripoAPIError(Exception): + """An error returned by the Tripo API (or a transport failure talking to it).""" + + def __init__(self, message, code=None, suggestion=None, raw=None): + super().__init__(message) + self.code = code + self.suggestion = suggestion + self.raw = raw + + def __str__(self): + msg = f"[Tripo Error {self.code}] {super().__str__()}" + if self.suggestion: + msg += f" (Suggestion: {self.suggestion})" + return msg + + @property + def is_auth_error(self): + """True when the JWT is missing/expired and a re-mint from the cookie could help.""" + return self.code in (401, 403, 1002) + + +class TripoTimeout(Exception): + """A task did not reach a terminal state within the allotted time.""" + + +# Signals that an account is out of credits. The exact server code is not +# confirmed, so the message text is the practical trigger; extra codes can be +# added without touching code via config ``insufficient_credit_codes``. +INSUFFICIENT_KEYWORDS = ( + "insufficient", + "not enough", + "no enough", + "balance", + "credit", + "quota", + "recharge", + "积分", + "余额", + "额度不足", +) + + +def is_insufficient_credits(error, extra_codes=()): + """True when ``error`` looks like "this account has run out of credits".""" + if not isinstance(error, TripoAPIError): + return False + if error.code is not None and error.code in set(extra_codes): + return True + text = str(error).lower() + return any(keyword in text for keyword in INSUFFICIENT_KEYWORDS) diff --git a/tripo/features/__init__.py b/tripo/features/__init__.py new file mode 100644 index 0000000..53110bb --- /dev/null +++ b/tripo/features/__init__.py @@ -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) +] diff --git a/tripo/features/_common.py b/tripo/features/_common.py new file mode 100644 index 0000000..184d899 --- /dev/null +++ b/tripo/features/_common.py @@ -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_`` (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 diff --git a/tripo/features/account.py b/tripo/features/account.py new file mode 100644 index 0000000..06e25b6 --- /dev/null +++ b/tripo/features/account.py @@ -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) diff --git a/tripo/features/account_mgmt.py b/tripo/features/account_mgmt.py new file mode 100644 index 0000000..44bd6a6 --- /dev/null +++ b/tripo/features/account_mgmt.py @@ -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) diff --git a/tripo/features/animate.py b/tripo/features/animate.py new file mode 100644 index 0000000..cecd813 --- /dev/null +++ b/tripo/features/animate.py @@ -0,0 +1,120 @@ +"""动画 — retarget a rigged model onto animation presets. + + tripocli animate -p --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 (示例: {', '.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 diff --git a/tripo/features/assets.py b/tripo/features/assets.py new file mode 100644 index 0000000..c277209 --- /dev/null +++ b/tripo/features/assets.py @@ -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 diff --git a/tripo/features/batch.py b/tripo/features/batch.py new file mode 100644 index 0000000..8dba6d0 --- /dev/null +++ b/tripo/features/batch.py @@ -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, "输出目录(每个结果保存为 .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 diff --git a/tripo/features/cancel.py b/tripo/features/cancel.py new file mode 100644 index 0000000..654ec5a --- /dev/null +++ b/tripo/features/cancel.py @@ -0,0 +1,42 @@ +"""取消任务 — cancel a running operation. + + tripocli cancel --operator --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 diff --git a/tripo/features/config_cmd.py b/tripo/features/config_cmd.py new file mode 100644 index 0000000..e22d672 --- /dev/null +++ b/tripo/features/config_cmd.py @@ -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) diff --git a/tripo/features/download.py b/tripo/features/download.py new file mode 100644 index 0000000..8a0c42e --- /dev/null +++ b/tripo/features/download.py @@ -0,0 +1,30 @@ +"""下载 — fetch a model by operator id through a signed URL. + + tripocli download --operator -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 diff --git a/tripo/features/export_model.py b/tripo/features/export_model.py new file mode 100644 index 0000000..cf8a879 --- /dev/null +++ b/tripo/features/export_model.py @@ -0,0 +1,70 @@ +"""格式转换导出 — convert a model to another format. + + tripocli export -p -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 diff --git a/tripo/features/fill_parts.py b/tripo/features/fill_parts.py new file mode 100644 index 0000000..5a39221 --- /dev/null +++ b/tripo/features/fill_parts.py @@ -0,0 +1,49 @@ +"""部件补全 — fill / complete parts of a segmented model. + + tripocli fill -p --parts all --wait + tripocli fill -p --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_`, +智能分割后是每个部件一个 `tripo_part_`(实测 `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="部件补全") diff --git a/tripo/features/image_assets.py b/tripo/features/image_assets.py new file mode 100644 index 0000000..f5c25b0 --- /dev/null +++ b/tripo/features/image_assets.py @@ -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 diff --git a/tripo/features/image_gen.py b/tripo/features/image_gen.py new file mode 100644 index 0000000..bde7b89 --- /dev/null +++ b/tripo/features/image_gen.py @@ -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) diff --git a/tripo/features/image_multiview.py b/tripo/features/image_multiview.py new file mode 100644 index 0000000..a48d0ad --- /dev/null +++ b/tripo/features/image_multiview.py @@ -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) diff --git a/tripo/features/image_to_3d.py b/tripo/features/image_to_3d.py new file mode 100644 index 0000000..d7b650e --- /dev/null +++ b/tripo/features/image_to_3d.py @@ -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) diff --git a/tripo/features/import_model.py b/tripo/features/import_model.py new file mode 100644 index 0000000..0bec04d --- /dev/null +++ b/tripo/features/import_model.py @@ -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="导入") diff --git a/tripo/features/inspect_model.py b/tripo/features/inspect_model.py new file mode 100644 index 0000000..368c9a0 --- /dev/null +++ b/tripo/features/inspect_model.py @@ -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 diff --git a/tripo/features/mesh_edit.py b/tripo/features/mesh_edit.py new file mode 100644 index 0000000..7e714d2 --- /dev/null +++ b/tripo/features/mesh_edit.py @@ -0,0 +1,51 @@ +"""网格编辑(局部重拓扑)— local_edit on a 智能网格 (Nexus) model. + + tripocli edit -p --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="网格编辑") diff --git a/tripo/features/motion.py b/tripo/features/motion.py new file mode 100644 index 0000000..a8d1fa0 --- /dev/null +++ b/tripo/features/motion.py @@ -0,0 +1,151 @@ +"""文字生成动作 — text to motion(AI 动画),并把动作应用到角色。 + + tripocli motion -p "让角色挥手打招呼" --duration 5 --wait # 生成动作,得到 motion_asset_id + tripocli motion -p "让角色走一段" --project --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": "", "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 --motion-asset {asset_id} --wait"), + ), + ) + return payload diff --git a/tripo/features/multiview.py b/tripo/features/multiview.py new file mode 100644 index 0000000..38f8575 --- /dev/null +++ b/tripo/features/multiview.py @@ -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) diff --git a/tripo/features/optimize_model.py b/tripo/features/optimize_model.py new file mode 100644 index 0000000..417ccfe --- /dev/null +++ b/tripo/features/optimize_model.py @@ -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 diff --git a/tripo/features/pbr.py b/tripo/features/pbr.py new file mode 100644 index 0000000..d6c563a --- /dev/null +++ b/tripo/features/pbr.py @@ -0,0 +1,32 @@ +"""PBR — generate metallic/roughness/normal maps. + + tripocli pbr -p --wait -o pbr.glb + +Endpoint ``/operation/pbr_generate``. Verified live: it needs the project's +``model_version`` **and** ``part_name_list`` (``tripo_node_``) — +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 生成") diff --git a/tripo/features/project.py b/tripo/features/project.py new file mode 100644 index 0000000..39f85b5 --- /dev/null +++ b/tripo/features/project.py @@ -0,0 +1,30 @@ +"""项目详情 — inspect a project and its current asset. + + tripocli project +""" + +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 diff --git a/tripo/features/remesh.py b/tripo/features/remesh.py new file mode 100644 index 0000000..c2232af --- /dev/null +++ b/tripo/features/remesh.py @@ -0,0 +1,36 @@ +"""重拓扑 — remesh an existing project into clean quad/tri topology. + + tripocli remesh -p --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_``. +""" + +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="重拓扑") diff --git a/tripo/features/rename.py b/tripo/features/rename.py new file mode 100644 index 0000000..e89f2b2 --- /dev/null +++ b/tripo/features/rename.py @@ -0,0 +1,25 @@ +"""重命名项目 — rename a project. + + tripocli rename -p --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 diff --git a/tripo/features/rigging.py b/tripo/features/rigging.py new file mode 100644 index 0000000..764cd4a --- /dev/null +++ b/tripo/features/rigging.py @@ -0,0 +1,80 @@ +"""绑定 — auto rigging with UniRig. + + tripocli rig -p --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="绑定") diff --git a/tripo/features/segmentation.py b/tripo/features/segmentation.py new file mode 100644 index 0000000..900122e --- /dev/null +++ b/tripo/features/segmentation.py @@ -0,0 +1,42 @@ +"""智能分割 — split a model into editable semantic parts. + + tripocli segment -p --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="智能分割") diff --git a/tripo/features/serve.py b/tripo/features/serve.py new file mode 100644 index 0000000..e6259bf --- /dev/null +++ b/tripo/features/serve.py @@ -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} diff --git a/tripo/features/stylize.py b/tripo/features/stylize.py new file mode 100644 index 0000000..3ccf6b3 --- /dev/null +++ b/tripo/features/stylize.py @@ -0,0 +1,28 @@ +"""3D 风格化 — stylize an existing model. + + tripocli stylize -p --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="风格化") diff --git a/tripo/features/task.py b/tripo/features/task.py new file mode 100644 index 0000000..b1a29da --- /dev/null +++ b/tripo/features/task.py @@ -0,0 +1,67 @@ +"""任务进度 — poll or block on an operator. + + tripocli task status + tripocli task wait -p -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) diff --git a/tripo/features/text_to_3d.py b/tripo/features/text_to_3d.py new file mode 100644 index 0000000..056711d --- /dev/null +++ b/tripo/features/text_to_3d.py @@ -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) diff --git a/tripo/features/texture.py b/tripo/features/texture.py new file mode 100644 index 0000000..2fbec03 --- /dev/null +++ b/tripo/features/texture.py @@ -0,0 +1,42 @@ +"""纹理生成 — (re)texture a model. + + tripocli texture -p --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="纹理生成") diff --git a/tripo/features/texture_upscale.py b/tripo/features/texture_upscale.py new file mode 100644 index 0000000..f614786 --- /dev/null +++ b/tripo/features/texture_upscale.py @@ -0,0 +1,39 @@ +"""纹理放大 — upscale an existing texture. + + tripocli upscale -p --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="纹理放大") diff --git a/tripo/features/update.py b/tripo/features/update.py new file mode 100644 index 0000000..4ca956d --- /dev/null +++ b/tripo/features/update.py @@ -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 diff --git a/tripo/features/uv.py b/tripo/features/uv.py new file mode 100644 index 0000000..cd3a11b --- /dev/null +++ b/tripo/features/uv.py @@ -0,0 +1,81 @@ +"""智能 UV — unfold UVs for a low-poly model. + + tripocli uv -p --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" + ) diff --git a/tripo/glb.py b/tripo/glb.py new file mode 100644 index 0000000..92a0108 --- /dev/null +++ b/tripo/glb.py @@ -0,0 +1,167 @@ +"""Read metadata and node names out of a GLB/GLTF file, using only the standard library. + +The studio builds ``part_name_list`` from the mesh's *node names*, which live +inside the model file — the project detail API never returns them. Unsegmented +models carry a single ``tripo_node_`` node; a segmented model +carries one ``tripo_part_`` node per part. +""" + +import json +import os +import struct + +GLB_MAGIC = b"glTF" +JSON_CHUNK = 0x4E4F534A + + +def gltf_document(data): + """Return the glTF JSON document from a ``.glb`` or ``.gltf`` byte string.""" + if data[:4] == GLB_MAGIC: + offset = 12 + while offset < len(data): + length, kind = struct.unpack_from(" -f glb --wait` 转为 GLB") + return json.loads(data.decode("utf-8")) + + +def read_gltf_file(path): + """Read just the JSON header chunk from a local ``.glb`` or ``.gltf`` file.""" + with open(path, "rb") as handle: + head = handle.read(20) + if head[:4] == GLB_MAGIC and len(head) >= 20: + length, kind = struct.unpack_from("= 3 and len(amax) >= 3: + has_bbox = True + for axis in range(3): + min_pt[axis] = min(min_pt[axis], float(amin[axis])) + max_pt[axis] = max(max_pt[axis], float(amax[axis])) + + mode = prim.get("mode", 4) # 4 = TRIANGLES + idx_acc_id = prim.get("indices") + if idx_acc_id is not None and 0 <= idx_acc_id < len(accessors): + idx_count = accessors[idx_acc_id].get("count") or 0 + if mode == 4: + triangles += idx_count // 3 + elif mode in (5, 6): + triangles += max(0, idx_count - 2) + elif mode == 4 and pos_count: + triangles += pos_count // 3 + + bbox = None + if has_bbox: + size = [round(max_pt[i] - min_pt[i], 4) for i in range(3)] + bbox = { + "min": [round(v, 4) for v in min_pt], + "max": [round(v, 4) for v in max_pt], + "size": size, + "ground_offset_y": round(-min_pt[1], 4), + } + + has_pbr = False + for mat in materials: + pbr = mat.get("pbrMetallicRoughness") or {} + if pbr.get("metallicRoughnessTexture") or mat.get("normalTexture"): + has_pbr = True + break + + joint_indices = set() + for skin in skins: + joint_indices.update(skin.get("joints") or []) + + mesh_nodes = [ + n.get("name") for i, n in enumerate(nodes) + if n.get("name") and ("mesh" in n or i not in joint_indices) + ] + anim_list = [ + {"name": a.get("name") or f"clip_{i}", "channels": len(a.get("channels") or [])} + for i, a in enumerate(animations) + ] + + warnings = [] + if not meshes and anim_list: + warnings.append("纯骨骼动画轨道文件(无网格),在引擎中需挂载到 rig 导出的角色本体网格上播放") + if triangles > 100000: + warnings.append( + f"面数较高 ({triangles:,} 三角面),放入实时游戏场景建议先运行: " + f"tripocli optimize {os.path.basename(path)}" + ) + if meshes and not textures: + warnings.append("无贴图白模(materials 未包含纹理贴图)") + + return { + "file": abspath, + "name": os.path.basename(abspath), + "file_size": file_size, + "file_size_mb": round(file_size / (1024 * 1024), 2), + "meshes": len(meshes), + "primitives": primitives_count, + "vertices": vertices, + "triangles": triangles, + "bbox": bbox, + "materials": len(materials), + "textures": len(textures), + "images": len(images), + "has_pbr_maps": has_pbr, + "is_rigged": bool(skins), + "joints": len(joint_indices), + "animations": anim_list, + "draco_compressed": "KHR_draco_mesh_compression" in extensions, + "extensions": extensions, + "mesh_nodes": mesh_nodes[:20], + "warnings": warnings, + } diff --git a/tripo/motions.py b/tripo/motions.py new file mode 100644 index 0000000..f613a28 --- /dev/null +++ b/tripo/motions.py @@ -0,0 +1,44 @@ +"""动作预设目录。 + +数据来自 studio 前端的静态表(页面加载的 chunk 里的 ``biped:[{action_type,category,name}...]``), +因为服务端**没有**提供预设清单接口(实测:``/motion/list_assets`` 返回的是用户自己的动画资产, +``/operation/get_user_operation_context`` 只返回「最近用过」的几个)。 + +动作标识格式:``preset::``,例如 ``preset:biped:afraid``。 +每个动作的预览图:``https://tripo-public.tripo3d.ai/studio_riging_data//.avif`` +""" + +import json +from pathlib import Path + +DATA_PATH = Path(__file__).parent / "data" / "motions.json" +PREVIEW_BASE = "https://tripo-public.tripo3d.ai/studio_riging_data" + +_cache = None + + +def catalog(): + """``{rig_type: [{action_type, category, name}, ...]}``""" + global _cache + if _cache is None: + try: + _cache = json.loads(DATA_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + _cache = {} + return _cache + + +def rig_types(): + return sorted(catalog()) + + +def names(rig_type="biped"): + return [item["name"] for item in catalog().get(rig_type, [])] + + +def identifier(rig_type, name): + return f"preset:{rig_type}:{name}" + + +def preview_url(rig_type, name): + return f"{PREVIEW_BASE}/{rig_type}/{name}.avif" diff --git a/tripo/output.py b/tripo/output.py new file mode 100644 index 0000000..d62bc62 --- /dev/null +++ b/tripo/output.py @@ -0,0 +1,46 @@ +"""Terminal output helpers. + +Every command prints either a human readable block or, with ``--json``, a single +JSON document on stdout so an agent can parse it. Errors go to stderr (and to a +JSON object on stdout when ``--json`` is active) so stdout stays machine safe. +""" + +import json +import sys + + +class Out: + def __init__(self, as_json=False): + self.as_json = as_json + + def json(self, payload): + print(json.dumps(payload, indent=2, ensure_ascii=False)) + + def data(self, payload, human=None): + """Print ``payload`` as JSON, or ``human()`` when not in JSON mode.""" + if self.as_json: + self.json(payload) + elif human: + human() + + def line(self, text=""): + if not self.as_json: + print(text) + + def notice(self, text): + """Progress/warning for humans; goes to stderr so stdout stays parseable.""" + print(text, file=sys.stderr) + + def progress(self, text): + """In-place progress line; suppressed in JSON mode.""" + if not self.as_json: + print(text, end="\r", flush=True) + + def progress_done(self): + if not self.as_json: + print() + + def error(self, message): + if self.as_json: + self.json({"error": str(message)}) + print(f"❌ {message}", file=sys.stderr) diff --git a/tripo/s3.py b/tripo/s3.py new file mode 100644 index 0000000..506c5e4 --- /dev/null +++ b/tripo/s3.py @@ -0,0 +1,82 @@ +"""Direct-to-S3 upload with AWS SigV4, using only the standard library. + +The studio hands out temporary AWS credentials via +``POST /v2/studio/storage/temporary_token``; the browser then PUTs the raw bytes +straight to S3 (``https://{bucket}.{host}/{key}``). No boto3 required. +""" + +import datetime +import hashlib +import hmac +import urllib.request + +REGION = "us-west-2" +SERVICE = "s3" + + +def _sign(key, message): + return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest() + + +def upload_bytes(sts, data, content_type="application/octet-stream", timeout=120): + """PUT ``data`` to the S3 object described by the STS response. + + ``sts`` is the ``data`` block of ``/v2/studio/storage/temporary_token``: + ``sts_ak``, ``sts_sk``, ``session_token``, ``host``, ``resource_bucket``, + ``resource_uri``. + """ + access_key = sts["sts_ak"] + secret_key = sts["sts_sk"] + session_token = sts["session_token"] + host = sts.get("host", "s3.us-west-2.amazonaws.com") + bucket = sts["resource_bucket"] + key = sts["resource_uri"] + + now = datetime.datetime.now(datetime.timezone.utc) + amz_date = now.strftime("%Y%m%dT%H%M%SZ") + date_stamp = now.strftime("%Y%m%d") + + host_header = f"{bucket}.{host}" + payload_hash = hashlib.sha256(data).hexdigest() + + canonical_headers = ( + f"host:{host_header}\n" + f"x-amz-content-sha256:{payload_hash}\n" + f"x-amz-date:{amz_date}\n" + f"x-amz-security-token:{session_token}\n" + ) + signed_headers = "host;x-amz-content-sha256;x-amz-date;x-amz-security-token" + canonical_request = f"PUT\n/{key}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}" + + scope = f"{date_stamp}/{REGION}/{SERVICE}/aws4_request" + string_to_sign = ( + f"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n" + f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}" + ) + + k_date = _sign(("AWS4" + secret_key).encode("utf-8"), date_stamp) + k_region = _sign(k_date, REGION) + k_service = _sign(k_region, SERVICE) + k_signing = _sign(k_service, "aws4_request") + signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest() + + authorization = ( + f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, " + f"SignedHeaders={signed_headers}, Signature={signature}" + ) + + request = urllib.request.Request( + f"https://{host_header}/{key}", + data=data, + method="PUT", + headers={ + "Host": host_header, + "x-amz-date": amz_date, + "x-amz-content-sha256": payload_hash, + "x-amz-security-token": session_token, + "Authorization": authorization, + "Content-Type": content_type, + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status in (200, 204) diff --git a/tripo/server.py b/tripo/server.py new file mode 100644 index 0000000..7107de2 --- /dev/null +++ b/tripo/server.py @@ -0,0 +1,253 @@ +"""Local account-management service (``tripocli serve``). + +Serves a small web page plus a JSON API so cookies can be added, updated and +inspected from a browser instead of the terminal: + + GET / the page + GET /api/accounts list accounts (identity, session expiry, credits) + POST /api/accounts add/update an account {name?, cookie} + POST /api/accounts//activate make it the active account + POST /api/accounts//refresh re-validate the cookie and re-mint the JWT + DELETE /api/accounts/ remove an account + +The page never receives a full cookie — only a masked form. The server binds to +loopback by default because whoever can reach it can act as the signed-in user. +""" + +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import unquote, urlparse + +from . import auth +from .accounts import Accounts +from .client import TripoClient +from .config import Config +from .errors import TripoAPIError + +PAGE = Path(__file__).parent / "web" / "accounts.html" +CREDIT_CACHE_SECONDS = 30 + + +def _mask(value, keep=8): + if not value: + return None + return f"{value[:keep]}…{value[-4:]}" if len(value) > keep + 6 else "***" + + +class AccountService: + """Account store plus a short-lived credits cache for the list view.""" + + def __init__(self, accounts=None, config=None): + self.accounts = accounts or Accounts() + self.config = config or Config() + self._lock = threading.Lock() + self._cache = {} # name -> (timestamp, payload) + + # --- helpers --- + + def _client(self, name=None): + return TripoClient(account=name, accounts=self.accounts, config=self.config) + + def _live_status(self, name): + """Credits/membership for one account, cached briefly to keep the page snappy.""" + cached = self._cache.get(name) + if cached and time.time() - cached[0] < CREDIT_CACHE_SECONDS: + return cached[1] + + status = {"total_credit": None, "expiring_credit": None, "expiring_date": None, + "member_type": None, "member_valid_until": None, "token_expires_at": None, + "error": None} + try: + client = self._client(name) + client.ensure_credentials() + profile = client.payment_profile() or {} + wallet = profile.get("wallet") or {} + member = profile.get("member") or {} + status.update({ + "total_credit": wallet.get("total_credit"), + "expiring_credit": wallet.get("expiring_credit"), + "expiring_date": wallet.get("expiring_date"), + "member_type": member.get("type"), + "member_valid_until": member.get("valid_until"), + "token_expires_at": _iso(client.token), + }) + except Exception as error: + status["error"] = str(error)[:200] + + self._cache[name] = (time.time(), status) + return status + + def list_accounts(self): + active = self.accounts.active_name() + result = [] + for name in self.accounts.names(): + record = self.accounts.data["accounts"][name] + item = { + "name": name, + "active": name == active, + "email": record.get("email"), + "user_id": record.get("user_id"), + "session_expires_at": record.get("session_expires_at"), + "cookie": _mask(record.get("cookie")), + } + item.update(self._live_status(name)) + result.append(item) + return {"active": active, "accounts": result} + + def add_account(self, name, cookie): + if not cookie: + raise ValueError("cookie 不能为空") + token, session_expires = auth.mint_token(self.config.api_base, cookie, self.config.device_id) + try: + user_id, email = auth.fetch_identity(self.config.api_base, cookie, self.config.device_id) + except Exception: + user_id, email = None, None + + name = (name or email or user_id or "default").strip() + with self._lock: + self.accounts.add(name, auth.normalize_cookie(cookie), token=token, + session_expires_at=session_expires, user_id=user_id, email=email) + self._cache.pop(name, None) + return {"name": name, "email": email, "user_id": user_id, "session_expires_at": session_expires} + + def activate(self, name): + with self._lock: + if not self.accounts.exists(name): + raise KeyError(name) + self.accounts.use(name) + return {"active": name} + + def remove(self, name): + with self._lock: + if not self.accounts.exists(name): + raise KeyError(name) + self.accounts.remove(name) + self._cache.pop(name, None) + return {"removed": name, "active": self.accounts.active_name()} + + def refresh(self, name): + with self._lock: + _, record = self.accounts.get(name) + if not record: + raise KeyError(name) + cookie = record.get("cookie") + if not cookie: + raise ValueError(f"账号 {name} 没有 cookie") + token, session_expires = auth.mint_token(self.config.api_base, cookie, self.config.device_id) + with self._lock: + self.accounts.update_token(name, token, session_expires) + self._cache.pop(name, None) + return {"name": name, "token_expires_at": _iso(token), "session_expires_at": session_expires} + + +def _iso(token): + exp = auth.decode_jwt_payload(token or "").get("exp") + if not exp: + return None + return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(int(exp))) + + +class Handler(BaseHTTPRequestHandler): + server_version = "tripocli" + service: AccountService = None + + # --- plumbing --- + + def log_message(self, fmt, *args): + print(f"[serve] {self.address_string()} {fmt % args}") + + def _send(self, status, payload, content_type="application/json; charset=utf-8"): + body = payload if isinstance(payload, bytes) else json.dumps(payload, ensure_ascii=False).encode() + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def _error(self, status, message): + self._send(status, {"error": str(message)}) + + def _read_json(self): + length = int(self.headers.get("Content-Length") or 0) + if not length: + return {} + try: + return json.loads(self.rfile.read(length).decode("utf-8")) + except json.JSONDecodeError: + raise ValueError("请求体不是合法 JSON") + + # --- routing --- + + def _segments(self): + path = urlparse(self.path).path + return [unquote(part) for part in path.strip("/").split("/") if part] + + def do_GET(self): + parts = self._segments() + if not parts: + try: + self._send(200, PAGE.read_bytes(), "text/html; charset=utf-8") + except OSError as error: + self._error(500, f"页面文件读取失败: {error}") + return + if parts == ["api", "accounts"]: + self._send(200, self.service.list_accounts()) + return + if parts == ["api", "health"]: + self._send(200, {"ok": True}) + return + self._error(404, "not found") + + def do_POST(self): + parts = self._segments() + try: + if parts == ["api", "accounts"]: + body = self._read_json() + self._send(200, self.service.add_account(body.get("name", ""), body.get("cookie", ""))) + return + if len(parts) == 4 and parts[:2] == ["api", "accounts"]: + name, action = parts[2], parts[3] + if action == "activate": + self._send(200, self.service.activate(name)) + return + if action == "refresh": + self._send(200, self.service.refresh(name)) + return + except KeyError as error: + self._error(404, f"账号不存在: {error.args[0]}") + return + except (ValueError, TripoAPIError) as error: + self._error(400, error) + return + except Exception as error: + self._error(500, error) + return + self._error(404, "not found") + + def do_DELETE(self): + parts = self._segments() + if len(parts) == 3 and parts[:2] == ["api", "accounts"]: + try: + self._send(200, self.service.remove(parts[2])) + except KeyError as error: + self._error(404, f"账号不存在: {error.args[0]}") + except Exception as error: + self._error(500, error) + return + self._error(404, "not found") + + +def create_server(host="127.0.0.1", port=8765, service=None): + handler = type("BoundHandler", (Handler,), {"service": service or AccountService()}) + return ThreadingHTTPServer((host, port), handler) + + +def serve(host="127.0.0.1", port=8765): + httpd = create_server(host, port) + actual_port = httpd.server_address[1] + return httpd, f"http://{host}:{actual_port}/" diff --git a/tripo/update.py b/tripo/update.py new file mode 100644 index 0000000..ff8be57 --- /dev/null +++ b/tripo/update.py @@ -0,0 +1,128 @@ +"""Self-update: refresh the installed CLI from the project source tree. + +The project is distributed as a source directory (no PyPI release yet), so +"updating" means: find the source tree, re-install it with the *same* +interpreter that is running us, and rehash pyenv shims if pyenv is in use. + +The source directory is remembered in ``~/.tripo/config.json`` (``install_source``) +the first time it is discovered, so later runs only need ``tripocli update``. +""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +from . import __version__ + +INSTALL_SOURCE_KEY = "install_source" + + +def _running_package_dir(): + """Directory of the ``tripo`` package that is currently imported.""" + return Path(__file__).resolve().parent + + +def _is_site_packages(path): + parts = {p.lower() for p in Path(path).parts} + return bool(parts & {"site-packages", "dist-packages"}) + + +def detect_source(explicit=None, config=None): + """Locate the source tree to install from. + + Order: ``--from`` > remembered ``install_source`` > the tree we were imported + from (only when that is not site-packages). + """ + if explicit: + candidate = Path(explicit).expanduser().resolve() + if not (candidate / "pyproject.toml").exists(): + raise ValueError(f"{candidate} 看起来不是项目目录(缺少 pyproject.toml)") + return candidate + + if config: + remembered = config.data.get(INSTALL_SOURCE_KEY) + if remembered and (Path(remembered) / "pyproject.toml").exists(): + return Path(remembered) + + package_dir = _running_package_dir() + if not _is_site_packages(package_dir): + root = package_dir.parent + if (root / "pyproject.toml").exists(): + return root + + return None + + +def installed_version(): + """Version of the copy in site-packages (what other directories will run).""" + try: + out = subprocess.run( + [sys.executable, "-m", "pip", "show", "tripocli"], + capture_output=True, text=True, timeout=60, + ) + except (OSError, subprocess.SubprocessError): + return None + for line in out.stdout.splitlines(): + if line.lower().startswith("version:"): + return line.split(":", 1)[1].strip() + return None + + +def _has_pyenv(): + return bool(shutil.which("pyenv") or os.environ.get("PYENV_ROOT")) + + +def update(source, rehash=True, dry_run=False): + """Re-install ``source`` with the running interpreter. Returns a report dict.""" + source = Path(source) + steps = [] + command = [ + sys.executable, "-m", "pip", "install", + "--no-build-isolation", "--no-deps", "--upgrade", "--force-reinstall", str(source), + ] + + if dry_run: + planned = [" ".join(command)] + if rehash and _has_pyenv(): + planned.append("pyenv rehash") + return {"source": str(source), "command": command, "steps": planned, + "rehash": False, "dry_run": True, "version": __version__} + + proc = subprocess.run(command, capture_output=True, text=True) + steps.append(" ".join(command)) + tail = "\n".join((proc.stdout or "").strip().splitlines()[-6:]) + if proc.returncode != 0: + raise RuntimeError( + f"pip 安装失败(退出码 {proc.returncode}):\n{(proc.stderr or proc.stdout or '')[-800:]}" + ) + steps.append(tail) + + rehashed = False + if rehash and _has_pyenv(): + subprocess.run(["pyenv", "rehash"], capture_output=True, text=True) + steps.append("pyenv rehash") + rehashed = True + + return { + "source": str(source), + "command": command, + "steps": steps, + "rehash": rehashed, + "version": __version__, + "installed_version": installed_version(), + } + + +def report_lines(report): + prefix = "将执行" if report.get("dry_run") else "已执行" + lines = [ + f"源码目录 : {report['source']}", + f"版本 : {report.get('installed_version') or report['version']}", + f"{prefix} : {'; '.join(report['steps'])}", + ] + if report.get("rehash"): + lines.append("已执行 : pyenv rehash") + return lines diff --git a/tripo/versions.py b/tripo/versions.py new file mode 100644 index 0000000..d0996db --- /dev/null +++ b/tripo/versions.py @@ -0,0 +1,42 @@ +"""Model version aliases. + +The web studio exposes friendly names (``P2.0``, ``H3.1`` ...) that map onto the +internal ``model_version`` strings the API expects. Verified against the live +API: ``Nexus-v2.0-20260801`` and ``v3.1-20260211`` both generate successfully. +""" + +SMART_MESH_DEFAULT = "Nexus-v2.0-20260801" +HIGH_DETAIL_DEFAULT = "v3.1-20260211" + +# Aliases are matched case-insensitively. +ALIASES = { + # 智能网格 Smart Mesh + "p2.0": SMART_MESH_DEFAULT, + "p2": SMART_MESH_DEFAULT, + "nexus-v2": SMART_MESH_DEFAULT, + "p1.0": "Nexus-v1.0-20260214", + "p1": "Nexus-v1.0-20260214", + "nexus-v1": "Nexus-v1.0-20260214", + # 高精度模型 High-Detail + "h3.1": HIGH_DETAIL_DEFAULT, + "v3.1": HIGH_DETAIL_DEFAULT, + "h3.0": "v3.0-20250812", + "v3.0": "v3.0-20250812", + "h2.5": "v2.5-20250123", + "v2.5": "v2.5-20250123", +} + + +def resolve(name, smart_mesh=False): + """Map a user supplied alias to an internal model version string. + + Unknown values pass through untouched so a brand new server-side version can + still be used without a client release. + """ + if not name: + return SMART_MESH_DEFAULT if smart_mesh else HIGH_DETAIL_DEFAULT + return ALIASES.get(name.strip().lower(), name.strip()) + + +def default_for(smart_mesh=False): + return SMART_MESH_DEFAULT if smart_mesh else HIGH_DETAIL_DEFAULT diff --git a/tripo/web/accounts.html b/tripo/web/accounts.html new file mode 100644 index 0000000..d1ebd65 --- /dev/null +++ b/tripo/web/accounts.html @@ -0,0 +1,178 @@ + + + + + +tripocli · 账号管理 + + + +
+

tripocli · 账号管理

+

粘贴 cookie 添加账号;CLI 会用 cookie 自动续期 JWT,无需浏览器常驻。

+
+
+
+

添加 / 更新账号

+
+
+ + +
+
+ + +
+
+
+ + +
+

获取 cookie:DevTools → Network → 任意 api.tripo3d.ai 请求 → Request Headers → 复制整条 Cookie。

+
+
+ +
+

账号列表

+ + + + + +
账号身份会话有效期积分会员操作
加载中…
+
+
+
+ + + + diff --git a/tripocli b/tripocli new file mode 120000 index 0000000..966f049 --- /dev/null +++ b/tripocli @@ -0,0 +1 @@ +tripocli.py \ No newline at end of file diff --git a/tripocli.py b/tripocli.py new file mode 100755 index 0000000..f9c7f80 --- /dev/null +++ b/tripocli.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""tripocli entry point. + +The implementation lives in the ``tripo`` package, split one module per feature +(see ``tripo/features``); this file only bootstraps the package import so the +command works when invoked directly or through the ``tripocli`` symlink. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from tripo.cli import main # noqa: E402 + +if __name__ == "__main__": + sys.exit(main())