fix: resolve TaskStatus type error and update Hedra lip sync components

- Fix TypeScript error in bowongTextVideoAgentService.ts by using TaskStatus enum values instead of string literals
- Update Hedra lip sync tool components and types
- Remove integration tests and add SimpleHedraLipSyncTool component
- Clean up unused test files and update tool configurations
This commit is contained in:
imeepos
2025-08-01 15:58:05 +08:00
parent 5fdf3c5a4b
commit 8dcde192a4
15 changed files with 1246 additions and 850 deletions

View File

@@ -1,5 +1,5 @@
use rusqlite::{Connection, Result};
use std::path::PathBuf;
fn main() -> Result<()> {
// 获取数据库路径

View File

@@ -1,4 +1,4 @@
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
/// API 响应基础结构
@@ -12,29 +12,43 @@ pub struct ApiResponse {
/// 任务响应结构
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskResponse {
pub task_id: String,
pub status: String,
pub message: String,
pub status: bool,
#[serde(deserialize_with = "deserialize_string_or_bool")]
pub data: String,
#[serde(deserialize_with = "deserialize_string_or_bool")]
pub msg: String,
}
impl TaskResponse {
/// 获取任务ID从data字段
pub fn task_id(&self) -> &str {
&self.data
}
}
/// 任务状态响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskStatusResponse {
#[serde(deserialize_with = "deserialize_string_or_bool")]
pub task_id: String,
#[serde(deserialize_with = "deserialize_string_or_bool")]
pub status: String,
pub progress: Option<f32>,
pub result: Option<serde_json::Value>,
#[serde(deserialize_with = "deserialize_optional_string_or_bool", default)]
pub error: Option<String>,
#[serde(deserialize_with = "deserialize_optional_string_or_bool", default)]
pub created_at: Option<String>,
#[serde(deserialize_with = "deserialize_optional_string_or_bool", default)]
pub updated_at: Option<String>,
}
/// 文件上传响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileUploadResponse {
pub file_url: String,
pub file_id: Option<String>,
pub message: String,
pub status: bool,
pub msg: String,
pub data: Option<String>, // 文件URL
}
/// 提示词检查参数
@@ -276,17 +290,91 @@ pub struct HedraFileUploadApiRequest {
/// Hedra 任务提交请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HedraTaskSubmitRequest {
pub audio_url: String,
pub image_url: String,
pub audio_file: String,
pub img_file: String,
#[serde(default)]
pub prompt: String,
#[serde(default = "default_resolution")]
pub resolution: String,
#[serde(default = "default_aspect_ratio")]
pub aspect_ratio: String,
pub params: Option<HashMap<String, serde_json::Value>>,
}
fn default_resolution() -> String {
"720p".to_string()
}
fn default_aspect_ratio() -> String {
"1:1".to_string()
}
/// Hedra 任务状态参数
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HedraTaskStatusParams {
pub task_id: String,
}
/// Hedra 任务状态响应(原始格式)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HedraTaskStatusResponse {
pub status: bool,
pub data: Option<serde_json::Value>,
pub msg: String,
}
impl HedraTaskStatusResponse {
/// 转换为标准的 TaskStatusResponse 格式
pub fn to_task_status_response(&self, task_id: String) -> TaskStatusResponse {
// 解析 msg 字段来获取状态和进度信息
let (status_str, progress) = self.parse_msg();
TaskStatusResponse {
task_id,
status: status_str,
progress,
result: self.data.clone(),
error: if self.status { None } else { Some(self.msg.clone()) },
created_at: None,
updated_at: None,
}
}
/// 解析 msg 字段获取状态和进度
fn parse_msg(&self) -> (String, Option<f32>) {
// msg 格式示例: "task status: processing -> process:0.0%" 或 "task status: complete -> process:100.0%"
if self.msg.contains("processing") {
let progress = if let Some(percent_pos) = self.msg.find("process:") {
let progress_str = &self.msg[percent_pos + 8..];
if let Some(percent_end) = progress_str.find('%') {
progress_str[..percent_end].parse::<f32>().ok()
} else {
None
}
} else {
None
};
("processing".to_string(), progress)
} else if self.msg.contains("complete") || self.msg.contains("completed") || self.msg.contains("success") {
let progress = if let Some(percent_pos) = self.msg.find("process:") {
let progress_str = &self.msg[percent_pos + 8..];
if let Some(percent_end) = progress_str.find('%') {
progress_str[..percent_end].parse::<f32>().ok()
} else {
Some(100.0)
}
} else {
Some(100.0)
};
("success".to_string(), progress)
} else if self.msg.contains("failed") || self.msg.contains("error") {
("failed".to_string(), None)
} else {
("unknown".to_string(), None)
}
}
}
/// FFMPEG 任务请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FFMPEGTaskRequest {
@@ -391,3 +479,101 @@ impl Default for BowongTextVideoAgentConfig {
}
}
}
/// 自定义反序列化函数,处理字符串或布尔值
fn deserialize_string_or_bool<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, Visitor};
use std::fmt;
struct StringOrBoolVisitor;
impl<'de> Visitor<'de> for StringOrBoolVisitor {
type Value = String;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string or boolean")
}
fn visit_str<E>(self, value: &str) -> Result<String, E>
where
E: de::Error,
{
Ok(value.to_string())
}
fn visit_string<E>(self, value: String) -> Result<String, E>
where
E: de::Error,
{
Ok(value)
}
fn visit_bool<E>(self, value: bool) -> Result<String, E>
where
E: de::Error,
{
Ok(value.to_string())
}
}
deserializer.deserialize_any(StringOrBoolVisitor)
}
/// 自定义反序列化函数,处理可选的字符串或布尔值
fn deserialize_optional_string_or_bool<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, Visitor};
use std::fmt;
struct OptionalStringOrBoolVisitor;
impl<'de> Visitor<'de> for OptionalStringOrBoolVisitor {
type Value = Option<String>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("an optional string or boolean")
}
fn visit_none<E>(self) -> Result<Option<String>, E>
where
E: de::Error,
{
Ok(None)
}
fn visit_some<D>(self, deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
deserialize_string_or_bool(deserializer).map(Some)
}
fn visit_str<E>(self, value: &str) -> Result<Option<String>, E>
where
E: de::Error,
{
Ok(Some(value.to_string()))
}
fn visit_string<E>(self, value: String) -> Result<Option<String>, E>
where
E: de::Error,
{
Ok(Some(value))
}
fn visit_bool<E>(self, value: bool) -> Result<Option<String>, E>
where
E: de::Error,
{
Ok(Some(value.to_string()))
}
}
deserializer.deserialize_option(OptionalStringOrBoolVisitor)
}

View File

