From 864d1b42a91ca236d09eddb2b766de641cd8644f Mon Sep 17 00:00:00 2001 From: imeepos Date: Sun, 13 Jul 2025 21:17:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=88=87=E5=88=86=E5=89=8D=E5=87=A0=E7=A7=92=E6=97=A0=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题分析: - 原因:使用 -c copy 流复制模式在非关键帧位置切分 - 症状:切分后的视频前几秒显示黑屏或无画面 解决方案: 1. 新增三种切分模式: - Fast: 快速模式(流复制,速度快但可能有画面问题) - Accurate: 精确模式(重新编码,确保画面完整) - Smart: 智能模式(关键帧对齐 + 快速切分) 2. 精确模式技术改进: - 使用 libx264 重新编码视频 - 使用 aac 重新编码音频 - 添加 -preset fast 提高编码速度 - 设置 -crf 23 保证质量 - 添加 -movflags +faststart 优化播放 3. 智能模式特性: - 自动获取视频关键帧信息 - 将切分点调整到最近的关键帧 - 结合快速切分提高效率 4. 新增调试功能: - test_video_split 命令测试不同切分模式 - 详细的切分日志输出 - 模式选择和参数配置 默认配置: - 使用 Accurate 模式确保画面完整 - 可通过配置切换到其他模式 现在切分的视频应该不会再有前几秒无画面的问题! --- .../src/business/services/material_service.rs | 37 ++++- .../src-tauri/src/data/models/material.rs | 16 ++ .../src-tauri/src/infrastructure/ffmpeg.rs | 153 ++++++++++++++++-- apps/desktop/src-tauri/src/lib.rs | 3 +- .../commands/material_commands.rs | 33 ++++ 5 files changed, 219 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src-tauri/src/business/services/material_service.rs b/apps/desktop/src-tauri/src/business/services/material_service.rs index 81f9fa2..b5b4f71 100644 --- a/apps/desktop/src-tauri/src/business/services/material_service.rs +++ b/apps/desktop/src-tauri/src/business/services/material_service.rs @@ -399,13 +399,36 @@ impl MaterialService { // 创建输出目录 let output_dir = format!("{}_segments", material.original_path.trim_end_matches(".mp4")); - // 执行视频切分 - let output_files = FFmpegService::split_video( - &material.original_path, - &output_dir, - &segments, - &material.name.replace(".mp4", ""), - )?; + // 根据配置选择切分模式 + let output_files = match config.split_mode { + crate::data::models::material::VideoSplitMode::Fast => { + println!("使用快速切分模式(可能有前几秒无画面问题)"); + FFmpegService::split_video_fast( + &material.original_path, + &output_dir, + &segments, + &material.name.replace(".mp4", ""), + )? + } + crate::data::models::material::VideoSplitMode::Accurate => { + println!("使用精确切分模式(重新编码,确保画面完整)"); + FFmpegService::split_video( + &material.original_path, + &output_dir, + &segments, + &material.name.replace(".mp4", ""), + )? + } + crate::data::models::material::VideoSplitMode::Smart => { + println!("使用智能切分模式(关键帧对齐)"); + FFmpegService::split_video_smart( + &material.original_path, + &output_dir, + &segments, + &material.name.replace(".mp4", ""), + )? + } + }; // 保存片段信息到数据库 for (index, output_file) in output_files.iter().enumerate() { diff --git a/apps/desktop/src-tauri/src/data/models/material.rs b/apps/desktop/src-tauri/src/data/models/material.rs index dee17cc..320c9a2 100644 --- a/apps/desktop/src-tauri/src/data/models/material.rs +++ b/apps/desktop/src-tauri/src/data/models/material.rs @@ -142,6 +142,20 @@ pub struct CreateMaterialRequest { pub max_segment_duration: Option, // 最大片段时长(秒) } +/// 视频切分模式 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum VideoSplitMode { + Fast, // 快速模式(流复制,可能有画面问题) + Accurate, // 精确模式(重新编码,确保画面完整) + Smart, // 智能模式(关键帧对齐 + 快速切分) +} + +impl Default for VideoSplitMode { + fn default() -> Self { + VideoSplitMode::Accurate + } +} + /// 素材处理配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MaterialProcessingConfig { @@ -152,6 +166,7 @@ pub struct MaterialProcessingConfig { pub audio_quality: String, // 音频质量设置 pub output_format: String, // 输出格式 pub auto_process: Option, // 是否自动处理 + pub split_mode: VideoSplitMode, // 视频切分模式 } impl Default for MaterialProcessingConfig { @@ -164,6 +179,7 @@ impl Default for MaterialProcessingConfig { audio_quality: "medium".to_string(), output_format: "mp4".to_string(), auto_process: Some(true), + split_mode: VideoSplitMode::Accurate, // 默认使用精确模式 } } } diff --git a/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs b/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs index c62c8ba..09e2586 100644 --- a/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs +++ b/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs @@ -391,12 +391,33 @@ impl FFmpegService { 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> { + Self::split_video_with_mode(input_path, output_dir, segments, output_prefix, false) + } + + /// 快速切分视频(流复制模式,速度快但可能有画面问题) + pub fn split_video_fast( + input_path: &str, + output_dir: &str, + segments: &[(f64, f64)], // (start_time, end_time) pairs + output_prefix: &str, + ) -> Result> { + Self::split_video_with_mode(input_path, output_dir, segments, output_prefix, true) + } + + /// 切分视频的内部实现 + fn split_video_with_mode( + input_path: &str, + output_dir: &str, + segments: &[(f64, f64)], // (start_time, end_time) pairs + output_prefix: &str, + use_copy_mode: bool, ) -> Result> { if !Path::new(input_path).exists() { return Err(anyhow!("输入文件不存在: {}", input_path)); @@ -410,31 +431,133 @@ impl FFmpegService { 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))?; + + println!("切分视频片段 {}: {}s - {}s (时长: {}s) [模式: {}]", + index + 1, start_time, end_time, duration, + if use_copy_mode { "快速复制" } else { "重新编码" }); + + let output = if use_copy_mode { + // 快速模式:流复制(可能有前几秒无画面问题) + 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))? + } else { + // 重新编码模式:确保切分点准确,避免前几秒无画面问题 + Command::new("ffmpeg") + .args([ + "-i", input_path, + "-ss", &start_time.to_string(), + "-t", &duration.to_string(), + "-c:v", "libx264", // 重新编码视频 + "-c:a", "aac", // 重新编码音频 + "-preset", "fast", // 编码速度设置 + "-crf", "23", // 质量设置(0-51,越小质量越好) + "-avoid_negative_ts", "make_zero", + "-movflags", "+faststart", // 优化网络播放 + "-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)); + return Err(anyhow!("视频切分失败 (片段 {}): {}", index + 1, error_msg)); } + println!("片段 {} 切分完成: {}", index + 1, output_file); output_files.push(output_file); } Ok(output_files) } + /// 智能切分视频(寻找最近的关键帧) + pub fn split_video_smart( + input_path: &str, + output_dir: &str, + segments: &[(f64, f64)], // (start_time, end_time) pairs + output_prefix: &str, + ) -> Result> { + // 首先获取关键帧信息 + let keyframes = Self::get_keyframes(input_path)?; + + // 调整切分点到最近的关键帧 + let adjusted_segments: Vec<(f64, f64)> = segments.iter().map(|(start, end)| { + let adjusted_start = Self::find_nearest_keyframe(&keyframes, *start); + let adjusted_end = Self::find_nearest_keyframe(&keyframes, *end); + (adjusted_start, adjusted_end) + }).collect(); + + println!("原始切分点: {:?}", segments); + println!("调整后切分点: {:?}", adjusted_segments); + + // 使用调整后的切分点进行快速切分 + Self::split_video_fast(input_path, output_dir, &adjusted_segments, output_prefix) + } + + /// 获取视频的关键帧时间点 + fn get_keyframes(input_path: &str) -> Result> { + let output = Command::new("ffprobe") + .args([ + "-v", "quiet", + "-select_streams", "v:0", + "-show_entries", "frame=pkt_pts_time", + "-of", "csv=p=0", + "-skip_frame", "nokey", // 只显示关键帧 + input_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)); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + let mut keyframes = Vec::new(); + + for line in output_str.lines() { + if let Ok(time) = line.trim().parse::() { + keyframes.push(time); + } + } + + keyframes.sort_by(|a, b| a.partial_cmp(b).unwrap()); + Ok(keyframes) + } + + /// 寻找最近的关键帧 + fn find_nearest_keyframe(keyframes: &[f64], target_time: f64) -> f64 { + if keyframes.is_empty() { + return target_time; + } + + let mut nearest = keyframes[0]; + let mut min_distance = (target_time - keyframes[0]).abs(); + + for &keyframe in keyframes { + let distance = (target_time - keyframe).abs(); + if distance < min_distance { + min_distance = distance; + nearest = keyframe; + } + } + + nearest + } + /// 获取视频缩略图 pub fn generate_thumbnail( input_path: &str, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 447fb11..78ba23d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -58,7 +58,8 @@ pub fn run() { commands::material_commands::detect_video_scenes, commands::material_commands::generate_video_thumbnail, commands::material_commands::test_scene_detection, - commands::material_commands::get_material_segments + commands::material_commands::get_material_segments, + commands::material_commands::test_video_split ]) .setup(|app| { // 初始化应用状态 diff --git a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs index 24df0e9..8bb7156 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs @@ -357,3 +357,36 @@ pub async fn get_material_segments( repository.get_segments(&material_id) .map_err(|e| e.to_string()) } + +/// 测试视频切分命令(用于调试不同切分模式) +#[command] +pub async fn test_video_split( + input_path: String, + output_dir: String, + start_time: f64, + end_time: f64, + mode: String, +) -> Result { + use crate::infrastructure::ffmpeg::FFmpegService; + + let segments = vec![(start_time, end_time)]; + let output_prefix = "test_segment"; + + let result = match mode.as_str() { + "fast" => { + FFmpegService::split_video_fast(&input_path, &output_dir, &segments, output_prefix) + } + "accurate" => { + FFmpegService::split_video(&input_path, &output_dir, &segments, output_prefix) + } + "smart" => { + FFmpegService::split_video_smart(&input_path, &output_dir, &segments, output_prefix) + } + _ => return Err("无效的切分模式,支持: fast, accurate, smart".to_string()), + }; + + match result { + Ok(files) => Ok(format!("切分成功,生成文件: {:?}", files)), + Err(e) => Err(format!("切分失败: {}", e)), + } +}