use super::*; pub(super) async fn generate_match3d_material_sheet( state: &AppState, config: &Match3DConfigJson, item_names: &[String], ) -> Result { 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, }) } fn require_match3d_vector_engine_gemini_image_settings( state: &AppState, ) -> Result { 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::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 { 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, aspect_ratio: &str, ) -> Value { json!({ "contents": [{ "role": "user", "parts": [{ "text": build_match3d_vector_engine_gemini_prompt(prompt, negative_prompt), }], }], "generationConfig": { "responseModalities": ["TEXT", "IMAGE"], "imageConfig": { "aspectRatio": aspect_ratio, }, }, }) } pub(super) fn build_match3d_vector_engine_gemini_generate_content_url( settings: &Match3DVectorEngineGeminiImageSettings, ) -> String { let base_url = settings.base_url.trim_end_matches("/v1"); format!( "{}/v1beta/models/{}:generateContent", base_url, MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL ) } fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str) -> String { let prompt = prompt.trim(); let negative_prompt = negative_prompt.trim(); if negative_prompt.is_empty() { return prompt.to_string(); } format!("{prompt}\n避免:{negative_prompt}") } async fn download_match3d_images_from_urls( http_client: &reqwest::Client, task_id: String, image_urls: Vec, candidate_count: u32, provider: &str, ) -> Result { 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 { 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, 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 { 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 { serde_json::from_str::(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 { 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 { let mut values = Vec::new(); collect_match3d_strings_by_key(payload, "b64_json", &mut values); collect_match3d_inline_image_data(payload, &mut values); values } fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec) { match payload { Value::Array(entries) => { for entry in entries { collect_match3d_inline_image_data(entry, results); } } Value::Object(object) => { for key in ["inlineData", "inline_data"] { if let Some(Value::Object(inline_data)) = object.get(key) { let mime_type = inline_data .get("mimeType") .or_else(|| inline_data.get("mime_type")) .and_then(Value::as_str) .map(str::trim) .unwrap_or("image/png") .to_ascii_lowercase(); if !mime_type.is_empty() && !mime_type.starts_with("image/") { continue; } if let Some(data) = inline_data .get("data") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { results.push(data.to_string()); } } } for nested_value in object.values() { collect_match3d_inline_image_data(nested_value, results); } } _ => {} } } fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option { 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) { match payload { Value::Array(entries) => { for entry in entries { collect_match3d_strings_by_key(entry, target_key, results); } } Value::Object(object) => { for (key, nested_value) in object { if key == target_key { match nested_value { Value::String(text) => { let text = text.trim(); if !text.is_empty() { results.push(text.to_string()); } } Value::Array(entries) => { for entry in entries { if let Some(text) = entry .as_str() .map(str::trim) .filter(|value| !value.is_empty()) { results.push(text.to_string()); } } } _ => {} } } collect_match3d_strings_by_key(nested_value, target_key, results); } } _ => {} } } 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::(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", } }