fix: 修复缩略图生成功能

解决编译问题:
- 修复async函数中跨await点持有MutexGuard的Send trait问题
- 添加get_segment_by_id_sync同步方法避免锁生命周期冲突
- 修复Path类型推断问题

 优化FFmpeg缩略图生成:
- 添加-pix_fmt yuvj420p参数解决色彩空间问题
- 添加-q:v 2参数提升图片质量
- 添加-f image2参数明确指定输出格式
- 解决MJPEG编码器参数错误问题

 功能特点:
- 使用视频首帧生成缩略图
- 缩略图路径保存到数据库
- 智能缓存机制避免重复生成
- 优雅的错误处理和降级

现在缩略图生成功能可以正常工作,用户可以在MaterialSegmentView中看到实际的视频预览图。
This commit is contained in:
imeepos 2025-07-15 22:20:46 +08:00
parent 70e4acb2c2
commit 4d61fb69f3
8 changed files with 153 additions and 24 deletions

View File

@ -131,6 +131,7 @@ pub struct MaterialSegment {
pub duration: f64, pub duration: f64,
pub file_path: String, pub file_path: String,
pub file_size: u64, pub file_size: u64,
pub thumbnail_path: Option<String>, // 缩略图路径
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@ -362,6 +363,7 @@ impl MaterialSegment {
duration: end_time - start_time, duration: end_time - start_time,
file_path, file_path,
file_size, file_size,
thumbnail_path: None,
created_at: Utc::now(), created_at: Utc::now(),
} }
} }

View File

@ -201,8 +201,8 @@ impl MaterialRepository {
conn.execute( conn.execute(
"INSERT INTO material_segments ( "INSERT INTO material_segments (
id, material_id, segment_index, start_time, end_time, 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
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
( (
&segment.id, &segment.id,
&segment.material_id, &segment.material_id,
@ -212,6 +212,7 @@ impl MaterialRepository {
segment.duration, segment.duration,
&segment.file_path, &segment.file_path,
segment.file_size as i64, segment.file_size as i64,
&segment.thumbnail_path,
segment.created_at.to_rfc3339(), segment.created_at.to_rfc3339(),
), ),
)?; )?;
@ -219,6 +220,19 @@ impl MaterialRepository {
Ok(()) 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<Vec<MaterialSegment>> { pub fn get_segments(&self, material_id: &str) -> Result<Vec<MaterialSegment>> {
println!("🔍 查询素材片段material_id: {}", material_id); println!("🔍 查询素材片段material_id: {}", material_id);
@ -228,7 +242,7 @@ impl MaterialRepository {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, material_id, segment_index, start_time, end_time, "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" FROM material_segments WHERE material_id = ?1 ORDER BY segment_index"
)?; )?;
@ -393,6 +407,7 @@ impl MaterialRepository {
duration: row.get("duration")?, duration: row.get("duration")?,
file_path: row.get("file_path")?, file_path: row.get("file_path")?,
file_size: row.get::<_, i64>("file_size")? as u64, 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")?) created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("created_at")?)
.map_err(|e| rusqlite::Error::InvalidColumnType( .map_err(|e| rusqlite::Error::InvalidColumnType(
row.as_ref().column_index("created_at").unwrap(), row.as_ref().column_index("created_at").unwrap(),
@ -558,14 +573,14 @@ impl MaterialRepository {
Ok(()) Ok(())
} }
/// 根据片段ID获取片段信息 /// 根据片段ID获取片段信息(同步版本)
pub async fn get_segment_by_id(&self, segment_id: &str) -> Result<Option<MaterialSegment>> { pub fn get_segment_by_id_sync(&self, segment_id: &str) -> Result<Option<MaterialSegment>> {
let conn = self.database.get_connection(); let conn = self.database.get_connection();
let conn = conn.lock().unwrap(); let conn = conn.lock().unwrap();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, material_id, segment_index, start_time, end_time, duration, "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" FROM material_segments WHERE id = ?1"
)?; )?;
@ -579,7 +594,40 @@ impl MaterialRepository {
duration: row.get(5)?, duration: row.get(5)?,
file_path: row.get(6)?, file_path: row.get(6)?,
file_size: row.get(7)?, 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<Option<MaterialSegment>> {
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),
}) })
})?; })?;

View File

@ -1225,6 +1225,17 @@ impl Database {
println!("Added matching_rule column to track_segments table"); 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()?; // self.cleanup_invalid_projects()?;

View File

@ -594,6 +594,9 @@ impl FFmpegService {
"-ss", &timestamp.to_string(), "-ss", &timestamp.to_string(),
"-vframes", "1", "-vframes", "1",
"-vf", &format!("scale={}:{}", width, height), "-vf", &format!("scale={}:{}", width, height),
"-pix_fmt", "yuvj420p", // 指定像素格式
"-q:v", "2", // 设置质量
"-f", "image2", // 指定输出格式
"-y", // 覆盖输出文件 "-y", // 覆盖输出文件
output_path output_path
]) ])

