Files
mixvideo-v2/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs
imeepos 7c43f7a7ed feat: 实现全局目录设置功能
- 扩展AppConfig结构体,添加DirectorySettings字段
- 创建DirectorySettingsService服务,提供CRUD操作
- 添加Tauri命令接口,支持前端调用
- 修改现有文件选择逻辑,优先使用保存的目录设置
- 创建DirectorySettingsModal等UI组件
- 集成到素材导入、模板导入、剪影导出等界面
- 实现自动记忆功能,可选择是否记住路径
- 编写完整的单元测试,确保功能可靠性

功能特点:
- 支持素材导入、模板导入、剪影导出、项目导出、缩略图导出等目录设置
- 自动记忆用户选择的目录,下次使用时作为默认路径
- 提供友好的UI界面管理目录设置
- 支持批量更新、清除、重置等操作
- 完整的错误处理和验证机制
2025-07-25 18:38:50 +08:00

2233 lines
76 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use tauri::{command, State, Emitter};
use std::sync::Arc;
use crate::app_state::AppState;
use crate::business::services::material_service::MaterialService;
use tracing::{info, warn, error, debug};
use crate::data::repositories::material_repository::MaterialRepository;
use crate::infrastructure::ffmpeg::FFmpegService;
use crate::data::models::material::{
CreateMaterialRequest, MaterialImportResult, MaterialProcessingConfig,
Material, MaterialStats, ProcessingStatus, MaterialMetadata
};
/// 导入素材命令
/// 遵循 Tauri 开发规范的命令设计模式
#[command]
pub async fn import_materials(
state: State<'_, AppState>,
request: CreateMaterialRequest,
) -> Result<MaterialImportResult, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
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;
}
if let Some(skip_ms) = request.skip_start_ms {
config.skip_start_ms = Some(skip_ms);
}
MaterialService::import_materials(repository, request, &config)
.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> {
// 添加调试日志
println!("Import request received with model_id: {:?}", request.model_id);
// 获取数据库实例
let database = state.get_database();
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(database)
.map_err(|e| format!("创建异步仓库失败: {}", e))?;
let repository_arc = Arc::new(async_repository);
// 使用简化的异步导入,直接通过 Tauri 事件发送进度
import_materials_with_tauri_events(
repository_arc,
request,
config,
app_handle,
).await.map_err(|e| e.to_string())
}
/// 使用 Tauri 事件系统的批量异步导入实现
/// 支持并发处理多个素材文件,遵循 Tauri 开发规范
async fn import_materials_with_tauri_events(
repository: Arc<MaterialRepository>,
request: CreateMaterialRequest,
config: MaterialProcessingConfig,
app_handle: tauri::AppHandle,
) -> Result<MaterialImportResult, anyhow::Error> {
use std::path::Path;
use std::time::Instant;
use std::sync::atomic::{AtomicU32, Ordering};
use tokio::sync::Semaphore;
use tracing::{info, warn, error, debug};
let start_time = Instant::now();
info!(
project_id = %request.project_id,
file_count = request.file_paths.len(),
"开始批量异步导入素材文件"
);
// 初始化结果统计
let total_files = request.file_paths.len() as u32;
let processed_count = Arc::new(AtomicU32::new(0));
let skipped_count = Arc::new(AtomicU32::new(0));
let failed_count = Arc::new(AtomicU32::new(0));
let created_materials = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let errors = Arc::new(tokio::sync::Mutex::new(Vec::new()));
// 限制并发数量,防止资源耗尽(遵循性能优化原则)
// 优先使用请求中的配置,然后是全局配置,最后是默认值
let base_concurrent = request.max_concurrent_imports
.or(config.max_concurrent_imports)
.unwrap_or(4);
// 根据系统资源动态调整并发数量
let max_concurrent = std::cmp::min(
adjust_concurrent_based_on_resources(base_concurrent),
request.file_paths.len()
);
let semaphore = Arc::new(Semaphore::new(max_concurrent));
info!("使用 {} 个并发任务处理 {} 个文件", max_concurrent, total_files);
// 发布开始导入事件
let _ = app_handle.emit("material_import_progress", serde_json::json!({
"current_file": "准备导入...",
"processed_count": 0,
"total_count": total_files,
"current_status": "正在准备导入文件",
"progress_percentage": 0.0
}));
// 添加熔断器机制,防止大量失败导致系统过载
let failure_threshold = (total_files as f64 * 0.5).ceil() as u32; // 50%失败率触发熔断
let circuit_breaker = Arc::new(std::sync::atomic::AtomicBool::new(false));
// 内存监控器,防止内存耗尽
let memory_monitor = Arc::new(std::sync::atomic::AtomicBool::new(false));
let memory_threshold_gb = 1.0; // 当可用内存低于1GB时暂停新任务
// 创建并发任务处理文件
let mut tasks = Vec::new();
// 启动资源监控任务
let resource_monitor_task = {
let memory_monitor_clone = Arc::clone(&memory_monitor);
let circuit_breaker_clone = Arc::clone(&circuit_breaker);
tokio::spawn(async move {
let mut check_interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
loop {
check_interval.tick().await;
// 检查内存状态
let available_memory = get_available_memory_gb();
if available_memory < memory_threshold_gb {
warn!(
available_memory = available_memory,
threshold = memory_threshold_gb,
"系统内存不足,触发内存保护"
);
memory_monitor_clone.store(true, std::sync::atomic::Ordering::Relaxed);
} else if available_memory > memory_threshold_gb * 2.0 {
// 内存恢复充足时,解除内存保护
if memory_monitor_clone.load(std::sync::atomic::Ordering::Relaxed) {
info!("内存恢复充足,解除内存保护");
memory_monitor_clone.store(false, std::sync::atomic::Ordering::Relaxed);
}
}
// 如果熔断器已触发,停止监控
if circuit_breaker_clone.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
}
})
};
for (index, file_path) in request.file_paths.iter().enumerate() {
let repository_clone = Arc::clone(&repository);
let config_clone = config.clone();
let app_handle_clone = app_handle.clone();
let file_path_clone = file_path.clone();
let project_id_clone = request.project_id.clone();
let model_id_clone = request.model_id.clone();
let semaphore_clone = Arc::clone(&semaphore);
let processed_count_clone = Arc::clone(&processed_count);
let skipped_count_clone = Arc::clone(&skipped_count);
let failed_count_clone = Arc::clone(&failed_count);
let created_materials_clone = Arc::clone(&created_materials);
let errors_clone = Arc::clone(&errors);
let circuit_breaker_clone = Arc::clone(&circuit_breaker);
let memory_monitor_clone = Arc::clone(&memory_monitor);
let task = tokio::spawn(async move {
// 检查熔断器状态
if circuit_breaker_clone.load(std::sync::atomic::Ordering::Relaxed) {
warn!(file_path = %file_path_clone, "熔断器已触发,跳过文件处理");
skipped_count_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return;
}
// 检查内存状态
if memory_monitor_clone.load(std::sync::atomic::Ordering::Relaxed) {
warn!(file_path = %file_path_clone, "内存不足,跳过文件处理");
skipped_count_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return;
}
// 在处理前检查内存
let available_memory = get_available_memory_gb();
if available_memory < memory_threshold_gb {
warn!(
file_path = %file_path_clone,
available_memory = available_memory,
threshold = memory_threshold_gb,
"内存不足,触发内存保护"
);
memory_monitor_clone.store(true, std::sync::atomic::Ordering::Relaxed);
skipped_count_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return;
}
// 获取信号量许可,限制并发数
let _permit = semaphore_clone.acquire().await.unwrap();
debug!(
file_path = %file_path_clone,
index = index,
"开始并发处理文件"
);
// 发送当前文件处理进度
let current_processed = processed_count_clone.load(Ordering::Relaxed);
let _ = app_handle_clone.emit("material_import_progress", serde_json::json!({
"current_file": file_path_clone,
"processed_count": current_processed,
"total_count": total_files,
"current_status": format!("正在处理文件: {}", Path::new(&file_path_clone).file_name()
.and_then(|n| n.to_str()).unwrap_or("unknown")),
"progress_percentage": (current_processed as f64 / total_files as f64) * 100.0
}));
// 处理单个文件 - 使用完整的业务逻辑,带重试机制
let result = process_single_file_with_retry(
repository_clone,
&project_id_clone,
&file_path_clone,
&config_clone,
model_id_clone,
app_handle_clone.clone(),
3, // 最大重试次数
).await;
match result {
Ok(Some(material)) => {
info!(
file_path = %file_path_clone,
material_id = %material.id,
"文件并发处理成功"
);
// 更新共享状态
{
let mut materials = created_materials_clone.lock().await;
materials.push(material);
}
processed_count_clone.fetch_add(1, Ordering::Relaxed);
// 定期触发垃圾回收,防止内存积累
let current_processed = processed_count_clone.load(Ordering::Relaxed);
if current_processed % 10 == 0 {
// 每处理10个文件触发一次垃圾回收提示
tokio::task::yield_now().await;
std::hint::black_box(()); // 防止编译器优化
}
}
Ok(None) => {
// 文件被跳过(重复)
warn!(file_path = %file_path_clone, "文件被跳过(重复)");
skipped_count_clone.fetch_add(1, Ordering::Relaxed);
}
Err(e) => {
error!(
file_path = %file_path_clone,
error = %e,
"文件并发处理失败(已重试)"
);
// 更新共享状态
{
let mut errors_vec = errors_clone.lock().await;
errors_vec.push(format!("处理文件 {} 失败: {}", file_path_clone, e));
}
let current_failed = failed_count_clone.fetch_add(1, Ordering::Relaxed) + 1;
// 检查是否需要触发熔断器
if current_failed >= failure_threshold {
warn!(
current_failed = current_failed,
threshold = failure_threshold,
"失败率过高,触发熔断器"
);
circuit_breaker_clone.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
}
});
tasks.push(task);
}
// 等待所有任务完成,带超时保护
info!("等待 {} 个并发任务完成", tasks.len());
let timeout_duration = tokio::time::Duration::from_secs(300); // 5分钟超时
// 使用简单的循环等待,避免额外依赖
let mut completed_tasks = 0;
for task in tasks {
let result = tokio::time::timeout(timeout_duration, task).await;
match result {
Ok(task_result) => {
completed_tasks += 1;
if let Err(e) = task_result {
error!(error = %e, "并发任务执行失败");
}
}
Err(_) => {
error!("单个任务超时");
// 超时的任务计入失败
failed_count.fetch_add(1, Ordering::Relaxed);
}
}
}
info!("批量导入完成,共处理 {} 个任务", completed_tasks);
// 停止资源监控任务
resource_monitor_task.abort();
// 构建最终结果
let final_processed = processed_count.load(Ordering::Relaxed);
let final_skipped = skipped_count.load(Ordering::Relaxed);
let final_failed = failed_count.load(Ordering::Relaxed);
let final_materials = created_materials.lock().await.clone();
let final_errors = errors.lock().await.clone();
let processing_time = start_time.elapsed().as_secs_f64();
let result = MaterialImportResult {
total_files,
processed_files: final_processed,
skipped_files: final_skipped,
failed_files: final_failed,
created_materials: final_materials,
errors: final_errors,
processing_time,
};
let success = result.failed_files == 0;
info!(
processed_files = result.processed_files,
skipped_files = result.skipped_files,
failed_files = result.failed_files,
processing_time = result.processing_time,
"批量异步素材导入完成"
);
// 发布导入完成事件
if success {
let _ = app_handle.emit("material_import_completed", &result);
} else {
let _ = app_handle.emit("material_import_failed",
format!("导入失败,{} 个文件处理失败", result.failed_files));
}
Ok(result)
}
/// 根据系统资源动态调整并发数量
/// 遵循性能优化原则,防止系统资源耗尽
fn adjust_concurrent_based_on_resources(base_concurrent: usize) -> usize {
// 获取系统内存信息
let available_memory_gb = get_available_memory_gb();
let cpu_cores = num_cpus::get();
// 基于内存的调整:每个并发任务预估需要 500MB 内存
let memory_based_limit = std::cmp::max(1, (available_memory_gb * 2.0) as usize);
// 基于CPU核心数的调整不超过CPU核心数的1.5倍
let cpu_based_limit = std::cmp::max(1, (cpu_cores as f64 * 1.5) as usize);
// 取最小值作为最终限制
let resource_limit = std::cmp::min(memory_based_limit, cpu_based_limit);
let final_concurrent = std::cmp::min(base_concurrent, resource_limit);
info!(
base_concurrent = base_concurrent,
available_memory_gb = available_memory_gb,
cpu_cores = cpu_cores,
memory_based_limit = memory_based_limit,
cpu_based_limit = cpu_based_limit,
final_concurrent = final_concurrent,
"动态调整并发数量"
);
final_concurrent
}
/// 获取可用内存GB
fn get_available_memory_gb() -> f64 {
#[cfg(target_os = "windows")]
{
use std::mem;
use winapi::um::sysinfoapi::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
unsafe {
let mut mem_status: MEMORYSTATUSEX = mem::zeroed();
mem_status.dwLength = mem::size_of::<MEMORYSTATUSEX>() as u32;
if GlobalMemoryStatusEx(&mut mem_status) != 0 {
let available_bytes = mem_status.ullAvailPhys;
return available_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
}
}
}
// 默认假设有4GB可用内存
4.0
}
/// 带重试机制的单文件处理函数
/// 遵循 Tauri 开发规范的错误处理和重试机制
async fn process_single_file_with_retry(
repository: Arc<MaterialRepository>,
project_id: &str,
file_path: &str,
config: &MaterialProcessingConfig,
model_id: Option<String>,
app_handle: tauri::AppHandle,
max_retries: usize,
) -> Result<Option<Material>, anyhow::Error> {
let mut last_error = None;
for attempt in 0..=max_retries {
if attempt > 0 {
// 指数退避重试策略
let delay_ms = std::cmp::min(1000 * (2_u64.pow(attempt as u32 - 1)), 10000);
debug!(
file_path = %file_path,
attempt = attempt,
delay_ms = delay_ms,
"重试处理文件"
);
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
}
match process_single_file_with_full_logic(
Arc::clone(&repository),
project_id,
file_path,
config,
model_id.clone(),
app_handle.clone(),
).await {
Ok(result) => {
if attempt > 0 {
info!(
file_path = %file_path,
attempt = attempt,
"文件处理重试成功"
);
}
return Ok(result);
}
Err(e) => {
warn!(
file_path = %file_path,
attempt = attempt,
error = %e,
"文件处理失败"
);
last_error = Some(e);
// 检查是否是不可重试的错误
if is_non_retryable_error(&last_error.as_ref().unwrap()) {
break;
}
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("未知错误")))
}
/// 判断是否为不可重试的错误
fn is_non_retryable_error(error: &anyhow::Error) -> bool {
let error_str = error.to_string().to_lowercase();
// 文件不存在、权限错误、格式不支持等不可重试
error_str.contains("file not found") ||
error_str.contains("permission denied") ||
error_str.contains("unsupported format") ||
error_str.contains("invalid file") ||
error_str.contains("文件不存在") ||
error_str.contains("权限不足") ||
error_str.contains("格式不支持") ||
error_str.contains("无效文件")
}
/// 使用完整业务逻辑的单文件处理函数
async fn process_single_file_with_full_logic(
repository: Arc<MaterialRepository>,
project_id: &str,
file_path: &str,
config: &MaterialProcessingConfig,
model_id: Option<String>,
app_handle: tauri::AppHandle,
) -> Result<Option<Material>, anyhow::Error> {
use anyhow::anyhow;
use std::path::Path;
use std::fs;
use tokio::task;
use crate::data::models::material::{Material, MaterialType, ProcessingStatus};
use crate::business::errors::error_utils;
use tracing::{info, warn, error, debug};
debug!(file_path = %file_path, "开始处理单个文件");
// 验证文件路径
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)? {
warn!(file_path = %file_path, "文件已存在,跳过");
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);
// 添加调试日志
println!("Creating material with model_id: {:?}", model_id);
// 创建素材对象(带模特绑定)
let mut material = Material::new_with_model(
project_id.to_string(),
file_name.clone(),
file_path.to_string(),
file_size,
md5_hash,
material_type.clone(),
model_id,
);
// 保存到数据库
repository.create(&material)?;
info!(material_id = %material.id, file_name = %file_name, "素材创建成功");
// 发送处理进度事件
let _ = app_handle.emit("material_processing_progress", serde_json::json!({
"material_id": material.id,
"file_name": file_name,
"stage": "开始处理",
"progress_percentage": 0.0
}));
// 如果启用自动处理,则进行完整的业务处理
if config.auto_process.unwrap_or(true) {
// 更新状态为处理中
MaterialService::update_material_status(
&repository,
&material.id,
ProcessingStatus::Processing,
None,
)?;
// 1. 提取元数据
let _ = app_handle.emit("material_processing_progress", serde_json::json!({
"material_id": material.id,
"file_name": file_name,
"stage": "提取元数据",
"progress_percentage": 25.0
}));
let original_path = material.original_path.clone();
let material_type_clone = material.material_type.clone();
match task::spawn_blocking(move || {
MaterialService::extract_metadata(&original_path, &material_type_clone)
}).await? {
Ok(metadata) => {
material.set_metadata(metadata);
repository.update(&material)?;
info!(material_id = %material.id, "元数据提取成功");
}
Err(e) => {
error!(material_id = %material.id, error = %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 _ = app_handle.emit("material_processing_progress", serde_json::json!({
"material_id": material.id,
"file_name": file_name,
"stage": "场景检测",
"progress_percentage": 50.0
}));
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!(material_id = %material.id, scenes = scene_detection.scenes.len(), "场景检测成功");
material.set_scene_detection(scene_detection);
repository.update(&material)?;
}
Err(e) => {
// 场景检测失败不应该导致整个处理失败
warn!(material_id = %material.id, error = %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 _ = app_handle.emit("material_processing_progress", serde_json::json!({
"material_id": material.id,
"file_name": file_name,
"stage": "视频切分",
"progress_percentage": 75.0
}));
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_id = %material.id, "视频切分完成");
}
Err(e) => {
error!(material_id = %material.id, error = %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 _ = app_handle.emit("material_processing_progress", serde_json::json!({
"material_id": material.id,
"file_name": file_name,
"stage": "处理完成",
"progress_percentage": 100.0
}));
info!(material_id = %material.id, "素材处理完成");
}
Ok(Some(material))
}
/// 选择文件夹进行批量导入
#[command]
pub async fn select_material_folders(app_handle: tauri::AppHandle) -> Result<Vec<String>, String> {
use crate::presentation::commands::system_commands::select_directory_with_options;
use crate::business::services::directory_settings_service::DirectorySettingsService;
use crate::config::DirectorySettingType;
// 获取默认目录设置
let service = DirectorySettingsService::new();
let default_dir = service.get_directory_setting(DirectorySettingType::MaterialImport)
.unwrap_or(None);
// 使用带选项的目录选择功能
match select_directory_with_options(
app_handle,
Some("选择素材导入目录".to_string()),
default_dir
) {
Ok(Some(folder_path)) => {
// 如果启用了自动记忆,更新默认目录
if let Ok(true) = service.is_auto_remember_enabled() {
let _ = service.update_directory_setting(
DirectorySettingType::MaterialImport,
folder_path.clone()
);
}
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(
state: State<'_, AppState>,
project_id: String,
) -> Result<Vec<Material>, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
MaterialService::get_project_materials(repository, &project_id)
.map_err(|e| e.to_string())
}
/// 获取所有素材列表命令
#[command]
pub async fn get_all_materials(
state: State<'_, AppState>,
) -> Result<Vec<Material>, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
MaterialService::get_all_materials(repository)
.map_err(|e| e.to_string())
}
/// 获取素材详情命令
#[command]
pub async fn get_material_by_id(
state: State<'_, AppState>,
id: String,
) -> Result<Option<Material>, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
MaterialService::get_material_by_id(repository, &id)
.map_err(|e| e.to_string())
}
/// 删除素材命令
#[command]
pub async fn delete_material(
state: State<'_, AppState>,
id: String,
) -> Result<(), String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
MaterialService::delete_material(repository, &id)
.map_err(|e| e.to_string())
}
/// 批量删除素材命令
#[command]
pub async fn batch_delete_materials(
state: State<'_, AppState>,
material_ids: Vec<String>,
) -> Result<crate::data::models::material::BatchDeleteResult, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
MaterialService::batch_delete_materials(repository, material_ids)
.map_err(|e| e.to_string())
}
/// 获取项目素材统计命令
#[command]
pub async fn get_project_material_stats(
state: State<'_, AppState>,
project_id: String,
) -> Result<MaterialStats, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
MaterialService::get_project_stats(repository, &project_id)
.map_err(|e| e.to_string())
}
/// 批量处理素材命令
#[command]
pub async fn batch_process_materials(
state: State<'_, AppState>,
material_ids: Vec<String>,
) -> Result<Vec<String>, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
let config = MaterialProcessingConfig::default();
MaterialService::batch_process_materials(repository, material_ids, &config)
.map_err(|e| e.to_string())
}
/// 更新素材处理状态命令
#[command]
pub async fn update_material_status(
state: State<'_, AppState>,
id: String,
status: String,
error_message: Option<String>,
) -> Result<(), String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
// 解析状态字符串
let processing_status = match status.as_str() {
"Pending" => ProcessingStatus::Pending,
"Processing" => ProcessingStatus::Processing,
"Completed" => ProcessingStatus::Completed,
"Failed" => ProcessingStatus::Failed,
"Skipped" => ProcessingStatus::Skipped,
_ => return Err("无效的处理状态".to_string()),
};
MaterialService::update_material_status(repository, &id, processing_status, error_message)
.map_err(|e| e.to_string())
}
/// 清理失效素材命令
#[command]
pub async fn cleanup_invalid_materials(
state: State<'_, AppState>,
project_id: String,
) -> Result<String, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
let cleaned_count = MaterialService::cleanup_invalid_materials(repository, &project_id)
.map_err(|e| e.to_string())?;
Ok(format!("已清理 {} 个失效的素材记录", cleaned_count))
}
/// 检查文件是否为支持格式命令
#[command]
pub async fn is_supported_format(file_path: String) -> Result<bool, String> {
Ok(MaterialService::is_supported_format(&file_path))
}
/// 获取支持的文件扩展名命令
#[command]
pub async fn get_supported_extensions() -> Result<Vec<String>, String> {
let extensions = MaterialService::get_supported_extensions()
.into_iter()
.map(|s| s.to_string())
.collect();
Ok(extensions)
}
/// 选择素材文件命令
#[command]
pub async fn select_material_files(app: tauri::AppHandle) -> Result<Vec<String>, String> {
use tauri_plugin_dialog::DialogExt;
use crate::business::services::directory_settings_service::DirectorySettingsService;
use crate::config::DirectorySettingType;
// 获取默认目录设置
let service = DirectorySettingsService::new();
let mut dialog = app.dialog().file()
.set_title("选择素材文件")
.add_filter("视频文件", &["mp4", "avi", "mov", "mkv", "wmv", "flv", "webm", "m4v"])
.add_filter("音频文件", &["mp3", "wav", "flac", "aac", "ogg", "wma", "m4a"])
.add_filter("图片文件", &["jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp", "svg"])
.add_filter("文档文件", &["pdf", "doc", "docx", "txt", "md", "rtf"])
.add_filter("所有支持的文件", &MaterialService::get_supported_extensions());
// 设置默认目录
if let Ok(Some(default_dir)) = service.get_directory_setting(DirectorySettingType::MaterialImport) {
if std::path::Path::new(&default_dir).exists() {
dialog = dialog.set_directory(&default_dir);
}
}
let (tx, rx) = std::sync::mpsc::channel();
dialog.pick_files(move |file_paths| {
let paths = file_paths.map(|paths| {
paths.into_iter()
.map(|path| path.to_string())
.collect::<Vec<String>>()
}).unwrap_or_default();
let _ = tx.send(paths);
});
match rx.recv_timeout(std::time::Duration::from_secs(30)) {
Ok(paths) => {
// 如果选择了文件且启用了自动记忆,更新默认目录
if !paths.is_empty() {
if let Ok(true) = service.is_auto_remember_enabled() {
if let Some(first_file) = paths.first() {
if let Some(parent_dir) = std::path::Path::new(first_file).parent() {
let parent_path = parent_dir.to_string_lossy().to_string();
let _ = service.update_directory_setting(
DirectorySettingType::MaterialImport,
parent_path
);
}
}
}
}
Ok(paths)
},
Err(_) => Err("文件选择操作超时或失败".to_string()),
}
}
/// 验证素材文件命令
#[command]
pub async fn validate_material_files(file_paths: Vec<String>) -> Result<Vec<String>, String> {
let mut valid_files = Vec::new();
for file_path in file_paths {
if std::path::Path::new(&file_path).exists() &&
MaterialService::is_supported_format(&file_path) {
valid_files.push(file_path);
}
}
Ok(valid_files)
}
/// 获取文件信息命令
#[command]
pub async fn get_file_info(file_path: String) -> Result<serde_json::Value, String> {
use std::path::Path;
let path = Path::new(&file_path);
if !path.exists() {
return Err("文件不存在".to_string());
}
let metadata = std::fs::metadata(path)
.map_err(|e| format!("获取文件信息失败: {}", e))?;
let file_name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let extension = path.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
let material_type = crate::data::models::material::MaterialType::from_extension(extension);
let info = serde_json::json!({
"name": file_name,
"path": file_path,
"size": metadata.len(),
"extension": extension,
"material_type": material_type,
"is_supported": MaterialService::is_supported_format(&file_path),
"modified": metadata.modified()
.map(|time| time.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs())
.unwrap_or(0)
});
Ok(info)
}
/// 检查 FFmpeg 是否可用命令
#[command]
pub async fn check_ffmpeg_available() -> Result<bool, String> {
Ok(FFmpegService::is_available())
}
/// 获取 FFmpeg 版本命令
#[command]
pub async fn get_ffmpeg_version() -> Result<String, String> {
FFmpegService::get_version().map_err(|e| e.to_string())
}
/// 获取 FFmpeg 状态信息命令
#[command]
pub async fn get_ffmpeg_status() -> Result<String, String> {
FFmpegService::get_status_info().map_err(|e| e.to_string())
}
/// 提取文件元数据命令
#[command]
pub async fn extract_file_metadata(file_path: String) -> Result<MaterialMetadata, String> {
FFmpegService::extract_metadata(&file_path).map_err(|e| e.to_string())
}
/// 检测视频场景命令
#[command]
pub async fn detect_video_scenes(
file_path: String,
threshold: f64,
) -> Result<Vec<f64>, String> {
FFmpegService::detect_scenes(&file_path, threshold).map_err(|e| e.to_string())
}
/// 生成视频缩略图命令
#[command]
pub async fn generate_video_thumbnail(
input_path: String,
output_path: String,
timestamp: f64,
width: u32,
height: u32,
) -> Result<(), String> {
FFmpegService::generate_thumbnail(&input_path, &output_path, timestamp, width, height)
.map_err(|e| e.to_string())
}
/// 为片段生成并保存缩略图
#[command]
pub async fn generate_and_save_segment_thumbnail(
segment_id: String,
app_state: State<'_, AppState>,
) -> Result<Option<String>, String> {
use crate::infrastructure::ffmpeg::FFmpegService;
use std::path::Path;
// 获取片段信息不跨越await点持有锁
let segment = {
let material_repository_guard = app_state.material_repository.lock().unwrap();
let material_repository = material_repository_guard.as_ref()
.ok_or("MaterialRepository未初始化")?;
// 这里不能使用await因为会跨越锁的生命周期
// 我们需要使用同步方法或者重新设计
material_repository.get_segment_by_id_sync(&segment_id)
.map_err(|e| e.to_string())?
};
let segment = match segment {
Some(s) => s,
None => return Err("片段不存在".to_string()),
};
// 如果已经有缩略图,直接返回
if let Some(ref thumbnail_path) = segment.thumbnail_path {
if std::path::Path::new(thumbnail_path).exists() {
return Ok(Some(thumbnail_path.clone()));
}
}
// 生成缩略图路径
let video_path = &segment.file_path;
let thumbnail_filename = format!("{}_thumbnail.jpg", segment.id);
let video_dir = Path::new(video_path).parent()
.ok_or("无法获取视频文件目录")?;
let thumbnail_path = video_dir.join(thumbnail_filename);
let thumbnail_path_str = thumbnail_path.to_string_lossy().to_string();
// 生成缩略图(使用首帧)
let timestamp = segment.start_time;
// 获取视频元数据来确定合适的缩略图尺寸
let video_info = FFmpegService::get_video_info(video_path)
.map_err(|e| e.to_string())?;
// 计算缩略图尺寸保持宽高比最大宽度160
let max_width = 160;
let (thumb_width, thumb_height) = if video_info.width > 0 && video_info.height > 0 {
let aspect_ratio = video_info.width as f64 / video_info.height as f64;
if video_info.width > max_width {
let new_width = max_width;
let new_height = (new_width as f64 / aspect_ratio).round() as u32;
(new_width, new_height)
} else {
(video_info.width, video_info.height)
}
} else {
// 如果无法获取视频尺寸,使用默认值
(160, 120)
};
// 先进行预检查
FFmpegService::validate_thumbnail_generation(
video_path,
&thumbnail_path_str,
timestamp
).map_err(|e| e.to_string())?;
// 使用带重试机制的缩略图生成
FFmpegService::generate_thumbnail_with_retry(
video_path,
&thumbnail_path_str,
timestamp,
thumb_width,
thumb_height
).map_err(|e| e.to_string())?;
// 保存缩略图路径到数据库
{
let material_repository_guard = app_state.material_repository.lock().unwrap();
let material_repository = material_repository_guard.as_ref()
.ok_or("MaterialRepository未初始化")?;
material_repository.update_segment_thumbnail(&segment_id, &thumbnail_path_str)
.map_err(|e| e.to_string())?;
}
Ok(Some(thumbnail_path_str))
}
/// 读取缩略图文件并转换为base64数据URL
#[command]
pub async fn read_thumbnail_as_data_url(file_path: String) -> Result<String, String> {
use std::fs;
use base64::{Engine as _, engine::general_purpose};
// 去掉Windows长路径前缀
let clean_path = if file_path.starts_with("\\\\?\\") {
&file_path[4..]
} else {
&file_path
};
// 读取文件
let file_data = fs::read(clean_path)
.map_err(|e| format!("读取文件失败: {}", e))?;
// 转换为base64
let base64_data = general_purpose::STANDARD.encode(&file_data);
// 返回data URL
Ok(format!("data:image/jpeg;base64,{}", base64_data))
}
/// 根据materialId获取缩略图base64数据URL
#[command]
pub async fn get_material_thumbnail_base64(
material_id: String,
app_state: State<'_, AppState>,
) -> Result<String, String> {
use crate::infrastructure::ffmpeg::FFmpegService;
use std::path::Path;
use std::fs;
use base64::{Engine as _, engine::general_purpose};
// 获取素材信息
let material = {
let material_repository_guard = app_state.material_repository.lock().unwrap();
let material_repository = material_repository_guard.as_ref()
.ok_or("MaterialRepository未初始化")?;
material_repository.get_by_id(&material_id)
.map_err(|e| e.to_string())?
};
let material = match material {
Some(m) => m,
None => return Err("素材不存在".to_string()),
};
// 检查数据库中是否已有缩略图路径
if let Some(ref thumbnail_path) = material.thumbnail_path {
// 去掉Windows长路径前缀
let clean_path = if thumbnail_path.starts_with("\\\\?\\") {
&thumbnail_path[4..]
} else {
thumbnail_path
};
// 检查文件是否存在
if Path::new(clean_path).exists() {
// 文件存在,直接读取并返回
let file_data = fs::read(clean_path)
.map_err(|e| format!("读取缩略图文件失败: {}", e))?;
let base64_data = general_purpose::STANDARD.encode(&file_data);
return Ok(format!("data:image/jpeg;base64,{}", base64_data));
}
}
// 根据素材类型处理
match material.material_type {
crate::data::models::material::MaterialType::Image => {
// 图片类型直接读取图片文件并返回base64
let image_path = &material.original_path;
// 去掉Windows长路径前缀
let clean_image_path = if image_path.starts_with("\\\\?\\") {
&image_path[4..]
} else {
image_path
};
// 检查图片文件是否存在
if !Path::new(clean_image_path).exists() {
let error_msg = format!("图片文件不存在: {}", clean_image_path);
tracing::error!(
material_id = %material_id,
image_path = %clean_image_path,
"图片文件不存在"
);
return Err(error_msg);
}
// 直接读取图片文件
let file_data = fs::read(clean_image_path)
.map_err(|e| format!("读取图片文件失败: {}", e))?;
// 根据文件扩展名确定MIME类型
let mime_type = match Path::new(clean_image_path)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_lowercase())
.as_deref()
{
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("bmp") => "image/bmp",
Some("tiff") | Some("tif") => "image/tiff",
Some("svg") => "image/svg+xml",
_ => "image/jpeg", // 默认为JPEG
};
let base64_data = general_purpose::STANDARD.encode(&file_data);
return Ok(format!("data:{};base64,{}", mime_type, base64_data));
}
crate::data::models::material::MaterialType::Video => {
// 视频类型:生成缩略图
let video_path = &material.original_path;
// 去掉Windows长路径前缀
let clean_video_path = if video_path.starts_with("\\\\?\\") {
&video_path[4..]
} else {
video_path
};
// 检查视频文件是否存在,不存在则报错
if !Path::new(clean_video_path).exists() {
let error_msg = format!("视频文件不存在,无法生成缩略图: {}", clean_video_path);
tracing::error!(
material_id = %material_id,
video_path = %clean_video_path,
"视频文件不存在"
);
return Err(error_msg);
}
// 生成缩略图路径
let thumbnail_filename = format!("{}_material_thumbnail.jpg", material.id);
let video_dir = Path::new(clean_video_path).parent()
.ok_or("无法获取视频文件目录")?;
let thumbnail_path = video_dir.join(thumbnail_filename);
let thumbnail_path_str = thumbnail_path.to_string_lossy().to_string();
// 获取视频信息来确定合适的缩略图尺寸
let video_info = FFmpegService::get_video_info(clean_video_path)
.map_err(|e| e.to_string())?;
// 计算缩略图尺寸保持宽高比最大宽度160
let max_width = 160;
let (thumb_width, thumb_height) = if video_info.width > 0 && video_info.height > 0 {
let aspect_ratio = video_info.width as f64 / video_info.height as f64;
if video_info.width > max_width {
let new_width = max_width;
let new_height = (new_width as f64 / aspect_ratio).round() as u32;
(new_width, new_height)
} else {
(video_info.width, video_info.height)
}
} else {
// 如果无法获取视频尺寸,使用默认值
(160, 120)
};
// 生成缩略图(使用首帧,带重试机制)
let timestamp = 0.0; // 使用视频开始时间
// 先进行预检查
if let Err(e) = FFmpegService::validate_thumbnail_generation(
clean_video_path,
&thumbnail_path_str,
timestamp
) {
let error_msg = format!(
"缩略图生成预检查失败: {} (视频: {})",
e, clean_video_path
);
tracing::error!(
material_id = %material_id,
video_path = %clean_video_path,
error = %e,
"缩略图生成预检查失败"
);
return Err(error_msg);
}
// 使用带重试机制的缩略图生成
if let Err(e) = FFmpegService::generate_thumbnail_with_retry(
clean_video_path,
&thumbnail_path_str,
timestamp,
thumb_width,
thumb_height
) {
let error_msg = format!(
"FFmpeg生成缩略图失败已重试: {} (视频: {}, 输出: {}, 时间戳: {}s)",
e, clean_video_path, thumbnail_path_str, timestamp
);
tracing::error!(
material_id = %material_id,
video_path = %clean_video_path,
thumbnail_path = %thumbnail_path_str,
timestamp = timestamp,
error = %e,
"FFmpeg缩略图生成失败已重试"
);
return Err(error_msg);
}
// 验证缩略图文件是否生成成功
if !Path::new(&thumbnail_path_str).exists() {
let error_msg = format!(
"缩略图文件生成失败,文件不存在: {}",
thumbnail_path_str
);
tracing::error!(
material_id = %material_id,
thumbnail_path = %thumbnail_path_str,
"缩略图文件生成失败"
);
return Err(error_msg);
}
// 保存缩略图路径到数据库
{
let material_repository_guard = app_state.material_repository.lock().unwrap();
let material_repository = material_repository_guard.as_ref()
.ok_or("MaterialRepository未初始化")?;
material_repository.update_material_thumbnail(&material_id, &thumbnail_path_str)
.map_err(|e| e.to_string())?;
}
// 读取生成的缩略图文件并返回base64
let file_data = fs::read(&thumbnail_path_str)
.map_err(|e| format!("读取生成的缩略图失败: {}", e))?;
let base64_data = general_purpose::STANDARD.encode(&file_data);
Ok(format!("data:image/jpeg;base64,{}", base64_data))
}
_ => {
// 其他类型(音频等)不支持缩略图
Err(format!("不支持的素材类型: {:?}", material.material_type))
}
}
}
/// 根据segmentId获取缩略图base64数据URL
#[command]
pub async fn get_segment_thumbnail_base64(
segment_id: String,
app_state: State<'_, AppState>,
) -> Result<String, String> {
use crate::infrastructure::ffmpeg::FFmpegService;
use std::path::Path;
use std::fs;
use base64::{Engine as _, engine::general_purpose};
// 获取片段信息
let segment = {
let material_repository_guard = app_state.material_repository.lock().unwrap();
let material_repository = material_repository_guard.as_ref()
.ok_or("MaterialRepository未初始化")?;
material_repository.get_segment_by_id_sync(&segment_id)
.map_err(|e| e.to_string())?
};
let segment = match segment {
Some(s) => s,
None => return Err("片段不存在".to_string()),
};
// 检查数据库中是否已有缩略图路径
if let Some(ref thumbnail_path) = segment.thumbnail_path {
// 去掉Windows长路径前缀
let clean_path = if thumbnail_path.starts_with("\\\\?\\") {
&thumbnail_path[4..]
} else {
thumbnail_path
};
// 检查文件是否存在
if Path::new(clean_path).exists() {
// 文件存在,直接读取并返回
let file_data = fs::read(clean_path)
.map_err(|e| format!("读取缩略图文件失败: {}", e))?;
let base64_data = general_purpose::STANDARD.encode(&file_data);
return Ok(format!("data:image/jpeg;base64,{}", base64_data));
}
}
// 缩略图不存在或文件已丢失,需要重新生成
let video_path = &segment.file_path;
// 去掉Windows长路径前缀
let clean_video_path = if video_path.starts_with("\\\\?\\") {
&video_path[4..]
} else {
video_path
};
// 检查视频文件是否存在,不存在则报错
if !Path::new(clean_video_path).exists() {
let error_msg = format!("视频文件不存在,无法生成缩略图: {}", clean_video_path);
tracing::error!(
segment_id = %segment_id,
video_path = %clean_video_path,
"视频文件不存在"
);
return Err(error_msg);
}
// 生成缩略图路径
let thumbnail_filename = format!("{}_thumbnail.jpg", segment.id);
let video_dir = Path::new(clean_video_path).parent()
.ok_or("无法获取视频文件目录")?;
let thumbnail_path = video_dir.join(thumbnail_filename);
let thumbnail_path_str = thumbnail_path.to_string_lossy().to_string();
// 获取视频信息来确定合适的缩略图尺寸
let video_info = FFmpegService::get_video_info(clean_video_path)
.map_err(|e| e.to_string())?;
// 计算缩略图尺寸保持宽高比最大宽度160
let max_width = 160;
let (thumb_width, thumb_height) = if video_info.width > 0 && video_info.height > 0 {
let aspect_ratio = video_info.width as f64 / video_info.height as f64;
if video_info.width > max_width {
let new_width = max_width;
let new_height = (new_width as f64 / aspect_ratio).round() as u32;
(new_width, new_height)
} else {
(video_info.width, video_info.height)
}
} else {
// 如果无法获取视频尺寸,使用默认值
(160, 120)
};
// 生成缩略图(使用首帧,带重试机制)
let timestamp = segment.start_time;
// 先进行预检查
if let Err(e) = FFmpegService::validate_thumbnail_generation(
clean_video_path,
&thumbnail_path_str,
timestamp
) {
let error_msg = format!(
"缩略图生成预检查失败: {} (视频: {})",
e, clean_video_path
);
tracing::error!(
segment_id = %segment_id,
video_path = %clean_video_path,
error = %e,
"缩略图生成预检查失败"
);
return Err(error_msg);
}
// 使用带重试机制的缩略图生成
if let Err(e) = FFmpegService::generate_thumbnail_with_retry(
clean_video_path,
&thumbnail_path_str,
timestamp,
thumb_width,
thumb_height
) {
let error_msg = format!(
"FFmpeg生成缩略图失败已重试: {} (视频: {}, 输出: {}, 时间戳: {}s)",
e, clean_video_path, thumbnail_path_str, timestamp
);
tracing::error!(
segment_id = %segment_id,
video_path = %clean_video_path,
thumbnail_path = %thumbnail_path_str,
timestamp = timestamp,
error = %e,
"FFmpeg缩略图生成失败已重试"
);
return Err(error_msg);
}
// 检查是否thumbnail_path_str是否存在 不存在报错
if !Path::new(&thumbnail_path_str).exists() {
let error_msg = format!("缩略图生成失败,输出文件不存在: {}", thumbnail_path_str);
tracing::error!(
segment_id = %segment_id,
thumbnail_path = %thumbnail_path_str,
"FFmpeg执行完成但缩略图文件不存在"
);
return Err(error_msg);
}
// 检查文件大小,确保不是空文件
let file_size = fs::metadata(&thumbnail_path_str)
.map_err(|e| format!("获取缩略图文件信息失败: {}", e))?
.len();
if file_size == 0 {
let error_msg = format!("缩略图文件为空: {}", thumbnail_path_str);
tracing::error!(
segment_id = %segment_id,
thumbnail_path = %thumbnail_path_str,
"生成的缩略图文件大小为0"
);
return Err(error_msg);
}
tracing::info!(
segment_id = %segment_id,
thumbnail_path = %thumbnail_path_str,
file_size = file_size,
"缩略图文件验证成功"
);
// 保存缩略图路径到数据库
{
let material_repository_guard = app_state.material_repository.lock().unwrap();
let material_repository = material_repository_guard.as_ref()
.ok_or("MaterialRepository未初始化")?;
material_repository.update_segment_thumbnail(&segment_id, &thumbnail_path_str)
.map_err(|e| e.to_string())?;
}
// 读取生成的缩略图文件并返回base64
let file_data = fs::read(&thumbnail_path_str)
.map_err(|e| format!("读取生成的缩略图失败: {}", e))?;
let base64_data = general_purpose::STANDARD.encode(&file_data);
Ok(format!("data:image/jpeg;base64,{}", base64_data))
}
/// 获取音频文件的base64数据URL
#[command]
pub async fn get_audio_file_base64(
material_id: String,
app_state: State<'_, AppState>,
) -> Result<String, String> {
use std::fs;
use base64::{Engine as _, engine::general_purpose};
// 获取素材信息
let material = {
let material_repository_guard = app_state.material_repository.lock().unwrap();
let material_repository = material_repository_guard.as_ref()
.ok_or("MaterialRepository未初始化")?;
material_repository.get_by_id(&material_id)
.map_err(|e| e.to_string())?
};
let material = match material {
Some(m) => m,
None => return Err("素材不存在".to_string()),
};
// 检查是否为音频类型
if !matches!(material.material_type, crate::data::models::material::MaterialType::Audio) {
return Err("不是音频类型的素材".to_string());
}
let audio_path = &material.original_path;
// 去掉Windows长路径前缀
let clean_audio_path = if audio_path.starts_with("\\\\?\\") {
&audio_path[4..]
} else {
audio_path
};
// 检查音频文件是否存在
if !std::path::Path::new(clean_audio_path).exists() {
let error_msg = format!("音频文件不存在: {}", clean_audio_path);
tracing::error!(
material_id = %material_id,
audio_path = %clean_audio_path,
"音频文件不存在"
);
return Err(error_msg);
}
// 读取音频文件
let file_data = fs::read(clean_audio_path)
.map_err(|e| format!("读取音频文件失败: {}", e))?;
// 根据文件扩展名确定MIME类型
let mime_type = match std::path::Path::new(clean_audio_path)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_lowercase())
.as_deref()
{
Some("mp3") => "audio/mpeg",
Some("wav") => "audio/wav",
Some("flac") => "audio/flac",
Some("aac") => "audio/aac",
Some("ogg") => "audio/ogg",
Some("wma") => "audio/x-ms-wma",
Some("m4a") => "audio/mp4",
_ => "audio/mpeg", // 默认为MP3
};
let base64_data = general_purpose::STANDARD.encode(&file_data);
Ok(format!("data:{};base64,{}", mime_type, base64_data))
}
/// 测试FFmpeg缩略图生成命令用于调试
#[command]
pub async fn test_ffmpeg_thumbnail(
video_path: String,
output_path: String,
timestamp: f64,
) -> Result<String, String> {
use crate::infrastructure::ffmpeg::FFmpegService;
use std::path::Path;
use tracing::{info, error};
info!(
video_path = %video_path,
output_path = %output_path,
timestamp = timestamp,
"开始测试FFmpeg缩略图生成"
);
// 检查输入文件
if !Path::new(&video_path).exists() {
let error_msg = format!("视频文件不存在: {}", video_path);
error!(error = %error_msg);
return Err(error_msg);
}
// 获取视频信息
let video_info = match FFmpegService::get_video_info(&video_path) {
Ok(info) => {
info!(
video_path = %video_path,
width = info.width,
height = info.height,
duration = info.duration,
"视频信息获取成功"
);
info
}
Err(e) => {
let error_msg = format!("获取视频信息失败: {}", e);
error!(error = %error_msg);
return Err(error_msg);
}
};
// 计算缩略图尺寸
let max_width = 160;
let (thumb_width, thumb_height) = if video_info.width > 0 && video_info.height > 0 {
let aspect_ratio = video_info.width as f64 / video_info.height as f64;
if video_info.width > max_width {
let new_width = max_width;
let new_height = (new_width as f64 / aspect_ratio).round() as u32;
(new_width, new_height)
} else {
(video_info.width, video_info.height)
}
} else {
(160, 120)
};
// 生成缩略图
match FFmpegService::generate_thumbnail(
&video_path,
&output_path,
timestamp,
thumb_width,
thumb_height
) {
Ok(_) => {
// 验证文件是否生成
if Path::new(&output_path).exists() {
let file_size = std::fs::metadata(&output_path)
.map(|m| m.len())
.unwrap_or(0);
let success_msg = format!(
"FFmpeg缩略图生成成功\n视频文件: {}\n输出文件: {}\n时间戳: {}s\n缩略图尺寸: {}x{}\n文件大小: {} 字节",
video_path, output_path, timestamp, thumb_width, thumb_height, file_size
);
info!(message = %success_msg);
Ok(success_msg)
} else {
let error_msg = "FFmpeg命令执行成功但输出文件不存在".to_string();
error!(error = %error_msg);
Err(error_msg)
}
}
Err(e) => {
let error_msg = format!("FFmpeg缩略图生成失败: {}", e);
error!(error = %error_msg);
Err(error_msg)
}
}
}
/// 测试文件存在性检查命令(用于调试)
#[command]
pub async fn test_file_exists(file_path: String) -> Result<String, String> {
use std::path::Path;
use tracing::{info, error};
info!(file_path = %file_path, "开始检查文件是否存在");
// 去掉Windows长路径前缀
let clean_path = if file_path.starts_with("\\\\?\\") {
&file_path[4..]
} else {
&file_path
};
if Path::new(clean_path).exists() {
let metadata = std::fs::metadata(clean_path)
.map_err(|e| format!("获取文件信息失败: {}", e))?;
let file_size = metadata.len();
let is_file = metadata.is_file();
let is_dir = metadata.is_dir();
let result = format!(
"文件存在检查结果:\n原始路径: {}\n清理后路径: {}\n文件存在: 是\n文件大小: {} 字节\n是文件: {}\n是目录: {}",
file_path, clean_path, file_size, is_file, is_dir
);
info!(
file_path = %clean_path,
file_size = file_size,
is_file = is_file,
is_dir = is_dir,
"文件存在性检查完成"
);
Ok(result)
} else {
let error_msg = format!(
"文件不存在:\n原始路径: {}\n清理后路径: {}\n文件存在: 否",
file_path, clean_path
);
error!(
file_path = %clean_path,
"文件不存在"
);
Err(error_msg)
}
}
/// 测试场景检测命令(用于调试)
#[command]
pub async fn test_scene_detection(file_path: String) -> Result<String, String> {
// 首先检查FFmpeg状态
let ffmpeg_status = FFmpegService::get_status_info().unwrap_or_else(|_| "无法获取FFmpeg状态".to_string());
let mut result = format!("FFmpeg状态:\n{}\n", ffmpeg_status);
// 检查文件是否存在
if !std::path::Path::new(&file_path).exists() {
return Ok(format!("{}文件不存在: {}", result, file_path));
}
result.push_str(&format!("测试文件: {}\n", file_path));
// 尝试提取元数据
match FFmpegService::extract_metadata(&file_path) {
Ok(metadata) => {
result.push_str(&format!("元数据提取成功: {:?}\n", metadata));
}
Err(e) => {
result.push_str(&format!("元数据提取失败: {}\n", e));
}
}
// 尝试场景检测
match FFmpegService::detect_scenes(&file_path, 0.3) {
Ok(scenes) => {
result.push_str(&format!("场景检测成功,发现 {} 个场景切点: {:?}\n", scenes.len(), scenes));
}
Err(e) => {
result.push_str(&format!("场景检测失败: {}\n", e));
}
}
Ok(result)
}
/// 获取素材的切分片段信息命令
#[command]
pub async fn get_material_segments(
state: State<'_, AppState>,
material_id: String,
) -> Result<Vec<crate::data::models::material::MaterialSegment>, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
repository.get_segments(&material_id)
.map_err(|e| e.to_string())
}
/// 根据片段ID获取片段详细信息命令
#[command]
pub async fn get_material_segment_by_id(
state: State<'_, AppState>,
segment_id: String,
) -> Result<Option<crate::data::models::material::MaterialSegment>, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
repository.get_segment_by_id_sync(&segment_id)
.map_err(|e| e.to_string())
}
/// 测试视频切分命令(用于调试不同切分模式)
#[command]
pub async fn test_video_split(
input_path: String,
output_dir: String,
start_time: f64,
end_time: f64,
mode: String,
) -> Result<String, String> {
use crate::infrastructure::ffmpeg::FFmpegService;
let segments = vec![(start_time, end_time)];
let output_prefix = "test_segment";
let result = match mode.as_str() {
"fast" => {
FFmpegService::split_video_fast(&input_path, &output_dir, &segments, output_prefix)
}
"accurate" => {
FFmpegService::split_video(&input_path, &output_dir, &segments, output_prefix)
}
"smart" => {
FFmpegService::split_video_smart(&input_path, &output_dir, &segments, output_prefix)
}
_ => return Err("无效的切分模式,支持: fast, accurate, smart".to_string()),
};
match result {
Ok(files) => Ok(format!("切分成功,生成文件: {:?}", files)),
Err(e) => Err(format!("切分失败: {}", e)),
}
}
/// 关联素材到模特命令
#[command]
pub async fn associate_material_to_model(
state: State<'_, AppState>,
material_id: String,
model_id: String,
) -> Result<(), String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
repository.associate_material_to_model(&material_id, &model_id)
.map_err(|e| format!("关联素材到模特失败: {}", e))
}
/// 取消素材与模特的关联命令
#[command]
pub async fn disassociate_material_from_model(
state: State<'_, AppState>,
material_id: String,
) -> Result<(), String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
repository.disassociate_material_from_model(&material_id)
.map_err(|e| format!("取消关联失败: {}", e))
}
/// 根据模特ID获取关联的素材命令
#[command]
pub async fn get_materials_by_model_id(
state: State<'_, AppState>,
model_id: String,
) -> Result<Vec<Material>, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
repository.get_materials_by_model_id(&model_id)
.map_err(|e| format!("获取模特关联素材失败: {}", e))
}
/// 获取未关联模特的素材命令
#[command]
pub async fn get_unassociated_materials(
state: State<'_, AppState>,
project_id: Option<String>,
) -> Result<Vec<Material>, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
repository.get_unassociated_materials(project_id.as_deref())
.map_err(|e| format!("获取未关联素材失败: {}", e))
}
/// 批量绑定素材到模特命令
#[command]
pub async fn batch_bind_materials_to_model(
state: State<'_, AppState>,
material_ids: Vec<String>,
model_id: String,
) -> Result<(), String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
MaterialService::batch_bind_materials_to_model(repository, &material_ids, &model_id)
.map_err(|e| format!("批量绑定素材到模特失败: {}", e))
}
/// 批量解除素材与模特的绑定命令
#[command]
pub async fn batch_unbind_materials_from_model(
state: State<'_, AppState>,
material_ids: Vec<String>,
) -> Result<(), String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
MaterialService::batch_unbind_materials_from_model(repository, &material_ids)
.map_err(|e| format!("批量解除绑定失败: {}", e))
}
/// 切换素材的模特绑定命令
#[command]
pub async fn switch_material_model(
state: State<'_, AppState>,
material_id: String,
new_model_id: String,
) -> Result<(), String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
MaterialService::switch_material_model(repository, &material_id, &new_model_id)
.map_err(|e| format!("切换素材模特绑定失败: {}", e))
}
/// 获取模特的素材统计信息命令
#[command]
pub async fn get_model_material_statistics(
state: State<'_, AppState>,
model_id: String,
) -> Result<crate::data::repositories::material_repository::ModelMaterialStatistics, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
MaterialService::get_model_material_statistics(repository, &model_id)
.map_err(|e| format!("获取模特素材统计失败: {}", e))
}
/// 获取项目中模特绑定统计信息命令
#[command]
pub async fn get_project_model_binding_stats(
state: State<'_, AppState>,
project_id: String,
) -> Result<crate::business::services::material_service::ProjectModelBindingStats, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
MaterialService::get_project_model_binding_stats(repository, &project_id)
.map_err(|e| format!("获取项目模特绑定统计失败: {}", e))
}
/// 获取全局模特绑定统计信息命令
#[command]
pub async fn get_global_model_binding_stats(
state: State<'_, AppState>,
) -> Result<crate::business::services::material_service::ProjectModelBindingStats, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
MaterialService::get_global_model_binding_stats(repository)
.map_err(|e| format!("获取全局模特绑定统计失败: {}", e))
}
/// 更新素材信息命令(包括模特绑定)
#[command]
pub async fn update_material(
state: State<'_, AppState>,
id: String,
updates: MaterialUpdateRequest,
) -> Result<(), String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
// 获取现有素材
let mut material = repository.get_by_id(&id)
.map_err(|e| format!("获取素材失败: {}", e))?
.ok_or("找不到指定的素材")?;
// 更新字段
if let Some(model_id) = updates.model_id {
material.model_id = if model_id.is_empty() { None } else { Some(model_id) };
}
if let Some(name) = updates.name {
material.name = name;
}
// 更新时间戳
material.updated_at = chrono::Utc::now();
// 保存更新
repository.update(&material)
.map_err(|e| format!("更新素材失败: {}", e))?;
Ok(())
}
/// 素材更新请求
#[derive(Debug, serde::Deserialize)]
pub struct MaterialUpdateRequest {
pub model_id: Option<String>,
pub name: Option<String>,
}