@@ -1805,6 +1805,220 @@ async fn generate_match3d_material_sheet(
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn generate_match3d_rodin_model_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
item_slug: &str,
|
||||
item_name: &str,
|
||||
config: &Match3DConfigJson,
|
||||
image_bytes: Vec<u8>,
|
||||
generated_at_micros: i64,
|
||||
) -> Result<Match3DRodinModelAsset, AppError> {
|
||||
let image_data_url = build_match3d_png_data_url(&image_bytes);
|
||||
let submit_response = submit_image_to_model(
|
||||
state,
|
||||
hyper3d_contract::Hyper3dImageToModelRequest {
|
||||
image_data_urls: vec![image_data_url],
|
||||
image_urls: Vec::new(),
|
||||
prompt: Some(build_match3d_rodin_model_prompt(config, item_name)),
|
||||
condition_mode: Some("concat".to_string()),
|
||||
seed: None,
|
||||
geometry_file_format: Some("glb".to_string()),
|
||||
material: Some("PBR".to_string()),
|
||||
quality: Some("medium".to_string()),
|
||||
mesh_mode: Some("Quad".to_string()),
|
||||
addons: Vec::new(),
|
||||
bbox_condition: None,
|
||||
preview_render: Some(true),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
wait_for_match3d_rodin_model(
|
||||
state,
|
||||
submit_response.subscription_key.as_str(),
|
||||
item_name,
|
||||
)
|
||||
.await?;
|
||||
let download_response = query_downloads(
|
||||
state,
|
||||
hyper3d_contract::Hyper3dDownloadRequest {
|
||||
task_uuid: submit_response.task_uuid.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let model_file = select_match3d_glb_download(
|
||||
&download_response.files,
|
||||
submit_response.task_uuid.as_str(),
|
||||
item_name,
|
||||
)?;
|
||||
let downloaded_model = download_match3d_rodin_model(model_file).await?;
|
||||
let uploaded_model = persist_match3d_generated_bytes(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
&[
|
||||
"items",
|
||||
item_slug,
|
||||
"model",
|
||||
submit_response.task_uuid.as_str(),
|
||||
],
|
||||
downloaded_model.file_name.as_str(),
|
||||
downloaded_model.content_type.as_str(),
|
||||
downloaded_model.bytes,
|
||||
"match3d_item_model",
|
||||
Some(submit_response.task_uuid.as_str()),
|
||||
generated_at_micros,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Match3DRodinModelAsset {
|
||||
task_uuid: submit_response.task_uuid,
|
||||
subscription_key: submit_response.subscription_key,
|
||||
model_file_name: downloaded_model.file_name,
|
||||
upload: uploaded_model,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_match3d_png_data_url(image_bytes: &[u8]) -> String {
|
||||
format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(image_bytes)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_match3d_rodin_model_prompt(config: &Match3DConfigJson, item_name: &str) -> String {
|
||||
let style_clause = resolve_match3d_asset_style_prompt(config)
|
||||
.map(|prompt| format!("画风遵循:{prompt}。"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{theme}题材抓大鹅游戏物件:{item_name}。{style_clause}生成单个完整游戏 3D 模型,主体清晰,低面数,PBR 材质,适合移动端实时渲染,不要文字、底座、场景和额外物体。",
|
||||
theme = config.theme_text,
|
||||
style_clause = style_clause,
|
||||
)
|
||||
}
|
||||
|
||||
async fn wait_for_match3d_rodin_model(
|
||||
state: &AppState,
|
||||
subscription_key: &str,
|
||||
item_name: &str,
|
||||
) -> Result<(), AppError> {
|
||||
for attempt in 0..MATCH3D_RODIN_STATUS_MAX_ATTEMPTS {
|
||||
let status_response = query_task_status(
|
||||
state,
|
||||
hyper3d_contract::Hyper3dTaskStatusRequest {
|
||||
subscription_key: subscription_key.to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
match status_response.status.as_str() {
|
||||
"done" => return Ok(()),
|
||||
"failed" => {
|
||||
let message = status_response
|
||||
.jobs
|
||||
.iter()
|
||||
.filter_map(|job| job.message.as_deref())
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty())
|
||||
.unwrap_or("Rodin 模型生成失败");
|
||||
return Err(match3d_bad_gateway(format!(
|
||||
"{item_name} 3D 模型生成失败:{message}"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if attempt + 1 < MATCH3D_RODIN_STATUS_MAX_ATTEMPTS {
|
||||
tokio::time::sleep(Duration::from_millis(
|
||||
MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Err(match3d_bad_gateway(format!(
|
||||
"{item_name} 3D 模型生成超时,请稍后重试"
|
||||
)))
|
||||
}
|
||||
|
||||
fn select_match3d_glb_download<'a>(
|
||||
files: &'a [hyper3d_contract::Hyper3dDownloadFilePayload],
|
||||
task_uuid: &str,
|
||||
item_name: &str,
|
||||
) -> Result<&'a hyper3d_contract::Hyper3dDownloadFilePayload, AppError> {
|
||||
files
|
||||
.iter()
|
||||
.find(|file| {
|
||||
file.name.to_ascii_lowercase().ends_with(".glb")
|
||||
|| file.url.to_ascii_lowercase().split('?').next().unwrap_or("").ends_with(".glb")
|
||||
})
|
||||
.or_else(|| files.first())
|
||||
.ok_or_else(|| {
|
||||
match3d_bad_gateway(format!(
|
||||
"{item_name} 3D 模型已完成但未返回可下载模型文件:{task_uuid}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_match3d_rodin_model(
|
||||
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
|
||||
) -> Result<Match3DDownloadedModel, AppError> {
|
||||
let response = reqwest::Client::new()
|
||||
.get(file.url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| match3d_bad_gateway(format!("下载 Rodin 模型失败:{error}")))?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("model/gltf-binary")
|
||||
.to_string();
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| match3d_bad_gateway(format!("读取 Rodin 模型内容失败:{error}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(match3d_bad_gateway(format!(
|
||||
"下载 Rodin 模型失败:HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
if bytes.is_empty() || bytes.len() > MATCH3D_RODIN_MAX_MODEL_BYTES {
|
||||
return Err(match3d_bad_gateway("Rodin 模型内容为空或超过大小上限"));
|
||||
}
|
||||
|
||||
Ok(Match3DDownloadedModel {
|
||||
bytes: bytes.to_vec(),
|
||||
file_name: normalize_match3d_model_file_name(file.name.as_str()),
|
||||
content_type: normalize_match3d_model_content_type(content_type.as_str()),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_match3d_model_file_name(raw: &str) -> String {
|
||||
let trimmed = raw.trim().rsplit('/').next().unwrap_or(raw).trim();
|
||||
let without_query = trimmed.split('?').next().unwrap_or(trimmed).trim();
|
||||
let sanitized = sanitize_match3d_asset_segment(without_query, "model");
|
||||
if sanitized.to_ascii_lowercase().ends_with(".glb") {
|
||||
sanitized
|
||||
} else {
|
||||
"model.glb".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_match3d_model_content_type(raw: &str) -> String {
|
||||
let normalized = raw.split(';').next().unwrap_or(raw).trim().to_lowercase();
|
||||
if normalized == "model/gltf-binary" || normalized == "application/octet-stream" {
|
||||
return normalized;
|
||||
}
|
||||
"model/gltf-binary".to_string()
|
||||
}
|
||||
|
||||
fn build_match3d_material_sheet_prompt(
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
|
||||
Reference in New Issue
Block a user