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 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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -594,6 +594,9 @@ impl FFmpegService {
|
||||||
"-ss", ×tamp.to_string(),
|
"-ss", ×tamp.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
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue