From 4269b72c04547a31cf6516983408edd9bf9ddc85 Mon Sep 17 00:00:00 2001 From: imeepos Date: Wed, 23 Jul 2025 13:44:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E7=BC=A9=E7=95=A5=E5=9B=BE=E7=94=9F=E6=88=90=E5=99=A8=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加批量缩略图生成的数据模型和类型定义 - 实现ThumbnailGeneratorService核心服务 - 实现BatchThumbnailProcessor批量处理器 - 添加Tauri命令接口支持前端调用 - 创建完整的前端UI组件和页面 - 支持多种时间戳配置和尺寸预设 - 支持时间轴缩略图生成 - 支持并发处理和进度监控 - 集成到便捷工具页面 遵循promptx/tauri-desktop-app-expert开发规范 --- .../services/batch_thumbnail_processor.rs | 469 +++++++++++++++ .../src-tauri/src/business/services/mod.rs | 2 + .../services/thumbnail_generator_service.rs | 534 ++++++++++++++++++ apps/desktop/src-tauri/src/data/models/mod.rs | 1 + .../src-tauri/src/data/models/thumbnail.rs | 273 +++++++++ apps/desktop/src-tauri/src/lib.rs | 14 +- .../src/presentation/commands/mod.rs | 1 + .../commands/thumbnail_commands.rs | 308 ++++++++++ apps/desktop/src/App.tsx | 2 + .../components/thumbnail/BatchProgress.tsx | 256 +++++++++ .../src/components/thumbnail/TaskList.tsx | 271 +++++++++ .../thumbnail/ThumbnailConfigPanel.tsx | 300 ++++++++++ .../components/thumbnail/ThumbnailPreview.tsx | 99 ++++ .../thumbnail/TimelineConfigPanel.tsx | 235 ++++++++ .../components/thumbnail/VideoFileList.tsx | 132 +++++ apps/desktop/src/data/tools.ts | 18 +- .../pages/tools/BatchThumbnailGenerator.tsx | 455 +++++++++++++++ apps/desktop/src/types/thumbnail.ts | 303 ++++++++++ 18 files changed, 3671 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src-tauri/src/business/services/batch_thumbnail_processor.rs create mode 100644 apps/desktop/src-tauri/src/business/services/thumbnail_generator_service.rs create mode 100644 apps/desktop/src-tauri/src/data/models/thumbnail.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/thumbnail_commands.rs create mode 100644 apps/desktop/src/components/thumbnail/BatchProgress.tsx create mode 100644 apps/desktop/src/components/thumbnail/TaskList.tsx create mode 100644 apps/desktop/src/components/thumbnail/ThumbnailConfigPanel.tsx create mode 100644 apps/desktop/src/components/thumbnail/ThumbnailPreview.tsx create mode 100644 apps/desktop/src/components/thumbnail/TimelineConfigPanel.tsx create mode 100644 apps/desktop/src/components/thumbnail/VideoFileList.tsx create mode 100644 apps/desktop/src/pages/tools/BatchThumbnailGenerator.tsx create mode 100644 apps/desktop/src/types/thumbnail.ts diff --git a/apps/desktop/src-tauri/src/business/services/batch_thumbnail_processor.rs b/apps/desktop/src-tauri/src/business/services/batch_thumbnail_processor.rs new file mode 100644 index 0000000..4f6c61f --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/batch_thumbnail_processor.rs @@ -0,0 +1,469 @@ +use anyhow::{Result, anyhow}; +use std::sync::{Arc, Mutex}; +use std::path::PathBuf; +use std::collections::HashMap; +use tokio::sync::Semaphore; +use tracing::{info, error}; +use chrono::Utc; +use uuid::Uuid; + +use crate::data::models::thumbnail::{ + BatchThumbnailTask, ThumbnailConfig, TimelineConfig, TaskStatus, + BatchProgress, ThumbnailGenerationResult, ThumbnailGenerationOptions +}; +use crate::business::services::thumbnail_generator_service::ThumbnailGeneratorService; + +/// 批量缩略图处理器 +/// 遵循 Tauri 开发规范的服务层设计原则 +pub struct BatchThumbnailProcessor { + generator: Arc, + tasks: Arc>>, + semaphore: Arc, +} + +impl BatchThumbnailProcessor { + /// 创建新的批量处理器实例 + pub fn new(options: ThumbnailGenerationOptions) -> Self { + let max_concurrent = options.max_concurrent as usize; + + Self { + generator: Arc::new(ThumbnailGeneratorService::new(options)), + tasks: Arc::new(Mutex::new(HashMap::new())), + semaphore: Arc::new(Semaphore::new(max_concurrent)), + } + } + + /// 启动批量缩略图生成任务 + pub async fn start_batch_generation( + &self, + video_paths: Vec, + config: ThumbnailConfig, + timeline_config: Option, + ) -> Result { + let task_id = Uuid::new_v4().to_string(); + let now = Utc::now(); + + info!( + task_id = %task_id, + video_count = video_paths.len(), + "启动批量缩略图生成任务" + ); + + // 创建任务 + let task = BatchThumbnailTask { + task_id: task_id.clone(), + video_files: video_paths.clone(), + config: config.clone(), + timeline_config: timeline_config.clone(), + status: TaskStatus::Pending, + progress: BatchProgress { + total_files: video_paths.len() as u32, + processed_files: 0, + failed_files: 0, + current_file: None, + progress_percentage: 0.0, + estimated_remaining_ms: None, + processing_speed: None, + errors: Vec::new(), + results: Vec::new(), + }, + created_at: now, + updated_at: now, + started_at: None, + completed_at: None, + }; + + // 存储任务 + { + let mut tasks = self.tasks.lock().unwrap(); + tasks.insert(task_id.clone(), task); + } + + // 异步执行批量处理 + let processor = self.clone(); + let task_id_clone = task_id.clone(); + tokio::spawn(async move { + if let Err(e) = processor.execute_batch_task(&task_id_clone).await { + error!( + task_id = %task_id_clone, + error = %e, + "批量缩略图生成任务执行失败" + ); + processor.update_task_status(&task_id_clone, TaskStatus::Failed).await; + } + }); + + Ok(task_id) + } + + /// 获取任务状态 + pub fn get_task_status(&self, task_id: &str) -> Result { + let tasks = self.tasks.lock().unwrap(); + tasks.get(task_id) + .cloned() + .ok_or_else(|| anyhow!("任务不存在: {}", task_id)) + } + + /// 取消任务 + pub async fn cancel_task(&self, task_id: &str) -> Result { + info!(task_id = %task_id, "取消批量缩略图生成任务"); + + self.update_task_status(task_id, TaskStatus::Cancelled).await; + Ok(true) + } + + /// 暂停任务 + pub async fn pause_task(&self, task_id: &str) -> Result { + info!(task_id = %task_id, "暂停批量缩略图生成任务"); + + self.update_task_status(task_id, TaskStatus::Paused).await; + Ok(true) + } + + /// 恢复任务 + pub async fn resume_task(&self, task_id: &str) -> Result { + info!(task_id = %task_id, "恢复批量缩略图生成任务"); + + self.update_task_status(task_id, TaskStatus::Running).await; + + // 重新启动任务执行 + let processor = self.clone(); + let task_id_clone = task_id.to_string(); + tokio::spawn(async move { + if let Err(e) = processor.execute_batch_task(&task_id_clone).await { + error!( + task_id = %task_id_clone, + error = %e, + "恢复批量缩略图生成任务失败" + ); + processor.update_task_status(&task_id_clone, TaskStatus::Failed).await; + } + }); + + Ok(true) + } + + /// 执行批量任务 + async fn execute_batch_task(&self, task_id: &str) -> Result<()> { + let start_time = std::time::Instant::now(); + + // 更新任务状态为运行中 + self.update_task_status(task_id, TaskStatus::Running).await; + self.update_task_started_time(task_id).await; + + // 获取任务信息 + let (video_files, config, timeline_config) = { + let tasks = self.tasks.lock().unwrap(); + let task = tasks.get(task_id) + .ok_or_else(|| anyhow!("任务不存在: {}", task_id))?; + ( + task.video_files.clone(), + task.config.clone(), + task.timeline_config.clone() + ) + }; + + info!( + task_id = %task_id, + video_count = video_files.len(), + "开始执行批量缩略图生成" + ); + + // 并发处理视频文件 + let mut handles: Vec, anyhow::Error>>> = Vec::new(); + + for (index, video_path) in video_files.iter().enumerate() { + let permit = self.semaphore.clone().acquire_owned().await?; + let generator = self.generator.clone(); + let config = config.clone(); + let timeline_config = timeline_config.clone(); + let video_path = video_path.clone(); + let task_id = task_id.to_string(); + let processor = self.clone(); + + let handle = tokio::spawn(async move { + let _permit = permit; // 持有许可证直到任务完成 + + // 检查任务是否被取消或暂停 + if processor.is_task_cancelled_or_paused(&task_id).await { + return Ok(None); + } + + // 更新当前处理文件 + processor.update_current_file(&task_id, &video_path.to_string_lossy()).await; + + let video_path_str = video_path.to_string_lossy().to_string(); + + // 生成缩略图 + let result = generator.generate_thumbnail(&video_path_str, &config).await; + + // 如果配置了时间轴,生成时间轴缩略图 + let timeline_result = if let Some(ref timeline_cfg) = timeline_config { + generator.generate_timeline_thumbnail(&video_path_str, timeline_cfg, &config).await + } else { + Ok(String::new()) + }; + + match result { + Ok(mut thumbnail_result) => { + if let Ok(timeline_path) = timeline_result { + if !timeline_path.is_empty() { + thumbnail_result.timeline_path = Some(timeline_path); + } + } + + processor.update_task_progress(&task_id, index, true, Some(thumbnail_result)).await; + Ok(Some(true)) + } + Err(e) => { + error!( + task_id = %task_id, + video_path = %video_path_str, + error = %e, + "视频缩略图生成失败" + ); + + let error_result = ThumbnailGenerationResult { + video_path: video_path_str, + success: false, + output_paths: Vec::new(), + timeline_path: None, + processing_time_ms: 0, + error_message: Some(e.to_string()), + metadata: Default::default(), + }; + + processor.update_task_progress(&task_id, index, false, Some(error_result)).await; + Ok(Some(false)) + } + } + }); + + handles.push(handle); + } + + // 等待所有任务完成 + let mut success_count = 0; + let mut failed_count = 0; + + for handle in handles { + match handle.await { + Ok(Ok(Some(true))) => success_count += 1, + Ok(Ok(Some(false))) => failed_count += 1, + Ok(Ok(None)) => { + // 任务被取消或暂停 + info!(task_id = %task_id, "任务被取消或暂停"); + return Ok(()); + } + Ok(Err(e)) => { + error!(task_id = %task_id, error = %e, "任务执行错误"); + failed_count += 1; + } + Err(e) => { + error!(task_id = %task_id, error = %e, "任务句柄错误"); + failed_count += 1; + } + } + } + + let processing_time = start_time.elapsed(); + + // 更新任务完成状态 + self.update_task_completed_time(task_id).await; + + if failed_count == 0 { + self.update_task_status(task_id, TaskStatus::Completed).await; + } else if success_count == 0 { + self.update_task_status(task_id, TaskStatus::Failed).await; + } else { + self.update_task_status(task_id, TaskStatus::Completed).await; + } + + info!( + task_id = %task_id, + success_count = success_count, + failed_count = failed_count, + processing_time_ms = processing_time.as_millis(), + "批量缩略图生成任务完成" + ); + + Ok(()) + } + + /// 扫描文件夹并创建批量任务 + pub async fn scan_and_create_task( + &self, + folder_path: &str, + config: ThumbnailConfig, + timeline_config: Option, + ) -> Result { + info!(folder_path = %folder_path, "扫描文件夹创建批量缩略图任务"); + + // 扫描视频文件 + let video_files = self.generator.scan_video_files(folder_path)?; + + if video_files.is_empty() { + return Err(anyhow!("文件夹中没有找到视频文件: {}", folder_path)); + } + + let video_paths: Vec = video_files.into_iter() + .filter(|f| f.is_valid) + .map(|f| f.path) + .collect(); + + if video_paths.is_empty() { + return Err(anyhow!("文件夹中没有找到有效的视频文件: {}", folder_path)); + } + + info!( + folder_path = %folder_path, + valid_video_count = video_paths.len(), + "扫描完成,创建批量任务" + ); + + self.start_batch_generation(video_paths, config, timeline_config).await + } + + /// 获取所有任务列表 + pub fn get_all_tasks(&self) -> Vec { + let tasks = self.tasks.lock().unwrap(); + tasks.values().cloned().collect() + } + + /// 清理已完成的任务 + pub fn cleanup_completed_tasks(&self) -> usize { + let mut tasks = self.tasks.lock().unwrap(); + let initial_count = tasks.len(); + + tasks.retain(|_, task| { + !matches!(task.status, TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled) + }); + + let removed_count = initial_count - tasks.len(); + + if removed_count > 0 { + info!(removed_count = removed_count, "清理已完成的任务"); + } + + removed_count + } + + /// 更新任务状态 + async fn update_task_status(&self, task_id: &str, status: TaskStatus) { + let mut tasks = self.tasks.lock().unwrap(); + if let Some(task) = tasks.get_mut(task_id) { + task.status = status; + task.updated_at = Utc::now(); + } + } + + /// 更新任务开始时间 + async fn update_task_started_time(&self, task_id: &str) { + let mut tasks = self.tasks.lock().unwrap(); + if let Some(task) = tasks.get_mut(task_id) { + task.started_at = Some(Utc::now()); + task.updated_at = Utc::now(); + } + } + + /// 更新任务完成时间 + async fn update_task_completed_time(&self, task_id: &str) { + let mut tasks = self.tasks.lock().unwrap(); + if let Some(task) = tasks.get_mut(task_id) { + task.completed_at = Some(Utc::now()); + task.updated_at = Utc::now(); + } + } + + /// 更新当前处理文件 + async fn update_current_file(&self, task_id: &str, current_file: &str) { + let mut tasks = self.tasks.lock().unwrap(); + if let Some(task) = tasks.get_mut(task_id) { + task.progress.current_file = Some(current_file.to_string()); + task.updated_at = Utc::now(); + } + } + + /// 更新任务进度 + async fn update_task_progress( + &self, + task_id: &str, + _file_index: usize, + success: bool, + result: Option, + ) { + let mut tasks = self.tasks.lock().unwrap(); + if let Some(task) = tasks.get_mut(task_id) { + if success { + task.progress.processed_files += 1; + } else { + task.progress.failed_files += 1; + if let Some(ref result) = result { + if let Some(ref error) = result.error_message { + task.progress.errors.push(error.clone()); + } + } + } + + if let Some(result) = result { + task.progress.results.push(result); + } + + // 计算进度百分比 + let total_processed = task.progress.processed_files + task.progress.failed_files; + task.progress.progress_percentage = + (total_processed as f32 / task.progress.total_files as f32) * 100.0; + + // 估算剩余时间 + if total_processed > 0 && task.started_at.is_some() { + let elapsed = Utc::now().signed_duration_since(task.started_at.unwrap()); + let elapsed_ms = elapsed.num_milliseconds() as u64; + let avg_time_per_file = elapsed_ms / total_processed as u64; + let remaining_files = task.progress.total_files - total_processed; + task.progress.estimated_remaining_ms = Some(avg_time_per_file * remaining_files as u64); + + // 计算处理速度 + if elapsed_ms > 0 { + task.progress.processing_speed = Some( + (total_processed as f32 * 1000.0) / elapsed_ms as f32 + ); + } + } + + task.updated_at = Utc::now(); + } + } + + /// 检查任务是否被取消或暂停 + async fn is_task_cancelled_or_paused(&self, task_id: &str) -> bool { + let tasks = self.tasks.lock().unwrap(); + if let Some(task) = tasks.get(task_id) { + matches!(task.status, TaskStatus::Cancelled | TaskStatus::Paused) + } else { + true // 任务不存在,视为已取消 + } + } +} + +impl Clone for BatchThumbnailProcessor { + fn clone(&self) -> Self { + Self { + generator: self.generator.clone(), + tasks: self.tasks.clone(), + semaphore: self.semaphore.clone(), + } + } +} + +// 为ThumbnailMetadata实现Default +impl Default for crate::data::models::thumbnail::ThumbnailMetadata { + fn default() -> Self { + Self { + video_duration: 0.0, + video_resolution: (0, 0), + thumbnail_count: 0, + total_file_size: 0, + timestamps_used: Vec::new(), + } + } +} diff --git a/apps/desktop/src-tauri/src/business/services/mod.rs b/apps/desktop/src-tauri/src/business/services/mod.rs index 9857c2c..eff0f6e 100644 --- a/apps/desktop/src-tauri/src/business/services/mod.rs +++ b/apps/desktop/src-tauri/src/business/services/mod.rs @@ -21,6 +21,8 @@ pub mod project_template_binding_service; pub mod material_matching_service; pub mod template_matching_result_service; pub mod export_record_service; +pub mod thumbnail_generator_service; +pub mod batch_thumbnail_processor; pub mod watermark_detection_service; pub mod watermark_removal_service; pub mod watermark_addition_service; diff --git a/apps/desktop/src-tauri/src/business/services/thumbnail_generator_service.rs b/apps/desktop/src-tauri/src/business/services/thumbnail_generator_service.rs new file mode 100644 index 0000000..8a9fdb1 --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/thumbnail_generator_service.rs @@ -0,0 +1,534 @@ +use anyhow::{Result, anyhow}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tracing::{info, error, warn}; +use uuid::Uuid; + +use crate::data::models::thumbnail::{ + ThumbnailConfig, ThumbnailSize, TimePoint, ImageFormat, + TimelineConfig, ThumbnailGenerationResult, ThumbnailMetadata, + VideoFile, SceneDetectionResult, SceneInfo, SceneDetectionMethod, + ThumbnailGenerationOptions +}; +use crate::infrastructure::ffmpeg::FFmpegService; +use crate::infrastructure::filename_utils::FilenameUtils; + +/// 缩略图生成服务 +/// 遵循 Tauri 开发规范的服务层设计原则 +pub struct ThumbnailGeneratorService { + ffmpeg_service: Arc, + options: ThumbnailGenerationOptions, +} + +impl ThumbnailGeneratorService { + /// 创建新的缩略图生成服务实例 + pub fn new(options: ThumbnailGenerationOptions) -> Self { + Self { + ffmpeg_service: Arc::new(FFmpegService), + options, + } + } + + /// 为单个视频生成缩略图 + pub async fn generate_thumbnail( + &self, + video_path: &str, + config: &ThumbnailConfig, + ) -> Result { + let start_time = std::time::Instant::now(); + + info!( + video_path = %video_path, + config = ?config, + "开始生成视频缩略图" + ); + + // 验证输入文件 + self.validate_video_file(video_path)?; + + // 获取视频信息 + let video_info = FFmpegService::get_video_info(video_path)?; + let video_duration = video_info.duration; + let video_resolution = ( + video_info.width, + video_info.height + ); + + // 计算实际时间戳 + let timestamps = self.calculate_timestamps(&config.time_points, video_duration)?; + + // 确保输出目录存在 + std::fs::create_dir_all(&config.output_dir)?; + + // 生成缩略图 + let mut output_paths = Vec::new(); + let mut total_file_size = 0u64; + + for (index, timestamp) in timestamps.iter().enumerate() { + let output_path = self.generate_output_path( + video_path, + *timestamp, + index, + config + )?; + + match self.generate_single_thumbnail( + video_path, + &output_path, + *timestamp, + &config.size, + &config.format, + config.quality, + ).await { + Ok(_) => { + if let Ok(metadata) = std::fs::metadata(&output_path) { + total_file_size += metadata.len(); + } + output_paths.push(output_path); + } + Err(e) => { + error!( + video_path = %video_path, + timestamp = timestamp, + error = %e, + "单个缩略图生成失败" + ); + if !self.options.enable_retry { + return Err(e); + } + } + } + } + + let processing_time = start_time.elapsed().as_millis() as u64; + + let result = ThumbnailGenerationResult { + video_path: video_path.to_string(), + success: !output_paths.is_empty(), + output_paths, + timeline_path: None, + processing_time_ms: processing_time, + error_message: None, + metadata: ThumbnailMetadata { + video_duration, + video_resolution, + thumbnail_count: timestamps.len() as u32, + total_file_size, + timestamps_used: timestamps, + }, + }; + + info!( + video_path = %video_path, + thumbnail_count = result.metadata.thumbnail_count, + processing_time_ms = processing_time, + "缩略图生成完成" + ); + + Ok(result) + } + + /// 生成时间轴缩略图条 + pub async fn generate_timeline_thumbnail( + &self, + video_path: &str, + config: &TimelineConfig, + thumbnail_config: &ThumbnailConfig, + ) -> Result { + info!( + video_path = %video_path, + frame_count = config.frame_count, + layout = ?config.layout, + "开始生成时间轴缩略图" + ); + + // 获取视频信息 + let video_info = FFmpegService::get_video_info(video_path)?; + let video_duration = video_info.duration; + + // 计算时间轴帧的时间戳 + let timestamps = self.calculate_timeline_timestamps(video_duration, config.frame_count)?; + + // 生成临时缩略图 + let mut temp_thumbnails = Vec::new(); + for (index, timestamp) in timestamps.iter().enumerate() { + let temp_path = thumbnail_config.output_dir.join(format!( + "temp_timeline_{}_{}.jpg", + Uuid::new_v4().to_string(), + index + )); + + self.generate_single_thumbnail( + video_path, + &temp_path.to_string_lossy(), + *timestamp, + &thumbnail_config.size, + &ImageFormat::Jpg, + thumbnail_config.quality, + ).await?; + + temp_thumbnails.push(temp_path); + } + + // 合成时间轴缩略图 + let timeline_path = self.compose_timeline_thumbnail( + &temp_thumbnails, + config, + thumbnail_config, + video_path, + ).await?; + + // 清理临时文件 + for temp_path in temp_thumbnails { + let _ = std::fs::remove_file(temp_path); + } + + info!( + video_path = %video_path, + timeline_path = %timeline_path, + "时间轴缩略图生成完成" + ); + + Ok(timeline_path) + } + + /// 智能场景检测 + pub async fn detect_best_frames( + &self, + video_path: &str, + frame_count: u32, + ) -> Result { + info!( + video_path = %video_path, + frame_count = frame_count, + "开始智能场景检测" + ); + + // 获取视频信息 + let video_info = FFmpegService::get_video_info(video_path)?; + let video_duration = video_info.duration; + + // 使用FFmpeg的场景检测功能 + let scenes = self.detect_scenes_with_ffmpeg(video_path, video_duration).await?; + + // 从场景中选择最佳帧 + let best_frames = self.select_best_frames_from_scenes(&scenes, frame_count); + + let result = SceneDetectionResult { + video_path: video_path.to_string(), + scenes, + best_frames, + detection_method: SceneDetectionMethod::ContentBased, + confidence_scores: vec![0.8; frame_count as usize], // 简化的置信度 + }; + + info!( + video_path = %video_path, + scene_count = result.scenes.len(), + best_frame_count = result.best_frames.len(), + "场景检测完成" + ); + + Ok(result) + } + + /// 验证视频文件 + fn validate_video_file(&self, video_path: &str) -> Result<()> { + if !Path::new(video_path).exists() { + return Err(anyhow!("视频文件不存在: {}", video_path)); + } + + if !FilenameUtils::is_video_file(video_path) { + return Err(anyhow!("不支持的视频格式: {}", video_path)); + } + + if self.options.enable_validation { + let metadata = std::fs::metadata(video_path)?; + if metadata.len() < self.options.min_file_size { + return Err(anyhow!("视频文件太小: {} bytes", metadata.len())); + } + } + + Ok(()) + } + + /// 计算时间戳 + fn calculate_timestamps(&self, time_points: &[TimePoint], duration: f64) -> Result> { + let mut timestamps = Vec::new(); + + for time_point in time_points { + match time_point { + TimePoint::Fixed(seconds) => { + let timestamp = seconds.min(duration).max(0.0); + timestamps.push(timestamp); + } + TimePoint::Percentage(percentage) => { + let timestamp = duration * (*percentage as f64); + timestamps.push(timestamp.min(duration).max(0.0)); + } + TimePoint::Multiple(points) => { + for point in points { + let timestamp = point.min(duration).max(0.0); + timestamps.push(timestamp); + } + } + TimePoint::SmartDetection(count) => { + // 这里应该调用智能检测,暂时使用均匀分布 + for i in 0..*count { + let percentage = (i as f64 + 1.0) / (*count as f64 + 1.0); + let timestamp = duration * percentage; + timestamps.push(timestamp); + } + } + } + } + + // 去重并排序 + timestamps.sort_by(|a, b| a.partial_cmp(b).unwrap()); + timestamps.dedup_by(|a, b| (*a - *b).abs() < 0.1); // 0.1秒内的时间戳视为重复 + + Ok(timestamps) + } + + /// 计算时间轴时间戳 + fn calculate_timeline_timestamps(&self, duration: f64, frame_count: u32) -> Result> { + let mut timestamps = Vec::new(); + + for i in 0..frame_count { + let percentage = (i as f64 + 0.5) / frame_count as f64; + let timestamp = duration * percentage; + timestamps.push(timestamp); + } + + Ok(timestamps) + } + + /// 生成输出路径 + fn generate_output_path( + &self, + video_path: &str, + timestamp: f64, + index: usize, + config: &ThumbnailConfig, + ) -> Result { + let video_name = Path::new(video_path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("video"); + + let filename = config.naming_pattern + .replace("{filename}", video_name) + .replace("{timestamp}", &format!("{:.1}", timestamp)) + .replace("{index}", &index.to_string()) + .replace("{ext}", config.format.extension()); + + let output_path = config.output_dir.join(filename); + Ok(output_path.to_string_lossy().to_string()) + } + + /// 生成单个缩略图 + async fn generate_single_thumbnail( + &self, + video_path: &str, + output_path: &str, + timestamp: f64, + size: &ThumbnailSize, + _format: &ImageFormat, + _quality: u8, + ) -> Result<()> { + let mut attempts = 0; + let max_attempts = if self.options.enable_retry { self.options.max_retries + 1 } else { 1 }; + + while attempts < max_attempts { + match FFmpegService::generate_thumbnail( + video_path, + output_path, + timestamp, + size.width, + size.height, + ) { + Ok(_) => { + // 验证生成的文件 + if Path::new(output_path).exists() { + return Ok(()); + } else { + return Err(anyhow!("缩略图文件生成失败")); + } + } + Err(e) => { + attempts += 1; + if attempts < max_attempts { + warn!( + video_path = %video_path, + timestamp = timestamp, + attempt = attempts, + error = %e, + "缩略图生成失败,正在重试" + ); + tokio::time::sleep(tokio::time::Duration::from_millis( + self.options.retry_delay_ms + )).await; + } else { + return Err(e); + } + } + } + } + + Err(anyhow!("缩略图生成失败,已达到最大重试次数")) + } + + /// 使用FFmpeg进行场景检测 + async fn detect_scenes_with_ffmpeg( + &self, + _video_path: &str, + duration: f64, + ) -> Result> { + // 简化的场景检测实现 + // 在实际应用中,这里应该使用FFmpeg的scene filter + let scene_count = (duration / 30.0).ceil() as u32; // 每30秒一个场景 + let mut scenes = Vec::new(); + + for i in 0..scene_count { + let start_time = i as f64 * 30.0; + let end_time = ((i + 1) as f64 * 30.0).min(duration); + let scene_duration = end_time - start_time; + + scenes.push(SceneInfo { + start_time, + end_time, + duration: scene_duration, + confidence: 0.8, // 简化的置信度 + representative_frame: start_time + scene_duration / 2.0, + }); + } + + Ok(scenes) + } + + /// 从场景中选择最佳帧 + fn select_best_frames_from_scenes( + &self, + scenes: &[SceneInfo], + frame_count: u32, + ) -> Vec { + let mut best_frames = Vec::new(); + + if scenes.is_empty() { + return best_frames; + } + + // 如果请求的帧数少于或等于场景数,从每个场景选择代表帧 + if frame_count <= scenes.len() as u32 { + let step = scenes.len() / frame_count as usize; + for i in (0..scenes.len()).step_by(step.max(1)) { + if best_frames.len() < frame_count as usize { + best_frames.push(scenes[i].representative_frame); + } + } + } else { + // 如果请求的帧数多于场景数,在每个场景内选择多个帧 + let frames_per_scene = frame_count / scenes.len() as u32; + let extra_frames = frame_count % scenes.len() as u32; + + for (scene_index, scene) in scenes.iter().enumerate() { + let mut scene_frame_count = frames_per_scene; + if scene_index < extra_frames as usize { + scene_frame_count += 1; + } + + for i in 0..scene_frame_count { + let progress = (i as f64 + 0.5) / scene_frame_count as f64; + let frame_time = scene.start_time + scene.duration * progress; + best_frames.push(frame_time); + } + } + } + + best_frames.sort_by(|a, b| a.partial_cmp(b).unwrap()); + best_frames + } + + /// 合成时间轴缩略图 + async fn compose_timeline_thumbnail( + &self, + temp_thumbnails: &[PathBuf], + _config: &TimelineConfig, + thumbnail_config: &ThumbnailConfig, + video_path: &str, + ) -> Result { + let video_name = Path::new(video_path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("video"); + + let timeline_filename = format!("{}_timeline.{}", video_name, thumbnail_config.format.extension()); + let timeline_path = thumbnail_config.output_dir.join(timeline_filename); + + // 这里应该使用图像处理库(如image crate)来合成时间轴 + // 为了简化,我们暂时复制第一个缩略图作为时间轴 + if let Some(first_thumbnail) = temp_thumbnails.first() { + std::fs::copy(first_thumbnail, &timeline_path)?; + } + + Ok(timeline_path.to_string_lossy().to_string()) + } + + /// 扫描文件夹中的视频文件 + pub fn scan_video_files(&self, folder_path: &str) -> Result> { + let mut video_files = Vec::new(); + let folder = Path::new(folder_path); + + if !folder.exists() || !folder.is_dir() { + return Err(anyhow!("文件夹不存在或不是目录: {}", folder_path)); + } + + for entry in std::fs::read_dir(folder)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + let path_str = path.to_string_lossy().to_string(); + if FilenameUtils::is_video_file(&path_str) { + let video_file = self.create_video_file_info(&path)?; + video_files.push(video_file); + } + } + } + + video_files.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(video_files) + } + + /// 创建视频文件信息 + fn create_video_file_info(&self, path: &Path) -> Result { + let metadata = std::fs::metadata(path)?; + let name = path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let path_str = path.to_string_lossy().to_string(); + + // 尝试获取视频信息 + let (duration, resolution, format, is_valid) = match FFmpegService::get_video_info(&path_str) { + Ok(info) => ( + Some(info.duration), + Some((info.width, info.height)), + Some(info.format), + true + ), + Err(_) => (None, None, None, false), + }; + + Ok(VideoFile { + path: path.to_path_buf(), + name, + size: metadata.len(), + duration, + resolution, + format, + is_valid, + }) + } +} diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index 2752250..0a01add 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -16,3 +16,4 @@ pub mod outfit_search; pub mod gemini_analysis; pub mod custom_tag; pub mod watermark; +pub mod thumbnail; diff --git a/apps/desktop/src-tauri/src/data/models/thumbnail.rs b/apps/desktop/src-tauri/src/data/models/thumbnail.rs new file mode 100644 index 0000000..0857a38 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/thumbnail.rs @@ -0,0 +1,273 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; +use std::path::PathBuf; + +/// 缩略图生成配置 +/// 遵循 Tauri 开发规范的数据模型设计原则 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThumbnailConfig { + pub time_points: Vec, + pub size: ThumbnailSize, + pub format: ImageFormat, + pub quality: u8, + pub output_dir: PathBuf, + pub naming_pattern: String, + pub preserve_aspect_ratio: bool, +} + +/// 时间点配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TimePoint { + Fixed(f64), // 固定时间点(秒) + Percentage(f32), // 百分比位置 (0.0-1.0) + Multiple(Vec), // 多个时间点 + SmartDetection(u32), // 智能场景检测(帧数) +} + +/// 缩略图尺寸配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThumbnailSize { + pub width: u32, + pub height: u32, + pub preset: Option, +} + +/// 预设尺寸 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum SizePreset { + Tiny, // 160x120 + Small, // 320x240 + Medium, // 640x480 + Large, // 1280x720 + FullHD, // 1920x1080 + Custom, // 自定义尺寸 +} + +/// 图片格式 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ImageFormat { + Jpg, + Png, + WebP, +} + +/// 时间轴缩略图配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimelineConfig { + pub frame_count: u32, + pub layout: TimelineLayout, + pub show_timestamps: bool, + pub spacing: u32, + pub background_color: Option, + pub border_width: Option, +} + +/// 时间轴布局 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TimelineLayout { + Horizontal, + Vertical, + Grid { columns: u32 }, +} + +/// 批量缩略图任务 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchThumbnailTask { + pub task_id: String, + pub video_files: Vec, + pub config: ThumbnailConfig, + pub timeline_config: Option, + pub status: TaskStatus, + pub progress: BatchProgress, + pub created_at: DateTime, + pub updated_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, +} + +/// 任务状态 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TaskStatus { + Pending, // 等待中 + Running, // 执行中 + Completed, // 已完成 + Failed, // 失败 + Cancelled, // 已取消 + Paused, // 已暂停 +} + +/// 批量处理进度 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchProgress { + pub total_files: u32, + pub processed_files: u32, + pub failed_files: u32, + pub current_file: Option, + pub progress_percentage: f32, + pub estimated_remaining_ms: Option, + pub processing_speed: Option, // 文件/秒 + pub errors: Vec, + pub results: Vec, +} + +/// 缩略图生成结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThumbnailGenerationResult { + pub video_path: String, + pub success: bool, + pub output_paths: Vec, + pub timeline_path: Option, + pub processing_time_ms: u64, + pub error_message: Option, + pub metadata: ThumbnailMetadata, +} + +/// 缩略图元数据 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThumbnailMetadata { + pub video_duration: f64, + pub video_resolution: (u32, u32), + pub thumbnail_count: u32, + pub total_file_size: u64, + pub timestamps_used: Vec, +} + +/// 视频文件信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoFile { + pub path: PathBuf, + pub name: String, + pub size: u64, + pub duration: Option, + pub resolution: Option<(u32, u32)>, + pub format: Option, + pub is_valid: bool, +} + +/// 场景检测结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SceneDetectionResult { + pub video_path: String, + pub scenes: Vec, + pub best_frames: Vec, + pub detection_method: SceneDetectionMethod, + pub confidence_scores: Vec, +} + +/// 场景信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SceneInfo { + pub start_time: f64, + pub end_time: f64, + pub duration: f64, + pub confidence: f32, + pub representative_frame: f64, +} + +/// 场景检测方法 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum SceneDetectionMethod { + ContentBased, // 基于内容的场景检测 + MotionBased, // 基于运动的场景检测 + ColorBased, // 基于颜色的场景检测 + Combined, // 组合检测 +} + +/// 缩略图生成选项 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThumbnailGenerationOptions { + pub enable_retry: bool, + pub max_retries: u32, + pub retry_delay_ms: u64, + pub enable_parallel: bool, + pub max_concurrent: u32, + pub enable_cache: bool, + pub cache_duration_hours: u32, + pub enable_validation: bool, + pub min_file_size: u64, +} + +// 默认实现 +impl Default for ThumbnailConfig { + fn default() -> Self { + Self { + time_points: vec![TimePoint::Percentage(0.5)], // 默认50%位置 + size: ThumbnailSize::default(), + format: ImageFormat::Jpg, + quality: 85, + output_dir: PathBuf::from("thumbnails"), + naming_pattern: "{filename}_{timestamp}.{ext}".to_string(), + preserve_aspect_ratio: true, + } + } +} + +impl Default for ThumbnailSize { + fn default() -> Self { + Self { + width: 320, + height: 240, + preset: Some(SizePreset::Small), + } + } +} + +impl Default for TimelineConfig { + fn default() -> Self { + Self { + frame_count: 10, + layout: TimelineLayout::Horizontal, + show_timestamps: true, + spacing: 2, + background_color: Some("#000000".to_string()), + border_width: Some(1), + } + } +} + +impl Default for ThumbnailGenerationOptions { + fn default() -> Self { + Self { + enable_retry: true, + max_retries: 3, + retry_delay_ms: 1000, + enable_parallel: true, + max_concurrent: 4, + enable_cache: true, + cache_duration_hours: 24, + enable_validation: true, + min_file_size: 1024, // 1KB + } + } +} + +impl SizePreset { + pub fn to_dimensions(&self) -> (u32, u32) { + match self { + SizePreset::Tiny => (160, 120), + SizePreset::Small => (320, 240), + SizePreset::Medium => (640, 480), + SizePreset::Large => (1280, 720), + SizePreset::FullHD => (1920, 1080), + SizePreset::Custom => (320, 240), // 默认值 + } + } +} + +impl ImageFormat { + pub fn extension(&self) -> &'static str { + match self { + ImageFormat::Jpg => "jpg", + ImageFormat::Png => "png", + ImageFormat::WebP => "webp", + } + } + + pub fn mime_type(&self) -> &'static str { + match self { + ImageFormat::Jpg => "image/jpeg", + ImageFormat::Png => "image/png", + ImageFormat::WebP => "image/webp", + } + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4470300..760947d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -348,7 +348,19 @@ pub fn run() { commands::watermark_commands::delete_watermark_template, commands::watermark_commands::get_batch_task_status, commands::watermark_commands::get_watermark_template_thumbnail, - commands::watermark_commands::cancel_batch_task + commands::watermark_commands::cancel_batch_task, + // 批量缩略图生成命令 + commands::thumbnail_commands::start_batch_thumbnail_generation, + commands::thumbnail_commands::scan_folder_and_generate_thumbnails, + commands::thumbnail_commands::get_thumbnail_task_status, + commands::thumbnail_commands::cancel_thumbnail_task, + commands::thumbnail_commands::pause_thumbnail_task, + commands::thumbnail_commands::resume_thumbnail_task, + commands::thumbnail_commands::get_all_thumbnail_tasks, + commands::thumbnail_commands::cleanup_completed_thumbnail_tasks, + commands::thumbnail_commands::select_video_folder, + commands::thumbnail_commands::scan_video_files, + commands::thumbnail_commands::preview_thumbnail ]) .setup(|app| { // 初始化日志系统 diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index 9413df8..110f379 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -22,6 +22,7 @@ pub mod outfit_search_commands; pub mod custom_tag_commands; pub mod tolerant_json_commands; pub mod markdown_commands; +pub mod thumbnail_commands; pub mod rag_grounding_commands; pub mod image_download_commands; pub mod conversation_commands; diff --git a/apps/desktop/src-tauri/src/presentation/commands/thumbnail_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/thumbnail_commands.rs new file mode 100644 index 0000000..aaf17d3 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/thumbnail_commands.rs @@ -0,0 +1,308 @@ +use tauri::command; +use std::sync::{Arc, Mutex}; +use std::path::PathBuf; +use tracing::{info, error, warn}; +use lazy_static::lazy_static; + +use crate::data::models::thumbnail::{ + ThumbnailConfig, TimelineConfig, BatchThumbnailTask, + ThumbnailGenerationOptions, VideoFile +}; +use crate::business::services::batch_thumbnail_processor::BatchThumbnailProcessor; + +/// 全局批量缩略图处理器实例 +/// 遵循 Tauri 开发规范的状态管理模式 +lazy_static! { + static ref THUMBNAIL_PROCESSOR: Arc>>> = + Arc::new(Mutex::new(None)); +} + +/// 获取或创建批量缩略图处理器实例 +fn get_or_create_processor() -> Arc { + let mut processor_guard = THUMBNAIL_PROCESSOR.lock().unwrap(); + + if processor_guard.is_none() { + let options = ThumbnailGenerationOptions::default(); + *processor_guard = Some(Arc::new(BatchThumbnailProcessor::new(options))); + info!("创建新的批量缩略图处理器实例"); + } + + processor_guard.as_ref().unwrap().clone() +} + +/// 批量缩略图生成请求 +#[derive(Debug, serde::Deserialize)] +pub struct BatchThumbnailRequest { + pub video_paths: Vec, + pub config: ThumbnailConfig, + pub timeline_config: Option, +} + +/// 文件夹扫描请求 +#[derive(Debug, serde::Deserialize)] +pub struct FolderScanRequest { + pub folder_path: String, + pub config: ThumbnailConfig, + pub timeline_config: Option, +} + +/// 缩略图预览请求 +#[derive(Debug, serde::Deserialize)] +pub struct ThumbnailPreviewRequest { + pub video_path: String, + pub timestamp: f64, + pub width: u32, + pub height: u32, +} + +/// 启动批量缩略图生成 +/// 接受视频文件路径列表和配置,返回任务ID +#[command] +pub async fn start_batch_thumbnail_generation( + request: BatchThumbnailRequest, +) -> Result { + info!( + video_count = request.video_paths.len(), + config = ?request.config, + "启动批量缩略图生成" + ); + + let processor = get_or_create_processor(); + + let video_paths: Vec = request.video_paths + .into_iter() + .map(PathBuf::from) + .collect(); + + processor + .start_batch_generation(video_paths, request.config, request.timeline_config) + .await + .map_err(|e| { + error!(error = %e, "启动批量缩略图生成失败"); + e.to_string() + }) +} + +/// 扫描文件夹并启动批量生成 +/// 扫描指定文件夹中的所有视频文件并启动批量生成任务 +#[command] +pub async fn scan_folder_and_generate_thumbnails( + request: FolderScanRequest, +) -> Result { + info!( + folder_path = %request.folder_path, + config = ?request.config, + "扫描文件夹并启动批量缩略图生成" + ); + + let processor = get_or_create_processor(); + + processor + .scan_and_create_task(&request.folder_path, request.config, request.timeline_config) + .await + .map_err(|e| { + error!( + folder_path = %request.folder_path, + error = %e, + "扫描文件夹并启动批量生成失败" + ); + e.to_string() + }) +} + +/// 获取任务状态 +/// 根据任务ID获取批量生成任务的详细状态信息 +#[command] +pub async fn get_thumbnail_task_status( + task_id: String, +) -> Result { + let processor = get_or_create_processor(); + + processor + .get_task_status(&task_id) + .map_err(|e| { + warn!(task_id = %task_id, error = %e, "获取任务状态失败"); + e.to_string() + }) +} + +/// 取消缩略图生成任务 +/// 取消指定的批量生成任务 +#[command] +pub async fn cancel_thumbnail_task( + task_id: String, +) -> Result { + info!(task_id = %task_id, "取消缩略图生成任务"); + + let processor = get_or_create_processor(); + + processor + .cancel_task(&task_id) + .await + .map_err(|e| { + error!(task_id = %task_id, error = %e, "取消任务失败"); + e.to_string() + }) +} + +/// 暂停缩略图生成任务 +/// 暂停指定的批量生成任务 +#[command] +pub async fn pause_thumbnail_task( + task_id: String, +) -> Result { + info!(task_id = %task_id, "暂停缩略图生成任务"); + + let processor = get_or_create_processor(); + + processor + .pause_task(&task_id) + .await + .map_err(|e| { + error!(task_id = %task_id, error = %e, "暂停任务失败"); + e.to_string() + }) +} + +/// 恢复缩略图生成任务 +/// 恢复已暂停的批量生成任务 +#[command] +pub async fn resume_thumbnail_task( + task_id: String, +) -> Result { + info!(task_id = %task_id, "恢复缩略图生成任务"); + + let processor = get_or_create_processor(); + + processor + .resume_task(&task_id) + .await + .map_err(|e| { + error!(task_id = %task_id, error = %e, "恢复任务失败"); + e.to_string() + }) +} + +/// 获取所有任务列表 +/// 获取所有批量生成任务的列表 +#[command] +pub async fn get_all_thumbnail_tasks() -> Result, String> { + let processor = get_or_create_processor(); + Ok(processor.get_all_tasks()) +} + +/// 清理已完成的任务 +/// 清理所有已完成、失败或取消的任务 +#[command] +pub async fn cleanup_completed_thumbnail_tasks() -> Result { + let processor = get_or_create_processor(); + let removed_count = processor.cleanup_completed_tasks(); + + info!(removed_count = removed_count, "清理已完成的缩略图任务"); + Ok(removed_count) +} + +/// 选择视频文件夹 +/// 打开文件夹选择对话框,返回选中的文件夹路径 +#[command] +pub async fn select_video_folder() -> Result, String> { + // 这里需要在实际的Tauri应用上下文中调用 + // 暂时返回None,实际实现需要在前端调用文件夹选择对话框 + warn!("select_video_folder 需要在前端实现文件夹选择对话框"); + Ok(None) +} + +/// 扫描文件夹中的视频文件 +/// 扫描指定文件夹,返回其中的视频文件列表 +#[command] +pub async fn scan_video_files( + folder_path: String, +) -> Result, String> { + info!(folder_path = %folder_path, "扫描文件夹中的视频文件"); + + // 创建临时的生成器服务来扫描文件 + let options = ThumbnailGenerationOptions::default(); + let generator = crate::business::services::thumbnail_generator_service::ThumbnailGeneratorService::new(options); + + generator + .scan_video_files(&folder_path) + .map_err(|e| { + error!( + folder_path = %folder_path, + error = %e, + "扫描视频文件失败" + ); + e.to_string() + }) +} + +/// 生成缩略图预览 +/// 为指定视频在指定时间戳生成预览缩略图,返回base64编码的图片数据 +#[command] +pub async fn preview_thumbnail( + request: ThumbnailPreviewRequest, +) -> Result { + info!( + video_path = %request.video_path, + timestamp = request.timestamp, + size = format!("{}x{}", request.width, request.height), + "生成缩略图预览" + ); + + use crate::infrastructure::ffmpeg::FFmpegService; + use std::fs; + use base64::{Engine as _, engine::general_purpose}; + + // 创建临时输出文件 + let temp_dir = std::env::temp_dir(); + let temp_filename = format!("preview_{}_{}.jpg", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(), + uuid::Uuid::new_v4().to_string() + ); + let temp_path = temp_dir.join(temp_filename); + let temp_path_str = temp_path.to_string_lossy().to_string(); + + // 生成缩略图 + match FFmpegService::generate_thumbnail( + &request.video_path, + &temp_path_str, + request.timestamp, + request.width, + request.height, + ) { + Ok(_) => { + // 读取文件并转换为base64 + match fs::read(&temp_path) { + Ok(image_data) => { + let base64_data = general_purpose::STANDARD.encode(&image_data); + let data_url = format!("data:image/jpeg;base64,{}", base64_data); + + // 清理临时文件 + let _ = fs::remove_file(&temp_path); + + Ok(data_url) + } + Err(e) => { + error!( + temp_path = %temp_path_str, + error = %e, + "读取预览缩略图文件失败" + ); + let _ = fs::remove_file(&temp_path); + Err(format!("读取预览缩略图失败: {}", e)) + } + } + } + Err(e) => { + error!( + video_path = %request.video_path, + error = %e, + "生成预览缩略图失败" + ); + Err(format!("生成预览缩略图失败: {}", e)) + } + } +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index d01cfb2..1ba06e2 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -16,6 +16,7 @@ import DebugPanelTool from './pages/tools/DebugPanelTool'; import ChatTool from './pages/tools/ChatTool'; import ChatTestPage from './pages/tools/ChatTestPage'; import WatermarkTool from './pages/tools/WatermarkTool'; +import BatchThumbnailGenerator from './pages/tools/BatchThumbnailGenerator'; import Navigation from './components/Navigation'; import { NotificationSystem, useNotifications } from './components/NotificationSystem'; @@ -101,6 +102,7 @@ function App() { } /> } /> } /> + } /> diff --git a/apps/desktop/src/components/thumbnail/BatchProgress.tsx b/apps/desktop/src/components/thumbnail/BatchProgress.tsx new file mode 100644 index 0000000..c9b7740 --- /dev/null +++ b/apps/desktop/src/components/thumbnail/BatchProgress.tsx @@ -0,0 +1,256 @@ +import React from 'react'; +import { + Play, + Pause, + Square, + Clock, + CheckCircle, + AlertCircle, + FileVideo, + Image, + TrendingUp +} from 'lucide-react'; +import { + BatchThumbnailTask, + TaskStatus, + TASK_STATUS_LABELS, + TASK_STATUS_COLORS, + formatDuration, + formatFileSize, + formatProcessingSpeed +} from '../../types/thumbnail'; + +interface BatchProgressProps { + task: BatchThumbnailTask; + onCancel: () => void; + onPause: () => void; + onResume: () => void; +} + +/** + * 批量处理进度组件 + * 遵循 Tauri 开发规范和 UI/UX 设计标准 + */ +export const BatchProgress: React.FC = ({ + task, + onCancel, + onPause, + onResume, +}) => { + const { progress, status } = task; + + // 计算剩余时间显示 + const formatRemainingTime = (ms?: number): string => { + if (!ms) return '未知'; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}秒`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}分钟`; + const hours = Math.floor(minutes / 60); + return `${hours}小时${minutes % 60}分钟`; + }; + + // 获取状态颜色 + const getStatusColor = (status: TaskStatus): string => { + return TASK_STATUS_COLORS[status] || 'text-gray-600 bg-gray-50'; + }; + + return ( +
+ {/* 任务标题和状态 */} +
+
+

