1
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
convert::Infallible,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
@@ -12,18 +13,25 @@ use axum::{
|
||||
sse::{Event, Sse},
|
||||
},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
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::{
|
||||
hyper3d as hyper3d_contract,
|
||||
match3d_agent::{
|
||||
CreateMatch3DAgentSessionRequest, ExecuteMatch3DAgentActionRequest,
|
||||
Match3DAgentActionResponse, Match3DAgentMessageResponse, Match3DAgentSessionResponse,
|
||||
Match3DAgentSessionSnapshotResponse, Match3DAnchorItemResponse, Match3DAnchorPackResponse,
|
||||
Match3DCreatorConfigResponse, Match3DResultDraftResponse, SendMatch3DAgentMessageRequest,
|
||||
Match3DCreatorConfigResponse,
|
||||
Match3DGeneratedItemAssetResponse as Match3DAgentGeneratedItemAssetResponse,
|
||||
Match3DResultDraftResponse, SendMatch3DAgentMessageRequest,
|
||||
},
|
||||
match3d_runtime::{
|
||||
ClickMatch3DItemRequest, Match3DClickConfirmationResponse, Match3DClickResponse,
|
||||
@@ -51,6 +59,12 @@ use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
hyper3d_generation::{query_downloads, query_task_status, submit_image_to_model},
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
platform_errors::{map_llm_error, map_oss_error},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
@@ -62,6 +76,12 @@ 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_GENERATED_ITEM_COUNT: usize = 3;
|
||||
const MATCH3D_GENERATED_CLEAR_COUNT: u32 = 3;
|
||||
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 2;
|
||||
const MATCH3D_RODIN_STATUS_MAX_ATTEMPTS: usize = 36;
|
||||
const MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS: u64 = 5_000;
|
||||
const MATCH3D_RODIN_MAX_MODEL_BYTES: usize = 120 * 1024 * 1024;
|
||||
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
|
||||
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
|
||||
const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作的关卡是难度几";
|
||||
@@ -73,6 +93,70 @@ struct Match3DConfigJson {
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Match3DGeneratedItemAsset {
|
||||
item_id: String,
|
||||
item_name: String,
|
||||
image_src: Option<String>,
|
||||
image_object_key: Option<String>,
|
||||
model_src: Option<String>,
|
||||
model_object_key: Option<String>,
|
||||
model_file_name: Option<String>,
|
||||
task_uuid: Option<String>,
|
||||
subscription_key: Option<String>,
|
||||
status: String,
|
||||
error: Option<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)]
|
||||
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>,
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Match3DAssetUpload {
|
||||
src: String,
|
||||
object_key: String,
|
||||
}
|
||||
|
||||
struct Match3DRodinModelAsset {
|
||||
task_uuid: String,
|
||||
subscription_key: String,
|
||||
model_file_name: String,
|
||||
upload: Match3DAssetUpload,
|
||||
}
|
||||
|
||||
struct Match3DDownloadedModel {
|
||||
bytes: Vec<u8>,
|
||||
file_name: String,
|
||||
content_type: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -265,7 +349,7 @@ pub async fn execute_match3d_agent_action(
|
||||
));
|
||||
}
|
||||
|
||||
let session = compile_match3d_draft_for_session(
|
||||
let (session, generated_item_assets) = compile_match3d_draft_for_session(
|
||||
&state,
|
||||
&request_context,
|
||||
&authenticated,
|
||||
@@ -280,7 +364,10 @@ pub async fn execute_match3d_agent_action(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
Match3DAgentActionResponse {
|
||||
session: map_match3d_agent_session_response(session),
|
||||
session: map_match3d_agent_session_response_with_assets(
|
||||
session,
|
||||
&generated_item_assets,
|
||||
),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -307,7 +394,7 @@ pub async fn compile_match3d_agent_draft(
|
||||
"sessionId",
|
||||
)?;
|
||||
|
||||
let session = compile_match3d_draft_for_session(
|
||||
let (session, generated_item_assets) = compile_match3d_draft_for_session(
|
||||
&state,
|
||||
&request_context,
|
||||
&authenticated,
|
||||
@@ -322,7 +409,10 @@ pub async fn compile_match3d_agent_draft(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
Match3DAgentActionResponse {
|
||||
session: map_match3d_agent_session_response(session),
|
||||
session: map_match3d_agent_session_response_with_assets(
|
||||
session,
|
||||
&generated_item_assets,
|
||||
),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -876,7 +966,7 @@ async fn compile_match3d_draft_for_session(
|
||||
summary: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
cover_image_src: Option<String>,
|
||||
) -> Result<Match3DAgentSessionRecord, Response> {
|
||||
) -> Result<(Match3DAgentSessionRecord, Vec<Match3DGeneratedItemAsset>), Response> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
@@ -889,7 +979,14 @@ async fn compile_match3d_draft_for_session(
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})?;
|
||||
if session.current_turn < 3 || session.progress_percent < 100 {
|
||||
let mut config = resolve_config_or_default(session.config.as_ref());
|
||||
config.clear_count = MATCH3D_GENERATED_CLEAR_COUNT;
|
||||
// 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 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 && (session.current_turn < 3 || session.progress_percent < 100) {
|
||||
return Err(match3d_bad_request(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
@@ -897,17 +994,27 @@ async fn compile_match3d_draft_for_session(
|
||||
));
|
||||
}
|
||||
|
||||
let config = resolve_config_or_default(session.config.as_ref());
|
||||
let tags_json = tags
|
||||
.as_ref()
|
||||
.map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default());
|
||||
|
||||
state
|
||||
let profile_id = build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX);
|
||||
let generated_item_assets = generate_match3d_item_assets(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
&config,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.compile_match3d_draft(Match3DCompileDraftRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
profile_id: build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX),
|
||||
profile_id,
|
||||
author_display_name: resolve_author_display_name(state, authenticated),
|
||||
game_name: game_name.or_else(|| Some(format!("{}抓大鹅", config.theme_text))),
|
||||
summary_text: summary,
|
||||
@@ -915,6 +1022,9 @@ async fn compile_match3d_draft_for_session(
|
||||
cover_image_src,
|
||||
cover_asset_id: None,
|
||||
compiled_at_micros: current_utc_micros(),
|
||||
generated_item_assets_json: serialize_match3d_generated_item_assets(
|
||||
&generated_item_assets,
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
@@ -923,7 +1033,9 @@ async fn compile_match3d_draft_for_session(
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok((session, generated_item_assets))
|
||||
}
|
||||
|
||||
fn map_match3d_agent_session_response(
|
||||
@@ -952,6 +1064,21 @@ fn map_match3d_agent_session_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_agent_session_response_with_assets(
|
||||
session: Match3DAgentSessionRecord,
|
||||
generated_item_assets: &[Match3DGeneratedItemAsset],
|
||||
) -> Match3DAgentSessionSnapshotResponse {
|
||||
let mut response = map_match3d_agent_session_response(session);
|
||||
if let Some(draft) = response.draft.as_mut() {
|
||||
draft.generated_item_assets = generated_item_assets
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(map_match3d_generated_item_asset_for_agent)
|
||||
.collect();
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn map_match3d_anchor_pack_response_for_turn(
|
||||
anchor: Match3DAnchorPackRecord,
|
||||
current_turn: u32,
|
||||
@@ -1015,6 +1142,9 @@ fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCre
|
||||
reference_image_src: config.reference_image_src,
|
||||
clear_count: config.clear_count,
|
||||
difficulty: config.difficulty,
|
||||
asset_style_id: config.asset_style_id,
|
||||
asset_style_label: config.asset_style_label,
|
||||
asset_style_prompt: config.asset_style_prompt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,6 +1163,43 @@ fn map_match3d_draft_response(draft: Match3DResultDraftRecord) -> Match3DResultD
|
||||
total_item_count: draft.total_item_count,
|
||||
publish_ready: draft.publish_ready,
|
||||
blockers: draft.blockers,
|
||||
generated_item_assets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_generated_item_asset_for_agent(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) -> Match3DAgentGeneratedItemAssetResponse {
|
||||
Match3DAgentGeneratedItemAssetResponse {
|
||||
item_id: asset.item_id,
|
||||
item_name: asset.item_name,
|
||||
image_src: asset.image_src,
|
||||
image_object_key: asset.image_object_key,
|
||||
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,
|
||||
status: asset.status,
|
||||
error: asset.error,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_generated_item_asset_for_work(
|
||||
asset: Match3DGeneratedItemAssetJson,
|
||||
) -> shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
|
||||
shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
|
||||
item_id: asset.item_id,
|
||||
item_name: asset.item_name,
|
||||
image_src: asset.image_src,
|
||||
image_object_key: asset.image_object_key,
|
||||
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,
|
||||
status: asset.status,
|
||||
error: asset.error,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1047,6 +1214,11 @@ fn map_match3d_message_response(message: Match3DAgentMessageRecord) -> Match3DAg
|
||||
}
|
||||
|
||||
fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DWorkSummaryResponse {
|
||||
let generated_item_assets =
|
||||
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref())
|
||||
.into_iter()
|
||||
.map(map_match3d_generated_item_asset_for_work)
|
||||
.collect();
|
||||
Match3DWorkSummaryResponse {
|
||||
work_id: item.work_id,
|
||||
profile_id: item.profile_id,
|
||||
@@ -1065,6 +1237,7 @@ fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DW
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
publish_ready: item.publish_ready,
|
||||
generated_item_assets,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1156,14 +1329,14 @@ fn build_config_from_create_request(
|
||||
.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)
|
||||
.max(1),
|
||||
clear_count: MATCH3D_GENERATED_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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1186,6 +1359,9 @@ fn build_config_from_message(
|
||||
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;
|
||||
|
||||
match session.current_turn {
|
||||
0 => {
|
||||
@@ -1219,6 +1395,9 @@ fn build_config_from_message(
|
||||
reference_image_src,
|
||||
clear_count,
|
||||
difficulty,
|
||||
asset_style_id,
|
||||
asset_style_label,
|
||||
asset_style_prompt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1229,15 +1408,28 @@ fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Mat
|
||||
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(),
|
||||
})
|
||||
.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,
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -1349,6 +1541,44 @@ fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||||
result
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
status: asset.status,
|
||||
error: asset.error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_author_display_name(
|
||||
state: &AppState,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
@@ -1363,6 +1593,376 @@ fn resolve_author_display_name(
|
||||
.unwrap_or_else(|| "玩家".to_string())
|
||||
}
|
||||
|
||||
async fn generate_match3d_item_assets(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
config: &Match3DConfigJson,
|
||||
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
|
||||
// 中文注释:外部模型、下载和 OSS 写入都留在 api-server,SpacetimeDB reducer 只保存确定性草稿。
|
||||
let item_names = generate_match3d_item_names(state, config)
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
let material_sheet = generate_match3d_material_sheet(state, config, &item_names)
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
let generated_at_micros = current_utc_micros();
|
||||
let _sheet_upload = persist_match3d_generated_bytes(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
&["material-sheet", material_sheet.task_id.as_str()],
|
||||
"sheet.png",
|
||||
"image/png",
|
||||
material_sheet.image.bytes.clone(),
|
||||
"match3d_material_sheet",
|
||||
Some(material_sheet.task_id.as_str()),
|
||||
generated_at_micros,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
let item_images = slice_match3d_material_sheet(&material_sheet.image, &item_names)
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
|
||||
let mut item_assets = Vec::with_capacity(item_images.len());
|
||||
for (index, item_image) in item_images.into_iter().enumerate() {
|
||||
let item_name = item_names
|
||||
.get(index)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("物品{}", index + 1));
|
||||
let item_id = format!("match3d-item-{}", index + 1);
|
||||
let item_slug = format!(
|
||||
"{item_id}-{}",
|
||||
sanitize_match3d_asset_segment(&item_name, "item")
|
||||
);
|
||||
let image_bytes = item_image.bytes;
|
||||
let image_upload = persist_match3d_generated_bytes(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
&["items", item_slug.as_str(), "image"],
|
||||
"image.png",
|
||||
"image/png",
|
||||
image_bytes.clone(),
|
||||
"match3d_item_image",
|
||||
Some(material_sheet.task_id.as_str()),
|
||||
generated_at_micros.saturating_add(index as i64 + 1),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
let model_asset = generate_match3d_rodin_model_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
&item_slug,
|
||||
&item_name,
|
||||
config,
|
||||
image_bytes,
|
||||
generated_at_micros.saturating_add(100 + index as i64),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
item_assets.push(Match3DGeneratedItemAsset {
|
||||
item_id,
|
||||
item_name,
|
||||
image_src: Some(image_upload.src),
|
||||
image_object_key: Some(image_upload.object_key),
|
||||
model_src: Some(model_asset.upload.src),
|
||||
model_object_key: Some(model_asset.upload.object_key),
|
||||
model_file_name: Some(model_asset.model_file_name),
|
||||
task_uuid: Some(model_asset.task_uuid),
|
||||
subscription_key: Some(model_asset.subscription_key),
|
||||
status: "model_ready".to_string(),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 中文注释:草稿阶段必须同时产出 GLB 模型,结果页直接加载模型预览。
|
||||
Ok(item_assets)
|
||||
}
|
||||
|
||||
struct Match3DMaterialSheet {
|
||||
task_id: String,
|
||||
image: DownloadedOpenAiImage,
|
||||
}
|
||||
|
||||
struct Match3DSlicedItemImage {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn generate_match3d_item_names(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
let Some(llm_client) = state
|
||||
.creative_agent_gpt5_client()
|
||||
.or_else(|| state.llm_client())
|
||||
else {
|
||||
return Ok(fallback_match3d_item_names(config.theme_text.as_str()));
|
||||
};
|
||||
let system_prompt = "你是抓大鹅游戏的物品命名编辑,只返回 JSON 字符串数组。";
|
||||
let user_prompt = format!(
|
||||
"题材:{}\n请生成 {} 个适合抓大鹅点击消除玩法的短中文物品名称。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。",
|
||||
config.theme_text, MATCH3D_GENERATED_ITEM_COUNT
|
||||
);
|
||||
let response = llm_client
|
||||
.request_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_responses_api(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_llm_error)?;
|
||||
let parsed = parse_match3d_item_names(response.content.as_str());
|
||||
if parsed.len() == MATCH3D_GENERATED_ITEM_COUNT {
|
||||
return Ok(parsed);
|
||||
}
|
||||
|
||||
Ok(fallback_match3d_item_names(config.theme_text.as_str()))
|
||||
}
|
||||
|
||||
fn parse_match3d_item_names(raw: &str) -> Vec<String> {
|
||||
let raw = raw.trim();
|
||||
let parsed_array = serde_json::from_str::<Vec<String>>(raw)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
let start = raw.find('[')?;
|
||||
let end = raw.rfind(']')?;
|
||||
serde_json::from_str::<Vec<String>>(&raw[start..=end]).ok()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut names = Vec::new();
|
||||
for name in parsed_array {
|
||||
let normalized = normalize_match3d_item_name(name.as_str());
|
||||
if !normalized.is_empty() && !names.contains(&normalized) {
|
||||
names.push(normalized);
|
||||
}
|
||||
if names.len() >= MATCH3D_GENERATED_ITEM_COUNT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
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_GENERATED_ITEM_COUNT)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn generate_match3d_material_sheet(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
) -> Result<Match3DMaterialSheet, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let prompt = build_match3d_material_sheet_prompt(config, item_names);
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt.as_str(),
|
||||
Some("文字、水印、UI、边框、网格线、标签、人物手部、复杂背景"),
|
||||
"1:1",
|
||||
1,
|
||||
&[],
|
||||
"抓大鹅素材图生成失败",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "抓大鹅素材图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DMaterialSheet {
|
||||
task_id: generated.task_id,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
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();
|
||||
format!(
|
||||
"生成一张1:1图片。生成{grid}*{grid}网格素材图,画面是{theme}题材的抓大鹅游戏素材。{style_clause}只绘制这些物品:{items}。每个格子一个独立居中的完整物体,统一柔和光照,正交或轻微俯视角,清晰轮廓,适合后续切割成图生3D模型参考。不要出现文字、水印、UI、边框、网格线、标签。",
|
||||
grid = MATCH3D_MATERIAL_GRID_SIZE,
|
||||
theme = config.theme_text,
|
||||
style_clause = style_clause,
|
||||
items = item_names.join("、"),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_match3d_asset_style_prompt(config: &Match3DConfigJson) -> Option<String> {
|
||||
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)
|
||||
}
|
||||
|
||||
fn slice_match3d_material_sheet(
|
||||
image: &DownloadedOpenAiImage,
|
||||
item_names: &[String],
|
||||
) -> Result<Vec<Match3DSlicedItemImage>, AppError> {
|
||||
// 中文注释:当前 3 件物品使用 2x2 素材图,按阅读顺序取前三格,第四格作为生成冗余。
|
||||
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 (width, height) = source.dimensions();
|
||||
let cell_width = width / MATCH3D_MATERIAL_GRID_SIZE;
|
||||
let cell_height = height / MATCH3D_MATERIAL_GRID_SIZE;
|
||||
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 index in 0..item_names.len().min(MATCH3D_GENERATED_ITEM_COUNT) {
|
||||
let col = (index as u32) % MATCH3D_MATERIAL_GRID_SIZE;
|
||||
let row = (index as u32) / MATCH3D_MATERIAL_GRID_SIZE;
|
||||
let cropped = source.crop_imm(col * cell_width, row * cell_height, cell_width, cell_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}"),
|
||||
}))
|
||||
})?;
|
||||
slices.push(Match3DSlicedItemImage {
|
||||
bytes: cursor.into_inner(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(slices)
|
||||
}
|
||||
|
||||
#[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 = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
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 put_result = oss_client
|
||||
.put_object(
|
||||
&reqwest::Client::new(),
|
||||
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 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",
|
||||
@@ -1529,6 +2129,9 @@ mod tests {
|
||||
reference_image_src: None,
|
||||
clear_count,
|
||||
difficulty,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1595,4 +2198,66 @@ mod tests {
|
||||
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_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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user