feat: 实现 Hedra 口型合成异步化改造
- 将 Hedra 口型合成任务改为异步处理模式 - 添加完整的数据模型和仓储层支持 - 实现后台任务轮询和实时进度通知 - 创建 HedraLipSyncRecords 页面显示任务列表 - 将原有功能封装为 Modal 组件 - 支持多任务并发处理和状态跟踪 - 添加事件驱动的前端状态更新机制 主要变更: - 新增 HedraLipSyncRecord 数据模型 - 新增 HedraLipSyncRepository 仓储层 - 新增 HedraLipSyncModal 组件 - 新增 HedraLipSyncRecords 页面 - 修改 bowong_text_video_agent_commands 支持异步处理 - 添加事件总线支持 Hedra 任务进度通知 - 更新路由配置和工具列表
This commit is contained in:
@@ -6,11 +6,13 @@ use crate::data::repositories::model_dynamic_repository::ModelDynamicRepository;
|
||||
use crate::data::repositories::video_generation_repository::VideoGenerationRepository;
|
||||
use crate::data::repositories::conversation_repository::ConversationRepository;
|
||||
use crate::data::repositories::outfit_photo_generation_repository::OutfitPhotoGenerationRepository;
|
||||
use crate::data::repositories::hedra_lipsync_repository::HedraLipSyncRepository;
|
||||
use crate::infrastructure::database::Database;
|
||||
use crate::infrastructure::performance::PerformanceMonitor;
|
||||
use crate::infrastructure::event_bus::EventBusManager;
|
||||
use crate::infrastructure::bowong_text_video_agent_service::BowongTextVideoAgentService;
|
||||
|
||||
|
||||
/// 应用全局状态管理
|
||||
/// 遵循 Tauri 开发规范的状态管理模式
|
||||
pub struct AppState {
|
||||
@@ -22,9 +24,11 @@ pub struct AppState {
|
||||
pub video_generation_repository: Mutex<Option<VideoGenerationRepository>>,
|
||||
pub conversation_repository: Mutex<Option<Arc<ConversationRepository>>>,
|
||||
pub outfit_photo_generation_repository: Mutex<Option<OutfitPhotoGenerationRepository>>,
|
||||
pub hedra_lipsync_repository: Mutex<Option<Arc<HedraLipSyncRepository>>>,
|
||||
pub performance_monitor: Mutex<PerformanceMonitor>,
|
||||
pub event_bus_manager: Arc<EventBusManager>,
|
||||
pub bowong_text_video_agent_service: Mutex<Option<BowongTextVideoAgentService>>,
|
||||
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -38,9 +42,11 @@ impl AppState {
|
||||
video_generation_repository: Mutex::new(None),
|
||||
conversation_repository: Mutex::new(None),
|
||||
outfit_photo_generation_repository: Mutex::new(None),
|
||||
hedra_lipsync_repository: Mutex::new(None),
|
||||
performance_monitor: Mutex::new(PerformanceMonitor::new()),
|
||||
event_bus_manager: Arc::new(EventBusManager::new()),
|
||||
bowong_text_video_agent_service: Mutex::new(None),
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +94,11 @@ impl AppState {
|
||||
// 初始化穿搭照片生成相关表
|
||||
let outfit_photo_generation_repository = OutfitPhotoGenerationRepository::new(database.clone())?;
|
||||
|
||||
// 初始化Hedra口型合成记录表
|
||||
let hedra_lipsync_repository = crate::data::repositories::hedra_lipsync_repository::HedraLipSyncRepository::new(database.clone());
|
||||
hedra_lipsync_repository.init_tables()?;
|
||||
let hedra_lipsync_repository = Arc::new(hedra_lipsync_repository);
|
||||
|
||||
*self.database.lock().unwrap() = Some(database.clone());
|
||||
*self.project_repository.lock().unwrap() = Some(project_repository);
|
||||
*self.material_repository.lock().unwrap() = Some(material_repository);
|
||||
@@ -96,6 +107,7 @@ impl AppState {
|
||||
*self.video_generation_repository.lock().unwrap() = Some(video_generation_repository);
|
||||
*self.conversation_repository.lock().unwrap() = Some(conversation_repository);
|
||||
*self.outfit_photo_generation_repository.lock().unwrap() = Some(outfit_photo_generation_repository);
|
||||
*self.hedra_lipsync_repository.lock().unwrap() = Some(hedra_lipsync_repository);
|
||||
|
||||
println!("数据库初始化完成,连接池状态: {}",
|
||||
if database.has_pool() { "已启用" } else { "未启用" });
|
||||
@@ -125,6 +137,11 @@ impl AppState {
|
||||
// 初始化穿搭照片生成相关表
|
||||
let outfit_photo_generation_repository = OutfitPhotoGenerationRepository::new(database.clone())?;
|
||||
|
||||
// 初始化Hedra口型合成记录表
|
||||
let hedra_lipsync_repository = crate::data::repositories::hedra_lipsync_repository::HedraLipSyncRepository::new(database.clone());
|
||||
hedra_lipsync_repository.init_tables()?;
|
||||
let hedra_lipsync_repository = Arc::new(hedra_lipsync_repository);
|
||||
|
||||
*self.database.lock().unwrap() = Some(database.clone());
|
||||
*self.project_repository.lock().unwrap() = Some(project_repository);
|
||||
*self.material_repository.lock().unwrap() = Some(material_repository);
|
||||
@@ -133,6 +150,7 @@ impl AppState {
|
||||
*self.video_generation_repository.lock().unwrap() = Some(video_generation_repository);
|
||||
*self.conversation_repository.lock().unwrap() = Some(conversation_repository);
|
||||
*self.outfit_photo_generation_repository.lock().unwrap() = Some(outfit_photo_generation_repository);
|
||||
*self.hedra_lipsync_repository.lock().unwrap() = Some(hedra_lipsync_repository);
|
||||
|
||||
println!("数据库初始化完成,使用单连接模式");
|
||||
Ok(())
|
||||
@@ -201,9 +219,11 @@ impl AppState {
|
||||
video_generation_repository: Mutex::new(None),
|
||||
conversation_repository: Mutex::new(None),
|
||||
outfit_photo_generation_repository: Mutex::new(None),
|
||||
hedra_lipsync_repository: Mutex::new(None),
|
||||
performance_monitor: Mutex::new(PerformanceMonitor::new()),
|
||||
event_bus_manager: Arc::new(EventBusManager::new()),
|
||||
bowong_text_video_agent_service: Mutex::new(None),
|
||||
|
||||
};
|
||||
// 不直接存储database,而是在需要时返回传入的database
|
||||
state
|
||||
@@ -227,6 +247,10 @@ impl AppState {
|
||||
self.bowong_text_video_agent_service.lock()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to acquire lock for BowongTextVideoAgent service: {}", e))
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{info, debug, error};
|
||||
|
||||
use crate::data::models::hedra_lipsync_record::HedraLipSyncStatus;
|
||||
use crate::data::repositories::hedra_lipsync_repository::HedraLipSyncRepository;
|
||||
use crate::infrastructure::bowong_text_video_agent_service::BowongTextVideoAgentService;
|
||||
use crate::infrastructure::event_bus::EventBusManager;
|
||||
use crate::data::models::bowong_text_video_agent::HedraTaskStatusParams;
|
||||
|
||||
/// Hedra 任务轮询器
|
||||
/// 负责在后台轮询 Hedra 任务状态并发送事件通知
|
||||
pub struct HedraTaskPoller {
|
||||
/// 正在轮询的任务
|
||||
polling_tasks: Arc<Mutex<HashMap<String, PollingTask>>>,
|
||||
/// BowongTextVideoAgent 服务
|
||||
service: Arc<BowongTextVideoAgentService>,
|
||||
/// 事件总线管理器
|
||||
event_bus: Arc<EventBusManager>,
|
||||
/// 数据库仓库
|
||||
repository: Arc<HedraLipSyncRepository>,
|
||||
/// 是否正在运行
|
||||
is_running: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
/// 轮询任务信息
|
||||
#[derive(Debug, Clone)]
|
||||
struct PollingTask {
|
||||
task_id: String,
|
||||
record_id: String,
|
||||
start_time: std::time::Instant,
|
||||
poll_count: u32,
|
||||
max_polls: u32,
|
||||
}
|
||||
|
||||
impl HedraTaskPoller {
|
||||
/// 创建新的 Hedra 任务轮询器
|
||||
pub fn new(
|
||||
service: Arc<BowongTextVideoAgentService>,
|
||||
event_bus: Arc<EventBusManager>,
|
||||
repository: Arc<HedraLipSyncRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
polling_tasks: Arc::new(Mutex::new(HashMap::new())),
|
||||
service,
|
||||
event_bus,
|
||||
repository,
|
||||
is_running: Arc::new(Mutex::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加任务到轮询队列
|
||||
pub fn add_task(&self, task_id: String, record_id: String) -> Result<()> {
|
||||
let mut tasks = self.polling_tasks.lock().unwrap();
|
||||
|
||||
let polling_task = PollingTask {
|
||||
task_id: task_id.clone(),
|
||||
record_id: record_id.clone(),
|
||||
start_time: std::time::Instant::now(),
|
||||
poll_count: 0,
|
||||
max_polls: 360, // 30分钟 (5秒间隔 * 360次)
|
||||
};
|
||||
|
||||
tasks.insert(task_id.clone(), polling_task);
|
||||
info!("添加 Hedra 任务到轮询队列: task_id={}, record_id={}", task_id, record_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 移除轮询任务
|
||||
pub fn remove_task(&self, task_id: &str) {
|
||||
let mut tasks = self.polling_tasks.lock().unwrap();
|
||||
if tasks.remove(task_id).is_some() {
|
||||
debug!("从轮询队列移除任务: {}", task_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动轮询器
|
||||
pub async fn start(&self) -> Result<()> {
|
||||
{
|
||||
let mut running = self.is_running.lock().unwrap();
|
||||
if *running {
|
||||
return Ok(());
|
||||
}
|
||||
*running = true;
|
||||
}
|
||||
|
||||
info!("启动 Hedra 任务轮询器");
|
||||
|
||||
// 启动轮询循环
|
||||
let polling_tasks = Arc::clone(&self.polling_tasks);
|
||||
let service = Arc::clone(&self.service);
|
||||
let event_bus = Arc::clone(&self.event_bus);
|
||||
let repository = Arc::clone(&self.repository);
|
||||
let is_running = Arc::clone(&self.is_running);
|
||||
|
||||
tokio::spawn(async move {
|
||||
while *is_running.lock().unwrap() {
|
||||
// 获取当前需要轮询的任务
|
||||
let tasks_to_poll: Vec<PollingTask> = {
|
||||
let tasks = polling_tasks.lock().unwrap();
|
||||
tasks.values().cloned().collect()
|
||||
};
|
||||
|
||||
// 轮询每个任务
|
||||
for mut task in tasks_to_poll {
|
||||
if let Err(e) = Self::poll_single_task(
|
||||
&mut task,
|
||||
&service,
|
||||
&event_bus,
|
||||
&repository,
|
||||
&polling_tasks,
|
||||
).await {
|
||||
error!("轮询任务 {} 时出错: {}", task.task_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 等待 5 秒后继续下一轮轮询
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
|
||||
info!("Hedra 任务轮询器已停止");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 停止轮询器
|
||||
pub fn stop(&self) {
|
||||
let mut running = self.is_running.lock().unwrap();
|
||||
*running = false;
|
||||
info!("停止 Hedra 任务轮询器");
|
||||
}
|
||||
|
||||
/// 轮询单个任务
|
||||
async fn poll_single_task(
|
||||
task: &mut PollingTask,
|
||||
service: &BowongTextVideoAgentService,
|
||||
event_bus: &EventBusManager,
|
||||
repository: &HedraLipSyncRepository,
|
||||
polling_tasks: &Arc<Mutex<HashMap<String, PollingTask>>>,
|
||||
) -> Result<()> {
|
||||
task.poll_count += 1;
|
||||
|
||||
debug!("轮询 Hedra 任务: {} (第 {} 次)", task.task_id, task.poll_count);
|
||||
|
||||
// 检查是否超过最大轮询次数
|
||||
if task.poll_count > task.max_polls {
|
||||
error!("任务 {} 轮询超时,移除任务", task.task_id);
|
||||
|
||||
// 发送超时事件
|
||||
let _ = event_bus.publish_hedra_task_progress(
|
||||
task.task_id.clone(),
|
||||
task.record_id.clone(),
|
||||
"failed".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some("任务轮询超时".to_string()),
|
||||
).await;
|
||||
|
||||
// 更新数据库状态
|
||||
let _ = repository.update_status_and_error(
|
||||
&task.record_id,
|
||||
HedraLipSyncStatus::Failed,
|
||||
Some("任务轮询超时".to_string()),
|
||||
);
|
||||
|
||||
// 从轮询队列移除
|
||||
polling_tasks.lock().unwrap().remove(&task.task_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 查询任务状态
|
||||
let status_params = HedraTaskStatusParams {
|
||||
task_id: task.task_id.clone(),
|
||||
};
|
||||
|
||||
match service.hedra_query_task_status(&status_params).await {
|
||||
Ok(status_response) => {
|
||||
debug!("任务 {} 状态: {}", task.task_id, status_response.status);
|
||||
|
||||
// 映射状态
|
||||
let mapped_status = match status_response.status.as_str() {
|
||||
"success" => "completed",
|
||||
"running" => "processing",
|
||||
"failed" => "failed",
|
||||
"cancelled" => "cancelled",
|
||||
"pending" | _ => "pending",
|
||||
};
|
||||
|
||||
// 提取视频URL
|
||||
let video_url = if status_response.status == "success" {
|
||||
status_response.result
|
||||
.as_ref()
|
||||
.and_then(|result| {
|
||||
result.get("video_url")
|
||||
.or_else(|| result.get("url"))
|
||||
.or_else(|| result.get("video"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 发送进度事件
|
||||
let _ = event_bus.publish_hedra_task_progress(
|
||||
task.task_id.clone(),
|
||||
task.record_id.clone(),
|
||||
mapped_status.to_string(),
|
||||
status_response.progress.map(|p| p as f64),
|
||||
video_url.clone(),
|
||||
status_response.error.clone(),
|
||||
).await;
|
||||
|
||||
// 更新数据库
|
||||
let db_status = match mapped_status {
|
||||
"completed" => HedraLipSyncStatus::Completed,
|
||||
"processing" => HedraLipSyncStatus::Processing,
|
||||
"failed" => HedraLipSyncStatus::Failed,
|
||||
"cancelled" => HedraLipSyncStatus::Cancelled,
|
||||
_ => HedraLipSyncStatus::Pending,
|
||||
};
|
||||
|
||||
let _ = repository.update_progress_and_result(
|
||||
&task.record_id,
|
||||
db_status,
|
||||
status_response.progress.unwrap_or(0.0) as i32,
|
||||
video_url,
|
||||
status_response.error,
|
||||
);
|
||||
|
||||
// 如果任务完成或失败,从轮询队列移除
|
||||
if matches!(mapped_status, "completed" | "failed" | "cancelled") {
|
||||
info!("任务 {} 已完成,状态: {}", task.task_id, mapped_status);
|
||||
polling_tasks.lock().unwrap().remove(&task.task_id);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("查询任务 {} 状态失败: {}", task.task_id, e);
|
||||
|
||||
// 如果连续查询失败次数过多,标记为失败
|
||||
if task.poll_count > 10 {
|
||||
let _ = event_bus.publish_hedra_task_progress(
|
||||
task.task_id.clone(),
|
||||
task.record_id.clone(),
|
||||
"failed".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(format!("状态查询失败: {}", e)),
|
||||
).await;
|
||||
|
||||
polling_tasks.lock().unwrap().remove(&task.task_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取当前轮询任务数量
|
||||
pub fn get_polling_task_count(&self) -> usize {
|
||||
self.polling_tasks.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ pub mod outfit_photo_generation_service;
|
||||
pub mod workflow_management_service;
|
||||
pub mod error_handling_service;
|
||||
pub mod volcano_video_service;
|
||||
pub mod hedra_task_poller;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests;
|
||||
|
||||
370
apps/desktop/src-tauri/src/data/models/hedra_lipsync_record.rs
Normal file
370
apps/desktop/src-tauri/src/data/models/hedra_lipsync_record.rs
Normal file
@@ -0,0 +1,370 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use rusqlite::{params, Connection, Result as SqliteResult, Row};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Hedra 口型合成记录状态
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HedraLipSyncStatus {
|
||||
Pending, // 等待中
|
||||
Processing, // 处理中
|
||||
Completed, // 已完成
|
||||
Failed, // 失败
|
||||
Cancelled, // 已取消
|
||||
}
|
||||
|
||||
impl HedraLipSyncStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
HedraLipSyncStatus::Pending => "pending",
|
||||
HedraLipSyncStatus::Processing => "processing",
|
||||
HedraLipSyncStatus::Completed => "completed",
|
||||
HedraLipSyncStatus::Failed => "failed",
|
||||
HedraLipSyncStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"pending" => HedraLipSyncStatus::Pending,
|
||||
"processing" => HedraLipSyncStatus::Processing,
|
||||
"completed" => HedraLipSyncStatus::Completed,
|
||||
"failed" => HedraLipSyncStatus::Failed,
|
||||
"cancelled" => HedraLipSyncStatus::Cancelled,
|
||||
_ => HedraLipSyncStatus::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hedra 口型合成记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HedraLipSyncRecord {
|
||||
pub id: String,
|
||||
pub task_id: Option<String>,
|
||||
pub image_path: String, // 原始图片路径
|
||||
pub image_url: Option<String>, // 上传后的图片URL
|
||||
pub audio_path: String, // 原始音频路径
|
||||
pub audio_url: Option<String>, // 上传后的音频URL
|
||||
pub prompt: Option<String>, // 提示词(如果有的话)
|
||||
pub status: HedraLipSyncStatus,
|
||||
pub progress: f32,
|
||||
pub result_video_url: Option<String>, // 生成的视频URL
|
||||
pub result_video_path: Option<String>, // 下载后的视频本地路径
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub duration_ms: Option<i64>,
|
||||
pub file_size_bytes: Option<i64>, // 生成视频文件大小
|
||||
pub video_duration_seconds: Option<f32>, // 视频时长
|
||||
}
|
||||
|
||||
impl HedraLipSyncRecord {
|
||||
/// 创建新的 Hedra 口型合成记录
|
||||
pub fn new(
|
||||
image_path: String,
|
||||
audio_path: String,
|
||||
prompt: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
task_id: None,
|
||||
image_path,
|
||||
image_url: None,
|
||||
audio_path,
|
||||
audio_url: None,
|
||||
prompt,
|
||||
status: HedraLipSyncStatus::Pending,
|
||||
progress: 0.0,
|
||||
result_video_url: None,
|
||||
result_video_path: None,
|
||||
error_message: None,
|
||||
created_at: Utc::now(),
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
file_size_bytes: None,
|
||||
video_duration_seconds: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始处理
|
||||
pub fn start_processing(&mut self, task_id: String) {
|
||||
self.task_id = Some(task_id);
|
||||
self.status = HedraLipSyncStatus::Processing;
|
||||
self.started_at = Some(Utc::now());
|
||||
}
|
||||
|
||||
/// 设置上传的文件URL
|
||||
pub fn set_uploaded_urls(&mut self, image_url: String, audio_url: String) {
|
||||
self.image_url = Some(image_url);
|
||||
self.audio_url = Some(audio_url);
|
||||
}
|
||||
|
||||
/// 更新进度
|
||||
pub fn update_progress(&mut self, progress: f32) {
|
||||
self.progress = progress;
|
||||
if progress > 0.0 && self.status == HedraLipSyncStatus::Pending {
|
||||
self.status = HedraLipSyncStatus::Processing;
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成处理
|
||||
pub fn complete_processing(
|
||||
&mut self,
|
||||
result_video_url: String,
|
||||
file_size_bytes: Option<i64>,
|
||||
video_duration_seconds: Option<f32>,
|
||||
) {
|
||||
self.status = HedraLipSyncStatus::Completed;
|
||||
self.progress = 1.0;
|
||||
self.result_video_url = Some(result_video_url);
|
||||
self.file_size_bytes = file_size_bytes;
|
||||
self.video_duration_seconds = video_duration_seconds;
|
||||
self.completed_at = Some(Utc::now());
|
||||
|
||||
if let Some(started_at) = self.started_at {
|
||||
self.duration_ms = Some((Utc::now() - started_at).num_milliseconds());
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置下载后的本地视频路径
|
||||
pub fn set_local_video_path(&mut self, local_path: String) {
|
||||
self.result_video_path = Some(local_path);
|
||||
}
|
||||
|
||||
/// 失败处理
|
||||
pub fn fail_processing(&mut self, error_message: String) {
|
||||
self.status = HedraLipSyncStatus::Failed;
|
||||
self.error_message = Some(error_message);
|
||||
self.completed_at = Some(Utc::now());
|
||||
|
||||
if let Some(started_at) = self.started_at {
|
||||
self.duration_ms = Some((Utc::now() - started_at).num_milliseconds());
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消处理
|
||||
pub fn cancel_processing(&mut self) {
|
||||
self.status = HedraLipSyncStatus::Cancelled;
|
||||
self.completed_at = Some(Utc::now());
|
||||
|
||||
if let Some(started_at) = self.started_at {
|
||||
self.duration_ms = Some((Utc::now() - started_at).num_milliseconds());
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取图片文件名
|
||||
pub fn get_image_filename(&self) -> String {
|
||||
std::path::Path::new(&self.image_path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("unknown.jpg")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 获取音频文件名
|
||||
pub fn get_audio_filename(&self) -> String {
|
||||
std::path::Path::new(&self.audio_path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("unknown.mp3")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 从数据库行创建记录
|
||||
pub fn from_row(row: &Row) -> SqliteResult<Self> {
|
||||
let created_at_str: String = row.get("created_at")?;
|
||||
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
|
||||
.map_err(|_| rusqlite::Error::InvalidColumnType(0, "created_at".to_string(), rusqlite::types::Type::Text))?
|
||||
.with_timezone(&Utc);
|
||||
|
||||
let started_at_str: Option<String> = row.get("started_at")?;
|
||||
let started_at = started_at_str
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
|
||||
let completed_at_str: Option<String> = row.get("completed_at")?;
|
||||
let completed_at = completed_at_str
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
|
||||
Ok(Self {
|
||||
id: row.get("id")?,
|
||||
task_id: row.get("task_id")?,
|
||||
image_path: row.get("image_path")?,
|
||||
image_url: row.get("image_url")?,
|
||||
audio_path: row.get("audio_path")?,
|
||||
audio_url: row.get("audio_url")?,
|
||||
prompt: row.get("prompt")?,
|
||||
status: HedraLipSyncStatus::from_str(&row.get::<_, String>("status")?),
|
||||
progress: row.get("progress")?,
|
||||
result_video_url: row.get("result_video_url")?,
|
||||
result_video_path: row.get("result_video_path")?,
|
||||
error_message: row.get("error_message")?,
|
||||
created_at,
|
||||
started_at,
|
||||
completed_at,
|
||||
duration_ms: row.get("duration_ms")?,
|
||||
file_size_bytes: row.get("file_size_bytes")?,
|
||||
video_duration_seconds: row.get("video_duration_seconds")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 保存到数据库
|
||||
pub fn save(&self, conn: &Connection) -> SqliteResult<()> {
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT OR REPLACE INTO hedra_lipsync_records (
|
||||
id, task_id, image_path, image_url, audio_path, audio_url,
|
||||
prompt, status, progress, result_video_url, result_video_path,
|
||||
error_message, created_at, started_at, completed_at, duration_ms,
|
||||
file_size_bytes, video_duration_seconds
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
||||
"#,
|
||||
params![
|
||||
self.id,
|
||||
self.task_id,
|
||||
self.image_path,
|
||||
self.image_url,
|
||||
self.audio_path,
|
||||
self.audio_url,
|
||||
self.prompt,
|
||||
self.status.as_str(),
|
||||
self.progress,
|
||||
self.result_video_url,
|
||||
self.result_video_path,
|
||||
self.error_message,
|
||||
self.created_at.to_rfc3339(),
|
||||
self.started_at.map(|dt| dt.to_rfc3339()),
|
||||
self.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
self.duration_ms,
|
||||
self.file_size_bytes,
|
||||
self.video_duration_seconds,
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 根据ID查找记录
|
||||
pub fn find_by_id(conn: &Connection, id: &str) -> SqliteResult<Option<Self>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT * FROM hedra_lipsync_records WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query_map(params![id], Self::from_row)?;
|
||||
|
||||
match rows.next() {
|
||||
Some(row) => Ok(Some(row?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据任务ID查找记录
|
||||
pub fn find_by_task_id(conn: &Connection, task_id: &str) -> SqliteResult<Option<Self>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT * FROM hedra_lipsync_records WHERE task_id = ?1"
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query_map(params![task_id], Self::from_row)?;
|
||||
|
||||
match rows.next() {
|
||||
Some(row) => Ok(Some(row?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有记录(按创建时间倒序)
|
||||
pub fn get_all(conn: &Connection, limit: Option<i32>) -> SqliteResult<Vec<Self>> {
|
||||
let sql = if let Some(limit) = limit {
|
||||
format!("SELECT * FROM hedra_lipsync_records ORDER BY created_at DESC LIMIT {}", limit)
|
||||
} else {
|
||||
"SELECT * FROM hedra_lipsync_records ORDER BY created_at DESC".to_string()
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([], Self::from_row)?;
|
||||
|
||||
let mut records = Vec::new();
|
||||
for row in rows {
|
||||
records.push(row?);
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// 根据状态获取记录
|
||||
pub fn get_by_status(conn: &Connection, status: HedraLipSyncStatus) -> SqliteResult<Vec<Self>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT * FROM hedra_lipsync_records WHERE status = ?1 ORDER BY created_at DESC"
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![status.as_str()], Self::from_row)?;
|
||||
|
||||
let mut records = Vec::new();
|
||||
for row in rows {
|
||||
records.push(row?);
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// 删除记录
|
||||
pub fn delete(&self, conn: &Connection) -> SqliteResult<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM hedra_lipsync_records WHERE id = ?1",
|
||||
params![self.id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 Hedra 口型合成记录表
|
||||
pub fn create_table(conn: &Connection) -> SqliteResult<()> {
|
||||
conn.execute(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS hedra_lipsync_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT,
|
||||
image_path TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
audio_path TEXT NOT NULL,
|
||||
audio_url TEXT,
|
||||
prompt TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
progress REAL NOT NULL DEFAULT 0.0,
|
||||
result_video_url TEXT,
|
||||
result_video_path TEXT,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
file_size_bytes INTEGER,
|
||||
video_duration_seconds REAL
|
||||
)
|
||||
"#,
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_hedra_lipsync_records_task_id ON hedra_lipsync_records(task_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_hedra_lipsync_records_created_at ON hedra_lipsync_records(created_at)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_hedra_lipsync_records_status ON hedra_lipsync_records(status)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -29,3 +29,4 @@ pub mod system_voice;
|
||||
pub mod outfit_image;
|
||||
pub mod outfit_photo_generation;
|
||||
pub mod video_generation_record;
|
||||
pub mod hedra_lipsync_record;
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::data::models::hedra_lipsync_record::{HedraLipSyncRecord, HedraLipSyncStatus};
|
||||
use crate::infrastructure::database::Database;
|
||||
|
||||
/// Hedra 口型合成记录仓储
|
||||
/// 遵循 Tauri 开发规范的数据访问层设计
|
||||
pub struct HedraLipSyncRepository {
|
||||
database: Arc<Database>,
|
||||
}
|
||||
|
||||
impl HedraLipSyncRepository {
|
||||
/// 创建新的仓储实例
|
||||
pub fn new(database: Arc<Database>) -> Self {
|
||||
Self { database }
|
||||
}
|
||||
|
||||
/// 初始化数据库表
|
||||
pub fn init_tables(&self) -> Result<()> {
|
||||
if !self.database.has_pool() {
|
||||
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||
}
|
||||
|
||||
let conn = self
|
||||
.database
|
||||
.acquire_from_pool()
|
||||
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||
|
||||
crate::data::models::hedra_lipsync_record::create_table(&conn)
|
||||
.map_err(|e| anyhow!("创建 Hedra 口型合成记录表失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 保存记录
|
||||
pub fn save(&self, record: &HedraLipSyncRecord) -> Result<()> {
|
||||
if !self.database.has_pool() {
|
||||
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||
}
|
||||
|
||||
let conn = self
|
||||
.database
|
||||
.acquire_from_pool()
|
||||
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||
|
||||
record.save(&conn)
|
||||
.map_err(|e| anyhow!("保存 Hedra 口型合成记录失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 根据ID查找记录
|
||||
pub fn find_by_id(&self, id: &str) -> Result<Option<HedraLipSyncRecord>> {
|
||||
if !self.database.has_pool() {
|
||||
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||
}
|
||||
|
||||
let conn = self
|
||||
.database
|
||||
.acquire_from_pool()
|
||||
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||
|
||||
HedraLipSyncRecord::find_by_id(&conn, id)
|
||||
.map_err(|e| anyhow!("查找 Hedra 口型合成记录失败: {}", e))
|
||||
}
|
||||
|
||||
/// 根据任务ID查找记录
|
||||
pub fn find_by_task_id(&self, task_id: &str) -> Result<Option<HedraLipSyncRecord>> {
|
||||
if !self.database.has_pool() {
|
||||
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||
}
|
||||
|
||||
let conn = self
|
||||
.database
|
||||
.acquire_from_pool()
|
||||
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||
|
||||
HedraLipSyncRecord::find_by_task_id(&conn, task_id)
|
||||
.map_err(|e| anyhow!("根据任务ID查找 Hedra 口型合成记录失败: {}", e))
|
||||
}
|
||||
|
||||
/// 获取所有记录
|
||||
pub fn get_all(&self, limit: Option<i32>) -> Result<Vec<HedraLipSyncRecord>> {
|
||||
if !self.database.has_pool() {
|
||||
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||
}
|
||||
|
||||
let conn = self
|
||||
.database
|
||||
.acquire_from_pool()
|
||||
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||
|
||||
HedraLipSyncRecord::get_all(&conn, limit)
|
||||
.map_err(|e| anyhow!("获取所有 Hedra 口型合成记录失败: {}", e))
|
||||
}
|
||||
|
||||
/// 根据状态获取记录
|
||||
pub fn get_by_status(&self, status: HedraLipSyncStatus) -> Result<Vec<HedraLipSyncRecord>> {
|
||||
if !self.database.has_pool() {
|
||||
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||
}
|
||||
|
||||
let conn = self
|
||||
.database
|
||||
.acquire_from_pool()
|
||||
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||
|
||||
HedraLipSyncRecord::get_by_status(&conn, status)
|
||||
.map_err(|e| anyhow!("根据状态获取 Hedra 口型合成记录失败: {}", e))
|
||||
}
|
||||
|
||||
/// 删除记录
|
||||
pub fn delete(&self, record: &HedraLipSyncRecord) -> Result<()> {
|
||||
if !self.database.has_pool() {
|
||||
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||
}
|
||||
|
||||
let conn = self
|
||||
.database
|
||||
.acquire_from_pool()
|
||||
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||
|
||||
record.delete(&conn)
|
||||
.map_err(|e| anyhow!("删除 Hedra 口型合成记录失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 根据ID删除记录
|
||||
pub fn delete_by_id(&self, id: &str) -> Result<()> {
|
||||
if let Some(record) = self.find_by_id(id)? {
|
||||
self.delete(&record)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 批量删除记录
|
||||
pub fn delete_batch(&self, ids: &[String]) -> Result<usize> {
|
||||
if !self.database.has_pool() {
|
||||
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||
}
|
||||
|
||||
let conn = self
|
||||
.database
|
||||
.acquire_from_pool()
|
||||
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||
|
||||
let mut deleted_count = 0;
|
||||
for id in ids {
|
||||
let result = conn.execute(
|
||||
"DELETE FROM hedra_lipsync_records WHERE id = ?1",
|
||||
rusqlite::params![id],
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(changes) => deleted_count += changes,
|
||||
Err(e) => {
|
||||
eprintln!("删除记录 {} 失败: {}", id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deleted_count)
|
||||
}
|
||||
|
||||
/// 更新记录状态
|
||||
pub fn update_status(&self, id: &str, status: HedraLipSyncStatus) -> Result<()> {
|
||||
if let Some(mut record) = self.find_by_id(id)? {
|
||||
match status {
|
||||
HedraLipSyncStatus::Processing => {
|
||||
record.status = status;
|
||||
if record.started_at.is_none() {
|
||||
record.started_at = Some(chrono::Utc::now());
|
||||
}
|
||||
}
|
||||
HedraLipSyncStatus::Completed | HedraLipSyncStatus::Failed | HedraLipSyncStatus::Cancelled => {
|
||||
record.status = status;
|
||||
if record.completed_at.is_none() {
|
||||
record.completed_at = Some(chrono::Utc::now());
|
||||
if let Some(started_at) = record.started_at {
|
||||
record.duration_ms = Some((chrono::Utc::now() - started_at).num_milliseconds());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
record.status = status;
|
||||
}
|
||||
}
|
||||
self.save(&record)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新记录状态和错误信息
|
||||
pub fn update_status_and_error(&self, id: &str, status: HedraLipSyncStatus, error_message: Option<String>) -> Result<()> {
|
||||
if let Some(mut record) = self.find_by_id(id)? {
|
||||
record.status = status.clone();
|
||||
record.error_message = error_message;
|
||||
|
||||
match status {
|
||||
HedraLipSyncStatus::Processing => {
|
||||
if record.started_at.is_none() {
|
||||
record.started_at = Some(chrono::Utc::now());
|
||||
}
|
||||
}
|
||||
HedraLipSyncStatus::Completed | HedraLipSyncStatus::Failed | HedraLipSyncStatus::Cancelled => {
|
||||
if record.completed_at.is_none() {
|
||||
record.completed_at = Some(chrono::Utc::now());
|
||||
if let Some(started_at) = record.started_at {
|
||||
record.duration_ms = Some((chrono::Utc::now() - started_at).num_milliseconds());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.save(&record)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新记录进度和结果
|
||||
pub fn update_progress_and_result(
|
||||
&self,
|
||||
id: &str,
|
||||
status: HedraLipSyncStatus,
|
||||
progress: i32,
|
||||
result_video_url: Option<String>,
|
||||
error_message: Option<String>
|
||||
) -> Result<()> {
|
||||
if let Some(mut record) = self.find_by_id(id)? {
|
||||
record.status = status.clone();
|
||||
record.progress = progress as f32;
|
||||
record.result_video_url = result_video_url;
|
||||
record.error_message = error_message;
|
||||
|
||||
match status {
|
||||
HedraLipSyncStatus::Processing => {
|
||||
if record.started_at.is_none() {
|
||||
record.started_at = Some(chrono::Utc::now());
|
||||
}
|
||||
}
|
||||
HedraLipSyncStatus::Completed | HedraLipSyncStatus::Failed | HedraLipSyncStatus::Cancelled => {
|
||||
if record.completed_at.is_none() {
|
||||
record.completed_at = Some(chrono::Utc::now());
|
||||
if let Some(started_at) = record.started_at {
|
||||
record.duration_ms = Some((chrono::Utc::now() - started_at).num_milliseconds());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.save(&record)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取统计信息
|
||||
pub fn get_statistics(&self) -> Result<HedraLipSyncStatistics> {
|
||||
if !self.database.has_pool() {
|
||||
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||
}
|
||||
|
||||
let conn = self
|
||||
.database
|
||||
.acquire_from_pool()
|
||||
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||
|
||||
let total: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM hedra_lipsync_records",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap_or(0);
|
||||
|
||||
let completed: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM hedra_lipsync_records WHERE status = 'completed'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap_or(0);
|
||||
|
||||
let processing: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM hedra_lipsync_records WHERE status = 'processing'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap_or(0);
|
||||
|
||||
let failed: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM hedra_lipsync_records WHERE status = 'failed'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap_or(0);
|
||||
|
||||
Ok(HedraLipSyncStatistics {
|
||||
total: total as u32,
|
||||
completed: completed as u32,
|
||||
processing: processing as u32,
|
||||
failed: failed as u32,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Hedra 口型合成统计信息
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct HedraLipSyncStatistics {
|
||||
pub total: u32,
|
||||
pub completed: u32,
|
||||
pub processing: u32,
|
||||
pub failed: u32,
|
||||
}
|
||||
@@ -19,3 +19,4 @@ pub mod system_voice_repository;
|
||||
pub mod outfit_image_repository;
|
||||
pub mod outfit_photo_generation_repository;
|
||||
pub mod video_generation_record_repository;
|
||||
pub mod hedra_lipsync_repository;
|
||||
|
||||
@@ -2,7 +2,6 @@ use anyhow::{anyhow, Result};
|
||||
use rusqlite::{params, Row};
|
||||
use std::sync::Arc;
|
||||
use chrono::{DateTime, Utc};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::data::models::outfit_image::{
|
||||
OutfitImageRecord, OutfitImageStatus, ProductImage, OutfitImage, OutfitImageStats
|
||||
@@ -209,7 +208,7 @@ impl OutfitImageRepository {
|
||||
|
||||
println!("📷 创建商品图片记录,共 {} 个...", record.product_images.len());
|
||||
// 创建商品图片记录
|
||||
for (index, product_image) in record.product_images.iter().enumerate() {
|
||||
for (_index, product_image) in record.product_images.iter().enumerate() {
|
||||
tx.execute(
|
||||
r#"
|
||||
INSERT INTO product_images (
|
||||
|
||||
@@ -73,6 +73,15 @@ pub enum DataEvent {
|
||||
stage: String, // "metadata", "scene_detection", "video_splitting"
|
||||
progress_percentage: f64,
|
||||
},
|
||||
/// Hedra 口型合成任务进度事件
|
||||
HedraTaskProgress {
|
||||
task_id: String,
|
||||
record_id: String,
|
||||
status: String, // "pending", "processing", "completed", "failed"
|
||||
progress: Option<f64>,
|
||||
result_video_url: Option<String>,
|
||||
error_message: Option<String>,
|
||||
},
|
||||
/// 批量匹配进度事件
|
||||
BatchMatchingProgress {
|
||||
project_id: String,
|
||||
@@ -325,6 +334,26 @@ impl EventBusManager {
|
||||
elapsed_time_ms,
|
||||
})).await
|
||||
}
|
||||
|
||||
/// 发布 Hedra 任务进度事件
|
||||
pub async fn publish_hedra_task_progress(
|
||||
&self,
|
||||
task_id: String,
|
||||
record_id: String,
|
||||
status: String,
|
||||
progress: Option<f64>,
|
||||
result_video_url: Option<String>,
|
||||
error_message: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
self.event_bus.publish(Event::Data(DataEvent::HedraTaskProgress {
|
||||
task_id,
|
||||
record_id,
|
||||
status,
|
||||
progress,
|
||||
result_video_url,
|
||||
error_message,
|
||||
})).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventBusManager {
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::fs;
|
||||
use base64::prelude::*;
|
||||
use uuid::Uuid;
|
||||
use tokio::time::sleep;
|
||||
use image::{ImageFormat, DynamicImage};
|
||||
use image::{ImageFormat};
|
||||
|
||||
use crate::data::models::image_editing::{
|
||||
ImageEditingConfig, ImageEditingRequest, ImageEditingResponse, ImageEditingTask,
|
||||
|
||||
@@ -563,7 +563,18 @@ pub fn run() {
|
||||
// Hedra 口型合成命令
|
||||
commands::bowong_text_video_agent_commands::hedra_upload_file,
|
||||
commands::bowong_text_video_agent_commands::hedra_submit_task,
|
||||
commands::bowong_text_video_agent_commands::hedra_query_task_status
|
||||
commands::bowong_text_video_agent_commands::hedra_submit_task_async,
|
||||
commands::bowong_text_video_agent_commands::hedra_query_task_status,
|
||||
// Hedra 口型合成记录管理命令
|
||||
commands::hedra_lipsync_commands::create_hedra_lipsync_record,
|
||||
commands::hedra_lipsync_commands::update_hedra_lipsync_record,
|
||||
commands::hedra_lipsync_commands::get_hedra_lipsync_record,
|
||||
commands::hedra_lipsync_commands::get_hedra_lipsync_record_by_task_id,
|
||||
commands::hedra_lipsync_commands::get_all_hedra_lipsync_records,
|
||||
commands::hedra_lipsync_commands::get_hedra_lipsync_records_by_status,
|
||||
commands::hedra_lipsync_commands::delete_hedra_lipsync_record,
|
||||
commands::hedra_lipsync_commands::batch_delete_hedra_lipsync_records,
|
||||
commands::hedra_lipsync_commands::get_hedra_lipsync_statistics
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -2,6 +2,8 @@ use tauri::{command, State};
|
||||
use crate::app_state::AppState;
|
||||
use crate::infrastructure::bowong_text_video_agent_service::BowongTextVideoAgentService;
|
||||
use crate::data::models::bowong_text_video_agent::*;
|
||||
use crate::infrastructure::event_bus::EventBusManager;
|
||||
use crate::data::repositories::hedra_lipsync_repository::HedraLipSyncRepository;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -566,7 +568,7 @@ pub async fn bowong_union_async_generate_video(
|
||||
/// 获取运行节点
|
||||
#[command]
|
||||
pub async fn bowong_get_running_node(
|
||||
params: Option<GetRunningNodeParams>,
|
||||
_params: Option<GetRunningNodeParams>,
|
||||
) -> Result<ApiResponse, String> {
|
||||
Ok(ApiResponse {
|
||||
status: true,
|
||||
@@ -583,7 +585,7 @@ pub async fn bowong_get_running_node(
|
||||
/// 提交 ComfyUI 任务
|
||||
#[command]
|
||||
pub async fn bowong_submit_comfyui_task(
|
||||
request: ComfyUITaskRequest,
|
||||
_request: ComfyUITaskRequest,
|
||||
) -> Result<TaskResponse, String> {
|
||||
Ok(TaskResponse {
|
||||
status: true,
|
||||
@@ -756,6 +758,77 @@ pub async fn hedra_submit_task(
|
||||
result
|
||||
}
|
||||
|
||||
/// Hedra 异步提交任务(立即返回,后台轮询)
|
||||
#[command]
|
||||
pub async fn hedra_submit_task_async(
|
||||
state: State<'_, AppState>,
|
||||
record_id: String,
|
||||
params: HedraTaskSubmitRequest,
|
||||
) -> Result<TaskResponse, String> {
|
||||
println!("=== Hedra Async Submit Task Command ===");
|
||||
println!("接收到的参数: {:?}", params);
|
||||
println!("记录ID: {}", record_id);
|
||||
|
||||
// 克隆服务以避免跨 await 点持有 MutexGuard
|
||||
let service = {
|
||||
let service_guard = state.get_bowong_service()
|
||||
.map_err(|e| format!("Failed to get BowongTextVideoAgent service: {}", e))?;
|
||||
|
||||
service_guard.as_ref()
|
||||
.ok_or_else(|| "BowongTextVideoAgent service not initialized".to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
// 提交任务
|
||||
println!("=== 调用服务方法 ===");
|
||||
let result = service.hedra_submit_task(¶ms)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
println!("=== 服务调用失败 ===");
|
||||
println!("错误信息: {}", e);
|
||||
format!("Failed to submit Hedra task: {}", e)
|
||||
})?;
|
||||
|
||||
println!("=== 服务调用成功 ===");
|
||||
println!("响应: {:?}", result);
|
||||
|
||||
// 如果任务提交成功,启动后台轮询
|
||||
if result.status && !result.data.is_empty() {
|
||||
let task_id = result.data.clone();
|
||||
|
||||
// 获取必要的服务和仓库用于后台轮询
|
||||
let service_clone = service.clone();
|
||||
let event_bus = state.event_bus_manager.clone();
|
||||
let hedra_repo = {
|
||||
let repo_guard = state.hedra_lipsync_repository.lock()
|
||||
.map_err(|e| format!("获取仓库锁失败: {}", e))?;
|
||||
repo_guard.as_ref()
|
||||
.ok_or("Hedra repository not initialized")?
|
||||
.clone()
|
||||
};
|
||||
|
||||
let task_id_clone = task_id.clone();
|
||||
let record_id_clone = record_id.clone();
|
||||
|
||||
// 在后台启动异步轮询任务
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = poll_hedra_task_status(
|
||||
task_id_clone,
|
||||
record_id_clone,
|
||||
service_clone,
|
||||
event_bus,
|
||||
hedra_repo,
|
||||
).await {
|
||||
eprintln!("Hedra 任务轮询失败: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
println!("✅ 任务已提交并启动后台轮询: task_id={}, record_id={}", task_id, record_id);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Hedra 查询任务状态
|
||||
#[command]
|
||||
pub async fn hedra_query_task_status(
|
||||
@@ -776,3 +849,136 @@ pub async fn hedra_query_task_status(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query Hedra task status: {}", e))
|
||||
}
|
||||
|
||||
/// 后台轮询 Hedra 任务状态
|
||||
async fn poll_hedra_task_status(
|
||||
task_id: String,
|
||||
record_id: String,
|
||||
service: BowongTextVideoAgentService,
|
||||
event_bus: Arc<EventBusManager>,
|
||||
repository: Arc<HedraLipSyncRepository>,
|
||||
) -> anyhow::Result<()> {
|
||||
use tokio::time::{sleep, Duration};
|
||||
use crate::data::models::hedra_lipsync_record::HedraLipSyncStatus;
|
||||
|
||||
let max_polls = 360; // 30分钟 (5秒间隔 * 360次)
|
||||
let poll_interval = Duration::from_secs(5);
|
||||
|
||||
for poll_count in 1..=max_polls {
|
||||
// 查询任务状态
|
||||
let status_params = HedraTaskStatusParams {
|
||||
task_id: task_id.clone(),
|
||||
};
|
||||
|
||||
match service.hedra_query_task_status(&status_params).await {
|
||||
Ok(status_response) => {
|
||||
println!("任务 {} 状态: {} (轮询次数: {})", task_id, status_response.status, poll_count);
|
||||
|
||||
// 映射状态
|
||||
let mapped_status = match status_response.status.as_str() {
|
||||
"success" => "completed",
|
||||
"running" => "processing",
|
||||
"failed" => "failed",
|
||||
"cancelled" => "cancelled",
|
||||
"pending" | _ => "pending",
|
||||
};
|
||||
|
||||
// 提取视频URL
|
||||
let video_url = if status_response.status == "success" {
|
||||
println!("任务成功,提取视频URL,result: {:?}", status_response.result);
|
||||
let url = status_response.result
|
||||
.as_ref()
|
||||
.and_then(|result| {
|
||||
result.get("video_url")
|
||||
.or_else(|| result.get("url"))
|
||||
.or_else(|| result.get("video"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
println!("提取到的视频URL: {:?}", url);
|
||||
url
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 发送进度事件
|
||||
let _ = event_bus.publish_hedra_task_progress(
|
||||
task_id.clone(),
|
||||
record_id.clone(),
|
||||
mapped_status.to_string(),
|
||||
status_response.progress.map(|p| p as f64),
|
||||
video_url.clone(),
|
||||
status_response.error.clone(),
|
||||
).await;
|
||||
|
||||
// 更新数据库
|
||||
let db_status = match mapped_status {
|
||||
"completed" => HedraLipSyncStatus::Completed,
|
||||
"processing" => HedraLipSyncStatus::Processing,
|
||||
"failed" => HedraLipSyncStatus::Failed,
|
||||
"cancelled" => HedraLipSyncStatus::Cancelled,
|
||||
_ => HedraLipSyncStatus::Pending,
|
||||
};
|
||||
|
||||
let _ = repository.update_progress_and_result(
|
||||
&record_id,
|
||||
db_status,
|
||||
status_response.progress.unwrap_or(0.0) as i32,
|
||||
video_url,
|
||||
status_response.error,
|
||||
);
|
||||
|
||||
// 如果任务完成或失败,结束轮询
|
||||
if matches!(mapped_status, "completed" | "failed" | "cancelled") {
|
||||
println!("任务 {} 已完成,状态: {}", task_id, mapped_status);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("查询任务 {} 状态失败: {}", task_id, e);
|
||||
|
||||
// 如果连续查询失败次数过多,标记为失败
|
||||
if poll_count > 10 {
|
||||
let _ = event_bus.publish_hedra_task_progress(
|
||||
task_id.clone(),
|
||||
record_id.clone(),
|
||||
"failed".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(format!("状态查询失败: {}", e)),
|
||||
).await;
|
||||
|
||||
let _ = repository.update_status_and_error(
|
||||
&record_id,
|
||||
HedraLipSyncStatus::Failed,
|
||||
Some(format!("状态查询失败: {}", e)),
|
||||
);
|
||||
|
||||
return Err(anyhow::anyhow!("连续查询失败: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 等待下次轮询
|
||||
sleep(poll_interval).await;
|
||||
}
|
||||
|
||||
// 轮询超时
|
||||
let timeout_msg = "任务轮询超时";
|
||||
let _ = event_bus.publish_hedra_task_progress(
|
||||
task_id.clone(),
|
||||
record_id.clone(),
|
||||
"failed".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(timeout_msg.to_string()),
|
||||
).await;
|
||||
|
||||
let _ = repository.update_status_and_error(
|
||||
&record_id,
|
||||
HedraLipSyncStatus::Failed,
|
||||
Some(timeout_msg.to_string()),
|
||||
);
|
||||
|
||||
Err(anyhow::anyhow!("任务轮询超时"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::{command, State};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::data::models::hedra_lipsync_record::{HedraLipSyncRecord, HedraLipSyncStatus};
|
||||
use crate::data::repositories::hedra_lipsync_repository::{HedraLipSyncRepository, HedraLipSyncStatistics};
|
||||
use crate::infrastructure::database::Database;
|
||||
|
||||
/// Hedra 口型合成记录创建请求
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateHedraLipSyncRecordRequest {
|
||||
pub image_path: String,
|
||||
pub audio_path: String,
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
/// Hedra 口型合成记录更新请求
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateHedraLipSyncRecordRequest {
|
||||
pub id: String,
|
||||
pub task_id: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub audio_url: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub progress: Option<f32>,
|
||||
pub result_video_url: Option<String>,
|
||||
pub result_video_path: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub file_size_bytes: Option<i64>,
|
||||
pub video_duration_seconds: Option<f32>,
|
||||
}
|
||||
|
||||
/// 批量删除请求
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BatchDeleteRequest {
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// 调试记录信息
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DebugRecordInfo {
|
||||
pub id: String,
|
||||
pub status: String,
|
||||
pub result_video_url: Option<String>,
|
||||
pub result_video_path: Option<String>,
|
||||
pub progress: f32,
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
/// 创建 Hedra 口型合成记录
|
||||
#[command]
|
||||
pub async fn create_hedra_lipsync_record(
|
||||
request: CreateHedraLipSyncRecordRequest,
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<HedraLipSyncRecord, String> {
|
||||
info!("创建 Hedra 口型合成记录: {:?}", request);
|
||||
|
||||
let repository = HedraLipSyncRepository::new(database.inner().clone());
|
||||
|
||||
let record = HedraLipSyncRecord::new(
|
||||
request.image_path,
|
||||
request.audio_path,
|
||||
request.prompt,
|
||||
);
|
||||
|
||||
repository
|
||||
.save(&record)
|
||||
.map_err(|e| {
|
||||
error!("保存 Hedra 口型合成记录失败: {}", e);
|
||||
format!("保存记录失败: {}", e)
|
||||
})?;
|
||||
|
||||
info!("Hedra 口型合成记录创建成功: {}", record.id);
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// 更新 Hedra 口型合成记录
|
||||
#[command]
|
||||
pub async fn update_hedra_lipsync_record(
|
||||
request: UpdateHedraLipSyncRecordRequest,
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<HedraLipSyncRecord, String> {
|
||||
info!("更新 Hedra 口型合成记录: {:?}", request);
|
||||
|
||||
let repository = HedraLipSyncRepository::new(database.inner().clone());
|
||||
|
||||
let mut record = repository
|
||||
.find_by_id(&request.id)
|
||||
.map_err(|e| {
|
||||
error!("查找 Hedra 口型合成记录失败: {}", e);
|
||||
format!("查找记录失败: {}", e)
|
||||
})?
|
||||
.ok_or_else(|| format!("未找到ID为 {} 的记录", request.id))?;
|
||||
|
||||
// 更新字段
|
||||
if let Some(task_id) = request.task_id {
|
||||
record.task_id = Some(task_id);
|
||||
}
|
||||
if let Some(image_url) = request.image_url {
|
||||
record.image_url = Some(image_url);
|
||||
}
|
||||
if let Some(audio_url) = request.audio_url {
|
||||
record.audio_url = Some(audio_url);
|
||||
}
|
||||
if let Some(status_str) = request.status {
|
||||
record.status = HedraLipSyncStatus::from_str(&status_str);
|
||||
}
|
||||
if let Some(progress) = request.progress {
|
||||
record.progress = progress;
|
||||
}
|
||||
if let Some(result_video_url) = request.result_video_url {
|
||||
record.result_video_url = Some(result_video_url);
|
||||
}
|
||||
if let Some(result_video_path) = request.result_video_path {
|
||||
record.result_video_path = Some(result_video_path);
|
||||
}
|
||||
if let Some(error_message) = request.error_message {
|
||||
record.error_message = Some(error_message);
|
||||
}
|
||||
if let Some(file_size_bytes) = request.file_size_bytes {
|
||||
record.file_size_bytes = Some(file_size_bytes);
|
||||
}
|
||||
if let Some(video_duration_seconds) = request.video_duration_seconds {
|
||||
record.video_duration_seconds = Some(video_duration_seconds);
|
||||
}
|
||||
|
||||
repository
|
||||
.save(&record)
|
||||
.map_err(|e| {
|
||||
error!("更新 Hedra 口型合成记录失败: {}", e);
|
||||
format!("更新记录失败: {}", e)
|
||||
})?;
|
||||
|
||||
info!("Hedra 口型合成记录更新成功: {}", record.id);
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// 获取 Hedra 口型合成记录
|
||||
#[command]
|
||||
pub async fn get_hedra_lipsync_record(
|
||||
id: String,
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<Option<HedraLipSyncRecord>, String> {
|
||||
info!("获取 Hedra 口型合成记录: {}", id);
|
||||
|
||||
let repository = HedraLipSyncRepository::new(database.inner().clone());
|
||||
|
||||
repository
|
||||
.find_by_id(&id)
|
||||
.map_err(|e| {
|
||||
error!("查找 Hedra 口型合成记录失败: {}", e);
|
||||
format!("查找记录失败: {}", e)
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据任务ID获取 Hedra 口型合成记录
|
||||
#[command]
|
||||
pub async fn get_hedra_lipsync_record_by_task_id(
|
||||
task_id: String,
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<Option<HedraLipSyncRecord>, String> {
|
||||
info!("根据任务ID获取 Hedra 口型合成记录: {}", task_id);
|
||||
|
||||
let repository = HedraLipSyncRepository::new(database.inner().clone());
|
||||
|
||||
repository
|
||||
.find_by_task_id(&task_id)
|
||||
.map_err(|e| {
|
||||
error!("根据任务ID查找 Hedra 口型合成记录失败: {}", e);
|
||||
format!("查找记录失败: {}", e)
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取所有 Hedra 口型合成记录
|
||||
#[command]
|
||||
pub async fn get_all_hedra_lipsync_records(
|
||||
limit: Option<i32>,
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<Vec<HedraLipSyncRecord>, String> {
|
||||
info!("获取所有 Hedra 口型合成记录,限制: {:?}", limit);
|
||||
|
||||
let repository = HedraLipSyncRepository::new(database.inner().clone());
|
||||
|
||||
repository
|
||||
.get_all(limit)
|
||||
.map_err(|e| {
|
||||
error!("获取所有 Hedra 口型合成记录失败: {}", e);
|
||||
format!("获取记录失败: {}", e)
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据状态获取 Hedra 口型合成记录
|
||||
#[command]
|
||||
pub async fn get_hedra_lipsync_records_by_status(
|
||||
status: String,
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<Vec<HedraLipSyncRecord>, String> {
|
||||
info!("根据状态获取 Hedra 口型合成记录: {}", status);
|
||||
|
||||
let repository = HedraLipSyncRepository::new(database.inner().clone());
|
||||
let status_enum = HedraLipSyncStatus::from_str(&status);
|
||||
|
||||
repository
|
||||
.get_by_status(status_enum)
|
||||
.map_err(|e| {
|
||||
error!("根据状态获取 Hedra 口型合成记录失败: {}", e);
|
||||
format!("获取记录失败: {}", e)
|
||||
})
|
||||
}
|
||||
|
||||
/// 删除 Hedra 口型合成记录
|
||||
#[command]
|
||||
pub async fn delete_hedra_lipsync_record(
|
||||
id: String,
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<(), String> {
|
||||
info!("删除 Hedra 口型合成记录: {}", id);
|
||||
|
||||
let repository = HedraLipSyncRepository::new(database.inner().clone());
|
||||
|
||||
repository
|
||||
.delete_by_id(&id)
|
||||
.map_err(|e| {
|
||||
error!("删除 Hedra 口型合成记录失败: {}", e);
|
||||
format!("删除记录失败: {}", e)
|
||||
})?;
|
||||
|
||||
info!("Hedra 口型合成记录删除成功: {}", id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 批量删除 Hedra 口型合成记录
|
||||
#[command]
|
||||
pub async fn batch_delete_hedra_lipsync_records(
|
||||
request: BatchDeleteRequest,
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<usize, String> {
|
||||
info!("批量删除 Hedra 口型合成记录: {:?}", request.ids);
|
||||
|
||||
let repository = HedraLipSyncRepository::new(database.inner().clone());
|
||||
|
||||
let deleted_count = repository
|
||||
.delete_batch(&request.ids)
|
||||
.map_err(|e| {
|
||||
error!("批量删除 Hedra 口型合成记录失败: {}", e);
|
||||
format!("批量删除记录失败: {}", e)
|
||||
})?;
|
||||
|
||||
info!("批量删除 Hedra 口型合成记录成功,删除数量: {}", deleted_count);
|
||||
Ok(deleted_count)
|
||||
}
|
||||
|
||||
/// 获取 Hedra 口型合成统计信息
|
||||
#[command]
|
||||
pub async fn get_hedra_lipsync_statistics(
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<HedraLipSyncStatistics, String> {
|
||||
info!("获取 Hedra 口型合成统计信息");
|
||||
|
||||
let repository = HedraLipSyncRepository::new(database.inner().clone());
|
||||
|
||||
repository
|
||||
.get_statistics()
|
||||
.map_err(|e| {
|
||||
error!("获取 Hedra 口型合成统计信息失败: {}", e);
|
||||
format!("获取统计信息失败: {}", e)
|
||||
})
|
||||
}
|
||||
|
||||
/// 调试:获取所有记录的状态信息
|
||||
#[command]
|
||||
pub async fn debug_hedra_lipsync_records(
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<Vec<DebugRecordInfo>, String> {
|
||||
info!("调试:获取所有 Hedra 口型合成记录状态");
|
||||
|
||||
let repository = HedraLipSyncRepository::new(database.inner().clone());
|
||||
|
||||
let records = repository
|
||||
.get_all(Some(100))
|
||||
.map_err(|e| {
|
||||
error!("获取 Hedra 口型合成记录失败: {}", e);
|
||||
format!("获取记录失败: {}", e)
|
||||
})?;
|
||||
|
||||
let debug_info: Vec<DebugRecordInfo> = records
|
||||
.into_iter()
|
||||
.map(|record| DebugRecordInfo {
|
||||
id: record.id,
|
||||
status: record.status.as_str().to_string(),
|
||||
result_video_url: record.result_video_url,
|
||||
result_video_path: record.result_video_path,
|
||||
progress: record.progress,
|
||||
completed_at: record.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
info!("调试信息获取成功,共 {} 条记录", debug_info.len());
|
||||
Ok(debug_info)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use tauri::{State, Manager, AppHandle, Emitter};
|
||||
use tauri::{State, AppHandle, Emitter};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// use std::sync::Arc; // 未使用的导入
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{Result};
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
|
||||
@@ -42,3 +42,4 @@ pub mod workflow_management_commands;
|
||||
pub mod error_handling_commands;
|
||||
pub mod volcano_video_commands;
|
||||
pub mod bowong_text_video_agent_commands;
|
||||
pub mod hedra_lipsync_commands;
|
||||
|
||||
@@ -2,11 +2,10 @@ use tauri::{command, State, AppHandle, Emitter};
|
||||
use tracing::{info, error, warn};
|
||||
use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::data::models::outfit_photo_generation::{
|
||||
OutfitPhotoGenerationRequest, OutfitPhotoGenerationResponse, GenerationStatus, WorkflowProgress
|
||||
OutfitPhotoGenerationRequest, OutfitPhotoGenerationResponse, GenerationStatus
|
||||
};
|
||||
use crate::business::services::outfit_photo_generation_service::{
|
||||
OutfitPhotoGenerationService, create_outfit_photo_prompt_id_based_progress_callback
|
||||
|
||||
@@ -29,8 +29,8 @@ import ImageGenerationTool from './pages/tools/ImageGenerationTool';
|
||||
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 HedraLipSyncRecords from './pages/tools/HedraLipSyncRecords';
|
||||
import { EnrichedAnalysisDemo } from './pages/tools/EnrichedAnalysisDemo';
|
||||
import MaterialCenter from './pages/MaterialCenter';
|
||||
import VideoGeneration from './pages/VideoGeneration';
|
||||
@@ -149,8 +149,9 @@ function App() {
|
||||
<Route path="/tools/voice-generation-history" element={<VoiceGenerationHistory />} />
|
||||
<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/hedra-lip-sync" element={<SimpleHedraLipSyncTool />} />
|
||||
<Route path="/tools/simple-hedra-lip-sync" element={<SimpleHedraLipSyncTool />} />
|
||||
<Route path="/tools/hedra-records" element={<HedraLipSyncRecords />} />
|
||||
<Route path="/tools/advanced-filter-demo" element={<AdvancedFilterTool />} />
|
||||
<Route path="/tools/enriched-analysis-demo" element={<EnrichedAnalysisDemo />} />
|
||||
</Routes>
|
||||
|
||||
467
apps/desktop/src/components/HedraLipSyncModal.tsx
Normal file
467
apps/desktop/src/components/HedraLipSyncModal.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
MessageCircle,
|
||||
Image,
|
||||
Music,
|
||||
Play,
|
||||
Download,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
FileImage,
|
||||
FileAudio,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useNotifications } from './NotificationSystem';
|
||||
import {
|
||||
HedraTaskSubmitRequest,
|
||||
TaskResponse
|
||||
} 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;
|
||||
}
|
||||
|
||||
interface HedraLipSyncModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onTaskCreated?: (recordId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hedra 口型合成Modal组件
|
||||
*/
|
||||
const HedraLipSyncModal: React.FC<HedraLipSyncModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onTaskCreated
|
||||
}) => {
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
// 组件状态
|
||||
const [state, setState] = useState<SimpleHedraState>({
|
||||
selectedImage: null,
|
||||
selectedAudio: null,
|
||||
prompt: '',
|
||||
resolution: '720p',
|
||||
aspectRatio: '16:9',
|
||||
task: { status: 'idle' },
|
||||
isProcessing: false
|
||||
});
|
||||
|
||||
// 重置状态
|
||||
const resetState = useCallback(() => {
|
||||
setState({
|
||||
selectedImage: null,
|
||||
selectedAudio: null,
|
||||
prompt: '',
|
||||
resolution: '720p',
|
||||
aspectRatio: '16:9',
|
||||
task: { status: 'idle' },
|
||||
isProcessing: false
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 关闭Modal
|
||||
const handleClose = useCallback(() => {
|
||||
if (!state.isProcessing) {
|
||||
resetState();
|
||||
onClose();
|
||||
}
|
||||
}, [state.isProcessing, resetState, onClose]);
|
||||
|
||||
// 选择图片文件
|
||||
const selectImageFile = useCallback(async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{
|
||||
name: 'Image',
|
||||
extensions: ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']
|
||||
}]
|
||||
});
|
||||
|
||||
if (selected && typeof selected === 'string') {
|
||||
const fileName = selected.split(/[/\\]/).pop() || 'unknown';
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
selectedImage: { path: selected, name: fileName }
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择图片文件失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '文件选择失败',
|
||||
message: '无法选择图片文件'
|
||||
});
|
||||
}
|
||||
}, [addNotification]);
|
||||
|
||||
// 选择音频文件
|
||||
const selectAudioFile = useCallback(async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{
|
||||
name: 'Audio',
|
||||
extensions: ['mp3', 'wav', 'aac', 'flac', 'm4a', 'ogg']
|
||||
}]
|
||||
});
|
||||
|
||||
if (selected && typeof selected === 'string') {
|
||||
const fileName = selected.split(/[/\\]/).pop() || 'unknown';
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
selectedAudio: { path: selected, name: fileName }
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择音频文件失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '文件选择失败',
|
||||
message: '无法选择音频文件'
|
||||
});
|
||||
}
|
||||
}, [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 recordRequest = {
|
||||
image_path: state.selectedImage.path,
|
||||
audio_path: state.selectedAudio.path,
|
||||
prompt: state.prompt || null
|
||||
};
|
||||
|
||||
const record = await invoke<{ id: string }>('create_hedra_lipsync_record', { request: recordRequest });
|
||||
console.log('创建记录成功:', record);
|
||||
|
||||
// 使用异步提交任务
|
||||
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_async', {
|
||||
recordId: record.id,
|
||||
params: taskRequest
|
||||
});
|
||||
|
||||
if (taskResponse.data) {
|
||||
// 更新记录的任务ID和状态
|
||||
await invoke('update_hedra_lipsync_record', {
|
||||
request: {
|
||||
id: record.id,
|
||||
task_id: taskResponse.data,
|
||||
status: 'processing'
|
||||
}
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '任务已提交',
|
||||
message: `口型合成任务已开始处理,任务ID:${taskResponse.data}`
|
||||
});
|
||||
|
||||
// 通知父组件任务创建成功
|
||||
if (onTaskCreated) {
|
||||
onTaskCreated(record.id);
|
||||
}
|
||||
|
||||
// 立即关闭 modal
|
||||
onClose();
|
||||
} 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, onTaskCreated, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
{/* Modal Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">创建Hedra口型合成任务</h2>
|
||||
<p className="text-gray-600 mt-1">上传图片和音频文件,生成口型同步视频</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={state.isProcessing}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 文件选择区域 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">文件上传</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 图片选择 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
<Image className="w-4 h-4 inline mr-2" />
|
||||
图片文件 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div
|
||||
onClick={selectImageFile}
|
||||
className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-green-400 hover:bg-green-50 transition-colors"
|
||||
>
|
||||
{state.selectedImage ? (
|
||||
<div className="space-y-2">
|
||||
<FileImage className="w-8 h-8 text-green-600 mx-auto" />
|
||||
<p className="text-sm font-medium text-green-600">{state.selectedImage.name}</p>
|
||||
<p className="text-xs text-gray-500">点击重新选择</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<FileImage className="w-8 h-8 text-gray-400 mx-auto" />
|
||||
<p className="text-sm text-gray-600">点击选择图片文件</p>
|
||||
<p className="text-xs text-gray-500">支持 PNG, JPG, JPEG, GIF, BMP, WebP</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音频选择 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
<Music className="w-4 h-4 inline mr-2" />
|
||||
音频文件 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div
|
||||
onClick={selectAudioFile}
|
||||
className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-green-400 hover:bg-green-50 transition-colors"
|
||||
>
|
||||
{state.selectedAudio ? (
|
||||
<div className="space-y-2">
|
||||
<FileAudio className="w-8 h-8 text-green-600 mx-auto" />
|
||||
<p className="text-sm font-medium text-green-600">{state.selectedAudio.name}</p>
|
||||
<p className="text-xs text-gray-500">点击重新选择</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<FileAudio className="w-8 h-8 text-gray-400 mx-auto" />
|
||||
<p className="text-sm text-gray-600">点击选择音频文件</p>
|
||||
<p className="text-xs text-gray-500">支持 MP3, WAV, AAC, FLAC, M4A, OGG</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示词输入 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
<MessageCircle className="w-4 h-4 inline mr-2" />
|
||||
提示词 (可选)
|
||||
</label>
|
||||
<textarea
|
||||
value={state.prompt}
|
||||
onChange={(e) => setState(prev => ({ ...prev, prompt: e.target.value }))}
|
||||
placeholder="输入描述或提示词,帮助生成更好的口型同步效果..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:opacity-50 resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 生成参数 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
生成参数
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 分辨率 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
分辨率
|
||||
</label>
|
||||
<select
|
||||
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-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
>
|
||||
<option value="720p">720p (高清)</option>
|
||||
<option value="540p">540p (标清)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 宽高比 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
宽高比
|
||||
</label>
|
||||
<select
|
||||
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-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
>
|
||||
<option value="16:9">16:9 (横屏)</option>
|
||||
<option value="9:16">9:16 (竖屏)</option>
|
||||
<option value="1:1">1:1 (正方形)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成状态显示 */}
|
||||
{state.task.status !== 'idle' && (
|
||||
<div className="p-4 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
{state.task.status === 'submitting' && (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-blue-600">正在提交任务</p>
|
||||
<p className="text-xs text-gray-500">请稍候,正在处理您的请求...</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{state.task.status === 'processing' && (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 text-yellow-600 animate-spin" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-yellow-600">正在生成视频</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{state.task.progress !== undefined
|
||||
? `处理进度: ${state.task.progress}%`
|
||||
: '正在处理口型同步,请耐心等待...'}
|
||||
</p>
|
||||
{state.task.progress !== undefined && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-yellow-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${state.task.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{state.task.status === 'completed' && (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-green-600">生成成功</p>
|
||||
<p className="text-xs text-gray-500">口型同步视频已生成完成</p>
|
||||
{state.task.result && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.href = state.task.result;
|
||||
link.download = `hedra_video_${Date.now()}.mp4`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 mt-2 bg-green-600 text-white text-xs rounded-md hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
下载视频
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{state.task.status === 'failed' && (
|
||||
<>
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-600">生成失败</p>
|
||||
<p className="text-xs text-gray-500">{state.task.error || '任务处理失败,请重试'}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex justify-end gap-3 p-6 border-t bg-gray-50">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={state.isProcessing}
|
||||
className="px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitTask}
|
||||
disabled={!state.selectedImage || !state.selectedAudio || state.isProcessing}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{state.isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
开始生成
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HedraLipSyncModal;
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Mic,
|
||||
Video,
|
||||
Wand2,
|
||||
MessageCircle
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
|
||||
|
||||
@@ -142,7 +142,7 @@ export const TOOLS_DATA: Tool[] = [
|
||||
},
|
||||
{
|
||||
id: 'volcano-video-generation',
|
||||
name: '图加视频生成视频',
|
||||
name: '图片模特模仿视频动作',
|
||||
description: '基于火山云API的智能视频生成工具,支持图片转视频、音频配音等功能',
|
||||
longDescription: '专业的火山云视频生成工具,集成火山云先进的视频生成API。支持图片转视频、音频配音、多种视频参数配置、实时进度监控等功能。提供直观的任务管理界面,支持批量操作、下载管理等完整的视频生成流程。适用于内容创作、营销推广、艺术创作等多种场景。',
|
||||
icon: Video,
|
||||
@@ -155,9 +155,24 @@ export const TOOLS_DATA: Tool[] = [
|
||||
version: '1.0.0',
|
||||
lastUpdated: '2024-01-31'
|
||||
},
|
||||
{
|
||||
id: 'hedra-records',
|
||||
name: '图片说话/唱歌',
|
||||
description: '查看和管理所有 Hedra 口型合成任务记录',
|
||||
longDescription: 'Hedra 口型合成记录管理工具,提供完整的任务历史记录查看功能。支持查看所有合成任务的详细信息,包括输入文件、处理状态、结果视频等。提供批量下载、删除操作,以及详细的统计信息展示。帮助用户更好地管理和追踪口型合成工作流程。',
|
||||
icon: FileText,
|
||||
route: '/tools/hedra-records',
|
||||
category: ToolCategory.AI_TOOLS,
|
||||
status: ToolStatus.STABLE,
|
||||
tags: ['记录管理', '历史查看', '批量操作', 'Hedra', '数据管理'],
|
||||
isNew: true,
|
||||
isPopular: false,
|
||||
version: '1.0.0',
|
||||
lastUpdated: '2025-08-01'
|
||||
},
|
||||
{
|
||||
id: 'image-editing',
|
||||
name: '图像编辑工具',
|
||||
name: '智能PS',
|
||||
description: '基于火山云SeedEdit 3.0 API的智能图像编辑工具,支持单张和批量图片编辑',
|
||||
longDescription: '专业的AI图像编辑工具,集成火山云SeedEdit 3.0先进的图像编辑模型。支持通过提示词进行图像编辑、风格转换、场景变换等功能。提供单张图片编辑和批量处理模式,支持多种参数配置、实时进度监控、任务管理等完整的图像编辑流程。适用于创意设计、内容创作、图片处理等多种场景。',
|
||||
icon: Wand2,
|
||||
@@ -169,22 +184,6 @@ export const TOOLS_DATA: Tool[] = [
|
||||
isPopular: true,
|
||||
version: '1.0.0',
|
||||
lastUpdated: '2024-01-31'
|
||||
},
|
||||
|
||||
{
|
||||
id: 'simple-hedra-lip-sync',
|
||||
name: '图加音频生成视频',
|
||||
description: '简化版 Hedra 口型合成工具,直接使用本地文件路径,无需上传',
|
||||
longDescription: '简化版的 AI 口型合成工具,基于 Hedra API 提供更直接的工作流程。直接选择本地图片和音频文件,无需上传步骤,一键提交任务并实时监控处理状态。提供清晰的任务进度显示、错误处理和结果下载功能。适合快速制作数字人视频、口型同步内容等场景。',
|
||||
icon: MessageCircle,
|
||||
route: '/tools/simple-hedra-lip-sync',
|
||||
category: ToolCategory.AI_TOOLS,
|
||||
status: ToolStatus.STABLE,
|
||||
tags: ['口型合成', 'AI视频', '数字人', '简化流程', 'Hedra API'],
|
||||
isNew: true,
|
||||
isPopular: false,
|
||||
version: '1.0.0',
|
||||
lastUpdated: '2024-08-01'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
643
apps/desktop/src/pages/tools/HedraLipSyncRecords.tsx
Normal file
643
apps/desktop/src/pages/tools/HedraLipSyncRecords.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Image,
|
||||
Music,
|
||||
Video,
|
||||
Plus
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useNotifications } from '../../components/NotificationSystem';
|
||||
import HedraLipSyncModal from '../../components/HedraLipSyncModal';
|
||||
import { getImageSrc } from '../../utils/imagePathUtils';
|
||||
|
||||
interface HedraLipSyncRecord {
|
||||
id: string;
|
||||
task_id?: string;
|
||||
image_path: string;
|
||||
image_url?: string;
|
||||
audio_path: string;
|
||||
audio_url?: string;
|
||||
prompt?: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number;
|
||||
result_video_url?: string;
|
||||
result_video_path?: string;
|
||||
error_message?: string;
|
||||
created_at: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
duration_ms?: number;
|
||||
file_size_bytes?: number;
|
||||
video_duration_seconds?: number;
|
||||
}
|
||||
|
||||
interface HedraLipSyncStatistics {
|
||||
total: number;
|
||||
completed: number;
|
||||
processing: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hedra 口型合成记录列表页面
|
||||
*/
|
||||
const HedraLipSyncRecords: React.FC = () => {
|
||||
const { addNotification } = useNotifications();
|
||||
const [records, setRecords] = useState<HedraLipSyncRecord[]>([]);
|
||||
const [statistics, setStatistics] = useState<HedraLipSyncStatistics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRecords, setSelectedRecords] = useState<Set<string>>(new Set());
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// 加载记录列表
|
||||
const loadRecords = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await invoke<HedraLipSyncRecord[]>('get_all_hedra_lipsync_records', { limit: 100 });
|
||||
setRecords(data);
|
||||
} catch (error) {
|
||||
console.error('加载记录失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '加载失败',
|
||||
message: '无法加载Hedra合成记录'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addNotification]);
|
||||
|
||||
// 加载统计信息
|
||||
const loadStatistics = useCallback(async () => {
|
||||
try {
|
||||
const stats = await invoke<HedraLipSyncStatistics>('get_hedra_lipsync_statistics');
|
||||
setStatistics(stats);
|
||||
} catch (error) {
|
||||
console.error('加载统计信息失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始化加载
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
loadStatistics();
|
||||
}, [loadRecords, loadStatistics]);
|
||||
|
||||
// 监听 Hedra 任务进度事件
|
||||
useEffect(() => {
|
||||
const unlisten = listen('hedra-task-progress', (event: any) => {
|
||||
console.log('收到 Hedra 任务进度事件:', event.payload);
|
||||
|
||||
const { record_id, status, progress, result_video_url, error_message } = event.payload;
|
||||
|
||||
// 更新对应记录的状态
|
||||
setRecords(prevRecords =>
|
||||
prevRecords.map(record => {
|
||||
if (record.id === record_id) {
|
||||
return {
|
||||
...record,
|
||||
status: status as HedraLipSyncRecord['status'],
|
||||
progress: progress || record.progress,
|
||||
result_video_url: result_video_url || record.result_video_url,
|
||||
error_message: error_message || record.error_message
|
||||
};
|
||||
}
|
||||
return record;
|
||||
})
|
||||
);
|
||||
|
||||
// 显示通知
|
||||
if (status === 'completed') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Hedra 任务完成',
|
||||
message: '口型合成视频已生成完成'
|
||||
});
|
||||
} else if (status === 'failed') {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Hedra 任务失败',
|
||||
message: error_message || '口型合成任务执行失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then(fn => fn());
|
||||
};
|
||||
}, [addNotification]);
|
||||
|
||||
// 删除记录
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('delete_hedra_lipsync_record', { id });
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '删除成功',
|
||||
message: '记录已删除'
|
||||
});
|
||||
loadRecords();
|
||||
loadStatistics();
|
||||
} catch (error) {
|
||||
console.error('删除记录失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '删除失败',
|
||||
message: '无法删除记录'
|
||||
});
|
||||
}
|
||||
}, [addNotification, loadRecords, loadStatistics]);
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
if (selectedRecords.size === 0) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: '未选择记录',
|
||||
message: '请先选择要删除的记录'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ids = Array.from(selectedRecords);
|
||||
await invoke('batch_delete_hedra_lipsync_records', { request: { ids } });
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '批量删除成功',
|
||||
message: `已删除 ${selectedRecords.size} 条记录`
|
||||
});
|
||||
setSelectedRecords(new Set());
|
||||
loadRecords();
|
||||
loadStatistics();
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '批量删除失败',
|
||||
message: '无法删除选中的记录'
|
||||
});
|
||||
}
|
||||
}, [selectedRecords, addNotification, loadRecords, loadStatistics]);
|
||||
|
||||
// 批量下载
|
||||
const handleBatchDownload = useCallback(async () => {
|
||||
if (selectedRecords.size === 0) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: '未选择记录',
|
||||
message: '请先选择要下载的记录'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedRecordsList = records.filter(record => selectedRecords.has(record.id));
|
||||
const downloadableRecords = selectedRecordsList.filter(record => record.result_video_url);
|
||||
|
||||
if (downloadableRecords.length === 0) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: '无可下载文件',
|
||||
message: '选中的记录中没有可下载的视频文件'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const record of downloadableRecords) {
|
||||
try {
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.href = record.result_video_url!;
|
||||
link.download = `hedra_video_${record.id}_${new Date().getTime()}.mp4`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`下载失败 ${record.id}:`, error);
|
||||
failCount++;
|
||||
}
|
||||
// 添加小延迟避免同时下载太多文件
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
addNotification({
|
||||
type: successCount > 0 ? 'success' : 'error',
|
||||
title: '批量下载完成',
|
||||
message: `成功下载 ${successCount} 个文件${failCount > 0 ? `,失败 ${failCount} 个` : ''}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('批量下载失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '批量下载失败',
|
||||
message: '批量下载过程中发生错误'
|
||||
});
|
||||
}
|
||||
}, [selectedRecords, records, addNotification]);
|
||||
|
||||
// 处理Modal任务创建成功
|
||||
const handleTaskCreated = useCallback((recordId: string) => {
|
||||
console.log('新任务创建成功:', recordId);
|
||||
// 刷新记录列表和统计信息
|
||||
loadRecords();
|
||||
loadStatistics();
|
||||
// 关闭Modal
|
||||
setIsModalOpen(false);
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '任务创建成功',
|
||||
message: '新的口型合成任务已创建,请在列表中查看进度'
|
||||
});
|
||||
}, [loadRecords, loadStatistics, addNotification]);
|
||||
|
||||
// 下载视频
|
||||
const handleDownload = useCallback(async (record: HedraLipSyncRecord) => {
|
||||
if (!record.result_video_url) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: '无法下载',
|
||||
message: '该记录没有可下载的视频'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.href = record.result_video_url;
|
||||
link.download = `hedra_${record.id}.mp4`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '开始下载',
|
||||
message: '视频下载已开始'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '下载失败',
|
||||
message: '无法下载视频文件'
|
||||
});
|
||||
}
|
||||
}, [addNotification]);
|
||||
|
||||
// 预览音频
|
||||
const handlePreviewAudio = useCallback(async (audioPath: string) => {
|
||||
try {
|
||||
// 使用Tauri的convertFileSrc来转换文件路径为可访问的URL
|
||||
const { convertFileSrc } = await import('@tauri-apps/api/core');
|
||||
const audioUrl = convertFileSrc(audioPath);
|
||||
|
||||
// 创建音频元素并播放
|
||||
const audio = new Audio(audioUrl);
|
||||
audio.play().catch(error => {
|
||||
console.error('播放音频失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '播放失败',
|
||||
message: '无法播放音频文件'
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('预览音频失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '预览失败',
|
||||
message: '无法预览音频文件'
|
||||
});
|
||||
}
|
||||
}, [addNotification]);
|
||||
|
||||
// 状态图标
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'processing':
|
||||
return <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
case 'cancelled':
|
||||
return <XCircle className="w-4 h-4 text-gray-500" />;
|
||||
default:
|
||||
return <AlertCircle className="w-4 h-4 text-yellow-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '等待中';
|
||||
case 'processing':
|
||||
return '处理中';
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
case 'cancelled':
|
||||
return '已取消';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeString: string) => {
|
||||
return new Date(timeString).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
// 过滤记录
|
||||
const filteredRecords = records.filter(record => {
|
||||
if (statusFilter === 'all') return true;
|
||||
return record.status === statusFilter;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 页面标题和统计 */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Hedra 口型合成记录</h1>
|
||||
<p className="text-gray-600 mt-1">管理和查看所有的口型合成任务记录</p>
|
||||
</div>
|
||||
|
||||
{statistics && (
|
||||
<div className="grid grid-cols-4 gap-4 text-center">
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-blue-600">{statistics.total}</div>
|
||||
<div className="text-sm text-blue-600">总计</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-green-600">{statistics.completed}</div>
|
||||
<div className="text-sm text-green-600">已完成</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-yellow-600">{statistics.processing}</div>
|
||||
<div className="text-sm text-yellow-600">处理中</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-red-600">{statistics.failed}</div>
|
||||
<div className="text-sm text-red-600">失败</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 状态筛选 */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="pending">等待中</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
|
||||
{/* 批量操作 */}
|
||||
{selectedRecords.size > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleBatchDownload}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
批量下载 ({selectedRecords.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBatchDelete}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
删除选中 ({selectedRecords.size})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
创建新任务
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
loadRecords();
|
||||
loadStatistics();
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 记录列表 */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
<span className="ml-2 text-gray-600">加载中...</span>
|
||||
</div>
|
||||
) : filteredRecords.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Video className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">暂无记录</h3>
|
||||
<p className="text-gray-600">还没有任何口型合成记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
|
||||
{filteredRecords.map((record) => (
|
||||
<div key={record.id} className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow">
|
||||
{/* 卡片头部:选择框和状态 */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRecords.has(record.id)}
|
||||
onChange={(e) => {
|
||||
const newSelected = new Set(selectedRecords);
|
||||
if (e.target.checked) {
|
||||
newSelected.add(record.id);
|
||||
} else {
|
||||
newSelected.delete(record.id);
|
||||
}
|
||||
setSelectedRecords(newSelected);
|
||||
}}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
{record.task_id && (
|
||||
<span className="text-xs text-gray-500">ID: {record.task_id.slice(-8)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusIcon(record.status)}
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
{getStatusText(record.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{record.status === 'processing' && record.progress > 0 && (
|
||||
<div className="space-y-1 mb-3">
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>进度</span>
|
||||
<span>{Math.round(record.progress * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-600 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${record.progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片和内容区域 */}
|
||||
<div className="flex gap-3">
|
||||
{/* 左侧:缩小的图片 */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={getImageSrc(record.image_path)}
|
||||
alt="原始图片"
|
||||
className="w-16 h-16 object-cover rounded-lg cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onError={(e) => {
|
||||
// 如果图片加载失败,显示占位图
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
<div className="hidden w-16 h-16 flex items-center justify-center bg-gray-100 rounded-lg">
|
||||
<Image className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:提示词和音频 */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* 提示词 */}
|
||||
{record.prompt && (
|
||||
<div className="text-xs text-gray-800 line-clamp-2" title={record.prompt}>
|
||||
{record.prompt}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 音频播放 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="w-3 h-3 text-green-500 flex-shrink-0" />
|
||||
<button
|
||||
onClick={() => handlePreviewAudio(record.audio_path)}
|
||||
className="text-xs text-green-600 hover:text-green-800 truncate"
|
||||
title={record.audio_path}
|
||||
>
|
||||
{record.audio_path.split(/[/\\]/).pop()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* 视频预览区域 - 总是显示,用于调试 */}
|
||||
{record.status === 'completed' && (
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="aspect-video bg-black">
|
||||
{(record.result_video_url || record.result_video_path) ? (
|
||||
<video
|
||||
controls
|
||||
className="w-full h-full object-contain"
|
||||
preload="metadata"
|
||||
onError={(e) => {
|
||||
console.error('视频加载失败:', e);
|
||||
console.log('视频源:', record.result_video_url || record.result_video_path);
|
||||
}}
|
||||
>
|
||||
<source src={getImageSrc(record.result_video_url || record.result_video_path!)} type="video/mp4" />
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<Video className="w-12 h-12 mx-auto mb-2 text-gray-400" />
|
||||
<p className="text-sm text-gray-400">视频URL为空</p>
|
||||
<p className="text-xs text-gray-500 mt-1">任务已完成但未获取到视频链接</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建时间和操作按钮 - 放在最底部 */}
|
||||
<div className="px-4 py-2 border-t border-gray-100 bg-gray-50 flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatTime(record.created_at)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{record.result_video_url && (
|
||||
<button
|
||||
onClick={() => handleDownload(record)}
|
||||
className="px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
下载
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(record.id)}
|
||||
className="px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hedra 口型合成Modal */}
|
||||
<HedraLipSyncModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onTaskCreated={handleTaskCreated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HedraLipSyncRecords;
|
||||
@@ -1,592 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
MessageCircle,
|
||||
Upload,
|
||||
Image,
|
||||
Music,
|
||||
Play,
|
||||
Download,
|
||||
RefreshCw,
|
||||
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 {
|
||||
HedraLipSyncState,
|
||||
HedraFileUploadRequest,
|
||||
HedraTaskSubmitRequest,
|
||||
HedraTaskStatusParams,
|
||||
FileUploadResponse,
|
||||
TaskResponse,
|
||||
TaskStatusResponse
|
||||
} from '../../types/hedraLipSync';
|
||||
|
||||
/**
|
||||
* Hedra 口型合成工具
|
||||
* 基于 Hedra API 实现图片与音频的口型同步
|
||||
*/
|
||||
const HedraLipSyncTool: React.FC = () => {
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
// 主要状态
|
||||
const [state, setState] = useState<HedraLipSyncState>({
|
||||
imageFile: null,
|
||||
audioFile: null,
|
||||
task: { status: 'idle' },
|
||||
isProcessing: false
|
||||
});
|
||||
|
||||
// 文件选择处理 - 直接选择文件路径
|
||||
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: { name: selected.split('\\').pop() || selected } as File,
|
||||
filePath: selected,
|
||||
uploadStatus: 'idle'
|
||||
}
|
||||
}));
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '图片已选择',
|
||||
message: `已选择图片:${selected.split('\\').pop() || selected}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('选择图片失败:', error);
|
||||
}
|
||||
}, [addNotification]);
|
||||
|
||||
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: { name: selected.split('\\').pop() || selected } as File,
|
||||
filePath: selected,
|
||||
uploadStatus: 'idle'
|
||||
}
|
||||
}));
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '音频已选择',
|
||||
message: `已选择音频:${selected.split('\\').pop() || selected}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('选择音频失败:', error);
|
||||
}
|
||||
}, [addNotification]);
|
||||
|
||||
// 上传已选择的文件到服务器
|
||||
const uploadFileToServer = useCallback(async (filePath: string, purpose: 'image' | 'audio'): Promise<string> => {
|
||||
try {
|
||||
console.log(`上传 ${purpose} 文件:`, filePath);
|
||||
|
||||
const request: HedraFileUploadRequest = {
|
||||
file_path: filePath,
|
||||
purpose
|
||||
};
|
||||
|
||||
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);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
// 轮询任务状态
|
||||
const pollTaskStatus = useCallback(async (taskId: string) => {
|
||||
const maxAttempts = 60; // 最多轮询60次(5分钟)
|
||||
let attempts = 0;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const statusParams: HedraTaskStatusParams = { task_id: taskId };
|
||||
const statusResponse: TaskStatusResponse = await invoke('hedra_query_task_status', { params: statusParams });
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
task: {
|
||||
...prev.task,
|
||||
status: statusResponse.status === 'success' ? 'completed' : 'processing',
|
||||
progress: statusResponse.progress,
|
||||
resultUrl: statusResponse.result?.video_url || statusResponse.result?.url,
|
||||
errorMessage: statusResponse.error,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}));
|
||||
|
||||
if (statusResponse.status === 'success') {
|
||||
setState(prev => ({ ...prev, isProcessing: false }));
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '任务完成',
|
||||
message: '口型合成视频已生成完成!'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusResponse.status === 'failed' || statusResponse.error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
task: {
|
||||
...prev.task,
|
||||
status: 'failed',
|
||||
errorMessage: statusResponse.error || '任务处理失败'
|
||||
},
|
||||
isProcessing: false
|
||||
}));
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '任务失败',
|
||||
message: statusResponse.error || '口型合成任务处理失败'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000); // 5秒后再次查询
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
task: {
|
||||
...prev.task,
|
||||
status: 'failed',
|
||||
errorMessage: '任务超时'
|
||||
},
|
||||
isProcessing: false
|
||||
}));
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '任务超时',
|
||||
message: '任务处理时间过长,请稍后手动查询结果'
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('查询任务状态失败:', error);
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000);
|
||||
} else {
|
||||
setState(prev => ({ ...prev, isProcessing: false }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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({
|
||||
imageFile: null,
|
||||
audioFile: null,
|
||||
task: { status: 'idle' },
|
||||
isProcessing: false
|
||||
});
|
||||
|
||||
// 重置完成,不需要清理input元素
|
||||
}, []);
|
||||
|
||||
// 下载结果
|
||||
const downloadResult = useCallback(() => {
|
||||
if (state.task.resultUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = state.task.resultUrl;
|
||||
link.download = `hedra_lipsync_${state.task.taskId || Date.now()}.mp4`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
}, [state.task.resultUrl, state.task.taskId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-600 rounded-xl flex items-center justify-center shadow-lg hover:shadow-xl transition-all duration-300">
|
||||
<MessageCircle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-purple-600 bg-clip-text text-transparent">
|
||||
Hedra 口型合成工具
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">让静态图片"开口说话",AI 驱动的口型同步技术</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 重置按钮 */}
|
||||
<button
|
||||
onClick={resetState}
|
||||
disabled={state.isProcessing}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 文件上传区域 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 图片上传 */}
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Image className="w-5 h-5 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">选择图片</h3>
|
||||
</div>
|
||||
|
||||
<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={handleImageSelect}
|
||||
>
|
||||
{state.imageFile ? (
|
||||
<div className="space-y-3">
|
||||
<FileImage className="w-12 h-12 text-blue-600 mx-auto" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{state.imageFile.file.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{state.imageFile.filePath}
|
||||
</p>
|
||||
</div>
|
||||
{state.imageFile.uploadStatus === 'uploading' && (
|
||||
<div className="flex items-center justify-center gap-2 text-blue-600">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">上传中...</span>
|
||||
</div>
|
||||
)}
|
||||
{state.imageFile.uploadStatus === 'uploaded' && (
|
||||
<div className="flex items-center justify-center gap-2 text-green-600">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-sm">上传完成</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Upload className="w-12 h-12 text-gray-400 mx-auto" />
|
||||
<div>
|
||||
<p className="text-lg font-medium text-gray-900">点击选择图片</p>
|
||||
<p className="text-sm text-gray-500">支持 JPG、PNG、GIF 格式</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音频上传 */}
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Music className="w-5 h-5 text-green-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">选择音频</h3>
|
||||
</div>
|
||||
|
||||
<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={handleAudioSelect}
|
||||
>
|
||||
{state.audioFile ? (
|
||||
<div className="space-y-3">
|
||||
<FileAudio className="w-12 h-12 text-green-600 mx-auto" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{state.audioFile.file.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{(state.audioFile.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
{state.audioFile.uploadStatus === 'uploading' && (
|
||||
<div className="flex items-center justify-center gap-2 text-green-600">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">上传中...</span>
|
||||
</div>
|
||||
)}
|
||||
{state.audioFile.uploadStatus === 'uploaded' && (
|
||||
<div className="flex items-center justify-center gap-2 text-green-600">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-sm">上传完成</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Upload className="w-12 h-12 text-gray-400 mx-auto" />
|
||||
<div>
|
||||
<p className="text-lg font-medium text-gray-900">点击选择音频</p>
|
||||
<p className="text-sm text-gray-500">支持 MP3、WAV、M4A 格式</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
onClick={submitLipSyncTask}
|
||||
disabled={!state.imageFile || !state.audioFile || state.isProcessing}
|
||||
className="flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl hover:from-purple-700 hover:to-pink-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-lg font-medium text-lg"
|
||||
>
|
||||
{state.isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-5 h-5" />
|
||||
开始口型合成
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务状态和结果 */}
|
||||
{state.task.status !== 'idle' && (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<MessageCircle className="w-5 h-5 text-purple-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">任务状态</h3>
|
||||
</div>
|
||||
|
||||
<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 font-medium">提交任务中...</span>
|
||||
</>
|
||||
)}
|
||||
{state.task.status === 'processing' && (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 text-purple-600 animate-spin" />
|
||||
<span className="text-purple-600 font-medium">处理中...</span>
|
||||
{state.task.progress && (
|
||||
<span className="text-sm text-gray-500">({state.task.progress}%)</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{state.task.status === 'completed' && (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
<span className="text-green-600 font-medium">处理完成</span>
|
||||
</>
|
||||
)}
|
||||
{state.task.status === 'failed' && (
|
||||
<>
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
<span className="text-red-600 font-medium">处理失败</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 任务信息 */}
|
||||
{state.task.taskId && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>任务ID: {state.task.taskId}</p>
|
||||
{state.task.createdAt && (
|
||||
<p>创建时间: {new Date(state.task.createdAt).toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{state.task.errorMessage && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-red-800">错误信息</p>
|
||||
<p className="text-red-700">{state.task.errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果下载 */}
|
||||
{state.task.status === 'completed' && state.task.resultUrl && (
|
||||
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<p className="font-medium text-green-800">口型合成完成</p>
|
||||
<p className="text-green-700">视频已生成,可以下载查看</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={downloadResult}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
下载视频
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HedraLipSyncTool;
|
||||
@@ -21,6 +21,7 @@ import { listen } from '@tauri-apps/api/event';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { useNotifications } from '../../components/NotificationSystem';
|
||||
import { ImageGalleryModal } from '../../components/ImageGalleryModal';
|
||||
import { DeleteConfirmDialog } from '../../components/DeleteConfirmDialog';
|
||||
import {
|
||||
PromptCheckResponse,
|
||||
ImageGenerationRequest,
|
||||
@@ -58,7 +59,20 @@ const ImageGenerationTool: React.FC = () => {
|
||||
initialIndex: 0,
|
||||
title: ''
|
||||
});
|
||||
|
||||
|
||||
// 确认对话框状态
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {}
|
||||
});
|
||||
|
||||
// 通知系统
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
@@ -261,12 +275,24 @@ const ImageGenerationTool: React.FC = () => {
|
||||
|
||||
// 如果提示词未通过检查,询问用户是否继续
|
||||
if (promptCheckResult && !promptCheckResult.is_valid) {
|
||||
const confirmed = window.confirm(
|
||||
`提示词检查未通过:${promptCheckResult.message}\n\n是否仍要继续生成?`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: '提示词检查未通过',
|
||||
message: `${promptCheckResult.message}\n\n是否仍要继续生成?`,
|
||||
onConfirm: () => {
|
||||
setConfirmDialog(prev => ({ ...prev, isOpen: false }));
|
||||
proceedWithGeneration();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await proceedWithGeneration();
|
||||
}, [prompt, referenceImagePath, promptCheckResult, addNotification]);
|
||||
|
||||
// 实际执行生成的函数
|
||||
const proceedWithGeneration = useCallback(async () => {
|
||||
|
||||
try {
|
||||
// 1. 创建数据库记录
|
||||
const record = await invoke<ImageGenerationRecord>('create_image_generation_record', {
|
||||
@@ -322,7 +348,7 @@ const ImageGenerationTool: React.FC = () => {
|
||||
message: `图片生成过程中出现错误: ${error}`
|
||||
});
|
||||
}
|
||||
}, [prompt, referenceImagePath, promptCheckResult, addNotification]);
|
||||
}, [prompt, referenceImagePath, addNotification]);
|
||||
|
||||
// 重置输入
|
||||
const resetInput = useCallback(() => {
|
||||
@@ -550,6 +576,15 @@ const ImageGenerationTool: React.FC = () => {
|
||||
onClose={closeImagePreview}
|
||||
title={previewModal.title}
|
||||
/>
|
||||
|
||||
{/* 确认对话框 */}
|
||||
<DeleteConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog(prev => ({ ...prev, isOpen: false }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -130,7 +130,7 @@ const SimpleHedraLipSyncTool: React.FC = () => {
|
||||
}, [addNotification]);
|
||||
|
||||
// 轮询任务状态
|
||||
const pollTaskStatus = useCallback(async (taskId: string) => {
|
||||
const pollTaskStatus = useCallback(async (taskId: string, recordId?: string) => {
|
||||
const maxAttempts = 60; // 最多轮询60次(5分钟)
|
||||
let attempts = 0;
|
||||
|
||||
@@ -152,6 +152,58 @@ const SimpleHedraLipSyncTool: React.FC = () => {
|
||||
}
|
||||
}));
|
||||
|
||||
// 更新数据库记录
|
||||
if (recordId) {
|
||||
// 确定视频URL
|
||||
const videoUrl = statusResponse.result?.video_url ||
|
||||
statusResponse.result?.url ||
|
||||
statusResponse.result?.videoUrl ||
|
||||
(typeof statusResponse.result === 'string' ? statusResponse.result : null);
|
||||
|
||||
// 正确映射状态 - 从TaskStatus枚举值映射到数据库字符串
|
||||
let mappedStatus: string;
|
||||
switch (statusResponse.status) {
|
||||
case 'success':
|
||||
mappedStatus = 'completed';
|
||||
break;
|
||||
case 'running':
|
||||
mappedStatus = 'processing';
|
||||
break;
|
||||
case 'failed':
|
||||
mappedStatus = 'failed';
|
||||
break;
|
||||
case 'cancelled':
|
||||
mappedStatus = 'cancelled';
|
||||
break;
|
||||
case 'pending':
|
||||
default:
|
||||
mappedStatus = 'pending';
|
||||
break;
|
||||
}
|
||||
|
||||
console.log('轮询状态更新:', {
|
||||
originalStatus: statusResponse.status,
|
||||
mappedStatus: mappedStatus,
|
||||
progress: statusResponse.progress,
|
||||
result: statusResponse.result,
|
||||
videoUrl: videoUrl,
|
||||
error: statusResponse.error
|
||||
});
|
||||
|
||||
// 只有真正的错误才保存到error_message,状态信息不保存
|
||||
const errorMessage = statusResponse.error && statusResponse.status === 'failed' ? statusResponse.error : null;
|
||||
|
||||
await invoke('update_hedra_lipsync_record', {
|
||||
request: {
|
||||
id: recordId,
|
||||
status: mappedStatus,
|
||||
progress: statusResponse.progress || 0,
|
||||
result_video_url: videoUrl,
|
||||
error_message: errorMessage
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 检查任务状态
|
||||
if (statusResponse.status === 'success') {
|
||||
setState(prev => ({
|
||||
@@ -252,6 +304,16 @@ const SimpleHedraLipSyncTool: React.FC = () => {
|
||||
task: { status: 'submitting' }
|
||||
}));
|
||||
|
||||
// 创建数据库记录
|
||||
const recordRequest = {
|
||||
image_path: state.selectedImage.path,
|
||||
audio_path: state.selectedAudio.path,
|
||||
prompt: state.prompt || null
|
||||
};
|
||||
|
||||
const record = await invoke<{ id: string }>('create_hedra_lipsync_record', { request: recordRequest });
|
||||
console.log('创建记录成功:', record);
|
||||
|
||||
// 直接使用本地文件路径提交任务
|
||||
const taskRequest: HedraTaskSubmitRequest = {
|
||||
img_file: state.selectedImage.path,
|
||||
@@ -270,6 +332,15 @@ const SimpleHedraLipSyncTool: React.FC = () => {
|
||||
}
|
||||
}));
|
||||
|
||||
// 更新记录的任务ID和状态
|
||||
await invoke('update_hedra_lipsync_record', {
|
||||
request: {
|
||||
id: record.id,
|
||||
task_id: taskResponse.data,
|
||||
status: 'processing'
|
||||
}
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '任务已提交',
|
||||
@@ -277,7 +348,7 @@ const SimpleHedraLipSyncTool: React.FC = () => {
|
||||
});
|
||||
|
||||
// 开始轮询任务状态
|
||||
pollTaskStatus(taskResponse.data);
|
||||
pollTaskStatus(taskResponse.data, record.id);
|
||||
} else {
|
||||
throw new Error('任务提交失败:未获取到任务ID');
|
||||
}
|
||||
|
||||
@@ -7,19 +7,21 @@ export interface HedraFileInfo {
|
||||
file: File;
|
||||
filePath?: string; // 本地文件路径
|
||||
url?: string; // 上传后的URL
|
||||
uploadStatus: 'idle' | 'uploading' | 'uploaded' | 'error';
|
||||
uploadStatus: 'idle' | 'uploading' | 'uploaded' | 'completed' | 'error';
|
||||
uploadProgress?: number;
|
||||
errorMessage?: string;
|
||||
uploadedUrl?: string; // 上传后的URL
|
||||
}
|
||||
|
||||
export interface HedraTaskInfo {
|
||||
taskId?: string;
|
||||
status: 'idle' | 'submitting' | 'processing' | 'completed' | 'failed';
|
||||
status: 'idle' | 'submitting' | 'submitted' | 'processing' | 'completed' | 'failed';
|
||||
progress?: number;
|
||||
resultUrl?: string;
|
||||
errorMessage?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface HedraLipSyncState {
|
||||
|
||||
Reference in New Issue
Block a user