diff --git a/apps/desktop/src-tauri/src/data/models/material.rs b/apps/desktop/src-tauri/src/data/models/material.rs index 738e996..c571c11 100644 --- a/apps/desktop/src-tauri/src/data/models/material.rs +++ b/apps/desktop/src-tauri/src/data/models/material.rs @@ -131,6 +131,7 @@ pub struct MaterialSegment { pub duration: f64, pub file_path: String, pub file_size: u64, + pub thumbnail_path: Option, // 缩略图路径 pub created_at: DateTime, } @@ -362,6 +363,7 @@ impl MaterialSegment { duration: end_time - start_time, file_path, file_size, + thumbnail_path: None, created_at: Utc::now(), } } diff --git a/apps/desktop/src-tauri/src/data/repositories/material_repository.rs b/apps/desktop/src-tauri/src/data/repositories/material_repository.rs index 0c649a0..9fa67a9 100644 --- a/apps/desktop/src-tauri/src/data/repositories/material_repository.rs +++ b/apps/desktop/src-tauri/src/data/repositories/material_repository.rs @@ -201,8 +201,8 @@ impl MaterialRepository { conn.execute( "INSERT INTO material_segments ( id, material_id, segment_index, start_time, end_time, - duration, file_path, file_size, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + duration, file_path, file_size, thumbnail_path, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", ( &segment.id, &segment.material_id, @@ -212,6 +212,7 @@ impl MaterialRepository { segment.duration, &segment.file_path, segment.file_size as i64, + &segment.thumbnail_path, segment.created_at.to_rfc3339(), ), )?; @@ -219,6 +220,19 @@ impl MaterialRepository { Ok(()) } + /// 更新片段的缩略图路径 + pub fn update_segment_thumbnail(&self, segment_id: &str, thumbnail_path: &str) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + conn.execute( + "UPDATE material_segments SET thumbnail_path = ?1 WHERE id = ?2", + (thumbnail_path, segment_id), + )?; + + Ok(()) + } + /// 获取素材的所有片段 pub fn get_segments(&self, material_id: &str) -> Result> { println!("🔍 查询素材片段,material_id: {}", material_id); @@ -228,7 +242,7 @@ impl MaterialRepository { let mut stmt = conn.prepare( "SELECT id, material_id, segment_index, start_time, end_time, - duration, file_path, file_size, created_at + duration, file_path, file_size, thumbnail_path, created_at FROM material_segments WHERE material_id = ?1 ORDER BY segment_index" )?; @@ -393,6 +407,7 @@ impl MaterialRepository { duration: row.get("duration")?, file_path: row.get("file_path")?, file_size: row.get::<_, i64>("file_size")? as u64, + thumbnail_path: row.get("thumbnail_path")?, created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("created_at")?) .map_err(|e| rusqlite::Error::InvalidColumnType( row.as_ref().column_index("created_at").unwrap(), @@ -558,14 +573,14 @@ impl MaterialRepository { Ok(()) } - /// 根据片段ID获取片段信息 - pub async fn get_segment_by_id(&self, segment_id: &str) -> Result> { + /// 根据片段ID获取片段信息(同步版本) + pub fn get_segment_by_id_sync(&self, segment_id: &str) -> Result> { let conn = self.database.get_connection(); let conn = conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, material_id, segment_index, start_time, end_time, duration, - file_path, file_size, created_at + file_path, file_size, thumbnail_path, created_at FROM material_segments WHERE id = ?1" )?; @@ -579,7 +594,40 @@ impl MaterialRepository { duration: row.get(5)?, file_path: row.get(6)?, file_size: row.get(7)?, - created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(8)?).map_err(|_| rusqlite::Error::InvalidColumnType(8, "created_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&chrono::Utc), + thumbnail_path: row.get(8)?, + created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(9)?).map_err(|_| rusqlite::Error::InvalidColumnType(9, "created_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&chrono::Utc), + }) + })?; + + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } + } + + /// 根据片段ID获取片段信息 + pub async fn get_segment_by_id(&self, segment_id: &str) -> Result> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT id, material_id, segment_index, start_time, end_time, duration, + file_path, file_size, thumbnail_path, created_at + FROM material_segments WHERE id = ?1" + )?; + + let mut rows = stmt.query_map([segment_id], |row| { + Ok(MaterialSegment { + id: row.get(0)?, + material_id: row.get(1)?, + segment_index: row.get(2)?, + start_time: row.get(3)?, + end_time: row.get(4)?, + duration: row.get(5)?, + file_path: row.get(6)?, + file_size: row.get(7)?, + thumbnail_path: row.get(8)?, + created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(9)?).map_err(|_| rusqlite::Error::InvalidColumnType(9, "created_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&chrono::Utc), }) })?; diff --git a/apps/desktop/src-tauri/src/infrastructure/database.rs b/apps/desktop/src-tauri/src/infrastructure/database.rs index 4f0b8c4..27b41c9 100644 --- a/apps/desktop/src-tauri/src/infrastructure/database.rs +++ b/apps/desktop/src-tauri/src/infrastructure/database.rs @@ -1225,6 +1225,17 @@ impl Database { println!("Added matching_rule column to track_segments table"); } + // 添加缩略图路径字段到素材片段表 + let has_thumbnail_path_column = conn.prepare("SELECT thumbnail_path FROM material_segments LIMIT 1").is_ok(); + if !has_thumbnail_path_column { + println!("Adding thumbnail_path column to material_segments table"); + conn.execute( + "ALTER TABLE material_segments ADD COLUMN thumbnail_path TEXT", + [], + )?; + println!("Added thumbnail_path column to material_segments table"); + } + // 暂时禁用自动清理,避免启动时卡住 // self.cleanup_invalid_projects()?; diff --git a/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs b/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs index 34221da..74114e3 100644 --- a/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs +++ b/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs @@ -594,6 +594,9 @@ impl FFmpegService { "-ss", ×tamp.to_string(), "-vframes", "1", "-vf", &format!("scale={}:{}", width, height), + "-pix_fmt", "yuvj420p", // 指定像素格式 + "-q:v", "2", // 设置质量 + "-f", "image2", // 指定输出格式 "-y", // 覆盖输出文件 output_path ]) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 49392c4..b330042 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -77,6 +77,7 @@ pub fn run() { commands::material_commands::extract_file_metadata, commands::material_commands::detect_video_scenes, commands::material_commands::generate_video_thumbnail, + commands::material_commands::generate_and_save_segment_thumbnail, 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 b88fce8..0234883 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs @@ -787,6 +787,70 @@ pub async fn generate_video_thumbnail( .map_err(|e| e.to_string()) } +/// 为片段生成并保存缩略图 +#[command] +pub async fn generate_and_save_segment_thumbnail( + segment_id: String, + app_state: State<'_, AppState>, +) -> Result, String> { + use crate::infrastructure::ffmpeg::FFmpegService; + use std::path::Path; + + // 获取片段信息(不跨越await点持有锁) + let segment = { + let material_repository_guard = app_state.material_repository.lock().unwrap(); + let material_repository = material_repository_guard.as_ref() + .ok_or("MaterialRepository未初始化")?; + + // 这里不能使用await,因为会跨越锁的生命周期 + // 我们需要使用同步方法或者重新设计 + 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 { + if std::path::Path::new(thumbnail_path).exists() { + return Ok(Some(thumbnail_path.clone())); + } + } + + // 生成缩略图路径 + let video_path = &segment.file_path; + let thumbnail_filename = format!("{}_thumbnail.jpg", segment.id); + let video_dir = Path::new(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 timestamp = segment.start_time; + FFmpegService::generate_thumbnail( + video_path, + &thumbnail_path_str, + timestamp, + 160, + 120 + ).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())?; + } + + Ok(Some(thumbnail_path_str)) +} + /// 测试场景检测命令(用于调试) #[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 e164d1a..7c201af 100644 --- a/apps/desktop/src/components/MaterialSegmentView.tsx +++ b/apps/desktop/src/components/MaterialSegmentView.tsx @@ -26,6 +26,7 @@ interface SegmentWithDetails { end_time: number; duration: number; file_path: string; + thumbnail_path?: string; }; material_name: string; material_type: string; @@ -109,22 +110,11 @@ const playVideoSegment = async (filePath: string, startTime: number, endTime: nu // 生成片段缩略图(使用首帧) const generateSegmentThumbnail = async (segment: SegmentWithDetails): Promise => { try { - // 使用片段的开始时间作为缩略图时间戳(首帧) - const timestamp = 0; - - // 生成缩略图文件名 - const thumbnailFileName = `${segment.segment.id}_thumbnail.jpg`; - const thumbnailPath = `${segment.segment.file_path.replace(/\.[^/.]+$/, '')}_${thumbnailFileName}`; - - await invoke('generate_video_thumbnail', { - inputPath: segment.segment.file_path, - outputPath: thumbnailPath, - timestamp, - width: 160, - height: 120 + const result = await invoke('generate_and_save_segment_thumbnail', { + segmentId: segment.segment.id }); - return thumbnailPath; + return result; } catch (error) { console.error('生成缩略图失败:', error); return null; @@ -158,12 +148,21 @@ const ThumbnailDisplay: React.FC = ({ return; } - // 生成缩略图 + // 首先检查数据库中是否已有缩略图 + if (segment.segment.thumbnail_path) { + const thumbnailUrl = `file://${segment.segment.thumbnail_path}`; + setThumbnailUrl(thumbnailUrl); + // 更新缓存 + setThumbnailCache(prev => new Map(prev.set(segmentId, thumbnailUrl))); + return; + } + + // 如果数据库中没有缩略图,则生成新的 setLoading(true); try { const thumbnailPath = await generateSegmentThumbnail(segment); if (thumbnailPath) { - // 转换为可访问的URL(这里需要根据实际情况调整) + // 转换为可访问的URL const thumbnailUrl = `file://${thumbnailPath}`; setThumbnailUrl(thumbnailUrl); @@ -178,7 +177,7 @@ const ThumbnailDisplay: React.FC = ({ }; loadThumbnail(); - }, [segment.segment.id, thumbnailCache, setThumbnailCache, generateSegmentThumbnail]); + }, [segment.segment.id, segment.segment.thumbnail_path, thumbnailCache, setThumbnailCache, generateSegmentThumbnail]); return (
diff --git a/apps/desktop/src/types/material.ts b/apps/desktop/src/types/material.ts index 1271e2d..52bc52b 100644 --- a/apps/desktop/src/types/material.ts +++ b/apps/desktop/src/types/material.ts @@ -69,6 +69,7 @@ export interface MaterialSegment { duration: number; file_path: string; file_size: number; + thumbnail_path?: string; // 缩略图路径 created_at: string; }