refactor: extract platform media crates

This commit is contained in:
kdletters
2026-05-26 13:18:13 +08:00
parent 50f44489cd
commit 44c65df5c9
92 changed files with 7381 additions and 5848 deletions

View File

@@ -0,0 +1,179 @@
use reqwest::multipart;
use serde_json::json;
use shared_contracts::hyper3d as contract;
use crate::{
error::Hyper3dError,
request::{
build_common_submit_fields, build_submit_options_from_image,
build_submit_options_from_text, decode_image_data_urls, normalize_condition_mode,
normalize_optional_limited_text, normalize_required_opaque_text, normalize_required_text,
},
response::{
build_submit_response, extract_download_files, extract_job_statuses,
resolve_hyper3d_overall_status,
},
transport::{post_hyper3d_json, post_hyper3d_multipart},
types::{
HYPER3D_PROVIDER, Hyper3dSettings, MAX_IMAGE_COUNT, MAX_NEGATIVE_PROMPT_CHARS,
MAX_PROMPT_CHARS, RODIN_GEN2_TIER,
},
};
pub fn build_hyper3d_http_client(
settings: &Hyper3dSettings,
) -> Result<reqwest::Client, Hyper3dError> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(
settings.request_timeout_ms.max(1),
))
.build()
.map_err(|error| {
Hyper3dError::invalid_config(
"build_hyper3d_http_client",
format!("构造 Hyper3D HTTP 客户端失败:{error}"),
)
})
}
pub async fn submit_text_to_model(
state: &Hyper3dSettings,
payload: contract::Hyper3dTextToModelRequest,
) -> Result<contract::Hyper3dTaskSubmitResponse, Hyper3dError> {
let http_client = build_hyper3d_http_client(state)?;
let prompt = normalize_required_text(&payload.prompt, "prompt", MAX_PROMPT_CHARS)?;
let options = build_submit_options_from_text(&payload)?;
let mut form = multipart::Form::new()
.text("tier", RODIN_GEN2_TIER.to_string())
.text("prompt", prompt);
form = build_common_submit_fields(form, &options)?;
if let Some(negative_prompt) = normalize_optional_limited_text(
payload.negative_prompt.as_deref(),
MAX_NEGATIVE_PROMPT_CHARS,
)? {
form = form.text("negative_prompt", negative_prompt);
}
let response = post_hyper3d_multipart(
&http_client,
state,
"/rodin",
form,
"提交 Hyper3D 文生模型任务失败",
)
.await?;
build_submit_response(contract::Hyper3dGenerationMode::TextToModel, response)
}
pub async fn submit_image_to_model(
state: &Hyper3dSettings,
payload: contract::Hyper3dImageToModelRequest,
) -> Result<contract::Hyper3dTaskSubmitResponse, Hyper3dError> {
let http_client = build_hyper3d_http_client(state)?;
let options = build_submit_options_from_image(&payload)?;
let mut form = multipart::Form::new().text("tier", RODIN_GEN2_TIER.to_string());
form = build_common_submit_fields(form, &options)?;
let condition_mode = normalize_condition_mode(payload.condition_mode.as_deref())?;
form = form.text("condition_mode", condition_mode);
if let Some(prompt) =
normalize_optional_limited_text(payload.prompt.as_deref(), MAX_PROMPT_CHARS)?
{
form = form.text("prompt", prompt);
}
for image_url in payload
.image_urls
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
form = form.text("image_urls", image_url.to_string());
}
for image in decode_image_data_urls(&payload.image_data_urls)? {
let part = multipart::Part::bytes(image.bytes)
.file_name(image.file_name)
.mime_str(&image.mime_type)
.map_err(|error| {
Hyper3dError::invalid_request(
Some("imageDataUrls"),
format!("构造图生模型图片字段失败:{error}"),
)
})?;
form = form.part("images", part);
}
if payload.image_data_urls.is_empty() && payload.image_urls.is_empty() {
return Err(Hyper3dError::invalid_request(
Some("imageDataUrls"),
"图生模型至少需要一张参考图",
));
}
if payload.image_data_urls.len() + payload.image_urls.len() > MAX_IMAGE_COUNT {
return Err(Hyper3dError::invalid_request(
Some("imageDataUrls"),
format!("图生模型最多支持 {} 张参考图", MAX_IMAGE_COUNT),
));
}
let response = post_hyper3d_multipart(
&http_client,
state,
"/rodin",
form,
"提交 Hyper3D 图生模型任务失败",
)
.await?;
build_submit_response(contract::Hyper3dGenerationMode::ImageToModel, response)
}
pub async fn query_task_status(
state: &Hyper3dSettings,
payload: contract::Hyper3dTaskStatusRequest,
) -> Result<contract::Hyper3dTaskStatusResponse, Hyper3dError> {
let http_client = build_hyper3d_http_client(state)?;
let subscription_key =
normalize_required_opaque_text(&payload.subscription_key, "subscriptionKey")?;
let response = post_hyper3d_json(
&http_client,
state,
"/status",
json!({ "subscription_key": subscription_key }),
"查询 Hyper3D 模型任务状态失败",
)
.await?;
let jobs = extract_job_statuses(&response);
let status = resolve_hyper3d_overall_status(&response, &jobs);
Ok(contract::Hyper3dTaskStatusResponse {
ok: true,
provider: HYPER3D_PROVIDER.to_string(),
status,
jobs,
raw: response,
})
}
pub async fn query_downloads(
state: &Hyper3dSettings,
payload: contract::Hyper3dDownloadRequest,
) -> Result<contract::Hyper3dDownloadResponse, Hyper3dError> {
let http_client = build_hyper3d_http_client(state)?;
let task_uuid = normalize_required_text(&payload.task_uuid, "taskUuid", 256)?;
let response = post_hyper3d_json(
&http_client,
state,
"/download",
json!({ "task_uuid": task_uuid }),
"获取 Hyper3D 模型下载列表失败",
)
.await?;
Ok(contract::Hyper3dDownloadResponse {
ok: true,
provider: HYPER3D_PROVIDER.to_string(),
files: extract_download_files(&response),
raw: response,
})
}

