🔧 主要修复: 1. Tauri 插件配置: - 添加 tauri-plugin-dialog 依赖 - 添加 tauri-plugin-fs 依赖 - 在 Rust 中注册对话框和文件系统插件 - 清理重复的依赖项 2. 文件选择功能增强: - 使用 Tauri 原生文件对话框 API - 支持图片格式过滤 (jpg, jpeg, png, bmp, gif, tiff, webp) - 添加详细的调试日志 - 改进错误处理和用户提示 3. 用户体验优化: - 文件选择失败时提供备用方案 - 添加手动输入路径选项 - 显示选中文件的文件名 - 为所有按钮添加工具提示 4. 兼容性处理: - 处理对话框返回值的不同格式 (string | string[]) - 保留 HTML input 作为备用方案 - 添加详细的错误信息和解决建议 ✅ 修复效果: - 图片文件选择:原生对话框 ✓ - 文件夹选择:原生目录选择 ✓ - 输出目录选择:原生对话框 ✓ - 手动路径输入:备用方案 ✓ - 错误处理:友好提示 ✓ 现在用户可以通过原生文件对话框轻松选择文件和文件夹!
494 lines
17 KiB
TypeScript
494 lines
17 KiB
TypeScript
import React, { useState, useRef } from 'react'
|
||
import { Upload, Play, Settings, Folder, FileText, Clock, Cpu, Trash2, Download } from 'lucide-react'
|
||
import { useAIVideoStore, useAIVideoJobs, useAIVideoProcessing, useAIVideoSettings } from '../stores/useAIVideoStore'
|
||
import { open } from '@tauri-apps/plugin-dialog'
|
||
|
||
interface AIVideoGeneratorProps {
|
||
className?: string
|
||
}
|
||
|
||
const AIVideoGenerator: React.FC<AIVideoGeneratorProps> = ({ className = '' }) => {
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
// State
|
||
const [mode, setMode] = useState<'single' | 'batch'>('single')
|
||
const [selectedImage, setSelectedImage] = useState<string>('')
|
||
const [selectedFolder, setSelectedFolder] = useState<string>('')
|
||
const [customPrompt, setCustomPrompt] = useState<string>('')
|
||
const [outputFolder, setOutputFolder] = useState<string>('')
|
||
const [duration, setDuration] = useState<string>('5')
|
||
const [modelType, setModelType] = useState<string>('lite')
|
||
|
||
// Store
|
||
const {
|
||
generateSingleVideo,
|
||
batchGenerateVideos,
|
||
removeJob,
|
||
clearCompletedJobs,
|
||
setDefaultDuration,
|
||
setDefaultModelType
|
||
} = useAIVideoStore()
|
||
|
||
const jobs = useAIVideoJobs()
|
||
const isProcessing = useAIVideoProcessing()
|
||
const { defaultPrompts, defaultDuration, defaultModelType } = useAIVideoSettings()
|
||
|
||
// Initialize settings
|
||
React.useEffect(() => {
|
||
setDuration(defaultDuration)
|
||
setModelType(defaultModelType)
|
||
}, [defaultDuration, defaultModelType])
|
||
|
||
// Handle file selection
|
||
const handleImageSelect = async () => {
|
||
try {
|
||
console.log('Opening file dialog...')
|
||
const selected = await open({
|
||
multiple: false,
|
||
filters: [{
|
||
name: 'Images',
|
||
extensions: ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'tiff', 'webp']
|
||
}]
|
||
})
|
||
|
||
console.log('File dialog result:', selected)
|
||
|
||
if (selected) {
|
||
// Handle both string and array returns
|
||
const filePath = Array.isArray(selected) ? selected[0] : selected
|
||
if (filePath) {
|
||
setSelectedImage(filePath)
|
||
console.log('Selected image:', filePath)
|
||
}
|
||
} else {
|
||
console.log('No file selected')
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to select image:', error)
|
||
alert(`文件选择失败: ${error instanceof Error ? error.message : '未知错误'}\n\n请尝试手动输入文件路径或检查应用权限。`)
|
||
|
||
// Fallback to HTML file input
|
||
fileInputRef.current?.click()
|
||
}
|
||
}
|
||
|
||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
// This is now unused but kept for compatibility
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
setSelectedImage(file.name)
|
||
}
|
||
}
|
||
|
||
const handleFolderSelect = async () => {
|
||
try {
|
||
console.log('Opening folder dialog...')
|
||
const selected = await open({
|
||
directory: true,
|
||
multiple: false
|
||
})
|
||
|
||
console.log('Folder dialog result:', selected)
|
||
|
||
if (selected) {
|
||
// Handle both string and array returns
|
||
const folderPath = Array.isArray(selected) ? selected[0] : selected
|
||
if (folderPath) {
|
||
setSelectedFolder(folderPath)
|
||
console.log('Selected folder:', folderPath)
|
||
}
|
||
} else {
|
||
console.log('No folder selected')
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to select folder:', error)
|
||
alert(`文件夹选择失败: ${error instanceof Error ? error.message : '未知错误'}\n\n请尝试手动输入文件夹路径或检查应用权限。`)
|
||
|
||
// Fallback to HTML folder input
|
||
folderInputRef.current?.click()
|
||
}
|
||
}
|
||
|
||
const handleFolderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
// This is now unused but kept for compatibility
|
||
const files = e.target.files
|
||
if (files && files.length > 0) {
|
||
const firstFile = files[0]
|
||
const path = (firstFile as any).webkitRelativePath || firstFile.name
|
||
const folderPath = path.split('/')[0]
|
||
setSelectedFolder(folderPath)
|
||
}
|
||
}
|
||
|
||
// Handle generation
|
||
const handleGenerate = async () => {
|
||
try {
|
||
if (mode === 'single') {
|
||
if (!selectedImage || !customPrompt) {
|
||
alert('请选择图片文件并输入提示词')
|
||
return
|
||
}
|
||
|
||
await generateSingleVideo({
|
||
image_path: selectedImage,
|
||
prompt: customPrompt,
|
||
duration,
|
||
model_type: modelType,
|
||
output_path: outputFolder || undefined,
|
||
timeout: 300
|
||
})
|
||
} else {
|
||
if (!selectedFolder || !outputFolder) {
|
||
alert('请选择图片文件夹和输出目录')
|
||
return
|
||
}
|
||
|
||
const prompts = customPrompt
|
||
? customPrompt.split('\n').filter(p => p.trim())
|
||
: defaultPrompts
|
||
|
||
await batchGenerateVideos({
|
||
image_folder: selectedFolder,
|
||
prompts,
|
||
output_folder: outputFolder,
|
||
duration,
|
||
model_type: modelType,
|
||
timeout: 300
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.error('Generation failed:', error)
|
||
|
||
let errorMessage = '未知错误'
|
||
if (error instanceof Error) {
|
||
errorMessage = error.message
|
||
} else if (typeof error === 'string') {
|
||
errorMessage = error
|
||
}
|
||
|
||
// Try to extract more details from the error
|
||
const errorDetails = {
|
||
message: errorMessage,
|
||
type: error instanceof Error ? error.name : typeof error,
|
||
stack: error instanceof Error ? error.stack : undefined,
|
||
timestamp: new Date().toISOString()
|
||
}
|
||
|
||
console.error('Detailed error information:', errorDetails)
|
||
|
||
alert(`生成失败: ${errorMessage}\n\n详细信息已输出到控制台,请检查开发者工具。`)
|
||
}
|
||
}
|
||
|
||
// Format time
|
||
const formatTime = (timestamp: number): string => {
|
||
return new Date(timestamp).toLocaleTimeString()
|
||
}
|
||
|
||
// Format duration
|
||
const formatDuration = (start: number, end?: number): string => {
|
||
if (!end) return '进行中...'
|
||
const duration = Math.round((end - start) / 1000)
|
||
return `${duration}秒`
|
||
}
|
||
|
||
return (
|
||
<div className={`bg-white rounded-lg shadow-sm border border-secondary-200 ${className}`}>
|
||
{/* Header */}
|
||
<div className="p-6 border-b border-secondary-200">
|
||
<h2 className="text-xl font-semibold text-secondary-900 mb-4">AI 视频生成</h2>
|
||
|
||
{/* Mode Selection */}
|
||
<div className="flex items-center space-x-4 mb-6">
|
||
<button
|
||
onClick={() => setMode('single')}
|
||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||
mode === 'single'
|
||
? 'bg-primary-100 text-primary-700 border border-primary-300'
|
||
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200'
|
||
}`}
|
||
>
|
||
单张图片
|
||
</button>
|
||
<button
|
||
onClick={() => setMode('batch')}
|
||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||
mode === 'batch'
|
||
? 'bg-primary-100 text-primary-700 border border-primary-300'
|
||
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200'
|
||
}`}
|
||
>
|
||
批量处理
|
||
</button>
|
||
</div>
|
||
|
||
{/* Input Section */}
|
||
<div className="space-y-4">
|
||
{mode === 'single' ? (
|
||
<div>
|
||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||
选择图片文件
|
||
</label>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center space-x-3">
|
||
<button
|
||
onClick={handleImageSelect}
|
||
className="flex items-center px-4 py-2 bg-secondary-100 hover:bg-secondary-200 rounded-lg transition-colors"
|
||
>
|
||
<Upload size={16} className="mr-2" />
|
||
选择文件
|
||
</button>
|
||
<span className="text-sm text-secondary-600">
|
||
{selectedImage ? `已选择: ${selectedImage.split(/[/\\]/).pop()}` : '未选择文件'}
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={selectedImage}
|
||
onChange={(e) => setSelectedImage(e.target.value)}
|
||
placeholder="或手动输入图片文件路径"
|
||
className="input w-full text-sm"
|
||
/>
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleImageChange}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div>
|
||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||
选择图片文件夹
|
||
</label>
|
||
<div className="flex items-center space-x-3">
|
||
<button
|
||
onClick={handleFolderSelect}
|
||
className="flex items-center px-4 py-2 bg-secondary-100 hover:bg-secondary-200 rounded-lg transition-colors"
|
||
>
|
||
<Folder size={16} className="mr-2" />
|
||
选择文件夹
|
||
</button>
|
||
<span className="text-sm text-secondary-600">
|
||
{selectedFolder || '未选择文件夹'}
|
||
</span>
|
||
</div>
|
||
<input
|
||
ref={folderInputRef}
|
||
type="file"
|
||
{...({ webkitdirectory: "" } as any)}
|
||
multiple
|
||
onChange={handleFolderChange}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||
视频保存目录
|
||
</label>
|
||
<div className="flex items-center space-x-3">
|
||
<input
|
||
type="text"
|
||
value={outputFolder}
|
||
onChange={(e) => setOutputFolder(e.target.value)}
|
||
placeholder="输入保存目录路径"
|
||
className="input flex-1"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
try {
|
||
console.log('Opening output folder dialog...')
|
||
const selected = await open({
|
||
directory: true,
|
||
multiple: false
|
||
})
|
||
console.log('Output folder dialog result:', selected)
|
||
|
||
if (selected) {
|
||
const folderPath = Array.isArray(selected) ? selected[0] : selected
|
||
if (folderPath) {
|
||
setOutputFolder(folderPath)
|
||
console.log('Selected output folder:', folderPath)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to select output folder:', error)
|
||
alert(`输出文件夹选择失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||
}
|
||
}}
|
||
className="btn-secondary px-3 py-2"
|
||
title="选择输出文件夹"
|
||
>
|
||
<Folder size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Prompt Input */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||
生成提示词 {mode === 'batch' && '(每行一个,将循环使用)'}
|
||
</label>
|
||
<textarea
|
||
value={customPrompt}
|
||
onChange={(e) => setCustomPrompt(e.target.value)}
|
||
placeholder={mode === 'single'
|
||
? "输入视频生成提示词..."
|
||
: "输入提示词,每行一个。留空将使用默认提示词。"
|
||
}
|
||
rows={mode === 'single' ? 3 : 6}
|
||
className="input w-full resize-none"
|
||
/>
|
||
</div>
|
||
|
||
{/* Settings */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||
<Clock size={16} className="inline mr-1" />
|
||
视频时长
|
||
</label>
|
||
<select
|
||
value={duration}
|
||
onChange={(e) => {
|
||
setDuration(e.target.value)
|
||
setDefaultDuration(e.target.value)
|
||
}}
|
||
className="input w-full"
|
||
>
|
||
<option value="5">5秒</option>
|
||
<option value="10">10秒</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||
<Cpu size={16} className="inline mr-1" />
|
||
模型类型
|
||
</label>
|
||
<select
|
||
value={modelType}
|
||
onChange={(e) => {
|
||
setModelType(e.target.value)
|
||
setDefaultModelType(e.target.value)
|
||
}}
|
||
className="input w-full"
|
||
>
|
||
<option value="lite">Lite (720p)</option>
|
||
<option value="pro">Pro (1080p)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Generate Button */}
|
||
<button
|
||
onClick={handleGenerate}
|
||
disabled={isProcessing}
|
||
className="btn-primary w-full py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<Play size={16} className="mr-2" />
|
||
{isProcessing ? '生成中...' : '开始生成'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Jobs List */}
|
||
<div className="p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-lg font-medium text-secondary-900">生成任务</h3>
|
||
{jobs.length > 0 && (
|
||
<button
|
||
onClick={clearCompletedJobs}
|
||
className="text-sm text-secondary-600 hover:text-secondary-800 transition-colors"
|
||
>
|
||
清除已完成
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{jobs.length === 0 ? (
|
||
<div className="text-center py-8 text-secondary-500">
|
||
<FileText size={32} className="mx-auto mb-2" />
|
||
<p>暂无生成任务</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{jobs.map(job => (
|
||
<div
|
||
key={job.id}
|
||
className="border border-secondary-200 rounded-lg p-4"
|
||
>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="flex items-center space-x-2">
|
||
<span className={`w-2 h-2 rounded-full ${
|
||
job.status === 'completed' ? 'bg-green-500' :
|
||
job.status === 'failed' ? 'bg-red-500' :
|
||
job.status === 'processing' ? 'bg-blue-500' :
|
||
'bg-yellow-500'
|
||
}`} />
|
||
<span className="font-medium text-secondary-900">
|
||
{job.type === 'single' ? '单张图片' : '批量处理'}
|
||
</span>
|
||
<span className="text-sm text-secondary-600">
|
||
{formatTime(job.startTime)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center space-x-2">
|
||
<span className="text-sm text-secondary-600">
|
||
{formatDuration(job.startTime, job.endTime)}
|
||
</span>
|
||
<button
|
||
onClick={() => removeJob(job.id)}
|
||
className="text-secondary-400 hover:text-red-500 transition-colors"
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{job.status === 'processing' && (
|
||
<div className="w-full bg-secondary-200 rounded-full h-2 mb-2">
|
||
<div
|
||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||
style={{ width: `${job.progress}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{job.error && (
|
||
<div className="text-sm text-red-600 bg-red-50 p-2 rounded">
|
||
{job.error}
|
||
</div>
|
||
)}
|
||
|
||
{job.result && job.status === 'completed' && (
|
||
<div className="text-sm text-green-600 bg-green-50 p-2 rounded">
|
||
生成成功!
|
||
{job.result.video_path && (
|
||
<button className="ml-2 text-blue-600 hover:text-blue-800">
|
||
<Download size={14} className="inline mr-1" />
|
||
查看结果
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default AIVideoGenerator
|