分层Tripo生成的provider参数与平台API请求契约

- common 新增 Model3dGenerationSource、Model3dGenerationTarget、Model3dGenerationTargetRef 与产物元数据
- text/image 各自的参数类型独立成 params,API 请求为 generation + 平台字段 + target
- 图片输入只接受站内引用,input 由服务端解析后注入 provider
- 删除宽松的作业与结果占位 DTO,改为按端点的严格结果与端点 tagged enum
- 新增契约严格性测试,覆盖分支必填字段、图片输入歧义与顶层未知字段
This commit is contained in:
2026-09-21 12:38:46 +08:00
parent 93306952d9
commit e0108f7d57
17 changed files with 409 additions and 160 deletions
@@ -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,
}
@@ -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,
},
}
@@ -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<EditorCanvasGenerationCompletionPayload>,
},
AssetLibrary {
#[serde(rename = "folderId")]
folder_id: String,
label: String,
},
}
@@ -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,
},
}
@@ -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;
@@ -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),
}
@@ -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;
@@ -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<bool>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub model_seed: Option<i64>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub texture_seed: Option<i64>,
#[ts(optional = nullable)]
pub texture: Option<bool>,
#[ts(optional = nullable)]
pub pbr: Option<bool>,
#[ts(optional = nullable)]
pub texture_quality: Option<Model3dTextureQuality>,
#[ts(optional = nullable)]
pub texture_version: Option<Model3dTextureVersion>,
#[ts(optional = nullable)]
pub delight: Option<bool>,
#[ts(optional = nullable)]
pub texture_alignment: Option<Model3dTextureAlignment>,
#[ts(optional = nullable)]
pub geometry_quality: Option<Model3dGeometryQuality>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub face_limit: Option<i64>,
#[ts(optional = nullable)]
pub auto_size: Option<bool>,
#[ts(optional = nullable)]
pub orientation: Option<Model3dInputOrientation>,
#[ts(optional = nullable)]
pub quad: Option<bool>,
#[ts(optional = nullable)]
pub smart_low_poly: Option<bool>,
#[ts(optional = nullable)]
pub generate_parts: Option<bool>,
#[ts(optional = nullable)]
pub compress: Option<Model3dCompression>,
#[ts(optional = nullable)]
pub export_uv: Option<bool>,
#[ts(optional = nullable)]
pub export_orientation: Option<Model3dExportOrientation>,
}
@@ -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<bool>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub model_seed: Option<i64>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub texture_seed: Option<i64>,
#[ts(optional = nullable)]
pub texture: Option<bool>,
#[ts(optional = nullable)]
pub pbr: Option<bool>,
#[ts(optional = nullable)]
pub texture_quality: Option<Model3dTextureQuality>,
#[ts(optional = nullable)]
pub texture_version: Option<Model3dTextureVersion>,
#[ts(optional = nullable)]
pub delight: Option<bool>,
#[ts(optional = nullable)]
pub texture_alignment: Option<Model3dTextureAlignment>,
#[ts(optional = nullable)]
pub geometry_quality: Option<Model3dGeometryQuality>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub face_limit: Option<i64>,
#[ts(optional = nullable)]
pub auto_size: Option<bool>,
#[ts(optional = nullable)]
pub orientation: Option<Model3dInputOrientation>,
#[ts(optional = nullable)]
pub quad: Option<bool>,
#[ts(optional = nullable)]
pub smart_low_poly: Option<bool>,
#[ts(optional = nullable)]
pub generate_parts: Option<bool>,
#[ts(optional = nullable)]
pub compress: Option<Model3dCompression>,
#[ts(optional = nullable)]
pub export_uv: Option<bool>,
#[ts(optional = nullable)]
pub export_orientation: Option<Model3dExportOrientation>,
pub source: Model3dGenerationSource,
pub generation: Model3dImageToModelParams,
pub target: Model3dGenerationTarget,
}
@@ -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,
}
@@ -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";
@@ -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;
@@ -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<String>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub image_seed: Option<i64>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub model_seed: Option<i64>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub texture_seed: Option<i64>,
#[ts(optional = nullable)]
pub texture: Option<bool>,
#[ts(optional = nullable)]
pub pbr: Option<bool>,
#[ts(optional = nullable)]
pub texture_quality: Option<Model3dTextureQuality>,
#[ts(optional = nullable)]
pub texture_version: Option<Model3dTextureVersion>,
#[ts(optional = nullable)]
pub delight: Option<bool>,
#[ts(optional = nullable)]
pub geometry_quality: Option<Model3dGeometryQuality>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub face_limit: Option<i64>,
#[ts(optional = nullable)]
pub auto_size: Option<bool>,
#[ts(optional = nullable)]
pub quad: Option<bool>,
#[ts(optional = nullable)]
pub smart_low_poly: Option<bool>,
#[ts(optional = nullable)]
pub generate_parts: Option<bool>,
#[ts(optional = nullable)]
pub compress: Option<Model3dCompression>,
#[ts(optional = nullable)]
pub export_uv: Option<bool>,
#[ts(optional = nullable)]
pub export_orientation: Option<Model3dExportOrientation>,
}
@@ -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<String>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub image_seed: Option<i64>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub model_seed: Option<i64>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub texture_seed: Option<i64>,
#[ts(optional = nullable)]
pub texture: Option<bool>,
#[ts(optional = nullable)]
pub pbr: Option<bool>,
#[ts(optional = nullable)]
pub texture_quality: Option<Model3dTextureQuality>,
#[ts(optional = nullable)]
pub texture_version: Option<Model3dTextureVersion>,
#[ts(optional = nullable)]
pub delight: Option<bool>,
#[ts(optional = nullable)]
pub geometry_quality: Option<Model3dGeometryQuality>,
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub face_limit: Option<i64>,
#[ts(optional = nullable)]
pub auto_size: Option<bool>,
#[ts(optional = nullable)]
pub quad: Option<bool>,
#[ts(optional = nullable)]
pub smart_low_poly: Option<bool>,
#[ts(optional = nullable)]
pub generate_parts: Option<bool>,
#[ts(optional = nullable)]
pub compress: Option<Model3dCompression>,
#[ts(optional = nullable)]
pub export_uv: Option<bool>,
#[ts(optional = nullable)]
pub export_orientation: Option<Model3dExportOrientation>,
pub generation: Model3dTextToModelParams,
pub target: Model3dGenerationTarget,
}
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<Model3dGenerationResult>,
/// 更新时间(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<Model3dArtifact>,
}
#[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<String>,
}
@@ -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,
}
@@ -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::<Model3dGenerationTarget>(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::<Model3dImageToModelRequest>(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::<Model3dImageToModelRequest>(raw_input)
.expect_err("裸字符串图片输入应被拒绝");
}
#[test]
fn api_request_rejects_unknown_top_level_fields() {
let error = serde_json::from_value::<Model3dTextToModelRequest>(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(),
}
);
}