View File

@@ -0,0 +1,180 @@
use std::{error::Error, fmt};
use crate::HYPER3D_PROVIDER;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Hyper3dStatusHint {
BadRequest,
ServiceUnavailable,
BadGateway,
GatewayTimeout,
}
#[derive(Clone, Debug)]
pub enum Hyper3dError {
InvalidConfig {
provider: &'static str,
reason: Option<&'static str>,
message: String,
},
InvalidRequest {
provider: &'static str,
field: Option<&'static str>,
message: String,
allowed: Option<Vec<String>>,
},
Request {
provider: &'static str,
message: String,
endpoint: Option<String>,
timeout: bool,
connect: bool,
request: bool,
body: bool,
status_code: Option<u16>,
source: Option<String>,
},
Upstream {
provider: &'static str,
message: String,
upstream_status: u16,
raw_excerpt: String,
},
ResponseParse {
provider: &'static str,
message: String,
raw_excerpt: String,
},
MissingField {
provider: &'static str,
message: String,
},
}
impl Hyper3dError {
pub fn provider(&self) -> &'static str {
match self {
Self::InvalidConfig { provider, .. }
| Self::InvalidRequest { provider, .. }
| Self::Request { provider, .. }
| Self::Upstream { provider, .. }
| Self::ResponseParse { provider, .. }
| Self::MissingField { provider, .. } => provider,
}
}
pub fn message(&self) -> &str {
match self {
Self::InvalidConfig { message, .. }
| Self::InvalidRequest { message, .. }
| Self::Request { message, .. }
| Self::Upstream { message, .. }
| Self::ResponseParse { message, .. }
| Self::MissingField { message, .. } => message,
}
}
pub fn status_hint(&self) -> Hyper3dStatusHint {
match self {
Self::InvalidConfig { .. } => Hyper3dStatusHint::ServiceUnavailable,
Self::InvalidRequest { .. } => Hyper3dStatusHint::BadRequest,
Self::Request { timeout, .. } if *timeout => Hyper3dStatusHint::GatewayTimeout,
Self::Request { .. }
| Self::Upstream { .. }
| Self::ResponseParse { .. }
| Self::MissingField { .. } => Hyper3dStatusHint::BadGateway,
}
}
pub fn invalid_config(reason: &'static str, message: impl Into<String>) -> Self {
Self::InvalidConfig {
provider: HYPER3D_PROVIDER,
reason: Some(reason),
message: message.into(),
}
}
pub(crate) fn invalid_request(field: Option<&'static str>, message: impl Into<String>) -> Self {
Self::InvalidRequest {
provider: HYPER3D_PROVIDER,
field,
message: message.into(),
allowed: None,
}
}
pub(crate) fn invalid_request_allowed(
field: &'static str,
message: impl Into<String>,
allowed: &[&str],
) -> Self {
Self::InvalidRequest {
provider: HYPER3D_PROVIDER,
field: Some(field),
message: message.into(),
allowed: Some(allowed.iter().map(|value| value.to_string()).collect()),
}
}
pub(crate) fn request(
message: impl Into<String>,
endpoint: Option<String>,
timeout: bool,
connect: bool,
request: bool,
body: bool,
status_code: Option<u16>,
source: Option<String>,
) -> Self {
Self::Request {
provider: HYPER3D_PROVIDER,
message: message.into(),
endpoint,
timeout,
connect,
request,
body,
status_code,
source,
}
}
pub(crate) fn upstream(
message: impl Into<String>,
upstream_status: u16,
raw_excerpt: impl Into<String>,
) -> Self {
Self::Upstream {
provider: HYPER3D_PROVIDER,
message: message.into(),
upstream_status,
raw_excerpt: raw_excerpt.into(),
}
}
pub(crate) fn response_parse(
message: impl Into<String>,
raw_excerpt: impl Into<String>,
) -> Self {
Self::ResponseParse {
provider: HYPER3D_PROVIDER,
message: message.into(),
raw_excerpt: raw_excerpt.into(),
}
}
pub(crate) fn missing_field(message: impl Into<String>) -> Self {
Self::MissingField {
provider: HYPER3D_PROVIDER,
message: message.into(),
}
}
}
impl fmt::Display for Hyper3dError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.message())
}
}
impl Error for Hyper3dError {}

