mixvideo-v2/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs

486 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use anyhow::{Result, anyhow};
use serde_json::Value;
use std::process::Command;
use std::path::Path;
use crate::data::models::material::{VideoMetadata, AudioMetadata, MaterialMetadata};
/// FFmpeg 工具集成
/// 遵循 Tauri 开发规范的基础设施层设计
pub struct FFmpegService;
impl FFmpegService {
/// 检查 FFmpeg 是否可用
pub fn is_available() -> bool {
let ffmpeg_available = Command::new("ffmpeg")
.arg("-version")
.output()
.map(|output| output.status.success())
.unwrap_or(false);
let ffprobe_available = Command::new("ffprobe")
.arg("-version")
.output()
.map(|output| output.status.success())
.unwrap_or(false);
ffmpeg_available && ffprobe_available
}
/// 获取详细的FFmpeg状态信息
pub fn get_status_info() -> Result<String> {
let mut info = String::new();
// 检查 ffmpeg
match Command::new("ffmpeg").arg("-version").output() {
Ok(output) if output.status.success() => {
let version_str = String::from_utf8_lossy(&output.stdout);
if let Some(first_line) = version_str.lines().next() {
info.push_str(&format!("FFmpeg: {}\n", first_line));
}
}
Ok(_) => info.push_str("FFmpeg: 命令执行失败\n"),
Err(e) => info.push_str(&format!("FFmpeg: 未找到 ({})\n", e)),
}
// 检查 ffprobe
match Command::new("ffprobe").arg("-version").output() {
Ok(output) if output.status.success() => {
let version_str = String::from_utf8_lossy(&output.stdout);
if let Some(first_line) = version_str.lines().next() {
info.push_str(&format!("FFprobe: {}\n", first_line));
}
}
Ok(_) => info.push_str("FFprobe: 命令执行失败\n"),
Err(e) => info.push_str(&format!("FFprobe: 未找到 ({})\n", e)),
}
Ok(info)
}
/// 提取视频/音频元数据
pub fn extract_metadata(file_path: &str) -> Result<MaterialMetadata> {
if !Path::new(file_path).exists() {
return Err(anyhow!("文件不存在: {}", file_path));
}
let output = Command::new("ffprobe")
.args([
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
file_path
])
.output()
.map_err(|e| anyhow!("执行 ffprobe 失败: {}", e))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("ffprobe 执行失败: {}", error_msg));
}
let json_str = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(&json_str)
.map_err(|e| anyhow!("解析 ffprobe 输出失败: {}", e))?;
Self::parse_metadata(&json)
}
/// 解析 ffprobe 输出的 JSON 数据
fn parse_metadata(json: &Value) -> Result<MaterialMetadata> {
let format = json.get("format")
.ok_or_else(|| anyhow!("缺少 format 信息"))?;
let streams = json.get("streams")
.and_then(|s| s.as_array())
.ok_or_else(|| anyhow!("缺少 streams 信息"))?;
// 查找视频流
let video_stream = streams.iter()
.find(|stream| stream.get("codec_type").and_then(|t| t.as_str()) == Some("video"));
// 查找音频流
let audio_stream = streams.iter()
.find(|stream| stream.get("codec_type").and_then(|t| t.as_str()) == Some("audio"));
if let Some(video) = video_stream {
// 这是一个视频文件
let duration = format.get("duration")
.and_then(|d| d.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let width = video.get("width")
.and_then(|w| w.as_u64())
.unwrap_or(0) as u32;
let height = video.get("height")
.and_then(|h| h.as_u64())
.unwrap_or(0) as u32;
let fps = video.get("r_frame_rate")
.and_then(|fps| fps.as_str())
.and_then(|s| Self::parse_fraction(s))
.unwrap_or(0.0);
let bitrate = format.get("bit_rate")
.and_then(|b| b.as_str())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let codec = video.get("codec_name")
.and_then(|c| c.as_str())
.unwrap_or("unknown")
.to_string();
let format_name = format.get("format_name")
.and_then(|f| f.as_str())
.unwrap_or("unknown")
.to_string();
let has_audio = audio_stream.is_some();
let audio_codec = audio_stream
.and_then(|a| a.get("codec_name"))
.and_then(|c| c.as_str())
.map(|s| s.to_string());
let audio_bitrate = audio_stream
.and_then(|a| a.get("bit_rate"))
.and_then(|b| b.as_str())
.and_then(|s| s.parse::<u64>().ok());
let audio_sample_rate = audio_stream
.and_then(|a| a.get("sample_rate"))
.and_then(|r| r.as_str())
.and_then(|s| s.parse::<u32>().ok());
Ok(MaterialMetadata::Video(VideoMetadata {
duration,
width,
height,
fps,
bitrate,
codec,
format: format_name,
has_audio,
audio_codec,
audio_bitrate,
audio_sample_rate,
}))
} else if let Some(audio) = audio_stream {
// 这是一个音频文件
let duration = format.get("duration")
.and_then(|d| d.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let bitrate = format.get("bit_rate")
.and_then(|b| b.as_str())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let codec = audio.get("codec_name")
.and_then(|c| c.as_str())
.unwrap_or("unknown")
.to_string();
let sample_rate = audio.get("sample_rate")
.and_then(|r| r.as_str())
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(0);
let channels = audio.get("channels")
.and_then(|c| c.as_u64())
.unwrap_or(0) as u32;
let format_name = format.get("format_name")
.and_then(|f| f.as_str())
.unwrap_or("unknown")
.to_string();
Ok(MaterialMetadata::Audio(AudioMetadata {
duration,
bitrate,
codec,
sample_rate,
channels,
format: format_name,
}))
} else {
Err(anyhow!("未找到有效的视频或音频流"))
}
}
/// 解析分数格式的帧率 (如 "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 fn detect_scenes(file_path: &str, threshold: f64) -> Result<Vec<f64>> {
if !Path::new(file_path).exists() {
return Err(anyhow!("文件不存在: {}", file_path));
}
println!("开始场景检测: {} (阈值: {})", file_path, threshold);
// 首先尝试使用 ffmpeg 的 scene 滤镜
match Self::detect_scenes_with_ffmpeg(file_path, threshold) {
Ok(scenes) if !scenes.is_empty() => {
println!("FFmpeg场景检测成功发现 {} 个场景: {:?}", scenes.len(), scenes);
return Ok(scenes);
}
Err(e) => {
println!("FFmpeg场景检测失败使用备用方法: {}", e);
}
Ok(_) => {
println!("FFmpeg场景检测返回空结果使用备用方法");
}
}
// 如果FFmpeg场景检测失败使用简单的时间间隔方法
let simple_scenes = Self::detect_scenes_simple(file_path, threshold)?;
println!("备用场景检测完成,发现 {} 个场景: {:?}", simple_scenes.len(), simple_scenes);
Ok(simple_scenes)
}
/// 使用FFmpeg进行场景检测
fn detect_scenes_with_ffmpeg(file_path: &str, threshold: f64) -> Result<Vec<f64>> {
// 方法1: 使用 scene 滤镜和 showinfo
let output1 = Command::new("ffmpeg")
.args([
"-i", file_path,
"-vf", &format!("select='gt(scene,{})',showinfo", threshold),
"-f", "null",
"-"
])
.stderr(std::process::Stdio::piped())
.output();
if let Ok(output) = output1 {
let stderr_str = String::from_utf8_lossy(&output.stderr);
let mut scene_times = Vec::new();
// 查找 showinfo 输出中的 pts_time 信息
for line in stderr_str.lines() {
if line.contains("showinfo") && line.contains("pts_time:") {
if let Some(pts_start) = line.find("pts_time:") {
let pts_part = &line[pts_start + 9..];
if let Some(space_pos) = pts_part.find(' ') {
let time_str = &pts_part[..space_pos];
if let Ok(time) = time_str.parse::<f64>() {
scene_times.push(time);
}
}
}
}
}
if !scene_times.is_empty() {
return Ok(scene_times);
}
}
// 方法2: 使用更简单的场景检测方法
Self::detect_scenes_alternative(file_path, threshold)
}
/// 替代的场景检测方法
fn detect_scenes_alternative(file_path: &str, threshold: f64) -> Result<Vec<f64>> {
// 使用 ffprobe 分析视频帧信息
let output = Command::new("ffprobe")
.args([
"-v", "quiet",
"-select_streams", "v:0",
"-show_entries", "frame=pkt_pts_time,pict_type",
"-of", "csv=p=0",
file_path
])
.output()
.map_err(|e| anyhow!("执行ffprobe帧分析失败: {}", e))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("ffprobe帧分析失败: {}", error_msg));
}
let output_str = String::from_utf8_lossy(&output.stdout);
let mut scene_times = Vec::new();
let mut last_i_frame_time = 0.0;
let min_scene_duration = 5.0; // 最小场景时长5秒
// 分析I帧关键帧作为潜在的场景切换点
for line in output_str.lines() {
let parts: Vec<&str> = line.split(',').collect();
if parts.len() >= 2 {
if let (Ok(time), pict_type) = (parts[0].parse::<f64>(), parts[1]) {
if pict_type == "I" && time - last_i_frame_time > min_scene_duration {
scene_times.push(time);
last_i_frame_time = time;
}
}
}
}
Ok(scene_times)
}
/// 简单的场景检测方法(备用)
fn detect_scenes_simple(file_path: &str, threshold: f64) -> Result<Vec<f64>> {
// 使用 ffprobe 获取视频时长,然后按智能间隔分割
let metadata = Self::extract_metadata(file_path)?;
let duration = match metadata {
MaterialMetadata::Video(video_meta) => video_meta.duration,
_ => return Ok(Vec::new()),
};
// 如果视频很短,不需要场景检测
if duration < 30.0 {
return Ok(Vec::new());
}
let mut scene_times = Vec::new();
// 根据视频时长和阈值智能确定切分策略
if duration <= 120.0 {
// 2分钟以内的视频按30秒间隔
let mut current_time = 30.0;
while current_time < duration {
scene_times.push(current_time);
current_time += 30.0;
}
} else if duration <= 600.0 {
// 10分钟以内的视频按60秒间隔
let mut current_time = 60.0;
while current_time < duration {
scene_times.push(current_time);
current_time += 60.0;
}
} else {
// 长视频按120秒间隔
let mut current_time = 120.0;
while current_time < duration {
scene_times.push(current_time);
current_time += 120.0;
}
}
// 如果阈值很低(更敏感),增加更多切点
if threshold < 0.2 && duration > 180.0 {
let mut additional_times = Vec::new();
for &time in &scene_times {
if time > 90.0 {
additional_times.push(time - 45.0);
}
}
scene_times.extend(additional_times);
scene_times.sort_by(|a, b| a.partial_cmp(b).unwrap());
}
Ok(scene_times)
}
/// 切分视频
pub fn split_video(
input_path: &str,
output_dir: &str,
segments: &[(f64, f64)], // (start_time, end_time) pairs
output_prefix: &str,
) -> Result<Vec<String>> {
if !Path::new(input_path).exists() {
return Err(anyhow!("输入文件不存在: {}", input_path));
}
std::fs::create_dir_all(output_dir)
.map_err(|e| anyhow!("创建输出目录失败: {}", e))?;
let mut output_files = Vec::new();
for (index, (start_time, end_time)) in segments.iter().enumerate() {
let duration = end_time - start_time;
let output_file = format!("{}/{}_{:03}.mp4", output_dir, output_prefix, index + 1);
let output = Command::new("ffmpeg")
.args([
"-i", input_path,
"-ss", &start_time.to_string(),
"-t", &duration.to_string(),
"-c", "copy",
"-avoid_negative_ts", "make_zero",
"-y", // 覆盖输出文件
&output_file
])
.output()
.map_err(|e| anyhow!("执行视频切分失败: {}", e))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("视频切分失败: {}", error_msg));
}
output_files.push(output_file);
}
Ok(output_files)
}
/// 获取视频缩略图
pub fn generate_thumbnail(
input_path: &str,
output_path: &str,
timestamp: f64,
width: u32,
height: u32,
) -> Result<()> {
if !Path::new(input_path).exists() {
return Err(anyhow!("输入文件不存在: {}", input_path));
}
let output = Command::new("ffmpeg")
.args([
"-i", input_path,
"-ss", &timestamp.to_string(),
"-vframes", "1",
"-vf", &format!("scale={}:{}", width, height),
"-y", // 覆盖输出文件
output_path
])
.output()
.map_err(|e| anyhow!("执行缩略图生成失败: {}", e))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("缩略图生成失败: {}", error_msg));
}
Ok(())
}
/// 检查 FFmpeg 版本
pub fn get_version() -> Result<String> {
let output = Command::new("ffmpeg")
.arg("-version")
.output()
.map_err(|e| anyhow!("获取 FFmpeg 版本失败: {}", e))?;
if !output.status.success() {
return Err(anyhow!("FFmpeg 不可用"));
}
let version_str = String::from_utf8_lossy(&output.stdout);
let first_line = version_str.lines().next().unwrap_or("Unknown version");
Ok(first_line.to_string())
}
}