refactor: extract platform media crates
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
use base64::Engine as _;
|
||||
|
||||
use crate::{
|
||||
error::Hyper3dError,
|
||||
types::{DecodedImageDataUrl, MAX_IMAGE_BYTES},
|
||||
};
|
||||
|
||||
pub(crate) fn decode_image_data_urls(
|
||||
values: &[String],
|
||||
) -> Result<Vec<DecodedImageDataUrl>, Hyper3dError> {
|
||||
values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| decode_image_data_url(value, index + 1))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn decode_image_data_url(
|
||||
value: &str,
|
||||
index: usize,
|
||||
) -> Result<DecodedImageDataUrl, Hyper3dError> {
|
||||
let value = value.trim();
|
||||
let Some((metadata, encoded)) = value.split_once(',') else {
|
||||
return Err(invalid_image_data_url("参考图必须是 data URL"));
|
||||
};
|
||||
if !metadata.starts_with("data:image/") || !metadata.ends_with(";base64") {
|
||||
return Err(invalid_image_data_url(
|
||||
"参考图只支持 image/png、image/jpeg 或 image/webp 的 base64 data URL",
|
||||
));
|
||||
}
|
||||
let mime_type = metadata
|
||||
.trim_start_matches("data:")
|
||||
.trim_end_matches(";base64")
|
||||
.to_string();
|
||||
let extension = match mime_type.as_str() {
|
||||
"image/png" => "png",
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
"image/webp" => "webp",
|
||||
_ => {
|
||||
return Err(invalid_image_data_url(
|
||||
"参考图只支持 image/png、image/jpeg 或 image/webp",
|
||||
));
|
||||
}
|
||||
};
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|_| invalid_image_data_url("参考图 base64 解码失败"))?;
|
||||
if bytes.is_empty() || bytes.len() > MAX_IMAGE_BYTES {
|
||||
return Err(invalid_image_data_url("参考图为空或超过 10MB"));
|
||||
}
|
||||
|
||||
Ok(DecodedImageDataUrl {
|
||||
bytes,
|
||||
mime_type,
|
||||
file_name: format!("reference-{index:02}.{extension}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn invalid_image_data_url(message: &str) -> Hyper3dError {
|
||||
Hyper3dError::invalid_request(Some("imageDataUrls"), message.to_string())
|
||||
}
|
||||
119
server-rs/crates/platform-hyper3d/src/request/normalize.rs
Normal file
119
server-rs/crates/platform-hyper3d/src/request/normalize.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use crate::{error::Hyper3dError, types::DEFAULT_CONDITION_MODE};
|
||||
|
||||
pub(crate) fn normalize_required_text(
|
||||
value: &str,
|
||||
field: &'static str,
|
||||
max_chars: usize,
|
||||
) -> Result<String, Hyper3dError> {
|
||||
let normalized = value.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return Err(Hyper3dError::invalid_request(
|
||||
Some(field),
|
||||
format!("{field} 不能为空"),
|
||||
));
|
||||
}
|
||||
if normalized.chars().count() > max_chars {
|
||||
return Err(Hyper3dError::invalid_request(
|
||||
Some(field),
|
||||
format!("{field} 超过 {} 字符", max_chars),
|
||||
));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_optional_limited_text(
|
||||
value: Option<&str>,
|
||||
max_chars: usize,
|
||||
) -> Result<Option<String>, Hyper3dError> {
|
||||
let Some(normalized) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if normalized.chars().count() > max_chars {
|
||||
return Err(Hyper3dError::invalid_request(
|
||||
None,
|
||||
format!("文本超过 {} 字符", max_chars),
|
||||
));
|
||||
}
|
||||
Ok(Some(normalized.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_required_opaque_text(
|
||||
value: &str,
|
||||
field: &'static str,
|
||||
) -> Result<String, Hyper3dError> {
|
||||
let normalized = value.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return Err(Hyper3dError::invalid_request(
|
||||
Some(field),
|
||||
format!("{field} 不能为空"),
|
||||
));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_enum(
|
||||
value: Option<&str>,
|
||||
default_value: &str,
|
||||
allowed_values: &[&str],
|
||||
field: &'static str,
|
||||
) -> Result<String, Hyper3dError> {
|
||||
let value = value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(default_value);
|
||||
if let Some(allowed) = allowed_values
|
||||
.iter()
|
||||
.find(|allowed| allowed.eq_ignore_ascii_case(value))
|
||||
{
|
||||
return Ok((*allowed).to_string());
|
||||
}
|
||||
Err(Hyper3dError::invalid_request_allowed(
|
||||
field,
|
||||
format!("{} 取值非法", field),
|
||||
allowed_values,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_condition_mode(value: Option<&str>) -> Result<String, Hyper3dError> {
|
||||
normalize_enum(
|
||||
value,
|
||||
DEFAULT_CONDITION_MODE,
|
||||
&["concat", "fuse"],
|
||||
"conditionMode",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_addons(values: Vec<String>) -> Result<Vec<String>, Hyper3dError> {
|
||||
let mut addons = Vec::new();
|
||||
for value in values {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if value != "HighPack" {
|
||||
return Err(Hyper3dError::invalid_request(
|
||||
Some("addons"),
|
||||
"addons 首版只支持 HighPack",
|
||||
));
|
||||
}
|
||||
if !addons.iter().any(|addon| addon == value) {
|
||||
addons.push(value.to_string());
|
||||
}
|
||||
}
|
||||
Ok(addons)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_bbox_condition(
|
||||
value: Option<Vec<f32>>,
|
||||
) -> Result<Option<Vec<f32>>, Hyper3dError> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.len() != 3 || value.iter().any(|item| !item.is_finite() || *item <= 0.0) {
|
||||
return Err(Hyper3dError::invalid_request(
|
||||
Some("bboxCondition"),
|
||||
"bboxCondition 必须包含 3 个正数",
|
||||
));
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
111
server-rs/crates/platform-hyper3d/src/request/options.rs
Normal file
111
server-rs/crates/platform-hyper3d/src/request/options.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use crate::{
|
||||
error::Hyper3dError,
|
||||
request::normalize::{normalize_addons, normalize_bbox_condition, normalize_enum},
|
||||
types::{
|
||||
DEFAULT_GEOMETRY_FILE_FORMAT, DEFAULT_MATERIAL, DEFAULT_MESH_MODE, DEFAULT_QUALITY,
|
||||
SubmitOptions,
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) fn build_submit_options_from_text(
|
||||
payload: &shared_contracts::hyper3d::Hyper3dTextToModelRequest,
|
||||
) -> Result<SubmitOptions, Hyper3dError> {
|
||||
SubmitOptions::new(
|
||||
payload.seed,
|
||||
payload.geometry_file_format.as_deref(),
|
||||
payload.material.as_deref(),
|
||||
payload.quality.as_deref(),
|
||||
payload.mesh_mode.as_deref(),
|
||||
payload.addons.clone(),
|
||||
payload.bbox_condition.clone(),
|
||||
payload.preview_render,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_submit_options_from_image(
|
||||
payload: &shared_contracts::hyper3d::Hyper3dImageToModelRequest,
|
||||
) -> Result<SubmitOptions, Hyper3dError> {
|
||||
SubmitOptions::new(
|
||||
payload.seed,
|
||||
payload.geometry_file_format.as_deref(),
|
||||
payload.material.as_deref(),
|
||||
payload.quality.as_deref(),
|
||||
payload.mesh_mode.as_deref(),
|
||||
payload.addons.clone(),
|
||||
payload.bbox_condition.clone(),
|
||||
payload.preview_render,
|
||||
)
|
||||
}
|
||||
|
||||
impl SubmitOptions {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
seed: Option<u32>,
|
||||
geometry_file_format: Option<&str>,
|
||||
material: Option<&str>,
|
||||
quality: Option<&str>,
|
||||
mesh_mode: Option<&str>,
|
||||
addons: Vec<String>,
|
||||
bbox_condition: Option<Vec<f32>>,
|
||||
preview_render: Option<bool>,
|
||||
) -> Result<Self, Hyper3dError> {
|
||||
Ok(Self {
|
||||
seed,
|
||||
geometry_file_format: normalize_enum(
|
||||
geometry_file_format,
|
||||
DEFAULT_GEOMETRY_FILE_FORMAT,
|
||||
&["glb", "usdz", "fbx", "obj", "stl"],
|
||||
"geometryFileFormat",
|
||||
)?,
|
||||
material: normalize_enum(
|
||||
material,
|
||||
DEFAULT_MATERIAL,
|
||||
&["PBR", "Shaded", "All"],
|
||||
"material",
|
||||
)?,
|
||||
quality: normalize_enum(
|
||||
quality,
|
||||
DEFAULT_QUALITY,
|
||||
&["high", "medium", "low", "extra-low"],
|
||||
"quality",
|
||||
)?,
|
||||
mesh_mode: normalize_enum(mesh_mode, DEFAULT_MESH_MODE, &["Quad", "Raw"], "meshMode")?,
|
||||
addons: normalize_addons(addons)?,
|
||||
bbox_condition: normalize_bbox_condition(bbox_condition)?,
|
||||
preview_render: preview_render.unwrap_or(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_common_submit_fields(
|
||||
form: reqwest::multipart::Form,
|
||||
options: &SubmitOptions,
|
||||
) -> Result<reqwest::multipart::Form, Hyper3dError> {
|
||||
let mut form = form
|
||||
.text(
|
||||
"geometry_file_format",
|
||||
options.geometry_file_format.to_string(),
|
||||
)
|
||||
.text("material", options.material.to_string())
|
||||
.text("quality", options.quality.to_string())
|
||||
.text("mesh_mode", options.mesh_mode.to_string())
|
||||
.text("preview_render", options.preview_render.to_string());
|
||||
if let Some(seed) = options.seed {
|
||||
form = form.text("seed", seed.to_string());
|
||||
}
|
||||
for addon in &options.addons {
|
||||
form = form.text("addons", addon.to_string());
|
||||
}
|
||||
if let Some(bbox_condition) = &options.bbox_condition {
|
||||
form = form.text(
|
||||
"bbox_condition",
|
||||
serde_json::to_string(bbox_condition).map_err(|error| {
|
||||
Hyper3dError::invalid_request(
|
||||
Some("bboxCondition"),
|
||||
format!("bboxCondition 序列化失败:{error}"),
|
||||
)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
Ok(form)
|
||||
}
|
||||
64
server-rs/crates/platform-hyper3d/src/request/tests.rs
Normal file
64
server-rs/crates/platform-hyper3d/src/request/tests.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use shared_contracts::hyper3d as contract;
|
||||
|
||||
use super::{
|
||||
image_data_url::decode_image_data_url,
|
||||
normalize::{normalize_bbox_condition, normalize_required_opaque_text},
|
||||
options::build_submit_options_from_text,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn validates_and_defaults_submit_options() {
|
||||
let payload = contract::Hyper3dTextToModelRequest {
|
||||
prompt: "宝箱".to_string(),
|
||||
negative_prompt: None,
|
||||
seed: Some(7),
|
||||
geometry_file_format: None,
|
||||
material: None,
|
||||
quality: None,
|
||||
mesh_mode: None,
|
||||
addons: vec!["HighPack".to_string()],
|
||||
bbox_condition: Some(vec![1.0, 2.0, 3.0]),
|
||||
preview_render: None,
|
||||
};
|
||||
|
||||
let options = build_submit_options_from_text(&payload).expect("options should build");
|
||||
|
||||
assert_eq!(options.geometry_file_format, "glb");
|
||||
assert_eq!(options.material, "PBR");
|
||||
assert_eq!(options.quality, "medium");
|
||||
assert_eq!(options.mesh_mode, "Quad");
|
||||
assert_eq!(options.addons, vec!["HighPack"]);
|
||||
assert!(options.preview_render);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_bbox_condition() {
|
||||
let error =
|
||||
normalize_bbox_condition(Some(vec![1.0, 0.0, 3.0])).expect_err("invalid bbox should fail");
|
||||
|
||||
assert_eq!(error.status_hint(), crate::Hyper3dStatusHint::BadRequest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_opaque_subscription_key_without_length_cap() {
|
||||
let long_key = "a".repeat(300);
|
||||
let normalized = normalize_required_opaque_text(&format!(" {long_key} "), "subscriptionKey")
|
||||
.expect("subscription key should be accepted");
|
||||
|
||||
assert_eq!(normalized, long_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_png_data_url() {
|
||||
let data_url = format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")
|
||||
);
|
||||
|
||||
let image = decode_image_data_url(&data_url, 1).expect("image should decode");
|
||||
|
||||
assert_eq!(image.mime_type, "image/png");
|
||||
assert_eq!(image.file_name, "reference-01.png");
|
||||
assert!(!image.bytes.is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user