批量缩略图生成进度

+

任务ID: {task.task_id}

+
+
+ + {TASK_STATUS_LABELS[status]} + +
+ {status === TaskStatus.Running && ( + + )} + {status === TaskStatus.Paused && ( + + )} + {(status === TaskStatus.Running || status === TaskStatus.Paused) && ( + + )} +
+
+
+ + {/* 进度条 */} +
+
+ 总体进度 + {progress.progress_percentage.toFixed(1)}% +
+
+
+
+
+ + {progress.processed_files + progress.failed_files} / {progress.total_files} 文件 + + {progress.estimated_remaining_ms && ( + + + 剩余 {formatRemainingTime(progress.estimated_remaining_ms)} + + )} +
+
+ + {/* 当前处理文件 */} + {progress.current_file && status === TaskStatus.Running && ( +
+
+ + 正在处理: + + {progress.current_file.split(/[/\\]/).pop()} + +
+
+ )} + + {/* 统计信息 */} +
+
+
+ +
+
+ {progress.processed_files} +
+
已完成
+
+ +
+
+ +
+
+ {progress.failed_files} +
+
失败
+
+ +
+
+ +
+
+ {progress.total_files} +
+
总文件
+
+ +
+
+ +
+
+ {progress.processing_speed ? formatProcessingSpeed(progress.processing_speed) : '-'} +
+
处理速度
+
+
+ + {/* 生成结果 */} + {progress.results.length > 0 && ( +
+

+ + 生成结果 ({progress.results.length}) +

+
+ {progress.results.slice(-10).map((result, index) => ( +
+
+ {result.success ? ( + + ) : ( + + )} +
+
+
+ {result.video_path.split(/[/\\]/).pop()} +
+
+ {result.success ? ( + <> + {result.output_paths.length} 个缩略图 • + {result.processing_time_ms}ms • + {formatFileSize(result.metadata.total_file_size)} + + ) : ( + result.error_message + )} +
+
+
+ ))} +
+
+ )} + + {/* 错误信息 */} + {progress.errors.length > 0 && ( +
+

+ + 错误信息 ({progress.errors.length}) +

+
+ {progress.errors.slice(-5).map((error, index) => ( +
+ {error} +
+ ))} +
+
+ )} + + {/* 任务时间信息 */} +
+
创建时间: {new Date(task.created_at).toLocaleString()}
+ {task.started_at && ( +
开始时间: {new Date(task.started_at).toLocaleString()}
+ )} + {task.completed_at && ( +
完成时间: {new Date(task.completed_at).toLocaleString()}
+ )} +
+
+ ); +}; diff --git a/apps/desktop/src/components/thumbnail/TaskList.tsx b/apps/desktop/src/components/thumbnail/TaskList.tsx new file mode 100644 index 0000000..d392e52 --- /dev/null +++ b/apps/desktop/src/components/thumbnail/TaskList.tsx @@ -0,0 +1,271 @@ +import React from 'react'; +import { + Play, + Pause, + Square, + Trash2, + Clock, + CheckCircle, + AlertCircle, + FileVideo, + MoreHorizontal +} from 'lucide-react'; +import { + BatchThumbnailTask, + TaskStatus, + TASK_STATUS_LABELS, + TASK_STATUS_COLORS, + formatDuration +} from '../../types/thumbnail'; + +interface TaskListProps { + tasks: BatchThumbnailTask[]; + onCancel: (taskId: string) => void; + onPause: (taskId: string) => void; + onResume: (taskId: string) => void; + onCleanup: () => void; +} + +/** + * 任务列表组件 + * 遵循 Tauri 开发规范和 UI/UX 设计标准 + */ +export const TaskList: React.FC = ({ + tasks, + onCancel, + onPause, + onResume, + onCleanup, +}) => { + // 按状态分组任务 + const groupedTasks = React.useMemo(() => { + const groups: Record = { + [TaskStatus.Running]: [], + [TaskStatus.Paused]: [], + [TaskStatus.Pending]: [], + [TaskStatus.Completed]: [], + [TaskStatus.Failed]: [], + [TaskStatus.Cancelled]: [], + }; + + tasks.forEach(task => { + groups[task.status].push(task); + }); + + return groups; + }, [tasks]); + + // 获取状态颜色 + const getStatusColor = (status: TaskStatus): string => { + return TASK_STATUS_COLORS[status] || 'text-gray-600 bg-gray-50'; + }; + + // 渲染任务项 + const renderTask = (task: BatchThumbnailTask) => { + const { progress } = task; + const canControl = task.status === TaskStatus.Running || task.status === TaskStatus.Paused; + + return ( +
+ {/* 任务头部 */} +
+
+
+ +
+
+

+ 批量缩略图任务 +

+

+ {task.video_files.length} 个文件 • {task.task_id.slice(0, 8)}... +

+
+
+ +
+ + {TASK_STATUS_LABELS[task.status]} + + + {canControl && ( +
+ {task.status === TaskStatus.Running && ( + + )} + {task.status === TaskStatus.Paused && ( + + )} + +
+ )} +
+
+ + {/* 进度信息 */} + {task.status !== TaskStatus.Pending && ( +
+
+ + {progress.processed_files + progress.failed_files} / {progress.total_files} + + + {progress.progress_percentage.toFixed(1)}% + +
+ +
+
+
+ +
+
+ + + {progress.processed_files} + + {progress.failed_files > 0 && ( + + + {progress.failed_files} + + )} +
+ +
+ + {new Date(task.updated_at).toLocaleTimeString()} +
+
+
+ )} + + {/* 当前处理文件 */} + {progress.current_file && task.status === TaskStatus.Running && ( +
+ 正在处理: + + {progress.current_file.split(/[/\\]/).pop()} + +
+ )} + + {/* 错误信息 */} + {progress.errors.length > 0 && ( +
+
+ 错误 ({progress.errors.length}) +
+
+ {progress.errors[progress.errors.length - 1]} +
+
+ )} +
+ ); + }; + + // 渲染任务组 + const renderTaskGroup = (status: TaskStatus, tasks: BatchThumbnailTask[]) => { + if (tasks.length === 0) return null; + + return ( +
+
+

