feat: 创建统一的缩略图获取接口,使用视频实际尺寸
新增get_segment_thumbnail_base64接口: - 根据segmentId统一获取缩略图base64数据URL - 智能检查:数据库路径 -> 文件存在性 -> 自动重新生成 - 完整的错误处理和文件验证机制 使用视频实际尺寸生成缩略图: - 添加get_video_info方法获取视频元数据 - 保持原始宽高比,最大宽度160像素 - 支持音频流信息解析和完整的VideoMetadata结构 代码优化: - 简化前端缩略图加载逻辑,统一使用新接口 - 移除重复的generateSegmentThumbnail函数 - 清理不必要的参数传递和依赖项 功能特点: - 自动检测文件丢失并重新生成 - 使用视频原始尺寸保持最佳显示效果 - 统一的错误处理和缓存机制 - 减少代码重复,提高维护性 现在缩略图生成更加智能和高效,能够自动处理文件丢失的情况,并使用视频的实际尺寸生成最佳质量的缩略图。
This commit is contained in:
parent
44f3f40705
commit
8c742bf262
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue