forked from 77media/video-flow
710 lines
29 KiB
TypeScript
710 lines
29 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { ArrowLeft, ChevronDown, ChevronUp, Video, ListOrdered, Play, Loader2, Pause, MoreHorizontal, Edit2, Check, X, RefreshCw, Lightbulb, Package, Crown, ArrowUp, Search, Filter, Grid, Grid3X3, Calendar, Clock, Eye, Heart, Share2 } 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 './style/create-to-video2.css';
|
||
import { Dropdown, Menu } from 'antd';
|
||
import type { MenuProps } from 'antd';
|
||
import dynamic from 'next/dynamic';
|
||
import { ModeEnum, ResolutionEnum } from "@/app/model/enums";
|
||
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
|
||
import { getUploadToken, uploadToQiniu } from "@/api/common";
|
||
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
|
||
import { ScriptEditDialog } from '@/components/script-edit-dialog';
|
||
|
||
|
||
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 [isCreating, setIsCreating] = useState(false);
|
||
const [generatedVideoList, setGeneratedVideoList] = useState<any[]>([]);
|
||
const [projectName, setProjectName] = useState('默认名称');
|
||
const [episodeList, setEpisodeList] = useState<any[]>([]);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [totalPage, setTotalPage] = useState(1);
|
||
const [limit, setLimit] = useState(12);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [hasMore, setHasMore] = useState(true);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||
const [sortBy, setSortBy] = useState<string>('created_at');
|
||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||
const [isSmartAssistantExpanded, setIsSmartAssistantExpanded] = useState(false);
|
||
const [userId, setUserId] = useState<number>(0);
|
||
const [isComposing, setIsComposing] = useState(false);
|
||
const [isScriptEditDialogOpen, setIsScriptEditDialogOpen] = useState(false);
|
||
// 在客户端挂载后读取localStorage
|
||
useEffect(() => {
|
||
if (typeof window !== 'undefined') {
|
||
const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{}');
|
||
console.log('currentUser', currentUser);
|
||
setUserId(currentUser.id);
|
||
const savedProjectName = localStorage.getItem('projectName');
|
||
if (savedProjectName) {
|
||
setProjectName(savedProjectName);
|
||
}
|
||
getEpisodeList(currentUser.id);
|
||
}
|
||
}, []);
|
||
|
||
// 获取剧集列表
|
||
const getEpisodeList = async (userId: number) => {
|
||
if (isLoading || isLoadingMore) return;
|
||
console.log('getEpisodeList', userId);
|
||
|
||
setIsLoading(true);
|
||
|
||
try {
|
||
const params = {
|
||
user_id: String(userId),
|
||
};
|
||
|
||
const episodeListResponse = await getScriptEpisodeListNew(params);
|
||
console.log('episodeListResponse', episodeListResponse);
|
||
|
||
if (episodeListResponse.code === 0) {
|
||
setEpisodeList(episodeListResponse.data.movie_projects);
|
||
// 每一项 有
|
||
// final_video_url: "", // 生成的视频地址
|
||
// last_message: "",
|
||
// name: "After the Flood", // 剧集名称
|
||
// project_id: "9c34fcc4-c8d8-44fc-879e-9bd56f608c76", // 剧集ID
|
||
// status: "INIT", // 剧集状态 INIT 初始化
|
||
// step: "INIT" // 剧集步骤 INIT 初始化
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Failed to fetch episode list:', error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
setIsLoadingMore(false);
|
||
}
|
||
};
|
||
|
||
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 getUploadToken();
|
||
|
||
// 上传到七牛云
|
||
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 () => {
|
||
setIsScriptEditDialogOpen(true);
|
||
return;
|
||
setIsCreating(true);
|
||
// 创建剧集数据
|
||
let episodeData: any = {
|
||
user_id: String(userId),
|
||
script: script,
|
||
mode: selectedMode,
|
||
resolution: selectedResolution
|
||
};
|
||
|
||
// 调用创建剧集API
|
||
const episodeResponse = await createScriptEpisodeNew(episodeData);
|
||
console.log('episodeResponse', episodeResponse);
|
||
if (episodeResponse.code !== 0) {
|
||
console.error(`创建剧集失败: ${episodeResponse.message}`);
|
||
alert(`创建剧集失败: ${episodeResponse.message}`);
|
||
return;
|
||
}
|
||
let episodeId = episodeResponse.data.project_id;
|
||
// let episodeId = '9c34fcc4-c8d8-44fc-879e-9bd56f608c76';
|
||
router.push(`/create/work-flow?episodeId=${episodeId}`);
|
||
setIsCreating(false);
|
||
}
|
||
|
||
// 下拉菜单项配置
|
||
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>
|
||
</div>
|
||
<span className="text-sm text-gray-400">Automatically execute the workflow, you can't edit the workflow before it's finished.</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">Manually control the workflow, you can control the workflow everywhere.</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(key as ModeEnum);
|
||
};
|
||
|
||
// 处理分辨率选择
|
||
const handleResolutionSelect: MenuProps['onClick'] = ({ key }) => {
|
||
setSelectedResolution(key as ResolutionEnum);
|
||
};
|
||
|
||
const handleStartCreating = () => {
|
||
setActiveTab('script');
|
||
setInputText(ideaText);
|
||
}
|
||
|
||
// 处理编辑器聚焦
|
||
const handleEditorFocus = () => {
|
||
setIsFocus(true);
|
||
if (editorRef.current) {
|
||
const range = document.createRange();
|
||
const selection = window.getSelection();
|
||
const textNode = Array.from(editorRef.current.childNodes).find(
|
||
node => node.nodeType === Node.TEXT_NODE
|
||
);
|
||
|
||
if (!textNode) {
|
||
const newTextNode = document.createTextNode(script || '');
|
||
editorRef.current.appendChild(newTextNode);
|
||
range.setStart(newTextNode, (script || '').length);
|
||
range.setEnd(newTextNode, (script || '').length);
|
||
} else {
|
||
range.setStart(textNode, textNode.textContent?.length || 0);
|
||
range.setEnd(textNode, textNode.textContent?.length || 0);
|
||
}
|
||
|
||
selection?.removeAllRanges();
|
||
selection?.addRange(range);
|
||
}
|
||
};
|
||
|
||
const handleEditorChange = (e: React.FormEvent<HTMLDivElement>) => {
|
||
const newText = e.currentTarget.textContent || '';
|
||
|
||
// 如果正在输入中文,只更新内部文本,不更新状态
|
||
if (isComposing) {
|
||
return;
|
||
}
|
||
|
||
// 更新状态
|
||
setInputText(newText);
|
||
|
||
// 保存当前选区位置
|
||
const selection = window.getSelection();
|
||
if (selection && selection.rangeCount > 0) {
|
||
const range = selection.getRangeAt(0);
|
||
const currentPosition = range.startOffset;
|
||
|
||
// 使用 requestAnimationFrame 确保在下一帧恢复光标位置
|
||
requestAnimationFrame(() => {
|
||
if (editorRef.current) {
|
||
// 找到或创建文本节点
|
||
let textNode = Array.from(editorRef.current.childNodes).find(
|
||
node => node.nodeType === Node.TEXT_NODE
|
||
) as Text;
|
||
|
||
if (!textNode) {
|
||
textNode = document.createTextNode(newText);
|
||
editorRef.current.appendChild(textNode);
|
||
}
|
||
|
||
// 计算正确的光标位置
|
||
const finalPosition = Math.min(currentPosition, textNode.length);
|
||
|
||
// 设置新的选区
|
||
const newRange = document.createRange();
|
||
newRange.setStart(textNode, finalPosition);
|
||
newRange.setEnd(textNode, finalPosition);
|
||
|
||
selection.removeAllRanges();
|
||
selection.addRange(newRange);
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
// 处理中文输入开始
|
||
const handleCompositionStart = () => {
|
||
setIsComposing(true);
|
||
};
|
||
|
||
// 处理中文输入结束
|
||
const handleCompositionEnd = (e: React.CompositionEvent<HTMLDivElement>) => {
|
||
setIsComposing(false);
|
||
|
||
// 在输入完成后更新内容
|
||
const newText = e.currentTarget.textContent || '';
|
||
setInputText(newText);
|
||
|
||
// 保存并恢复光标位置
|
||
const selection = window.getSelection();
|
||
if (selection && selection.rangeCount > 0) {
|
||
const range = selection.getRangeAt(0);
|
||
const currentPosition = range.startOffset;
|
||
|
||
requestAnimationFrame(() => {
|
||
if (editorRef.current) {
|
||
let textNode = Array.from(editorRef.current.childNodes).find(
|
||
node => node.nodeType === Node.TEXT_NODE
|
||
) as Text;
|
||
|
||
if (!textNode) {
|
||
textNode = document.createTextNode(newText);
|
||
editorRef.current.appendChild(textNode);
|
||
}
|
||
|
||
// 计算正确的光标位置
|
||
const finalPosition = Math.min(currentPosition, textNode.length);
|
||
|
||
// 设置新的选区
|
||
const newRange = document.createRange();
|
||
newRange.setStart(textNode, finalPosition);
|
||
newRange.setEnd(textNode, finalPosition);
|
||
|
||
selection.removeAllRanges();
|
||
selection.addRange(newRange);
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
// 检查是否需要显示引导
|
||
useEffect(() => {
|
||
if (typeof window !== 'undefined') {
|
||
const hasCompletedTour = localStorage.getItem('hasCompletedTour');
|
||
if (hasCompletedTour) {
|
||
setRunTour(false);
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setIsClient(true);
|
||
}, []);
|
||
|
||
// 渲染剧集卡片
|
||
const renderEpisodeCard = (episode: any) => {
|
||
return (
|
||
<div
|
||
key={episode.project_id}
|
||
className="group relative bg-white/[0.08] backdrop-blur-[20px] border border-white/[0.12] rounded-[12px] overflow-hidden hover:bg-white/[0.12] transition-all duration-300 hover:shadow-[0_8px_32px_rgba(0,0,0,0.4)] cursor-pointer"
|
||
onClick={() => router.push(`/create/work-flow?episodeId=${episode.project_id}`)}
|
||
>
|
||
{/* 视频缩略图 */}
|
||
<div className="relative h-[180px] bg-gradient-to-br from-purple-500/20 to-blue-500/20 overflow-hidden">
|
||
{episode.final_video_url ? (
|
||
<video
|
||
src={episode.final_video_url}
|
||
className="w-full h-full object-cover"
|
||
muted
|
||
loop
|
||
onMouseEnter={(e) => (e.target as HTMLVideoElement).play()}
|
||
onMouseLeave={(e) => (e.target as HTMLVideoElement).pause()}
|
||
/>
|
||
) : (
|
||
<div className="flex items-center justify-center h-full">
|
||
<Video className="w-12 h-12 text-white/30" />
|
||
</div>
|
||
)}
|
||
|
||
{/* 播放按钮覆盖 */}
|
||
<div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||
<div className="w-12 h-12 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
|
||
<Play className="w-6 h-6 text-white ml-1" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 状态标签 */}
|
||
<div className="absolute top-3 left-3">
|
||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||
episode.status === 'COMPLETED' ? 'bg-green-500/80 text-white' :
|
||
episode.status !== 'COMPLETED' ? 'bg-yellow-500/80 text-white' :
|
||
'bg-gray-500/80 text-white'
|
||
}`}>
|
||
{episode.status === 'COMPLETED' ? 'finished' : 'processing'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 时长标签 */}
|
||
{episode.duration && (
|
||
<div className="absolute bottom-3 right-3">
|
||
<span className="px-2 py-1 bg-black/60 backdrop-blur-sm rounded text-xs text-white">
|
||
{episode.duration}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 内容区域 */}
|
||
<div className="p-4">
|
||
<h3 className="text-white font-medium text-sm mb-2 line-clamp-2 group-hover:text-blue-300 transition-colors">
|
||
{episode.name || episode.title || 'Unnamed episode'}
|
||
</h3>
|
||
|
||
{/* 元数据 */}
|
||
<div className="flex items-center justify-between text-xs text-white/40">
|
||
<div className="flex items-center gap-2">
|
||
<Calendar className="w-3 h-3" />
|
||
<span>{new Date(episode.created_at).toLocaleDateString()}</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
|
||
<div className="flex items-center gap-1">
|
||
<Clock className="w-3 h-3" />
|
||
<span>{new Date(episode.updated_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||
<div className="flex gap-2">
|
||
<button className="w-8 h-8 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white/30 transition-colors">
|
||
<Share2 className="w-4 h-4 text-white" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div className="container mx-auto h-[calc(100vh-6rem)] flex flex-col absolute top-[4rem] left-0 right-0 px-[1rem]">
|
||
{/* 优化后的主要内容区域 */}
|
||
<div className="flex-1 min-h-0">
|
||
<div
|
||
ref={scrollContainerRef}
|
||
className="h-full overflow-y-auto custom-scrollbar"
|
||
style={{
|
||
scrollbarWidth: 'thin',
|
||
scrollbarColor: 'rgba(255,255,255,0.1) transparent'
|
||
}}
|
||
>
|
||
{isLoading && episodeList.length === 0 ? (
|
||
/* 优化的加载状态 */
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6 pb-6">
|
||
{[...Array(10)].map((_, index) => (
|
||
<div
|
||
key={index}
|
||
className="bg-white/[0.05] backdrop-blur-[20px] border border-white/[0.08] rounded-2xl overflow-hidden animate-pulse"
|
||
>
|
||
<div className="h-[200px] bg-gradient-to-br from-white/[0.08] to-white/[0.04]">
|
||
<div className="h-full bg-white/[0.06] animate-pulse"></div>
|
||
</div>
|
||
<div className="p-5">
|
||
<div className="h-4 bg-white/[0.08] rounded-lg mb-3 animate-pulse"></div>
|
||
<div className="h-3 bg-white/[0.06] rounded-lg mb-4 w-3/4 animate-pulse"></div>
|
||
<div className="flex justify-between">
|
||
<div className="h-3 bg-white/[0.06] rounded-lg w-20 animate-pulse"></div>
|
||
<div className="h-3 bg-white/[0.06] rounded-lg w-16 animate-pulse"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : episodeList.length > 0 ? (
|
||
/* 优化的剧集网格 */
|
||
<div className="pb-8">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
|
||
{episodeList.map(renderEpisodeCard)}
|
||
</div>
|
||
|
||
{/* 加载更多指示器 */}
|
||
{isLoadingMore && (
|
||
<div className="flex justify-center py-12">
|
||
<div className="flex items-center gap-3 px-6 py-3 bg-white/[0.05] backdrop-blur-[20px] border border-white/[0.08] rounded-xl">
|
||
<Loader2 className="w-5 h-5 animate-spin text-blue-400" />
|
||
<span className="text-white/70 font-medium">Loading more episodes...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 到底提示 */}
|
||
{!hasMore && episodeList.length > 0 && (
|
||
<div className="flex justify-center py-12">
|
||
<div className="text-center">
|
||
<div className="w-12 h-12 bg-white/[0.05] backdrop-blur-[20px] border border-white/[0.08] rounded-xl flex items-center justify-center mx-auto mb-3">
|
||
<Check className="w-6 h-6 text-green-400" />
|
||
</div>
|
||
<p className="text-white/50 text-sm">All episodes loaded</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<></>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 创建工具栏 */}
|
||
<div className='video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]'>
|
||
<div className='video-storyboard-tools grid gap-4 rounded-[20px] bg-white/[0.08] backdrop-blur-[20px] border border-white/[0.12] shadow-[0_8px_32px_rgba(0,0,0,0.3)]'>
|
||
{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 action</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]'>
|
||
{isUploading ? (
|
||
<Loader2 className='w-4 h-4 animate-spin' />
|
||
) : (
|
||
<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]'>
|
||
{isUploading ? '上传中...' : '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}
|
||
onCompositionStart={handleCompositionStart}
|
||
onCompositionEnd={handleCompositionEnd}
|
||
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 action. 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 ${selectedMode === ModeEnum.AUTOMATIC ? 'hidden' : ''}`} />
|
||
</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 ${selectedResolution === ResolutionEnum.HD_720P ? 'hidden' : ''}`} />
|
||
</div>
|
||
</Dropdown>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className='flex items-center gap-3'>
|
||
<div className={`tool-submit-button ${videoUrl || script ? '' : 'disabled'} ${isCreating ? 'loading' : ''}`} onClick={isCreating ? undefined : handleCreateVideo}>
|
||
{isCreating ? (
|
||
<>
|
||
<Loader2 className='w-4 h-4 animate-spin' />
|
||
Actioning...
|
||
</>
|
||
) : (
|
||
<>
|
||
<ArrowUp className='w-4 h-4' />
|
||
Action
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{episodeList.length === 0 && !isLoading && (
|
||
<div className='h-full flex flex-col items-center fixed top-[4rem] left-0 right-0 bottom-0'>
|
||
<EmptyStateAnimation className='' />
|
||
</div>
|
||
)}
|
||
|
||
{isScriptEditDialogOpen && (
|
||
<ScriptEditDialog
|
||
isOpen={isScriptEditDialogOpen}
|
||
onClose={() => setIsScriptEditDialogOpen(false)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
} |