Files
mxivideo/src/components/AIVideoGenerator.tsx
root 96e166725b feat: 集成 AI 视频生成功能到 MixVideo V2
🎬 主要功能:
-  完整的 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 应用的完整迁移!
2025-07-10 10:43:40 +08:00

382 lines
13 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, 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