From e0108f7d5783d1c412ed798610ecd6e7e6475d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 12:38:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E5=B1=82Tripo=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=9A=84provider=E5=8F=82=E6=95=B0=E4=B8=8E=E5=B9=B3=E5=8F=B0A?= =?UTF-8?q?PI=E8=AF=B7=E6=B1=82=E5=A5=91=E7=BA=A6=20-=20common=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20Model3dGenerationSource=E3=80=81Model3dGenerationTa?= =?UTF-8?q?rget=E3=80=81Model3dGenerationTargetRef=20=E4=B8=8E=E4=BA=A7?= =?UTF-8?q?=E7=89=A9=E5=85=83=E6=95=B0=E6=8D=AE=20-=20text/image=20?= =?UTF-8?q?=E5=90=84=E8=87=AA=E7=9A=84=E5=8F=82=E6=95=B0=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=88=90=20params=EF=BC=8CAPI=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E4=B8=BA=20generation=20+=20=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=20+=20target=20-=20=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=8F=AA=E6=8E=A5=E5=8F=97=E7=AB=99=E5=86=85?= =?UTF-8?q?=E5=BC=95=E7=94=A8=EF=BC=8Cinput=20=E7=94=B1=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E8=A7=A3=E6=9E=90=E5=90=8E=E6=B3=A8=E5=85=A5=20provid?= =?UTF-8?q?er=20-=20=E5=88=A0=E9=99=A4=E5=AE=BD=E6=9D=BE=E7=9A=84=E4=BD=9C?= =?UTF-8?q?=E4=B8=9A=E4=B8=8E=E7=BB=93=E6=9E=9C=E5=8D=A0=E4=BD=8D=20DTO?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E4=B8=BA=E6=8C=89=E7=AB=AF=E7=82=B9=E7=9A=84?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E7=BB=93=E6=9E=9C=E4=B8=8E=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=20tagged=20enum=20-=20=E6=96=B0=E5=A2=9E=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E6=80=A7=E6=B5=8B=E8=AF=95=EF=BC=8C=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=88=86=E6=94=AF=E5=BF=85=E5=A1=AB=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E3=80=81=E5=9B=BE=E7=89=87=E8=BE=93=E5=85=A5=E6=AD=A7=E4=B9=89?= =?UTF-8?q?=E4=B8=8E=E9=A1=B6=E5=B1=82=E6=9C=AA=E7=9F=A5=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/model3d/common/generated_artifact.rs | 19 +++ .../src/model3d/common/generation_source.rs | 24 ++++ .../src/model3d/common/generation_target.rs | 29 +++++ .../model3d/common/generation_target_ref.rs | 19 +++ .../src/model3d/common/mod.rs | 8 ++ .../src/model3d/generation_result.rs | 17 +++ .../src/model3d/image_to_model/mod.rs | 4 + .../src/model3d/image_to_model/params.rs | 61 +++++++++ .../src/model3d/image_to_model/request.rs | 54 +------- .../src/model3d/image_to_model/result.rs | 15 +++ .../shared-contracts/src/model3d/mod.rs | 4 + .../src/model3d/text_to_model/mod.rs | 8 +- .../src/model3d/text_to_model/params.rs | 60 +++++++++ .../src/model3d/text_to_model/request.rs | 54 ++------ .../src/model3d/text_to_model/response.rs | 62 ---------- .../src/model3d/text_to_model/result.rs | 15 +++ .../tests/model3d_api_request_contract.rs | 116 ++++++++++++++++++ 17 files changed, 409 insertions(+), 160 deletions(-) create mode 100644 server-rs/crates/shared-contracts/src/model3d/common/generated_artifact.rs create mode 100644 server-rs/crates/shared-contracts/src/model3d/common/generation_source.rs create mode 100644 server-rs/crates/shared-contracts/src/model3d/common/generation_target.rs create mode 100644 server-rs/crates/shared-contracts/src/model3d/common/generation_target_ref.rs create mode 100644 server-rs/crates/shared-contracts/src/model3d/generation_result.rs create mode 100644 server-rs/crates/shared-contracts/src/model3d/image_to_model/params.rs create mode 100644 server-rs/crates/shared-contracts/src/model3d/image_to_model/result.rs create mode 100644 server-rs/crates/shared-contracts/src/model3d/text_to_model/params.rs delete mode 100644 server-rs/crates/shared-contracts/src/model3d/text_to_model/response.rs create mode 100644 server-rs/crates/shared-contracts/src/model3d/text_to_model/result.rs create mode 100644 server-rs/crates/shared-contracts/tests/model3d_api_request_contract.rs diff --git a/server-rs/crates/shared-contracts/src/model3d/common/generated_artifact.rs b/server-rs/crates/shared-contracts/src/model3d/common/generated_artifact.rs new file mode 100644 index 000000000..448f4db43 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/generated_artifact.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +/// 单个生成产物的对象元数据。 +/// +/// 只描述服务端已经持久化的正式对象;不带 provider task ID,也不带带签名的临时下载地址。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dGeneratedArtifact { + pub object_key: String, + pub content_type: String, + /// 对象字节数;单体模型产物为几十 MB,远低于 2^53,按 TS number 导出。 + #[ts(type = "number")] + pub content_length: u64, + pub sha256: String, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/generation_source.rs b/server-rs/crates/shared-contracts/src/model3d/common/generation_source.rs new file mode 100644 index 000000000..d19a894e7 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/generation_source.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +/// API 层图片输入:只接受站内的画布资源或素材库对象,二者互斥。 +/// +/// 不接受裸字符串、远程 URL 与 data URL,因此 provider 需要的图片地址由服务端 +/// 按分支校验归属后解析,客户端无法绕过归属校验直接给 provider 传地址。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +// 注:该属性由 serde 生效,变体里的未知字段(例如同时给出两个 ID)会被拒绝; +// 已发布版 ts-rs 无法在 TS 绑定里表达这份严格性,见 model3d_multiview_request_contract.rs 的同类说明。 +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dGenerationSource { + Resource { + #[serde(rename = "resourceId")] + resource_id: String, + }, + Asset { + #[serde(rename = "assetId")] + asset_id: String, + }, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/generation_target.rs b/server-rs/crates/shared-contracts/src/model3d/common/generation_target.rs new file mode 100644 index 000000000..45e277436 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/generation_target.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; +use crate::editor_canvas::EditorCanvasGenerationCompletionPayload; + +/// 生成结果的落点。两个分支都必须给出定位字段,不存在“都不给就默认落素材库”的口径。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dGenerationTarget { + ProjectResource { + #[serde(rename = "projectId")] + project_id: String, + /// 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置。 + #[serde( + rename = "canvasCompletion", + default, + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + canvas_completion: Option, + }, + AssetLibrary { + #[serde(rename = "folderId")] + folder_id: String, + label: String, + }, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/generation_target_ref.rs b/server-rs/crates/shared-contracts/src/model3d/common/generation_target_ref.rs new file mode 100644 index 000000000..7b319e438 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/generation_target_ref.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +/// 结果落点引用:与请求 `target` 同分支,但给的是已创建资源 / 素材的正式 ID。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dGenerationTargetRef { + ProjectResource { + #[serde(rename = "resourceId")] + resource_id: String, + }, + AssetLibrary { + #[serde(rename = "assetId")] + asset_id: String, + }, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/mod.rs b/server-rs/crates/shared-contracts/src/model3d/common/mod.rs index dfb3b03fa..44411b1f5 100644 --- a/server-rs/crates/shared-contracts/src/model3d/common/mod.rs +++ b/server-rs/crates/shared-contracts/src/model3d/common/mod.rs @@ -2,6 +2,10 @@ pub(crate) const MODEL3D_TS_EXPORT_DIR: &str = crate::model3d::model3d_ts_export mod compression; mod export_orientation; +mod generated_artifact; +mod generation_source; +mod generation_target; +mod generation_target_ref; mod geometry_quality; mod input_orientation; mod model_version; @@ -13,6 +17,10 @@ mod texture_version; pub use compression::Model3dCompression; pub use export_orientation::Model3dExportOrientation; +pub use generated_artifact::Model3dGeneratedArtifact; +pub use generation_source::Model3dGenerationSource; +pub use generation_target::Model3dGenerationTarget; +pub use generation_target_ref::Model3dGenerationTargetRef; pub use geometry_quality::Model3dGeometryQuality; pub use input_orientation::Model3dInputOrientation; pub use model_version::Model3dModelVersion; diff --git a/server-rs/crates/shared-contracts/src/model3d/generation_result.rs b/server-rs/crates/shared-contracts/src/model3d/generation_result.rs new file mode 100644 index 000000000..b321517c2 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/generation_result.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +use super::image_to_model::Model3dImageToModelResult; +use super::text_to_model::Model3dTextToModelResult; + +pub(crate) const MODEL3D_TS_EXPORT_DIR: &str = crate::model3d::model3d_ts_export_dir!(""); + +/// 完成结果按端点严格 tagged enum:每个分支只携带该端点真正会产出的字段, +/// 不使用“所有端点共用一份大结构 + 一堆 Option”的宽松形态。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dGenerationResult { + TextToModel(Model3dTextToModelResult), + ImageToModel(Model3dImageToModelResult), +} diff --git a/server-rs/crates/shared-contracts/src/model3d/image_to_model/mod.rs b/server-rs/crates/shared-contracts/src/model3d/image_to_model/mod.rs index fc2591742..e68edf8ef 100644 --- a/server-rs/crates/shared-contracts/src/model3d/image_to_model/mod.rs +++ b/server-rs/crates/shared-contracts/src/model3d/image_to_model/mod.rs @@ -1,6 +1,10 @@ pub(crate) const MODEL3D_TS_EXPORT_DIR: &str = crate::model3d::model3d_ts_export_dir!("image-to-model/"); +mod params; mod request; +mod result; +pub use params::Model3dImageToModelParams; pub use request::Model3dImageToModelRequest; +pub use result::Model3dImageToModelResult; diff --git a/server-rs/crates/shared-contracts/src/model3d/image_to_model/params.rs b/server-rs/crates/shared-contracts/src/model3d/image_to_model/params.rs new file mode 100644 index 000000000..4e9464874 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/image_to_model/params.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; + +use crate::model3d::common::{ + Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dInputOrientation, + Model3dModelVersion, Model3dTextureAlignment, Model3dTextureQuality, Model3dTextureVersion, +}; + +use super::MODEL3D_TS_EXPORT_DIR; + +/// image-to-model 的 provider 生成参数。 +/// +/// 图片本体不在 provider 参数里:客户端只能给 `Model3dGenerationSource`, +/// 服务端解析归属后把 provider 需要的地址注入调用,见 `api-server::tripo3d`。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dImageToModelParams { + pub model: Model3dModelVersion, + #[ts(optional = nullable)] + pub enable_image_autofix: Option, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub model_seed: Option, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub texture_seed: Option, + #[ts(optional = nullable)] + pub texture: Option, + #[ts(optional = nullable)] + pub pbr: Option, + #[ts(optional = nullable)] + pub texture_quality: Option, + #[ts(optional = nullable)] + pub texture_version: Option, + #[ts(optional = nullable)] + pub delight: Option, + #[ts(optional = nullable)] + pub texture_alignment: Option, + #[ts(optional = nullable)] + pub geometry_quality: Option, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub face_limit: Option, + #[ts(optional = nullable)] + pub auto_size: Option, + #[ts(optional = nullable)] + pub orientation: Option, + #[ts(optional = nullable)] + pub quad: Option, + #[ts(optional = nullable)] + pub smart_low_poly: Option, + #[ts(optional = nullable)] + pub generate_parts: Option, + #[ts(optional = nullable)] + pub compress: Option, + #[ts(optional = nullable)] + pub export_uv: Option, + #[ts(optional = nullable)] + pub export_orientation: Option, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/image_to_model/request.rs b/server-rs/crates/shared-contracts/src/model3d/image_to_model/request.rs index e2f89c2a6..0ee11b0fa 100644 --- a/server-rs/crates/shared-contracts/src/model3d/image_to_model/request.rs +++ b/server-rs/crates/shared-contracts/src/model3d/image_to_model/request.rs @@ -1,58 +1,16 @@ use serde::{Deserialize, Serialize}; -use crate::model3d::common::{ - Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dInputOrientation, - Model3dModelVersion, Model3dTextureAlignment, Model3dTextureQuality, Model3dTextureVersion, -}; - use super::MODEL3D_TS_EXPORT_DIR; +use super::params::Model3dImageToModelParams; +use crate::model3d::common::{Model3dGenerationSource, Model3dGenerationTarget}; +/// image-to-model API 请求:站内图片引用 + provider 生成参数 + 平台字段。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[derive(ts_rs::TS)] #[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] pub struct Model3dImageToModelRequest { - pub input: String, - pub model: Model3dModelVersion, - #[ts(optional = nullable)] - pub enable_image_autofix: Option, - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub model_seed: Option, - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub texture_seed: Option, - #[ts(optional = nullable)] - pub texture: Option, - #[ts(optional = nullable)] - pub pbr: Option, - #[ts(optional = nullable)] - pub texture_quality: Option, - #[ts(optional = nullable)] - pub texture_version: Option, - #[ts(optional = nullable)] - pub delight: Option, - #[ts(optional = nullable)] - pub texture_alignment: Option, - #[ts(optional = nullable)] - pub geometry_quality: Option, - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub face_limit: Option, - #[ts(optional = nullable)] - pub auto_size: Option, - #[ts(optional = nullable)] - pub orientation: Option, - #[ts(optional = nullable)] - pub quad: Option, - #[ts(optional = nullable)] - pub smart_low_poly: Option, - #[ts(optional = nullable)] - pub generate_parts: Option, - #[ts(optional = nullable)] - pub compress: Option, - #[ts(optional = nullable)] - pub export_uv: Option, - #[ts(optional = nullable)] - pub export_orientation: Option, + pub source: Model3dGenerationSource, + pub generation: Model3dImageToModelParams, + pub target: Model3dGenerationTarget, } diff --git a/server-rs/crates/shared-contracts/src/model3d/image_to_model/result.rs b/server-rs/crates/shared-contracts/src/model3d/image_to_model/result.rs new file mode 100644 index 000000000..31812bcf1 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/image_to_model/result.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; +use crate::model3d::common::{Model3dGeneratedArtifact, Model3dGenerationTargetRef}; + +/// image-to-model 的完成结果:只包含正式资源引用与已持久化产物。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dImageToModelResult { + pub target: Model3dGenerationTargetRef, + pub model: Model3dGeneratedArtifact, + pub preview: Model3dGeneratedArtifact, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/mod.rs b/server-rs/crates/shared-contracts/src/model3d/mod.rs index 1a5cffa72..6d6708bf1 100644 --- a/server-rs/crates/shared-contracts/src/model3d/mod.rs +++ b/server-rs/crates/shared-contracts/src/model3d/mod.rs @@ -14,9 +14,13 @@ macro_rules! model3d_ts_export_dir { pub(crate) use model3d_ts_export_dir; +mod generation_result; + pub mod common; pub mod image_to_model; pub mod multiview_to_model; pub mod text_to_model; +pub use generation_result::Model3dGenerationResult; + pub const MODEL3D_CONTRACT_VERSION: &str = "model3d.v1"; diff --git a/server-rs/crates/shared-contracts/src/model3d/text_to_model/mod.rs b/server-rs/crates/shared-contracts/src/model3d/text_to_model/mod.rs index 70861cabb..5614b92dd 100644 --- a/server-rs/crates/shared-contracts/src/model3d/text_to_model/mod.rs +++ b/server-rs/crates/shared-contracts/src/model3d/text_to_model/mod.rs @@ -1,9 +1,9 @@ pub(crate) const MODEL3D_TS_EXPORT_DIR: &str = crate::model3d::model3d_ts_export_dir!("text-to-model/"); +mod params; mod request; -mod response; +mod result; +pub use params::Model3dTextToModelParams; pub use request::Model3dTextToModelRequest; -pub use response::{ - Model3dArtifact, Model3dGenerationJob, Model3dGenerationResult, Model3dGenerationSubmission, -}; +pub use result::Model3dTextToModelResult; diff --git a/server-rs/crates/shared-contracts/src/model3d/text_to_model/params.rs b/server-rs/crates/shared-contracts/src/model3d/text_to_model/params.rs new file mode 100644 index 000000000..12d94b2f0 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/text_to_model/params.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; +use crate::model3d::common::{ + Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dModelVersion, + Model3dTextureQuality, Model3dTextureVersion, +}; + +/// text-to-model 的 provider 生成参数。 +/// +/// 这里保持与 provider 一致的宽松形态(未提供的参数由 provider 取默认值), +/// 平台侧的必填口径与组合校验在调用 provider 之前完成,见 `api-server::tripo3d::validation`。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dTextToModelParams { + pub prompt: String, + pub model: Model3dModelVersion, + #[ts(optional = nullable)] + pub negative_prompt: Option, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub image_seed: Option, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub model_seed: Option, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub texture_seed: Option, + #[ts(optional = nullable)] + pub texture: Option, + #[ts(optional = nullable)] + pub pbr: Option, + #[ts(optional = nullable)] + pub texture_quality: Option, + #[ts(optional = nullable)] + pub texture_version: Option, + #[ts(optional = nullable)] + pub delight: Option, + #[ts(optional = nullable)] + pub geometry_quality: Option, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub face_limit: Option, + #[ts(optional = nullable)] + pub auto_size: Option, + #[ts(optional = nullable)] + pub quad: Option, + #[ts(optional = nullable)] + pub smart_low_poly: Option, + #[ts(optional = nullable)] + pub generate_parts: Option, + #[ts(optional = nullable)] + pub compress: Option, + #[ts(optional = nullable)] + pub export_uv: Option, + #[ts(optional = nullable)] + pub export_orientation: Option, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/text_to_model/request.rs b/server-rs/crates/shared-contracts/src/model3d/text_to_model/request.rs index 1198deb8b..fa4196b23 100644 --- a/server-rs/crates/shared-contracts/src/model3d/text_to_model/request.rs +++ b/server-rs/crates/shared-contracts/src/model3d/text_to_model/request.rs @@ -1,56 +1,18 @@ use serde::{Deserialize, Serialize}; use super::MODEL3D_TS_EXPORT_DIR; -use crate::model3d::common::{ - Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dModelVersion, - Model3dTextureQuality, Model3dTextureVersion, -}; +use super::params::Model3dTextToModelParams; +use crate::model3d::common::Model3dGenerationTarget; +/// text-to-model API 请求:provider 生成参数 + 平台字段。 +/// +/// 生成参数单独嵌一层而不是摊平到顶层:serde 的 `deny_unknown_fields` 与 `flatten` +/// 不能共存,摊平会让顶层未知字段静默通过。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[derive(ts_rs::TS)] #[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] pub struct Model3dTextToModelRequest { - pub prompt: String, - pub model: Model3dModelVersion, - #[ts(optional = nullable)] - pub negative_prompt: Option, - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub image_seed: Option, - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub model_seed: Option, - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub texture_seed: Option, - #[ts(optional = nullable)] - pub texture: Option, - #[ts(optional = nullable)] - pub pbr: Option, - #[ts(optional = nullable)] - pub texture_quality: Option, - #[ts(optional = nullable)] - pub texture_version: Option, - #[ts(optional = nullable)] - pub delight: Option, - #[ts(optional = nullable)] - pub geometry_quality: Option, - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub face_limit: Option, - #[ts(optional = nullable)] - pub auto_size: Option, - #[ts(optional = nullable)] - pub quad: Option, - #[ts(optional = nullable)] - pub smart_low_poly: Option, - #[ts(optional = nullable)] - pub generate_parts: Option, - #[ts(optional = nullable)] - pub compress: Option, - #[ts(optional = nullable)] - pub export_uv: Option, - #[ts(optional = nullable)] - pub export_orientation: Option, + pub generation: Model3dTextToModelParams, + pub target: Model3dGenerationTarget, } diff --git a/server-rs/crates/shared-contracts/src/model3d/text_to_model/response.rs b/server-rs/crates/shared-contracts/src/model3d/text_to_model/response.rs deleted file mode 100644 index 6f562b0bf..000000000 --- a/server-rs/crates/shared-contracts/src/model3d/text_to_model/response.rs +++ /dev/null @@ -1,62 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::model3d::common::{Model3dOutputFormat, Model3dTaskStatus}; - -use super::MODEL3D_TS_EXPORT_DIR; - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -#[derive(ts_rs::TS)] -#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] -pub struct Model3dGenerationSubmission { - pub operation_id: String, - pub status: Model3dTaskStatus, - pub status_url: String, - /// 轮询间隔(毫秒),量级为分钟级,远低于 2^53,按 TS number 导出。 - #[ts(type = "number")] - pub poll_after_ms: u64, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -#[derive(ts_rs::TS)] -#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] -pub struct Model3dGenerationJob { - pub operation_id: String, - pub status: Model3dTaskStatus, - pub phase: String, - pub progress: u8, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub result: Option, - /// 更新时间(Unix 微秒);2^53 微秒约到公元 2255 年,按 TS number 导出即可精确表示。 - #[ts(type = "number")] - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -#[derive(ts_rs::TS)] -#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] -pub struct Model3dGenerationResult { - pub resource_id: String, - pub asset_id: String, - pub artifacts: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -#[derive(ts_rs::TS)] -#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] -pub struct Model3dArtifact { - pub resource_id: String, - pub format: Model3dOutputFormat, - pub content_type: String, - /// 产物字节数,当前单模型产物为几十 MB,远低于 2^53,按 TS number 导出。 - #[ts(type = "number")] - pub size_bytes: u64, - pub sha256: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preview_resource_id: Option, -} diff --git a/server-rs/crates/shared-contracts/src/model3d/text_to_model/result.rs b/server-rs/crates/shared-contracts/src/model3d/text_to_model/result.rs new file mode 100644 index 000000000..b55ac234c --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/text_to_model/result.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; +use crate::model3d::common::{Model3dGeneratedArtifact, Model3dGenerationTargetRef}; + +/// text-to-model 的完成结果:只包含正式资源引用与已持久化产物。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dTextToModelResult { + pub target: Model3dGenerationTargetRef, + pub model: Model3dGeneratedArtifact, + pub preview: Model3dGeneratedArtifact, +} diff --git a/server-rs/crates/shared-contracts/tests/model3d_api_request_contract.rs b/server-rs/crates/shared-contracts/tests/model3d_api_request_contract.rs new file mode 100644 index 000000000..05c4a14ba --- /dev/null +++ b/server-rs/crates/shared-contracts/tests/model3d_api_request_contract.rs @@ -0,0 +1,116 @@ +use serde_json::json; +use shared_contracts::model3d::Model3dGenerationResult; +use shared_contracts::model3d::common::{ + Model3dGeneratedArtifact, Model3dGenerationTarget, Model3dGenerationTargetRef, +}; +use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest; +use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest; + +#[test] +fn text_to_model_request_accepts_generation_and_target() { + let request: Model3dTextToModelRequest = serde_json::from_value(json!({ + "generation": { "prompt": "一把木椅", "model": "v3.1-20260211", "texture": true }, + "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" } + })) + .expect("合法请求应可反序列化"); + + assert_eq!(request.generation.prompt, "一把木椅"); + assert!(matches!( + request.target, + Model3dGenerationTarget::AssetLibrary { .. } + )); +} + +#[test] +fn target_requires_branch_local_fields() { + for invalid in [ + json!({ "kind": "assetLibrary", "label": "木椅" }), + json!({ "kind": "assetLibrary", "folderId": "folder-1" }), + json!({ "kind": "projectResource" }), + ] { + let error = serde_json::from_value::(invalid.clone()) + .expect_err("缺少分支必填字段应被拒绝"); + assert!( + error.to_string().contains("missing field"), + "缺少字段的报错应指出 missing field,输入 {invalid},实际为:{error}" + ); + } +} + +#[test] +fn image_source_rejects_ambiguous_and_raw_input() { + let both_ids = json!({ + "source": { "kind": "resource", "resourceId": "r1", "assetId": "a1" }, + "generation": { "model": "v3.1-20260211" }, + "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" } + }); + let error = serde_json::from_value::(both_ids) + .expect_err("同时给出资源与素材 ID 应被拒绝"); + assert!( + error.to_string().contains("unknown field"), + "同时给出两个 ID 应报 unknown field,实际为:{error}" + ); + + let raw_input = json!({ + "source": "https://example.com/a.png", + "generation": { "model": "v3.1-20260211" }, + "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" } + }); + serde_json::from_value::(raw_input) + .expect_err("裸字符串图片输入应被拒绝"); +} + +#[test] +fn api_request_rejects_unknown_top_level_fields() { + let error = serde_json::from_value::(json!({ + "generation": { "prompt": "一把木椅", "model": "v3.1-20260211" }, + "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" }, + "unexpected": true + })) + .expect_err("顶层未知字段应被拒绝"); + + assert!( + error.to_string().contains("unknown field"), + "顶层未知字段应报 unknown field,实际为:{error}" + ); +} + +#[test] +fn generation_result_is_tagged_per_endpoint() { + let result: Model3dGenerationResult = serde_json::from_value(json!({ + "kind": "textToModel", + "target": { "kind": "projectResource", "resourceId": "res-1" }, + "model": { + "objectKey": "models/res-1.glb", + "contentType": "model/gltf-binary", + "contentLength": 41_636_076_u64, + "sha256": "0f1e" + }, + "preview": { + "objectKey": "models/res-1.webp", + "contentType": "image/webp", + "contentLength": 20_480_u64, + "sha256": "2a3b" + } + })) + .expect("按端点的结果应可反序列化"); + + let Model3dGenerationResult::TextToModel(text) = result else { + panic!("kind=textToModel 不应解成 imageToModel 分支"); + }; + assert_eq!( + text.target, + Model3dGenerationTargetRef::ProjectResource { + resource_id: "res-1".into() + } + ); + assert_eq!( + text.model, + Model3dGeneratedArtifact { + object_key: "models/res-1.glb".into(), + content_type: "model/gltf-binary".into(), + content_length: 41_636_076, + sha256: "0f1e".into(), + } + ); +}