🎬 主要功能: - ✅ 完整的 AI 视频生成模块 (Python) - ✅ 图片转视频 API 集成 (字节跳动 Seedance) - ✅ 云存储支持 (腾讯云 COS) - ✅ 单张图片和批量处理模式 - ✅ 现代化 React 界面组件 - ✅ Tauri 桥接通信 🛠️ 技术实现: - Python 模块:VideoGenerator, CloudStorage, APIClient - Rust 命令:generate_ai_video, batch_generate_ai_videos - React 组件:AIVideoGenerator, AIVideoPage - 状态管理:useAIVideoStore (Zustand) - 路由集成:/ai-video 页面 �� 新增文件: - python_core/ai_video/ - AI 视频生成核心模块 - src/components/AIVideoGenerator.tsx - 主要 UI 组件 - src/pages/AIVideoPage.tsx - AI 视频生成页面 - src/stores/useAIVideoStore.ts - 状态管理 🎯 功能特性: - 支持 Lite (720p) 和 Pro (1080p) 模型 - 可配置视频时长 (5秒/10秒) - 实时进度跟踪和任务管理 - 批量处理多张图片 - 云存储自动上传下载 - 错误处理和重试机制 🔗 界面集成: - 侧边栏导航添加 'AI 视频' 入口 - 首页快速操作卡片 - 完整的用户引导和帮助文档 这是从原始 Tkinter GUI 到现代 Web 应用的完整迁移!
382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
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'
|
||
|
||
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 = () => {
|
||
fileInputRef.current?.click()
|
||
}
|
||
|
||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
setSelectedImage(file.path || file.name)
|
||
}
|
||
}
|
||
|
||
const handleFolderSelect = () => {
|
||
folderInputRef.current?.click()
|
||
}
|
||
|
||
const handleFolderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = e.target.files
|
||
if (files && files.length > 0) {
|
||
// Get the directory path from the first file
|
||
const firstFile = files[0]
|
||
const path = firstFile.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)
|
||
alert(`生成失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||
}
|
||
}
|
||
|
||
// 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="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 || '未选择文件'}
|
||
</span>
|
||
</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=""
|
||
multiple
|
||
onChange={handleFolderChange}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||
视频保存目录
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={outputFolder}
|
||
onChange={(e) => setOutputFolder(e.target.value)}
|
||
placeholder="输入保存目录路径"
|
||
className="input w-full"
|
||
/>
|
||
</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
|