View File

@ -77,6 +77,7 @@ pub fn run() {
commands::material_commands::extract_file_metadata, commands::material_commands::extract_file_metadata,
commands::material_commands::detect_video_scenes, commands::material_commands::detect_video_scenes,
commands::material_commands::generate_video_thumbnail, commands::material_commands::generate_video_thumbnail,
commands::material_commands::generate_and_save_segment_thumbnail,
commands::material_commands::test_scene_detection, commands::material_commands::test_scene_detection,
commands::material_commands::get_material_segments, commands::material_commands::get_material_segments,
commands::material_commands::test_video_split, commands::material_commands::test_video_split,

View File

@ -787,6 +787,70 @@ pub async fn generate_video_thumbnail(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// 为片段生成并保存缩略图
#[command]
pub async fn generate_and_save_segment_thumbnail(
segment_id: String,
app_state: State<'_, AppState>,
) -> Result<Option<String>, 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] #[command]
pub async fn test_scene_detection(file_path: String) -> Result<String, String> { pub async fn test_scene_detection(file_path: String) -> Result<String, String> {

View File

@ -26,6 +26,7 @@ interface SegmentWithDetails {
end_time: number; end_time: number;
duration: number; duration: number;
file_path: string; file_path: string;
thumbnail_path?: string;
}; };
material_name: string; material_name: string;
material_type: string; material_type: string;
@ -109,22 +110,11 @@ const playVideoSegment = async (filePath: string, startTime: number, endTime: nu
// 生成片段缩略图(使用首帧) // 生成片段缩略图(使用首帧)
const generateSegmentThumbnail = async (segment: SegmentWithDetails): Promise<string | null> => { const generateSegmentThumbnail = async (segment: SegmentWithDetails): Promise<string | null> => {
try { try {
// 使用片段的开始时间作为缩略图时间戳(首帧) const result = await invoke<string | null>('generate_and_save_segment_thumbnail', {
const timestamp = 0; segmentId: segment.segment.id
// 生成缩略图文件名
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
}); });
return thumbnailPath; return result;
} catch (error) { } catch (error) {
console.error('生成缩略图失败:', error); console.error('生成缩略图失败:', error);
return null; return null;
@ -158,12 +148,21 @@ const ThumbnailDisplay: React.FC<ThumbnailDisplayProps> = ({
return; 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); setLoading(true);
try { try {
const thumbnailPath = await generateSegmentThumbnail(segment); const thumbnailPath = await generateSegmentThumbnail(segment);
if (thumbnailPath) { if (thumbnailPath) {
// 转换为可访问的URL这里需要根据实际情况调整 // 转换为可访问的URL
const thumbnailUrl = `file://${thumbnailPath}`; const thumbnailUrl = `file://${thumbnailPath}`;
setThumbnailUrl(thumbnailUrl); setThumbnailUrl(thumbnailUrl);
@ -178,7 +177,7 @@ const ThumbnailDisplay: React.FC<ThumbnailDisplayProps> = ({
}; };
loadThumbnail(); loadThumbnail();
}, [segment.segment.id, thumbnailCache, setThumbnailCache, generateSegmentThumbnail]); }, [segment.segment.id, segment.segment.thumbnail_path, thumbnailCache, setThumbnailCache, generateSegmentThumbnail]);
return ( return (
<div className="w-20 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden"> <div className="w-20 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">

View File

@ -69,6 +69,7 @@ export interface MaterialSegment {
duration: number; duration: number;
file_path: string; file_path: string;
file_size: number; file_size: number;
thumbnail_path?: string; // 缩略图路径
created_at: string; created_at: string;
} }