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:
@@ -1,5 +1,5 @@
|
||||
use rusqlite::{Connection, Result};
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// 获取数据库路径
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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 模块结构完整");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
//! 集成测试模块
|
||||
//!
|
||||
//! 这个模块包含了各种集成测试,用于验证整个应用的功能和架构。
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod bowong_text_video_agent_integration_test;
|
||||
@@ -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!())
|
||||
|
||||
@@ -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(¶ms)
|
||||
println!("=== 调用服务方法 ===");
|
||||
let result = service.hedra_submit_task(¶ms)
|
||||
.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 查询任务状态
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
541
apps/desktop/src/pages/tools/SimpleHedraLipSyncTool.tsx
Normal file
541
apps/desktop/src/pages/tools/SimpleHedraLipSyncTool.tsx
Normal 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;
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user