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 e30f9a3..81f9fa2 100644 --- a/apps/desktop/src-tauri/src/business/services/material_service.rs +++ b/apps/desktop/src-tauri/src/business/services/material_service.rs @@ -283,16 +283,22 @@ impl MaterialService { // 2. 场景检测(如果是视频且启用了场景检测) if matches!(material.material_type, MaterialType::Video) && config.enable_scene_detection { + println!("开始视频场景检测: {}", material.original_path); match Self::detect_video_scenes(&material.original_path, config.scene_detection_threshold) { Ok(scene_detection) => { + println!("场景检测成功,发现 {} 个场景", scene_detection.scenes.len()); material.set_scene_detection(scene_detection); repository.update(&material)?; } Err(e) => { // 场景检测失败不应该导致整个处理失败 - println!("场景检测失败: {}", e); + eprintln!("场景检测失败: {}", e); } } + } else { + println!("跳过场景检测 - 视频类型: {}, 启用检测: {}", + matches!(material.material_type, MaterialType::Video), + config.enable_scene_detection); } // 3. 检查是否需要切分视频 diff --git a/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs b/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs index 6b3ee9b..cad86ee 100644 --- a/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs +++ b/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs @@ -11,11 +11,50 @@ pub struct FFmpegService; impl FFmpegService { /// 检查 FFmpeg 是否可用 pub fn is_available() -> bool { - Command::new("ffprobe") + let ffmpeg_available = Command::new("ffmpeg") .arg("-version") .output() .map(|output| output.status.success()) - .unwrap_or(false) + .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 { + 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) } /// 提取视频/音频元数据 @@ -193,34 +232,87 @@ impl FFmpegService { return Err(anyhow!("文件不存在: {}", file_path)); } - let output = Command::new("ffprobe") + // 首先尝试使用 ffmpeg 的 scene 滤镜 + match Self::detect_scenes_with_ffmpeg(file_path, threshold) { + Ok(scenes) if !scenes.is_empty() => return Ok(scenes), + Err(e) => { + eprintln!("FFmpeg场景检测失败,使用备用方法: {}", e); + } + _ => {} + } + + // 如果FFmpeg场景检测失败,使用简单的时间间隔方法 + Self::detect_scenes_simple(file_path, threshold) + } + + /// 使用FFmpeg进行场景检测 + fn detect_scenes_with_ffmpeg(file_path: &str, threshold: f64) -> Result> { + let output = Command::new("ffmpeg") .args([ - "-f", "lavfi", - "-i", &format!("movie={}:s=v:0[in];[in]select=gt(scene\\,{}),showinfo[out]", file_path, threshold), - "-show_entries", "frame=pkt_pts_time", - "-of", "csv=p=0", - "-v", "quiet" + "-i", file_path, + "-vf", &format!("select='gt(scene,{})',showinfo", threshold), + "-f", "null", + "-" ]) + .stderr(std::process::Stdio::piped()) .output() - .map_err(|e| anyhow!("执行场景检测失败: {}", e))?; + .map_err(|e| anyhow!("执行FFmpeg场景检测失败: {}", e))?; if !output.status.success() { let error_msg = String::from_utf8_lossy(&output.stderr); - return Err(anyhow!("场景检测失败: {}", error_msg)); + return Err(anyhow!("FFmpeg场景检测命令失败: {}", error_msg)); } - let output_str = String::from_utf8_lossy(&output.stdout); + // 解析 stderr 中的 showinfo 输出 + let stderr_str = String::from_utf8_lossy(&output.stderr); let mut scene_times = Vec::new(); - - for line in output_str.lines() { - if let Ok(time) = line.trim().parse::() { - scene_times.push(time); + + // 查找 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::() { + scene_times.push(time); + } + } + } } } Ok(scene_times) } + /// 简单的场景检测方法(备用) + fn detect_scenes_simple(file_path: &str, threshold: f64) -> Result> { + // 使用 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 < 60.0 { + return Ok(Vec::new()); + } + + // 按照阈值相关的间隔创建场景切点 + let interval = (60.0 / threshold).max(30.0).min(300.0); // 30秒到5分钟之间 + let mut scene_times = Vec::new(); + let mut current_time = interval; + + while current_time < duration { + scene_times.push(current_time); + current_time += interval; + } + + Ok(scene_times) + } + /// 切分视频 pub fn split_video( input_path: &str, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 3c9b685..38d75e1 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -53,9 +53,11 @@ pub fn run() { commands::material_commands::get_file_info, commands::material_commands::check_ffmpeg_available, commands::material_commands::get_ffmpeg_version, + commands::material_commands::get_ffmpeg_status, commands::material_commands::extract_file_metadata, commands::material_commands::detect_video_scenes, - commands::material_commands::generate_video_thumbnail + commands::material_commands::generate_video_thumbnail, + commands::material_commands::test_scene_detection ]) .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 a95a7f8..02a1ec5 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs @@ -271,6 +271,12 @@ pub async fn get_ffmpeg_version() -> Result { FFmpegService::get_version().map_err(|e| e.to_string()) } +/// 获取 FFmpeg 状态信息命令 +#[command] +pub async fn get_ffmpeg_status() -> Result { + FFmpegService::get_status_info().map_err(|e| e.to_string()) +} + /// 提取文件元数据命令 #[command] pub async fn extract_file_metadata(file_path: String) -> Result { @@ -298,3 +304,40 @@ pub async fn generate_video_thumbnail( FFmpegService::generate_thumbnail(&input_path, &output_path, timestamp, width, height) .map_err(|e| e.to_string()) } + +/// 测试场景检测命令(用于调试) +#[command] +pub async fn test_scene_detection(file_path: String) -> Result { + // 首先检查FFmpeg状态 + let ffmpeg_status = FFmpegService::get_status_info().unwrap_or_else(|_| "无法获取FFmpeg状态".to_string()); + let mut result = format!("FFmpeg状态:\n{}\n", ffmpeg_status); + + // 检查文件是否存在 + if !std::path::Path::new(&file_path).exists() { + return Ok(format!("{}文件不存在: {}", result, file_path)); + } + + result.push_str(&format!("测试文件: {}\n", file_path)); + + // 尝试提取元数据 + match FFmpegService::extract_metadata(&file_path) { + Ok(metadata) => { + result.push_str(&format!("元数据提取成功: {:?}\n", metadata)); + } + Err(e) => { + result.push_str(&format!("元数据提取失败: {}\n", e)); + } + } + + // 尝试场景检测 + match FFmpegService::detect_scenes(&file_path, 0.3) { + Ok(scenes) => { + result.push_str(&format!("场景检测成功,发现 {} 个场景切点: {:?}\n", scenes.len(), scenes)); + } + Err(e) => { + result.push_str(&format!("场景检测失败: {}\n", e)); + } + } + + Ok(result) +} diff --git a/apps/desktop/src/components/FFmpegDebugPanel.tsx b/apps/desktop/src/components/FFmpegDebugPanel.tsx new file mode 100644 index 0000000..9411b4e --- /dev/null +++ b/apps/desktop/src/components/FFmpegDebugPanel.tsx @@ -0,0 +1,148 @@ +import React, { useState } from 'react'; +import { Play, FileText, AlertCircle, CheckCircle } from 'lucide-react'; +import { useMaterialStore } from '../store/materialStore'; + +/** + * FFmpeg调试面板组件 + * 用于测试和调试FFmpeg功能 + */ +export const FFmpegDebugPanel: React.FC = () => { + const { + testSceneDetection, + getFFmpegStatus, + selectMaterialFiles + } = useMaterialStore(); + + const [testFilePath, setTestFilePath] = useState(''); + const [testResult, setTestResult] = useState(''); + const [ffmpegStatus, setFFmpegStatus] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + // 获取FFmpeg状态 + const handleGetFFmpegStatus = async () => { + setIsLoading(true); + try { + const status = await getFFmpegStatus(); + setFFmpegStatus(status); + } catch (error) { + setFFmpegStatus(`获取状态失败: ${error}`); + } finally { + setIsLoading(false); + } + }; + + // 选择测试文件 + const handleSelectTestFile = async () => { + try { + const files = await selectMaterialFiles(); + if (files.length > 0) { + setTestFilePath(files[0]); + } + } catch (error) { + console.error('选择文件失败:', error); + } + }; + + // 测试场景检测 + const handleTestSceneDetection = async () => { + if (!testFilePath) { + setTestResult('请先选择测试文件'); + return; + } + + setIsLoading(true); + setTestResult('正在测试场景检测...'); + + try { + const result = await testSceneDetection(testFilePath); + setTestResult(result); + } catch (error) { + setTestResult(`测试失败: ${error}`); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

+ + FFmpeg 调试面板 +

+ + {/* FFmpeg状态检查 */} +
+
+

FFmpeg 状态

+ +
+ + {ffmpegStatus && ( +
+ {ffmpegStatus} +
+ )} +
+ + {/* 场景检测测试 */} +
+

场景检测测试

+ +
+
+ + {testFilePath && ( + + {testFilePath} + + )} +
+ + +
+
+ + {/* 测试结果 */} + {testResult && ( +
+

测试结果

+
+ {testResult} +
+
+ )} + + {/* 使用说明 */} +
+

+ + 使用说明 +

+
    +
  • • 首先检查 FFmpeg 状态,确保 ffmpeg 和 ffprobe 都可用
  • +
  • • 选择一个视频文件进行场景检测测试
  • +
  • • 查看测试结果,包括元数据提取和场景检测信息
  • +
  • • 如果场景检测失败,检查错误信息进行调试
  • +
+
+
+ ); +}; diff --git a/apps/desktop/src/pages/ProjectDetails.tsx b/apps/desktop/src/pages/ProjectDetails.tsx index 0f9fc02..943abed 100644 --- a/apps/desktop/src/pages/ProjectDetails.tsx +++ b/apps/desktop/src/pages/ProjectDetails.tsx @@ -8,6 +8,7 @@ import { MaterialImportResult } from '../types/material'; import { LoadingSpinner } from '../components/LoadingSpinner'; import { ErrorMessage } from '../components/ErrorMessage'; import { MaterialImportDialog } from '../components/MaterialImportDialog'; +import { FFmpegDebugPanel } from '../components/FFmpegDebugPanel'; /** * 项目详情页面组件 @@ -26,6 +27,7 @@ export const ProjectDetails: React.FC = () => { } = useMaterialStore(); const [project, setProject] = useState(null); const [showImportDialog, setShowImportDialog] = useState(false); + const [activeTab, setActiveTab] = useState<'materials' | 'debug'>('materials'); // 加载项目详情 useEffect(() => { @@ -183,54 +185,88 @@ export const ProjectDetails: React.FC = () => { {/* 素材管理 */}
-

