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:
parent
70e4acb2c2
commit
4d61fb69f3
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
})?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export interface MaterialSegment {
|
|||
duration: number;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
thumbnail_path?: string; // 缩略图路径
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue