480 lines
16 KiB
TypeScript
480 lines
16 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
Image,
|
|
FolderOpen,
|
|
Play,
|
|
Settings,
|
|
Clock,
|
|
List,
|
|
Grid,
|
|
RefreshCw,
|
|
Download,
|
|
Eye,
|
|
Trash2,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
Info
|
|
} from 'lucide-react';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { open } from '@tauri-apps/plugin-dialog';
|
|
|
|
import {
|
|
FrameExtractionConfig,
|
|
VideoFileInfo,
|
|
FrameExtractionResult,
|
|
FrameType,
|
|
ImageFormat,
|
|
ExtractionTaskStatus,
|
|
DEFAULT_FRAME_EXTRACTION_CONFIG,
|
|
SUPPORTED_VIDEO_FORMATS,
|
|
FRAME_EXTRACTION_PRESETS
|
|
} from '../../types/frame-extractor';
|
|
|
|
import { FrameExtractionConfigPanel } from '../../components/frame-extractor/FrameExtractionConfigPanel';
|
|
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';
|
|
|
|
/**
|
|
* 视频关键帧提取工具主组件
|
|
* 遵循 Tauri 开发规范和 UI/UX 设计标准
|
|
*/
|
|
const FrameExtractorTool: React.FC = () => {
|
|
// 状态管理
|
|
const [selectedVideos, setSelectedVideos] = useState<VideoFileInfo[]>([]);
|
|
const [config, setConfig] = useState<FrameExtractionConfig>(DEFAULT_FRAME_EXTRACTION_CONFIG);
|
|
const [isExtracting, setIsExtracting] = useState(false);
|
|
const [isScanning, setIsScanning] = useState(false);
|
|
const [extractionResults, setExtractionResults] = useState<FrameExtractionResult[]>([]);
|
|
const [currentProgress, setCurrentProgress] = useState(0);
|
|
const [currentFile, setCurrentFile] = useState<string>('');
|
|
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);
|
|
const [recursiveScan, setRecursiveScan] = useState<boolean>(true);
|
|
|
|
// 检查 FFmpeg 可用性
|
|
useEffect(() => {
|
|
checkFFmpegAvailability();
|
|
}, []);
|
|
|
|
const checkFFmpegAvailability = useCallback(async () => {
|
|
try {
|
|
const available = await invoke<boolean>('check_ffmpeg_availability');
|
|
setFfmpegAvailable(available);
|
|
if (!available) {
|
|
console.warn('FFmpeg 不可用,某些功能可能无法正常工作');
|
|
}
|
|
} catch (error) {
|
|
console.error('检查 FFmpeg 可用性失败:', error);
|
|
setFfmpegAvailable(false);
|
|
}
|
|
}, []);
|
|
|
|
// 选择单个视频文件
|
|
const handleSelectFiles = useCallback(async () => {
|
|
try {
|
|
const selected = await open({
|
|
multiple: true,
|
|
filters: [{
|
|
name: '视频文件',
|
|
extensions: SUPPORTED_VIDEO_FORMATS
|
|
}],
|
|
title: '选择视频文件',
|
|
});
|
|
|
|
if (selected && Array.isArray(selected)) {
|
|
await loadVideoFiles(selected);
|
|
} else if (selected && typeof selected === 'string') {
|
|
await loadVideoFiles([selected]);
|
|
}
|
|
} catch (error) {
|
|
console.error('选择文件失败:', error);
|
|
}
|
|
}, []);
|
|
|
|
// 选择文件夹
|
|
const handleSelectFolder = useCallback(async () => {
|
|
try {
|
|
const selected = await open({
|
|
directory: true,
|
|
multiple: false,
|
|
title: '选择视频文件夹',
|
|
});
|
|
|
|
if (selected && typeof selected === 'string') {
|
|
setSelectedFolder(selected);
|
|
await scanVideoFiles(selected, recursiveScan);
|
|
}
|
|
} catch (error) {
|
|
console.error('选择文件夹失败:', error);
|
|
}
|
|
}, []);
|
|
|
|
// 扫描视频文件
|
|
const scanVideoFiles = useCallback(async (folderPath: string, recursive: boolean) => {
|
|
setIsScanning(true);
|
|
setCurrentFile(`正在扫描文件夹: ${folderPath}${recursive ? ' (递归)' : ''}`);
|
|
|
|
try {
|
|
const files = await invoke<VideoFileInfo[]>('scan_video_files', {
|
|
request: {
|
|
folder_path: folderPath,
|
|
recursive,
|
|
supported_formats: SUPPORTED_VIDEO_FORMATS,
|
|
}
|
|
});
|
|
|
|
const validFiles = files.filter(f => f.is_valid);
|
|
setSelectedVideos(validFiles);
|
|
|
|
// 显示扫描结果
|
|
const totalFiles = files.length;
|
|
const validCount = validFiles.length;
|
|
const invalidCount = totalFiles - validCount;
|
|
|
|
console.log(`扫描完成: 找到 ${totalFiles} 个文件,其中 ${validCount} 个有效,${invalidCount} 个无效`);
|
|
setCurrentFile(`扫描完成: 找到 ${validCount} 个有效视频文件`);
|
|
|
|
// 2秒后清除状态信息
|
|
setTimeout(() => {
|
|
setCurrentFile('');
|
|
}, 2000);
|
|
|
|
} catch (error) {
|
|
console.error('扫描视频文件失败:', error);
|
|
setCurrentFile('扫描失败,请重试');
|
|
setTimeout(() => {
|
|
setCurrentFile('');
|
|
}, 3000);
|
|
} finally {
|
|
setIsScanning(false);
|
|
}
|
|
}, []);
|
|
|
|
// 加载视频文件信息
|
|
const loadVideoFiles = useCallback(async (filePaths: string[]) => {
|
|
setIsScanning(true);
|
|
const videoInfos: VideoFileInfo[] = [];
|
|
|
|
try {
|
|
for (const filePath of filePaths) {
|
|
try {
|
|
const info = await invoke<VideoFileInfo>('get_video_info', {
|
|
videoPath: filePath
|
|
});
|
|
videoInfos.push(info);
|
|
} catch (error) {
|
|
console.error(`获取视频信息失败 ${filePath}:`, error);
|
|
}
|
|
}
|
|
|
|
setSelectedVideos(videoInfos.filter(info => info.is_valid));
|
|
} finally {
|
|
setIsScanning(false);
|
|
}
|
|
}, []);
|
|
|
|
// 开始提取
|
|
const handleStartExtraction = useCallback(async () => {
|
|
if (selectedVideos.length === 0) {
|
|
alert('请先选择视频文件');
|
|
return;
|
|
}
|
|
|
|
if (!config.output_directory) {
|
|
alert('请设置输出目录');
|
|
return;
|
|
}
|
|
|
|
setIsExtracting(true);
|
|
setViewMode('progress');
|
|
setCurrentProgress(0);
|
|
setExtractionResults([]);
|
|
|
|
try {
|
|
const videoPaths = selectedVideos.map(v => v.path);
|
|
const results = await invoke<FrameExtractionResult[]>('extract_frames_batch', {
|
|
request: {
|
|
video_files: videoPaths,
|
|
config: config,
|
|
}
|
|
});
|
|
|
|
setExtractionResults(results);
|
|
setViewMode('results');
|
|
|
|
const successCount = results.filter(r => r.success).length;
|
|
console.log(`提取完成: ${successCount}/${results.length} 成功`);
|
|
} catch (error) {
|
|
console.error('批量提取失败:', error);
|
|
alert(`提取失败: ${error}`);
|
|
} finally {
|
|
setIsExtracting(false);
|
|
}
|
|
}, [selectedVideos, config]);
|
|
|
|
// 清除选择
|
|
const handleClearSelection = useCallback(() => {
|
|
setSelectedVideos([]);
|
|
setSelectedFolder('');
|
|
setExtractionResults([]);
|
|
setCurrentProgress(0);
|
|
setCurrentFile('');
|
|
}, []);
|
|
|
|
// 应用预设配置
|
|
const handleApplyPreset = useCallback((presetId: string) => {
|
|
const preset = FRAME_EXTRACTION_PRESETS.find(p => p.id === presetId);
|
|
if (preset) {
|
|
setConfig({ ...preset.config });
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 页面标题 */}
|
|
<div className="page-header flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg hover:shadow-xl transition-all duration-300">
|
|
<Image className="w-6 h-6 text-white" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-blue-600 bg-clip-text text-transparent">
|
|
视频关键帧提取工具
|
|
</h1>
|
|
<p className="text-gray-600 text-lg">快速提取视频的第一帧、最后帧或自定义位置的帧</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* FFmpeg 状态指示器 */}
|
|
<div className="flex items-center gap-2">
|
|
{ffmpegAvailable === null ? (
|
|
<div className="flex items-center gap-2 text-gray-500">
|
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
<span className="text-sm">检查中...</span>
|
|
</div>
|
|
) : ffmpegAvailable ? (
|
|
<div className="flex items-center gap-2 text-green-600">
|
|
<CheckCircle className="w-4 h-4" />
|
|
<span className="text-sm">FFmpeg 可用</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 text-red-600">
|
|
<AlertCircle className="w-4 h-4" />
|
|
<span className="text-sm">FFmpeg 不可用</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 工具栏 */}
|
|
<div className="flex items-center justify-between bg-white rounded-lg p-4 shadow-sm border">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={handleSelectFiles}
|
|
disabled={isScanning || isExtracting}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<Image className="w-4 h-4" />
|
|
选择文件
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleSelectFolder}
|
|
disabled={isScanning || isExtracting}
|
|
className="flex items-center gap-2 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
title={recursiveScan ? "选择文件夹并递归扫描所有子文件夹中的视频文件" : "选择文件夹并扫描其中的视频文件"}
|
|
>
|
|
<FolderOpen className="w-4 h-4" />
|
|
选择文件夹{recursiveScan ? ' (递归)' : ''}
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleClearSelection}
|
|
disabled={isScanning || isExtracting}
|
|
className="flex items-center gap-2 px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
清除选择
|
|
</button>
|
|
|
|
{/* 递归扫描选项 */}
|
|
<div className="flex items-center gap-2 ml-4 pl-4 border-l border-gray-200">
|
|
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={recursiveScan}
|
|
onChange={(e) => setRecursiveScan(e.target.checked)}
|
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span>递归扫描子文件夹</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-sm text-gray-600">
|
|
已选择 {selectedVideos.length} 个视频文件
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleStartExtraction}
|
|
disabled={selectedVideos.length === 0 || isExtracting || !ffmpegAvailable}
|
|
className="flex items-center gap-2 px-6 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{isExtracting ? (
|
|
<>
|
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
提取中...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Play className="w-4 h-4" />
|
|
开始提取
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 状态显示区域 */}
|
|
{(isScanning || currentFile) && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
<div className="flex items-center gap-2">
|
|
{isScanning && <RefreshCw className="w-4 h-4 text-blue-600 animate-spin" />}
|
|
<span className="text-sm text-blue-800">
|
|
{currentFile || '正在处理...'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 视图切换 */}
|
|
<div className="flex items-center gap-2 bg-white rounded-lg p-1 shadow-sm border w-fit">
|
|
<button
|
|
onClick={() => setViewMode('config')}
|
|
className={`px-4 py-2 rounded-md transition-colors ${
|
|
viewMode === 'config'
|
|
? 'bg-blue-100 text-blue-600'
|
|
: 'text-gray-600 hover:text-gray-800'
|
|
}`}
|
|
>
|
|
<Settings className="w-4 h-4 inline mr-2" />
|
|
配置
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('progress')}
|
|
className={`px-4 py-2 rounded-md transition-colors ${
|
|
viewMode === 'progress'
|
|
? 'bg-blue-100 text-blue-600'
|
|
: 'text-gray-600 hover:text-gray-800'
|
|
}`}
|
|
>
|
|
<Clock className="w-4 h-4 inline mr-2" />
|
|
进度
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('results')}
|
|
className={`px-4 py-2 rounded-md transition-colors ${
|
|
viewMode === 'results'
|
|
? 'bg-blue-100 text-blue-600'
|
|
: 'text-gray-600 hover:text-gray-800'
|
|
}`}
|
|
>
|
|
<List className="w-4 h-4 inline mr-2" />
|
|
结果
|
|
</button>
|
|
</div>
|
|
|
|
{/* 主要内容区域 */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* 左侧:视频文件列表 */}
|
|
<div className="lg:col-span-1">
|
|
<VideoFileList
|
|
videos={selectedVideos}
|
|
isScanning={isScanning}
|
|
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 space-y-6">
|
|
{viewMode === 'config' && (
|
|
<>
|
|
{/* 视频预览播放器 */}
|
|
{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' && (
|
|
<ExtractionProgress
|
|
isExtracting={isExtracting}
|
|
progress={currentProgress}
|
|
currentFile={currentFile}
|
|
totalFiles={selectedVideos.length}
|
|
/>
|
|
)}
|
|
|
|
{viewMode === 'results' && (
|
|
<ExtractionResults
|
|
results={extractionResults}
|
|
onOpenResult={(result: FrameExtractionResult) => {
|
|
// 打开结果文件所在目录
|
|
invoke('open_file_directory', { path: result.output_path });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FrameExtractorTool;
|