372 lines
12 KiB
TypeScript
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;
|