1
This commit is contained in:
@@ -64,9 +64,10 @@ use spacetime_client::{
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput,
|
||||
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
|
||||
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
|
||||
@@ -80,7 +81,10 @@ use crate::{
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
|
||||
openai_image_generation::VECTOR_ENGINE_GPT_IMAGE_2_MODEL,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, build_openai_image_http_client,
|
||||
create_openai_image_generation, require_openai_image_settings,
|
||||
},
|
||||
platform_errors::map_oss_error,
|
||||
prompt::puzzle::{
|
||||
draft::{
|
||||
@@ -100,6 +104,9 @@ use crate::{
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
vector_engine_audio_generation::{
|
||||
GeneratedCreationAudioTarget, generate_background_music_asset_for_creation,
|
||||
},
|
||||
work_author::resolve_work_author_by_user_id,
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
};
|
||||
@@ -119,6 +126,10 @@ const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
|
||||
const PUZZLE_UI_BACKGROUND_REFERENCE_IMAGE_PATH: &str =
|
||||
"public/ui-previews/puzzle-image-compact-ui-2026-05-08.png";
|
||||
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
|
||||
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
|
||||
|
||||
pub async fn create_puzzle_agent_session(
|
||||
State(state): State<AppState>,
|
||||
@@ -229,6 +240,9 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
level_name: level_name.clone(),
|
||||
picture_description: prompt_text.clone(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates,
|
||||
selected_candidate_id: Some(selected.candidate_id.clone()),
|
||||
@@ -991,6 +1005,117 @@ pub async fn execute_puzzle_agent_action(
|
||||
session,
|
||||
)
|
||||
}
|
||||
"generate_puzzle_ui_background" => {
|
||||
let target_level_id = payload.level_id.clone();
|
||||
let raw_prompt = payload
|
||||
.prompt_text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let levels_json = normalize_puzzle_levels_json_for_module(
|
||||
payload.levels_json.as_deref(),
|
||||
)
|
||||
.map_err(|message| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": message,
|
||||
}))
|
||||
});
|
||||
let session = execute_billable_asset_operation_with_cost(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_ui_background_image",
|
||||
&billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
async {
|
||||
let levels_json = levels_json?;
|
||||
let session = get_puzzle_session_for_image_generation(
|
||||
&state,
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
&payload,
|
||||
levels_json.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
let mut draft = session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
if let Some(levels_json) = levels_json.as_ref() {
|
||||
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
|
||||
}
|
||||
let target_level =
|
||||
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
|
||||
let resolved_prompt = normalize_puzzle_ui_background_prompt(
|
||||
raw_prompt.as_str(),
|
||||
&draft,
|
||||
&target_level,
|
||||
);
|
||||
let generated = generate_puzzle_ui_background_image(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&target_level.level_name,
|
||||
resolved_prompt.as_str(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||
let save_result = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput {
|
||||
session_id: session.session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json,
|
||||
prompt: resolved_prompt.clone(),
|
||||
image_src: generated.image_src.clone(),
|
||||
image_object_key: Some(generated.object_key.clone()),
|
||||
saved_at_micros: now,
|
||||
})
|
||||
.await;
|
||||
match save_result {
|
||||
Ok(session) => Ok(session),
|
||||
Err(error)
|
||||
if should_skip_asset_operation_billing_for_connectivity(&error) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %session.session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
error = %error,
|
||||
"拼图 UI 背景图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
|
||||
);
|
||||
let fallback_session =
|
||||
replace_puzzle_session_draft_snapshot(session, draft, now);
|
||||
Ok(apply_generated_puzzle_ui_background_to_session_snapshot(
|
||||
fallback_session,
|
||||
target_level.level_id.as_str(),
|
||||
resolved_prompt,
|
||||
generated.image_src,
|
||||
Some(generated.object_key),
|
||||
now,
|
||||
))
|
||||
}
|
||||
Err(error) => Err(map_puzzle_client_error(error)),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
});
|
||||
(
|
||||
"generate_puzzle_ui_background",
|
||||
"UI 背景图生成",
|
||||
"已生成拼图 UI 背景图。",
|
||||
session,
|
||||
)
|
||||
}
|
||||
"generate_puzzle_tags" => {
|
||||
let work_title = payload
|
||||
.work_title
|
||||
@@ -2061,6 +2186,9 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft
|
||||
level_name: level.level_name,
|
||||
picture_description: level.picture_description,
|
||||
picture_reference: level.picture_reference,
|
||||
ui_background_prompt: level.ui_background_prompt,
|
||||
ui_background_image_src: level.ui_background_image_src,
|
||||
ui_background_image_object_key: level.ui_background_image_object_key,
|
||||
background_music: level.background_music.map(map_puzzle_audio_asset_record_response),
|
||||
candidates: level
|
||||
.candidates
|
||||
@@ -2377,6 +2505,8 @@ fn map_puzzle_runtime_level_response(
|
||||
author_display_name: level.author_display_name,
|
||||
theme_tags: level.theme_tags,
|
||||
cover_image_src: level.cover_image_src,
|
||||
ui_background_image_src: level.ui_background_image_src,
|
||||
background_music: level.background_music.map(map_puzzle_audio_asset_record_response),
|
||||
board: map_puzzle_board_response(level.board),
|
||||
status: level.status,
|
||||
started_at_ms: level.started_at_ms,
|
||||
@@ -2667,6 +2797,9 @@ fn parse_puzzle_level_records_from_module_json(
|
||||
level_name: level.level_name,
|
||||
picture_description: level.picture_description,
|
||||
picture_reference: level.picture_reference,
|
||||
ui_background_prompt: level.ui_background_prompt,
|
||||
ui_background_image_src: level.ui_background_image_src,
|
||||
ui_background_image_object_key: level.ui_background_image_object_key,
|
||||
background_music: level.background_music.map(map_puzzle_audio_asset_domain_record),
|
||||
candidates: level
|
||||
.candidates
|
||||
@@ -2835,6 +2968,9 @@ fn serialize_puzzle_levels_response(
|
||||
"level_name": level.level_name,
|
||||
"picture_description": level.picture_description,
|
||||
"picture_reference": level.picture_reference,
|
||||
"ui_background_prompt": level.ui_background_prompt,
|
||||
"ui_background_image_src": level.ui_background_image_src,
|
||||
"ui_background_image_object_key": level.ui_background_image_object_key,
|
||||
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
|
||||
"candidates": level
|
||||
.candidates
|
||||
@@ -2884,6 +3020,9 @@ fn normalize_puzzle_levels_json_for_module(value: Option<&str>) -> Result<Option
|
||||
"level_name": level.level_name,
|
||||
"picture_description": level.picture_description,
|
||||
"picture_reference": level.picture_reference,
|
||||
"ui_background_prompt": level.ui_background_prompt,
|
||||
"ui_background_image_src": level.ui_background_image_src,
|
||||
"ui_background_image_object_key": level.ui_background_image_object_key,
|
||||
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
|
||||
"candidates": level
|
||||
.candidates
|
||||
@@ -3174,6 +3313,10 @@ fn build_puzzle_levels_with_primary_update(
|
||||
.or_else(|| (!levels.is_empty()).then_some(0))
|
||||
{
|
||||
levels[index].level_name = target_level.level_name.clone();
|
||||
levels[index].ui_background_prompt = target_level.ui_background_prompt.clone();
|
||||
levels[index].ui_background_image_src = target_level.ui_background_image_src.clone();
|
||||
levels[index].ui_background_image_object_key =
|
||||
target_level.ui_background_image_object_key.clone();
|
||||
if let Some(picture_reference) = picture_reference
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -3184,6 +3327,135 @@ fn build_puzzle_levels_with_primary_update(
|
||||
levels
|
||||
}
|
||||
|
||||
fn resolve_puzzle_background_music_title(
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
) -> String {
|
||||
let work_title = draft.work_title.trim();
|
||||
if !work_title.is_empty() {
|
||||
return work_title.to_string();
|
||||
}
|
||||
target_level.level_name.trim().to_string()
|
||||
}
|
||||
|
||||
fn normalize_puzzle_ui_background_prompt(
|
||||
raw_prompt: &str,
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
) -> String {
|
||||
let prompt = raw_prompt.trim();
|
||||
if !prompt.is_empty() {
|
||||
return prompt.chars().take(420).collect();
|
||||
}
|
||||
|
||||
let title = draft.work_title.trim();
|
||||
let title = if title.is_empty() {
|
||||
target_level.level_name.trim()
|
||||
} else {
|
||||
title
|
||||
};
|
||||
let tags = draft
|
||||
.theme_tags
|
||||
.iter()
|
||||
.map(|tag| tag.trim())
|
||||
.filter(|tag| !tag.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
[
|
||||
title,
|
||||
draft.work_description.trim(),
|
||||
target_level.picture_description.trim(),
|
||||
tags.as_str(),
|
||||
"移动端拼图游戏 UI 背景,中央正方形拼图区边界清晰",
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("。")
|
||||
.chars()
|
||||
.take(420)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_puzzle_ui_background_generation_prompt(level_name: &str, prompt: &str) -> String {
|
||||
let level_name = level_name.trim();
|
||||
let title_clause = if level_name.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("当前拼图关卡名称:{level_name}。")
|
||||
};
|
||||
format!(
|
||||
"{title_clause}{prompt}\n严格参考输入图的构图关系:生成一张 9:16 竖屏拼图游戏 UI 背景图,中央必须预留清晰正方形拼图区,拼图区与外部 UI 背景必须有明确边界、描边或容器层次;拼图区之外可以生成与作品名称相关的氛围背景、顶部安全区和底部工具区背景,但不要画文字、按钮文字、数字、拼图碎片、完整拼图图像、教程浮层或水印。"
|
||||
)
|
||||
}
|
||||
|
||||
fn attach_puzzle_level_background_music(
|
||||
levels: &mut [PuzzleDraftLevelRecord],
|
||||
level_id: &str,
|
||||
music: CreationAudioAsset,
|
||||
) {
|
||||
let Some(index) = levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == level_id)
|
||||
.or_else(|| (!levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
levels[index].background_music = Some(PuzzleAudioAssetRecord {
|
||||
task_id: music.task_id,
|
||||
provider: music.provider,
|
||||
asset_object_id: music.asset_object_id,
|
||||
asset_kind: music.asset_kind,
|
||||
audio_src: music.audio_src,
|
||||
prompt: music.prompt,
|
||||
title: music.title,
|
||||
updated_at: music.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
async fn try_generate_puzzle_background_music(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
title: &str,
|
||||
) -> Option<CreationAudioAsset> {
|
||||
let normalized_title = title.trim();
|
||||
if normalized_title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match generate_background_music_asset_for_creation(
|
||||
state,
|
||||
owner_user_id,
|
||||
String::new(),
|
||||
normalized_title.to_string(),
|
||||
Some("轻快, 拼图, 循环, instrumental".to_string()),
|
||||
None,
|
||||
GeneratedCreationAudioTarget {
|
||||
entity_kind: PUZZLE_ENTITY_KIND.to_string(),
|
||||
entity_id: profile_id.to_string(),
|
||||
slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(),
|
||||
asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(),
|
||||
profile_id: Some(profile_id.to_string()),
|
||||
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(music) => Some(music),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id,
|
||||
profile_id,
|
||||
error = %error,
|
||||
"拼图草稿背景音乐生成失败,保留草稿并允许结果页重试"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn compile_puzzle_draft_with_initial_cover(
|
||||
state: &AppState,
|
||||
session_id: String,
|
||||
@@ -3249,8 +3521,27 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
target_level.level_name = refined_level_name;
|
||||
}
|
||||
let generated_level_name = target_level.level_name.clone();
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
|
||||
let mut updated_levels =
|
||||
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
|
||||
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
|
||||
if let Some(music) = try_generate_puzzle_background_music(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
music_title.as_str(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
attach_puzzle_level_background_music(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
music,
|
||||
);
|
||||
}
|
||||
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
|
||||
&build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src),
|
||||
&updated_levels,
|
||||
)?);
|
||||
let candidates_json = serde_json::to_string(
|
||||
&candidates
|
||||
@@ -3270,7 +3561,7 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json: levels_json_with_generated_name,
|
||||
levels_json: levels_json_with_generated_name.clone(),
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
})
|
||||
@@ -3288,11 +3579,15 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
"拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
|
||||
);
|
||||
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
apply_generated_puzzle_levels_to_session_snapshot(
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
),
|
||||
updated_levels.clone(),
|
||||
now,
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
@@ -3400,8 +3695,27 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
target_level.level_name = refined_level_name;
|
||||
}
|
||||
let generated_level_name = target_level.level_name.clone();
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
|
||||
let mut updated_levels =
|
||||
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
|
||||
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
|
||||
if let Some(music) = try_generate_puzzle_background_music(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
music_title.as_str(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
attach_puzzle_level_background_music(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
music,
|
||||
);
|
||||
}
|
||||
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
|
||||
&build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src),
|
||||
&updated_levels,
|
||||
)?);
|
||||
let persisted_upload = persist_puzzle_generated_asset(
|
||||
state,
|
||||
@@ -3438,7 +3752,7 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json: levels_json_with_generated_name,
|
||||
levels_json: levels_json_with_generated_name.clone(),
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
})
|
||||
@@ -3455,11 +3769,15 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
"拼图上传图草稿回写不可用,降级返回本地快照"
|
||||
);
|
||||
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
apply_generated_puzzle_levels_to_session_snapshot(
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
),
|
||||
updated_levels.clone(),
|
||||
now,
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
@@ -3552,6 +3870,23 @@ fn apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
fn apply_generated_puzzle_levels_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
levels: Vec<PuzzleDraftLevelRecord>,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
if levels.is_empty() {
|
||||
return session;
|
||||
}
|
||||
draft.levels = levels;
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
fn apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
@@ -3607,6 +3942,38 @@ fn replace_puzzle_session_draft_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
fn apply_generated_puzzle_ui_background_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
prompt: String,
|
||||
image_src: String,
|
||||
image_object_key: Option<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
let Some(target_index) = draft
|
||||
.levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level_id)
|
||||
.or_else(|| (!draft.levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return session;
|
||||
};
|
||||
let level = &mut draft.levels[target_index];
|
||||
level.ui_background_prompt = Some(prompt);
|
||||
level.ui_background_image_src = Some(image_src);
|
||||
level.ui_background_image_object_key = image_object_key;
|
||||
if target_index == 0 {
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
}
|
||||
session.progress_percent = session.progress_percent.max(96);
|
||||
session.last_assistant_reply = Some("拼图 UI 背景图已生成。".to_string());
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
async fn generate_puzzle_work_tags(
|
||||
state: &AppState,
|
||||
work_title: &str,
|
||||
@@ -3877,6 +4244,9 @@ fn serialize_puzzle_level_records_for_module(
|
||||
"level_name": level.level_name,
|
||||
"picture_description": level.picture_description,
|
||||
"picture_reference": level.picture_reference,
|
||||
"ui_background_prompt": level.ui_background_prompt,
|
||||
"ui_background_image_src": level.ui_background_image_src,
|
||||
"ui_background_image_object_key": level.ui_background_image_object_key,
|
||||
"background_music": puzzle_audio_asset_record_module_json(&level.background_music),
|
||||
"candidates": level
|
||||
.candidates
|
||||
@@ -4305,6 +4675,72 @@ async fn generate_puzzle_image_candidates(
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
async fn generate_puzzle_ui_background_image(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let reference_image = load_puzzle_ui_background_reference_data_url().await?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
|
||||
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、角色手指、模糊边界"),
|
||||
"9:16",
|
||||
1,
|
||||
&[reference_image],
|
||||
"拼图 UI 背景图生成失败",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "拼图 UI 背景图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
persist_puzzle_ui_background_image(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
level_name,
|
||||
generated.task_id.as_str(),
|
||||
image,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn build_puzzle_ui_background_request_prompt_for_test(level_name: &str, prompt: &str) -> String {
|
||||
build_puzzle_ui_background_generation_prompt(level_name, prompt)
|
||||
}
|
||||
|
||||
async fn load_puzzle_ui_background_reference_data_url() -> Result<String, AppError> {
|
||||
let bytes = tokio::fs::read(PUZZLE_UI_BACKGROUND_REFERENCE_IMAGE_PATH)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("读取拼图 UI 背景参考图失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
if bytes.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(
|
||||
json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图 UI 背景参考图为空",
|
||||
}),
|
||||
));
|
||||
}
|
||||
Ok(format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(bytes)
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -4583,6 +5019,9 @@ mod tests {
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
background_music: Some(CreationAudioAsset {
|
||||
task_id: "suno-task-1".to_string(),
|
||||
provider: "vector-engine-suno".to_string(),
|
||||
@@ -4635,6 +5074,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
|
||||
let level = PuzzleDraftLevelResponse {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()),
|
||||
ui_background_image_src: Some(
|
||||
"/generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||
),
|
||||
ui_background_image_object_key: Some(
|
||||
"generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||
),
|
||||
background_music: None,
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
|
||||
cover_asset_id: Some("asset-1".to_string()),
|
||||
generation_status: "ready".to_string(),
|
||||
};
|
||||
let request_context = RequestContext::new(
|
||||
"test-request".to_string(),
|
||||
"PUT /api/runtime/puzzle/works/test".to_string(),
|
||||
Duration::ZERO,
|
||||
false,
|
||||
);
|
||||
|
||||
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
|
||||
.expect("levels should serialize");
|
||||
let payload: Value =
|
||||
serde_json::from_str(&levels_json).expect("levels json should parse");
|
||||
assert_eq!(
|
||||
payload[0]["ui_background_prompt"],
|
||||
Value::String("雨夜猫街竖屏拼图UI背景".to_string())
|
||||
);
|
||||
assert!(payload[0].get("uiBackgroundPrompt").is_none());
|
||||
|
||||
let records = parse_puzzle_level_records_from_module_json(&levels_json)
|
||||
.expect("levels should map back into records");
|
||||
assert_eq!(
|
||||
records[0].ui_background_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/session/ui/background.png")
|
||||
);
|
||||
|
||||
let response = map_puzzle_draft_level_response(records[0].clone());
|
||||
assert_eq!(
|
||||
response.ui_background_image_object_key.as_deref(),
|
||||
Some("generated-puzzle-assets/session/ui/background.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_ui_background_prompt_keeps_square_boundary_constraint() {
|
||||
let prompt = build_puzzle_ui_background_request_prompt_for_test(
|
||||
"雨夜猫街",
|
||||
"雨夜猫街主题背景",
|
||||
);
|
||||
|
||||
assert!(prompt.contains("9:16"));
|
||||
assert!(prompt.contains("中央必须预留清晰正方形拼图区"));
|
||||
assert!(prompt.contains("明确边界"));
|
||||
assert!(prompt.contains("不要画文字"));
|
||||
}
|
||||
|
||||
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
|
||||
let item = PuzzleAnchorItemRecord {
|
||||
key: "visualSubject".to_string(),
|
||||
@@ -4673,6 +5177,9 @@ mod tests {
|
||||
level_name: "猫画面".to_string(),
|
||||
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
@@ -4846,6 +5353,11 @@ struct GeneratedPuzzleAssetResponse {
|
||||
asset_id: String,
|
||||
}
|
||||
|
||||
struct GeneratedPuzzleUiBackgroundResponse {
|
||||
image_src: String,
|
||||
object_key: String,
|
||||
}
|
||||
|
||||
fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel {
|
||||
match value.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => {
|
||||
@@ -5474,6 +5986,47 @@ async fn persist_puzzle_generated_asset(
|
||||
})
|
||||
}
|
||||
|
||||
async fn persist_puzzle_ui_background_image(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
task_id: &str,
|
||||
image: DownloadedOpenAiImage,
|
||||
) -> Result<GeneratedPuzzleUiBackgroundResponse, 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 http_client = reqwest::Client::new();
|
||||
let put_result = oss_client
|
||||
.put_object(
|
||||
&http_client,
|
||||
OssPutObjectRequest {
|
||||
prefix: LegacyAssetPrefix::PuzzleAssets,
|
||||
path_segments: vec![
|
||||
sanitize_path_segment(session_id, "session"),
|
||||
sanitize_path_segment(level_name, "puzzle"),
|
||||
"ui-background".to_string(),
|
||||
sanitize_path_segment(task_id, "task"),
|
||||
],
|
||||
file_name: format!("background.{}", image.extension),
|
||||
content_type: Some(image.mime_type.clone()),
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: build_puzzle_ui_background_asset_metadata(owner_user_id, session_id),
|
||||
body: image.bytes,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_asset_oss_error)?;
|
||||
Ok(GeneratedPuzzleUiBackgroundResponse {
|
||||
image_src: put_result.legacy_public_path,
|
||||
object_key: put_result.object_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_puzzle_asset_spacetime_index_error(
|
||||
error: SpacetimeClientError,
|
||||
owner_user_id: &str,
|
||||
@@ -5512,6 +6065,22 @@ fn build_puzzle_asset_metadata(
|
||||
])
|
||||
}
|
||||
|
||||
fn build_puzzle_ui_background_asset_metadata(
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
) -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
(
|
||||
"asset_kind".to_string(),
|
||||
"puzzle_ui_background_image".to_string(),
|
||||
),
|
||||
("owner_user_id".to_string(), owner_user_id.to_string()),
|
||||
("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()),
|
||||
("entity_id".to_string(), session_id.to_string()),
|
||||
("slot".to_string(), "ui_background".to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result<Value, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
|
||||
Reference in New Issue
Block a user