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
+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
|
||||
Reference in new issue
Block a user