- 扩展AppConfig结构体,添加DirectorySettings字段 - 创建DirectorySettingsService服务,提供CRUD操作 - 添加Tauri命令接口,支持前端调用 - 修改现有文件选择逻辑,优先使用保存的目录设置 - 创建DirectorySettingsModal等UI组件 - 集成到素材导入、模板导入、剪影导出等界面 - 实现自动记忆功能,可选择是否记住路径 - 编写完整的单元测试,确保功能可靠性 功能特点: - 支持素材导入、模板导入、剪影导出、项目导出、缩略图导出等目录设置 - 自动记忆用户选择的目录,下次使用时作为默认路径 - 提供友好的UI界面管理目录设置 - 支持批量更新、清除、重置等操作 - 完整的错误处理和验证机制
2233 lines
76 KiB
Rust
2233 lines
76 KiB
Rust
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>,
|
||
}
|