Files
mixvideo-v2/apps/desktop/src-tauri/src/infrastructure/frame_extractor.rs
imeepos 332648d29e fix: 修复关键帧提取中的整数溢出错误
- 修复 jpeg_quality_to_qscale 函数中的 u8 乘法溢出问题
- 将计算过程转换为 u32 类型以避免溢出
- 解决了导致线程 panic 和前端界面卡住的问题
2025-08-11 13:09:56 +08:00

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
}
}