mxivideo/src/components/AIVideoGenerator.tsx

437 lines
16 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 } 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