This commit is contained in:
2026-05-11 20:27:41 +08:00
parent e30b733b17
commit 481a27fc53
60 changed files with 6357 additions and 1100 deletions

View File

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

View File

@@ -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);
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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,

View File

@@ -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()
}