diff --git a/apps/desktop/src-tauri/src/bin/check_db.rs b/apps/desktop/src-tauri/src/bin/check_db.rs index ac90ce0..8453144 100644 --- a/apps/desktop/src-tauri/src/bin/check_db.rs +++ b/apps/desktop/src-tauri/src/bin/check_db.rs @@ -1,5 +1,5 @@ use rusqlite::{Connection, Result}; -use std::path::PathBuf; + fn main() -> Result<()> { // 获取数据库路径 diff --git a/apps/desktop/src-tauri/src/data/models/bowong_text_video_agent.rs b/apps/desktop/src-tauri/src/data/models/bowong_text_video_agent.rs index c59dcd8..7ee08d2 100644 --- a/apps/desktop/src-tauri/src/data/models/bowong_text_video_agent.rs +++ b/apps/desktop/src-tauri/src/data/models/bowong_text_video_agent.rs @@ -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, pub result: Option, + #[serde(deserialize_with = "deserialize_optional_string_or_bool", default)] pub error: Option, + #[serde(deserialize_with = "deserialize_optional_string_or_bool", default)] pub created_at: Option, + #[serde(deserialize_with = "deserialize_optional_string_or_bool", default)] pub updated_at: Option, } /// 文件上传响应 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileUploadResponse { - pub file_url: String, - pub file_id: Option, - pub message: String, + pub status: bool, + pub msg: String, + pub data: Option, // 文件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>, } +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, + 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) { + // 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::().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::().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 +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(self, value: &str) -> Result + where + E: de::Error, + { + Ok(value.to_string()) + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + Ok(value) + } + + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + Ok(value.to_string()) + } + } + + deserializer.deserialize_any(StringOrBoolVisitor) +} + +/// 自定义反序列化函数,处理可选的字符串或布尔值 +fn deserialize_optional_string_or_bool<'de, D>(deserializer: D) -> Result, 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; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an optional string or boolean") + } + + fn visit_none(self) -> Result, E> + where + E: de::Error, + { + Ok(None) + } + + fn visit_some(self, deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + deserialize_string_or_bool(deserializer).map(Some) + } + + fn visit_str(self, value: &str) -> Result, E> + where + E: de::Error, + { + Ok(Some(value.to_string())) + } + + fn visit_string(self, value: String) -> Result, E> + where + E: de::Error, + { + Ok(Some(value)) + } + + fn visit_bool(self, value: bool) -> Result, E> + where + E: de::Error, + { + Ok(Some(value.to_string())) + } + } + + deserializer.deserialize_option(OptionalStringOrBoolVisitor) +} diff --git a/apps/desktop/src-tauri/src/infrastructure/bowong_text_video_agent_service.rs b/apps/desktop/src-tauri/src/infrastructure/bowong_text_video_agent_service.rs index 0610141..d6ba92f 100644 --- a/apps/desktop/src-tauri/src/infrastructure/bowong_text_video_agent_service.rs +++ b/apps/desktop/src-tauri/src/infrastructure/bowong_text_video_agent_service.rs @@ -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::("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 { - 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 { - 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) } // ============================================================================ diff --git a/apps/desktop/src-tauri/src/integration_tests/bowong_text_video_agent_integration_test.rs b/apps/desktop/src-tauri/src/integration_tests/bowong_text_video_agent_integration_test.rs deleted file mode 100644 index f1b650d..0000000 --- a/apps/desktop/src-tauri/src/integration_tests/bowong_text_video_agent_integration_test.rs +++ /dev/null @@ -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 模块结构完整"); - } -} diff --git a/apps/desktop/src-tauri/src/integration_tests/mod.rs b/apps/desktop/src-tauri/src/integration_tests/mod.rs deleted file mode 100644 index f0e86b6..0000000 --- a/apps/desktop/src-tauri/src/integration_tests/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! 集成测试模块 -//! -//! 这个模块包含了各种集成测试,用于验证整个应用的功能和架构。 - -#[cfg(test)] -pub mod bowong_text_video_agent_integration_test; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e37d7f6..2234de1 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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!()) diff --git a/apps/desktop/src-tauri/src/presentation/commands/bowong_text_video_agent_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/bowong_text_video_agent_commands.rs index 14be3cd..5ae5993 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/bowong_text_video_agent_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/bowong_text_video_agent_commands.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use tokio::sync::RwLock; /// BowongTextVideoAgent 服务的全局状态 -type BowongServiceState = Arc>>; + /// 初始化 BowongTextVideoAgent 服务 #[command] @@ -59,9 +59,9 @@ pub async fn bowong_upload_file_to_s3( ) -> Result { // 实际实现需要调用服务 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { + 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 查询任务状态 diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 90e3a0e..d204917 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/desktop/src/data/tools.ts b/apps/desktop/src/data/tools.ts index ff7aaa8..a10ff3d 100644 --- a/apps/desktop/src/data/tools.ts +++ b/apps/desktop/src/data/tools.ts @@ -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' } diff --git a/apps/desktop/src/pages/tools/HedraLipSyncTool.tsx b/apps/desktop/src/pages/tools/HedraLipSyncTool.tsx index f01389f..4f18d39 100644 --- a/apps/desktop/src/pages/tools/HedraLipSyncTool.tsx +++ b/apps/desktop/src/pages/tools/HedraLipSyncTool.tsx @@ -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(null); - const audioInputRef = useRef(null); - - // 创建服务实例 - const bowongService = createBowongTextVideoAgentService({ - baseUrl: 'http://localhost:8000', // 这里应该从配置中获取 - timeout: 30000 - }); // 主要状态 const [state, setState] = useState({ @@ -48,23 +42,26 @@ const HedraLipSyncTool: React.FC = () => { isProcessing: false }); - // 文件选择处理 - const handleImageSelect = useCallback((event: React.ChangeEvent) => { - 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) => { - 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 => { + // 上传已选择的文件到服务器 + const uploadFileToServer = useCallback(async (filePath: string, purpose: 'image' | 'audio'): Promise => { 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 = () => {
imageInputRef.current?.click()} + onClick={handleImageSelect} > {state.imageFile ? (
@@ -392,7 +402,7 @@ const HedraLipSyncTool: React.FC = () => {

{state.imageFile.file.name}

- {(state.imageFile.file.size / 1024 / 1024).toFixed(2)} MB + {state.imageFile.filePath}

{state.imageFile.uploadStatus === 'uploading' && ( @@ -418,14 +428,6 @@ const HedraLipSyncTool: React.FC = () => {
)}
- -
@@ -439,7 +441,7 @@ const HedraLipSyncTool: React.FC = () => {
audioInputRef.current?.click()} + onClick={handleAudioSelect} > {state.audioFile ? (
@@ -473,14 +475,6 @@ const HedraLipSyncTool: React.FC = () => {
)}
- -
diff --git a/apps/desktop/src/pages/tools/SimpleHedraLipSyncTool.tsx b/apps/desktop/src/pages/tools/SimpleHedraLipSyncTool.tsx new file mode 100644 index 0000000..fa584be --- /dev/null +++ b/apps/desktop/src/pages/tools/SimpleHedraLipSyncTool.tsx @@ -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({ + 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 ( +
+ {/* 页面标题 */} +
+
+ +

Hedra 口型合成工具

+
+

+ 上传图片和音频文件,生成口型同步视频 +

+
+ + {/* 文件选择区域 */} +
+ {/* 图片选择 */} +
+
+ +

选择图片

+

支持 PNG, JPG, JPEG, GIF, BMP, WebP

+ + {state.selectedImage ? ( +
+
+ + {state.selectedImage.name} +
+
+ ) : ( + + )} +
+
+ + {/* 音频选择 */} +
+
+ +

选择音频

+

支持 MP3, WAV, AAC, FLAC, M4A, OGG

+ + {state.selectedAudio ? ( +
+
+ + {state.selectedAudio.name} +
+
+ ) : ( + + )} +
+
+
+ + {/* 参数配置区域 */} +
+

生成参数

+ +
+ {/* Prompt 输入 */} +
+ +