feat: 实现 Hedra 口型合成异步化改造

- 将 Hedra 口型合成任务改为异步处理模式
- 添加完整的数据模型和仓储层支持
- 实现后台任务轮询和实时进度通知
- 创建 HedraLipSyncRecords 页面显示任务列表
- 将原有功能封装为 Modal 组件
- 支持多任务并发处理和状态跟踪
- 添加事件驱动的前端状态更新机制

主要变更:
- 新增 HedraLipSyncRecord 数据模型
- 新增 HedraLipSyncRepository 仓储层
- 新增 HedraLipSyncModal 组件
- 新增 HedraLipSyncRecords 页面
- 修改 bowong_text_video_agent_commands 支持异步处理
- 添加事件总线支持 Hedra 任务进度通知
- 更新路由配置和工具列表
This commit is contained in:
imeepos
2025-08-01 18:40:54 +08:00
parent 8dcde192a4
commit 46c3ea6501
25 changed files with 2782 additions and 633 deletions

View File

@@ -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 {

View File

@@ -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()
}
}

View File

@@ -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;

View 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(())
}

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -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;

View File

@@ -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 (

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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| {
// 初始化日志系统

View File

@@ -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(&params)
.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!("任务成功提取视频URLresult: {:?}", 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!("任务轮询超时"))
}

View File

@@ -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)
}

View File

@@ -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;

View File

@@ -1,5 +1,5 @@
// use std::sync::Arc; // 未使用的导入
use anyhow::{anyhow, Result};
use anyhow::{Result};
use tauri::State;
use crate::app_state::AppState;

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View 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;

View File

@@ -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'
}
];

View 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;

View File

@@ -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"> JPGPNGGIF </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"> MP3WAVM4A </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;

View File

@@ -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>
);
};

View File

@@ -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');
}

View File

@@ -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 {