清理后端编译警告
删除后端未使用的历史 helper、mapper、handler 和 re-export 将仅测试使用的导入、常量和辅助函数收口到 cfg(test) 补齐 Jump Hop 测试构造体字段并对齐 Match3D 当前素材表测试契约 验证后端 workspace cargo check 与 Match3D、Puzzle 相关测试
This commit is contained in:
@@ -735,10 +735,9 @@ pub(super) struct Match3DMaterialSheet {
|
||||
pub(super) image: DownloadedOpenAiImage,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) struct Match3DVectorEngineGeminiImageSettings {
|
||||
pub(super) base_url: String,
|
||||
pub(super) api_key: String,
|
||||
pub(super) request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1482,6 +1481,7 @@ pub(super) fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgrou
|
||||
.is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn build_match3d_material_sheet_prompt(
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset {
|
||||
Match3DGeneratedItemAsset {
|
||||
item_id: format!("match3d-item-{index}"),
|
||||
@@ -149,17 +147,17 @@ fn match3d_item_image_path_segments_stay_unique_for_chinese_names() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
let width = 500;
|
||||
let height = 500;
|
||||
fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() {
|
||||
let width = 1000;
|
||||
let height = 1000;
|
||||
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
|
||||
let mut sheet = image::RgbaImage::new(width, height);
|
||||
for row in 0..5 {
|
||||
for col in 0..5 {
|
||||
for row in 0..10 {
|
||||
for col in 0..10 {
|
||||
let color = image::Rgba([
|
||||
32 + row as u8 * 40,
|
||||
24 + col as u8 * 36,
|
||||
210 - row as u8 * 30,
|
||||
24 + row as u8 * 16,
|
||||
30 + col as u8 * 14,
|
||||
210 - row as u8 * 10,
|
||||
255,
|
||||
]);
|
||||
for y in row * 100..(row + 1) * 100 {
|
||||
@@ -182,22 +180,24 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice");
|
||||
|
||||
assert_eq!(slices.len(), 3);
|
||||
for (row, views) in slices.iter().enumerate() {
|
||||
for (item_index, views) in slices.iter().enumerate() {
|
||||
assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT);
|
||||
for (col, view) in views.iter().enumerate() {
|
||||
for (view_index, view) in views.iter().enumerate() {
|
||||
let decoded = image::load_from_memory(view.bytes.as_slice())
|
||||
.expect("view should decode")
|
||||
.to_rgba8();
|
||||
let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2);
|
||||
let source_row = item_index / 2;
|
||||
let source_col = (item_index % 2) * MATCH3D_ITEM_VIEW_COUNT + view_index;
|
||||
assert_eq!(
|
||||
pixel.0,
|
||||
[
|
||||
32 + row as u8 * 40,
|
||||
24 + col as u8 * 36,
|
||||
210 - row as u8 * 30,
|
||||
24 + source_row as u8 * 16,
|
||||
30 + source_col as u8 * 14,
|
||||
210 - source_row as u8 * 10,
|
||||
255,
|
||||
],
|
||||
"row {row} col {col} should be cut from the fixed 5*5 grid row"
|
||||
"item {item_index} view {view_index} should be cut from the fixed 10*10 grid"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -205,8 +205,8 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() {
|
||||
let width = 500;
|
||||
let height = 500;
|
||||
let width = 1000;
|
||||
let height = 1000;
|
||||
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
|
||||
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255]));
|
||||
for y in 1..5 {
|
||||
@@ -689,35 +689,35 @@ fn match3d_legacy_item_asset_without_size_defaults_to_large() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() {
|
||||
fn match3d_draft_item_plan_rounds_up_to_full_spritesheet_batch() {
|
||||
let plan = parse_match3d_draft_plan(
|
||||
r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#,
|
||||
&config("水果", 12, 4),
|
||||
)
|
||||
.expect("draft plan should parse");
|
||||
|
||||
assert_eq!(plan.items.len(), 10);
|
||||
assert_eq!(plan.items.len(), MATCH3D_MATERIAL_ITEM_BATCH_SIZE);
|
||||
assert_eq!(plan.items[8].name, "蓝莓");
|
||||
assert_ne!(plan.items[9].name, "蓝莓");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_generated_item_count_rounds_up_to_five_multiples() {
|
||||
fn match3d_generated_item_count_uses_full_spritesheet_batch() {
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 8, 2)),
|
||||
5
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 12, 4)),
|
||||
10
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 16, 6)),
|
||||
15
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
|
||||
25
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
@@ -733,12 +733,12 @@ fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_item_asset_points_cost_counts_five_item_batches() {
|
||||
fn match3d_item_asset_points_cost_counts_spritesheet_batches() {
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(0), 0);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(1), 2);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(5), 2);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(6), 4);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(10), 4);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(20), 2);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(21), 4);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(40), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -777,7 +777,10 @@ fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() {
|
||||
);
|
||||
|
||||
assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]);
|
||||
assert_eq!(plan.padded_item_names.len(), 5);
|
||||
assert_eq!(
|
||||
plan.padded_item_names.len(),
|
||||
MATCH3D_MATERIAL_ITEM_BATCH_SIZE
|
||||
);
|
||||
assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]);
|
||||
assert_eq!(
|
||||
calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()),
|
||||
@@ -900,28 +903,27 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() {
|
||||
fn match3d_material_sheet_prompt_requires_uniform_ten_by_ten_layout() {
|
||||
let prompt = build_match3d_material_sheet_prompt(
|
||||
&config("水果", 12, 4),
|
||||
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
|
||||
);
|
||||
|
||||
assert!(prompt.contains("5行*5列"));
|
||||
assert!(prompt.contains("严格5*5均匀排布"));
|
||||
assert!(prompt.contains("10行*10列"));
|
||||
assert!(prompt.contains("素材间距严格均匀分布"));
|
||||
assert!(prompt.contains("每一行包含两种物品"));
|
||||
assert!(prompt.contains("每种物品的五个不同形态"));
|
||||
assert!(prompt.contains("绿幕背景"));
|
||||
assert!(prompt.contains("#00FF00"));
|
||||
assert!(prompt.contains("单个素材格宽度的1/4空白间距"));
|
||||
assert!(prompt.contains("约25%单格宽度"));
|
||||
assert!(prompt.contains("禁止主体跨格"));
|
||||
assert!(prompt.contains("贴边或越界"));
|
||||
assert!(prompt.contains("严禁出现两种高相似度的物品"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_prompt_hardens_pixel_retro_style() {
|
||||
fn match3d_pixel_retro_style_prompt_hardens_asset_style_and_negative_prompt() {
|
||||
let mut config = config("水果", 12, 4);
|
||||
config.asset_style_id = Some("pixel-retro".to_string());
|
||||
config.asset_style_label = Some("像素复古".to_string());
|
||||
let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]);
|
||||
let prompt = resolve_match3d_asset_style_prompt(&config).expect("style prompt should exist");
|
||||
let negative_prompt = build_match3d_material_sheet_negative_prompt(&config);
|
||||
|
||||
assert!(prompt.contains("64x64"));
|
||||
@@ -1004,13 +1006,9 @@ fn match3d_extracts_vector_engine_gemini_inline_image_data() {
|
||||
fn match3d_vector_engine_gemini_url_accepts_root_or_v1_base() {
|
||||
let root_settings = Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: "https://api.vectorengine.cn".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
};
|
||||
let v1_settings = Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: "https://api.vectorengine.cn/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,165 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn generate_match3d_material_sheet(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
) -> Result<Match3DMaterialSheet, AppError> {
|
||||
let settings = require_match3d_vector_engine_gemini_image_settings(state)?;
|
||||
let http_client = build_match3d_vector_engine_gemini_image_http_client(&settings)?;
|
||||
let prompt = build_match3d_material_sheet_prompt(config, item_names);
|
||||
let negative_prompt = build_match3d_material_sheet_negative_prompt(config);
|
||||
let generated = create_match3d_vector_engine_gemini_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt.as_str(),
|
||||
negative_prompt.as_str(),
|
||||
"抓大鹅素材图生成失败",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": "抓大鹅素材图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DMaterialSheet {
|
||||
task_id: generated.task_id,
|
||||
prompt,
|
||||
image_src: None,
|
||||
image_object_key: None,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
fn require_match3d_vector_engine_gemini_image_settings(
|
||||
state: &AppState,
|
||||
) -> Result<Match3DVectorEngineGeminiImageSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.vector_engine_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.vector_engine_api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_match3d_vector_engine_gemini_image_http_client(
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_match3d_vector_engine_gemini_image_generation(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let request_body = build_match3d_vector_engine_gemini_image_request_body(
|
||||
prompt,
|
||||
negative_prompt,
|
||||
MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO,
|
||||
);
|
||||
let response = http_client
|
||||
.post(build_match3d_vector_engine_gemini_generate_content_url(
|
||||
settings,
|
||||
))
|
||||
.query(&[("key", settings.api_key.as_str())])
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||
"{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||
"{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}"
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(map_match3d_vector_engine_gemini_image_upstream_error(
|
||||
status,
|
||||
response_text.as_str(),
|
||||
failure_context,
|
||||
));
|
||||
}
|
||||
|
||||
let payload = parse_match3d_json_payload(
|
||||
response_text.as_str(),
|
||||
"解析抓大鹅 VectorEngine Gemini 图片生成响应失败",
|
||||
"vector-engine-gemini",
|
||||
)?;
|
||||
let image_urls = extract_match3d_image_urls(&payload);
|
||||
if !image_urls.is_empty() {
|
||||
return download_match3d_images_from_urls(
|
||||
http_client,
|
||||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||
image_urls,
|
||||
1,
|
||||
"vector-engine-gemini",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let b64_images = extract_match3d_b64_images(&payload);
|
||||
if !b64_images.is_empty() {
|
||||
return Ok(match3d_images_from_base64(
|
||||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||
b64_images,
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片",
|
||||
"rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_vector_engine_gemini_image_request_body(
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
@@ -201,125 +41,6 @@ fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str
|
||||
format!("{prompt}\n避免:{negative_prompt}")
|
||||
}
|
||||
|
||||
async fn download_match3d_images_from_urls(
|
||||
http_client: &reqwest::Client,
|
||||
task_id: String,
|
||||
image_urls: Vec<String>,
|
||||
candidate_count: u32,
|
||||
provider: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
{
|
||||
images
|
||||
.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
|
||||
}
|
||||
Ok(OpenAiGeneratedImages {
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_match3d_remote_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
provider: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("下载抓大鹅生成图片失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("读取抓大鹅生成图片内容失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": "下载抓大鹅生成图片失败",
|
||||
"status": status.as_u16(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str());
|
||||
Ok(DownloadedOpenAiImage {
|
||||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes: body.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn match3d_images_from_base64(
|
||||
task_id: String,
|
||||
b64_images: Vec<String>,
|
||||
candidate_count: u32,
|
||||
) -> OpenAiGeneratedImages {
|
||||
let images = b64_images
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
.filter_map(|raw| decode_match3d_base64_image(raw.as_str()))
|
||||
.collect();
|
||||
OpenAiGeneratedImages {
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_match3d_base64_image(raw: &str) -> Option<DownloadedOpenAiImage> {
|
||||
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
|
||||
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
|
||||
Some(DownloadedOpenAiImage {
|
||||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_match3d_json_payload(
|
||||
raw_text: &str,
|
||||
failure_context: &str,
|
||||
provider: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("{failure_context}:{error}"),
|
||||
"rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_match3d_image_urls(payload: &Value) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, "url", &mut urls);
|
||||
collect_match3d_strings_by_key(payload, "image", &mut urls);
|
||||
collect_match3d_strings_by_key(payload, "image_url", &mut urls);
|
||||
let mut deduped = Vec::new();
|
||||
for url in urls {
|
||||
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
|
||||
deduped.push(url);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
pub(super) fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
|
||||
let mut values = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, "b64_json", &mut values);
|
||||
@@ -365,12 +86,6 @@ fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec<String>)
|
||||
}
|
||||
}
|
||||
|
||||
fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||
let mut results = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, target_key, &mut results);
|
||||
results.into_iter().next()
|
||||
}
|
||||
|
||||
fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||
match payload {
|
||||
Value::Array(entries) => {
|
||||
@@ -408,79 +123,3 @@ fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &m
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_vector_engine_gemini_image_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_match3d_vector_engine_gemini_image_upstream_error(
|
||||
upstream_status: reqwest::StatusCode,
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
) -> AppError {
|
||||
let message = parse_match3d_api_error_message(raw_text, fallback_message);
|
||||
let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800);
|
||||
tracing::warn!(
|
||||
provider = "vector-engine-gemini",
|
||||
upstream_status = upstream_status.as_u16(),
|
||||
message = %message,
|
||||
raw_excerpt = %raw_excerpt,
|
||||
"抓大鹅 VectorEngine Gemini 图片生成上游请求失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"upstreamStatus": upstream_status.as_u16(),
|
||||
"message": message,
|
||||
"rawExcerpt": raw_excerpt,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||
let trimmed = raw_text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return fallback_message.to_string();
|
||||
}
|
||||
if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
|
||||
for key in ["message", "code"] {
|
||||
if let Some(value) = find_first_match3d_string_by_key(&payload, key) {
|
||||
return if key == "message" {
|
||||
value
|
||||
} else {
|
||||
format!("{fallback_message}({value})")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
|
||||
raw_text.chars().take(max_chars).collect()
|
||||
}
|
||||
|
||||
fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or("image/png");
|
||||
match mime_type {
|
||||
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
|
||||
mime_type.to_string()
|
||||
}
|
||||
_ => "image/png".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn match3d_mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,54 +189,6 @@ pub(super) fn resolve_author_display_name(
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "玩家".to_string())
|
||||
}
|
||||
pub(super) async fn ensure_match3d_background_asset(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
config: &Match3DConfigJson,
|
||||
background_prompt: &str,
|
||||
mut assets: Vec<Match3DGeneratedItemAsset>,
|
||||
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
|
||||
let normalized_prompt = normalize_match3d_background_prompt(background_prompt);
|
||||
let resolved_prompt = if normalized_prompt.is_empty() {
|
||||
build_fallback_match3d_background_prompt(config)
|
||||
} else {
|
||||
normalized_prompt
|
||||
};
|
||||
if let Some(existing_background) = find_match3d_generated_background_asset(&assets) {
|
||||
if is_match3d_background_asset_ready(&existing_background) {
|
||||
return Ok(assets);
|
||||
}
|
||||
}
|
||||
|
||||
let generated_background = generate_match3d_level_asset_bundle(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
config,
|
||||
&resolved_prompt,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
attach_match3d_background_asset_to_assets(&mut assets, generated_background);
|
||||
persist_match3d_generated_item_assets_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
&assets,
|
||||
)
|
||||
.await?;
|
||||
Ok(assets)
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_or_generate_match3d_level_asset_bundle(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
@@ -769,6 +721,7 @@ pub(super) fn build_match3d_background_from_scene_prompt() -> String {
|
||||
"移除画面中的所有UI组件和容器中的内含物,完整保留容器和背景,补全被UI覆盖的背景内容".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn build_match3d_background_generation_prompt(
|
||||
config: &Match3DConfigJson,
|
||||
prompt: &str,
|
||||
|
||||
Reference in New Issue
Block a user