feat: 实现视频关键帧提取工具
- 新增关键帧提取功能,支持提取视频的第一帧、最后一帧、自定义时间点和多个时间点 - 添加完整的前端UI组件,包括配置面板、文件列表、预览和进度显示 - 实现后端Rust服务,集成FFmpeg进行视频处理 - 支持多种输出格式(JPG/PNG/WebP/BMP)和质量设置 - 支持输出尺寸调整和宽高比保持 - 添加批量处理功能和进度跟踪 - 修复TypeScript类型错误和Rust编译问题 - 添加用户指南文档和单元测试
This commit is contained in:
252
Cargo.lock
generated
252
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
973
apps/desktop/src-tauri/src/infrastructure/frame_extractor.rs
Normal file
973
apps/desktop/src-tauri/src/infrastructure/frame_extractor.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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, "扫描文件夹中的视频文件");
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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="输入时间点,用逗号分隔 支持格式: - 秒数:1.5, 10, 30 - 百分比:25%, 50%, 75% - 帧数: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>
|
||||
);
|
||||
};
|
||||
287
apps/desktop/src/components/frame-extractor/FramePreview.tsx
Normal file
287
apps/desktop/src/components/frame-extractor/FramePreview.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
212
apps/desktop/src/components/frame-extractor/VideoFileList.tsx
Normal file
212
apps/desktop/src/components/frame-extractor/VideoFileList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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工具',
|
||||
|
||||
393
apps/desktop/src/pages/tools/FrameExtractorTool.tsx
Normal file
393
apps/desktop/src/pages/tools/FrameExtractorTool.tsx
Normal 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;
|
||||
234
apps/desktop/src/types/frame-extractor.ts
Normal file
234
apps/desktop/src/types/frame-extractor.ts
Normal 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(),
|
||||
},
|
||||
];
|
||||
207
docs/tools/frame-extractor-user-guide.md
Normal file
207
docs/tools/frame-extractor-user-guide.md
Normal 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)
|
||||
- 初始版本发布
|
||||
- 支持基本的帧提取功能
|
||||
- 提供多种输出格式
|
||||
- 实现批量处理能力
|
||||
- 添加实时预览功能
|
||||
Reference in New Issue
Block a user