video-flow-b/components/common/EmptyStateAnimation.tsx
2025-07-03 06:12:07 +08:00

920 lines
27 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.

"use client";
import { useState, useEffect, useRef } from 'react';
import { ArrowUp } from 'lucide-react';
import Image from 'next/image';
import gsap from 'gsap';
import { SplitText } from 'gsap/SplitText';
// 注册 SplitText 插件
if (typeof window !== 'undefined') {
gsap.registerPlugin(SplitText);
}
const ideaText = 'a cute capybara with an orange on its head, staring into the distance and walking forward';
const AnimatedText = ({ text, onComplete, shouldStart }: { text: string; onComplete: () => void; shouldStart: boolean }) => {
const containerRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLDivElement>(null);
const inputContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
const cursorRef = useRef<HTMLDivElement>(null);
const mouseRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<any>(null);
const [displayText, setDisplayText] = useState('');
const demoText = "a cute capybara with an orange on its head";
useEffect(() => {
if (containerRef.current && typeof window !== 'undefined' && shouldStart) {
// 清理之前的动画
if (animationRef.current) {
animationRef.current.kill();
}
// 重置状态
setDisplayText('');
if (containerRef.current) {
containerRef.current.style.opacity = '1';
}
// 设置初始状态
gsap.set([titleRef.current, inputContainerRef.current], {
opacity: 1
});
// 创建主时间轴
const mainTl = gsap.timeline();
// 1. 显示标题和输入框
mainTl.fromTo(titleRef.current, {
y: -30,
opacity: 0
}, {
y: 0,
opacity: 1,
duration: 0.3,
ease: "power2.out"
})
.fromTo(inputContainerRef.current, {
scale: 0.9,
opacity: 0
}, {
scale: 1,
opacity: 1,
duration: 0.2,
ease: "back.out(1.7)"
}, "-=0.3");
// 2. 显示鼠标指针并移动到输入框
mainTl.fromTo(mouseRef.current,
{ opacity: 0, x: -100, y: -50 },
{
opacity: 1,
x: -150,
y: 0,
duration: 0.3,
ease: "power2.out"
},
"+=0.5"
);
// 3. 鼠标移动到输入框中心
mainTl.to(mouseRef.current, {
x: 0,
y: 0,
duration: 0.2,
ease: "power2.inOut"
});
// 4. 输入框聚焦效果
mainTl.to(inputRef.current, {
scale: 1.05,
rotationY: 1,
rotationX: 15,
transformOrigin: "center center",
boxShadow: `
inset 0 3px 0 rgba(255,255,255,0.35),
inset 0 -3px 0 rgba(0,0,0,0.2),
inset 3px 0 0 rgba(255,255,255,0.15),
inset -3px 0 0 rgba(0,0,0,0.08),
0 0 0 3px rgba(59, 130, 246, 0.4),
0 4px 8px rgba(0,0,0,0.15),
0 12px 24px rgba(0,0,0,0.2),
0 20px 40px rgba(0,0,0,0.15),
0 0 0 1px rgba(59, 130, 246, 0.6)
`,
borderColor: 'rgba(79, 70, 229, 0.8)',
duration: 0.1,
ease: "back.out(1.7)"
}, "+=0.2");
// 5. 显示按钮
mainTl.fromTo(buttonRef.current,
{ scale: 0, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.1, ease: "back.out(1.7)" },
"+=0.3"
);
// 隐藏鼠标指针
mainTl.to(mouseRef.current, {
opacity: 0,
duration: 0.1
}, "+=0.2");
// 6. 打字动画
const typingDuration = demoText.length * 0.01;
let currentChar = 0;
mainTl.to({}, {
duration: typingDuration,
ease: "none",
onUpdate: function() {
const progress = this.progress();
const targetChar = Math.floor(progress * demoText.length);
if (targetChar !== currentChar && targetChar <= demoText.length) {
currentChar = targetChar;
setDisplayText(demoText.slice(0, currentChar));
}
}
}, "+=0.3");
// 7. 隐藏光标
mainTl.to(cursorRef.current, {
opacity: 0,
duration: 0.1
}, "+=0.2");
// 重新显示鼠标指针并移动到按钮
mainTl.fromTo(mouseRef.current,
{ opacity: 0, x: 0, y: 0 },
{
opacity: 1,
duration: 0.1,
ease: "power2.out"
},
"-=0.3"
);
// 鼠标移动到按钮
mainTl.to(mouseRef.current, {
x: 20,
y: 10,
duration: 0.1,
ease: "power2.inOut"
}, "+=0.5");
// 8. 输入框失焦效果
mainTl.to(inputRef.current, {
scale: 1,
rotationY: 0,
rotationX: 0,
transformOrigin: "center center",
boxShadow: `
inset 0 2px 0 rgba(255,255,255,0.25),
inset 0 -2px 0 rgba(0,0,0,0.15),
inset 2px 0 0 rgba(255,255,255,0.1),
inset -2px 0 0 rgba(0,0,0,0.05),
0 2px 4px rgba(0,0,0,0.1),
0 8px 16px rgba(0,0,0,0.15),
0 16px 32px rgba(0,0,0,0.1),
0 0 0 1px rgba(255,255,255,0.1)
`,
duration: 0.1,
ease: "power2.out"
}, "+=0.2");
// 点击效果
mainTl.to(mouseRef.current, {
scale: 0.8,
duration: 0.1,
ease: "power2.out",
yoyo: true,
repeat: 1
})
.to(buttonRef.current, {
scale: 0.95,
duration: 0.1,
ease: "power2.out",
yoyo: true,
repeat: 1
}, "-=0.2")
.to(buttonRef.current, {
boxShadow: "0 0 20px rgba(79, 70, 229, 0.6)",
duration: 0.1,
ease: "power2.out"
}, "-=0.1");
// 停留展示时间
mainTl.to({}, { duration: 0.1 });
// 退场动画
mainTl.to(containerRef.current, {
opacity: 0,
y: -50,
duration: 0.3,
ease: "power2.in",
onComplete: () => {
onComplete();
}
});
animationRef.current = mainTl;
}
return () => {
if (animationRef.current) {
animationRef.current.kill();
}
};
}, [shouldStart, onComplete]);
return (
<div ref={containerRef} className="animated-text flex flex-col items-center gap-6 text-center">
{/* 标题 */}
<div ref={titleRef} className="title-text">
<h2 className="text-xl font-medium text-white/70 mb-2">
You can input script to generate video
</h2>
</div>
{/* 输入框模拟 */}
<div ref={inputContainerRef} className="input-container relative" style={{ perspective: '1000px' }}>
<div
ref={inputRef}
className="relative w-[600px] h-[60px] bg-gradient-to-br from-white/12 to-white/4 backdrop-blur-md rounded-lg px-4 py-3 flex items-center transition-all duration-300"
style={{
background: 'rgba(233,231,231,0.7)',
boxShadow: `
inset 0 2px 0 rgba(233,231,231,0.25),
inset 0 -2px 0 rgba(0,0,0,0.15),
inset 2px 0 0 rgba(233,231,231,0.1),
inset -2px 0 0 rgba(0,0,0,0.05),
0 2px 4px rgba(0,0,0,0.1),
0 8px 16px rgba(0,0,0,0.15),
0 16px 32px rgba(0,0,0,0.1),
0 0 0 1px rgba(233,231,231,0.1)
`
}}
>
<span className="text-[#000] text-base font-mono">
{displayText}
</span>
<div
ref={cursorRef}
className="w-0.5 h-5 bg-blue-400 ml-1 animate-pulse"
/>
</div>
{/* Create按钮 */}
<div
ref={buttonRef}
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2 rounded-md text-sm font-medium cursor-pointer transition-all duration-200 flex items-center gap-2"
>
<ArrowUp className="w-4 h-4" />
Create
</div>
{/* 鼠标指针 */}
<div
ref={mouseRef}
className="absolute right-8 top-1/2 -translate-y-1/2 opacity-0 pointer-events-none z-10"
>
<svg
width="30"
height="30"
viewBox="0 0 24 24"
fill="none"
className="text-white drop-shadow-lg"
>
<path
d="M8 2L8 22L12 18L16 22L22 16L12 6L8 2Z"
fill="currentColor"
stroke="rgba(0,0,0,0.2)"
strokeWidth="1"
/>
</svg>
</div>
</div>
</div>
);
};
// 阶段文字解释组件
const StageExplanation = ({
text,
stage,
shouldStart,
onComplete
}: {
text: string;
stage: 'images' | 'replacing' | 'merging';
shouldStart: boolean;
onComplete: () => void;
}) => {
const textRef = useRef<HTMLDivElement>(null);
const splitRef = useRef<any>(null);
const animationRef = useRef<any>(null);
useEffect(() => {
if (textRef.current && typeof window !== 'undefined' && shouldStart) {
// 重置显示
textRef.current.style.opacity = '1';
// 清理之前的动画
if (splitRef.current) {
splitRef.current.revert();
}
if (animationRef.current) {
animationRef.current.kill();
}
// 创建分割文本
splitRef.current = new SplitText(textRef.current, { type: "words" });
let tl = gsap.timeline({
onComplete: () => {
// 延迟后开始退场动画
setTimeout(() => {
const exitTl = gsap.timeline({
onComplete: () => {
onComplete();
}
});
// 不同阶段的退场动画
switch (stage) {
case 'images':
exitTl.to(splitRef.current.words, {
y: -50,
opacity: 0,
duration: 0.2,
ease: "power2.in",
stagger: 0.05
});
break;
case 'replacing':
exitTl.to(splitRef.current.words, {
scale: 0,
rotation: 360,
opacity: 0,
duration: 0.4,
ease: "back.in(2)",
stagger: 0.08
});
break;
case 'merging':
exitTl.to(splitRef.current.words, {
x: "random(-200, 200)",
y: "random(-100, -200)",
opacity: 0,
duration: 0.7,
ease: "power3.in",
stagger: 0.06
});
break;
}
}, 1000); // 显示1秒
}
});
// 不同阶段的入场动画
switch (stage) {
case 'images':
// 从下方弹跳进入
tl.from(splitRef.current.words, {
y: 100,
opacity: 0,
duration: 0.5,
ease: "bounce.out",
stagger: 0.1
});
break;
case 'replacing':
// 旋转缩放进入
tl.from(splitRef.current.words, {
scale: 0,
rotation: -180,
opacity: 0,
duration: 0.4,
ease: "back.out(2)",
stagger: 0.08
});
break;
case 'merging':
// 从四周飞入
tl.from(splitRef.current.words, {
x: "random(-300, 300)",
y: "random(-200, 200)",
opacity: 0,
duration: 0.7,
ease: "power3.out",
stagger: 0.06
});
break;
}
animationRef.current = tl;
}
return () => {
if (animationRef.current) {
animationRef.current.kill();
}
if (splitRef.current) {
splitRef.current.revert();
}
};
}, [shouldStart, stage, onComplete]);
return (
<div
ref={textRef}
className="fixed top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center z-10"
>
<p className="text-lg font-medium text-white/80 max-w-[600px]">{text}</p>
</div>
);
};
const ImageQueue = ({ shouldStart, onComplete }: { shouldStart: boolean; onComplete: () => void }) => {
const imagesRef = useRef<HTMLDivElement>(null);
const [currentStage, setCurrentStage] = useState<'images' | 'replacing' | 'merging'>('images');
const [replacementIndex, setReplacementIndex] = useState(0);
const [showStageText, setShowStageText] = useState(false);
const finalVideoContainerRef = useRef<HTMLDivElement | null>(null);
const imageUrls = [
'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 videoUrls = [
'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 finalVideoUrl = 'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4';
// 阶段文字映射
const stageTexts = {
images: 'Then, these are the key frames split from the storyboard',
replacing: 'Then, these are the videos corresponding to the split storyboard',
merging: 'Finally, efficiently edit the perfect video'
};
// 重置函数
const resetComponent = () => {
// 清理最终视频容器
if (finalVideoContainerRef.current) {
finalVideoContainerRef.current.remove();
finalVideoContainerRef.current = null;
}
// 额外清理:查找并移除所有可能的最终视频容器
const allFinalVideos = document.querySelectorAll('div[class*="fixed"][class*="top-1/2"][class*="w-[400px]"]');
allFinalVideos.forEach(container => {
if (container.parentNode === document.body) {
container.remove();
}
});
// 重置所有状态
setCurrentStage('images');
setReplacementIndex(0);
setShowStageText(false);
// 重置DOM结构
if (imagesRef.current) {
imagesRef.current.style.display = 'flex';
Array.from(imagesRef.current.children).forEach((container, index) => {
const htmlContainer = container as HTMLElement;
// 移除视频元素
const videos = htmlContainer.querySelectorAll('video');
videos.forEach(video => video.remove());
// 使用GSAP重置容器样式
gsap.set(htmlContainer, {
position: 'static',
x: 0,
y: index % 2 ? 20 : -20,
opacity: 1,
scale: 1,
rotation: 0
});
// 确保图片显示
const img = htmlContainer.querySelector('img');
if (img) {
img.style.opacity = '1';
}
});
}
};
// 当shouldStart改变时重置并开始新的动画
useEffect(() => {
if (shouldStart) {
resetComponent();
// 延迟显示阶段文字
setTimeout(() => {
setShowStageText(true);
}, 500);
}
}, [shouldStart]);
// 阶段文字完成回调
const handleStageTextComplete = () => {
setShowStageText(false);
// 根据当前阶段决定下一步
if (currentStage === 'images') {
// 开始图片入场动画
startImagesAnimation();
} else if (currentStage === 'replacing') {
// 继续图片替换视频
// replacementIndex会自动处理
} else if (currentStage === 'merging') {
// 开始合并动画
startMergingAnimation();
}
};
// 图片入场动画
const startImagesAnimation = () => {
if (imagesRef.current) {
const images = imagesRef.current.children;
gsap.set(images, {
x: window.innerWidth,
opacity: 0,
rotation: 45
});
const tl = gsap.timeline({
onComplete: () => {
// 图片动画完成后,切换到替换阶段
setTimeout(() => {
setCurrentStage('replacing');
setShowStageText(true);
}, 1000);
}
});
tl.to(images, {
x: 0,
opacity: 1,
rotation: 0,
duration: 1,
ease: "elastic.out(1, 0.5)",
stagger: {
amount: 1,
from: "random"
}
});
}
};
// 开始合并动画
const startMergingAnimation = () => {
if (imagesRef.current) {
const containers = Array.from(imagesRef.current.children) as HTMLElement[];
const videos = containers.map(container => container.querySelector('video')).filter(Boolean) as HTMLVideoElement[];
if (videos.length > 0) {
// 创建最终的大视频容器
const finalVideoContainer = document.createElement('div');
finalVideoContainer.className = 'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] rounded-lg overflow-hidden shadow-2xl opacity-0';
const finalVideo = document.createElement('video');
finalVideo.src = finalVideoUrl;
finalVideo.autoplay = true;
finalVideo.loop = true;
finalVideo.muted = true;
finalVideo.playsInline = true;
finalVideo.className = 'w-full h-full object-cover';
finalVideoContainer.appendChild(finalVideo);
document.body.appendChild(finalVideoContainer);
finalVideoContainerRef.current = finalVideoContainer;
// 等待最终视频准备就绪
const onFinalVideoReady = () => {
finalVideo.removeEventListener('canplay', onFinalVideoReady);
finalVideo.play().then(() => {
const tl = gsap.timeline({
onComplete: () => {
// 移除原有的小视频容器
if (imagesRef.current) {
imagesRef.current.style.display = 'none';
}
// 延迟3秒后调用完成回调开始下一轮循环
setTimeout(() => {
onComplete();
}, 3000);
}
});
// 执行合并动画
tl.to(containers, {
rotation: "random(-720, 720)",
x: "random(-200, 200)",
y: "random(-200, 200)",
scale: 0.8,
duration: 0.7,
ease: "power2.out",
stagger: 0.1
})
.to(containers, {
x: 0,
y: 0,
rotation: 0,
scale: 1,
duration: 0.5,
ease: "power2.inOut",
stagger: 0.05
})
.to(containers, {
scale: 1.2,
duration: 0.1,
ease: "power2.inOut"
})
.to(finalVideoContainer, {
opacity: 1,
scale: 1,
duration: 0.2,
ease: "power2.out"
}, "-=0.2")
.to(containers, {
opacity: 0,
scale: 0.8,
duration: 0.2,
ease: "power2.in"
}, "-=0.5")
.to(finalVideoContainer, {
scale: 1,
duration: 0.1,
ease: "back.out(1.7)"
});
}).catch(() => {
console.error('最终视频播放失败');
finalVideoContainer.remove();
onComplete();
});
};
finalVideo.addEventListener('canplay', onFinalVideoReady);
finalVideo.addEventListener('error', () => {
console.error('最终视频加载失败');
finalVideoContainer.remove();
onComplete();
});
finalVideo.load();
}
}
};
// 图片到视频的替换动画
useEffect(() => {
if (currentStage === 'replacing' && imagesRef.current && replacementIndex < imageUrls.length && shouldStart && !showStageText) {
const targetContainer = imagesRef.current.children[replacementIndex] as HTMLElement;
if (targetContainer) {
// 创建视频元素
const video = document.createElement('video');
video.src = videoUrls[replacementIndex];
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.className = 'absolute inset-0 w-full h-full object-cover opacity-0';
// 添加视频到容器中
targetContainer.style.position = 'relative';
targetContainer.appendChild(video);
// 等待视频准备好播放
const onCanPlay = () => {
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('error', onError);
// 开始播放
video.play().then(() => {
// 创建无缝替换动画
const tl = gsap.timeline({
onComplete: () => {
// 移除图片元素
const img = targetContainer.querySelector('img');
if (img) {
img.remove();
}
// 移除绝对定位,让视频正常占位
video.className = 'w-full h-full object-cover';
// 检查是否是最后一个视频
if (replacementIndex === imageUrls.length - 1) {
// 最后一个视频替换完成,切换到合并阶段
setTimeout(() => {
setCurrentStage('merging');
setShowStageText(true);
}, 1000);
} else {
// 延迟后替换下一个
setTimeout(() => {
setReplacementIndex(prev => prev + 1);
}, 600);
}
}
});
// 同时淡入视频和淡出图片
tl.to(video, {
opacity: 1,
duration: 0.1,
ease: "power2.inOut"
})
.to(targetContainer.querySelector('img'), {
opacity: 0,
duration: 0.1,
ease: "power2.inOut"
}, 0)
.to(targetContainer, {
scale: 1.02,
duration: 0.1,
ease: "power2.out",
yoyo: true,
repeat: 1
}, 0.1);
}).catch(onError);
};
const onError = () => {
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('error', onError);
console.error('视频加载或播放失败:', videoUrls[replacementIndex]);
// 移除失败的视频元素
video.remove();
// 跳过到下一个
if (replacementIndex === imageUrls.length - 1) {
setTimeout(() => {
setCurrentStage('merging');
setShowStageText(true);
}, 500);
} else {
setTimeout(() => {
setReplacementIndex(prev => prev + 1);
}, 500);
}
};
video.addEventListener('canplay', onCanPlay);
video.addEventListener('error', onError);
// 加载视频
video.load();
}
}
}, [currentStage, replacementIndex, shouldStart, showStageText]);
return (
<>
{showStageText && (
<StageExplanation
text={stageTexts[currentStage]}
stage={currentStage}
shouldStart={showStageText}
onComplete={handleStageTextComplete}
/>
)}
<div ref={imagesRef} className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center justify-center gap-4">
{imageUrls.map((url, index) => (
<div
key={index}
className="w-[200px] h-[150px] rounded-lg overflow-hidden shadow-lg"
style={{
transform: `translateY(${index % 2 ? '20px' : '-20px'})`
}}
>
<Image
src={url}
alt={`reference-${index + 1}`}
width={200}
height={150}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</>
);
};
// 主要的空状态动画组件
export const EmptyStateAnimation = () => {
const [showText, setShowText] = useState(false);
const [showImages, setShowImages] = useState(false);
const [animationCycle, setAnimationCycle] = useState(0);
const [isClient, setIsClient] = useState(false);
// 全局清理函数
const globalCleanup = () => {
// 清理所有可能的最终视频容器
const existingFinalVideos = document.querySelectorAll('div[class*="fixed"][class*="top-1/2"][class*="w-[400px]"]');
existingFinalVideos.forEach(container => {
if (container.parentNode === document.body) {
container.remove();
}
});
// 清理所有body下的视频相关元素
const bodyChildren = Array.from(document.body.children);
bodyChildren.forEach(child => {
if (child instanceof HTMLElement &&
(child.querySelector('video') || child.tagName === 'VIDEO')) {
child.remove();
}
});
};
// 循环控制
const startNextCycle = () => {
// 先进行全局清理
globalCleanup();
setAnimationCycle(prev => prev + 1);
setShowImages(false);
// 延迟一下再显示文字,确保清理完成
setTimeout(() => {
setShowText(true);
}, 100);
};
const handleTextComplete = () => {
setShowText(false);
setTimeout(() => {
setShowImages(true);
}, 100);
};
const handleImagesComplete = () => {
setShowImages(false);
// 延迟后开始新的循环
setTimeout(() => {
startNextCycle();
}, 1000);
};
// 开始第一轮动画
useEffect(() => {
if (isClient && animationCycle === 0) {
startNextCycle();
}
}, [isClient]);
// 组件卸载时清理
useEffect(() => {
return () => {
globalCleanup();
};
}, []);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return null;
}
return (
<div className='flex flex-col justify-center items-center'>
{showText && (
<AnimatedText
key={`text-${animationCycle}`}
text="Choose to input a script below or select a video to replicate. Before clicking the create button, you can choose the generation mode - automatic or manual intervention, as well as the resolution of the generated video. Once confirmed, you can click the create button."
onComplete={handleTextComplete}
shouldStart={showText}
/>
)}
{showImages && (
<ImageQueue
key={`images-${animationCycle}`}
shouldStart={showImages}
onComplete={handleImagesComplete}
/>
)}
</div>
);
};