forked from 77media/video-flow
985 lines
36 KiB
TypeScript
985 lines
36 KiB
TypeScript
"use client"
|
||
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||
import { Play, ChevronUp, Loader2, Edit3, FileText, Pause } from "lucide-react";
|
||
import "./style/work-flow.css";
|
||
import LiquidGlass from '@/plugins/liquid-glass';
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { AISuggestionBar } from "@/components/ai-suggestion-bar";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import { debounce } from "lodash";
|
||
import { GlassIconButton } from "@/components/ui/glass-icon-button";
|
||
import { EditModal } from "@/components/ui/edit-modal";
|
||
|
||
const MOCK_SKETCH_URLS = [
|
||
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-1.jpg',
|
||
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-2.jpg',
|
||
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-3.jpg',
|
||
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-4.jpg',
|
||
];
|
||
const MOCK_SKETCH_SCRIPT = [
|
||
'script-123',
|
||
'script-123',
|
||
'script-123',
|
||
'script-123',
|
||
];
|
||
const MOCK_VIDEO_URLS = [
|
||
'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4',
|
||
'https://cdn.qikongjian.com/videos/1750389908_37d4fffa-8516-43a3-a423-fc0274f40e8a_text_to_video_0.mp4',
|
||
'https://cdn.qikongjian.com/videos/1750384661_d8e30b79-828e-48cd-9025-ab62a996717c_text_to_video_0.mp4',
|
||
'https://cdn.qikongjian.com/videos/1750320040_4b47996e-7c70-490e-8433-80c7df990fdd_text_to_video_0.mp4',
|
||
];
|
||
|
||
const MOCK_SKETCH_COUNT = 8;
|
||
|
||
export default function WorkFlow() {
|
||
const [taskObject, setTaskObject] = useState<any>(null);
|
||
const [projectObject, setProjectObject] = useState<any>(null);
|
||
const [taskSketch, setTaskSketch] = useState<any[]>([]);
|
||
const [sketchCount, setSketchCount] = useState(0);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isAIBarVisible, setIsAIBarVisible] = useState(true);
|
||
const [currentStep, setCurrentStep] = useState('0');
|
||
const [currentSketchIndex, setCurrentSketchIndex] = useState(0);
|
||
const [isGeneratingSketch, setIsGeneratingSketch] = useState(false);
|
||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [startX, setStartX] = useState(0);
|
||
const [scrollLeft, setScrollLeft] = useState(0);
|
||
const [showControls, setShowControls] = useState(false);
|
||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||
const [taskVideos, setTaskVideos] = useState<any[]>([]);
|
||
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
|
||
const mainVideoRef = useRef<HTMLVideoElement>(null);
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const playTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||
const [isVideoPlaying, setIsVideoPlaying] = useState(true);
|
||
const videoPlayTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||
const [currentLoadingText, setCurrentLoadingText] = useState('加载中...');
|
||
|
||
// 模拟 AI 建议
|
||
const mockSuggestions = [
|
||
"优化场景转场效果",
|
||
"调整画面构图",
|
||
"改进角色动作设计",
|
||
"增加环境氛围",
|
||
"调整镜头语言"
|
||
];
|
||
|
||
useEffect(() => {
|
||
const taskId = localStorage.getItem("taskId") || "taskId-123";
|
||
getTaskDetail(taskId).then(async (data) => {
|
||
setTaskObject(data);
|
||
setIsLoading(false);
|
||
setCurrentStep('1');
|
||
// 只在任务详情加载完成后获取分镜草图
|
||
await getTaskSketch(taskId);
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
// 首先修改 taskObject 下的 taskStatus 为 '2'
|
||
setTaskObject((prev: any) => ({
|
||
...prev,
|
||
taskStatus: '2'
|
||
}));
|
||
setCurrentStep('2');
|
||
// 获取分镜草图后,开始绘制角色
|
||
await getTaskRole(taskId);
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
// 首先修改 taskObject 下的 taskStatus 为 '3'
|
||
setTaskObject((prev: any) => ({
|
||
...prev,
|
||
taskStatus: '3'
|
||
}));
|
||
setCurrentStep('3');
|
||
// 获取绘制角色后,开始获取分镜视频
|
||
await getTaskVideo(taskId);
|
||
});
|
||
}, []);
|
||
|
||
// 监听当前选中索引变化,自动滚动到对应位置
|
||
useEffect(() => {
|
||
if (thumbnailsRef.current && taskSketch.length > 0) {
|
||
const container = thumbnailsRef.current;
|
||
const thumbnailWidth = container.offsetWidth / 4; // 每个缩略图宽度(包含间距)
|
||
const scrollPosition = currentSketchIndex * thumbnailWidth;
|
||
|
||
container.scrollTo({
|
||
left: scrollPosition,
|
||
behavior: 'smooth'
|
||
});
|
||
}
|
||
}, [currentSketchIndex, taskSketch.length]);
|
||
|
||
// 处理鼠标/触摸拖动事件
|
||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
setIsDragging(true);
|
||
setStartX(e.pageX - thumbnailsRef.current!.offsetLeft);
|
||
setScrollLeft(thumbnailsRef.current!.scrollLeft);
|
||
};
|
||
|
||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
if (!isDragging) return;
|
||
e.preventDefault();
|
||
const x = e.pageX - thumbnailsRef.current!.offsetLeft;
|
||
const walk = (x - startX) * 2;
|
||
thumbnailsRef.current!.scrollLeft = scrollLeft - walk;
|
||
};
|
||
|
||
const handleMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
setIsDragging(false);
|
||
if (!isDragging) return;
|
||
|
||
const container = thumbnailsRef.current!;
|
||
const thumbnailWidth = container.offsetWidth / 4;
|
||
const currentScroll = container.scrollLeft;
|
||
const nearestIndex = Math.round(currentScroll / thumbnailWidth);
|
||
|
||
// 只有在拖动距离较小时才触发选中
|
||
const x = e.pageX - container.offsetLeft;
|
||
const walk = Math.abs(x - startX);
|
||
if (walk < 10) {
|
||
return; // 如果拖动距离太小,保持原有的点击选中逻辑
|
||
}
|
||
|
||
setCurrentSketchIndex(Math.min(Math.max(0, nearestIndex), taskSketch.length - 1));
|
||
};
|
||
|
||
// 模拟接口请求 获取任务详情
|
||
const getTaskDetail = async (taskId: string) => {
|
||
// const response = await fetch(`/api/task/${taskId}`);
|
||
// const data = await response.json();
|
||
// mock data
|
||
const data = {
|
||
projectId: 'projectId-123',
|
||
projectName: "Project 1",
|
||
taskId: taskId,
|
||
taskName: "Task 1",
|
||
taskDescription: "Task 1 Description",
|
||
taskStatus: "1", // '1' 绘制分镜、'2' 绘制角色、'3' 生成分镜视频、'4' 视频后期制作、'5' 最终成品
|
||
taskProgress: 0,
|
||
mode: 'auto', // 托管模式、人工干预模式
|
||
resolution: '1080p', // 1080p、2160p
|
||
taskCreatedAt: new Date().toISOString(),
|
||
taskUpdatedAt: new Date().toISOString(),
|
||
};
|
||
return data;
|
||
}
|
||
|
||
// 模拟接口请求 每次获取一个分镜草图 轮询获取
|
||
const getTaskSketch = async (taskId: string) => {
|
||
// 避免重复调用
|
||
if (isGeneratingSketch || taskSketch.length > 0) return;
|
||
|
||
setIsGeneratingSketch(true);
|
||
setTaskSketch([]);
|
||
|
||
// 模拟分批获取分镜草图
|
||
for (let i = 0; i < MOCK_SKETCH_COUNT; i++) {
|
||
await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟2秒延迟
|
||
|
||
const newSketch = {
|
||
id: `sketch-${i}`,
|
||
url: MOCK_SKETCH_URLS[i % MOCK_SKETCH_URLS.length],
|
||
script: MOCK_SKETCH_SCRIPT[i % MOCK_SKETCH_SCRIPT.length],
|
||
status: 'done'
|
||
};
|
||
|
||
setTaskSketch(prev => {
|
||
// 避免重复添加相同id的sketch
|
||
if (prev.find(sketch => sketch.id === newSketch.id)) {
|
||
return prev;
|
||
}
|
||
return [...prev, newSketch];
|
||
});
|
||
setCurrentSketchIndex(i);
|
||
setSketchCount(i + 1);
|
||
}
|
||
|
||
setIsGeneratingSketch(false);
|
||
}
|
||
|
||
// 模拟接口请求 每次获取一个角色 轮询获取
|
||
const getTaskRole = async (taskId: string) => {
|
||
await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟2秒延迟
|
||
}
|
||
|
||
// 模拟接口请求 每次获取一个分镜视频 轮询获取
|
||
const getTaskVideo = async (taskId: string) => {
|
||
setIsGeneratingVideo(true);
|
||
setTaskVideos([]);
|
||
|
||
// 模拟分批获取分镜视频
|
||
for (let i = 0; i < MOCK_SKETCH_COUNT; i++) {
|
||
await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟2秒延迟
|
||
|
||
const newVideo = {
|
||
id: `video-${i}`,
|
||
url: MOCK_VIDEO_URLS[i % MOCK_VIDEO_URLS.length],
|
||
script: MOCK_SKETCH_SCRIPT[i % MOCK_SKETCH_SCRIPT.length],
|
||
status: 'done'
|
||
};
|
||
|
||
setTaskVideos(prev => {
|
||
// 避免重复添加相同id的video
|
||
if (prev.find(video => video.id === newVideo.id)) {
|
||
return prev;
|
||
}
|
||
return [...prev, newVideo];
|
||
});
|
||
setCurrentSketchIndex(i);
|
||
}
|
||
|
||
setIsGeneratingVideo(false);
|
||
};
|
||
|
||
const handleSuggestionClick = (suggestion: string) => {
|
||
console.log('Selected suggestion:', suggestion);
|
||
};
|
||
|
||
const handleSubmit = (text: string) => {
|
||
console.log('Submitted text:', text);
|
||
};
|
||
|
||
// 缓存渲染的缩略图列表
|
||
const renderedSketches = useMemo(() =>
|
||
taskSketch.map((sketch, index) => (
|
||
<motion.div
|
||
key={sketch.id}
|
||
className={`relative aspect-video rounded-lg overflow-hidden
|
||
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`}
|
||
onClick={() => !isDragging && setCurrentSketchIndex(index)}
|
||
initial={false}
|
||
animate={{
|
||
scale: currentSketchIndex === index ? 1.05 : 1,
|
||
rotateY: currentSketchIndex === index ? 5 : 0,
|
||
rotateX: currentSketchIndex === index ? -5 : 0,
|
||
translateZ: currentSketchIndex === index ? '20px' : '0px',
|
||
transition: {
|
||
type: "spring",
|
||
stiffness: 300,
|
||
damping: 20
|
||
}
|
||
}}
|
||
style={{
|
||
transformStyle: 'preserve-3d',
|
||
perspective: '1000px'
|
||
}}
|
||
>
|
||
<motion.img
|
||
className="w-full h-full object-cover"
|
||
src={sketch.url}
|
||
alt={`缩略图 ${index + 1}`}
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ duration: 0.3 }}
|
||
/>
|
||
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
|
||
<span className="text-xs text-white/90">场景 {index + 1}</span>
|
||
</div>
|
||
</motion.div>
|
||
)), [taskSketch, currentSketchIndex, isDragging]
|
||
);
|
||
|
||
// 缓存渲染的视频缩略图列表
|
||
const renderedVideos = useMemo(() =>
|
||
taskVideos.map((video, index) => (
|
||
<motion.div
|
||
key={video.id}
|
||
className={`relative aspect-video rounded-lg overflow-hidden
|
||
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`}
|
||
onClick={() => !isDragging && setCurrentSketchIndex(index)}
|
||
initial={false}
|
||
animate={{
|
||
scale: currentSketchIndex === index ? 1.05 : 1,
|
||
rotateY: currentSketchIndex === index ? 5 : 0,
|
||
rotateX: currentSketchIndex === index ? -5 : 0,
|
||
translateZ: currentSketchIndex === index ? '20px' : '0px',
|
||
transition: {
|
||
type: "spring",
|
||
stiffness: 300,
|
||
damping: 20
|
||
}
|
||
}}
|
||
style={{
|
||
transformStyle: 'preserve-3d',
|
||
perspective: '1000px'
|
||
}}
|
||
>
|
||
<video
|
||
className="w-full h-full object-cover"
|
||
src={video.url}
|
||
muted
|
||
playsInline
|
||
loop
|
||
poster={taskSketch[index]?.url}
|
||
/>
|
||
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
|
||
<span className="text-xs text-white/90">场景 {index + 1}</span>
|
||
</div>
|
||
</motion.div>
|
||
)), [taskVideos, currentSketchIndex, isDragging, taskSketch]
|
||
);
|
||
|
||
// 处理播放/暂停
|
||
const togglePlay = useCallback(() => {
|
||
setIsPlaying(prev => !prev);
|
||
}, []);
|
||
|
||
// 自动播放逻辑
|
||
useEffect(() => {
|
||
if (isPlaying && taskSketch.length > 0) {
|
||
playTimerRef.current = setInterval(() => {
|
||
setCurrentSketchIndex(prev => {
|
||
const nextIndex = (prev + 1) % taskSketch.length;
|
||
return nextIndex;
|
||
});
|
||
}, 2000); // 每2秒切换一次
|
||
} else if (playTimerRef.current) {
|
||
clearInterval(playTimerRef.current);
|
||
}
|
||
|
||
return () => {
|
||
if (playTimerRef.current) {
|
||
clearInterval(playTimerRef.current);
|
||
}
|
||
};
|
||
}, [isPlaying, taskSketch.length]);
|
||
|
||
// 当切换到视频模式时,停止播放
|
||
useEffect(() => {
|
||
if (currentStep === '3') {
|
||
setIsPlaying(false);
|
||
}
|
||
}, [currentStep]);
|
||
|
||
// 处理视频播放/暂停
|
||
const toggleVideoPlay = useCallback(() => {
|
||
setIsVideoPlaying(prev => !prev);
|
||
}, []);
|
||
|
||
// 视频自动播放逻辑
|
||
useEffect(() => {
|
||
if (isVideoPlaying && taskVideos.length > 0) {
|
||
// 确保当前视频开始播放
|
||
if (mainVideoRef.current) {
|
||
mainVideoRef.current.play();
|
||
}
|
||
} else {
|
||
// 暂停当前视频
|
||
if (mainVideoRef.current) {
|
||
mainVideoRef.current.pause();
|
||
}
|
||
// 清除定时器
|
||
if (videoPlayTimerRef.current) {
|
||
clearInterval(videoPlayTimerRef.current);
|
||
}
|
||
}
|
||
|
||
return () => {
|
||
if (videoPlayTimerRef.current) {
|
||
clearInterval(videoPlayTimerRef.current);
|
||
}
|
||
};
|
||
}, [isVideoPlaying, taskVideos.length]);
|
||
|
||
// 当切换视频时重置视频播放
|
||
useEffect(() => {
|
||
if (mainVideoRef.current) {
|
||
mainVideoRef.current.currentTime = 0;
|
||
if (isVideoPlaying) {
|
||
mainVideoRef.current.play();
|
||
}
|
||
}
|
||
}, [currentSketchIndex, isVideoPlaying]);
|
||
|
||
// 当切换到分镜草图模式时,停止视频播放
|
||
useEffect(() => {
|
||
if (currentStep !== '3') {
|
||
setIsVideoPlaying(false);
|
||
}
|
||
}, [currentStep]);
|
||
|
||
// 更新加载文字
|
||
useEffect(() => {
|
||
if (isLoading) {
|
||
setCurrentLoadingText('正在加载任务信息...');
|
||
return;
|
||
}
|
||
|
||
if (currentStep === '1') {
|
||
if (isGeneratingSketch) {
|
||
setCurrentLoadingText(`正在生成分镜草图 ${sketchCount + 1}/${MOCK_SKETCH_COUNT}...`);
|
||
} else {
|
||
setCurrentLoadingText('分镜草图生成完成');
|
||
}
|
||
} else if (currentStep === '2') {
|
||
setCurrentLoadingText('正在绘制角色...');
|
||
} else if (currentStep === '3') {
|
||
if (isGeneratingVideo) {
|
||
setCurrentLoadingText(`正在生成分镜视频 ${taskVideos.length + 1}/${taskSketch.length}...`);
|
||
} else {
|
||
setCurrentLoadingText('分镜视频生成完成');
|
||
}
|
||
}
|
||
}, [isLoading, currentStep, isGeneratingSketch, sketchCount, isGeneratingVideo, taskVideos.length, taskSketch.length]);
|
||
|
||
const renderSketchContent = () => {
|
||
if (!taskObject) {
|
||
return (
|
||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-black/40 via-black/20 to-black/40 backdrop-blur-sm rounded-lg overflow-hidden">
|
||
<motion.div
|
||
className="flex flex-col items-center gap-4"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5 }}
|
||
>
|
||
<motion.div
|
||
animate={{
|
||
scale: [1, 1.2, 1],
|
||
rotate: [0, 360],
|
||
}}
|
||
transition={{
|
||
duration: 2,
|
||
repeat: Infinity,
|
||
ease: "linear"
|
||
}}
|
||
>
|
||
<Loader2 className="w-8 h-8 text-blue-500" />
|
||
</motion.div>
|
||
<motion.p
|
||
className="text-sm text-white/70"
|
||
animate={{
|
||
opacity: [0.5, 1, 0.5],
|
||
}}
|
||
transition={{
|
||
duration: 2,
|
||
repeat: Infinity,
|
||
ease: "linear"
|
||
}}
|
||
>
|
||
加载中...
|
||
</motion.p>
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (currentStep === '3') {
|
||
return (
|
||
<div
|
||
className="relative w-full h-full"
|
||
onMouseEnter={() => setShowControls(true)}
|
||
onMouseLeave={() => setShowControls(false)}
|
||
>
|
||
{taskVideos[currentSketchIndex] ? (
|
||
<motion.div className="relative w-full h-full">
|
||
<motion.video
|
||
ref={mainVideoRef}
|
||
key={taskVideos[currentSketchIndex].url}
|
||
className="w-full h-full rounded-lg object-cover object-center"
|
||
src={taskVideos[currentSketchIndex].url}
|
||
autoPlay={isVideoPlaying}
|
||
muted
|
||
loop={false}
|
||
playsInline
|
||
poster={taskSketch[currentSketchIndex]?.url}
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{
|
||
opacity: 1,
|
||
scale: 1,
|
||
transition: {
|
||
type: "spring",
|
||
stiffness: 300,
|
||
damping: 25
|
||
}
|
||
}}
|
||
onEnded={() => {
|
||
if (isVideoPlaying) {
|
||
// 当前视频播放完成后,自动切换到下一个
|
||
setCurrentSketchIndex(prev => (prev + 1) % taskVideos.length);
|
||
}
|
||
}}
|
||
/>
|
||
|
||
{/* 播放进度指示器 */}
|
||
<AnimatePresence>
|
||
{isVideoPlaying && (
|
||
<motion.div
|
||
className="absolute bottom-0 left-0 right-0 h-1 bg-blue-500/20"
|
||
initial={{ scaleX: 0 }}
|
||
animate={{ scaleX: 1 }}
|
||
exit={{ scaleX: 0 }}
|
||
transition={{
|
||
duration: mainVideoRef.current?.duration || 6,
|
||
repeat: Infinity
|
||
}}
|
||
style={{ transformOrigin: "left" }}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
</motion.div>
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-500/20 via-purple-500/10 to-pink-500/20 backdrop-blur-sm rounded-lg overflow-hidden">
|
||
<motion.div
|
||
className="flex flex-col items-center gap-4"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5 }}
|
||
>
|
||
<div className="relative">
|
||
<motion.div
|
||
className="absolute -inset-4 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 rounded-full opacity-20 blur-xl"
|
||
animate={{
|
||
scale: [1, 1.2, 1],
|
||
rotate: [0, 180, 360],
|
||
}}
|
||
transition={{
|
||
duration: 4,
|
||
repeat: Infinity,
|
||
ease: "linear"
|
||
}}
|
||
/>
|
||
<motion.div
|
||
animate={{
|
||
rotate: [0, 360],
|
||
}}
|
||
transition={{
|
||
duration: 2,
|
||
repeat: Infinity,
|
||
ease: "linear"
|
||
}}
|
||
>
|
||
<Loader2 className="w-8 h-8 text-blue-500 relative z-10" />
|
||
</motion.div>
|
||
</div>
|
||
<motion.p
|
||
className="text-sm text-white/70"
|
||
animate={{
|
||
opacity: [0.5, 1, 0.5],
|
||
}}
|
||
transition={{
|
||
duration: 2,
|
||
repeat: Infinity,
|
||
ease: "linear"
|
||
}}
|
||
>
|
||
正在生成分镜视频 {taskVideos.length + 1}/{taskSketch.length}
|
||
</motion.p>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 操作按钮组 */}
|
||
<AnimatePresence>
|
||
{showControls && (
|
||
<>
|
||
{/* 顶部按钮组 */}
|
||
<motion.div
|
||
className="absolute top-4 right-4 flex gap-2"
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -10 }}
|
||
transition={{ duration: 0.2 }}
|
||
>
|
||
<GlassIconButton
|
||
icon={Edit3}
|
||
tooltip="编辑分镜"
|
||
onClick={() => setIsEditModalOpen(true)}
|
||
/>
|
||
{/* <GlassIconButton
|
||
icon={FileText}
|
||
tooltip="显示脚本"
|
||
onClick={() => console.log('显示脚本')}
|
||
/> */}
|
||
</motion.div>
|
||
</>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* 底部播放按钮 */}
|
||
<AnimatePresence>
|
||
<motion.div
|
||
className="absolute bottom-4 left-4"
|
||
initial={{ opacity: 0, scale: 0.8 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.8 }}
|
||
transition={{ duration: 0.2 }}
|
||
>
|
||
<motion.div
|
||
whileHover={{ scale: 1.1 }}
|
||
whileTap={{ scale: 0.9 }}
|
||
>
|
||
<GlassIconButton
|
||
icon={isVideoPlaying ? Pause : Play}
|
||
tooltip={isVideoPlaying ? "暂停播放" : "自动播放"}
|
||
onClick={toggleVideoPlay}
|
||
size="sm"
|
||
/>
|
||
</motion.div>
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="relative w-full h-full"
|
||
onMouseEnter={() => setShowControls(true)}
|
||
onMouseLeave={() => setShowControls(false)}
|
||
>
|
||
{taskSketch[currentSketchIndex] ? (
|
||
<motion.img
|
||
key={currentSketchIndex}
|
||
src={taskSketch[currentSketchIndex].url}
|
||
alt={`分镜草图 ${currentSketchIndex + 1}`}
|
||
className="w-full h-full rounded-lg"
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{
|
||
opacity: 1,
|
||
scale: 1,
|
||
transition: {
|
||
type: "spring",
|
||
stiffness: 300,
|
||
damping: 25
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-500/20 via-purple-500/10 to-pink-500/20 backdrop-blur-sm rounded-lg overflow-hidden">
|
||
<motion.div
|
||
className="flex flex-col items-center gap-4"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5 }}
|
||
>
|
||
<div className="relative">
|
||
<motion.div
|
||
className="absolute -inset-4 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 rounded-full opacity-20 blur-xl"
|
||
animate={{
|
||
scale: [1, 1.2, 1],
|
||
rotate: [0, 180, 360],
|
||
}}
|
||
transition={{
|
||
duration: 4,
|
||
repeat: Infinity,
|
||
ease: "linear"
|
||
}}
|
||
/>
|
||
<motion.div
|
||
animate={{
|
||
rotate: [0, 360],
|
||
}}
|
||
transition={{
|
||
duration: 2,
|
||
repeat: Infinity,
|
||
ease: "linear"
|
||
}}
|
||
>
|
||
<Loader2 className="w-8 h-8 text-blue-500 relative z-10" />
|
||
</motion.div>
|
||
</div>
|
||
<motion.p
|
||
className="text-sm text-white/70"
|
||
animate={{
|
||
opacity: [0.5, 1, 0.5],
|
||
}}
|
||
transition={{
|
||
duration: 2,
|
||
repeat: Infinity,
|
||
ease: "linear"
|
||
}}
|
||
>
|
||
正在生成分镜草图 {sketchCount + 1}/{MOCK_SKETCH_COUNT}
|
||
</motion.p>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 操作按钮组 */}
|
||
<AnimatePresence>
|
||
{showControls && (
|
||
<>
|
||
{/* 顶部按钮组 */}
|
||
<motion.div
|
||
className="absolute top-4 right-4 flex gap-2"
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -10 }}
|
||
transition={{ duration: 0.2 }}
|
||
>
|
||
<GlassIconButton
|
||
icon={Edit3}
|
||
tooltip="编辑分镜"
|
||
onClick={() => setIsEditModalOpen(true)}
|
||
/>
|
||
{/* <GlassIconButton
|
||
icon={FileText}
|
||
tooltip="显示脚本"
|
||
onClick={() => console.log('显示脚本')}
|
||
/> */}
|
||
</motion.div>
|
||
</>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* 底部播放按钮 */}
|
||
<AnimatePresence>
|
||
<motion.div
|
||
className="absolute bottom-4 left-4"
|
||
initial={{ opacity: 0, scale: 0.8 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.8 }}
|
||
transition={{ duration: 0.2 }}
|
||
>
|
||
<motion.div
|
||
whileHover={{ scale: 1.1 }}
|
||
whileTap={{ scale: 0.9 }}
|
||
>
|
||
<GlassIconButton
|
||
icon={isPlaying ? Pause : Play}
|
||
tooltip={isPlaying ? "暂停播放" : "自动播放"}
|
||
onClick={togglePlay}
|
||
size="sm"
|
||
/>
|
||
</motion.div>
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
|
||
|
||
{/* 播放进度指示器 */}
|
||
<AnimatePresence>
|
||
{isPlaying && (
|
||
<motion.div
|
||
className="absolute bottom-0 left-0 right-0 h-1 bg-blue-500/20"
|
||
initial={{ scaleX: 0 }}
|
||
animate={{ scaleX: 1 }}
|
||
exit={{ scaleX: 0 }}
|
||
transition={{ duration: 2, repeat: Infinity }}
|
||
style={{ transformOrigin: "left" }}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="w-full h-full overflow-hidden">
|
||
<div className="flex h-full flex-col p-6 justify-center items-center pt-0">
|
||
<div className="container-H2sRZG">
|
||
<div className="splashContainer-otuV_A">
|
||
<div className="content-vPGYx8">
|
||
<div className="info-UUGkPJ">
|
||
{isLoading ? (
|
||
<>
|
||
<Skeleton className="h-8 w-64 mb-2" />
|
||
<Skeleton className="h-4 w-96" />
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="title-JtMejk">{taskObject?.projectName}:{taskObject?.taskName}</div>
|
||
{/* 实时反馈当前 currentLoadingText */}
|
||
<motion.div
|
||
className="flex items-center gap-2 justify-center"
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.3 }}
|
||
>
|
||
<motion.div
|
||
className="w-1.5 h-1.5 rounded-full bg-blue-500"
|
||
animate={{
|
||
scale: [1, 1.5, 1],
|
||
opacity: [1, 0.5, 1]
|
||
}}
|
||
transition={{
|
||
duration: 1,
|
||
repeat: Infinity,
|
||
repeatDelay: 0.2
|
||
}}
|
||
/>
|
||
<motion.p
|
||
className="normalS400 subtitle-had8uE text-blue-500/80"
|
||
key={currentLoadingText}
|
||
initial={{ opacity: 0, x: -10 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: 10 }}
|
||
transition={{ duration: 0.3 }}
|
||
>
|
||
{currentLoadingText}
|
||
</motion.p>
|
||
<motion.div
|
||
className="w-1.5 h-1.5 rounded-full bg-blue-500"
|
||
animate={{
|
||
scale: [1, 1.5, 1],
|
||
opacity: [1, 0.5, 1]
|
||
}}
|
||
transition={{
|
||
duration: 1,
|
||
repeat: Infinity,
|
||
repeatDelay: 0.2,
|
||
delay: 0.3
|
||
}}
|
||
/>
|
||
<motion.div
|
||
className="w-1.5 h-1.5 rounded-full bg-blue-500"
|
||
animate={{
|
||
scale: [1, 1.5, 1],
|
||
opacity: [1, 0.5, 1]
|
||
}}
|
||
transition={{
|
||
duration: 1,
|
||
repeat: Infinity,
|
||
repeatDelay: 0.2,
|
||
delay: 0.6
|
||
}}
|
||
/>
|
||
</motion.div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="media-Ocdu1O">
|
||
<div className="videoContainer-qteKNi" ref={containerRef}>
|
||
{isLoading ? (
|
||
<Skeleton className="w-full aspect-video rounded-lg" />
|
||
) : (
|
||
<>
|
||
<div className="heroVideo-FIzuK1" style={{ aspectRatio: "16 / 9" }}>
|
||
{renderSketchContent()}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="imageGrid-ymZV9z hidden-scrollbar">
|
||
{isLoading ? (
|
||
<>
|
||
<Skeleton className="w-[128px] h-[128px] rounded-lg" />
|
||
<Skeleton className="w-[128px] h-[128px] rounded-lg" />
|
||
<Skeleton className="w-[128px] h-[128px] rounded-lg" />
|
||
<Skeleton className="w-[128px] h-[128px] rounded-lg" />
|
||
</>
|
||
) : (
|
||
<div
|
||
ref={thumbnailsRef}
|
||
className="w-full grid grid-flow-col auto-cols-[20%] gap-4 overflow-x-auto hidden-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing"
|
||
onMouseDown={handleMouseDown}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={handleMouseUp}
|
||
onMouseLeave={() => setIsDragging(false)}
|
||
>
|
||
{currentStep === '3' ? (
|
||
<>
|
||
{renderedVideos}
|
||
{isGeneratingVideo && taskVideos.length < taskSketch.length && (
|
||
<motion.div
|
||
className="relative aspect-video rounded-lg overflow-hidden bg-gradient-to-br from-blue-500/20 via-purple-500/10 to-pink-500/20 backdrop-blur-sm"
|
||
initial={{ opacity: 0, scale: 0.8 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
transition={{ duration: 0.3 }}
|
||
>
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<div className="relative">
|
||
<motion.div
|
||
className="absolute -inset-4 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 rounded-full opacity-20 blur-xl"
|
||
animate={{
|
||
scale: [1, 1.2, 1],
|
||
rotate: [0, 180, 360],
|
||
}}
|
||
transition={{
|
||
duration: 4,
|
||
repeat: Infinity,
|
||
ease: "linear"
|
||
}}
|
||
/>
|
||
<motion.div
|
||
animate={{
|
||
rotate: [0, 360],
|
||
}}
|
||
transition={{
|
||
duration: 2,
|
||
repeat: Infinity,
|
||
ease: "linear"
|
||
}}
|
||
>
|
||
<Loader2 className="w-6 h-6 text-blue-500 relative z-10" />
|
||
</motion.div>
|
||
</div>
|
||
</div>
|
||
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
|
||
<span className="text-xs text-white/90">场景 {taskVideos.length + 1}</span>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
{renderedSketches}
|
||
{isGeneratingSketch && sketchCount < MOCK_SKETCH_COUNT && (
|
||
<motion.div
|
||
className="relative aspect-video rounded-lg overflow-hidden bg-gradient-to-br from-blue-500/20 via-purple-500/10 to-pink-500/20 backdrop-blur-sm"
|
||
initial={{ opacity: 0, scale: 0.8 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
transition={{ duration: 0.3 }}
|
||
>
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<div className="relative">
|
||
<motion.div
|
||
className="absolute -inset-4 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 rounded-full opacity-20 blur-xl"
|
||
animate={{
|
||
scale: [1, 1.2, 1],
|
||
rotate: [0, 180, 360],
|
||
}}
|
||
transition={{
|
||
duration: 4,
|
||
repeat: Infinity,
|
||
ease: "linear"
|
||
}}
|
||
/>
|
||
<motion.div
|
||
animate={{
|
||
rotate: [0, 360],
|
||
}}
|
||
transition={{
|
||
duration: 2,
|
||
repeat: Infinity,
|
||
ease: "linear"
|
||
}}
|
||
>
|
||
<Loader2 className="w-6 h-6 text-blue-500 relative z-10" />
|
||
</motion.div>
|
||
</div>
|
||
</div>
|
||
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
|
||
<span className="text-xs text-white/90">场景 {sketchCount + 1}</span>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI 建议栏 */}
|
||
<AISuggestionBar
|
||
suggestions={mockSuggestions}
|
||
onSuggestionClick={handleSuggestionClick}
|
||
onSubmit={handleSubmit}
|
||
placeholder="请输入你的想法,或点击预设词条获取 AI 建议..."
|
||
/>
|
||
|
||
<EditModal
|
||
isOpen={isEditModalOpen}
|
||
onClose={() => setIsEditModalOpen(false)}
|
||
taskStatus={taskObject?.taskStatus || '1'}
|
||
taskSketch={taskSketch}
|
||
currentSketchIndex={currentSketchIndex}
|
||
onSketchSelect={setCurrentSketchIndex}
|
||
/>
|
||
</div>
|
||
)
|
||
} |