View File

@@ -0,0 +1,13 @@
mod client;
mod error;
mod request;
mod response;
mod transport;
mod types;
pub use client::{
build_hyper3d_http_client, query_downloads, query_task_status, submit_image_to_model,
submit_text_to_model,
};
pub use error::{Hyper3dError, Hyper3dStatusHint};
pub use types::{HYPER3D_PROVIDER, Hyper3dSettings};

View File

@@ -0,0 +1,14 @@
mod image_data_url;
mod normalize;
mod options;
#[cfg(test)]
mod tests;
pub(crate) use image_data_url::decode_image_data_urls;
pub(crate) use normalize::{
normalize_condition_mode, normalize_optional_limited_text, normalize_required_opaque_text,
normalize_required_text,
};
pub(crate) use options::{
build_common_submit_fields, build_submit_options_from_image, build_submit_options_from_text,
};

View File

@@ -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())
}

View 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))
}

View 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)
}

View 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());
}

View File

@@ -0,0 +1,11 @@
mod downloads;
mod parsing;
mod status;
mod submit;
#[cfg(test)]
mod tests;
pub(crate) use downloads::extract_download_files;
pub(crate) use parsing::parse_api_error_message;
pub(crate) use status::{extract_job_statuses, resolve_hyper3d_overall_status};
pub(crate) use submit::build_submit_response;

View File

