Files
tripocli/tripo/client.py
T

348 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.1.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