+ + {TASK_STATUS_LABELS[status]} ({tasks.length}) +

+
+
+ {tasks.map(renderTask)} +
+
+ ); + }; + + if (tasks.length === 0) { + return ( +
+
+ +

没有任务

+

还没有创建任何批量缩略图生成任务

+
+
+ ); + } + + return ( +
+ {/* 任务列表头部 */} +
+
+

任务列表

+

共 {tasks.length} 个任务

+
+ +
+ +
+
+ + {/* 任务统计 */} +
+ {Object.entries(groupedTasks).map(([status, tasks]) => ( +
+
+ {tasks.length} +
+
+ {TASK_STATUS_LABELS[status as TaskStatus]} +
+
+ ))} +
+ + {/* 任务分组列表 */} +
+ {renderTaskGroup(TaskStatus.Running, groupedTasks[TaskStatus.Running])} + {renderTaskGroup(TaskStatus.Paused, groupedTasks[TaskStatus.Paused])} + {renderTaskGroup(TaskStatus.Pending, groupedTasks[TaskStatus.Pending])} + {renderTaskGroup(TaskStatus.Completed, groupedTasks[TaskStatus.Completed])} + {renderTaskGroup(TaskStatus.Failed, groupedTasks[TaskStatus.Failed])} + {renderTaskGroup(TaskStatus.Cancelled, groupedTasks[TaskStatus.Cancelled])} +
+
+ ); +}; diff --git a/apps/desktop/src/components/thumbnail/ThumbnailConfigPanel.tsx b/apps/desktop/src/components/thumbnail/ThumbnailConfigPanel.tsx new file mode 100644 index 0000000..521a2c9 --- /dev/null +++ b/apps/desktop/src/components/thumbnail/ThumbnailConfigPanel.tsx @@ -0,0 +1,300 @@ +import React from 'react'; +import { Settings, Clock, Image as ImageIcon, Sliders } from 'lucide-react'; +import { + ThumbnailConfig, + TimePoint, + SizePreset, + ImageFormat, + SIZE_PRESET_DIMENSIONS +} from '../../types/thumbnail'; +import { CustomSelect } from '../CustomSelect'; + +interface ThumbnailConfigPanelProps { + config: ThumbnailConfig; + onChange: (config: ThumbnailConfig) => void; +} + +/** + * 缩略图配置面板组件 + * 遵循 Tauri 开发规范和 UI/UX 设计标准 + */ +export const ThumbnailConfigPanel: React.FC = ({ + config, + onChange, +}) => { + // 更新配置的辅助函数 + const updateConfig = (updates: Partial) => { + onChange({ ...config, ...updates }); + }; + + // 更新时间点配置 + const updateTimePoints = (timePoints: TimePoint[]) => { + updateConfig({ time_points: timePoints }); + }; + + // 更新尺寸配置 + const updateSize = (updates: Partial) => { + updateConfig({ + size: { ...config.size, ...updates } + }); + }; + + // 处理预设尺寸变化 + const handlePresetChange = (preset: string) => { + const sizePreset = preset as SizePreset; + if (sizePreset === SizePreset.Custom) { + updateSize({ preset: sizePreset }); + } else { + const [width, height] = SIZE_PRESET_DIMENSIONS[sizePreset]; + updateSize({ width, height, preset: sizePreset }); + } + }; + + // 添加时间点 + const addTimePoint = () => { + const newTimePoint: TimePoint = { Percentage: 0.5 }; + updateTimePoints([...config.time_points, newTimePoint]); + }; + + // 删除时间点 + const removeTimePoint = (index: number) => { + const newTimePoints = config.time_points.filter((_, i) => i !== index); + updateTimePoints(newTimePoints); + }; + + // 更新时间点 + const updateTimePoint = (index: number, timePoint: TimePoint) => { + const newTimePoints = [...config.time_points]; + newTimePoints[index] = timePoint; + updateTimePoints(newTimePoints); + }; + + // 渲染时间点配置 + const renderTimePointConfig = (timePoint: TimePoint, index: number) => { + const timePointType = Object.keys(timePoint)[0] as keyof TimePoint; + const timePointValue = Object.values(timePoint)[0]; + + return ( +
+
+ { + let newTimePoint: TimePoint; + switch (type) { + case 'Fixed': + newTimePoint = { Fixed: 5.0 }; + break; + case 'Percentage': + newTimePoint = { Percentage: 0.5 }; + break; + case 'SmartDetection': + newTimePoint = { SmartDetection: 5 }; + break; + default: + newTimePoint = { Percentage: 0.5 }; + } + updateTimePoint(index, newTimePoint); + }} + options={[ + { value: 'Fixed', label: '固定时间' }, + { value: 'Percentage', label: '百分比' }, + { value: 'SmartDetection', label: '智能检测' }, + ]} + className="text-sm" + /> + +
+ { + const value = parseFloat(e.target.value); + let newTimePoint: TimePoint; + switch (timePointType) { + case 'Fixed': + newTimePoint = { Fixed: value }; + break; + case 'Percentage': + newTimePoint = { Percentage: Math.max(0, Math.min(1, value)) }; + break; + case 'SmartDetection': + newTimePoint = { SmartDetection: Math.max(1, Math.floor(value)) }; + break; + default: + newTimePoint = { Percentage: 0.5 }; + } + updateTimePoint(index, newTimePoint); + }} + min={timePointType === 'Percentage' ? 0 : timePointType === 'SmartDetection' ? 1 : 0} + max={timePointType === 'Percentage' ? 1 : undefined} + step={timePointType === 'Percentage' ? 0.1 : timePointType === 'SmartDetection' ? 1 : 0.1} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> + + {timePointType === 'Fixed' ? '秒' : + timePointType === 'Percentage' ? '%' : '帧'} + +
+
+ + +
+ ); + }; + + return ( +
+

+ + 缩略图配置 +

+ + {/* 时间点配置 */} +
+
+ + +
+ +
+ {config.time_points.map((timePoint, index) => + renderTimePointConfig(timePoint, index) + )} +
+
+ + {/* 尺寸配置 */} +
+ + +
+
+ + +
+ +
+
+ + updateSize({ width: parseInt(e.target.value), preset: SizePreset.Custom })} + min={1} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+
+ + updateSize({ height: parseInt(e.target.value), preset: SizePreset.Custom })} + min={1} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+
+
+
+ + {/* 格式和质量配置 */} +
+
+ + updateConfig({ format: format as ImageFormat })} + options={[ + { value: ImageFormat.Jpg, label: 'JPEG' }, + { value: ImageFormat.Png, label: 'PNG' }, + { value: ImageFormat.WebP, label: 'WebP' }, + ]} + /> +
+ +
+ + updateConfig({ quality: parseInt(e.target.value) })} + className="w-full" + /> +
+
+ + {/* 输出配置 */} +
+
+ + updateConfig({ output_dir: e.target.value })} + placeholder="thumbnails" + className="w-full px-3 py-2 border border-gray-300 rounded-lg" + /> +
+ +
+ + updateConfig({ naming_pattern: e.target.value })} + placeholder="{filename}_{timestamp}.{ext}" + className="w-full px-3 py-2 border border-gray-300 rounded-lg" + /> +