@@ -0,0 +1,67 @@
use serde_json::Value;
pub(crate) fn extract_download_files(
payload: &Value,
) -> Vec<shared_contracts::hyper3d::Hyper3dDownloadFilePayload> {
let mut files = Vec::new();
collect_download_files(payload, &mut files);
let mut deduped = Vec::new();
for file in files {
if !deduped.iter().any(
|entry: &shared_contracts::hyper3d::Hyper3dDownloadFilePayload| entry.url == file.url,
) {
deduped.push(file);
}
}
deduped
}
fn collect_download_files(
value: &Value,
output: &mut Vec<shared_contracts::hyper3d::Hyper3dDownloadFilePayload>,
) {
match value {
Value::Object(object) => {
let maybe_url = object
.get("url")
.or_else(|| object.get("download_url"))
.or_else(|| object.get("downloadUrl"))
.or_else(|| object.get("file_url"))
.or_else(|| object.get("fileUrl"))
.or_else(|| object.get("signed_url"))
.or_else(|| object.get("signedUrl"))
.or_else(|| object.get("presigned_url"))
.or_else(|| object.get("presignedUrl"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| value.starts_with("http://") || value.starts_with("https://"));
if let Some(url) = maybe_url {
let name = object
.get("name")
.or_else(|| object.get("file_name"))
.or_else(|| object.get("filename"))
.or_else(|| object.get("fileName"))
.or_else(|| object.get("display_name"))
.or_else(|| object.get("displayName"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("model")
.to_string();
output.push(shared_contracts::hyper3d::Hyper3dDownloadFilePayload {
name,
url: url.to_string(),
});
}
for nested in object.values() {
collect_download_files(nested, output);
}
}
Value::Array(items) => {
for item in items {
collect_download_files(item, output);
}
}
_ => {}
}
}

View File

@@ -0,0 +1,159 @@
use serde_json::Value;
pub(crate) fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
if let Ok(parsed) = serde_json::from_str::<Value>(raw_text) {
for key in ["message", "detail", "error"] {
if let Some(message) = find_first_string_by_key(&parsed, key)
&& !message.trim().is_empty()
{
return message;
}
}
}
raw_text
.trim()
.chars()
.take(240)
.collect::<String>()
.trim()
.to_string()
.chars()
.next()
.map(|_| raw_text.trim().chars().take(240).collect())
.unwrap_or_else(|| fallback_message.to_string())
}
pub(crate) fn find_first_array_by_keys<'a>(
value: &'a Value,
keys: &[&str],
) -> Option<&'a Vec<Value>> {
match value {
Value::Object(object) => {
for (key, value) in object {
if keys.iter().any(|target| key.eq_ignore_ascii_case(target))
&& let Some(array) = value.as_array()
{
return Some(array);
}
if let Some(found) = find_first_array_by_keys(value, keys) {
return Some(found);
}
}
None
}
Value::Array(items) => items
.iter()
.find_map(|item| find_first_array_by_keys(item, keys)),
_ => None,
}
}
pub(crate) fn find_first_string_by_keys(value: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| find_first_string_by_key(value, key))
}
pub(crate) fn find_first_f64_by_keys(value: &Value, keys: &[&str]) -> Option<f64> {
match value {
Value::Object(object) => {
for (key, value) in object {
if keys.iter().any(|target| key.eq_ignore_ascii_case(target))
&& let Some(number) = value.as_f64()
{
return Some(number);
}
if let Some(found) = find_first_f64_by_keys(value, keys) {
return Some(found);
}
}
None
}
Value::Array(items) => items
.iter()
.find_map(|item| find_first_f64_by_keys(item, keys)),
_ => None,
}
}
pub(crate) fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
match value {
Value::Object(object) => {
for (key, value) in object {
if key.eq_ignore_ascii_case(target_key)
&& let Some(text) = value.as_str()
{
return Some(text.trim().to_string());
}
if let Some(found) = find_first_string_by_key(value, target_key) {
return Some(found);
}
}
None
}
Value::Array(items) => items
.iter()
.find_map(|item| find_first_string_by_key(item, target_key)),
_ => None,
}
}
pub(crate) fn find_root_string_by_keys(value: &Value, keys: &[&str]) -> Option<String> {
let object = value.as_object()?;
for key in keys {
if let Some(text) = object
.iter()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
.and_then(|(_, value)| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(text.to_string());
}
}
None
}
pub(crate) fn collect_strings_by_keys(value: &Value, keys: &[&str]) -> Vec<String> {
let mut results = Vec::new();
collect_strings(value, keys, &mut results);
let mut deduped = Vec::new();
for result in results {
if !deduped.contains(&result) {
deduped.push(result);
}
}
deduped
}
fn collect_strings(value: &Value, keys: &[&str], output: &mut Vec<String>) {
match value {
Value::Object(object) => {
for (key, value) in object {
if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) {
match value {
Value::String(text) if !text.trim().is_empty() => {
output.push(text.trim().to_string());
}
Value::Array(items) => {
for item in items {
if let Some(text) = item.as_str().map(str::trim)
&& !text.is_empty()
{
output.push(text.to_string());
}
}
}
_ => {}
}
}
collect_strings(value, keys, output);
}
}
Value::Array(items) => {
for item in items {
collect_strings(item, keys, output);
}
}
_ => {}
}
}

