486 lines
17 KiB
Rust
486 lines
17 KiB
Rust
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", ×tamp.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())
|
||
}
|
||
}
|