This commit is contained in:
2026-05-13 00:28:07 +08:00
parent ef4f91a75e
commit 01c5ab985a
101 changed files with 10635 additions and 2292 deletions

View File

@@ -90,10 +90,13 @@ use crate::{
match3d::{
click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session,
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
generate_match3d_work_tags, get_match3d_agent_session, get_match3d_run,
get_match3d_work_detail, get_match3d_works, list_match3d_gallery, publish_match3d_work,
put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run,
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
generate_match3d_background_image_for_work, generate_match3d_cover_image,
generate_match3d_item_assets_for_work, generate_match3d_work_tags,
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail,
get_match3d_works, list_match3d_gallery, persist_match3d_generated_model,
publish_match3d_work, put_match3d_audio_assets, put_match3d_work,
restart_match3d_run, start_match3d_run, stop_match3d_run,
stream_match3d_agent_message, submit_match3d_agent_message,
},
password_entry::password_entry,
password_management::{change_password, reset_password},
@@ -951,6 +954,33 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/cover-image",
post(generate_match3d_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/background-image",
post(generate_match3d_background_image_for_work).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/match3d/works/{profile_id}/item-assets",
post(generate_match3d_item_assets_for_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/generated-models",
post(persist_match3d_generated_model).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/publish",
post(publish_match3d_work).route_layer(middleware::from_fn_with_state(

View File

@@ -36,10 +36,12 @@ use crate::{
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 9] = [
"character_visual",
"scene_image",
"puzzle_cover_image",
"match3d_cover_image",
"match3d_item_image",
"square_hole_cover_image",
"square_hole_background_image",
"square_hole_shape_image",
@@ -765,6 +767,8 @@ mod tests {
assert!(super::is_supported_asset_history_kind("character_visual"));
assert!(super::is_supported_asset_history_kind("scene_image"));
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
assert!(super::is_supported_asset_history_kind("match3d_cover_image"));
assert!(super::is_supported_asset_history_kind("match3d_item_image"));
assert!(super::is_supported_asset_history_kind(
"square_hole_cover_image"
));
@@ -786,7 +790,7 @@ mod tests {
fn asset_history_kind_message_lists_all_supported_kinds() {
assert_eq!(
super::supported_asset_history_kind_message(),
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image、square_hole_hole_image"
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image、match3d_cover_image、match3d_item_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image、square_hole_hole_image"
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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!({

View File

@@ -16,8 +16,9 @@ use serde_json::{Map, Value, json};
use shared_contracts::{creation_audio, visual_novel as contract};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
platform_errors::map_oss_error, request_context::RequestContext, state::AppState,
api_response::json_success_body, asset_billing::execute_billable_asset_operation_with_cost,
auth::AuthenticatedAccessToken, http_error::AppError, platform_errors::map_oss_error,
request_context::RequestContext, state::AppState,
};
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
@@ -36,6 +37,7 @@ const SUNO_TAGS_MAX_CHARS: usize = 160;
const VIDU_PROMPT_MAX_CHARS: usize = 1_500;
const DEFAULT_SOUND_EFFECT_DURATION_SECONDS: u8 = 5;
const MAX_GENERATED_AUDIO_BYTES: usize = 40 * 1024 * 1024;
const CREATION_AUDIO_POINTS_COST: u64 = 10;
#[derive(Clone, Debug)]
struct VectorEngineAudioSettings {
@@ -62,6 +64,16 @@ struct AudioAssetBindingTarget {
storage_scope: String,
}
#[derive(Clone, Debug)]
pub(crate) struct GeneratedCreationAudioTarget {
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub profile_id: Option<String>,
pub storage_prefix: LegacyAssetPrefix,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AudioAssetSlot {
BackgroundMusic,
@@ -167,7 +179,6 @@ pub async fn create_background_music_task(
let Json(payload) = parse_json_payload(&request_context, payload)?;
create_background_music_task_response(
&state,
&request_context,
payload.prompt,
payload.title,
payload.tags,
@@ -240,9 +251,113 @@ pub async fn create_sound_effect_task(
.map_err(|error| error.into_response_with_context(Some(&request_context)))
}
pub(crate) async fn generate_sound_effect_asset_for_creation(
state: &AppState,
owner_user_id: &str,
prompt: String,
duration: Option<u8>,
seed: Option<u64>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let normalized_prompt = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
let task = create_sound_effect_task_response(
state,
normalized_prompt.clone(),
duration,
seed,
)
.await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::SoundEffect,
target,
)
.await?;
let audio_src = generated.audio_src.ok_or_else(|| {
vector_engine_bad_gateway("音效生成完成但缺少播放地址")
})?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: None,
updated_at: Some(current_utc_iso_text()),
})
}
pub(crate) async fn generate_background_music_asset_for_creation(
state: &AppState,
owner_user_id: &str,
prompt: String,
title: String,
tags: Option<String>,
model: Option<String>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let normalized_prompt = normalize_limited_text_allow_empty(
&prompt,
"prompt",
SUNO_PROMPT_MAX_CHARS,
)?;
let normalized_title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?;
let task = create_background_music_task_response(
state,
normalized_prompt.clone(),
normalized_title.clone(),
tags,
model,
)
.await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::BackgroundMusic,
target,
)
.await?;
let audio_src = generated.audio_src.ok_or_else(|| {
vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址")
})?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: Some(normalized_title),
updated_at: Some(current_utc_iso_text()),
})
}
async fn create_background_music_task_response(
state: &AppState,
_request_context: &RequestContext,
prompt: String,
title: String,
tags: Option<String>,
@@ -250,7 +365,7 @@ async fn create_background_music_task_response(
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
let settings = require_vector_engine_audio_settings(state)?;
let http_client = build_vector_engine_audio_http_client(&settings)?;
let prompt = normalize_limited_text(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
let prompt = normalize_limited_text_allow_empty(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
let title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?;
let tags = tags
.as_deref()
@@ -264,6 +379,7 @@ async fn create_background_music_task_response(
("mv".to_string(), Value::String(model)),
("title".to_string(), Value::String(title)),
("task".to_string(), Value::String("generate".to_string())),
("make_instrumental".to_string(), Value::Bool(true)),
]);
if let Some(tags) = tags {
body.insert("tags".to_string(), Value::String(tags));
@@ -505,15 +621,27 @@ async fn publish_generated_audio_asset(
.into_iter()
.next()
.ok_or_else(|| vector_engine_bad_gateway("音频生成尚未返回可下载地址"))?;
let audio = download_generated_audio(&http_client, &audio_url, slot.provider()).await?;
let persisted = persist_generated_audio_asset(
let billing_asset_kind = target.asset_kind.clone();
let billing_asset_id = build_audio_billing_asset_id(&task_id, slot, &target);
let persisted = execute_billable_asset_operation_with_cost(
state,
&http_client,
owner_user_id,
&task_id,
slot,
target.clone(),
audio,
billing_asset_kind.as_str(),
billing_asset_id.as_str(),
CREATION_AUDIO_POINTS_COST,
async {
let audio = download_generated_audio(&http_client, &audio_url, slot.provider()).await?;
persist_generated_audio_asset(
state,
&http_client,
owner_user_id,
&task_id,
slot,
target.clone(),
audio,
)
.await
},
)
.await?;
@@ -528,6 +656,54 @@ async fn publish_generated_audio_asset(
})
}
async fn wait_for_generated_audio_asset(
state: &AppState,
owner_user_id: &str,
task_id: String,
slot: AudioAssetSlot,
target: AudioAssetBindingTarget,
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
let mut latest_status = String::new();
for _ in 0..40 {
let response = publish_generated_audio_asset(
state,
owner_user_id,
task_id.clone(),
slot,
target.clone(),
)
.await?;
if response.audio_src.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) {
return Ok(response);
}
latest_status = response.status;
tokio::time::sleep(Duration::from_millis(3_000)).await;
}
Err(vector_engine_bad_gateway(format!(
"音频生成超时:{}",
if latest_status.trim().is_empty() {
task_id
} else {
latest_status
}
)))
}
fn build_audio_billing_asset_id(
task_id: &str,
slot: AudioAssetSlot,
target: &AudioAssetBindingTarget,
) -> String {
format!(
"creation-audio:{}:{}:{}:{}",
slot.file_stem(),
task_id,
target.entity_id,
target.slot
)
}
async fn fetch_audio_task_payload(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
@@ -1068,6 +1244,24 @@ fn normalize_limited_text(
Ok(normalized)
}
fn normalize_limited_text_allow_empty(
value: &str,
field: &'static str,
max_chars: usize,
) -> Result<String, AppError> {
let normalized = value.trim().to_string();
if normalized.chars().count() > max_chars {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"field": field,
"message": format!("{field} 超过 {} 字符", max_chars),
})),
);
}
Ok(normalized)
}
fn normalize_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
@@ -1166,6 +1360,11 @@ fn current_utc_micros() -> i64 {
shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
}
fn current_utc_iso_text() -> String {
shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc())
.unwrap_or_else(|_| shared_kernel::format_timestamp_micros(current_utc_micros()))
}
fn map_asset_field_error(error: module_assets::AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",