View File

@@ -0,0 +1,69 @@
use serde_json::Value;
pub(crate) fn normalize_task_status(status: &str) -> String {
match status.trim().to_ascii_lowercase().as_str() {
"waiting" | "pending" | "queued" => "waiting".to_string(),
"generating" | "running" | "processing" => "generating".to_string(),
"done" | "finished" | "completed" | "success" | "succeeded" => "done".to_string(),
"failed" | "error" | "canceled" | "cancelled" => "failed".to_string(),
_ => "unknown".to_string(),
}
}
pub(crate) fn extract_job_statuses(
payload: &Value,
) -> Vec<shared_contracts::hyper3d::Hyper3dJobStatusPayload> {
let Some(array) = super::parsing::find_first_array_by_keys(payload, &["jobs", "tasks"]) else {
return Vec::new();
};
array
.iter()
.filter_map(|value| {
let status = super::parsing::find_first_string_by_keys(value, &["status", "state"])
.map(|value| normalize_task_status(&value))?;
Some(shared_contracts::hyper3d::Hyper3dJobStatusPayload {
uuid: super::parsing::find_first_string_by_keys(
value,
&["uuid", "task_uuid", "taskUuid"],
),
progress: super::parsing::find_first_f64_by_keys(
value,
&["progress", "percentage"],
)
.map(|value| value as f32),
message: super::parsing::find_first_string_by_keys(
value,
&["message", "detail", "error"],
),
status,
})
})
.collect()
}
pub(crate) fn resolve_hyper3d_overall_status(
payload: &Value,
jobs: &[shared_contracts::hyper3d::Hyper3dJobStatusPayload],
) -> String {
if !jobs.is_empty() {
if jobs.iter().any(|job| job.status == "failed") {
return "failed".to_string();
}
if jobs.iter().all(|job| job.status == "done") {
return "done".to_string();
}
if jobs.iter().any(|job| job.status == "generating") {
return "generating".to_string();
}
if jobs.iter().any(|job| job.status == "waiting") {
return "waiting".to_string();
}
return "unknown".to_string();
}
normalize_task_status(
super::parsing::find_first_string_by_key(payload, "status")
.as_deref()
.unwrap_or("unknown"),
)
}

View File

