Files
mxivideo/src/components/AIVideoGenerator.tsx
root 1132cb9777 feat: 添加 AI 视频生成结果预览功能
🎬 新增功能:

1. AI 视频结果预览组件 (AIVideoResultPreview):
   - 支持单个和批量视频生成结果预览
   - 内置视频播放器,支持在线播放
   - 显示生成提示词、本地路径、在线链接
   - 批量结果支持视频选择器浏览多个视频
   - 优雅的错误处理和加载状态

2. 视频预览功能:
   - HTML5 视频播放器,支持完整控制
   - 自动使用原图作为视频封面
   - 播放失败时显示友好错误信息
   - 响应式设计,适配不同屏幕尺寸

3. 批量结果管理:
   - 显示批量处理统计信息(成功/失败数量)
   - 视频选择器,方便浏览多个生成结果
   - 每个视频显示对应的提示词和路径信息
   - 统一的操作界面和交互体验

4. 文件系统集成:
   - 新增 open_folder Tauri 命令
   - 跨平台支持(Windows/macOS/Linux)
   - 一键打开视频所在文件夹
   - 备用方案:显示完整文件路径

5. 用户界面增强:
   - 任务完成后显示 '预览视频' 按钮
   - 改进的成功状态显示,包含统计信息
   - 模态对话框形式的预览界面
   - 直观的操作按钮和状态指示

6. 操作功能:
   - 打开文件夹:直接定位到视频文件位置
   - 在新窗口打开:使用浏览器播放在线视频
   - 复制路径:方便用户获取文件信息
   - 关闭预览:返回主界面

 用户体验提升:
- 生成完成后可立即预览结果 ✓
- 批量处理结果一目了然 ✓
- 便捷的文件管理操作 ✓
- 专业的视频播放体验 ✓

现在用户可以直接在应用内预览生成的 AI 视频!
2025-07-10 13:33:11 +08:00

490 lines
18 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'
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