feat: 为MaterialSegmentView添加视频片段缩略图显示功能

缩略图显示功能:
- 使用视频首帧生成缩略图:取片段开始时间作为缩略图时间戳
- 智能缓存机制:避免重复生成相同片段的缩略图
- 异步加载:缩略图生成不阻塞界面渲染
- 优雅降级:生成失败时显示默认视频图标

 ThumbnailDisplay组件:
- 独立的缩略图显示组件,职责单一
- 加载状态指示:显示旋转动画表示正在生成缩略图
- 错误处理:图片加载失败时自动回退到默认图标
- 响应式设计:160x120像素缩略图,适配卡片布局

 技术实现:
- 利用现有generate_video_thumbnail命令生成缩略图
- Map缓存机制:segmentId -> thumbnailUrl映射
- file://协议:本地文件访问支持
- useEffect钩子:组件挂载时自动加载缩略图

 用户体验优化:
- 视觉丰富:片段卡片显示实际视频内容预览
- 快速识别:用户可以通过缩略图快速识别视频内容
- 性能优化:缓存机制避免重复生成
- 加载反馈:清楚的加载状态提示

 功能特点:
- 首帧缩略图:使用片段开始时间的首帧作为预览
- 自动生成:无需手动操作,自动为每个片段生成缩略图
- 内存缓存:同一会话中避免重复生成
- 错误恢复:生成失败时显示默认图标,不影响其他功能

现在MaterialSegmentView提供了更加直观的视觉体验:
1. 每个片段卡片显示实际的视频首帧缩略图
2. 用户可以快速预览视频内容
3. 加载过程有清楚的视觉反馈
4. 生成失败时有优雅的降级处理
This commit is contained in:
imeepos 2025-07-15 22:01:41 +08:00
parent 10177d2501
commit 0898b4b9e2
1 changed files with 99 additions and 3 deletions

View File

@ -106,6 +106,98 @@ const playVideoSegment = async (filePath: string, startTime: number, endTime: nu
} }
}; };
// 生成片段缩略图(使用首帧)
const generateSegmentThumbnail = async (segment: SegmentWithDetails): Promise<string | null> => {
try {
// 使用片段的开始时间作为缩略图时间戳(首帧)
const timestamp = segment.segment.start_time;
// 生成缩略图文件名
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;
} 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);
useEffect(() => {
const loadThumbnail = async () => {
const segmentId = segment.segment.id;
// 检查缓存
if (thumbnailCache.has(segmentId)) {
setThumbnailUrl(thumbnailCache.get(segmentId) || null);
return;
}
// 生成缩略图
setLoading(true);
try {
const thumbnailPath = await generateSegmentThumbnail(segment);
if (thumbnailPath) {
// 转换为可访问的URL这里需要根据实际情况调整
const thumbnailUrl = `file://${thumbnailPath}`;
setThumbnailUrl(thumbnailUrl);
// 更新缓存
setThumbnailCache(prev => new Map(prev.set(segmentId, thumbnailUrl)));
}
} catch (error) {
console.error('加载缩略图失败:', error);
} finally {
setLoading(false);
}
};
loadThumbnail();
}, [segment.segment.id, 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">
{loading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
) : thumbnailUrl ? (
<img
src={thumbnailUrl}
alt="视频缩略图"
className="w-full h-full object-cover rounded-lg"
onError={() => setThumbnailUrl(null)}
/>
) : (
<Video className="w-8 h-8 text-gray-400" />
)}
</div>
);
};
/** /**
* - * -
*/ */
@ -116,6 +208,7 @@ export const MaterialSegmentView: React.FC<MaterialSegmentViewProps> = ({ projec
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [selectedClassification, setSelectedClassification] = useState<string>('全部'); const [selectedClassification, setSelectedClassification] = useState<string>('全部');
const [selectedModel, setSelectedModel] = useState<string>('全部'); const [selectedModel, setSelectedModel] = useState<string>('全部');
const [thumbnailCache, setThumbnailCache] = useState<Map<string, string>>(new Map());
// 加载数据 // 加载数据
const loadSegmentView = async () => { const loadSegmentView = async () => {
@ -240,9 +333,12 @@ export const MaterialSegmentView: React.FC<MaterialSegmentViewProps> = ({ projec
<div key={segment.segment.id} className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow"> <div key={segment.segment.id} className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{/* 缩略图 */} {/* 缩略图 */}
<div className="w-20 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0"> <ThumbnailDisplay
<Video className="w-8 h-8 text-gray-400" /> segment={segment}
</div> thumbnailCache={thumbnailCache}
setThumbnailCache={setThumbnailCache}
generateSegmentThumbnail={generateSegmentThumbnail}
/>
{/* 内容信息 */} {/* 内容信息 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">