1
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
@@ -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!({
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user