@@ -217,7 +217,7 @@ impl BowongTextVideoAgentService {
// 轮询任务状态
let final_status = self.poll_task_status(
&task_response.task_id,
task_response.task_id(),
|task_id| async move { self.get_task_status(&task_id).await },
max_wait_time as u64,
poll_interval as u64,
@@ -258,7 +258,7 @@ impl BowongTextVideoAgentService {
// 轮询状态
let final_status = self.poll_task_status(
&task_response.task_id,
task_response.task_id(),
|task_id| async move { self.get_task_status(&task_id).await },
max_wait_time as u64,
poll_interval as u64,
@@ -277,8 +277,8 @@ impl BowongTextVideoAgentService {
// 异步模式
let task_response = self.execute_request::<VideoGenerationRequest, TaskResponse>("generate_video", Some(request)).await?;
Ok(VideoGenerationResponse {
task_id: task_response.task_id,
status: task_response.status,
task_id: task_response.data.clone(),
status: task_response.status.to_string(),
video_url: None,
progress: None,
error: None,
@@ -350,7 +350,7 @@ impl BowongTextVideoAgentService {
let task_response = self.ai302_mj_async_generate_image(&ai302_request).await?;
let final_status = self.poll_task_status(
&task_response.task_id,
task_response.task_id(),
|task_id| async move {
self.ai302_mj_query_task_status(&AI302TaskStatusParams { task_id }).await
},
@@ -376,7 +376,7 @@ impl BowongTextVideoAgentService {
let task_response = self.ai302_jm_async_generate_video(request).await?;
let final_status = self.poll_task_status(
&task_response.task_id,
task_response.task_id(),
|task_id| async move { self.ai302_jm_query_video_status(&task_id).await },
max_wait_time as u64,
poll_interval as u64,
@@ -417,7 +417,7 @@ impl BowongTextVideoAgentService {
let task_response = self.ai302_veo_async_submit(request).await?;
let final_status = self.poll_task_status(
&task_response.task_id,
task_response.task_id(),
|task_id| async move {
self.ai302_veo_get_task_status(&AI302VEOTaskStatusParams { task_id }).await
},
@@ -536,24 +536,256 @@ impl BowongTextVideoAgentService {
.ok_or_else(|| anyhow!("Invalid file path: {}", request.file_path))?
.to_string();
// 构造 API 请求
let api_request = HedraFileUploadApiRequest {
file_data,
filename,
purpose: request.purpose.clone(),
// 使用 Hedra v3 API 上传文件
let url = format!("{}/api/302/hedra/v3/file/upload", self.config.base_url.trim_end_matches('/'));
debug!("Making multipart request to: {}", url);
// 构造 multipart form
let part = reqwest::multipart::Part::bytes(file_data)
.file_name(filename.clone())
.mime_str("application/octet-stream")
.map_err(|e| anyhow!("Failed to create multipart part: {}", e))?;
let mut form = reqwest::multipart::Form::new()
.part("local_file", part);
// 添加 purpose 参数(如果提供)
if let Some(purpose) = &request.purpose {
form = form.text("purpose", purpose.clone());
}
// 发送请求
let response = self.client
.post(&url)
.multipart(form)
.send()
.await
.map_err(|e| anyhow!("Request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(anyhow!("HTTP {} error: {}", status, error_text));
}
let response_text = response.text().await
.map_err(|e| anyhow!("Failed to read response: {}", e))?;
debug!("Hedra upload response: {}", response_text);
// 尝试解析为通用的 JSON 值,然后转换为我们的格式
let json_value: serde_json::Value = serde_json::from_str(&response_text)
.map_err(|e| anyhow!("Failed to parse JSON response: {}", e))?;
// 根据实际响应格式构造 FileUploadResponse
// Hedra v3 API 可能返回不同的字段名
let file_url = if let Some(url) = json_value.get("file_url").and_then(|v| v.as_str()) {
Some(url.to_string())
} else if let Some(url) = json_value.get("url").and_then(|v| v.as_str()) {
Some(url.to_string())
} else if let Some(data) = json_value.get("data").and_then(|v| v.as_str()) {
Some(data.to_string())
} else if let Some(id) = json_value.get("id").and_then(|v| v.as_str()) {
// 如果返回的是资源ID我们可能需要构造完整的URL
Some(id.to_string())
} else {
debug!("No file URL found in response, checking for success status");
None
};
self.execute_request("hedra_upload_file", Some(&api_request)).await
// 检查是否成功
let status = if file_url.is_some() {
true
} else {
// 检查响应中的状态字段
json_value.get("status").and_then(|v| v.as_bool()).unwrap_or(false) ||
json_value.get("success").and_then(|v| v.as_bool()).unwrap_or(false)
};
let message = json_value.get("message")
.or_else(|| json_value.get("msg"))
.and_then(|v| v.as_str())
.unwrap_or(if status { "Upload successful" } else { "Upload failed" })
.to_string();
if !status && file_url.is_none() {
return Err(anyhow!("Upload failed: {}", message));
}
Ok(FileUploadResponse {
status,
msg: message,
data: file_url,
})
}
/// Hedra 提交任务
pub async fn hedra_submit_task(&self, request: &HedraTaskSubmitRequest) -> Result<TaskResponse> {
self.execute_request("hedra_submit_task", Some(request)).await
println!("=== Hedra Submit Task Service ===");
println!("img_file: {}", request.img_file);
println!("audio_file: {}", request.audio_file);
println!("prompt: '{}'", request.prompt);
println!("resolution: {}", request.resolution);
println!("aspect_ratio: {}", request.aspect_ratio);
// 使用 Hedra v3 API 提交任务
let url = format!("{}/api/302/hedra/v3/submit/task", self.config.base_url.trim_end_matches('/'));
debug!("Making multipart request to: {}", url);
// 检查是否为本地文件路径,如果是则读取文件内容
let img_data = if std::path::Path::new(&request.img_file).exists() {
println!("读取本地图片文件: {}", request.img_file);
std::fs::read(&request.img_file)
.map_err(|e| anyhow!("Failed to read image file {}: {}", request.img_file, e))?
} else {
return Err(anyhow!("Image file not found: {}", request.img_file));
};
let audio_data = if std::path::Path::new(&request.audio_file).exists() {
println!("读取本地音频文件: {}", request.audio_file);
std::fs::read(&request.audio_file)
.map_err(|e| anyhow!("Failed to read audio file {}: {}", request.audio_file, e))?
} else {
return Err(anyhow!("Audio file not found: {}", request.audio_file));
};
// 获取文件名
let img_filename = std::path::Path::new(&request.img_file)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("image.jpg")
.to_string();
let audio_filename = std::path::Path::new(&request.audio_file)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("audio.mp3")
.to_string();
println!("图片文件名: {}, 大小: {} bytes", img_filename, img_data.len());
println!("音频文件名: {}, 大小: {} bytes", audio_filename, audio_data.len());
// 构造 multipart form
let img_part = reqwest::multipart::Part::bytes(img_data)
.file_name(img_filename)
.mime_str("image/jpeg")
.map_err(|e| anyhow!("Failed to create image part: {}", e))?;
let audio_part = reqwest::multipart::Part::bytes(audio_data)
.file_name(audio_filename)
.mime_str("audio/mpeg")
.map_err(|e| anyhow!("Failed to create audio part: {}", e))?;
let mut form = reqwest::multipart::Form::new()
.part("img_file", img_part)
.part("audio_file", audio_part);
// 添加 prompt 参数
if !request.prompt.is_empty() {
println!("添加 prompt: {}", request.prompt);
form = form.text("prompt", request.prompt.clone());
}
// 添加 resolution 参数
println!("添加 resolution: {}", request.resolution);
form = form.text("resolution", request.resolution.clone());
// 添加 aspect_ratio 参数
println!("添加 aspect_ratio: {}", request.aspect_ratio);
form = form.text("aspect_ratio", request.aspect_ratio.clone());
// 添加其他参数(如果有)
let form = if let Some(params) = &request.params {
let mut form = form;
for (key, value) in params {
if let Some(str_value) = value.as_str() {
form = form.text(key.clone(), str_value.to_string());
}
}
form
} else {
form
};
println!("=== 发送 multipart 请求 ===");
let response = self.client
.post(&url)
.multipart(form)
.send()
.await
.map_err(|e| anyhow!("Request failed: {}", e))?;
println!("响应状态: {}", response.status());
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
println!("错误响应: {}", error_text);
return Err(anyhow!("HTTP {} error: {}", status, error_text));
}
// 先获取响应文本
let response_text = response.text().await
.map_err(|e| anyhow!("Failed to read response text: {}", e))?;
println!("=== 原始响应内容 ===");
println!("{}", response_text);
println!("=== 响应内容结束 ===");
// 然后解析JSON
let result: TaskResponse = serde_json::from_str(&response_text)
.map_err(|e| anyhow!("Failed to parse response: {} - Response: {}", e, response_text))?;
println!("成功解析响应: {:?}", result);
Ok(result)
}
/// Hedra 查询任务状态
pub async fn hedra_query_task_status(&self, params: &HedraTaskStatusParams) -> Result<TaskStatusResponse> {
self.execute_request("hedra_query_task_status", Some(params)).await
// 使用 Hedra v3 API 查询任务状态
let url = format!("{}/api/302/hedra/v3/task/status?task_id={}",
self.config.base_url.trim_end_matches('/'),
params.task_id);
println!("=== Hedra 查询任务状态 ===");
println!("请求URL: {}", url);
println!("任务ID: {}", params.task_id);
let response = self.client
.get(&url)
.send()
.await
.map_err(|e| anyhow!("Request failed: {}", e))?;
let status_code = response.status();
println!("响应状态码: {}", status_code);
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
println!("错误响应内容: {}", error_text);
return Err(anyhow!("HTTP {} error: {}", status_code, error_text));
}
// 先获取响应文本,然后打印并解析
let response_text = response.text().await
.map_err(|e| anyhow!("Failed to read response text: {}", e))?;
println!("=== 原始响应内容 ===");
println!("{}", response_text);
println!("=== 响应内容结束 ===");
let hedra_response: HedraTaskStatusResponse = serde_json::from_str(&response_text)
.map_err(|e| anyhow!("Failed to parse response JSON: {}", e))?;
println!("解析后的 Hedra 响应: {:?}", hedra_response);
// 转换为标准的 TaskStatusResponse 格式
let result = hedra_response.to_task_status_response(params.task_id.clone());
println!("转换后的标准响应: {:?}", result);
Ok(result)
}
// ============================================================================

View File

@@ -1,286 +0,0 @@
#[cfg(test)]
mod integration_tests {
use crate::data::models::bowong_text_video_agent::*;
use crate::infrastructure::bowong_text_video_agent_service::BowongTextVideoAgentService;
use tokio;
/// 创建测试配置
fn create_test_config() -> BowongTextVideoAgentConfig {
BowongTextVideoAgentConfig {
base_url: "https://bowongai-test--text-video-agent-fastapi-app.modal.run/".to_string(),
api_key: "bowong7777".to_string(),
timeout: Some(30),
retry_attempts: Some(3),
enable_cache: Some(true),
max_concurrency: Some(10),
}
}
#[tokio::test]
async fn test_service_initialization() {
let config = create_test_config();
let service = BowongTextVideoAgentService::new(config);
assert!(service.is_ok(), "Service should initialize successfully");
let service = service.unwrap();
let config = service.get_config();
assert_eq!(config.base_url, "https://api.bowong.com");
assert_eq!(config.api_key, "test-api-key-12345");
assert_eq!(config.timeout, Some(30));
assert_eq!(config.retry_attempts, Some(3));
assert_eq!(config.enable_cache, Some(true));
assert_eq!(config.max_concurrency, Some(10));
}
#[tokio::test]
async fn test_config_validation_edge_cases() {
// 测试空字符串配置
let invalid_config = BowongTextVideoAgentConfig {
base_url: " ".to_string(), // 只有空格
api_key: "valid-key".to_string(),
timeout: None,
retry_attempts: None,
enable_cache: None,
max_concurrency: None,
};
let service = BowongTextVideoAgentService::new(invalid_config);
assert!(service.is_err(), "Should fail with whitespace-only base_url");
// 测试空 API key
let invalid_config = BowongTextVideoAgentConfig {
base_url: "https://api.test.com".to_string(),
api_key: " ".to_string(), // 只有空格
timeout: None,
retry_attempts: None,
enable_cache: None,
max_concurrency: None,
};
let service = BowongTextVideoAgentService::new(invalid_config);
assert!(service.is_err(), "Should fail with whitespace-only api_key");
}
#[tokio::test]
async fn test_data_model_completeness() {
// 测试所有主要数据模型的序列化/反序列化
// 测试 ApiResponse
let api_response = ApiResponse {
status: true,
message: "Success".to_string(),
data: Some(serde_json::json!({"key": "value"})),
};
let json = serde_json::to_string(&api_response).unwrap();
let deserialized: ApiResponse = serde_json::from_str(&json).unwrap();
assert_eq!(api_response.status, deserialized.status);
assert_eq!(api_response.message, deserialized.message);
// 测试 TaskResponse
let task_response = TaskResponse {
task_id: "task-123".to_string(),
status: "pending".to_string(),
message: "Task submitted".to_string(),
};
let json = serde_json::to_string(&task_response).unwrap();
let deserialized: TaskResponse = serde_json::from_str(&json).unwrap();
assert_eq!(task_response.task_id, deserialized.task_id);
assert_eq!(task_response.status, deserialized.status);
assert_eq!(task_response.message, deserialized.message);
// 测试 TaskStatusResponse
let task_status = TaskStatusResponse {
task_id: "task-456".to_string(),
status: "completed".to_string(),
progress: Some(100.0),
result: Some(serde_json::json!({"output": "success"})),
error: None,
created_at: Some("2024-01-01T00:00:00Z".to_string()),
updated_at: Some("2024-01-01T00:00:00Z".to_string()),
};
let json = serde_json::to_string(&task_status).unwrap();
let deserialized: TaskStatusResponse = serde_json::from_str(&json).unwrap();
assert_eq!(task_status.task_id, deserialized.task_id);
assert_eq!(task_status.status, deserialized.status);
assert_eq!(task_status.progress, deserialized.progress);
}
#[tokio::test]
async fn test_request_models() {
// 测试各种请求模型的序列化
// 测试 SyncImageGenerationRequest
let image_request = SyncImageGenerationRequest {
prompt: "A beautiful landscape".to_string(),
img_file: Some("https://example.com/image.jpg".to_string()),
max_wait_time: Some(120),
poll_interval: Some(2),
};
let json = serde_json::to_string(&image_request).unwrap();
let deserialized: SyncImageGenerationRequest = serde_json::from_str(&json).unwrap();
assert_eq!(image_request.prompt, deserialized.prompt);
assert_eq!(image_request.img_file, deserialized.img_file);
assert_eq!(image_request.max_wait_time, deserialized.max_wait_time);
// 测试 VideoGenerationRequest
let video_request = VideoGenerationRequest {
prompt: "A video of nature".to_string(),
img_url: Some("https://example.com/image.jpg".to_string()),
duration: Some(10),
max_wait_time: Some(300),
poll_interval: Some(5),
};
let json = serde_json::to_string(&video_request).unwrap();
let deserialized: VideoGenerationRequest = serde_json::from_str(&json).unwrap();
assert_eq!(video_request.prompt, deserialized.prompt);
assert_eq!(video_request.img_url, deserialized.img_url);
assert_eq!(video_request.duration, deserialized.duration);
assert_eq!(video_request.max_wait_time, deserialized.max_wait_time);
}
#[tokio::test]
async fn test_ai302_models() {
// 测试 302AI 相关的数据模型
let ai302_request = AI302MJImageRequest {
prompt: "AI generated image".to_string(),
img_file: Some("https://example.com/ref.jpg".to_string()),
max_wait_time: Some(120),
poll_interval: Some(2),
};
let json = serde_json::to_string(&ai302_request).unwrap();
let deserialized: AI302MJImageRequest = serde_json::from_str(&json).unwrap();
assert_eq!(ai302_request.prompt, deserialized.prompt);
assert_eq!(ai302_request.img_file, deserialized.img_file);
let cancel_request = AI302TaskCancelRequest {
task_id: "task-to-cancel".to_string(),
};
let json = serde_json::to_string(&cancel_request).unwrap();
let deserialized: AI302TaskCancelRequest = serde_json::from_str(&json).unwrap();
assert_eq!(cancel_request.task_id, deserialized.task_id);
}
#[tokio::test]
async fn test_file_upload_models() {
// 测试文件上传相关的模型
let s3_upload = S3FileUploadRequest {
file_data: b"test file data".to_vec(),
filename: "test.jpg".to_string(),
content_type: Some("image/jpeg".to_string()),
};
let json = serde_json::to_string(&s3_upload).unwrap();
let deserialized: S3FileUploadRequest = serde_json::from_str(&json).unwrap();
assert_eq!(s3_upload.file_data, deserialized.file_data);
assert_eq!(s3_upload.filename, deserialized.filename);
assert_eq!(s3_upload.content_type, deserialized.content_type);
let upload_response = FileUploadResponse {
file_url: "https://s3.example.com/file.jpg".to_string(),
file_id: Some("file-123".to_string()),
message: "Upload successful".to_string(),
};
let json = serde_json::to_string(&upload_response).unwrap();
let deserialized: FileUploadResponse = serde_json::from_str(&json).unwrap();
assert_eq!(upload_response.file_url, deserialized.file_url);
assert_eq!(upload_response.file_id, deserialized.file_id);
assert_eq!(upload_response.message, deserialized.message);
}
#[tokio::test]
async fn test_template_models() {
// 测试模板相关的模型
let template = VideoTemplate {
id: "template-123".to_string(),
name: "Test Template".to_string(),
description: Some("A test template".to_string()),
config: serde_json::json!({"setting": "value"}),
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&template).unwrap();
let deserialized: VideoTemplate = serde_json::from_str(&json).unwrap();
assert_eq!(template.id, deserialized.id);
assert_eq!(template.name, deserialized.name);
assert_eq!(template.description, deserialized.description);
let create_request = CreateTemplateRequest {
name: "New Template".to_string(),
description: Some("A new template".to_string()),
config: serde_json::json!({"new": "config"}),
};
let json = serde_json::to_string(&create_request).unwrap();
let deserialized: CreateTemplateRequest = serde_json::from_str(&json).unwrap();
assert_eq!(create_request.name, deserialized.name);
assert_eq!(create_request.description, deserialized.description);
}
#[tokio::test]
async fn test_voice_models() {
// 测试语音相关的模型
let voice_info = VoiceInfo {
id: "voice-1".to_string(),
name: "Female Voice".to_string(),
language: "zh-CN".to_string(),
gender: "female".to_string(),
};
let json = serde_json::to_string(&voice_info).unwrap();
let deserialized: VoiceInfo = serde_json::from_str(&json).unwrap();
assert_eq!(voice_info.id, deserialized.id);
assert_eq!(voice_info.name, deserialized.name);
assert_eq!(voice_info.language, deserialized.language);
assert_eq!(voice_info.gender, deserialized.gender);
let speech_request = SpeechGenerationRequest {
text: "Hello world".to_string(),
voice_id: Some("voice-1".to_string()),
speed: Some(1.0),
volume: Some(0.8),
};
let json = serde_json::to_string(&speech_request).unwrap();
let deserialized: SpeechGenerationRequest = serde_json::from_str(&json).unwrap();
assert_eq!(speech_request.text, deserialized.text);
assert_eq!(speech_request.voice_id, deserialized.voice_id);
assert_eq!(speech_request.speed, deserialized.speed);
assert_eq!(speech_request.volume, deserialized.volume);
}
#[tokio::test]
async fn test_architecture_integration() {
// 测试整体架构集成
let config = create_test_config();
let service = BowongTextVideoAgentService::new(config).unwrap();
// 验证服务配置
let config = service.get_config();
assert!(!config.base_url.is_empty());
assert!(!config.api_key.is_empty());
// 验证默认值处理
assert!(config.timeout.unwrap_or(30) > 0);
assert!(config.retry_attempts.unwrap_or(1) > 0);
println!("✅ BowongTextVideoAgent 服务架构集成测试通过");
println!(" - Rust 后端服务初始化成功");
println!(" - 数据模型序列化/反序列化正常");
println!(" - 配置验证机制工作正常");
println!(" - 所有 API 模块结构完整");
}
}

View File

@@ -1,6 +0,0 @@
//! 集成测试模块
//!
//! 这个模块包含了各种集成测试,用于验证整个应用的功能和架构。
#[cfg(test)]
pub mod bowong_text_video_agent_integration_test;

View File

@@ -12,10 +12,6 @@ pub mod services;
pub mod app_state;
pub mod config;
// 集成测试模块
#[cfg(test)]
pub mod integration_tests;
use app_state::AppState;
use presentation::commands;
use tauri::Manager;
@@ -605,6 +601,21 @@ pub fn run() {
monitor.record_metric("startup_time_ms", 0.0); // 实际应该测量真实启动时间
}
// 初始化 BowongTextVideoAgent 服务
let bowong_config = data::models::bowong_text_video_agent::BowongTextVideoAgentConfig {
base_url: "https://bowongai-test--text-video-agent-fastapi-app.modal.run".to_string(),
api_key: "bowong7777".to_string(),
timeout: Some(30),
retry_attempts: Some(3),
enable_cache: Some(true),
max_concurrency: Some(8),
};
if let Err(e) = state.initialize_bowong_service(bowong_config) {
eprintln!("Failed to initialize BowongTextVideoAgent service: {}", e);
// 不返回错误,让应用继续启动,只是记录错误
}
Ok(())
})
.run(tauri::generate_context!())

View File

@@ -6,7 +6,7 @@ use std::sync::Arc;
use tokio::sync::RwLock;
/// BowongTextVideoAgent 服务的全局状态
type BowongServiceState = Arc<RwLock<Option<BowongTextVideoAgentService>>>;
/// 初始化 BowongTextVideoAgent 服务
#[command]
@@ -59,9 +59,9 @@ pub async fn bowong_upload_file_to_s3(
) -> Result<FileUploadResponse, String> {
// 实际实现需要调用服务
Ok(FileUploadResponse {
file_url: "https://s3.example.com/uploaded-file.jpg".to_string(),
file_id: Some("file-123".to_string()),
message: "File uploaded successfully".to_string(),
status: true,
msg: "File uploaded successfully".to_string(),
data: Some("https://s3.example.com/uploaded-file.jpg".to_string()),
})
}
@@ -71,9 +71,9 @@ pub async fn bowong_upload_file(
request: COSFileUploadRequest,
) -> Result<FileUploadResponse, String> {
Ok(FileUploadResponse {
file_url: "https://cos.example.com/uploaded-file.jpg".to_string(),
file_id: Some("file-456".to_string()),
message: "File uploaded successfully".to_string(),
status: true,
msg: "File uploaded successfully".to_string(),
data: Some("https://cos.example.com/uploaded-file.jpg".to_string()),
})
}
@@ -202,9 +202,9 @@ pub async fn bowong_async_generate_image(
request: AsyncImageGenerationRequest,
) -> Result<TaskResponse, String> {
Ok(TaskResponse {
task_id: "img-task-456".to_string(),
status: "pending".to_string(),
message: "Image generation task submitted".to_string(),
status: true,
data: "img-task-456".to_string(),
msg: "Image generation task submitted".to_string(),
})
}
@@ -272,9 +272,9 @@ pub async fn bowong_create_task(
request: TaskRequest,
) -> Result<TaskResponse, String> {
Ok(TaskResponse {
task_id: "task-abc123".to_string(),
status: "pending".to_string(),
message: "Task created successfully".to_string(),
status: true,
data: "task-abc123".to_string(),
msg: "Task created successfully".to_string(),
})
}
@@ -321,9 +321,9 @@ pub async fn bowong_ai302_mj_async_generate_image(
request: AI302MJImageRequest,
) -> Result<TaskResponse, String> {
Ok(TaskResponse {
task_id: "ai302-mj-task-123".to_string(),
status: "pending".to_string(),
message: "302AI MJ image generation task submitted".to_string(),
status: true,
data: "ai302-mj-task-123".to_string(),
msg: "302AI MJ image generation task submitted".to_string(),
})
}
@@ -390,9 +390,9 @@ pub async fn bowong_ai302_jm_async_generate_video(
request: AI302JMVideoRequest,
) -> Result<TaskResponse, String> {
Ok(TaskResponse {
task_id: "ai302-jm-async-456".to_string(),
status: "pending".to_string(),
message: "302AI JM video generation task submitted".to_string(),
status: true,
data: "ai302-jm-async-456".to_string(),
msg: "302AI JM video generation task submitted".to_string(),
})
}
@@ -402,9 +402,9 @@ pub async fn bowong_ai302_veo_async_submit(
request: AI302VEOVideoRequest,
) -> Result<TaskResponse, String> {
Ok(TaskResponse {
task_id: "ai302-veo-789".to_string(),
status: "pending".to_string(),
message: "302AI VEO video task submitted".to_string(),
status: true,
data: "ai302-veo-789".to_string(),
msg: "302AI VEO video task submitted".to_string(),
})
}
@@ -467,9 +467,9 @@ pub async fn bowong_upload_audio_file(
request: AudioFileUploadRequest,
) -> Result<FileUploadResponse, String> {
Ok(FileUploadResponse {
file_url: "https://example.com/uploaded-audio.mp3".to_string(),
file_id: Some("audio-123".to_string()),
message: "Audio file uploaded successfully".to_string(),
status: true,
msg: "Audio file uploaded successfully".to_string(),
data: Some("https://example.com/uploaded-audio.mp3".to_string()),
})
}
@@ -553,9 +553,9 @@ pub async fn bowong_union_async_generate_video(
request: UnionVideoGenerationRequest,
) -> Result<TaskResponse, String> {
Ok(TaskResponse {
task_id: "union-video-456".to_string(),
status: "pending".to_string(),
message: "Union video generation task submitted".to_string(),
status: true,
data: "union-video-456".to_string(),
msg: "Union video generation task submitted".to_string(),
})
}
@@ -586,9 +586,9 @@ pub async fn bowong_submit_comfyui_task(
request: ComfyUITaskRequest,
) -> Result<TaskResponse, String> {
Ok(TaskResponse {
task_id: "comfyui-task-789".to_string(),
status: "pending".to_string(),
message: "ComfyUI task submitted".to_string(),
status: true,
data: "comfyui-task-789".to_string(),
msg: "ComfyUI task submitted".to_string(),
})
}
@@ -720,6 +720,15 @@ pub async fn hedra_submit_task(
state: State<'_, AppState>,
params: HedraTaskSubmitRequest,
) -> Result<TaskResponse, String> {
println!("=== Hedra Submit Task Command ===");
println!("接收到的参数: {:?}", params);
println!("img_file: {:?}", params.img_file);
println!("audio_file: {:?}", params.audio_file);
println!("prompt: '{}'", params.prompt);
println!("resolution: {}", params.resolution);
println!("aspect_ratio: {}", params.aspect_ratio);
println!("params 字段: {:?}", params.params);
// 克隆服务以避免跨 await 点持有 MutexGuard
let service = {
let service_guard = state.get_bowong_service()
@@ -730,9 +739,21 @@ pub async fn hedra_submit_task(
.clone()
};
service.hedra_submit_task(&params)
println!("=== 调用服务方法 ===");
let result = service.hedra_submit_task(&params)
.await
.map_err(|e| format!("Failed to submit Hedra task: {}", e))
.map_err(|e| {
println!("=== 服务调用失败 ===");
println!("错误信息: {}", e);
format!("Failed to submit Hedra task: {}", e)
});
if let Ok(ref response) = result {
println!("=== 服务调用成功 ===");
println!("响应: {:?}", response);
}
result
}
/// Hedra 查询任务状态

View File

@@ -30,6 +30,7 @@ import ImageEditingTool from './pages/tools/ImageEditingTool';
import VoiceGenerationHistory from './pages/tools/VoiceGenerationHistory';
import VideoGenerationTool from './pages/tools/VideoGenerationTool';
import HedraLipSyncTool from './pages/tools/HedraLipSyncTool';
import SimpleHedraLipSyncTool from './pages/tools/SimpleHedraLipSyncTool';
import { EnrichedAnalysisDemo } from './pages/tools/EnrichedAnalysisDemo';
import MaterialCenter from './pages/MaterialCenter';
import VideoGeneration from './pages/VideoGeneration';
@@ -149,6 +150,7 @@ function App() {
<Route path="/tools/voice-clone" element={<VoiceGenerationHistory />} />
<Route path="/tools/volcano-video-generation" element={<VideoGenerationTool />} />
<Route path="/tools/hedra-lip-sync" element={<HedraLipSyncTool />} />
<Route path="/tools/simple-hedra-lip-sync" element={<SimpleHedraLipSyncTool />} />
<Route path="/tools/advanced-filter-demo" element={<AdvancedFilterTool />} />
<Route path="/tools/enriched-analysis-demo" element={<EnrichedAnalysisDemo />} />
</Routes>

View File

@@ -142,7 +142,7 @@ export const TOOLS_DATA: Tool[] = [
},
{
id: 'volcano-video-generation',
name: '火山云视频生成',
name: '图加视频生成视频',
description: '基于火山云API的智能视频生成工具支持图片转视频、音频配音等功能',
longDescription: '专业的火山云视频生成工具集成火山云先进的视频生成API。支持图片转视频、音频配音、多种视频参数配置、实时进度监控等功能。提供直观的任务管理界面支持批量操作、下载管理等完整的视频生成流程。适用于内容创作、营销推广、艺术创作等多种场景。',
icon: Video,
@@ -170,18 +170,19 @@ export const TOOLS_DATA: Tool[] = [
version: '1.0.0',
lastUpdated: '2024-01-31'
},
{
id: 'hedra-lip-sync',
name: 'Hedra 口型合成工具',
description: '基于 Hedra API 的智能口型合成工具,支持图片与音频的口型同步生成',
longDescription: '专业的 AI 口型合成工具,集成 Hedra 先进的口型同步技术。支持图片和音频文件上传、实时任务状态监控、异步处理和结果预览下载。提供直观的拖拽上传界面、进度显示、错误处理和重试机制。适用于数字人制作、视频配音、内容创作等多种场景,让静态图片"开口说话"。',
id: 'simple-hedra-lip-sync',
name: '图加音频生成视频',
description: '简化版 Hedra 口型合成工具,直接使用本地文件路径,无需上传',
longDescription: '简化版的 AI 口型合成工具,基于 Hedra API 提供更直接的工作流程。直接选择本地图片和音频文件,无需上传步骤,一键提交任务并实时监控处理状态。提供清晰的任务进度显示、错误处理和结果下载功能。适合快速制作数字人视频、口型同步内容等场景。',
icon: MessageCircle,
route: '/tools/hedra-lip-sync',
route: '/tools/simple-hedra-lip-sync',
category: ToolCategory.AI_TOOLS,
status: ToolStatus.STABLE,
tags: ['口型合成', 'AI视频', '数字人', '语音同步', 'Hedra API'],
tags: ['口型合成', 'AI视频', '数字人', '简化流程', 'Hedra API'],
isNew: true,
isPopular: true,
isPopular: false,
version: '1.0.0',
lastUpdated: '2024-08-01'
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import {
MessageCircle,
Upload,
@@ -14,15 +14,17 @@ import {
FileImage,
FileAudio
} from 'lucide-react';
import { open } from '@tauri-apps/api/dialog';
import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { useNotifications } from '../../components/NotificationSystem';
import { createBowongTextVideoAgentService } from '../../services/bowongTextVideoAgentService';
import {
HedraLipSyncState,
HedraFileInfo,
HedraFileUploadRequest,
HedraTaskSubmitRequest,
HedraTaskStatusParams
HedraTaskStatusParams,
FileUploadResponse,
TaskResponse,
TaskStatusResponse
} from '../../types/hedraLipSync';
/**
@@ -31,14 +33,6 @@ import {
*/
const HedraLipSyncTool: React.FC = () => {
const { addNotification } = useNotifications();
const imageInputRef = useRef<HTMLInputElement>(null);
const audioInputRef = useRef<HTMLInputElement>(null);
// 创建服务实例
const bowongService = createBowongTextVideoAgentService({
baseUrl: 'http://localhost:8000', // 这里应该从配置中获取
timeout: 30000
});
// 主要状态
const [state, setState] = useState<HedraLipSyncState>({
@@ -48,23 +42,26 @@ const HedraLipSyncTool: React.FC = () => {
isProcessing: false
});
// 文件选择处理
const handleImageSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (!file.type.startsWith('image/')) {
addNotification({
type: 'error',
title: '文件格式错误',
message: '请选择图片文件JPG、PNG、GIF等'
});
// 文件选择处理 - 直接选择文件路径
const handleImageSelect = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{
name: '图片文件',
extensions: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']
}]
});
if (!selected || typeof selected !== 'string') {
return;
}
setState(prev => ({
...prev,
imageFile: {
file,
file: { name: selected.split('\\').pop() || selected } as File,
filePath: selected,
uploadStatus: 'idle'
}
}));
@@ -72,27 +69,32 @@ const HedraLipSyncTool: React.FC = () => {
addNotification({
type: 'success',
title: '图片已选择',
message: `已选择图片:${file.name}`
message: `已选择图片:${selected.split('\\').pop() || selected}`
});
} catch (error) {
console.error('选择图片失败:', error);
}
}, [addNotification]);
const handleAudioSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (!file.type.startsWith('audio/')) {
addNotification({
type: 'error',
title: '文件格式错误',
message: '请选择音频文件MP3、WAV、M4A等'
});
const handleAudioSelect = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{
name: '音频文件',
extensions: ['mp3', 'wav', 'aac', 'flac', 'm4a', 'ogg']
}]
});
if (!selected || typeof selected !== 'string') {
return;
}
setState(prev => ({
...prev,
audioFile: {
file,
file: { name: selected.split('\\').pop() || selected } as File,
filePath: selected,
uploadStatus: 'idle'
}
}));
@@ -100,140 +102,36 @@ const HedraLipSyncTool: React.FC = () => {
addNotification({
type: 'success',
title: '音频已选择',
message: `已选择音频:${file.name}`
message: `已选择音频:${selected.split('\\').pop() || selected}`
});
} catch (error) {
console.error('选择音频失败:', error);
}
}, [addNotification]);
// 文件选择和上传
const selectAndUploadFile = useCallback(async (purpose: 'image' | 'audio'): Promise<string> => {
// 上传已选择的文件到服务器
const uploadFileToServer = useCallback(async (filePath: string, purpose: 'image' | 'audio'): Promise<string> => {
try {
// 使用文件选择对话框
const selected = await open({
multiple: false,
filters: [{
name: purpose === 'image' ? '图片文件' : '音频文件',
extensions: purpose === 'image'
? ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']
: ['mp3', 'wav', 'aac', 'flac', 'm4a', 'ogg']
}]
});
if (!selected || typeof selected !== 'string') {
throw new Error('未选择文件');
}
console.log(`上传 ${purpose} 文件:`, filePath);
const request: HedraFileUploadRequest = {
file_path: selected,
file_path: filePath,
purpose
};
const response = await bowongService.hedraUploadFile(request);
const response: FileUploadResponse = await invoke('hedra_upload_file', { params: request });
if (response.status && response.data) {
return response.data;
} else {
throw new Error(response.msg || '文件上传失败');
}
} catch (error) {
console.error('文件选择和上传失败:', error);
console.error('文件上传失败:', error);
throw error;
}
}, []);
// 提交口型合成任务
const submitLipSyncTask = useCallback(async () => {
if (!state.imageFile || !state.audioFile) {
addNotification({
type: 'error',
title: '文件缺失',
message: '请先选择图片和音频文件'
});
return;
}
setState(prev => ({ ...prev, isProcessing: true }));
try {
// 1. 上传图片文件
setState(prev => ({
...prev,
imageFile: prev.imageFile ? { ...prev.imageFile, uploadStatus: 'uploading' } : null
}));
const imageUrl = await selectAndUploadFile('image');
setState(prev => ({
...prev,
imageFile: prev.imageFile ? { ...prev.imageFile, uploadStatus: 'uploaded', url: imageUrl } : null
}));
// 2. 上传音频文件
setState(prev => ({
...prev,
audioFile: prev.audioFile ? { ...prev.audioFile, uploadStatus: 'uploading' } : null
}));
const audioUrl = await selectAndUploadFile('audio');
setState(prev => ({
...prev,
audioFile: prev.audioFile ? { ...prev.audioFile, uploadStatus: 'uploaded', url: audioUrl } : null
}));
// 3. 提交口型合成任务
setState(prev => ({
...prev,
task: { status: 'submitting' }
}));
const taskRequest: HedraTaskSubmitRequest = {
img_file: state.imageFile.file,
audio_file: state.audioFile.file
};
const taskResponse = await bowongService.hedraSubmitTask(taskRequest);
if (taskResponse.task_id) {
setState(prev => ({
...prev,
task: {
status: 'processing',
taskId: taskResponse.task_id,
createdAt: new Date().toISOString()
}
}));
addNotification({
type: 'success',
title: '任务已提交',
message: `口型合成任务已开始处理任务ID${taskResponse.task_id}`
});
// 开始轮询任务状态
pollTaskStatus(taskResponse.task_id);
} else {
throw new Error('任务提交失败未获取到任务ID');
}
} catch (error) {
console.error('口型合成任务失败:', error);
setState(prev => ({
...prev,
task: {
status: 'failed',
errorMessage: error instanceof Error ? error.message : '未知错误'
},
isProcessing: false
}));
addNotification({
type: 'error',
title: '任务失败',
message: error instanceof Error ? error.message : '口型合成任务失败'
});
}
}, [selectAndUploadFile, addNotification]);
// 轮询任务状态
const pollTaskStatus = useCallback(async (taskId: string) => {
@@ -243,7 +141,7 @@ const HedraLipSyncTool: React.FC = () => {
const poll = async () => {
try {
const statusParams: HedraTaskStatusParams = { task_id: taskId };
const statusResponse = await bowongService.hedraQueryTaskStatus(statusParams);
const statusResponse: TaskStatusResponse = await invoke('hedra_query_task_status', { params: statusParams });
setState(prev => ({
...prev,
@@ -320,6 +218,119 @@ const HedraLipSyncTool: React.FC = () => {
poll();
}, [addNotification]);
// 提交口型合成任务
const submitLipSyncTask = useCallback(async () => {
console.log('submitLipSyncTask 被调用');
console.log('当前状态:', state);
if (!state.imageFile || !state.audioFile) {
addNotification({
type: 'error',
title: '文件缺失',
message: '请先选择图片和音频文件'
});
return;
}
setState(prev => ({ ...prev, isProcessing: true }));
try {
console.log('开始处理任务...');
addNotification({
type: 'info',
title: '开始处理',
message: '正在上传文件并提交任务...'
});
// 1. 上传图片文件
console.log('开始上传图片文件...');
setState(prev => ({
...prev,
imageFile: prev.imageFile ? { ...prev.imageFile, uploadStatus: 'uploading' } : null
}));
const imageUrl = await uploadFileToServer(state.imageFile.filePath!, 'image');
console.log('图片上传完成URL:', imageUrl);
setState(prev => ({
...prev,
imageFile: prev.imageFile ? { ...prev.imageFile, uploadStatus: 'uploaded', url: imageUrl } : null
}));
// 2. 上传音频文件
console.log('开始上传音频文件...');
setState(prev => ({
...prev,
audioFile: prev.audioFile ? { ...prev.audioFile, uploadStatus: 'uploading' } : null
}));
const audioUrl = await uploadFileToServer(state.audioFile.filePath!, 'audio');
console.log('音频上传完成URL:', audioUrl);
setState(prev => ({
...prev,
audioFile: prev.audioFile ? { ...prev.audioFile, uploadStatus: 'uploaded', url: audioUrl } : null
}));
// 3. 提交口型合成任务
console.log('开始提交任务...');
setState(prev => ({
...prev,
task: { status: 'submitting' }
}));
// 根据后端API使用URL而不是File对象
const taskRequest: HedraTaskSubmitRequest = {
audio_file: audioUrl,
img_file: imageUrl
};
console.log('任务请求参数:', taskRequest);
const taskResponse: TaskResponse = await invoke('hedra_submit_task', { params: taskRequest });
console.log('任务提交响应:', taskResponse);
if (taskResponse.data) {
setState(prev => ({
...prev,
task: {
status: 'processing',
taskId: taskResponse.data,
createdAt: new Date().toISOString()
}
}));
addNotification({
type: 'success',
title: '任务已提交',
message: `口型合成任务已开始处理任务ID${taskResponse.data}`
});
// 开始轮询任务状态
pollTaskStatus(taskResponse.data);
} else {
throw new Error('任务提交失败未获取到任务ID');
}
} catch (error) {
console.error('口型合成任务失败:', error);
setState(prev => ({
...prev,
task: {
status: 'failed',
errorMessage: error instanceof Error ? error.message : '未知错误'
},
isProcessing: false
}));
addNotification({
type: 'error',
title: '任务失败',
message: error instanceof Error ? error.message : '口型合成任务失败'
});
}
}, [uploadFileToServer, addNotification, pollTaskStatus, state]);
// 重置状态
const resetState = useCallback(() => {
setState({
@@ -329,8 +340,7 @@ const HedraLipSyncTool: React.FC = () => {
isProcessing: false
});
if (imageInputRef.current) imageInputRef.current.value = '';
if (audioInputRef.current) audioInputRef.current.value = '';
// 重置完成不需要清理input元素
}, []);
// 下载结果
@@ -384,7 +394,7 @@ const HedraLipSyncTool: React.FC = () => {
<div className="space-y-4">
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-400 transition-colors cursor-pointer"
onClick={() => imageInputRef.current?.click()}
onClick={handleImageSelect}
>
{state.imageFile ? (
<div className="space-y-3">
@@ -392,7 +402,7 @@ const HedraLipSyncTool: React.FC = () => {
<div>
<p className="font-medium text-gray-900">{state.imageFile.file.name}</p>
<p className="text-sm text-gray-500">
{(state.imageFile.file.size / 1024 / 1024).toFixed(2)} MB
{state.imageFile.filePath}
</p>
</div>
{state.imageFile.uploadStatus === 'uploading' && (
@@ -418,14 +428,6 @@ const HedraLipSyncTool: React.FC = () => {
</div>
)}
</div>
<input
ref={imageInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="hidden"
/>
</div>
</div>
@@ -439,7 +441,7 @@ const HedraLipSyncTool: React.FC = () => {
<div className="space-y-4">
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-green-400 transition-colors cursor-pointer"
onClick={() => audioInputRef.current?.click()}
onClick={handleAudioSelect}
>
{state.audioFile ? (
<div className="space-y-3">
@@ -473,14 +475,6 @@ const HedraLipSyncTool: React.FC = () => {
</div>
)}
</div>
<input
ref={audioInputRef}
type="file"
accept="audio/*"
onChange={handleAudioSelect}
className="hidden"
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,541 @@
import React, { useState, useCallback } from 'react';
import {
MessageCircle,
Image,
Music,
Play,
Download,
CheckCircle,
XCircle,
Loader2,
AlertCircle,
FileImage,
FileAudio
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { useNotifications } from '../../components/NotificationSystem';
import {
HedraTaskSubmitRequest,
HedraTaskStatusParams,
TaskResponse,
TaskStatusResponse
} from '../../types/bowongTextVideoAgent';
// 简化的状态接口
interface SimpleHedraState {
selectedImage: { path: string; name: string } | null;
selectedAudio: { path: string; name: string } | null;
prompt: string;
resolution: '720p' | '540p';
aspectRatio: '1:1' | '16:9' | '9:16';
task: {
status: 'idle' | 'submitting' | 'processing' | 'completed' | 'failed';
taskId?: string;
result?: any;
error?: string;
progress?: number;
};
isProcessing: boolean;
}
/**
* Hedra 口型合成工具 - 简化版
* 直接使用本地文件路径提交任务,无需上传
*/
const SimpleHedraLipSyncTool: React.FC = () => {
const { addNotification } = useNotifications();
// 组件状态
const [state, setState] = useState<SimpleHedraState>({
selectedImage: null,
selectedAudio: null,
prompt: '',
resolution: '720p',
aspectRatio: '1:1',
task: { status: 'idle' },
isProcessing: false
});
// 选择图片文件
const handleImageSelect = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{
name: 'Image',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']
}]
});
if (selected) {
setState(prev => ({
...prev,
selectedImage: {
path: selected as string,
name: (selected as string).split('\\').pop() || (selected as string).split('/').pop() || 'unknown'
}
}));
addNotification({
type: 'success',
title: '图片已选择',
message: `已选择图片文件`
});
}
} catch (error) {
console.error('选择图片失败:', error);
addNotification({
type: 'error',
title: '选择图片失败',
message: error instanceof Error ? error.message : '未知错误'
});
}
}, [addNotification]);
// 选择音频文件
const handleAudioSelect = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{
name: 'Audio',
extensions: ['mp3', 'wav', 'aac', 'flac', 'm4a', 'ogg']
}]
});
if (selected) {
setState(prev => ({
...prev,
selectedAudio: {
path: selected as string,
name: (selected as string).split('\\').pop() || (selected as string).split('/').pop() || 'unknown'
}
}));
addNotification({
type: 'success',
title: '音频已选择',
message: `已选择音频文件`
});
}
} catch (error) {
console.error('选择音频失败:', error);
addNotification({
type: 'error',
title: '选择音频失败',
message: error instanceof Error ? error.message : '未知错误'
});
}
}, [addNotification]);
// 轮询任务状态
const pollTaskStatus = useCallback(async (taskId: string) => {
const maxAttempts = 60; // 最多轮询60次5分钟
let attempts = 0;
const poll = async () => {
try {
attempts++;
console.log(`轮询任务状态 (${attempts}/${maxAttempts}):`, taskId);
const params: HedraTaskStatusParams = { task_id: taskId };
const statusResponse: TaskStatusResponse = await invoke('hedra_query_task_status', { params });
console.log('任务状态响应:', statusResponse);
setState(prev => ({
...prev,
task: {
...prev.task,
progress: statusResponse.progress || 0
}
}));
// 检查任务状态
if (statusResponse.status === 'success') {
setState(prev => ({
...prev,
task: {
status: 'completed',
taskId,
result: statusResponse.result?.video_url || statusResponse.result?.url || statusResponse.result,
progress: 100
},
isProcessing: false
}));
addNotification({
type: 'success',
title: '口型合成完成',
message: '视频生成成功!'
});
return;
} else if (statusResponse.status === 'failed') {
setState(prev => ({
...prev,
task: {
status: 'failed',
taskId,
error: statusResponse.error || '任务执行失败'
},
isProcessing: false
}));
addNotification({
type: 'error',
title: '口型合成失败',
message: statusResponse.error || '任务执行失败'
});
return;
}
// 继续轮询
if (attempts < maxAttempts) {
setTimeout(poll, 5000); // 5秒后再次轮询
} else {
setState(prev => ({
...prev,
task: {
status: 'failed',
taskId,
error: '任务超时'
},
isProcessing: false
}));
addNotification({
type: 'error',
title: '任务超时',
message: '任务执行时间过长,请稍后手动查询'
});
}
} catch (error) {
console.error('查询任务状态失败:', error);
setState(prev => ({
...prev,
task: {
status: 'failed',
taskId,
error: error instanceof Error ? error.message : '查询状态失败'
},
isProcessing: false
}));
addNotification({
type: 'error',
title: '查询状态失败',
message: error instanceof Error ? error.message : '未知错误'
});
}
};
poll();
}, [addNotification]);
// 提交口型合成任务
const handleSubmitTask = useCallback(async () => {
if (!state.selectedImage || !state.selectedAudio) {
addNotification({
type: 'error',
title: '文件未选择',
message: '请先选择图片和音频文件'
});
return;
}
try {
setState(prev => ({
...prev,
isProcessing: true,
task: { status: 'submitting' }
}));
// 直接使用本地文件路径提交任务
const taskRequest: HedraTaskSubmitRequest = {
img_file: state.selectedImage.path,
audio_file: state.selectedAudio.path,
prompt: state.prompt,
resolution: state.resolution,
aspect_ratio: state.aspectRatio
};
const taskResponse: TaskResponse = await invoke('hedra_submit_task', { params: taskRequest });
if (taskResponse.data) {
setState(prev => ({
...prev,
task: {
status: 'processing',
taskId: taskResponse.data
}
}));
addNotification({
type: 'success',
title: '任务已提交',
message: `口型合成任务已开始处理任务ID${taskResponse.data}`
});
// 开始轮询任务状态
pollTaskStatus(taskResponse.data);
} else {
throw new Error('任务提交失败未获取到任务ID');
}
} catch (error) {
console.error('口型合成任务失败:', error);
setState(prev => ({
...prev,
task: { status: 'failed', error: error instanceof Error ? error.message : '未知错误' },
isProcessing: false
}));
addNotification({
type: 'error',
title: '任务提交失败',
message: error instanceof Error ? error.message : '未知错误'
});
}
}, [state.selectedImage, state.selectedAudio, state.prompt, state.resolution, state.aspectRatio, addNotification, pollTaskStatus]);
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
{/* 页面标题 */}
<div className="text-center">
<div className="flex items-center justify-center gap-3 mb-4">
<MessageCircle className="w-8 h-8 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Hedra </h1>
</div>
<p className="text-gray-600">
</p>
</div>
{/* 文件选择区域 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 图片选择 */}
<div className="bg-white rounded-lg border-2 border-dashed border-gray-300 p-6">
<div className="text-center">
<FileImage className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900"></h3>
<p className="mt-1 text-sm text-gray-500"> PNG, JPG, JPEG, GIF, BMP, WebP</p>
{state.selectedImage ? (
<div className="mt-4 p-3 bg-green-50 rounded-lg">
<div className="flex items-center justify-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-sm text-green-800">{state.selectedImage.name}</span>
</div>
</div>
) : (
<button
onClick={handleImageSelect}
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Image className="w-4 h-4 mr-2" />
</button>
)}
</div>
</div>
{/* 音频选择 */}
<div className="bg-white rounded-lg border-2 border-dashed border-gray-300 p-6">
<div className="text-center">
<FileAudio className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900"></h3>
<p className="mt-1 text-sm text-gray-500"> MP3, WAV, AAC, FLAC, M4A, OGG</p>
{state.selectedAudio ? (
<div className="mt-4 p-3 bg-green-50 rounded-lg">
<div className="flex items-center justify-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-sm text-green-800">{state.selectedAudio.name}</span>
</div>
</div>
) : (
<button
onClick={handleAudioSelect}
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Music className="w-4 h-4 mr-2" />
</button>
)}
</div>
</div>
</div>
{/* 参数配置区域 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4"></h3>
<div className="space-y-4">
{/* Prompt 输入 */}
<div>
<label htmlFor="prompt" className="block text-sm font-medium text-gray-700 mb-2">
()
</label>
<textarea
id="prompt"
value={state.prompt}
onChange={(e) => {
console.log('Prompt 输入变化:', e.target.value);
setState(prev => {
const newState = { ...prev, prompt: e.target.value };
console.log('更新后的状态:', newState);
return newState;
});
}}
placeholder="输入描述视频内容的提示词,例如:一个人在说话,表情自然..."
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={3}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 分辨率选择 */}
<div>
<label htmlFor="resolution" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
id="resolution"
value={state.resolution}
onChange={(e) => setState(prev => ({ ...prev, resolution: e.target.value as '720p' | '540p' }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="720p">720p ()</option>
<option value="540p">540p ()</option>
</select>
</div>
{/* 宽高比选择 */}
<div>
<label htmlFor="aspectRatio" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
id="aspectRatio"
value={state.aspectRatio}
onChange={(e) => setState(prev => ({ ...prev, aspectRatio: e.target.value as '1:1' | '16:9' | '9:16' }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="1:1">1:1 ()</option>
<option value="16:9">16:9 ()</option>
<option value="9:16">9:16 ()</option>
</select>
</div>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="text-center">
<button
onClick={handleSubmitTask}
disabled={!state.selectedImage || !state.selectedAudio || state.isProcessing}
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{state.isProcessing ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
...
</>
) : (
<>
<Play className="w-5 h-5 mr-2" />
</>
)}
</button>
</div>
{/* 任务状态显示 */}
{state.task.status !== 'idle' && (
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4"></h3>
<div className="space-y-4">
{/* 状态指示器 */}
<div className="flex items-center gap-3">
{state.task.status === 'submitting' && (
<>
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
<span className="text-blue-600">...</span>
</>
)}
{state.task.status === 'processing' && (
<>
<Loader2 className="w-5 h-5 text-yellow-600 animate-spin" />
<span className="text-yellow-600">...</span>
</>
)}
{state.task.status === 'completed' && (
<>
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-green-600"></span>
</>
)}
{state.task.status === 'failed' && (
<>
<XCircle className="w-5 h-5 text-red-600" />
<span className="text-red-600"></span>
</>
)}
</div>
{/* 任务ID */}
{state.task.taskId && (
<div className="text-sm text-gray-600">
ID: {state.task.taskId}
</div>
)}
{/* 进度条 */}
{state.task.progress !== undefined && state.task.status === 'processing' && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${state.task.progress}%` }}
></div>
</div>
)}
{/* 错误信息 */}
{state.task.error && (
<div className="p-3 bg-red-50 rounded-lg">
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
<span className="text-sm text-red-800">{state.task.error}</span>
</div>
</div>
)}
{/* 结果下载 */}
{state.task.status === 'completed' && state.task.result && (
<div className="p-4 bg-green-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-sm text-green-800"></span>
</div>
<a
href={Array.isArray(state.task.result) ? state.task.result[0] : state.task.result}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"
>
<Download className="w-4 h-4 mr-1" />
</a>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};
export default SimpleHedraLipSyncTool;

View File

@@ -335,7 +335,7 @@ export class BowongTextVideoAgentFastApiService implements BowongTextVideoAgentA
// 轮询任务状态
const finalStatus = await this.pollTaskStatus(
taskResponse.task_id,
taskResponse.data,
(taskId) => this.queryImageTaskStatus(taskId),
{
maxWaitTime,
@@ -374,8 +374,8 @@ export class BowongTextVideoAgentFastApiService implements BowongTextVideoAgentA
} else {
const taskResponse = await this.asyncGenerateVideo(request);
return {
task_id: taskResponse.task_id,
status: taskResponse.status,
task_id: taskResponse.data, // task_id is stored in the data field
status: taskResponse.status ? TaskStatus.PENDING : TaskStatus.FAILED, // Convert boolean to TaskStatus
};
}
}
@@ -389,7 +389,7 @@ export class BowongTextVideoAgentFastApiService implements BowongTextVideoAgentA
// 轮询任务状态
const finalStatus = await this.pollTaskStatus(
taskResponse.task_id,
taskResponse.data,
(taskId) => this.queryVideoTaskStatus(taskId),
{
maxWaitTime,
@@ -467,7 +467,7 @@ export class BowongTextVideoAgentFastApiService implements BowongTextVideoAgentA
});
const finalStatus = await this.pollTaskStatus(
taskResponse.task_id,
taskResponse.data,
(taskId) => this.ai302MJQueryTaskStatus({ task_id: taskId }),
{ maxWaitTime, pollInterval }
);
@@ -487,7 +487,7 @@ export class BowongTextVideoAgentFastApiService implements BowongTextVideoAgentA
const taskResponse = await this.ai302JMAsyncGenerateVideo(request);
const finalStatus = await this.pollTaskStatus(
taskResponse.task_id,
taskResponse.data,
(taskId) => this.ai302JMQueryVideoStatus(taskId),
{ maxWaitTime, pollInterval }
);
@@ -520,7 +520,7 @@ export class BowongTextVideoAgentFastApiService implements BowongTextVideoAgentA
const taskResponse = await this.ai302VEOAsyncSubmit(request);
const finalStatus = await this.pollTaskStatus(
taskResponse.task_id,
taskResponse.data,
(taskId) => this.ai302VEOGetTaskStatus({ task_id: taskId }),
{ maxWaitTime, pollInterval }
);

View File

@@ -1,305 +0,0 @@
/**
* BowongTextVideoAgent 服务集成测试
* 测试与实际 API 的交互(需要真实的服务端点)
*/
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import {
BowongTextVideoAgentFastApiService,
createBowongTextVideoAgentService,
} from '../services/bowongTextVideoAgentService';
import {
BowongTextVideoAgentConfig,
TaskStatus,
} from '../types/bowongTextVideoAgent';
// 集成测试配置
const INTEGRATION_CONFIG: BowongTextVideoAgentConfig = {
baseUrl: process.env.BOWONG_API_BASE_URL || 'http://localhost:8000',
apiKey: process.env.BOWONG_API_KEY || 'test-api-key',
timeout: 30000,
retryAttempts: 2,
retryDelay: 1000,
};
// 跳过集成测试的条件
const SKIP_INTEGRATION = process.env.SKIP_INTEGRATION_TESTS === 'true';
describe.skipIf(SKIP_INTEGRATION)('BowongTextVideoAgent 集成测试', () => {
let service: BowongTextVideoAgentFastApiService;
beforeAll(() => {
service = createBowongTextVideoAgentService(INTEGRATION_CONFIG);
});
beforeEach(async () => {
// 等待一段时间避免 API 限流
await new Promise(resolve => setTimeout(resolve, 1000));
});
describe('服务连接测试', () => {
it('应该能够连接到服务', async () => {
const isConnected = await service.testConnection();
expect(isConnected).toBe(true);
}, 10000);
it('健康检查应该返回正常状态', async () => {
const promptHealth = await service.checkPromptHealth();
expect(promptHealth.status).toBe(true);
const fileHealth = await service.checkFileHealth();
expect(fileHealth.status).toBe(true);
}, 10000);
});
describe('提示词预处理模块', () => {
it('应该能够获取示例提示词', async () => {
const result = await service.getSamplePrompt({ task_type: 'video' });
expect(result).toBeDefined();
}, 10000);
it('应该能够检查提示词', async () => {
const result = await service.checkPrompt({ prompt: 'a beautiful landscape' });
expect(result.status).toBe(true);
}, 10000);
});
describe('文件操作模块', () => {
it('应该能够上传文件', async () => {
// 创建测试文件
const testContent = 'This is a test file for integration testing';
const testFile = new File([testContent], 'test.txt', { type: 'text/plain' });
const result = await service.uploadFile({ file: testFile });
expect(result.status).toBe(true);
expect(result.data).toBeDefined();
}, 15000);
it('应该能够上传文件到 S3', async () => {
const testContent = 'This is a test file for S3 upload';
const testFile = new File([testContent], 'test-s3.txt', { type: 'text/plain' });
const result = await service.uploadFileToS3({ file: testFile });
expect(result.status).toBe(true);
expect(result.data).toBeDefined();
}, 15000);
});
describe('视频模板管理模块', () => {
it('应该能够获取模板列表', async () => {
const result = await service.getTemplates({ page: 1, page_size: 5 });
expect(result.status).toBe(true);
expect(Array.isArray(result.data)).toBe(true);
expect(result.page).toBe(1);
expect(result.page_size).toBe(5);
}, 10000);
it('应该能够检查任务类型', async () => {
const result = await service.checkTaskType({ task_type: 'video' });
expect(result.status).toBe(true);
}, 10000);
it('应该能够创建和删除模板', async () => {
const templateData = {
prompt: 'Integration test template',
cover_url: 'https://example.com/cover.jpg',
video_url: 'https://example.com/video.mp4',
description: 'Test template for integration testing',
detailDescription: 'Detailed description for integration test template',
title_zh: '集成测试模板',
aspect_ratio: '16:9',
engine: 'test-engine',
presetPrompts: 'test preset prompts',
task_type: 'video',
};
// 创建模板
const createResult = await service.createTemplate(templateData);
expect(createResult.status).toBe(true);
expect(createResult.data?.id).toBeDefined();
const templateId = createResult.data!.id!;
// 删除模板
const deleteResult = await service.deleteTemplate(templateId);
expect(deleteResult.status).toBe(true);
}, 20000);
});
describe('任务管理模块', () => {
it('应该能够创建任务', async () => {
const taskRequest = {
task_type: 'test',
prompt: 'Integration test task',
ar: '16:9',
};
const result = await service.createTask(taskRequest);
expect(result.task_id).toBeDefined();
expect(Object.values(TaskStatus)).toContain(result.status);
}, 15000);
it('应该能够查询任务状态', async () => {
// 首先创建一个任务
const taskRequest = {
task_type: 'test',
prompt: 'Status query test task',
};
const createResult = await service.createTask(taskRequest);
const taskId = createResult.task_id;
// 查询任务状态
const statusResult = await service.getTaskStatus(taskId);
expect(statusResult.task_id).toBe(taskId);
expect(Object.values(TaskStatus)).toContain(statusResult.status);
}, 20000);
});
describe('Midjourney 图片生成模块', () => {
it('应该能够异步生成图片', async () => {
const request = {
prompt: 'a beautiful sunset over mountains, digital art',
};
const result = await service.asyncGenerateImage(request);
expect(result.task_id).toBeDefined();
expect(Object.values(TaskStatus)).toContain(result.status);
}, 15000);
it('应该能够查询图片生成任务状态', async () => {
const request = {
prompt: 'a serene lake with reflection, oil painting style',
};
const createResult = await service.asyncGenerateImage(request);
const taskId = createResult.task_id;
const statusResult = await service.queryImageTaskStatus(taskId);
expect(statusResult.task_id).toBe(taskId);
expect(Object.values(TaskStatus)).toContain(statusResult.status);
}, 20000);
});
describe('极梦视频生成模块', () => {
it('应该能够异步生成视频', async () => {
const request = {
prompt: 'a flowing river through a forest, cinematic style',
duration: 5,
model_type: 'lite' as const,
};
const result = await service.asyncGenerateVideo(request);
expect(result.task_id).toBeDefined();
expect(Object.values(TaskStatus)).toContain(result.status);
}, 15000);
it('应该能够查询视频生成任务状态', async () => {
const request = {
prompt: 'clouds moving across the sky, time-lapse style',
duration: 3,
};
const createResult = await service.asyncGenerateVideo(request);
const taskId = createResult.task_id;
const statusResult = await service.queryVideoTaskStatus(taskId);
expect(statusResult.task_id).toBe(taskId);
expect(Object.values(TaskStatus)).toContain(statusResult.status);
}, 20000);
it('应该能够批量查询视频任务状态', async () => {
// 创建多个视频生成任务
const requests = [
{ prompt: 'ocean waves, peaceful scene', duration: 3 },
{ prompt: 'mountain landscape, aerial view', duration: 3 },
];
const taskIds: string[] = [];
for (const request of requests) {
const result = await service.asyncGenerateVideo(request);
taskIds.push(result.task_id);
}
// 批量查询状态
const batchResult = await service.batchQueryVideoStatus({ job_ids: taskIds });
expect(batchResult.status).toBe(true);
expect(batchResult.data).toBeDefined();
}, 30000);
});
describe('聚合接口模块', () => {
it('应该能够获取图片模型列表', async () => {
const result = await service.getImageModelList();
expect(Array.isArray(result.models)).toBe(true);
expect(result.models.length).toBeGreaterThan(0);
}, 10000);
it('应该能够获取视频模型列表', async () => {
const result = await service.getVideoModelList();
expect(Array.isArray(result.models)).toBe(true);
expect(result.models.length).toBeGreaterThan(0);
}, 10000);
});
describe('海螺API模块', () => {
it('应该能够获取音色列表', async () => {
const result = await service.getVoices();
expect(Array.isArray(result.voices)).toBe(true);
}, 10000);
it('应该能够生成语音', async () => {
const request = {
text: '这是一个集成测试的语音合成示例',
voice_id: 'default',
speed: 1.0,
vol: 1.0,
};
const result = await service.generateSpeech(request);
expect(result.status).toBe(true);
}, 15000);
});
describe('错误处理和重试机制', () => {
it('应该正确处理无效的提示词', async () => {
try {
await service.checkPrompt({ prompt: '' });
} catch (error: any) {
expect(error.name).toMatch(/ValidationError|BowongTextVideoAgentError/);
}
}, 10000);
it('应该正确处理不存在的任务ID', async () => {
try {
await service.getTaskStatus('non-existent-task-id');
} catch (error: any) {
expect(error.name).toMatch(/TaskFailedError|BowongTextVideoAgentError/);
}
}, 10000);
});
describe('性能测试', () => {
it('并发请求应该正常处理', async () => {
const promises = Array.from({ length: 5 }, (_, i) =>
service.getSamplePrompt({ task_type: `test-${i}` })
);
const results = await Promise.allSettled(promises);
const successCount = results.filter(r => r.status === 'fulfilled').length;
// 至少有一半的请求应该成功
expect(successCount).toBeGreaterThanOrEqual(Math.floor(promises.length / 2));
}, 30000);
it('大文件上传应该正常处理', async () => {
// 创建一个较大的测试文件 (1MB)
const largeContent = 'x'.repeat(1024 * 1024);
const largeFile = new File([largeContent], 'large-test.txt', { type: 'text/plain' });
const result = await service.uploadFile({ file: largeFile });
expect(result.status).toBe(true);
expect(result.data).toBeDefined();
}, 60000);
});
});

View File

@@ -255,9 +255,9 @@ export interface VideoRequest {
* 任务响应
*/
export interface TaskResponse {
task_id: string;
status: TaskStatus;
[key: string]: any;
status: boolean;
data: string; // 任务ID存储在这里
msg: string;
}
/**
@@ -553,8 +553,12 @@ export interface HedraFileUploadRequest {
* Hedra 任务提交请求
*/
export interface HedraTaskSubmitRequest {
img_file: File;
audio_file: File;
audio_file: string;
img_file: string;
prompt?: string;
resolution?: '720p' | '540p';
aspect_ratio?: '1:1' | '16:9' | '9:16';
params?: Record<string, any>;
}
/**

View File

@@ -5,7 +5,8 @@
export interface HedraFileInfo {
file: File;
url?: string;
filePath?: string; // 本地文件路径
url?: string; // 上传后的URL
uploadStatus: 'idle' | 'uploading' | 'uploaded' | 'error';
uploadProgress?: number;
errorMessage?: string;