diff --git a/CONTEXT.md b/CONTEXT.md index 1351b1033..b5c4adc36 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -50,6 +50,24 @@ _Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成 ## Language +### 3D Model Generation + +**3D 模型生成操作**: +由文本提示驱动、可异步查询并最终产生模型文件的 provider-neutral 操作;操作状态和产品资源结果分开建模。 +_Avoid_: 直接把第三方 provider task 当作产品资源、把模型 URL 当作永久资源地址 + +**Provider task**: +第三方 3D 生成平台返回的任务引用与任务快照,只存在于 provider adapter 的 Rust 边界内;它不是 Genarrative 的内部操作 ID。 +_Avoid_: 把 Tripo task ID 当作稳定产品 operationId、让 SDK 类型穿透 api-server + +**临时 provider URL**: +第三方任务完成后返回的短时模型下载地址,必须通过显式下载流程转换为本地或持久化资源,不能直接当作长期资产地址。 +_Avoid_: 永久 OSS URL、资源 ID + +**模型 artifact**: +由 text-to-model、image-to-model 或 multiview-to-model 任务产生、待应用层登记和持久化的模型文件结果;provider adapter 只提供可下载结果,不负责生成 resourceId 或 assetId。 +_Avoid_: 仅 UI 中展示的模型链接、没有来源任务的静态文件 + ### Puzzle Clear **拼消消**: diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 42699068e..be749d083 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -5016,6 +5016,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "ts-rs", ] [[package]] diff --git a/docs/README.md b/docs/README.md index 3de2b212c..c845863ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,8 @@ ## AI 游戏创作与 Agent Runtime +- [Tripo 3D 模型 Provider 集成](./technical/【技术方案】Tripo文本到3D模型Provider集成-2026-09-18.md):实现 text-to-model、image-to-model、multiview-to-model 的 Rust provider adapter,暂不接入 api-server。 + - [策划 Agent 生产迁移与工作区浏览](./technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md):实施中;以自由协作原型为行为基线,复用生产基建,采用阶段审批与用户工作区文件浏览。无旧 V2 会话的策划入口切换到新设计 Agent。 - [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。 @@ -74,6 +76,8 @@ ## 后端、运维与测试 +- [Tripo SDK 隔离与 Provider DTO 边界 ADR](./adr/【ADR】0001-Tripo%20SDK隔离与Provider%20DTO边界-2026-09-18.md):固定 SDK 只存在于 `platform-tripo` 内部,provider task 与产品资源结果分层。 + - [BgFilter 受限资源调度方案](./technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md) - [Issue225 登录成功 AGC 用户归属修复](./technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md):登录 route tracking 的真实用户归属、`daily_login` 幂等边界和实施验收。 - [SpacetimeDB 连接池取消安全](./【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md) diff --git a/docs/adr/【ADR】0001-Tripo SDK隔离与Provider DTO边界-2026-09-18.md b/docs/adr/【ADR】0001-Tripo SDK隔离与Provider DTO边界-2026-09-18.md new file mode 100644 index 000000000..09c6a1fde --- /dev/null +++ b/docs/adr/【ADR】0001-Tripo SDK隔离与Provider DTO边界-2026-09-18.md @@ -0,0 +1,7 @@ +# 【ADR】0001-Tripo SDK隔离与Provider DTO边界-2026-09-18 + +状态:已接受 + +`tripo3d-sdk` 只允许出现在 `platform-tripo` 内部。该 crate 以显式必填配置创建可复用 provider client,只公开自己的 text-to-model、image-to-model、multiview-to-model DTO、通用单次 task 查询、显式模型下载和结构化错误;不暴露 SDK 类型、`wait_for_task` 或后台轮询。共享产品契约与 provider task 结果分层,provider adapter 不伪造 `resourceId`/`assetId`;shared-contracts 的 Rust 类型始终通过 ts-rs 生成目录化 TypeScript binding。 + +选择这个边界是为了避免 API 层绑定第三方 SDK 的任务/错误模型,同时让未来的资源持久化、幂等和 provider 替换由应用层负责。`compress/style` 的正式 enum 和完整组合校验等待 Tripo API 文档,当前只保留编译占位和 TODO,不通过裸字符串逃生。 diff --git a/docs/technical/【技术方案】Tripo文本到3D模型Provider集成-2026-09-18.md b/docs/technical/【技术方案】Tripo文本到3D模型Provider集成-2026-09-18.md new file mode 100644 index 000000000..5749346e9 --- /dev/null +++ b/docs/technical/【技术方案】Tripo文本到3D模型Provider集成-2026-09-18.md @@ -0,0 +1,31 @@ +# 【技术方案】Tripo文本到3D模型 Provider 集成-2026-09-18 + +## 目标 + +本阶段在 `server-rs/crates/platform-tripo` 内接入固定 revision 的 `tripo3d-sdk`,完成 text-to-model、image-to-model 和 multiview-to-model 三个 3D 生成入口的 Rust provider adapter。adapter 提供显式配置、提交、单次通用 task 查询和模型下载能力;共享产品契约继续由 `shared-contracts` 管理,并始终生成目录化 ts-rs TypeScript binding。 + +代码按 API 目录组织:公共 SDK 配置、client、任务映射、错误、下载类型和通用 task DTO 位于 `platform-tripo/src/common/`;三个生成 API 各自拥有独立目录。Rust 与 TypeScript contracts 按 `common/`、`text_to_model/`、`image_to_model/`、`multiview_to_model/` 分目录生成。 + +## 非目标 + +- 不接入 `api-server`、worker、SpacetimeDB、OSS、计费或 UI。 +- 不暴露 SDK 的 `wait_for_task`,不在 adapter 内启动后台轮询。 +- 不实现后处理、rig、animation、图片生成或其它 SDK endpoint。 + +## 边界与状态 + +`platform-tripo` 是唯一接触 `tripo3d-sdk` 的边界。它返回自己的 provider DTO,不让 SDK 类型穿透到未来的 `api-server`。Tripo 的 `success` 映射为 `completed`;`failed`/`banned` 映射为 `failed`;`cancelled` 和 `expired` 保留为独立终态;未知状态返回结构化错误。 + +provider task 结果与产品资源结果分离。provider adapter 不生成 `resourceId` 或 `assetId`;未来应用层在资源持久化后再构造带资源 ID 的产品 DTO。模型 URL 被视为临时 provider 引用,下载由显式方法完成。 + +## 请求与类型 + +text-to-model 请求包含 SDK 已公开的字段。稳定且已由 SDK 常量定义的字段使用 Rust/TypeScript enum;`compress` 与 `style` 暂时使用带示例 variant 的 enum,并保留官方 API 文档补全 TODO。完整组合校验等待官方 API 文档后一次性落地,不复制不完整的规则集合。 + +## 验收 + +- `platform-tripo` 不公开 re-export SDK 类型或 `wait_for_task`。 +- 三个 3D 生成入口的 submit、通用 get task、固定 SDK output 字段映射和显式 download API 可编译。 +- provider 错误统一为 adapter 错误类型。 +- shared contracts 的 ts-rs binding 无 feature 开关且始终可生成。 +- 未运行真实 Provider smoke;需要 API key 的联调另行执行。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index d76f865be..2b29b2e59 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -52,17 +52,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - [[package]] name = "ahash" version = "0.8.12" @@ -182,7 +171,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -193,7 +182,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1133,7 +1122,7 @@ dependencies = [ "openssl-sys", "schannel", "socket2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1148,7 +1137,7 @@ dependencies = [ "openssl-sys", "pkg-config", "vcpkg", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1451,7 +1440,7 @@ version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" dependencies = [ - "ahash 0.8.12", + "ahash", "bytemuck", "document-features", "egui", @@ -1486,7 +1475,7 @@ version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" dependencies = [ - "ahash 0.8.12", + "ahash", "bitflags 2.13.0", "emath", "epaint", @@ -1503,7 +1492,7 @@ version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" dependencies = [ - "ahash 0.8.12", + "ahash", "bytemuck", "document-features", "egui", @@ -1598,7 +1587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" dependencies = [ "ab_glyph", - "ahash 0.8.12", + "ahash", "bytemuck", "ecolor", "emath", @@ -1628,7 +1617,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2062,9 +2051,6 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash 0.7.8", -] [[package]] name = "hashbrown" @@ -2094,6 +2080,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -3084,7 +3075,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3739,7 +3730,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527735ac204efb9fa3884bfd9224d016c5735fabe1d394ebed145b40e7545b99" dependencies = [ - "ahash 0.8.12", + "ahash", "async-trait", "blake2", "bstr", @@ -3776,7 +3767,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a7ffe2f5acf9f94fd255cfd1438866bc9124f8f0c7d42562bd3f853df2094b7" dependencies = [ - "ahash 0.8.12", + "ahash", "async-trait", "brotli", "bstr", @@ -3914,7 +3905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6705a26ad89d241a989a5395641931ba37076f5ab5fbd19ee92402414a43af32" dependencies = [ "arrayvec", - "hashbrown 0.12.3", + "hashbrown 0.17.1", "parking_lot", "rand 0.8.6", ] @@ -4164,6 +4155,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "platform-tripo" +version = "0.1.0" +dependencies = [ + "shared-contracts", + "tripo3d-sdk", + "url", +] + [[package]] name = "platform-wechat" version = "0.1.0" @@ -4856,7 +4856,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5247,6 +5247,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "ts-rs", ] [[package]] @@ -5420,7 +5421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5529,7 +5530,7 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c4e9d07e21ad665c45736871e5005d8457ab3d2cdd7806e351aa5e1c8c2ac32" dependencies = [ - "ahash 0.8.12", + "ahash", "crossbeam-queue", "either", "hashbrown 0.16.1", @@ -5815,7 +5816,16 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", ] [[package]] @@ -6278,12 +6288,49 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "tripo3d-sdk" +version = "0.1.0" +source = "git+https://github.com/VAST-AI-Research/tripo-rust-sdk.git?rev=1986be88627b04b1fa582a795bf6ac15ae6e0c6d#1986be88627b04b1fa582a795bf6ac15ae6e0c6d" +dependencies = [ + "bytes", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "url", +] + [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror 2.0.18", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "termcolor", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -6901,7 +6948,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7134,7 +7181,7 @@ version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ - "ahash 0.8.12", + "ahash", "android-activity", "atomic-waker", "bitflags 2.13.0", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 05310ccac..574747abf 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -29,6 +29,7 @@ members = [ "crates/platform-matting", "crates/platform-wechat", "crates/platform-speech", + "crates/platform-tripo", "crates/platform-editor-agent", "crates/pingora-gateway", "crates/preview-deployer-server", @@ -63,6 +64,7 @@ platform-llm = { path = "crates/platform-llm", default-features = false } platform-matting = { path = "crates/platform-matting", default-features = false } platform-oss = { path = "crates/platform-oss", default-features = false } platform-speech = { path = "crates/platform-speech", default-features = false } +platform-tripo = { path = "crates/platform-tripo", default-features = false } platform-wechat = { path = "crates/platform-wechat", default-features = false } pingora-gateway = { path = "crates/pingora-gateway", default-features = false } shared-contracts = { path = "crates/shared-contracts", default-features = false } @@ -114,6 +116,8 @@ time = "0.3" tokio = "1" tokio-stream = "0.1" tokio-tungstenite = "0.27" +tripo3d-sdk = { git = "https://github.com/VAST-AI-Research/tripo-rust-sdk.git", rev = "1986be88627b04b1fa582a795bf6ac15ae6e0c6d" } +ts-rs = "12.0.1" tower = "0.5" tower-http = "0.6" tracing = "0.1" diff --git a/server-rs/crates/platform-tripo/Cargo.toml b/server-rs/crates/platform-tripo/Cargo.toml new file mode 100644 index 000000000..c7a4b2b4b --- /dev/null +++ b/server-rs/crates/platform-tripo/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "platform-tripo" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +shared-contracts = { workspace = true } +tripo3d-sdk = { workspace = true } +url = { workspace = true } diff --git a/server-rs/crates/platform-tripo/src/common/client.rs b/server-rs/crates/platform-tripo/src/common/client.rs new file mode 100644 index 000000000..5940541c0 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/client.rs @@ -0,0 +1,65 @@ +use tripo3d_sdk::TripoClient; + +use super::{ + TripoDownloadedModel, TripoError, TripoModelUrl, TripoSettings, TripoTaskHandle, + TripoTaskSnapshot, map_task, validate_task_id, +}; + +pub struct TripoProviderClient { + pub(crate) client: TripoClient, +} + +impl TripoProviderClient { + pub fn new(settings: TripoSettings) -> Result { + Ok(Self { + client: TripoClient::new(settings.client_options()).map_err(TripoError::from)?, + }) + } + + pub async fn get_task( + &self, + handle: &TripoTaskHandle, + ) -> Result { + validate_task_id(&handle.task_id)?; + let task = self + .client + .get_task(&handle.task_id) + .await + .map_err(TripoError::from)?; + map_task(task) + } + + pub async fn download_model( + &self, + task: &TripoTaskSnapshot, + ) -> Result { + let handle = &task.handle; + validate_task_id(&handle.task_id)?; + 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)? + .ok_or_else(|| TripoError::OutputSchema { + task_id: handle.task_id.clone(), + message: "completed task has no model URL".into(), + })?; + let url = TripoModelUrl::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, + }) + } +} diff --git a/server-rs/crates/platform-tripo/src/common/config.rs b/server-rs/crates/platform-tripo/src/common/config.rs new file mode 100644 index 000000000..c874e44c2 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/config.rs @@ -0,0 +1,52 @@ +use std::time::Duration; + +use tripo3d_sdk::ClientOptions; + +#[derive(Clone)] +pub struct TripoSettings { + pub api_key: String, + pub base_url: String, + pub request_timeout: Duration, + pub retries: u32, + pub user_agent: String, +} + +impl std::fmt::Debug for TripoSettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TripoSettings") + .field("api_key", &"") + .field("base_url", &self.base_url) + .field("request_timeout", &self.request_timeout) + .field("retries", &self.retries) + .field("user_agent", &self.user_agent) + .finish() + } +} + +impl TripoSettings { + pub fn new( + api_key: String, + base_url: String, + request_timeout: Duration, + retries: u32, + user_agent: String, + ) -> Self { + Self { + api_key, + base_url, + request_timeout, + retries, + user_agent, + } + } + + pub(crate) fn client_options(&self) -> ClientOptions { + ClientOptions { + api_key: Some(self.api_key.clone()), + base_url: Some(self.base_url.clone()), + timeout: Some(self.request_timeout), + retries: Some(self.retries), + user_agent: Some(self.user_agent.clone()), + } + } +} diff --git a/server-rs/crates/platform-tripo/src/common/error.rs b/server-rs/crates/platform-tripo/src/common/error.rs new file mode 100644 index 000000000..ecec4511c --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/error.rs @@ -0,0 +1,194 @@ +use std::fmt; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TripoField { + Prompt, + Model, + NegativePrompt, + ImageSeed, + ModelSeed, + TextureSeed, + Texture, + Pbr, + TextureQuality, + GeometryQuality, + FaceLimit, + AutoSize, + Quad, + SmartLowPoly, + GenerateParts, + Compress, + ExportUv, + ExportOrientation, + Style, + TaskId, +} + +impl fmt::Display for TripoField { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::Prompt => "prompt", + Self::Model => "model", + Self::NegativePrompt => "negative_prompt", + Self::ImageSeed => "image_seed", + Self::ModelSeed => "model_seed", + Self::TextureSeed => "texture_seed", + Self::Texture => "texture", + Self::Pbr => "pbr", + Self::TextureQuality => "texture_quality", + Self::GeometryQuality => "geometry_quality", + Self::FaceLimit => "face_limit", + Self::AutoSize => "auto_size", + Self::Quad => "quad", + Self::SmartLowPoly => "smart_low_poly", + Self::GenerateParts => "generate_parts", + Self::Compress => "compress", + Self::ExportUv => "export_uv", + Self::ExportOrientation => "export_orientation", + Self::Style => "style", + Self::TaskId => "task_id", + }; + f.write_str(name) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TripoValidationReason { + Required, + InvalidCombination, +} + +impl fmt::Display for TripoValidationReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Required => "required", + Self::InvalidCombination => "invalid combination", + }) + } +} + +#[derive(Debug)] +pub enum TripoError { + InvalidParameters { + field: Option, + reason: TripoValidationReason, + message: String, + }, + SdkInvalidArgument(String), + Api { + code: i64, + message: Option, + suggestion: Option, + status: Option, + }, + Request { + message: String, + status: Option, + }, + TaskFailure { + task_id: String, + status: String, + message: Option, + }, + OutputSchema { + task_id: String, + message: String, + }, + Sdk { + message: String, + }, +} + +impl fmt::Display for TripoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidParameters { + field, + reason, + message, + } => { + write!(f, "invalid Tripo parameters ({reason})")?; + match field { + Some(field) => write!(f, " [{field}]: {message}"), + None => write!(f, ": {message}"), + } + } + Self::SdkInvalidArgument(message) => write!(f, "Tripo SDK invalid argument: {message}"), + Self::Api { + code, + message, + suggestion, + status, + } => write!( + f, + "Tripo API error code={code} status={status:?} message={message:?} suggestion={suggestion:?}" + ), + Self::Request { message, status } => { + write!(f, "Tripo request error status={status:?}: {message}") + } + Self::TaskFailure { + task_id, + status, + message, + } => write!(f, "Tripo task {task_id} ended with {status}: {message:?}"), + Self::OutputSchema { task_id, message } => { + write!(f, "Tripo task {task_id} output schema error: {message}") + } + Self::Sdk { message } => write!(f, "Tripo SDK error: {message}"), + } + } +} + +impl std::error::Error for TripoError {} + +impl TripoError { + pub fn is_retryable(&self) -> bool { + match self { + Self::Api { status, .. } => matches!(status, Some(429 | 500..=599)), + Self::Request { status, .. } => { + status.is_none() || matches!(status, Some(408 | 425 | 429 | 500..=599)) + } + _ => false, + } + } +} + +impl From for TripoError { + fn from(error: tripo3d_sdk::Error) -> Self { + match error { + tripo3d_sdk::Error::InvalidArgument(message) => Self::SdkInvalidArgument(message), + tripo3d_sdk::Error::Api { + code, + message, + suggestion, + status, + } => Self::Api { + code, + message, + suggestion, + status, + }, + tripo3d_sdk::Error::Request { + message, status, .. + } => Self::Request { message, status }, + tripo3d_sdk::Error::Task { task } => Self::TaskFailure { + task_id: task.task_id.clone(), + status: task.status.to_string(), + message: task.error_message.clone(), + }, + tripo3d_sdk::Error::Timeout { + task_id, + timeout_ms, + } => Self::Request { + message: format!("unexpected SDK timeout after {timeout_ms}ms for task {task_id}"), + status: None, + }, + tripo3d_sdk::Error::Io(error) => Self::Sdk { + message: error.to_string(), + }, + tripo3d_sdk::Error::Serde(error) => Self::Sdk { + message: error.to_string(), + }, + } + } +} diff --git a/server-rs/crates/platform-tripo/src/common/mapping.rs b/server-rs/crates/platform-tripo/src/common/mapping.rs new file mode 100644 index 000000000..529233af8 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/mapping.rs @@ -0,0 +1,121 @@ +use shared_contracts::model3d::common::Model3dTaskStatus; +use tripo3d_sdk::{Task, TaskOutput, TaskStatus}; + +use super::{ + TripoError, TripoModelUrl, TripoTaskFailure, TripoTaskHandle, TripoTaskOutput, + TripoTaskSnapshot, TripoTaskType, +}; + +pub(crate) fn map_task(task: Task) -> Result { + let task_id = task.task_id.clone(); + let task_type = map_task_type(&task.task_type, &task_id)?; + let status = map_status(task.status)?; + let output = match status { + Model3dTaskStatus::Completed => Some(map_output( + &task_id, + task.output + .as_ref() + .ok_or_else(|| TripoError::OutputSchema { + task_id: task_id.clone(), + message: "completed task has no output".into(), + })?, + )?), + _ => None, + }; + + Ok(TripoTaskSnapshot { + handle: TripoTaskHandle { task_id }, + task_type, + status, + progress: task.progress, + output, + failure: (task.error_code.is_some() || task.error_message.is_some()).then(|| { + TripoTaskFailure { + code: task.error_code, + message: task.error_message.clone(), + } + }), + created_at: task.created_at, + completed_at: task.completed_at, + }) +} + +fn map_task_type(task_type: &str, task_id: &str) -> Result { + match task_type { + "text_to_model" => Ok(TripoTaskType::TextToModel), + "image_to_model" => Ok(TripoTaskType::ImageToModel), + "multiview_to_model" => Ok(TripoTaskType::MultiviewToModel), + other => Err(TripoError::OutputSchema { + task_id: task_id.into(), + message: format!("unsupported task type: {other}"), + }), + } +} + +fn map_status(status: TaskStatus) -> Result { + match status { + TaskStatus::Queued => Ok(Model3dTaskStatus::Queued), + TaskStatus::Running => Ok(Model3dTaskStatus::Running), + TaskStatus::Success => Ok(Model3dTaskStatus::Completed), + TaskStatus::Failed | TaskStatus::Banned => Ok(Model3dTaskStatus::Failed), + TaskStatus::Cancelled => Ok(Model3dTaskStatus::Cancelled), + TaskStatus::Expired => Ok(Model3dTaskStatus::Expired), + TaskStatus::Unknown => Err(TripoError::Sdk { + message: "Tripo returned unknown task status".into(), + }), + } +} + +fn map_output(task_id: &str, output: &TaskOutput) -> Result { + let mut model_urls = Vec::new(); + for candidate in output + .model_urls + .iter() + .flat_map(|urls| urls.iter()) + .chain(output.model_url.iter()) + .chain(output.model.iter()) + .chain(output.pbr_model.iter()) + .chain(output.base_model.iter()) + { + if !model_urls + .iter() + .any(|url: &TripoModelUrl| url.as_str() == candidate) + { + model_urls.push(parse_url(candidate, task_id)?); + } + } + if model_urls.is_empty() { + return Err(TripoError::OutputSchema { + task_id: task_id.into(), + message: "completed task output contains no model URL".into(), + }); + } + + let rendered_image_url = output + .rendered_image_url + .as_ref() + .or(output.rendered_image.as_ref()) + .map(|value| parse_url(value, task_id)) + .transpose()?; + let generated_image_url = output + .generated_image_url + .as_ref() + .map(|value| parse_url(value, task_id)) + .transpose()?; + + Ok(TripoTaskOutput { + model_urls, + rendered_image_url, + generated_image_url, + }) +} + +fn parse_url(value: &str, task_id: &str) -> Result { + TripoModelUrl::parse(value).map_err(|error| match error { + TripoError::OutputSchema { message, .. } => TripoError::OutputSchema { + task_id: task_id.into(), + message, + }, + other => other, + }) +} diff --git a/server-rs/crates/platform-tripo/src/common/mod.rs b/server-rs/crates/platform-tripo/src/common/mod.rs new file mode 100644 index 000000000..aa1b1f703 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/mod.rs @@ -0,0 +1,16 @@ +mod client; +mod config; +mod error; +mod mapping; +mod types; +mod validation; + +pub use client::TripoProviderClient; +pub use config::TripoSettings; +pub use error::{TripoError, TripoField, TripoValidationReason}; +pub(crate) use mapping::map_task; +pub use types::{ + TripoDownloadedModel, TripoModelUrl, TripoTaskFailure, TripoTaskHandle, TripoTaskOutput, + TripoTaskSnapshot, TripoTaskType, +}; +pub(crate) use validation::validate_task_id; diff --git a/server-rs/crates/platform-tripo/src/common/types.rs b/server-rs/crates/platform-tripo/src/common/types.rs new file mode 100644 index 000000000..7a2f09241 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/types.rs @@ -0,0 +1,97 @@ +use std::fmt; + +use url::Url; + +use shared_contracts::model3d::common::Model3dTaskStatus; + +use crate::common::TripoError; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TripoTaskHandle { + pub task_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TripoTaskType { + TextToModel, + ImageToModel, + MultiviewToModel, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TripoModelUrl(Url); + +impl TripoModelUrl { + pub(crate) fn parse(value: &str) -> Result { + let url = Url::parse(value).map_err(|error| TripoError::OutputSchema { + task_id: "unknown".into(), + message: format!("model URL is invalid: {error}"), + })?; + if !matches!(url.scheme(), "http" | "https") { + return Err(TripoError::OutputSchema { + task_id: "unknown".into(), + message: "model URL must use http or https".into(), + }); + } + Ok(Self(url)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl fmt::Display for TripoModelUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TripoTaskFailure { + pub code: Option, + pub message: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TripoTaskOutput { + pub model_urls: Vec, + pub rendered_image_url: Option, + pub generated_image_url: Option, + // TODO(tripo-docs): add fixed per-task output fields after the provider + // contract is supplied and verified against real task responses. +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TripoTaskSnapshot { + pub handle: TripoTaskHandle, + pub task_type: TripoTaskType, + pub status: Model3dTaskStatus, + pub progress: Option, + pub output: Option, + pub failure: Option, + pub created_at: Option, + pub completed_at: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TripoDownloadedModel { + pub url: TripoModelUrl, + pub content_type: Option, + pub data: Vec, +} + +impl TripoDownloadedModel { + pub fn filename(&self, name: &str) -> String { + let extension = self + .url + .0 + .path_segments() + .and_then(|segments| segments.last()) + .and_then(|segment| segment.rsplit_once('.')) + .map(|(_, extension)| extension) + .filter(|extension| !extension.is_empty()) + .unwrap_or("glb"); + format!("{name}.{extension}") + } +} diff --git a/server-rs/crates/platform-tripo/src/common/validation.rs b/server-rs/crates/platform-tripo/src/common/validation.rs new file mode 100644 index 000000000..aa3730e06 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/validation.rs @@ -0,0 +1,12 @@ +use super::{TripoError, TripoField, TripoValidationReason}; + +pub(crate) fn validate_task_id(task_id: &str) -> Result<(), TripoError> { + if task_id.trim().is_empty() { + return Err(TripoError::InvalidParameters { + field: Some(TripoField::TaskId), + reason: TripoValidationReason::Required, + message: "task_id must not be blank".into(), + }); + } + Ok(()) +} diff --git a/server-rs/crates/platform-tripo/src/image_to_model/client.rs b/server-rs/crates/platform-tripo/src/image_to_model/client.rs new file mode 100644 index 000000000..f7cfdf71a --- /dev/null +++ b/server-rs/crates/platform-tripo/src/image_to_model/client.rs @@ -0,0 +1,75 @@ +use shared_contracts::model3d::common::{ + Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dInputOrientation, + Model3dModelVersion, Model3dStyle, Model3dTextureAlignment, Model3dTextureQuality, +}; +use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest; +use tripo3d_sdk::{models::FileInput, params::ImageToModelParams}; + +use crate::common::{TripoError, TripoProviderClient, TripoTaskHandle}; + +impl TripoProviderClient { + pub async fn submit_image_to_model( + &self, + request: &Model3dImageToModelRequest, + ) -> Result { + if request.input.trim().is_empty() { + return Err(TripoError::SdkInvalidArgument( + "image_to_model: input is required".into(), + )); + } + + let task_id = self + .client + .image_to_model(to_sdk_params(request)) + .await + .map_err(TripoError::from)?; + Ok(TripoTaskHandle { task_id }) + } +} + +fn to_sdk_params(request: &Model3dImageToModelRequest) -> ImageToModelParams { + ImageToModelParams { + input: FileInput::from(request.input.as_str()), + model: request + .model + .map(Model3dModelVersion::as_str) + .map(str::to_owned), + enable_image_autofix: request.enable_image_autofix, + model_seed: request.model_seed, + texture_seed: request.texture_seed, + texture: request.texture, + pbr: request.pbr, + texture_quality: request + .texture_quality + .map(Model3dTextureQuality::as_str) + .map(str::to_owned), + texture_alignment: request + .texture_alignment + .map(Model3dTextureAlignment::as_str) + .map(str::to_owned), + geometry_quality: request + .geometry_quality + .map(Model3dGeometryQuality::as_str) + .map(str::to_owned), + face_limit: request.face_limit, + auto_size: request.auto_size, + orientation: request + .orientation + .map(Model3dInputOrientation::as_str) + .map(str::to_owned), + quad: request.quad, + smart_low_poly: request.smart_low_poly, + generate_parts: request.generate_parts, + compress: request + .compress + .map(Model3dCompression::as_str) + .map(str::to_owned), + export_uv: request.export_uv, + export_orientation: request + .export_orientation + .map(Model3dExportOrientation::as_str) + .map(str::to_owned), + style: request.style.map(Model3dStyle::as_str).map(str::to_owned), + ..Default::default() + } +} diff --git a/server-rs/crates/platform-tripo/src/image_to_model/mod.rs b/server-rs/crates/platform-tripo/src/image_to_model/mod.rs new file mode 100644 index 000000000..b79c47fca --- /dev/null +++ b/server-rs/crates/platform-tripo/src/image_to_model/mod.rs @@ -0,0 +1 @@ +mod client; diff --git a/server-rs/crates/platform-tripo/src/lib.rs b/server-rs/crates/platform-tripo/src/lib.rs new file mode 100644 index 000000000..156754e59 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/lib.rs @@ -0,0 +1,15 @@ +//! Tripo text-to-model provider adapter. +//! +//! The SDK is deliberately private to this crate. Callers receive provider +//! DTOs and `TripoError`, never `tripo3d_sdk` types or its poller API. + +mod common; +mod image_to_model; +mod multiview_to_model; +mod text_to_model; + +pub use common::{ + TripoDownloadedModel, TripoError, TripoField, TripoModelUrl, TripoProviderClient, + TripoSettings, TripoTaskFailure, TripoTaskHandle, TripoTaskOutput, TripoTaskSnapshot, + TripoTaskType, TripoValidationReason, +}; diff --git a/server-rs/crates/platform-tripo/src/multiview_to_model/client.rs b/server-rs/crates/platform-tripo/src/multiview_to_model/client.rs new file mode 100644 index 000000000..6d321e1b9 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/multiview_to_model/client.rs @@ -0,0 +1,82 @@ +use shared_contracts::model3d::common::{ + Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dInputOrientation, + Model3dModelVersion, Model3dTextureAlignment, Model3dTextureQuality, +}; +use shared_contracts::model3d::multiview_to_model::{ + Model3dMultiviewInputs, Model3dMultiviewToModelRequest, +}; +use tripo3d_sdk::{models::FileInput, params::MultiviewToModelParams}; + +use crate::common::{TripoError, TripoProviderClient, TripoTaskHandle}; + +impl TripoProviderClient { + pub async fn submit_multiview_to_model( + &self, + request: &Model3dMultiviewToModelRequest, + ) -> Result { + let task_id = self + .client + .multiview_to_model(to_sdk_params(request)) + .await + .map_err(TripoError::from)?; + Ok(TripoTaskHandle { task_id }) + } +} + +fn to_sdk_params(request: &Model3dMultiviewToModelRequest) -> MultiviewToModelParams { + let mut params = match &request.inputs { + Model3dMultiviewInputs::Views { + front, + left, + back, + right, + } => MultiviewToModelParams::from_views([ + Some(FileInput::from(front.as_str())), + left.as_deref().map(FileInput::from), + back.as_deref().map(FileInput::from), + right.as_deref().map(FileInput::from), + ]), + Model3dMultiviewInputs::TaskId { task_id } => { + MultiviewToModelParams::from_task_id(task_id.clone()) + } + }; + params.model = request + .model + .map(Model3dModelVersion::as_str) + .map(str::to_owned); + params.model_seed = request.model_seed; + params.texture_seed = request.texture_seed; + params.texture = request.texture; + params.pbr = request.pbr; + params.texture_quality = request + .texture_quality + .map(Model3dTextureQuality::as_str) + .map(str::to_owned); + params.geometry_quality = request + .geometry_quality + .map(Model3dGeometryQuality::as_str) + .map(str::to_owned); + params.texture_alignment = request + .texture_alignment + .map(Model3dTextureAlignment::as_str) + .map(str::to_owned); + params.face_limit = request.face_limit; + params.auto_size = request.auto_size; + params.orientation = request + .orientation + .map(Model3dInputOrientation::as_str) + .map(str::to_owned); + params.quad = request.quad; + params.smart_low_poly = request.smart_low_poly; + params.generate_parts = request.generate_parts; + params.compress = request + .compress + .map(Model3dCompression::as_str) + .map(str::to_owned); + params.export_uv = request.export_uv; + params.export_orientation = request + .export_orientation + .map(Model3dExportOrientation::as_str) + .map(str::to_owned); + params +} diff --git a/server-rs/crates/platform-tripo/src/multiview_to_model/mod.rs b/server-rs/crates/platform-tripo/src/multiview_to_model/mod.rs new file mode 100644 index 000000000..b79c47fca --- /dev/null +++ b/server-rs/crates/platform-tripo/src/multiview_to_model/mod.rs @@ -0,0 +1 @@ +mod client; diff --git a/server-rs/crates/platform-tripo/src/text_to_model/client.rs b/server-rs/crates/platform-tripo/src/text_to_model/client.rs new file mode 100644 index 000000000..d6b6a6735 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/text_to_model/client.rs @@ -0,0 +1,63 @@ +use crate::common::{TripoError, TripoProviderClient, TripoTaskHandle}; +use shared_contracts::model3d::common::{ + Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dModelVersion, + Model3dStyle, Model3dTextureQuality, +}; +use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest; + +use super::validation::validate_text_to_model_request; + +impl TripoProviderClient { + pub async fn submit( + &self, + request: &Model3dTextToModelRequest, + ) -> Result { + validate_text_to_model_request(request)?; + let task_id = self + .client + .text_to_model(to_sdk_params(request)) + .await + .map_err(TripoError::from)?; + Ok(TripoTaskHandle { task_id }) + } +} + +fn to_sdk_params(request: &Model3dTextToModelRequest) -> tripo3d_sdk::params::TextToModelParams { + tripo3d_sdk::params::TextToModelParams { + prompt: request.prompt.clone(), + model: request + .model + .map(Model3dModelVersion::as_str) + .map(str::to_owned), + negative_prompt: request.negative_prompt.clone(), + image_seed: request.image_seed, + model_seed: request.model_seed, + texture_seed: request.texture_seed, + texture: request.texture, + pbr: request.pbr, + texture_quality: request + .texture_quality + .map(Model3dTextureQuality::as_str) + .map(str::to_owned), + geometry_quality: request + .geometry_quality + .map(Model3dGeometryQuality::as_str) + .map(str::to_owned), + face_limit: request.face_limit, + auto_size: request.auto_size, + quad: request.quad, + smart_low_poly: request.smart_low_poly, + generate_parts: request.generate_parts, + compress: request + .compress + .map(Model3dCompression::as_str) + .map(str::to_owned), + export_uv: request.export_uv, + export_orientation: request + .export_orientation + .map(Model3dExportOrientation::as_str) + .map(str::to_owned), + style: request.style.map(Model3dStyle::as_str).map(str::to_owned), + ..Default::default() + } +} diff --git a/server-rs/crates/platform-tripo/src/text_to_model/mod.rs b/server-rs/crates/platform-tripo/src/text_to_model/mod.rs new file mode 100644 index 000000000..66a22e439 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/text_to_model/mod.rs @@ -0,0 +1,2 @@ +mod client; +mod validation; diff --git a/server-rs/crates/platform-tripo/src/text_to_model/validation.rs b/server-rs/crates/platform-tripo/src/text_to_model/validation.rs new file mode 100644 index 000000000..9fef1edef --- /dev/null +++ b/server-rs/crates/platform-tripo/src/text_to_model/validation.rs @@ -0,0 +1,19 @@ +use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest; + +use crate::common::{TripoError, TripoField, TripoValidationReason}; + +pub(crate) fn validate_text_to_model_request( + request: &Model3dTextToModelRequest, +) -> Result<(), TripoError> { + if request.prompt.trim().is_empty() { + return Err(TripoError::InvalidParameters { + field: Some(TripoField::Prompt), + reason: TripoValidationReason::Required, + message: "prompt must not be blank".into(), + }); + } + + // TODO(tripo-docs): add the complete documented cross-field validation + // matrix in one pass when the official text-to-model API contract arrives. + Ok(()) +}