From 8c742bf26205d24f7de87c001e76603e93564d9f Mon Sep 17 00:00:00 2001 From: imeepos Date: Tue, 15 Jul 2025 22:49:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=9A=84=E7=BC=A9=E7=95=A5=E5=9B=BE=E8=8E=B7=E5=8F=96=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E4=BD=BF=E7=94=A8=E8=A7=86=E9=A2=91=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E5=B0=BA=E5=AF=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增get_segment_thumbnail_base64接口: - 根据segmentId统一获取缩略图base64数据URL - 智能检查:数据库路径 -> 文件存在性 -> 自动重新生成 - 完整的错误处理和文件验证机制 使用视频实际尺寸生成缩略图: - 添加get_video_info方法获取视频元数据 - 保持原始宽高比,最大宽度160像素 - 支持音频流信息解析和完整的VideoMetadata结构 代码优化: - 简化前端缩略图加载逻辑,统一使用新接口 - 移除重复的generateSegmentThumbnail函数 - 清理不必要的参数传递和依赖项 功能特点: - 自动检测文件丢失并重新生成 - 使用视频原始尺寸保持最佳显示效果 - 统一的错误处理和缓存机制 - 减少代码重复,提高维护性 现在缩略图生成更加智能和高效,能够自动处理文件丢失的情况,并使用视频的实际尺寸生成最佳质量的缩略图。 --- .../src-tauri/src/infrastructure/ffmpeg.rs | 89 ++++++++++++ apps/desktop/src-tauri/src/lib.rs | 1 + .../commands/material_commands.rs | 136 +++++++++++++++++- .../src/components/MaterialSegmentView.tsx | 70 ++------- 4 files changed, 239 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs b/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs index 74114e3..172e40f 100644 --- a/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs +++ b/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs @@ -611,6 +611,95 @@ impl FFmpegService { Ok(()) } + /// 获取视频信息 + pub fn get_video_info(input_path: &str) -> Result { + if !Path::new(input_path).exists() { + return Err(anyhow!("输入文件不存在: {}", input_path)); + } + + let output = Self::create_hidden_command("ffprobe") + .args([ + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + input_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))?; + + // 查找视频流 + let streams = json["streams"].as_array() + .ok_or_else(|| anyhow!("未找到流信息"))?; + + for stream in streams { + if stream["codec_type"].as_str() == Some("video") { + let width = stream["width"].as_u64().unwrap_or(0) as u32; + let height = stream["height"].as_u64().unwrap_or(0) as u32; + let duration = stream["duration"].as_str() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.0); + let fps = stream["r_frame_rate"].as_str() + .and_then(|s| { + let parts: Vec<&str> = s.split('/').collect(); + if parts.len() == 2 { + let num = parts[0].parse::().ok()?; + let den = parts[1].parse::().ok()?; + if den != 0.0 { Some(num / den) } else { None } + } else { + s.parse::().ok() + } + }) + .unwrap_or(0.0); + + // 查找音频流信息 + let mut has_audio = false; + let mut audio_codec = None; + let mut audio_bitrate = None; + let mut audio_sample_rate = None; + + for audio_stream in streams { + if audio_stream["codec_type"].as_str() == Some("audio") { + has_audio = true; + audio_codec = audio_stream["codec_name"].as_str().map(|s| s.to_string()); + audio_bitrate = audio_stream["bit_rate"].as_str() + .and_then(|s| s.parse::().ok()); + audio_sample_rate = audio_stream["sample_rate"].as_str() + .and_then(|s| s.parse::().ok()); + break; + } + } + + return Ok(VideoMetadata { + width, + height, + duration, + fps, + codec: stream["codec_name"].as_str().unwrap_or("unknown").to_string(), + bitrate: stream["bit_rate"].as_str() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0), + format: json["format"]["format_name"].as_str().unwrap_or("unknown").to_string(), + has_audio, + audio_codec, + audio_bitrate, + audio_sample_rate, + }); + } + } + + Err(anyhow!("未找到视频流")) + } + /// 检查 FFmpeg 版本 pub fn get_version() -> Result { let output = Self::create_hidden_command("ffmpeg") diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 60981b4..76a50f2 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -75,6 +75,7 @@ pub fn run() { commands::material_commands::generate_video_thumbnail, commands::material_commands::generate_and_save_segment_thumbnail, commands::material_commands::read_thumbnail_as_data_url, + commands::material_commands::get_segment_thumbnail_base64, commands::material_commands::test_scene_detection, commands::material_commands::get_material_segments, commands::material_commands::test_video_split, 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 881675f..3a706a3 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs @@ -830,12 +830,33 @@ pub async fn generate_and_save_segment_thumbnail( // 生成缩略图(使用首帧) let timestamp = segment.start_time; + + // 获取视频元数据来确定合适的缩略图尺寸 + let video_info = FFmpegService::get_video_info(video_path) + .map_err(|e| e.to_string())?; + + // 计算缩略图尺寸,保持宽高比,最大宽度160 + let max_width = 160; + let (thumb_width, thumb_height) = if video_info.width > 0 && video_info.height > 0 { + let aspect_ratio = video_info.width as f64 / video_info.height as f64; + if video_info.width > max_width { + let new_width = max_width; + let new_height = (new_width as f64 / aspect_ratio).round() as u32; + (new_width, new_height) + } else { + (video_info.width, video_info.height) + } + } else { + // 如果无法获取视频尺寸,使用默认值 + (160, 120) + }; + FFmpegService::generate_thumbnail( video_path, &thumbnail_path_str, timestamp, - 160, - 120 + thumb_width, + thumb_height ).map_err(|e| e.to_string())?; // 保存缩略图路径到数据库 @@ -875,6 +896,117 @@ pub async fn read_thumbnail_as_data_url(file_path: String) -> Result, +) -> Result { + use crate::infrastructure::ffmpeg::FFmpegService; + use std::path::Path; + use std::fs; + use base64::{Engine as _, engine::general_purpose}; + + // 获取片段信息 + let segment = { + let material_repository_guard = app_state.material_repository.lock().unwrap(); + let material_repository = material_repository_guard.as_ref() + .ok_or("MaterialRepository未初始化")?; + + material_repository.get_segment_by_id_sync(&segment_id) + .map_err(|e| e.to_string())? + }; + + let segment = match segment { + Some(s) => s, + None => return Err("片段不存在".to_string()), + }; + + // 检查数据库中是否已有缩略图路径 + if let Some(ref thumbnail_path) = segment.thumbnail_path { + // 去掉Windows长路径前缀 + let clean_path = if thumbnail_path.starts_with("\\\\?\\") { + &thumbnail_path[4..] + } else { + thumbnail_path + }; + + // 检查文件是否存在 + if Path::new(clean_path).exists() { + // 文件存在,直接读取并返回 + let file_data = fs::read(clean_path) + .map_err(|e| format!("读取缩略图文件失败: {}", e))?; + + let base64_data = general_purpose::STANDARD.encode(&file_data); + return Ok(format!("data:image/jpeg;base64,{}", base64_data)); + } + } + + // 缩略图不存在或文件已丢失,需要重新生成 + let video_path = &segment.file_path; + + // 去掉Windows长路径前缀 + let clean_video_path = if video_path.starts_with("\\\\?\\") { + &video_path[4..] + } else { + video_path + }; + + // 生成缩略图路径 + let thumbnail_filename = format!("{}_thumbnail.jpg", segment.id); + let video_dir = Path::new(clean_video_path).parent() + .ok_or("无法获取视频文件目录")?; + let thumbnail_path = video_dir.join(thumbnail_filename); + let thumbnail_path_str = thumbnail_path.to_string_lossy().to_string(); + + // 获取视频信息来确定合适的缩略图尺寸 + let video_info = FFmpegService::get_video_info(clean_video_path) + .map_err(|e| e.to_string())?; + + // 计算缩略图尺寸,保持宽高比,最大宽度160 + let max_width = 160; + let (thumb_width, thumb_height) = if video_info.width > 0 && video_info.height > 0 { + let aspect_ratio = video_info.width as f64 / video_info.height as f64; + if video_info.width > max_width { + let new_width = max_width; + let new_height = (new_width as f64 / aspect_ratio).round() as u32; + (new_width, new_height) + } else { + (video_info.width, video_info.height) + } + } else { + // 如果无法获取视频尺寸,使用默认值 + (160, 120) + }; + + // 生成缩略图(使用首帧) + let timestamp = segment.start_time; + FFmpegService::generate_thumbnail( + clean_video_path, + &thumbnail_path_str, + timestamp, + thumb_width, + thumb_height + ).map_err(|e| e.to_string())?; + + // 保存缩略图路径到数据库 + { + let material_repository_guard = app_state.material_repository.lock().unwrap(); + let material_repository = material_repository_guard.as_ref() + .ok_or("MaterialRepository未初始化")?; + + material_repository.update_segment_thumbnail(&segment_id, &thumbnail_path_str) + .map_err(|e| e.to_string())?; + } + + // 读取生成的缩略图文件并返回base64 + let file_data = fs::read(&thumbnail_path_str) + .map_err(|e| format!("读取生成的缩略图失败: {}", e))?; + + let base64_data = general_purpose::STANDARD.encode(&file_data); + Ok(format!("data:image/jpeg;base64,{}", base64_data)) +} + /// 测试场景检测命令(用于调试) #[command] pub async fn test_scene_detection(file_path: String) -> Result { diff --git a/apps/desktop/src/components/MaterialSegmentView.tsx b/apps/desktop/src/components/MaterialSegmentView.tsx index fd289d7..782c546 100644 --- a/apps/desktop/src/components/MaterialSegmentView.tsx +++ b/apps/desktop/src/components/MaterialSegmentView.tsx @@ -107,33 +107,19 @@ const playVideoSegment = async (filePath: string, startTime: number, endTime: nu } }; -// 生成片段缩略图(使用首帧) -const generateSegmentThumbnail = async (segment: SegmentWithDetails): Promise => { - try { - const result = await invoke('generate_and_save_segment_thumbnail', { - segmentId: segment.segment.id - }); - return result; - } catch (error) { - console.error('生成缩略图失败:', error); - return null; - } -}; // 缩略图显示组件 interface ThumbnailDisplayProps { segment: SegmentWithDetails; thumbnailCache: Map; setThumbnailCache: React.Dispatch>>; - generateSegmentThumbnail: (segment: SegmentWithDetails) => Promise; } const ThumbnailDisplay: React.FC = ({ segment, thumbnailCache, setThumbnailCache, - generateSegmentThumbnail }) => { const [loading, setLoading] = useState(false); const [thumbnailUrl, setThumbnailUrl] = useState(null); @@ -147,53 +133,28 @@ const ThumbnailDisplay: React.FC = ({ setThumbnailUrl(thumbnailCache.get(segmentId) || null); return; } - // 首先检查数据库中是否已有缩略图 - if (segment.segment.thumbnail_path) { - try { - console.log('读取缩略图:', segment.segment.thumbnail_path); - const dataUrl = await invoke('read_thumbnail_as_data_url', { - filePath: segment.segment.thumbnail_path - }); - console.log('获取到数据URL'); - setThumbnailUrl(dataUrl); - // 更新缓存 - setThumbnailCache(prev => new Map(prev.set(segmentId, dataUrl))); - return; - } catch (error) { - console.error('读取缩略图失败:', error); - // 如果读取失败,继续生成新的缩略图 - } - } - // 如果数据库中没有缩略图,则生成新的 + // 使用新的统一接口获取缩略图 setLoading(true); try { - const thumbnailPath = await generateSegmentThumbnail(segment); - if (thumbnailPath) { - try { - console.log('生成的缩略图路径:', thumbnailPath); - const cleanPath = thumbnailPath.replace(/^\\\\\?\\/, ''); - const dataUrl = await invoke('read_thumbnail_as_data_url', { - filePath: cleanPath - }); - console.log('转换为数据URL成功'); - setThumbnailUrl(dataUrl); + console.log('获取片段缩略图:', segmentId); + const dataUrl = await invoke('get_segment_thumbnail_base64', { + segmentId: segmentId + }); + console.log('获取缩略图成功'); + setThumbnailUrl(dataUrl); - // 更新缓存 - setThumbnailCache(prev => new Map(prev.set(segmentId, dataUrl))); - } catch (error) { - console.error('读取生成的缩略图失败:', error); - } - } + // 更新缓存 + setThumbnailCache(prev => new Map(prev.set(segmentId, dataUrl))); } catch (error) { - console.error('加载缩略图失败:', error); + console.error('获取缩略图失败:', error); } finally { setLoading(false); } }; loadThumbnail(); - }, [segment.segment.id, segment.segment.thumbnail_path, thumbnailCache, setThumbnailCache, generateSegmentThumbnail]); + }, [segment.segment.id, thumbnailCache, setThumbnailCache]); return (
@@ -352,7 +313,6 @@ export const MaterialSegmentView: React.FC = ({ projec segment={segment} thumbnailCache={thumbnailCache} setThumbnailCache={setThumbnailCache} - generateSegmentThumbnail={generateSegmentThumbnail} /> {/* 内容信息 */} @@ -501,8 +461,8 @@ export const MaterialSegmentView: React.FC = ({ projec key={option.value} onClick={() => setSelectedClassification(option.value)} className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${selectedClassification === option.value - ? 'bg-blue-100 text-blue-800 border border-blue-200' - : 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200' + ? 'bg-blue-100 text-blue-800 border border-blue-200' + : 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200' }`} > {option.label} @@ -526,8 +486,8 @@ export const MaterialSegmentView: React.FC = ({ projec key={option.value} onClick={() => setSelectedModel(option.value)} className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${selectedModel === option.value - ? 'bg-green-100 text-green-800 border border-green-200' - : 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200' + ? 'bg-green-100 text-green-800 border border-green-200' + : 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200' }`} > {option.label}