feat: 实现异步导入处理和批量导入功能
- 新增异步素材导入服务 (AsyncMaterialService) - 实现实时进度反馈和事件驱动架构 - 添加批量文件夹选择和扫描功能 - 更新导入对话框支持文件夹批量导入 - 优化用户体验,避免UI阻塞 Features: 1. 异步导入处理:支持实时进度更新,不阻塞UI 2. 批量导入:支持文件夹选择和递归扫描 3. 事件驱动:使用Tauri事件系统进行进度通信 4. 文件类型过滤:支持按扩展名过滤文件 5. 改进的用户界面:新增批量导入配置步骤 遵循promptx/tauri-desktop-app-expert开发规范
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
use std::time::Instant;
|
||||
use std::sync::Arc;
|
||||
use tokio::task;
|
||||
|
||||
use crate::data::models::material::{
|
||||
Material, MaterialType, ProcessingStatus, CreateMaterialRequest,
|
||||
MaterialImportResult, MaterialProcessingConfig
|
||||
};
|
||||
use crate::data::repositories::material_repository::MaterialRepository;
|
||||
use crate::infrastructure::monitoring::PERFORMANCE_MONITOR;
|
||||
use crate::infrastructure::event_bus::EventBusManager;
|
||||
use crate::business::errors::error_utils;
|
||||
use crate::business::services::material_service::MaterialService;
|
||||
use tracing::{info, warn, error, debug};
|
||||
|
||||
/// 异步素材服务
|
||||
/// 遵循 Tauri 开发规范的异步业务逻辑层设计
|
||||
pub struct AsyncMaterialService;
|
||||
|
||||
impl AsyncMaterialService {
|
||||
/// 异步导入素材文件
|
||||
/// 提供实时进度反馈和非阻塞用户体验
|
||||
pub async fn import_materials_async(
|
||||
repository: Arc<MaterialRepository>,
|
||||
request: CreateMaterialRequest,
|
||||
config: MaterialProcessingConfig,
|
||||
event_bus: Arc<EventBusManager>,
|
||||
) -> Result<MaterialImportResult> {
|
||||
let timer = PERFORMANCE_MONITOR.start_operation("async_import_materials");
|
||||
let start_time = Instant::now();
|
||||
|
||||
info!(
|
||||
project_id = %request.project_id,
|
||||
file_count = request.file_paths.len(),
|
||||
"开始异步导入素材文件"
|
||||
);
|
||||
|
||||
let mut result = MaterialImportResult {
|
||||
total_files: request.file_paths.len() as u32,
|
||||
processed_files: 0,
|
||||
skipped_files: 0,
|
||||
failed_files: 0,
|
||||
created_materials: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
processing_time: 0.0,
|
||||
};
|
||||
|
||||
// 发布开始导入事件
|
||||
let _ = event_bus.publish_material_import_progress(
|
||||
request.project_id.clone(),
|
||||
"准备导入...".to_string(),
|
||||
0,
|
||||
result.total_files,
|
||||
"正在准备导入文件".to_string(),
|
||||
).await;
|
||||
|
||||
// 异步处理每个文件
|
||||
for (index, file_path) in request.file_paths.iter().enumerate() {
|
||||
debug!(file_path = %file_path, "异步处理文件");
|
||||
|
||||
// 发布当前文件处理进度
|
||||
let _ = event_bus.publish_material_import_progress(
|
||||
request.project_id.clone(),
|
||||
file_path.clone(),
|
||||
index as u32,
|
||||
result.total_files,
|
||||
format!("正在处理文件: {}", Path::new(file_path).file_name()
|
||||
.and_then(|n| n.to_str()).unwrap_or("unknown")),
|
||||
).await;
|
||||
|
||||
// 在独立的任务中处理文件,避免阻塞
|
||||
let repository_clone = Arc::clone(&repository);
|
||||
let project_id_clone = request.project_id.clone();
|
||||
let file_path_clone = file_path.clone();
|
||||
let config_clone = config.clone();
|
||||
let event_bus_clone = Arc::clone(&event_bus);
|
||||
|
||||
match Self::process_single_file_async(
|
||||
repository_clone,
|
||||
&project_id_clone,
|
||||
&file_path_clone,
|
||||
&config_clone,
|
||||
event_bus_clone,
|
||||
).await {
|
||||
Ok(Some(material)) => {
|
||||
info!(
|
||||
file_path = %file_path,
|
||||
material_id = %material.id,
|
||||
"文件异步处理成功"
|
||||
);
|
||||
result.created_materials.push(material);
|
||||
result.processed_files += 1;
|
||||
}
|
||||
Ok(None) => {
|
||||
// 文件被跳过(重复)
|
||||
warn!(file_path = %file_path, "文件被跳过(重复)");
|
||||
result.skipped_files += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
file_path = %file_path,
|
||||
error = %e,
|
||||
"文件异步处理失败"
|
||||
);
|
||||
result.failed_files += 1;
|
||||
result.errors.push(format!("处理文件 {} 失败: {}", file_path, e));
|
||||
}
|
||||
}
|
||||
|
||||
// 让出控制权,避免长时间占用线程
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
result.processing_time = start_time.elapsed().as_secs_f64();
|
||||
|
||||
let success = result.failed_files == 0;
|
||||
timer.finish(success);
|
||||
|
||||
info!(
|
||||
processed_files = result.processed_files,
|
||||
skipped_files = result.skipped_files,
|
||||
failed_files = result.failed_files,
|
||||
processing_time = result.processing_time,
|
||||
"异步素材导入完成"
|
||||
);
|
||||
|
||||
PERFORMANCE_MONITOR.record_metric("async_import_files_per_second",
|
||||
result.total_files as f64 / result.processing_time);
|
||||
|
||||
// 发布导入完成事件
|
||||
if success {
|
||||
let _ = event_bus.publish_material_import_completed(
|
||||
request.project_id.clone(),
|
||||
result.clone(),
|
||||
).await;
|
||||
} else {
|
||||
let _ = event_bus.publish_material_import_failed(
|
||||
request.project_id.clone(),
|
||||
format!("导入失败,{} 个文件处理失败", result.failed_files),
|
||||
).await;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 异步处理单个文件
|
||||
async fn process_single_file_async(
|
||||
repository: Arc<MaterialRepository>,
|
||||
project_id: &str,
|
||||
file_path: &str,
|
||||
config: &MaterialProcessingConfig,
|
||||
event_bus: Arc<EventBusManager>,
|
||||
) -> Result<Option<Material>> {
|
||||
let _timer = PERFORMANCE_MONITOR.start_operation("async_process_single_file");
|
||||
|
||||
// 验证文件路径
|
||||
error_utils::validate_file_path(file_path)
|
||||
.map_err(|e| anyhow!("文件验证失败: {}", e))?;
|
||||
|
||||
let path = Path::new(file_path);
|
||||
|
||||
// 获取文件信息
|
||||
let metadata = fs::metadata(path)?;
|
||||
let file_size = metadata.len();
|
||||
|
||||
// 计算MD5哈希(在独立任务中执行)
|
||||
let file_path_clone = file_path.to_string();
|
||||
let md5_hash = task::spawn_blocking(move || {
|
||||
MaterialService::calculate_md5(&file_path_clone)
|
||||
}).await??;
|
||||
|
||||
// 检查是否已存在相同的文件
|
||||
if repository.exists_by_md5(project_id, &md5_hash)? {
|
||||
return Ok(None); // 跳过重复文件
|
||||
}
|
||||
|
||||
// 获取文件名和扩展名
|
||||
let file_name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let extension = path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// 确定素材类型
|
||||
let material_type = MaterialType::from_extension(extension);
|
||||
|
||||
// 创建素材对象
|
||||
let material = Material::new(
|
||||
project_id.to_string(),
|
||||
file_name.clone(),
|
||||
file_path.to_string(),
|
||||
file_size,
|
||||
md5_hash,
|
||||
material_type,
|
||||
);
|
||||
|
||||
// 保存到数据库
|
||||
repository.create(&material)?;
|
||||
|
||||
// 如果启用自动处理,则异步开始处理
|
||||
if config.auto_process.unwrap_or(true) {
|
||||
// 异步处理素材(提取元数据、场景检测等)
|
||||
let repository_clone = Arc::clone(&repository);
|
||||
let material_id = material.id.clone();
|
||||
let config_clone = config.clone();
|
||||
let event_bus_clone = Arc::clone(&event_bus);
|
||||
let file_name_clone = file_name.clone();
|
||||
|
||||
// 在后台任务中处理,不阻塞导入流程
|
||||
tokio::spawn(async move {
|
||||
match Self::process_material_async(
|
||||
repository_clone,
|
||||
&material_id,
|
||||
&config_clone,
|
||||
event_bus_clone,
|
||||
&file_name_clone,
|
||||
).await {
|
||||
Ok(_) => {
|
||||
debug!(material_id = %material_id, "素材异步处理完成");
|
||||
}
|
||||
Err(e) => {
|
||||
error!(material_id = %material_id, error = %e, "素材异步处理失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Some(material))
|
||||
}
|
||||
|
||||
/// 异步处理单个素材(提取元数据、场景检测等)
|
||||
async fn process_material_async(
|
||||
repository: Arc<MaterialRepository>,
|
||||
material_id: &str,
|
||||
config: &MaterialProcessingConfig,
|
||||
event_bus: Arc<EventBusManager>,
|
||||
file_name: &str,
|
||||
) -> Result<()> {
|
||||
// 更新状态为处理中
|
||||
MaterialService::update_material_status(
|
||||
&repository,
|
||||
material_id,
|
||||
ProcessingStatus::Processing,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// 发布处理开始事件
|
||||
let _ = event_bus.publish_material_processing_progress(
|
||||
material_id.to_string(),
|
||||
file_name.to_string(),
|
||||
"开始处理".to_string(),
|
||||
0.0,
|
||||
).await;
|
||||
|
||||
// 获取素材信息
|
||||
let mut material = repository.get_by_id(material_id)?
|
||||
.ok_or_else(|| anyhow!("素材不存在: {}", material_id))?;
|
||||
|
||||
// 1. 异步提取元数据
|
||||
let _ = event_bus.publish_material_processing_progress(
|
||||
material_id.to_string(),
|
||||
file_name.to_string(),
|
||||
"提取元数据".to_string(),
|
||||
25.0,
|
||||
).await;
|
||||
|
||||
let original_path = material.original_path.clone();
|
||||
let material_type = material.material_type.clone();
|
||||
|
||||
match task::spawn_blocking(move || {
|
||||
MaterialService::extract_metadata(&original_path, &material_type)
|
||||
}).await? {
|
||||
Ok(metadata) => {
|
||||
material.set_metadata(metadata);
|
||||
repository.update(&material)?;
|
||||
}
|
||||
Err(e) => {
|
||||
MaterialService::update_material_status(
|
||||
&repository,
|
||||
material_id,
|
||||
ProcessingStatus::Failed,
|
||||
Some(format!("元数据提取失败: {}", e)),
|
||||
)?;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 异步场景检测(如果是视频且启用了场景检测)
|
||||
if matches!(material.material_type, MaterialType::Video) && config.enable_scene_detection {
|
||||
let _ = event_bus.publish_material_processing_progress(
|
||||
material_id.to_string(),
|
||||
file_name.to_string(),
|
||||
"场景检测".to_string(),
|
||||
50.0,
|
||||
).await;
|
||||
|
||||
let original_path = material.original_path.clone();
|
||||
let threshold = config.scene_detection_threshold;
|
||||
|
||||
match task::spawn_blocking(move || {
|
||||
MaterialService::detect_video_scenes(&original_path, threshold)
|
||||
}).await? {
|
||||
Ok(scene_detection) => {
|
||||
info!("异步场景检测成功,发现 {} 个场景", scene_detection.scenes.len());
|
||||
material.set_scene_detection(scene_detection);
|
||||
repository.update(&material)?;
|
||||
}
|
||||
Err(e) => {
|
||||
// 场景检测失败不应该导致整个处理失败
|
||||
warn!("异步场景检测失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 异步检查是否需要切分视频
|
||||
let should_segment = material.needs_segmentation(config.max_segment_duration) ||
|
||||
(matches!(material.material_type, MaterialType::Video) && material.scene_detection.is_some());
|
||||
|
||||
if should_segment {
|
||||
let _ = event_bus.publish_material_processing_progress(
|
||||
material_id.to_string(),
|
||||
file_name.to_string(),
|
||||
"视频切分".to_string(),
|
||||
75.0,
|
||||
).await;
|
||||
|
||||
let repository_clone = Arc::clone(&repository);
|
||||
let material_clone = material.clone();
|
||||
let config_clone = config.clone();
|
||||
|
||||
match task::spawn_blocking(move || {
|
||||
MaterialService::segment_video(&repository_clone, &material_clone, &config_clone)
|
||||
}).await? {
|
||||
Ok(_) => {
|
||||
info!("异步视频切分完成: {}", material.name);
|
||||
}
|
||||
Err(e) => {
|
||||
MaterialService::update_material_status(
|
||||
&repository,
|
||||
material_id,
|
||||
ProcessingStatus::Failed,
|
||||
Some(format!("视频切分失败: {}", e)),
|
||||
)?;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标记为完成
|
||||
MaterialService::update_material_status(
|
||||
&repository,
|
||||
material_id,
|
||||
ProcessingStatus::Completed,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// 发布处理完成事件
|
||||
let _ = event_bus.publish_material_processing_progress(
|
||||
material_id.to_string(),
|
||||
file_name.to_string(),
|
||||
"处理完成".to_string(),
|
||||
100.0,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -380,7 +380,7 @@ impl MaterialService {
|
||||
}
|
||||
|
||||
/// 提取素材元数据
|
||||
fn extract_metadata(file_path: &str, material_type: &MaterialType) -> Result<MaterialMetadata> {
|
||||
pub fn extract_metadata(file_path: &str, material_type: &MaterialType) -> Result<MaterialMetadata> {
|
||||
match material_type {
|
||||
MaterialType::Video | MaterialType::Audio => {
|
||||
// 使用 FFmpeg 提取视频/音频元数据
|
||||
@@ -398,7 +398,7 @@ impl MaterialService {
|
||||
}
|
||||
|
||||
/// 检测视频场景
|
||||
fn detect_video_scenes(
|
||||
pub fn detect_video_scenes(
|
||||
file_path: &str,
|
||||
threshold: f64,
|
||||
) -> Result<crate::data::models::material::SceneDetection> {
|
||||
@@ -460,7 +460,7 @@ impl MaterialService {
|
||||
}
|
||||
|
||||
/// 切分视频
|
||||
fn segment_video(
|
||||
pub fn segment_video(
|
||||
repository: &MaterialRepository,
|
||||
material: &Material,
|
||||
config: &MaterialProcessingConfig,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod project_service;
|
||||
pub mod material_service;
|
||||
pub mod async_material_service;
|
||||
|
||||
@@ -18,6 +18,11 @@ impl MaterialRepository {
|
||||
Ok(Self { connection })
|
||||
}
|
||||
|
||||
/// 获取数据库连接(用于创建新的仓库实例)
|
||||
pub fn get_connection(&self) -> Arc<Mutex<Connection>> {
|
||||
Arc::clone(&self.connection)
|
||||
}
|
||||
|
||||
/// 创建素材
|
||||
pub fn create(&self, material: &Material) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::broadcast;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::data::models::material::MaterialImportResult;
|
||||
|
||||
/// 事件类型定义
|
||||
/// 遵循模块化设计原则,清晰分类不同类型的事件
|
||||
@@ -46,6 +47,32 @@ pub enum DataEvent {
|
||||
ProjectDataChanged { project_id: String },
|
||||
DatabaseError { error_message: String },
|
||||
DataSyncCompleted,
|
||||
/// 素材导入进度事件
|
||||
MaterialImportProgress {
|
||||
project_id: String,
|
||||
current_file: String,
|
||||
processed_count: u32,
|
||||
total_count: u32,
|
||||
current_status: String,
|
||||
progress_percentage: f64,
|
||||
},
|
||||
/// 素材导入完成事件
|
||||
MaterialImportCompleted {
|
||||
project_id: String,
|
||||
result: MaterialImportResult,
|
||||
},
|
||||
/// 素材导入失败事件
|
||||
MaterialImportFailed {
|
||||
project_id: String,
|
||||
error: String,
|
||||
},
|
||||
/// 单个素材处理进度事件
|
||||
MaterialProcessingProgress {
|
||||
material_id: String,
|
||||
file_name: String,
|
||||
stage: String, // "metadata", "scene_detection", "video_splitting"
|
||||
progress_percentage: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// UI事件
|
||||
@@ -201,6 +228,71 @@ impl EventBusManager {
|
||||
value,
|
||||
})).await
|
||||
}
|
||||
|
||||
/// 发布素材导入进度事件
|
||||
pub async fn publish_material_import_progress(
|
||||
&self,
|
||||
project_id: String,
|
||||
current_file: String,
|
||||
processed_count: u32,
|
||||
total_count: u32,
|
||||
current_status: String,
|
||||
) -> Result<(), String> {
|
||||
let progress_percentage = if total_count > 0 {
|
||||
(processed_count as f64 / total_count as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
self.event_bus.publish(Event::Data(DataEvent::MaterialImportProgress {
|
||||
project_id,
|
||||
current_file,
|
||||
processed_count,
|
||||
total_count,
|
||||
current_status,
|
||||
progress_percentage,
|
||||
})).await
|
||||
}
|
||||
|
||||
/// 发布素材导入完成事件
|
||||
pub async fn publish_material_import_completed(
|
||||
&self,
|
||||
project_id: String,
|
||||
result: MaterialImportResult,
|
||||
) -> Result<(), String> {
|
||||
self.event_bus.publish(Event::Data(DataEvent::MaterialImportCompleted {
|
||||
project_id,
|
||||
result,
|
||||
})).await
|
||||
}
|
||||
|
||||
/// 发布素材导入失败事件
|
||||
pub async fn publish_material_import_failed(
|
||||
&self,
|
||||
project_id: String,
|
||||
error: String,
|
||||
) -> Result<(), String> {
|
||||
self.event_bus.publish(Event::Data(DataEvent::MaterialImportFailed {
|
||||
project_id,
|
||||
error,
|
||||
})).await
|
||||
}
|
||||
|
||||
/// 发布素材处理进度事件
|
||||
pub async fn publish_material_processing_progress(
|
||||
&self,
|
||||
material_id: String,
|
||||
file_name: String,
|
||||
stage: String,
|
||||
progress_percentage: f64,
|
||||
) -> Result<(), String> {
|
||||
self.event_bus.publish(Event::Data(DataEvent::MaterialProcessingProgress {
|
||||
material_id,
|
||||
file_name,
|
||||
stage,
|
||||
progress_percentage,
|
||||
})).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventBusManager {
|
||||
|
||||
@@ -46,6 +46,9 @@ pub fn run() {
|
||||
commands::system_commands::record_performance_metric,
|
||||
commands::system_commands::cleanup_invalid_projects,
|
||||
commands::material_commands::import_materials,
|
||||
commands::material_commands::import_materials_async,
|
||||
commands::material_commands::select_material_folders,
|
||||
commands::material_commands::scan_folder_materials,
|
||||
commands::material_commands::get_project_materials,
|
||||
commands::material_commands::get_material_by_id,
|
||||
commands::material_commands::delete_material,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use tauri::{command, State};
|
||||
use std::sync::Arc;
|
||||
use crate::app_state::AppState;
|
||||
use crate::business::services::material_service::MaterialService;
|
||||
use crate::business::services::async_material_service::AsyncMaterialService;
|
||||
use crate::data::repositories::material_repository::MaterialRepository;
|
||||
use crate::infrastructure::ffmpeg::FFmpegService;
|
||||
use crate::data::models::material::{
|
||||
CreateMaterialRequest, MaterialImportResult, MaterialProcessingConfig,
|
||||
@@ -31,6 +34,158 @@ pub async fn import_materials(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 异步导入素材命令
|
||||
/// 遵循 Tauri 开发规范的异步命令设计模式
|
||||
/// 提供实时进度反馈和非阻塞用户体验
|
||||
#[command]
|
||||
pub async fn import_materials_async(
|
||||
state: State<'_, AppState>,
|
||||
request: CreateMaterialRequest,
|
||||
_app_handle: tauri::AppHandle,
|
||||
) -> Result<MaterialImportResult, String> {
|
||||
// 获取数据库连接,避免持有MutexGuard
|
||||
let connection = {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
repository.get_connection()
|
||||
}; // repository_guard在这里被释放
|
||||
|
||||
let mut config = MaterialProcessingConfig::default();
|
||||
config.auto_process = Some(request.auto_process);
|
||||
if let Some(max_duration) = request.max_segment_duration {
|
||||
config.max_segment_duration = max_duration;
|
||||
}
|
||||
|
||||
// 创建一个新的MaterialRepository实例用于异步操作
|
||||
let async_repository = MaterialRepository::new(connection)
|
||||
.map_err(|e| format!("创建异步仓库失败: {}", e))?;
|
||||
let repository_arc = Arc::new(async_repository);
|
||||
|
||||
// 获取事件总线
|
||||
let event_bus = state.event_bus_manager.clone();
|
||||
|
||||
// 直接执行异步导入
|
||||
AsyncMaterialService::import_materials_async(
|
||||
repository_arc,
|
||||
request,
|
||||
config,
|
||||
event_bus,
|
||||
).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 选择文件夹进行批量导入
|
||||
#[command]
|
||||
pub async fn select_material_folders(app_handle: tauri::AppHandle) -> Result<Vec<String>, String> {
|
||||
use crate::presentation::commands::system_commands::select_directory;
|
||||
|
||||
// 使用现有的目录选择功能,但允许多选
|
||||
// 注意:这是一个简化实现,实际可能需要修改系统命令以支持多选
|
||||
match select_directory(app_handle) {
|
||||
Ok(Some(folder_path)) => Ok(vec![folder_path]),
|
||||
Ok(None) => Ok(Vec::new()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// 扫描文件夹中的素材文件
|
||||
#[command]
|
||||
pub async fn scan_folder_materials(
|
||||
folder_paths: Vec<String>,
|
||||
recursive: bool,
|
||||
file_types: Vec<String>, // 文件类型过滤,如 ["mp4", "avi", "mov"]
|
||||
) -> Result<Vec<String>, String> {
|
||||
use std::path::Path;
|
||||
|
||||
let mut all_files = Vec::new();
|
||||
|
||||
for folder_path in folder_paths {
|
||||
let folder = Path::new(&folder_path);
|
||||
if !folder.exists() || !folder.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let files = if recursive {
|
||||
scan_directory_recursive(folder, &file_types)?
|
||||
} else {
|
||||
scan_directory_shallow(folder, &file_types)?
|
||||
};
|
||||
|
||||
all_files.extend(files);
|
||||
}
|
||||
|
||||
// 去重
|
||||
all_files.sort();
|
||||
all_files.dedup();
|
||||
|
||||
Ok(all_files)
|
||||
}
|
||||
|
||||
/// 递归扫描目录
|
||||
fn scan_directory_recursive(dir: &std::path::Path, file_types: &[String]) -> Result<Vec<String>, String> {
|
||||
use std::fs;
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(dir)
|
||||
.map_err(|e| format!("读取目录失败 {}: {}", dir.display(), e))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("读取目录项失败: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
// 递归扫描子目录
|
||||
let sub_files = scan_directory_recursive(&path, file_types)?;
|
||||
files.extend(sub_files);
|
||||
} else if path.is_file() {
|
||||
// 检查文件类型
|
||||
if let Some(extension) = path.extension() {
|
||||
if let Some(ext_str) = extension.to_str() {
|
||||
let ext_lower = ext_str.to_lowercase();
|
||||
if file_types.is_empty() || file_types.contains(&ext_lower) {
|
||||
files.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// 浅层扫描目录(不递归)
|
||||
fn scan_directory_shallow(dir: &std::path::Path, file_types: &[String]) -> Result<Vec<String>, String> {
|
||||
use std::fs;
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(dir)
|
||||
.map_err(|e| format!("读取目录失败 {}: {}", dir.display(), e))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("读取目录项失败: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() {
|
||||
// 检查文件类型
|
||||
if let Some(extension) = path.extension() {
|
||||
if let Some(ext_str) = extension.to_str() {
|
||||
let ext_lower = ext_str.to_lowercase();
|
||||
if file_types.is_empty() || file_types.contains(&ext_lower) {
|
||||
files.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// 获取项目素材列表命令
|
||||
#[command]
|
||||
pub async fn get_project_materials(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Upload, FileText, AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useMaterialStore } from '../store/materialStore';
|
||||
import { CreateMaterialRequest, MaterialImportResult } from '../types/material';
|
||||
|
||||
@@ -24,10 +25,14 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||
// isImporting,
|
||||
importProgress,
|
||||
error,
|
||||
importMaterials,
|
||||
importMaterialsAsync,
|
||||
updateImportProgress,
|
||||
selectMaterialFiles,
|
||||
selectMaterialFolders,
|
||||
scanFolderMaterials,
|
||||
validateMaterialFiles,
|
||||
checkFFmpegAvailable,
|
||||
getSupportedExtensions,
|
||||
clearError
|
||||
} = useMaterialStore();
|
||||
|
||||
@@ -35,7 +40,11 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||
const [autoProcess, setAutoProcess] = useState(true);
|
||||
const [maxSegmentDuration, setMaxSegmentDuration] = useState(300); // 5分钟
|
||||
const [ffmpegAvailable, setFFmpegAvailable] = useState(false);
|
||||
const [step, setStep] = useState<'select' | 'configure' | 'importing' | 'complete'>('select');
|
||||
const [step, setStep] = useState<'select' | 'batch' | 'configure' | 'importing' | 'complete'>('select');
|
||||
const [importMode, setImportMode] = useState<'files' | 'folders'>('files');
|
||||
const [selectedFolders, setSelectedFolders] = useState<string[]>([]);
|
||||
const [recursiveScan, setRecursiveScan] = useState(true);
|
||||
const [selectedFileTypes, setSelectedFileTypes] = useState<string[]>([]);
|
||||
|
||||
// 检查 FFmpeg 可用性
|
||||
useEffect(() => {
|
||||
@@ -53,6 +62,48 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||
}
|
||||
}, [isOpen, clearError]);
|
||||
|
||||
// 设置事件监听器用于接收导入进度更新
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const setupEventListeners = async () => {
|
||||
// 监听导入进度事件
|
||||
const unlistenProgress = await listen('material_import_progress', (event: any) => {
|
||||
const progressData = event.payload;
|
||||
updateImportProgress({
|
||||
current_file: progressData.current_file,
|
||||
processed_count: progressData.processed_count,
|
||||
total_count: progressData.total_count,
|
||||
current_status: progressData.current_status,
|
||||
progress_percentage: progressData.progress_percentage,
|
||||
});
|
||||
});
|
||||
|
||||
// 监听导入完成事件
|
||||
const unlistenCompleted = await listen('material_import_completed', (event: any) => {
|
||||
const result = event.payload;
|
||||
setStep('complete');
|
||||
onImportComplete(result);
|
||||
});
|
||||
|
||||
// 监听导入失败事件
|
||||
const unlistenFailed = await listen('material_import_failed', (event: any) => {
|
||||
const errorMessage = event.payload;
|
||||
console.error('导入失败:', errorMessage);
|
||||
setStep('configure'); // 返回配置步骤
|
||||
});
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
unlistenProgress();
|
||||
unlistenCompleted();
|
||||
unlistenFailed();
|
||||
};
|
||||
};
|
||||
|
||||
setupEventListeners();
|
||||
}, [isOpen, updateImportProgress, onImportComplete]);
|
||||
|
||||
// 选择文件
|
||||
const handleSelectFiles = async () => {
|
||||
try {
|
||||
@@ -61,6 +112,7 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||
const validFiles = await validateMaterialFiles(filePaths);
|
||||
setSelectedFiles(validFiles);
|
||||
if (validFiles.length > 0) {
|
||||
setImportMode('files');
|
||||
setStep('configure');
|
||||
}
|
||||
}
|
||||
@@ -69,7 +121,42 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 开始导入
|
||||
// 选择文件夹
|
||||
const handleSelectFolders = async () => {
|
||||
try {
|
||||
const folderPaths = await selectMaterialFolders();
|
||||
if (folderPaths.length > 0) {
|
||||
setSelectedFolders(folderPaths);
|
||||
setImportMode('folders');
|
||||
setStep('batch');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择文件夹失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 扫描文件夹并进入配置步骤
|
||||
const handleScanFolders = async () => {
|
||||
try {
|
||||
const supportedTypes = await getSupportedExtensions();
|
||||
const fileTypes = selectedFileTypes.length > 0 ? selectedFileTypes : supportedTypes;
|
||||
|
||||
const scannedFiles = await scanFolderMaterials(selectedFolders, recursiveScan, fileTypes);
|
||||
if (scannedFiles.length > 0) {
|
||||
const validFiles = await validateMaterialFiles(scannedFiles);
|
||||
setSelectedFiles(validFiles);
|
||||
if (validFiles.length > 0) {
|
||||
setStep('configure');
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到任何支持的文件');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('扫描文件夹失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始导入(使用异步版本)
|
||||
const handleStartImport = async () => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
@@ -83,11 +170,12 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||
max_segment_duration: maxSegmentDuration,
|
||||
};
|
||||
|
||||
const result = await importMaterials(request);
|
||||
setStep('complete');
|
||||
onImportComplete(result);
|
||||
// 使用异步导入,进度更新通过事件监听器处理
|
||||
await importMaterialsAsync(request);
|
||||
// 注意:完成状态由事件监听器设置,这里不需要手动设置
|
||||
} catch (error) {
|
||||
console.error('导入失败:', error);
|
||||
setStep('configure'); // 返回配置步骤
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,27 +232,100 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤 1: 选择文件 */}
|
||||
{/* 步骤 1: 选择导入方式 */}
|
||||
{step === 'select' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<Upload className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">选择素材文件</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">选择导入方式</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
支持视频、音频、图片等多种格式
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSelectFiles}
|
||||
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Upload className="w-5 h-5 mr-2" />
|
||||
选择文件
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={handleSelectFiles}
|
||||
className="p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors group"
|
||||
>
|
||||
<FileText className="w-12 h-12 text-gray-400 group-hover:text-blue-500 mx-auto mb-3" />
|
||||
<h4 className="text-lg font-medium text-gray-900 mb-2">选择文件</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
手动选择单个或多个文件
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSelectFolders}
|
||||
className="p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors group"
|
||||
>
|
||||
<Upload className="w-12 h-12 text-gray-400 group-hover:text-blue-500 mx-auto mb-3" />
|
||||
<h4 className="text-lg font-medium text-gray-900 mb-2">选择文件夹</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
批量导入整个文件夹
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤 2: 配置导入选项 */}
|
||||
{/* 步骤 2: 批量导入配置 */}
|
||||
{step === 'batch' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<Upload className="w-16 h-16 text-blue-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">配置批量导入</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
已选择 {selectedFolders.length} 个文件夹
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">选择的文件夹:</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedFolders.map((folder, index) => (
|
||||
<div key={index} className="text-sm text-gray-600 bg-white p-2 rounded border">
|
||||
{folder}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="recursive"
|
||||
checked={recursiveScan}
|
||||
onChange={(e) => setRecursiveScan(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="recursive" className="ml-2 text-sm text-gray-700">
|
||||
递归扫描子文件夹
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
文件类型过滤 (留空表示所有支持的类型)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="例如: mp4,avi,mov (用逗号分隔)"
|
||||
value={selectedFileTypes.join(',')}
|
||||
onChange={(e) => setSelectedFileTypes(
|
||||
e.target.value.split(',').map(t => t.trim()).filter(t => t)
|
||||
)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤 3: 配置导入选项 */}
|
||||
{step === 'configure' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -282,7 +443,7 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200">
|
||||
{step === 'configure' && (
|
||||
{step === 'batch' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setStep('select')}
|
||||
@@ -290,6 +451,24 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
onClick={handleScanFolders}
|
||||
disabled={selectedFolders.length === 0}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
扫描文件夹
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'configure' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setStep(importMode === 'folders' ? 'batch' : 'select')}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartImport}
|
||||
disabled={selectedFiles.length === 0}
|
||||
@@ -299,7 +478,7 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{(step === 'select' || step === 'complete') && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
||||
@@ -34,11 +34,25 @@ interface MaterialState {
|
||||
loadMaterials: (projectId: string) => Promise<void>;
|
||||
loadMaterialStats: (projectId: string) => Promise<void>;
|
||||
importMaterials: (request: CreateMaterialRequest) => Promise<MaterialImportResult>;
|
||||
importMaterialsAsync: (request: CreateMaterialRequest) => Promise<MaterialImportResult>;
|
||||
getMaterialById: (id: string) => Promise<Material | null>;
|
||||
getMaterialSegments: (materialId: string) => Promise<MaterialSegment[]>;
|
||||
deleteMaterial: (id: string) => Promise<void>;
|
||||
processMaterials: (materialIds: string[]) => Promise<void>;
|
||||
updateMaterialStatus: (id: string, status: ProcessingStatus, errorMessage?: string) => Promise<void>;
|
||||
|
||||
// 进度更新方法
|
||||
updateImportProgress: (progress: {
|
||||
current_file: string;
|
||||
processed_count: number;
|
||||
total_count: number;
|
||||
current_status: string;
|
||||
progress_percentage: number;
|
||||
}) => void;
|
||||
|
||||
// 批量导入相关方法
|
||||
selectMaterialFolders: () => Promise<string[]>;
|
||||
scanFolderMaterials: (folderPaths: string[], recursive: boolean, fileTypes: string[]) => Promise<string[]>;
|
||||
cleanupInvalidMaterials: (projectId: string) => Promise<string>;
|
||||
setCurrentMaterial: (material: Material | null) => void;
|
||||
clearError: () => void;
|
||||
@@ -88,7 +102,7 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// 导入素材
|
||||
// 导入素材(同步版本)
|
||||
importMaterials: async (request: CreateMaterialRequest) => {
|
||||
set({ isImporting: true, error: null, importProgress: {
|
||||
current_file: '',
|
||||
@@ -100,10 +114,10 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
|
||||
|
||||
try {
|
||||
const result = await invoke<MaterialImportResult>('import_materials', { request });
|
||||
|
||||
|
||||
// 更新素材列表
|
||||
const { materials } = get();
|
||||
set({
|
||||
set({
|
||||
materials: [...materials, ...result.created_materials],
|
||||
isImporting: false,
|
||||
importProgress: null
|
||||
@@ -114,8 +128,8 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error as string,
|
||||
set({
|
||||
error: error as string,
|
||||
isImporting: false,
|
||||
importProgress: null
|
||||
});
|
||||
@@ -123,6 +137,60 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// 异步导入素材(新版本,支持实时进度)
|
||||
importMaterialsAsync: async (request: CreateMaterialRequest) => {
|
||||
set({ isImporting: true, error: null, importProgress: {
|
||||
current_file: '',
|
||||
processed_count: 0,
|
||||
total_count: request.file_paths.length,
|
||||
current_status: '准备导入...',
|
||||
errors: []
|
||||
}});
|
||||
|
||||
try {
|
||||
const result = await invoke<MaterialImportResult>('import_materials_async', { request });
|
||||
|
||||
// 更新素材列表
|
||||
const { materials } = get();
|
||||
set({
|
||||
materials: [...materials, ...result.created_materials],
|
||||
isImporting: false,
|
||||
importProgress: null
|
||||
});
|
||||
|
||||
// 重新加载统计信息
|
||||
get().loadMaterialStats(request.project_id);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error as string,
|
||||
isImporting: false,
|
||||
importProgress: null
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新导入进度
|
||||
updateImportProgress: (progress: {
|
||||
current_file: string;
|
||||
processed_count: number;
|
||||
total_count: number;
|
||||
current_status: string;
|
||||
progress_percentage: number;
|
||||
}) => {
|
||||
set({
|
||||
importProgress: {
|
||||
current_file: progress.current_file,
|
||||
processed_count: progress.processed_count,
|
||||
total_count: progress.total_count,
|
||||
current_status: progress.current_status,
|
||||
errors: get().importProgress?.errors || []
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 获取素材详情
|
||||
getMaterialById: async (id: string) => {
|
||||
try {
|
||||
@@ -325,4 +393,28 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 选择素材文件夹
|
||||
selectMaterialFolders: async () => {
|
||||
try {
|
||||
return await invoke<string[]>('select_material_folders');
|
||||
} catch (error) {
|
||||
console.error('选择文件夹失败:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 扫描文件夹中的素材文件
|
||||
scanFolderMaterials: async (folderPaths: string[], recursive: boolean, fileTypes: string[]) => {
|
||||
try {
|
||||
return await invoke<string[]>('scan_folder_materials', {
|
||||
folderPaths,
|
||||
recursive,
|
||||
fileTypes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('扫描文件夹失败:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user