8176 lines
286 KiB
Rust
8176 lines
286 KiB
Rust
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<String>,
|
||
clear_count: u32,
|
||
difficulty: u32,
|
||
#[serde(default)]
|
||
asset_style_id: Option<String>,
|
||
#[serde(default)]
|
||
asset_style_label: Option<String>,
|
||
#[serde(default)]
|
||
asset_style_prompt: Option<String>,
|
||
#[serde(default)]
|
||
generate_click_sound: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct Match3DGeneratedItemAsset {
|
||
item_id: String,
|
||
item_name: String,
|
||
image_src: Option<String>,
|
||
image_object_key: Option<String>,
|
||
image_views: Vec<Match3DGeneratedItemImageView>,
|
||
model_src: Option<String>,
|
||
model_object_key: Option<String>,
|
||
model_file_name: Option<String>,
|
||
task_uuid: Option<String>,
|
||
subscription_key: Option<String>,
|
||
sound_prompt: Option<String>,
|
||
background_music_title: Option<String>,
|
||
background_music_style: Option<String>,
|
||
background_music_prompt: Option<String>,
|
||
background_music: Option<CreationAudioAsset>,
|
||
click_sound: Option<CreationAudioAsset>,
|
||
background_asset: Option<Match3DGeneratedBackgroundAsset>,
|
||
status: String,
|
||
error: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct Match3DGeneratedItemImageView {
|
||
view_id: String,
|
||
view_index: u32,
|
||
#[serde(default)]
|
||
image_src: Option<String>,
|
||
#[serde(default)]
|
||
image_object_key: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct Match3DGeneratedBackgroundAsset {
|
||
prompt: String,
|
||
#[serde(default)]
|
||
image_src: Option<String>,
|
||
#[serde(default)]
|
||
image_object_key: Option<String>,
|
||
#[serde(default)]
|
||
container_prompt: Option<String>,
|
||
#[serde(default)]
|
||
container_image_src: Option<String>,
|
||
#[serde(default)]
|
||
container_image_object_key: Option<String>,
|
||
status: String,
|
||
#[serde(default)]
|
||
error: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct Match3DGeneratedWorkMetadata {
|
||
game_name: String,
|
||
summary: String,
|
||
tags: Vec<String>,
|
||
}
|
||
|
||
#[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<Match3DGeneratedItemPlan>,
|
||
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<String>,
|
||
#[serde(default)]
|
||
image_object_key: Option<String>,
|
||
#[serde(default)]
|
||
image_views: Vec<Match3DGeneratedItemImageView>,
|
||
#[serde(default)]
|
||
model_src: Option<String>,
|
||
#[serde(default)]
|
||
model_object_key: Option<String>,
|
||
#[serde(default)]
|
||
model_file_name: Option<String>,
|
||
#[serde(default)]
|
||
task_uuid: Option<String>,
|
||
#[serde(default)]
|
||
subscription_key: Option<String>,
|
||
#[serde(default)]
|
||
sound_prompt: Option<String>,
|
||
#[serde(default)]
|
||
background_music_title: Option<String>,
|
||
#[serde(default)]
|
||
background_music_style: Option<String>,
|
||
#[serde(default)]
|
||
background_music_prompt: Option<String>,
|
||
#[serde(default)]
|
||
background_music: Option<CreationAudioAsset>,
|
||
#[serde(default)]
|
||
click_sound: Option<CreationAudioAsset>,
|
||
#[serde(default)]
|
||
background_asset: Option<Match3DGeneratedBackgroundAsset>,
|
||
status: String,
|
||
#[serde(default)]
|
||
error: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct Match3DAssetUpload {
|
||
src: String,
|
||
object_key: String,
|
||
}
|
||
|
||
struct Match3DDownloadedModel {
|
||
bytes: Vec<u8>,
|
||
file_name: String,
|
||
content_type: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct CompileMatch3DDraftRequest {
|
||
#[serde(default)]
|
||
game_name: Option<String>,
|
||
#[serde(default)]
|
||
summary: Option<String>,
|
||
#[serde(default)]
|
||
tags: Option<Vec<String>>,
|
||
#[serde(default)]
|
||
cover_image_src: Option<String>,
|
||
#[serde(default)]
|
||
generate_click_sound: Option<bool>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct GenerateMatch3DWorkTagsRequest {
|
||
game_name: String,
|
||
theme_text: String,
|
||
#[serde(default)]
|
||
summary: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct GenerateMatch3DWorkTagsResponse {
|
||
tags: Vec<String>,
|
||
}
|
||
|
||
struct Match3DWorkAssetContext {
|
||
owner_user_id: String,
|
||
session_id: String,
|
||
profile: Match3DWorkProfileRecord,
|
||
config: Match3DConfigJson,
|
||
assets: Vec<Match3DGeneratedItemAsset>,
|
||
}
|
||
|
||
struct Match3DItemAssetAppendPlan {
|
||
requested_item_names: Vec<String>,
|
||
padded_item_names: Vec<String>,
|
||
}
|
||
|
||
struct Match3DItemAssetReplacePlan {
|
||
requested_item_names: Vec<String>,
|
||
padded_item_names: Vec<String>,
|
||
target_assets: Vec<Match3DGeneratedItemAsset>,
|
||
}
|
||
|
||
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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<CreateMatch3DAgentSessionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<SendMatch3DAgentMessageRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<SendMatch3DAgentMessageRequest>, JsonRejection>,
|
||
) -> Result<Response, Response> {
|
||
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::<Event, Infallible>(match3d_sse_json_event_or_error(
|
||
"reply_delta",
|
||
json!({ "text": reply }),
|
||
));
|
||
}
|
||
yield Ok::<Event, Infallible>(match3d_sse_json_event_or_error(
|
||
"session",
|
||
json!({ "session": session_response }),
|
||
));
|
||
yield Ok::<Event, Infallible>(match3d_sse_json_event_or_error(
|
||
"done",
|
||
json!({ "ok": true }),
|
||
));
|
||
}
|
||
Err(response) => {
|
||
yield Ok::<Event, Infallible>(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<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<ExecuteMatch3DAgentActionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<CompileMatch3DDraftRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<PutMatch3DWorkRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<PutMatch3DAudioAssetsRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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::<Vec<_>>();
|
||
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<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<PersistMatch3DGeneratedModelRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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::<Vec<_>>();
|
||
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<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<GenerateMatch3DCoverImageRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<Match3DWorkProfileRecord, Response> {
|
||
// 中文注释:封面生成是定向图片槽位更新,不能复用草稿编译路径重算题材、难度或素材 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<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<GenerateMatch3DBackgroundImageRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<GenerateMatch3DContainerImageRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<GenerateMatch3DItemAssetsRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<GenerateMatch3DWorkTagsRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
|
||
let tags = generate_match3d_work_tags_for_profile(
|
||
&state,
|
||
payload.game_name.as_str(),
|
||
payload.theme_text.as_str(),
|
||
payload.summary.as_deref(),
|
||
)
|
||
.await;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
GenerateMatch3DWorkTagsResponse { tags },
|
||
))
|
||
}
|
||
|
||
pub async fn publish_match3d_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<StartMatch3DRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<ClickMatch3DItemRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<StopMatch3DRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<Match3DAgentSessionRecord, Response> {
|
||
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<String>,
|
||
summary: Option<String>,
|
||
tags: Option<Vec<String>>,
|
||
cover_image_src: Option<String>,
|
||
generate_click_sound: Option<bool>,
|
||
) -> Result<(Match3DAgentSessionRecord, Vec<Match3DGeneratedItemAsset>), 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<T, Fut>(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
owner_user_id: &str,
|
||
billing_asset_id: &str,
|
||
operation: Fut,
|
||
) -> Result<T, Response>
|
||
where
|
||
Fut: Future<Output = Result<T, Response>>,
|
||
{
|
||
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<bool, Response> {
|
||
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<String>,
|
||
summary_text: Option<String>,
|
||
tags_json: Option<String>,
|
||
cover_image_src: Option<String>,
|
||
cover_asset_id: Option<String>,
|
||
generated_item_assets_json: Option<String>,
|
||
) -> Result<Match3DAgentSessionRecord, Response> {
|
||
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<Match3DGeneratedItemAsset> {
|
||
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<String> {
|
||
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<Match3DWorkAssetContext, Response> {
|
||
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::<Vec<_>>();
|
||
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<String> {
|
||
value
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(str::to_string)
|
||
}
|
||
|
||
fn serialize_match3d_config(config: &Match3DConfigJson) -> Option<String> {
|
||
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<String> {
|
||
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<u32> {
|
||
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<u32> {
|
||
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::<u32>().ok().filter(|value| *value > 0)
|
||
}
|
||
|
||
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||
let mut result: Vec<String> = 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<String>) -> Option<String> {
|
||
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<String> {
|
||
if assets.is_empty() {
|
||
return None;
|
||
}
|
||
let items = assets
|
||
.iter()
|
||
.cloned()
|
||
.map(Match3DGeneratedItemAssetJson::from)
|
||
.collect::<Vec<_>>();
|
||
serde_json::to_string(&items).ok()
|
||
}
|
||
|
||
fn parse_match3d_generated_item_assets(value: Option<&str>) -> Vec<Match3DGeneratedItemAssetJson> {
|
||
value
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.and_then(|value| serde_json::from_str::<Vec<Match3DGeneratedItemAssetJson>>(value).ok())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
impl From<Match3DGeneratedItemAsset> 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<Match3DGeneratedItemAssetJson> 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<shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse>
|
||
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<Match3DGeneratedItemPlan>,
|
||
background_music_plan: Match3DGeneratedBackgroundMusicPlan,
|
||
existing_assets: Vec<Match3DGeneratedItemAsset>,
|
||
) -> Result<Vec<Match3DGeneratedItemAsset>, 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<Match3DGeneratedItemPlan>,
|
||
existing_assets: Vec<Match3DGeneratedItemAsset>,
|
||
) -> Result<Vec<Match3DGeneratedItemAsset>, 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::<Vec<_>>();
|
||
|
||
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<String>,
|
||
background_music_style: Option<String>,
|
||
background_music_prompt: Option<String>,
|
||
background_asset: Option<Match3DGeneratedBackgroundAsset>,
|
||
}
|
||
|
||
struct Match3DMaterialBatchOutput {
|
||
task_id: String,
|
||
generated_at_micros: i64,
|
||
items: Vec<(Match3DItemImageGenerationSeed, Vec<Match3DSlicedItemImage>)>,
|
||
}
|
||
|
||
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<Match3DItemImageGenerationSeed>,
|
||
) -> Result<Vec<Match3DGeneratedItemImageAssetOutput>, 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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
let persisted_item_names = persisted_seeds
|
||
.iter()
|
||
.map(|item| item.item_name.clone())
|
||
.collect::<Vec<_>>();
|
||
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::<Vec<_>>(),
|
||
})
|
||
}
|
||
})
|
||
.collect::<FuturesUnordered<_>>();
|
||
|
||
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<Match3DGeneratedItemAsset>,
|
||
) -> Result<Vec<Match3DGeneratedItemAsset>, 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<Match3DGeneratedItemAsset>,
|
||
) -> Result<Vec<Match3DGeneratedItemAsset>, 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::<Vec<_>>();
|
||
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::<FuturesUnordered<_>>();
|
||
|
||
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<Match3DGeneratedItemAsset>,
|
||
) -> Result<Vec<Match3DGeneratedItemAsset>, 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<CreationAudioAsset, AppError> {
|
||
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<CreationAudioAsset, AppError> {
|
||
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<Match3DGeneratedItemAsset>,
|
||
) -> Result<Vec<Match3DGeneratedItemAsset>, 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::<Vec<_>>();
|
||
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<Match3DGeneratedItemAsset>,
|
||
) -> Result<Vec<Match3DGeneratedItemAsset>, 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::<std::collections::HashMap<_, _>>();
|
||
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::<Vec<_>>();
|
||
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<u8>,
|
||
}
|
||
|
||
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<Match3DGeneratedDraftPlan> {
|
||
let raw = raw.trim();
|
||
let json_text = if let Some(start) = raw.find('{')
|
||
&& let Some(end) = raw.rfind('}')
|
||
&& end > start
|
||
{
|
||
&raw[start..=end]
|
||
} else {
|
||
raw
|
||
};
|
||
let value = serde_json::from_str::<Value>(json_text).ok()?;
|
||
let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?);
|
||
if game_name.is_empty() {
|
||
return None;
|
||
}
|
||
let tags = value
|
||
.get("tags")
|
||
.and_then(Value::as_array)
|
||
.map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str)))
|
||
.unwrap_or_default();
|
||
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::<Vec<_>>()
|
||
})
|
||
.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<Match3DGeneratedWorkMetadata> {
|
||
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::<String>()
|
||
.trim()
|
||
.to_string()
|
||
}
|
||
|
||
fn normalize_match3d_work_summary(raw: &str) -> String {
|
||
raw.trim()
|
||
.trim_matches(['"', '\'', '“', '”'])
|
||
.split_whitespace()
|
||
.collect::<Vec<_>>()
|
||
.join("")
|
||
.chars()
|
||
.filter(|character| !character.is_control())
|
||
.take(80)
|
||
.collect::<String>()
|
||
.trim()
|
||
.to_string()
|
||
}
|
||
|
||
fn normalize_match3d_audio_title(raw: &str) -> String {
|
||
raw.trim()
|
||
.trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、'])
|
||
.chars()
|
||
.filter(|character| !character.is_control())
|
||
.take(40)
|
||
.collect::<String>()
|
||
.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::<Vec<_>>()
|
||
.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::<Vec<_>>();
|
||
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::<String>()
|
||
.trim()
|
||
.to_string()
|
||
}
|
||
|
||
fn fallback_match3d_item_names(theme_text: &str) -> Vec<String> {
|
||
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<Match3DGeneratedItemPlan>,
|
||
) -> Vec<Match3DGeneratedItemPlan> {
|
||
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<Match3DGeneratedItemPlan>,
|
||
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<String>) -> Vec<String> {
|
||
let mut normalized: Vec<String> = 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<String>,
|
||
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<String>,
|
||
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::<Vec<_>>();
|
||
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<String> {
|
||
let mut padded = item_names
|
||
.iter()
|
||
.take(available_capacity)
|
||
.cloned()
|
||
.collect::<Vec<_>>();
|
||
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<String>,
|
||
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::<String>()
|
||
.trim()
|
||
.to_string()
|
||
}
|
||
|
||
fn normalize_match3d_audio_prompt(raw: &str) -> String {
|
||
raw.trim()
|
||
.chars()
|
||
.filter(|character| !character.is_control())
|
||
.take(500)
|
||
.collect::<String>()
|
||
.trim()
|
||
.to_string()
|
||
}
|
||
|
||
fn normalize_match3d_background_prompt(raw: &str) -> String {
|
||
raw.trim()
|
||
.chars()
|
||
.filter(|character| !character.is_control())
|
||
.take(900)
|
||
.collect::<String>()
|
||
.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<Match3DGeneratedItemAsset>,
|
||
) -> Vec<Match3DGeneratedItemAsset> {
|
||
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<Match3DGeneratedItemAsset>,
|
||
) -> Vec<Match3DGeneratedItemAsset> {
|
||
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::<u32>().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<Match3DGeneratedItemAsset>,
|
||
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<Match3DGeneratedItemAsset>,
|
||
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<Match3DGeneratedItemAsset>,
|
||
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
|
||
let normalized_prompt = normalize_match3d_background_prompt(background_prompt);
|
||
let resolved_prompt = if normalized_prompt.is_empty() {
|
||
build_fallback_match3d_background_prompt(config)
|
||
} else {
|
||
normalized_prompt
|
||
};
|
||
if let Some(existing_background) = find_match3d_generated_background_asset(&assets) {
|
||
if is_match3d_background_asset_ready(&existing_background) {
|
||
return Ok(assets);
|
||
}
|
||
}
|
||
|
||
let generated_background = generate_match3d_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<Match3DGeneratedItemAsset>,
|
||
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<String>,
|
||
reference_image_srcs: Vec<String>,
|
||
) -> Result<Match3DAssetUpload, AppError> {
|
||
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<Match3DGeneratedBackgroundAsset, AppError> {
|
||
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<Match3DGeneratedBackgroundAsset, AppError> {
|
||
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<OpenAiReferenceImage, AppError> {
|
||
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<DownloadedOpenAiImage, AppError> {
|
||
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<Match3DMaterialSheet, AppError> {
|
||
let settings = require_match3d_vector_engine_gemini_image_settings(state)?;
|
||
let http_client = build_match3d_vector_engine_gemini_image_http_client(&settings)?;
|
||
let prompt = build_match3d_material_sheet_prompt(config, item_names);
|
||
let negative_prompt = build_match3d_material_sheet_negative_prompt(config);
|
||
let generated = create_match3d_vector_engine_gemini_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
prompt.as_str(),
|
||
negative_prompt.as_str(),
|
||
"抓大鹅素材图生成失败",
|
||
)
|
||
.await?;
|
||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine-gemini",
|
||
"message": "抓大鹅素材图生成失败:未返回图片",
|
||
}))
|
||
})?;
|
||
|
||
Ok(Match3DMaterialSheet {
|
||
task_id: generated.task_id,
|
||
image,
|
||
})
|
||
}
|
||
|
||
fn require_match3d_vector_engine_gemini_image_settings(
|
||
state: &AppState,
|
||
) -> Result<Match3DVectorEngineGeminiImageSettings, AppError> {
|
||
let base_url = state
|
||
.config
|
||
.vector_engine_base_url
|
||
.trim()
|
||
.trim_end_matches('/');
|
||
if base_url.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "vector-engine-gemini",
|
||
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||
})),
|
||
);
|
||
}
|
||
|
||
let api_key = state
|
||
.config
|
||
.vector_engine_api_key
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "vector-engine-gemini",
|
||
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||
}))
|
||
})?;
|
||
|
||
Ok(Match3DVectorEngineGeminiImageSettings {
|
||
base_url: base_url.to_string(),
|
||
api_key: api_key.to_string(),
|
||
request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1),
|
||
})
|
||
}
|
||
|
||
fn build_match3d_vector_engine_gemini_image_http_client(
|
||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||
) -> Result<reqwest::Client, AppError> {
|
||
reqwest::Client::builder()
|
||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||
.build()
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "vector-engine-gemini",
|
||
"message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"),
|
||
}))
|
||
})
|
||
}
|
||
|
||
async fn create_match3d_vector_engine_gemini_image_generation(
|
||
http_client: &reqwest::Client,
|
||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||
prompt: &str,
|
||
negative_prompt: &str,
|
||
failure_context: &str,
|
||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||
let request_body = build_match3d_vector_engine_gemini_image_request_body(
|
||
prompt,
|
||
negative_prompt,
|
||
MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO,
|
||
);
|
||
let response = http_client
|
||
.post(build_match3d_vector_engine_gemini_generate_content_url(
|
||
settings,
|
||
))
|
||
.query(&[("key", settings.api_key.as_str())])
|
||
.header(header::ACCEPT, "application/json")
|
||
.header(header::CONTENT_TYPE, "application/json")
|
||
.json(&request_body)
|
||
.send()
|
||
.await
|
||
.map_err(|error| {
|
||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||
"{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}"
|
||
))
|
||
})?;
|
||
let status = response.status();
|
||
let response_text = response.text().await.map_err(|error| {
|
||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||
"{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}"
|
||
))
|
||
})?;
|
||
if !status.is_success() {
|
||
return Err(map_match3d_vector_engine_gemini_image_upstream_error(
|
||
status,
|
||
response_text.as_str(),
|
||
failure_context,
|
||
));
|
||
}
|
||
|
||
let payload = parse_match3d_json_payload(
|
||
response_text.as_str(),
|
||
"解析抓大鹅 VectorEngine Gemini 图片生成响应失败",
|
||
"vector-engine-gemini",
|
||
)?;
|
||
let image_urls = extract_match3d_image_urls(&payload);
|
||
if !image_urls.is_empty() {
|
||
return download_match3d_images_from_urls(
|
||
http_client,
|
||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||
image_urls,
|
||
1,
|
||
"vector-engine-gemini",
|
||
)
|
||
.await;
|
||
}
|
||
|
||
let b64_images = extract_match3d_b64_images(&payload);
|
||
if !b64_images.is_empty() {
|
||
return Ok(match3d_images_from_base64(
|
||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||
b64_images,
|
||
1,
|
||
));
|
||
}
|
||
|
||
Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine-gemini",
|
||
"message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片",
|
||
"rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800),
|
||
})),
|
||
)
|
||
}
|
||
|
||
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<String>,
|
||
candidate_count: u32,
|
||
provider: &str,
|
||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
|
||
for image_url in image_urls
|
||
.into_iter()
|
||
.take(candidate_count.clamp(1, 4) as usize)
|
||
{
|
||
images
|
||
.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
|
||
}
|
||
Ok(OpenAiGeneratedImages {
|
||
task_id,
|
||
actual_prompt: None,
|
||
images,
|
||
})
|
||
}
|
||
|
||
async fn download_match3d_remote_image(
|
||
http_client: &reqwest::Client,
|
||
image_url: &str,
|
||
provider: &str,
|
||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": provider,
|
||
"message": format!("下载抓大鹅生成图片失败:{error}"),
|
||
}))
|
||
})?;
|
||
let status = response.status();
|
||
let content_type = response
|
||
.headers()
|
||
.get(header::CONTENT_TYPE)
|
||
.and_then(|value| value.to_str().ok())
|
||
.unwrap_or("image/png")
|
||
.to_string();
|
||
let body = response.bytes().await.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": provider,
|
||
"message": format!("读取抓大鹅生成图片内容失败:{error}"),
|
||
}))
|
||
})?;
|
||
if !status.is_success() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": provider,
|
||
"message": "下载抓大鹅生成图片失败",
|
||
"status": status.as_u16(),
|
||
})),
|
||
);
|
||
}
|
||
|
||
let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str());
|
||
Ok(DownloadedOpenAiImage {
|
||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||
mime_type,
|
||
bytes: body.to_vec(),
|
||
})
|
||
}
|
||
|
||
fn match3d_images_from_base64(
|
||
task_id: String,
|
||
b64_images: Vec<String>,
|
||
candidate_count: u32,
|
||
) -> OpenAiGeneratedImages {
|
||
let images = b64_images
|
||
.into_iter()
|
||
.take(candidate_count.clamp(1, 4) as usize)
|
||
.filter_map(|raw| decode_match3d_base64_image(raw.as_str()))
|
||
.collect();
|
||
OpenAiGeneratedImages {
|
||
task_id,
|
||
actual_prompt: None,
|
||
images,
|
||
}
|
||
}
|
||
|
||
fn decode_match3d_base64_image(raw: &str) -> Option<DownloadedOpenAiImage> {
|
||
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
|
||
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
|
||
Some(DownloadedOpenAiImage {
|
||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||
mime_type,
|
||
bytes,
|
||
})
|
||
}
|
||
|
||
fn parse_match3d_json_payload(
|
||
raw_text: &str,
|
||
failure_context: &str,
|
||
provider: &str,
|
||
) -> Result<Value, AppError> {
|
||
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": provider,
|
||
"message": format!("{failure_context}:{error}"),
|
||
"rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800),
|
||
}))
|
||
})
|
||
}
|
||
|
||
fn extract_match3d_image_urls(payload: &Value) -> Vec<String> {
|
||
let mut urls = Vec::new();
|
||
collect_match3d_strings_by_key(payload, "url", &mut urls);
|
||
collect_match3d_strings_by_key(payload, "image", &mut urls);
|
||
collect_match3d_strings_by_key(payload, "image_url", &mut urls);
|
||
let mut deduped = Vec::new();
|
||
for url in urls {
|
||
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
|
||
deduped.push(url);
|
||
}
|
||
}
|
||
deduped
|
||
}
|
||
|
||
fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
|
||
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<String>) {
|
||
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<String> {
|
||
let mut results = Vec::new();
|
||
collect_match3d_strings_by_key(payload, target_key, &mut results);
|
||
results.into_iter().next()
|
||
}
|
||
|
||
fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
|
||
match payload {
|
||
Value::Array(entries) => {
|
||
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::<Value>(trimmed) {
|
||
for key in ["message", "code"] {
|
||
if let Some(value) = find_first_match3d_string_by_key(&payload, key) {
|
||
return if key == "message" {
|
||
value
|
||
} else {
|
||
format!("{fallback_message}({value})")
|
||
};
|
||
}
|
||
}
|
||
}
|
||
trimmed.to_string()
|
||
}
|
||
|
||
fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
|
||
raw_text.chars().take(max_chars).collect()
|
||
}
|
||
|
||
fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String {
|
||
let mime_type = content_type
|
||
.split(';')
|
||
.next()
|
||
.map(str::trim)
|
||
.unwrap_or("image/png");
|
||
match mime_type {
|
||
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
|
||
mime_type.to_string()
|
||
}
|
||
_ => "image/png".to_string(),
|
||
}
|
||
}
|
||
|
||
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<Match3DDownloadedModel, AppError> {
|
||
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<Vec<u8>, 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<Option<String>, 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<String> {
|
||
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<String>,
|
||
reference_image_srcs: Vec<String>,
|
||
) -> Vec<String> {
|
||
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<String>,
|
||
max_size_bytes: usize,
|
||
) -> Result<Vec<String>, 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<Option<OpenAiReferenceImage>, 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<Vec<u8>, 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::<Vec<_>>()
|
||
.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<String> {
|
||
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<Vec<Vec<Match3DSlicedItemImage>>, 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<Match3DMaterialCellBounds> {
|
||
let background = sample_match3d_material_cell_background(source, cell);
|
||
let mut foreground: Option<Match3DMaterialCellBounds> = 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::<usize>::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<usize>| {
|
||
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::<usize>::new();
|
||
let mut queue_index = 0usize;
|
||
|
||
let seed_pixel = |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec<usize>| {
|
||
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<u8>,
|
||
asset_kind: &str,
|
||
source_job_id: Option<&str>,
|
||
generated_at_micros: i64,
|
||
) -> Result<Match3DAssetUpload, AppError> {
|
||
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::<String>();
|
||
let collapsed = normalized
|
||
.split('-')
|
||
.filter(|part| !part.is_empty())
|
||
.collect::<Vec<_>>()
|
||
.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<T>(
|
||
payload: Result<Json<T>, JsonRejection>,
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
) -> Result<Json<T>, 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, AppError> {
|
||
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::<Vec<_>>();
|
||
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
|
||
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"<html>expired</html>"));
|
||
|
||
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::<Vec<_>>();
|
||
|
||
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")
|
||
);
|
||
}
|
||
}
|