feat: 实现视频关键帧提取工具
- 新增关键帧提取功能,支持提取视频的第一帧、最后一帧、自定义时间点和多个时间点 - 添加完整的前端UI组件,包括配置面板、文件列表、预览和进度显示 - 实现后端Rust服务,集成FFmpeg进行视频处理 - 支持多种输出格式(JPG/PNG/WebP/BMP)和质量设置 - 支持输出尺寸调整和宽高比保持 - 添加批量处理功能和进度跟踪 - 修复TypeScript类型错误和Rust编译问题 - 添加用户指南文档和单元测试
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user