platform-tripo支持图片上传与预览图下载

- 产物下载类型改名为 TripoDownloadedArtifact,模型与预览图共用同一条流式读取路径
- 新增 download_rendered_image,读取 task 结果里的预览图地址
- 新增 upload_image,把图片字节上传 provider 换 file_token
- 图片输入收敛为 TripoImageInput 枚举,只有 file_token 与公网地址两种形态,不再接受裸字符串
- smoke 示例跟随新的图片输入类型
This commit is contained in:
2026-09-21 14:06:47 +08:00
parent 4edeadae7d
commit a8799adf5d
7 changed files with 126 additions and 24 deletions
@@ -14,7 +14,7 @@
use std::{env, time::Duration};
use platform_tripo::TripoProviderClient;
use platform_tripo::{TripoImageInput, TripoProviderClient};
use shared_contracts::model3d::{
common::{Model3dModelVersion, Model3dTaskStatus, Model3dTextureQuality},
image_to_model::Model3dImageToModelParams,
@@ -73,7 +73,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let image = client
.submit_image_to_model(
SAMPLE_IMAGE_URL,
&TripoImageInput::PublicUrl(SAMPLE_IMAGE_URL.to_string()),
&Model3dImageToModelParams {
model: Model3dModelVersion::H31,
enable_image_autofix: None,
@@ -3,8 +3,8 @@ use std::time::Duration;
use tripo3d_sdk::TripoClient;
use super::{
TripoDownloadedModel, TripoError, TripoSettings, TripoTaskHandle, TripoTaskSnapshot, TripoUrl,
map_task, validate_task_id,
TripoDownloadedArtifact, TripoError, TripoSettings, TripoTaskHandle, TripoTaskSnapshot,
TripoUrl, map_task, validate_task_id,
};
pub struct TripoProviderClient {
@@ -46,7 +46,7 @@ impl TripoProviderClient {
pub async fn download_model(
&self,
task: &TripoTaskSnapshot,
) -> Result<TripoDownloadedModel, TripoError> {
) -> Result<TripoDownloadedArtifact, TripoError> {
let handle = &task.handle;
validate_task_id(&handle.task_id)?;
let output = task
@@ -62,11 +62,32 @@ impl TripoProviderClient {
.await
}
/// 下载 provider 渲染出的预览图。
///
/// 预览图和模型是同一次 task 结果里的两个产物,共用同一条产物下载链路;
/// 命名按“预览图”而不是“渲染图”,避免调用方把它当成模型对象。
pub async fn download_rendered_image(
&self,
task: &TripoTaskSnapshot,
) -> Result<TripoDownloadedArtifact, TripoError> {
let handle = &task.handle;
validate_task_id(&handle.task_id)?;
let output = task
.output
.as_ref()
.ok_or_else(|| TripoError::OutputSchema {
task_id: handle.task_id.clone(),
message: "task has no completed model output".into(),
})?;
self.download_artifact(&handle.task_id, output.rendered_image_url())
.await
}
async fn download_artifact(
&self,
task_id: &str,
url: &TripoUrl,
) -> Result<TripoDownloadedModel, TripoError> {
) -> Result<TripoDownloadedArtifact, TripoError> {
let total_attempts = self.artifact_retries.saturating_add(1);
let mut last_error = None;
@@ -88,7 +109,7 @@ impl TripoProviderClient {
&self,
task_id: &str,
url: &TripoUrl,
) -> Result<TripoDownloadedModel, TripoError> {
) -> Result<TripoDownloadedArtifact, TripoError> {
let response = self
.artifact_client
.get(url.as_str())
@@ -115,7 +136,7 @@ impl TripoProviderClient {
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let content_length = response.content_length();
Ok(TripoDownloadedModel::new(
Ok(TripoDownloadedArtifact::new(
url.clone(),
content_type,
content_length,
@@ -10,7 +10,7 @@ pub use config::TripoSettings;
pub use error::{TripoError, TripoField, TripoValidationReason};
pub(crate) use mapping::map_task;
pub use types::{
TripoDownloadedModel, TripoTaskFailure, TripoTaskHandle, TripoTaskOutput, TripoTaskSnapshot,
TripoDownloadedArtifact, TripoTaskFailure, TripoTaskHandle, TripoTaskOutput, TripoTaskSnapshot,
TripoTaskType, TripoUrl,
};
pub(crate) use validation::{
@@ -85,6 +85,15 @@ impl TripoTaskOutput {
Self::MultiviewToModel(result) => &result.model_url,
}
}
/// 预览图地址;三个端点都返回渲染图,缺失时按输出结构错误处理。
pub(crate) fn rendered_image_url(&self) -> &TripoUrl {
match self {
Self::TextToModel(result) => &result.rendered_image_url,
Self::ImageToModel(result) => &result.rendered_image_url,
Self::MultiviewToModel(result) => &result.rendered_image_url,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -99,7 +108,9 @@ pub struct TripoTaskSnapshot {
pub completed_at: Option<String>,
}
pub struct TripoDownloadedModel {
/// provider 产物的下载句柄。模型与预览图共用同一条流式读取路径,
/// 因此类型名按“产物”而不是“模型”命名。
pub struct TripoDownloadedArtifact {
pub url: TripoUrl,
pub content_type: Option<String>,
pub content_length: Option<u64>,
@@ -108,7 +119,7 @@ pub struct TripoDownloadedModel {
received: u64,
}
impl TripoDownloadedModel {
impl TripoDownloadedArtifact {
pub(crate) fn new(
url: TripoUrl,
content_type: Option<String>,
@@ -11,22 +11,91 @@ use crate::common::{
use super::validation::validate_image_to_model_params;
/// provider 能读取的图片输入形态。
///
/// 平台层只接受站内引用,所以调用方只会产出这两种形态:先上传拿 `file_token`
/// 或给出公网可读地址。不提供裸字符串变体,避免 SDK 把字符串按前缀推断成 task_id。
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TripoImageInput {
FileToken(String),
PublicUrl(String),
}
impl TripoImageInput {
fn to_sdk_file_input(&self) -> Result<FileInput, TripoError> {
let (value, kind) = match self {
Self::FileToken(value) => (value, "file_token"),
Self::PublicUrl(value) => (value, "url"),
};
let value = value.trim();
if value.is_empty() {
return Err(TripoError::InvalidParameters {
field: Some(TripoField::Input),
reason: TripoValidationReason::Required,
message: format!("image {kind} must not be blank"),
});
}
Ok(match self {
Self::FileToken(_) => FileInput::FileToken(value.to_string()),
Self::PublicUrl(_) => FileInput::Url(value.to_string()),
})
}
}
impl TripoProviderClient {
/// 把图片字节上传到 provider,换取可直接用于生成任务的 `file_token`。
///
/// 站内图片保存在私有 OSS 上,provider 无法直接读取,所以图片输入统一先上传再提交,
/// 不把带签名的临时地址交给第三方。
pub async fn upload_image(
&self,
bytes: Vec<u8>,
file_name: &str,
content_type: &str,
) -> Result<TripoImageInput, TripoError> {
let file_name = file_name.trim();
if file_name.is_empty() {
return Err(TripoError::InvalidParameters {
field: Some(TripoField::Input),
reason: TripoValidationReason::Required,
message: "upload file name must not be blank".into(),
});
}
if bytes.is_empty() {
return Err(TripoError::InvalidParameters {
field: Some(TripoField::Input),
reason: TripoValidationReason::Required,
message: "upload body must not be empty".into(),
});
}
let content_type = content_type.trim();
let uploaded = self
.client
.upload_file(
bytes,
file_name.to_string(),
(!content_type.is_empty()).then_some(content_type),
)
.await
.map_err(TripoError::from)?;
let token = uploaded.file_token.trim().to_string();
if token.is_empty() {
return Err(TripoError::OutputSchema {
task_id: "upload".into(),
message: "upload response has no file_token".into(),
});
}
Ok(TripoImageInput::FileToken(token))
}
/// 图片输入由调用方解析后传入:provider 只负责把它交给 SDK
/// 站内资源 / 素材的归属校验属于平台层。
pub async fn submit_image_to_model(
&self,
input: &str,
input: &TripoImageInput,
params: &Model3dImageToModelParams,
) -> Result<TripoTaskHandle, TripoError> {
let input = input.trim();
if input.is_empty() {
return Err(TripoError::InvalidParameters {
field: Some(TripoField::Input),
reason: TripoValidationReason::Required,
message: "input must not be blank".into(),
});
}
let input = input.to_sdk_file_input()?;
validate_image_to_model_params(params)?;
let task_id = self
@@ -38,9 +107,9 @@ impl TripoProviderClient {
}
}
fn to_sdk_params(input: &str, params: &Model3dImageToModelParams) -> ImageToModelParams {
fn to_sdk_params(input: FileInput, params: &Model3dImageToModelParams) -> ImageToModelParams {
ImageToModelParams {
input: FileInput::from(input),
input,
model: Some(params.model.as_str().to_owned()),
enable_image_autofix: params.enable_image_autofix,
model_seed: params.model_seed,
@@ -2,4 +2,5 @@ mod client;
pub mod result;
mod validation;
pub use client::TripoImageInput;
pub use validation::validate_image_to_model_params;
+2 -2
View File
@@ -9,9 +9,9 @@ pub mod multiview_to_model;
pub mod text_to_model;
pub use common::{
TripoDownloadedModel, TripoError, TripoField, TripoProviderClient, TripoSettings,
TripoDownloadedArtifact, TripoError, TripoField, TripoProviderClient, TripoSettings,
TripoTaskFailure, TripoTaskHandle, TripoTaskOutput, TripoTaskSnapshot, TripoTaskType, TripoUrl,
TripoValidationReason,
};
pub use image_to_model::validate_image_to_model_params;
pub use image_to_model::{TripoImageInput, validate_image_to_model_params};
pub use text_to_model::validate_text_to_model_params;