feat: 实现视频关键帧提取工具

- 新增关键帧提取功能,支持提取视频的第一帧、最后一帧、自定义时间点和多个时间点
- 添加完整的前端UI组件,包括配置面板、文件列表、预览和进度显示
- 实现后端Rust服务,集成FFmpeg进行视频处理
- 支持多种输出格式(JPG/PNG/WebP/BMP)和质量设置
- 支持输出尺寸调整和宽高比保持
- 添加批量处理功能和进度跟踪
- 修复TypeScript类型错误和Rust编译问题
- 添加用户指南文档和单元测试
This commit is contained in:
imeepos
2025-08-11 13:05:27 +08:00
parent 2075a731d2
commit 779bb054c2
21 changed files with 4442 additions and 4 deletions

252
Cargo.lock generated
View File

@@ -85,6 +85,15 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "arbitrary"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "ashpd"
version = "0.9.2"
@@ -906,6 +915,17 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1224,6 +1244,19 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "ffmpeg-sidecar"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f35f3bfdf862abfb6999b3f9079c5ec5c55dfa1010e907ed055e4fbdd64c335"
dependencies = [
"anyhow",
"tar",
"ureq",
"xz2",
"zip 4.3.0",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1234,6 +1267,18 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
dependencies = [
"cfg-if",
"libc",
"libredox",
"windows-sys 0.59.0",
]
[[package]]
name = "flate2"
version = "1.1.2"
@@ -1241,6 +1286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
dependencies = [
"crc32fast",
"libz-rs-sys",
"miniz_oxide",
]
@@ -2425,6 +2471,7 @@ checksum = "360e552c93fa0e8152ab463bc4c4837fce76a225df11dfaeea66c313de5e61f7"
dependencies = [
"bitflags 2.9.1",
"libc",
"redox_syscall",
]
[[package]]
@@ -2438,6 +2485,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "libz-rs-sys"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221"
dependencies = [
"zlib-rs",
]
[[package]]
name = "linux-raw-sys"
version = "0.9.4"
@@ -2466,6 +2522,17 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "mac"
version = "0.1.1"
@@ -2580,6 +2647,7 @@ dependencies = [
"chrono",
"comfyui-sdk",
"dirs 5.0.1",
"ffmpeg-sidecar",
"futures-util",
"hex",
"hmac",
@@ -2617,7 +2685,7 @@ dependencies = [
"urlencoding",
"uuid",
"winapi",
"zip",
"zip 0.6.6",
]
[[package]]
@@ -3788,7 +3856,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls-pemfile",
"rustls-pemfile 1.0.4",
"serde",
"serde_json",
"serde_urlencoded",
@@ -3864,6 +3932,20 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "ring"
version = "0.17.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e75ec5e92c4d8aede845126adc388046234541629e76029599ed35a003c7ed24"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.16",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.31.0"
@@ -3907,6 +3989,21 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "rustls"
version = "0.23.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
@@ -3916,6 +4013,35 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.21"
@@ -4576,6 +4702,17 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "tar"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -5456,6 +5593,42 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f0fde9bc91026e381155f8c67cb354bcd35260b2f4a29bcc84639f762760c39"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pemfile 2.2.0",
"rustls-pki-types",
"ureq-proto",
"utf-8",
"webpki-roots 0.26.11",
]
[[package]]
name = "ureq-proto"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59db78ad1923f2b1be62b6da81fe80b173605ca0d57f85da2e005382adf693f7"
dependencies = [
"base64 0.22.1",
"http 1.3.1",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.4"
@@ -5792,6 +5965,24 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.2",
]
[[package]]
name = "webpki-roots"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.0"
@@ -6486,6 +6677,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af3a19837351dc82ba89f8a125e22a3c475f05aba604acc023d62b2739ae2909"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
@@ -6496,6 +6697,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yoke"
version = "0.8.0"
@@ -6626,6 +6836,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
[[package]]
name = "zerotrie"
version = "0.2.2"
@@ -6679,6 +6895,38 @@ dependencies = [
"zstd",
]
[[package]]
name = "zip"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aed4ac33e8eb078c89e6cbb1d5c4c7703ec6d299fc3e7c3695af8f8b423468b"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap 2.10.0",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a"
[[package]]
name = "zopfli"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.11.2+zstd.1.5.2"

View File

@@ -60,6 +60,7 @@ bincode = "1.3"
zip = "0.6"
sysinfo = "0.30"
comfyui-sdk = { path = "../../../cargos/comfyui-sdk" }
ffmpeg-sidecar = "2.0"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["sysinfoapi"] }

View File

