From 779bb054c2f97a8e52d5c234ba7be7a1989ad284 Mon Sep 17 00:00:00 2001 From: imeepos Date: Mon, 11 Aug 2025 13:05:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E5=B8=A7=E6=8F=90=E5=8F=96=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增关键帧提取功能,支持提取视频的第一帧、最后一帧、自定义时间点和多个时间点 - 添加完整的前端UI组件,包括配置面板、文件列表、预览和进度显示 - 实现后端Rust服务,集成FFmpeg进行视频处理 - 支持多种输出格式(JPG/PNG/WebP/BMP)和质量设置 - 支持输出尺寸调整和宽高比保持 - 添加批量处理功能和进度跟踪 - 修复TypeScript类型错误和Rust编译问题 - 添加用户指南文档和单元测试 --- Cargo.lock | 252 ++++- apps/desktop/src-tauri/Cargo.toml | 1 + .../src/infrastructure/frame_extractor.rs | 973 ++++++++++++++++++ .../infrastructure/frame_extractor_test.rs | 61 ++ .../src-tauri/src/infrastructure/mod.rs | 1 + apps/desktop/src-tauri/src/lib.rs | 11 +- .../commands/frame_extractor_commands.rs | 385 +++++++ .../src/presentation/commands/mod.rs | 1 + .../commands/thumbnail_commands.rs | 2 +- apps/desktop/src/App.tsx | 2 + .../frame-extractor/ExtractionProgress.tsx | 218 ++++ .../frame-extractor/ExtractionResults.tsx | 297 ++++++ .../FrameExtractionConfigPanel.tsx | 376 +++++++ .../frame-extractor/FramePreview.tsx | 287 ++++++ .../frame-extractor/VideoFileList.tsx | 212 ++++ .../FrameExtractionConfigPanel.test.tsx | 283 +++++ .../__tests__/VideoFileList.test.tsx | 234 +++++ apps/desktop/src/data/tools.ts | 16 + .../src/pages/tools/FrameExtractorTool.tsx | 393 +++++++ apps/desktop/src/types/frame-extractor.ts | 234 +++++ docs/tools/frame-extractor-user-guide.md | 207 ++++ 21 files changed, 4442 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src-tauri/src/infrastructure/frame_extractor.rs create mode 100644 apps/desktop/src-tauri/src/infrastructure/frame_extractor_test.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/frame_extractor_commands.rs create mode 100644 apps/desktop/src/components/frame-extractor/ExtractionProgress.tsx create mode 100644 apps/desktop/src/components/frame-extractor/ExtractionResults.tsx create mode 100644 apps/desktop/src/components/frame-extractor/FrameExtractionConfigPanel.tsx create mode 100644 apps/desktop/src/components/frame-extractor/FramePreview.tsx create mode 100644 apps/desktop/src/components/frame-extractor/VideoFileList.tsx create mode 100644 apps/desktop/src/components/frame-extractor/__tests__/FrameExtractionConfigPanel.test.tsx create mode 100644 apps/desktop/src/components/frame-extractor/__tests__/VideoFileList.test.tsx create mode 100644 apps/desktop/src/pages/tools/FrameExtractorTool.tsx create mode 100644 apps/desktop/src/types/frame-extractor.ts create mode 100644 docs/tools/frame-extractor-user-guide.md diff --git a/Cargo.lock b/Cargo.lock index 8053027..baf0334 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 7f7467b..859b48b 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -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"] } diff --git a/apps/desktop/src-tauri/src/infrastructure/frame_extractor.rs b/apps/desktop/src-tauri/src/infrastructure/frame_extractor.rs new file mode 100644 index 0000000..a1dd2a6 --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/frame_extractor.rs @@ -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, +} + +/// 输出尺寸配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutputSize { + pub width: Option, + pub height: Option, + pub maintain_aspect_ratio: bool, + pub scale_filter: Option, +} + +/// 帧提取配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FrameExtractionConfig { + pub frame_type: FrameType, + pub time_points: Option>, + pub custom_time: Option, + pub output_format: ImageQuality, + pub output_size: Option, + 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, +} + +/// 单个帧提取结果 +#[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, + 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 { + 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 { + 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::().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 { + let parts: Vec<&str> = fraction_str.split('/').collect(); + if parts.len() == 2 { + if let (Ok(numerator), Ok(denominator)) = ( + parts[0].parse::(), + parts[1].parse::() + ) { + if denominator != 0.0 { + return Some(numerator / denominator); + } + } + } + None + } + + /// 扫描文件夹中的视频文件 + pub async fn scan_video_files( + folder_path: &str, + recursive: bool, + supported_formats: &[String], + ) -> Result> { + 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, + ) -> Pin> + 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 { + 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> { + 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 { + 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, + ) -> Result { + 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> { + 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 + } +} diff --git a/apps/desktop/src-tauri/src/infrastructure/frame_extractor_test.rs b/apps/desktop/src-tauri/src/infrastructure/frame_extractor_test.rs new file mode 100644 index 0000000..0346e9c --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/frame_extractor_test.rs @@ -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); + } +} diff --git a/apps/desktop/src-tauri/src/infrastructure/mod.rs b/apps/desktop/src-tauri/src/infrastructure/mod.rs index 622e257..44dc2aa 100644 --- a/apps/desktop/src-tauri/src/infrastructure/mod.rs +++ b/apps/desktop/src-tauri/src/infrastructure/mod.rs @@ -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; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1f2d4b6..8ef9863 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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, diff --git a/apps/desktop/src-tauri/src/presentation/commands/frame_extractor_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/frame_extractor_commands.rs new file mode 100644 index 0000000..4f51ec2 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/frame_extractor_commands.rs @@ -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> = 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, +} + +/// 帧提取请求 +#[derive(Debug, serde::Deserialize)] +pub struct FrameExtractionRequest { + pub video_files: Vec, + pub config: FrameExtractionConfig, +} + +/// 帧预览请求 +#[derive(Debug, serde::Deserialize)] +pub struct FramePreviewRequest { + pub video_path: String, + pub timestamp: f64, + pub output_size: Option, +} + +/// 帧预览结果 +#[derive(Debug, serde::Serialize)] +pub struct FramePreviewResult { + pub success: bool, + pub preview_data: Option, // Base64 encoded image + pub error_message: Option, + pub timestamp: f64, + pub original_size: (u32, u32), + pub preview_size: (u32, u32), +} + +/// 检查 FFmpeg 是否可用 +#[command] +pub async fn check_ffmpeg_availability() -> Result { + ensure_initialized().await?; + Ok(FrameExtractorService::is_available()) +} + +/// 获取视频文件信息 +#[command] +pub async fn get_video_info(video_path: String) -> Result { + 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, 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 { + 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, 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, 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, +) -> Result { + // 获取视频信息 + 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 { + 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), + }) + } + } +} diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index 6087fc1..ac680ab 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -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; diff --git a/apps/desktop/src-tauri/src/presentation/commands/thumbnail_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/thumbnail_commands.rs index cb4bea5..db16a91 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/thumbnail_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/thumbnail_commands.rs @@ -213,7 +213,7 @@ pub async fn select_video_folder() -> Result, String> { /// 扫描文件夹中的视频文件 /// 扫描指定文件夹,返回其中的视频文件列表 #[command] -pub async fn scan_video_files( +pub async fn scan_video_files_for_thumbnails( folder_path: String, ) -> Result, String> { info!(folder_path = %folder_path, "扫描文件夹中的视频文件"); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 2726634..b682c28 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/desktop/src/components/frame-extractor/ExtractionProgress.tsx b/apps/desktop/src/components/frame-extractor/ExtractionProgress.tsx new file mode 100644 index 0000000..94a4573 --- /dev/null +++ b/apps/desktop/src/components/frame-extractor/ExtractionProgress.tsx @@ -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 = ({ + 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 ( +
+ {/* 标题 */} +
+ {isExtracting ? ( + + ) : ( + + )} +

