refactor: extract platform media crates

This commit is contained in:
kdletters
2026-05-26 13:18:13 +08:00
parent 50f44489cd
commit 44c65df5c9
92 changed files with 7381 additions and 5848 deletions

View File

@@ -0,0 +1,8 @@
pub(super) fn current_utc_micros() -> i64 {
shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
}
pub(super) fn current_utc_iso_text() -> String {
shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc())
.unwrap_or_else(|_| shared_kernel::format_timestamp_micros(current_utc_micros()))
}

View File

@@ -0,0 +1,120 @@
use axum::{Json, extract::rejection::JsonRejection, http::StatusCode, response::Response};
use platform_audio::{AudioError, AudioStatusHint};
use serde_json::json;
use crate::{http_error::AppError, request_context::RequestContext};
use super::types::VECTOR_ENGINE_PROVIDER;
pub(super) fn normalize_limited_text(
value: &str,
field: &'static str,
max_chars: usize,
) -> Result<String, AppError> {
let normalized = value.trim().to_string();
if normalized.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"field": field,
"message": format!("{field} 不能为空"),
})),
);
}
if normalized.chars().count() > max_chars {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"field": field,
"message": format!("{field} 超过 {} 字符", max_chars),
})),
);
}
Ok(normalized)
}
pub(super) fn normalize_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub(super) fn map_asset_field_error(error: module_assets::AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
pub(super) fn map_spacetime_error(error: spacetime_client::SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
pub(super) fn map_platform_audio_error(error: AudioError) -> AppError {
let status = match error.status_hint() {
AudioStatusHint::BadRequest => StatusCode::BAD_REQUEST,
AudioStatusHint::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
AudioStatusHint::BadGateway => StatusCode::BAD_GATEWAY,
AudioStatusHint::GatewayTimeout => StatusCode::GATEWAY_TIMEOUT,
};
let mut details = json!({
"provider": error.provider(),
"message": error.message(),
});
match &error {
AudioError::InvalidConfig { .. } | AudioError::InvalidRequest { .. } => {}
AudioError::Request {
endpoint,
timeout,
connect,
request,
body,
status_code,
source,
..
} => {
details["endpoint"] = json!(endpoint);
details["timeout"] = json!(timeout);
details["connect"] = json!(connect);
details["request"] = json!(request);
details["body"] = json!(body);
details["status"] = json!(status_code);
details["source"] = json!(source);
}
AudioError::Upstream {
upstream_status,
raw_excerpt,
..
} => {
details["upstreamStatus"] = json!(upstream_status);
details["rawExcerpt"] = json!(raw_excerpt);
}
AudioError::ResponseParse { raw_excerpt, .. } => {
details["rawExcerpt"] = json!(raw_excerpt);
}
AudioError::MissingAudio { .. } => {}
}
AppError::from_status(status).with_details(details)
}
pub(super) fn vector_engine_bad_gateway(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": message.into(),
}))
}
pub(super) fn parse_json_payload<T>(
request_context: &RequestContext,
payload: Result<Json<T>, JsonRejection>,
) -> Result<Json<T>, Response> {
payload.map_err(|rejection| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(format!("请求体 JSON 不合法:{rejection}"))
.into_response_with_context(Some(request_context))
})
}

View File

