feat: 添加视频预览播放器和时间联动功能
- 新增 VideoPlayer 组件,支持完整的视频播放控制 - 实现播放/暂停、快进/快退、音量控制、全屏等功能 - 添加键盘快捷键支持 (空格、方向键、M、F、R等) - 实现视频播放时间与自定义时间输入的双向联动 - 在关键帧提取工具中集成视频预览功能 - 支持单个视频选择时显示预览播放器 - 添加'使用当前播放时间'按钮,方便设置提取时间点 - 优化用户界面,提升视频处理工作流体验
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
127
apps/desktop/src/components/frame-extractor/VideoPlayer.css
Normal file
127
apps/desktop/src/components/frame-extractor/VideoPlayer.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
339
apps/desktop/src/components/frame-extractor/VideoPlayer.tsx
Normal file
339
apps/desktop/src/components/frame-extractor/VideoPlayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user