forked from 77media/video-flow
939 lines
28 KiB
TypeScript
939 lines
28 KiB
TypeScript
"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[];
|
||
|
||
const finalVideoContainer = document.getElementById('final-video-container') as HTMLDivElement;
|
||
if (videos.length > 0 && finalVideoContainer) {
|
||
// 创建最终的大视频容器
|
||
|
||
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);
|
||
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 = ({className}: {className: string}) => {
|
||
const [showText, setShowText] = useState(false);
|
||
const [showImages, setShowImages] = useState(false);
|
||
const [animationCycle, setAnimationCycle] = useState(0);
|
||
const [isClient, setIsClient] = useState(false);
|
||
|
||
// 全局清理函数
|
||
const globalCleanup = () => {
|
||
// 清理最终视频容器中的视频元素
|
||
const finalVideoContainer = document.getElementById('final-video-container') as HTMLDivElement;
|
||
if (finalVideoContainer) {
|
||
const finalVideo = finalVideoContainer.querySelector('video');
|
||
if (finalVideo) {
|
||
finalVideo.remove();
|
||
}
|
||
// 重置容器透明度
|
||
finalVideoContainer.style.opacity = '0';
|
||
}
|
||
|
||
// 清理所有图片容器中的视频元素
|
||
const allVideoElements = document.querySelectorAll('video');
|
||
allVideoElements.forEach(video => {
|
||
// 确保视频不是在final-video-container中(已经处理过了)
|
||
if (!finalVideoContainer?.contains(video)) {
|
||
// 停止播放并移除
|
||
video.pause();
|
||
video.removeAttribute('src');
|
||
video.load(); // 释放资源
|
||
video.remove();
|
||
}
|
||
});
|
||
|
||
// 清理任何可能遗留的视频容器
|
||
const imageContainers = document.querySelectorAll('[class*="w-[200px]"][class*="h-[150px]"]');
|
||
imageContainers.forEach(container => {
|
||
const videos = container.querySelectorAll('video');
|
||
videos.forEach(video => {
|
||
video.pause();
|
||
video.removeAttribute('src');
|
||
video.load();
|
||
video.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={`${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 id="final-video-container" 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"></div>
|
||
</div>
|
||
);
|
||
};
|