@@ -0,0 +1,122 @@
use shared_contracts::creation_audio;
use crate::{http_error::AppError, state::AppState};
use super::{
clock::current_utc_iso_text,
errors::{map_platform_audio_error, vector_engine_bad_gateway},
publish::wait_for_generated_audio_asset,
tasks::{create_background_music_task_response, create_sound_effect_task_response},
types::{AudioAssetBindingTarget, AudioAssetSlot, GeneratedCreationAudioTarget},
};
pub(crate) async fn generate_sound_effect_asset_for_creation(
state: &AppState,
owner_user_id: &str,
prompt: String,
duration: Option<u8>,
seed: Option<u64>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let normalized_prompt = platform_audio::normalize_limited_text(
&prompt,
"prompt",
platform_audio::VIDU_PROMPT_MAX_CHARS,
)
.map_err(map_platform_audio_error)?;
let task =
create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::SoundEffect,
target,
)
.await?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: None,
updated_at: Some(current_utc_iso_text()),
})
}
pub(crate) async fn generate_background_music_asset_for_creation(
state: &AppState,
owner_user_id: &str,
prompt: String,
title: String,
tags: Option<String>,
model: Option<String>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let normalized_prompt = platform_audio::normalize_limited_text_allow_empty(
&prompt,
"prompt",
platform_audio::SUNO_PROMPT_MAX_CHARS,
)
.map_err(map_platform_audio_error)?;
let normalized_title = platform_audio::normalize_limited_text(
&title,
"title",
platform_audio::SUNO_TITLE_MAX_CHARS,
)
.map_err(map_platform_audio_error)?;
let task = create_background_music_task_response(
state,
normalized_prompt.clone(),
normalized_title.clone(),
tags,
model,
)
.await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::BackgroundMusic,
target,
)
.await?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: Some(normalized_title),
updated_at: Some(current_utc_iso_text()),
})
}

View File

@@ -0,0 +1,216 @@
use axum::{
Json,
extract::{Path, State, rejection::JsonRejection},
response::Response,
};
use platform_audio::{BackgroundMusicTaskRequest, SoundEffectTaskRequest};
use serde_json::Value;
use shared_contracts::{creation_audio, visual_novel as contract};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken,
request_context::RequestContext, state::AppState,
};
use super::{
errors::{map_platform_audio_error, parse_json_payload},
publish::publish_generated_audio_asset,
settings::require_vector_engine_audio_settings,
targets::{
build_creation_audio_target, build_visual_novel_audio_target,
creation_audio_generation_disabled_error,
creation_audio_generation_disabled_error_for_target,
},
types::AudioAssetSlot,
};
pub async fn create_visual_novel_background_music_task(
State(state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
payload: Result<Json<contract::CreateVisualNovelBackgroundMusicRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
let settings = require_vector_engine_audio_settings(&state)?;
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
.map_err(map_platform_audio_error)?;
let task = platform_audio::submit_background_music_task(
&http_client,
&settings,
BackgroundMusicTaskRequest {
prompt: payload.prompt,
title: payload.title,
tags: payload.tags,
model: payload.model,
instrumental: true,
},
)
.await
.map_err(map_platform_audio_error)?;
Ok(json_success_body(
Some(&request_context),
contract::VisualNovelAudioGenerationTaskResponse {
kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic,
task_id: task.task_id,
provider: task.provider,
status: task.status,
},
))
}
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 _ = parse_json_payload(&request_context, payload)?;
Err(creation_audio_generation_disabled_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>,
payload: Result<Json<contract::CreateVisualNovelSoundEffectRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
let settings = require_vector_engine_audio_settings(&state)?;
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
.map_err(map_platform_audio_error)?;
let task = platform_audio::submit_sound_effect_task(
&http_client,
&settings,
SoundEffectTaskRequest {
prompt: payload.prompt,
duration: payload
.duration
.unwrap_or(platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS)
.clamp(2, 10),
seed: payload.seed,
},
)
.await
.map_err(map_platform_audio_error)?;
Ok(json_success_body(
Some(&request_context),
contract::VisualNovelAudioGenerationTaskResponse {
kind: contract::VisualNovelAudioGenerationKind::SoundEffect,
task_id: task.task_id,
provider: task.provider,
status: task.status,
},
))
}
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 _ = parse_json_payload(&request_context, payload)?;
Err(creation_audio_generation_disabled_error()
.into_response_with_context(Some(&request_context)))
}
pub async fn publish_visual_novel_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<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,
authenticated.claims().user_id(),
task_id,
AudioAssetSlot::BackgroundMusic,
target,
)
.await
.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)))
}
pub async fn publish_visual_novel_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<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,
authenticated.claims().user_id(),
task_id,
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;
Err(creation_audio_generation_disabled_error_for_target(payload)
.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, AudioAssetSlot::SoundEffect)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
publish_generated_audio_asset(
&state,
authenticated.claims().user_id(),
task_id,
AudioAssetSlot::SoundEffect,
target,
)
.await
.map(|payload| json_success_body(Some(&request_context), payload))
.map_err(|error| error.into_response_with_context(Some(&request_context)))
}