@@ -0,0 +1,64 @@
use serde_json::Value;
use crate::{
error::Hyper3dError,
types::{HYPER3D_PROVIDER, RODIN_GEN2_TIER},
};
pub(crate) fn build_submit_response(
mode: shared_contracts::hyper3d::Hyper3dGenerationMode,
response: Value,
) -> Result<shared_contracts::hyper3d::Hyper3dTaskSubmitResponse, Hyper3dError> {
let task_uuid =
super::parsing::find_root_string_by_keys(&response, &["uuid", "task_uuid", "taskUuid"])
.or_else(|| {
super::parsing::find_first_string_by_keys(&response, &["task_uuid", "taskUuid"])
})
.ok_or_else(|| Hyper3dError::missing_field("Hyper3D 已响应,但未返回任务 uuid"))?;
let subscription_key = super::parsing::find_root_string_by_keys(
&response,
&["subscription_key", "subscriptionKey"],
)
.or_else(|| {
super::parsing::find_first_string_by_keys(
&response,
&["subscription_key", "subscriptionKey"],
)
})
.ok_or_else(|| Hyper3dError::missing_field("Hyper3D 已响应,但未返回 subscription_key"))?;
let job_uuids = extract_job_uuids(&response);
let message = super::parsing::find_first_string_by_keys(&response, &["message", "detail"]);
Ok(shared_contracts::hyper3d::Hyper3dTaskSubmitResponse {
ok: true,
provider: HYPER3D_PROVIDER.to_string(),
mode,
task_uuid,
subscription_key,
job_uuids,
message,
tier: RODIN_GEN2_TIER.to_string(),
})
}
fn extract_job_uuids(payload: &Value) -> Vec<String> {
let mut job_uuids = Vec::new();
if let Some(jobs) = payload.get("jobs") {
for uuid in super::parsing::collect_strings_by_keys(
jobs,
&["uuid", "task_uuid", "taskUuid", "uuids"],
) {
if !job_uuids.contains(&uuid) {
job_uuids.push(uuid);
}
}
}
for uuid in
super::parsing::collect_strings_by_keys(payload, &["job_uuids", "jobUuids", "uuids"])
{
if !job_uuids.contains(&uuid) {
job_uuids.push(uuid);
}
}
job_uuids
}

View File

@@ -0,0 +1,88 @@
use serde_json::json;
use shared_contracts::hyper3d as contract;
use super::{
build_submit_response, extract_download_files, extract_job_statuses,
resolve_hyper3d_overall_status,
};
use super::status::normalize_task_status;
#[test]
fn extracts_submit_response_from_nested_payload() {
let response = build_submit_response(
contract::Hyper3dGenerationMode::TextToModel,
json!({
"uuid": "task-1",
"jobs": {
"uuids": ["job-1", "job-2"],
"subscription_key": "sub-1"
},
"message": "submitted"
}),
)
.expect("submit response should build");
assert_eq!(response.task_uuid, "task-1");
assert_eq!(response.subscription_key, "sub-1");
assert_eq!(response.job_uuids, vec!["job-1", "job-2"]);
}
#[test]
fn extracts_download_files_from_file_url_aliases() {
let files = extract_download_files(&json!({
"result": {
"files": [
{
"fileName": "rodin-result.glb",
"fileUrl": "https://cdn.example/rodin-result.glb?token=1"
},
{
"displayName": "preview.png",
"signedUrl": "https://cdn.example/preview.png?token=1"
}
]
}
}));
assert_eq!(files.len(), 2);
assert_eq!(files[0].name, "rodin-result.glb");
assert_eq!(files[0].url, "https://cdn.example/rodin-result.glb?token=1");
}
#[test]
fn normalizes_status_values() {
assert_eq!(normalize_task_status("Waiting"), "waiting");
assert_eq!(normalize_task_status("Generating"), "generating");
assert_eq!(normalize_task_status("Done"), "done");
assert_eq!(normalize_task_status("Failed"), "failed");
}
#[test]
fn resolves_status_done_only_when_all_jobs_done() {
let jobs = extract_job_statuses(&json!({
"jobs": [
{ "uuid": "preview", "status": "Done" },
{ "uuid": "model", "status": "Generating" }
]
}));
assert_eq!(
resolve_hyper3d_overall_status(&json!({ "status": "Done" }), &jobs),
"generating"
);
}
#[test]
fn resolves_status_failed_when_any_job_failed() {
let jobs = extract_job_statuses(&json!({
"jobs": [
{ "uuid": "preview", "status": "Done" },
{ "uuid": "model", "status": "Failed", "message": "bad input" }
]
}));
assert_eq!(
resolve_hyper3d_overall_status(&json!({ "status": "Generating" }), &jobs),
"failed"
);
}

View File

