cad861a13d
抽出单一 SFX Worker 编排并覆盖计费、翻译、ElevenLabs、OSS 与写回 补齐稳定失败分类、退款和零副作用组合测试 修复画布 Agent 的自动时长、小数范围、Loop 与 Prompt 参数契约 同步测试证据、发布门禁和项目共享记忆
411 lines
14 KiB
Rust
411 lines
14 KiB
Rust
use std::{error::Error, time::Instant};
|
|
|
|
use bytes::BytesMut;
|
|
use reqwest::header;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::{
|
|
AudioError, DownloadedAudio, MAX_GENERATED_AUDIO_BYTES, validate_sound_effect_duration_seconds,
|
|
validate_sound_effect_prompt,
|
|
};
|
|
|
|
pub const ELEVENLABS_PROVIDER: &str = "elevenlabs";
|
|
pub const ELEVENLABS_SOUND_EFFECT_MODEL: &str = "eleven_text_to_sound_v2";
|
|
pub const ELEVENLABS_SOUND_EFFECT_OUTPUT_FORMAT: &str = "mp3_44100_128";
|
|
pub const ELEVENLABS_SOUND_EFFECT_PROMPT_INFLUENCE: f64 = 0.3;
|
|
const ELEVENLABS_SOUND_GENERATION_PATH: &str = "/v1/sound-generation";
|
|
|
|
#[derive(Clone)]
|
|
pub struct ElevenLabsAudioSettings {
|
|
pub base_url: String,
|
|
pub api_key: String,
|
|
pub request_timeout_ms: u64,
|
|
/// Worker 为本次任务留出的 provider 调用截止时刻。超出后不得再发起 provider 请求,
|
|
/// 否则终态写回预留时间会被 provider 调用吃掉。为 `None` 时只受固定超时约束。
|
|
pub request_deadline: Option<Instant>,
|
|
}
|
|
|
|
impl std::fmt::Debug for ElevenLabsAudioSettings {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
formatter
|
|
.debug_struct("ElevenLabsAudioSettings")
|
|
.field("base_url", &self.base_url)
|
|
.field("api_key", &"[redacted]")
|
|
.field("request_timeout_ms", &self.request_timeout_ms)
|
|
.field("request_deadline", &self.request_deadline)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// 单次 provider 请求的实际超时:固定超时与剩余预算取更严格的一个。
|
|
/// 返回 `None` 表示预算已经耗尽,调用方必须在发出请求之前失败。
|
|
fn effective_request_timeout_ms(
|
|
configured_timeout_ms: u64,
|
|
request_deadline: Option<Instant>,
|
|
) -> Option<u64> {
|
|
effective_request_timeout_ms_at(configured_timeout_ms, request_deadline, Instant::now())
|
|
}
|
|
|
|
fn effective_request_timeout_ms_at(
|
|
configured_timeout_ms: u64,
|
|
request_deadline: Option<Instant>,
|
|
now: Instant,
|
|
) -> Option<u64> {
|
|
let configured_timeout_ms = configured_timeout_ms.max(1);
|
|
let Some(request_deadline) = request_deadline else {
|
|
return Some(configured_timeout_ms);
|
|
};
|
|
let remaining_ms = u64::try_from(request_deadline.checked_duration_since(now)?.as_millis())
|
|
.unwrap_or(u64::MAX);
|
|
if remaining_ms == 0 {
|
|
return None;
|
|
}
|
|
Some(configured_timeout_ms.min(remaining_ms))
|
|
}
|
|
|
|
fn elevenlabs_budget_exhausted_error(endpoint: &str) -> AudioError {
|
|
AudioError::request_for(
|
|
ELEVENLABS_PROVIDER,
|
|
"ElevenLabs 音效生成超出本次任务的 provider 调用预算".to_string(),
|
|
Some(endpoint.to_string()),
|
|
true,
|
|
false,
|
|
true,
|
|
false,
|
|
None,
|
|
None,
|
|
)
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct ElevenLabsSoundEffectRequest {
|
|
pub text: String,
|
|
pub duration_seconds: Option<f64>,
|
|
pub loop_enabled: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct ElevenLabsGeneratedSoundEffect {
|
|
pub audio: DownloadedAudio,
|
|
pub duration_seconds: f64,
|
|
}
|
|
|
|
pub fn build_elevenlabs_audio_http_client(
|
|
settings: &ElevenLabsAudioSettings,
|
|
) -> Result<reqwest::Client, AudioError> {
|
|
reqwest::Client::builder()
|
|
.retry(reqwest::retry::never())
|
|
.timeout(std::time::Duration::from_millis(
|
|
settings.request_timeout_ms.max(1),
|
|
))
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.map_err(|error| {
|
|
AudioError::invalid_config_for(
|
|
ELEVENLABS_PROVIDER,
|
|
format!("构造 ElevenLabs 音效生成 HTTP 客户端失败:{error}"),
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn elevenlabs_sound_generation_endpoint(base_url: &str) -> String {
|
|
let normalized = base_url.trim().trim_end_matches('/');
|
|
let root = normalized
|
|
.strip_suffix("/v1")
|
|
.unwrap_or(normalized)
|
|
.trim_end_matches('/');
|
|
format!("{root}{ELEVENLABS_SOUND_GENERATION_PATH}")
|
|
}
|
|
|
|
pub fn build_elevenlabs_sound_effect_body(
|
|
request: &ElevenLabsSoundEffectRequest,
|
|
) -> Result<Value, AudioError> {
|
|
let prompt = validate_sound_effect_prompt(&request.text).map_err(|error| {
|
|
AudioError::invalid_request_for(ELEVENLABS_PROVIDER, error.message().to_string())
|
|
})?;
|
|
let duration_seconds = validate_sound_effect_duration_seconds(request.duration_seconds)
|
|
.map_err(|error| {
|
|
AudioError::invalid_request_for(ELEVENLABS_PROVIDER, error.message().to_string())
|
|
})?;
|
|
Ok(json!({
|
|
"text": prompt.prompt,
|
|
"model_id": ELEVENLABS_SOUND_EFFECT_MODEL,
|
|
"duration_seconds": duration_seconds,
|
|
"loop": request.loop_enabled,
|
|
"prompt_influence": ELEVENLABS_SOUND_EFFECT_PROMPT_INFLUENCE,
|
|
}))
|
|
}
|
|
|
|
pub async fn generate_elevenlabs_sound_effect(
|
|
http_client: &reqwest::Client,
|
|
settings: &ElevenLabsAudioSettings,
|
|
request: ElevenLabsSoundEffectRequest,
|
|
) -> Result<ElevenLabsGeneratedSoundEffect, AudioError> {
|
|
let endpoint = elevenlabs_sound_generation_endpoint(&settings.base_url);
|
|
let body = build_elevenlabs_sound_effect_body(&request)?;
|
|
// 预算耗尽时必须在发出请求之前失败:翻译轮次拖长后再发 provider 请求,会把 worker
|
|
// 留给终态写回的时间吃掉,且这次调用一定来不及被本 attempt 使用。
|
|
let Some(attempt_timeout_ms) =
|
|
effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline)
|
|
else {
|
|
return Err(elevenlabs_budget_exhausted_error(endpoint.as_str()));
|
|
};
|
|
let response = http_client
|
|
.post(endpoint.as_str())
|
|
.timeout(std::time::Duration::from_millis(attempt_timeout_ms))
|
|
.query(&[("output_format", ELEVENLABS_SOUND_EFFECT_OUTPUT_FORMAT)])
|
|
.header("xi-api-key", settings.api_key.as_str())
|
|
.header(
|
|
header::ACCEPT,
|
|
"audio/mpeg,audio/mp3,application/octet-stream",
|
|
)
|
|
.header(header::ACCEPT_ENCODING, "identity")
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|error| map_elevenlabs_reqwest_error(endpoint.as_str(), false, error))?;
|
|
|
|
read_elevenlabs_sound_effect_response(response, endpoint.as_str()).await
|
|
}
|
|
|
|
async fn read_elevenlabs_sound_effect_response(
|
|
mut response: reqwest::Response,
|
|
endpoint: &str,
|
|
) -> Result<ElevenLabsGeneratedSoundEffect, AudioError> {
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
return Err(AudioError::upstream_for(
|
|
ELEVENLABS_PROVIDER,
|
|
format!("ElevenLabs 音效生成失败:HTTP {}", status.as_u16()),
|
|
status.as_u16(),
|
|
"",
|
|
));
|
|
}
|
|
if response
|
|
.content_length()
|
|
.is_some_and(|length| length > MAX_GENERATED_AUDIO_BYTES as u64)
|
|
{
|
|
return Err(invalid_elevenlabs_audio("生成音频超过 40 MiB 大小上限"));
|
|
}
|
|
|
|
let content_type = response
|
|
.headers()
|
|
.get(header::CONTENT_TYPE)
|
|
.map(|value| {
|
|
value
|
|
.to_str()
|
|
.map(normalize_content_type)
|
|
.map_err(|_| invalid_elevenlabs_audio("生成音频 Content-Type 非法"))
|
|
})
|
|
.transpose()?;
|
|
match content_type.as_deref() {
|
|
Some("audio/mpeg" | "audio/mp3" | "application/octet-stream") | None => {}
|
|
Some(_) => return Err(invalid_elevenlabs_audio("生成结果不是允许的 MP3 MIME 类型")),
|
|
}
|
|
|
|
let mut bytes = BytesMut::with_capacity(
|
|
response
|
|
.content_length()
|
|
.and_then(|length| usize::try_from(length).ok())
|
|
.unwrap_or(0)
|
|
.min(MAX_GENERATED_AUDIO_BYTES),
|
|
);
|
|
let read_limit = MAX_GENERATED_AUDIO_BYTES + 1;
|
|
while let Some(chunk) = response
|
|
.chunk()
|
|
.await
|
|
.map_err(|error| map_elevenlabs_reqwest_error(endpoint, true, error))?
|
|
{
|
|
let remaining = read_limit.saturating_sub(bytes.len());
|
|
bytes.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
|
|
if bytes.len() > MAX_GENERATED_AUDIO_BYTES {
|
|
return Err(invalid_elevenlabs_audio("生成音频超过 40 MiB 大小上限"));
|
|
}
|
|
}
|
|
if bytes.is_empty() {
|
|
return Err(invalid_elevenlabs_audio("生成音频内容为空"));
|
|
}
|
|
|
|
let bytes = bytes.freeze();
|
|
let duration_seconds = crate::mp3::probe_mp3_duration_seconds(bytes.clone())
|
|
.map_err(elevenlabs_duration_probe_failed)?;
|
|
|
|
Ok(ElevenLabsGeneratedSoundEffect {
|
|
audio: DownloadedAudio {
|
|
bytes: bytes.to_vec(),
|
|
mime_type: "audio/mpeg".to_string(),
|
|
extension: "mp3".to_string(),
|
|
},
|
|
duration_seconds,
|
|
})
|
|
}
|
|
|
|
fn normalize_content_type(content_type: &str) -> String {
|
|
content_type
|
|
.split(';')
|
|
.next()
|
|
.map(str::trim)
|
|
.unwrap_or_default()
|
|
.to_ascii_lowercase()
|
|
}
|
|
|
|
fn invalid_elevenlabs_audio(message: impl Into<String>) -> AudioError {
|
|
AudioError::missing_audio_with_reason_for(ELEVENLABS_PROVIDER, "invalid_audio", message)
|
|
}
|
|
|
|
fn elevenlabs_duration_probe_failed(message: impl Into<String>) -> AudioError {
|
|
AudioError::missing_audio_with_reason_for(ELEVENLABS_PROVIDER, "duration_probe_failed", message)
|
|
}
|
|
|
|
fn map_elevenlabs_reqwest_error(endpoint: &str, body: bool, error: reqwest::Error) -> AudioError {
|
|
AudioError::request_for(
|
|
ELEVENLABS_PROVIDER,
|
|
if body {
|
|
format!("读取 ElevenLabs 音效响应失败:{error}")
|
|
} else {
|
|
format!("请求 ElevenLabs 音效生成失败:{error}")
|
|
},
|
|
Some(endpoint.to_string()),
|
|
error.is_timeout(),
|
|
error.is_connect(),
|
|
error.is_request(),
|
|
body || error.is_body(),
|
|
error.status().map(|status| status.as_u16()),
|
|
Error::source(&error).map(ToString::to_string),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn endpoint_normalization_appends_the_sound_generation_path_once() {
|
|
for base_url in [
|
|
"https://api.elevenlabs.io",
|
|
"https://api.elevenlabs.io/",
|
|
"https://api.elevenlabs.io/v1",
|
|
" https://api.elevenlabs.io/v1/ ",
|
|
] {
|
|
assert_eq!(
|
|
elevenlabs_sound_generation_endpoint(base_url),
|
|
"https://api.elevenlabs.io/v1/sound-generation"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn request_body_fixes_provider_owned_fields_and_preserves_duration_precision() {
|
|
for (duration_seconds, loop_enabled) in [
|
|
(None, false),
|
|
(None, true),
|
|
(Some(1.234_567_89), false),
|
|
(Some(30.0), true),
|
|
] {
|
|
let body = build_elevenlabs_sound_effect_body(&ElevenLabsSoundEffectRequest {
|
|
text: " crisp metallic coin pickup ".to_string(),
|
|
duration_seconds,
|
|
loop_enabled,
|
|
})
|
|
.expect("valid ElevenLabs request body should build");
|
|
|
|
assert_eq!(body["text"], "crisp metallic coin pickup");
|
|
assert_eq!(body["model_id"], ELEVENLABS_SOUND_EFFECT_MODEL);
|
|
assert_eq!(body["duration_seconds"], json!(duration_seconds));
|
|
assert_eq!(body["loop"], loop_enabled);
|
|
assert_eq!(body["prompt_influence"], 0.3);
|
|
assert_eq!(body.as_object().expect("body should be an object").len(), 5);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn settings_debug_redacts_the_api_key() {
|
|
let settings = ElevenLabsAudioSettings {
|
|
base_url: "https://api.elevenlabs.test".to_string(),
|
|
api_key: "elevenlabs-secret-test-key".to_string(),
|
|
request_timeout_ms: 1_000,
|
|
request_deadline: None,
|
|
};
|
|
let debug = format!("{settings:?}");
|
|
|
|
assert!(debug.contains("[redacted]"));
|
|
assert!(!debug.contains(settings.api_key.as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn configured_timeout_is_preserved_without_a_request_deadline() {
|
|
let now = Instant::now();
|
|
|
|
assert_eq!(
|
|
effective_request_timeout_ms_at(180_000, None, now),
|
|
Some(180_000)
|
|
);
|
|
// 0 会被 reqwest 当成非法超时,最低收敛到 1ms。
|
|
assert_eq!(effective_request_timeout_ms_at(0, None, now), Some(1));
|
|
}
|
|
|
|
#[test]
|
|
fn request_timeout_is_clipped_to_the_remaining_provider_budget() {
|
|
let now = Instant::now();
|
|
let deadline = now + std::time::Duration::from_millis(2_000);
|
|
|
|
assert_eq!(
|
|
effective_request_timeout_ms_at(180_000, Some(deadline), now),
|
|
Some(2_000)
|
|
);
|
|
// 预算比固定超时宽松时不放大固定超时。
|
|
assert_eq!(
|
|
effective_request_timeout_ms_at(
|
|
500,
|
|
Some(now + std::time::Duration::from_secs(600)),
|
|
now
|
|
),
|
|
Some(500)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn exhausted_budget_stops_the_provider_request_before_it_is_sent() {
|
|
let now = Instant::now();
|
|
|
|
assert_eq!(
|
|
effective_request_timeout_ms_at(
|
|
180_000,
|
|
Some(now - std::time::Duration::from_millis(1)),
|
|
now
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
effective_request_timeout_ms_at(180_000, Some(now), now),
|
|
None
|
|
);
|
|
|
|
let error =
|
|
elevenlabs_budget_exhausted_error("https://api.elevenlabs.test/v1/sound-generation");
|
|
assert_eq!(error.provider(), ELEVENLABS_PROVIDER);
|
|
assert!(matches!(error, AudioError::Request { timeout: true, .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn request_validation_errors_are_attributed_to_elevenlabs() {
|
|
let error = build_elevenlabs_sound_effect_body(&ElevenLabsSoundEffectRequest {
|
|
text: " ".to_string(),
|
|
duration_seconds: None,
|
|
loop_enabled: false,
|
|
})
|
|
.expect_err("empty text should fail");
|
|
|
|
assert_eq!(error.provider(), ELEVENLABS_PROVIDER);
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_prompt_helper_uses_ecmascript_trim_boundaries() {
|
|
assert_eq!(
|
|
crate::canonicalize_sound_effect_prompt("\u{feff}sound\u{200b}\u{feff}"),
|
|
"sound\u{200b}"
|
|
);
|
|
}
|
|
}
|