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