1
This commit is contained in:
@@ -0,0 +1,973 @@
|
||||
use std::{collections::BTreeMap, time::Duration};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
|
||||
generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
|
||||
use reqwest::header;
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::visual_novel as contract;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
platform_errors::map_oss_error, request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
const VECTOR_ENGINE_SUNO_PROVIDER: &str = "vector-engine-suno";
|
||||
const VECTOR_ENGINE_VIDU_PROVIDER: &str = "vector-engine-vidu";
|
||||
const SUNO_DEFAULT_MODEL: &str = "chirp-v4";
|
||||
const VIDU_AUDIO_MODEL: &str = "audio1.0";
|
||||
const AUDIO_ENTITY_KIND: &str = "visual_novel_scene";
|
||||
const MUSIC_ASSET_KIND: &str = "visual_novel_music";
|
||||
const AMBIENT_SOUND_ASSET_KIND: &str = "visual_novel_ambient_sound";
|
||||
const MUSIC_SLOT: &str = "music";
|
||||
const AMBIENT_SOUND_SLOT: &str = "ambient_sound";
|
||||
const SUNO_PROMPT_MAX_CHARS: usize = 5_000;
|
||||
const SUNO_TITLE_MAX_CHARS: usize = 80;
|
||||
const SUNO_TAGS_MAX_CHARS: usize = 160;
|
||||
const VIDU_PROMPT_MAX_CHARS: usize = 1_500;
|
||||
const DEFAULT_SOUND_EFFECT_DURATION_SECONDS: u8 = 5;
|
||||
const MAX_GENERATED_AUDIO_BYTES: usize = 40 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct VectorEngineAudioSettings {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct DownloadedAudio {
|
||||
bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
extension: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AudioAssetSlot {
|
||||
BackgroundMusic,
|
||||
SoundEffect,
|
||||
}
|
||||
|
||||
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,
|
||||
Self::SoundEffect => VECTOR_ENGINE_VIDU_PROVIDER,
|
||||
}
|
||||
}
|
||||
|
||||
fn asset_kind(self) -> &'static str {
|
||||
match self {
|
||||
Self::BackgroundMusic => MUSIC_ASSET_KIND,
|
||||
Self::SoundEffect => AMBIENT_SOUND_ASSET_KIND,
|
||||
}
|
||||
}
|
||||
|
||||
fn slot(self) -> &'static str {
|
||||
match self {
|
||||
Self::BackgroundMusic => MUSIC_SLOT,
|
||||
Self::SoundEffect => AMBIENT_SOUND_SLOT,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_stem(self) -> &'static str {
|
||||
match self {
|
||||
Self::BackgroundMusic => "background-music",
|
||||
Self::SoundEffect => "sound-effect",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = build_vector_engine_audio_http_client(&settings)?;
|
||||
let prompt = normalize_limited_text(&payload.prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
|
||||
let title = normalize_limited_text(&payload.title, "title", SUNO_TITLE_MAX_CHARS)?;
|
||||
let tags = payload
|
||||
.tags
|
||||
.as_deref()
|
||||
.map(|value| normalize_limited_text(value, "tags", SUNO_TAGS_MAX_CHARS))
|
||||
.transpose()?;
|
||||
let model = normalize_optional_text(payload.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(json_success_body(
|
||||
Some(&request_context),
|
||||
contract::VisualNovelAudioGenerationTaskResponse {
|
||||
kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic,
|
||||
task_id,
|
||||
provider: VECTOR_ENGINE_SUNO_PROVIDER.to_string(),
|
||||
status: "submitted".to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
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 = build_vector_engine_audio_http_client(&settings)?;
|
||||
let prompt = normalize_limited_text(&payload.prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
|
||||
let duration = payload
|
||||
.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) = payload.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(json_success_body(
|
||||
Some(&request_context),
|
||||
contract::VisualNovelAudioGenerationTaskResponse {
|
||||
kind: contract::VisualNovelAudioGenerationKind::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>,
|
||||
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> {
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
parse_json_payload(&request_context, payload)?.0,
|
||||
AudioAssetSlot::BackgroundMusic,
|
||||
)
|
||||
.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_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> {
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
parse_json_payload(&request_context, payload)?.0,
|
||||
AudioAssetSlot::SoundEffect,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
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> {
|
||||
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?;
|
||||
let status = normalize_task_status(
|
||||
find_first_string_by_key(&task_payload, "status")
|
||||
.or_else(|| find_first_string_by_key(&task_payload, "state"))
|
||||
.or_else(|| find_first_string_by_key(&task_payload, "Status"))
|
||||
.as_deref()
|
||||
.unwrap_or(""),
|
||||
);
|
||||
|
||||
let mut audio_urls = extract_audio_urls(&task_payload);
|
||||
if slot == AudioAssetSlot::BackgroundMusic && audio_urls.is_empty() {
|
||||
if let Some(clip_id) = extract_string_by_path(&task_payload, &["data"])
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
let wav_payload = get_vector_engine_json(
|
||||
&http_client,
|
||||
&settings,
|
||||
&format!("/suno/act/wav/{}", encode_path_segment(clip_id.as_str())),
|
||||
"获取 Suno wav 音频失败",
|
||||
)
|
||||
.await?;
|
||||
audio_urls = extract_audio_urls(&wav_payload);
|
||||
}
|
||||
}
|
||||
|
||||
if is_pending_task_status(&status) && audio_urls.is_empty() {
|
||||
return Ok(contract::VisualNovelGeneratedAudioAssetResponse {
|
||||
kind: slot.contract_kind(),
|
||||
task_id,
|
||||
provider: slot.provider().to_string(),
|
||||
status,
|
||||
asset_object_id: None,
|
||||
asset_kind: None,
|
||||
audio_src: None,
|
||||
});
|
||||
}
|
||||
|
||||
if 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 audio = download_generated_audio(&http_client, &audio_url, slot.provider()).await?;
|
||||
let persisted = persist_generated_audio_asset(
|
||||
state,
|
||||
&http_client,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
scene_id,
|
||||
&task_id,
|
||||
slot,
|
||||
audio,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(contract::VisualNovelGeneratedAudioAssetResponse {
|
||||
kind: slot.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()),
|
||||
audio_src: Some(persisted.audio_src),
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_audio_task_payload(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
slot: AudioAssetSlot,
|
||||
task_id: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
match slot {
|
||||
AudioAssetSlot::BackgroundMusic => {
|
||||
get_vector_engine_json(
|
||||
http_client,
|
||||
settings,
|
||||
&format!("/suno/fetch/{}", encode_path_segment(task_id)),
|
||||
"查询 Suno 背景音乐任务失败",
|
||||
)
|
||||
.await
|
||||
}
|
||||
AudioAssetSlot::SoundEffect => {
|
||||
get_vector_engine_json(
|
||||
http_client,
|
||||
settings,
|
||||
&format!("/ent/v2/tasks/{}/creations", encode_path_segment(task_id)),
|
||||
"查询 Vidu 音效任务失败",
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PersistedAudioAsset {
|
||||
asset_object_id: String,
|
||||
audio_src: String,
|
||||
}
|
||||
|
||||
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,
|
||||
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 file_name = format!("{}-{}.{}", slot.file_stem(), task_id, audio.extension);
|
||||
let put_result = oss_client
|
||||
.put_object(
|
||||
http_client,
|
||||
OssPutObjectRequest {
|
||||
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
path_segments: vec![
|
||||
"visual-novel".to_string(),
|
||||
profile_id.clone().unwrap_or_else(|| "draft".to_string()),
|
||||
scene_id.clone(),
|
||||
slot.slot().to_string(),
|
||||
],
|
||||
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,
|
||||
slot,
|
||||
),
|
||||
body: audio.bytes,
|
||||
},
|
||||
)
|
||||
.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,
|
||||
slot.asset_kind().to_string(),
|
||||
Some(task_id.to_string()),
|
||||
Some(owner_user_id.to_string()),
|
||||
profile_id.clone(),
|
||||
Some(scene_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(),
|
||||
AUDIO_ENTITY_KIND.to_string(),
|
||||
scene_id,
|
||||
slot.slot().to_string(),
|
||||
slot.asset_kind().to_string(),
|
||||
Some(owner_user_id.to_string()),
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_audio_asset_metadata(
|
||||
owner_user_id: &str,
|
||||
profile_id: Option<&str>,
|
||||
scene_id: &str,
|
||||
slot: AudioAssetSlot,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut metadata = BTreeMap::from([
|
||||
("asset-kind".to_string(), slot.asset_kind().to_string()),
|
||||
("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()),
|
||||
("provider".to_string(), slot.provider().to_string()),
|
||||
]);
|
||||
if let Some(profile_id) = profile_id {
|
||||
metadata.insert("profile-id".to_string(), profile_id.to_string());
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_vector_engine_audio_http_client(
|
||||
settings: &VectorEngineAudioSettings,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": format!("构造 VectorEngine 音频生成 HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn post_vector_engine_json(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
path: &str,
|
||||
body: Value,
|
||||
failure_context: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
let response = http_client
|
||||
.post(format!("{}{}", settings.base_url, path))
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| vector_engine_bad_gateway(format!("{failure_context}:{error}")))?;
|
||||
parse_vector_engine_response(response, failure_context).await
|
||||
}
|
||||
|
||||
async fn get_vector_engine_json(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
path: &str,
|
||||
failure_context: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
let response = http_client
|
||||
.get(format!("{}{}", settings.base_url, path))
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| vector_engine_bad_gateway(format!("{failure_context}:{error}")))?;
|
||||
parse_vector_engine_response(response, failure_context).await
|
||||
}
|
||||
|
||||
async fn parse_vector_engine_response(
|
||||
response: reqwest::Response,
|
||||
failure_context: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
let status = response.status();
|
||||
let raw_text = response.text().await.map_err(|error| {
|
||||
vector_engine_bad_gateway(format!("{failure_context}:读取响应失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": failure_context,
|
||||
"status": status.as_u16(),
|
||||
"rawExcerpt": truncate_raw(raw_text.as_str()),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let payload = serde_json::from_str::<Value>(&raw_text).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": format!("{failure_context}:解析响应失败:{error}"),
|
||||
"rawExcerpt": truncate_raw(raw_text.as_str()),
|
||||
}))
|
||||
})?;
|
||||
if let Some(code) = payload.get("code").and_then(Value::as_str)
|
||||
&& !matches!(
|
||||
code.trim().to_ascii_lowercase().as_str(),
|
||||
"success" | "succeeded" | "ok"
|
||||
)
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": payload
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(failure_context),
|
||||
"code": code,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn download_generated_audio(
|
||||
http_client: &reqwest::Client,
|
||||
audio_url: &str,
|
||||
provider: &str,
|
||||
) -> Result<DownloadedAudio, AppError> {
|
||||
let response = http_client
|
||||
.get(audio_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| vector_engine_bad_gateway(format!("下载生成音频失败:{error}")))?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("audio/mpeg")
|
||||
.to_string();
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| vector_engine_bad_gateway(format!("读取生成音频内容失败:{error}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": "下载生成音频失败",
|
||||
"status": status.as_u16(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
if body.is_empty() || body.len() > MAX_GENERATED_AUDIO_BYTES {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": "生成音频内容为空或超过大小上限",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mime_type = normalize_audio_mime_type(&content_type, audio_url);
|
||||
Ok(DownloadedAudio {
|
||||
extension: audio_mime_to_extension(&mime_type).to_string(),
|
||||
mime_type,
|
||||
bytes: body.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_audio_urls(payload: &Value) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
collect_audio_url_strings(payload, &mut urls);
|
||||
let mut deduped = Vec::new();
|
||||
for url in urls {
|
||||
if !deduped.contains(&url) {
|
||||
deduped.push(url);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
fn collect_audio_url_strings(value: &Value, output: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if let Some(raw) = value.as_str()
|
||||
&& looks_like_audio_url_key(key)
|
||||
&& looks_like_http_url(raw)
|
||||
{
|
||||
output.push(raw.trim().to_string());
|
||||
}
|
||||
collect_audio_url_strings(value, output);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_audio_url_strings(item, output);
|
||||
}
|
||||
}
|
||||
Value::String(raw) if looks_like_http_url(raw) && looks_like_audio_url(raw) => {
|
||||
output.push(raw.trim().to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_audio_url_key(key: &str) -> bool {
|
||||
let normalized = key.trim().to_ascii_lowercase();
|
||||
normalized.contains("audio")
|
||||
|| normalized.contains("wav")
|
||||
|| normalized.contains("mp3")
|
||||
|| normalized.contains("fileurl")
|
||||
|| normalized == "url"
|
||||
|| normalized.ends_with("_url")
|
||||
|| normalized.ends_with("url")
|
||||
}
|
||||
|
||||
fn looks_like_http_url(value: &str) -> bool {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
value.starts_with("http://") || value.starts_with("https://")
|
||||
}
|
||||
|
||||
fn looks_like_audio_url(value: &str) -> bool {
|
||||
let value = value
|
||||
.trim()
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
value.ends_with(".mp3")
|
||||
|| value.ends_with(".wav")
|
||||
|| value.ends_with(".m4a")
|
||||
|| value.ends_with(".aac")
|
||||
|| value.ends_with(".ogg")
|
||||
|| value.ends_with(".webm")
|
||||
|| value.ends_with(".flac")
|
||||
}
|
||||
|
||||
fn normalize_audio_mime_type(content_type: &str, audio_url: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|value| value.starts_with("audio/"))
|
||||
.unwrap_or("");
|
||||
match mime_type {
|
||||
"audio/mpeg" | "audio/mp3" => "audio/mpeg".to_string(),
|
||||
"audio/wav" | "audio/wave" | "audio/x-wav" => "audio/wav".to_string(),
|
||||
"audio/ogg" => "audio/ogg".to_string(),
|
||||
"audio/webm" => "audio/webm".to_string(),
|
||||
"audio/aac" => "audio/aac".to_string(),
|
||||
"audio/flac" => "audio/flac".to_string(),
|
||||
"audio/mp4" | "audio/x-m4a" => "audio/mp4".to_string(),
|
||||
_ => mime_type_from_audio_url(audio_url),
|
||||
}
|
||||
}
|
||||
|
||||
fn mime_type_from_audio_url(audio_url: &str) -> String {
|
||||
let path = audio_url
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if path.ends_with(".wav") {
|
||||
"audio/wav".to_string()
|
||||
} else if path.ends_with(".ogg") {
|
||||
"audio/ogg".to_string()
|
||||
} else if path.ends_with(".webm") {
|
||||
"audio/webm".to_string()
|
||||
} else if path.ends_with(".aac") {
|
||||
"audio/aac".to_string()
|
||||
} else if path.ends_with(".flac") {
|
||||
"audio/flac".to_string()
|
||||
} else if path.ends_with(".m4a") {
|
||||
"audio/mp4".to_string()
|
||||
} else {
|
||||
"audio/mpeg".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn audio_mime_to_extension(mime_type: &str) -> &'static str {
|
||||
match mime_type {
|
||||
"audio/wav" => "wav",
|
||||
"audio/ogg" => "ogg",
|
||||
"audio/webm" => "webm",
|
||||
"audio/aac" => "aac",
|
||||
"audio/flac" => "flac",
|
||||
"audio/mp4" => "m4a",
|
||||
_ => "mp3",
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn normalize_task_status(status: &str) -> String {
|
||||
let normalized = status.trim().to_ascii_lowercase().replace(' ', "_");
|
||||
match normalized.as_str() {
|
||||
"finish" | "finished" | "complete" | "completed" | "success" | "succeeded" => {
|
||||
"completed".to_string()
|
||||
}
|
||||
"" => "processing".to_string(),
|
||||
value => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_pending_task_status(status: &str) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
"created" | "pending" | "queued" | "processing" | "running" | "submitted" | "started"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_failed_task_status(status: &str) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
"failed" | "error" | "canceled" | "cancelled" | "rejected" | "expired"
|
||||
)
|
||||
}
|
||||
|
||||
fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if key.eq_ignore_ascii_case(target_key)
|
||||
&& let Some(text) = value.as_str()
|
||||
{
|
||||
return Some(text.trim().to_string());
|
||||
}
|
||||
if let Some(found) = find_first_string_by_key(value, target_key) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.find_map(|item| find_first_string_by_key(item, target_key)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_string_by_path(value: &Value, path: &[&str]) -> Option<String> {
|
||||
let mut current = value;
|
||||
for key in path {
|
||||
current = current.get(*key)?;
|
||||
}
|
||||
current.as_str().map(str::trim).map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn encode_path_segment(value: &str) -> String {
|
||||
urlencoding::encode(value).into_owned()
|
||||
}
|
||||
|
||||
fn truncate_raw(raw_text: &str) -> String {
|
||||
raw_text.chars().take(800).collect()
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
|
||||
}
|
||||
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_spacetime_error(error: spacetime_client::SpacetimeClientError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_audio_mime_type_from_content_type_and_url() {
|
||||
assert_eq!(
|
||||
normalize_audio_mime_type("audio/x-wav; charset=utf-8", "https://x/a.bin"),
|
||||
"audio/wav"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_audio_mime_type("application/octet-stream", "https://x/a.m4a?token=1"),
|
||||
"audio/mp4"
|
||||
);
|
||||
assert_eq!(audio_mime_to_extension("audio/mp4"), "m4a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_nested_audio_urls() {
|
||||
let payload = json!({
|
||||
"Response": {
|
||||
"Status": "FINISH",
|
||||
"Task": {
|
||||
"Output": {
|
||||
"FileInfos": [
|
||||
{ "FileUrl": "https://cdn.example.test/audio.wav" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
extract_audio_urls(&payload),
|
||||
vec!["https://cdn.example.test/audio.wav".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_engine_task_status_is_stable() {
|
||||
assert_eq!(normalize_task_status("FINISH"), "completed");
|
||||
assert!(is_pending_task_status("processing"));
|
||||
assert!(is_failed_task_status("failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_prompt_length() {
|
||||
let prompt = "声".repeat(VIDU_PROMPT_MAX_CHARS + 1);
|
||||
let error = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS)
|
||||
.expect_err("long prompt should fail");
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user