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

View File

@ -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<Vec<MaterialSegment>> {
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<Option<MaterialSegment>> {
/// 根据片段ID获取片段信息(同步版本)
pub fn get_segment_by_id_sync(&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, 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<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");
}
// 添加缩略图路径字段到素材片段表
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()?;

View File

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

View File

@ -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,

View File

@ -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<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]
pub async fn test_scene_detection(file_path: String) -> Result<String, String> {

View File

@ -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<string | null> => {
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<string | null>('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<ThumbnailDisplayProps> = ({
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<ThumbnailDisplayProps> = ({
};
loadThumbnail();
}, [segment.segment.id, thumbnailCache, setThumbnailCache, generateSegmentThumbnail]);
}, [segment.segment.id, segment.segment.thumbnail_path, thumbnailCache, setThumbnailCache, generateSegmentThumbnail]);
return (
<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;
file_path: string;
file_size: number;
thumbnail_path?: string; // 缩略图路径
created_at: string;
}