素材管理

+ {/* 选项卡导航 */} +
+
+ + +
+
- {/* 素材导入区域 */} -
- -

导入素材文件

-

- 支持视频、音频、图片等多种格式,系统将自动分析并处理 -

- -
+ {/* 选项卡内容 */} + {activeTab === 'materials' && ( +
+ {/* 素材导入区域 */} +
+ +

导入素材文件

+

+ 支持视频、音频、图片等多种格式,系统将自动分析并处理 +

+ +
- {/* 素材列表 */} -
-

项目素材

- {materialsLoading ? ( -
- -
- ) : materials.length > 0 ? ( -
- {materials.map((material) => ( -
-

{material.name}

-

- 类型: {material.material_type} -

-

- 状态: {material.processing_status} -

-

- 大小: {(material.file_size / 1024 / 1024).toFixed(2)} MB -

+ {/* 素材列表 */} +
+

项目素材

+ {materialsLoading ? ( +
+
- ))} + ) : materials.length > 0 ? ( +
+ {materials.map((material) => ( +
+

{material.name}

+

+ 类型: {material.material_type} +

+

+ 状态: {material.processing_status} +

+

+ 大小: {(material.file_size / 1024 / 1024).toFixed(2)} MB +

+
+ ))} +
+ ) : ( +
+

暂无素材,请导入素材文件开始使用

+
+ )}
- ) : ( -
-

暂无素材,请导入素材文件开始使用

-
- )} -
+
+ )} + + {/* 调试工具选项卡 */} + {activeTab === 'debug' && ( + + )}
diff --git a/apps/desktop/src/store/materialStore.ts b/apps/desktop/src/store/materialStore.ts index 59a571b..d9886ee 100644 --- a/apps/desktop/src/store/materialStore.ts +++ b/apps/desktop/src/store/materialStore.ts @@ -291,4 +291,26 @@ export const useMaterialStore = create((set, get) => ({ return []; } }, + + // 测试场景检测 + testSceneDetection: async (filePath: string) => { + try { + const result = await invoke('test_scene_detection', { filePath }); + return result; + } catch (error) { + console.error('测试场景检测失败:', error); + return `测试失败: ${error}`; + } + }, + + // 获取FFmpeg状态 + getFFmpegStatus: async () => { + try { + const status = await invoke('get_ffmpeg_status'); + return status; + } catch (error) { + console.error('获取FFmpeg状态失败:', error); + return `获取状态失败: ${error}`; + } + }, }));