437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
import React, { useState } from 'react'
|
||
import { Upload, Play, Folder, FileText, Clock, Cpu, Trash2, Download } from 'lucide-react'
|
||
import { useAIVideoStore, useAIVideoJobs, useAIVideoProcessing, useAIVideoSettings } from '../stores/useAIVideoStore'
|
||
import { invoke } from '@tauri-apps/api/core'
|
||
|
||
interface AIVideoGeneratorProps {
|
||
className?: string
|
||
}
|
||
|
||
const AIVideoGenerator: React.FC<AIVideoGeneratorProps> = ({ className = '' }) => {
|
||
|
||
// 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 filePath = await invoke('select_image_file') as string
|
||
console.log('Selected image:', filePath)
|
||
setSelectedImage(filePath)
|
||
} catch (error) {
|
||
console.error('Failed to select image:', error)
|
||
alert(`文件选择失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||
}
|
||
}
|
||
|
||
|
||
|
||
const handleFolderSelect = async () => {
|
||
try {
|
||
console.log('Opening folder dialog...')
|
||
const folderPath = await invoke('select_folder') as string
|
||
console.log('Selected folder:', folderPath)
|
||
setSelectedFolder(folderPath)
|
||
} catch (error) {
|
||
console.error('Failed to select folder:', error)
|
||
alert(`文件夹选择失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// Handle generation
|
||
const handleGenerate = async () => {
|
||
try {
|
||
if (mode === 'single') {
|
||
if (!selectedImage || !customPrompt) {
|
||
alert('请选择图片文件并输入提示词')
|
||
return
|
||
}
|
||
|
||
// Validate file path
|
||
if (!selectedImage.includes('\\') && !selectedImage.includes('/')) {
|
||
alert('请选择完整的文件路径,而不是仅仅文件名。\n\n请点击"选择文件"按钮来选择图片文件。')
|
||
return
|
||
}
|
||
|
||
console.log('Generating video with:', {
|
||
image_path: selectedImage,
|
||
prompt: customPrompt,
|
||
duration,
|
||
model_type: modelType,
|
||
output_path: outputFolder
|
||
})
|
||
|
||
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">
|
||
<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-primary-100 hover:bg-primary-200 text-primary-700 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="或手动输入完整的图片文件路径 (例如: C:\Users\用户名\Pictures\image.jpg)"
|
||
className="input w-full text-sm"
|
||
/>
|
||
</div>
|
||
|
||
</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>
|
||
|
||
</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 folderPath = await invoke('select_folder') as string
|
||
console.log('Selected output folder:', folderPath)
|
||
setOutputFolder(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-3 rounded border border-red-200">
|
||
<div className="font-medium mb-1">❌ 生成失败</div>
|
||
<div className="mb-2">{job.error}</div>
|
||
{job.result && job.result.msg && job.result.msg !== job.error && (
|
||
<div className="text-xs text-red-500 mt-1">
|
||
详细信息: {job.result.msg}
|
||
</div>
|
||
)}
|
||
<div className="text-xs text-red-400 mt-2">
|
||
💡 提示: 请确保选择了完整的文件路径,而不是仅仅文件名
|
||
</div>
|
||
</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
|