View File

@@ -0,0 +1,115 @@
use axum::http::StatusCode;
use module_assets::{
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
generate_asset_binding_id, generate_asset_object_id,
};
use platform_audio::{DownloadedAudio, GeneratedAudioPersistInput, GeneratedAudioPersistTarget};
use serde_json::json;
use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppState};
use super::{
clock::current_utc_micros,
errors::{map_asset_field_error, map_spacetime_error},
types::{AudioAssetBindingTarget, AudioAssetSlot},
};
#[derive(Clone, Debug)]
pub(super) struct PersistedAudioAsset {
pub(super) asset_object_id: String,
pub(super) audio_src: String,
}
pub(super) async fn persist_generated_audio_asset(
state: &AppState,
http_client: &reqwest::Client,
owner_user_id: &str,
task_id: &str,
slot: AudioAssetSlot,
target: AudioAssetBindingTarget,
audio: DownloadedAudio,
) -> Result<PersistedAudioAsset, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let audio_mime_type = audio.mime_type.clone();
let put_request =
platform_audio::prepare_generated_audio_put_request(GeneratedAudioPersistInput {
owner_user_id: owner_user_id.to_string(),
task_id: task_id.to_string(),
task_kind: slot.task_kind(),
target: GeneratedAudioPersistTarget {
entity_kind: target.entity_kind.clone(),
entity_id: target.entity_id.clone(),
slot: target.slot.clone(),
asset_kind: target.asset_kind.clone(),
profile_id: target.profile_id.clone(),
storage_prefix: target.storage_prefix,
storage_scope: target.storage_scope.clone(),
},
audio,
});
let put_result = oss_client
.put_object(http_client, put_request)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let head = oss_client
.head_object(
http_client,
platform_oss::OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let now_micros = current_utc_micros();
let asset_object = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(audio_mime_type)),
head.content_length,
head.etag,
target.asset_kind.clone(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
target.profile_id.clone(),
Some(target.entity_id.clone()),
now_micros,
)
.map_err(map_asset_field_error)?,
)
.await
.map_err(map_spacetime_error)?;
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object.asset_object_id.clone(),
target.entity_kind,
target.entity_id,
target.slot,
target.asset_kind,
Some(owner_user_id.to_string()),
target.profile_id,
now_micros,
)
.map_err(map_asset_field_error)?,
)
.await
.map_err(map_spacetime_error)?;
Ok(PersistedAudioAsset {
asset_object_id: asset_object.asset_object_id,
audio_src: put_result.legacy_public_path,
})
}

View File

