feat: 实现批量缩略图生成器功能

- 添加批量缩略图生成的数据模型和类型定义
- 实现ThumbnailGeneratorService核心服务
- 实现BatchThumbnailProcessor批量处理器
- 添加Tauri命令接口支持前端调用
- 创建完整的前端UI组件和页面
- 支持多种时间戳配置和尺寸预设
- 支持时间轴缩略图生成
- 支持并发处理和进度监控
- 集成到便捷工具页面

遵循promptx/tauri-desktop-app-expert开发规范
This commit is contained in:
imeepos
2025-07-23 13:44:19 +08:00
parent 29a4c32096
commit 4269b72c04
18 changed files with 3671 additions and 2 deletions

View File

@@ -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(),
}
}
}

View File

@@ -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;

View File

@@ -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,
})
}
}

View File

@@ -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;

View 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",
}
}
}

View File

@@ -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| {
// 初始化日志系统 // 初始化日志系统

View File

@@ -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;

View File

@@ -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))
}
}
}

View File

@@ -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>

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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'
} }
]; ];

View 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;

View 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)} 文件/秒`;
};