mixvideo-v2/apps/desktop/src/pages/tools/FrameExtractorTool.tsx

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;