348 lines
14 KiB
Python
348 lines
14 KiB
Python
"""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
|