+ 可用变量: {'{filename}'}, {'{timestamp}'}, {'{index}'}, {'{ext}'} +

+
+ + +
+
+ ); +}; diff --git a/apps/desktop/src/components/thumbnail/ThumbnailPreview.tsx b/apps/desktop/src/components/thumbnail/ThumbnailPreview.tsx new file mode 100644 index 0000000..fd8c71b --- /dev/null +++ b/apps/desktop/src/components/thumbnail/ThumbnailPreview.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { X, Download, ZoomIn, ZoomOut } from 'lucide-react'; + +interface ThumbnailPreviewProps { + imageUrl: string; + onClose: () => void; +} + +/** + * 缩略图预览组件 + * 遵循 Tauri 开发规范和 UI/UX 设计标准 + */ +export const ThumbnailPreview: React.FC = ({ + imageUrl, + onClose, +}) => { + const [zoom, setZoom] = React.useState(1); + + const handleDownload = () => { + const link = document.createElement('a'); + link.href = imageUrl; + link.download = `thumbnail_${Date.now()}.jpg`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleZoomIn = () => { + setZoom(prev => Math.min(prev * 1.2, 3)); + }; + + const handleZoomOut = () => { + setZoom(prev => Math.max(prev / 1.2, 0.5)); + }; + + const resetZoom = () => { + setZoom(1); + }; + + return ( +
+
+

缩略图预览

+
+ + + + + +
+
+ +
+
+ 缩略图预览 +
+
+ +
+ 点击工具栏按钮可以缩放、下载或关闭预览 +
+
+ ); +}; diff --git a/apps/desktop/src/components/thumbnail/TimelineConfigPanel.tsx b/apps/desktop/src/components/thumbnail/TimelineConfigPanel.tsx new file mode 100644 index 0000000..59a14da --- /dev/null +++ b/apps/desktop/src/components/thumbnail/TimelineConfigPanel.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { Grid, Palette, Ruler } from 'lucide-react'; +import { TimelineConfig, TimelineLayout } from '../../types/thumbnail'; +import { CustomSelect } from '../CustomSelect'; + +interface TimelineConfigPanelProps { + config: TimelineConfig; + onChange: (config: TimelineConfig) => void; +} + +/** + * 时间轴配置面板组件 + * 遵循 Tauri 开发规范和 UI/UX 设计标准 + */ +export const TimelineConfigPanel: React.FC = ({ + config, + onChange, +}) => { + // 更新配置的辅助函数 + const updateConfig = (updates: Partial) => { + onChange({ ...config, ...updates }); + }; + + // 处理布局变化 + const handleLayoutChange = (layoutType: string) => { + let layout: TimelineLayout; + switch (layoutType) { + case 'Horizontal': + layout = 'Horizontal'; + break; + case 'Vertical': + layout = 'Vertical'; + break; + case 'Grid': + layout = { Grid: { columns: 3 } }; + break; + default: + layout = 'Horizontal'; + } + updateConfig({ layout }); + }; + + // 获取当前布局类型 + const getCurrentLayoutType = (): string => { + if (typeof config.layout === 'string') { + return config.layout; + } else if (typeof config.layout === 'object' && 'Grid' in config.layout) { + return 'Grid'; + } + return 'Horizontal'; + }; + + // 获取网格列数 + const getGridColumns = (): number => { + if (typeof config.layout === 'object' && 'Grid' in config.layout) { + return config.layout.Grid.columns; + } + return 3; + }; + + // 更新网格列数 + const updateGridColumns = (columns: number) => { + updateConfig({ layout: { Grid: { columns } } }); + }; + + return ( +
+ {/* 帧数配置 */} +
+
+ + updateConfig({ frame_count: parseInt(e.target.value) })} + min={1} + max={50} + className="w-full px-3 py-2 border border-gray-300 rounded-lg" + /> +
+ +
+ + updateConfig({ spacing: parseInt(e.target.value) })} + min={0} + max={20} + className="w-full px-3 py-2 border border-gray-300 rounded-lg" + /> +
+
+ + {/* 布局配置 */} +
+ + + + + {getCurrentLayoutType() === 'Grid' && ( +
+ + updateGridColumns(parseInt(e.target.value))} + min={1} + max={10} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+ )} +
+ + {/* 样式配置 */} +
+ + +
+
+ +
+ updateConfig({ background_color: e.target.value })} + className="w-10 h-8 border border-gray-300 rounded cursor-pointer" + /> + updateConfig({ background_color: e.target.value })} + placeholder="#000000" + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+
+ +
+ + updateConfig({ border_width: parseInt(e.target.value) })} + min={0} + max={10} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+
+ + +
+ + {/* 预览区域 */} +
+