@@ -0,0 +1,973 @@
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
use std::time::Instant;
use std::pin::Pin;
use std::boxed::Box;
use std::future::Future;
use tracing::{info, warn, error, debug};
use ffmpeg_sidecar::{
command::FfmpegCommand,
download::auto_download,
};
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
/// 帧提取类型
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FrameType {
First,
Last,
Custom,
Multiple,
}
/// 时间点配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum TimePoint {
#[serde(rename = "seconds")]
Seconds { value: f64 },
#[serde(rename = "percentage")]
Percentage { value: f64 },
#[serde(rename = "frame")]
Frame { value: u32 },
}
/// 图片格式
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImageFormat {
Jpg,
Png,
WebP,
Bmp,
}
/// 图片质量设置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageQuality {
pub format: ImageFormat,
pub quality: u8,
pub compression_level: Option<u8>,
}
/// 输出尺寸配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputSize {
pub width: Option<u32>,
pub height: Option<u32>,
pub maintain_aspect_ratio: bool,
pub scale_filter: Option<String>,
}
/// 帧提取配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrameExtractionConfig {
pub frame_type: FrameType,
pub time_points: Option<Vec<TimePoint>>,
pub custom_time: Option<f64>,
pub output_format: ImageQuality,
pub output_size: Option<OutputSize>,
pub output_directory: String,
pub filename_pattern: String,
pub overwrite_existing: bool,
}
/// 视频文件信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoFileInfo {
pub path: String,
pub filename: String,
pub size: u64,
pub duration: f64,
pub width: u32,
pub height: u32,
pub fps: f64,
pub format: String,
pub is_valid: bool,
pub error_message: Option<String>,
}
/// 单个帧提取结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrameExtractionResult {
pub video_path: String,
pub frame_type: FrameType,
pub timestamp: f64,
pub output_path: String,
pub success: bool,
pub error_message: Option<String>,
pub processing_time_ms: u64,
pub file_size: u64,
}
/// 关键帧提取服务
/// 遵循 Tauri 开发规范的基础设施层设计
pub struct FrameExtractorService;
impl FrameExtractorService {
/// 创建隐藏控制台窗口的命令
/// 在 Windows 上防止命令行闪现
fn create_hidden_command(program: &str) -> Command {
let mut cmd = Command::new(program);
#[cfg(target_os = "windows")]
{
// 在 Windows 上隐藏控制台窗口
// CREATE_NO_WINDOW = 0x08000000
cmd.creation_flags(0x08000000);
}
cmd
}
/// 初始化 FFmpeg
pub async fn initialize() -> Result<()> {
info!("初始化 FrameExtractorService");
// 自动下载 FFmpeg 二进制文件
match auto_download() {
Ok(_) => {
info!("FFmpeg 二进制文件准备就绪");
Ok(())
}
Err(e) => {
error!("FFmpeg 初始化失败: {}", e);
Err(anyhow!("FFmpeg 初始化失败: {}", e))
}
}
}
/// 检查 FFmpeg 是否可用
pub fn is_available() -> bool {
match FfmpegCommand::new().arg("-version").spawn() {
Ok(mut child) => {
match child.wait() {
Ok(status) => status.success(),
Err(_) => false,
}
}
Err(_) => false,
}
}
/// 获取视频文件信息
pub async fn get_video_info(video_path: &str) -> Result<VideoFileInfo> {
let path = Path::new(video_path);
if !path.exists() {
return Ok(VideoFileInfo {
path: video_path.to_string(),
filename: path.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
size: 0,
duration: 0.0,
width: 0,
height: 0,
fps: 0.0,
format: String::new(),
is_valid: false,
error_message: Some("文件不存在".to_string()),
});
}
let metadata = std::fs::metadata(path)?;
let filename = path.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
// 使用 ffprobe 获取视频信息
let mut cmd = Self::create_hidden_command("ffprobe");
cmd.arg("-v")
.arg("quiet")
.arg("-print_format")
.arg("json")
.arg("-show_format")
.arg("-show_streams")
.arg(video_path);
match cmd.output() {
Ok(output) => {
if output.status.success() {
Self::parse_video_info(&output.stdout, video_path, &filename, metadata.len())
} else {
Ok(VideoFileInfo {
path: video_path.to_string(),
filename,
size: metadata.len(),
duration: 0.0,
width: 0,
height: 0,
fps: 0.0,
format: String::new(),
is_valid: false,
error_message: Some("无法解析视频信息".to_string()),
})
}
}
Err(e) => {
error!("启动 ffprobe 失败: {}", e);
Ok(VideoFileInfo {
path: video_path.to_string(),
filename,
size: metadata.len(),
duration: 0.0,
width: 0,
height: 0,
fps: 0.0,
format: String::new(),
is_valid: false,
error_message: Some(format!("启动 ffprobe 失败: {}", e)),
})
}
}
}
/// 解析视频信息
fn parse_video_info(
json_data: &[u8],
video_path: &str,
filename: &str,
file_size: u64,
) -> Result<VideoFileInfo> {
let json_str = String::from_utf8_lossy(json_data);
let json: serde_json::Value = serde_json::from_str(&json_str)?;
let format = json["format"].as_object()
.ok_or_else(|| anyhow!("无法解析格式信息"))?;
let duration = format["duration"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let format_name = format["format_name"]
.as_str()
.unwrap_or("unknown")
.to_string();
// 查找视频流
let streams = json["streams"].as_array()
.ok_or_else(|| anyhow!("无法解析流信息"))?;
let video_stream = streams.iter()
.find(|stream| stream["codec_type"].as_str() == Some("video"))
.ok_or_else(|| anyhow!("未找到视频流"))?;
let width = video_stream["width"].as_u64().unwrap_or(0) as u32;
let height = video_stream["height"].as_u64().unwrap_or(0) as u32;
// 解析帧率
let fps = if let Some(r_frame_rate) = video_stream["r_frame_rate"].as_str() {
Self::parse_fraction(r_frame_rate).unwrap_or(0.0)
} else {
0.0
};
Ok(VideoFileInfo {
path: video_path.to_string(),
filename: filename.to_string(),
size: file_size,
duration,
width,
height,
fps,
format: format_name,
is_valid: duration > 0.0 && width > 0 && height > 0,
error_message: None,
})
}
/// 解析分数格式的帧率 (如 "30/1")
fn parse_fraction(fraction_str: &str) -> Option<f64> {
let parts: Vec<&str> = fraction_str.split('/').collect();
if parts.len() == 2 {
if let (Ok(numerator), Ok(denominator)) = (
parts[0].parse::<f64>(),
parts[1].parse::<f64>()
) {
if denominator != 0.0 {
return Some(numerator / denominator);
}
}
}
None
}
/// 扫描文件夹中的视频文件
pub async fn scan_video_files(
folder_path: &str,
recursive: bool,
supported_formats: &[String],
) -> Result<Vec<VideoFileInfo>> {
let path = Path::new(folder_path);
if !path.exists() || !path.is_dir() {
return Err(anyhow!("指定路径不存在或不是文件夹"));
}
let mut video_files = Vec::new();
Self::scan_directory_recursive(path, recursive, supported_formats, &mut video_files).await?;
info!("扫描完成,找到 {} 个视频文件", video_files.len());
Ok(video_files)
}
/// 递归扫描目录
fn scan_directory_recursive<'a>(
dir: &'a Path,
recursive: bool,
supported_formats: &'a [String],
video_files: &'a mut Vec<VideoFileInfo>,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
let entries = std::fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Some(extension) = path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
if supported_formats.contains(&ext) {
match Self::get_video_info(&path.to_string_lossy()).await {
Ok(info) => video_files.push(info),
Err(e) => {
warn!("无法获取视频信息 {}: {}", path.display(), e);
}
}
}
}
} else if path.is_dir() && recursive {
Self::scan_directory_recursive(&path, recursive, supported_formats, video_files).await?;
}
}
Ok(())
})
}
/// 提取单个帧
pub async fn extract_frame(
video_path: &str,
timestamp: f64,
output_path: &str,
config: &FrameExtractionConfig,
) -> Result<FrameExtractionResult> {
let start_time = Instant::now();
debug!(
"开始提取帧: video={}, timestamp={}, output={}",
video_path, timestamp, output_path
);
// 确保输出目录存在
if let Some(parent) = Path::new(output_path).parent() {
std::fs::create_dir_all(parent)?;
}
// 构建 FFmpeg 命令
let mut cmd = FfmpegCommand::new();
// 输入文件
cmd.arg("-i").arg(video_path);
// 寻找到指定时间戳
cmd.arg("-ss").arg(timestamp.to_string());
// 只提取一帧
cmd.arg("-vframes").arg("1");
// 构建视频过滤器
let mut filters = Vec::new();
// 尺寸调整
if let Some(size) = &config.output_size {
let scale_filter = if let (Some(width), Some(height)) = (size.width, size.height) {
if size.maintain_aspect_ratio {
format!("scale={}:{}:force_original_aspect_ratio=decrease", width, height)
} else {
format!("scale={}:{}", width, height)
}
} else if let Some(width) = size.width {
format!("scale={}:-1", width)
} else if let Some(height) = size.height {
format!("scale=-1:{}", height)
} else {
String::new()
};
if !scale_filter.is_empty() {
filters.push(scale_filter);
}
}
// 应用过滤器
if !filters.is_empty() {
cmd.arg("-vf").arg(filters.join(","));
}
// 设置像素格式
match config.output_format.format {
ImageFormat::Jpg => {
cmd.arg("-pix_fmt").arg("yuvj420p");
cmd.arg("-q:v").arg(Self::jpeg_quality_to_qscale(config.output_format.quality).to_string());
}
ImageFormat::Png => {
cmd.arg("-pix_fmt").arg("rgba");
cmd.arg("-compression_level").arg(config.output_format.quality.to_string());
}
ImageFormat::WebP => {
cmd.arg("-pix_fmt").arg("yuv420p");
cmd.arg("-quality").arg(config.output_format.quality.to_string());
if let Some(level) = config.output_format.compression_level {
cmd.arg("-compression_level").arg(level.to_string());
}
}
ImageFormat::Bmp => {
cmd.arg("-pix_fmt").arg("bgr24");
}
}
// 覆盖输出文件
if config.overwrite_existing {
cmd.arg("-y");
} else {
cmd.arg("-n");
}
// 输出文件
cmd.arg(output_path);
// 执行命令
match cmd.spawn() {
Ok(mut child) => {
match child.wait() {
Ok(status) => {
let processing_time = start_time.elapsed().as_millis() as u64;
if status.success() {
let file_size = std::fs::metadata(output_path)
.map(|m| m.len())
.unwrap_or(0);
info!(
"帧提取成功: {} -> {} ({}ms)",
video_path, output_path, processing_time
);
Ok(FrameExtractionResult {
video_path: video_path.to_string(),
frame_type: config.frame_type.clone(),
timestamp,
output_path: output_path.to_string(),
success: true,
error_message: None,
processing_time_ms: processing_time,
file_size,
})
} else {
let error_msg = "FFmpeg 执行失败".to_string();
error!("{}: {}", error_msg, video_path);
Ok(FrameExtractionResult {
video_path: video_path.to_string(),
frame_type: config.frame_type.clone(),
timestamp,
output_path: output_path.to_string(),
success: false,
error_message: Some(error_msg),
processing_time_ms: processing_time,
file_size: 0,
})
}
}
Err(e) => {
let error_msg = format!("等待 FFmpeg 进程失败: {}", e);
error!("{}", error_msg);
Ok(FrameExtractionResult {
video_path: video_path.to_string(),
frame_type: config.frame_type.clone(),
timestamp,
output_path: output_path.to_string(),
success: false,
error_message: Some(error_msg),
processing_time_ms: start_time.elapsed().as_millis() as u64,
file_size: 0,
})
}
}
}
Err(e) => {
let error_msg = format!("启动 FFmpeg 进程失败: {}", e);
error!("{}", error_msg);
Ok(FrameExtractionResult {
video_path: video_path.to_string(),
frame_type: config.frame_type.clone(),
timestamp,
output_path: output_path.to_string(),
success: false,
error_message: Some(error_msg),
processing_time_ms: start_time.elapsed().as_millis() as u64,
file_size: 0,
})
}
}
}
/// 将 JPEG 质量值转换为 FFmpeg 的 qscale 值
fn jpeg_quality_to_qscale(quality: u8) -> u8 {
// FFmpeg qscale: 1 (最高质量) 到 31 (最低质量)
// 质量值: 1-100
let clamped_quality = quality.clamp(1, 100);
31 - ((clamped_quality - 1) * 30 / 99)
}
/// 计算提取时间点
pub fn calculate_extraction_timestamps(
video_info: &VideoFileInfo,
config: &FrameExtractionConfig,
) -> Result<Vec<f64>> {
let mut timestamps = Vec::new();
match &config.frame_type {
FrameType::First => {
timestamps.push(0.0);
}
FrameType::Last => {
// 最后一帧,稍微提前一点以确保能提取到
let last_timestamp = (video_info.duration - 0.1).max(0.0);
timestamps.push(last_timestamp);
}
FrameType::Custom => {
if let Some(custom_time) = config.custom_time {
let timestamp = custom_time.clamp(0.0, video_info.duration);
timestamps.push(timestamp);
} else {
// 默认提取中间帧
timestamps.push(video_info.duration / 2.0);
}
}
FrameType::Multiple => {
if let Some(time_points) = &config.time_points {
for time_point in time_points {
let timestamp = Self::time_point_to_seconds(time_point, video_info)?;
timestamps.push(timestamp);
}
}
}
}
Ok(timestamps)
}
/// 将时间点配置转换为秒数
fn time_point_to_seconds(time_point: &TimePoint, video_info: &VideoFileInfo) -> Result<f64> {
match time_point {
TimePoint::Seconds { value } => {
Ok(value.clamp(0.0, video_info.duration))
}
TimePoint::Percentage { value } => {
let percentage = value.clamp(0.0, 1.0);
Ok(video_info.duration * percentage)
}
TimePoint::Frame { value } => {
if video_info.fps > 0.0 {
let timestamp = (*value as f64) / video_info.fps;
Ok(timestamp.clamp(0.0, video_info.duration))
} else {
Err(anyhow!("无法计算帧时间戳:视频帧率信息不可用"))
}
}
}
}
/// 生成输出文件路径
pub fn generate_output_path(
video_info: &VideoFileInfo,
config: &FrameExtractionConfig,
timestamp: f64,
index: Option<usize>,
) -> Result<String> {
let video_path = Path::new(&video_info.path);
let video_name = video_path
.file_stem()
.ok_or_else(|| anyhow!("无法获取视频文件名"))?
.to_string_lossy();
let frame_type_str = match &config.frame_type {
FrameType::First => "first",
FrameType::Last => "last",
FrameType::Custom => "custom",
FrameType::Multiple => "frame",
};
let extension = match config.output_format.format {
ImageFormat::Jpg => "jpg",
ImageFormat::Png => "png",
ImageFormat::WebP => "webp",
ImageFormat::Bmp => "bmp",
};
// 替换文件名模式中的变量
let mut filename = config.filename_pattern.clone();
filename = filename.replace("{name}", &video_name);
filename = filename.replace("{frame_type}", frame_type_str);
filename = filename.replace("{timestamp}", &format!("{:.2}", timestamp));
if let Some(idx) = index {
filename = filename.replace("{index}", &format!("{:03}", idx + 1));
}
// 确保文件名有效
filename = Self::sanitize_filename(&filename);
let output_path = Path::new(&config.output_directory)
.join(format!("{}.{}", filename, extension));
Ok(output_path.to_string_lossy().to_string())
}
/// 清理文件名,移除无效字符
fn sanitize_filename(filename: &str) -> String {
filename
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '|' | '?' | '*' => '_',
'/' | '\\' => '_',
c if c.is_control() => '_',
c => c,
})
.collect()
}
/// 批量提取帧
pub async fn extract_frames_batch(
video_files: &[VideoFileInfo],
config: &FrameExtractionConfig,
) -> Result<Vec<FrameExtractionResult>> {
let mut results = Vec::new();
for (index, video_info) in video_files.iter().enumerate() {
info!(
"处理视频 {}/{}: {}",
index + 1,
video_files.len(),
video_info.filename
);
if !video_info.is_valid {
warn!("跳过无效视频文件: {}", video_info.path);
continue;
}
// 计算提取时间点
let timestamps = match Self::calculate_extraction_timestamps(video_info, config) {
Ok(timestamps) => timestamps,
Err(e) => {
error!("计算时间戳失败 {}: {}", video_info.path, e);
continue;
}
};
// 为每个时间点提取帧
for (timestamp_index, timestamp) in timestamps.iter().enumerate() {
let output_path = match Self::generate_output_path(
video_info,
config,
*timestamp,
if timestamps.len() > 1 { Some(timestamp_index) } else { None },
) {
Ok(path) => path,
Err(e) => {
error!("生成输出路径失败: {}", e);
continue;
}
};
// 检查文件是否已存在
if !config.overwrite_existing && Path::new(&output_path).exists() {
info!("跳过已存在的文件: {}", output_path);
continue;
}
// 提取帧
match Self::extract_frame(&video_info.path, *timestamp, &output_path, config).await {
Ok(result) => {
results.push(result);
}
Err(e) => {
error!("提取帧失败 {}: {}", video_info.path, e);
results.push(FrameExtractionResult {
video_path: video_info.path.clone(),
frame_type: config.frame_type.clone(),
timestamp: *timestamp,
output_path,
success: false,
error_message: Some(e.to_string()),
processing_time_ms: 0,
file_size: 0,
});
}
}
}
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_video_info() -> VideoFileInfo {
VideoFileInfo {
path: "/test/video.mp4".to_string(),
filename: "video.mp4".to_string(),
size: 1024 * 1024, // 1MB
duration: 60.0, // 60 seconds
width: 1920,
height: 1080,
fps: 30.0,
format: "mp4".to_string(),
is_valid: true,
error_message: None,
}
}
fn create_test_config() -> FrameExtractionConfig {
FrameExtractionConfig {
frame_type: FrameType::First,
time_points: None,
custom_time: None,
output_format: ImageQuality {
format: ImageFormat::Jpg,
quality: 85,
compression_level: None,
},
output_size: Some(OutputSize {
width: Some(640),
height: Some(360),
maintain_aspect_ratio: true,
scale_filter: Some("lanczos".to_string()),
}),
output_directory: "/tmp".to_string(),
filename_pattern: "{name}_{frame_type}".to_string(),
overwrite_existing: false,
}
}
#[test]
fn test_parse_fraction() {
assert_eq!(FrameExtractorService::parse_fraction("30/1"), Some(30.0));
assert_eq!(FrameExtractorService::parse_fraction("25/1"), Some(25.0));
assert_eq!(FrameExtractorService::parse_fraction("30000/1001"), Some(29.970029970029973));
assert_eq!(FrameExtractorService::parse_fraction("invalid"), None);
assert_eq!(FrameExtractorService::parse_fraction("30/0"), None);
}
#[test]
fn test_jpeg_quality_to_qscale() {
assert_eq!(FrameExtractorService::jpeg_quality_to_qscale(100), 1);
assert_eq!(FrameExtractorService::jpeg_quality_to_qscale(1), 31);
assert_eq!(FrameExtractorService::jpeg_quality_to_qscale(85), 6);
assert_eq!(FrameExtractorService::jpeg_quality_to_qscale(50), 16);
}
#[test]
fn test_calculate_extraction_timestamps_first_frame() {
let video_info = create_test_video_info();
let config = FrameExtractionConfig {
frame_type: FrameType::First,
..create_test_config()
};
let timestamps = FrameExtractorService::calculate_extraction_timestamps(&video_info, &config)
.expect("Should calculate timestamps");
assert_eq!(timestamps.len(), 1);
assert_eq!(timestamps[0], 0.0);
}
#[test]
fn test_calculate_extraction_timestamps_last_frame() {
let video_info = create_test_video_info();
let config = FrameExtractionConfig {
frame_type: FrameType::Last,
..create_test_config()
};
let timestamps = FrameExtractorService::calculate_extraction_timestamps(&video_info, &config)
.expect("Should calculate timestamps");
assert_eq!(timestamps.len(), 1);
assert_eq!(timestamps[0], 59.9); // duration - 0.1
}
#[test]
fn test_calculate_extraction_timestamps_custom() {
let video_info = create_test_video_info();
let config = FrameExtractionConfig {
frame_type: FrameType::Custom,
custom_time: Some(30.0),
..create_test_config()
};
let timestamps = FrameExtractorService::calculate_extraction_timestamps(&video_info, &config)
.expect("Should calculate timestamps");
assert_eq!(timestamps.len(), 1);
assert_eq!(timestamps[0], 30.0);
}
#[test]
fn test_calculate_extraction_timestamps_custom_default() {
let video_info = create_test_video_info();
let config = FrameExtractionConfig {
frame_type: FrameType::Custom,
custom_time: None,
..create_test_config()
};
let timestamps = FrameExtractorService::calculate_extraction_timestamps(&video_info, &config)
.expect("Should calculate timestamps");
assert_eq!(timestamps.len(), 1);
assert_eq!(timestamps[0], 30.0); // duration / 2
}
#[test]
fn test_time_point_to_seconds() {
let video_info = create_test_video_info();
// Test seconds
let time_point = TimePoint::Seconds { value: 15.5 };
let result = FrameExtractorService::time_point_to_seconds(&time_point, &video_info)
.expect("Should convert seconds");
assert_eq!(result, 15.5);
// Test percentage
let time_point = TimePoint::Percentage { value: 0.5 };
let result = FrameExtractorService::time_point_to_seconds(&time_point, &video_info)
.expect("Should convert percentage");
assert_eq!(result, 30.0); // 50% of 60 seconds
// Test frame number
let time_point = TimePoint::Frame { value: 900 };
let result = FrameExtractorService::time_point_to_seconds(&time_point, &video_info)
.expect("Should convert frame");
assert_eq!(result, 30.0); // 900 frames / 30 fps
}
#[test]
fn test_generate_output_path() {
let video_info = create_test_video_info();
let config = create_test_config();
let output_path = FrameExtractorService::generate_output_path(
&video_info,
&config,
15.5,
None,
).expect("Should generate output path");
assert!(output_path.contains("video_first"));
assert!(output_path.contains("15.50"));
assert!(output_path.ends_with(".jpg"));
}
#[test]
fn test_generate_output_path_with_index() {
let video_info = create_test_video_info();
let config = FrameExtractionConfig {
filename_pattern: "{name}_{frame_type}_{index}".to_string(),
..create_test_config()
};
let output_path = FrameExtractorService::generate_output_path(
&video_info,
&config,
15.5,
Some(2),
).expect("Should generate output path");
assert!(output_path.contains("video_first_003"));
}
#[test]
fn test_sanitize_filename() {
assert_eq!(
FrameExtractorService::sanitize_filename("normal_filename"),
"normal_filename"
);
assert_eq!(
FrameExtractorService::sanitize_filename("file<>name"),
"file__name"
);
assert_eq!(
FrameExtractorService::sanitize_filename("file/with\\path"),
"file_with_path"
);
assert_eq!(
FrameExtractorService::sanitize_filename("file:with|special*chars?"),
"file_with_special_chars_"
);
}
#[test]
fn test_calculate_extraction_timestamps_multiple() {
let video_info = create_test_video_info();
let time_points = vec![
TimePoint::Seconds { value: 10.0 },
TimePoint::Percentage { value: 0.5 },
TimePoint::Frame { value: 1500 },
];
let config = FrameExtractionConfig {
frame_type: FrameType::Multiple,
time_points: Some(time_points),
..create_test_config()
};
let timestamps = FrameExtractorService::calculate_extraction_timestamps(&video_info, &config)
.expect("Should calculate timestamps");
assert_eq!(timestamps.len(), 3);
assert_eq!(timestamps[0], 10.0);
assert_eq!(timestamps[1], 30.0); // 50% of 60 seconds
assert_eq!(timestamps[2], 50.0); // 1500 frames / 30 fps
}
#[test]
fn test_time_point_clamping() {
let video_info = create_test_video_info();
// Test seconds clamping
let time_point = TimePoint::Seconds { value: 100.0 }; // Beyond duration
let result = FrameExtractorService::time_point_to_seconds(&time_point, &video_info)
.expect("Should convert seconds");
assert_eq!(result, 60.0); // Clamped to duration
// Test negative seconds
let time_point = TimePoint::Seconds { value: -5.0 };
let result = FrameExtractorService::time_point_to_seconds(&time_point, &video_info)
.expect("Should convert seconds");
assert_eq!(result, 0.0); // Clamped to 0
// Test percentage clamping
let time_point = TimePoint::Percentage { value: 1.5 }; // > 1.0
let result = FrameExtractorService::time_point_to_seconds(&time_point, &video_info)
.expect("Should convert percentage");
assert_eq!(result, 60.0); // 100% of duration
}
}

View File

@@ -0,0 +1,61 @@
// 简单的编译测试文件
use super::frame_extractor::*;
#[cfg(test)]
mod compile_tests {
use super::*;
#[test]
fn test_frame_type_serialization() {
let frame_type = FrameType::First;
let serialized = serde_json::to_string(&frame_type).unwrap();
assert_eq!(serialized, "\"First\"");
}
#[test]
fn test_image_format_serialization() {
let format = ImageFormat::Jpg;
let serialized = serde_json::to_string(&format).unwrap();
assert_eq!(serialized, "\"Jpg\"");
}
#[test]
fn test_video_file_info_creation() {
let video_info = VideoFileInfo {
path: "/test/video.mp4".to_string(),
filename: "video.mp4".to_string(),
size: 1024,
duration: 60.0,
width: 1920,
height: 1080,
fps: 30.0,
format: "mp4".to_string(),
is_valid: true,
error_message: None,
};
assert_eq!(video_info.filename, "video.mp4");
assert!(video_info.is_valid);
}
#[test]
fn test_frame_extraction_config_creation() {
let config = FrameExtractionConfig {
frame_type: FrameType::First,
time_points: None,
custom_time: None,
output_format: ImageQuality {
format: ImageFormat::Jpg,
quality: 85,
compression_level: None,
},
output_size: None,
output_directory: "/tmp".to_string(),
filename_pattern: "{name}_{frame_type}".to_string(),
overwrite_existing: false,
};
assert_eq!(config.output_format.quality, 85);
assert_eq!(config.frame_type, FrameType::First);
}
}

View File

@@ -12,6 +12,7 @@ pub mod filename_utils;
pub mod event_bus;
pub mod ffmpeg;
pub mod ffmpeg_watermark;
pub mod frame_extractor;
pub mod monitoring;
pub mod logging;
pub mod gemini_service;

View File

@@ -403,8 +403,17 @@ pub fn run() {
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::scan_video_files_for_thumbnails,
commands::thumbnail_commands::preview_thumbnail,
// 关键帧提取命令
commands::frame_extractor_commands::check_ffmpeg_availability,
commands::frame_extractor_commands::get_video_info,
commands::frame_extractor_commands::scan_video_files,
commands::frame_extractor_commands::extract_single_frame,
commands::frame_extractor_commands::extract_frames_batch,
commands::frame_extractor_commands::calculate_extraction_timestamps,
commands::frame_extractor_commands::generate_output_path,
commands::frame_extractor_commands::preview_frame,
// 模板片段权重配置命令
commands::template_segment_weight_commands::create_template_segment_weight,
commands::template_segment_weight_commands::get_segment_weights_with_defaults,

View File

@@ -0,0 +1,385 @@
use tauri::command;
use std::sync::{Arc, Mutex};
use tracing::{info, error, warn, debug};
use lazy_static::lazy_static;
use base64::{Engine as _, engine::general_purpose};
use crate::infrastructure::frame_extractor::{
FrameExtractorService, FrameExtractionConfig, VideoFileInfo,
FrameExtractionResult, FrameType, TimePoint, ImageFormat,
ImageQuality, OutputSize
};
lazy_static! {
static ref FRAME_EXTRACTOR_INITIALIZED: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
}
/// 初始化关键帧提取服务
async fn ensure_initialized() -> Result<(), String> {
let needs_init = {
let initialized = FRAME_EXTRACTOR_INITIALIZED.lock().unwrap();
!*initialized
};
if needs_init {
match FrameExtractorService::initialize().await {
Ok(_) => {
let mut initialized = FRAME_EXTRACTOR_INITIALIZED.lock().unwrap();
*initialized = true;
info!("关键帧提取服务初始化成功");
Ok(())
}
Err(e) => {
error!("关键帧提取服务初始化失败: {}", e);
Err(format!("初始化失败: {}", e))
}
}
} else {
Ok(())
}
}
/// 文件夹扫描请求
#[derive(Debug, serde::Deserialize)]
pub struct FolderScanRequest {
pub folder_path: String,
pub recursive: bool,
pub supported_formats: Vec<String>,
}
/// 帧提取请求
#[derive(Debug, serde::Deserialize)]
pub struct FrameExtractionRequest {
pub video_files: Vec<String>,
pub config: FrameExtractionConfig,
}
/// 帧预览请求
#[derive(Debug, serde::Deserialize)]
pub struct FramePreviewRequest {
pub video_path: String,
pub timestamp: f64,
pub output_size: Option<OutputSize>,
}
/// 帧预览结果
#[derive(Debug, serde::Serialize)]
pub struct FramePreviewResult {
pub success: bool,
pub preview_data: Option<String>, // Base64 encoded image
pub error_message: Option<String>,
pub timestamp: f64,
pub original_size: (u32, u32),
pub preview_size: (u32, u32),
}
/// 检查 FFmpeg 是否可用
#[command]
pub async fn check_ffmpeg_availability() -> Result<bool, String> {
ensure_initialized().await?;
Ok(FrameExtractorService::is_available())
}
/// 获取视频文件信息
#[command]
pub async fn get_video_info(video_path: String) -> Result<VideoFileInfo, String> {
ensure_initialized().await?;
debug!("获取视频信息: {}", video_path);
match FrameExtractorService::get_video_info(&video_path).await {
Ok(info) => {
debug!("视频信息获取成功: {:?}", info);
Ok(info)
}
Err(e) => {
error!("获取视频信息失败 {}: {}", video_path, e);
Err(format!("获取视频信息失败: {}", e))
}
}
}
/// 扫描文件夹中的视频文件
#[command]
pub async fn scan_video_files(request: FolderScanRequest) -> Result<Vec<VideoFileInfo>, String> {
ensure_initialized().await?;
info!("扫描视频文件: {:?}", request);
match FrameExtractorService::scan_video_files(
&request.folder_path,
request.recursive,
&request.supported_formats,
).await {
Ok(files) => {
info!("扫描完成,找到 {} 个视频文件", files.len());
Ok(files)
}
Err(e) => {
error!("扫描视频文件失败: {}", e);
Err(format!("扫描失败: {}", e))
}
}
}
/// 提取单个帧
#[command]
pub async fn extract_single_frame(
video_path: String,
timestamp: f64,
output_path: String,
config: FrameExtractionConfig,
) -> Result<FrameExtractionResult, String> {
ensure_initialized().await?;
debug!("提取单个帧: video={}, timestamp={}, output={}", video_path, timestamp, output_path);
match FrameExtractorService::extract_frame(&video_path, timestamp, &output_path, &config).await {
Ok(result) => {
if result.success {
info!("帧提取成功: {} -> {}", video_path, output_path);
} else {
warn!("帧提取失败: {}", result.error_message.as_deref().unwrap_or("未知错误"));
}
Ok(result)
}
Err(e) => {
error!("提取帧失败: {}", e);
Err(format!("提取失败: {}", e))
}
}
}
/// 批量提取帧
#[command]
pub async fn extract_frames_batch(request: FrameExtractionRequest) -> Result<Vec<FrameExtractionResult>, String> {
ensure_initialized().await?;
info!("开始批量提取帧: {} 个视频文件", request.video_files.len());
// 首先获取所有视频文件信息
let mut video_infos = Vec::new();
for video_path in &request.video_files {
match FrameExtractorService::get_video_info(video_path).await {
Ok(info) => video_infos.push(info),
Err(e) => {
warn!("获取视频信息失败 {}: {}", video_path, e);
// 创建一个错误的视频信息对象
video_infos.push(VideoFileInfo {
path: video_path.clone(),
filename: std::path::Path::new(video_path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
size: 0,
duration: 0.0,
width: 0,
height: 0,
fps: 0.0,
format: String::new(),
is_valid: false,
error_message: Some(e.to_string()),
});
}
}
}
// 执行批量提取
match FrameExtractorService::extract_frames_batch(&video_infos, &request.config).await {
Ok(results) => {
let success_count = results.iter().filter(|r| r.success).count();
info!("批量提取完成: {}/{} 成功", success_count, results.len());
Ok(results)
}
Err(e) => {
error!("批量提取失败: {}", e);
Err(format!("批量提取失败: {}", e))
}
}
}
/// 计算提取时间戳
#[command]
pub async fn calculate_extraction_timestamps(
video_path: String,
config: FrameExtractionConfig,
) -> Result<Vec<f64>, String> {
ensure_initialized().await?;
// 获取视频信息
let video_info = match FrameExtractorService::get_video_info(&video_path).await {
Ok(info) => info,
Err(e) => return Err(format!("获取视频信息失败: {}", e)),
};
// 计算时间戳
match FrameExtractorService::calculate_extraction_timestamps(&video_info, &config) {
Ok(timestamps) => {
debug!("计算时间戳成功: {:?}", timestamps);
Ok(timestamps)
}
Err(e) => {
error!("计算时间戳失败: {}", e);
Err(format!("计算时间戳失败: {}", e))
}
}
}
/// 生成输出文件路径
#[command]
pub async fn generate_output_path(
video_path: String,
config: FrameExtractionConfig,
timestamp: f64,
index: Option<usize>,
) -> Result<String, String> {
// 获取视频信息
let video_info = match FrameExtractorService::get_video_info(&video_path).await {
Ok(info) => info,
Err(e) => return Err(format!("获取视频信息失败: {}", e)),
};
// 生成输出路径
match FrameExtractorService::generate_output_path(&video_info, &config, timestamp, index) {
Ok(path) => {
debug!("生成输出路径: {}", path);
Ok(path)
}
Err(e) => {
error!("生成输出路径失败: {}", e);
Err(format!("生成输出路径失败: {}", e))
}
}
}
/// 预览帧(返回 Base64 编码的图片数据)
#[command]
pub async fn preview_frame(request: FramePreviewRequest) -> Result<FramePreviewResult, String> {
ensure_initialized().await?;
debug!("预览帧: video={}, timestamp={}", request.video_path, request.timestamp);
// 获取视频信息
let video_info = match FrameExtractorService::get_video_info(&request.video_path).await {
Ok(info) => info,
Err(e) => {
return Ok(FramePreviewResult {
success: false,
preview_data: None,
error_message: Some(format!("获取视频信息失败: {}", e)),
timestamp: request.timestamp,
original_size: (0, 0),
preview_size: (0, 0),
});
}
};
// 创建临时预览配置
let preview_config = FrameExtractionConfig {
frame_type: FrameType::Custom,
time_points: None,
custom_time: Some(request.timestamp),
output_format: ImageQuality {
format: ImageFormat::Jpg,
quality: 85,
compression_level: None,
},
output_size: request.output_size.clone(),
output_directory: std::env::temp_dir().to_string_lossy().to_string(),
filename_pattern: format!("preview_{}", uuid::Uuid::new_v4().to_string()),
overwrite_existing: true,
};
// 生成临时输出路径
let temp_path = match FrameExtractorService::generate_output_path(
&video_info,
&preview_config,
request.timestamp,
None,
) {
Ok(path) => path,
Err(e) => {
return Ok(FramePreviewResult {
success: false,
preview_data: None,
error_message: Some(format!("生成临时路径失败: {}", e)),
timestamp: request.timestamp,
original_size: (video_info.width, video_info.height),
preview_size: (0, 0),
});
}
};
// 提取预览帧
match FrameExtractorService::extract_frame(
&request.video_path,
request.timestamp,
&temp_path,
&preview_config,
).await {
Ok(result) => {
if result.success {
// 读取文件并转换为 Base64
match std::fs::read(&temp_path) {
Ok(data) => {
let base64_data = general_purpose::STANDARD.encode(&data);
// 清理临时文件
let _ = std::fs::remove_file(&temp_path);
// 计算预览尺寸
let preview_size = if let Some(size) = &request.output_size {
(
size.width.unwrap_or(video_info.width),
size.height.unwrap_or(video_info.height),
)
} else {
(video_info.width, video_info.height)
};
Ok(FramePreviewResult {
success: true,
preview_data: Some(base64_data),
error_message: None,
timestamp: request.timestamp,
original_size: (video_info.width, video_info.height),
preview_size,
})
}
Err(e) => {
let _ = std::fs::remove_file(&temp_path);
Ok(FramePreviewResult {
success: false,
preview_data: None,
error_message: Some(format!("读取预览文件失败: {}", e)),
timestamp: request.timestamp,
original_size: (video_info.width, video_info.height),
preview_size: (0, 0),
})
}
}
} else {
Ok(FramePreviewResult {
success: false,
preview_data: None,
error_message: result.error_message,
timestamp: request.timestamp,
original_size: (video_info.width, video_info.height),
preview_size: (0, 0),
})
}
}
Err(e) => {
let _ = std::fs::remove_file(&temp_path);
Ok(FramePreviewResult {
success: false,
preview_data: None,
error_message: Some(format!("提取预览帧失败: {}", e)),
timestamp: request.timestamp,
original_size: (video_info.width, video_info.height),
preview_size: (0, 0),
})
}
}
}

View File

@@ -27,6 +27,7 @@ pub mod custom_tag_commands;
pub mod tolerant_json_commands;
pub mod markdown_commands;
pub mod thumbnail_commands;
pub mod frame_extractor_commands;
pub mod rag_grounding_commands;
pub mod image_download_commands;
pub mod conversation_commands;

View File

@@ -213,7 +213,7 @@ pub async fn select_video_folder() -> Result<Option<String>, String> {
/// 扫描文件夹中的视频文件
/// 扫描指定文件夹,返回其中的视频文件列表
#[command]
pub async fn scan_video_files(
pub async fn scan_video_files_for_thumbnails(
folder_path: String,
) -> Result<Vec<VideoFile>, String> {
info!(folder_path = %folder_path, "扫描文件夹中的视频文件");

View File

@@ -19,6 +19,7 @@ import ChatTestPage from './pages/tools/ChatTestPage';
import WatermarkTool from './pages/tools/WatermarkTool';
import SimilaritySearchTool from './pages/tools/SimilaritySearchTool';
import BatchThumbnailGenerator from './pages/tools/BatchThumbnailGenerator';
import FrameExtractorTool from './pages/tools/FrameExtractorTool';
import OutfitRecommendationTool from './pages/tools/OutfitRecommendationTool';
import AdvancedFilterTool from './pages/tools/AdvancedFilterTool';
import OutfitSearchTool from './pages/tools/OutfitSearchTool';
@@ -161,6 +162,7 @@ function App() {
<Route path="/tools/watermark" element={<WatermarkTool />} />
<Route path="/tools/similarity-search" element={<SimilaritySearchTool />} />
<Route path="/tools/batch-thumbnail-generator" element={<BatchThumbnailGenerator />} />
<Route path="/tools/frame-extractor" element={<FrameExtractorTool />} />
<Route path="/tools/outfit-recommendation" element={<OutfitRecommendationTool />} />
<Route path="/tools/outfit-search" element={<OutfitSearchTool />} />
<Route path="/tools/outfit-favorites" element={<OutfitFavoritesTool />} />

View File

@@ -0,0 +1,218 @@
import React from 'react';
import {
Clock,
Play,
Pause,
Square,
RefreshCw,
CheckCircle,
AlertCircle,
TrendingUp
} from 'lucide-react';
interface ExtractionProgressProps {
isExtracting: boolean;
progress: number;
currentFile: string;
totalFiles: number;
processedFiles?: number;
failedFiles?: number;
estimatedTimeRemaining?: number;
currentSpeed?: number;
}
/**
* 关键帧提取进度组件
* 遵循 Tauri 开发规范和 UI/UX 设计标准
*/
export const ExtractionProgress: React.FC<ExtractionProgressProps> = ({
isExtracting,
progress,
currentFile,
totalFiles,
processedFiles = 0,
failedFiles = 0,
estimatedTimeRemaining,
currentSpeed
}) => {
// 格式化时间
const formatTime = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)}`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}${remainingSeconds}`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}小时${minutes}分钟`;
}
};
// 格式化处理速度
const formatSpeed = (speed: number): string => {
return `${speed.toFixed(1)} 文件/秒`;
};
// 计算成功率
const successRate = processedFiles > 0 ? ((processedFiles - failedFiles) / processedFiles * 100) : 0;
return (
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-6">
{/* 标题 */}
<div className="flex items-center gap-3">
{isExtracting ? (
<RefreshCw className="w-5 h-5 text-blue-600 animate-spin" />
) : (
<Clock className="w-5 h-5 text-gray-600" />
)}
<h3 className="text-lg font-semibold text-gray-900">
{isExtracting ? '正在提取关键帧...' : '提取进度'}
</h3>
</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 text-gray-900">{Math.round(progress)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{processedFiles} / {totalFiles} </span>
{estimatedTimeRemaining && estimatedTimeRemaining > 0 && (
<span>: {formatTime(estimatedTimeRemaining)}</span>
)}
</div>
</div>
{/* 当前处理文件 */}
{currentFile && (
<div className="space-y-2">
<div className="text-sm font-medium text-gray-700">:</div>
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center gap-2">
<Play className="w-4 h-4 text-blue-600 flex-shrink-0" />
<span className="text-sm text-blue-800 truncate" title={currentFile}>
{currentFile.split('/').pop() || currentFile}
</span>
</div>
</div>
</div>
)}
{/* 统计信息 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-gray-900">{totalFiles}</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">{processedFiles - failedFiles}</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{failedFiles}</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center p-3 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{Math.round(successRate)}%</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
{/* 性能指标 */}
{(currentSpeed || estimatedTimeRemaining) && (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700"></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{currentSpeed && (
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<TrendingUp className="w-4 h-4 text-green-600" />
<div>
<div className="text-sm font-medium text-gray-900"></div>
<div className="text-xs text-gray-500">{formatSpeed(currentSpeed)}</div>
</div>
</div>
)}
{estimatedTimeRemaining && estimatedTimeRemaining > 0 && (
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<Clock className="w-4 h-4 text-blue-600" />
<div>
<div className="text-sm font-medium text-gray-900"></div>
<div className="text-xs text-gray-500">{formatTime(estimatedTimeRemaining)}</div>
</div>
</div>
)}
</div>
</div>
)}
{/* 状态指示器 */}
<div className="flex items-center justify-center gap-4 pt-4 border-t">
{isExtracting ? (
<div className="flex items-center gap-2 text-blue-600">
<RefreshCw className="w-4 h-4 animate-spin" />
<span className="text-sm font-medium">...</span>
</div>
) : processedFiles > 0 ? (
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="w-4 h-4" />
<span className="text-sm font-medium"></span>
</div>
) : (
<div className="flex items-center gap-2 text-gray-500">
<Pause className="w-4 h-4" />
<span className="text-sm font-medium"></span>
</div>
)}
</div>
{/* 错误提示 */}
{failedFiles > 0 && !isExtracting && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div>
<div className="text-sm font-medium text-red-800">
{failedFiles}
</div>
<div className="text-xs text-red-600 mt-1">
</div>
</div>
</div>
</div>
)}
{/* 完成提示 */}
{!isExtracting && processedFiles > 0 && failedFiles === 0 && (
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
<div>
<div className="text-sm font-medium text-green-800">
</div>
<div className="text-xs text-green-600 mt-1">
{processedFiles}
</div>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,297 @@
import React, { useState } from 'react';
import {
CheckCircle,
AlertCircle,
ExternalLink,
Download,
Eye,
Clock,
HardDrive,
Image,
Filter,
Grid,
List,
Search
} from 'lucide-react';
import { FrameExtractionResult, FrameType } from '../../types/frame-extractor';
interface ExtractionResultsProps {
results: FrameExtractionResult[];
onOpenResult: (result: FrameExtractionResult) => void;
onPreviewResult?: (result: FrameExtractionResult) => void;
onDownloadResult?: (result: FrameExtractionResult) => void;
}
/**
* 关键帧提取结果展示组件
* 遵循 Tauri 开发规范和 UI/UX 设计标准
*/
export const ExtractionResults: React.FC<ExtractionResultsProps> = ({
results,
onOpenResult,
onPreviewResult,
onDownloadResult
}) => {
const [filter, setFilter] = useState<'all' | 'success' | 'failed'>('all');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
const [searchQuery, setSearchQuery] = useState('');
// 过滤结果
const filteredResults = results.filter(result => {
// 状态过滤
if (filter === 'success' && !result.success) return false;
if (filter === 'failed' && result.success) return false;
// 搜索过滤
if (searchQuery) {
const query = searchQuery.toLowerCase();
const videoName = result.video_path.toLowerCase();
const outputName = result.output_path.toLowerCase();
if (!videoName.includes(query) && !outputName.includes(query)) {
return false;
}
}
return true;
});
// 统计信息
const stats = {
total: results.length,
success: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
totalSize: results.reduce((sum, r) => sum + (r.success ? r.file_size : 0), 0),
totalTime: results.reduce((sum, r) => sum + r.processing_time_ms, 0)
};
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// 格式化处理时间
const formatProcessingTime = (ms: number): string => {
if (ms < 1000) return `${ms}ms`;
const seconds = ms / 1000;
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
// 格式化时间戳
const formatTimestamp = (timestamp: number): string => {
const minutes = Math.floor(timestamp / 60);
const seconds = Math.floor(timestamp % 60);
const ms = Math.floor((timestamp % 1) * 1000);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
};
// 获取帧类型显示名称
const getFrameTypeName = (frameType: FrameType): string => {
switch (frameType) {
case FrameType.First: return '第一帧';
case FrameType.Last: return '最后一帧';
case FrameType.Custom: return '自定义';
case FrameType.Multiple: return '多帧';
default: return '未知';
}
};
return (
<div className="bg-white rounded-lg shadow-sm border space-y-6">
{/* 标题和统计 */}
<div className="p-6 border-b">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-lg transition-colors ${
viewMode === 'list'
? 'bg-blue-100 text-blue-600'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<List className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-lg transition-colors ${
viewMode === 'grid'
? 'bg-blue-100 text-blue-600'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<Grid className="w-4 h-4" />
</button>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">{stats.success}</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{stats.failed}</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center p-3 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{formatFileSize(stats.totalSize)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
{/* 过滤和搜索 */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-400" />
<select
value={filter}
onChange={(e) => setFilter(e.target.value as any)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="all"></option>
<option value="success"></option>
<option value="failed"></option>
</select>
</div>
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索文件名..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
</div>
{/* 结果列表 */}
<div className="px-6 pb-6">
{filteredResults.length === 0 ? (
<div className="text-center py-12">
<Image className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 mb-2">
{results.length === 0 ? '暂无提取结果' : '没有符合条件的结果'}
</p>
{results.length === 0 && (
<p className="text-sm text-gray-400"></p>
)}
</div>
) : (
<div className={viewMode === 'grid' ? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4' : 'space-y-3'}>
{filteredResults.map((result, index) => (
<div
key={`${result.video_path}-${result.timestamp}-${index}`}
className={`border rounded-lg p-4 hover:shadow-md transition-shadow ${
result.success ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{/* 状态和文件名 */}
<div className="flex items-center gap-2 mb-2">
{result.success ? (
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0" />
)}
<span className="font-medium text-gray-900 truncate">
{result.video_path.split('/').pop() || result.video_path}
</span>
</div>
{/* 详细信息 */}
<div className="space-y-1 text-xs text-gray-600">
<div className="flex items-center gap-4">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{getFrameTypeName(result.frame_type)}
</span>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{formatTimestamp(result.timestamp)}</span>
</div>
</div>
{result.success && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<HardDrive className="w-3 h-3" />
<span>{formatFileSize(result.file_size)}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{formatProcessingTime(result.processing_time_ms)}</span>
</div>
</div>
)}
{result.error_message && (
<div className="text-red-600 text-xs mt-1">
{result.error_message}
</div>
)}
</div>
{/* 输出路径 */}
{result.success && (
<div className="mt-2 text-xs text-gray-500 truncate" title={result.output_path}>
{result.output_path}
</div>
)}
</div>
{/* 操作按钮 */}
{result.success && (
<div className="flex items-center gap-1 flex-shrink-0">
{onPreviewResult && (
<button
onClick={() => onPreviewResult(result)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="预览"
>
<Eye className="w-4 h-4" />
</button>
)}
{onDownloadResult && (
<button
onClick={() => onDownloadResult(result)}
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded transition-colors"
title="下载"
>
<Download className="w-4 h-4" />
</button>
)}
<button
onClick={() => onOpenResult(result)}
className="p-1.5 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded transition-colors"
title="打开文件位置"
>
<ExternalLink className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,376 @@
import React, { useState, useCallback } from 'react';
import {
Settings,
FolderOpen,
Image,
Sliders,
Clock,
Percent,
Hash,
Save,
RotateCcw,
Info
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import {
FrameExtractionConfig,
FrameExtractionPreset,
FrameType,
ImageFormat,
TimePoint
} from '../../types/frame-extractor';
interface FrameExtractionConfigPanelProps {
config: FrameExtractionConfig;
onConfigChange: (config: FrameExtractionConfig) => void;
onApplyPreset: (presetId: string) => void;
presets: FrameExtractionPreset[];
}
/**
* 关键帧提取配置面板组件
* 遵循 Tauri 开发规范和 UI/UX 设计标准
*/
export const FrameExtractionConfigPanel: React.FC<FrameExtractionConfigPanelProps> = ({
config,
onConfigChange,
onApplyPreset,
presets
}) => {
const [customTimePoints, setCustomTimePoints] = useState<string>('');
// 更新配置
const updateConfig = useCallback((updates: Partial<FrameExtractionConfig>) => {
onConfigChange({ ...config, ...updates });
}, [config, onConfigChange]);
// 选择输出目录
const handleSelectOutputDirectory = useCallback(async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: '选择输出目录',
});
if (selected && typeof selected === 'string') {
updateConfig({ output_directory: selected });
}
} catch (error) {
console.error('选择输出目录失败:', error);
}
}, [updateConfig]);
// 处理帧类型变化
const handleFrameTypeChange = useCallback((frameType: FrameType) => {
const updates: Partial<FrameExtractionConfig> = { frame_type: frameType };
// 根据帧类型设置默认值
switch (frameType) {
case FrameType.First:
case FrameType.Last:
updates.custom_time = undefined;
updates.time_points = undefined;
break;
case FrameType.Custom:
updates.custom_time = 0;
updates.time_points = undefined;
break;
case FrameType.Multiple:
updates.custom_time = undefined;
updates.time_points = [];
break;
}
updateConfig(updates);
}, [updateConfig]);
// 处理多时间点输入
const handleTimePointsChange = useCallback((value: string) => {
setCustomTimePoints(value);
if (config.frame_type === FrameType.Multiple) {
try {
const points = value.split(',').map(p => p.trim()).filter(p => p);
const timePoints: TimePoint[] = points.map(point => {
if (point.endsWith('%')) {
const percentage = parseFloat(point.slice(0, -1)) / 100;
return { type: 'percentage', value: percentage };
} else if (point.includes('f')) {
const frameNumber = parseInt(point.replace('f', ''));
return { type: 'frame', value: frameNumber };
} else {
const seconds = parseFloat(point);
return { type: 'seconds', value: seconds };
}
});
updateConfig({ time_points: timePoints });
} catch (error) {
console.error('解析时间点失败:', error);
}
}
}, [config.frame_type, updateConfig]);
return (
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-6">
<div className="flex items-center gap-3 mb-6">
<Settings className="w-5 h-5 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900"></h3>
</div>
{/* 预设配置 */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{presets.map((preset) => (
<button
key={preset.id}
onClick={() => onApplyPreset(preset.id)}
className="p-3 text-left border rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="font-medium text-sm text-gray-900">{preset.name}</div>
<div className="text-xs text-gray-500 mt-1">{preset.description}</div>
</button>
))}
</div>
</div>
{/* 帧类型选择 */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
</label>
<div className="grid grid-cols-2 gap-2">
{Object.values(FrameType).map((type) => (
<button
key={type}
onClick={() => handleFrameTypeChange(type)}
className={`p-3 text-center border rounded-lg transition-colors ${
config.frame_type === type
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 hover:bg-gray-50'
}`}
>
<div className="font-medium text-sm">
{type === FrameType.First && '第一帧'}
{type === FrameType.Last && '最后一帧'}
{type === FrameType.Custom && '自定义时间'}
{type === FrameType.Multiple && '多个时间点'}
</div>
</button>
))}
</div>
</div>
{/* 自定义时间输入 */}
{config.frame_type === FrameType.Custom && (
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
()
</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="number"
min="0"
step="0.1"
value={config.custom_time || 0}
onChange={(e) => updateConfig({ custom_time: parseFloat(e.target.value) || 0 })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="输入时间(秒)"
/>
</div>
</div>
)}
{/* 多时间点输入 */}
{config.frame_type === FrameType.Multiple && (
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
</label>
<textarea
value={customTimePoints}
onChange={(e) => handleTimePointsChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={3}
placeholder="输入时间点,用逗号分隔&#10;支持格式:&#10;- 秒数1.5, 10, 30&#10;- 百分比25%, 50%, 75%&#10;- 帧数100f, 500f, 1000f"
/>
<div className="flex items-start gap-2 text-xs text-gray-500">
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
<div>
(1.5) (50%) (100f)
</div>
</div>
</div>
)}
{/* 输出格式设置 */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
</label>
<div className="grid grid-cols-4 gap-2">
{Object.values(ImageFormat).map((format) => (
<button
key={format}
onClick={() => updateConfig({
output_format: { ...config.output_format, format }
})}
className={`p-2 text-center border rounded-lg transition-colors ${
config.output_format.format === format
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 hover:bg-gray-50'
}`}
>
<div className="font-medium text-xs uppercase">{format}</div>
</button>
))}
</div>
</div>
{/* 质量设置 */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
: {config.output_format.quality}
</label>
<div className="relative">
<Sliders className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="range"
min="1"
max="100"
value={config.output_format.quality}
onChange={(e) => updateConfig({
output_format: {
...config.output_format,
quality: parseInt(e.target.value)
}
})}
className="w-full pl-10 pr-4"
/>
</div>
<div className="flex justify-between text-xs text-gray-500">
<span></span>
<span></span>
</div>
</div>
{/* 输出尺寸设置 */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
</label>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1"> ()</label>
<input
type="number"
min="1"
value={config.output_size?.width || ''}
onChange={(e) => updateConfig({
output_size: {
maintain_aspect_ratio: true,
...config.output_size,
width: e.target.value ? parseInt(e.target.value) : undefined
}
})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="自动"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"> ()</label>
<input
type="number"
min="1"
value={config.output_size?.height || ''}
onChange={(e) => updateConfig({
output_size: {
maintain_aspect_ratio: true,
...config.output_size,
height: e.target.value ? parseInt(e.target.value) : undefined
}
})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="自动"
/>
</div>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.output_size?.maintain_aspect_ratio ?? true}
onChange={(e) => updateConfig({
output_size: {
...config.output_size,
maintain_aspect_ratio: e.target.checked
}
})}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700"></span>
</label>
</div>
{/* 输出目录设置 */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
</label>
<div className="flex gap-2">
<input
type="text"
value={config.output_directory}
onChange={(e) => updateConfig({ output_directory: e.target.value })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="选择输出目录"
/>
<button
onClick={handleSelectOutputDirectory}
className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
<FolderOpen className="w-4 h-4" />
</button>
</div>
</div>
{/* 文件名模式 */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="text"
value={config.filename_pattern}
onChange={(e) => updateConfig({ filename_pattern: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="文件名模式"
/>
<div className="flex items-start gap-2 text-xs text-gray-500">
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
<div>
{'{name}'} (){'{frame_type}'} (){'{timestamp}'} (){'{index}'} ()
</div>
</div>
</div>
{/* 其他选项 */}
<div className="space-y-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.overwrite_existing}
onChange={(e) => updateConfig({ overwrite_existing: e.target.checked })}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700"></span>
</label>
</div>
</div>
);
};

View File

@@ -0,0 +1,287 @@
import React, { useState, useCallback } from 'react';
import {
Eye,
Download,
RefreshCw,
AlertCircle,
Clock,
Monitor,
Maximize2,
X
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { VideoFileInfo, FramePreviewRequest, FramePreviewResult } from '../../types/frame-extractor';
interface FramePreviewProps {
video: VideoFileInfo;
timestamp: number;
onTimestampChange: (timestamp: number) => void;
className?: string;
}
/**
* 帧预览组件
* 遵循 Tauri 开发规范和 UI/UX 设计标准
*/
export const FramePreview: React.FC<FramePreviewProps> = ({
video,
timestamp,
onTimestampChange,
className = ''
}) => {
const [previewResult, setPreviewResult] = useState<FramePreviewResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// 生成预览
const generatePreview = useCallback(async () => {
if (!video.is_valid) return;
setIsLoading(true);
try {
const request: FramePreviewRequest = {
video_path: video.path,
timestamp: timestamp,
output_size: {
width: 640,
height: 360,
maintain_aspect_ratio: true,
scale_filter: 'lanczos'
}
};
const result = await invoke<FramePreviewResult>('preview_frame', { request });
setPreviewResult(result);
} catch (error) {
console.error('生成预览失败:', error);
setPreviewResult({
success: false,
preview_data: undefined,
error_message: `预览失败: ${error}`,
timestamp: timestamp,
original_size: [video.width, video.height],
preview_size: [0, 0]
});
} finally {
setIsLoading(false);
}
}, [video, timestamp]);
// 时间戳变化处理
const handleTimestampChange = useCallback((newTimestamp: number) => {
const clampedTimestamp = Math.max(0, Math.min(newTimestamp, video.duration));
onTimestampChange(clampedTimestamp);
}, [video.duration, onTimestampChange]);
// 格式化时间
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 1000);
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
};
// 下载预览图片
const downloadPreview = useCallback(() => {
if (!previewResult?.preview_data) return;
try {
const link = document.createElement('a');
link.href = `data:image/jpeg;base64,${previewResult.preview_data}`;
link.download = `preview_${video.filename}_${formatTime(timestamp).replace(/:/g, '-')}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error('下载预览图片失败:', error);
}
}, [previewResult, video.filename, timestamp]);
return (
<>
<div className={`bg-white rounded-lg shadow-sm border p-4 space-y-4 ${className}`}>
{/* 标题 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Eye className="w-5 h-5 text-blue-600" />
<h3 className="font-semibold text-gray-900"></h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={generatePreview}
disabled={isLoading || !video.is_valid}
className="px-3 py-1.5 bg-blue-500 text-white text-sm rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? (
<>
<RefreshCw className="w-3 h-3 inline mr-1 animate-spin" />
...
</>
) : (
'生成预览'
)}
</button>
</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-mono text-gray-900">{formatTime(timestamp)}</span>
</div>
<div className="relative">
<input
type="range"
min="0"
max={video.duration}
step="0.1"
value={timestamp}
onChange={(e) => handleTimestampChange(parseFloat(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>00:00</span>
<span>{formatTime(video.duration)}</span>
</div>
</div>
{/* 快速跳转按钮 */}
<div className="flex items-center gap-2">
<button
onClick={() => handleTimestampChange(0)}
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
>
</button>
<button
onClick={() => handleTimestampChange(video.duration * 0.25)}
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
>
25%
</button>
<button
onClick={() => handleTimestampChange(video.duration * 0.5)}
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
>
50%
</button>
<button
onClick={() => handleTimestampChange(video.duration * 0.75)}
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
>
75%
</button>
<button
onClick={() => handleTimestampChange(video.duration - 0.1)}
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
>
</button>
</div>
</div>
{/* 预览区域 */}
<div className="space-y-3">
{isLoading && (
<div className="aspect-video bg-gray-100 rounded-lg flex items-center justify-center">
<div className="text-center">
<RefreshCw className="w-8 h-8 text-blue-500 animate-spin mx-auto mb-2" />
<p className="text-sm text-gray-500">...</p>
</div>
</div>
)}
{!isLoading && previewResult && (
<div className="space-y-3">
{previewResult.success && previewResult.preview_data ? (
<div className="relative group">
<img
src={`data:image/jpeg;base64,${previewResult.preview_data}`}
alt="Frame Preview"
className="w-full rounded-lg shadow-sm cursor-pointer hover:shadow-md transition-shadow"
onClick={() => setIsFullscreen(true)}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all duration-200 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100">
<Maximize2 className="w-8 h-8 text-white" />
</div>
</div>
) : (
<div className="aspect-video bg-red-50 border border-red-200 rounded-lg flex items-center justify-center">
<div className="text-center">
<AlertCircle className="w-8 h-8 text-red-500 mx-auto mb-2" />
<p className="text-sm text-red-600">
{previewResult.error_message || '预览生成失败'}
</p>
</div>
</div>
)}
{/* 预览信息 */}
{previewResult.success && (
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<Monitor className="w-3 h-3" />
<span>
{previewResult.preview_size[0]}×{previewResult.preview_size[1]}
</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{formatTime(previewResult.timestamp)}</span>
</div>
</div>
<button
onClick={downloadPreview}
className="flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors"
>
<Download className="w-3 h-3" />
<span></span>
</button>
</div>
)}
</div>
)}
{!isLoading && !previewResult && (
<div className="aspect-video bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center">
<div className="text-center">
<Eye className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p className="text-sm text-gray-500">"生成预览"</p>
</div>
</div>
)}
</div>
</div>
{/* 全屏预览模态框 */}
{isFullscreen && previewResult?.success && previewResult.preview_data && (
<div className="fixed inset-0 bg-black bg-opacity-90 z-50 flex items-center justify-center p-4">
<div className="relative max-w-full max-h-full">
<button
onClick={() => setIsFullscreen(false)}
className="absolute -top-12 right-0 text-white hover:text-gray-300 transition-colors"
>
<X className="w-8 h-8" />
</button>
<img
src={`data:image/jpeg;base64,${previewResult.preview_data}`}
alt="Frame Preview Fullscreen"
className="max-w-full max-h-full object-contain rounded-lg"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white p-4 rounded-b-lg">
<div className="flex items-center justify-between text-sm">
<span>{video.filename}</span>
<span>{formatTime(previewResult.timestamp)}</span>
</div>
</div>
</div>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,212 @@
import React from 'react';
import {
Video,
Clock,
Monitor,
HardDrive,
Trash2,
AlertCircle,
CheckCircle,
RefreshCw,
Eye
} from 'lucide-react';
import { VideoFileInfo } from '../../types/frame-extractor';
interface VideoFileListProps {
videos: VideoFileInfo[];
isScanning: boolean;
onRemoveVideo: (index: number) => void;
onPreviewVideo?: (video: VideoFileInfo) => void;
}
/**
* 视频文件列表组件
* 遵循 Tauri 开发规范和 UI/UX 设计标准
*/
export const VideoFileList: React.FC<VideoFileListProps> = ({
videos,
isScanning,
onRemoveVideo,
onPreviewVideo
}) => {
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// 格式化时长
const formatDuration = (seconds: number): string => {
if (seconds === 0) return '00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// 格式化分辨率
const formatResolution = (width: number, height: number): string => {
if (width === 0 || height === 0) return '未知';
return `${width}×${height}`;
};
// 格式化帧率
const formatFPS = (fps: number): string => {
if (fps === 0) return '未知';
return `${fps.toFixed(1)} fps`;
};
return (
<div className="bg-white rounded-lg shadow-sm border">
{/* 标题栏 */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<Video className="w-5 h-5 text-blue-600" />
<h3 className="font-semibold text-gray-900"></h3>
{isScanning && (
<RefreshCw className="w-4 h-4 text-blue-500 animate-spin" />
)}
</div>
<div className="text-sm text-gray-500">
{videos.length}
</div>
</div>
{/* 文件列表 */}
<div className="divide-y divide-gray-100 max-h-96 overflow-y-auto">
{isScanning && videos.length === 0 && (
<div className="p-8 text-center">
<RefreshCw className="w-8 h-8 text-blue-500 animate-spin mx-auto mb-3" />
<p className="text-gray-500">...</p>
</div>
)}
{!isScanning && videos.length === 0 && (
<div className="p-8 text-center">
<Video className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 mb-2"></p>
<p className="text-sm text-gray-400"></p>
</div>
)}
{videos.map((video, index) => (
<div key={`${video.path}-${index}`} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3">
{/* 视频信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
{video.is_valid ? (
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
)}
<h4 className="font-medium text-gray-900 truncate" title={video.filename}>
{video.filename}
</h4>
</div>
{video.is_valid ? (
<div className="grid grid-cols-2 gap-2 text-xs text-gray-500">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{formatDuration(video.duration)}</span>
</div>
<div className="flex items-center gap-1">
<Monitor className="w-3 h-3" />
<span>{formatResolution(video.width, video.height)}</span>
</div>
<div className="flex items-center gap-1">
<HardDrive className="w-3 h-3" />
<span>{formatFileSize(video.size)}</span>
</div>
<div className="flex items-center gap-1">
<Video className="w-3 h-3" />
<span>{formatFPS(video.fps)}</span>
</div>
</div>
) : (
<div className="text-xs text-red-500">
{video.error_message || '无法读取视频信息'}
</div>
)}
<div className="mt-2 text-xs text-gray-400 truncate" title={video.path}>
{video.path}
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1 flex-shrink-0">
{video.is_valid && onPreviewVideo && (
<button
onClick={() => onPreviewVideo(video)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="预览"
>
<Eye className="w-4 h-4" />
</button>
)}
<button
onClick={() => onRemoveVideo(index)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="移除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* 视频格式和编码信息 */}
{video.is_valid && video.format && (
<div className="mt-2 flex items-center gap-2">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{video.format.toUpperCase()}
</span>
</div>
)}
</div>
))}
</div>
{/* 统计信息 */}
{videos.length > 0 && (
<div className="p-4 border-t bg-gray-50">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500"></span>
<span className="font-medium text-green-600 ml-1">
{videos.filter(v => v.is_valid).length}
</span>
</div>
<div>
<span className="text-gray-500"></span>
<span className="font-medium text-red-600 ml-1">
{videos.filter(v => !v.is_valid).length}
</span>
</div>
<div>
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900 ml-1">
{formatDuration(videos.reduce((total, v) => total + (v.is_valid ? v.duration : 0), 0))}
</span>
</div>
<div>
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900 ml-1">
{formatFileSize(videos.reduce((total, v) => total + v.size, 0))}
</span>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,283 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { FrameExtractionConfigPanel } from '../FrameExtractionConfigPanel';
import {
FrameExtractionConfig,
FrameType,
ImageFormat,
DEFAULT_FRAME_EXTRACTION_CONFIG,
FRAME_EXTRACTION_PRESETS
} from '../../../types/frame-extractor';
// Mock Tauri API
vi.mock('@tauri-apps/plugin-dialog', () => ({
open: vi.fn(),
}));
describe('FrameExtractionConfigPanel', () => {
const mockOnConfigChange = vi.fn();
const mockOnApplyPreset = vi.fn();
const defaultProps = {
config: DEFAULT_FRAME_EXTRACTION_CONFIG,
onConfigChange: mockOnConfigChange,
onApplyPreset: mockOnApplyPreset,
presets: FRAME_EXTRACTION_PRESETS,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders configuration panel with all sections', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
expect(screen.getByText('提取配置')).toBeInTheDocument();
expect(screen.getByText('预设配置')).toBeInTheDocument();
expect(screen.getByText('提取类型')).toBeInTheDocument();
expect(screen.getByText('输出格式')).toBeInTheDocument();
expect(screen.getByText('图片质量')).toBeInTheDocument();
expect(screen.getByText('输出尺寸')).toBeInTheDocument();
expect(screen.getByText('输出目录')).toBeInTheDocument();
expect(screen.getByText('文件名模式')).toBeInTheDocument();
});
it('displays preset configurations', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
FRAME_EXTRACTION_PRESETS.forEach(preset => {
expect(screen.getByText(preset.name)).toBeInTheDocument();
expect(screen.getByText(preset.description)).toBeInTheDocument();
});
});
it('calls onApplyPreset when preset is clicked', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
const firstPreset = FRAME_EXTRACTION_PRESETS[0];
const presetButton = screen.getByText(firstPreset.name);
fireEvent.click(presetButton);
expect(mockOnApplyPreset).toHaveBeenCalledWith(firstPreset.id);
});
it('displays frame type options', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
expect(screen.getByText('第一帧')).toBeInTheDocument();
expect(screen.getByText('最后一帧')).toBeInTheDocument();
expect(screen.getByText('自定义时间')).toBeInTheDocument();
expect(screen.getByText('多个时间点')).toBeInTheDocument();
});
it('calls onConfigChange when frame type is changed', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
const lastFrameButton = screen.getByText('最后一帧');
fireEvent.click(lastFrameButton);
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({
frame_type: FrameType.Last,
custom_time: undefined,
time_points: undefined,
})
);
});
it('shows custom time input when Custom frame type is selected', () => {
const customConfig: FrameExtractionConfig = {
...DEFAULT_FRAME_EXTRACTION_CONFIG,
frame_type: FrameType.Custom,
custom_time: 15.5,
};
render(
<FrameExtractionConfigPanel
{...defaultProps}
config={customConfig}
/>
);
const timeInput = screen.getByDisplayValue('15.5');
expect(timeInput).toBeInTheDocument();
expect(timeInput).toHaveAttribute('type', 'number');
});
it('shows multiple time points input when Multiple frame type is selected', () => {
const multipleConfig: FrameExtractionConfig = {
...DEFAULT_FRAME_EXTRACTION_CONFIG,
frame_type: FrameType.Multiple,
};
render(
<FrameExtractionConfigPanel
{...defaultProps}
config={multipleConfig}
/>
);
expect(screen.getByText('多个时间点')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/输入时间点,用逗号分隔/)).toBeInTheDocument();
});
it('displays image format options', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
Object.values(ImageFormat).forEach(format => {
expect(screen.getByText(format.toUpperCase())).toBeInTheDocument();
});
});
it('calls onConfigChange when image format is changed', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
const pngButton = screen.getByText('PNG');
fireEvent.click(pngButton);
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({
output_format: expect.objectContaining({
format: ImageFormat.Png,
}),
})
);
});
it('updates quality when slider is moved', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
const qualitySlider = screen.getByRole('slider');
fireEvent.change(qualitySlider, { target: { value: '95' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({
output_format: expect.objectContaining({
quality: 95,
}),
})
);
});
it('updates output size when width/height inputs change', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
const widthInput = screen.getByPlaceholderText('自动');
fireEvent.change(widthInput, { target: { value: '1280' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({
output_size: expect.objectContaining({
width: 1280,
}),
})
);
});
it('updates filename pattern when input changes', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
const filenameInput = screen.getByDisplayValue(DEFAULT_FRAME_EXTRACTION_CONFIG.filename_pattern);
fireEvent.change(filenameInput, { target: { value: '{name}_{timestamp}' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({
filename_pattern: '{name}_{timestamp}',
})
);
});
it('toggles overwrite existing checkbox', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
const overwriteCheckbox = screen.getByLabelText('覆盖已存在的文件');
fireEvent.click(overwriteCheckbox);
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({
overwrite_existing: true,
})
);
});
it('toggles maintain aspect ratio checkbox', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
const aspectRatioCheckbox = screen.getByLabelText('保持宽高比');
fireEvent.click(aspectRatioCheckbox);
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({
output_size: expect.objectContaining({
maintain_aspect_ratio: false,
}),
})
);
});
it('handles custom time input changes', () => {
const customConfig: FrameExtractionConfig = {
...DEFAULT_FRAME_EXTRACTION_CONFIG,
frame_type: FrameType.Custom,
custom_time: 0,
};
render(
<FrameExtractionConfigPanel
{...defaultProps}
config={customConfig}
/>
);
const timeInput = screen.getByDisplayValue('0');
fireEvent.change(timeInput, { target: { value: '25.5' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({
custom_time: 25.5,
})
);
});
it('handles multiple time points input', () => {
const multipleConfig: FrameExtractionConfig = {
...DEFAULT_FRAME_EXTRACTION_CONFIG,
frame_type: FrameType.Multiple,
};
render(
<FrameExtractionConfigPanel
{...defaultProps}
config={multipleConfig}
/>
);
const timePointsInput = screen.getByPlaceholderText(/输入时间点,用逗号分隔/);
fireEvent.change(timePointsInput, { target: { value: '10, 50%, 1000f' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({
time_points: [
{ type: 'seconds', value: 10 },
{ type: 'percentage', value: 0.5 },
{ type: 'frame', value: 1000 },
],
})
);
});
it('updates output directory when input changes', () => {
render(<FrameExtractionConfigPanel {...defaultProps} />);
const directoryInput = screen.getByPlaceholderText('选择输出目录');
fireEvent.change(directoryInput, { target: { value: '/new/output/path' } });
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({
output_directory: '/new/output/path',
})
);
});
});

View File

@@ -0,0 +1,234 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { VideoFileList } from '../VideoFileList';
import { VideoFileInfo } from '../../../types/frame-extractor';
describe('VideoFileList', () => {
const mockOnRemoveVideo = vi.fn();
const mockOnPreviewVideo = vi.fn();
const validVideoFile: VideoFileInfo = {
path: '/test/video1.mp4',
filename: 'video1.mp4',
size: 1024 * 1024 * 100, // 100MB
duration: 120.5, // 2 minutes 30.5 seconds
width: 1920,
height: 1080,
fps: 30.0,
format: 'mp4',
is_valid: true,
error_message: undefined,
};
const invalidVideoFile: VideoFileInfo = {
path: '/test/invalid.mp4',
filename: 'invalid.mp4',
size: 1024,
duration: 0,
width: 0,
height: 0,
fps: 0,
format: '',
is_valid: false,
error_message: '无法读取视频信息',
};
const defaultProps = {
videos: [validVideoFile, invalidVideoFile],
isScanning: false,
onRemoveVideo: mockOnRemoveVideo,
onPreviewVideo: mockOnPreviewVideo,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders video file list with header', () => {
render(<VideoFileList {...defaultProps} />);
expect(screen.getByText('视频文件列表')).toBeInTheDocument();
expect(screen.getByText('2 个文件')).toBeInTheDocument();
});
it('displays valid video file information correctly', () => {
render(<VideoFileList {...defaultProps} />);
expect(screen.getByText('video1.mp4')).toBeInTheDocument();
expect(screen.getByText('02:00')).toBeInTheDocument(); // Duration formatted
expect(screen.getByText('1920×1080')).toBeInTheDocument(); // Resolution
expect(screen.getByText('100.00 MB')).toBeInTheDocument(); // File size
expect(screen.getByText('30.0 fps')).toBeInTheDocument(); // Frame rate
expect(screen.getByText('MP4')).toBeInTheDocument(); // Format badge
});
it('displays invalid video file with error message', () => {
render(<VideoFileList {...defaultProps} />);
expect(screen.getByText('invalid.mp4')).toBeInTheDocument();
expect(screen.getByText('无法读取视频信息')).toBeInTheDocument();
});
it('shows scanning state when isScanning is true', () => {
render(<VideoFileList {...defaultProps} isScanning={true} />);
expect(screen.getByText('正在扫描视频文件...')).toBeInTheDocument();
});
it('shows empty state when no videos and not scanning', () => {
render(
<VideoFileList
{...defaultProps}
videos={[]}
isScanning={false}
/>
);
expect(screen.getByText('暂无视频文件')).toBeInTheDocument();
expect(screen.getByText('请选择视频文件或文件夹')).toBeInTheDocument();
});
it('calls onRemoveVideo when remove button is clicked', () => {
render(<VideoFileList {...defaultProps} />);
const removeButtons = screen.getAllByTitle('移除');
fireEvent.click(removeButtons[0]);
expect(mockOnRemoveVideo).toHaveBeenCalledWith(0);
});
it('calls onPreviewVideo when preview button is clicked for valid video', () => {
render(<VideoFileList {...defaultProps} />);
const previewButton = screen.getByTitle('预览');
fireEvent.click(previewButton);
expect(mockOnPreviewVideo).toHaveBeenCalledWith(validVideoFile);
});
it('does not show preview button for invalid video', () => {
render(
<VideoFileList
{...defaultProps}
videos={[invalidVideoFile]}
/>
);
expect(screen.queryByTitle('预览')).not.toBeInTheDocument();
});
it('displays statistics correctly', () => {
render(<VideoFileList {...defaultProps} />);
expect(screen.getByText('有效文件:')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument(); // 1 valid file
expect(screen.getByText('无效文件:')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument(); // 1 invalid file
expect(screen.getByText('总时长:')).toBeInTheDocument();
expect(screen.getByText('02:00')).toBeInTheDocument(); // Only valid video duration
expect(screen.getByText('总大小:')).toBeInTheDocument();
});
it('formats file sizes correctly', () => {
const testVideos: VideoFileInfo[] = [
{ ...validVideoFile, size: 1024, filename: 'small.mp4' }, // 1 KB
{ ...validVideoFile, size: 1024 * 1024, filename: 'medium.mp4' }, // 1 MB
{ ...validVideoFile, size: 1024 * 1024 * 1024, filename: 'large.mp4' }, // 1 GB
];
render(
<VideoFileList
{...defaultProps}
videos={testVideos}
/>
);
expect(screen.getByText('1.00 KB')).toBeInTheDocument();
expect(screen.getByText('1.00 MB')).toBeInTheDocument();
expect(screen.getByText('1.00 GB')).toBeInTheDocument();
});
it('formats duration correctly for different lengths', () => {
const testVideos: VideoFileInfo[] = [
{ ...validVideoFile, duration: 45, filename: 'short.mp4' }, // 45 seconds
{ ...validVideoFile, duration: 125, filename: 'medium.mp4' }, // 2:05
{ ...validVideoFile, duration: 3665, filename: 'long.mp4' }, // 1:01:05
];
render(
<VideoFileList
{...defaultProps}
videos={testVideos}
/>
);
expect(screen.getByText('00:45')).toBeInTheDocument();
expect(screen.getByText('02:05')).toBeInTheDocument();
expect(screen.getByText('01:01:05')).toBeInTheDocument();
});
it('handles zero values gracefully', () => {
const zeroVideo: VideoFileInfo = {
...validVideoFile,
duration: 0,
width: 0,
height: 0,
fps: 0,
size: 0,
};
render(
<VideoFileList
{...defaultProps}
videos={[zeroVideo]}
/>
);
expect(screen.getByText('00:00')).toBeInTheDocument();
expect(screen.getByText('未知')).toBeInTheDocument(); // Resolution
expect(screen.getByText('未知')).toBeInTheDocument(); // FPS
expect(screen.getByText('0 B')).toBeInTheDocument();
});
it('truncates long file paths with title attribute', () => {
const longPathVideo: VideoFileInfo = {
...validVideoFile,
path: '/very/long/path/to/video/file/that/should/be/truncated/video.mp4',
};
render(
<VideoFileList
{...defaultProps}
videos={[longPathVideo]}
/>
);
const pathElement = screen.getByTitle(longPathVideo.path);
expect(pathElement).toBeInTheDocument();
expect(pathElement).toHaveClass('truncate');
});
it('shows correct file count in header', () => {
render(<VideoFileList {...defaultProps} />);
expect(screen.getByText('2 个文件')).toBeInTheDocument();
render(
<VideoFileList
{...defaultProps}
videos={[]}
/>
);
expect(screen.getByText('0 个文件')).toBeInTheDocument();
});
it('applies hover effects on video items', () => {
render(<VideoFileList {...defaultProps} />);
const videoItems = screen.getAllByText('video1.mp4').map(el =>
el.closest('.hover\\:bg-gray-50')
).filter(Boolean);
expect(videoItems.length).toBeGreaterThan(0);
});
});

View File

@@ -9,6 +9,7 @@ import {
Wand2,
FileText,
ImagePlus,
Frame,
} from 'lucide-react';
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
@@ -47,6 +48,21 @@ export const TOOLS_DATA: Tool[] = [
version: '1.0.0',
lastUpdated: '2024-01-29'
},
{
id: 'frame-extractor',
name: '视频关键帧提取工具',
description: '快速提取视频的第一帧、最后帧或自定义位置的帧,支持批量处理和多种输出格式',
longDescription: '专业的视频关键帧提取工具,基于 FFmpeg 提供高质量的帧提取功能。支持提取第一帧、最后帧、自定义时间点或多个时间点的帧。提供多种图片格式输出JPEG、PNG、WebP、BMP支持自定义输出尺寸和质量设置。具备批量处理能力可以处理单个文件或整个文件夹支持递归扫描。提供实时预览功能帮助用户精确选择提取时间点。适用于视频缩略图生成、关键帧分析、内容预览等多种场景。',
icon: Frame,
route: '/tools/frame-extractor',
category: ToolCategory.FILE_PROCESSING,
status: ToolStatus.STABLE,
tags: ['视频处理', '帧提取', '缩略图生成', '批量处理', 'FFmpeg', '图片导出'],
isNew: true,
isPopular: true,
version: '1.0.0',
lastUpdated: '2025-08-11'
},
{
id: 'voice-clone',
name: '声音克隆与TTS工具',

View File

@@ -0,0 +1,393 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Image,
FolderOpen,
Play,
Settings,
Clock,
List,
Grid,
RefreshCw,
Download,
Eye,
Trash2,
CheckCircle,
AlertCircle,
Info
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
import {
FrameExtractionConfig,
VideoFileInfo,
FrameExtractionResult,
FrameType,
ImageFormat,
ExtractionTaskStatus,
DEFAULT_FRAME_EXTRACTION_CONFIG,
SUPPORTED_VIDEO_FORMATS,
FRAME_EXTRACTION_PRESETS
} from '../../types/frame-extractor';
import { FrameExtractionConfigPanel } from '../../components/frame-extractor/FrameExtractionConfigPanel';
import { VideoFileList } from '../../components/frame-extractor/VideoFileList';
import { ExtractionProgress } from '../../components/frame-extractor/ExtractionProgress';
import { FramePreview } from '../../components/frame-extractor/FramePreview';
import { ExtractionResults } from '../../components/frame-extractor/ExtractionResults';
/**
* 视频关键帧提取工具主组件
* 遵循 Tauri 开发规范和 UI/UX 设计标准
*/
const FrameExtractorTool: React.FC = () => {
// 状态管理
const [selectedVideos, setSelectedVideos] = useState<VideoFileInfo[]>([]);
const [config, setConfig] = useState<FrameExtractionConfig>(DEFAULT_FRAME_EXTRACTION_CONFIG);
const [isExtracting, setIsExtracting] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [extractionResults, setExtractionResults] = useState<FrameExtractionResult[]>([]);
const [currentProgress, setCurrentProgress] = useState(0);
const [currentFile, setCurrentFile] = useState<string>('');
const [viewMode, setViewMode] = useState<'config' | 'progress' | 'results'>('config');
const [selectedFolder, setSelectedFolder] = useState<string>('');
const [ffmpegAvailable, setFfmpegAvailable] = useState<boolean | null>(null);
// 检查 FFmpeg 可用性
useEffect(() => {
checkFFmpegAvailability();
}, []);
const checkFFmpegAvailability = useCallback(async () => {
try {
const available = await invoke<boolean>('check_ffmpeg_availability');
setFfmpegAvailable(available);
if (!available) {
console.warn('FFmpeg 不可用,某些功能可能无法正常工作');
}
} catch (error) {
console.error('检查 FFmpeg 可用性失败:', error);
setFfmpegAvailable(false);
}
}, []);
// 选择单个视频文件
const handleSelectFiles = useCallback(async () => {
try {
const selected = await open({
multiple: true,
filters: [{
name: '视频文件',
extensions: SUPPORTED_VIDEO_FORMATS
}],
title: '选择视频文件',
});
if (selected && Array.isArray(selected)) {
await loadVideoFiles(selected);
} else if (selected && typeof selected === 'string') {
await loadVideoFiles([selected]);
}
} catch (error) {
console.error('选择文件失败:', error);
}
}, []);
// 选择文件夹
const handleSelectFolder = useCallback(async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: '选择视频文件夹',
});
if (selected && typeof selected === 'string') {
setSelectedFolder(selected);
await scanVideoFiles(selected, false);
}
} catch (error) {
console.error('选择文件夹失败:', error);
}
}, []);
// 扫描视频文件
const scanVideoFiles = useCallback(async (folderPath: string, recursive: boolean) => {
setIsScanning(true);
try {
const files = await invoke<VideoFileInfo[]>('scan_video_files', {
request: {
folder_path: folderPath,
recursive,
supported_formats: SUPPORTED_VIDEO_FORMATS,
}
});
setSelectedVideos(files.filter(f => f.is_valid));
} catch (error) {
console.error('扫描视频文件失败:', error);
} finally {
setIsScanning(false);
}
}, []);
// 加载视频文件信息
const loadVideoFiles = useCallback(async (filePaths: string[]) => {
setIsScanning(true);
const videoInfos: VideoFileInfo[] = [];
try {
for (const filePath of filePaths) {
try {
const info = await invoke<VideoFileInfo>('get_video_info', {
videoPath: filePath
});
videoInfos.push(info);
} catch (error) {
console.error(`获取视频信息失败 ${filePath}:`, error);
}
}
setSelectedVideos(videoInfos.filter(info => info.is_valid));
} finally {
setIsScanning(false);
}
}, []);
// 开始提取
const handleStartExtraction = useCallback(async () => {
if (selectedVideos.length === 0) {
alert('请先选择视频文件');
return;
}
if (!config.output_directory) {
alert('请设置输出目录');
return;
}
setIsExtracting(true);
setViewMode('progress');
setCurrentProgress(0);
setExtractionResults([]);
try {
const videoPaths = selectedVideos.map(v => v.path);
const results = await invoke<FrameExtractionResult[]>('extract_frames_batch', {
request: {
video_files: videoPaths,
config: config,
}
});
setExtractionResults(results);
setViewMode('results');
const successCount = results.filter(r => r.success).length;
console.log(`提取完成: ${successCount}/${results.length} 成功`);
} catch (error) {
console.error('批量提取失败:', error);
alert(`提取失败: ${error}`);
} finally {
setIsExtracting(false);
}
}, [selectedVideos, config]);
// 清除选择
const handleClearSelection = useCallback(() => {
setSelectedVideos([]);
setSelectedFolder('');
setExtractionResults([]);
setCurrentProgress(0);
setCurrentFile('');
}, []);
// 应用预设配置
const handleApplyPreset = useCallback((presetId: string) => {
const preset = FRAME_EXTRACTION_PRESETS.find(p => p.id === presetId);
if (preset) {
setConfig({ ...preset.config });
}
}, []);
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>
{/* FFmpeg 状态指示器 */}
<div className="flex items-center gap-2">
{ffmpegAvailable === null ? (
<div className="flex items-center gap-2 text-gray-500">
<RefreshCw className="w-4 h-4 animate-spin" />
<span className="text-sm">...</span>
</div>
) : ffmpegAvailable ? (
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="w-4 h-4" />
<span className="text-sm">FFmpeg </span>
</div>
) : (
<div className="flex items-center gap-2 text-red-600">
<AlertCircle className="w-4 h-4" />
<span className="text-sm">FFmpeg </span>
</div>
)}
</div>
</div>
{/* 工具栏 */}
<div className="flex items-center justify-between bg-white rounded-lg p-4 shadow-sm border">
<div className="flex items-center gap-3">
<button
onClick={handleSelectFiles}
disabled={isScanning || isExtracting}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Image className="w-4 h-4" />
</button>
<button
onClick={handleSelectFolder}
disabled={isScanning || isExtracting}
className="flex items-center gap-2 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<FolderOpen className="w-4 h-4" />
</button>
<button
onClick={handleClearSelection}
disabled={isScanning || isExtracting}
className="flex items-center gap-2 px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-3">
<div className="text-sm text-gray-600">
{selectedVideos.length}
</div>
<button
onClick={handleStartExtraction}
disabled={selectedVideos.length === 0 || isExtracting || !ffmpegAvailable}
className="flex items-center gap-2 px-6 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isExtracting ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Play className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
{/* 视图切换 */}
<div className="flex items-center gap-2 bg-white rounded-lg p-1 shadow-sm border w-fit">
<button
onClick={() => setViewMode('config')}
className={`px-4 py-2 rounded-md transition-colors ${
viewMode === 'config'
? 'bg-blue-100 text-blue-600'
: 'text-gray-600 hover:text-gray-800'
}`}
>
<Settings className="w-4 h-4 inline mr-2" />
</button>
<button
onClick={() => setViewMode('progress')}
className={`px-4 py-2 rounded-md transition-colors ${
viewMode === 'progress'
? 'bg-blue-100 text-blue-600'
: 'text-gray-600 hover:text-gray-800'
}`}
>
<Clock className="w-4 h-4 inline mr-2" />
</button>
<button
onClick={() => setViewMode('results')}
className={`px-4 py-2 rounded-md transition-colors ${
viewMode === 'results'
? 'bg-blue-100 text-blue-600'
: 'text-gray-600 hover:text-gray-800'
}`}
>
<List className="w-4 h-4 inline mr-2" />
</button>
</div>
{/* 主要内容区域 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:视频文件列表 */}
<div className="lg:col-span-1">
<VideoFileList
videos={selectedVideos}
isScanning={isScanning}
onRemoveVideo={(index) => {
const newVideos = [...selectedVideos];
newVideos.splice(index, 1);
setSelectedVideos(newVideos);
}}
/>
</div>
{/* 右侧:主要内容 */}
<div className="lg:col-span-2">
{viewMode === 'config' && (
<FrameExtractionConfigPanel
config={config}
onConfigChange={setConfig}
onApplyPreset={handleApplyPreset}
presets={FRAME_EXTRACTION_PRESETS}
/>
)}
{viewMode === 'progress' && (
<ExtractionProgress
isExtracting={isExtracting}
progress={currentProgress}
currentFile={currentFile}
totalFiles={selectedVideos.length}
/>
)}
{viewMode === 'results' && (
<ExtractionResults
results={extractionResults}
onOpenResult={(result) => {
// 打开结果文件所在目录
invoke('open_file_directory', { path: result.output_path });
}}
/>
)}
</div>
</div>
</div>
);
};
export default FrameExtractorTool;

View File

@@ -0,0 +1,234 @@
/**
* 视频关键帧提取工具类型定义
* 遵循 Tauri 开发规范的类型系统设计
*/
// 帧提取类型
export enum FrameType {
/** 第一帧 */
First = 'First',
/** 最后一帧 */
Last = 'Last',
/** 自定义时间点 */
Custom = 'Custom',
/** 多个时间点 */
Multiple = 'Multiple',
}
// 时间点配置
export type TimePoint =
| { type: 'seconds'; value: number } // 秒数
| { type: 'percentage'; value: number } // 百分比 (0.0-1.0)
| { type: 'frame'; value: number }; // 帧数
// 图片格式
export enum ImageFormat {
Jpg = 'jpg',
Png = 'png',
WebP = 'webp',
Bmp = 'bmp',
}
// 图片质量设置
export interface ImageQuality {
format: ImageFormat;
quality: number; // 1-100 for JPEG, 0-9 for PNG
compression_level?: number; // WebP compression level
}
// 输出尺寸配置
export interface OutputSize {
width?: number;
height?: number;
maintain_aspect_ratio: boolean;
scale_filter?: 'lanczos' | 'bicubic' | 'bilinear' | 'nearest';
}
// 帧提取配置
export interface FrameExtractionConfig {
frame_type: FrameType;
time_points?: TimePoint[];
custom_time?: number; // 自定义时间点(秒)
output_format: ImageQuality;
output_size?: OutputSize;
output_directory: string;
filename_pattern: string; // 支持变量: {name}, {timestamp}, {frame_type}, {index}
overwrite_existing: boolean;
}
// 视频文件信息
export interface VideoFileInfo {
path: string;
filename: string;
size: number;
duration: number;
width: number;
height: number;
fps: number;
format: string;
is_valid: boolean;
error_message?: string;
}
// 提取任务状态
export enum ExtractionTaskStatus {
Pending = 'Pending',
Running = 'Running',
Completed = 'Completed',
Failed = 'Failed',
Cancelled = 'Cancelled',
}
// 单个帧提取结果
export interface FrameExtractionResult {
video_path: string;
frame_type: FrameType;
timestamp: number;
output_path: string;
success: boolean;
error_message?: string;
processing_time_ms: number;
file_size: number;
}
// 批量提取任务
export interface BatchExtractionTask {
task_id: string;
video_files: VideoFileInfo[];
config: FrameExtractionConfig;
status: ExtractionTaskStatus;
progress: BatchExtractionProgress;
results: FrameExtractionResult[];
created_at: string;
started_at?: string;
completed_at?: string;
error_message?: string;
}
// 批量提取进度
export interface BatchExtractionProgress {
total_files: number;
processed_files: number;
failed_files: number;
current_file?: string;
current_frame_type?: FrameType;
progress_percentage: number;
estimated_remaining_time?: number; // 秒
}
// 提取统计信息
export interface ExtractionStatistics {
total_videos_processed: number;
total_frames_extracted: number;
total_processing_time_ms: number;
success_rate: number;
average_processing_time_per_video: number;
file_size_distribution: {
format: ImageFormat;
count: number;
total_size: number;
average_size: number;
}[];
}
// 预设配置
export interface FrameExtractionPreset {
id: string;
name: string;
description: string;
config: FrameExtractionConfig;
is_default: boolean;
created_at: string;
}
// 文件夹扫描请求
export interface FolderScanRequest {
folder_path: string;
recursive: boolean;
supported_formats: string[];
}
// 帧提取请求
export interface FrameExtractionRequest {
video_files: string[];
config: FrameExtractionConfig;
}
// 预览请求
export interface FramePreviewRequest {
video_path: string;
timestamp: number;
output_size?: OutputSize;
}
// 预览结果
export interface FramePreviewResult {
success: boolean;
preview_data?: string; // Base64 encoded image
error_message?: string;
timestamp: number;
original_size: [number, number];
preview_size: [number, number];
}
// 默认配置
export const DEFAULT_FRAME_EXTRACTION_CONFIG: FrameExtractionConfig = {
frame_type: FrameType.First,
output_format: {
format: ImageFormat.Jpg,
quality: 85,
},
output_size: {
maintain_aspect_ratio: true,
scale_filter: 'lanczos',
},
output_directory: '',
filename_pattern: '{name}_{frame_type}',
overwrite_existing: false,
};
// 支持的视频格式
export const SUPPORTED_VIDEO_FORMATS = [
'mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'webm', 'm4v', '3gp', 'ts', 'mts'
];
// 预设配置列表
export const FRAME_EXTRACTION_PRESETS: FrameExtractionPreset[] = [
{
id: 'first-frame-hq',
name: '第一帧高质量',
description: '提取视频第一帧高质量JPEG格式',
config: {
...DEFAULT_FRAME_EXTRACTION_CONFIG,
frame_type: FrameType.First,
output_format: { format: ImageFormat.Jpg, quality: 95 },
},
is_default: true,
created_at: new Date().toISOString(),
},
{
id: 'last-frame-png',
name: '最后一帧PNG',
description: '提取视频最后一帧PNG格式保持透明度',
config: {
...DEFAULT_FRAME_EXTRACTION_CONFIG,
frame_type: FrameType.Last,
output_format: { format: ImageFormat.Png, quality: 9 },
},
is_default: false,
created_at: new Date().toISOString(),
},
{
id: 'middle-frame-webp',
name: '中间帧WebP',
description: '提取视频中间帧WebP格式压缩',
config: {
...DEFAULT_FRAME_EXTRACTION_CONFIG,
frame_type: FrameType.Custom,
custom_time: 0, // 将在运行时计算为视频长度的50%
output_format: { format: ImageFormat.WebP, quality: 80, compression_level: 6 },
},
is_default: false,
created_at: new Date().toISOString(),
},
];

View File

@@ -0,0 +1,207 @@
# 视频关键帧提取工具使用指南
## 概述
视频关键帧提取工具是一个专业的视频处理工具,基于 FFmpeg 提供高质量的帧提取功能。支持提取视频的第一帧、最后帧、自定义时间点或多个时间点的帧,并提供多种输出格式和批量处理能力。
## 主要功能
### 🎯 核心功能
- **多种提取模式**:第一帧、最后帧、自定义时间点、多时间点批量提取
- **多格式支持**JPEG、PNG、WebP、BMP 等主流图片格式
- **批量处理**:支持单文件或整个文件夹的批量处理
- **实时预览**:提供帧预览功能,精确选择提取时间点
- **自定义配置**:灵活的输出尺寸、质量和文件命名设置
### 📁 支持的视频格式
- MP4、AVI、MOV、MKV、WMV、FLV、WebM
- M4V、3GP、TS、MTS 等常见视频格式
## 快速开始
### 1. 打开工具
在主界面点击"便捷小工具" → 找到"视频关键帧提取工具"并点击进入。
### 2. 选择视频文件
有两种方式选择视频文件:
- **选择文件**:点击"选择文件"按钮,选择一个或多个视频文件
- **选择文件夹**:点击"选择文件夹"按钮,选择包含视频文件的文件夹
### 3. 配置提取参数
在配置面板中设置:
- **提取类型**:选择第一帧、最后帧、自定义时间或多时间点
- **输出格式**:选择 JPEG、PNG、WebP 或 BMP
- **图片质量**调整输出图片的质量1-100
- **输出目录**:设置保存提取帧的目录
### 4. 开始提取
点击"开始提取"按钮,工具将自动处理所有选中的视频文件。
## 详细功能说明
### 提取类型
#### 第一帧
提取视频的第一帧,通常用于生成视频缩略图。
#### 最后帧
提取视频的最后一帧,适用于需要展示视频结尾内容的场景。
#### 自定义时间
指定具体的时间点(秒)提取帧,支持小数点精度。
#### 多个时间点
支持同时提取多个时间点的帧,时间点格式支持:
- **秒数**`10, 30, 60`提取第10秒、30秒、60秒的帧
- **百分比**`25%, 50%, 75%`提取视频25%、50%、75%位置的帧)
- **帧数**`300f, 900f, 1800f`提取第300、900、1800帧
### 输出格式设置
#### JPEG
- 适用于大多数场景
- 文件体积小
- 支持质量调节1-100
- 不支持透明度
#### PNG
- 支持透明度
- 无损压缩
- 文件体积较大
- 适用于需要透明背景的场景
#### WebP
- 现代图片格式
- 压缩效率高
- 支持透明度
- 文件体积小
#### BMP
- 无压缩格式
- 文件体积最大
- 兼容性最好
### 输出尺寸配置
#### 自动尺寸
保持视频原始分辨率。
#### 自定义尺寸
- **宽度**:设置输出图片宽度(像素)
- **高度**:设置输出图片高度(像素)
- **保持宽高比**:勾选后会自动调整尺寸以保持原始宽高比
### 文件命名模式
支持使用变量自定义文件名:
- `{name}`:视频文件名(不含扩展名)
- `{frame_type}`帧类型first、last、custom、frame
- `{timestamp}`:时间戳(格式:秒.毫秒)
- `{index}`索引号多帧提取时使用格式001、002...
**示例**
- `{name}_{frame_type}``video_first.jpg`
- `{name}_{timestamp}``video_15.50.jpg`
- `{name}_{frame_type}_{index}``video_frame_001.jpg`
## 预设配置
工具提供了几个常用的预设配置:
### 第一帧高质量
- 提取类型:第一帧
- 格式JPEG
- 质量95
- 适用于生成高质量缩略图
### 最后一帧PNG
- 提取类型:最后一帧
- 格式PNG
- 适用于需要透明度支持的场景
### 中间帧WebP
- 提取类型自定义时间50%位置)
- 格式WebP
- 质量80
- 适用于现代Web应用
## 批量处理
### 文件夹处理
1. 点击"选择文件夹"
2. 选择包含视频文件的文件夹
3. 工具会自动扫描并列出所有支持的视频文件
4. 配置提取参数
5. 点击"开始提取"进行批量处理
### 进度监控
批量处理时可以查看:
- 总体进度百分比
- 当前处理的文件
- 处理速度
- 成功/失败统计
- 预计剩余时间
## 结果管理
### 查看结果
提取完成后,在"结果"标签页可以:
- 查看所有提取结果
- 筛选成功/失败的结果
- 搜索特定文件
- 预览提取的帧
### 结果操作
对于每个提取结果,可以:
- **预览**:在应用内查看提取的帧
- **下载**:保存到指定位置
- **打开位置**:在文件管理器中打开文件所在目录
## 故障排除
### 常见问题
#### FFmpeg 不可用
**现象**:工具显示"FFmpeg 不可用"
**解决方案**
1. 工具会自动下载 FFmpeg请等待下载完成
2. 检查网络连接是否正常
3. 重启应用重试
#### 视频文件无法识别
**现象**:视频文件显示为"无效"
**可能原因**
- 视频文件损坏
- 不支持的视频格式
- 文件权限问题
#### 提取失败
**现象**:提取过程中出现错误
**解决方案**
1. 检查输出目录是否有写入权限
2. 确保磁盘空间充足
3. 检查视频文件是否完整
### 性能优化建议
1. **批量处理**:一次处理多个文件比逐个处理更高效
2. **输出格式**JPEG 格式处理速度最快
3. **输出尺寸**:较小的输出尺寸处理速度更快
4. **磁盘空间**:确保输出目录有足够的磁盘空间
## 技术规格
- **基于技术**FFmpeg + ffmpeg-sidecar
- **支持平台**Windows、macOS、Linux
- **最大文件大小**:无限制(取决于系统内存)
- **并发处理**:支持多文件并行处理
- **输出精度**:支持毫秒级时间精度
## 更新日志
### v1.0.0 (2025-08-11)
- 初始版本发布
- 支持基本的帧提取功能
- 提供多种输出格式
- 实现批量处理能力
- 添加实时预览功能