feat: 创建统一的缩略图获取接口,使用视频实际尺寸

新增get_segment_thumbnail_base64接口:
- 根据segmentId统一获取缩略图base64数据URL
- 智能检查:数据库路径 -> 文件存在性 -> 自动重新生成
- 完整的错误处理和文件验证机制

 使用视频实际尺寸生成缩略图:
- 添加get_video_info方法获取视频元数据
- 保持原始宽高比,最大宽度160像素
- 支持音频流信息解析和完整的VideoMetadata结构

 代码优化:
- 简化前端缩略图加载逻辑,统一使用新接口
- 移除重复的generateSegmentThumbnail函数
- 清理不必要的参数传递和依赖项

 功能特点:
- 自动检测文件丢失并重新生成
- 使用视频原始尺寸保持最佳显示效果
- 统一的错误处理和缓存机制
- 减少代码重复,提高维护性

现在缩略图生成更加智能和高效,能够自动处理文件丢失的情况,并使用视频的实际尺寸生成最佳质量的缩略图。
This commit is contained in:
imeepos 2025-07-15 22:49:53 +08:00
parent 44f3f40705
commit 8c742bf262
4 changed files with 239 additions and 57 deletions

View File

@ -611,6 +611,95 @@ impl FFmpegService {
Ok(())
}
/// 获取视频信息
pub fn get_video_info(input_path: &str) -> Result<VideoMetadata> {
if !Path::new(input_path).exists() {
return Err(anyhow!("输入文件不存在: {}", input_path));
}
let output = Self::create_hidden_command("ffprobe")
.args([
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
input_path
])
.output()
.map_err(|e| anyhow!("执行ffprobe失败: {}", e))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("ffprobe执行失败: {}", error_msg));
}
let json_str = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(&json_str)
.map_err(|e| anyhow!("解析ffprobe输出失败: {}", e))?;
// 查找视频流
let streams = json["streams"].as_array()
.ok_or_else(|| anyhow!("未找到流信息"))?;
for stream in streams {
if stream["codec_type"].as_str() == Some("video") {
let width = stream["width"].as_u64().unwrap_or(0) as u32;
let height = stream["height"].as_u64().unwrap_or(0) as u32;
let duration = stream["duration"].as_str()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let fps = stream["r_frame_rate"].as_str()
.and_then(|s| {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() == 2 {
let num = parts[0].parse::<f64>().ok()?;
let den = parts[1].parse::<f64>().ok()?;
if den != 0.0 { Some(num / den) } else { None }
} else {
s.parse::<f64>().ok()
}
})
.unwrap_or(0.0);
// 查找音频流信息
let mut has_audio = false;
let mut audio_codec = None;
let mut audio_bitrate = None;
let mut audio_sample_rate = None;
for audio_stream in streams {
if audio_stream["codec_type"].as_str() == Some("audio") {
has_audio = true;
audio_codec = audio_stream["codec_name"].as_str().map(|s| s.to_string());
audio_bitrate = audio_stream["bit_rate"].as_str()
.and_then(|s| s.parse::<u64>().ok());
audio_sample_rate = audio_stream["sample_rate"].as_str()
.and_then(|s| s.parse::<u32>().ok());
break;
}
}
return Ok(VideoMetadata {
width,
height,
duration,
fps,
codec: stream["codec_name"].as_str().unwrap_or("unknown").to_string(),
bitrate: stream["bit_rate"].as_str()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0),
format: json["format"]["format_name"].as_str().unwrap_or("unknown").to_string(),
has_audio,
audio_codec,
audio_bitrate,
audio_sample_rate,
});
}
}
Err(anyhow!("未找到视频流"))
}
/// 检查 FFmpeg 版本
pub fn get_version() -> Result<String> {
let output = Self::create_hidden_command("ffmpeg")

View File

@ -75,6 +75,7 @@ pub fn run() {
commands::material_commands::generate_video_thumbnail,
commands::material_commands::generate_and_save_segment_thumbnail,
commands::material_commands::read_thumbnail_as_data_url,
commands::material_commands::get_segment_thumbnail_base64,
commands::material_commands::test_scene_detection,
commands::material_commands::get_material_segments,
commands::material_commands::test_video_split,

View File

@ -830,12 +830,33 @@ pub async fn generate_and_save_segment_thumbnail(
// 生成缩略图(使用首帧)
let timestamp = segment.start_time;
// 获取视频元数据来确定合适的缩略图尺寸
let video_info = FFmpegService::get_video_info(video_path)
.map_err(|e| e.to_string())?;
// 计算缩略图尺寸保持宽高比最大宽度160
let max_width = 160;
let (thumb_width, thumb_height) = if video_info.width > 0 && video_info.height > 0 {
let aspect_ratio = video_info.width as f64 / video_info.height as f64;
if video_info.width > max_width {
let new_width = max_width;
let new_height = (new_width as f64 / aspect_ratio).round() as u32;
(new_width, new_height)
} else {
(video_info.width, video_info.height)
}
} else {
// 如果无法获取视频尺寸,使用默认值
(160, 120)
};
FFmpegService::generate_thumbnail(
video_path,
&thumbnail_path_str,
timestamp,
160,
120
thumb_width,
thumb_height
).map_err(|e| e.to_string())?;
// 保存缩略图路径到数据库
@ -875,6 +896,117 @@ pub async fn read_thumbnail_as_data_url(file_path: String) -> Result<String, Str
Ok(format!("data:image/jpeg;base64,{}", base64_data))
}
/// 根据segmentId获取缩略图base64数据URL
#[command]
pub async fn get_segment_thumbnail_base64(
segment_id: String,
app_state: State<'_, AppState>,
) -> Result<String, String> {
use crate::infrastructure::ffmpeg::FFmpegService;
use std::path::Path;
use std::fs;
use base64::{Engine as _, engine::general_purpose};
// 获取片段信息
let segment = {
let material_repository_guard = app_state.material_repository.lock().unwrap();
let material_repository = material_repository_guard.as_ref()
.ok_or("MaterialRepository未初始化")?;
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 {
// 去掉Windows长路径前缀
let clean_path = if thumbnail_path.starts_with("\\\\?\\") {
&thumbnail_path[4..]
} else {
thumbnail_path
};
// 检查文件是否存在
if Path::new(clean_path).exists() {
// 文件存在,直接读取并返回
let file_data = fs::read(clean_path)
.map_err(|e| format!("读取缩略图文件失败: {}", e))?;
let base64_data = general_purpose::STANDARD.encode(&file_data);
return Ok(format!("data:image/jpeg;base64,{}", base64_data));
}
}
// 缩略图不存在或文件已丢失,需要重新生成
let video_path = &segment.file_path;
// 去掉Windows长路径前缀
let clean_video_path = if video_path.starts_with("\\\\?\\") {
&video_path[4..]
} else {
video_path
};
// 生成缩略图路径
let thumbnail_filename = format!("{}_thumbnail.jpg", segment.id);
let video_dir = Path::new(clean_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 video_info = FFmpegService::get_video_info(clean_video_path)
.map_err(|e| e.to_string())?;
// 计算缩略图尺寸保持宽高比最大宽度160
let max_width = 160;
let (thumb_width, thumb_height) = if video_info.width > 0 && video_info.height > 0 {
let aspect_ratio = video_info.width as f64 / video_info.height as f64;
if video_info.width > max_width {
let new_width = max_width;
let new_height = (new_width as f64 / aspect_ratio).round() as u32;
(new_width, new_height)
} else {
(video_info.width, video_info.height)
}
} else {
// 如果无法获取视频尺寸,使用默认值
(160, 120)
};
// 生成缩略图(使用首帧)
let timestamp = segment.start_time;
FFmpegService::generate_thumbnail(
clean_video_path,
&thumbnail_path_str,
timestamp,
thumb_width,
thumb_height
).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())?;
}
// 读取生成的缩略图文件并返回base64
let file_data = fs::read(&thumbnail_path_str)
.map_err(|e| format!("读取生成的缩略图失败: {}", e))?;
let base64_data = general_purpose::STANDARD.encode(&file_data);
Ok(format!("data:image/jpeg;base64,{}", base64_data))
}
/// 测试场景检测命令(用于调试)
#[command]
pub async fn test_scene_detection(file_path: String) -> Result<String, String> {

View File

@ -107,33 +107,19 @@ const playVideoSegment = async (filePath: string, startTime: number, endTime: nu
}
};
// 生成片段缩略图(使用首帧)
const generateSegmentThumbnail = async (segment: SegmentWithDetails): Promise<string | null> => {
try {
const result = await invoke<string | null>('generate_and_save_segment_thumbnail', {
segmentId: segment.segment.id
});
return result;
} catch (error) {
console.error('生成缩略图失败:', error);
return null;
}
};
// 缩略图显示组件
interface ThumbnailDisplayProps {
segment: SegmentWithDetails;
thumbnailCache: Map<string, string>;
setThumbnailCache: React.Dispatch<React.SetStateAction<Map<string, string>>>;
generateSegmentThumbnail: (segment: SegmentWithDetails) => Promise<string | null>;
}
const ThumbnailDisplay: React.FC<ThumbnailDisplayProps> = ({
segment,
thumbnailCache,
setThumbnailCache,
generateSegmentThumbnail
}) => {
const [loading, setLoading] = useState(false);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
@ -147,53 +133,28 @@ const ThumbnailDisplay: React.FC<ThumbnailDisplayProps> = ({
setThumbnailUrl(thumbnailCache.get(segmentId) || null);
return;
}
// 首先检查数据库中是否已有缩略图
if (segment.segment.thumbnail_path) {
try {
console.log('读取缩略图:', segment.segment.thumbnail_path);
const dataUrl = await invoke<string>('read_thumbnail_as_data_url', {
filePath: segment.segment.thumbnail_path
});
console.log('获取到数据URL');
setThumbnailUrl(dataUrl);
// 更新缓存
setThumbnailCache(prev => new Map(prev.set(segmentId, dataUrl)));
return;
} catch (error) {
console.error('读取缩略图失败:', error);
// 如果读取失败,继续生成新的缩略图
}
}
// 如果数据库中没有缩略图,则生成新的
// 使用新的统一接口获取缩略图
setLoading(true);
try {
const thumbnailPath = await generateSegmentThumbnail(segment);
if (thumbnailPath) {
try {
console.log('生成的缩略图路径:', thumbnailPath);
const cleanPath = thumbnailPath.replace(/^\\\\\?\\/, '');
const dataUrl = await invoke<string>('read_thumbnail_as_data_url', {
filePath: cleanPath
});
console.log('转换为数据URL成功');
setThumbnailUrl(dataUrl);
console.log('获取片段缩略图:', segmentId);
const dataUrl = await invoke<string>('get_segment_thumbnail_base64', {
segmentId: segmentId
});
console.log('获取缩略图成功');
setThumbnailUrl(dataUrl);
// 更新缓存
setThumbnailCache(prev => new Map(prev.set(segmentId, dataUrl)));
} catch (error) {
console.error('读取生成的缩略图失败:', error);
}
}
// 更新缓存
setThumbnailCache(prev => new Map(prev.set(segmentId, dataUrl)));
} catch (error) {
console.error('加载缩略图失败:', error);
console.error('获取缩略图失败:', error);
} finally {
setLoading(false);
}
};
loadThumbnail();
}, [segment.segment.id, segment.segment.thumbnail_path, thumbnailCache, setThumbnailCache, generateSegmentThumbnail]);
}, [segment.segment.id, thumbnailCache, setThumbnailCache]);
return (
<div className="w-20 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
@ -352,7 +313,6 @@ export const MaterialSegmentView: React.FC<MaterialSegmentViewProps> = ({ projec
segment={segment}
thumbnailCache={thumbnailCache}
setThumbnailCache={setThumbnailCache}
generateSegmentThumbnail={generateSegmentThumbnail}
/>
{/* 内容信息 */}
@ -501,8 +461,8 @@ export const MaterialSegmentView: React.FC<MaterialSegmentViewProps> = ({ projec
key={option.value}
onClick={() => setSelectedClassification(option.value)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${selectedClassification === option.value
? 'bg-blue-100 text-blue-800 border border-blue-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200'
? 'bg-blue-100 text-blue-800 border border-blue-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200'
}`}
>
<span>{option.label}</span>
@ -526,8 +486,8 @@ export const MaterialSegmentView: React.FC<MaterialSegmentViewProps> = ({ projec
key={option.value}
onClick={() => setSelectedModel(option.value)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${selectedModel === option.value
? 'bg-green-100 text-green-800 border border-green-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200'
? 'bg-green-100 text-green-800 border border-green-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200'
}`}
>
<span>{option.label}</span>