- 修复 jpeg_quality_to_qscale 函数中的 u8 乘法溢出问题 - 将计算过程转换为 u32 类型以避免溢出 - 解决了导致线程 panic 和前端界面卡住的问题
974 lines
32 KiB
Rust
974 lines
32 KiB
Rust
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) as u32;
|
|
(31 - ((clamped_quality - 1) * 30 / 99)) as u8
|
|
}
|
|
|
|
/// 计算提取时间点
|
|
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
|
|
}
|
|
}
|