1
This commit is contained in:
@@ -5,7 +5,7 @@ use axum::{
|
||||
http::Request,
|
||||
middleware,
|
||||
response::Response,
|
||||
routing::{delete, get, post},
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
use tower_http::{
|
||||
classify::ServerErrorsFailureClass,
|
||||
@@ -92,8 +92,8 @@ use crate::{
|
||||
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_work, restart_match3d_run, start_match3d_run, stop_match3d_run,
|
||||
stream_match3d_agent_message, submit_match3d_agent_message,
|
||||
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},
|
||||
@@ -156,7 +156,9 @@ use crate::{
|
||||
},
|
||||
tracking::record_route_tracking_event_after_success,
|
||||
vector_engine_audio_generation::{
|
||||
create_background_music_task, create_sound_effect_task,
|
||||
create_visual_novel_background_music_task, create_visual_novel_sound_effect_task,
|
||||
publish_background_music_asset, publish_sound_effect_asset,
|
||||
publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset,
|
||||
},
|
||||
visual_novel::{
|
||||
@@ -942,6 +944,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/audio-assets",
|
||||
put(put_match3d_audio_assets).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(
|
||||
@@ -1517,7 +1526,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)),
|
||||
)
|
||||
.route("/api/auth/password/reset", post(reset_password))
|
||||
// 后端 runtime/API 路由只按 open 做熔断;visible 仅控制创作页入口展示。
|
||||
// 后端创作/运行态 API 路由只按 open 做熔断;visible 仅控制创作页入口展示。
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_creation_entry_route_enabled,
|
||||
@@ -1770,6 +1779,34 @@ fn visual_novel_router(state: AppState) -> Router<AppState> {
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/audio/background-music",
|
||||
post(create_background_music_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/audio/background-music/{task_id}/asset",
|
||||
post(publish_background_music_asset).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/audio/sound-effect",
|
||||
post(create_sound_effect_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/audio/sound-effect/{task_id}/asset",
|
||||
post(publish_sound_effect_asset).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/gallery",
|
||||
get(list_visual_novel_gallery),
|
||||
@@ -2007,6 +2044,38 @@ mod tests {
|
||||
assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_visual_novel_creation_route_returns_service_unavailable() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/creation/visual-novel/sessions")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"sourceMode": "idea",
|
||||
"seedText": "雨夜书店",
|
||||
"sourceAssetIds": []
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = read_json_response(response).await;
|
||||
assert_eq!(
|
||||
body["error"]["details"]["reason"],
|
||||
"creation_entry_disabled"
|
||||
);
|
||||
assert_eq!(body["error"]["details"]["creationTypeId"], "visual-novel");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_returns_standard_envelope_when_requested() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
@@ -4693,7 +4762,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn visual_novel_creation_route_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state.set_test_creation_entry_route_enabled("visual-novel", true);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
|
||||
@@ -34,7 +34,7 @@ pub async fn get_creation_entry_config_handler(
|
||||
Ok(json_success_body(Some(&request_context), config))
|
||||
}
|
||||
|
||||
/// 中文注释:api-server 路由熔断只拦运行态/API 请求,不改变前端入口展示规则。
|
||||
/// 中文注释:api-server 路由熔断只拦创作/运行态 API 请求,不改变前端入口展示规则。
|
||||
pub async fn require_creation_entry_route_enabled(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
@@ -87,6 +87,9 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
||||
if normalized.starts_with("/api/runtime/visual-novel") {
|
||||
return Some("visual-novel");
|
||||
}
|
||||
if normalized.starts_with("/api/creation/visual-novel") {
|
||||
return Some("visual-novel");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -115,7 +118,7 @@ pub(crate) fn test_creation_entry_config_response()
|
||||
test_creation_type("puzzle", true, true, 30),
|
||||
test_creation_type("match3d", true, true, 40),
|
||||
test_creation_type("square-hole", false, true, 50),
|
||||
test_creation_type("visual-novel", true, true, 60),
|
||||
test_creation_type("visual-novel", true, false, 60),
|
||||
test_creation_type("airp", true, false, 70),
|
||||
test_creation_type("creative-agent", false, true, 80),
|
||||
],
|
||||
@@ -165,6 +168,10 @@ mod tests {
|
||||
resolve_creation_entry_route_id("/api/runtime/visual-novel/works"),
|
||||
Some("visual-novel"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
||||
Some("visual-novel"),
|
||||
);
|
||||
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,14 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AppError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(self.body_text().as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AppError {}
|
||||
|
||||
impl From<AppError> for Response {
|
||||
fn from(error: AppError) -> Self {
|
||||
error.into_response()
|
||||
|
||||
@@ -563,11 +563,9 @@ fn resolve_hyper3d_overall_status(
|
||||
|
||||
fn extract_job_uuids(payload: &Value) -> Vec<String> {
|
||||
let mut job_uuids = Vec::new();
|
||||
if let Some(jobs) = find_first_array_by_keys(payload, &["jobs"]) {
|
||||
for job in jobs {
|
||||
if let Some(uuid) = find_first_string_by_keys(job, &["uuid", "task_uuid", "taskUuid"])
|
||||
&& !job_uuids.contains(&uuid)
|
||||
{
|
||||
if let Some(jobs) = payload.get("jobs") {
|
||||
for uuid in collect_strings_by_keys(jobs, &["uuid", "task_uuid", "taskUuid", "uuids"]) {
|
||||
if !job_uuids.contains(&uuid) {
|
||||
job_uuids.push(uuid);
|
||||
}
|
||||
}
|
||||
@@ -1076,8 +1074,10 @@ mod tests {
|
||||
contract::Hyper3dGenerationMode::TextToModel,
|
||||
json!({
|
||||
"uuid": "task-1",
|
||||
"subscription_key": "sub-1",
|
||||
"jobs": [{ "uuid": "job-1" }],
|
||||
"jobs": {
|
||||
"uuids": ["job-1", "job-2"],
|
||||
"subscription_key": "sub-1"
|
||||
},
|
||||
"message": "submitted"
|
||||
}),
|
||||
)
|
||||
@@ -1085,7 +1085,7 @@ mod tests {
|
||||
|
||||
assert_eq!(response.task_uuid, "task-1");
|
||||
assert_eq!(response.subscription_key, "sub-1");
|
||||
assert_eq!(response.job_uuids, vec!["job-1"]);
|
||||
assert_eq!(response.job_uuids, vec!["job-1", "job-2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ use platform_oss::{
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::{
|
||||
creation_audio::CreationAudioAsset,
|
||||
puzzle_agent::{
|
||||
CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest,
|
||||
PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse,
|
||||
@@ -56,7 +57,7 @@ use spacetime_client::{
|
||||
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
|
||||
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
|
||||
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
|
||||
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
|
||||
PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
@@ -228,6 +229,7 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
level_name: level_name.clone(),
|
||||
picture_description: prompt_text.clone(),
|
||||
picture_reference: None,
|
||||
background_music: None,
|
||||
candidates,
|
||||
selected_candidate_id: Some(selected.candidate_id.clone()),
|
||||
cover_image_src: Some(selected.image_src.clone()),
|
||||
@@ -2059,6 +2061,7 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft
|
||||
level_name: level.level_name,
|
||||
picture_description: level.picture_description,
|
||||
picture_reference: level.picture_reference,
|
||||
background_music: level.background_music.map(map_puzzle_audio_asset_record_response),
|
||||
candidates: level
|
||||
.candidates
|
||||
.into_iter()
|
||||
@@ -2071,6 +2074,70 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_audio_asset_record_response(asset: PuzzleAudioAssetRecord) -> CreationAudioAsset {
|
||||
CreationAudioAsset {
|
||||
task_id: asset.task_id,
|
||||
provider: asset.provider,
|
||||
asset_object_id: asset.asset_object_id,
|
||||
asset_kind: asset.asset_kind,
|
||||
audio_src: asset.audio_src,
|
||||
prompt: asset.prompt,
|
||||
title: asset.title,
|
||||
updated_at: asset.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_audio_asset_domain_record(
|
||||
asset: module_puzzle::PuzzleAudioAsset,
|
||||
) -> PuzzleAudioAssetRecord {
|
||||
PuzzleAudioAssetRecord {
|
||||
task_id: asset.task_id,
|
||||
provider: asset.provider,
|
||||
asset_object_id: asset.asset_object_id,
|
||||
asset_kind: asset.asset_kind,
|
||||
audio_src: asset.audio_src,
|
||||
prompt: asset.prompt,
|
||||
title: asset.title,
|
||||
updated_at: asset.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn puzzle_audio_asset_response_module_json(asset: &Option<CreationAudioAsset>) -> Value {
|
||||
asset
|
||||
.as_ref()
|
||||
.map(|asset| {
|
||||
json!({
|
||||
"task_id": asset.task_id,
|
||||
"provider": asset.provider,
|
||||
"asset_object_id": asset.asset_object_id,
|
||||
"asset_kind": asset.asset_kind,
|
||||
"audio_src": asset.audio_src,
|
||||
"prompt": asset.prompt,
|
||||
"title": asset.title,
|
||||
"updated_at": asset.updated_at,
|
||||
})
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn puzzle_audio_asset_record_module_json(asset: &Option<PuzzleAudioAssetRecord>) -> Value {
|
||||
asset
|
||||
.as_ref()
|
||||
.map(|asset| {
|
||||
json!({
|
||||
"task_id": asset.task_id,
|
||||
"provider": asset.provider,
|
||||
"asset_object_id": asset.asset_object_id,
|
||||
"asset_kind": asset.asset_kind,
|
||||
"audio_src": asset.audio_src,
|
||||
"prompt": asset.prompt,
|
||||
"title": asset.title,
|
||||
"updated_at": asset.updated_at,
|
||||
})
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn map_puzzle_creator_intent_response(
|
||||
intent: PuzzleCreatorIntentRecord,
|
||||
) -> PuzzleCreatorIntentResponse {
|
||||
@@ -2600,6 +2667,7 @@ fn parse_puzzle_level_records_from_module_json(
|
||||
level_name: level.level_name,
|
||||
picture_description: level.picture_description,
|
||||
picture_reference: level.picture_reference,
|
||||
background_music: level.background_music.map(map_puzzle_audio_asset_domain_record),
|
||||
candidates: level
|
||||
.candidates
|
||||
.into_iter()
|
||||
@@ -2767,6 +2835,7 @@ fn serialize_puzzle_levels_response(
|
||||
"level_name": level.level_name,
|
||||
"picture_description": level.picture_description,
|
||||
"picture_reference": level.picture_reference,
|
||||
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
|
||||
"candidates": level
|
||||
.candidates
|
||||
.iter()
|
||||
@@ -2815,6 +2884,7 @@ 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,
|
||||
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
|
||||
"candidates": level
|
||||
.candidates
|
||||
.iter()
|
||||
@@ -3806,6 +3876,8 @@ fn serialize_puzzle_level_records_for_module(
|
||||
"level_id": level.level_id,
|
||||
"level_name": level.level_name,
|
||||
"picture_description": level.picture_description,
|
||||
"picture_reference": level.picture_reference,
|
||||
"background_music": puzzle_audio_asset_record_module_json(&level.background_music),
|
||||
"candidates": level
|
||||
.candidates
|
||||
.iter()
|
||||
@@ -4504,6 +4576,65 @@ mod tests {
|
||||
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_audio_asset_roundtrips_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,
|
||||
background_music: Some(CreationAudioAsset {
|
||||
task_id: "suno-task-1".to_string(),
|
||||
provider: "vector-engine-suno".to_string(),
|
||||
asset_object_id: Some("assetobj_1".to_string()),
|
||||
asset_kind: Some("puzzle_background_music".to_string()),
|
||||
audio_src: "/generated-puzzle-assets/audio.mp3".to_string(),
|
||||
prompt: Some("轻快拼图音乐".to_string()),
|
||||
title: Some("雨夜猫街背景音乐".to_string()),
|
||||
updated_at: Some("2026-05-11T00:00:00Z".to_string()),
|
||||
}),
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
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]["background_music"]["audio_src"],
|
||||
Value::String("/generated-puzzle-assets/audio.mp3".to_string())
|
||||
);
|
||||
assert!(payload[0]["background_music"].get("audioSrc").is_none());
|
||||
|
||||
let records = parse_puzzle_level_records_from_module_json(&levels_json)
|
||||
.expect("levels should map back into records");
|
||||
let music = records[0]
|
||||
.background_music
|
||||
.as_ref()
|
||||
.expect("background music should exist");
|
||||
assert_eq!(music.audio_src, "/generated-puzzle-assets/audio.mp3");
|
||||
assert_eq!(music.asset_kind.as_deref(), Some("puzzle_background_music"));
|
||||
|
||||
let response = map_puzzle_draft_level_response(records[0].clone());
|
||||
assert_eq!(
|
||||
response
|
||||
.background_music
|
||||
.as_ref()
|
||||
.map(|asset| asset.audio_src.as_str()),
|
||||
Some("/generated-puzzle-assets/audio.mp3")
|
||||
);
|
||||
}
|
||||
|
||||
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
|
||||
let item = PuzzleAnchorItemRecord {
|
||||
key: "visualSubject".to_string(),
|
||||
@@ -4542,6 +4673,7 @@ mod tests {
|
||||
level_name: "猫画面".to_string(),
|
||||
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
picture_reference: None,
|
||||
background_music: None,
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
|
||||
@@ -13,7 +13,7 @@ use module_assets::{
|
||||
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
|
||||
use reqwest::header;
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::visual_novel as contract;
|
||||
use shared_contracts::{creation_audio, visual_novel as contract};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
@@ -51,6 +51,17 @@ struct DownloadedAudio {
|
||||
extension: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct AudioAssetBindingTarget {
|
||||
entity_kind: String,
|
||||
entity_id: String,
|
||||
slot: String,
|
||||
asset_kind: String,
|
||||
profile_id: Option<String>,
|
||||
storage_prefix: LegacyAssetPrefix,
|
||||
storage_scope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AudioAssetSlot {
|
||||
BackgroundMusic,
|
||||
@@ -58,13 +69,6 @@ enum AudioAssetSlot {
|
||||
}
|
||||
|
||||
impl AudioAssetSlot {
|
||||
fn contract_kind(self) -> contract::VisualNovelAudioGenerationKind {
|
||||
match self {
|
||||
Self::BackgroundMusic => contract::VisualNovelAudioGenerationKind::BackgroundMusic,
|
||||
Self::SoundEffect => contract::VisualNovelAudioGenerationKind::SoundEffect,
|
||||
}
|
||||
}
|
||||
|
||||
fn provider(self) -> &'static str {
|
||||
match self {
|
||||
Self::BackgroundMusic => VECTOR_ENGINE_SUNO_PROVIDER,
|
||||
@@ -92,6 +96,13 @@ impl AudioAssetSlot {
|
||||
Self::SoundEffect => "sound-effect",
|
||||
}
|
||||
}
|
||||
|
||||
fn creation_contract_kind(self) -> creation_audio::CreationAudioGenerationKind {
|
||||
match self {
|
||||
Self::BackgroundMusic => creation_audio::CreationAudioGenerationKind::BackgroundMusic,
|
||||
Self::SoundEffect => creation_audio::CreationAudioGenerationKind::SoundEffect,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_visual_novel_background_music_task(
|
||||
@@ -148,6 +159,25 @@ pub async fn create_visual_novel_background_music_task(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_background_music_task(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<creation_audio::CreateBackgroundMusicRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
create_background_music_task_response(
|
||||
&state,
|
||||
&request_context,
|
||||
payload.prompt,
|
||||
payload.title,
|
||||
payload.tags,
|
||||
payload.model,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn create_visual_novel_sound_effect_task(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
@@ -198,6 +228,116 @@ pub async fn create_visual_novel_sound_effect_task(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_sound_effect_task(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<creation_audio::CreateSoundEffectRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
create_sound_effect_task_response(&state, payload.prompt, payload.duration, payload.seed)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
async fn create_background_music_task_response(
|
||||
state: &AppState,
|
||||
_request_context: &RequestContext,
|
||||
prompt: String,
|
||||
title: String,
|
||||
tags: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> 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 title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?;
|
||||
let tags = tags
|
||||
.as_deref()
|
||||
.map(|value| normalize_limited_text(value, "tags", SUNO_TAGS_MAX_CHARS))
|
||||
.transpose()?;
|
||||
let model =
|
||||
normalize_optional_text(model.as_deref()).unwrap_or_else(|| SUNO_DEFAULT_MODEL.to_string());
|
||||
|
||||
let mut body = Map::from_iter([
|
||||
("prompt".to_string(), Value::String(prompt)),
|
||||
("mv".to_string(), Value::String(model)),
|
||||
("title".to_string(), Value::String(title)),
|
||||
("task".to_string(), Value::String("generate".to_string())),
|
||||
]);
|
||||
if let Some(tags) = tags {
|
||||
body.insert("tags".to_string(), Value::String(tags));
|
||||
}
|
||||
|
||||
let response = post_vector_engine_json(
|
||||
&http_client,
|
||||
&settings,
|
||||
"/suno/submit/music",
|
||||
Value::Object(body),
|
||||
"提交 Suno 背景音乐任务失败",
|
||||
)
|
||||
.await?;
|
||||
let task_id = extract_string_by_path(&response, &["data"])
|
||||
.or_else(|| find_first_string_by_key(&response, "task_id"))
|
||||
.or_else(|| find_first_string_by_key(&response, "taskId"))
|
||||
.ok_or_else(|| {
|
||||
vector_engine_bad_gateway("提交 Suno 背景音乐任务失败:上游未返回任务 ID")
|
||||
})?;
|
||||
|
||||
Ok(creation_audio::AudioGenerationTaskResponse {
|
||||
kind: creation_audio::CreationAudioGenerationKind::BackgroundMusic,
|
||||
task_id,
|
||||
provider: VECTOR_ENGINE_SUNO_PROVIDER.to_string(),
|
||||
status: "submitted".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_sound_effect_task_response(
|
||||
state: &AppState,
|
||||
prompt: String,
|
||||
duration: Option<u8>,
|
||||
seed: Option<u64>,
|
||||
) -> 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", VIDU_PROMPT_MAX_CHARS)?;
|
||||
let duration = duration
|
||||
.unwrap_or(DEFAULT_SOUND_EFFECT_DURATION_SECONDS)
|
||||
.clamp(2, 10);
|
||||
|
||||
let mut body = Map::from_iter([
|
||||
(
|
||||
"model".to_string(),
|
||||
Value::String(VIDU_AUDIO_MODEL.to_string()),
|
||||
),
|
||||
("prompt".to_string(), Value::String(prompt)),
|
||||
("duration".to_string(), json!(duration)),
|
||||
]);
|
||||
if let Some(seed) = seed {
|
||||
body.insert("seed".to_string(), json!(seed));
|
||||
}
|
||||
|
||||
let response = post_vector_engine_json(
|
||||
&http_client,
|
||||
&settings,
|
||||
"/ent/v2/text2audio",
|
||||
Value::Object(body),
|
||||
"提交 Vidu 音效任务失败",
|
||||
)
|
||||
.await?;
|
||||
let task_id = find_first_string_by_key(&response, "task_id")
|
||||
.or_else(|| find_first_string_by_key(&response, "taskId"))
|
||||
.ok_or_else(|| vector_engine_bad_gateway("提交 Vidu 音效任务失败:上游未返回任务 ID"))?;
|
||||
let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into());
|
||||
|
||||
Ok(creation_audio::AudioGenerationTaskResponse {
|
||||
kind: creation_audio::CreationAudioGenerationKind::SoundEffect,
|
||||
task_id,
|
||||
provider: VECTOR_ENGINE_VIDU_PROVIDER.to_string(),
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn publish_visual_novel_background_music_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
@@ -205,16 +345,30 @@ pub async fn publish_visual_novel_background_music_asset(
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<contract::PublishVisualNovelGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
let target = build_visual_novel_audio_target(payload, AudioAssetSlot::BackgroundMusic)?;
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
parse_json_payload(&request_context, payload)?.0,
|
||||
AudioAssetSlot::BackgroundMusic,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map(|payload| {
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
contract::VisualNovelGeneratedAudioAssetResponse {
|
||||
kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic,
|
||||
task_id: payload.task_id,
|
||||
provider: payload.provider,
|
||||
status: payload.status,
|
||||
asset_object_id: payload.asset_object_id,
|
||||
asset_kind: payload.asset_kind,
|
||||
audio_src: payload.audio_src,
|
||||
},
|
||||
)
|
||||
})
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
@@ -225,13 +379,69 @@ pub async fn publish_visual_novel_sound_effect_asset(
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<contract::PublishVisualNovelGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
let target = build_visual_novel_audio_target(payload, AudioAssetSlot::SoundEffect)?;
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
parse_json_payload(&request_context, payload)?.0,
|
||||
AudioAssetSlot::SoundEffect,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map(|payload| {
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
contract::VisualNovelGeneratedAudioAssetResponse {
|
||||
kind: contract::VisualNovelAudioGenerationKind::SoundEffect,
|
||||
task_id: payload.task_id,
|
||||
provider: payload.provider,
|
||||
status: payload.status,
|
||||
asset_object_id: payload.asset_object_id,
|
||||
asset_kind: payload.asset_kind,
|
||||
audio_src: payload.audio_src,
|
||||
},
|
||||
)
|
||||
})
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn publish_background_music_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
let target = build_creation_audio_target(payload)?;
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
AudioAssetSlot::BackgroundMusic,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn publish_sound_effect_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
let target = build_creation_audio_target(payload)?;
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
AudioAssetSlot::SoundEffect,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
@@ -240,15 +450,12 @@ pub async fn publish_visual_novel_sound_effect_asset(
|
||||
|
||||
async fn publish_generated_audio_asset(
|
||||
state: &AppState,
|
||||
_request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
task_id: String,
|
||||
payload: contract::PublishVisualNovelGeneratedAudioAssetRequest,
|
||||
slot: AudioAssetSlot,
|
||||
) -> Result<contract::VisualNovelGeneratedAudioAssetResponse, AppError> {
|
||||
target: AudioAssetBindingTarget,
|
||||
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
|
||||
let task_id = normalize_limited_text(&task_id, "taskId", 160)?;
|
||||
let scene_id = normalize_limited_text(&payload.scene_id, "sceneId", 160)?;
|
||||
let profile_id = normalize_optional_text(payload.profile_id.as_deref());
|
||||
let settings = require_vector_engine_audio_settings(state)?;
|
||||
let http_client = build_vector_engine_audio_http_client(&settings)?;
|
||||
let task_payload = fetch_audio_task_payload(&http_client, &settings, slot, &task_id).await?;
|
||||
@@ -277,8 +484,8 @@ async fn publish_generated_audio_asset(
|
||||
}
|
||||
|
||||
if is_pending_task_status(&status) && audio_urls.is_empty() {
|
||||
return Ok(contract::VisualNovelGeneratedAudioAssetResponse {
|
||||
kind: slot.contract_kind(),
|
||||
return Ok(creation_audio::GeneratedAudioAssetResponse {
|
||||
kind: slot.creation_contract_kind(),
|
||||
task_id,
|
||||
provider: slot.provider().to_string(),
|
||||
status,
|
||||
@@ -303,21 +510,20 @@ async fn publish_generated_audio_asset(
|
||||
state,
|
||||
&http_client,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
scene_id,
|
||||
&task_id,
|
||||
slot,
|
||||
target.clone(),
|
||||
audio,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(contract::VisualNovelGeneratedAudioAssetResponse {
|
||||
kind: slot.contract_kind(),
|
||||
Ok(creation_audio::GeneratedAudioAssetResponse {
|
||||
kind: slot.creation_contract_kind(),
|
||||
task_id,
|
||||
provider: slot.provider().to_string(),
|
||||
status: "completed".to_string(),
|
||||
asset_object_id: Some(persisted.asset_object_id),
|
||||
asset_kind: Some(slot.asset_kind().to_string()),
|
||||
asset_kind: Some(target.asset_kind),
|
||||
audio_src: Some(persisted.audio_src),
|
||||
})
|
||||
}
|
||||
@@ -360,10 +566,9 @@ async fn persist_generated_audio_asset(
|
||||
state: &AppState,
|
||||
http_client: &reqwest::Client,
|
||||
owner_user_id: &str,
|
||||
profile_id: Option<String>,
|
||||
scene_id: String,
|
||||
task_id: &str,
|
||||
slot: AudioAssetSlot,
|
||||
target: AudioAssetBindingTarget,
|
||||
audio: DownloadedAudio,
|
||||
) -> Result<PersistedAudioAsset, AppError> {
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
@@ -378,20 +583,26 @@ async fn persist_generated_audio_asset(
|
||||
.put_object(
|
||||
http_client,
|
||||
OssPutObjectRequest {
|
||||
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
prefix: target.storage_prefix,
|
||||
path_segments: vec![
|
||||
"visual-novel".to_string(),
|
||||
profile_id.clone().unwrap_or_else(|| "draft".to_string()),
|
||||
scene_id.clone(),
|
||||
slot.slot().to_string(),
|
||||
],
|
||||
target.storage_scope.clone(),
|
||||
target
|
||||
.profile_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "draft".to_string()),
|
||||
target.entity_id.clone(),
|
||||
target.slot.clone(),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|segment| sanitize_audio_path_segment(segment.as_str(), "audio"))
|
||||
.collect(),
|
||||
file_name,
|
||||
content_type: Some(audio.mime_type.clone()),
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: build_audio_asset_metadata(
|
||||
owner_user_id,
|
||||
profile_id.as_deref(),
|
||||
&scene_id,
|
||||
target.profile_id.as_deref(),
|
||||
&target,
|
||||
slot,
|
||||
),
|
||||
body: audio.bytes,
|
||||
@@ -420,11 +631,11 @@ async fn persist_generated_audio_asset(
|
||||
head.content_type.or(Some(audio.mime_type)),
|
||||
head.content_length,
|
||||
head.etag,
|
||||
slot.asset_kind().to_string(),
|
||||
target.asset_kind.clone(),
|
||||
Some(task_id.to_string()),
|
||||
Some(owner_user_id.to_string()),
|
||||
profile_id.clone(),
|
||||
Some(scene_id.clone()),
|
||||
target.profile_id.clone(),
|
||||
Some(target.entity_id.clone()),
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_field_error)?,
|
||||
@@ -437,12 +648,12 @@ async fn persist_generated_audio_asset(
|
||||
build_asset_entity_binding_input(
|
||||
generate_asset_binding_id(now_micros),
|
||||
asset_object.asset_object_id.clone(),
|
||||
AUDIO_ENTITY_KIND.to_string(),
|
||||
scene_id,
|
||||
slot.slot().to_string(),
|
||||
slot.asset_kind().to_string(),
|
||||
target.entity_kind,
|
||||
target.entity_id,
|
||||
target.slot,
|
||||
target.asset_kind,
|
||||
Some(owner_user_id.to_string()),
|
||||
profile_id,
|
||||
target.profile_id,
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_field_error)?,
|
||||
@@ -459,15 +670,15 @@ async fn persist_generated_audio_asset(
|
||||
fn build_audio_asset_metadata(
|
||||
owner_user_id: &str,
|
||||
profile_id: Option<&str>,
|
||||
scene_id: &str,
|
||||
target: &AudioAssetBindingTarget,
|
||||
slot: AudioAssetSlot,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut metadata = BTreeMap::from([
|
||||
("asset-kind".to_string(), slot.asset_kind().to_string()),
|
||||
("asset-kind".to_string(), target.asset_kind.clone()),
|
||||
("owner-user-id".to_string(), owner_user_id.to_string()),
|
||||
("entity-kind".to_string(), AUDIO_ENTITY_KIND.to_string()),
|
||||
("entity-id".to_string(), scene_id.to_string()),
|
||||
("slot".to_string(), slot.slot().to_string()),
|
||||
("entity-kind".to_string(), target.entity_kind.clone()),
|
||||
("entity-id".to_string(), target.entity_id.clone()),
|
||||
("slot".to_string(), target.slot.clone()),
|
||||
("provider".to_string(), slot.provider().to_string()),
|
||||
]);
|
||||
if let Some(profile_id) = profile_id {
|
||||
@@ -476,6 +687,51 @@ fn build_audio_asset_metadata(
|
||||
metadata
|
||||
}
|
||||
|
||||
fn build_visual_novel_audio_target(
|
||||
payload: contract::PublishVisualNovelGeneratedAudioAssetRequest,
|
||||
slot: AudioAssetSlot,
|
||||
) -> Result<AudioAssetBindingTarget, AppError> {
|
||||
let entity_id = normalize_limited_text(&payload.scene_id, "sceneId", 160)?;
|
||||
Ok(AudioAssetBindingTarget {
|
||||
entity_kind: AUDIO_ENTITY_KIND.to_string(),
|
||||
entity_id,
|
||||
slot: slot.slot().to_string(),
|
||||
asset_kind: slot.asset_kind().to_string(),
|
||||
profile_id: normalize_optional_text(payload.profile_id.as_deref()),
|
||||
storage_prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
storage_scope: "visual-novel".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_creation_audio_target(
|
||||
payload: creation_audio::PublishGeneratedAudioAssetRequest,
|
||||
) -> Result<AudioAssetBindingTarget, AppError> {
|
||||
let entity_kind = normalize_limited_text(&payload.entity_kind, "entityKind", 80)?;
|
||||
let entity_id = normalize_limited_text(&payload.entity_id, "entityId", 160)?;
|
||||
let slot = normalize_limited_text(&payload.slot, "slot", 80)?;
|
||||
let asset_kind = normalize_limited_text(&payload.asset_kind, "assetKind", 80)?;
|
||||
let storage_prefix = match payload.storage_prefix {
|
||||
Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets) => {
|
||||
LegacyAssetPrefix::PuzzleAssets
|
||||
}
|
||||
Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets) => {
|
||||
LegacyAssetPrefix::Match3DAssets
|
||||
}
|
||||
Some(creation_audio::CreationAudioStoragePrefix::CustomWorldScenes) | None => {
|
||||
LegacyAssetPrefix::CustomWorldScenes
|
||||
}
|
||||
};
|
||||
Ok(AudioAssetBindingTarget {
|
||||
storage_scope: entity_kind.clone(),
|
||||
entity_kind,
|
||||
entity_id,
|
||||
slot,
|
||||
asset_kind,
|
||||
profile_id: normalize_optional_text(payload.profile_id.as_deref()),
|
||||
storage_prefix,
|
||||
})
|
||||
}
|
||||
|
||||
fn require_vector_engine_audio_settings(
|
||||
state: &AppState,
|
||||
) -> Result<VectorEngineAudioSettings, AppError> {
|
||||
@@ -878,6 +1134,30 @@ fn encode_path_segment(value: &str) -> String {
|
||||
urlencoding::encode(value).into_owned()
|
||||
}
|
||||
|
||||
fn sanitize_audio_path_segment(raw: &str, fallback: &str) -> String {
|
||||
let normalized = raw
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let collapsed = normalized
|
||||
.split('-')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if collapsed.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
collapsed.chars().take(80).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_raw(raw_text: &str) -> String {
|
||||
raw_text.chars().take(800).collect()
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ pub fn compile_result_draft_from_seed(
|
||||
level_name: level_name.clone(),
|
||||
picture_description,
|
||||
picture_reference: None,
|
||||
background_music: None,
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
@@ -242,6 +243,7 @@ pub fn build_form_draft_from_parts(
|
||||
level_name: String::new(),
|
||||
picture_description: picture_description.clone().unwrap_or_default(),
|
||||
picture_reference: None,
|
||||
background_music: None,
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
@@ -347,6 +349,7 @@ pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft
|
||||
&draft.summary,
|
||||
),
|
||||
picture_reference: None,
|
||||
background_music: None,
|
||||
candidates: draft.candidates.clone(),
|
||||
selected_candidate_id: draft.selected_candidate_id.clone(),
|
||||
cover_image_src: draft.cover_image_src.clone(),
|
||||
@@ -433,6 +436,7 @@ pub fn append_blank_puzzle_level(draft: &PuzzleResultDraft) -> PuzzleResultDraft
|
||||
),
|
||||
picture_description,
|
||||
picture_reference: None,
|
||||
background_music: None,
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
@@ -2798,6 +2802,7 @@ mod tests {
|
||||
level_name: format!("{profile_id} 关"),
|
||||
picture_description: "summary".to_string(),
|
||||
picture_reference: None,
|
||||
background_music: None,
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: Some("/cover.png".to_string()),
|
||||
@@ -3012,6 +3017,7 @@ mod tests {
|
||||
level_name: "第一关".to_string(),
|
||||
picture_description: "第一关画面".to_string(),
|
||||
picture_reference: None,
|
||||
background_music: None,
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: Some("/level-1.png".to_string()),
|
||||
@@ -3023,6 +3029,7 @@ mod tests {
|
||||
level_name: "第二关".to_string(),
|
||||
picture_description: "第二关画面".to_string(),
|
||||
picture_reference: None,
|
||||
background_music: None,
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: Some("/level-2.png".to_string()),
|
||||
|
||||
@@ -169,6 +169,7 @@ pub fn build_puzzle_draft_from_creative_fields(
|
||||
.unwrap_or_else(|| format!("第{}关", index + 1)),
|
||||
picture_description,
|
||||
picture_reference: level.picture_reference.and_then(normalize_required_string),
|
||||
background_music: None,
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
|
||||
@@ -131,6 +131,8 @@ pub struct PuzzleDraftLevel {
|
||||
pub picture_description: String,
|
||||
#[serde(default)]
|
||||
pub picture_reference: Option<String>,
|
||||
#[serde(default)]
|
||||
pub background_music: Option<PuzzleAudioAsset>,
|
||||
pub candidates: Vec<PuzzleGeneratedImageCandidate>,
|
||||
pub selected_candidate_id: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
@@ -138,6 +140,24 @@ pub struct PuzzleDraftLevel {
|
||||
pub generation_status: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleAudioAsset {
|
||||
pub task_id: String,
|
||||
pub provider: String,
|
||||
#[serde(default)]
|
||||
pub asset_object_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub asset_kind: Option<String>,
|
||||
pub audio_src: String,
|
||||
#[serde(default)]
|
||||
pub prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleResultDraft {
|
||||
|
||||
@@ -663,7 +663,7 @@ mod tests {
|
||||
let payload = serde_json::to_value(CreateDirectUploadTicketResponse {
|
||||
upload: DirectUploadTicketPayload {
|
||||
signature_version: "v4".to_string(),
|
||||
provider: "aliyun-oss",
|
||||
provider: "aliyun-oss".to_string(),
|
||||
bucket: "genarrative-assets".to_string(),
|
||||
endpoint: "oss-cn-shanghai.aliyuncs.com".to_string(),
|
||||
host: "https://genarrative-assets.oss-cn-shanghai.aliyuncs.com".to_string(),
|
||||
|
||||
128
server-rs/crates/shared-contracts/src/creation_audio.rs
Normal file
128
server-rs/crates/shared-contracts/src/creation_audio.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CreationAudioGenerationKind {
|
||||
BackgroundMusic,
|
||||
SoundEffect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationAudioAsset {
|
||||
pub task_id: String,
|
||||
pub provider: String,
|
||||
#[serde(default)]
|
||||
pub asset_object_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub asset_kind: Option<String>,
|
||||
pub audio_src: String,
|
||||
#[serde(default)]
|
||||
pub prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateBackgroundMusicRequest {
|
||||
pub prompt: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub tags: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSoundEffectRequest {
|
||||
pub prompt: String,
|
||||
#[serde(default)]
|
||||
pub duration: Option<u8>,
|
||||
#[serde(default)]
|
||||
pub seed: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AudioGenerationTaskResponse {
|
||||
pub kind: CreationAudioGenerationKind,
|
||||
pub task_id: String,
|
||||
pub provider: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CreationAudioStoragePrefix {
|
||||
PuzzleAssets,
|
||||
#[serde(rename = "match3d_assets")]
|
||||
Match3DAssets,
|
||||
CustomWorldScenes,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublishGeneratedAudioAssetRequest {
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub slot: String,
|
||||
pub asset_kind: String,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub storage_prefix: Option<CreationAudioStoragePrefix>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GeneratedAudioAssetResponse {
|
||||
pub kind: CreationAudioGenerationKind,
|
||||
pub task_id: String,
|
||||
pub provider: String,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub asset_object_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub asset_kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub audio_src: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn creation_audio_contracts_use_camel_case_fields() {
|
||||
let request = PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "match3d_item".to_string(),
|
||||
entity_id: "match3d-item-1".to_string(),
|
||||
slot: "click_sound".to_string(),
|
||||
asset_kind: "match3d_click_sound".to_string(),
|
||||
profile_id: Some("profile-1".to_string()),
|
||||
storage_prefix: Some(CreationAudioStoragePrefix::Match3DAssets),
|
||||
};
|
||||
let payload = serde_json::to_value(request).expect("request should serialize");
|
||||
assert_eq!(payload["entityKind"], json!("match3d_item"));
|
||||
assert_eq!(payload["storagePrefix"], json!("match3d_assets"));
|
||||
|
||||
let asset = CreationAudioAsset {
|
||||
task_id: "task-1".to_string(),
|
||||
provider: "vector-engine-suno".to_string(),
|
||||
asset_object_id: Some("assetobj_1".to_string()),
|
||||
asset_kind: Some("puzzle_background_music".to_string()),
|
||||
audio_src: "/generated-puzzle-assets/a.mp3".to_string(),
|
||||
prompt: Some("轻快音乐".to_string()),
|
||||
title: Some("拼图音乐".to_string()),
|
||||
updated_at: Some("2026-05-11T00:00:00Z".to_string()),
|
||||
};
|
||||
let payload = serde_json::to_value(asset).expect("asset should serialize");
|
||||
assert_eq!(payload["taskId"], json!("task-1"));
|
||||
assert_eq!(payload["audioSrc"], json!("/generated-puzzle-assets/a.mp3"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod auth;
|
||||
pub mod big_fish;
|
||||
pub mod big_fish_works;
|
||||
pub mod creation_agent_document_input;
|
||||
pub mod creation_audio;
|
||||
pub mod creation_entry_config;
|
||||
pub mod creative_agent;
|
||||
pub mod hyper3d;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::creation_audio::CreationAudioAsset;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateMatch3DAgentSessionRequest {
|
||||
@@ -108,6 +110,10 @@ pub struct Match3DGeneratedItemAssetResponse {
|
||||
pub task_uuid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subscription_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub background_music: Option<CreationAudioAsset>,
|
||||
#[serde(default)]
|
||||
pub click_sound: Option<CreationAudioAsset>,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::creation_audio::CreationAudioAsset;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PutMatch3DWorkRequest {
|
||||
@@ -16,6 +18,12 @@ pub struct PutMatch3DWorkRequest {
|
||||
pub difficulty: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PutMatch3DAudioAssetsRequest {
|
||||
pub generated_item_assets: Vec<Match3DGeneratedItemAssetResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DWorkSummaryResponse {
|
||||
@@ -63,6 +71,10 @@ pub struct Match3DGeneratedItemAssetResponse {
|
||||
pub task_uuid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subscription_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub background_music: Option<CreationAudioAsset>,
|
||||
#[serde(default)]
|
||||
pub click_sound: Option<CreationAudioAsset>,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::creation_audio::CreationAudioAsset;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatePuzzleAgentSessionRequest {
|
||||
@@ -151,6 +153,8 @@ pub struct PuzzleDraftLevelResponse {
|
||||
pub picture_description: String,
|
||||
#[serde(default)]
|
||||
pub picture_reference: Option<String>,
|
||||
#[serde(default)]
|
||||
pub background_music: Option<CreationAudioAsset>,
|
||||
pub candidates: Vec<PuzzleGeneratedImageCandidateResponse>,
|
||||
#[serde(default)]
|
||||
pub selected_candidate_id: Option<String>,
|
||||
|
||||
@@ -40,8 +40,9 @@ pub use mapper::{
|
||||
PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord,
|
||||
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
|
||||
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
|
||||
PuzzleAnchorPackRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
||||
PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput,
|
||||
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
|
||||
PuzzleFormDraftSaveRecordInput,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
|
||||
@@ -2917,6 +2917,7 @@ pub(crate) fn map_puzzle_draft_level(snapshot: DomainPuzzleDraftLevel) -> Puzzle
|
||||
level_name: snapshot.level_name,
|
||||
picture_description: snapshot.picture_description,
|
||||
picture_reference: snapshot.picture_reference,
|
||||
background_music: snapshot.background_music.map(map_puzzle_audio_asset),
|
||||
candidates: snapshot
|
||||
.candidates
|
||||
.into_iter()
|
||||
@@ -2929,6 +2930,21 @@ pub(crate) fn map_puzzle_draft_level(snapshot: DomainPuzzleDraftLevel) -> Puzzle
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_puzzle_audio_asset(
|
||||
asset: module_puzzle::PuzzleAudioAsset,
|
||||
) -> PuzzleAudioAssetRecord {
|
||||
PuzzleAudioAssetRecord {
|
||||
task_id: asset.task_id,
|
||||
provider: asset.provider,
|
||||
asset_object_id: asset.asset_object_id,
|
||||
asset_kind: asset.asset_kind,
|
||||
audio_src: asset.audio_src,
|
||||
prompt: asset.prompt,
|
||||
title: asset.title,
|
||||
updated_at: asset.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_puzzle_creator_intent(
|
||||
snapshot: DomainPuzzleCreatorIntent,
|
||||
) -> PuzzleCreatorIntentRecord {
|
||||
@@ -7269,6 +7285,7 @@ pub struct PuzzleDraftLevelRecord {
|
||||
pub level_name: String,
|
||||
pub picture_description: String,
|
||||
pub picture_reference: Option<String>,
|
||||
pub background_music: Option<PuzzleAudioAssetRecord>,
|
||||
pub candidates: Vec<PuzzleGeneratedImageCandidateRecord>,
|
||||
pub selected_candidate_id: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
@@ -7276,6 +7293,18 @@ pub struct PuzzleDraftLevelRecord {
|
||||
pub generation_status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PuzzleAudioAssetRecord {
|
||||
pub task_id: String,
|
||||
pub provider: String,
|
||||
pub asset_object_id: Option<String>,
|
||||
pub asset_kind: Option<String>,
|
||||
pub audio_src: String,
|
||||
pub prompt: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PuzzleAgentMessageRecord {
|
||||
pub message_id: String,
|
||||
|
||||
@@ -443,21 +443,23 @@ fn compile_match3d_draft_tx(
|
||||
let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let config = normalize_match3d_generated_item_config(parse_config(&session.config_json)?);
|
||||
validate_config(&config)?;
|
||||
let tags = input
|
||||
.tags_json
|
||||
.as_deref()
|
||||
.map(parse_tags)
|
||||
.transpose()?
|
||||
.filter(|items| !items.is_empty())
|
||||
.unwrap_or_else(|| default_tags(&config.theme_text));
|
||||
let game_name =
|
||||
clean_optional(&input.game_name).unwrap_or_else(|| format!("{}抓大鹅", config.theme_text));
|
||||
let summary_text = input
|
||||
.summary_text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let existing_work = ctx
|
||||
.db
|
||||
.match3d_work_profile()
|
||||
.profile_id()
|
||||
.find(&input.profile_id)
|
||||
.filter(|row| row.owner_user_id == input.owner_user_id);
|
||||
let tags = resolve_compile_tags(
|
||||
input.tags_json.as_deref(),
|
||||
existing_work.as_ref(),
|
||||
config.theme_text.as_str(),
|
||||
)?;
|
||||
let game_name = resolve_compile_game_name(
|
||||
&input.game_name,
|
||||
existing_work.as_ref(),
|
||||
config.theme_text.as_str(),
|
||||
);
|
||||
let summary_text = resolve_compile_summary_text(&input.summary_text, existing_work.as_ref());
|
||||
let draft = Match3DDraftSnapshot {
|
||||
profile_id: input.profile_id.clone(),
|
||||
game_name: game_name.clone(),
|
||||
@@ -468,6 +470,31 @@ fn compile_match3d_draft_tx(
|
||||
difficulty: config.difficulty,
|
||||
};
|
||||
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
|
||||
let generated_item_assets_json = resolve_generated_item_assets_json_for_compile(
|
||||
input.generated_item_assets_json.as_deref(),
|
||||
existing_work.as_ref(),
|
||||
)?;
|
||||
let previous_publication_status = existing_work
|
||||
.as_ref()
|
||||
.map(|work| work.publication_status.clone())
|
||||
.unwrap_or_else(|| MATCH3D_PUBLICATION_DRAFT.to_string());
|
||||
let previous_play_count = existing_work
|
||||
.as_ref()
|
||||
.map(|work| work.play_count)
|
||||
.unwrap_or(0);
|
||||
let previous_published_at = existing_work.as_ref().and_then(|work| work.published_at);
|
||||
let cover_image_src = resolve_compile_optional_text(
|
||||
&input.cover_image_src,
|
||||
existing_work
|
||||
.as_ref()
|
||||
.map(|work| work.cover_image_src.as_str()),
|
||||
);
|
||||
let cover_asset_id = resolve_compile_optional_text(
|
||||
&input.cover_asset_id,
|
||||
existing_work
|
||||
.as_ref()
|
||||
.map(|work| work.cover_asset_id.as_str()),
|
||||
);
|
||||
let work = Match3DWorkProfileRow {
|
||||
profile_id: input.profile_id.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
@@ -477,18 +504,16 @@ fn compile_match3d_draft_tx(
|
||||
theme_text: config.theme_text.clone(),
|
||||
summary_text,
|
||||
tags_json: to_json_string(&tags),
|
||||
cover_image_src: clean_optional(&input.cover_image_src).unwrap_or_default(),
|
||||
cover_asset_id: clean_optional(&input.cover_asset_id).unwrap_or_default(),
|
||||
cover_image_src,
|
||||
cover_asset_id,
|
||||
clear_count: config.clear_count,
|
||||
difficulty: config.difficulty,
|
||||
config_json: to_json_string(&config),
|
||||
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
|
||||
play_count: 0,
|
||||
publication_status: previous_publication_status,
|
||||
play_count: previous_play_count,
|
||||
updated_at: compiled_at,
|
||||
published_at: None,
|
||||
generated_item_assets_json: normalize_generated_item_assets_json(
|
||||
input.generated_item_assets_json.as_deref(),
|
||||
)?,
|
||||
published_at: previous_published_at,
|
||||
generated_item_assets_json,
|
||||
};
|
||||
upsert_work(ctx, work);
|
||||
replace_session(
|
||||
@@ -1259,6 +1284,68 @@ fn normalize_generated_item_assets_json(value: Option<&str>) -> Result<Option<St
|
||||
Ok(Some(to_json_string(&parsed)))
|
||||
}
|
||||
|
||||
fn resolve_generated_item_assets_json_for_compile(
|
||||
input: Option<&str>,
|
||||
existing_work: Option<&Match3DWorkProfileRow>,
|
||||
) -> Result<Option<String>, String> {
|
||||
if input.is_some() {
|
||||
return normalize_generated_item_assets_json(input);
|
||||
}
|
||||
Ok(existing_work.and_then(|work| work.generated_item_assets_json.clone()))
|
||||
}
|
||||
|
||||
fn resolve_compile_tags(
|
||||
input_tags_json: Option<&str>,
|
||||
existing_work: Option<&Match3DWorkProfileRow>,
|
||||
theme_text: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
input_tags_json
|
||||
.or_else(|| existing_work.map(|work| work.tags_json.as_str()))
|
||||
.map(parse_tags)
|
||||
.transpose()
|
||||
.map(|tags| {
|
||||
tags.filter(|items| !items.is_empty())
|
||||
.unwrap_or_else(|| default_tags(theme_text))
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_compile_game_name(
|
||||
input_game_name: &Option<String>,
|
||||
existing_work: Option<&Match3DWorkProfileRow>,
|
||||
theme_text: &str,
|
||||
) -> String {
|
||||
clean_optional(input_game_name)
|
||||
.or_else(|| {
|
||||
existing_work
|
||||
.map(|work| clean_string(&work.game_name, ""))
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| format!("{theme_text}抓大鹅"))
|
||||
}
|
||||
|
||||
fn resolve_compile_summary_text(
|
||||
input_summary_text: &Option<String>,
|
||||
existing_work: Option<&Match3DWorkProfileRow>,
|
||||
) -> String {
|
||||
input_summary_text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(str::to_string)
|
||||
.or_else(|| existing_work.map(|work| work.summary_text.clone()))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn resolve_compile_optional_text(input: &Option<String>, existing: Option<&str>) -> String {
|
||||
clean_optional(input)
|
||||
.or_else(|| {
|
||||
existing
|
||||
.map(|value| clean_string(value, ""))
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn default_tags(theme_text: &str) -> Vec<String> {
|
||||
normalize_tags(vec![
|
||||
theme_text.to_string(),
|
||||
@@ -1664,6 +1751,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_compile_without_asset_payload_preserves_existing_generated_assets() {
|
||||
let existing = Match3DWorkProfileRow {
|
||||
profile_id: "profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_session_id: "session-1".to_string(),
|
||||
author_display_name: "作者".to_string(),
|
||||
game_name: "水果抓大鹅".to_string(),
|
||||
theme_text: "水果".to_string(),
|
||||
summary_text: String::new(),
|
||||
tags_json: "[\"水果\"]".to_string(),
|
||||
cover_image_src: String::new(),
|
||||
cover_asset_id: String::new(),
|
||||
clear_count: 3,
|
||||
difficulty: 3,
|
||||
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
|
||||
theme_text: "水果".to_string(),
|
||||
reference_image_src: None,
|
||||
clear_count: 3,
|
||||
difficulty: 3,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
}),
|
||||
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
|
||||
play_count: 2,
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
published_at: None,
|
||||
generated_item_assets_json: Some(
|
||||
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"#
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let preserved =
|
||||
resolve_generated_item_assets_json_for_compile(None, Some(&existing)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
preserved.as_deref(),
|
||||
existing.generated_item_assets_json.as_deref()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_compile_without_metadata_payload_preserves_existing_metadata() {
|
||||
let existing = Match3DWorkProfileRow {
|
||||
profile_id: "profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_session_id: "session-1".to_string(),
|
||||
author_display_name: "作者".to_string(),
|
||||
game_name: "果园大鹅宴".to_string(),
|
||||
theme_text: "水果".to_string(),
|
||||
summary_text: "保留描述".to_string(),
|
||||
tags_json: "[\"水果\",\"轻量休闲\"]".to_string(),
|
||||
cover_image_src: "/cover.png".to_string(),
|
||||
cover_asset_id: "cover-asset-1".to_string(),
|
||||
clear_count: 3,
|
||||
difficulty: 3,
|
||||
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
|
||||
theme_text: "水果".to_string(),
|
||||
reference_image_src: None,
|
||||
clear_count: 3,
|
||||
difficulty: 3,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
}),
|
||||
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
|
||||
play_count: 2,
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
published_at: None,
|
||||
generated_item_assets_json: None,
|
||||
};
|
||||
|
||||
let input_game_name = None;
|
||||
let input_summary_text = None;
|
||||
let input_tags_json = None;
|
||||
let input_cover_image_src = None;
|
||||
let input_cover_asset_id = None;
|
||||
let tags = resolve_compile_tags(input_tags_json, Some(&existing), "水果").unwrap();
|
||||
let game_name = resolve_compile_game_name(&input_game_name, Some(&existing), "水果");
|
||||
let summary_text = resolve_compile_summary_text(&input_summary_text, Some(&existing));
|
||||
let cover_image_src =
|
||||
resolve_compile_optional_text(&input_cover_image_src, Some(&existing.cover_image_src));
|
||||
let cover_asset_id =
|
||||
resolve_compile_optional_text(&input_cover_asset_id, Some(&existing.cover_asset_id));
|
||||
|
||||
assert_eq!(game_name, "果园大鹅宴");
|
||||
assert_eq!(summary_text, "保留描述");
|
||||
assert_eq!(tags, vec!["水果".to_string(), "轻量休闲".to_string()]);
|
||||
assert_eq!(cover_image_src, "/cover.png");
|
||||
assert_eq!(cover_asset_id, "cover-asset-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_compile_normalizes_clear_count_to_three_item_mvp() {
|
||||
let config = normalize_match3d_generated_item_config(Match3DCreatorConfigSnapshot {
|
||||
|
||||
@@ -31,9 +31,7 @@ pub struct CreationEntryTypeConfig {
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn get_creation_entry_config(
|
||||
ctx: &mut ProcedureContext,
|
||||
) -> CreationEntryConfigProcedureResult {
|
||||
pub fn get_creation_entry_config(ctx: &mut ProcedureContext) -> CreationEntryConfigProcedureResult {
|
||||
match ctx.try_with_tx(|tx| get_or_seed_creation_entry_config_snapshot(tx)) {
|
||||
Ok(record) => CreationEntryConfigProcedureResult {
|
||||
ok: true,
|
||||
@@ -180,18 +178,129 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
ctx.db.creation_entry_type_config().insert(seed);
|
||||
}
|
||||
}
|
||||
|
||||
migrate_visual_novel_entry_from_old_open_default(ctx, now);
|
||||
}
|
||||
|
||||
fn migrate_visual_novel_entry_from_old_open_default(ctx: &ReducerContext, now: Timestamp) {
|
||||
let id = "visual-novel".to_string();
|
||||
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 中文注释:只纠偏旧默认种子,不覆盖后台入口开关里后续手动调整的视觉小说配置。
|
||||
let still_old_default = row.title == "视觉小说"
|
||||
&& row.subtitle == "分支叙事体验"
|
||||
&& row.badge == "可创建"
|
||||
&& row.image_src == "/creation-type-references/visual-novel.webp"
|
||||
&& row.visible
|
||||
&& row.open
|
||||
&& row.sort_order == 60;
|
||||
if !still_old_default {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.creation_entry_type_config()
|
||||
.id()
|
||||
.update(CreationEntryTypeConfig {
|
||||
badge: "敬请期待".to_string(),
|
||||
open: false,
|
||||
updated_at: now,
|
||||
..row
|
||||
});
|
||||
}
|
||||
|
||||
fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeConfig> {
|
||||
vec![
|
||||
build_creation_entry_type_seed("rpg", "文字冒险", "经典 RPG 体验", "内测", "/creation-type-references/rpg.webp", false, true, 10, now),
|
||||
build_creation_entry_type_seed("big-fish", "摸鱼", "轻量闯关玩法", "可创建", "/creation-type-references/big-fish.webp", false, true, 20, now),
|
||||
build_creation_entry_type_seed("puzzle", "拼图", "拼图关卡创作", "可创建", "/creation-type-references/puzzle.webp", true, true, 30, now),
|
||||
build_creation_entry_type_seed("match3d", "抓大鹅", "3D 消除关卡", "可创建", "/creation-type-references/match3d.webp", true, true, 40, now),
|
||||
build_creation_entry_type_seed("square-hole", "方洞", "形状投放挑战", "可创建", "/creation-type-references/square-hole.webp", false, true, 50, now),
|
||||
build_creation_entry_type_seed("visual-novel", "视觉小说", "分支叙事体验", "可创建", "/creation-type-references/visual-novel.webp", true, true, 60, now),
|
||||
build_creation_entry_type_seed("airp", "AI RPG", "原生角色扮演", "即将开放", "/creation-type-references/airp.webp", true, false, 70, now),
|
||||
build_creation_entry_type_seed("creative-agent", "智能体创作", "对话式创作实验", "内测", "/creation-type-references/creative-agent.webp", false, true, 80, now),
|
||||
build_creation_entry_type_seed(
|
||||
"rpg",
|
||||
"文字冒险",
|
||||
"经典 RPG 体验",
|
||||
"内测",
|
||||
"/creation-type-references/rpg.webp",
|
||||
false,
|
||||
true,
|
||||
10,
|
||||
now,
|
||||
),
|
||||
build_creation_entry_type_seed(
|
||||
"big-fish",
|
||||
"摸鱼",
|
||||
"轻量闯关玩法",
|
||||
"可创建",
|
||||
"/creation-type-references/big-fish.webp",
|
||||
false,
|
||||
true,
|
||||
20,
|
||||
now,
|
||||
),
|
||||
build_creation_entry_type_seed(
|
||||
"puzzle",
|
||||
"拼图",
|
||||
"拼图关卡创作",
|
||||
"可创建",
|
||||
"/creation-type-references/puzzle.webp",
|
||||
true,
|
||||
true,
|
||||
30,
|
||||
now,
|
||||
),
|
||||
build_creation_entry_type_seed(
|
||||
"match3d",
|
||||
"抓大鹅",
|
||||
"3D 消除关卡",
|
||||
"可创建",
|
||||
"/creation-type-references/match3d.webp",
|
||||
true,
|
||||
true,
|
||||
40,
|
||||
now,
|
||||
),
|
||||
build_creation_entry_type_seed(
|
||||
"square-hole",
|
||||
"方洞",
|
||||
"形状投放挑战",
|
||||
"可创建",
|
||||
"/creation-type-references/square-hole.webp",
|
||||
false,
|
||||
true,
|
||||
50,
|
||||
now,
|
||||
),
|
||||
build_creation_entry_type_seed(
|
||||
"visual-novel",
|
||||
"视觉小说",
|
||||
"分支叙事体验",
|
||||
"敬请期待",
|
||||
"/creation-type-references/visual-novel.webp",
|
||||
true,
|
||||
false,
|
||||
60,
|
||||
now,
|
||||
),
|
||||
build_creation_entry_type_seed(
|
||||
"airp",
|
||||
"AI RPG",
|
||||
"原生角色扮演",
|
||||
"即将开放",
|
||||
"/creation-type-references/airp.webp",
|
||||
true,
|
||||
false,
|
||||
70,
|
||||
now,
|
||||
),
|
||||
build_creation_entry_type_seed(
|
||||
"creative-agent",
|
||||
"智能体创作",
|
||||
"对话式创作实验",
|
||||
"内测",
|
||||
"/creation-type-references/creative-agent.webp",
|
||||
false,
|
||||
true,
|
||||
80,
|
||||
now,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user