diff --git a/apps/desktop/src/components/video-generation/CentralVideoPreview.tsx b/apps/desktop/src/components/video-generation/CentralVideoPreview.tsx new file mode 100644 index 0000000..e3749a2 --- /dev/null +++ b/apps/desktop/src/components/video-generation/CentralVideoPreview.tsx @@ -0,0 +1,234 @@ +import React, { useState } from 'react'; +import { + PlayIcon, + PauseIcon, + SpeakerWaveIcon, + SpeakerXMarkIcon, + ArrowsPointingOutIcon, + EyeIcon +} from '@heroicons/react/24/outline'; +import { + VideoGenerationProject, + VideoGenerationConfig, + MaterialCategory, + MATERIAL_CATEGORY_CONFIG +} from '../../types/videoGeneration'; + +interface CentralVideoPreviewProps { + project: VideoGenerationProject; + onConfigChange?: (config: VideoGenerationConfig) => void; +} + +/** + * 中央视频预览组件 + * 专门用于工作台中央预览区域 + */ +export const CentralVideoPreview: React.FC = ({ + project, + onConfigChange +}) => { + const [isPlaying, setIsPlaying] = useState(false); + const [isMuted, setIsMuted] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + + // 获取所有选中的素材 + const getAllSelectedAssets = () => { + const allAssets: any[] = []; + Object.entries(project.selected_assets).forEach(([category, assets]) => { + if (assets && assets.length > 0) { + allAssets.push(...assets.map(asset => ({ ...asset, category: category as MaterialCategory }))); + } + }); + return allAssets; + }; + + const selectedAssets = getAllSelectedAssets(); + + // 模拟播放控制 + const handlePlayPause = () => { + setIsPlaying(!isPlaying); + }; + + const handleMuteToggle = () => { + setIsMuted(!isMuted); + }; + + const handleSeek = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + const newTime = percent * project.generation_config.duration; + setCurrentTime(Math.max(0, Math.min(newTime, project.generation_config.duration))); + }; + + return ( +
+ {/* 预览标题 */} +
+
+ +

视频预览

+
+
+ {project.generation_config.resolution} • {project.generation_config.frame_rate}fps • {project.generation_config.duration}s +
+
+ + {/* 主预览区域 */} +
+
+ {/* 视频预览窗口 */} +
+ {/* 模拟视频内容 */} +
+
+
+ +
+

视频预览

+

+ 已选择 {selectedAssets.length} 个素材 +

+
+
+ + {/* 播放控制覆盖层 */} +
+
+
+ {/* 播放控制 */} +
+ + + + + {/* 时间显示 */} +
+ {Math.floor(currentTime)}s / {project.generation_config.duration}s +
+
+ + {/* 全屏按钮 */} + +
+ + {/* 进度条 */} +
+
+
+
+
+
+
+
+ + {/* 选中素材概览 */} +
+
+

选中素材

+ {selectedAssets.length} 个素材 +
+ + {selectedAssets.length === 0 ? ( +
+

请从左侧素材区选择素材

+
+ ) : ( +
+ {Object.values(MaterialCategory).map((category) => { + const categoryAssets = project.selected_assets[category] || []; + if (categoryAssets.length === 0) return null; + + const config = MATERIAL_CATEGORY_CONFIG[category]; + + return categoryAssets.map((asset) => ( +
+
+ {asset.thumbnail_path ? ( + {asset.name} + ) : ( +
+ {config.icon} +
+ )} +
+ + {/* 素材信息提示 */} +
+
+
{asset.name}
+
{config.label}
+
+
+
+ )); + })} +
+ )} +
+ + {/* 生成统计 */} +
+
+
+
{selectedAssets.length}
+
选中素材
+
+
+
{project.generation_config.duration}s
+
视频时长
+
+
+
+ {(() => { + const baseSize = project.generation_config.duration * 2; + const qualityMultiplier = project.generation_config.quality === 'high' ? 2 : + project.generation_config.quality === 'medium' ? 1.5 : 1; + const resolutionMultiplier = project.generation_config.resolution === '4k' ? 4 : + project.generation_config.resolution === '1080p' ? 2 : 1; + return `${(baseSize * qualityMultiplier * resolutionMultiplier).toFixed(1)}MB`; + })()} +
+
预估大小
+
+
+
+ {project.generation_config.quality === 'high' ? '高' : + project.generation_config.quality === 'medium' ? '中' : '低'} +
+
输出质量
+
+
+
+
+ ); +}; diff --git a/apps/desktop/src/components/video-generation/CompactVideoConfigPanel.tsx b/apps/desktop/src/components/video-generation/CompactVideoConfigPanel.tsx new file mode 100644 index 0000000..4e39f1e --- /dev/null +++ b/apps/desktop/src/components/video-generation/CompactVideoConfigPanel.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { + CogIcon, + FilmIcon, + SpeakerWaveIcon, + ClockIcon +} from '@heroicons/react/24/outline'; +import { VideoGenerationConfig } from '../../types/videoGeneration'; + +interface CompactVideoConfigPanelProps { + config: VideoGenerationConfig; + onConfigChange: (config: VideoGenerationConfig) => void; +} + +/** + * 紧凑型视频配置面板组件 + * 适用于底部参数设置区域 + */ +export const CompactVideoConfigPanel: React.FC = ({ + config, + onConfigChange +}) => { + const handleConfigUpdate = (updates: Partial) => { + onConfigChange({ ...config, ...updates }); + }; + + return ( +
+ {/* 标题 */} +
+ +

视频参数

+
+ + {/* 参数控制区 */} +
+ {/* 输出格式 */} +
+ + +
+ + {/* 分辨率 */} +
+ + +
+ + {/* 帧率 */} +
+ + +
+ + {/* 时长 */} +
+ + handleConfigUpdate({ + duration: parseInt(e.target.value) || 30 + })} + className="w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-primary-500 focus:border-transparent" + /> +
+ + {/* 质量 */} +
+ + +
+ + {/* 音频开关 */} +
+ +
+ + + {config.audio_enabled ? '开启' : '关闭'} + +
+
+
+ + {/* 预估信息 */} +
+
+ + 预估大小: {(() => { + const baseSize = config.duration * 2; + const qualityMultiplier = config.quality === 'high' ? 2 : config.quality === 'medium' ? 1.5 : 1; + const resolutionMultiplier = config.resolution === '4k' ? 4 : config.resolution === '1080p' ? 2 : 1; + const estimatedSize = baseSize * qualityMultiplier * resolutionMultiplier; + return `${estimatedSize.toFixed(1)} MB`; + })()} + + + 预估时间: {(() => { + const baseTime = config.duration * 0.5; + const qualityMultiplier = config.quality === 'high' ? 2 : config.quality === 'medium' ? 1.5 : 1; + const estimatedTime = baseTime * qualityMultiplier; + return `${Math.ceil(estimatedTime)} 秒`; + })()} + +
+ +
+ {config.resolution} • {config.frame_rate}fps • {config.output_format.toUpperCase()} +
+
+ + {/* 高级选项(可展开) */} +
+ + 高级选项 + + + + + +
+ {/* 特效选项 */} + {[ + { id: 'fade', label: '淡入淡出' }, + { id: 'zoom', label: '缩放效果' }, + { id: 'slide', label: '滑动转场' }, + { id: 'blur', label: '模糊背景' } + ].map((effect) => ( + + ))} +
+
+
+ ); +}; diff --git a/apps/desktop/src/components/video-generation/TaskStatusPanel.tsx b/apps/desktop/src/components/video-generation/TaskStatusPanel.tsx new file mode 100644 index 0000000..ad699ba --- /dev/null +++ b/apps/desktop/src/components/video-generation/TaskStatusPanel.tsx @@ -0,0 +1,253 @@ +import React from 'react'; +import { + ClockIcon, + CheckCircleIcon, + ExclamationCircleIcon, + XCircleIcon, + PlayIcon, + PauseIcon, + TrashIcon +} from '@heroicons/react/24/outline'; + +export interface VideoGenerationTask { + id: string; + name: string; + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + progress: number; // 0-100 + startTime?: string; + endTime?: string; + duration?: number; + errorMessage?: string; + outputPath?: string; +} + +interface TaskStatusPanelProps { + tasks: VideoGenerationTask[]; + onCancelTask?: (taskId: string) => void; + onRetryTask?: (taskId: string) => void; + onDeleteTask?: (taskId: string) => void; +} + +/** + * 任务状态面板组件 + * 显示视频生成任务的运行状况 + */ +export const TaskStatusPanel: React.FC = ({ + tasks, + onCancelTask, + onRetryTask, + onDeleteTask +}) => { + const getStatusIcon = (status: VideoGenerationTask['status']) => { + switch (status) { + case 'pending': + return ; + case 'running': + return ; + case 'completed': + return ; + case 'failed': + return ; + case 'cancelled': + return ; + default: + return ; + } + }; + + const getStatusText = (status: VideoGenerationTask['status']) => { + switch (status) { + case 'pending': + return '等待中'; + case 'running': + return '运行中'; + case 'completed': + return '已完成'; + case 'failed': + return '失败'; + case 'cancelled': + return '已取消'; + default: + return '未知'; + } + }; + + const getStatusColor = (status: VideoGenerationTask['status']) => { + switch (status) { + case 'pending': + return 'bg-yellow-50 border-yellow-200'; + case 'running': + return 'bg-blue-50 border-blue-200'; + case 'completed': + return 'bg-green-50 border-green-200'; + case 'failed': + return 'bg-red-50 border-red-200'; + case 'cancelled': + return 'bg-gray-50 border-gray-200'; + default: + return 'bg-gray-50 border-gray-200'; + } + }; + + const formatDuration = (seconds?: number) => { + if (!seconds) return ''; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const formatTime = (timeString?: string) => { + if (!timeString) return ''; + const date = new Date(timeString); + return date.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + }; + + return ( +
+ {/* 任务统计 */} +
+
+
+
+ {tasks.filter(t => t.status === 'running').length} +
+
运行中
+
+
+
+ {tasks.filter(t => t.status === 'pending').length} +
+
等待中
+
+
+
+ + {/* 任务列表 */} +
+ {tasks.length === 0 ? ( +
+ +
暂无运行任务
+
+ 点击"开始生成"创建任务 +
+
+ ) : ( +
+ {tasks.map((task) => ( +
+ {/* 任务标题和状态 */} +
+
+
+ {task.name} +
+
+ {getStatusIcon(task.status)} + + {getStatusText(task.status)} + +
+
+ + {/* 操作按钮 */} +
+ {task.status === 'running' && onCancelTask && ( + + )} + {task.status === 'failed' && onRetryTask && ( + + )} + {(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && onDeleteTask && ( + + )} +
+
+ + {/* 进度条 */} + {task.status === 'running' && ( +
+
+ 进度 + {task.progress}% +
+
+
+
+
+ )} + + {/* 任务详情 */} +
+ {task.startTime && ( +
开始: {formatTime(task.startTime)}
+ )} + {task.endTime && ( +
结束: {formatTime(task.endTime)}
+ )} + {task.duration && ( +
耗时: {formatDuration(task.duration)}
+ )} + {task.status === 'failed' && task.errorMessage && ( +
+ + {task.errorMessage} +
+ )} + {task.status === 'completed' && task.outputPath && ( +
+ + 输出: {task.outputPath.split('/').pop()} +
+ )} +
+
+ ))} +
+ )} +
+ + {/* 底部操作 */} +
+ +
+
+ ); +}; diff --git a/apps/desktop/src/pages/VideoGeneration.tsx b/apps/desktop/src/pages/VideoGeneration.tsx index d4c2ecc..4fe8b63 100644 --- a/apps/desktop/src/pages/VideoGeneration.tsx +++ b/apps/desktop/src/pages/VideoGeneration.tsx @@ -15,8 +15,9 @@ import { MATERIAL_CATEGORY_CONFIG } from '../types/videoGeneration'; import { MaterialSelector } from '../components/video-generation/MaterialSelector'; -import { VideoConfigPanel } from '../components/video-generation/VideoConfigPanel'; -import { VideoPreview } from '../components/video-generation/VideoPreview'; +import { CompactVideoConfigPanel } from '../components/video-generation/CompactVideoConfigPanel'; +import { CentralVideoPreview } from '../components/video-generation/CentralVideoPreview'; +import { TaskStatusPanel, VideoGenerationTask } from '../components/video-generation/TaskStatusPanel'; /** * 视频生成页面 @@ -26,8 +27,10 @@ const VideoGeneration: React.FC = () => { const { projectId } = useParams<{ projectId?: string }>(); const [project, setProject] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [activeStep, setActiveStep] = useState<'select' | 'config' | 'preview'>('select'); + const [activeMaterialTab, setActiveMaterialTab] = useState(MaterialCategory.Model); const [isGenerating, setIsGenerating] = useState(false); + const [isTaskPanelCollapsed, setIsTaskPanelCollapsed] = useState(false); + const [tasks, setTasks] = useState([]); // 初始化项目 useEffect(() => { @@ -123,21 +126,66 @@ const VideoGeneration: React.FC = () => { // 生成视频 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 { // 模拟视频生成过程 - await new Promise(resolve => setTimeout(resolve, 3000)); - + 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() })); - - setActiveStep('preview'); + } 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, @@ -148,16 +196,32 @@ const VideoGeneration: React.FC = () => { } }; + // 任务管理函数 + 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)); + }; + // 检查是否可以进行下一步 const canProceedToConfig = () => { if (!project) return false; return Object.keys(project.selected_assets).length > 0; }; - const canProceedToPreview = () => { - return canProceedToConfig(); - }; - if (isLoading) { return (
@@ -184,183 +248,128 @@ const VideoGeneration: React.FC = () => { } return ( -
- {/* 页面标题和步骤指示器 */} -
+
+ {/* 顶部工具栏 */} +
-
-