+ {isExtracting ? '正在提取关键帧...' : '提取进度'} +

+
+ + {/* 主要进度条 */} +
+
+ 总体进度 + {Math.round(progress)}% +
+ +
+
+
+ +
+ {processedFiles} / {totalFiles} 文件 + {estimatedTimeRemaining && estimatedTimeRemaining > 0 && ( + 预计剩余: {formatTime(estimatedTimeRemaining)} + )} +
+
+ + {/* 当前处理文件 */} + {currentFile && ( +
+
当前处理文件:
+
+
+ + + {currentFile.split('/').pop() || currentFile} + +
+
+
+ )} + + {/* 统计信息 */} +
+
+
{totalFiles}
+
总文件数
+
+ +
+
{processedFiles - failedFiles}
+
成功处理
+
+ +
+
{failedFiles}
+
处理失败
+
+ +
+
{Math.round(successRate)}%
+
成功率
+
+
+ + {/* 性能指标 */} + {(currentSpeed || estimatedTimeRemaining) && ( +
+
性能指标
+
+ {currentSpeed && ( +
+ +
+
处理速度
+
{formatSpeed(currentSpeed)}
+
+
+ )} + + {estimatedTimeRemaining && estimatedTimeRemaining > 0 && ( +
+ +
+
预计剩余时间
+
{formatTime(estimatedTimeRemaining)}
+
+
+ )} +
+
+ )} + + {/* 状态指示器 */} +
+ {isExtracting ? ( +
+ + 正在处理中... +
+ ) : processedFiles > 0 ? ( +
+ + 处理完成 +
+ ) : ( +
+ + 等待开始 +
+ )} +
+ + {/* 错误提示 */} + {failedFiles > 0 && !isExtracting && ( +
+
+ +
+
+ 有 {failedFiles} 个文件处理失败 +
+
+ 请检查文件格式是否支持,或查看详细错误信息 +
+
+
+
+ )} + + {/* 完成提示 */} + {!isExtracting && processedFiles > 0 && failedFiles === 0 && ( +
+
+ +
+
+ 所有文件处理完成! +
+
+ 成功提取了 {processedFiles} 个文件的关键帧 +
+
+
+
+ )} +
+ ); +}; diff --git a/apps/desktop/src/components/frame-extractor/ExtractionResults.tsx b/apps/desktop/src/components/frame-extractor/ExtractionResults.tsx new file mode 100644 index 0000000..4d23fd7 --- /dev/null +++ b/apps/desktop/src/components/frame-extractor/ExtractionResults.tsx @@ -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 = ({ + 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 ( +
+ {/* 标题和统计 */} +
+
+

提取结果

+
+ + +
+
+ + {/* 统计卡片 */} +
+
+
{stats.total}
+
总数
+
+
+
{stats.success}
+
成功
+
+
+
{stats.failed}
+
失败
+
+
+
{formatFileSize(stats.totalSize)}
+
总大小
+
+
+ + {/* 过滤和搜索 */} +
+
+ + +
+ +
+ + 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" + /> +
+
+
+ + {/* 结果列表 */} +
+ {filteredResults.length === 0 ? ( +
+ +

+ {results.length === 0 ? '暂无提取结果' : '没有符合条件的结果'} +

+ {results.length === 0 && ( +

请先开始提取操作

+ )} +
+ ) : ( +
+ {filteredResults.map((result, index) => ( +
+
+
+ {/* 状态和文件名 */} +
+ {result.success ? ( + + ) : ( + + )} + + {result.video_path.split('/').pop() || result.video_path} + +
+ + {/* 详细信息 */} +
+
+ + {getFrameTypeName(result.frame_type)} + +
+ + {formatTimestamp(result.timestamp)} +
+
+ + {result.success && ( +
+
+ + {formatFileSize(result.file_size)} +
+
+ + {formatProcessingTime(result.processing_time_ms)} +
+
+ )} + + {result.error_message && ( +
+ {result.error_message} +
+ )} +
+ + {/* 输出路径 */} + {result.success && ( +
+ {result.output_path} +
+ )} +
+ + {/* 操作按钮 */} + {result.success && ( +
+ {onPreviewResult && ( + + )} + {onDownloadResult && ( + + )} + +
+ )} +
+
+ ))} +
+ )} +
+
+ ); +}; diff --git a/apps/desktop/src/components/frame-extractor/FrameExtractionConfigPanel.tsx b/apps/desktop/src/components/frame-extractor/FrameExtractionConfigPanel.tsx new file mode 100644 index 0000000..b58dc44 --- /dev/null +++ b/apps/desktop/src/components/frame-extractor/FrameExtractionConfigPanel.tsx @@ -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 = ({ + config, + onConfigChange, + onApplyPreset, + presets +}) => { + const [customTimePoints, setCustomTimePoints] = useState(''); + + // 更新配置 + const updateConfig = useCallback((updates: Partial) => { + 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 = { 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 ( +
+
+ +

提取配置

+
+ + {/* 预设配置 */} +
+ +
+ {presets.map((preset) => ( + + ))} +
+
+ + {/* 帧类型选择 */} +
+ +
+ {Object.values(FrameType).map((type) => ( + + ))} +
+
+ + {/* 自定义时间输入 */} + {config.frame_type === FrameType.Custom && ( +
+ +
+ + 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="输入时间(秒)" + /> +
+
+ )} + + {/* 多时间点输入 */} + {config.frame_type === FrameType.Multiple && ( +
+ +