forked from 77media/video-flow
783 lines
43 KiB
TypeScript
783 lines
43 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef } from 'react';
|
||
import { Card } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { ArrowLeft, ChevronDown, ChevronUp, Video, ListOrdered, Play, Loader2 } from 'lucide-react';
|
||
import { useRouter } from 'next/navigation';
|
||
import './style/video-to-video.css';
|
||
|
||
// 添加自定义滚动条样式
|
||
const scrollbarStyles = `
|
||
.custom-scrollbar::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
.custom-scrollbar::-webkit-scrollbar-track {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-radius: 2px;
|
||
}
|
||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 2px;
|
||
}
|
||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
}
|
||
`;
|
||
|
||
interface SceneVideo {
|
||
id: number;
|
||
video_url: string;
|
||
script: any;
|
||
}
|
||
|
||
export function VideoToVideo() {
|
||
const router = useRouter();
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const [videoUrl, setVideoUrl] = useState('');
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [loadingText, setLoadingText] = useState('Generating...');
|
||
const [generateObj, setGenerateObj] = useState<any>({
|
||
scripts: null,
|
||
frame_urls: null,
|
||
video_info: null,
|
||
scene_videos: null,
|
||
cut_video_url: null,
|
||
audio_video_url: null,
|
||
final_video_url: null
|
||
});
|
||
const [showAllFrames, setShowAllFrames] = useState(false);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const [showScrollNav, setShowScrollNav] = useState(false);
|
||
const [selectedVideoIndex, setSelectedVideoIndex] = useState<number | null>(null);
|
||
const videosContainerRef = useRef<HTMLDivElement>(null);
|
||
const scriptsContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 监听内容变化,自动滚动到底部
|
||
useEffect(() => {
|
||
if (containerRef.current && (generateObj.scripts || generateObj.frame_urls || generateObj.video_info)) {
|
||
setTimeout(() => {
|
||
containerRef.current?.scrollTo({
|
||
top: containerRef.current.scrollHeight,
|
||
behavior: 'smooth'
|
||
});
|
||
}, 100); // 给一个小延迟,确保内容已经渲染
|
||
}
|
||
}, [generateObj.scripts, generateObj.frame_urls, generateObj.video_info, generateObj.scene_videos, generateObj.cut_video_url, generateObj.audio_video_url, generateObj.final_video_url]);
|
||
|
||
// 计算每行可以显示的图片数量(基于图片高度100px和容器宽度)
|
||
const imagesPerRow = Math.floor(1080 / (100 * 16/9 + 8)); // 假设图片宽高比16:9,间距8px
|
||
// 计算三行可以显示的最大图片数量
|
||
const maxVisibleImages = imagesPerRow * 3;
|
||
|
||
const handleUploadVideo = () => {
|
||
console.log('upload video');
|
||
// 打开文件选择器
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'video/*';
|
||
input.onchange = (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0];
|
||
if (file) {
|
||
setVideoUrl(URL.createObjectURL(file));
|
||
}
|
||
}
|
||
input.click();
|
||
}
|
||
|
||
const generateSences = async () => {
|
||
try {
|
||
generateObj.scene_videos = [];
|
||
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',
|
||
'https://cdn.qikongjian.com/videos/1750303377_8c3c4ca6-c4ea-4376-8583-de3afa5681d8_text_to_video_0.mp4',
|
||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
||
];
|
||
setGenerateObj({...generateObj, scene_videos: []});
|
||
|
||
// 使用 Promise.all 和 Array.map 来处理异步操作
|
||
const promises = generateObj.scripts.map((element: any, index: number) => {
|
||
return new Promise<void>((resolveVideo) => {
|
||
setTimeout(() => {
|
||
generateObj.scene_videos.push({
|
||
id: index,
|
||
video_url: videoUrls[index],
|
||
script: element
|
||
});
|
||
setGenerateObj({...generateObj});
|
||
setLoadingText(`生成第 ${index + 1} 个分镜视频...`);
|
||
resolveVideo();
|
||
}, index * 3000); // 每个视频间隔3秒
|
||
});
|
||
});
|
||
|
||
// 等待所有视频生成完成
|
||
await Promise.all(promises);
|
||
} catch (error) {
|
||
console.error('生成分镜视频失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
const handleCreateVideo = async () => {
|
||
try {
|
||
// 清空所有数据
|
||
setGenerateObj({
|
||
scripts: null,
|
||
frame_urls: null,
|
||
video_info: null,
|
||
scene_videos: null,
|
||
cut_video_url: null,
|
||
audio_video_url: null,
|
||
final_video_url: null
|
||
});
|
||
console.log('create video');
|
||
setIsLoading(true);
|
||
setIsExpanded(true);
|
||
|
||
// 提取帧
|
||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||
setLoadingText('提取帧...');
|
||
|
||
// 生成帧
|
||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||
generateObj.frame_urls = [
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000000.jpg/1750507510_tmphfb431oc_000000.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000001.jpg/1750507511_tmphfb431oc_000001.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000002.jpg/1750507511_tmphfb431oc_000002.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000003.jpg/1750507511_tmphfb431oc_000003.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000004.jpg/1750507512_tmphfb431oc_000004.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000005.jpg/1750507512_tmphfb431oc_000005.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000006.jpg/1750507513_tmphfb431oc_000006.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000008.jpg/1750507515_tmphfb431oc_000008.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000009.jpg/1750507515_tmphfb431oc_000009.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000010.jpg/1750507516_tmphfb431oc_000010.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000011.jpg/1750507516_tmphfb431oc_000011.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000012.jpg/1750507516_tmphfb431oc_000012.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000013.jpg/1750507517_tmphfb431oc_000013.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000014.jpg/1750507517_tmphfb431oc_000014.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000015.jpg/1750507517_tmphfb431oc_000015.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000016.jpg/1750507517_tmphfb431oc_000016.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000017.jpg/1750507517_tmphfb431oc_000017.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000018.jpg/1750507518_tmphfb431oc_000018.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000019.jpg/1750507520_tmphfb431oc_000019.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000020.jpg/1750507521_tmphfb431oc_000020.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000021.jpg/1750507523_tmphfb431oc_000021.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000022.jpg/1750507523_tmphfb431oc_000022.jpg",
|
||
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000023.jpg/1750507524_tmphfb431oc_000023.jpg",
|
||
];
|
||
setGenerateObj({...generateObj});
|
||
setLoadingText('分析视频...');
|
||
|
||
// 生成视频信息
|
||
await new Promise(resolve => setTimeout(resolve, 6000));
|
||
generateObj.video_info = {
|
||
roles: [
|
||
{
|
||
name: '雪 (YUKI)',
|
||
core_identity: '一位接近二十岁或二十出头的年轻女性,东亚裔,拥有深色长发和刘海,五官柔和且富有表现力。',
|
||
avatar: 'https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000000.jpg/1750507510_tmphfb431oc_000000.jpg',
|
||
},{
|
||
name: '春 (HARU)',
|
||
core_identity: '一位接近二十岁或二十出头的年轻男性,东亚裔,拥有深色、发型整洁的中长发和深思的气质。',
|
||
avatar: 'https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000000.jpg/1750507510_tmphfb431oc_000000.jpg',
|
||
}
|
||
],
|
||
sence: '叙事在两个不同的时间段展开。现在时空设定在一个安静的乡下小镇,冬季时被大雪覆盖。记忆则设定在春夏两季,一个日本高中的校园内外,天气温暖而晴朗。',
|
||
style: '电影感,照片般逼真,带有柔和、梦幻般的质感。其美学风格让人联想到日本的浪漫剧情片。现在时空的场景使用冷色、蓝色调的调色板,而记忆序列则沐浴在温暖的黄金时刻光晕中。大量使用浅景深、微妙的镜头光晕,以及平滑、富有情感的节奏。8K分辨率。'
|
||
};
|
||
setGenerateObj({...generateObj});
|
||
setLoadingText('提取分镜脚本...');
|
||
|
||
// 生成分镜脚本
|
||
await new Promise(resolve => setTimeout(resolve, 6000));
|
||
generateObj.scripts = [
|
||
{
|
||
shot: '面部特写,拉远至广角镜头。',
|
||
frame: '序列以雪(YUKI)面部的特写开场,她闭着眼睛躺在一片纯净的雪地里。柔和的雪花轻轻飘落在她的黑发和苍白的皮肤上。摄像机缓慢拉远,形成一幅令人惊叹的广角画面,揭示出在黄昏时分,她是在一片广阔、寂静、白雪覆盖的景观中的一个渺小、孤独的身影。',
|
||
atmosphere: '忧郁、宁静、寂静且寒冷。'
|
||
}, {
|
||
shot: '切至室内中景,随后是一个透过窗户的主观视角镜头。',
|
||
frame: '我们切到雪(YUKI)在一个舒适、光线温暖的卧室里醒来。她穿着一件舒适的毛衣,从床上下来,走向一扇窗户。她的呼吸在冰冷的玻璃上凝成雾气。她的主观视角镜头揭示了外面的雪景,远处有一座红色的房子,以及一封信被放入邮箱的记忆,从而触发了一段闪回。',
|
||
atmosphere: '怀旧、温暖、内省。'
|
||
}, {
|
||
shot: '跟踪镜头,随后是一系列切出镜头和特写。',
|
||
frame: '记忆开始。一个跟踪镜头跟随着一群学生,包括雪(YUKI),他们在飘落的樱花花瓣构成的华盖下走路上学。场景切到一个阳光普照的教室。雪(YUKI)穿着校服,坐在她的课桌前,害羞地瞥了一眼坐在前几排的春(HARU)。他仿佛感觉到她的凝视,巧妙地转过头来。',
|
||
atmosphere: '充满青春气息、怀旧、温暖,带有一种萌芽的、未言明的浪漫感。'
|
||
}, {
|
||
shot: '静态中景,切到另一个中景,营造出共享空间的感觉。',
|
||
frame: '记忆转移到学校图书馆,充满了金色的光束。一个中景镜头显示春(HARU)靠在一个书架上,全神贯注地读一本书。然后摄像机切到雪(YUKI),她坐在附近的一张桌子旁,专注于画架上的一幅小画,当她感觉到他的存在时,嘴唇上泛起一丝淡淡的、秘密的微笑。',
|
||
atmosphere: '平和、亲密、书卷气、温暖。'
|
||
}, {
|
||
shot: '雪(YUKI)的中景,过渡到通过相机镜头的特写主观视角。',
|
||
frame: '在一个阳光明媚的运动日,雪(YUKI)站在运动场边缘,拿着一台老式相机。她举起相机,镜头推进到她透过取景器看的眼睛的特写。我们切到她的主观视角:除了站在运动场上、表情专注而坚定的春(HARU)之外,整个世界都是失焦的。',
|
||
atmosphere: '充满活力、专注,一种投入的观察和遥远的钦佩感。'
|
||
},
|
||
];
|
||
setGenerateObj({...generateObj}); // 使用展开运算符创建新对象,确保触发更新
|
||
setLoadingText('生成分镜视频...');
|
||
|
||
// 生成分镜视频
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
await generateSences();
|
||
setLoadingText('分镜剪辑...');
|
||
|
||
// 生成剪辑后的视频
|
||
await new Promise(resolve => setTimeout(resolve, 6000));
|
||
generateObj.cut_video_url = 'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4';
|
||
setGenerateObj({...generateObj});
|
||
setLoadingText('口型同步...');
|
||
|
||
// 口型同步后生成视频
|
||
await new Promise(resolve => setTimeout(resolve, 6000));
|
||
generateObj.audio_video_url = 'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4';
|
||
setGenerateObj({...generateObj});
|
||
setLoadingText('一致化处理...');
|
||
|
||
// 最终完成
|
||
await new Promise(resolve => setTimeout(resolve, 6000));
|
||
generateObj.final_video_url = 'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4';
|
||
setGenerateObj({...generateObj});
|
||
setLoadingText('完成');
|
||
setIsLoading(false);
|
||
} catch (error) {
|
||
console.error('视频生成过程出错:', error);
|
||
setLoadingText('生成失败,请重试');
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
// 处理视频选中
|
||
const handleVideoSelect = (index: number) => {
|
||
setSelectedVideoIndex(index);
|
||
// 滚动脚本到对应位置
|
||
const scriptElement = document.getElementById(`script-${index}`);
|
||
scriptElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||
};
|
||
|
||
// 处理脚本选中
|
||
const handleScriptSelect = (index: number) => {
|
||
setSelectedVideoIndex(index);
|
||
// 滚动视频到对应位置
|
||
const videoElement = document.getElementById(`video-${index}`);
|
||
videoElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
className="container mx-auto overflow-auto custom-scrollbar"
|
||
style={isExpanded ? {height: 'calc(100vh - 12rem)'} : {height: 'calc(100vh - 20rem)'}}
|
||
>
|
||
{/* 展示创作详细过程 */}
|
||
{generateObj && (
|
||
<div className='video-creation-process-container mb-6'>
|
||
{generateObj.frame_urls && (
|
||
<div id='step-frame_urls' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
|
||
<div className='video-creation-process-item-title mb-3'>
|
||
<span className='text-base font-medium'>提取帧</span>
|
||
</div>
|
||
<div className='relative'>
|
||
<div className={`video-creation-process-item-content flex flex-wrap gap-2 ${!showAllFrames && 'max-h-[324px]'} overflow-hidden transition-all duration-300`}>
|
||
{generateObj.frame_urls.map((frame: string, index: number) => (
|
||
<div key={index} className="relative group">
|
||
<img
|
||
src={frame}
|
||
alt={`frame ${index + 1}`}
|
||
className='h-[100px] rounded-md object-cover transition-transform duration-200 group-hover:scale-105'
|
||
/>
|
||
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-md flex items-center justify-center">
|
||
<span className="text-white/90 text-sm">Frame {index + 1}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{generateObj.frame_urls.length > maxVisibleImages && (
|
||
<div
|
||
className='absolute bottom-0 left-0 right-0 h-12 flex items-center justify-center bg-gradient-to-t from-black/20 to-transparent cursor-pointer'
|
||
onClick={() => setShowAllFrames(!showAllFrames)}
|
||
>
|
||
<div className='flex items-center gap-1 px-3 py-1 rounded-full bg-white/10 hover:bg-white/20 transition-colors'>
|
||
{showAllFrames ? (
|
||
<>
|
||
<ChevronUp className="w-4 h-4" />
|
||
<span className='text-sm'>收起</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<ChevronDown className="w-4 h-4" />
|
||
<span className='text-sm'>展开全部 ({generateObj.frame_urls.length} 帧)</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 视频信息 */}
|
||
{generateObj.video_info && (
|
||
<div id='step-video_info' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
|
||
<div className='video-creation-process-item-title mb-3'>
|
||
<span className='text-base font-medium'>视频信息</span>
|
||
</div>
|
||
{/* 展示:角色档案卡(头像、姓名、核心身份);场景;风格 */}
|
||
<div className='video-creation-process-item-content flex flex-col gap-6'>
|
||
{/* 角色档案卡 */}
|
||
<div className='space-y-4'>
|
||
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'>角色档案:</span>
|
||
<div className='flex flex-wrap gap-4'>
|
||
{generateObj.video_info.roles.map((role: any, index: number) => (
|
||
<div
|
||
key={index}
|
||
className='flex items-start gap-3 p-3 rounded-lg bg-white/[0.03] hover:bg-white/[0.05] transition-colors duration-200 min-w-[300px] max-w-[400px] group'
|
||
>
|
||
<div className='flex-shrink-0'>
|
||
<div className='w-[48px] h-[48px] rounded-full overflow-hidden border-2 border-white/10 group-hover:border-white/20 transition-colors duration-200'>
|
||
<img
|
||
src={role.avatar}
|
||
alt={role.name}
|
||
className='w-full h-full object-cover'
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className='flex-1 min-w-0'>
|
||
<div className='text-base font-medium mb-1 text-white/90'>{role.name}</div>
|
||
<div className='text-sm text-white/60 line-clamp-2'>{role.core_identity}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 场景和风格 */}
|
||
<div className='space-y-4'>
|
||
<div className='video-creation-process-item-content-item-scene'>
|
||
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'>场景</span>
|
||
<p className='text-sm text-white/80 leading-relaxed pl-1'>{generateObj.video_info.sence}</p>
|
||
</div>
|
||
<div className='video-creation-process-item-content-item-style'>
|
||
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'>风格</span>
|
||
<p className='text-sm text-white/80 leading-relaxed pl-1'>{generateObj.video_info.style}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 分镜脚本 */}
|
||
{generateObj.scripts && (
|
||
<div id='step-scripts' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
|
||
<div className='video-creation-process-item-title mb-3'>
|
||
<span className='text-base font-medium'>分镜脚本</span>
|
||
</div>
|
||
<div className='video-creation-process-item-content'>
|
||
<div className='flex flex-wrap gap-4'>
|
||
{generateObj.scripts.map((script: any, index: number) => (
|
||
<div
|
||
key={index}
|
||
className='flex-shrink-0 w-[360px] h-[400px] bg-white/[0.03] rounded-lg p-4 hover:bg-white/[0.05] transition-all duration-200 group'
|
||
>
|
||
{/* 序号 */}
|
||
<div className='flex items-center justify-between mb-3 pb-2 border-b border-white/10'>
|
||
<span className='text-lg font-medium text-blue-400/90'>Scene {index + 1}</span>
|
||
<div className='px-2 py-1 rounded-full bg-white/10 text-xs text-white/60'>
|
||
#{String(index + 1).padStart(2, '0')}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 滚动内容区域 */}
|
||
<div className='h-[calc(100%-40px)] overflow-y-auto pr-2 space-y-4 custom-scrollbar'>
|
||
<div>
|
||
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'>镜头</span>
|
||
<p className='text-sm text-white/80 leading-relaxed'>{script.shot}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'>场景</span>
|
||
<p className='text-sm text-white/80 leading-relaxed'>{script.frame}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'>氛围</span>
|
||
<p className='text-sm text-white/80 leading-relaxed'>{script.atmosphere}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 分镜视频 */}
|
||
{generateObj.scene_videos && (
|
||
<div id='step-scene_videos' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
|
||
<div className='video-creation-process-item-title mb-3'>
|
||
<span className='text-sm font-medium'>分镜视频</span>
|
||
</div>
|
||
<div className='video-creation-process-item-content space-y-6'>
|
||
{/* 视频展示区 */}
|
||
<div
|
||
ref={videosContainerRef}
|
||
className='flex gap-4 overflow-x-auto pb-4 custom-scrollbar'
|
||
>
|
||
{generateObj.scripts.map((script: any, index: number) => {
|
||
const video = generateObj.scene_videos.find((v: any) => v.id === index);
|
||
const isSelected = selectedVideoIndex === index;
|
||
|
||
return (
|
||
<div
|
||
key={index}
|
||
id={`video-${index}`}
|
||
className={`flex-shrink-0 w-[320px] aspect-video rounded-lg overflow-hidden relative cursor-pointer transition-all duration-300
|
||
${isSelected ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-[#191B1E]' : 'hover:ring-2 hover:ring-white/20'}`}
|
||
onClick={() => handleVideoSelect(index)}
|
||
>
|
||
{video ? (
|
||
<>
|
||
<video
|
||
src={video.video_url}
|
||
className="w-full h-full object-cover"
|
||
controls={isSelected}
|
||
/>
|
||
{!isSelected && (
|
||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||
<Play className="w-8 h-8 text-white/90" />
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="w-full h-full bg-white/5 flex items-center justify-center">
|
||
<Loader2 className="w-8 h-8 text-white/40 animate-spin" />
|
||
</div>
|
||
)}
|
||
<div className="absolute top-2 right-2 px-2 py-1 rounded-full bg-black/60 text-xs text-white/80">
|
||
Scene {index + 1}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 脚本展示区 */}
|
||
<div
|
||
ref={scriptsContainerRef}
|
||
className='flex gap-4 overflow-x-auto pb-4 custom-scrollbar'
|
||
>
|
||
{generateObj.scripts.map((script: any, index: number) => {
|
||
const isSelected = selectedVideoIndex === index;
|
||
|
||
return (
|
||
<div
|
||
key={index}
|
||
id={`script-${index}`}
|
||
className={`flex-shrink-0 w-[320px] p-4 rounded-lg cursor-pointer transition-all duration-300
|
||
${isSelected ? 'bg-blue-400/10 border border-blue-400/30' : 'bg-white/5 hover:bg-white/10'}`}
|
||
onClick={() => handleScriptSelect(index)}
|
||
>
|
||
<div className="text-xs text-white/40 mb-2">Scene {index + 1}</div>
|
||
<div className="text-sm text-white/80 line-clamp-4">{script.frame}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 剪辑后的视频 */}
|
||
{generateObj.cut_video_url && (
|
||
<div id='step-cut_video' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
|
||
<div className='video-creation-process-item-title mb-3'>
|
||
<span className='text-base font-medium'>剪辑后的视频</span>
|
||
</div>
|
||
<div className='video-creation-process-item-content'>
|
||
<div className='flex flex-wrap gap-4'>
|
||
<div className='flex-shrink-0 w-full bg-white/[0.03] rounded-lg p-4 hover:bg-white/[0.05] transition-all duration-200 group'>
|
||
<video src={generateObj.cut_video_url} className="w-full h-full object-cover" controls />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 口型同步后的视频 */}
|
||
{generateObj.audio_video_url && (
|
||
<div id='step-audio_video' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
|
||
<div className='video-creation-process-item-title mb-3'>
|
||
<span className='text-base font-medium'>口型同步后的视频</span>
|
||
</div>
|
||
<div className='video-creation-process-item-content'>
|
||
<div className='flex flex-wrap gap-4'>
|
||
<div className='flex-shrink-0 w-full bg-white/[0.03] rounded-lg p-4 hover:bg-white/[0.05] transition-all duration-200 group'>
|
||
<video src={generateObj.audio_video_url} className="w-full h-full object-cover" controls />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 最终视频 */}
|
||
{generateObj.final_video_url && (
|
||
<div id='step-final_video' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
|
||
<div className='video-creation-process-item-title mb-3'>
|
||
<span className='text-base font-medium'>最终视频</span>
|
||
</div>
|
||
<div className='video-creation-process-item-content'>
|
||
<div className='flex flex-wrap gap-4'>
|
||
<div className='flex-shrink-0 w-full bg-white/[0.03] rounded-lg p-4 hover:bg-white/[0.05] transition-all duration-200 group'>
|
||
<video src={generateObj.final_video_url} className="w-full h-full object-cover" controls />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
</div>
|
||
)}
|
||
|
||
{/* 回滚条 */}
|
||
<div
|
||
className="fixed right-8 top-1/2 -translate-y-1/2 z-50"
|
||
onMouseEnter={() => setShowScrollNav(true)}
|
||
onMouseLeave={() => setShowScrollNav(false)}
|
||
>
|
||
{/* 悬浮按钮 */}
|
||
<button
|
||
className={`flex items-center justify-center w-12 h-12 rounded-full bg-gradient-to-br from-blue-400/20 to-blue-600/20 backdrop-blur-lg hover:from-blue-400/30 hover:to-blue-600/30 transition-all duration-300 group
|
||
${showScrollNav ? 'opacity-0 scale-90' : 'opacity-100 scale-100'}`}
|
||
>
|
||
<ListOrdered className="w-5 h-5 text-white/70 group-hover:text-white/90 transition-colors" />
|
||
</button>
|
||
|
||
{/* 展开的回滚导航 */}
|
||
<div className={`absolute right-0 top-1/2 -translate-y-1/2 transition-all duration-300 ease-out
|
||
${showScrollNav ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-4 pointer-events-none'}`}
|
||
>
|
||
<div className="flex items-center gap-4 bg-gradient-to-b from-black/30 to-black/10 backdrop-blur-lg rounded-l-2xl pl-6 pr-6 py-6">
|
||
{generateObj && (
|
||
<>
|
||
{/* 进度条背景 */}
|
||
<div className="absolute w-[3px] h-[280px] bg-gradient-to-b from-white/5 to-white/0 rounded-full left-8" />
|
||
|
||
{/* 动态进度条 */}
|
||
<div className="absolute w-[3px] rounded-full transition-all duration-500 ease-out left-8 overflow-hidden"
|
||
style={{
|
||
height: generateObj.final_video_url ? '280px' :
|
||
generateObj.audio_video_url ? '240px' :
|
||
generateObj.cut_video_url ? '200px' :
|
||
generateObj.scene_videos ? '160px' :
|
||
generateObj.scripts ? '120px' :
|
||
generateObj.video_info ? '80px' :
|
||
generateObj.frame_urls ? '40px' : '0px',
|
||
top: '24px',
|
||
background: 'linear-gradient(180deg, rgba(96,165,250,0.7) 0%, rgba(96,165,250,0.3) 100%)',
|
||
boxShadow: '0 0 20px rgba(96,165,250,0.3)'
|
||
}}
|
||
/>
|
||
|
||
{/* 步骤按钮 */}
|
||
<div className="relative flex flex-col justify-between h-[280px] py-2">
|
||
{generateObj.frame_urls && (
|
||
<button
|
||
onClick={() => {
|
||
const element = document.getElementById('step-frame_urls');
|
||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}}
|
||
className="group flex items-center gap-3 -ml-1"
|
||
>
|
||
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right">提取帧</span>
|
||
<div className="relative w-3 h-3">
|
||
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
|
||
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
|
||
</div>
|
||
</button>
|
||
)}
|
||
|
||
{generateObj.video_info && (
|
||
<button
|
||
onClick={() => {
|
||
const element = document.getElementById('step-video_info');
|
||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}}
|
||
className="group flex items-center gap-3 -ml-1"
|
||
>
|
||
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right">视频信息</span>
|
||
<div className="relative w-3 h-3">
|
||
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
|
||
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
|
||
</div>
|
||
</button>
|
||
)}
|
||
|
||
{generateObj.scripts && (
|
||
<button
|
||
onClick={() => {
|
||
const element = document.getElementById('step-scripts');
|
||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}}
|
||
className="group flex items-center gap-3 -ml-1"
|
||
>
|
||
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right">分镜脚本</span>
|
||
<div className="relative w-3 h-3">
|
||
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
|
||
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
|
||
</div>
|
||
</button>
|
||
)}
|
||
|
||
{generateObj.scene_videos && (
|
||
<button
|
||
onClick={() => {
|
||
const element = document.getElementById('step-scene_videos');
|
||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}}
|
||
className="group flex items-center gap-3 -ml-1"
|
||
>
|
||
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right">分镜视频</span>
|
||
<div className="relative w-3 h-3">
|
||
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
|
||
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
|
||
</div>
|
||
</button>
|
||
)}
|
||
|
||
{generateObj.cut_video_url && (
|
||
<button
|
||
onClick={() => {
|
||
const element = document.getElementById('step-cut_video');
|
||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}}
|
||
className="group flex items-center gap-3 -ml-1"
|
||
>
|
||
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right">剪辑视频</span>
|
||
<div className="relative w-3 h-3">
|
||
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
|
||
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
|
||
</div>
|
||
</button>
|
||
)}
|
||
|
||
{generateObj.audio_video_url && (
|
||
<button
|
||
onClick={() => {
|
||
const element = document.getElementById('step-audio_video');
|
||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}}
|
||
className="group flex items-center gap-3 -ml-1"
|
||
>
|
||
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right">口型同步</span>
|
||
<div className="relative w-3 h-3">
|
||
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
|
||
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
|
||
</div>
|
||
</button>
|
||
)}
|
||
|
||
{generateObj.final_video_url && (
|
||
<button
|
||
onClick={() => {
|
||
const element = document.getElementById('step-final_video');
|
||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}}
|
||
className="group flex items-center gap-3 -ml-1"
|
||
>
|
||
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right">最终视频</span>
|
||
<div className="relative w-3 h-3">
|
||
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
|
||
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
|
||
</div>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 工具栏 */}
|
||
<div className='video-tool-component relative w-[1080px]'>
|
||
<div className='video-storyboard-tools grid gap-4 rounded-[20px] bg-[#fff3] backdrop-blur-[15px]'>
|
||
{isExpanded ? (
|
||
<div className='absolute top-0 bottom-0 left-0 right-0 z-[9] grid justify-items-center place-content-center rounded-[20px] bg-[#191B1E] bg-opacity-[0.3] backdrop-blur-[1.5px] cursor-pointer' onClick={() => setIsExpanded(false)}>
|
||
{/* 图标 展开按钮 */}
|
||
<ChevronUp className='w-4 h-4' />
|
||
<span className='text-sm'>Click to create</span>
|
||
</div>
|
||
) : (
|
||
<div className='absolute top-[-8px] left-[50%] z-[10] flex items-center justify-center w-[46px] h-4 rounded-[15px] bg-[#fff3] translate-x-[-50%] cursor-pointer hover:bg-[#ffffff1a]' onClick={() => setIsExpanded(true)}>
|
||
{/* 图标 折叠按钮 */}
|
||
<ChevronDown className='w-4 h-4' />
|
||
</div>
|
||
)}
|
||
|
||
<div className={`flex-shrink-0 p-4 overflow-hidden transition-all duration-300 ${isExpanded ? 'h-[16px]' : 'h-[162px]'}`}>
|
||
<div className='video-creation-tool-container flex flex-col gap-4'>
|
||
<div className='relative flex items-center gap-4'>
|
||
<div className='relative w-[72px] rounded-[6px] overflow-hidden cursor-pointer ant-dropdown-trigger' onClick={handleUploadVideo}>
|
||
<div className='relative flex items-center justify-center w-[72px] h-[72px] rounded-[6px 6px 0 0] bg-white/[0.05] transition-all overflow-hidden hover:bg-white/[0.10]'>
|
||
{/* 图标 添加视频 */}
|
||
<Video className='w-4 h-4' />
|
||
</div>
|
||
<div className='w-full h-[22px] flex items-center justify-center rounded-[0 0 6px 6px] bg-white/[0.03]'>
|
||
<span className='text-xs cursor-[inherit]'>Add Video</span>
|
||
</div>
|
||
</div>
|
||
{videoUrl && (
|
||
<div className='relative w-[72px] rounded-[6px] overflow-hidden cursor-pointer ant-dropdown-trigger'>
|
||
<div className='relative flex items-center justify-center w-[72px] h-[72px] rounded-[6px 6px 0 0] bg-white/[0.05] transition-all overflow-hidden hover:bg-white/[0.10]'>
|
||
<video src={videoUrl} className='w-full h-full object-cover' />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className='flex gap-3 justify-end'>
|
||
<div className='flex items-center gap-3'>
|
||
<div className='disabled tool-submit-button'>Stop</div>
|
||
<div className={`tool-submit-button ${videoUrl ? '' : 'disabled'}`} onClick={handleCreateVideo}>Create</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Loading动画 */}
|
||
{isLoading && (
|
||
<div className='mt-8 flex justify-center'>
|
||
<div className='relative'>
|
||
{/* 外圈动画 */}
|
||
<div className='w-[40px] h-[40px] rounded-full bg-white/[0.05] flex items-center justify-center animate-bounce'>
|
||
{/* 中圈动画 */}
|
||
<div className='w-[20px] h-[20px] rounded-full bg-white/[0.05] flex items-center justify-center animate-pulse'>
|
||
{/* 内圈动画 */}
|
||
<div className='w-[10px] h-[10px] rounded-full bg-white/[0.05] flex items-center justify-center animate-ping'>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* Loading文字 */}
|
||
<div className='absolute top-[50px] left-1/2 -translate-x-1/2 whitespace-nowrap'>
|
||
<span className='text-white/70 text-sm animate-pulse inline-block'>{loadingText}</span>
|
||
<span className='inline-block ml-1 animate-bounce'>
|
||
<span className='inline-block animate-bounce delay-100'>.</span>
|
||
<span className='inline-block animate-bounce delay-200'>.</span>
|
||
<span className='inline-block animate-bounce delay-300'>.</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|