+
+

视频生成工作台

-

- {project.name} - 选择素材并配置生成参数 +

+ {project.name}

- {/* 步骤指示器 */} -
-
-
- 1 -
- - 选择素材 - -
- - - -
-
- 2 -
- - 配置参数 - -
- - - -
-
- 3 -
- - 生成预览 - -
+
+

- {/* 主要内容区域 */} -
- {activeStep === 'select' && ( -
-
+ {/* 主要工作区域 */} +
+ {/* 左侧素材区 */} +
+ {/* 素材分类标签 */} +
+
{Object.values(MaterialCategory).map((category) => { const config = MATERIAL_CATEGORY_CONFIG[category]; const selectedAssets = project.selected_assets[category] || []; - + const isActive = activeMaterialTab === category; + return ( -
-
-
- {config.icon} -

- {config.label} -

- - ({selectedAssets.length} 已选择) - +
+ ); })}
- - {/* 下一步按钮 */} -
- -
- )} - {activeStep === 'config' && ( -
-
- + handleAssetsChange(activeMaterialTab, assets)} + allowMultiple={true} + /> +
+
+ + {/* 中央预览区 */} +
+ {/* 视频预览区域 */} +
+ +
+ + {/* 底部参数设置区 */} +
+ +
+
+ + {/* 右侧任务状态区(可折叠) */} +
+ {/* 折叠按钮 */} +
+ {!isTaskPanelCollapsed && ( +

任务状态

+ )} + +
+ + {/* 任务列表 */} + {!isTaskPanelCollapsed && ( +
+ - - {/* 操作按钮 */} -
- - - -
-
- )} - - {activeStep === 'preview' && ( -
-
- - - {/* 生成按钮 */} -
- -
-
-
- )} + )} +
); diff --git a/apps/desktop/src/styles/design-system.css b/apps/desktop/src/styles/design-system.css index 0fb6fb7..3881784 100644 --- a/apps/desktop/src/styles/design-system.css +++ b/apps/desktop/src/styles/design-system.css @@ -2494,3 +2494,43 @@ main::-webkit-scrollbar-thumb:hover { border-color: rgb(var(--color-yellow-500)); background-color: rgb(var(--color-yellow-50)); } + +/* 视频生成工作台专用样式 */ + +/* 滚动条隐藏 */ +.scrollbar-hidden { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.scrollbar-hidden::-webkit-scrollbar { + display: none; +} + +/* 自定义滚动条 */ +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.5) transparent; +} + +.custom-scrollbar::-webkit-scrollbar { + width: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.5); + border-radius: 3px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.7); +} + +/* 缩略图容器 */ +.thumbnail-container { + background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%); +}