布局预览

+
+ {getCurrentLayoutType() === 'Horizontal' && ( +
+ {Array.from({ length: Math.min(config.frame_count, 8) }).map((_, i) => ( +
+ ))} + {config.frame_count > 8 && ...} +
+ )} + + {getCurrentLayoutType() === 'Vertical' && ( +
+ {Array.from({ length: Math.min(config.frame_count, 6) }).map((_, i) => ( +
+ ))} + {config.frame_count > 6 && ...} +
+ )} + + {getCurrentLayoutType() === 'Grid' && ( +
+ {Array.from({ length: Math.min(config.frame_count, getGridColumns() * 3) }).map((_, i) => ( +
+ ))} + {config.frame_count > getGridColumns() * 3 && ( +
...
+ )} +
+ )} +
+
+
+ ); +}; diff --git a/apps/desktop/src/components/thumbnail/VideoFileList.tsx b/apps/desktop/src/components/thumbnail/VideoFileList.tsx new file mode 100644 index 0000000..f7a4484 --- /dev/null +++ b/apps/desktop/src/components/thumbnail/VideoFileList.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { FileVideo, Eye, Clock, HardDrive } from 'lucide-react'; +import { VideoFile, formatDuration, formatFileSize } from '../../types/thumbnail'; + +interface VideoFileListProps { + videos: VideoFile[]; + onPreview: (videoPath: string, timestamp: number) => void; +} + +/** + * 视频文件列表组件 + * 遵循 Tauri 开发规范和 UI/UX 设计标准 + */ +export const VideoFileList: React.FC = ({ + videos, + onPreview, +}) => { + if (videos.length === 0) { + return ( +
+ +

没有找到视频文件

+
+ ); + } + + return ( +
+
+

+ 视频文件列表 ({videos.length} 个) +

+
+ 总大小: {formatFileSize(videos.reduce((sum, v) => sum + v.size, 0))} +
+
+ +
+ {videos.map((video, index) => ( +
+ {/* 文件图标 */} +
+ +
+ + {/* 文件信息 */} +
+
+
+ {video.name} +
+ {!video.is_valid && ( + + 无效 + + )} +
+ +
+ {video.duration && ( +
+ + {formatDuration(video.duration)} +
+ )} + + {video.resolution && ( +
+ {video.resolution[0]}×{video.resolution[1]} +
+ )} + +
+ + {formatFileSize(video.size)} +
+ + {video.format && ( +
+ {video.format} +
+ )} +
+
+ + {/* 预览按钮 */} + {video.is_valid && video.duration && ( + + )} +
+ ))} +
+ + {/* 统计信息 */} +
+
+
+
+ {videos.filter(v => v.is_valid).length} +
+
有效文件
+
+
+
+ {videos.filter(v => !v.is_valid).length} +
+
无效文件
+
+
+
+ {formatDuration( + videos + .filter(v => v.duration) + .reduce((sum, v) => sum + (v.duration || 0), 0) + )} +
+
总时长
+
+
+
+
+ ); +}; diff --git a/apps/desktop/src/data/tools.ts b/apps/desktop/src/data/tools.ts index 21cbfe2..82baae3 100644 --- a/apps/desktop/src/data/tools.ts +++ b/apps/desktop/src/data/tools.ts @@ -6,7 +6,8 @@ import { Database, FileSearch, MessageCircle, - Droplets + Droplets, + Image } from 'lucide-react'; import { Tool, ToolCategory, ToolStatus } from '../types/tool'; @@ -85,6 +86,21 @@ export const TOOLS_DATA: Tool[] = [ isPopular: true, version: '1.0.0', lastUpdated: '2024-01-23' + }, + { + id: 'batch-thumbnail-generator', + name: '批量缩略图生成器', + description: '为视频文件批量生成预览缩略图和时间轴,支持自定义时间戳、尺寸和格式', + longDescription: '专业的批量缩略图生成工具,支持多种视频格式的批量处理。提供灵活的时间戳配置、多种尺寸预设、智能场景检测和时间轴缩略图生成功能。支持并发处理、进度监控和错误恢复机制。', + icon: Image, + route: '/tools/batch-thumbnail-generator', + category: ToolCategory.FILE_PROCESSING, + status: ToolStatus.STABLE, + tags: ['缩略图生成', '批量处理', '视频处理', '时间轴', '场景检测'], + isNew: true, + isPopular: true, + version: '1.0.0', + lastUpdated: '2024-01-24' } ]; diff --git a/apps/desktop/src/pages/tools/BatchThumbnailGenerator.tsx b/apps/desktop/src/pages/tools/BatchThumbnailGenerator.tsx new file mode 100644 index 0000000..8f7ec29 --- /dev/null +++ b/apps/desktop/src/pages/tools/BatchThumbnailGenerator.tsx @@ -0,0 +1,455 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Image, + FolderOpen, + Play, + Settings, + Clock, + List, + Grid, + RefreshCw +} from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; +import { open } from '@tauri-apps/plugin-dialog'; + +import { + BatchThumbnailTask, + ThumbnailConfig, + TimelineConfig, + VideoFile, + TaskStatus, + BatchThumbnailRequest, + FolderScanRequest, + ThumbnailPreviewRequest, + DEFAULT_THUMBNAIL_CONFIG, + DEFAULT_TIMELINE_CONFIG +} from '../../types/thumbnail'; + +import { ThumbnailConfigPanel } from '../../components/thumbnail/ThumbnailConfigPanel'; +import { TimelineConfigPanel } from '../../components/thumbnail/TimelineConfigPanel'; +import { ThumbnailPreview } from '../../components/thumbnail/ThumbnailPreview'; +import { BatchProgress } from '../../components/thumbnail/BatchProgress'; +import { VideoFileList } from '../../components/thumbnail/VideoFileList'; +import { TaskList } from '../../components/thumbnail/TaskList'; + +/** + * 批量缩略图生成器主组件 + * 遵循 Tauri 开发规范和 UI/UX 设计标准 + */ +const BatchThumbnailGenerator: React.FC = () => { + // 状态管理 + const [selectedVideos, setSelectedVideos] = useState([]); + const [config, setConfig] = useState(DEFAULT_THUMBNAIL_CONFIG); + const [timelineConfig, setTimelineConfig] = useState(DEFAULT_TIMELINE_CONFIG); + const [enableTimeline, setEnableTimeline] = useState(false); + const [currentTask, setCurrentTask] = useState(null); + const [allTasks, setAllTasks] = useState([]); + const [previewImage, setPreviewImage] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + const [isScanning, setIsScanning] = useState(false); + const [viewMode, setViewMode] = useState<'config' | 'progress' | 'tasks'>('config'); + const [selectedFolder, setSelectedFolder] = useState(''); + + // 轮询更新任务状态 + useEffect(() => { + const interval = setInterval(() => { + if (currentTask && currentTask.status === TaskStatus.Running) { + updateTaskStatus(currentTask.task_id); + } + updateAllTasks(); + }, 2000); + + return () => clearInterval(interval); + }, [currentTask]); + + // 选择文件夹 + const handleSelectFolder = useCallback(async () => { + try { + const selected = await open({ + directory: true, + multiple: false, + title: '选择视频文件夹', + }); + + if (selected && typeof selected === 'string') { + setSelectedFolder(selected); + await scanVideoFiles(selected); + } + } catch (error) { + console.error('选择文件夹失败:', error); + } + }, []); + + // 扫描视频文件 + const scanVideoFiles = useCallback(async (folderPath: string) => { + setIsScanning(true); + try { + const files: VideoFile[] = await invoke('scan_video_files', { + folderPath + }); + + setSelectedVideos(files.filter(f => f.is_valid)); + } catch (error) { + console.error('扫描视频文件失败:', error); + } finally { + setIsScanning(false); + } + }, []); + + // 启动批量生成 + const handleStartGeneration = useCallback(async () => { + if (selectedVideos.length === 0) { + alert('请先选择视频文件'); + return; + } + + setIsGenerating(true); + try { + const request: BatchThumbnailRequest = { + video_paths: selectedVideos.map(v => v.path), + config, + timeline_config: enableTimeline ? timelineConfig : undefined, + }; + + const taskId: string = await invoke('start_batch_thumbnail_generation', { + request + }); + + // 获取任务详情 + const task: BatchThumbnailTask = await invoke('get_thumbnail_task_status', { + taskId + }); + + setCurrentTask(task); + setViewMode('progress'); + } catch (error) { + console.error('启动批量生成失败:', error); + alert(`启动失败: ${error}`); + } finally { + setIsGenerating(false); + } + }, [selectedVideos, config, timelineConfig, enableTimeline]); + + // 扫描文件夹并启动生成 + const handleScanAndGenerate = useCallback(async () => { + if (!selectedFolder) { + alert('请先选择文件夹'); + return; + } + + setIsGenerating(true); + try { + const request: FolderScanRequest = { + folder_path: selectedFolder, + config, + timeline_config: enableTimeline ? timelineConfig : undefined, + }; + + const taskId: string = await invoke('scan_folder_and_generate_thumbnails', { + request + }); + + // 获取任务详情 + const task: BatchThumbnailTask = await invoke('get_thumbnail_task_status', { + taskId + }); + + setCurrentTask(task); + setViewMode('progress'); + } catch (error) { + console.error('扫描并生成失败:', error); + alert(`操作失败: ${error}`); + } finally { + setIsGenerating(false); + } + }, [selectedFolder, config, timelineConfig, enableTimeline]); + + // 更新任务状态 + const updateTaskStatus = useCallback(async (taskId: string) => { + try { + const task: BatchThumbnailTask = await invoke('get_thumbnail_task_status', { + taskId + }); + setCurrentTask(task); + } catch (error) { + console.error('更新任务状态失败:', error); + } + }, []); + + // 更新所有任务 + const updateAllTasks = useCallback(async () => { + try { + const tasks: BatchThumbnailTask[] = await invoke('get_all_thumbnail_tasks'); + setAllTasks(tasks); + } catch (error) { + console.error('获取任务列表失败:', error); + } + }, []); + + // 取消任务 + const handleCancelTask = useCallback(async (taskId: string) => { + try { + await invoke('cancel_thumbnail_task', { taskId }); + await updateTaskStatus(taskId); + } catch (error) { + console.error('取消任务失败:', error); + } + }, [updateTaskStatus]); + + // 暂停任务 + const handlePauseTask = useCallback(async (taskId: string) => { + try { + await invoke('pause_thumbnail_task', { taskId }); + await updateTaskStatus(taskId); + } catch (error) { + console.error('暂停任务失败:', error); + } + }, [updateTaskStatus]); + + // 恢复任务 + const handleResumeTask = useCallback(async (taskId: string) => { + try { + await invoke('resume_thumbnail_task', { taskId }); + await updateTaskStatus(taskId); + } catch (error) { + console.error('恢复任务失败:', error); + } + }, [updateTaskStatus]); + + // 预览缩略图 + const handlePreviewThumbnail = useCallback(async (videoPath: string, timestamp: number) => { + try { + const request: ThumbnailPreviewRequest = { + video_path: videoPath, + timestamp, + width: config.size.width, + height: config.size.height, + }; + + const dataUrl: string = await invoke('preview_thumbnail', { request }); + setPreviewImage(dataUrl); + } catch (error) { + console.error('预览缩略图失败:', error); + } + }, [config.size]); + + // 清理已完成任务 + const handleCleanupTasks = useCallback(async () => { + try { + const removedCount: number = await invoke('cleanup_completed_thumbnail_tasks'); + alert(`已清理 ${removedCount} 个已完成的任务`); + await updateAllTasks(); + } catch (error) { + console.error('清理任务失败:', error); + } + }, [updateAllTasks]); + + // 初始化时获取任务列表 + useEffect(() => { + updateAllTasks(); + }, [updateAllTasks]); + + return ( +
+ {/* 页面标题 */} +
+
+
+ +
+
+

+ 批量缩略图生成器 +

+

为视频文件批量生成预览缩略图和时间轴

+
+
+ + {/* 视图切换 */} +
+ + + +
+
+ + {/* 主要内容区域 */} +
+ {/* 左侧:文件选择和配置 */} +
+ {viewMode === 'config' && ( + <> + {/* 文件选择区域 */} +
+

+ + 视频文件选择 +

+ +
+
+ + + {selectedFolder && ( +
+ {selectedFolder} +
+ )} +
+ + {selectedVideos.length > 0 && ( + + )} +
+
+ + {/* 配置面板 */} + + + {/* 时间轴配置 */} +
+
+

+ + 时间轴缩略图 +

+ +
+ + {enableTimeline && ( + + )} +
+ + {/* 操作按钮 */} +
+ + + +
+ + )} + + {viewMode === 'progress' && currentTask && ( + handleCancelTask(currentTask.task_id)} + onPause={() => handlePauseTask(currentTask.task_id)} + onResume={() => handleResumeTask(currentTask.task_id)} + /> + )} + + {viewMode === 'tasks' && ( + + )} +
+ + {/* 右侧:预览区域 */} +
+ {previewImage && ( + setPreviewImage(null)} + /> + )} + + {/* 统计信息 */} +
+