@@ -0,0 +1,164 @@
use std::time::Duration;
use shared_contracts::creation_audio;
use crate::{
asset_billing::execute_billable_asset_operation_with_cost, http_error::AppError,
state::AppState,
};
use super::{
errors::{map_platform_audio_error, vector_engine_bad_gateway},
persist::persist_generated_audio_asset,
settings::require_vector_engine_audio_settings,
types::{
AudioAssetBindingTarget, AudioAssetSlot, CREATION_BACKGROUND_MUSIC_POINTS_COST,
CREATION_SOUND_EFFECT_POINTS_COST,
},
};
pub(super) async fn publish_generated_audio_asset(
state: &AppState,
owner_user_id: &str,
task_id: String,
slot: AudioAssetSlot,
target: AudioAssetBindingTarget,
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
let task_id = platform_audio::normalize_limited_text(&task_id, "taskId", 160)
.map_err(map_platform_audio_error)?;
let settings = require_vector_engine_audio_settings(state)?;
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
.map_err(map_platform_audio_error)?;
let (status, audio_urls): (String, Vec<String>) =
platform_audio::resolve_audio_task_download_urls(
&http_client,
&settings,
slot.task_kind(),
&task_id,
)
.await
.map_err(map_platform_audio_error)?;
if platform_audio::is_pending_task_status(&status) && audio_urls.is_empty() {
return Ok(creation_audio::GeneratedAudioAssetResponse {
kind: slot.creation_contract_kind(),
task_id,
provider: slot.provider().to_string(),
status: status.clone(),
asset_object_id: None,
asset_kind: None,
audio_src: None,
});
}
if platform_audio::is_failed_task_status(&status) {
return Err(vector_engine_bad_gateway(
"音频生成任务失败,请调整提示词后重试",
));
}
let audio_url = audio_urls
.into_iter()
.next()
.ok_or_else(|| vector_engine_bad_gateway("音频生成尚未返回可下载地址"))?;
let billing_asset_kind = target.asset_kind.clone();
let billing_asset_id = build_audio_billing_asset_id(&task_id, slot, &target);
let points_cost = resolve_creation_audio_points_cost(slot, &target);
let persisted = execute_billable_asset_operation_with_cost(
state,
owner_user_id,
billing_asset_kind.as_str(),
billing_asset_id.as_str(),
points_cost,
async {
let audio =
platform_audio::download_generated_audio(&http_client, &audio_url, slot.provider())
.await
.map_err(map_platform_audio_error)?;
persist_generated_audio_asset(
state,
&http_client,
owner_user_id,
&task_id,
slot,
target.clone(),
audio,
)
.await
},
)
.await?;
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(target.asset_kind),
audio_src: Some(persisted.audio_src),
})
}
pub(super) async fn wait_for_generated_audio_asset(
state: &AppState,
owner_user_id: &str,
task_id: String,
slot: AudioAssetSlot,
target: AudioAssetBindingTarget,
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
let mut latest_status = String::new();
for _ in 0..40 {
let response = publish_generated_audio_asset(
state,
owner_user_id,
task_id.clone(),
slot,
target.clone(),
)
.await?;
if response
.audio_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return Ok(response);
}
latest_status = response.status;
tokio::time::sleep(Duration::from_millis(3_000)).await;
}
Err(vector_engine_bad_gateway(format!(
"音频生成超时:{}",
if latest_status.trim().is_empty() {
task_id
} else {
latest_status
}
)))
}
pub(super) fn build_audio_billing_asset_id(
task_id: &str,
slot: AudioAssetSlot,
target: &AudioAssetBindingTarget,
) -> String {
format!(
"creation-audio:{}:{}:{}:{}",
slot.file_stem(),
task_id,
target.entity_id,
target.slot
)
}
pub(super) fn resolve_creation_audio_points_cost(
slot: AudioAssetSlot,
_target: &AudioAssetBindingTarget,
) -> u64 {
match slot {
AudioAssetSlot::BackgroundMusic => CREATION_BACKGROUND_MUSIC_POINTS_COST,
AudioAssetSlot::SoundEffect => CREATION_SOUND_EFFECT_POINTS_COST,
}
}

View File

@@ -0,0 +1,44 @@
use axum::http::StatusCode;
use platform_audio::VectorEngineAudioSettings;
use serde_json::json;
use crate::{http_error::AppError, state::AppState};
use super::types::VECTOR_ENGINE_PROVIDER;
pub(super) fn require_vector_engine_audio_settings(
state: &AppState,
) -> Result<VectorEngineAudioSettings, AppError> {
let base_url = state
.config
.vector_engine_base_url
.trim()
.trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.vector_engine_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"reason": "VECTOR_ENGINE_API_KEY 未配置",
}))
})?;
Ok(VectorEngineAudioSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.vector_engine_audio_request_timeout_ms.max(1),
})
}

View File

