forked from 77media/video-flow
550 lines
21 KiB
TypeScript
550 lines
21 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, Pause, MoreHorizontal, Edit2, Check, X, RefreshCw, Lightbulb, Package, Crown, ArrowUp } from 'lucide-react';
|
||
import { useRouter, useSearchParams } from 'next/navigation';
|
||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from "@/components/ui/sheet";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import './style/create-to-video2.css';
|
||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import LiquidGlass from '@/plugins/liquid-glass/index'
|
||
import { Dropdown } from 'antd';
|
||
import type { MenuProps } from 'antd';
|
||
import Image from 'next/image';
|
||
import dynamic from 'next/dynamic';
|
||
import { ProjectTypeEnum, ModeEnum, ResolutionEnum } from "@/api/enums";
|
||
import { createScriptEpisode, CreateScriptEpisodeRequest } from "@/api/script_episode";
|
||
import { getUploadTokenWithDomain, uploadToQiniu } from "@/api/common";
|
||
import { convertScriptToScene, convertVideoToScene } from "@/api/video_flow";
|
||
|
||
const JoyrideNoSSR = dynamic(() => import('react-joyride'), {
|
||
ssr: false,
|
||
});
|
||
|
||
// 导入Step类型
|
||
import type { Step } from 'react-joyride';
|
||
|
||
// 添加自定义滚动条样式
|
||
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;
|
||
}
|
||
|
||
const ideaText = 'a cute capybara with an orange on its head, staring into the distance and walking forward';
|
||
|
||
export function CreateToVideo2() {
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const projectId = searchParams.get('projectId') ? parseInt(searchParams.get('projectId')!) : 0;
|
||
const [isClient, setIsClient] = useState(false);
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const [videoUrl, setVideoUrl] = useState('');
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const [activeTab, setActiveTab] = useState('script');
|
||
const [isFocus, setIsFocus] = useState(false);
|
||
const [selectedMode, setSelectedMode] = useState<ModeEnum>(ModeEnum.AUTOMATIC);
|
||
const [selectedResolution, setSelectedResolution] = useState<ResolutionEnum>(ResolutionEnum.HD_720P);
|
||
const [script, setInputText] = useState('');
|
||
const editorRef = useRef<HTMLDivElement>(null);
|
||
const [runTour, setRunTour] = useState(true);
|
||
const [episodeId, setEpisodeId] = useState<number>(0);
|
||
|
||
const handleUploadVideo = async () => {
|
||
console.log('upload video');
|
||
// 打开文件选择器
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'video/*';
|
||
input.onchange = async (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0];
|
||
if (file) {
|
||
try {
|
||
setIsUploading(true);
|
||
|
||
// 获取上传token
|
||
const { token } = await getUploadTokenWithDomain();
|
||
|
||
// 上传到七牛云
|
||
const videoUrl = await uploadToQiniu(file, token);
|
||
|
||
// 上传成功,设置视频URL
|
||
setVideoUrl(videoUrl);
|
||
console.log('视频上传成功:', videoUrl);
|
||
} catch (error) {
|
||
console.error('上传错误:', error);
|
||
alert('上传失败,请稍后重试');
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
}
|
||
}
|
||
input.click();
|
||
}
|
||
|
||
const handleCreateVideo = async () => {
|
||
if (videoUrl || script) {
|
||
try {
|
||
let convertResponse;
|
||
|
||
// 根据选中的选项卡调用相应的API
|
||
if (activeTab === 'script') {
|
||
// 剧本模式:调用convertScriptToScene (第43-56行)
|
||
if (!script.trim()) {
|
||
alert('请输入剧本内容');
|
||
return;
|
||
}
|
||
convertResponse = await convertScriptToScene(script, episodeId, projectId);
|
||
} else {
|
||
// 视频模式:调用convertVideoToScene (第56-69行)
|
||
if (!videoUrl) {
|
||
alert('请先上传视频');
|
||
return;
|
||
}
|
||
if (!episodeId) {
|
||
alert('Episode ID not available');
|
||
return;
|
||
}
|
||
convertResponse = await convertVideoToScene(videoUrl, episodeId, projectId);
|
||
}
|
||
|
||
// 检查转换结果
|
||
if (convertResponse.code === 0) {
|
||
// 确定项目类型
|
||
// 创建剧集数据
|
||
const episodeData: CreateScriptEpisodeRequest = {
|
||
title: "episode 1",
|
||
script_id: projectId,
|
||
status: 1,
|
||
summary: script
|
||
};
|
||
|
||
// 调用创建剧集API
|
||
const episodeResponse = await createScriptEpisode(episodeData);
|
||
|
||
if (episodeResponse.code === 0) {
|
||
// 成功创建后跳转到work-flow页面, 并设置episodeId 和 projectType
|
||
router.push(`/create/work-flow?episodeId=${episodeResponse.data.id}`);
|
||
} else {
|
||
alert(`创建剧集失败: ${episodeResponse.message}`);
|
||
}
|
||
} else {
|
||
alert(`转换失败: ${convertResponse.message}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('创建过程出错:', error);
|
||
alert("创建项目时发生错误,请稍后重试");
|
||
}
|
||
}
|
||
}
|
||
|
||
// 下拉菜单项配置
|
||
const modeItems: MenuProps['items'] = [
|
||
{
|
||
type: 'group',
|
||
label: (
|
||
<div className="text-white/50 text-xs px-2 pb-2">Mode</div>
|
||
),
|
||
children: [
|
||
{
|
||
key: ModeEnum.AUTOMATIC,
|
||
label: (
|
||
<div className="flex flex-col gap-1 p-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-base font-medium">Auto</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
<span className="text-sm text-gray-400">Automatically selects the best model for optimal efficiency</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: ModeEnum.MANUAL,
|
||
label: (
|
||
<div className="flex flex-col gap-1 p-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-base font-medium">Manual</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
<span className="text-sm text-gray-400">Offers reliable, consistent performance every time</span>
|
||
</div>
|
||
),
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
// 分辨率选项配置
|
||
const resolutionItems: MenuProps['items'] = [
|
||
{
|
||
type: 'group',
|
||
label: (
|
||
<div className="text-white/50 text-xs px-2 pb-2">Resolution</div>
|
||
),
|
||
children: [
|
||
{
|
||
key: ResolutionEnum.HD_720P,
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">720P</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: ResolutionEnum.FULL_HD_1080P,
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">1080P</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: ResolutionEnum.UHD_2K,
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">2K</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: ResolutionEnum.UHD_4K,
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">4K</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
),
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
// 处理模式选择
|
||
const handleModeSelect: MenuProps['onClick'] = ({ key }) => {
|
||
setSelectedMode(Number(key) as ModeEnum);
|
||
};
|
||
|
||
// 处理分辨率选择
|
||
const handleResolutionSelect: MenuProps['onClick'] = ({ key }) => {
|
||
setSelectedResolution(Number(key) as ResolutionEnum);
|
||
};
|
||
|
||
const handleStartCreating = () => {
|
||
setActiveTab('script');
|
||
setInputText(ideaText);
|
||
}
|
||
|
||
// 处理编辑器聚焦
|
||
const handleEditorFocus = () => {
|
||
setIsFocus(true);
|
||
if (editorRef.current && script) {
|
||
// 创建范围对象
|
||
const range = document.createRange();
|
||
const selection = window.getSelection();
|
||
|
||
// 获取编辑器内的文本节点
|
||
const textNode = Array.from(editorRef.current.childNodes).find(
|
||
node => node.nodeType === Node.TEXT_NODE
|
||
) || editorRef.current.appendChild(document.createTextNode(script));
|
||
|
||
// 设置范围到文本末尾
|
||
range.setStart(textNode, script.length);
|
||
range.setEnd(textNode, script.length);
|
||
|
||
// 应用选择
|
||
selection?.removeAllRanges();
|
||
selection?.addRange(range);
|
||
}
|
||
};
|
||
|
||
// 处理编辑器内容变化
|
||
const handleEditorChange = (e: React.FormEvent<HTMLDivElement>) => {
|
||
const script = e.currentTarget.textContent || '';
|
||
setInputText(script);
|
||
};
|
||
|
||
// 引导步骤
|
||
const steps: Step[] = [
|
||
{
|
||
target: '.video-storyboard-tools',
|
||
content: 'Welcome to AI Video Creation Tool! This is the main creation area.',
|
||
placement: 'top',
|
||
},
|
||
{
|
||
target: '.storyboard-tools-tab',
|
||
content: 'Choose between Script mode to create videos from text, or Clone mode to recreate existing videos.',
|
||
placement: 'bottom',
|
||
},
|
||
{
|
||
target: '.video-prompt-editor',
|
||
content: 'Describe your video content here. Our AI will generate videos based on your description.',
|
||
placement: 'top',
|
||
},
|
||
{
|
||
target: '.tool-operation-button',
|
||
content: 'Select different creation modes and video resolutions to customize your output.',
|
||
placement: 'top',
|
||
},
|
||
{
|
||
target: '.tool-submit-button',
|
||
content: 'Click here to start creating your video once you are ready!',
|
||
placement: 'left',
|
||
},
|
||
];
|
||
|
||
// 处理引导结束
|
||
const handleJoyrideCallback = (data: any) => {
|
||
const { status } = data;
|
||
if (status === 'finished' || status === 'skipped') {
|
||
setRunTour(false);
|
||
// 可以在这里存储用户已完成引导的状态
|
||
localStorage.setItem('hasCompletedTour', 'true');
|
||
}
|
||
};
|
||
|
||
// 检查是否需要显示引导
|
||
useEffect(() => {
|
||
const hasCompletedTour = localStorage.getItem('hasCompletedTour');
|
||
if (hasCompletedTour) {
|
||
setRunTour(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setIsClient(true);
|
||
}, []);
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
className="container mx-auto overflow-hidden custom-scrollbar"
|
||
style={isExpanded ? {height: 'calc(100vh - 12rem)'} : {height: 'calc(100vh - 20rem)'}}
|
||
>
|
||
{isClient && (
|
||
<JoyrideNoSSR
|
||
steps={steps}
|
||
run={runTour}
|
||
continuous
|
||
showSkipButton
|
||
showProgress
|
||
styles={{
|
||
options: {
|
||
primaryColor: '#4F46E5',
|
||
zIndex: 10000,
|
||
backgroundColor: '#1F2937',
|
||
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
|
||
hideCloseButton
|
||
callback={handleJoyrideCallback}
|
||
locale={{
|
||
back: 'Back',
|
||
close: 'Close',
|
||
last: 'Finish',
|
||
next: 'Next',
|
||
skip: 'Skip'
|
||
}}
|
||
/>
|
||
)}
|
||
<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='video-tool-component relative w-[1080px]'>
|
||
<div className='video-storyboard-tools grid gap-4 rounded-[20px] bg-[#0C0E11] backdrop-blur-[15px]'>
|
||
{isExpanded ? (
|
||
<div className='absolute top-0 bottom-0 left-0 right-0 z-[1] 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-[2] 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='storyboard-tools-tab relative flex gap-8 px-4 py-[10px]'>
|
||
<div className={`tab-item ${activeTab === 'script' ? 'active' : ''} cursor-pointer`} onClick={() => setActiveTab('script')}>
|
||
<span className='text-lg opacity-60'>script</span>
|
||
</div>
|
||
<div className={`tab-item ${activeTab === 'clone' ? 'active' : ''} cursor-pointer`} onClick={() => setActiveTab('clone')}>
|
||
<span className='text-lg opacity-60'>clone</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`flex-shrink-0 p-4 overflow-hidden transition-all duration-300 pt-0 gap-4 ${isExpanded ? 'h-[16px]' : 'h-[162px]'}`}>
|
||
<div className='video-creation-tool-container flex flex-col gap-4'>
|
||
{activeTab === 'clone' && (
|
||
<div className='relative flex items-center gap-4 h-[94px]'>
|
||
<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 opacity-30 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>
|
||
)}
|
||
{activeTab === 'script' && (
|
||
<div className='relative flex items-center gap-4 h-[94px]'>
|
||
<div className={`video-prompt-editor relative flex flex-1 self-stretch items-center w-0 rounded-[6px] ${isFocus ? 'focus' : ''}`}>
|
||
<div
|
||
ref={editorRef}
|
||
className='editor-content flex-1 w-0 max-h-[78px] min-h-[26px] h-auto gap-4 pl-[10px] rounded-[10px] leading-[26px] text-sm border-none overflow-y-auto cursor-text'
|
||
contentEditable
|
||
style={{ paddingRight: '10px' }}
|
||
onFocus={handleEditorFocus}
|
||
onBlur={() => setIsFocus(false)}
|
||
onInput={handleEditorChange}
|
||
suppressContentEditableWarning
|
||
>
|
||
{script}
|
||
</div>
|
||
<div
|
||
className={`custom-placeholder absolute top-[50%] left-[10px] z-10 translate-y-[-50%] flex items-center gap-1 pointer-events-none text-[14px] leading-[26px] text-white/[0.40] ${script ? 'opacity-0' : 'opacity-100'}`}
|
||
>
|
||
<span>Describe the content you want to create. Get an </span>
|
||
<b
|
||
className='idea-link inline-flex items-center gap-0.5 text-white/[0.50] font-normal cursor-pointer pointer-events-auto underline'
|
||
onClick={() => setInputText(ideaText)}
|
||
>
|
||
<Lightbulb className='w-4 h-4' />idea
|
||
</b>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className='flex gap-3'>
|
||
<div className='tool-scroll-box relative flex-1 w-0'>
|
||
<div className='tool-scroll-box-content overflow-x-auto scrollbar-hide'>
|
||
<div className='flex items-center flex-1 gap-3'>
|
||
<Dropdown
|
||
menu={{
|
||
items: modeItems,
|
||
onClick: handleModeSelect,
|
||
selectedKeys: [selectedMode.toString()],
|
||
}}
|
||
trigger={['click']}
|
||
overlayClassName="mode-dropdown"
|
||
placement="bottomLeft"
|
||
>
|
||
<div className='tool-operation-button ant-dropdown-trigger'>
|
||
<Package className='w-4 h-4' />
|
||
<span className='text-nowrap opacity-70'>
|
||
{selectedMode === ModeEnum.AUTOMATIC ? 'Auto' : 'Manual'}
|
||
</span>
|
||
<Crown className='w-4 h-4 text-yellow-500' />
|
||
</div>
|
||
</Dropdown>
|
||
|
||
<Dropdown
|
||
menu={{
|
||
items: resolutionItems,
|
||
onClick: handleResolutionSelect,
|
||
selectedKeys: [selectedResolution.toString()],
|
||
}}
|
||
trigger={['click']}
|
||
overlayClassName="mode-dropdown"
|
||
placement="bottomLeft"
|
||
>
|
||
<div className='tool-operation-button ant-dropdown-trigger'>
|
||
<Video className='w-4 h-4' />
|
||
<span className='text-nowrap opacity-70'>
|
||
{selectedResolution === ResolutionEnum.HD_720P ? '720P' :
|
||
selectedResolution === ResolutionEnum.FULL_HD_1080P ? '1080P' :
|
||
selectedResolution === ResolutionEnum.UHD_2K ? '2K' : '4K'}
|
||
</span>
|
||
<Crown className='w-4 h-4 text-yellow-500' />
|
||
</div>
|
||
</Dropdown>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className='flex items-center gap-3'>
|
||
<div className={`tool-submit-button ${videoUrl || script ? '' : 'disabled'}`} onClick={handleCreateVideo}>
|
||
<ArrowUp className='w-4 h-4' />Create
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|