multiview 视图输入显式化为 url / fileToken,线上按文档 view-key 形态发出

- 契约新增 Model3dViewInput(url / fileToken 的 tagged enum),views 的四个视图都改用它,裸字符串在契约层即被拒绝
- platform-tripo 不再走 SDK 的位置数组形态:按 Tripo 文档推荐的 view-key 构造 inputs,值写成显式 {url} / {file_token},种类由契约字段决定、链路上不再猜前缀
- 视图校验(front 必填、至少两张、空白按未提供)与 inputs 构造合并成一条路径,不再两处重复规则
- 新增 4 条定向用例钉住发出的 JSON 形状、空白视图、视图数量与 taskId 复用;契约用例补齐裸字符串与视图内未知字段被拒
- 重新生成 ts-rs 绑定与导出索引,技术方案同步 multiview 输入口径
This commit is contained in:
2026-09-23 18:59:05 +08:00
parent 5ebc6df6ae
commit f198b7a6d0
9 changed files with 339 additions and 86 deletions
@@ -26,7 +26,7 @@ task 尚未完成时 `output` 为空;完成后 `output` 必须是与 task type
- text-to-model`prompt` 必填,上限 1024 字符;`negative_prompt` 上限 255 字符。空白 prompt 在提交前拒绝。
- image-to-model`source``Model3dGenerationSource`,即带 `kind` 的站内引用 tagged enum`resource { resourceId }``asset { assetId }`),不接受裸字符串、远程 URL 与 data URL。提交时只按 ID 定点校验归属与记录类型(跨 owner、未登记、类型不符统一 400),worker 执行时再从私有 OSS 读出字节、上传 provider 换回 `file_token` 后提交;provider 生成参数在 `generation` 里。
- multiview-to-model`inputs` 是带 `kind` 的 tagged enum,二选一——`views { front, left, back, right }` 或复用已有结果的 `taskId``views``front` 必填,其余视图至少再提供一张(少于两张视图直接拒绝);左/后/右视图为空白字符串时按未提供处理,不会上传空文件图片与视图引用都先去掉首尾空白再交给 SDK`taskId` 走统一的 task id 校验。
- multiview-to-model`inputs` 是带 `kind` 的 tagged enum,二选一——`views { front, left, back, right }` 或复用已有结果的 `taskId``views``front` 必填,其余视图至少再提供一张(少于两张视图直接拒绝);每个视图是 `Model3dViewInput``url``fileToken`,同样是带 `kind` 的 tagged enum),不接受裸字符串。取值空白(含纯空白)的视图按未提供处理,不会上传空文件图片引用与视图地址都先去掉首尾空白再交给 provider`taskId` 走统一的 task id 校验。线上 `inputs` 按 Tripo 文档推荐的 view-key 形态构造(`[{"front":{"url":…}},{"left":{"file_token":…}}]`):SDK 的 `MultiviewToModelParams::from_views` 只能发位置数组 `["<裸字符串>", "", …]`,服务端得按前缀猜那是 URL、file_token 还是 task_id,因此该字段由 `platform-tripo` 自己构造后经 `extra` 透传,种类由契约字段决定。
三个入口的 `model` 都必填,其余生成参数可选。SDK params 未命名的 `texture_version``delight`,由 image-to-model 和 multiview-to-model 通过 extra 字段透传。
@@ -17,6 +17,7 @@ export type * from './image-to-model/Model3dImageToModelResult';
export type * from './Model3dGenerationResult';
export type * from './multiview-to-model/Model3dMultiviewInputs';
export type * from './multiview-to-model/Model3dMultiviewToModelRequest';
export type * from './multiview-to-model/Model3dViewInput';
export type * from './text-to-model/Model3dTextToModelParams';
export type * from './text-to-model/Model3dTextToModelRequest';
export type * from './text-to-model/Model3dTextToModelResult';
@@ -1,11 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Model3dViewInput } from './Model3dViewInput';
export type Model3dMultiviewInputs =
| {
kind: 'views';
front: string;
left?: string | null;
back?: string | null;
right?: string | null;
front: Model3dViewInput;
left?: Model3dViewInput | null;
back?: Model3dViewInput | null;
right?: Model3dViewInput | null;
}
| { kind: 'taskId'; taskId: string };
@@ -0,0 +1,15 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 单个视图的输入形态:公网可读地址或 provider 上传返回的 `file_token`,显式二选一。
*
* 不用裸字符串:Tripo 文档里 `inputs` 的取值可以是 URL、file_token 或嵌套
* `{url}` / `{file_token}` / `{object:{bucket,key}}`,裸字符串要靠服务端按前缀猜种类,
* file_token 与 task_id 尤其容易混。契约里写明「哪一种」,平台层就能构造显式对象,
* 提交链路上不再有猜测。
*
* 平台自己只产出这两种:站内引用先上传换成 `file_token`,公网地址按 `url` 直传。
*/
export type Model3dViewInput =
| { kind: 'url'; url: string }
| { kind: 'fileToken'; fileToken: string };
@@ -18,7 +18,9 @@ use platform_tripo::{TripoImageInput, TripoProviderClient};
use shared_contracts::model3d::{
common::{Model3dModelVersion, Model3dTaskStatus, Model3dTextureQuality},
image_to_model::Model3dImageToModelParams,
multiview_to_model::{Model3dMultiviewInputs, Model3dMultiviewToModelRequest},
multiview_to_model::{
Model3dMultiviewInputs, Model3dMultiviewToModelRequest, Model3dViewInput,
},
text_to_model::Model3dTextToModelParams,
};
use tokio::{fs::File, io::AsyncWriteExt};
@@ -106,8 +108,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let multiview = client
.submit_multiview_to_model(&Model3dMultiviewToModelRequest {
inputs: Model3dMultiviewInputs::Views {
front: SAMPLE_IMAGE_URL.to_string(),
left: Some(SAMPLE_IMAGE_URL.to_string()),
front: Model3dViewInput::Url {
url: SAMPLE_IMAGE_URL.to_string(),
},
left: Some(Model3dViewInput::Url {
url: SAMPLE_IMAGE_URL.to_string(),
}),
back: None,
right: None,
},
@@ -1,7 +1,8 @@
use serde_json::Value;
use shared_contracts::model3d::multiview_to_model::{
Model3dMultiviewInputs, Model3dMultiviewToModelRequest,
Model3dMultiviewInputs, Model3dMultiviewToModelRequest, Model3dViewInput,
};
use tripo3d_sdk::{models::FileInput, params::MultiviewToModelParams};
use tripo3d_sdk::params::MultiviewToModelParams;
use crate::common::{
TripoError, TripoField, TripoGenerationOptions, TripoProviderClient, TripoTaskHandle,
@@ -9,43 +10,19 @@ use crate::common::{
validate_task_id, wire, wire_geometry_quality, wire_option,
};
/// `inputs` 的线上字段名。SDK 的位置数组形态表达不了文档推荐的 view-key 形态,
/// 因此这个字段由平台自己按文档构造后经 `extra` 透传(见 `view_inputs_wire`)。
const MODEL3D_MULTIVIEW_INPUTS_FIELD: &str = "inputs";
impl TripoProviderClient {
pub async fn submit_multiview_to_model(
&self,
request: &Model3dMultiviewToModelRequest,
) -> Result<TripoTaskHandle, TripoError> {
match &request.inputs {
Model3dMultiviewInputs::Views {
front,
left,
back,
right,
} => {
if front.trim().is_empty() {
return Err(TripoError::InvalidParameters {
field: Some(TripoField::Inputs),
reason: TripoValidationReason::Required,
message: "front view is required".into(),
});
}
if [left, back, right]
.into_iter()
.filter(|value| view_input(*value).is_some())
.count()
== 0
{
return Err(TripoError::InvalidParameters {
field: Some(TripoField::Inputs),
reason: TripoValidationReason::InvalidCombination,
message: "at least two views are required".into(),
});
}
}
Model3dMultiviewInputs::TaskId { task_id } => validate_task_id(task_id)?,
}
// 与 text / image 路径共用同一份字段映射(见 common::validation 的宏),
// 新增共享字段时三条路径一起跟上。
validate_generation_options(&TripoGenerationOptions::from(request))?;
// 视图校验与 `inputs` 构造走同一条路径,不在这里重复一遍规则。
let task_id = self
.client
.multiview_to_model(to_sdk_params(request)?)
@@ -54,41 +31,96 @@ impl TripoProviderClient {
submitted_task_handle(task_id)
}
}
/// 空白(含纯空白)视图按「未提供」处理:提交前校验与 SDK 参数转换共用这一条规则。
fn view_input(value: &Option<String>) -> Option<FileInput> {
value
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(FileInput::from)
}
fn to_sdk_params(
request: &Model3dMultiviewToModelRequest,
) -> Result<MultiviewToModelParams, TripoError> {
let mut params = match &request.inputs {
/// 视图输入的线上形态:Tripo 文档推荐的 view-key 数组,每项只含一个视图键,值为显式对象。
///
/// 不走 SDK 的 `MultiviewToModelParams::from_views`:它产出的是位置数组
/// `["<裸字符串>", "", ...]`,服务端只能按前缀猜那是 URL、file_token 还是 task_id
/// (见 review #77)。文档允许把值写成嵌套的 `{url}` / `{file_token}`,这里就按这个形态
/// 构造,种类由契约字段决定,链路上没有猜测。
///
/// 空白(含纯空白)视图按「未提供」处理:校验与请求构造共用这一条规则。
/// `front` 必填、至少两张视图的口径与 SDK 自己的校验一致,只是提前到提交前报错。
fn view_inputs_wire(inputs: &Model3dMultiviewInputs) -> Result<Vec<Value>, TripoError> {
match inputs {
Model3dMultiviewInputs::Views {
front,
left,
back,
right,
} => MultiviewToModelParams::from_views([
Some(FileInput::from(front.trim())),
view_input(left),
view_input(back),
view_input(right),
]),
Model3dMultiviewInputs::TaskId { task_id } => {
MultiviewToModelParams::from_task_id(task_id.trim().to_owned())
} => {
let front = view_value(front).ok_or_else(|| TripoError::InvalidParameters {
field: Some(TripoField::Inputs),
reason: TripoValidationReason::Required,
message: "front view is required".into(),
})?;
let mut entries = vec![view_entry("front", front)];
let mut provided = 0;
for (key, view) in [("left", left), ("back", back), ("right", right)] {
if let Some(value) = view.as_ref().and_then(view_value) {
provided += 1;
entries.push(view_entry(key, value));
}
}
if provided == 0 {
return Err(TripoError::InvalidParameters {
field: Some(TripoField::Inputs),
reason: TripoValidationReason::InvalidCombination,
message: "at least two views are required".into(),
});
}
Ok(entries)
}
};
Model3dMultiviewInputs::TaskId { task_id } => {
validate_task_id(task_id)?;
Ok(vec![view_entry(
"task_id",
Value::String(task_id.trim().to_string()),
)])
}
}
}
/// 单个视图的显式取值:URL 与 file_token 各自一种形态,不合并成裸字符串。
///
/// 只做非空白校验,不在这里收窄地址范围(例如拒绝 userinfo / 内网主机):该字段是
/// provider 侧要读的地址,与 image-to-model 的 `PublicUrl` 同一口径,见 ADR 0001 的边界约定。
fn view_value(view: &Model3dViewInput) -> Option<Value> {
match view {
Model3dViewInput::Url { url } => {
let url = url.trim();
(!url.is_empty()).then(|| serde_json::json!({ "url": url }))
}
Model3dViewInput::FileToken { file_token } => {
let file_token = file_token.trim();
(!file_token.is_empty()).then(|| serde_json::json!({ "file_token": file_token }))
}
}
}
fn view_entry(key: &str, value: Value) -> Value {
let mut entry = serde_json::Map::new();
entry.insert(key.to_string(), value);
Value::Object(entry)
}
fn to_sdk_params(
request: &Model3dMultiviewToModelRequest,
) -> Result<MultiviewToModelParams, TripoError> {
let mut params = MultiviewToModelParams::default();
params.extra.insert(
MODEL3D_MULTIVIEW_INPUTS_FIELD.to_string(),
Value::Array(view_inputs_wire(&request.inputs)?),
);
params.model = Some(wire(&request.model)?);
params.model_seed = request.model_seed;
params.texture_seed = request.texture_seed;
params.texture = request.texture;
params.pbr = request.pbr;
params.texture_quality = wire_option(request.texture_quality.as_ref())?;
params.extra = extra_fields(request.texture_version, request.delight)?;
params
.extra
.extend(extra_fields(request.texture_version, request.delight)?);
params.geometry_quality =
wire_option(wire_geometry_quality(request.model, request.geometry_quality).as_ref())?;
params.texture_alignment = wire_option(request.texture_alignment.as_ref())?;
@@ -103,3 +135,113 @@ fn to_sdk_params(
params.export_orientation = wire_option(request.export_orientation.as_ref())?;
Ok(params)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn views_request(inputs: Value) -> Model3dMultiviewToModelRequest {
serde_json::from_value(json!({
"inputs": inputs,
"model": "v3.1-20260211",
"texture": true,
"pbr": true,
"textureQuality": "standard",
"geometryQuality": "standard"
}))
.expect("测试请求应可反序列化")
}
fn serialized_inputs(inputs: Value) -> Value {
let request = views_request(inputs);
let params = to_sdk_params(&request).expect("视图输入应可映射成 SDK 参数");
serde_json::to_value(params).expect("SDK 参数应可序列化")["inputs"].clone()
}
/// 视图按文档推荐的 view-key 形态发出,值是显式对象:服务端不需要按前缀猜
/// URL / file_token / task_idfile_token 也就不会被误当成 task_id。
#[test]
fn views_are_sent_as_view_key_entries_with_explicit_values() {
assert_eq!(
serialized_inputs(json!({
"kind": "views",
"front": { "kind": "url", "url": " https://example.com/front.png " },
"left": { "kind": "fileToken", "fileToken": "tripo-token-1" },
"back": null,
"right": null
})),
json!([
{ "front": { "url": "https://example.com/front.png" } },
{ "left": { "file_token": "tripo-token-1" } }
])
);
}
/// 空白视图(含纯空白)等于没给:不会退化成空对象或空字符串发出去。
#[test]
fn blank_views_are_treated_as_missing() {
assert_eq!(
serialized_inputs(json!({
"kind": "views",
"front": { "kind": "url", "url": "https://example.com/front.png" },
"left": { "kind": "fileToken", "fileToken": " " },
"back": { "kind": "url", "url": "https://example.com/back.png" },
"right": { "kind": "fileToken", "fileToken": "" }
})),
json!([
{ "front": { "url": "https://example.com/front.png" } },
{ "back": { "url": "https://example.com/back.png" } }
])
);
}
/// front 必填、至少两张视图的口径在这里就报错,不把请求发出去。
#[test]
fn views_require_front_and_at_least_two_entries() {
for (inputs, expected) in [
(
json!({
"kind": "views",
"front": { "kind": "url", "url": " " },
"left": { "kind": "url", "url": "https://example.com/left.png" }
}),
"front view is required",
),
(
json!({
"kind": "views",
"front": { "kind": "url", "url": "https://example.com/front.png" },
"left": null,
"back": null,
"right": null
}),
"at least two views are required",
),
] {
let error = to_sdk_params(&views_request(inputs))
.err()
.map(|error| error.to_string())
.unwrap_or_else(|| panic!("{expected} 应被拒绝"));
assert!(error.contains(expected), "实际错误为:{error}");
}
}
/// 复用已有任务时也走同一条构造路径:task id 单独校验后放进 `[{task_id}]`。
#[test]
fn task_id_reuse_is_validated_and_wrapped() {
assert_eq!(
serialized_inputs(json!({ "kind": "taskId", "taskId": " tripo-task-1 " })),
json!([{ "task_id": "tripo-task-1" }])
);
let error = to_sdk_params(&views_request(json!({ "kind": "taskId", "taskId": " " })))
.err()
.expect("空白 task id 应被拒绝");
assert!(
error.to_string().contains("task_id"),
"实际错误为:{}",
error
);
}
}
@@ -3,4 +3,4 @@ pub(crate) const MODEL3D_TS_EXPORT_DIR: &str =
mod request;
pub use request::{Model3dMultiviewInputs, Model3dMultiviewToModelRequest};
pub use request::{Model3dMultiviewInputs, Model3dMultiviewToModelRequest, Model3dViewInput};
@@ -24,18 +24,46 @@ use super::MODEL3D_TS_EXPORT_DIR;
#[derive(ts_rs::TS)]
#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)]
pub enum Model3dMultiviewInputs {
/// 直接给多视图:`front` 必填,`left` / `back` / `right` 至少再给一张。
///
/// 视图顺序由服务端按 [front, left, back, right] 归一,这里的字段名就是视图语义,
/// 与 provider 的数组位置无关。
Views {
front: String,
front: Model3dViewInput,
#[ts(optional = nullable)]
left: Option<String>,
left: Option<Model3dViewInput>,
#[ts(optional = nullable)]
back: Option<String>,
back: Option<Model3dViewInput>,
#[ts(optional = nullable)]
right: Option<String>,
},
TaskId {
task_id: String,
right: Option<Model3dViewInput>,
},
/// 复用已有任务的视图数据:只给 provider task id,不再重复传图。
TaskId { task_id: String },
}
/// 单个视图的输入形态:公网可读地址或 provider 上传返回的 `file_token`,显式二选一。
///
/// 不用裸字符串:Tripo 文档里 `inputs` 的取值可以是 URL、file_token 或嵌套
/// `{url}` / `{file_token}` / `{object:{bucket,key}}`,裸字符串要靠服务端按前缀猜种类,
/// file_token 与 task_id 尤其容易混。契约里写明「哪一种」,平台层就能构造显式对象,
/// 提交链路上不再有猜测。
///
/// 平台自己只产出这两种:站内引用先上传换成 `file_token`,公网地址按 `url` 直传。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
// 见上:多词字段必须单独声明 camelCase。
rename_all_fields = "camelCase",
deny_unknown_fields
)]
#[derive(ts_rs::TS)]
#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)]
pub enum Model3dViewInput {
/// 公网可读地址;provider 自己去取。
Url { url: String },
/// provider 上传接口返回的 `file_token`;私有对象必须先上传再提交。
FileToken { file_token: String },
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
@@ -4,22 +4,32 @@ use shared_contracts::model3d::common::{
Model3dModelVersion, Model3dTextureAlignment, Model3dTextureQuality, Model3dTextureVersion,
};
use shared_contracts::model3d::multiview_to_model::{
Model3dMultiviewInputs, Model3dMultiviewToModelRequest,
Model3dMultiviewInputs, Model3dMultiviewToModelRequest, Model3dViewInput,
};
#[test]
fn multiview_inputs_accepts_declared_fields_and_camel_case_tag() {
let inputs: Model3dMultiviewInputs = serde_json::from_value(json!({
"kind": "views",
"front": "front.png",
"left": "left.png"
"front": { "kind": "url", "url": "front.png" },
"left": { "kind": "fileToken", "fileToken": "left-token" }
}))
.expect("已声明字段应可反序列化");
match inputs {
Model3dMultiviewInputs::Views { front, left, .. } => {
assert_eq!(front, "front.png");
assert_eq!(left.as_deref(), Some("left.png"));
assert_eq!(
front,
Model3dViewInput::Url {
url: "front.png".to_string()
}
);
assert_eq!(
left,
Some(Model3dViewInput::FileToken {
file_token: "left-token".to_string()
})
);
}
Model3dMultiviewInputs::TaskId { .. } => panic!("kind=views 不应解成 taskId 变体"),
}
@@ -39,10 +49,10 @@ fn multiview_inputs_reject_unknown_fields_inside_variants() {
// 这条用例钉住该严格性,防止为消除 ts-rs 告警而把它改成非严格模式。
let error = serde_json::from_value::<Model3dMultiviewInputs>(json!({
"kind": "views",
"front": "front.png",
"left": "left.png",
"back": "back.png",
"right": "right.png",
"front": { "kind": "url", "url": "front.png" },
"left": { "kind": "url", "url": "left.png" },
"back": { "kind": "url", "url": "back.png" },
"right": { "kind": "url", "url": "right.png" },
"unexpected": true
}))
.expect_err("变体里出现未知字段应被拒绝");
@@ -87,10 +97,10 @@ fn multiview_request_round_trips_every_optional_field() {
let payload = json!({
"inputs": {
"kind": "views",
"front": "front.png",
"left": "left.png",
"back": "back.png",
"right": "right.png"
"front": { "kind": "url", "url": "front.png" },
"left": { "kind": "fileToken", "fileToken": "left-token" },
"back": { "kind": "url", "url": "back.png" },
"right": { "kind": "fileToken", "fileToken": "right-token" }
},
"model": "v3.1-20260211",
"modelSeed": 7,
@@ -123,10 +133,30 @@ fn multiview_request_round_trips_every_optional_field() {
back,
right,
} => {
assert_eq!(front, "front.png");
assert_eq!(left.as_deref(), Some("left.png"));
assert_eq!(back.as_deref(), Some("back.png"));
assert_eq!(right.as_deref(), Some("right.png"));
assert_eq!(
front,
&Model3dViewInput::Url {
url: "front.png".to_string()
}
);
assert_eq!(
left,
&Some(Model3dViewInput::FileToken {
file_token: "left-token".to_string()
})
);
assert_eq!(
back,
&Some(Model3dViewInput::Url {
url: "back.png".to_string()
})
);
assert_eq!(
right,
&Some(Model3dViewInput::FileToken {
file_token: "right-token".to_string()
})
);
}
Model3dMultiviewInputs::TaskId { .. } => panic!("kind=views 不应解成 taskId 变体"),
}
@@ -213,3 +243,33 @@ fn multiview_request_round_trips_explicit_nulls() {
payload
);
}
#[test]
fn multiview_views_require_typed_inputs_without_unknown_fields() {
// 裸字符串只能靠前缀猜种类(URL / file_token / task_id),契约层直接拒绝,
// 逼调用方写明是 `url` 还是 `fileToken`。
let error = serde_json::from_value::<Model3dMultiviewInputs>(json!({
"kind": "views",
"front": "front.png",
"left": "left.png"
}))
.expect_err("裸字符串视图应被拒绝");
assert!(
error.to_string().contains("invalid type"),
"裸字符串应报类型错误,实际为:{error}"
);
// 每个视图只允许一种取值:kind=url 时再给 fileToken 会被拒绝。
let error = serde_json::from_value::<Model3dMultiviewInputs>(json!({
"kind": "views",
"front": { "kind": "url", "url": "front.png", "fileToken": "front-token" },
"left": { "kind": "fileToken", "fileToken": "left-token" }
}))
.expect_err("视图取值里出现未知字段应被拒绝");
assert!(
error.to_string().contains("unknown field"),
"未知字段错误应指出 unknown field,实际为:{error}"
);
}