@@ -0,0 +1,52 @@
use axum::http::StatusCode;
use platform_oss::LegacyAssetPrefix;
use serde_json::json;
use shared_contracts::{creation_audio, visual_novel as contract};
use crate::http_error::AppError;
use super::{
errors::{normalize_limited_text, normalize_optional_text},
types::{AUDIO_ENTITY_KIND, AudioAssetBindingTarget, AudioAssetSlot, VECTOR_ENGINE_PROVIDER},
};
pub(super) 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(),
})
}
pub(super) fn build_creation_audio_target(
payload: creation_audio::PublishGeneratedAudioAssetRequest,
_slot: AudioAssetSlot,
) -> Result<AudioAssetBindingTarget, AppError> {
Err(creation_audio_generation_disabled_error_for_target(payload))
}
pub(super) fn creation_audio_generation_disabled_error() -> AppError {
AppError::from_status(StatusCode::GONE).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "当前创作音频目标未开放",
}))
}
pub(super) fn creation_audio_generation_disabled_error_for_target(
payload: creation_audio::PublishGeneratedAudioAssetRequest,
) -> AppError {
creation_audio_generation_disabled_error().with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "当前创作音频目标未开放",
"entityKind": payload.entity_kind.trim(),
"slot": payload.slot.trim(),
}))
}

View File

@@ -0,0 +1,69 @@
use platform_audio::{BackgroundMusicTaskRequest, SoundEffectTaskRequest};
use shared_contracts::creation_audio;
use crate::{http_error::AppError, state::AppState};
use super::{errors::map_platform_audio_error, settings::require_vector_engine_audio_settings};
pub(super) async fn create_background_music_task_response(
state: &AppState,
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 = platform_audio::build_vector_engine_audio_http_client(&settings)
.map_err(map_platform_audio_error)?;
let task = platform_audio::submit_background_music_task(
&http_client,
&settings,
BackgroundMusicTaskRequest {
prompt,
title,
tags,
model,
instrumental: true,
},
)
.await
.map_err(map_platform_audio_error)?;
Ok(creation_audio::AudioGenerationTaskResponse {
kind: creation_audio::CreationAudioGenerationKind::BackgroundMusic,
task_id: task.task_id,
provider: task.provider,
status: task.status,
})
}
pub(super) 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 = platform_audio::build_vector_engine_audio_http_client(&settings)
.map_err(map_platform_audio_error)?;
let task = platform_audio::submit_sound_effect_task(
&http_client,
&settings,
SoundEffectTaskRequest {
prompt,
duration: duration
.unwrap_or(platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS)
.clamp(2, 10),
seed,
},
)
.await
.map_err(map_platform_audio_error)?;
Ok(creation_audio::AudioGenerationTaskResponse {
kind: creation_audio::CreationAudioGenerationKind::SoundEffect,
task_id: task.task_id,
provider: task.provider,
status: task.status,
})
}

View File

@@ -0,0 +1,79 @@
use axum::http::StatusCode;
use platform_oss::LegacyAssetPrefix;
use shared_contracts::creation_audio;
use super::{
publish::resolve_creation_audio_points_cost,
targets::{build_creation_audio_target, creation_audio_generation_disabled_error_for_target},
types::{AudioAssetBindingTarget, AudioAssetSlot},
};
#[test]
fn creation_audio_billing_uses_lower_cost_for_background_music() {
let target = AudioAssetBindingTarget {
entity_kind: "puzzle_work".to_string(),
entity_id: "puzzle-profile-1".to_string(),
slot: "background_music".to_string(),
asset_kind: "puzzle_background_music".to_string(),
profile_id: Some("puzzle-profile-1".to_string()),
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
storage_scope: "puzzle_work".to_string(),
};
assert_eq!(
resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target),
5
);
assert_eq!(
resolve_creation_audio_points_cost(AudioAssetSlot::SoundEffect, &target),
10
);
}
#[test]
fn disabled_creation_audio_targets_return_gone_including_wooden_fish_sound_effects() {
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
entity_kind: "puzzle_work".to_string(),
entity_id: "puzzle-profile-1".to_string(),
slot: "background_music".to_string(),
asset_kind: "puzzle_background_music".to_string(),
profile_id: Some("puzzle-profile-1".to_string()),
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets),
};
let error = creation_audio_generation_disabled_error_for_target(payload);
assert_eq!(error.status_code(), StatusCode::GONE);
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
entity_kind: "match3d_work".to_string(),
entity_id: "match3d-profile-1".to_string(),
slot: "background_music".to_string(),
asset_kind: "match3d_background_music".to_string(),
profile_id: Some("match3d-profile-1".to_string()),
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets),
};
let error = creation_audio_generation_disabled_error_for_target(payload);
assert_eq!(error.status_code(), StatusCode::GONE);
let payload = creation_audio::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("match3d-profile-1".to_string()),
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets),
};
let error = creation_audio_generation_disabled_error_for_target(payload);
assert_eq!(error.status_code(), StatusCode::GONE);
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
entity_kind: "wooden_fish_work".to_string(),
entity_id: "wooden-fish-profile-1".to_string(),
slot: "hit_sound".to_string(),
asset_kind: "wooden_fish_hit_sound".to_string(),
profile_id: Some("wooden-fish-profile-1".to_string()),
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::WoodenFishAssets),
};
let error = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect)
.expect_err("wooden fish hit sound target should be disabled");
assert_eq!(error.status_code(), StatusCode::GONE);
}

