Files
mxivideo/src/components/AIVideoGenerator.tsx
root 253d0ccdd1 fix: 修复文件选择功能 - 添加 Tauri 对话框支持
🔧 主要修复:

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 作为备用方案
   - 添加详细的错误信息和解决建议

 修复效果:
- 图片文件选择:原生对话框 ✓
- 文件夹选择:原生目录选择 ✓
- 输出目录选择:原生对话框 ✓
- 手动路径输入:备用方案 ✓
- 错误处理:友好提示 ✓

现在用户可以通过原生文件对话框轻松选择文件和文件夹!
2025-07-10 11:40:23 +08:00

494 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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