🎬 新增功能: 1. AI 视频结果预览组件 (AIVideoResultPreview): - 支持单个和批量视频生成结果预览 - 内置视频播放器,支持在线播放 - 显示生成提示词、本地路径、在线链接 - 批量结果支持视频选择器浏览多个视频 - 优雅的错误处理和加载状态 2. 视频预览功能: - HTML5 视频播放器,支持完整控制 - 自动使用原图作为视频封面 - 播放失败时显示友好错误信息 - 响应式设计,适配不同屏幕尺寸 3. 批量结果管理: - 显示批量处理统计信息(成功/失败数量) - 视频选择器,方便浏览多个生成结果 - 每个视频显示对应的提示词和路径信息 - 统一的操作界面和交互体验 4. 文件系统集成: - 新增 open_folder Tauri 命令 - 跨平台支持(Windows/macOS/Linux) - 一键打开视频所在文件夹 - 备用方案:显示完整文件路径 5. 用户界面增强: - 任务完成后显示 '预览视频' 按钮 - 改进的成功状态显示,包含统计信息 - 模态对话框形式的预览界面 - 直观的操作按钮和状态指示 6. 操作功能: - 打开文件夹:直接定位到视频文件位置 - 在新窗口打开:使用浏览器播放在线视频 - 复制路径:方便用户获取文件信息 - 关闭预览:返回主界面 ✅ 用户体验提升: - 生成完成后可立即预览结果 ✓ - 批量处理结果一目了然 ✓ - 便捷的文件管理操作 ✓ - 专业的视频播放体验 ✓ 现在用户可以直接在应用内预览生成的 AI 视频!
490 lines
18 KiB
TypeScript
490 lines
18 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'
|
||
import AIVideoResultPreview from './AIVideoResultPreview'
|
||
|
||
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')
|
||
const [previewData, setPreviewData] = useState<{
|
||
isOpen: boolean
|
||
result: any
|
||
jobType: 'single' | 'batch'
|
||
prompt?: string
|
||
imagePath?: string
|
||
}>({
|
||
isOpen: false,
|
||
result: null,
|
||
jobType: 'single'
|
||
})
|
||
|
||
// 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])
|
||
|
||
// Preview functions
|
||
const openPreview = (job: any) => {
|
||
setPreviewData({
|
||
isOpen: true,
|
||
result: job.result,
|
||
jobType: job.type,
|
||
prompt: job.type === 'single' ? job.request.prompt : undefined,
|
||
imagePath: job.type === 'single' ? job.request.imagePath : undefined
|
||
})
|
||
}
|
||
|
||
const closePreview = () => {
|
||
setPreviewData({
|
||
isOpen: false,
|
||
result: null,
|
||
jobType: 'single'
|
||
})
|
||
}
|
||
|
||
// 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-3 rounded">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="font-medium mb-1">✅ 生成成功!</div>
|
||
{job.type === 'batch' && job.result.success_count && (
|
||
<div className="text-xs text-green-600">
|
||
成功: {job.result.success_count} 个,失败: {job.result.failed_count || 0} 个
|
||
</div>
|
||
)}
|
||
</div>
|
||
{(job.result.video_path || job.result.video_url || job.result.results) && (
|
||
<button
|
||
onClick={() => openPreview(job)}
|
||
className="flex items-center gap-1 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors"
|
||
>
|
||
<Play size={12} />
|
||
预览视频
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Video Preview Modal */}
|
||
<AIVideoResultPreview
|
||
isOpen={previewData.isOpen}
|
||
onClose={closePreview}
|
||
result={previewData.result}
|
||
jobType={previewData.jobType}
|
||
prompt={previewData.prompt}
|
||
imagePath={previewData.imagePath}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default AIVideoGenerator
|