feat: 添加视频预览播放器和时间联动功能

- 新增 VideoPlayer 组件,支持完整的视频播放控制
- 实现播放/暂停、快进/快退、音量控制、全屏等功能
- 添加键盘快捷键支持 (空格、方向键、M、F、R等)
- 实现视频播放时间与自定义时间输入的双向联动
- 在关键帧提取工具中集成视频预览功能
- 支持单个视频选择时显示预览播放器
- 添加'使用当前播放时间'按钮,方便设置提取时间点
- 优化用户界面,提升视频处理工作流体验
This commit is contained in:
imeepos
2025-08-11 13:19:16 +08:00
parent 332648d29e
commit 28c778e3ae
5 changed files with 562 additions and 18 deletions

View File

@@ -18,7 +18,8 @@ import {
FrameExtractionPreset, FrameExtractionPreset,
FrameType, FrameType,
ImageFormat, ImageFormat,
TimePoint TimePoint,
VideoFileInfo
} from '../../types/frame-extractor'; } from '../../types/frame-extractor';
interface FrameExtractionConfigPanelProps { interface FrameExtractionConfigPanelProps {
@@ -26,6 +27,9 @@ interface FrameExtractionConfigPanelProps {
onConfigChange: (config: FrameExtractionConfig) => void; onConfigChange: (config: FrameExtractionConfig) => void;
onApplyPreset: (presetId: string) => void; onApplyPreset: (presetId: string) => void;
presets: FrameExtractionPreset[]; presets: FrameExtractionPreset[];
currentVideo?: VideoFileInfo;
currentTime?: number;
onTimeChange?: (time: number) => void;
} }
/** /**
@@ -36,10 +40,26 @@ export const FrameExtractionConfigPanel: React.FC<FrameExtractionConfigPanelProp
config, config,
onConfigChange, onConfigChange,
onApplyPreset, onApplyPreset,
presets presets,
currentVideo,
currentTime,
onTimeChange
}) => { }) => {
const [customTimePoints, setCustomTimePoints] = useState<string>(''); const [customTimePoints, setCustomTimePoints] = useState<string>('');
// 格式化时长显示
const formatDuration = (seconds: number): string => {
if (isNaN(seconds)) return '00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// 更新配置 // 更新配置
const updateConfig = useCallback((updates: Partial<FrameExtractionConfig>) => { const updateConfig = useCallback((updates: Partial<FrameExtractionConfig>) => {
onConfigChange({ ...config, ...updates }); onConfigChange({ ...config, ...updates });
@@ -178,12 +198,32 @@ export const FrameExtractionConfigPanel: React.FC<FrameExtractionConfigPanelProp
type="number" type="number"
min="0" min="0"
step="0.1" step="0.1"
max={currentVideo?.duration || undefined}
value={config.custom_time || 0} value={config.custom_time || 0}
onChange={(e) => updateConfig({ custom_time: parseFloat(e.target.value) || 0 })} onChange={(e) => {
const time = parseFloat(e.target.value) || 0;
updateConfig({ custom_time: time });
onTimeChange?.(time);
}}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="输入时间(秒)" placeholder="输入时间(秒)"
/> />
</div> </div>
{currentVideo && (
<div className="flex items-center justify-between text-sm text-gray-500">
<span>: {formatDuration(currentVideo.duration)}</span>
{currentTime !== undefined && (
<button
onClick={() => {
updateConfig({ custom_time: currentTime });
}}
className="text-blue-600 hover:text-blue-800 font-medium"
>
使 ({formatDuration(currentTime)})
</button>
)}
</div>
)}
</div> </div>
)} )}

View File

@@ -0,0 +1,127 @@
/* 视频播放器样式 */
/* 进度条样式 */
.slider {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
.slider::-webkit-slider-track {
background: rgba(255, 255, 255, 0.3);
height: 4px;
border-radius: 2px;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
background: #3b82f6;
height: 16px;
width: 16px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
}
.slider::-webkit-slider-thumb:hover {
background: #2563eb;
transform: scale(1.1);
}
.slider::-moz-range-track {
background: rgba(255, 255, 255, 0.3);
height: 4px;
border-radius: 2px;
border: none;
}
.slider::-moz-range-thumb {
background: #3b82f6;
height: 16px;
width: 16px;
border-radius: 50%;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
.slider::-moz-range-thumb:hover {
background: #2563eb;
transform: scale(1.1);
}
/* 音量控制样式 */
input[type="range"]::-webkit-slider-track {
background: rgba(255, 255, 255, 0.3);
height: 2px;
border-radius: 1px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
background: #ffffff;
height: 12px;
width: 12px;
border-radius: 50%;
cursor: pointer;
}
input[type="range"]::-moz-range-track {
background: rgba(255, 255, 255, 0.3);
height: 2px;
border-radius: 1px;
border: none;
}
input[type="range"]::-moz-range-thumb {
background: #ffffff;
height: 12px;
width: 12px;
border-radius: 50%;
cursor: pointer;
border: none;
}
/* 控制栏动画 */
.video-controls {
transition: opacity 0.3s ease;
}
/* 全屏样式 */
.video-player:fullscreen {
background: black;
}
.video-player:fullscreen video {
width: 100vw;
height: 100vh;
object-fit: contain;
}
/* 加载动画 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-spinner {
animation: spin 1s linear infinite;
}
/* 响应式设计 */
@media (max-width: 768px) {
.video-controls {
padding: 12px;
}
.video-controls button {
padding: 8px;
}
.video-controls input[type="range"] {
width: 60px;
}
}

View File

@@ -0,0 +1,339 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import {
Play,
Pause,
Volume2,
VolumeX,
Maximize,
SkipBack,
SkipForward,
RotateCcw
} from 'lucide-react';
import { VideoFileInfo } from '../../types/frame-extractor';
import './VideoPlayer.css';
interface VideoPlayerProps {
video: VideoFileInfo;
currentTime?: number;
onTimeUpdate?: (time: number) => void;
onDurationChange?: (duration: number) => void;
className?: string;
}
/**
* 视频播放器组件
* 支持播放控制和时间同步
*/
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
video,
currentTime,
onTimeUpdate,
onDurationChange,
className = ''
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 格式化时间显示
const formatTime = (seconds: number): string => {
if (isNaN(seconds)) return '00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// 播放/暂停切换
const togglePlay = useCallback(() => {
if (!videoRef.current) return;
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
}, [isPlaying]);
// 跳转到指定时间
const seekTo = useCallback((time: number) => {
if (!videoRef.current) return;
videoRef.current.currentTime = Math.max(0, Math.min(time, duration));
}, [duration]);
// 快进/快退
const skip = useCallback((seconds: number) => {
if (!videoRef.current) return;
const newTime = videoRef.current.currentTime + seconds;
seekTo(newTime);
}, [seekTo]);
// 音量控制
const toggleMute = useCallback(() => {
if (!videoRef.current) return;
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}, [isMuted]);
const handleVolumeChange = useCallback((newVolume: number) => {
if (!videoRef.current) return;
videoRef.current.volume = newVolume;
setVolume(newVolume);
setIsMuted(newVolume === 0);
}, []);
// 全屏控制
const toggleFullscreen = useCallback(() => {
if (!videoRef.current) return;
if (!isFullscreen) {
if (videoRef.current.requestFullscreen) {
videoRef.current.requestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
}, [isFullscreen]);
// 重置视频
const resetVideo = useCallback(() => {
if (!videoRef.current) return;
videoRef.current.currentTime = 0;
setIsPlaying(false);
}, []);
// 外部时间同步
useEffect(() => {
if (currentTime !== undefined && videoRef.current) {
const currentVideoTime = videoRef.current.currentTime;
// 只有当时间差超过1秒时才同步避免频繁更新
if (Math.abs(currentVideoTime - currentTime) > 1) {
seekTo(currentTime);
}
}
}, [currentTime, seekTo]);
// 视频事件处理
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleLoadedMetadata = () => {
setDuration(video.duration);
setIsLoading(false);
onDurationChange?.(video.duration);
};
const handleTimeUpdate = () => {
onTimeUpdate?.(video.currentTime);
};
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleError = () => {
setError('视频加载失败');
setIsLoading(false);
};
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('error', handleError);
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('error', handleError);
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, [onTimeUpdate, onDurationChange]);
// 键盘快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!videoRef.current) return;
switch (e.code) {
case 'Space':
e.preventDefault();
togglePlay();
break;
case 'ArrowLeft':
e.preventDefault();
skip(-10);
break;
case 'ArrowRight':
e.preventDefault();
skip(10);
break;
case 'ArrowUp':
e.preventDefault();
handleVolumeChange(Math.min(1, volume + 0.1));
break;
case 'ArrowDown':
e.preventDefault();
handleVolumeChange(Math.max(0, volume - 0.1));
break;
case 'KeyM':
e.preventDefault();
toggleMute();
break;
case 'KeyF':
e.preventDefault();
toggleFullscreen();
break;
case 'KeyR':
e.preventDefault();
resetVideo();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [togglePlay, skip, handleVolumeChange, volume, toggleMute, toggleFullscreen, resetVideo]);
if (error) {
return (
<div className={`bg-gray-100 rounded-lg flex items-center justify-center p-8 ${className}`}>
<div className="text-center">
<div className="text-red-500 mb-2"></div>
<div className="text-sm text-gray-500">{error}</div>
</div>
</div>
);
}
return (
<div className={`bg-black rounded-lg overflow-hidden relative group ${className}`}>
{/* 视频元素 */}
<video
ref={videoRef}
src={`file://${video.path}`}
className="w-full h-full object-contain"
preload="metadata"
/>
{/* 加载状态 */}
{isLoading && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="text-white">...</div>
</div>
)}
{/* 控制栏 */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity">
{/* 进度条 */}
<div className="mb-3">
<input
type="range"
min="0"
max={duration || 0}
value={currentTime || 0}
onChange={(e) => seekTo(parseFloat(e.target.value))}
className="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
/>
</div>
{/* 控制按钮 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => skip(-10)}
className="text-white hover:text-blue-400 transition-colors"
title="后退10秒 (←)"
>
<SkipBack className="w-5 h-5" />
</button>
<button
onClick={togglePlay}
className="text-white hover:text-blue-400 transition-colors"
title="播放/暂停 (空格)"
>
{isPlaying ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</button>
<button
onClick={() => skip(10)}
className="text-white hover:text-blue-400 transition-colors"
title="前进10秒 (→)"
>
<SkipForward className="w-5 h-5" />
</button>
<button
onClick={resetVideo}
className="text-white hover:text-blue-400 transition-colors"
title="重置 (R)"
>
<RotateCcw className="w-4 h-4" />
</button>
<div className="flex items-center gap-2 ml-4">
<button
onClick={toggleMute}
className="text-white hover:text-blue-400 transition-colors"
title="静音 (M)"
>
{isMuted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={isMuted ? 0 : volume}
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
className="w-16 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer"
/>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-white text-sm">
{formatTime(currentTime || 0)} / {formatTime(duration)}
</div>
<button
onClick={toggleFullscreen}
className="text-white hover:text-blue-400 transition-colors"
title="全屏 (F)"
>
<Maximize className="w-5 h-5" />
</button>
</div>
</div>
</div>
{/* 快捷键提示 */}
<div className="absolute top-4 right-4 bg-black bg-opacity-50 text-white text-xs p-2 rounded opacity-0 group-hover:opacity-100 transition-opacity">
<div>空格: 播放/</div>
<div>/: 退/</div>
<div>/: </div>
<div>M: 静音 F: 全屏 R: 重置</div>
</div>
</div>
);
};

View File

@@ -35,6 +35,7 @@ import { VideoFileList } from '../../components/frame-extractor/VideoFileList';
import { ExtractionProgress } from '../../components/frame-extractor/ExtractionProgress'; import { ExtractionProgress } from '../../components/frame-extractor/ExtractionProgress';
import { FramePreview } from '../../components/frame-extractor/FramePreview'; import { FramePreview } from '../../components/frame-extractor/FramePreview';
import { ExtractionResults } from '../../components/frame-extractor/ExtractionResults'; import { ExtractionResults } from '../../components/frame-extractor/ExtractionResults';
import { VideoPlayer } from '../../components/frame-extractor/VideoPlayer';
/** /**
* 视频关键帧提取工具主组件 * 视频关键帧提取工具主组件
@@ -52,6 +53,8 @@ const FrameExtractorTool: React.FC = () => {
const [viewMode, setViewMode] = useState<'config' | 'progress' | 'results'>('config'); const [viewMode, setViewMode] = useState<'config' | 'progress' | 'results'>('config');
const [selectedFolder, setSelectedFolder] = useState<string>(''); const [selectedFolder, setSelectedFolder] = useState<string>('');
const [ffmpegAvailable, setFfmpegAvailable] = useState<boolean | null>(null); const [ffmpegAvailable, setFfmpegAvailable] = useState<boolean | null>(null);
const [selectedVideoForPreview, setSelectedVideoForPreview] = useState<VideoFileInfo | null>(null);
const [currentPlayTime, setCurrentPlayTime] = useState<number>(0);
// 检查 FFmpeg 可用性 // 检查 FFmpeg 可用性
useEffect(() => { useEffect(() => {
@@ -347,23 +350,58 @@ const FrameExtractorTool: React.FC = () => {
<VideoFileList <VideoFileList
videos={selectedVideos} videos={selectedVideos}
isScanning={isScanning} isScanning={isScanning}
onRemoveVideo={(index) => { onRemoveVideo={(index: number) => {
const newVideos = [...selectedVideos]; const newVideos = [...selectedVideos];
newVideos.splice(index, 1); newVideos.splice(index, 1);
setSelectedVideos(newVideos); setSelectedVideos(newVideos);
// 如果删除的是当前预览的视频,清除预览
if (selectedVideoForPreview && selectedVideos[index]?.path === selectedVideoForPreview.path) {
setSelectedVideoForPreview(null);
}
}}
onPreviewVideo={(video: VideoFileInfo) => {
setSelectedVideoForPreview(video);
setViewMode('config'); // 切换到配置视图以显示预览
}} }}
/> />
</div> </div>
{/* 右侧:主要内容 */} {/* 右侧:主要内容 */}
<div className="lg:col-span-2"> <div className="lg:col-span-2 space-y-6">
{viewMode === 'config' && ( {viewMode === 'config' && (
<FrameExtractionConfigPanel <>
config={config} {/* 视频预览播放器 */}
onConfigChange={setConfig} {selectedVideoForPreview && (
onApplyPreset={handleApplyPreset} <div className="bg-white rounded-lg shadow-sm border p-4">
presets={FRAME_EXTRACTION_PRESETS} <div className="flex items-center justify-between mb-4">
/> <h3 className="font-semibold text-gray-900"></h3>
<button
onClick={() => setSelectedVideoForPreview(null)}
className="text-gray-400 hover:text-gray-600"
>
<Eye className="w-5 h-5" />
</button>
</div>
<VideoPlayer
video={selectedVideoForPreview}
currentTime={currentPlayTime}
onTimeUpdate={setCurrentPlayTime}
className="w-full h-64"
/>
</div>
)}
{/* 配置面板 */}
<FrameExtractionConfigPanel
config={config}
onConfigChange={setConfig}
onApplyPreset={handleApplyPreset}
presets={FRAME_EXTRACTION_PRESETS}
currentVideo={selectedVideoForPreview || undefined}
currentTime={currentPlayTime}
onTimeChange={setCurrentPlayTime}
/>
</>
)} )}
{viewMode === 'progress' && ( {viewMode === 'progress' && (
@@ -378,7 +416,7 @@ const FrameExtractorTool: React.FC = () => {
{viewMode === 'results' && ( {viewMode === 'results' && (
<ExtractionResults <ExtractionResults
results={extractionResults} results={extractionResults}
onOpenResult={(result) => { onOpenResult={(result: FrameExtractionResult) => {
// 打开结果文件所在目录 // 打开结果文件所在目录
invoke('open_file_directory', { path: result.output_path }); invoke('open_file_directory', { path: result.output_path });
}} }}

View File

@@ -208,25 +208,25 @@ export const FRAME_EXTRACTION_PRESETS: FrameExtractionPreset[] = [
}, },
{ {
id: 'last-frame-png', id: 'last-frame-png',
name: '最后一帧PNG', name: '最后一帧',
description: '提取视频最后一帧PNG格式保持透明度', description: '提取视频最后一帧',
config: { config: {
...DEFAULT_FRAME_EXTRACTION_CONFIG, ...DEFAULT_FRAME_EXTRACTION_CONFIG,
frame_type: FrameType.Last, frame_type: FrameType.Last,
output_format: { format: ImageFormat.Png, quality: 9 }, output_format: { format: ImageFormat.Jpg, quality: 95 },
}, },
is_default: false, is_default: false,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}, },
{ {
id: 'middle-frame-webp', id: 'middle-frame-webp',
name: '中间帧WebP', name: '中间帧',
description: '提取视频中间帧WebP格式压缩', description: '提取视频中间帧',
config: { config: {
...DEFAULT_FRAME_EXTRACTION_CONFIG, ...DEFAULT_FRAME_EXTRACTION_CONFIG,
frame_type: FrameType.Custom, frame_type: FrameType.Custom,
custom_time: 0, // 将在运行时计算为视频长度的50% custom_time: 0, // 将在运行时计算为视频长度的50%
output_format: { format: ImageFormat.WebP, quality: 80, compression_level: 6 }, output_format: { format: ImageFormat.Jpg, quality: 95 },
}, },
is_default: false, is_default: false,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),