feat: 添加视频预览播放器和时间联动功能
- 新增 VideoPlayer 组件,支持完整的视频播放控制 - 实现播放/暂停、快进/快退、音量控制、全屏等功能 - 添加键盘快捷键支持 (空格、方向键、M、F、R等) - 实现视频播放时间与自定义时间输入的双向联动 - 在关键帧提取工具中集成视频预览功能 - 支持单个视频选择时显示预览播放器 - 添加'使用当前播放时间'按钮,方便设置提取时间点 - 优化用户界面,提升视频处理工作流体验
This commit is contained in:
@@ -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<FrameExtractionConfigPanelProp
|
||||
config,
|
||||
onConfigChange,
|
||||
onApplyPreset,
|
||||
presets
|
||||
presets,
|
||||
currentVideo,
|
||||
currentTime,
|
||||
onTimeChange
|
||||
}) => {
|
||||
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>) => {
|
||||
onConfigChange({ ...config, ...updates });
|
||||
@@ -178,12 +198,32 @@ export const FrameExtractionConfigPanel: React.FC<FrameExtractionConfigPanelProp
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
max={currentVideo?.duration || undefined}
|
||||
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"
|
||||
placeholder="输入时间(秒)"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
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 { 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<string>('');
|
||||
const [ffmpegAvailable, setFfmpegAvailable] = useState<boolean | null>(null);
|
||||
const [selectedVideoForPreview, setSelectedVideoForPreview] = useState<VideoFileInfo | null>(null);
|
||||
const [currentPlayTime, setCurrentPlayTime] = useState<number>(0);
|
||||
|
||||
// 检查 FFmpeg 可用性
|
||||
useEffect(() => {
|
||||
@@ -347,23 +350,58 @@ const FrameExtractorTool: React.FC = () => {
|
||||
<VideoFileList
|
||||
videos={selectedVideos}
|
||||
isScanning={isScanning}
|
||||
onRemoveVideo={(index) => {
|
||||
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'); // 切换到配置视图以显示预览
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧:主要内容 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{viewMode === 'config' && (
|
||||
<FrameExtractionConfigPanel
|
||||
config={config}
|
||||
onConfigChange={setConfig}
|
||||
onApplyPreset={handleApplyPreset}
|
||||
presets={FRAME_EXTRACTION_PRESETS}
|
||||
/>
|
||||
<>
|
||||
{/* 视频预览播放器 */}
|
||||
{selectedVideoForPreview && (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4">
|
||||
<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' && (
|
||||
@@ -378,7 +416,7 @@ const FrameExtractorTool: React.FC = () => {
|
||||
{viewMode === 'results' && (
|
||||
<ExtractionResults
|
||||
results={extractionResults}
|
||||
onOpenResult={(result) => {
|
||||
onOpenResult={(result: FrameExtractionResult) => {
|
||||
// 打开结果文件所在目录
|
||||
invoke('open_file_directory', { path: result.output_path });
|
||||
}}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user