@@ -0,0 +1,99 @@
use std::error::Error;
use reqwest::header;
use serde_json::Value;
use crate::{error::Hyper3dError, response::parse_api_error_message, types::Hyper3dSettings};
pub(crate) async fn post_hyper3d_multipart(
http_client: &reqwest::Client,
settings: &Hyper3dSettings,
path: &str,
form: reqwest::multipart::Form,
failure_context: &str,
) -> Result<Value, Hyper3dError> {
let response = http_client
.post(format!("{}{}", settings.base_url, path))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.multipart(form)
.send()
.await
.map_err(|error| map_reqwest_error(failure_context, path, error))?;
parse_hyper3d_response(response, failure_context).await
}
pub(crate) async fn post_hyper3d_json(
http_client: &reqwest::Client,
settings: &Hyper3dSettings,
path: &str,
body: Value,
failure_context: &str,
) -> Result<Value, Hyper3dError> {
let response = http_client
.post(format!("{}{}", settings.base_url, path))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/json")
.json(&body)
.send()
.await
.map_err(|error| map_reqwest_error(failure_context, path, error))?;
parse_hyper3d_response(response, failure_context).await
}
async fn parse_hyper3d_response(
response: reqwest::Response,
failure_context: &str,
) -> Result<Value, Hyper3dError> {
let status = response.status();
let raw_text = response.text().await.map_err(|error| {
Hyper3dError::request(
format!("{failure_context}:读取上游响应失败:{error}"),
None,
false,
false,
false,
true,
None,
None,
)
})?;
if !status.is_success() {
return Err(Hyper3dError::upstream(
parse_api_error_message(&raw_text, failure_context),
status.as_u16(),
truncate_raw(&raw_text),
));
}
serde_json::from_str::<Value>(&raw_text).map_err(|error| {
Hyper3dError::response_parse(
format!("{failure_context}:解析上游 JSON 失败:{error}"),
truncate_raw(&raw_text),
)
})
}
fn map_reqwest_error(failure_context: &str, endpoint: &str, error: reqwest::Error) -> Hyper3dError {
Hyper3dError::request(
format!("{failure_context}{error}"),
Some(endpoint.to_string()),
error.is_timeout(),
error.is_connect(),
error.is_request(),
error.is_body(),
error.status().map(|status| status.as_u16()),
Error::source(&error).map(ToString::to_string),
)
}
fn truncate_raw(raw_text: &str) -> String {
raw_text.chars().take(800).collect()
}

View File

@@ -0,0 +1,37 @@
#[derive(Clone, Debug)]
pub struct Hyper3dSettings {
pub base_url: String,
pub api_key: String,
pub request_timeout_ms: u64,
}
#[derive(Clone, Debug)]
pub(crate) struct DecodedImageDataUrl {
pub(crate) bytes: Vec<u8>,
pub(crate) mime_type: String,
pub(crate) file_name: String,
}
#[derive(Clone, Debug)]
pub(crate) struct SubmitOptions {
pub(crate) seed: Option<u32>,
pub(crate) geometry_file_format: String,
pub(crate) material: String,
pub(crate) quality: String,
pub(crate) mesh_mode: String,
pub(crate) addons: Vec<String>,
pub(crate) bbox_condition: Option<Vec<f32>>,
pub(crate) preview_render: bool,
}
pub const HYPER3D_PROVIDER: &str = "hyper3d-rodin";
pub(crate) const RODIN_GEN2_TIER: &str = "Gen-2";
pub(crate) const DEFAULT_GEOMETRY_FILE_FORMAT: &str = "glb";
pub(crate) const DEFAULT_MATERIAL: &str = "PBR";
pub(crate) const DEFAULT_QUALITY: &str = "medium";
pub(crate) const DEFAULT_MESH_MODE: &str = "Quad";
pub(crate) const DEFAULT_CONDITION_MODE: &str = "concat";
pub(crate) const MAX_PROMPT_CHARS: usize = 2_000;
pub(crate) const MAX_NEGATIVE_PROMPT_CHARS: usize = 1_000;
pub(crate) const MAX_IMAGE_COUNT: usize = 5;
pub(crate) const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;