feat: 实现视频关键帧提取工具
- 新增关键帧提取功能,支持提取视频的第一帧、最后一帧、自定义时间点和多个时间点 - 添加完整的前端UI组件,包括配置面板、文件列表、预览和进度显示 - 实现后端Rust服务,集成FFmpeg进行视频处理 - 支持多种输出格式(JPG/PNG/WebP/BMP)和质量设置 - 支持输出尺寸调整和宽高比保持 - 添加批量处理功能和进度跟踪 - 修复TypeScript类型错误和Rust编译问题 - 添加用户指南文档和单元测试
This commit is contained in:
393
apps/desktop/src/pages/tools/FrameExtractorTool.tsx
Normal file
393
apps/desktop/src/pages/tools/FrameExtractorTool.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 视频关键帧提取工具主组件
|
||||
* 遵循 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);
|
||||
|
||||
// 检查 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, false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择文件夹失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 扫描视频文件
|
||||
const scanVideoFiles = useCallback(async (folderPath: string, recursive: boolean) => {
|
||||
setIsScanning(true);
|
||||
try {
|
||||
const files = await invoke<VideoFileInfo[]>('scan_video_files', {
|
||||
request: {
|
||||
folder_path: folderPath,
|
||||
recursive,
|
||||
supported_formats: SUPPORTED_VIDEO_FORMATS,
|
||||
}
|
||||
});
|
||||
|
||||
setSelectedVideos(files.filter(f => f.is_valid));
|
||||
} catch (error) {
|
||||
console.error('扫描视频文件失败:', error);
|
||||
} 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"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
选择文件夹
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 视图切换 */}
|
||||
<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) => {
|
||||
const newVideos = [...selectedVideos];
|
||||
newVideos.splice(index, 1);
|
||||
setSelectedVideos(newVideos);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧:主要内容 */}
|
||||
<div className="lg:col-span-2">
|
||||
{viewMode === 'config' && (
|
||||
<FrameExtractionConfigPanel
|
||||
config={config}
|
||||
onConfigChange={setConfig}
|
||||
onApplyPreset={handleApplyPreset}
|
||||
presets={FRAME_EXTRACTION_PRESETS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'progress' && (
|
||||
<ExtractionProgress
|
||||
isExtracting={isExtracting}
|
||||
progress={currentProgress}
|
||||
currentFile={currentFile}
|
||||
totalFiles={selectedVideos.length}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'results' && (
|
||||
<ExtractionResults
|
||||
results={extractionResults}
|
||||
onOpenResult={(result) => {
|
||||
// 打开结果文件所在目录
|
||||
invoke('open_file_directory', { path: result.output_path });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrameExtractorTool;
|
||||
Reference in New Issue
Block a user