diff --git a/apps/desktop/src/components/frame-extractor/FrameExtractionConfigPanel.tsx b/apps/desktop/src/components/frame-extractor/FrameExtractionConfigPanel.tsx index b58dc44..39b82d8 100644 --- a/apps/desktop/src/components/frame-extractor/FrameExtractionConfigPanel.tsx +++ b/apps/desktop/src/components/frame-extractor/FrameExtractionConfigPanel.tsx @@ -18,7 +18,8 @@ import { FrameExtractionPreset, FrameType, ImageFormat, - TimePoint + TimePoint, + VideoFileInfo } from '../../types/frame-extractor'; interface FrameExtractionConfigPanelProps { @@ -26,6 +27,9 @@ interface FrameExtractionConfigPanelProps { onConfigChange: (config: FrameExtractionConfig) => void; onApplyPreset: (presetId: string) => void; presets: FrameExtractionPreset[]; + currentVideo?: VideoFileInfo; + currentTime?: number; + onTimeChange?: (time: number) => void; } /** @@ -36,10 +40,26 @@ export const FrameExtractionConfigPanel: React.FC { const [customTimePoints, setCustomTimePoints] = useState(''); + // 格式化时长显示 + 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) => { onConfigChange({ ...config, ...updates }); @@ -178,12 +198,32 @@ export const FrameExtractionConfigPanel: React.FC 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" placeholder="输入时间(秒)" /> + {currentVideo && ( +
+ 视频总时长: {formatDuration(currentVideo.duration)} + {currentTime !== undefined && ( + + )} +
+ )} )} diff --git a/apps/desktop/src/components/frame-extractor/VideoPlayer.css b/apps/desktop/src/components/frame-extractor/VideoPlayer.css new file mode 100644 index 0000000..e110c70 --- /dev/null +++ b/apps/desktop/src/components/frame-extractor/VideoPlayer.css @@ -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; + } +} diff --git a/apps/desktop/src/components/frame-extractor/VideoPlayer.tsx b/apps/desktop/src/components/frame-extractor/VideoPlayer.tsx new file mode 100644 index 0000000..313bfa4 --- /dev/null +++ b/apps/desktop/src/components/frame-extractor/VideoPlayer.tsx @@ -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 = ({ + video, + currentTime, + onTimeUpdate, + onDurationChange, + className = '' +}) => { + const videoRef = useRef(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(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 ( +
+
+
视频加载失败
+
{error}
+
+
+ ); + } + + return ( +
+ {/* 视频元素 */} +
+ ); +}; diff --git a/apps/desktop/src/pages/tools/FrameExtractorTool.tsx b/apps/desktop/src/pages/tools/FrameExtractorTool.tsx index f670c0d..9ee4fb1 100644 --- a/apps/desktop/src/pages/tools/FrameExtractorTool.tsx +++ b/apps/desktop/src/pages/tools/FrameExtractorTool.tsx @@ -35,6 +35,7 @@ import { VideoFileList } from '../../components/frame-extractor/VideoFileList'; import { ExtractionProgress } from '../../components/frame-extractor/ExtractionProgress'; import { FramePreview } from '../../components/frame-extractor/FramePreview'; 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 [selectedFolder, setSelectedFolder] = useState(''); const [ffmpegAvailable, setFfmpegAvailable] = useState(null); + const [selectedVideoForPreview, setSelectedVideoForPreview] = useState(null); + const [currentPlayTime, setCurrentPlayTime] = useState(0); // 检查 FFmpeg 可用性 useEffect(() => { @@ -347,23 +350,58 @@ const FrameExtractorTool: React.FC = () => { { + onRemoveVideo={(index: number) => { const newVideos = [...selectedVideos]; newVideos.splice(index, 1); setSelectedVideos(newVideos); + // 如果删除的是当前预览的视频,清除预览 + if (selectedVideoForPreview && selectedVideos[index]?.path === selectedVideoForPreview.path) { + setSelectedVideoForPreview(null); + } + }} + onPreviewVideo={(video: VideoFileInfo) => { + setSelectedVideoForPreview(video); + setViewMode('config'); // 切换到配置视图以显示预览 }} /> {/* 右侧:主要内容 */} -
+
{viewMode === 'config' && ( - + <> + {/* 视频预览播放器 */} + {selectedVideoForPreview && ( +
+
+

视频预览

+ +
+ +
+ )} + + {/* 配置面板 */} + + )} {viewMode === 'progress' && ( @@ -378,7 +416,7 @@ const FrameExtractorTool: React.FC = () => { {viewMode === 'results' && ( { + onOpenResult={(result: FrameExtractionResult) => { // 打开结果文件所在目录 invoke('open_file_directory', { path: result.output_path }); }} diff --git a/apps/desktop/src/types/frame-extractor.ts b/apps/desktop/src/types/frame-extractor.ts index 61bc6c8..e4798af 100644 --- a/apps/desktop/src/types/frame-extractor.ts +++ b/apps/desktop/src/types/frame-extractor.ts @@ -208,25 +208,25 @@ export const FRAME_EXTRACTION_PRESETS: FrameExtractionPreset[] = [ }, { id: 'last-frame-png', - name: '最后一帧PNG', - description: '提取视频最后一帧,PNG格式保持透明度', + name: '最后一帧', + description: '提取视频最后一帧', config: { ...DEFAULT_FRAME_EXTRACTION_CONFIG, frame_type: FrameType.Last, - output_format: { format: ImageFormat.Png, quality: 9 }, + output_format: { format: ImageFormat.Jpg, quality: 95 }, }, is_default: false, created_at: new Date().toISOString(), }, { id: 'middle-frame-webp', - name: '中间帧WebP', - description: '提取视频中间帧,WebP格式压缩', + name: '中间帧', + description: '提取视频中间帧', config: { ...DEFAULT_FRAME_EXTRACTION_CONFIG, frame_type: FrameType.Custom, custom_time: 0, // 将在运行时计算为视频长度的50% - output_format: { format: ImageFormat.WebP, quality: 80, compression_level: 6 }, + output_format: { format: ImageFormat.Jpg, quality: 95 }, }, is_default: false, created_at: new Date().toISOString(),