统计信息

+
+
+ 选中视频: + {selectedVideos.length} 个 +
+
+ 活跃任务: + + {allTasks.filter(t => t.status === TaskStatus.Running).length} 个 + +
+
+ 已完成任务: + + {allTasks.filter(t => t.status === TaskStatus.Completed).length} 个 + +
+
+
+
+
+
+ ); +}; + +export default BatchThumbnailGenerator; diff --git a/apps/desktop/src/types/thumbnail.ts b/apps/desktop/src/types/thumbnail.ts new file mode 100644 index 0000000..da20281 --- /dev/null +++ b/apps/desktop/src/types/thumbnail.ts @@ -0,0 +1,303 @@ +/** + * 批量缩略图生成器类型定义 + * 遵循 Tauri 开发规范的类型系统设计 + */ + +// 时间点配置 +export type TimePoint = + | { Fixed: number } // 固定时间点(秒) + | { Percentage: number } // 百分比位置 (0.0-1.0) + | { Multiple: number[] } // 多个时间点 + | { SmartDetection: number }; // 智能场景检测(帧数) + +// 预设尺寸 +export enum SizePreset { + Tiny = 'Tiny', // 160x120 + Small = 'Small', // 320x240 + Medium = 'Medium', // 640x480 + Large = 'Large', // 1280x720 + FullHD = 'FullHD', // 1920x1080 + Custom = 'Custom', // 自定义尺寸 +} + +// 缩略图尺寸配置 +export interface ThumbnailSize { + width: number; + height: number; + preset?: SizePreset; +} + +// 图片格式 +export enum ImageFormat { + Jpg = 'Jpg', + Png = 'Png', + WebP = 'WebP', +} + +// 缩略图生成配置 +export interface ThumbnailConfig { + time_points: TimePoint[]; + size: ThumbnailSize; + format: ImageFormat; + quality: number; + output_dir: string; + naming_pattern: string; + preserve_aspect_ratio: boolean; +} + +// 时间轴布局 +export type TimelineLayout = + | 'Horizontal' + | 'Vertical' + | { Grid: { columns: number } }; + +// 时间轴缩略图配置 +export interface TimelineConfig { + frame_count: number; + layout: TimelineLayout; + show_timestamps: boolean; + spacing: number; + background_color?: string; + border_width?: number; +} + +// 任务状态 +export enum TaskStatus { + Pending = 'Pending', // 等待中 + Running = 'Running', // 执行中 + Completed = 'Completed', // 已完成 + Failed = 'Failed', // 失败 + Cancelled = 'Cancelled', // 已取消 + Paused = 'Paused', // 已暂停 +} + +// 缩略图元数据 +export interface ThumbnailMetadata { + video_duration: number; + video_resolution: [number, number]; + thumbnail_count: number; + total_file_size: number; + timestamps_used: number[]; +} + +// 缩略图生成结果 +export interface ThumbnailGenerationResult { + video_path: string; + success: boolean; + output_paths: string[]; + timeline_path?: string; + processing_time_ms: number; + error_message?: string; + metadata: ThumbnailMetadata; +} + +// 批量处理进度 +export interface BatchProgress { + total_files: number; + processed_files: number; + failed_files: number; + current_file?: string; + progress_percentage: number; + estimated_remaining_ms?: number; + processing_speed?: number; // 文件/秒 + errors: string[]; + results: ThumbnailGenerationResult[]; +} + +// 批量缩略图任务 +export interface BatchThumbnailTask { + task_id: string; + video_files: string[]; + config: ThumbnailConfig; + timeline_config?: TimelineConfig; + status: TaskStatus; + progress: BatchProgress; + created_at: string; + updated_at: string; + started_at?: string; + completed_at?: string; +} + +// 视频文件信息 +export interface VideoFile { + path: string; + name: string; + size: number; + duration?: number; + resolution?: [number, number]; + format?: string; + is_valid: boolean; +} + +// 场景信息 +export interface SceneInfo { + start_time: number; + end_time: number; + duration: number; + confidence: number; + representative_frame: number; +} + +// 场景检测方法 +export enum SceneDetectionMethod { + ContentBased = 'ContentBased', // 基于内容的场景检测 + MotionBased = 'MotionBased', // 基于运动的场景检测 + ColorBased = 'ColorBased', // 基于颜色的场景检测 + Combined = 'Combined', // 组合检测 +} + +// 场景检测结果 +export interface SceneDetectionResult { + video_path: string; + scenes: SceneInfo[]; + best_frames: number[]; + detection_method: SceneDetectionMethod; + confidence_scores: number[]; +} + +// 缩略图生成选项 +export interface ThumbnailGenerationOptions { + enable_retry: boolean; + max_retries: number; + retry_delay_ms: number; + enable_parallel: boolean; + max_concurrent: number; + enable_cache: boolean; + cache_duration_hours: number; + enable_validation: boolean; + min_file_size: number; +} + +// 批量缩略图生成请求 +export interface BatchThumbnailRequest { + video_paths: string[]; + config: ThumbnailConfig; + timeline_config?: TimelineConfig; +} + +// 文件夹扫描请求 +export interface FolderScanRequest { + folder_path: string; + config: ThumbnailConfig; + timeline_config?: TimelineConfig; +} + +// 缩略图预览请求 +export interface ThumbnailPreviewRequest { + video_path: string; + timestamp: number; + width: number; + height: number; +} + +// 默认配置 +export const DEFAULT_THUMBNAIL_CONFIG: ThumbnailConfig = { + time_points: [{ Percentage: 0.5 }], // 默认50%位置 + size: { + width: 320, + height: 240, + preset: SizePreset.Small, + }, + format: ImageFormat.Jpg, + quality: 85, + output_dir: 'thumbnails', + naming_pattern: '{filename}_{timestamp}.{ext}', + preserve_aspect_ratio: true, +}; + +export const DEFAULT_TIMELINE_CONFIG: TimelineConfig = { + frame_count: 10, + layout: 'Horizontal', + show_timestamps: true, + spacing: 2, + background_color: '#000000', + border_width: 1, +}; + +export const DEFAULT_GENERATION_OPTIONS: ThumbnailGenerationOptions = { + enable_retry: true, + max_retries: 3, + retry_delay_ms: 1000, + enable_parallel: true, + max_concurrent: 4, + enable_cache: true, + cache_duration_hours: 24, + enable_validation: true, + min_file_size: 1024, // 1KB +}; + +// 尺寸预设映射 +export const SIZE_PRESET_DIMENSIONS: Record = { + [SizePreset.Tiny]: [160, 120], + [SizePreset.Small]: [320, 240], + [SizePreset.Medium]: [640, 480], + [SizePreset.Large]: [1280, 720], + [SizePreset.FullHD]: [1920, 1080], + [SizePreset.Custom]: [320, 240], // 默认值 +}; + +// 图片格式扩展名映射 +export const IMAGE_FORMAT_EXTENSIONS: Record = { + [ImageFormat.Jpg]: 'jpg', + [ImageFormat.Png]: 'png', + [ImageFormat.WebP]: 'webp', +}; + +// 图片格式MIME类型映射 +export const IMAGE_FORMAT_MIME_TYPES: Record = { + [ImageFormat.Jpg]: 'image/jpeg', + [ImageFormat.Png]: 'image/png', + [ImageFormat.WebP]: 'image/webp', +}; + +// 任务状态显示文本映射 +export const TASK_STATUS_LABELS: Record = { + [TaskStatus.Pending]: '等待中', + [TaskStatus.Running]: '执行中', + [TaskStatus.Completed]: '已完成', + [TaskStatus.Failed]: '失败', + [TaskStatus.Cancelled]: '已取消', + [TaskStatus.Paused]: '已暂停', +}; + +// 任务状态颜色映射 +export const TASK_STATUS_COLORS: Record = { + [TaskStatus.Pending]: 'text-yellow-600 bg-yellow-50', + [TaskStatus.Running]: 'text-blue-600 bg-blue-50', + [TaskStatus.Completed]: 'text-green-600 bg-green-50', + [TaskStatus.Failed]: 'text-red-600 bg-red-50', + [TaskStatus.Cancelled]: 'text-gray-600 bg-gray-50', + [TaskStatus.Paused]: 'text-orange-600 bg-orange-50', +}; + +// 工具函数 +export const formatDuration = (seconds: number): string => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; +}; + +export const formatFileSize = (bytes: number): string => { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; +}; + +export const formatProcessingSpeed = (speed: number): string => { + if (speed < 1) { + return `${(speed * 60).toFixed(1)} 文件/分钟`; + } + return `${speed.toFixed(1)} 文件/秒`; +};