1
This commit is contained in:
@@ -14,6 +14,7 @@ use axum::{
|
||||
},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use futures_util::future::try_join_all;
|
||||
use image::{GenericImageView, ImageFormat};
|
||||
use module_match3d::{
|
||||
MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX,
|
||||
@@ -79,9 +80,12 @@ const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4;
|
||||
const MATCH3D_GENERATED_ITEM_COUNT: usize = 3;
|
||||
const MATCH3D_GENERATED_CLEAR_COUNT: u32 = 3;
|
||||
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 2;
|
||||
const MATCH3D_RODIN_STATUS_MAX_ATTEMPTS: usize = 36;
|
||||
const MATCH3D_RODIN_STATUS_MAX_ATTEMPTS: usize = 120;
|
||||
const MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS: u64 = 5_000;
|
||||
const MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS: usize = 60;
|
||||
const MATCH3D_RODIN_DOWNLOAD_POLL_INTERVAL_MS: u64 = 5_000;
|
||||
const MATCH3D_RODIN_MAX_MODEL_BYTES: usize = 120 * 1024 * 1024;
|
||||
const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o";
|
||||
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
|
||||
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
|
||||
const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作的关卡是难度几";
|
||||
@@ -116,6 +120,12 @@ struct Match3DGeneratedItemAsset {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Match3DGeneratedWorkMetadata {
|
||||
game_name: String,
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Match3DGeneratedItemAssetJson {
|
||||
@@ -146,6 +156,16 @@ struct Match3DAssetUpload {
|
||||
object_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Match3DGeneratedItemModelSeed {
|
||||
item_id: String,
|
||||
item_name: String,
|
||||
item_slug: String,
|
||||
image_upload: Match3DAssetUpload,
|
||||
image_bytes: Vec<u8>,
|
||||
generated_at_micros: i64,
|
||||
}
|
||||
|
||||
struct Match3DRodinModelAsset {
|
||||
task_uuid: String,
|
||||
subscription_key: String,
|
||||
@@ -172,6 +192,19 @@ pub(crate) struct CompileMatch3DDraftRequest {
|
||||
cover_image_src: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GenerateMatch3DWorkTagsRequest {
|
||||
game_name: String,
|
||||
theme_text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GenerateMatch3DWorkTagsResponse {
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn create_match3d_agent_session(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -571,6 +604,26 @@ pub async fn put_match3d_work(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_match3d_work_tags(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<GenerateMatch3DWorkTagsRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
|
||||
let tags = generate_match3d_work_tags_for_profile(
|
||||
&state,
|
||||
payload.game_name.as_str(),
|
||||
payload.theme_text.as_str(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
GenerateMatch3DWorkTagsResponse { tags },
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn publish_match3d_work(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
@@ -994,11 +1047,8 @@ async fn compile_match3d_draft_for_session(
|
||||
));
|
||||
}
|
||||
|
||||
let tags_json = tags
|
||||
.as_ref()
|
||||
.map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default());
|
||||
|
||||
let profile_id = build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX);
|
||||
let generated_work_metadata = generate_match3d_work_metadata(state, &config).await;
|
||||
let generated_item_assets = generate_match3d_item_assets(
|
||||
state,
|
||||
request_context,
|
||||
@@ -1008,6 +1058,13 @@ async fn compile_match3d_draft_for_session(
|
||||
&config,
|
||||
)
|
||||
.await?;
|
||||
let resolved_game_name =
|
||||
normalize_optional_match3d_text(game_name).unwrap_or(generated_work_metadata.game_name);
|
||||
let resolved_tags = tags
|
||||
.map(normalize_tags)
|
||||
.filter(|items| !items.is_empty())
|
||||
.unwrap_or(generated_work_metadata.tags);
|
||||
let tags_json = Some(serde_json::to_string(&resolved_tags).unwrap_or_default());
|
||||
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
@@ -1016,8 +1073,8 @@ async fn compile_match3d_draft_for_session(
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
author_display_name: resolve_author_display_name(state, authenticated),
|
||||
game_name: game_name.or_else(|| Some(format!("{}抓大鹅", config.theme_text))),
|
||||
summary_text: summary,
|
||||
game_name: Some(resolved_game_name),
|
||||
summary_text: normalize_optional_match3d_text(summary).or_else(|| Some(String::new())),
|
||||
tags_json,
|
||||
cover_image_src,
|
||||
cover_asset_id: None,
|
||||
@@ -1535,11 +1592,11 @@ fn first_positive_integer(text: &str) -> Option<u32> {
|
||||
}
|
||||
|
||||
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
let mut result: Vec<String> = Vec::new();
|
||||
for tag in tags {
|
||||
let trimmed = tag.trim();
|
||||
if !trimmed.is_empty() && !result.iter().any(|value| value == trimmed) {
|
||||
result.push(trimmed.to_string());
|
||||
let trimmed = normalize_match3d_tag(tag.as_str());
|
||||
if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) {
|
||||
result.push(trimmed);
|
||||
}
|
||||
if result.len() >= 6 {
|
||||
break;
|
||||
@@ -1548,6 +1605,138 @@ fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||||
result
|
||||
}
|
||||
|
||||
fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn normalize_match3d_tag(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
let without_number_prefix = trimmed
|
||||
.char_indices()
|
||||
.find_map(|(index, ch)| {
|
||||
if index == 0 || !matches!(ch, '.' | '、' | ')' | ')') {
|
||||
return None;
|
||||
}
|
||||
let prefix = &trimmed[..index];
|
||||
if prefix.chars().all(|candidate| candidate.is_ascii_digit()) {
|
||||
Some(trimmed[index + ch.len_utf8()..].trim_start())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(trimmed);
|
||||
|
||||
without_number_prefix
|
||||
.trim_matches(|ch: char| {
|
||||
ch.is_ascii_punctuation()
|
||||
|| matches!(
|
||||
ch,
|
||||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||
)
|
||||
})
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.take(6)
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn normalize_match3d_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut tags = Vec::new();
|
||||
for candidate in candidates {
|
||||
let normalized = normalize_match3d_tag(candidate.as_ref());
|
||||
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
|
||||
continue;
|
||||
}
|
||||
tags.push(normalized);
|
||||
if tags.len() >= 6 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for fallback in ["抓大鹅", "经典消除", "3D素材", "轻量休闲", "收集", "挑战"] {
|
||||
if tags.len() >= 6 {
|
||||
break;
|
||||
}
|
||||
if !tags.iter().any(|tag| tag == fallback) {
|
||||
tags.push(fallback.to_string());
|
||||
}
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
async fn generate_match3d_work_tags_for_profile(
|
||||
state: &AppState,
|
||||
game_name: &str,
|
||||
theme_text: &str,
|
||||
) -> Vec<String> {
|
||||
let Some(llm_client) = state
|
||||
.creative_agent_gpt5_client()
|
||||
.or_else(|| state.llm_client())
|
||||
else {
|
||||
return fallback_match3d_work_tags(game_name, theme_text);
|
||||
};
|
||||
let user_prompt = format!(
|
||||
"题材设定:{}\n作品名称:{}\n请生成 3 到 6 个适合抓大鹅作品发布的中文短标签。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。",
|
||||
theme_text, game_name
|
||||
);
|
||||
let response = llm_client
|
||||
.request_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system("你是抓大鹅作品标签编辑,只返回 JSON 字符串数组。"),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
|
||||
.with_responses_api(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) => {
|
||||
let tags = parse_match3d_tags_from_text(response.content.as_str());
|
||||
if tags.len() >= MATCH3D_MIN_GENERATED_TAG_COUNT {
|
||||
return tags;
|
||||
}
|
||||
fallback_match3d_work_tags(game_name, theme_text)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = MATCH3D_WORKS_PROVIDER,
|
||||
game_name,
|
||||
error = %error,
|
||||
"抓大鹅 AI 标签生成失败,降级使用本地标签"
|
||||
);
|
||||
fallback_match3d_work_tags(game_name, theme_text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MATCH3D_MIN_GENERATED_TAG_COUNT: usize = 3;
|
||||
|
||||
fn parse_match3d_tags_from_text(raw: &str) -> Vec<String> {
|
||||
let raw = raw.trim();
|
||||
let json_text = if let Some(start) = raw.find('[')
|
||||
&& let Some(end) = raw.rfind(']')
|
||||
&& end > start
|
||||
{
|
||||
&raw[start..=end]
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
let parsed = serde_json::from_str::<Vec<String>>(json_text).unwrap_or_default();
|
||||
normalize_match3d_tag_candidates(parsed)
|
||||
}
|
||||
|
||||
fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec<String> {
|
||||
normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "3D素材"])
|
||||
}
|
||||
|
||||
fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
|
||||
if assets.is_empty() {
|
||||
return None;
|
||||
@@ -1634,7 +1823,7 @@ async fn generate_match3d_item_assets(
|
||||
let item_images = slice_match3d_material_sheet(&material_sheet.image, &item_names)
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
|
||||
let mut item_assets = Vec::with_capacity(item_images.len());
|
||||
let mut model_seeds = Vec::with_capacity(item_images.len());
|
||||
for (index, item_image) in item_images.into_iter().enumerate() {
|
||||
let item_name = item_names
|
||||
.get(index)
|
||||
@@ -1661,36 +1850,61 @@ async fn generate_match3d_item_assets(
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
let model_asset = generate_match3d_rodin_model_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
&item_slug,
|
||||
&item_name,
|
||||
config,
|
||||
image_bytes,
|
||||
generated_at_micros.saturating_add(100 + index as i64),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
item_assets.push(Match3DGeneratedItemAsset {
|
||||
model_seeds.push(Match3DGeneratedItemModelSeed {
|
||||
item_id,
|
||||
item_name,
|
||||
image_src: Some(image_upload.src),
|
||||
image_object_key: Some(image_upload.object_key),
|
||||
model_src: Some(model_asset.upload.src),
|
||||
model_object_key: Some(model_asset.upload.object_key),
|
||||
model_file_name: Some(model_asset.model_file_name),
|
||||
task_uuid: Some(model_asset.task_uuid),
|
||||
subscription_key: Some(model_asset.subscription_key),
|
||||
status: "model_ready".to_string(),
|
||||
error: None,
|
||||
item_slug,
|
||||
image_upload,
|
||||
image_bytes,
|
||||
generated_at_micros: generated_at_micros.saturating_add(100 + index as i64),
|
||||
});
|
||||
}
|
||||
|
||||
// 中文注释:Rodin 单个模型耗时不可控,必须在图片切割和入库后并行提交所有图生模型,
|
||||
// 避免多个物品的排队和轮询时间串行叠加导致 action 超时。
|
||||
let model_results = try_join_all(model_seeds.into_iter().map(|seed| {
|
||||
async move {
|
||||
let Match3DGeneratedItemModelSeed {
|
||||
item_id,
|
||||
item_name,
|
||||
item_slug,
|
||||
image_upload,
|
||||
image_bytes,
|
||||
generated_at_micros,
|
||||
} = seed;
|
||||
let model_asset = generate_match3d_rodin_model_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
item_slug.as_str(),
|
||||
item_name.as_str(),
|
||||
config,
|
||||
image_bytes,
|
||||
generated_at_micros,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<_, AppError>(Match3DGeneratedItemAsset {
|
||||
item_id,
|
||||
item_name,
|
||||
image_src: Some(image_upload.src),
|
||||
image_object_key: Some(image_upload.object_key),
|
||||
model_src: Some(model_asset.upload.src),
|
||||
model_object_key: Some(model_asset.upload.object_key),
|
||||
model_file_name: Some(model_asset.model_file_name),
|
||||
task_uuid: Some(model_asset.task_uuid),
|
||||
subscription_key: Some(model_asset.subscription_key),
|
||||
status: "model_ready".to_string(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}))
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
|
||||
// 中文注释:草稿阶段必须同时产出 GLB 模型,结果页直接加载模型预览。
|
||||
Ok(item_assets)
|
||||
Ok(model_results)
|
||||
}
|
||||
|
||||
struct Match3DMaterialSheet {
|
||||
@@ -1702,6 +1916,93 @@ struct Match3DSlicedItemImage {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn generate_match3d_work_metadata(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
) -> Match3DGeneratedWorkMetadata {
|
||||
let Some(llm_client) = state
|
||||
.creative_agent_gpt5_client()
|
||||
.or_else(|| state.llm_client())
|
||||
else {
|
||||
return fallback_match3d_work_metadata(config.theme_text.as_str());
|
||||
};
|
||||
let system_prompt = "你是抓大鹅游戏的作品命名编辑,只返回 JSON。";
|
||||
let user_prompt = format!(
|
||||
"题材设定:{}\n请生成抓大鹅游戏作品元信息。要求:只返回 JSON 对象,字段为 gameName、tags。gameName 为 4 到 12 个中文字符,不要包含“作品”“游戏”;tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字。不要生成描述。",
|
||||
config.theme_text
|
||||
);
|
||||
let response = llm_client
|
||||
.request_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
|
||||
.with_responses_api(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) => parse_match3d_work_metadata(response.content.as_str())
|
||||
.unwrap_or_else(|| fallback_match3d_work_metadata(config.theme_text.as_str())),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = MATCH3D_AGENT_PROVIDER,
|
||||
theme_text = config.theme_text.as_str(),
|
||||
error = %error,
|
||||
"抓大鹅作品名称生成失败,降级使用本地元信息"
|
||||
);
|
||||
fallback_match3d_work_metadata(config.theme_text.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_match3d_work_metadata(raw: &str) -> Option<Match3DGeneratedWorkMetadata> {
|
||||
let raw = raw.trim();
|
||||
let json_text = if let Some(start) = raw.find('{')
|
||||
&& let Some(end) = raw.rfind('}')
|
||||
&& end > start
|
||||
{
|
||||
&raw[start..=end]
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
let value = serde_json::from_str::<Value>(json_text).ok()?;
|
||||
let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?);
|
||||
if game_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let tags = value
|
||||
.get("tags")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str)))
|
||||
.unwrap_or_default();
|
||||
Some(Match3DGeneratedWorkMetadata {
|
||||
game_name,
|
||||
tags: normalize_match3d_tag_candidates(tags),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_match3d_game_name(raw: &str) -> String {
|
||||
raw.trim()
|
||||
.trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、'])
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(16)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata {
|
||||
let theme = theme_text.trim();
|
||||
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
|
||||
Match3DGeneratedWorkMetadata {
|
||||
game_name: format!("{normalized_theme}抓大鹅"),
|
||||
tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "3D素材"]),
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_match3d_item_names(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
@@ -1844,25 +2145,12 @@ async fn generate_match3d_rodin_model_asset(
|
||||
)
|
||||
.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?;
|
||||
wait_for_match3d_rodin_model(state, submit_response.subscription_key.as_str(), item_name)
|
||||
.await?;
|
||||
let model_file =
|
||||
wait_for_match3d_rodin_download_file(state, submit_response.task_uuid.as_str(), item_name)
|
||||
.await?;
|
||||
let downloaded_model = download_match3d_rodin_model(&model_file).await?;
|
||||
let uploaded_model = persist_match3d_generated_bytes(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -1940,10 +2228,7 @@ async fn wait_for_match3d_rodin_model(
|
||||
}
|
||||
|
||||
if attempt + 1 < MATCH3D_RODIN_STATUS_MAX_ATTEMPTS {
|
||||
tokio::time::sleep(Duration::from_millis(
|
||||
MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS,
|
||||
))
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1952,25 +2237,77 @@ async fn wait_for_match3d_rodin_model(
|
||||
)))
|
||||
}
|
||||
|
||||
fn select_match3d_glb_download<'a>(
|
||||
files: &'a [hyper3d_contract::Hyper3dDownloadFilePayload],
|
||||
async fn wait_for_match3d_rodin_download_file(
|
||||
state: &AppState,
|
||||
task_uuid: &str,
|
||||
item_name: &str,
|
||||
) -> Result<&'a hyper3d_contract::Hyper3dDownloadFilePayload, AppError> {
|
||||
) -> Result<hyper3d_contract::Hyper3dDownloadFilePayload, AppError> {
|
||||
for attempt in 0..MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS {
|
||||
let download_response = query_downloads(
|
||||
state,
|
||||
hyper3d_contract::Hyper3dDownloadRequest {
|
||||
task_uuid: task_uuid.to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if let Some(model_file) = find_match3d_glb_download(&download_response.files) {
|
||||
return Ok(model_file.clone());
|
||||
}
|
||||
|
||||
// 中文注释:Rodin 状态 Done 后下载列表偶尔会延迟发布,短轮询避免把已完成任务误判失败。
|
||||
if attempt + 1 < MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS {
|
||||
tokio::time::sleep(Duration::from_millis(
|
||||
MATCH3D_RODIN_DOWNLOAD_POLL_INTERVAL_MS,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Err(match3d_bad_gateway(format!(
|
||||
"{item_name} 3D 模型已完成但未返回可下载模型文件:{task_uuid}"
|
||||
)))
|
||||
}
|
||||
|
||||
fn find_match3d_glb_download(
|
||||
files: &[hyper3d_contract::Hyper3dDownloadFilePayload],
|
||||
) -> Option<&hyper3d_contract::Hyper3dDownloadFilePayload> {
|
||||
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}"
|
||||
))
|
||||
.find(|file| match3d_download_file_has_extension(file, ".glb"))
|
||||
.or_else(|| {
|
||||
files
|
||||
.iter()
|
||||
.find(|file| !is_match3d_preview_or_image_download(file))
|
||||
})
|
||||
}
|
||||
|
||||
fn match3d_download_file_has_extension(
|
||||
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
|
||||
extension: &str,
|
||||
) -> bool {
|
||||
file.name.to_ascii_lowercase().ends_with(extension)
|
||||
|| file
|
||||
.url
|
||||
.to_ascii_lowercase()
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.ends_with(extension)
|
||||
}
|
||||
|
||||
fn is_match3d_preview_or_image_download(
|
||||
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
|
||||
) -> bool {
|
||||
let name = file.name.to_ascii_lowercase();
|
||||
let url_path = file.url.to_ascii_lowercase();
|
||||
let url_path = url_path.split('?').next().unwrap_or(url_path.as_str());
|
||||
name.contains("preview")
|
||||
|| url_path.contains("preview")
|
||||
|| [".png", ".jpg", ".jpeg", ".webp", ".gif"]
|
||||
.iter()
|
||||
.any(|extension| name.ends_with(extension) || url_path.ends_with(extension))
|
||||
}
|
||||
|
||||
async fn download_match3d_rodin_model(
|
||||
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
|
||||
) -> Result<Match3DDownloadedModel, AppError> {
|
||||
@@ -2010,17 +2347,22 @@ async fn download_match3d_rodin_model(
|
||||
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()
|
||||
}
|
||||
let normalized = without_query.to_ascii_lowercase();
|
||||
let stem = without_query
|
||||
.strip_suffix(".glb")
|
||||
.or_else(|| {
|
||||
normalized
|
||||
.strip_suffix(".glb")
|
||||
.map(|_| &without_query[..without_query.len().saturating_sub(4)])
|
||||
})
|
||||
.unwrap_or(without_query);
|
||||
let sanitized_stem = sanitize_match3d_asset_segment(stem, "model");
|
||||
format!("{sanitized_stem}.glb")
|
||||
}
|
||||
|
||||
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" {
|
||||
if normalized == "model/gltf-binary" {
|
||||
return normalized;
|
||||
}
|
||||
"model/gltf-binary".to_string()
|
||||
@@ -2445,6 +2787,96 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_metadata_parses_gpt4o_json() {
|
||||
let metadata = parse_match3d_work_metadata(
|
||||
r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅","经典消除","轻量休闲"]}"#,
|
||||
)
|
||||
.expect("metadata should parse");
|
||||
|
||||
assert_eq!(metadata.game_name, "果园大鹅宴");
|
||||
assert_eq!(
|
||||
metadata.tags,
|
||||
vec!["水果", "抓大鹅", "经典消除", "轻量休闲", "3D素材", "收集"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_metadata_fallback_keeps_empty_description_boundary() {
|
||||
let metadata = fallback_match3d_work_metadata("水果");
|
||||
|
||||
assert_eq!(metadata.game_name, "水果抓大鹅");
|
||||
assert!(metadata.tags.contains(&"水果".to_string()));
|
||||
assert!(metadata.tags.contains(&"抓大鹅".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_tag_normalization_only_strips_numbered_list_prefix() {
|
||||
assert_eq!(normalize_match3d_tag("3D素材"), "3D素材");
|
||||
assert_eq!(normalize_match3d_tag("1. 3D素材"), "3D素材");
|
||||
assert_eq!(normalize_match3d_tag("2、轻量休闲"), "轻量休闲");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_model_download_metadata_normalizes_to_glb() {
|
||||
assert_eq!(
|
||||
normalize_match3d_model_file_name("https://example.com/Fruit Model.GLB?token=1"),
|
||||
"fruit-model.glb"
|
||||
);
|
||||
assert_eq!(normalize_match3d_model_file_name("模型文件"), "model.glb");
|
||||
assert_eq!(
|
||||
normalize_match3d_model_content_type("application/octet-stream"),
|
||||
"model/gltf-binary"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_match3d_model_content_type("model/gltf-binary; charset=utf-8"),
|
||||
"model/gltf-binary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_model_download_prefers_glb_file() {
|
||||
let files = vec![
|
||||
hyper3d_contract::Hyper3dDownloadFilePayload {
|
||||
name: "preview.png".to_string(),
|
||||
url: "https://cdn.example/preview.png".to_string(),
|
||||
},
|
||||
hyper3d_contract::Hyper3dDownloadFilePayload {
|
||||
name: "model".to_string(),
|
||||
url: "https://cdn.example/model.glb?token=1".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let selected = find_match3d_glb_download(&files).expect("glb download should be selected");
|
||||
|
||||
assert_eq!(selected.url, "https://cdn.example/model.glb?token=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_model_download_falls_back_to_first_file() {
|
||||
let files = vec![hyper3d_contract::Hyper3dDownloadFilePayload {
|
||||
name: "model".to_string(),
|
||||
url: "https://cdn.example/download?id=1".to_string(),
|
||||
}];
|
||||
|
||||
let selected =
|
||||
find_match3d_glb_download(&files).expect("opaque download url should be accepted");
|
||||
|
||||
assert_eq!(selected.url, "https://cdn.example/download?id=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_model_download_does_not_accept_preview_image_only() {
|
||||
let files = vec![hyper3d_contract::Hyper3dDownloadFilePayload {
|
||||
name: "preview.png".to_string(),
|
||||
url: "https://cdn.example/preview.png".to_string(),
|
||||
}];
|
||||
|
||||
let result = find_match3d_glb_download(&files);
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_summary_maps_persisted_generated_item_assets() {
|
||||
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {
|
||||
|
||||
Reference in New Issue
Block a user