use super::*; struct BigFishDashScopeSettings { base_url: String, api_key: String, request_timeout_ms: u64, } struct BigFishGeneratedImage { image_url: String, task_id: String, } struct BigFishDownloadedImage { mime_type: String, bytes: Vec, } struct BigFishFormalAssetContext { entity_id: String, prompt: String, negative_prompt: String, size: String, asset_object_kind: String, binding_slot: String, path_segments: Vec, apply_transparent_background_post_process: bool, } const BIG_FISH_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash"; const BIG_FISH_ENTITY_KIND: &str = "big_fish_session"; pub(super) async fn generate_big_fish_formal_asset( state: &AppState, owner_user_id: &str, session_id: &str, asset_kind: &str, level: Option, motion_key: Option<&str>, generated_at_micros: i64, ) -> Result { let session = state .spacetime_client() .get_big_fish_session(session_id.to_string(), owner_user_id.to_string()) .await .map_err(map_big_fish_client_error)?; let draft = session.draft.as_ref().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "big-fish", "message": "玩法草稿尚未编译,不能生成正式图片。", })) })?; let context = build_big_fish_formal_asset_context( &session, draft, asset_kind, level, motion_key, generated_at_micros, )?; let settings = require_big_fish_dashscope_settings(state)?; let http_client = build_big_fish_dashscope_http_client(&settings)?; let generated = create_big_fish_text_to_image_generation( &http_client, &settings, context.prompt.as_str(), context.negative_prompt.as_str(), context.size.as_str(), ) .await?; let downloaded = download_big_fish_remote_image( &http_client, generated.image_url.as_str(), "下载 Big Fish 正式图片失败", context.apply_transparent_background_post_process, ) .await?; persist_big_fish_formal_asset( state, owner_user_id, &context, generated, downloaded, generated_at_micros, ) .await } fn build_big_fish_formal_asset_context( session: &BigFishSessionRecord, draft: &BigFishGameDraftRecord, asset_kind: &str, level: Option, motion_key: Option<&str>, generated_at_micros: i64, ) -> Result { let asset_id = format!("asset-{generated_at_micros}"); match asset_kind { "level_main_image" => { let level = find_big_fish_level_blueprint(draft, level)?; let level_part = build_big_fish_level_part(Some(level.level)); Ok(BigFishFormalAssetContext { entity_id: session.session_id.clone(), prompt: build_big_fish_level_main_image_prompt(draft, level), negative_prompt: BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.to_string(), size: "1024*1024".to_string(), asset_object_kind: "big_fish_level_main_image".to_string(), binding_slot: format!("level_main_image:{level_part}"), path_segments: vec![ sanitize_big_fish_path_segment(session.session_id.as_str(), "session"), "level-main-image".to_string(), level_part, asset_id, ], apply_transparent_background_post_process: true, }) } "level_motion" => { let level = find_big_fish_level_blueprint(draft, level)?; let motion_key = motion_key .map(str::trim) .filter(|value| matches!(*value, "idle_float" | "move_swim")) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "big-fish", "message": "motionKey 必须是 idle_float 或 move_swim。", })) })?; let level_part = build_big_fish_level_part(Some(level.level)); Ok(BigFishFormalAssetContext { entity_id: session.session_id.clone(), prompt: build_big_fish_level_motion_prompt(draft, level, motion_key), negative_prompt: BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.to_string(), size: "1024*1024".to_string(), asset_object_kind: "big_fish_level_motion".to_string(), binding_slot: format!("level_motion:{level_part}:{motion_key}"), path_segments: vec![ sanitize_big_fish_path_segment(session.session_id.as_str(), "session"), "level-motion".to_string(), level_part, sanitize_big_fish_path_segment(motion_key, "motion"), asset_id, ], apply_transparent_background_post_process: true, }) } "stage_background" => Ok(BigFishFormalAssetContext { entity_id: session.session_id.clone(), prompt: build_big_fish_stage_background_prompt(draft), negative_prompt: BIG_FISH_DEFAULT_NEGATIVE_PROMPT.to_string(), size: "720*1280".to_string(), asset_object_kind: "big_fish_stage_background".to_string(), binding_slot: "stage_background".to_string(), path_segments: vec![ sanitize_big_fish_path_segment(session.session_id.as_str(), "session"), "stage-background".to_string(), asset_id, ], apply_transparent_background_post_process: false, }), _ => Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "big-fish", "message": format!("assetKind `{asset_kind}` 不支持正式图片生成。"), })), ), } } fn find_big_fish_level_blueprint( draft: &BigFishGameDraftRecord, level: Option, ) -> Result<&BigFishLevelBlueprintRecord, AppError> { let level = level.ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "big-fish", "message": "level 是等级资产生成的必填项。", })) })?; draft .levels .iter() .find(|blueprint| blueprint.level == level) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "big-fish", "message": format!("level `{level}` 不存在于当前 Big Fish 草稿。"), })) }) } fn require_big_fish_dashscope_settings( state: &AppState, ) -> Result { let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "dashscope", "reason": "DASHSCOPE_BASE_URL 未配置", })), ); } let api_key = state .config .dashscope_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": "dashscope", "reason": "DASHSCOPE_API_KEY 未配置", })) })?; Ok(BigFishDashScopeSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1), }) } fn build_big_fish_dashscope_http_client( settings: &BigFishDashScopeSettings, ) -> 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": "dashscope", "message": format!("构造 DashScope HTTP 客户端失败:{error}"), })) }) } async fn create_big_fish_text_to_image_generation( http_client: &reqwest::Client, settings: &BigFishDashScopeSettings, prompt: &str, negative_prompt: &str, size: &str, ) -> Result { let mut parameters = Map::from_iter([ ("n".to_string(), json!(1)), ("size".to_string(), Value::String(size.to_string())), ("prompt_extend".to_string(), Value::Bool(true)), ("watermark".to_string(), Value::Bool(false)), ]); if !negative_prompt.trim().is_empty() { parameters.insert( "negative_prompt".to_string(), Value::String(negative_prompt.trim().to_string()), ); } let response = http_client .post(format!( "{}/services/aigc/text2image/image-synthesis", settings.base_url )) .header( reqwest::header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .header(reqwest::header::CONTENT_TYPE, "application/json") .header("X-DashScope-Async", "enable") .json(&json!({ "model": BIG_FISH_TEXT_TO_IMAGE_MODEL, "input": { "prompt": prompt, }, "parameters": parameters, })) .send() .await .map_err(|error| { map_big_fish_dashscope_request_error(format!("创建 Big Fish 图片生成任务失败:{error}")) })?; let status = response.status(); let response_text = response.text().await.map_err(|error| { map_big_fish_dashscope_request_error(format!("读取 Big Fish 图片生成响应失败:{error}")) })?; if !status.is_success() { return Err(map_big_fish_dashscope_upstream_error( response_text.as_str(), "创建 Big Fish 图片生成任务失败", )); } let payload = parse_big_fish_json_payload(response_text.as_str(), "解析 Big Fish 图片生成响应失败")?; let task_id = extract_big_fish_task_id(&payload).ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": "Big Fish 图片生成任务未返回 task_id", })) })?; let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); while Instant::now() < deadline { let poll_response = http_client .get(format!("{}/tasks/{}", settings.base_url, task_id)) .header( reqwest::header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .send() .await .map_err(|error| { map_big_fish_dashscope_request_error(format!( "查询 Big Fish 图片生成任务失败:{error}" )) })?; let poll_status = poll_response.status(); let poll_text = poll_response.text().await.map_err(|error| { map_big_fish_dashscope_request_error(format!( "读取 Big Fish 图片生成任务响应失败:{error}" )) })?; if !poll_status.is_success() { return Err(map_big_fish_dashscope_upstream_error( poll_text.as_str(), "查询 Big Fish 图片生成任务失败", )); } let poll_payload = parse_big_fish_json_payload(poll_text.as_str(), "解析 Big Fish 图片生成任务响应失败")?; let task_status = find_first_big_fish_string_by_key(&poll_payload, "task_status") .unwrap_or_default() .trim() .to_string(); if task_status == "SUCCEEDED" { let image_url = extract_big_fish_image_urls(&poll_payload) .into_iter() .next() .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": "Big Fish 图片生成成功但未返回图片地址", })) })?; return Ok(BigFishGeneratedImage { image_url, task_id }); } if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { return Err(map_big_fish_dashscope_upstream_error( poll_text.as_str(), "Big Fish 图片生成任务失败", )); } sleep(Duration::from_secs(2)).await; } Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": "Big Fish 图片生成超时或未返回图片地址", })), ) } async fn download_big_fish_remote_image( http_client: &reqwest::Client, image_url: &str, fallback_message: &str, apply_transparent_background_post_process: bool, ) -> Result { let response = http_client.get(image_url).send().await.map_err(|error| { map_big_fish_dashscope_request_error(format!("{fallback_message}:{error}")) })?; let status = response.status(); let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or("image/jpeg") .to_string(); let bytes = response.bytes().await.map_err(|error| { map_big_fish_dashscope_request_error(format!("{fallback_message}:{error}")) })?; if !status.is_success() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": fallback_message, "status": status.as_u16(), })), ); } let mime_type = normalize_big_fish_downloaded_image_mime_type(content_type.as_str()); let mut normalized_bytes = bytes.to_vec(); let mut normalized_mime_type = mime_type; // 中文注释:Big Fish 的等级主图与动作关键帧要和 RPG 角色主图保持同一后处理口径。 // 因此在上游已经输出 PNG 时,统一补一层透明背景 alpha 清理,避免只靠 prompt 约束导致残留底色。 if apply_transparent_background_post_process && normalized_mime_type == "image/png" && let Some(optimized) = try_apply_background_alpha_to_png(normalized_bytes.as_slice()) { normalized_bytes = optimized; normalized_mime_type = "image/png".to_string(); } Ok(BigFishDownloadedImage { mime_type: normalized_mime_type, bytes: normalized_bytes, }) } async fn persist_big_fish_formal_asset( state: &AppState, owner_user_id: &str, context: &BigFishFormalAssetContext, generated: BigFishGeneratedImage, downloaded: BigFishDownloadedImage, generated_at_micros: i64, ) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; let http_client = reqwest::Client::new(); let image_format = normalize_generated_image_asset_mime(downloaded.mime_type.as_str()); let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { prefix: LegacyAssetPrefix::BigFishAssets, path_segments: context.path_segments.clone(), file_stem: "image".to_string(), image: GeneratedImageAssetDataUrl { format: image_format, bytes: downloaded.bytes, }, access: OssObjectAccess::Private, metadata: GeneratedImageAssetAdapterMetadata { asset_kind: Some(context.asset_object_kind.clone()), owner_user_id: Some(owner_user_id.to_string()), entity_kind: Some(BIG_FISH_ENTITY_KIND.to_string()), entity_id: Some(context.entity_id.clone()), slot: Some(context.binding_slot.clone()), provider: Some("dashscope".to_string()), task_id: Some(generated.task_id.clone()), }, extra_metadata: BTreeMap::new(), }) .map_err(map_big_fish_generated_image_asset_error)?; let persisted_mime_type = prepared.format.mime_type.clone(); let put_result = oss_client .put_object(&http_client, prepared.request) .await .map_err(map_big_fish_asset_oss_error)?; let head = oss_client .head_object( &http_client, OssHeadObjectRequest { object_key: put_result.object_key.clone(), }, ) .await .map_err(map_big_fish_asset_oss_error)?; let asset_object = state .spacetime_client() .confirm_asset_object( build_asset_object_upsert_input( generate_asset_object_id(generated_at_micros), head.bucket, head.object_key, AssetObjectAccessPolicy::Private, head.content_type.or(Some(persisted_mime_type)), head.content_length, head.etag, context.asset_object_kind.clone(), Some(generated.task_id), Some(owner_user_id.to_string()), None, Some(context.entity_id.clone()), generated_at_micros, ) .map_err(map_big_fish_asset_object_prepare_error)?, ) .await .map_err(map_big_fish_asset_spacetime_error)?; state .spacetime_client() .bind_asset_object_to_entity( build_asset_entity_binding_input( generate_asset_binding_id(generated_at_micros), asset_object.asset_object_id, BIG_FISH_ENTITY_KIND.to_string(), context.entity_id.clone(), context.binding_slot.clone(), context.asset_object_kind.clone(), Some(owner_user_id.to_string()), None, generated_at_micros, ) .map_err(map_big_fish_asset_binding_prepare_error)?, ) .await .map_err(map_big_fish_asset_spacetime_error)?; Ok(put_result.legacy_public_path) } fn map_big_fish_generated_image_asset_error( error: crate::generated_image_assets::helpers::GeneratedImageAssetHelperError, ) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "generated-image-assets", "message": format!("准备 Big Fish 图片资产上传请求失败:{error:?}"), })) } fn parse_big_fish_json_payload(raw_text: &str, fallback_message: &str) -> Result { serde_json::from_str::(raw_text).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": format!("{fallback_message}:{error}"), })) }) } fn extract_big_fish_task_id(payload: &Value) -> Option { find_first_big_fish_string_by_key(payload, "task_id") } fn extract_big_fish_image_urls(payload: &Value) -> Vec { let mut urls = Vec::new(); collect_big_fish_strings_by_key(payload, "image", &mut urls); collect_big_fish_strings_by_key(payload, "url", &mut urls); let mut deduped = Vec::new(); for url in urls { if !deduped.contains(&url) { deduped.push(url); } } deduped } fn find_first_big_fish_string_by_key(payload: &Value, target_key: &str) -> Option { let mut results = Vec::new(); collect_big_fish_strings_by_key(payload, target_key, &mut results); results.into_iter().next() } fn collect_big_fish_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec) { match payload { Value::Array(entries) => { for entry in entries { collect_big_fish_strings_by_key(entry, target_key, results); } } Value::Object(object) => { for (key, value) in object { if key == target_key && let Some(text) = value.as_str() { results.push(text.to_string()); } collect_big_fish_strings_by_key(value, target_key, results); } } _ => {} } } fn normalize_big_fish_downloaded_image_mime_type(content_type: &str) -> String { let mime_type = content_type .split(';') .next() .map(str::trim) .unwrap_or("image/jpeg"); match mime_type { "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { mime_type.to_string() } _ => "image/jpeg".to_string(), } } fn map_big_fish_dashscope_request_error(message: String) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": message, })) } fn map_big_fish_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": parse_big_fish_api_error_message(raw_text, fallback_message), })) } fn parse_big_fish_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) && let Some(message) = find_first_big_fish_string_by_key(&payload, "message") .or_else(|| find_first_big_fish_string_by_key(&payload, "code")) { return message; } let excerpt = trimmed.chars().take(240).collect::(); format!("{fallback_message}:{excerpt}") } fn map_big_fish_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", "message": error.to_string(), })) } fn map_big_fish_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-entity-binding", "message": error.to_string(), })) } fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn map_big_fish_asset_oss_error(error: platform_oss::OssError) -> AppError { map_oss_error(error, "aliyun-oss") } fn build_big_fish_level_part(level: Option) -> String { level .map(|value| format!("level-{value}")) .unwrap_or_else(|| "stage".to_string()) }