forked from 77media/video-flow
空页面动画展示
This commit is contained in:
parent
b6d608fd54
commit
a5a6da472d
920
components/common/EmptyStateAnimation.tsx
Normal file
920
components/common/EmptyStateAnimation.tsx
Normal file
@ -0,0 +1,920 @@
|
||||
"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.5,
|
||||
ease: "power2.out"
|
||||
})
|
||||
.fromTo(inputContainerRef.current, {
|
||||
scale: 0.9,
|
||||
opacity: 0
|
||||
}, {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: 0.3,
|
||||
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.5,
|
||||
ease: "power2.out"
|
||||
},
|
||||
"+=0.5"
|
||||
);
|
||||
|
||||
// 3. 鼠标移动到输入框中心
|
||||
mainTl.to(mouseRef.current, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
duration: 0.3,
|
||||
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.2,
|
||||
ease: "back.out(1.7)"
|
||||
}, "+=0.2");
|
||||
|
||||
// 5. 显示按钮
|
||||
mainTl.fromTo(buttonRef.current,
|
||||
{ scale: 0, opacity: 0 },
|
||||
{ scale: 1, opacity: 1, duration: 0.2, ease: "back.out(1.7)" },
|
||||
"+=0.3"
|
||||
);
|
||||
|
||||
// 隐藏鼠标指针
|
||||
mainTl.to(mouseRef.current, {
|
||||
opacity: 0,
|
||||
duration: 0.2
|
||||
}, "+=0.2");
|
||||
|
||||
// 6. 打字动画
|
||||
const typingDuration = demoText.length * 0.03;
|
||||
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.2
|
||||
}, "+=0.2");
|
||||
|
||||
// 重新显示鼠标指针并移动到按钮
|
||||
mainTl.fromTo(mouseRef.current,
|
||||
{ opacity: 0, x: 0, y: 0 },
|
||||
{
|
||||
opacity: 1,
|
||||
duration: 0.2,
|
||||
ease: "power2.out"
|
||||
},
|
||||
"-=0.3"
|
||||
);
|
||||
|
||||
// 鼠标移动到按钮
|
||||
mainTl.to(mouseRef.current, {
|
||||
x: 20,
|
||||
y: 10,
|
||||
duration: 0.3,
|
||||
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.2,
|
||||
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.5,
|
||||
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.9)',
|
||||
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: -100,
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
@ -20,6 +20,7 @@ import { ProjectTypeEnum, ModeEnum, ResolutionEnum } from "@/api/enums";
|
||||
import { createScriptEpisode, CreateScriptEpisodeRequest, updateScriptEpisode, UpdateScriptEpisodeRequest } from "@/api/script_episode";
|
||||
import { getUploadTokenWithDomain, uploadToQiniu } from "@/api/common";
|
||||
import { convertScriptToScene, convertVideoToScene } from "@/api/video_flow";
|
||||
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation';
|
||||
|
||||
const JoyrideNoSSR = dynamic(() => import('react-joyride'), {
|
||||
ssr: false,
|
||||
@ -27,6 +28,11 @@ const JoyrideNoSSR = dynamic(() => import('react-joyride'), {
|
||||
|
||||
// 导入Step类型
|
||||
import type { Step } from 'react-joyride';
|
||||
// interface Step {
|
||||
// target: string;
|
||||
// content: string;
|
||||
// placement?: 'top' | 'bottom' | 'left' | 'right';
|
||||
// }
|
||||
|
||||
// 添加自定义滚动条样式
|
||||
const scrollbarStyles = `
|
||||
@ -72,6 +78,8 @@ export function CreateToVideo2() {
|
||||
const [runTour, setRunTour] = useState(true);
|
||||
const [episodeId, setEpisodeId] = useState<number>(0);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [generatedVideoList, setGeneratedVideoList] = useState<any[]>([]);
|
||||
const [projectName, setProjectName] = useState(localStorage.getItem('projectName') || '默认名称');
|
||||
|
||||
const handleUploadVideo = async () => {
|
||||
console.log('upload video');
|
||||
@ -373,28 +381,7 @@ export function CreateToVideo2() {
|
||||
overlayColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textColor: '#F3F4F6',
|
||||
arrowColor: '#1F2937',
|
||||
},
|
||||
tooltip: {
|
||||
borderRadius: '1rem',
|
||||
},
|
||||
tooltipContainer: {
|
||||
textAlign: 'left',
|
||||
},
|
||||
buttonNext: {
|
||||
fontSize: '14px',
|
||||
},
|
||||
buttonBack: {
|
||||
fontSize: '14px',
|
||||
},
|
||||
buttonSkip: {
|
||||
fontSize: '14px',
|
||||
},
|
||||
buttonClose: {
|
||||
fontSize: '14px',
|
||||
},
|
||||
spotlight: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}
|
||||
}}
|
||||
disableOverlayClose
|
||||
spotlightClicks
|
||||
@ -409,24 +396,9 @@ export function CreateToVideo2() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className='scroll-load-box h-full overflow-y-scroll w-[calc(100%-16px)] mx-auto'>
|
||||
<div className='min-h-[100%]'>
|
||||
<div className='flex flex-col items-center fixed top-1/2 left-1/2 -translate-x-1/2 translate-y-[calc(-50%-68px)]'>
|
||||
<Image
|
||||
src='/assets/empty_video.png'
|
||||
width={160}
|
||||
height={160}
|
||||
alt='empty_video'
|
||||
priority
|
||||
/>
|
||||
<div className='text-[16px] font-[400] leading-[24px]'>
|
||||
<span className='opacity-60'>Generated videos will appear here. </span>
|
||||
<span className='font-[700] border-0 border-solid border-white hover:border-b-[1px] cursor-pointer' onClick={() => handleStartCreating()}>Start creating!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='min-h-[100%] flex flex-col justify-center items-center'>
|
||||
{/* 空状态 */}
|
||||
<EmptyStateAnimation />
|
||||
{/* 工具栏 */}
|
||||
<div className='video-tool-component relative w-[1080px]'>
|
||||
<div className='video-storyboard-tools grid gap-4 rounded-[20px] bg-[#0C0E11] backdrop-blur-[15px]'>
|
||||
@ -570,5 +542,6 @@ export function CreateToVideo2() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -137,3 +137,13 @@
|
||||
.mode-dropdown.ant-dropdown .ant-dropdown-menu-item-selected {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.animated-text {
|
||||
color: #dfdcff;
|
||||
font-size: clamp(2rem, 12rem, 1vw);
|
||||
line-height: 1.2;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
perspective: 500px;
|
||||
}
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@ -41,6 +41,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@types/gsap": "^1.20.2",
|
||||
"@types/node": "20.6.2",
|
||||
"@types/react": "18.2.22",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
@ -57,6 +58,7 @@
|
||||
"eslint": "8.49.0",
|
||||
"eslint-config-next": "13.5.1",
|
||||
"framer-motion": "^12.19.1",
|
||||
"gsap": "^3.13.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.446.0",
|
||||
@ -6524,6 +6526,12 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/gsap": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@types/gsap/-/gsap-1.20.2.tgz",
|
||||
"integrity": "sha512-i9nUsnS32+VTgoX5IlaCYukJpCoB3c6h3bZvO67aIRdb3z8NFTWgeUDpQutLyb1ujowp6nw37qLQNvEowqq8yw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hoist-non-react-statics": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmmirror.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz",
|
||||
@ -9061,6 +9069,12 @@
|
||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmmirror.com/gsap/-/gsap-3.13.0.tgz",
|
||||
"integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz",
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@types/gsap": "^1.20.2",
|
||||
"@types/node": "20.6.2",
|
||||
"@types/react": "18.2.22",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
@ -58,6 +59,7 @@
|
||||
"eslint": "8.49.0",
|
||||
"eslint-config-next": "13.5.1",
|
||||
"framer-motion": "^12.19.1",
|
||||
"gsap": "^3.13.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.446.0",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user