mixvideo-v2/apps/desktop/src/pages/VideoGeneration.tsx

372 lines
12 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import {
SparklesIcon,
ArrowRightIcon
} from '@heroicons/react/24/outline';
import {
VideoGenerationProject,
MaterialCategory,
MaterialAsset,
VideoGenerationConfig,
VideoGenerationStatus,
MATERIAL_CATEGORY_CONFIG
} from '../types/videoGeneration';
import { MaterialSelector } from '../components/video-generation/MaterialSelector';
import { CompactVideoConfigPanel } from '../components/video-generation/CompactVideoConfigPanel';
import { CentralVideoPreview } from '../components/video-generation/CentralVideoPreview';
import { TaskStatusPanel, VideoGenerationTask } from '../components/video-generation/TaskStatusPanel';
/**
* 视频生成页面
* 支持素材选择、配置设置和视频生成预览
*/
const VideoGeneration: React.FC = () => {
const { projectId } = useParams<{ projectId?: string }>();
const [project, setProject] = useState<VideoGenerationProject | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [activeMaterialTab, setActiveMaterialTab] = useState<MaterialCategory>(MaterialCategory.Model);
const [isGenerating, setIsGenerating] = useState(false);
const [isTaskPanelCollapsed, setIsTaskPanelCollapsed] = useState(false);
const [tasks, setTasks] = useState<VideoGenerationTask[]>([]);
// 初始化项目
useEffect(() => {
if (projectId) {
loadProject(projectId);
} else {
createNewProject();
}
}, [projectId]);
const loadProject = async (id: string) => {
setIsLoading(true);
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500));
// 模拟项目数据
const mockProject: VideoGenerationProject = {
id,
name: `视频生成项目 ${id}`,
description: '基于AI的视频生成项目',
selected_assets: {},
generation_config: {
output_format: 'mp4',
resolution: '1080p',
frame_rate: 30,
duration: 30,
quality: 'high',
audio_enabled: true,
effects: []
},
status: VideoGenerationStatus.Pending,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
setProject(mockProject);
} catch (error) {
console.error('Failed to load project:', error);
} finally {
setIsLoading(false);
}
};
const createNewProject = () => {
const newProject: VideoGenerationProject = {
id: Date.now().toString(),
name: '新建视频生成项目',
description: '',
selected_assets: {},
generation_config: {
output_format: 'mp4',
resolution: '1080p',
frame_rate: 30,
duration: 30,
quality: 'high',
audio_enabled: true,
effects: []
},
status: VideoGenerationStatus.Pending,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
setProject(newProject);
};
// 处理素材选择变化
const handleAssetsChange = (category: MaterialCategory, assets: MaterialAsset[]) => {
if (!project) return;
setProject(prev => ({
...prev!,
selected_assets: {
...prev!.selected_assets,
[category]: assets
},
updated_at: new Date().toISOString()
}));
};
// 处理配置变化
const handleConfigChange = (config: VideoGenerationConfig) => {
if (!project) return;
setProject(prev => ({
...prev!,
generation_config: config,
updated_at: new Date().toISOString()
}));
};
// 生成视频
const handleGenerateVideo = async () => {
if (!project) return;
setIsGenerating(true);
// 创建新任务
const newTask: VideoGenerationTask = {
id: Date.now().toString(),
name: `视频生成 - ${project.name}`,
status: 'running',
progress: 0,
startTime: new Date().toISOString()
};
setTasks(prev => [newTask, ...prev]);
try {
// 模拟视频生成过程
for (let i = 0; i <= 100; i += 10) {
await new Promise(resolve => setTimeout(resolve, 300));
setTasks(prev => prev.map(task =>
task.id === newTask.id
? { ...task, progress: i }
: task
));
}
// 完成任务
setTasks(prev => prev.map(task =>
task.id === newTask.id
? {
...task,
status: 'completed' as const,
progress: 100,
endTime: new Date().toISOString(),
duration: Math.floor((Date.now() - new Date(newTask.startTime!).getTime()) / 1000),
outputPath: `/outputs/video_${newTask.id}.mp4`
}
: task
));
setProject(prev => ({
...prev!,
status: VideoGenerationStatus.Completed,
updated_at: new Date().toISOString()
}));
} catch (error) {
console.error('Failed to generate video:', error);
// 标记任务失败
setTasks(prev => prev.map(task =>
task.id === newTask.id
? {
...task,
status: 'failed' as const,
endTime: new Date().toISOString(),
errorMessage: '视频生成失败,请重试'
}
: task
));
setProject(prev => ({
...prev!,
status: VideoGenerationStatus.Failed,
updated_at: new Date().toISOString()
}));
} finally {
setIsGenerating(false);
}
};
// 任务管理函数
const handleCancelTask = (taskId: string) => {
setTasks(prev => prev.map(task =>
task.id === taskId
? { ...task, status: 'cancelled' as const, endTime: new Date().toISOString() }
: task
));
};
const handleRetryTask = (taskId: string) => {
const task = tasks.find(t => t.id === taskId);
if (task) {
handleGenerateVideo();
}
};
const handleDeleteTask = (taskId: string) => {
setTasks(prev => prev.filter(task => task.id !== taskId));
};
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<span className="ml-3 text-gray-600">...</span>
</div>
);
}
if (!project) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<p className="text-gray-500"></p>
<button
onClick={createNewProject}
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors duration-200"
>
</button>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-gradient-to-br from-gray-50 via-white to-gray-50">
{/* 顶部工具栏 */}
<div className="flex-shrink-0 bg-white/95 backdrop-blur-sm border-b border-gray-200/50 px-6 py-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-heading-3 text-gradient-primary">
</h1>
<p className="text-caption text-medium-emphasis">
{project.name}
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleGenerateVideo}
disabled={isGenerating}
className="flex items-center gap-2 px-4 py-2 bg-gradient-primary text-white rounded-lg hover:shadow-medium transition-all duration-200 hover-lift touch-target disabled:opacity-50"
>
<SparklesIcon className="h-4 w-4" />
{isGenerating ? '生成中...' : '开始生成'}
</button>
</div>
</div>
</div>
{/* 主要工作区域 */}
<div className="flex-1 flex overflow-hidden">
{/* 左侧素材区 */}
<div className="w-80 bg-white border-r border-gray-200 flex flex-col">
{/* 素材分类标签 */}
<div className="flex-shrink-0 border-b border-gray-200">
<div className="flex overflow-x-auto overflow-y-hidden scrollbar-hidden"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
{Object.values(MaterialCategory).map((category) => {
const config = MATERIAL_CATEGORY_CONFIG[category];
const selectedAssets = project.selected_assets[category] || [];
const isActive = activeMaterialTab === category;
return (
<button
key={category}
onClick={() => setActiveMaterialTab(category)}
className={`flex-shrink-0 flex items-center gap-2 px-3 py-3 border-b-2 transition-all duration-200 min-w-max ${
isActive
? 'border-primary-500 text-primary-600 bg-primary-50'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:bg-gray-50'
}`}
>
<span className="text-base">{config.icon}</span>
<div className="text-left">
<div className="text-xs font-medium whitespace-nowrap">{config.label}</div>
<div className="text-xs text-gray-500">
{selectedAssets.length}
</div>
</div>
</button>
);
})}
</div>
</div>
{/* 素材列表 */}
<div className="flex-1 overflow-hidden">
<MaterialSelector
category={activeMaterialTab}
selectedAssets={project.selected_assets[activeMaterialTab] || []}
onAssetsChange={(assets) => handleAssetsChange(activeMaterialTab, assets)}
allowMultiple={true}
/>
</div>
</div>
{/* 中央预览区 */}
<div className="flex-1 flex flex-col">
{/* 视频预览区域 */}
<div className="flex-1">
<CentralVideoPreview
project={project}
onConfigChange={handleConfigChange}
/>
</div>
{/* 底部参数设置区 */}
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
<CompactVideoConfigPanel
config={project.generation_config}
onConfigChange={handleConfigChange}
/>
</div>
</div>
{/* 右侧任务状态区(可折叠) */}
<div className={`bg-white border-l border-gray-200 transition-all duration-300 ${
isTaskPanelCollapsed ? 'w-12' : 'w-80'
}`}>
{/* 折叠按钮 */}
<div className="flex items-center justify-between p-3 border-b border-gray-200">
{!isTaskPanelCollapsed && (
<h3 className="text-sm font-medium text-gray-900"></h3>
)}
<button
onClick={() => setIsTaskPanelCollapsed(!isTaskPanelCollapsed)}
className="p-1 text-gray-500 hover:text-gray-700 rounded"
>
<ArrowRightIcon className={`h-4 w-4 transition-transform duration-200 ${
isTaskPanelCollapsed ? 'rotate-180' : ''
}`} />
</button>
</div>
{/* 任务列表 */}
{!isTaskPanelCollapsed && (
<div className="flex-1 overflow-hidden">
<TaskStatusPanel
tasks={tasks}
onCancelTask={handleCancelTask}
onRetryTask={handleRetryTask}
onDeleteTask={handleDeleteTask}
/>
</div>
)}
</div>
</div>
</div>
);
};
export default VideoGeneration;