改用Provider流式下载Tripo产物
保持第三方SDK不变并在Provider侧读取签名模型URL 逐块暴露模型响应并校验Content-Length,smoke示例改为异步落盘 为未来SDK原生stream能力保留迁移TODO
This commit is contained in:
Generated
+2
@@ -4159,6 +4159,8 @@ dependencies = [
|
||||
name = "platform-tripo"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"shared-contracts",
|
||||
"tokio",
|
||||
|
||||
@@ -8,7 +8,10 @@ license.workspace = true
|
||||
shared-contracts = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tripo3d-sdk = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["rustls-tls", "stream"] }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
tokio = { workspace = true, features = ["fs", "io-util", "macros", "rt-multi-thread"] }
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! output, and writes the downloaded model into the current directory. The
|
||||
//! SDK poller is used only here and is not exposed by `platform-tripo`.
|
||||
|
||||
use std::{env, fs, time::Duration};
|
||||
use std::{env, time::Duration};
|
||||
|
||||
use platform_tripo::TripoProviderClient;
|
||||
use shared_contracts::model3d::{
|
||||
@@ -21,6 +21,7 @@ use shared_contracts::model3d::{
|
||||
multiview_to_model::{Model3dMultiviewInputs, Model3dMultiviewToModelRequest},
|
||||
text_to_model::Model3dTextToModelRequest,
|
||||
};
|
||||
use tokio::{fs::File, io::AsyncWriteExt};
|
||||
use tripo3d_sdk::{ClientOptions, TripoClient, WaitOptions};
|
||||
|
||||
const SAMPLE_IMAGE_URL: &str = "https://www.rustacean.net/assets/rustacean-flat-happy.png";
|
||||
@@ -164,11 +165,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let downloaded = client.download_model(&snapshot).await?;
|
||||
let filename = downloaded.filename(name);
|
||||
fs::write(&filename, &downloaded.data)?;
|
||||
let mut file = File::create(&filename).await?;
|
||||
let mut bytes = 0u64;
|
||||
let mut downloaded = downloaded;
|
||||
while let Some(chunk) = downloaded.next_chunk().await? {
|
||||
bytes += chunk.len() as u64;
|
||||
file.write_all(&chunk).await?;
|
||||
}
|
||||
println!(
|
||||
"{name} artifact={} bytes={} url={}",
|
||||
filename,
|
||||
downloaded.data.len(),
|
||||
bytes,
|
||||
downloaded.url.redacted()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tripo3d_sdk::TripoClient;
|
||||
|
||||
use super::{
|
||||
@@ -7,12 +9,24 @@ use super::{
|
||||
|
||||
pub struct TripoProviderClient {
|
||||
pub(crate) client: TripoClient,
|
||||
artifact_client: reqwest::Client,
|
||||
artifact_retries: u32,
|
||||
}
|
||||
|
||||
impl TripoProviderClient {
|
||||
pub fn new(settings: TripoSettings) -> Result<Self, TripoError> {
|
||||
let artifact_client = reqwest::Client::builder()
|
||||
.user_agent(settings.user_agent.clone())
|
||||
.timeout(settings.request_timeout)
|
||||
.build()
|
||||
.map_err(|error| TripoError::Request {
|
||||
message: format!("failed to build artifact download client: {error}"),
|
||||
status: None,
|
||||
})?;
|
||||
Ok(Self {
|
||||
client: TripoClient::new(settings.client_options()).map_err(TripoError::from)?,
|
||||
artifact_client,
|
||||
artifact_retries: settings.retries,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,33 +49,91 @@ impl TripoProviderClient {
|
||||
) -> Result<TripoDownloadedModel, TripoError> {
|
||||
let handle = &task.handle;
|
||||
validate_task_id(&handle.task_id)?;
|
||||
// TODO SDK 只公开 download_model(&Task),必须按 handle 再取一次 task;若将来支持按 URL 下载,
|
||||
// 可直接使用 snapshot.output 里的模型 URL,省掉这次请求。
|
||||
let sdk_task = self
|
||||
.client
|
||||
.get_task(&handle.task_id)
|
||||
.await
|
||||
.map_err(TripoError::from)?;
|
||||
let downloaded = self
|
||||
.client
|
||||
.download_model(&sdk_task)
|
||||
.await
|
||||
.map_err(TripoError::from)?
|
||||
let output = task
|
||||
.output
|
||||
.as_ref()
|
||||
.ok_or_else(|| TripoError::OutputSchema {
|
||||
task_id: handle.task_id.clone(),
|
||||
message: "completed task has no model URL".into(),
|
||||
message: "task has no completed model output".into(),
|
||||
})?;
|
||||
let url = TripoUrl::parse(&downloaded.url).map_err(|error| match error {
|
||||
TripoError::OutputSchema { message, .. } => TripoError::OutputSchema {
|
||||
task_id: handle.task_id.clone(),
|
||||
message,
|
||||
},
|
||||
other => other,
|
||||
})?;
|
||||
Ok(TripoDownloadedModel {
|
||||
url,
|
||||
content_type: downloaded.content_type,
|
||||
data: downloaded.data,
|
||||
})
|
||||
// TODO SDK upstream: expose a streaming artifact API; then replace this
|
||||
// provider-side reqwest client with the SDK stream and remove the duplicate downloader.
|
||||
self.download_artifact(&handle.task_id, output.model_url())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn download_artifact(
|
||||
&self,
|
||||
task_id: &str,
|
||||
url: &TripoUrl,
|
||||
) -> Result<TripoDownloadedModel, TripoError> {
|
||||
let total_attempts = self.artifact_retries.saturating_add(1);
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 1..=total_attempts {
|
||||
match self.download_artifact_once(task_id, url).await {
|
||||
Ok(downloaded) => return Ok(downloaded),
|
||||
Err(error) if attempt < total_attempts && error.is_retryable() => {
|
||||
last_error = Some(error);
|
||||
tokio::time::sleep(download_backoff(attempt)).await;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.expect("artifact download has at least one attempt"))
|
||||
}
|
||||
|
||||
async fn download_artifact_once(
|
||||
&self,
|
||||
task_id: &str,
|
||||
url: &TripoUrl,
|
||||
) -> Result<TripoDownloadedModel, TripoError> {
|
||||
let response = self
|
||||
.artifact_client
|
||||
.get(url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| TripoError::Request {
|
||||
message: format!(
|
||||
"artifact download transport failure for task {task_id}: {}",
|
||||
transport_error_kind(&error)
|
||||
),
|
||||
status: None,
|
||||
})?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(TripoError::Request {
|
||||
message: format!("artifact download failed for task {task_id}"),
|
||||
status: Some(status.as_u16()),
|
||||
});
|
||||
}
|
||||
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
let content_length = response.content_length();
|
||||
Ok(TripoDownloadedModel::new(
|
||||
url.clone(),
|
||||
content_type,
|
||||
content_length,
|
||||
response,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn download_backoff(attempt: u32) -> Duration {
|
||||
Duration::from_millis(250u64.saturating_mul(2u64.saturating_pow(attempt.min(6))))
|
||||
}
|
||||
|
||||
fn transport_error_kind(error: &reqwest::Error) -> &'static str {
|
||||
if error.is_timeout() {
|
||||
"timeout"
|
||||
} else if error.is_connect() {
|
||||
"connectivity error"
|
||||
} else {
|
||||
"request error"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use std::fmt;
|
||||
|
||||
use bytes::Bytes;
|
||||
use reqwest::Response;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use shared_contracts::model3d::common::Model3dTaskStatus;
|
||||
@@ -74,6 +77,16 @@ pub enum TripoTaskOutput {
|
||||
MultiviewToModel(TripoMultiviewToModelResult),
|
||||
}
|
||||
|
||||
impl TripoTaskOutput {
|
||||
pub(crate) fn model_url(&self) -> &TripoUrl {
|
||||
match self {
|
||||
Self::TextToModel(result) => &result.model_url,
|
||||
Self::ImageToModel(result) => &result.model_url,
|
||||
Self::MultiviewToModel(result) => &result.model_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TripoTaskSnapshot {
|
||||
pub handle: TripoTaskHandle,
|
||||
@@ -86,14 +99,63 @@ pub struct TripoTaskSnapshot {
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TripoDownloadedModel {
|
||||
pub url: TripoUrl,
|
||||
pub content_type: Option<String>,
|
||||
pub data: Vec<u8>,
|
||||
pub content_length: Option<u64>,
|
||||
response: Response,
|
||||
status: u16,
|
||||
received: u64,
|
||||
}
|
||||
|
||||
impl TripoDownloadedModel {
|
||||
pub(crate) fn new(
|
||||
url: TripoUrl,
|
||||
content_type: Option<String>,
|
||||
content_length: Option<u64>,
|
||||
response: Response,
|
||||
) -> Self {
|
||||
Self {
|
||||
url,
|
||||
content_type,
|
||||
content_length,
|
||||
status: response.status().as_u16(),
|
||||
response,
|
||||
received: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn next_chunk(&mut self) -> Result<Option<Bytes>, TripoError> {
|
||||
let chunk = self
|
||||
.response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|error| TripoError::Request {
|
||||
message: format!("failed to read artifact response body: {error}"),
|
||||
status: Some(self.status),
|
||||
})?;
|
||||
match chunk {
|
||||
Some(chunk) => {
|
||||
self.received += chunk.len() as u64;
|
||||
Ok(Some(chunk))
|
||||
}
|
||||
None => {
|
||||
if let Some(expected) = self.content_length {
|
||||
if self.received != expected {
|
||||
return Err(TripoError::Request {
|
||||
message: format!(
|
||||
"artifact length mismatch: expected {expected} bytes, received {}",
|
||||
self.received
|
||||
),
|
||||
status: Some(self.status),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filename(&self, name: &str) -> String {
|
||||
let extension = self
|
||||
.url
|
||||
|
||||
Reference in New Issue
Block a user