use std::{ collections::BTreeMap, convert::Infallible, future::Future, time::{Duration, SystemTime, UNIX_EPOCH}, }; use axum::{ Json, extract::{Extension, Path, State, rejection::JsonRejection}, http::{HeaderName, StatusCode, header}, response::{ IntoResponse, Response, sse::{Event, Sse}, }, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use futures_util::{StreamExt, stream::FuturesUnordered}; use image::{GenericImageView, ImageFormat}; use module_match3d::{ MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX, MATCH3D_SESSION_ID_PREFIX, }; use platform_llm::{LlmMessage, LlmTextRequest}; use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::{ creation_audio::CreationAudioAsset, hyper3d as hyper3d_contract, match3d_agent::{ CreateMatch3DAgentSessionRequest, ExecuteMatch3DAgentActionRequest, Match3DAgentActionResponse, Match3DAgentMessageResponse, Match3DAgentSessionResponse, Match3DAgentSessionSnapshotResponse, Match3DAnchorItemResponse, Match3DAnchorPackResponse, Match3DCreatorConfigResponse, Match3DGeneratedItemAssetResponse as Match3DAgentGeneratedItemAssetResponse, Match3DResultDraftResponse, SendMatch3DAgentMessageRequest, }, match3d_runtime::{ ClickMatch3DItemRequest, Match3DClickConfirmationResponse, Match3DClickResponse, Match3DItemSnapshotResponse, Match3DRunResponse, Match3DRunSnapshotResponse, Match3DTraySlotResponse, StartMatch3DRunRequest, StopMatch3DRunRequest, }, match3d_works::{ GenerateMatch3DBackgroundImageRequest, GenerateMatch3DBackgroundImageResponse, GenerateMatch3DContainerImageRequest, GenerateMatch3DContainerImageResponse, GenerateMatch3DCoverImageRequest, GenerateMatch3DCoverImageResponse, GenerateMatch3DItemAssetsRequest, GenerateMatch3DItemAssetsResponse, Match3DWorkDetailResponse, Match3DWorkMutationResponse, Match3DWorkProfileResponse, Match3DWorkSummaryResponse, Match3DWorksResponse, PersistMatch3DGeneratedModelRequest, PersistMatch3DGeneratedModelResponse, PutMatch3DAudioAssetsRequest, PutMatch3DWorkRequest, }, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ Match3DAgentMessageFinalizeRecordInput, Match3DAgentMessageRecord, Match3DAgentMessageSubmitRecordInput, Match3DAgentSessionCreateRecordInput, Match3DAgentSessionRecord, Match3DAnchorItemRecord, Match3DAnchorPackRecord, Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, Match3DCreatorConfigRecord, Match3DItemSnapshotRecord, Match3DResultDraftRecord, Match3DRunClickRecordInput, Match3DRunRecord, Match3DRunRestartRecordInput, Match3DRunStartRecordInput, Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, Match3DTraySlotRecord, Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput, SpacetimeClientError, }; use crate::{ api_response::json_success_body, asset_billing::{ execute_billable_asset_operation_with_cost, map_asset_operation_wallet_error, should_skip_asset_operation_billing_for_connectivity, }, auth::AuthenticatedAccessToken, config::AppConfig, http_error::AppError, openai_image_generation::{ DownloadedOpenAiImage, OpenAiGeneratedImages, OpenAiReferenceImage, build_openai_image_http_client, create_openai_image_edit, create_openai_image_generation, require_openai_image_settings, }, platform_errors::map_oss_error, request_context::RequestContext, state::AppState, vector_engine_audio_generation::{ GeneratedCreationAudioTarget, generate_background_music_asset_for_creation, generate_sound_effect_asset_for_creation, }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent"; const MATCH3D_WORKS_PROVIDER: &str = "match3d-works"; const MATCH3D_RUNTIME_PROVIDER: &str = "match3d-runtime"; const MATCH3D_DEFAULT_THEME: &str = "缤纷玩具"; const MATCH3D_DEFAULT_CLEAR_COUNT: u32 = 12; const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4; const MATCH3D_DRAFT_GENERATION_POINTS_COST: u64 = 10; const MATCH3D_BACKGROUND_IMAGE_POINTS_COST: u64 = 2; const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH: u64 = 2; const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 5; const MATCH3D_ITEM_VIEW_COUNT: usize = 5; const MATCH3D_MATERIAL_GRID_SIZE: u32 = 5; const MATCH3D_MATERIAL_FOREGROUND_DIFF_THRESHOLD: i32 = 36; const MATCH3D_MATERIAL_FOREGROUND_ALPHA_THRESHOLD: i32 = 36; const MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE: f32 = 0.34; const MATCH3D_MATERIAL_GREEN_SCREEN_SOFT_SCORE: f32 = 0.18; const MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE: f32 = 0.82; const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 25; const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL: &str = "gemini-3-pro-image-preview"; const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO: &str = "1:1"; const MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000; const MATCH3D_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000; const MATCH3D_LEGACY_MODEL_MAX_BYTES: usize = 120 * 1024 * 1024; const MATCH3D_ITEM_IMAGE_MAX_BYTES: usize = 20 * 1024 * 1024; const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o"; const MATCH3D_CONTAINER_REFERENCE_IMAGE_PATH: &str = "public/match3d-background-references/pot-fused-reference.png"; const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材"; const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关"; const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作的关卡是难度几"; const MATCH3D_BACKGROUND_MUSIC_ASSET_KIND: &str = "match3d_background_music"; const MATCH3D_CLICK_SOUND_ASSET_KIND: &str = "match3d_click_sound"; const MATCH3D_PIXEL_RETRO_STYLE_PROMPT: &str = "真正复古像素 2D 游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。"; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Match3DConfigJson { theme_text: String, reference_image_src: Option, clear_count: u32, difficulty: u32, #[serde(default)] asset_style_id: Option, #[serde(default)] asset_style_label: Option, #[serde(default)] asset_style_prompt: Option, #[serde(default)] generate_click_sound: bool, } #[derive(Clone, Debug)] struct Match3DGeneratedItemAsset { item_id: String, item_name: String, image_src: Option, image_object_key: Option, image_views: Vec, model_src: Option, model_object_key: Option, model_file_name: Option, task_uuid: Option, subscription_key: Option, sound_prompt: Option, background_music_title: Option, background_music_style: Option, background_music_prompt: Option, background_music: Option, click_sound: Option, background_asset: Option, status: String, error: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct Match3DGeneratedItemImageView { view_id: String, view_index: u32, #[serde(default)] image_src: Option, #[serde(default)] image_object_key: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Match3DGeneratedBackgroundAsset { prompt: String, #[serde(default)] image_src: Option, #[serde(default)] image_object_key: Option, #[serde(default)] container_prompt: Option, #[serde(default)] container_image_src: Option, #[serde(default)] container_image_object_key: Option, status: String, #[serde(default)] error: Option, } #[derive(Clone, Debug)] struct Match3DGeneratedWorkMetadata { game_name: String, summary: String, tags: Vec, } #[derive(Clone, Debug)] struct Match3DGeneratedItemPlan { name: String, sound_prompt: String, } #[derive(Clone, Debug)] struct Match3DGeneratedBackgroundMusicPlan { title: String, style: String, prompt: String, } #[derive(Clone, Debug)] struct Match3DGeneratedDraftPlan { metadata: Match3DGeneratedWorkMetadata, items: Vec, background_music: Match3DGeneratedBackgroundMusicPlan, background_prompt: String, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Match3DGeneratedItemAssetJson { item_id: String, item_name: String, #[serde(default)] image_src: Option, #[serde(default)] image_object_key: Option, #[serde(default)] image_views: Vec, #[serde(default)] model_src: Option, #[serde(default)] model_object_key: Option, #[serde(default)] model_file_name: Option, #[serde(default)] task_uuid: Option, #[serde(default)] subscription_key: Option, #[serde(default)] sound_prompt: Option, #[serde(default)] background_music_title: Option, #[serde(default)] background_music_style: Option, #[serde(default)] background_music_prompt: Option, #[serde(default)] background_music: Option, #[serde(default)] click_sound: Option, #[serde(default)] background_asset: Option, status: String, #[serde(default)] error: Option, } #[derive(Clone, Debug)] struct Match3DAssetUpload { src: String, object_key: String, } struct Match3DDownloadedModel { bytes: Vec, file_name: String, content_type: String, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CompileMatch3DDraftRequest { #[serde(default)] game_name: Option, #[serde(default)] summary: Option, #[serde(default)] tags: Option>, #[serde(default)] cover_image_src: Option, #[serde(default)] generate_click_sound: Option, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct GenerateMatch3DWorkTagsRequest { game_name: String, theme_text: String, #[serde(default)] summary: Option, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct GenerateMatch3DWorkTagsResponse { tags: Vec, } struct Match3DWorkAssetContext { owner_user_id: String, session_id: String, profile: Match3DWorkProfileRecord, config: Match3DConfigJson, assets: Vec, } struct Match3DItemAssetAppendPlan { requested_item_names: Vec, padded_item_names: Vec, } struct Match3DItemAssetReplacePlan { requested_item_names: Vec, padded_item_names: Vec, target_assets: Vec, } enum Match3DItemAssetsGenerationPlan { Append(Match3DItemAssetAppendPlan), Replace(Match3DItemAssetReplacePlan), } enum Match3DItemAssetsGenerationMode { Append, Replace, } impl Match3DItemAssetsGenerationPlan { fn billed_item_count(&self) -> usize { match self { Self::Append(plan) => plan.requested_item_names.len(), Self::Replace(plan) => plan.requested_item_names.len(), } } fn billing_fingerprint_source(&self) -> String { match self { Self::Append(plan) => format!("append:{}", plan.requested_item_names.join("|")), Self::Replace(plan) => format!("replace:{}", plan.requested_item_names.join("|")), } } } pub async fn create_match3d_agent_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; let config = build_config_from_create_request(&payload); let seed_text = build_seed_text(&payload, &config); let welcome_message_text = MATCH3D_QUESTION_THEME.to_string(); let session = state .spacetime_client() .create_match3d_agent_session(Match3DAgentSessionCreateRecordInput { session_id: build_prefixed_uuid_id(MATCH3D_SESSION_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), seed_text, welcome_message_id: build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX), welcome_message_text, config_json: serialize_match3d_config(&config), created_at_micros: current_utc_micros(), }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DAgentSessionResponse { session: map_match3d_agent_session_response(session), }, )) } pub async fn get_match3d_agent_session( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, MATCH3D_AGENT_PROVIDER, &session_id, "sessionId", )?; let session = state .spacetime_client() .get_match3d_agent_session(session_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DAgentSessionResponse { session: map_match3d_agent_session_response(session), }, )) } pub async fn submit_match3d_agent_message( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; let session = submit_and_finalize_match3d_message( &state, &request_context, authenticated.claims().user_id(), session_id, payload, ) .await?; Ok(json_success_body( Some(&request_context), Match3DAgentSessionResponse { session: map_match3d_agent_session_response(session), }, )) } pub async fn stream_match3d_agent_message( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_AGENT_PROVIDER, &session_id, "sessionId", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let request_context_for_stream = request_context.clone(); let stream = async_stream::stream! { let result = submit_and_finalize_match3d_message( &state, &request_context_for_stream, owner_user_id.as_str(), session_id, payload, ) .await; match result { Ok(session) => { let session_response = map_match3d_agent_session_response(session); if let Some(reply) = session_response.last_assistant_reply.clone() { yield Ok::(match3d_sse_json_event_or_error( "reply_delta", json!({ "text": reply }), )); } yield Ok::(match3d_sse_json_event_or_error( "session", json!({ "session": session_response }), )); yield Ok::(match3d_sse_json_event_or_error( "done", json!({ "ok": true }), )); } Err(response) => { yield Ok::(match3d_sse_json_event_or_error( "error", json!({ "message": response.status().to_string() }), )); } } }; Ok(Sse::new(stream).into_response()) } pub async fn execute_match3d_agent_action( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_AGENT_PROVIDER, &session_id, "sessionId", )?; if payload.action.trim() != "match3d_compile_draft" { return Err(match3d_bad_request( &request_context, MATCH3D_AGENT_PROVIDER, "unknown match3d action", )); } let (session, generated_item_assets) = compile_match3d_draft_for_session( &state, &request_context, &authenticated, session_id, payload.game_name, payload.summary, payload.tags, payload.cover_image_src, payload.generate_click_sound, ) .await?; Ok(json_success_body( Some(&request_context), Match3DAgentActionResponse { session: map_match3d_agent_session_response_with_assets( session, &generated_item_assets, ), }, )) } pub async fn compile_match3d_agent_draft( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let payload = payload .map(|Json(payload)| payload) .unwrap_or(CompileMatch3DDraftRequest { game_name: None, summary: None, tags: None, cover_image_src: None, generate_click_sound: None, }); ensure_non_empty( &request_context, MATCH3D_AGENT_PROVIDER, &session_id, "sessionId", )?; let (session, generated_item_assets) = compile_match3d_draft_for_session( &state, &request_context, &authenticated, session_id, payload.game_name, payload.summary, payload.tags, payload.cover_image_src, payload.generate_click_sound, ) .await?; Ok(json_success_body( Some(&request_context), Match3DAgentActionResponse { session: map_match3d_agent_session_response_with_assets( session, &generated_item_assets, ), }, )) } pub async fn get_match3d_works( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let items = state .spacetime_client() .list_match3d_works(authenticated.claims().user_id().to_string()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DWorksResponse { items: items .into_iter() .map(map_match3d_work_summary_response) .collect(), }, )) } pub async fn list_match3d_gallery( State(state): State, Extension(request_context): Extension, ) -> Result, Response> { let items = state .spacetime_client() .list_match3d_gallery() .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DWorksResponse { items: items .into_iter() .map(map_match3d_work_summary_response) .collect(), }, )) } pub async fn get_match3d_work_detail( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .get_match3d_work_detail(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DWorkDetailResponse { item: map_match3d_work_profile_response(item), }, )) } pub async fn put_match3d_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let existing = state .spacetime_client() .get_match3d_work_detail( profile_id.clone(), authenticated.claims().user_id().to_string(), ) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; let theme_text = payload .theme_text .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or(existing.theme_text); let item = state .spacetime_client() .update_match3d_work(Match3DWorkUpdateRecordInput { profile_id, owner_user_id: authenticated.claims().user_id().to_string(), game_name: payload.game_name, theme_text, summary_text: payload.summary, tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(), cover_image_src: payload.cover_image_src.unwrap_or_default(), cover_asset_id: String::new(), clear_count: payload.clear_count, difficulty: payload.difficulty, updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DWorkMutationResponse { item: map_match3d_work_profile_response(item), }, )) } pub async fn put_match3d_audio_assets( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let existing = state .spacetime_client() .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; let session_id = existing.source_session_id.clone().ok_or_else(|| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "message": "抓大鹅作品缺少来源 session,无法写回音频素材", })), ) })?; let assets = payload .generated_item_assets .into_iter() .map(Match3DGeneratedItemAsset::from) .collect::>(); let session = upsert_match3d_draft_snapshot( &state, &request_context, &authenticated, session_id, owner_user_id.clone(), profile_id.clone(), Some(existing.game_name), Some(existing.summary), Some(serde_json::to_string(&existing.tags).unwrap_or_default()), existing.cover_image_src, None, serialize_match3d_generated_item_assets(&assets), ) .await?; let item = state .spacetime_client() .get_match3d_work_detail(profile_id, owner_user_id) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; let _ = session; Ok(json_success_body( Some(&request_context), Match3DWorkMutationResponse { item: map_match3d_work_profile_response(item), }, )) } pub async fn persist_match3d_generated_model( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &payload.item_id, "itemId", )?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &payload.item_name, "itemName", )?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &payload.source_url, "sourceUrl", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let existing = state .spacetime_client() .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; let session_id = existing.source_session_id.clone().ok_or_else(|| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "message": "抓大鹅作品缺少来源 session,无法保存历史模型", })), ) })?; let mut assets = parse_match3d_generated_item_assets(existing.generated_item_assets_json.as_deref()) .into_iter() .map(Match3DGeneratedItemAsset::from) .collect::>(); let current_asset = assets .iter() .find(|asset| asset.item_id == payload.item_id) .cloned(); let item_name = normalize_match3d_item_name(payload.item_name.as_str()); let item_name = if item_name.is_empty() { current_asset .as_ref() .map(|asset| asset.item_name.clone()) .unwrap_or_else(|| payload.item_name.trim().to_string()) } else { item_name }; let model_file = hyper3d_contract::Hyper3dDownloadFilePayload { name: normalize_optional_text(payload.file_name.as_deref()) .unwrap_or_else(|| "model.glb".to_string()), url: payload.source_url.trim().to_string(), }; let downloaded_model = download_match3d_legacy_model(&model_file) .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; let task_uuid = normalize_optional_text(payload.task_uuid.as_deref()); let item_slug = build_match3d_item_slug(payload.item_id.as_str(), item_name.as_str()); let generated_at_micros = current_utc_micros(); let uploaded_model = persist_match3d_generated_bytes( &state, owner_user_id.as_str(), session_id.as_str(), profile_id.as_str(), &[ "items", item_slug.as_str(), "model", task_uuid.as_deref().unwrap_or("manual"), ], downloaded_model.file_name.as_str(), downloaded_model.content_type.as_str(), downloaded_model.bytes, "match3d_item_model", task_uuid.as_deref(), generated_at_micros, ) .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; let next_asset = Match3DGeneratedItemAsset { item_id: payload.item_id, item_name, image_src: current_asset .as_ref() .and_then(|asset| asset.image_src.clone()), image_object_key: current_asset .as_ref() .and_then(|asset| asset.image_object_key.clone()), image_views: current_asset .as_ref() .map(|asset| asset.image_views.clone()) .unwrap_or_default(), model_src: Some(uploaded_model.src), model_object_key: Some(uploaded_model.object_key), model_file_name: Some(downloaded_model.file_name), task_uuid, subscription_key: normalize_optional_text(payload.subscription_key.as_deref()).or_else( || { current_asset .as_ref() .and_then(|asset| asset.subscription_key.clone()) }, ), sound_prompt: current_asset .as_ref() .and_then(|asset| asset.sound_prompt.clone()), background_music_title: current_asset .as_ref() .and_then(|asset| asset.background_music_title.clone()), background_music_style: current_asset .as_ref() .and_then(|asset| asset.background_music_style.clone()), background_music_prompt: current_asset .as_ref() .and_then(|asset| asset.background_music_prompt.clone()), background_music: current_asset .as_ref() .and_then(|asset| asset.background_music.clone()), click_sound: current_asset .as_ref() .and_then(|asset| asset.click_sound.clone()), background_asset: current_asset .as_ref() .and_then(|asset| asset.background_asset.clone()), status: "model_ready".to_string(), error: None, }; upsert_match3d_generated_item_asset(&mut assets, next_asset.clone()); persist_match3d_generated_item_assets_snapshot( &state, &request_context, &authenticated, session_id.as_str(), owner_user_id.as_str(), profile_id.as_str(), &assets, ) .await?; Ok(json_success_body( Some(&request_context), PersistMatch3DGeneratedModelResponse { asset: map_match3d_generated_item_asset_for_work(Match3DGeneratedItemAssetJson::from( next_asset, )), }, )) } pub async fn generate_match3d_cover_image( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let prompt = normalize_match3d_cover_prompt(payload.prompt.as_str()); ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; let context = load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) .await?; let generated_cover = generate_match3d_cover_image_asset( &state, &context.owner_user_id, context.session_id.as_str(), profile_id.as_str(), &context.config, prompt.as_str(), payload.uploaded_image_src, collect_match3d_cover_reference_image_sources( payload.reference_image_src, payload.reference_image_srcs, ), ) .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; let item = update_match3d_work_cover_only( &state, &request_context, context.owner_user_id.as_str(), context.profile, generated_cover.src.as_str(), ) .await?; Ok(json_success_body( Some(&request_context), GenerateMatch3DCoverImageResponse { item: map_match3d_work_profile_response(item), cover_image_src: generated_cover.src, cover_image_object_key: generated_cover.object_key, prompt, }, )) } async fn update_match3d_work_cover_only( state: &AppState, request_context: &RequestContext, owner_user_id: &str, profile: Match3DWorkProfileRecord, cover_image_src: &str, ) -> Result { // 中文注释:封面生成是定向图片槽位更新,不能复用草稿编译路径重算题材、难度或素材 JSON。 state .spacetime_client() .update_match3d_work(Match3DWorkUpdateRecordInput { profile_id: profile.profile_id, owner_user_id: owner_user_id.to_string(), game_name: profile.game_name, theme_text: profile.theme_text, summary_text: profile.summary, tags_json: serde_json::to_string(&normalize_tags(profile.tags)).unwrap_or_default(), cover_image_src: cover_image_src.to_string(), cover_asset_id: profile.cover_asset_id.unwrap_or_default(), clear_count: profile.clear_count, difficulty: profile.difficulty, updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { match3d_error_response( request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) }) } pub async fn generate_match3d_background_image_for_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let prompt = normalize_match3d_background_prompt(payload.prompt.as_str()); ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str()); let context = load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) .await?; let Match3DWorkAssetContext { owner_user_id, session_id, profile, config, assets, } = context; let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, prompt_fingerprint); let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost( &state, owner_user_id.as_str(), "match3d_ui_background_image", billing_asset_id.as_str(), MATCH3D_BACKGROUND_IMAGE_POINTS_COST, async { let generated_background = generate_match3d_background_image( &state, owner_user_id.as_str(), session_id.as_str(), profile_id.as_str(), &config, prompt.as_str(), ) .await?; let mut assets = assets; attach_match3d_background_asset_to_assets(&mut assets, generated_background.clone()); let save_result = persist_match3d_generated_item_assets_snapshot( &state, &request_context, &authenticated, session_id.as_str(), owner_user_id.as_str(), profile_id.as_str(), &assets, ) .await; if let Err(response) = save_result { tracing::warn!( provider = MATCH3D_WORKS_PROVIDER, profile_id, owner_user_id = %owner_user_id, status = %response.status(), "抓大鹅 UI 背景图已生成但 SpacetimeDB 草稿写回不可用,降级返回本次生成资产" ); } Ok((generated_background, assets)) }, ) .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; let item = state .spacetime_client() .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) .await .map(|item| map_match3d_work_profile_response(item)) .unwrap_or_else(|error| { tracing::warn!( provider = MATCH3D_WORKS_PROVIDER, profile_id, owner_user_id = %owner_user_id, error = %error, "抓大鹅 UI 背景图生成后读取作品详情失败,降级使用写回前快照" ); map_match3d_work_profile_response(build_match3d_work_profile_record_with_assets( profile, &generated_assets, )) }); let background_image_src = generated_background.image_src.clone().unwrap_or_default(); let background_image_object_key = generated_background .image_object_key .clone() .unwrap_or_default(); Ok(json_success_body( Some(&request_context), GenerateMatch3DBackgroundImageResponse { item, background_image_src, background_image_object_key, generated_background_asset: map_match3d_background_asset_for_work(generated_background), prompt, }, )) } pub async fn generate_match3d_container_image_for_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let prompt = normalize_match3d_background_prompt(payload.prompt.as_str()); ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str()); let context = load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) .await?; let Match3DWorkAssetContext { owner_user_id, session_id, profile, config, assets, } = context; let billing_asset_id = format!( "{}:{}:{}:container", session_id, profile_id, prompt_fingerprint ); let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost( &state, owner_user_id.as_str(), "match3d_ui_container_image", billing_asset_id.as_str(), MATCH3D_BACKGROUND_IMAGE_POINTS_COST, async { let generated_container = generate_match3d_container_image( &state, owner_user_id.as_str(), session_id.as_str(), profile_id.as_str(), &config, prompt.as_str(), ) .await?; let mut assets = assets; let generated_background = merge_match3d_container_image_into_background_asset(&assets, generated_container); attach_match3d_background_asset_to_assets(&mut assets, generated_background.clone()); let save_result = persist_match3d_generated_item_assets_snapshot( &state, &request_context, &authenticated, session_id.as_str(), owner_user_id.as_str(), profile_id.as_str(), &assets, ) .await; if let Err(response) = save_result { tracing::warn!( provider = MATCH3D_WORKS_PROVIDER, profile_id, owner_user_id = %owner_user_id, status = %response.status(), "抓大鹅容器形象已生成但 SpacetimeDB 草稿写回不可用,降级返回本次生成资产" ); } Ok((generated_background, assets)) }, ) .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; let item = state .spacetime_client() .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) .await .map(|item| map_match3d_work_profile_response(item)) .unwrap_or_else(|error| { tracing::warn!( provider = MATCH3D_WORKS_PROVIDER, profile_id, owner_user_id = %owner_user_id, error = %error, "抓大鹅容器形象生成后读取作品详情失败,降级使用写回前快照" ); map_match3d_work_profile_response(build_match3d_work_profile_record_with_assets( profile, &generated_assets, )) }); let container_image_src = generated_background .container_image_src .clone() .unwrap_or_default(); let container_image_object_key = generated_background .container_image_object_key .clone() .unwrap_or_default(); Ok(json_success_body( Some(&request_context), GenerateMatch3DContainerImageResponse { item, container_image_src, container_image_object_key, generated_background_asset: map_match3d_background_asset_for_work(generated_background), prompt, }, )) } pub async fn generate_match3d_item_assets_for_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let item_names = normalize_match3d_batch_item_names(payload.item_names); if item_names.is_empty() { return Err(match3d_bad_request( &request_context, MATCH3D_WORKS_PROVIDER, "请填写至少一个物品名称", )); } let generation_mode = normalize_match3d_item_assets_generation_mode(payload.mode.as_deref()); let context = load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) .await?; let Match3DWorkAssetContext { owner_user_id, session_id, profile, config, assets, } = context; let generation_plan = build_match3d_item_assets_generation_plan(generation_mode, item_names, &assets); if generation_plan.billed_item_count() == 0 { return Ok(json_success_body( Some(&request_context), GenerateMatch3DItemAssetsResponse { item: map_match3d_work_profile_response(profile), generated_item_assets: sort_match3d_generated_assets(assets) .into_iter() .map(Match3DGeneratedItemAssetJson::from) .map(map_match3d_generated_item_asset_for_work) .collect(), }, )); } let billed_item_count = generation_plan.billed_item_count(); let points_cost = calculate_match3d_item_assets_points_cost(billed_item_count); let billing_asset_id = format!( "{}:{}:{}:{}", session_id, profile_id, billed_item_count, build_match3d_prompt_fingerprint(generation_plan.billing_fingerprint_source().as_str()) ); let generated_assets = execute_billable_asset_operation_with_cost( &state, owner_user_id.as_str(), "match3d_item_assets", billing_asset_id.as_str(), points_cost, async { append_match3d_item_assets( &state, &request_context, &authenticated, owner_user_id.as_str(), session_id.as_str(), profile_id.as_str(), &config, generation_plan, assets, ) .await .map_err(|response| { AppError::from_status(response.status()).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "message": "抓大鹅批量新增物品素材失败", })) }) }, ) .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; let item = state .spacetime_client() .get_match3d_work_detail(profile_id, owner_user_id) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), GenerateMatch3DItemAssetsResponse { item: map_match3d_work_profile_response(item), generated_item_assets: generated_assets .into_iter() .map(Match3DGeneratedItemAssetJson::from) .map(map_match3d_generated_item_asset_for_work) .collect(), }, )) } pub async fn generate_match3d_work_tags( State(state): State, Extension(request_context): Extension, Extension(_authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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(), payload.summary.as_deref(), ) .await; Ok(json_success_body( Some(&request_context), GenerateMatch3DWorkTagsResponse { tags }, )) } pub async fn publish_match3d_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .publish_match3d_work( profile_id, authenticated.claims().user_id().to_string(), current_utc_micros(), ) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DWorkMutationResponse { item: map_match3d_work_profile_response(item), }, )) } pub async fn delete_match3d_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let items = state .spacetime_client() .delete_match3d_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DWorksResponse { items: items .into_iter() .map(map_match3d_work_summary_response) .collect(), }, )) } pub async fn start_match3d_run( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let maybe_payload = payload.ok().map(|Json(payload)| payload); let profile_id = maybe_payload .as_ref() .map(|payload| payload.profile_id.clone()) .filter(|value| !value.trim().is_empty()) .unwrap_or(profile_id); ensure_non_empty( &request_context, MATCH3D_RUNTIME_PROVIDER, &profile_id, "profileId", )?; let run = state .spacetime_client() .start_match3d_run(Match3DRunStartRecordInput { run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), profile_id: profile_id.clone(), started_at_ms: current_utc_ms(), item_type_count_override: maybe_payload .as_ref() .and_then(|payload| payload.item_type_count_override) .unwrap_or(0), }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_RUNTIME_PROVIDER, map_match3d_client_error(error), ) })?; record_work_play_start_after_success( &state, &request_context, WorkPlayTrackingDraft::new( "match3d", profile_id.clone(), &authenticated, "/api/runtime/match3d/...", ) .profile_id(profile_id.clone()) .extra(json!({ "runId": run.run_id, })), ) .await; Ok(json_success_body( Some(&request_context), Match3DRunResponse { run: map_match3d_run_response(run), }, )) } pub async fn get_match3d_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .get_match3d_run(run_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_RUNTIME_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DRunResponse { run: map_match3d_run_response(run), }, )) } pub async fn click_match3d_item( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_RUNTIME_PROVIDER)?; ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; ensure_non_empty( &request_context, MATCH3D_RUNTIME_PROVIDER, &payload.item_instance_id, "itemInstanceId", )?; ensure_non_empty( &request_context, MATCH3D_RUNTIME_PROVIDER, &payload.client_event_id, "clientEventId", )?; let confirmation = state .spacetime_client() .click_match3d_item(Match3DRunClickRecordInput { run_id: payload.run_id.unwrap_or(run_id), owner_user_id: authenticated.claims().user_id().to_string(), item_instance_id: payload.item_instance_id, client_snapshot_version: payload.client_snapshot_version.min(u32::MAX as u64) as u32, client_event_id: payload.client_event_id, clicked_at_ms: payload.clicked_at_ms.min(i64::MAX as u64) as i64, }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_RUNTIME_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DClickResponse { confirmation: map_match3d_click_confirmation_response(confirmation), }, )) } pub async fn stop_match3d_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let _ = payload.ok(); ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .stop_match3d_run(Match3DRunStopRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), stopped_at_ms: current_utc_ms(), }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_RUNTIME_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DRunResponse { run: map_match3d_run_response(run), }, )) } pub async fn restart_match3d_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .restart_match3d_run(Match3DRunRestartRecordInput { source_run_id: run_id, next_run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), restarted_at_ms: current_utc_ms(), }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_RUNTIME_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DRunResponse { run: map_match3d_run_response(run), }, )) } pub async fn finish_match3d_time_up( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .finish_match3d_time_up(Match3DRunTimeUpRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), finished_at_ms: current_utc_ms(), }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_RUNTIME_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DRunResponse { run: map_match3d_run_response(run), }, )) } async fn submit_and_finalize_match3d_message( state: &AppState, request_context: &RequestContext, owner_user_id: &str, session_id: String, payload: SendMatch3DAgentMessageRequest, ) -> Result { ensure_non_empty( request_context, MATCH3D_AGENT_PROVIDER, &session_id, "sessionId", )?; ensure_non_empty( request_context, MATCH3D_AGENT_PROVIDER, &payload.client_message_id, "clientMessageId", )?; ensure_non_empty( request_context, MATCH3D_AGENT_PROVIDER, &payload.text, "text", )?; let submitted = state .spacetime_client() .submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.to_string(), user_message_id: payload.client_message_id.clone(), user_message_text: payload.text.clone(), submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| { match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) })?; let next_turn = submitted.current_turn.saturating_add(1); let next_config = build_config_from_message(&submitted, &payload); let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn); let progress_percent = resolve_progress_percent_for_turn(next_turn); let stage = if progress_percent >= 100 { "ReadyToCompile" } else { "Collecting" } .to_string(); state .spacetime_client() .finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput { session_id, owner_user_id: owner_user_id.to_string(), assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)), assistant_reply_text: Some(assistant_reply), config_json: serialize_match3d_config(&next_config), progress_percent, stage, updated_at_micros: current_utc_micros(), error_message: None, }) .await .map_err(|error| { match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) }) } async fn compile_match3d_draft_for_session( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, session_id: String, game_name: Option, summary: Option, tags: Option>, cover_image_src: Option, generate_click_sound: Option, ) -> Result<(Match3DAgentSessionRecord, Vec), Response> { let owner_user_id = authenticated.claims().user_id().to_string(); let initial_session = state .spacetime_client() .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|error| { match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) })?; let mut config = resolve_config_or_default(initial_session.config.as_ref()); if let Some(generate_click_sound) = generate_click_sound { config.generate_click_sound = generate_click_sound; } // 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session // 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。 let has_complete_form_config = !config.theme_text.trim().is_empty() && config.clear_count > 0 && (1..=10).contains(&config.difficulty); if !has_complete_form_config && (initial_session.current_turn < 3 || initial_session.progress_percent < 100) { return Err(match3d_bad_request( request_context, MATCH3D_AGENT_PROVIDER, "match3d 创作配置尚未确认完成", )); } let requested_game_name = normalize_optional_match3d_text(game_name); let requested_summary = normalize_optional_match3d_text(summary); let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty()); let requested_cover_image_src = normalize_optional_match3d_text(cover_image_src); let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str()); let profile_id = resolve_match3d_draft_profile_id(&initial_session); let initial_game_name = requested_game_name .clone() .unwrap_or_else(|| fallback_work_metadata.game_name.clone()); let initial_tags = requested_tags .clone() .unwrap_or_else(|| fallback_work_metadata.tags.clone()); let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros()); execute_billable_match3d_draft_generation( state, request_context, owner_user_id.as_str(), billing_asset_id.as_str(), async { let mut session = upsert_match3d_draft_snapshot( state, request_context, authenticated, session_id.clone(), owner_user_id.clone(), profile_id.clone(), Some(initial_game_name), requested_summary.clone().or_else(|| Some(String::new())), Some(serde_json::to_string(&initial_tags).unwrap_or_default()), requested_cover_image_src.clone(), None, None, ) .await?; if session.draft.is_none() { return Err(match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"), )); } let mut generated_work_metadata = generate_match3d_draft_plan(state, &config).await; let resolved_game_name = requested_game_name .unwrap_or_else(|| generated_work_metadata.metadata.game_name.clone()); let resolved_summary = requested_summary .clone() .unwrap_or_else(|| generated_work_metadata.metadata.summary.clone()); let resolved_tags = match requested_tags { Some(tags) => tags, None => { generate_match3d_work_tags_for_plan( state, resolved_game_name.as_str(), config.theme_text.as_str(), resolved_summary.as_str(), &generated_work_metadata.metadata.tags, ) .await } }; generated_work_metadata.metadata.tags = resolved_tags.clone(); session = upsert_match3d_draft_snapshot( state, request_context, authenticated, session_id, owner_user_id.clone(), profile_id.clone(), Some(resolved_game_name), Some(resolved_summary), Some(serde_json::to_string(&resolved_tags).unwrap_or_default()), requested_cover_image_src.clone(), None, None, ) .await?; let existing_assets = get_match3d_existing_generated_item_assets( state, owner_user_id.as_str(), profile_id.as_str(), ) .await; let generated_item_assets = generate_match3d_item_assets( state, request_context, authenticated, owner_user_id.as_str(), session.session_id.as_str(), profile_id.as_str(), &config, generated_work_metadata.items, generated_work_metadata.background_music, existing_assets, ) .await?; let generated_item_assets = ensure_match3d_background_asset( state, request_context, authenticated, owner_user_id.as_str(), session.session_id.as_str(), profile_id.as_str(), &config, generated_work_metadata.background_prompt.as_str(), generated_item_assets, ) .await?; let existing_cover_image_src = get_match3d_existing_cover_image_src( state, owner_user_id.as_str(), profile_id.as_str(), ) .await; let default_cover_image_src = requested_cover_image_src .clone() .or(existing_cover_image_src) .or_else(|| resolve_match3d_default_cover_image_src(&generated_item_assets)); let next_session = upsert_match3d_draft_snapshot( state, request_context, authenticated, session.session_id.clone(), owner_user_id.clone(), profile_id, None, None, None, default_cover_image_src, None, serialize_match3d_generated_item_assets(&generated_item_assets), ) .await?; Ok((next_session, generated_item_assets)) }, ) .await } /// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。 async fn execute_billable_match3d_draft_generation( state: &AppState, request_context: &RequestContext, owner_user_id: &str, billing_asset_id: &str, operation: Fut, ) -> Result where Fut: Future>, { let points_consumed = consume_match3d_draft_generation_points( state, request_context, owner_user_id, billing_asset_id, ) .await?; match operation.await { Ok(value) => Ok(value), Err(response) => { if points_consumed { refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id) .await; } Err(response) } } } async fn consume_match3d_draft_generation_points( state: &AppState, request_context: &RequestContext, owner_user_id: &str, billing_asset_id: &str, ) -> Result { let ledger_id = format!( "asset_operation_consume:{}:match3d_draft_generation:{}", owner_user_id, billing_asset_id ); match state .spacetime_client() .consume_profile_wallet_points( owner_user_id.to_string(), MATCH3D_DRAFT_GENERATION_POINTS_COST, ledger_id, current_utc_micros(), ) .await { Ok(_) => Ok(true), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { tracing::warn!( owner_user_id, billing_asset_id, error = %error, "抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过" ); Ok(false) } Err(error) => Err(match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, map_asset_operation_wallet_error(error), )), } } async fn refund_match3d_draft_generation_points( state: &AppState, owner_user_id: &str, billing_asset_id: &str, ) { let ledger_id = format!( "asset_operation_refund:{}:match3d_draft_generation:{}", owner_user_id, billing_asset_id ); if let Err(error) = state .spacetime_client() .refund_profile_wallet_points( owner_user_id.to_string(), MATCH3D_DRAFT_GENERATION_POINTS_COST, ledger_id, current_utc_micros(), ) .await { tracing::error!( owner_user_id, billing_asset_id, error = %error, "抓大鹅草稿生成失败后的泥点退款失败" ); } } fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String { session .draft .as_ref() .map(|draft| draft.profile_id.trim()) .filter(|profile_id| !profile_id.is_empty()) .or_else(|| { session .published_profile_id .as_deref() .map(str::trim) .filter(|profile_id| !profile_id.is_empty()) }) .map(str::to_string) .unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX)) } #[allow(clippy::too_many_arguments)] async fn upsert_match3d_draft_snapshot( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, session_id: String, owner_user_id: String, profile_id: String, game_name: Option, summary_text: Option, tags_json: Option, cover_image_src: Option, cover_asset_id: Option, generated_item_assets_json: Option, ) -> Result { state .spacetime_client() .compile_match3d_draft(Match3DCompileDraftRecordInput { session_id, owner_user_id, profile_id, author_display_name: resolve_author_display_name(state, authenticated), game_name, summary_text, tags_json, cover_image_src, cover_asset_id, compiled_at_micros: current_utc_micros(), generated_item_assets_json, }) .await .map_err(|error| { match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) }) } async fn get_match3d_existing_generated_item_assets( state: &AppState, owner_user_id: &str, profile_id: &str, ) -> Vec { match state .spacetime_client() .get_match3d_work_detail(profile_id.to_string(), owner_user_id.to_string()) .await { Ok(profile) => { parse_match3d_generated_item_assets(profile.generated_item_assets_json.as_deref()) .into_iter() .map(Match3DGeneratedItemAsset::from) .collect() } Err(error) => { tracing::debug!( provider = MATCH3D_AGENT_PROVIDER, profile_id, error = %error, "读取抓大鹅已有素材失败,按空素材继续生成" ); Vec::new() } } } async fn get_match3d_existing_cover_image_src( state: &AppState, owner_user_id: &str, profile_id: &str, ) -> Option { state .spacetime_client() .get_match3d_work_detail(profile_id.to_string(), owner_user_id.to_string()) .await .ok() .and_then(|profile| profile.cover_image_src) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } async fn load_match3d_work_asset_context( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, profile_id: &str, ) -> Result { let owner_user_id = authenticated.claims().user_id().to_string(); let profile = state .spacetime_client() .get_match3d_work_detail(profile_id.to_string(), owner_user_id.clone()) .await .map_err(|error| { match3d_error_response( request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; let session_id = profile.source_session_id.clone().ok_or_else(|| { match3d_error_response( request_context, MATCH3D_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "message": "抓大鹅作品缺少来源 session,无法生成素材", })), ) })?; let config = match state .spacetime_client() .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) .await { Ok(session) => { let mut config = resolve_config_or_default(session.config.as_ref()); if config.theme_text.trim().is_empty() { config.theme_text = profile.theme_text.clone(); } config } Err(error) => { tracing::debug!( provider = MATCH3D_WORKS_PROVIDER, profile_id, session_id = session_id.as_str(), error = %error, "读取抓大鹅 session 配置失败,使用作品 profile 派生素材配置" ); Match3DConfigJson { theme_text: profile.theme_text.clone(), reference_image_src: profile.reference_image_src.clone(), clear_count: profile.clear_count, difficulty: profile.difficulty, asset_style_id: None, asset_style_label: None, asset_style_prompt: None, generate_click_sound: false, } } }; let assets = parse_match3d_generated_item_assets(profile.generated_item_assets_json.as_deref()) .into_iter() .map(Match3DGeneratedItemAsset::from) .collect::>(); Ok(Match3DWorkAssetContext { owner_user_id, session_id, profile, config, assets, }) } #[allow(clippy::too_many_arguments)] async fn persist_match3d_generated_item_assets_snapshot( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, session_id: &str, owner_user_id: &str, profile_id: &str, assets: &[Match3DGeneratedItemAsset], ) -> Result<(), Response> { upsert_match3d_draft_snapshot( state, request_context, authenticated, session_id.to_string(), owner_user_id.to_string(), profile_id.to_string(), None, None, None, None, None, serialize_match3d_generated_item_assets(assets), ) .await .map(|_| ()) } mod mappers; use mappers::*; fn build_config_from_create_request( payload: &CreateMatch3DAgentSessionRequest, ) -> Match3DConfigJson { Match3DConfigJson { theme_text: payload .theme_text .as_deref() .or(payload.seed_text.as_deref()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(MATCH3D_DEFAULT_THEME) .to_string(), reference_image_src: payload.reference_image_src.clone(), clear_count: payload.clear_count.unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT), difficulty: payload .difficulty .unwrap_or(MATCH3D_DEFAULT_DIFFICULTY) .clamp(1, 10), asset_style_id: normalize_optional_text(payload.asset_style_id.as_deref()), asset_style_label: normalize_optional_text(payload.asset_style_label.as_deref()), asset_style_prompt: normalize_optional_text(payload.asset_style_prompt.as_deref()), generate_click_sound: payload.generate_click_sound.unwrap_or(false), } } fn build_config_from_message( session: &Match3DAgentSessionRecord, payload: &SendMatch3DAgentMessageRequest, ) -> Match3DConfigJson { let current = resolve_config_or_default(session.config.as_ref()); let text = payload.text.trim(); let reference_image_src = payload .reference_image_src .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .or(current.reference_image_src); let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置"); let mut theme_text = current.theme_text; let mut clear_count = current.clear_count.max(1); let mut difficulty = current.difficulty.clamp(1, 10); let asset_style_id = current.asset_style_id; let asset_style_label = current.asset_style_label; let asset_style_prompt = current.asset_style_prompt; let generate_click_sound = current.generate_click_sound; match session.current_turn { 0 => { theme_text = if quick_fill_requested { MATCH3D_DEFAULT_THEME.to_string() } else { parse_theme_answer(text).unwrap_or(theme_text) }; } 1 => { clear_count = if quick_fill_requested { clear_count } else { parse_number_after_keywords(text, &["消除", "次数", "clearCount"]) .unwrap_or(clear_count) } .max(1); } _ => { difficulty = if quick_fill_requested { difficulty } else { parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty) } .clamp(1, 10); } } Match3DConfigJson { theme_text, reference_image_src, clear_count, difficulty, asset_style_id, asset_style_label, asset_style_prompt, generate_click_sound, } } fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson { config .map(|config| Match3DConfigJson { theme_text: config.theme_text.clone(), reference_image_src: config.reference_image_src.clone(), clear_count: config.clear_count.max(1), difficulty: config.difficulty.clamp(1, 10), asset_style_id: config.asset_style_id.clone(), asset_style_label: config.asset_style_label.clone(), asset_style_prompt: config.asset_style_prompt.clone(), generate_click_sound: config.generate_click_sound, }) .unwrap_or_else(|| Match3DConfigJson { theme_text: MATCH3D_DEFAULT_THEME.to_string(), reference_image_src: None, clear_count: MATCH3D_DEFAULT_CLEAR_COUNT, difficulty: MATCH3D_DEFAULT_DIFFICULTY, asset_style_id: None, asset_style_label: None, asset_style_prompt: None, generate_click_sound: false, }) } fn normalize_optional_text(value: Option<&str>) -> Option { value .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) } fn serialize_match3d_config(config: &Match3DConfigJson) -> Option { serde_json::to_string(config).ok() } fn build_seed_text( payload: &CreateMatch3DAgentSessionRequest, config: &Match3DConfigJson, ) -> String { payload .seed_text .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| { format!( "{}题材,消除{}次,难度{}", config.theme_text, config.clear_count, config.difficulty ) }) } fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String { format!( "已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}。", config.theme_text, config.clear_count, config.clear_count.saturating_mul(3), config.difficulty ) } fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String { match current_turn { 0 => MATCH3D_QUESTION_THEME.to_string(), 1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(), 2 => MATCH3D_QUESTION_DIFFICULTY.to_string(), _ => build_match3d_assistant_reply(config), } } fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 { match current_turn { 0 => 0, 1 => 33, 2 => 66, _ => 100, } } fn parse_theme_answer(text: &str) -> Option { for marker in ["题材", "主题"] { if let Some((_, value)) = text.split_once(marker) { let normalized = value .trim_matches(|ch: char| ch == ':' || ch == ':' || ch.is_whitespace()) .split_whitespace() .next() .unwrap_or_default() .trim_matches(['。', ',', ',', ';', ';']) .to_string(); if !normalized.is_empty() { return Some(normalized); } } } let trimmed = text.trim(); if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit()) { return Some(trimmed.to_string()); } None } fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option { for keyword in keywords { if let Some(index) = text.find(keyword) { let suffix = &text[index + keyword.len()..]; if let Some(value) = first_positive_integer(suffix) { return Some(value); } } } first_positive_integer(text) } fn first_positive_integer(text: &str) -> Option { let mut digits = String::new(); for ch in text.chars() { if ch.is_ascii_digit() { digits.push(ch); } else if !digits.is_empty() { break; } } digits.parse::().ok().filter(|value| *value > 0) } fn normalize_tags(tags: Vec) -> Vec { let mut result: Vec = Vec::new(); for tag in tags { 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; } } result } fn normalize_optional_match3d_text(value: Option) -> Option { value .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } mod tags; use tags::*; fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option { if assets.is_empty() { return None; } let items = assets .iter() .cloned() .map(Match3DGeneratedItemAssetJson::from) .collect::>(); serde_json::to_string(&items).ok() } fn parse_match3d_generated_item_assets(value: Option<&str>) -> Vec { value .map(str::trim) .filter(|value| !value.is_empty()) .and_then(|value| serde_json::from_str::>(value).ok()) .unwrap_or_default() } impl From for Match3DGeneratedItemAssetJson { fn from(asset: Match3DGeneratedItemAsset) -> Self { Self { item_id: asset.item_id, item_name: asset.item_name, image_src: asset.image_src, image_object_key: asset.image_object_key, image_views: asset.image_views, model_src: asset.model_src, model_object_key: asset.model_object_key, model_file_name: asset.model_file_name, task_uuid: asset.task_uuid, subscription_key: asset.subscription_key, sound_prompt: asset.sound_prompt, background_music_title: asset.background_music_title, background_music_style: asset.background_music_style, background_music_prompt: asset.background_music_prompt, background_music: asset.background_music, click_sound: asset.click_sound, background_asset: asset.background_asset, status: asset.status, error: asset.error, } } } impl From for Match3DGeneratedItemAsset { fn from(asset: Match3DGeneratedItemAssetJson) -> Self { Self { item_id: asset.item_id, item_name: asset.item_name, image_src: asset.image_src, image_object_key: asset.image_object_key, image_views: asset.image_views, model_src: asset.model_src, model_object_key: asset.model_object_key, model_file_name: asset.model_file_name, task_uuid: asset.task_uuid, subscription_key: asset.subscription_key, sound_prompt: asset.sound_prompt, background_music_title: asset.background_music_title, background_music_style: asset.background_music_style, background_music_prompt: asset.background_music_prompt, background_music: asset.background_music, click_sound: asset.click_sound, background_asset: asset.background_asset, status: asset.status, error: asset.error, } } } impl From for Match3DGeneratedItemAsset { fn from(asset: shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse) -> Self { Self { item_id: asset.item_id, item_name: asset.item_name, image_src: asset.image_src, image_object_key: asset.image_object_key, image_views: asset .image_views .into_iter() .map(map_match3d_image_view_from_work) .collect(), model_src: asset.model_src, model_object_key: asset.model_object_key, model_file_name: asset.model_file_name, task_uuid: asset.task_uuid, subscription_key: asset.subscription_key, sound_prompt: asset.sound_prompt, background_music_title: asset.background_music_title, background_music_style: asset.background_music_style, background_music_prompt: asset.background_music_prompt, background_music: asset.background_music, click_sound: asset.click_sound, background_asset: asset .background_asset .map(|asset| Match3DGeneratedBackgroundAsset { prompt: asset.prompt, image_src: asset.image_src, image_object_key: asset.image_object_key, container_prompt: asset.container_prompt, container_image_src: asset.container_image_src, container_image_object_key: asset.container_image_object_key, status: asset.status, error: asset.error, }), status: asset.status, error: asset.error, } } } fn resolve_author_display_name( state: &AppState, authenticated: &AuthenticatedAccessToken, ) -> String { state .auth_user_service() .get_user_by_id(authenticated.claims().user_id()) .ok() .flatten() .map(|user| user.display_name) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "玩家".to_string()) } async fn generate_match3d_item_assets( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, item_plan: Vec, background_music_plan: Match3DGeneratedBackgroundMusicPlan, existing_assets: Vec, ) -> Result, Response> { // 中文注释:外部生图、音频和 OSS 写入都留在 api-server,SpacetimeDB reducer 只保存确定性草稿。 let target_item_count = resolve_match3d_generated_item_count(config); let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); if has_match3d_required_item_images(&assets, target_item_count) && assets .iter() .take(target_item_count) .any(has_match3d_background_music_audio) && (!config.generate_click_sound || assets .iter() .take(target_item_count) .all(|asset| asset.click_sound.is_some())) { return Ok(assets.into_iter().take(target_item_count).collect()); } if !has_match3d_required_item_images(&assets, target_item_count) { assets = ensure_match3d_item_image_assets( state, request_context, authenticated, owner_user_id, session_id, profile_id, config, item_plan, assets, ) .await?; } assets = ensure_match3d_background_music_asset( state, request_context, authenticated, owner_user_id, session_id, profile_id, &background_music_plan, assets, ) .await?; assets = ensure_match3d_click_sound_assets( state, request_context, authenticated, owner_user_id, session_id, profile_id, config, assets, ) .await?; persist_match3d_generated_item_assets_snapshot( state, request_context, authenticated, session_id, owner_user_id, profile_id, &assets, ) .await?; Ok(assets.into_iter().take(target_item_count).collect()) } #[allow(clippy::too_many_arguments)] async fn ensure_match3d_item_image_assets( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, item_plan: Vec, existing_assets: Vec, ) -> Result, Response> { let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); let target_item_count = resolve_match3d_generated_item_count(config); let item_plan = normalize_match3d_item_plan(config, item_plan); let missing_items = item_plan .iter() .take(target_item_count) .enumerate() .filter_map(|(index, item)| { let item_id = format!("match3d-item-{}", index + 1); if assets.iter().any(|asset| { asset.item_id == item_id && is_match3d_generated_asset_image_ready(asset) }) { return None; } Some(Match3DItemImageGenerationSeed { item_id, item_name: item.name.clone(), sound_prompt: item.sound_prompt.clone(), persist_asset: true, background_music_title: None, background_music_style: None, background_music_prompt: None, background_asset: if index == 0 { assets .first() .and_then(|asset| asset.background_asset.clone()) } else { None }, }) }) .collect::>(); let generated_assets = generate_match3d_item_image_assets_in_batches( state, request_context, MATCH3D_AGENT_PROVIDER, owner_user_id, session_id, profile_id, config, missing_items, ) .await?; for generated_asset in generated_assets .into_iter() .filter(|generated| generated.persist_asset) .map(|generated| generated.asset) { upsert_match3d_generated_item_asset(&mut assets, generated_asset); persist_match3d_generated_item_assets_snapshot( state, request_context, authenticated, session_id, owner_user_id, profile_id, &assets, ) .await?; } Ok(assets) } #[derive(Clone)] struct Match3DItemImageGenerationSeed { item_id: String, item_name: String, sound_prompt: String, persist_asset: bool, background_music_title: Option, background_music_style: Option, background_music_prompt: Option, background_asset: Option, } struct Match3DMaterialBatchOutput { task_id: String, generated_at_micros: i64, items: Vec<(Match3DItemImageGenerationSeed, Vec)>, } struct Match3DGeneratedItemImageAssetOutput { asset: Match3DGeneratedItemAsset, persist_asset: bool, } #[allow(clippy::too_many_arguments)] async fn generate_match3d_item_image_assets_in_batches( state: &AppState, request_context: &RequestContext, provider: &str, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, item_seeds: Vec, ) -> Result, Response> { if item_seeds.is_empty() { return Ok(Vec::new()); } require_match3d_oss_client(state) .map_err(|error| match3d_error_response(request_context, provider, error))?; let mut batch_tasks = item_seeds .chunks(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) .map(|chunk| { let chunk_seeds = chunk.to_vec(); async move { let item_names = chunk_seeds .iter() .map(|item| item.item_name.clone()) .collect::>(); let material_sheet = generate_match3d_material_sheet(state, config, &item_names).await?; let generated_at_micros = current_utc_micros(); let persisted_seed_count = chunk_seeds .iter() .position(|seed| !seed.persist_asset) .unwrap_or(chunk_seeds.len()); debug_assert!( chunk_seeds[persisted_seed_count..] .iter() .all(|seed| !seed.persist_asset) ); let persisted_seeds = chunk_seeds .into_iter() .take(persisted_seed_count) .collect::>(); let persisted_item_names = persisted_seeds .iter() .map(|item| item.item_name.clone()) .collect::>(); let item_images = slice_match3d_material_sheet(&material_sheet.image, &persisted_item_names)?; Ok::<_, AppError>(Match3DMaterialBatchOutput { task_id: material_sheet.task_id, generated_at_micros, items: persisted_seeds .into_iter() .zip(item_images.into_iter()) .collect::>(), }) } }) .collect::>(); let mut batches = Vec::new(); while let Some(batch_result) = batch_tasks.next().await { batches.push( batch_result .map_err(|error| match3d_error_response(request_context, provider, error))?, ); } let mut generated_assets = Vec::new(); for batch in batches { let sheet_task_id = batch.task_id; let generated_at_micros = batch.generated_at_micros; for (item_index, (seed, item_images)) in batch.items.into_iter().enumerate() { let item_slug = build_match3d_item_slug(seed.item_id.as_str(), seed.item_name.as_str()); let mut image_views = Vec::with_capacity(item_images.len()); for (view_index, item_image) in item_images.into_iter().enumerate() { let view_number = view_index + 1; let view_upload = persist_match3d_generated_bytes( state, owner_user_id, session_id, profile_id, &["items", item_slug.as_str(), "views"], format!("view-{view_number:02}.png").as_str(), "image/png", item_image.bytes, "match3d_item_image_view", Some(sheet_task_id.as_str()), generated_at_micros.saturating_add( (item_index * MATCH3D_ITEM_VIEW_COUNT + view_index) as i64 + 1, ), ) .await .map_err(|error| match3d_error_response(request_context, provider, error))?; image_views.push(Match3DGeneratedItemImageView { view_id: format!("view-{view_number:02}"), view_index: view_number as u32, image_src: Some(view_upload.src), image_object_key: Some(view_upload.object_key), }); } let primary_view = image_views.first().cloned(); generated_assets.push(Match3DGeneratedItemImageAssetOutput { persist_asset: seed.persist_asset, asset: Match3DGeneratedItemAsset { item_id: seed.item_id, item_name: seed.item_name, image_src: primary_view .as_ref() .and_then(|view| view.image_src.clone()), image_object_key: primary_view .as_ref() .and_then(|view| view.image_object_key.clone()), image_views, model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: Some(seed.sound_prompt), background_music_title: seed.background_music_title, background_music_style: seed.background_music_style, background_music_prompt: seed.background_music_prompt, background_music: None, click_sound: None, background_asset: seed.background_asset, status: "image_ready".to_string(), error: None, }, }); } } generated_assets.sort_by(|left, right| { match3d_item_sort_index(left.asset.item_id.as_str()) .cmp(&match3d_item_sort_index(right.asset.item_id.as_str())) .then_with(|| left.asset.item_id.cmp(&right.asset.item_id)) }); Ok(generated_assets) } #[allow(clippy::too_many_arguments)] async fn append_match3d_item_assets( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, generation_plan: Match3DItemAssetsGenerationPlan, existing_assets: Vec, ) -> Result, Response> { match generation_plan { Match3DItemAssetsGenerationPlan::Append(append_plan) => { append_match3d_new_item_assets( state, request_context, authenticated, owner_user_id, session_id, profile_id, config, append_plan, existing_assets, ) .await } Match3DItemAssetsGenerationPlan::Replace(replace_plan) => { replace_match3d_item_assets( state, request_context, authenticated, owner_user_id, session_id, profile_id, config, replace_plan, existing_assets, ) .await } } } #[allow(clippy::too_many_arguments)] async fn ensure_match3d_click_sound_assets( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, assets: Vec, ) -> Result, Response> { if !config.generate_click_sound { return Ok(assets); } let mut assets = normalize_match3d_generated_item_assets_for_resume(assets); let seeds = assets .iter() .filter(|asset| is_match3d_generated_asset_image_ready(asset)) .filter(|asset| asset.click_sound.is_none()) .cloned() .collect::>(); if seeds.is_empty() { return Ok(assets); } let mut sound_tasks = seeds .into_iter() .map(|asset| async move { let prompt = asset .sound_prompt .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| { build_fallback_match3d_item_sound_prompt(config, asset.item_name.as_str()) }); let result = generate_match3d_click_sound_asset( state, owner_user_id, profile_id, asset.item_id.as_str(), asset.item_name.as_str(), prompt.as_str(), ) .await; (asset, prompt, result) }) .collect::>(); while let Some((mut asset, prompt, result)) = sound_tasks.next().await { match result { Ok(click_sound) => { asset.sound_prompt = Some(prompt); asset.click_sound = Some(click_sound); asset.error = None; } Err(error) => { tracing::warn!( provider = MATCH3D_AGENT_PROVIDER, session_id, profile_id, item_id = asset.item_id.as_str(), error = %error, "抓大鹅入口内联点击音效生成失败,保留草稿并允许结果页重试" ); } } upsert_match3d_generated_item_asset(&mut assets, asset); persist_match3d_generated_item_assets_snapshot( state, request_context, authenticated, session_id, owner_user_id, profile_id, &assets, ) .await?; } Ok(assets) } #[allow(clippy::too_many_arguments)] async fn ensure_match3d_background_music_asset( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, owner_user_id: &str, session_id: &str, profile_id: &str, plan: &Match3DGeneratedBackgroundMusicPlan, assets: Vec, ) -> Result, Response> { let mut assets = normalize_match3d_generated_item_assets_for_resume(assets); if assets.iter().any(has_match3d_background_music_audio) { return Ok(assets); } let Some(first_index) = assets .iter() .enumerate() .min_by_key(|(_, asset)| match3d_item_sort_index(asset.item_id.as_str())) .map(|(index, _)| index) else { return Err(match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, match3d_background_music_missing_error("抓大鹅草稿缺少可写入背景音乐的物品素材"), )); }; let title = require_match3d_background_music_title(plan) .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; let style = normalize_match3d_audio_style(plan.style.as_str()); match generate_match3d_background_music_asset(state, owner_user_id, profile_id, &title, &style) .await { Ok(music) => { let asset = &mut assets[first_index]; asset.background_music_title = Some(title); asset.background_music_style = (!style.trim().is_empty()).then_some(style); asset.background_music_prompt = Some(String::new()); asset.background_music = Some(music); asset.error = None; persist_match3d_generated_item_assets_snapshot( state, request_context, authenticated, session_id, owner_user_id, profile_id, &assets, ) .await?; } Err(error) => { tracing::error!( provider = MATCH3D_AGENT_PROVIDER, session_id, profile_id, error = %error, "抓大鹅草稿背景音乐生成失败,终止本次草稿生成并等待重试" ); return Err(match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, error, )); } } Ok(assets) } fn has_match3d_background_music_audio(asset: &Match3DGeneratedItemAsset) -> bool { asset .background_music .as_ref() .is_some_and(|music| !music.audio_src.trim().is_empty()) } async fn generate_match3d_click_sound_asset( state: &AppState, owner_user_id: &str, profile_id: &str, item_id: &str, item_name: &str, prompt: &str, ) -> Result { let mut asset = generate_sound_effect_asset_for_creation( state, owner_user_id, prompt.to_string(), Some(3), None, GeneratedCreationAudioTarget { entity_kind: "match3d_item".to_string(), entity_id: item_id.to_string(), slot: "click_sound".to_string(), asset_kind: MATCH3D_CLICK_SOUND_ASSET_KIND.to_string(), profile_id: Some(profile_id.to_string()), storage_prefix: LegacyAssetPrefix::Match3DAssets, }, ) .await?; asset.title = Some(format!("{item_name}点击音效")); Ok(asset) } async fn generate_match3d_background_music_asset( state: &AppState, owner_user_id: &str, profile_id: &str, title: &str, style: &str, ) -> Result { generate_background_music_asset_for_creation( state, owner_user_id, String::new(), title.to_string(), (!style.trim().is_empty()).then_some(style.to_string()), None, GeneratedCreationAudioTarget { entity_kind: "match3d_work".to_string(), entity_id: profile_id.to_string(), slot: "background_music".to_string(), asset_kind: MATCH3D_BACKGROUND_MUSIC_ASSET_KIND.to_string(), profile_id: Some(profile_id.to_string()), storage_prefix: LegacyAssetPrefix::Match3DAssets, }, ) .await } #[allow(clippy::too_many_arguments)] async fn append_match3d_new_item_assets( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, append_plan: Match3DItemAssetAppendPlan, existing_assets: Vec, ) -> Result, Response> { let mut assets = sort_match3d_generated_assets(existing_assets); let existing_item_count = assets.len(); let requested_item_count = append_plan.requested_item_names.len(); if requested_item_count == 0 { return Ok(assets); } let mut next_item_index = next_match3d_generated_item_index(&assets); let item_seeds = append_plan .padded_item_names .into_iter() .enumerate() .map(|(index, item_name)| { let item_id = allocate_match3d_generated_item_id(&assets, &mut next_item_index); Match3DItemImageGenerationSeed { item_id, sound_prompt: build_fallback_match3d_item_sound_prompt(config, item_name.as_str()), item_name, persist_asset: index < requested_item_count, background_music_title: None, background_music_style: None, background_music_prompt: None, background_asset: None, } }) .collect::>(); let generated_assets = generate_match3d_item_image_assets_in_batches( state, request_context, MATCH3D_WORKS_PROVIDER, owner_user_id, session_id, profile_id, config, item_seeds, ) .await?; for generated_asset in generated_assets .into_iter() .filter(|generated| generated.persist_asset) .map(|generated| generated.asset) { upsert_match3d_generated_item_asset(&mut assets, generated_asset); persist_match3d_generated_item_assets_snapshot( state, request_context, authenticated, session_id, owner_user_id, profile_id, &assets, ) .await?; } ensure_match3d_click_sound_assets( state, request_context, authenticated, owner_user_id, session_id, profile_id, config, assets, ) .await .map(|assets| { sort_match3d_generated_assets(assets) .into_iter() .take(existing_item_count + requested_item_count) .collect() }) } #[allow(clippy::too_many_arguments)] async fn replace_match3d_item_assets( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, replace_plan: Match3DItemAssetReplacePlan, existing_assets: Vec, ) -> Result, Response> { let mut assets = sort_match3d_generated_assets(existing_assets); if replace_plan.target_assets.is_empty() { return Ok(assets); } let target_by_name = replace_plan .target_assets .iter() .map(|asset| (asset.item_name.trim().to_string(), asset.clone())) .collect::>(); let mut next_item_index = next_match3d_generated_item_index(&assets); let requested_item_count = replace_plan.requested_item_names.len(); let item_seeds = replace_plan .padded_item_names .into_iter() .enumerate() .map(|(index, item_name)| { let matched_asset = target_by_name.get(item_name.trim()).cloned(); let item_id = matched_asset .as_ref() .map(|asset| asset.item_id.clone()) .unwrap_or_else(|| { allocate_match3d_generated_item_id(&assets, &mut next_item_index) }); Match3DItemImageGenerationSeed { item_id, sound_prompt: matched_asset .as_ref() .and_then(|asset| asset.sound_prompt.clone()) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| { build_fallback_match3d_item_sound_prompt(config, item_name.as_str()) }), item_name, persist_asset: index < requested_item_count, background_music_title: matched_asset .as_ref() .and_then(|asset| asset.background_music_title.clone()), background_music_style: matched_asset .as_ref() .and_then(|asset| asset.background_music_style.clone()), background_music_prompt: matched_asset .as_ref() .and_then(|asset| asset.background_music_prompt.clone()), background_asset: matched_asset .as_ref() .and_then(|asset| asset.background_asset.clone()), } }) .collect::>(); let generated_assets = generate_match3d_item_image_assets_in_batches( state, request_context, MATCH3D_WORKS_PROVIDER, owner_user_id, session_id, profile_id, config, item_seeds, ) .await?; for generated_asset in generated_assets .into_iter() .filter(|generated| generated.persist_asset) .map(|generated| generated.asset) { let current_asset = assets .iter() .find(|candidate| candidate.item_id == generated_asset.item_id) .cloned(); upsert_match3d_generated_item_asset( &mut assets, merge_regenerated_match3d_item_asset(current_asset, generated_asset), ); persist_match3d_generated_item_assets_snapshot( state, request_context, authenticated, session_id, owner_user_id, profile_id, &assets, ) .await?; } ensure_match3d_click_sound_assets( state, request_context, authenticated, owner_user_id, session_id, profile_id, config, assets, ) .await .map(sort_match3d_generated_assets) } struct Match3DMaterialSheet { task_id: String, image: DownloadedOpenAiImage, } struct Match3DVectorEngineGeminiImageSettings { base_url: String, api_key: String, request_timeout_ms: u64, } struct Match3DSlicedItemImage { bytes: Vec, } async fn generate_match3d_draft_plan( state: &AppState, config: &Match3DConfigJson, ) -> Match3DGeneratedDraftPlan { let Some(llm_client) = state .creative_agent_gpt5_client() .or_else(|| state.llm_client()) else { return fallback_match3d_draft_plan(config); }; let system_prompt = "你是抓大鹅游戏的草稿生成编辑,只返回 JSON。"; let gameplay_item_count = resolve_match3d_gameplay_item_count(config); let generated_item_count = resolve_match3d_generated_item_count(config); let user_prompt = format!( "题材设定:{}\n请生成抓大鹅游戏草稿生成计划。要求:只返回 JSON 对象,字段为 gameName、summary、tags、backgroundPrompt、items。gameName 为 4 到 12 个中文字符,不要包含“作品”“游戏”;summary 为 18 到 48 个中文字符的作品描述,说明题材氛围和核心体验,不要写规则说明;tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字,后续会用同一作品信息再次生成作品标签;backgroundPrompt 是用于生成局内纯背景图的中文提示词,只描述竖屏移动端抓大鹅题材氛围、色彩和环境,不得描述锅、圆盘、托盘、拼图槽、物品槽、HUD、UI、文字、按钮、倒计时、分数或物品;当前玩法需要 {} 种物品,但素材图固定每 5 个物品一批,因此 items 必须向上补齐并正好返回 {} 项,每项包含 name 和 soundPrompt,name 为 2 到 6 个汉字,soundPrompt 只作为历史字段保留,可返回空字符串。", config.theme_text, gameplay_item_count, generated_item_count ); 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_draft_plan(response.content.as_str(), config) .unwrap_or_else(|| fallback_match3d_draft_plan(config)), Err(error) => { tracing::warn!( provider = MATCH3D_AGENT_PROVIDER, theme_text = config.theme_text.as_str(), error = %error, "抓大鹅草稿生成计划失败,降级使用本地生成计划" ); fallback_match3d_draft_plan(config) } } } fn parse_match3d_draft_plan( raw: &str, config: &Match3DConfigJson, ) -> Option { 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::(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(); let fallback = fallback_match3d_draft_plan(config); let summary = value .get("summary") .or_else(|| value.get("description")) .or_else(|| value.get("workSummary")) .or_else(|| value.get("work_summary")) .and_then(Value::as_str) .map(normalize_match3d_work_summary) .filter(|value| !value.is_empty()) .unwrap_or(fallback.metadata.summary); let items = value .get("items") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(|item| { let name = normalize_match3d_item_name(item.get("name").and_then(Value::as_str)?); if name.is_empty() { return None; } let sound_prompt = item .get("soundPrompt") .or_else(|| item.get("sound_prompt")) .and_then(Value::as_str) .map(normalize_match3d_audio_prompt) .filter(|value| !value.is_empty()) .unwrap_or_else(|| build_fallback_match3d_item_sound_prompt(config, &name)); Some(Match3DGeneratedItemPlan { name, sound_prompt }) }) .collect::>() }) .unwrap_or_default(); let background_music = value .get("backgroundMusic") .or_else(|| value.get("background_music")) .and_then(|music| { let title = music .get("title") .and_then(Value::as_str) .map(normalize_match3d_audio_title) .filter(|value| !value.is_empty())?; let style = music .get("style") .and_then(Value::as_str) .map(normalize_match3d_audio_style) .filter(|value| !value.is_empty())?; let prompt = music .get("prompt") .and_then(Value::as_str) .map(normalize_match3d_audio_prompt) .unwrap_or_default(); Some(Match3DGeneratedBackgroundMusicPlan { title, style, prompt, }) }) .unwrap_or(fallback.background_music); let background_prompt = value .get("backgroundPrompt") .or_else(|| value.get("background_prompt")) .and_then(Value::as_str) .map(normalize_match3d_background_prompt) .filter(|value| !value.is_empty()) .unwrap_or(fallback.background_prompt); Some(Match3DGeneratedDraftPlan { metadata: Match3DGeneratedWorkMetadata { game_name, summary, tags: normalize_match3d_tag_candidates(tags), }, items: normalize_match3d_item_plan(config, items), background_music, background_prompt, }) } #[cfg(test)] fn parse_match3d_work_metadata(raw: &str) -> Option { let config = Match3DConfigJson { theme_text: MATCH3D_DEFAULT_THEME.to_string(), reference_image_src: None, clear_count: MATCH3D_DEFAULT_CLEAR_COUNT, difficulty: MATCH3D_DEFAULT_DIFFICULTY, asset_style_id: None, asset_style_label: None, asset_style_prompt: None, generate_click_sound: false, }; parse_match3d_draft_plan(raw, &config).map(|plan| plan.metadata) } fn normalize_match3d_game_name(raw: &str) -> String { raw.trim() .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) .chars() .filter(|character| !character.is_control()) .take(16) .collect::() .trim() .to_string() } fn normalize_match3d_work_summary(raw: &str) -> String { raw.trim() .trim_matches(['"', '\'', '“', '”']) .split_whitespace() .collect::>() .join("") .chars() .filter(|character| !character.is_control()) .take(80) .collect::() .trim() .to_string() } fn normalize_match3d_audio_title(raw: &str) -> String { raw.trim() .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) .chars() .filter(|character| !character.is_control()) .take(40) .collect::() .trim() .to_string() } fn normalize_match3d_audio_style(raw: &str) -> String { raw.split([',', ',', '、', '\n']) .map(normalize_match3d_tag) .filter(|value| !value.is_empty()) .take(6) .collect::>() .join(", ") } 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}抓大鹅"), summary: normalize_match3d_work_summary( format!("{normalized_theme}主题的轻量抓取消除作品,适合快速体验。").as_str(), ), tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "2D素材"]), } } fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDraftPlan { let metadata = fallback_match3d_work_metadata(config.theme_text.as_str()); let items = fallback_match3d_item_names(config.theme_text.as_str()) .into_iter() .take(resolve_match3d_generated_item_count(config)) .map(|name| Match3DGeneratedItemPlan { sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name), name, }) .collect::>(); Match3DGeneratedDraftPlan { background_prompt: build_fallback_match3d_background_prompt(config), background_music: build_fallback_match3d_background_music_plan(config, &metadata.game_name), metadata, items, } } fn build_fallback_match3d_background_music_plan( _config: &Match3DConfigJson, game_name: &str, ) -> Match3DGeneratedBackgroundMusicPlan { Match3DGeneratedBackgroundMusicPlan { title: normalize_match3d_audio_title(format!("{game_name}音乐").as_str()), style: "轻快, 休闲, 消除, instrumental".to_string(), prompt: String::new(), } } fn normalize_match3d_item_name(raw: &str) -> String { raw.trim() .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) .chars() .filter(|character| !character.is_control()) .take(12) .collect::() .trim() .to_string() } fn fallback_match3d_item_names(theme_text: &str) -> Vec { let theme = theme_text.trim(); let normalized_theme = if theme.is_empty() { "主题" } else { theme }; [ "小物件", "徽章", "摆件", "挂件", "圆球", "方块", "钥匙", "杯子", "糖果", "星星", "宝石", "铃铛", "叶片", "蘑菇", "花朵", "果冻", "小瓶", "帽子", "贝壳", "纽扣", "积木", "印章", "彩蛋", "小鼓", "风车", ] .into_iter() .map(|suffix| format!("{normalized_theme}{suffix}")) .take(MATCH3D_MAX_GENERATED_ITEM_COUNT) .collect() } fn normalize_match3d_item_plan( config: &Match3DConfigJson, items: Vec, ) -> Vec { let target_item_count = resolve_match3d_generated_item_count(config); let mut normalized = Vec::new(); for item in items { let name = normalize_match3d_item_name(item.name.as_str()); if name.is_empty() || normalized .iter() .any(|candidate: &Match3DGeneratedItemPlan| candidate.name == name) { continue; } let sound_prompt = normalize_match3d_audio_prompt(item.sound_prompt.as_str()); normalized.push(Match3DGeneratedItemPlan { sound_prompt: if sound_prompt.is_empty() { build_fallback_match3d_item_sound_prompt(config, &name) } else { sound_prompt }, name, }); if normalized.len() >= target_item_count { break; } } if normalized.len() < target_item_count { for name in fallback_match3d_item_names(config.theme_text.as_str()) { if normalized.iter().any(|candidate| candidate.name == name) { continue; } normalized.push(Match3DGeneratedItemPlan { sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name), name, }); if normalized.len() >= target_item_count { break; } } } if normalized.len() < target_item_count { fill_match3d_item_plan_to_count(config, &mut normalized, target_item_count); } normalized } fn fill_match3d_item_plan_to_count( config: &Match3DConfigJson, normalized: &mut Vec, target_item_count: usize, ) { let normalized_theme = config.theme_text.trim(); let fallback_prefix = if normalized_theme.is_empty() { "补充物品".to_string() } else { format!("{normalized_theme}补充") }; let mut index = 1usize; while normalized.len() < target_item_count { let name = normalize_match3d_item_name(format!("{fallback_prefix}{index}").as_str()); if !name.is_empty() && !normalized .iter() .any(|candidate: &Match3DGeneratedItemPlan| candidate.name == name) { normalized.push(Match3DGeneratedItemPlan { sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name), name, }); } index += 1; } } fn normalize_match3d_batch_item_names(items: Vec) -> Vec { let mut normalized: Vec = Vec::new(); for item in items { let name = normalize_match3d_item_name(item.as_str()); if name.is_empty() || normalized.iter().any(|candidate| candidate == &name) { continue; } normalized.push(name); if normalized.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT { break; } } normalized } fn normalize_match3d_item_assets_generation_mode( mode: Option<&str>, ) -> Match3DItemAssetsGenerationMode { match mode .unwrap_or_default() .trim() .to_ascii_lowercase() .as_str() { "replace" | "regenerate" => Match3DItemAssetsGenerationMode::Replace, _ => Match3DItemAssetsGenerationMode::Append, } } fn build_match3d_item_assets_generation_plan( mode: Match3DItemAssetsGenerationMode, item_names: Vec, existing_assets: &[Match3DGeneratedItemAsset], ) -> Match3DItemAssetsGenerationPlan { match mode { Match3DItemAssetsGenerationMode::Append => Match3DItemAssetsGenerationPlan::Append( build_match3d_item_asset_append_plan(item_names, existing_assets), ), Match3DItemAssetsGenerationMode::Replace => Match3DItemAssetsGenerationPlan::Replace( build_match3d_item_asset_replace_plan(item_names, existing_assets), ), } } fn build_match3d_item_asset_append_plan( item_names: Vec, existing_assets: &[Match3DGeneratedItemAsset], ) -> Match3DItemAssetAppendPlan { let available_capacity = MATCH3D_MAX_GENERATED_ITEM_COUNT.saturating_sub(existing_assets.len()); let mut requested_item_names = item_names .into_iter() .filter(|name| { !existing_assets .iter() .any(|asset| asset.item_name.trim() == name.trim()) }) .take(available_capacity) .collect::>(); requested_item_names.truncate(available_capacity); let padded_item_names = build_match3d_padded_item_names_for_generation( &requested_item_names, existing_assets, available_capacity, ); Match3DItemAssetAppendPlan { requested_item_names, padded_item_names, } } fn build_match3d_padded_item_names_for_generation( item_names: &[String], existing_assets: &[Match3DGeneratedItemAsset], available_capacity: usize, ) -> Vec { let mut padded = item_names .iter() .take(available_capacity) .cloned() .collect::>(); let target_item_count = round_match3d_item_count_to_full_sheet(padded.len()); let mut fallback_index = 1usize; while padded.len() < target_item_count { let candidate = normalize_match3d_item_name(format!("追加物品{fallback_index}").as_str()); fallback_index += 1; if candidate.is_empty() || padded.iter().any(|name| name == &candidate) || existing_assets .iter() .any(|asset| asset.item_name.trim() == candidate.as_str()) { continue; } padded.push(candidate); } padded } fn build_match3d_item_asset_replace_plan( item_names: Vec, existing_assets: &[Match3DGeneratedItemAsset], ) -> Match3DItemAssetReplacePlan { let mut requested_item_names = Vec::new(); let mut target_assets = Vec::new(); for item_name in item_names { let Some(asset) = existing_assets .iter() .find(|asset| asset.item_name.trim() == item_name.trim()) else { continue; }; if target_assets .iter() .any(|candidate: &Match3DGeneratedItemAsset| candidate.item_id == asset.item_id) { continue; } requested_item_names.push(asset.item_name.clone()); target_assets.push(asset.clone()); if requested_item_names.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT { break; } } let padded_item_names = build_match3d_padded_item_names_for_generation( &requested_item_names, existing_assets, MATCH3D_MAX_GENERATED_ITEM_COUNT, ); Match3DItemAssetReplacePlan { requested_item_names, padded_item_names, target_assets, } } fn calculate_match3d_item_assets_points_cost(item_count: usize) -> u64 { if item_count == 0 { return 0; } item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) as u64 * MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH } fn normalize_match3d_cover_prompt(raw: &str) -> String { raw.trim() .chars() .filter(|character| !character.is_control()) .take(900) .collect::() .trim() .to_string() } fn normalize_match3d_audio_prompt(raw: &str) -> String { raw.trim() .chars() .filter(|character| !character.is_control()) .take(500) .collect::() .trim() .to_string() } fn normalize_match3d_background_prompt(raw: &str) -> String { raw.trim() .chars() .filter(|character| !character.is_control()) .take(900) .collect::() .trim() .to_string() } fn build_match3d_prompt_fingerprint(value: &str) -> String { let mut hash = 0u32; for character in value.chars() { hash = hash.wrapping_mul(31).wrapping_add(character as u32); } format!("{hash:08x}") } fn build_fallback_match3d_background_prompt(config: &Match3DConfigJson) -> String { let theme = config.theme_text.trim(); let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme }; normalize_match3d_background_prompt( format!( "{normalized_theme}题材抓大鹅游戏竖屏纯背景图,表现题材环境、绿色纵向渐变和轻快休闲氛围,中央区域保持干净通透,方便运行态叠加默认交互容器。无锅、无圆盘、无托盘、无拼图槽、无物品槽、无文字、无水印、无 UI、无按钮、无倒计时、无物品、无角色、无手。" ) .as_str(), ) } fn build_fallback_match3d_item_sound_prompt(config: &Match3DConfigJson, item_name: &str) -> String { let theme = config.theme_text.trim(); let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme }; normalize_match3d_audio_prompt( format!( "{normalized_theme}题材抓大鹅中“{item_name}”被点击并消除时的短促反馈音效,清脆、可爱、有轻微弹跳感,适合移动端休闲游戏。" ) .as_str(), ) } fn normalize_match3d_generated_item_assets_for_resume( assets: Vec, ) -> Vec { let mut normalized = Vec::new(); for asset in sort_match3d_generated_assets(assets) { if asset.item_id.trim().is_empty() || normalized .iter() .any(|candidate: &Match3DGeneratedItemAsset| candidate.item_id == asset.item_id) { continue; } normalized.push(asset); if normalized.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT { break; } } normalized } fn resolve_match3d_gameplay_item_count(config: &Match3DConfigJson) -> usize { match config.clear_count { 8 => 3, 12 => 9, 16 => 15, 20 | 21 => 21, _ => match config.difficulty { 0..=2 => 3, 3..=4 => 9, 5..=6 => 15, _ => 21, }, } .min(MATCH3D_MAX_GENERATED_ITEM_COUNT) } fn resolve_match3d_generated_item_count(config: &Match3DConfigJson) -> usize { round_match3d_item_count_to_full_sheet(resolve_match3d_gameplay_item_count(config)) .min(MATCH3D_MAX_GENERATED_ITEM_COUNT) } fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize { if item_count == 0 { return 0; } item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) * MATCH3D_MATERIAL_ITEM_BATCH_SIZE } fn sort_match3d_generated_assets( mut assets: Vec, ) -> Vec { assets.sort_by(|left, right| { match3d_item_sort_index(left.item_id.as_str()) .cmp(&match3d_item_sort_index(right.item_id.as_str())) .then_with(|| left.item_id.cmp(&right.item_id)) }); assets } fn match3d_item_sort_index(item_id: &str) -> u32 { item_id .rsplit('-') .next() .and_then(|value| value.parse::().ok()) .unwrap_or(u32::MAX) } fn is_match3d_generated_asset_image_ready(asset: &Match3DGeneratedItemAsset) -> bool { let view_count = asset .image_views .iter() .filter(|view| { view.image_object_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() || view .image_src .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() }) .count(); view_count >= MATCH3D_ITEM_VIEW_COUNT } fn has_match3d_required_item_images( assets: &[Match3DGeneratedItemAsset], required_item_count: usize, ) -> bool { assets.len() >= required_item_count && assets .iter() .take(required_item_count) .all(is_match3d_generated_asset_image_ready) } fn upsert_match3d_generated_item_asset( assets: &mut Vec, asset: Match3DGeneratedItemAsset, ) { if let Some(current) = assets .iter_mut() .find(|candidate| candidate.item_id == asset.item_id) { *current = asset; *assets = sort_match3d_generated_assets(std::mem::take(assets)); return; } assets.push(asset); *assets = sort_match3d_generated_assets(std::mem::take(assets)); } fn merge_regenerated_match3d_item_asset( current_asset: Option, generated_asset: Match3DGeneratedItemAsset, ) -> Match3DGeneratedItemAsset { let Some(current_asset) = current_asset else { return generated_asset; }; Match3DGeneratedItemAsset { item_id: current_asset.item_id, item_name: current_asset.item_name, image_src: generated_asset.image_src, image_object_key: generated_asset.image_object_key, image_views: generated_asset.image_views, model_src: current_asset.model_src, model_object_key: current_asset.model_object_key, model_file_name: current_asset.model_file_name, task_uuid: generated_asset.task_uuid.or(current_asset.task_uuid), subscription_key: generated_asset .subscription_key .or(current_asset.subscription_key), sound_prompt: generated_asset.sound_prompt.or(current_asset.sound_prompt), background_music_title: current_asset.background_music_title, background_music_style: current_asset.background_music_style, background_music_prompt: current_asset.background_music_prompt, background_music: current_asset.background_music, click_sound: current_asset.click_sound, background_asset: current_asset.background_asset, status: generated_asset.status, error: generated_asset.error, } } fn next_match3d_generated_item_index(assets: &[Match3DGeneratedItemAsset]) -> u32 { assets .iter() .filter_map(|asset| { let value = match3d_item_sort_index(asset.item_id.as_str()); if value == u32::MAX { None } else { Some(value) } }) .max() .unwrap_or(0) .saturating_add(1) } fn allocate_match3d_generated_item_id( assets: &[Match3DGeneratedItemAsset], next_item_index: &mut u32, ) -> String { loop { let candidate = format!("match3d-item-{}", *next_item_index); *next_item_index = next_item_index.saturating_add(1); if !assets.iter().any(|asset| asset.item_id == candidate) { return candidate; } } } fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgroundAsset) -> bool { asset.status == "image_ready" && (asset .image_object_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() || asset .image_src .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some()) && (asset .container_image_object_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() || asset .container_image_src .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some()) } #[allow(clippy::too_many_arguments)] 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, ) -> Result, 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_background_image( state, 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) } fn attach_match3d_background_asset_to_assets( assets: &mut Vec, background_asset: Match3DGeneratedBackgroundAsset, ) { if let Some(first_asset) = assets .iter_mut() .min_by_key(|asset| match3d_item_sort_index(asset.item_id.as_str())) { first_asset.background_asset = Some(background_asset); } } fn build_match3d_item_slug(item_id: &str, item_name: &str) -> String { format!( "{}-{}", sanitize_match3d_asset_segment(item_id, "match3d-item"), sanitize_match3d_asset_segment(item_name, "item") ) } async fn generate_match3d_cover_image_asset( state: &AppState, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, prompt: &str, uploaded_image_src: Option, reference_image_srcs: Vec, ) -> Result { require_match3d_oss_client(state)?; let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; let cover_prompt = build_match3d_cover_generation_prompt(config, prompt); let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit( state, uploaded_image_src.as_deref(), MATCH3D_ITEM_IMAGE_MAX_BYTES, "match3d-cover-upload", ) .await? { create_openai_image_edit( &http_client, &settings, build_match3d_cover_edit_prompt(cover_prompt.as_str()).as_str(), Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"), "1:1", &uploaded_image, "抓大鹅封面图重绘失败", ) .await? } else { let reference_images = resolve_match3d_cover_reference_image_data_urls( state, reference_image_srcs, MATCH3D_ITEM_IMAGE_MAX_BYTES, ) .await?; create_openai_image_generation( &http_client, &settings, build_match3d_cover_reference_generation_prompt( cover_prompt.as_str(), !reference_images.is_empty(), ) .as_str(), Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"), "1:1", 1, reference_images.as_slice(), "抓大鹅封面图生成失败", ) .await? }; let image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "抓大鹅封面图生成失败:未返回图片", })) })?; let file_name = format!("cover.{}", image.extension); persist_match3d_generated_bytes( state, owner_user_id, session_id, profile_id, &["cover", generated.task_id.as_str()], file_name.as_str(), image.mime_type.as_str(), image.bytes, "match3d_cover_image", Some(generated.task_id.as_str()), current_utc_micros(), ) .await } fn build_match3d_cover_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String { let style_clause = resolve_match3d_asset_style_prompt(config) .map(|style| format!("整体美术风格遵循:{style}。")) .unwrap_or_default(); format!( "{theme}题材抓大鹅作品封面图。{style_clause}{prompt}。画面为1:1封面,主体清晰、色彩明亮、适合移动端作品卡片展示;可以包含生成物品或主题元素,但不要出现任何文字、按钮、倒计时、分数或 UI。", theme = config.theme_text, style_clause = style_clause, prompt = prompt, ) } fn build_match3d_cover_edit_prompt(prompt: &str) -> String { format!( concat!( "请以随请求上传的封面图作为第一优先级重绘依据,保留主图的主体、构图、视角和主要配色;", "允许按文字要求提升美术质量、统一风格和补充细节,但不要改成与主图无关的新画面。\n", "{prompt}" ), prompt = prompt.trim() ) } fn build_match3d_cover_reference_generation_prompt( prompt: &str, has_reference_images: bool, ) -> String { if !has_reference_images { return prompt.trim().to_string(); } format!( concat!( "请参考随请求提供的一张或多张图片作为题材、物体和美术风格参考,融合为一张新的抓大鹅作品封面;", "参考图只用于主体元素、材质、配色和风格启发,不要拼贴成素材墙或多图排版。\n", "{prompt}" ), prompt = prompt.trim() ) } async fn generate_match3d_background_image( state: &AppState, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, prompt: &str, ) -> Result { require_match3d_oss_client(state)?; let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; let reference_image = load_match3d_container_reference_image().await?; let generated_background = create_openai_image_generation( &http_client, &settings, build_match3d_background_generation_prompt(config, prompt).as_str(), Some("文字、水印、UI、按钮、倒计时、分数、物品、角色、手、边框、教程浮层、菜单"), "9:16", 1, &[], "抓大鹅背景图生成失败", ) .await?; let background_image = generated_background .images .into_iter() .next() .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "抓大鹅背景图生成失败:未返回图片", })) })?; let background_upload = persist_match3d_generated_bytes( state, owner_user_id, session_id, profile_id, &["background", generated_background.task_id.as_str()], "background.png", background_image.mime_type.as_str(), background_image.bytes, "match3d_background_image", Some(generated_background.task_id.as_str()), current_utc_micros(), ) .await?; let container_prompt = build_match3d_container_generation_prompt(config, prompt); let generated_container = create_openai_image_edit( &http_client, &settings, container_prompt.as_str(), Some("文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层、菜单、整页背景、小容器、正俯视圆盘、侧视碗、餐盘、托盘、画布大留白"), "1:1", &reference_image, "抓大鹅容器 UI 图生成失败", ) .await?; let container_image = generated_container .images .into_iter() .next() .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "抓大鹅容器 UI 图生成失败:未返回图片", })) })?; let container_image = make_match3d_container_image_transparent(container_image)?; let container_upload = persist_match3d_generated_bytes( state, owner_user_id, session_id, profile_id, &["ui-container", generated_container.task_id.as_str()], "container.png", container_image.mime_type.as_str(), container_image.bytes, "match3d_ui_container_image", Some(generated_container.task_id.as_str()), current_utc_micros(), ) .await?; Ok(Match3DGeneratedBackgroundAsset { prompt: prompt.to_string(), image_src: Some(background_upload.src), image_object_key: Some(background_upload.object_key), container_prompt: Some(container_prompt), container_image_src: Some(container_upload.src), container_image_object_key: Some(container_upload.object_key), status: "image_ready".to_string(), error: None, }) } async fn generate_match3d_container_image( state: &AppState, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, prompt: &str, ) -> Result { require_match3d_oss_client(state)?; let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; let reference_image = load_match3d_container_reference_image().await?; let container_prompt = build_match3d_container_generation_prompt(config, prompt); let generated_container = create_openai_image_edit( &http_client, &settings, container_prompt.as_str(), Some("文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层、菜单、整页背景、小容器、正俯视圆盘、侧视碗、餐盘、托盘、画布大留白"), "1:1", &reference_image, "抓大鹅容器 UI 图生成失败", ) .await?; let container_image = generated_container .images .into_iter() .next() .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "抓大鹅容器 UI 图生成失败:未返回图片", })) })?; let container_image = make_match3d_container_image_transparent(container_image)?; let container_upload = persist_match3d_generated_bytes( state, owner_user_id, session_id, profile_id, &["ui-container", generated_container.task_id.as_str()], "container.png", container_image.mime_type.as_str(), container_image.bytes, "match3d_ui_container_image", Some(generated_container.task_id.as_str()), current_utc_micros(), ) .await?; Ok(Match3DGeneratedBackgroundAsset { prompt: prompt.to_string(), image_src: None, image_object_key: None, container_prompt: Some(container_prompt), container_image_src: Some(container_upload.src), container_image_object_key: Some(container_upload.object_key), status: "image_ready".to_string(), error: None, }) } fn merge_match3d_container_image_into_background_asset( assets: &[Match3DGeneratedItemAsset], container_asset: Match3DGeneratedBackgroundAsset, ) -> Match3DGeneratedBackgroundAsset { let existing_background = find_match3d_generated_background_asset(assets); let prompt = existing_background .as_ref() .map(|asset| asset.prompt.trim()) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| container_asset.prompt.clone()); Match3DGeneratedBackgroundAsset { prompt, image_src: existing_background .as_ref() .and_then(|asset| asset.image_src.clone()), image_object_key: existing_background .as_ref() .and_then(|asset| asset.image_object_key.clone()), container_prompt: container_asset.container_prompt, container_image_src: container_asset.container_image_src, container_image_object_key: container_asset.container_image_object_key, status: "image_ready".to_string(), error: container_asset.error, } } async fn load_match3d_container_reference_image() -> Result { let bytes = tokio::fs::read(MATCH3D_CONTAINER_REFERENCE_IMAGE_PATH) .await .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": MATCH3D_AGENT_PROVIDER, "message": format!("读取抓大鹅容器参考图失败:{error}"), })) })?; if bytes.is_empty() { return Err( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": MATCH3D_AGENT_PROVIDER, "message": "抓大鹅容器参考图为空", })), ); } Ok(OpenAiReferenceImage { bytes, mime_type: "image/png".to_string(), file_name: "match3d-container-reference.png".to_string(), }) } fn build_match3d_background_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String { let style_clause = resolve_match3d_asset_style_prompt(config) .map(|style| format!("整体美术风格参考:{style}。")) .unwrap_or_default(); format!( "{prompt}\n{style_clause}生成一张 9:16 竖屏抓大鹅游戏纯背景图,只表现题材氛围、色彩层次和场景环境。画面不得出现锅、圆盘、托盘、拼图槽、物品槽、棋盘、容器边框、HUD、文字、按钮、倒计时、分数、物品、角色或手。中央区域保持干净通透,方便运行态后续叠加默认交互容器和物品素材。" ) } fn build_match3d_container_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String { let style_clause = resolve_match3d_asset_style_prompt(config) .map(|style| format!("整体美术风格参考:{style}。")) .unwrap_or_default(); format!( "{prompt}\n{style_clause}生成一张 1:1 抓大鹅中心容器 UI 图,只绘制一个贴合题材设定的圆形或浅盘状竞技容器。严格参考输入参考图的容器范围和视图角度:容器外轮廓必须接近画布四边,占画布宽度约 86%-92%、高度约 82%-90%,中心在画布中心略偏下,只保留少量透明留白;视角为轻俯视 3/4 上方视角,能看到圆形碗体外壁、厚实前沿和横向椭圆形内口,不能画成正俯视扁圆盘、侧视碗、小托盘或居中的小容器。容器需要有清晰外沿、内侧可放置 2D 物品的干净空间、轻微阴影和高辨识边界;背景必须是透明 alpha,不得出现白底、纯色底、渐变底、场景底或整页背景。禁止文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层和菜单。" ) } fn make_match3d_container_image_transparent( image: DownloadedOpenAiImage, ) -> Result { let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "match3d-assets", "message": format!("抓大鹅容器图解码失败:{error}"), })) })?; let mut rgba = source.to_rgba8(); let (width, height) = rgba.dimensions(); remove_match3d_container_plain_background(rgba.as_mut(), width as usize, height as usize); let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(rgba) .write_to(&mut encoded, ImageFormat::Png) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "match3d-assets", "message": format!("抓大鹅容器图透明化失败:{error}"), })) })?; Ok(DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }) } 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, 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), })), ) } 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, }, }, }) } 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 } 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(), } } 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", } } async fn download_match3d_legacy_model( file: &hyper3d_contract::Hyper3dDownloadFilePayload, ) -> Result { let http_client = reqwest::Client::builder() .timeout(Duration::from_millis( MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS, )) .build() .map_err(|error| match3d_bad_gateway(format!("构造历史模型下载客户端失败:{error}")))?; tracing::info!( provider = MATCH3D_AGENT_PROVIDER, file_name = file.name.as_str(), "抓大鹅历史 GLB 下载开始" ); let response = http_client .get(file.url.as_str()) .send() .await .map_err(|error| match3d_bad_gateway(format!("下载历史模型失败:{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!("读取历史模型内容失败:{error}")))?; if !status.is_success() { return Err(match3d_bad_gateway(format!( "下载历史模型失败:HTTP {}", status.as_u16() ))); } if !is_match3d_downloaded_model_payload(file.name.as_str(), content_type.as_str()) { return Err(match3d_bad_gateway("历史模型下载结果不是 GLB 模型文件")); } if bytes.is_empty() || bytes.len() > MATCH3D_LEGACY_MODEL_MAX_BYTES { return Err(match3d_bad_gateway("历史模型内容为空或超过大小上限")); } if !is_match3d_glb_binary_payload(&bytes) { return Err(match3d_bad_gateway("历史模型下载结果不是有效 GLB 模型文件")); } 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 is_match3d_downloaded_model_payload(file_name: &str, content_type: &str) -> bool { let normalized_file_name = file_name.to_ascii_lowercase(); let normalized_content_type = content_type .split(';') .next() .unwrap_or(content_type) .trim() .to_ascii_lowercase(); normalized_file_name.ends_with(".glb") || matches!( normalized_content_type.as_str(), "model/gltf-binary" | "application/octet-stream" ) } 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 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" { return normalized; } "model/gltf-binary".to_string() } fn is_match3d_glb_binary_payload(bytes: &[u8]) -> bool { if bytes.len() < 12 { return false; } let magic = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); let version = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); let declared_length = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize; magic == 0x4654_6c67 && version == 2 && declared_length == bytes.len() } async fn read_match3d_generated_object_bytes( state: &AppState, object_key: &str, message_prefix: &str, max_size_bytes: usize, ) -> Result, AppError> { let object_key = object_key.trim().trim_start_matches('/'); if object_key.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "match3d-assets", "message": format!("{message_prefix}:objectKey 不能为空"), })), ); } let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; let signed = oss_client .sign_get_object_url(platform_oss::OssSignedGetObjectUrlRequest { object_key: object_key.to_string(), expire_seconds: Some(300), }) .map_err(|error| map_oss_error(error, "aliyun-oss"))?; let response = reqwest::Client::new() .get(signed.signed_url.as_str()) .send() .await .map_err(|error| match3d_bad_gateway(format!("{message_prefix}:{error}")))?; let status = response.status(); if !status.is_success() { return Err(match3d_bad_gateway(format!( "{message_prefix}:HTTP {}", status.as_u16() ))); } let bytes = response .bytes() .await .map_err(|error| match3d_bad_gateway(format!("{message_prefix}:{error}")))?; if bytes.is_empty() || bytes.len() > max_size_bytes { return Err(match3d_bad_gateway(format!( "{message_prefix}:内容为空或超过大小上限" ))); } Ok(bytes.to_vec()) } async fn resolve_match3d_reference_image_data_url( state: &AppState, source: Option<&str>, max_size_bytes: usize, ) -> Result, AppError> { let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); }; if source.starts_with("data:image/") { return Ok(Some(source.to_string())); } if let Some(public_path) = normalize_match3d_public_reference_image_path(source) { let bytes = tokio::fs::read(public_path.as_str()) .await .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "message": format!("读取抓大鹅本地参考图失败:{error}"), "path": public_path, })) })?; if bytes.is_empty() || bytes.len() > max_size_bytes { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "field": "referenceImageSrcs", "message": "封面参考图过大,请压缩后重试。", "maxBytes": max_size_bytes, "actualBytes": bytes.len(), })), ); } return Ok(Some(format!( "data:{};base64,{}", infer_match3d_image_mime_type(bytes.as_slice()), BASE64_STANDARD.encode(bytes) ))); } if !source.trim_start_matches('/').starts_with("generated-") { return Ok(Some(source.to_string())); } let bytes = read_match3d_generated_object_bytes(state, source, "读取抓大鹅参考图失败", max_size_bytes) .await?; Ok(Some(format!( "data:{};base64,{}", infer_match3d_image_mime_type(bytes.as_slice()), BASE64_STANDARD.encode(bytes) ))) } fn normalize_match3d_public_reference_image_path(source: &str) -> Option { let source = source .trim() .split('?') .next() .unwrap_or_default() .trim() .trim_start_matches('/'); if !source.starts_with("match3d-background-references/") { return None; } if source.contains("..") || source.contains('\\') { return None; } let lower = source.to_ascii_lowercase(); if !matches!( lower.rsplit('.').next(), Some("png" | "jpg" | "jpeg" | "webp") ) { return None; } Some(format!("public/{source}")) } fn collect_match3d_cover_reference_image_sources( legacy_reference_image_src: Option, reference_image_srcs: Vec, ) -> Vec { let mut sources = Vec::new(); for source in legacy_reference_image_src .into_iter() .chain(reference_image_srcs) { let normalized = source.trim(); if normalized.is_empty() { continue; } if !sources .iter() .any(|existing: &String| existing == normalized) { sources.push(normalized.to_string()); } if sources.len() >= 6 { break; } } sources } async fn resolve_match3d_cover_reference_image_data_urls( state: &AppState, sources: Vec, max_size_bytes: usize, ) -> Result, AppError> { let mut resolved = Vec::new(); for source in sources { if let Some(data_url) = resolve_match3d_reference_image_data_url(state, Some(source.as_str()), max_size_bytes) .await? { resolved.push(data_url); } } Ok(resolved) } async fn resolve_match3d_reference_image_for_edit( state: &AppState, source: Option<&str>, max_size_bytes: usize, file_name_prefix: &str, ) -> Result, AppError> { let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); }; let bytes = if source.starts_with("data:image/") { decode_match3d_data_url_bytes(source)? } else if source.trim_start_matches('/').starts_with("generated-") { read_match3d_generated_object_bytes( state, source, "读取抓大鹅封面上传图失败", max_size_bytes, ) .await? } else { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "field": "uploadedImageSrc", "message": "封面上传图必须是图片 Data URL 或 /generated-* 路径。", })), ); }; if bytes.is_empty() || bytes.len() > max_size_bytes { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "field": "uploadedImageSrc", "message": "封面上传图过大,请压缩后重试。", "maxBytes": max_size_bytes, "actualBytes": bytes.len(), })), ); } let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string(); Ok(Some(OpenAiReferenceImage { file_name: format!( "{}.{}", file_name_prefix, match3d_mime_to_extension(mime_type.as_str()) ), mime_type, bytes, })) } fn decode_match3d_data_url_bytes(source: &str) -> Result, AppError> { let Some((header, data)) = source.split_once(',') else { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "field": "uploadedImageSrc", "message": "图片 Data URL 格式不正确。", })), ); }; if !header.starts_with("data:image/") || !header.contains(";base64") { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "field": "uploadedImageSrc", "message": "图片 Data URL 必须是 base64 图片。", })), ); } BASE64_STANDARD.decode(data.trim()).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "field": "uploadedImageSrc", "message": format!("图片 Data URL 解码失败:{error}"), })) }) } fn infer_match3d_image_mime_type(bytes: &[u8]) -> &'static str { if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { return "image/png"; } if bytes.starts_with(&[0xff, 0xd8, 0xff]) { return "image/jpeg"; } if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { return "image/webp"; } "image/png" } fn build_match3d_material_sheet_prompt( config: &Match3DConfigJson, item_names: &[String], ) -> String { let asset_style_prompt = resolve_match3d_asset_style_prompt(config); let style_clause = asset_style_prompt .as_ref() .map(|prompt| format!("整体画风遵循:{prompt}。")) .unwrap_or_default(); let item_rows = item_names .iter() .enumerate() .map(|(index, name)| format!("第{}行:{name} 的 5 个不同视角", index + 1)) .collect::>() .join(";"); format!( "生成一张1024x1024的1:1图片。固定生成5行*5列网格素材图,画面是{theme}题材的抓大鹅游戏2D物品素材。{style_clause}严格5*5均匀排布,严格按行组织:{item_rows}。同一行五格必须是同一物品的五个不同视角,依次为正面、左前、右前、俯视、背面;每个格子一个独立居中的完整物体,每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。物体本身不得使用与绿幕相同的纯绿色;若物品天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。统一柔和光照,清晰轮廓,适合直接切割成游戏2D图标。请让每个物体完整落在自己的格子中央,四周保留留白,相邻物体主体之间必须至少保留单个素材格宽度的1/4空白间距(约25%单格宽度),包含左右相邻格和上下相邻行,物体主体不得占满格子。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子影响裁剪后的效果。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。", theme = config.theme_text, style_clause = style_clause, item_rows = item_rows, ) } fn build_match3d_material_sheet_negative_prompt(config: &Match3DConfigJson) -> String { let base = "文字、水印、UI、边框、网格线、标签、人物手部、复杂背景、非绿幕背景、白色背景、灰色背景、渐变背景、纹理背景"; if !is_match3d_pixel_retro_style(config) { return base.to_string(); } format!( "{base}、抗锯齿、平滑插画、柔焦、软边渐变、矢量扁平插画、真实 3D 渲染、PBR 材质、摄影棚光照" ) } fn resolve_match3d_asset_style_prompt(config: &Match3DConfigJson) -> Option { let prompt = config .asset_style_prompt .as_deref() .or(config.asset_style_label.as_deref()) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); if !is_match3d_pixel_retro_style(config) { return prompt; } Some(match prompt { Some(prompt) if prompt.contains("禁止抗锯齿") && prompt.contains("64x64") => prompt, Some(prompt) => format!("{prompt};{MATCH3D_PIXEL_RETRO_STYLE_PROMPT}"), None => MATCH3D_PIXEL_RETRO_STYLE_PROMPT.to_string(), }) } fn is_match3d_pixel_retro_style(config: &Match3DConfigJson) -> bool { config .asset_style_id .as_deref() .map(str::trim) .is_some_and(|value| value.eq_ignore_ascii_case("pixel-retro")) || config .asset_style_label .as_deref() .map(str::trim) .is_some_and(|value| value.contains("像素复古")) } fn slice_match3d_material_sheet( image: &DownloadedOpenAiImage, item_names: &[String], ) -> Result>, AppError> { // 中文注释:素材图提示词固定要求 5*5 均匀排布;切图也固定按 5 行 5 列定位格子。 // 每个格子内再基于前景像素二次校准,避免固定内缩裁断物品边缘。 let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "match3d-assets", "message": format!("抓大鹅素材图解码失败:{error}"), })) })?; // 中文注释:素材图按绿幕背景生成;先把整张 sheet 的绿幕转成 alpha,再进入格子裁切。 let source = apply_match3d_material_green_screen_alpha(source); let (width, height) = source.dimensions(); let row_count = MATCH3D_MATERIAL_GRID_SIZE; let cell_width = width / MATCH3D_MATERIAL_GRID_SIZE; let cell_height = height / row_count; if cell_width == 0 || cell_height == 0 { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "match3d-assets", "message": "抓大鹅素材图尺寸过小,无法切割", })), ); } let mut slices = Vec::with_capacity(item_names.len()); for item_index in 0..item_names.len().min(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) { let row = item_index as u32; let mut views = Vec::with_capacity(MATCH3D_ITEM_VIEW_COUNT); for view_index in 0..MATCH3D_ITEM_VIEW_COUNT { let col = view_index as u32; let (crop_x, crop_y, crop_width, crop_height) = resolve_match3d_material_cell_crop(&source, row_count, row, col); let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height); let mut cursor = std::io::Cursor::new(Vec::new()); cropped .write_to(&mut cursor, ImageFormat::Png) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "match3d-assets", "message": format!("抓大鹅素材图切割失败:{error}"), })) })?; views.push(Match3DSlicedItemImage { bytes: cursor.into_inner(), }); } slices.push(views); } Ok(slices) } fn resolve_match3d_material_cell_crop( source: &image::DynamicImage, row_count: u32, row: u32, col: u32, ) -> (u32, u32, u32, u32) { let (image_width, image_height) = source.dimensions(); let cell = resolve_match3d_material_cell_bounds(image_width, image_height, row_count, row, col); let Some(foreground) = detect_match3d_material_foreground_bounds(source, cell) else { return cell.to_crop_tuple(); }; let cell_width = cell.width(); let cell_height = cell.height(); let pad_x = (cell_width / 16).clamp(4, 16); let pad_y = (cell_height / 16).clamp(4, 16); let crop = Match3DMaterialCellBounds { x0: foreground.x0.saturating_sub(pad_x).max(cell.x0), y0: foreground.y0.saturating_sub(pad_y).max(cell.y0), x1: foreground.x1.saturating_add(pad_x).min(cell.x1), y1: foreground.y1.saturating_add(pad_y).min(cell.y1), }; crop.to_crop_tuple() } #[derive(Clone, Copy, Debug)] struct Match3DMaterialCellBounds { x0: u32, y0: u32, x1: u32, y1: u32, } impl Match3DMaterialCellBounds { fn width(self) -> u32 { self.x1.saturating_sub(self.x0).max(1) } fn height(self) -> u32 { self.y1.saturating_sub(self.y0).max(1) } fn area(self) -> u32 { self.width().saturating_mul(self.height()) } fn to_crop_tuple(self) -> (u32, u32, u32, u32) { (self.x0, self.y0, self.width(), self.height()) } } fn resolve_match3d_material_cell_bounds( image_width: u32, image_height: u32, row_count: u32, row: u32, col: u32, ) -> Match3DMaterialCellBounds { let normalized_rows = row_count.clamp(1, MATCH3D_MATERIAL_GRID_SIZE); let cell_x0 = col.saturating_mul(image_width) / MATCH3D_MATERIAL_GRID_SIZE; let cell_x1 = (col.saturating_add(1)).saturating_mul(image_width) / MATCH3D_MATERIAL_GRID_SIZE; let cell_y0 = row.saturating_mul(image_height) / normalized_rows; let cell_y1 = (row.saturating_add(1)).saturating_mul(image_height) / normalized_rows; Match3DMaterialCellBounds { x0: cell_x0.min(image_width.saturating_sub(1)), y0: cell_y0.min(image_height.saturating_sub(1)), x1: cell_x1.clamp(cell_x0.saturating_add(1), image_width), y1: cell_y1.clamp(cell_y0.saturating_add(1), image_height), } } fn detect_match3d_material_foreground_bounds( source: &image::DynamicImage, cell: Match3DMaterialCellBounds, ) -> Option { let background = sample_match3d_material_cell_background(source, cell); let mut foreground: Option = None; let mut foreground_pixels = 0u32; for y in cell.y0..cell.y1 { for x in cell.x0..cell.x1 { if !is_match3d_material_foreground_pixel(source.get_pixel(x, y).0, background) { continue; } foreground_pixels = foreground_pixels.saturating_add(1); foreground = Some(match foreground { Some(bounds) => Match3DMaterialCellBounds { x0: bounds.x0.min(x), y0: bounds.y0.min(y), x1: bounds.x1.max(x.saturating_add(1)), y1: bounds.y1.max(y.saturating_add(1)), }, None => Match3DMaterialCellBounds { x0: x, y0: y, x1: x.saturating_add(1), y1: y.saturating_add(1), }, }); } } let min_foreground_pixels = (cell.area() / 320).clamp(12, 220); foreground.filter(|bounds| { foreground_pixels >= min_foreground_pixels && bounds.width() > 2 && bounds.height() > 2 }) } fn sample_match3d_material_cell_background( source: &image::DynamicImage, cell: Match3DMaterialCellBounds, ) -> [u8; 4] { let sample_size = (cell.width().min(cell.height()) / 12).clamp(2, 8); let sample_points = [ (cell.x0, cell.y0), (cell.x1.saturating_sub(sample_size), cell.y0), (cell.x0, cell.y1.saturating_sub(sample_size)), ( cell.x1.saturating_sub(sample_size), cell.y1.saturating_sub(sample_size), ), ]; let mut samples = Vec::new(); for (start_x, start_y) in sample_points { let mut totals = [0u32; 4]; let mut count = 0u32; for y in start_y..start_y.saturating_add(sample_size).min(cell.y1) { for x in start_x..start_x.saturating_add(sample_size).min(cell.x1) { let pixel = source.get_pixel(x, y).0; totals[0] = totals[0].saturating_add(pixel[0] as u32); totals[1] = totals[1].saturating_add(pixel[1] as u32); totals[2] = totals[2].saturating_add(pixel[2] as u32); totals[3] = totals[3].saturating_add(pixel[3] as u32); count = count.saturating_add(1); } } if count > 0 { samples.push([ (totals[0] / count) as u8, (totals[1] / count) as u8, (totals[2] / count) as u8, (totals[3] / count) as u8, ]); } } samples .into_iter() .min_by_key(|sample| { let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16; (sample[3] as u16, u16::MAX.saturating_sub(luminance)) }) .unwrap_or([255, 255, 255, 255]) } fn clamp_match3d_material_unit(value: f32) -> f32 { value.clamp(0.0, 1.0) } fn lerp_match3d_material_channel(from: f32, to: f32, t: f32) -> f32 { from + (to - from) * clamp_match3d_material_unit(t) } fn is_match3d_material_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool { let alpha_diff = pixel[3] as i32 - background[3] as i32; if alpha_diff.abs() >= MATCH3D_MATERIAL_FOREGROUND_ALPHA_THRESHOLD && pixel[3] > 24 { return true; } if pixel[3] <= 24 { return false; } let color_diff = (pixel[0] as i32 - background[0] as i32).abs() + (pixel[1] as i32 - background[1] as i32).abs() + (pixel[2] as i32 - background[2] as i32).abs(); color_diff >= MATCH3D_MATERIAL_FOREGROUND_DIFF_THRESHOLD } fn apply_match3d_material_green_screen_alpha(source: image::DynamicImage) -> image::DynamicImage { let mut image = source.to_rgba8(); let (width, height) = image.dimensions(); remove_match3d_material_green_screen_background( image.as_mut(), width as usize, height as usize, ); image::DynamicImage::ImageRgba8(image) } fn remove_match3d_material_green_screen_background( pixels: &mut [u8], width: usize, height: usize, ) -> bool { let pixel_count = width.saturating_mul(height); if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { return false; } let mut green_scores = vec![0.0f32; pixel_count]; let mut white_scores = vec![0.0f32; pixel_count]; let mut background_hints = vec![0.0f32; pixel_count]; let mut background_mask = vec![0u8; pixel_count]; let mut queue = Vec::::new(); let mut queue_index = 0usize; for pixel_index in 0..pixel_count { let offset = pixel_index * 4; let red = pixels[offset]; let green = pixels[offset + 1]; let blue = pixels[offset + 2]; let alpha = pixels[offset + 3]; let green_score = compute_match3d_material_green_screen_score([red, green, blue, alpha]); let white_score = compute_match3d_material_white_screen_score([red, green, blue, alpha]); let transparency_hint = clamp_match3d_material_unit((56.0 - alpha as f32) / 56.0) * 0.75; green_scores[pixel_index] = green_score; white_scores[pixel_index] = white_score; background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint); } let seed_background_pixel = |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec| { if background_mask[pixel_index] != 0 { return; } let alpha = pixels[pixel_index * 4 + 3]; let strong_candidate = alpha < 40 || green_scores[pixel_index] >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE || (alpha < 224 && green_scores[pixel_index] > MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE) || white_scores[pixel_index] > 0.32; if !strong_candidate { return; } background_mask[pixel_index] = 1; queue.push(pixel_index); }; for x in 0..width { seed_background_pixel(x, &mut background_mask, &mut queue); seed_background_pixel((height - 1) * width + x, &mut background_mask, &mut queue); } for y in 1..height.saturating_sub(1) { seed_background_pixel(y * width, &mut background_mask, &mut queue); seed_background_pixel(y * width + width - 1, &mut background_mask, &mut queue); } while queue_index < queue.len() { let pixel_index = queue[queue_index]; queue_index += 1; let x = pixel_index % width; let y = pixel_index / width; let neighbor_indexes = [ if x > 0 { Some(pixel_index - 1) } else { None }, if x + 1 < width { Some(pixel_index + 1) } else { None }, if y > 0 { Some(pixel_index - width) } else { None }, if y + 1 < height { Some(pixel_index + width) } else { None }, ]; for next_pixel_index in neighbor_indexes.into_iter().flatten() { if background_mask[next_pixel_index] != 0 { continue; } let next_offset = next_pixel_index * 4; let alpha = pixels[next_offset + 3]; let green_score = green_scores[next_pixel_index]; let white_score = white_scores[next_pixel_index]; let hint = background_hints[next_pixel_index]; let reachable_soft_edge = hint > 0.08 && alpha < 224 && (green_score > 0.04 || white_score > 0.08 || alpha < 180); let green_background = green_score >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE || (alpha < 224 && green_score > MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE); if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge { background_mask[next_pixel_index] = 1; queue.push(next_pixel_index); } } } // 中文注释:Gemini 有时把每个素材格生成成独立绿幕块,块外又是近白背景; // 这类绿幕不一定和整张 sheet 外边缘连通,必须用高置信绿幕直接补进背景层。 for pixel_index in 0..pixel_count { if background_mask[pixel_index] == 0 && green_scores[pixel_index] >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE { background_mask[pixel_index] = 1; } } // 中文注释:较厚的抗锯齿绿边可能低于 hard 阈值;先沿整张 sheet 的透明背景向内吃掉 // 软绿边,再进入格子裁剪,避免每张切图自带绿色描边。 let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14); for _ in 0..soft_green_cleanup_rounds { let mut expanded_mask = background_mask.clone(); let mut changed_this_round = false; for y in 0..height { for x in 0..width { let pixel_index = y * width + x; if background_mask[pixel_index] != 0 { continue; } let offset = pixel_index * 4; let pixel = [ pixels[offset], pixels[offset + 1], pixels[offset + 2], pixels[offset + 3], ]; let green_score = green_scores[pixel_index]; let white_score = white_scores[pixel_index]; if !is_match3d_material_soft_green_matte_pixel(pixel, green_score, white_score) { continue; } if !touches_match3d_material_background_mask(x, y, width, height, &background_mask) { continue; } expanded_mask[pixel_index] = 1; changed_this_round = true; } } background_mask = expanded_mask; if !changed_this_round { break; } } // 中文注释:主体边缘常带一圈绿幕或白底抗锯齿,扩一层软边,避免切割后残留毛边。 for _ in 0..2 { let mut expanded_mask = background_mask.clone(); for y in 0..height { for x in 0..width { let pixel_index = y * width + x; if background_mask[pixel_index] != 0 { continue; } let alpha = pixels[pixel_index * 4 + 3]; let green_score = green_scores[pixel_index]; let white_score = white_scores[pixel_index]; let hint = background_hints[pixel_index]; let soft_matte_candidate = alpha < 224 || white_score > 0.10 || green_score >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE; if hint < MATCH3D_MATERIAL_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate { continue; } let mut adjacent_background_count = 0usize; for offset_y in -1i32..=1 { for offset_x in -1i32..=1 { if offset_x == 0 && offset_y == 0 { continue; } let next_x = x as i32 + offset_x; let next_y = y as i32 + offset_y; if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { adjacent_background_count += 1; continue; } if background_mask[next_y as usize * width + next_x as usize] != 0 { adjacent_background_count += 1; } } } if adjacent_background_count >= 2 || (adjacent_background_count >= 1 && hint >= MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE) { expanded_mask[pixel_index] = 1; } } } background_mask = expanded_mask; } let mut changed = false; for pixel_index in 0..pixel_count { if background_mask[pixel_index] == 0 { continue; } let alpha_offset = pixel_index * 4 + 3; if pixels[alpha_offset] != 0 { pixels[alpha_offset] = 0; changed = true; } } for y in 0..height { for x in 0..width { let pixel_index = y * width + x; let offset = pixel_index * 4; let alpha = pixels[offset + 3]; if alpha == 0 { continue; } let mut touches_transparent_edge = false; for offset_y in -1i32..=1 { for offset_x in -1i32..=1 { if offset_x == 0 && offset_y == 0 { continue; } let next_x = x as i32 + offset_x; let next_y = y as i32 + offset_y; if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { touches_transparent_edge = true; continue; } let next_pixel_index = next_y as usize * width + next_x as usize; if background_mask[next_pixel_index] != 0 || pixels[next_pixel_index * 4 + 3] < 16 { touches_transparent_edge = true; } } } if !touches_transparent_edge { continue; } let green_score = green_scores[pixel_index]; let white_score = white_scores[pixel_index]; let contamination = green_score.max(white_score).max(if alpha < 220 { ((220 - alpha) as f32 / 220.0) * 0.25 } else { 0.0 }); if contamination < 0.06 { continue; } let sample = collect_match3d_material_foreground_neighbor_color( pixels, width, height, x, y, &background_mask, &background_hints, ); let mut red = pixels[offset] as f32; let mut green = pixels[offset + 1] as f32; let mut blue = pixels[offset + 2] as f32; let blend = clamp_match3d_material_unit(contamination.max(0.22)); if let Some((sample_red, sample_green, sample_blue)) = sample { red = lerp_match3d_material_channel(red, sample_red as f32, blend); green = lerp_match3d_material_channel(green, sample_green as f32, blend); blue = lerp_match3d_material_channel(blue, sample_blue as f32, blend); if green_score > 0.04 { green = green.min(sample_green as f32 + 18.0); } if white_score > 0.1 { red = red.min(sample_red as f32 + 26.0); green = green.min(sample_green as f32 + 26.0); blue = blue.min(sample_blue as f32 + 26.0); } } else { if green_score > 0.04 { let toned_green = (green - (green - red.max(blue)) * 0.78) .round() .max(red.max(blue)); green = green.min(toned_green).min(red.max(blue) + 18.0); } if white_score > 0.12 { let spread = red.max(green).max(blue) - red.min(green).min(blue); if spread < 20.0 { let toned_value = ((red + green + blue) / 3.0 * 0.88).round(); red = red.min(toned_value); green = green.min(toned_value); blue = blue.min(toned_value); } } } let mut next_alpha = alpha; let edge_fade = (green_score * 0.35).max(white_score * 0.28); if edge_fade > 0.08 { next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8; if next_alpha < 10 { next_alpha = 0; } } let next_red = red.round().clamp(0.0, 255.0) as u8; let next_green = green.round().clamp(0.0, 255.0) as u8; let next_blue = blue.round().clamp(0.0, 255.0) as u8; if next_red != pixels[offset] || next_green != pixels[offset + 1] || next_blue != pixels[offset + 2] || next_alpha != alpha { pixels[offset] = next_red; pixels[offset + 1] = next_green; pixels[offset + 2] = next_blue; pixels[offset + 3] = next_alpha; changed = true; } } } changed } fn touches_match3d_material_background_mask( x: usize, y: usize, width: usize, height: usize, background_mask: &[u8], ) -> bool { for offset_y in -1i32..=1 { for offset_x in -1i32..=1 { if offset_x == 0 && offset_y == 0 { continue; } let next_x = x as i32 + offset_x; let next_y = y as i32 + offset_y; if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { return true; } if background_mask[next_y as usize * width + next_x as usize] != 0 { return true; } } } false } fn is_match3d_material_soft_green_matte_pixel( pixel: [u8; 4], green_score: f32, white_score: f32, ) -> bool { if pixel[3] == 0 || green_score < MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE { return false; } let red = pixel[0]; let green = pixel[1]; let blue = pixel[2]; let foreground_mix = red.max(blue); green >= 188 && white_score < 0.34 && green.saturating_sub(foreground_mix) >= 42 && (red >= 48 || blue >= 96 || pixel[3] < 236) } fn compute_match3d_material_green_screen_score(pixel: [u8; 4]) -> f32 { if pixel[3] == 0 { return 1.0; } let red = pixel[0] as f32; let green = pixel[1] as f32; let blue = pixel[2] as f32; let green_lead = green - red.max(blue); if green < 96.0 || green_lead <= 18.0 { return 0.0; } let green_ratio = green / (red + blue).max(1.0); if green_ratio <= 0.9 { return 0.0; } (((green - 96.0) / 128.0).clamp(0.0, 1.0) * 0.34 + ((green_lead - 18.0) / 120.0).clamp(0.0, 1.0) * 0.46 + ((green_ratio - 0.9) / 2.4).clamp(0.0, 1.0) * 0.20) .clamp(0.0, 1.0) } fn compute_match3d_material_white_screen_score(pixel: [u8; 4]) -> f32 { if pixel[3] == 0 { return 1.0; } let red = pixel[0] as f32; let green = pixel[1] as f32; let blue = pixel[2] as f32; let max_channel = red.max(green).max(blue); let min_channel = red.min(green).min(blue); let average = (red + green + blue) / 3.0; if average < 188.0 || min_channel < 168.0 { return 0.0; } let spread = max_channel - min_channel; let neutrality = 1.0 - clamp_match3d_material_unit((spread - 6.0) / 34.0); let brightness = clamp_match3d_material_unit((average - 188.0) / 55.0); let floor = clamp_match3d_material_unit((min_channel - 168.0) / 60.0); clamp_match3d_material_unit(neutrality * (brightness * 0.85 + floor * 0.15)) } fn remove_match3d_container_plain_background( pixels: &mut [u8], width: usize, height: usize, ) -> bool { let pixel_count = width.saturating_mul(height); if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { return false; } let mut background_mask = vec![0u8; pixel_count]; let mut queue = Vec::::new(); let mut queue_index = 0usize; let seed_pixel = |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec| { if background_mask[pixel_index] != 0 { return; } let offset = pixel_index * 4; if is_match3d_container_background_pixel([ pixels[offset], pixels[offset + 1], pixels[offset + 2], pixels[offset + 3], ]) { background_mask[pixel_index] = 1; queue.push(pixel_index); } }; for x in 0..width { seed_pixel(x, &mut background_mask, &mut queue); seed_pixel((height - 1) * width + x, &mut background_mask, &mut queue); } for y in 1..height.saturating_sub(1) { seed_pixel(y * width, &mut background_mask, &mut queue); seed_pixel(y * width + width - 1, &mut background_mask, &mut queue); } while queue_index < queue.len() { let pixel_index = queue[queue_index]; queue_index += 1; let x = pixel_index % width; let y = pixel_index / width; let neighbors = [ (x > 0).then(|| pixel_index - 1), (x + 1 < width).then_some(pixel_index + 1), (y > 0).then(|| pixel_index - width), (y + 1 < height).then_some(pixel_index + width), ]; for next_pixel_index in neighbors.into_iter().flatten() { if background_mask[next_pixel_index] != 0 { continue; } let offset = next_pixel_index * 4; if is_match3d_container_background_pixel([ pixels[offset], pixels[offset + 1], pixels[offset + 2], pixels[offset + 3], ]) { background_mask[next_pixel_index] = 1; queue.push(next_pixel_index); } } } // 中文注释:图生图偶尔会在容器边缘留下白底抗锯齿,扩一层只清理连到背景的浅色边。 for _ in 0..2 { let mut expanded_mask = background_mask.clone(); for y in 0..height { for x in 0..width { let pixel_index = y * width + x; if background_mask[pixel_index] != 0 { continue; } let offset = pixel_index * 4; let pixel = [ pixels[offset], pixels[offset + 1], pixels[offset + 2], pixels[offset + 3], ]; if !is_match3d_container_soft_background_pixel(pixel) { continue; } let mut adjacent_background_count = 0usize; for offset_y in -1i32..=1 { for offset_x in -1i32..=1 { if offset_x == 0 && offset_y == 0 { continue; } let next_x = x as i32 + offset_x; let next_y = y as i32 + offset_y; if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { adjacent_background_count += 1; continue; } if background_mask[next_y as usize * width + next_x as usize] != 0 { adjacent_background_count += 1; } } } if adjacent_background_count >= 3 { expanded_mask[pixel_index] = 1; } } } background_mask = expanded_mask; } let mut changed = false; for pixel_index in 0..pixel_count { if background_mask[pixel_index] == 0 { continue; } let offset = pixel_index * 4; if pixels[offset + 3] != 0 { pixels[offset + 3] = 0; changed = true; } } changed } fn is_match3d_container_background_pixel(pixel: [u8; 4]) -> bool { pixel[3] < 16 || compute_match3d_material_white_screen_score(pixel) > 0.34 } fn is_match3d_container_soft_background_pixel(pixel: [u8; 4]) -> bool { pixel[3] < 80 || compute_match3d_material_white_screen_score(pixel) > 0.18 } fn collect_match3d_material_foreground_neighbor_color( pixels: &[u8], width: usize, height: usize, x: usize, y: usize, background_mask: &[u8], background_hints: &[f32], ) -> Option<(u8, u8, u8)> { let mut total_weight = 0.0f32; let mut total_red = 0.0f32; let mut total_green = 0.0f32; let mut total_blue = 0.0f32; for offset_y in -2i32..=2 { for offset_x in -2i32..=2 { if offset_x == 0 && offset_y == 0 { continue; } let next_x = x as i32 + offset_x; let next_y = y as i32 + offset_y; if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { continue; } let next_pixel_index = next_y as usize * width + next_x as usize; if background_mask[next_pixel_index] != 0 || background_hints[next_pixel_index] >= 0.18 { continue; } let next_offset = next_pixel_index * 4; let next_alpha = pixels[next_offset + 3]; if next_alpha < 96 { continue; } let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs(); let weight = (next_alpha as f32 / 255.0) * if distance <= 1 { 1.8 } else if distance == 2 { 1.2 } else { 0.7 }; total_weight += weight; total_red += pixels[next_offset] as f32 * weight; total_green += pixels[next_offset + 1] as f32 * weight; total_blue += pixels[next_offset + 2] as f32 * weight; } } if total_weight <= 0.0 { return None; } Some(( (total_red / total_weight).round() as u8, (total_green / total_weight).round() as u8, (total_blue / total_weight).round() as u8, )) } #[allow(clippy::too_many_arguments)] async fn persist_match3d_generated_bytes( state: &AppState, owner_user_id: &str, session_id: &str, profile_id: &str, path_segments: &[&str], file_name: &str, content_type: &str, bytes: Vec, asset_kind: &str, source_job_id: Option<&str>, generated_at_micros: i64, ) -> Result { let oss_client = require_match3d_oss_client(state)?; let mut metadata = BTreeMap::new(); metadata.insert("x-oss-meta-asset-kind".to_string(), asset_kind.to_string()); metadata.insert( "x-oss-meta-owner-user-id".to_string(), owner_user_id.to_string(), ); metadata.insert("x-oss-meta-profile-id".to_string(), profile_id.to_string()); if let Some(source_job_id) = source_job_id.filter(|value| !value.trim().is_empty()) { metadata.insert( "x-oss-meta-source-job-id".to_string(), source_job_id.to_string(), ); } let oss_http_client = reqwest::Client::builder() .timeout(Duration::from_millis(MATCH3D_OSS_PUT_TIMEOUT_MS)) .build() .map_err(|error| match3d_bad_gateway(format!("构造抓大鹅 OSS 上传客户端失败:{error}")))?; let put_result = oss_client .put_object( &oss_http_client, OssPutObjectRequest { prefix: LegacyAssetPrefix::Match3DAssets, path_segments: std::iter::once(session_id) .chain(std::iter::once(profile_id)) .chain(path_segments.iter().copied()) .map(|segment| sanitize_match3d_asset_segment(segment, "asset")) .collect(), file_name: file_name.to_string(), content_type: Some(content_type.to_string()), access: OssObjectAccess::Private, metadata, body: bytes, }, ) .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; let _ = generated_at_micros; Ok(Match3DAssetUpload { src: put_result.legacy_public_path, object_key: put_result.object_key, }) } fn require_match3d_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { state .oss_client() .ok_or_else(|| match3d_oss_config_error(&state.config)) } fn match3d_oss_config_error(config: &AppConfig) -> AppError { let missing = missing_match3d_oss_env_keys(config); let reason = match3d_oss_missing_reason(&missing); AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": reason, "missingEnv": missing, })) } fn match3d_oss_missing_reason(missing: &[&str]) -> String { if missing.is_empty() { "OSS 未完成环境变量配置".to_string() } else { format!("OSS 未完成环境变量配置,缺少:{}", missing.join(", ")) } } fn missing_match3d_oss_env_keys(config: &AppConfig) -> Vec<&'static str> { [ ("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()), ("ALIYUN_OSS_ENDPOINT", config.oss_endpoint.as_deref()), ( "ALIYUN_OSS_ACCESS_KEY_ID", config.oss_access_key_id.as_deref(), ), ( "ALIYUN_OSS_ACCESS_KEY_SECRET", config.oss_access_key_secret.as_deref(), ), ] .into_iter() .filter_map(|(name, value)| match value { Some(value) if !value.trim().is_empty() => None, _ => Some(name), }) .collect() } fn sanitize_match3d_asset_segment(raw: &str, fallback: &str) -> String { let normalized = raw .trim() .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { ch.to_ascii_lowercase() } else { '-' } }) .collect::(); let collapsed = normalized .split('-') .filter(|part| !part.is_empty()) .collect::>() .join("-"); if collapsed.is_empty() { fallback.to_string() } else { collapsed.chars().take(64).collect() } } fn normalize_match3d_run_status(value: &str) -> &str { match value { "Running" => "running", "Won" => "won", "Failed" => "failed", "Stopped" => "stopped", _ => value, } } fn normalize_match3d_item_state(value: &str) -> &str { match value { "InBoard" => "in_board", "InTray" => "in_tray", "Cleared" => "cleared", _ => value, } } fn normalize_match3d_failure_reason(value: &str) -> &str { match value { "TimeUp" => "time_up", "TrayFull" => "tray_full", _ => value, } } fn normalize_match3d_click_reject_reason(value: &str) -> &str { match value { "RejectedNotClickable" => "item_not_clickable", "RejectedAlreadyMoved" => "item_not_in_board", "RejectedTrayFull" => "tray_full", "VersionConflict" => "snapshot_version_mismatch", "RunFinished" => "run_not_active", _ => value, } } fn ensure_non_empty( request_context: &RequestContext, provider: &str, value: &str, field_name: &str, ) -> Result<(), Response> { if value.trim().is_empty() { return Err(match3d_bad_request( request_context, provider, format!("{field_name} is required").as_str(), )); } Ok(()) } fn match3d_json( payload: Result, JsonRejection>, request_context: &RequestContext, provider: &str, ) -> Result, Response> { payload.map_err(|error| { match3d_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": error.body_text(), })), ) }) } fn match3d_bad_request( request_context: &RequestContext, provider: &str, message: &str, ) -> Response { match3d_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": message, })), ) } fn map_match3d_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Procedure(message) if message.contains("不存在") || message.contains("not found") || message.contains("does not exist") => { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(message) if message.contains("发布需要") || message.contains("不能为空") || message.contains("必须") => { StatusCode::BAD_REQUEST } _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn match3d_error_response( request_context: &RequestContext, provider: &str, error: AppError, ) -> Response { let mut response = error.into_response_with_context(Some(request_context)); response.headers_mut().insert( HeaderName::from_static("x-genarrative-provider"), header::HeaderValue::from_str(provider) .unwrap_or_else(|_| header::HeaderValue::from_static("match3d")), ); response } fn match3d_sse_json_event(event_name: &str, payload: Value) -> Result { Event::default() .event(event_name) .json_data(payload) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "sse", "message": format!("SSE payload 序列化失败:{error}"), })) }) } fn match3d_sse_json_event_or_error(event_name: &str, payload: Value) -> Event { match match3d_sse_json_event(event_name, payload) { Ok(event) => event, Err(error) => Event::default().event("error").data(format!("{error:?}")), } } fn current_utc_micros() -> i64 { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); (duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros()) } fn current_utc_ms() -> i64 { current_utc_micros().saturating_div(1000) } #[cfg(test)] mod tests { use super::*; fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset { Match3DGeneratedItemAsset { item_id: format!("match3d-item-{index}"), item_name: name.to_string(), image_src: Some(format!( "/generated-match3d-assets/s/p/items/i{index}/views/view-01.png" )), image_object_key: Some(format!( "generated-match3d-assets/s/p/items/i{index}/views/view-01.png" )), image_views: (1..=MATCH3D_ITEM_VIEW_COUNT) .map(|view_index| Match3DGeneratedItemImageView { view_id: format!("view-{view_index:02}"), view_index: view_index as u32, image_src: Some(format!( "/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" )), image_object_key: Some(format!( "generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" )), }) .collect(), model_src: Some(format!( "/generated-match3d-assets/s/p/items/i{index}/model/model.glb" )), model_object_key: Some(format!( "generated-match3d-assets/s/p/items/i{index}/model/model.glb" )), model_file_name: Some("model.glb".to_string()), task_uuid: Some(format!("task-{index}")), subscription_key: Some(format!("sub-{index}")), sound_prompt: Some(format!("{name}点击音效")), background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, } } fn config(theme_text: &str, clear_count: u32, difficulty: u32) -> Match3DConfigJson { Match3DConfigJson { theme_text: theme_text.to_string(), reference_image_src: None, clear_count, difficulty, asset_style_id: None, asset_style_label: None, asset_style_prompt: None, generate_click_sound: false, } } #[test] fn match3d_agent_reply_asks_three_questions_before_confirmation() { let current = config("水果", 4, 6); assert_eq!( build_match3d_assistant_reply_for_turn(¤t, 0), MATCH3D_QUESTION_THEME ); assert_eq!( build_match3d_assistant_reply_for_turn(¤t, 1), MATCH3D_QUESTION_CLEAR_COUNT ); assert_eq!( build_match3d_assistant_reply_for_turn(¤t, 2), MATCH3D_QUESTION_DIFFICULTY ); assert_eq!( build_match3d_assistant_reply_for_turn(¤t, 3), "已确认:水果题材,需要消除 4 次,共 12 件物品,难度 6。" ); } #[test] fn match3d_agent_progress_follows_question_turns() { assert_eq!(resolve_progress_percent_for_turn(0), 0); assert_eq!(resolve_progress_percent_for_turn(1), 33); assert_eq!(resolve_progress_percent_for_turn(2), 66); assert_eq!(resolve_progress_percent_for_turn(3), 100); assert_eq!(resolve_progress_percent_for_turn(8), 100); } #[test] fn match3d_anchor_pack_masks_uncollected_default_values() { let pack = Match3DAnchorPackRecord { theme: Match3DAnchorItemRecord { key: "theme".to_string(), label: "题材主题".to_string(), value: "缤纷玩具".to_string(), status: "confirmed".to_string(), }, clear_count: Match3DAnchorItemRecord { key: "clearCount".to_string(), label: "需要消除次数".to_string(), value: "12".to_string(), status: "confirmed".to_string(), }, difficulty: Match3DAnchorItemRecord { key: "difficulty".to_string(), label: "难度".to_string(), value: "4".to_string(), status: "confirmed".to_string(), }, }; let response = map_match3d_anchor_pack_response_for_turn(pack, 0, "Collecting"); assert_eq!(response.theme.value, ""); assert_eq!(response.theme.status, "missing"); assert_eq!(response.clear_count.value, ""); assert_eq!(response.clear_count.status, "missing"); assert_eq!(response.difficulty.value, ""); assert_eq!(response.difficulty.status, "missing"); } #[test] fn match3d_item_image_path_segments_stay_unique_for_chinese_names() { let item_names = ["草莓", "苹果", "香蕉"]; let slugs = item_names .iter() .enumerate() .map(|(index, item_name)| { let item_id = format!("match3d-item-{}", index + 1); format!( "{item_id}-{}", sanitize_match3d_asset_segment(item_name, "item") ) }) .collect::>(); assert_eq!( slugs, vec![ "match3d-item-1-item", "match3d-item-2-item", "match3d-item-3-item", ] ); } #[test] fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() { let width = 500; let height = 500; 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 { let color = image::Rgba([ 32 + row as u8 * 40, 24 + col as u8 * 36, 210 - row as u8 * 30, 255, ]); for y in row * 100..(row + 1) * 100 { for x in col * 100..(col + 1) * 100 { sheet.put_pixel(x, y, color); } } } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; 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() { assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT); for (col, 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); assert_eq!( pixel.0, [ 32 + row as u8 * 40, 24 + col as u8 * 36, 210 - row as u8 * 30, 255, ], "row {row} col {col} should be cut from the fixed 5*5 grid row" ); } } } #[test] fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() { let width = 500; let height = 500; 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 { for x in 18..82 { sheet.put_pixel(x, y, image::Rgba([20, 80, 240, 255])); } } for y in 5..96 { for x in 18..82 { sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); } } for y in 96..99 { for x in 18..82 { sheet.put_pixel(x, y, image::Rgba([20, 180, 64, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) .expect("view should decode") .to_rgba8(); let pixels = decoded.pixels().map(|pixel| pixel.0).collect::>(); assert!( pixels.iter().any(|pixel| *pixel == [20, 80, 240, 255]), "贴近顶部的前景像素不能被固定内缩切掉" ); assert!( pixels.iter().any(|pixel| *pixel == [20, 180, 64, 255]), "贴近底部的前景像素不能被固定内缩切掉" ); } #[test] fn match3d_material_sheet_slicing_makes_green_screen_transparent_before_crop() { let width = 500; let height = 500; let item_names = vec!["草莓".to_string()]; let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); for y in 35..65 { for x in 35..65 { sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) .expect("view should decode") .to_rgba8(); assert_eq!( decoded.get_pixel(0, 0).0[3], 0, "绿幕背景必须在切割输出中变成透明 alpha" ); assert!( decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), "物品主体不能被绿幕去背误删" ); } #[test] fn match3d_material_sheet_slicing_removes_isolated_green_cell_background() { let width = 500; let height = 500; let item_names = vec!["葡萄".to_string()]; let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([245, 245, 245, 255])); for y in 8..92 { for x in 8..92 { sheet.put_pixel(x, y, image::Rgba([0, 236, 18, 255])); } } for y in 35..65 { for x in 35..65 { sheet.put_pixel(x, y, image::Rgba([136, 64, 210, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) .expect("view should decode") .to_rgba8(); assert!( decoded .pixels() .all(|pixel| pixel.0[3] == 0 || pixel.0[1] < 180), "没有连到整张 sheet 外边缘的绿幕块也必须被转成透明" ); assert!( decoded.pixels().any(|pixel| pixel.0 == [136, 64, 210, 255]), "绿幕清理不能误删物品主体" ); } #[test] fn match3d_material_sheet_slicing_removes_soft_green_matte_before_crop() { let width = 500; let height = 500; let item_names = vec!["草莓".to_string()]; let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); for y in 28..72 { for x in 28..72 { sheet.put_pixel(x, y, image::Rgba([64, 198, 112, 255])); } } for y in 36..64 { for x in 36..64 { sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) .expect("view should decode") .to_rgba8(); assert!( decoded.pixels().all(|pixel| { let [red, green, blue, alpha] = pixel.0; alpha == 0 || green <= red.max(blue).saturating_add(32) }), "整张 sheet 去绿后再裁剪,输出 PNG 不能保留可见软绿边" ); assert!( decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), "软绿边清理不能误删物品主体" ); } #[test] fn match3d_background_music_title_is_required_for_auto_draft() { let missing = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan { title: " ,。 ".to_string(), style: "轻快, 休闲".to_string(), prompt: String::new(), }) .expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名"); assert!(missing.body_text().contains("背景音乐")); let title = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan { title: " 果园轻舞。 ".to_string(), style: "轻快, 休闲".to_string(), prompt: String::new(), }) .expect("valid title should pass"); assert_eq!(title, "果园轻舞"); } #[test] fn match3d_material_sheet_slicing_cleans_white_matte_edge() { let width = 500; let height = 500; let item_names = vec!["羽毛".to_string()]; let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); for y in 32..68 { for x in 32..68 { sheet.put_pixel(x, y, image::Rgba([248, 248, 244, 255])); } } for y in 36..64 { for x in 36..64 { sheet.put_pixel(x, y, image::Rgba([225, 174, 58, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) .expect("view should decode") .to_rgba8(); assert!( decoded.pixels().all(|pixel| { let [red, green, blue, alpha] = pixel.0; alpha == 0 || !(red >= 238 && green >= 238 && blue >= 232) }), "近白抠图边必须被清成透明或去污染,不能在输出 PNG 中形成白边" ); assert!( decoded.pixels().any(|pixel| pixel.0 == [225, 174, 58, 255]), "白边清理不能误删物品主体" ); } #[test] fn match3d_container_image_postprocess_removes_plain_background() { let width = 256; let height = 256; let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([248, 248, 246, 255])); for y in 68..190 { for x in 38..218 { image.put_pixel(x, y, image::Rgba([160, 104, 54, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(image) .write_to(&mut encoded, ImageFormat::Png) .expect("container should encode"); let processed = make_match3d_container_image_transparent(DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }) .expect("container should postprocess"); let decoded = image::load_from_memory(processed.bytes.as_slice()) .expect("processed container should decode") .to_rgba8(); assert_eq!(processed.mime_type, "image/png"); assert_eq!(processed.extension, "png"); assert_eq!( decoded.get_pixel(0, 0).0[3], 0, "容器图四周白底必须在入库前转成透明 alpha" ); assert_eq!( decoded.get_pixel(width / 2, height / 2).0[3], 255, "容器主体不能被透明化误删" ); } #[test] fn match3d_work_metadata_parses_gpt4o_json() { let metadata = parse_match3d_work_metadata( r#"{"gameName":"果园大鹅宴","summary":"在明亮果园里收集水果小物件,节奏轻快适合随手游玩。","tags":["水果","抓大鹅","经典消除","轻量休闲"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":"果园主题循环背景音乐"},"backgroundPrompt":"果园主题绿色果园竖屏纯背景图","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"}]}"#, ) .expect("metadata should parse"); assert_eq!(metadata.game_name, "果园大鹅宴"); assert_eq!( metadata.summary, "在明亮果园里收集水果小物件,节奏轻快适合随手游玩。" ); assert_eq!( metadata.tags, vec!["水果", "抓大鹅", "经典消除", "轻量休闲", "2D素材", "收集"] ); } #[test] fn match3d_work_metadata_fallback_keeps_empty_description_boundary() { let metadata = fallback_match3d_work_metadata("水果"); assert_eq!(metadata.game_name, "水果抓大鹅"); assert!(metadata.summary.contains("水果主题")); assert!(metadata.tags.contains(&"水果".to_string())); assert!(metadata.tags.contains(&"抓大鹅".to_string())); } #[test] fn match3d_draft_plan_parses_audio_prompts() { let plan = parse_match3d_draft_plan( r#"{"gameName":"果园大鹅宴","summary":"明亮果园里堆满水果小物,轻快收集感突出。","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题抓大鹅竖屏纯背景,绿色渐变和明亮果园氛围","items":[{"name":"草莓","soundPrompt":"草莓点击消除的清脆音效"},{"name":"苹果","soundPrompt":"苹果落入托盘的弹跳音"},{"name":"香蕉","soundPrompt":"香蕉消除时的轻快提示音"}]}"#, &config("水果", 3, 3), ) .expect("draft plan should parse"); assert_eq!(plan.metadata.game_name, "果园大鹅宴"); assert_eq!( plan.metadata.summary, "明亮果园里堆满水果小物,轻快收集感突出。" ); assert!(plan.background_prompt.contains("纯背景")); assert_eq!(plan.items[0].name, "草莓"); assert!(plan.items[0].sound_prompt.contains("草莓")); } #[test] fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() { 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[8].name, "蓝莓"); assert_ne!(plan.items[9].name, "蓝莓"); } #[test] fn match3d_generated_item_count_rounds_up_to_five_multiples() { assert_eq!( resolve_match3d_generated_item_count(&config("水果", 8, 2)), 5 ); assert_eq!( resolve_match3d_generated_item_count(&config("水果", 12, 4)), 10 ); assert_eq!( resolve_match3d_generated_item_count(&config("水果", 16, 6)), 15 ); assert_eq!( resolve_match3d_generated_item_count(&config("水果", 21, 8)), 25 ); } #[test] fn match3d_item_asset_points_cost_counts_five_item_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); } #[test] fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() { let existing_assets = vec![Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), image_src: None, image_object_key: None, image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }]; let plan = build_match3d_item_asset_append_plan( vec![ "草莓".to_string(), "苹果".to_string(), "香蕉".to_string(), "梨子".to_string(), ], &existing_assets, ); assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]); assert_eq!(plan.padded_item_names.len(), 5); assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]); assert_eq!( calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()), 2 ); } #[test] fn match3d_item_asset_append_plan_still_generates_full_sheet_when_capacity_is_low() { let existing_assets = (1..MATCH3D_MAX_GENERATED_ITEM_COUNT) .map(|index| Match3DGeneratedItemAsset { item_id: format!("match3d-item-{index}"), item_name: format!("已有物品{index}"), image_src: None, image_object_key: None, image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }) .collect::>(); let plan = build_match3d_item_asset_append_plan(vec!["新物品".to_string()], &existing_assets); assert_eq!(plan.requested_item_names, vec!["新物品"]); assert_eq!( plan.padded_item_names.len(), MATCH3D_MATERIAL_ITEM_BATCH_SIZE ); assert_eq!(plan.padded_item_names[0], "新物品"); } #[test] fn match3d_item_asset_replace_plan_only_targets_existing_names() { let existing_assets = vec![ test_match3d_generated_item_asset(1, "草莓"), test_match3d_generated_item_asset(2, "苹果"), ]; let plan = build_match3d_item_asset_replace_plan( vec!["苹果".to_string(), "不存在".to_string(), "苹果".to_string()], &existing_assets, ); assert_eq!(plan.requested_item_names, vec!["苹果"]); assert_eq!(plan.target_assets.len(), 1); assert_eq!(plan.target_assets[0].item_id, "match3d-item-2"); assert_eq!( plan.padded_item_names.len(), MATCH3D_MATERIAL_ITEM_BATCH_SIZE ); assert_eq!(plan.padded_item_names[0], "苹果"); } #[test] fn match3d_item_assets_generation_mode_defaults_to_append() { assert!(matches!( normalize_match3d_item_assets_generation_mode(None), Match3DItemAssetsGenerationMode::Append )); assert!(matches!( normalize_match3d_item_assets_generation_mode(Some("replace")), Match3DItemAssetsGenerationMode::Replace )); assert!(matches!( normalize_match3d_item_assets_generation_mode(Some("regenerate")), Match3DItemAssetsGenerationMode::Replace )); } #[test] fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() { let mut current_asset = test_match3d_generated_item_asset(1, "草莓"); current_asset.background_music_title = Some("果园轻舞".to_string()); current_asset.background_asset = Some(Match3DGeneratedBackgroundAsset { prompt: "果园背景".to_string(), image_src: Some("/generated-match3d-assets/s/p/background/bg.png".to_string()), image_object_key: None, container_prompt: Some("果园容器".to_string()), container_image_src: Some( "/generated-match3d-assets/s/p/ui-container/container.png".to_string(), ), container_image_object_key: None, status: "image_ready".to_string(), error: None, }); let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓"); generated_asset.image_src = Some("/generated-match3d-assets/s/p/items/new/views/view-01.png".to_string()); generated_asset.model_src = None; generated_asset.model_object_key = None; let merged = merge_regenerated_match3d_item_asset(Some(current_asset.clone()), generated_asset); assert_eq!(merged.item_id, "match3d-item-1"); assert_eq!(merged.item_name, "草莓"); assert_eq!( merged.image_src.as_deref(), Some("/generated-match3d-assets/s/p/items/new/views/view-01.png") ); assert_eq!( merged.model_src.as_deref(), current_asset.model_src.as_deref() ); assert_eq!(merged.background_music_title.as_deref(), Some("果园轻舞")); assert!(merged.background_asset.is_some()); assert_eq!(merged.status, "image_ready"); } #[test] fn match3d_material_sheet_prompt_requires_uniform_five_by_five_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("绿幕背景")); assert!(prompt.contains("#00FF00")); assert!(prompt.contains("单个素材格宽度的1/4空白间距")); assert!(prompt.contains("约25%单格宽度")); assert!(prompt.contains("禁止主体跨格")); assert!(prompt.contains("贴边或越界")); } #[test] fn match3d_material_sheet_prompt_hardens_pixel_retro_style() { 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 negative_prompt = build_match3d_material_sheet_negative_prompt(&config); assert!(prompt.contains("64x64")); assert!(prompt.contains("整数倍放大")); assert!(prompt.contains("禁止抗锯齿")); assert!(prompt.contains("真实 3D 渲染")); assert!(prompt.contains("PBR 材质")); assert!(negative_prompt.contains("抗锯齿")); assert!(negative_prompt.contains("平滑插画")); assert!(negative_prompt.contains("真实 3D 渲染")); } #[test] fn match3d_material_sheet_request_uses_vector_engine_gemini_contract() { let body = build_match3d_vector_engine_gemini_image_request_body( "生成水果素材图", "文字、水印", MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO, ); assert_eq!(body["generationConfig"]["responseModalities"][0], "TEXT"); assert_eq!(body["generationConfig"]["responseModalities"][1], "IMAGE"); assert_eq!( body["generationConfig"]["imageConfig"]["aspectRatio"], MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO ); assert!(body.get("model").is_none()); assert!(body.get("n").is_none()); assert!(body.get("official_fallback").is_none()); assert!(body.get("image").is_none()); assert!(body.get("image_urls").is_none()); assert!( body["contents"][0]["parts"][0]["text"] .as_str() .unwrap_or_default() .contains("文字、水印") ); } #[test] fn match3d_extracts_vector_engine_gemini_inline_image_data() { let payload = json!({ "candidates": [{ "content": { "parts": [ { "text": "已生成" }, { "inlineData": { "mimeType": "image/png", "data": "iVBORw0KGgo=" } }, { "inline_data": { "mime_type": "image/webp", "data": "UklGRg==" } }, { "inlineData": { "mimeType": "text/plain", "data": "not-image-data" } }, { "data": "not-inline-image-data" } ] } }] }); assert_eq!( extract_match3d_b64_images(&payload), vec!["iVBORw0KGgo=", "UklGRg=="] ); } #[test] 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!( build_match3d_vector_engine_gemini_generate_content_url(&root_settings), "https://api.vectorengine.cn/v1beta/models/gemini-3-pro-image-preview:generateContent" ); assert_eq!( build_match3d_vector_engine_gemini_generate_content_url(&v1_settings), "https://api.vectorengine.cn/v1beta/models/gemini-3-pro-image-preview:generateContent" ); } #[test] fn match3d_background_and_container_prompts_keep_ui_layers_split() { let config = config("水果", 3, 3); let background_prompt = build_match3d_background_generation_prompt(&config, "果园绿色竖屏纯背景"); let container_prompt = build_match3d_container_generation_prompt(&config, "果园绿色竖屏纯背景"); assert!(background_prompt.contains("9:16")); assert!(background_prompt.contains("纯背景图")); assert!(background_prompt.contains("不得出现锅")); assert!(background_prompt.contains("拼图槽")); assert!(background_prompt.contains("物品槽")); assert!(background_prompt.contains("默认交互容器")); assert!(container_prompt.contains("1:1")); assert!(container_prompt.contains("中心容器 UI 图")); assert!(container_prompt.contains("贴合题材设定")); assert!(container_prompt.contains("占画布宽度约 86%-92%")); assert!(container_prompt.contains("轻俯视 3/4")); assert!(container_prompt.contains("横向椭圆形内口")); assert!(container_prompt.contains("不能画成正俯视扁圆盘")); assert!(container_prompt.contains("透明 alpha")); assert!(container_prompt.contains("白底")); assert!(container_prompt.contains("整页背景")); assert!(container_prompt.contains("禁止文字")); } #[test] fn match3d_background_asset_requires_background_and_container_images() { let background_only = Match3DGeneratedBackgroundAsset { prompt: "果园背景".to_string(), image_src: Some( "/generated-match3d-assets/session/profile/background/bg.png".to_string(), ), image_object_key: None, container_prompt: None, container_image_src: None, container_image_object_key: None, status: "image_ready".to_string(), error: None, }; let with_container = Match3DGeneratedBackgroundAsset { container_prompt: Some("果园容器".to_string()), container_image_src: Some( "/generated-match3d-assets/session/profile/ui-container/container.png".to_string(), ), ..background_only.clone() }; assert!(!is_match3d_background_asset_ready(&background_only)); assert!(is_match3d_background_asset_ready(&with_container)); } #[test] fn match3d_default_cover_prefers_generated_container_ui_image() { let assets = vec![Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), image_src: None, image_object_key: None, image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: Some(Match3DGeneratedBackgroundAsset { prompt: "果园背景".to_string(), image_src: Some( "/generated-match3d-assets/session/profile/background/background.png" .to_string(), ), image_object_key: None, container_prompt: Some("果园容器".to_string()), container_image_src: Some( "/generated-match3d-assets/session/profile/ui-container/container.png" .to_string(), ), container_image_object_key: None, status: "image_ready".to_string(), error: None, }), status: "image_ready".to_string(), error: None, }]; assert_eq!( resolve_match3d_default_cover_image_src(&assets).as_deref(), Some("/generated-match3d-assets/session/profile/ui-container/container.png") ); } #[test] fn match3d_cover_reference_sources_are_deduped_and_limited() { let sources = collect_match3d_cover_reference_image_sources( Some("/generated-match3d-assets/a.png".to_string()), vec![ "/generated-match3d-assets/a.png".to_string(), "data:image/png;base64,b".to_string(), "/generated-match3d-assets/c.png".to_string(), "/generated-match3d-assets/d.png".to_string(), "/generated-match3d-assets/e.png".to_string(), "/generated-match3d-assets/f.png".to_string(), "/generated-match3d-assets/g.png".to_string(), ], ); assert_eq!(sources.len(), 6); assert_eq!(sources[0], "/generated-match3d-assets/a.png"); assert_eq!(sources[1], "data:image/png;base64,b"); assert!(!sources.contains(&"/generated-match3d-assets/g.png".to_string())); } #[test] fn match3d_public_reference_image_paths_are_limited_to_known_assets() { assert_eq!( normalize_match3d_public_reference_image_path( "/match3d-background-references/pot-fused-reference.png?cache=1" ) .as_deref(), Some("public/match3d-background-references/pot-fused-reference.png") ); assert!(normalize_match3d_public_reference_image_path("/icons/logo.png").is_none()); assert!( normalize_match3d_public_reference_image_path( "/match3d-background-references/../secret.png" ) .is_none() ); } #[test] fn match3d_cover_reference_prompt_marks_reference_images() { let prompt = build_match3d_cover_reference_generation_prompt("水果封面", true); assert!(prompt.contains("一张或多张图片")); assert!(prompt.contains("不要拼贴成素材墙")); assert!(prompt.contains("水果封面")); } #[test] fn match3d_cover_edit_prompt_preserves_uploaded_image() { let prompt = build_match3d_cover_edit_prompt("水果封面"); assert!(prompt.contains("上传的封面图作为第一优先级")); assert!(prompt.contains("保留主图的主体、构图、视角和主要配色")); } #[test] fn match3d_fallback_work_profile_keeps_generated_background_asset() { let assets = vec![Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), image_src: None, image_object_key: None, image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: Some(Match3DGeneratedBackgroundAsset { prompt: "果园背景".to_string(), image_src: Some( "/generated-match3d-assets/session/profile/background/background.png" .to_string(), ), image_object_key: Some( "generated-match3d-assets/session/profile/background/background.png" .to_string(), ), container_prompt: Some("果园容器".to_string()), container_image_src: Some( "/generated-match3d-assets/session/profile/ui-container/container.png" .to_string(), ), container_image_object_key: Some( "generated-match3d-assets/session/profile/ui-container/container.png" .to_string(), ), status: "image_ready".to_string(), error: None, }), status: "image_ready".to_string(), error: None, }]; let profile = build_match3d_work_profile_record_with_assets( Match3DWorkProfileRecord { work_id: "match3d-profile-1".to_string(), profile_id: "match3d-profile-1".to_string(), owner_user_id: "user-1".to_string(), source_session_id: Some("match3d-session-1".to_string()), author_display_name: "玩家".to_string(), game_name: "水果抓大鹅".to_string(), theme_text: "水果".to_string(), summary: "水果主题".to_string(), tags: vec!["水果".to_string()], cover_image_src: None, cover_asset_id: None, reference_image_src: None, clear_count: 3, difficulty: 3, publication_status: "draft".to_string(), play_count: 0, updated_at: "2026-05-14T00:00:00Z".to_string(), published_at: None, publish_ready: false, generated_item_assets_json: None, }, &assets, ); let response = map_match3d_work_summary_response(profile); assert_eq!( response.background_image_src.as_deref(), Some("/generated-match3d-assets/session/profile/background/background.png") ); assert_eq!( response.cover_image_src.as_deref(), Some("/generated-match3d-assets/session/profile/ui-container/container.png") ); assert_eq!(response.generated_item_assets.len(), 1); assert_eq!( response .generated_background_asset .as_ref() .and_then(|asset| asset.container_image_src.as_deref()), Some("/generated-match3d-assets/session/profile/ui-container/container.png") ); } #[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_plan_tags_are_kept_before_local_fallback_tags() { let tags = merge_match3d_plan_tags_with_fallback( "果园大鹅宴", "水果", &["果园".to_string(), "轻快".to_string(), "抓大鹅".to_string()], ); assert_eq!(tags[0], "果园"); assert_eq!(tags[1], "轻快"); assert_eq!(tags[2], "抓大鹅"); assert!(tags.contains(&"水果".to_string())); assert!(tags.contains(&"经典消除".to_string())); } #[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_requires_valid_glb_header() { let mut glb = Vec::new(); glb.extend_from_slice(&0x4654_6c67_u32.to_le_bytes()); glb.extend_from_slice(&2_u32.to_le_bytes()); glb.extend_from_slice(&12_u32.to_le_bytes()); assert!(is_match3d_glb_binary_payload(&glb)); assert!(!is_match3d_glb_binary_payload(b"expired")); let mut wrong_length = glb.clone(); wrong_length[8..12].copy_from_slice(&16_u32.to_le_bytes()); assert!(!is_match3d_glb_binary_payload(&wrong_length)); } #[test] fn match3d_generated_asset_resume_keeps_stable_item_order() { let assets = normalize_match3d_generated_item_assets_for_resume(vec![ Match3DGeneratedItemAsset { item_id: "match3d-item-2".to_string(), item_name: "苹果".to_string(), image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), image_object_key: Some( "generated-match3d-assets/s/p/items/i2/image.png".to_string(), ), image_views: Vec::new(), model_src: Some( "/generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), ), model_object_key: Some( "generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), ), model_file_name: Some("model.glb".to_string()), task_uuid: Some("task-2".to_string()), subscription_key: Some("sub-2".to_string()), sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "model_ready".to_string(), error: None, }, Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), image_object_key: Some( "generated-match3d-assets/s/p/items/i1/image.png".to_string(), ), image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }, ]); assert_eq!(assets[0].item_id, "match3d-item-1"); assert_eq!(assets[1].item_id, "match3d-item-2"); } #[test] fn match3d_required_item_images_require_five_views() { let assets = vec![ Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), image_object_key: Some( "generated-match3d-assets/s/p/items/i1/image.png".to_string(), ), image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }, Match3DGeneratedItemAsset { item_id: "match3d-item-2".to_string(), item_name: "苹果".to_string(), image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), image_object_key: Some( "generated-match3d-assets/s/p/items/i2/image.png".to_string(), ), image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }, Match3DGeneratedItemAsset { item_id: "match3d-item-3".to_string(), item_name: "香蕉".to_string(), image_src: Some("/generated-match3d-assets/s/p/items/i3/image.png".to_string()), image_object_key: None, image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }, ]; assert!(!has_match3d_required_item_images(&assets, 3)); let five_view_assets = (1..=3) .map(|index| Match3DGeneratedItemAsset { item_id: format!("match3d-item-{index}"), item_name: format!("物品{index}"), image_src: Some(format!( "/generated-match3d-assets/s/p/items/i{index}/views/view-01.png" )), image_object_key: Some(format!( "generated-match3d-assets/s/p/items/i{index}/views/view-01.png" )), image_views: (1..=MATCH3D_ITEM_VIEW_COUNT) .map(|view_index| Match3DGeneratedItemImageView { view_id: format!("view-{view_index:02}"), view_index: view_index as u32, image_src: Some(format!( "/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" )), image_object_key: Some(format!( "generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" )), }) .collect(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }) .collect::>(); assert!(has_match3d_required_item_images(&five_view_assets, 3)); } #[test] fn match3d_oss_config_error_lists_missing_env_keys() { let mut app_config = AppConfig { oss_bucket: Some("genarrative-assets".to_string()), oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), ..AppConfig::default() }; let missing = missing_match3d_oss_env_keys(&app_config); assert_eq!( missing, vec!["ALIYUN_OSS_ACCESS_KEY_ID", "ALIYUN_OSS_ACCESS_KEY_SECRET"] ); assert_eq!( match3d_oss_missing_reason(&missing), "OSS 未完成环境变量配置,缺少:ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET" ); app_config.oss_access_key_id = Some("ak".to_string()); app_config.oss_access_key_secret = Some("sk".to_string()); assert!(missing_match3d_oss_env_keys(&app_config).is_empty()); } #[test] fn match3d_work_summary_maps_persisted_generated_item_assets() { let response = map_match3d_work_summary_response(Match3DWorkProfileRecord { work_id: "match3d-profile-1".to_string(), profile_id: "match3d-profile-1".to_string(), owner_user_id: "user-1".to_string(), source_session_id: Some("match3d-session-1".to_string()), author_display_name: "玩家".to_string(), game_name: "水果抓大鹅".to_string(), theme_text: "水果".to_string(), summary: "水果主题".to_string(), tags: vec!["水果".to_string()], cover_image_src: None, cover_asset_id: None, reference_image_src: None, clear_count: 3, difficulty: 3, publication_status: "draft".to_string(), play_count: 0, updated_at: "2026-05-10T00:00:00.000Z".to_string(), published_at: None, publish_ready: false, generated_item_assets_json: Some( r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png","imageObjectKey":"generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png","status":"image_ready"}]"# .to_string(), ), }); assert_eq!(response.generated_item_assets.len(), 1); assert_eq!(response.generated_item_assets[0].item_name, "草莓"); assert_eq!(response.generated_item_assets[0].status, "image_ready"); assert_eq!( response.generated_item_assets[0].image_src.as_deref(), Some("/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png") ); } }