feat: 实现批量缩略图生成器功能
- 添加批量缩略图生成的数据模型和类型定义 - 实现ThumbnailGeneratorService核心服务 - 实现BatchThumbnailProcessor批量处理器 - 添加Tauri命令接口支持前端调用 - 创建完整的前端UI组件和页面 - 支持多种时间戳配置和尺寸预设 - 支持时间轴缩略图生成 - 支持并发处理和进度监控 - 集成到便捷工具页面 遵循promptx/tauri-desktop-app-expert开发规范
This commit is contained in:
@@ -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<ThumbnailGeneratorService>,
|
||||||
|
tasks: Arc<Mutex<HashMap<String, BatchThumbnailTask>>>,
|
||||||
|
semaphore: Arc<Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PathBuf>,
|
||||||
|
config: ThumbnailConfig,
|
||||||
|
timeline_config: Option<TimelineConfig>,
|
||||||
|
) -> Result<String> {
|
||||||
|
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<BatchThumbnailTask> {
|
||||||
|
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<bool> {
|
||||||
|
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<bool> {
|
||||||
|
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<bool> {
|
||||||
|
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<tokio::task::JoinHandle<Result<Option<bool>, 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<TimelineConfig>,
|
||||||
|
) -> Result<String> {
|
||||||
|
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<PathBuf> = 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<BatchThumbnailTask> {
|
||||||
|
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<ThumbnailGenerationResult>,
|
||||||
|
) {
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ pub mod project_template_binding_service;
|
|||||||
pub mod material_matching_service;
|
pub mod material_matching_service;
|
||||||
pub mod template_matching_result_service;
|
pub mod template_matching_result_service;
|
||||||
pub mod export_record_service;
|
pub mod export_record_service;
|
||||||
|
pub mod thumbnail_generator_service;
|
||||||
|
pub mod batch_thumbnail_processor;
|
||||||
pub mod watermark_detection_service;
|
pub mod watermark_detection_service;
|
||||||
pub mod watermark_removal_service;
|
pub mod watermark_removal_service;
|
||||||
pub mod watermark_addition_service;
|
pub mod watermark_addition_service;
|
||||||
|
|||||||
@@ -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<FFmpegService>,
|
||||||
|
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<ThumbnailGenerationResult> {
|
||||||
|
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<String> {
|
||||||
|
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<SceneDetectionResult> {
|
||||||
|
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<Vec<f64>> {
|
||||||
|
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<Vec<f64>> {
|
||||||
|
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<String> {
|
||||||
|
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<Vec<SceneInfo>> {
|
||||||
|
// 简化的场景检测实现
|
||||||
|
// 在实际应用中,这里应该使用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<f64> {
|
||||||
|
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<String> {
|
||||||
|
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<Vec<VideoFile>> {
|
||||||
|
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<VideoFile> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,3 +16,4 @@ pub mod outfit_search;
|
|||||||
pub mod gemini_analysis;
|
pub mod gemini_analysis;
|
||||||
pub mod custom_tag;
|
pub mod custom_tag;
|
||||||
pub mod watermark;
|
pub mod watermark;
|
||||||
|
pub mod thumbnail;
|
||||||
|
|||||||
273
apps/desktop/src-tauri/src/data/models/thumbnail.rs
Normal file
273
apps/desktop/src-tauri/src/data/models/thumbnail.rs
Normal file
@@ -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<TimePoint>,
|
||||||
|
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<f64>), // 多个时间点
|
||||||
|
SmartDetection(u32), // 智能场景检测(帧数)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 缩略图尺寸配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ThumbnailSize {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub preset: Option<SizePreset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预设尺寸
|
||||||
|
#[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<String>,
|
||||||
|
pub border_width: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时间轴布局
|
||||||
|
#[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<PathBuf>,
|
||||||
|
pub config: ThumbnailConfig,
|
||||||
|
pub timeline_config: Option<TimelineConfig>,
|
||||||
|
pub status: TaskStatus,
|
||||||
|
pub progress: BatchProgress,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub started_at: Option<DateTime<Utc>>,
|
||||||
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务状态
|
||||||
|
#[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<String>,
|
||||||
|
pub progress_percentage: f32,
|
||||||
|
pub estimated_remaining_ms: Option<u64>,
|
||||||
|
pub processing_speed: Option<f32>, // 文件/秒
|
||||||
|
pub errors: Vec<String>,
|
||||||
|
pub results: Vec<ThumbnailGenerationResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 缩略图生成结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ThumbnailGenerationResult {
|
||||||
|
pub video_path: String,
|
||||||
|
pub success: bool,
|
||||||
|
pub output_paths: Vec<String>,
|
||||||
|
pub timeline_path: Option<String>,
|
||||||
|
pub processing_time_ms: u64,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
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<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 视频文件信息
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VideoFile {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub resolution: Option<(u32, u32)>,
|
||||||
|
pub format: Option<String>,
|
||||||
|
pub is_valid: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 场景检测结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SceneDetectionResult {
|
||||||
|
pub video_path: String,
|
||||||
|
pub scenes: Vec<SceneInfo>,
|
||||||
|
pub best_frames: Vec<f64>,
|
||||||
|
pub detection_method: SceneDetectionMethod,
|
||||||
|
pub confidence_scores: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 场景信息
|
||||||
|
#[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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -348,7 +348,19 @@ pub fn run() {
|
|||||||
commands::watermark_commands::delete_watermark_template,
|
commands::watermark_commands::delete_watermark_template,
|
||||||
commands::watermark_commands::get_batch_task_status,
|
commands::watermark_commands::get_batch_task_status,
|
||||||
commands::watermark_commands::get_watermark_template_thumbnail,
|
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| {
|
.setup(|app| {
|
||||||
// 初始化日志系统
|
// 初始化日志系统
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod outfit_search_commands;
|
|||||||
pub mod custom_tag_commands;
|
pub mod custom_tag_commands;
|
||||||
pub mod tolerant_json_commands;
|
pub mod tolerant_json_commands;
|
||||||
pub mod markdown_commands;
|
pub mod markdown_commands;
|
||||||
|
pub mod thumbnail_commands;
|
||||||
pub mod rag_grounding_commands;
|
pub mod rag_grounding_commands;
|
||||||
pub mod image_download_commands;
|
pub mod image_download_commands;
|
||||||
pub mod conversation_commands;
|
pub mod conversation_commands;
|
||||||
|
|||||||
@@ -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<Mutex<Option<Arc<BatchThumbnailProcessor>>>> =
|
||||||
|
Arc::new(Mutex::new(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取或创建批量缩略图处理器实例
|
||||||
|
fn get_or_create_processor() -> Arc<BatchThumbnailProcessor> {
|
||||||
|
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<String>,
|
||||||
|
pub config: ThumbnailConfig,
|
||||||
|
pub timeline_config: Option<TimelineConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文件夹扫描请求
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct FolderScanRequest {
|
||||||
|
pub folder_path: String,
|
||||||
|
pub config: ThumbnailConfig,
|
||||||
|
pub timeline_config: Option<TimelineConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 缩略图预览请求
|
||||||
|
#[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<String, String> {
|
||||||
|
info!(
|
||||||
|
video_count = request.video_paths.len(),
|
||||||
|
config = ?request.config,
|
||||||
|
"启动批量缩略图生成"
|
||||||
|
);
|
||||||
|
|
||||||
|
let processor = get_or_create_processor();
|
||||||
|
|
||||||
|
let video_paths: Vec<PathBuf> = 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<String, String> {
|
||||||
|
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<BatchThumbnailTask, String> {
|
||||||
|
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<bool, String> {
|
||||||
|
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<bool, String> {
|
||||||
|
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<bool, String> {
|
||||||
|
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<Vec<BatchThumbnailTask>, String> {
|
||||||
|
let processor = get_or_create_processor();
|
||||||
|
Ok(processor.get_all_tasks())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理已完成的任务
|
||||||
|
/// 清理所有已完成、失败或取消的任务
|
||||||
|
#[command]
|
||||||
|
pub async fn cleanup_completed_thumbnail_tasks() -> Result<usize, String> {
|
||||||
|
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<Option<String>, String> {
|
||||||
|
// 这里需要在实际的Tauri应用上下文中调用
|
||||||
|
// 暂时返回None,实际实现需要在前端调用文件夹选择对话框
|
||||||
|
warn!("select_video_folder 需要在前端实现文件夹选择对话框");
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描文件夹中的视频文件
|
||||||
|
/// 扫描指定文件夹,返回其中的视频文件列表
|
||||||
|
#[command]
|
||||||
|
pub async fn scan_video_files(
|
||||||
|
folder_path: String,
|
||||||
|
) -> Result<Vec<VideoFile>, 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<String, String> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import DebugPanelTool from './pages/tools/DebugPanelTool';
|
|||||||
import ChatTool from './pages/tools/ChatTool';
|
import ChatTool from './pages/tools/ChatTool';
|
||||||
import ChatTestPage from './pages/tools/ChatTestPage';
|
import ChatTestPage from './pages/tools/ChatTestPage';
|
||||||
import WatermarkTool from './pages/tools/WatermarkTool';
|
import WatermarkTool from './pages/tools/WatermarkTool';
|
||||||
|
import BatchThumbnailGenerator from './pages/tools/BatchThumbnailGenerator';
|
||||||
|
|
||||||
import Navigation from './components/Navigation';
|
import Navigation from './components/Navigation';
|
||||||
import { NotificationSystem, useNotifications } from './components/NotificationSystem';
|
import { NotificationSystem, useNotifications } from './components/NotificationSystem';
|
||||||
@@ -101,6 +102,7 @@ function App() {
|
|||||||
<Route path="/tools/ai-chat" element={<ChatTool />} />
|
<Route path="/tools/ai-chat" element={<ChatTool />} />
|
||||||
<Route path="/tools/chat-test" element={<ChatTestPage />} />
|
<Route path="/tools/chat-test" element={<ChatTestPage />} />
|
||||||
<Route path="/tools/watermark" element={<WatermarkTool />} />
|
<Route path="/tools/watermark" element={<WatermarkTool />} />
|
||||||
|
<Route path="/tools/batch-thumbnail-generator" element={<BatchThumbnailGenerator />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
256
apps/desktop/src/components/thumbnail/BatchProgress.tsx
Normal file
256
apps/desktop/src/components/thumbnail/BatchProgress.tsx
Normal file
@@ -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<BatchProgressProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
{/* 任务标题和状态 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">批量缩略图生成进度</h3>
|
||||||
|
<p className="text-sm text-gray-600">任务ID: {task.task_id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(status)}`}>
|
||||||
|
{TASK_STATUS_LABELS[status]}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{status === TaskStatus.Running && (
|
||||||
|
<button
|
||||||
|
onClick={onPause}
|
||||||
|
className="p-2 text-orange-500 hover:text-orange-700 hover:bg-orange-50 rounded-lg transition-colors"
|
||||||
|
title="暂停"
|
||||||
|
>
|
||||||
|
<Pause className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{status === TaskStatus.Paused && (
|
||||||
|
<button
|
||||||
|
onClick={onResume}
|
||||||
|
className="p-2 text-green-500 hover:text-green-700 hover:bg-green-50 rounded-lg transition-colors"
|
||||||
|
title="恢复"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(status === TaskStatus.Running || status === TaskStatus.Paused) && (
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="p-2 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="取消"
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">总体进度</span>
|
||||||
|
<span className="font-medium">{progress.progress_percentage.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-3 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progress.progress_percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<span>
|
||||||
|
{progress.processed_files + progress.failed_files} / {progress.total_files} 文件
|
||||||
|
</span>
|
||||||
|
{progress.estimated_remaining_ms && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
剩余 {formatRemainingTime(progress.estimated_remaining_ms)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 当前处理文件 */}
|
||||||
|
{progress.current_file && status === TaskStatus.Running && (
|
||||||
|
<div className="p-3 bg-blue-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<FileVideo className="w-4 h-4 text-blue-500" />
|
||||||
|
<span className="text-gray-600">正在处理:</span>
|
||||||
|
<span className="font-medium text-blue-700 truncate">
|
||||||
|
{progress.current_file.split(/[/\\]/).pop()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-center mb-1">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-green-700">
|
||||||
|
{progress.processed_files}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-green-600">已完成</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-center mb-1">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-red-700">
|
||||||
|
{progress.failed_files}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-red-600">失败</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-center mb-1">
|
||||||
|
<FileVideo className="w-5 h-5 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-gray-700">
|
||||||
|
{progress.total_files}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">总文件</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-3 bg-purple-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-center mb-1">
|
||||||
|
<TrendingUp className="w-5 h-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-purple-700">
|
||||||
|
{progress.processing_speed ? formatProcessingSpeed(progress.processing_speed) : '-'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-purple-600">处理速度</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 生成结果 */}
|
||||||
|
{progress.results.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<Image className="w-4 h-4" />
|
||||||
|
生成结果 ({progress.results.length})
|
||||||
|
</h4>
|
||||||
|
<div className="max-h-48 overflow-y-auto space-y-2">
|
||||||
|
{progress.results.slice(-10).map((result, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg ${
|
||||||
|
result.success ? 'bg-green-50' : 'bg-red-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{result.success ? (
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{result.video_path.split(/[/\\]/).pop()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{result.success ? (
|
||||||
|
<>
|
||||||
|
{result.output_paths.length} 个缩略图 •
|
||||||
|
{result.processing_time_ms}ms •
|
||||||
|
{formatFileSize(result.metadata.total_file_size)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
result.error_message
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{progress.errors.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-red-700 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
错误信息 ({progress.errors.length})
|
||||||
|
</h4>
|
||||||
|
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||||
|
{progress.errors.slice(-5).map((error, index) => (
|
||||||
|
<div key={index} className="text-xs text-red-600 p-2 bg-red-50 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 任务时间信息 */}
|
||||||
|
<div className="pt-4 border-t border-gray-200 text-xs text-gray-500 space-y-1">
|
||||||
|
<div>创建时间: {new Date(task.created_at).toLocaleString()}</div>
|
||||||
|
{task.started_at && (
|
||||||
|
<div>开始时间: {new Date(task.started_at).toLocaleString()}</div>
|
||||||
|
)}
|
||||||
|
{task.completed_at && (
|
||||||
|
<div>完成时间: {new Date(task.completed_at).toLocaleString()}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
271
apps/desktop/src/components/thumbnail/TaskList.tsx
Normal file
271
apps/desktop/src/components/thumbnail/TaskList.tsx
Normal file
@@ -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<TaskListProps> = ({
|
||||||
|
tasks,
|
||||||
|
onCancel,
|
||||||
|
onPause,
|
||||||
|
onResume,
|
||||||
|
onCleanup,
|
||||||
|
}) => {
|
||||||
|
// 按状态分组任务
|
||||||
|
const groupedTasks = React.useMemo(() => {
|
||||||
|
const groups: Record<TaskStatus, BatchThumbnailTask[]> = {
|
||||||
|
[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 (
|
||||||
|
<div key={task.task_id} className="p-4 bg-white border border-gray-200 rounded-lg">
|
||||||
|
{/* 任务头部 */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<FileVideo className="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900">
|
||||||
|
批量缩略图任务
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{task.video_files.length} 个文件 • {task.task_id.slice(0, 8)}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(task.status)}`}>
|
||||||
|
{TASK_STATUS_LABELS[task.status]}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{canControl && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{task.status === TaskStatus.Running && (
|
||||||
|
<button
|
||||||
|
onClick={() => onPause(task.task_id)}
|
||||||
|
className="p-1 text-orange-500 hover:text-orange-700 hover:bg-orange-50 rounded transition-colors"
|
||||||
|
title="暂停"
|
||||||
|
>
|
||||||
|
<Pause className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{task.status === TaskStatus.Paused && (
|
||||||
|
<button
|
||||||
|
onClick={() => onResume(task.task_id)}
|
||||||
|
className="p-1 text-green-500 hover:text-green-700 hover:bg-green-50 rounded transition-colors"
|
||||||
|
title="恢复"
|
||||||
|
>
|
||||||
|
<Play className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onCancel(task.task_id)}
|
||||||
|
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded transition-colors"
|
||||||
|
title="取消"
|
||||||
|
>
|
||||||
|
<Square className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度信息 */}
|
||||||
|
{task.status !== TaskStatus.Pending && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{progress.processed_files + progress.failed_files} / {progress.total_files}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{progress.progress_percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${
|
||||||
|
task.status === TaskStatus.Completed
|
||||||
|
? 'bg-green-500'
|
||||||
|
: task.status === TaskStatus.Failed
|
||||||
|
? 'bg-red-500'
|
||||||
|
: 'bg-blue-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${progress.progress_percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-3 h-3 text-green-500" />
|
||||||
|
{progress.processed_files}
|
||||||
|
</span>
|
||||||
|
{progress.failed_files > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3 text-red-500" />
|
||||||
|
{progress.failed_files}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{new Date(task.updated_at).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 当前处理文件 */}
|
||||||
|
{progress.current_file && task.status === TaskStatus.Running && (
|
||||||
|
<div className="mt-3 p-2 bg-blue-50 rounded text-xs">
|
||||||
|
<span className="text-blue-600">正在处理: </span>
|
||||||
|
<span className="text-blue-800 font-medium">
|
||||||
|
{progress.current_file.split(/[/\\]/).pop()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{progress.errors.length > 0 && (
|
||||||
|
<div className="mt-3 p-2 bg-red-50 rounded">
|
||||||
|
<div className="text-xs text-red-600 font-medium mb-1">
|
||||||
|
错误 ({progress.errors.length})
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-red-500">
|
||||||
|
{progress.errors[progress.errors.length - 1]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染任务组
|
||||||
|
const renderTaskGroup = (status: TaskStatus, tasks: BatchThumbnailTask[]) => {
|
||||||
|
if (tasks.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={status} className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${getStatusColor(status).split(' ')[1]}`} />
|
||||||
|
{TASK_STATUS_LABELS[status]} ({tasks.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tasks.map(renderTask)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<FileVideo className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">没有任务</h3>
|
||||||
|
<p>还没有创建任何批量缩略图生成任务</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 任务列表头部 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">任务列表</h3>
|
||||||
|
<p className="text-sm text-gray-600">共 {tasks.length} 个任务</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onCleanup}
|
||||||
|
className="px-3 py-2 text-sm bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
清理已完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 任务统计 */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-6 gap-3">
|
||||||
|
{Object.entries(groupedTasks).map(([status, tasks]) => (
|
||||||
|
<div key={status} className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="text-lg font-semibold text-gray-900">
|
||||||
|
{tasks.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{TASK_STATUS_LABELS[status as TaskStatus]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 任务分组列表 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{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])}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
300
apps/desktop/src/components/thumbnail/ThumbnailConfigPanel.tsx
Normal file
300
apps/desktop/src/components/thumbnail/ThumbnailConfigPanel.tsx
Normal file
@@ -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<ThumbnailConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
// 更新配置的辅助函数
|
||||||
|
const updateConfig = (updates: Partial<ThumbnailConfig>) => {
|
||||||
|
onChange({ ...config, ...updates });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新时间点配置
|
||||||
|
const updateTimePoints = (timePoints: TimePoint[]) => {
|
||||||
|
updateConfig({ time_points: timePoints });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新尺寸配置
|
||||||
|
const updateSize = (updates: Partial<ThumbnailConfig['size']>) => {
|
||||||
|
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 (
|
||||||
|
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-3">
|
||||||
|
<CustomSelect
|
||||||
|
value={timePointType}
|
||||||
|
onChange={(type) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={Array.isArray(timePointValue) ? timePointValue[0] : timePointValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{timePointType === 'Fixed' ? '秒' :
|
||||||
|
timePointType === 'Percentage' ? '%' : '帧'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => removeTimePoint(index)}
|
||||||
|
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
disabled={config.time_points.length <= 1}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
缩略图配置
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 时间点配置 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
时间点设置
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={addTimePoint}
|
||||||
|
className="px-3 py-1 text-sm bg-primary-100 text-primary-600 rounded-lg hover:bg-primary-200 transition-colors"
|
||||||
|
>
|
||||||
|
+ 添加时间点
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{config.time_points.map((timePoint, index) =>
|
||||||
|
renderTimePointConfig(timePoint, index)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 尺寸配置 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<ImageIcon className="w-4 h-4" />
|
||||||
|
缩略图尺寸
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">预设尺寸</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={config.size.preset || SizePreset.Custom}
|
||||||
|
onChange={handlePresetChange}
|
||||||
|
options={[
|
||||||
|
{ value: SizePreset.Tiny, label: '微小 (160×120)' },
|
||||||
|
{ value: SizePreset.Small, label: '小 (320×240)' },
|
||||||
|
{ value: SizePreset.Medium, label: '中 (640×480)' },
|
||||||
|
{ value: SizePreset.Large, label: '大 (1280×720)' },
|
||||||
|
{ value: SizePreset.FullHD, label: '全高清 (1920×1080)' },
|
||||||
|
{ value: SizePreset.Custom, label: '自定义' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">宽度</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.size.width}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">高度</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.size.height}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 格式和质量配置 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">输出格式</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={config.format}
|
||||||
|
onChange={(format) => updateConfig({ format: format as ImageFormat })}
|
||||||
|
options={[
|
||||||
|
{ value: ImageFormat.Jpg, label: 'JPEG' },
|
||||||
|
{ value: ImageFormat.Png, label: 'PNG' },
|
||||||
|
{ value: ImageFormat.WebP, label: 'WebP' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
|
||||||
|
<Sliders className="w-4 h-4" />
|
||||||
|
质量 ({config.quality}%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={config.quality}
|
||||||
|
onChange={(e) => updateConfig({ quality: parseInt(e.target.value) })}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输出配置 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">输出目录</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.output_dir}
|
||||||
|
onChange={(e) => updateConfig({ output_dir: e.target.value })}
|
||||||
|
placeholder="thumbnails"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">文件命名模式</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.naming_pattern}
|
||||||
|
onChange={(e) => updateConfig({ naming_pattern: e.target.value })}
|
||||||
|
placeholder="{filename}_{timestamp}.{ext}"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
可用变量: {'{filename}'}, {'{timestamp}'}, {'{index}'}, {'{ext}'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.preserve_aspect_ratio}
|
||||||
|
onChange={(e) => updateConfig({ preserve_aspect_ratio: e.target.checked })}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">保持宽高比</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
99
apps/desktop/src/components/thumbnail/ThumbnailPreview.tsx
Normal file
99
apps/desktop/src/components/thumbnail/ThumbnailPreview.tsx
Normal file
@@ -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<ThumbnailPreviewProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">缩略图预览</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
title="缩小"
|
||||||
|
>
|
||||||
|
<ZoomOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={resetZoom}
|
||||||
|
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
||||||
|
title="重置缩放"
|
||||||
|
>
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
title="放大"
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="p-2 text-blue-500 hover:text-blue-700 hover:bg-blue-50 rounded-lg transition-colors"
|
||||||
|
title="下载"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative overflow-auto max-h-96 bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="缩略图预览"
|
||||||
|
className="max-w-full h-auto rounded shadow-lg transition-transform duration-200"
|
||||||
|
style={{ transform: `scale(${zoom})` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-sm text-gray-500 text-center">
|
||||||
|
点击工具栏按钮可以缩放、下载或关闭预览
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
235
apps/desktop/src/components/thumbnail/TimelineConfigPanel.tsx
Normal file
235
apps/desktop/src/components/thumbnail/TimelineConfigPanel.tsx
Normal file
@@ -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<TimelineConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
// 更新配置的辅助函数
|
||||||
|
const updateConfig = (updates: Partial<TimelineConfig>) => {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 帧数配置 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
时间轴帧数
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.frame_count}
|
||||||
|
onChange={(e) => updateConfig({ frame_count: parseInt(e.target.value) })}
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
帧间距 (px)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.spacing}
|
||||||
|
onChange={(e) => updateConfig({ spacing: parseInt(e.target.value) })}
|
||||||
|
min={0}
|
||||||
|
max={20}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 布局配置 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<Grid className="w-4 h-4" />
|
||||||
|
布局方式
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<CustomSelect
|
||||||
|
value={getCurrentLayoutType()}
|
||||||
|
onChange={handleLayoutChange}
|
||||||
|
options={[
|
||||||
|
{ value: 'Horizontal', label: '水平排列' },
|
||||||
|
{ value: 'Vertical', label: '垂直排列' },
|
||||||
|
{ value: 'Grid', label: '网格排列' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{getCurrentLayoutType() === 'Grid' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">网格列数</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={getGridColumns()}
|
||||||
|
onChange={(e) => updateGridColumns(parseInt(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 样式配置 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<Palette className="w-4 h-4" />
|
||||||
|
样式设置
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">背景颜色</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={config.background_color || '#000000'}
|
||||||
|
onChange={(e) => updateConfig({ background_color: e.target.value })}
|
||||||
|
className="w-10 h-8 border border-gray-300 rounded cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.background_color || '#000000'}
|
||||||
|
onChange={(e) => updateConfig({ background_color: e.target.value })}
|
||||||
|
placeholder="#000000"
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">边框宽度 (px)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.border_width || 1}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.show_timestamps}
|
||||||
|
onChange={(e) => updateConfig({ show_timestamps: e.target.checked })}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">显示时间戳</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 预览区域 */}
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-3">布局预览</h4>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{getCurrentLayoutType() === 'Horizontal' && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{Array.from({ length: Math.min(config.frame_count, 8) }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-8 h-6 bg-blue-200 rounded border"
|
||||||
|
style={{ marginRight: i < Math.min(config.frame_count, 8) - 1 ? `${config.spacing}px` : 0 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{config.frame_count > 8 && <span className="text-xs text-gray-500 ml-2">...</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{getCurrentLayoutType() === 'Vertical' && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{Array.from({ length: Math.min(config.frame_count, 6) }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-12 h-4 bg-blue-200 rounded border"
|
||||||
|
style={{ marginBottom: i < Math.min(config.frame_count, 6) - 1 ? `${config.spacing}px` : 0 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{config.frame_count > 6 && <span className="text-xs text-gray-500 mt-1">...</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{getCurrentLayoutType() === 'Grid' && (
|
||||||
|
<div
|
||||||
|
className="grid gap-1"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${getGridColumns()}, 1fr)`,
|
||||||
|
gap: `${config.spacing}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: Math.min(config.frame_count, getGridColumns() * 3) }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-6 h-4 bg-blue-200 rounded border"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{config.frame_count > getGridColumns() * 3 && (
|
||||||
|
<div className="col-span-full text-xs text-gray-500 text-center mt-1">...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
132
apps/desktop/src/components/thumbnail/VideoFileList.tsx
Normal file
132
apps/desktop/src/components/thumbnail/VideoFileList.tsx
Normal file
@@ -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<VideoFileListProps> = ({
|
||||||
|
videos,
|
||||||
|
onPreview,
|
||||||
|
}) => {
|
||||||
|
if (videos.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<FileVideo className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||||
|
<p>没有找到视频文件</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700">
|
||||||
|
视频文件列表 ({videos.length} 个)
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
总大小: {formatFileSize(videos.reduce((sum, v) => sum + v.size, 0))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||||
|
{videos.map((video, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
{/* 文件图标 */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<FileVideo className={`w-5 h-5 ${video.is_valid ? 'text-blue-500' : 'text-red-500'}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文件信息 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h5 className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{video.name}
|
||||||
|
</h5>
|
||||||
|
{!video.is_valid && (
|
||||||
|
<span className="px-2 py-1 text-xs bg-red-100 text-red-600 rounded">
|
||||||
|
无效
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mt-1 text-xs text-gray-500">
|
||||||
|
{video.duration && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatDuration(video.duration)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{video.resolution && (
|
||||||
|
<div>
|
||||||
|
{video.resolution[0]}×{video.resolution[1]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<HardDrive className="w-3 h-3" />
|
||||||
|
{formatFileSize(video.size)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{video.format && (
|
||||||
|
<div className="uppercase">
|
||||||
|
{video.format}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 预览按钮 */}
|
||||||
|
{video.is_valid && video.duration && (
|
||||||
|
<button
|
||||||
|
onClick={() => onPreview(video.path, video.duration! / 2)}
|
||||||
|
className="flex-shrink-0 p-2 text-blue-500 hover:text-blue-700 hover:bg-blue-50 rounded-lg transition-colors"
|
||||||
|
title="预览缩略图"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<div className="pt-3 border-t border-gray-200">
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900">
|
||||||
|
{videos.filter(v => v.is_valid).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">有效文件</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900">
|
||||||
|
{videos.filter(v => !v.is_valid).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">无效文件</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900">
|
||||||
|
{formatDuration(
|
||||||
|
videos
|
||||||
|
.filter(v => v.duration)
|
||||||
|
.reduce((sum, v) => sum + (v.duration || 0), 0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">总时长</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Droplets
|
Droplets,
|
||||||
|
Image
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
|
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
|
||||||
|
|
||||||
@@ -85,6 +86,21 @@ export const TOOLS_DATA: Tool[] = [
|
|||||||
isPopular: true,
|
isPopular: true,
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
lastUpdated: '2024-01-23'
|
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'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
455
apps/desktop/src/pages/tools/BatchThumbnailGenerator.tsx
Normal file
455
apps/desktop/src/pages/tools/BatchThumbnailGenerator.tsx
Normal file
@@ -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<VideoFile[]>([]);
|
||||||
|
const [config, setConfig] = useState<ThumbnailConfig>(DEFAULT_THUMBNAIL_CONFIG);
|
||||||
|
const [timelineConfig, setTimelineConfig] = useState<TimelineConfig>(DEFAULT_TIMELINE_CONFIG);
|
||||||
|
const [enableTimeline, setEnableTimeline] = useState(false);
|
||||||
|
const [currentTask, setCurrentTask] = useState<BatchThumbnailTask | null>(null);
|
||||||
|
const [allTasks, setAllTasks] = useState<BatchThumbnailTask[]>([]);
|
||||||
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<'config' | 'progress' | 'tasks'>('config');
|
||||||
|
const [selectedFolder, setSelectedFolder] = useState<string>('');
|
||||||
|
|
||||||
|
// 轮询更新任务状态
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="page-header flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg hover:shadow-xl transition-all duration-300">
|
||||||
|
<Image className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-blue-600 bg-clip-text text-transparent">
|
||||||
|
批量缩略图生成器
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 text-lg">为视频文件批量生成预览缩略图和时间轴</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 视图切换 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('config')}
|
||||||
|
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||||
|
viewMode === 'config'
|
||||||
|
? 'bg-primary-100 text-primary-600'
|
||||||
|
: 'text-gray-400 hover:text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('progress')}
|
||||||
|
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||||
|
viewMode === 'progress'
|
||||||
|
? 'bg-primary-100 text-primary-600'
|
||||||
|
: 'text-gray-400 hover:text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('tasks')}
|
||||||
|
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||||
|
viewMode === 'tasks'
|
||||||
|
? 'bg-primary-100 text-primary-600'
|
||||||
|
: 'text-gray-400 hover:text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主要内容区域 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* 左侧:文件选择和配置 */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{viewMode === 'config' && (
|
||||||
|
<>
|
||||||
|
{/* 文件选择区域 */}
|
||||||
|
<div className="card p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<FolderOpen className="w-5 h-5" />
|
||||||
|
视频文件选择
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handleSelectFolder}
|
||||||
|
disabled={isScanning}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-4 h-4" />
|
||||||
|
{isScanning ? '扫描中...' : '选择文件夹'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{selectedFolder && (
|
||||||
|
<div className="flex-1 px-3 py-2 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||||
|
{selectedFolder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedVideos.length > 0 && (
|
||||||
|
<VideoFileList
|
||||||
|
videos={selectedVideos}
|
||||||
|
onPreview={handlePreviewThumbnail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 配置面板 */}
|
||||||
|
<ThumbnailConfigPanel
|
||||||
|
config={config}
|
||||||
|
onChange={setConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 时间轴配置 */}
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Grid className="w-5 h-5" />
|
||||||
|
时间轴缩略图
|
||||||
|
</h3>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enableTimeline}
|
||||||
|
onChange={(e) => setEnableTimeline(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">启用时间轴生成</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enableTimeline && (
|
||||||
|
<TimelineConfigPanel
|
||||||
|
config={timelineConfig}
|
||||||
|
onChange={setTimelineConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handleStartGeneration}
|
||||||
|
disabled={isGenerating || selectedVideos.length === 0}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
{isGenerating ? '生成中...' : '开始生成'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleScanAndGenerate}
|
||||||
|
disabled={isGenerating || !selectedFolder}
|
||||||
|
className="btn-secondary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
扫描并生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'progress' && currentTask && (
|
||||||
|
<BatchProgress
|
||||||
|
task={currentTask}
|
||||||
|
onCancel={() => handleCancelTask(currentTask.task_id)}
|
||||||
|
onPause={() => handlePauseTask(currentTask.task_id)}
|
||||||
|
onResume={() => handleResumeTask(currentTask.task_id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'tasks' && (
|
||||||
|
<TaskList
|
||||||
|
tasks={allTasks}
|
||||||
|
onCancel={handleCancelTask}
|
||||||
|
onPause={handlePauseTask}
|
||||||
|
onResume={handleResumeTask}
|
||||||
|
onCleanup={handleCleanupTasks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:预览区域 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{previewImage && (
|
||||||
|
<ThumbnailPreview
|
||||||
|
imageUrl={previewImage}
|
||||||
|
onClose={() => setPreviewImage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<div className="card p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">统计信息</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">选中视频:</span>
|
||||||
|
<span className="font-medium">{selectedVideos.length} 个</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">活跃任务:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{allTasks.filter(t => t.status === TaskStatus.Running).length} 个
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">已完成任务:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{allTasks.filter(t => t.status === TaskStatus.Completed).length} 个
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BatchThumbnailGenerator;
|
||||||
303
apps/desktop/src/types/thumbnail.ts
Normal file
303
apps/desktop/src/types/thumbnail.ts
Normal file
@@ -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, [number, number]> = {
|
||||||
|
[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, string> = {
|
||||||
|
[ImageFormat.Jpg]: 'jpg',
|
||||||
|
[ImageFormat.Png]: 'png',
|
||||||
|
[ImageFormat.WebP]: 'webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 图片格式MIME类型映射
|
||||||
|
export const IMAGE_FORMAT_MIME_TYPES: Record<ImageFormat, string> = {
|
||||||
|
[ImageFormat.Jpg]: 'image/jpeg',
|
||||||
|
[ImageFormat.Png]: 'image/png',
|
||||||
|
[ImageFormat.WebP]: 'image/webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 任务状态显示文本映射
|
||||||
|
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
|
||||||
|
[TaskStatus.Pending]: '等待中',
|
||||||
|
[TaskStatus.Running]: '执行中',
|
||||||
|
[TaskStatus.Completed]: '已完成',
|
||||||
|
[TaskStatus.Failed]: '失败',
|
||||||
|
[TaskStatus.Cancelled]: '已取消',
|
||||||
|
[TaskStatus.Paused]: '已暂停',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 任务状态颜色映射
|
||||||
|
export const TASK_STATUS_COLORS: Record<TaskStatus, string> = {
|
||||||
|
[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)} 文件/秒`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user