View File

@@ -0,0 +1,77 @@
use platform_audio::AudioTaskKind;
use platform_oss::LegacyAssetPrefix;
use shared_contracts::creation_audio;
pub(super) const VECTOR_ENGINE_PROVIDER: &str = platform_audio::VECTOR_ENGINE_PROVIDER;
pub(super) const AUDIO_ENTITY_KIND: &str = "visual_novel_scene";
pub(super) const MUSIC_ASSET_KIND: &str = "visual_novel_music";
pub(super) const AMBIENT_SOUND_ASSET_KIND: &str = "visual_novel_ambient_sound";
pub(super) const MUSIC_SLOT: &str = "music";
pub(super) const AMBIENT_SOUND_SLOT: &str = "ambient_sound";
pub(super) const CREATION_BACKGROUND_MUSIC_POINTS_COST: u64 = 5;
pub(super) const CREATION_SOUND_EFFECT_POINTS_COST: u64 = 10;
#[derive(Clone, Debug)]
pub(super) struct AudioAssetBindingTarget {
pub(super) entity_kind: String,
pub(super) entity_id: String,
pub(super) slot: String,
pub(super) asset_kind: String,
pub(super) profile_id: Option<String>,
pub(super) storage_prefix: LegacyAssetPrefix,
pub(super) storage_scope: String,
}
#[derive(Clone, Debug)]
pub(crate) struct GeneratedCreationAudioTarget {
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub profile_id: Option<String>,
pub storage_prefix: LegacyAssetPrefix,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum AudioAssetSlot {
BackgroundMusic,
SoundEffect,
}
impl AudioAssetSlot {
pub(super) fn task_kind(self) -> AudioTaskKind {
match self {
Self::BackgroundMusic => AudioTaskKind::BackgroundMusic,
Self::SoundEffect => AudioTaskKind::SoundEffect,
}
}
pub(super) fn provider(self) -> &'static str {
self.task_kind().provider()
}
pub(super) fn asset_kind(self) -> &'static str {
match self {
Self::BackgroundMusic => MUSIC_ASSET_KIND,
Self::SoundEffect => AMBIENT_SOUND_ASSET_KIND,
}
}
pub(super) fn slot(self) -> &'static str {
match self {
Self::BackgroundMusic => MUSIC_SLOT,
Self::SoundEffect => AMBIENT_SOUND_SLOT,
}
}
pub(super) fn file_stem(self) -> &'static str {
self.task_kind().file_stem()
}
pub(super) fn creation_contract_kind(self) -> creation_audio::CreationAudioGenerationKind {
match self {
Self::BackgroundMusic => creation_audio::CreationAudioGenerationKind::BackgroundMusic,
Self::SoundEffect => creation_audio::CreationAudioGenerationKind::SoundEffect,
}
}
}