第三版ing

This commit is contained in:
北枳 2025-06-26 20:12:55 +08:00
parent bb65acfe5e
commit d9ed81ca3b
43 changed files with 5686 additions and 1565 deletions

View File

@ -1,7 +1,7 @@
// import { redirect } from 'next/navigation'; // import { redirect } from 'next/navigation';
import { CreateToVideo } from '@/components/pages/create-to-video'; import { CreateToVideo2 } from '@/components/pages/create-to-video2';
export default function CreatePage() { export default function CreatePage() {
// redirect('/create/video-to-video'); // redirect('/create/video-to-video');
return <CreateToVideo />; return <CreateToVideo2 />;
} }

View File

@ -0,0 +1,5 @@
import WorkFlow from '@/components/pages/work-flow';
export default function ScriptWorkFlowPage() {
return <WorkFlow />;
}

View File

@ -6,6 +6,10 @@
--foreground-rgb: 0, 0, 0; --foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220; --background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255; --background-end-rgb: 255, 255, 255;
--ui-hoverable: #a5b4c7;
--text-secondary: #a0aec0;
--text-primary: #fff;
--ui-level1-layerbase: #131416;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -18,35 +22,35 @@
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 0 0% 0%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 0 0% 100%; --card: 0 0% 3.9%;
--card-foreground: 0 0% 3.9%; --card-foreground: 0 0% 98%;
--popover: 0 0% 100%; --popover: 0 0% 3.9%;
--popover-foreground: 0 0% 3.9%; --popover-foreground: 0 0% 98%;
--primary: 0 0% 98%; --primary: 0 0% 98%;
--primary-foreground: 0 0% 98%; --primary-foreground: 0 0% 9%;
--secondary: 0 0% 96.1%; --secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 9%; --secondary-foreground: 0 0% 98%;
--muted: 0 0% 96.1%; --muted: 0 0% 14.9%;
--muted-foreground: 0 0% 45.1%; --muted-foreground: 0 0% 63.9%;
--accent: 0 0% 96.1%; --accent: 0 0% 14.9%;
--accent-foreground: 0 0% 9%; --accent-foreground: 0 0% 98%;
--destructive: 0 84.2% 60.2%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%; --border: 0 0% 14.9%;
--input: 0 0% 89.8%; --input: 0 0% 14.9%;
--ring: 0 0% 3.9%; --ring: 0 0% 83.1%;
--chart-1: 12 76% 61%; --chart-1: 220 70% 50%;
--chart-2: 173 58% 39%; --chart-2: 160 60% 45%;
--chart-3: 197 37% 24%; --chart-3: 30 80% 55%;
--chart-4: 43 74% 66%; --chart-4: 280 65% 60%;
--chart-5: 27 87% 67%; --chart-5: 340 75% 55%;
--radius: 0.5rem; --radius: 0.5rem;
} }
.dark { .light {
--background: 0 0% 3.9%; --background: 0 0% 0%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 0 0% 3.9%; --card: 0 0% 3.9%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
@ -85,7 +89,11 @@ body {
radial-gradient(circle at 93.3% 75%, radial-gradient(circle at 93.3% 75%,
rgba(0, 0, 255, 0.4), rgba(0, 0, 255, 0.4),
rgba(0, 0, 255, 0) 70.71%) beige !important; */ rgba(0, 0, 255, 0) 70.71%) beige !important; */
background: #6370b0 !important; background: #000000 !important;
/* background-image: url('https://picsum.photos/1200/1200');
background-size: cover;
background-position: center;
background-repeat: no-repeat; */
} }
.hide-scrollbar::-webkit-scrollbar { .hide-scrollbar::-webkit-scrollbar {
@ -101,3 +109,19 @@ body {
@apply text-foreground; @apply text-foreground;
} }
} }
.button-NxtqWZ {
color: var(--ui-hoverable) !important;
cursor: pointer;
background-color: #0000;
border: none;
border-radius: 8px;
padding: 8px;
}
.button-NxtqWZ:hover {
background-color: #2f3237 !important;
}
.bg-muted {
width: 100%;
}

View File

@ -21,7 +21,7 @@ export default function RootLayout({
<body className={inter.className}> <body className={inter.className}>
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="light" defaultTheme="dark"
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >

View File

@ -0,0 +1,142 @@
import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Sparkles, Send, X, Lightbulb } from 'lucide-react';
interface AISuggestionBarProps {
suggestions: string[];
onSuggestionClick: (suggestion: string) => void;
onSubmit: (text: string) => void;
placeholder?: string;
}
export function AISuggestionBar({
suggestions,
onSuggestionClick,
onSubmit,
placeholder = "输入你的想法,或点击预设词条获取 AI 建议..."
}: AISuggestionBarProps) {
const [inputText, setInputText] = useState('');
const [isFocused, setIsFocused] = useState(false);
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
// 自动调整输入框高度
useEffect(() => {
if (inputRef.current) {
inputRef.current.style.height = 'auto';
inputRef.current.style.height = `${inputRef.current.scrollHeight}px`;
}
}, [inputText]);
// 处理提交
const handleSubmit = () => {
if (inputText.trim()) {
onSubmit(inputText.trim());
setInputText('');
if (inputRef.current) {
inputRef.current.style.height = 'auto';
}
}
};
// 处理回车提交
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="fixed bottom-0 left-0 right-0 z-50 bg-gradient-to-t from-[#0C0E11] via-[#0C0E11] to-transparent pb-8"
>
<div className="max-w-5xl mx-auto px-6">
{/* 智能预设词条 */}
<AnimatePresence>
{showSuggestions && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="mb-4 overflow-hidden bg-black/30"
>
<div className="flex items-center gap-3 mb-3">
<Lightbulb className="w-4 h-4 text-yellow-500" />
<span className="text-sm text-white/60"></span>
</div>
<div className="flex flex-wrap gap-2">
{suggestions.map((suggestion, index) => (
<motion.button
key={suggestion}
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: 1,
scale: 1,
transition: { delay: index * 0.1 }
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="px-3 py-1.5 rounded-full bg-white/5 hover:bg-white/10 backdrop-blur-sm
text-sm text-white/70 hover:text-white transition-colors flex items-center gap-2"
onClick={() => onSuggestionClick(suggestion)}
>
<Sparkles className="w-3 h-3" />
{suggestion}
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* 输入区域 */}
<div className={`
relative rounded-xl bg-white/5 backdrop-blur-sm transition-all duration-300
${isFocused ? 'ring-2 ring-blue-500/50 bg-white/10' : 'hover:bg-white/[0.07]'}
`}>
<textarea
ref={inputRef}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
setIsFocused(true);
setShowSuggestions(true);
}}
onBlur={() => { setIsFocused(false); setShowSuggestions(false) }}
placeholder={placeholder}
className="w-full resize-none bg-transparent border-none px-4 py-3 text-white placeholder:text-white/40
focus:outline-none min-h-[52px] max-h-[150px] pr-[100px]"
rows={1}
/>
{/* 操作按钮 */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
<button
className={`
p-2 rounded-lg transition-colors
${showSuggestions ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white/60'}
`}
onClick={() => setShowSuggestions(!showSuggestions)}
>
{showSuggestions ? <X className="w-5 h-5" /> : <Sparkles className="w-5 h-5" />}
</button>
<button
className={`
p-2 rounded-lg transition-colors
${inputText.trim() ? 'bg-blue-500 text-white' : 'bg-white/5 text-white/20'}
`}
onClick={handleSubmit}
disabled={!inputText.trim()}
>
<Send className="w-5 h-5" />
</button>
</div>
</div>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,224 @@
import { useState, useRef, useEffect } from 'react';
import { motion, useAnimation, useMotionValue, useTransform, useSpring } from 'framer-motion';
import { ChevronLeft, ChevronRight, Upload, BookOpen, Brain, Users, Film, Flag } from 'lucide-react';
// 定义步骤数据结构
interface Step {
id: string;
icon: JSX.Element;
title: string;
subtitle: string;
description: string;
}
// 步骤配置
const STEPS: Step[] = [
{
id: 'overview',
icon: <BookOpen className="w-6 h-6" />,
title: '剧本大纲',
subtitle: 'Script Overview',
description: '提取剧本结构和关键要素'
},
{
id: 'storyboard',
icon: <Brain className="w-6 h-6" />,
title: '分镜草图',
subtitle: 'Storyboard',
description: '可视化场景设计和转场'
},
{
id: 'character',
icon: <Users className="w-6 h-6" />,
title: '演员角色',
subtitle: 'Character Design',
description: '定制角色形象和个性'
},
{
id: 'post',
icon: <Film className="w-6 h-6" />,
title: '后期制作',
subtitle: 'Post Production',
description: '音效配乐和特效处理'
},
{
id: 'output',
icon: <Flag className="w-6 h-6" />,
title: '最终成品',
subtitle: 'Final Output',
description: '预览和导出作品'
}
];
interface FilmstripStepperProps {
currentStep: string;
onStepChange: (stepId: string) => void;
}
export function FilmstripStepper({ currentStep, onStepChange }: FilmstripStepperProps) {
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const controls = useAnimation();
// 滚动位置状态
const x = useMotionValue(0);
const springX = useSpring(x, { stiffness: 300, damping: 30 });
// 处理滚动边界
useEffect(() => {
if (!containerRef.current || !scrollRef.current) return;
const container = containerRef.current;
const scroll = scrollRef.current;
const maxScroll = -(scroll.scrollWidth - container.clientWidth);
x.set(Math.max(Math.min(x.get(), 0), maxScroll));
}, [x]);
// 处理拖拽结束
const handleDragEnd = () => {
setIsDragging(false);
if (!containerRef.current || !scrollRef.current) return;
const container = containerRef.current;
const scroll = scrollRef.current;
const maxScroll = -(scroll.scrollWidth - container.clientWidth);
// 确保不会过度滚动
if (x.get() > 0) {
controls.start({ x: 0 });
} else if (x.get() < maxScroll) {
controls.start({ x: maxScroll });
}
};
// 滚动到指定步骤
const scrollToStep = (stepId: string) => {
if (!containerRef.current || !scrollRef.current) return;
const stepElement = document.getElementById(`step-${stepId}`);
if (!stepElement) return;
const container = containerRef.current;
const stepLeft = stepElement.offsetLeft;
const stepWidth = stepElement.offsetWidth;
const containerWidth = container.clientWidth;
const targetX = -(stepLeft - (containerWidth - stepWidth) / 2);
controls.start({ x: targetX });
};
return (
<div className="relative w-full">
{/* 滚动箭头 */}
<button
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 backdrop-blur-sm flex items-center justify-center transition-colors"
onClick={() => controls.start({ x: x.get() + 300 })}
>
<ChevronLeft className="w-6 h-6" />
</button>
<button
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 backdrop-blur-sm flex items-center justify-center transition-colors"
onClick={() => controls.start({ x: x.get() - 300 })}
>
<ChevronRight className="w-6 h-6" />
</button>
{/* 胶片容器 */}
<div
ref={containerRef}
className="w-full overflow-hidden px-20"
style={{ perspective: '1000px' }}
>
<motion.div
ref={scrollRef}
drag="x"
dragConstraints={containerRef}
dragElastic={0.1}
onDragStart={() => setIsDragging(true)}
onDragEnd={handleDragEnd}
animate={controls}
style={{ x: springX }}
className="flex gap-6 px-4 py-8"
>
{STEPS.map((step, index) => {
const isActive = currentStep === step.id;
return (
<motion.div
key={step.id}
id={`step-${step.id}`}
className={`
relative flex-shrink-0 w-64 h-40 rounded-lg cursor-pointer
${isActive ? 'z-10' : 'opacity-70'}
`}
whileHover={{ scale: 1.05, y: -5 }}
whileTap={{ scale: 0.95 }}
animate={isActive ? {
scale: 1.1,
y: -10,
transition: { type: 'spring', stiffness: 300, damping: 25 }
} : {
scale: 1,
y: 0
}}
onClick={() => {
if (!isDragging) {
onStepChange(step.id);
scrollToStep(step.id);
}
}}
>
{/* 胶片打孔效果 */}
<div className="absolute -left-2 top-1/2 -translate-y-1/2 space-y-4">
<div className="w-4 h-4 rounded-full bg-black/20 backdrop-blur-sm border border-white/10" />
<div className="w-4 h-4 rounded-full bg-black/20 backdrop-blur-sm border border-white/10" />
</div>
<div className="absolute -right-2 top-1/2 -translate-y-1/2 space-y-4">
<div className="w-4 h-4 rounded-full bg-black/20 backdrop-blur-sm border border-white/10" />
<div className="w-4 h-4 rounded-full bg-black/20 backdrop-blur-sm border border-white/10" />
</div>
{/* 卡片内容 */}
<div
className={`
relative w-full h-full rounded-lg p-4 overflow-hidden
bg-gradient-to-br from-white/10 to-white/5
${isActive ? 'ring-2 ring-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.5)]' : ''}
`}
>
<div className="absolute top-0 left-0 w-full h-full bg-black/20 backdrop-blur-[2px]" />
<div className="relative z-10 flex flex-col h-full">
<div className="flex items-center gap-3 mb-2">
<div className={`
p-2 rounded-lg
${isActive ? 'bg-blue-500/20 text-blue-400' : 'bg-white/10 text-white/60'}
`}>
{step.icon}
</div>
<div>
<h3 className="text-sm font-medium">{step.title}</h3>
<p className="text-xs text-white/50">{step.subtitle}</p>
</div>
</div>
<p className="text-sm text-white/70">{step.description}</p>
</div>
</div>
{/* 步骤序号 */}
<div className={`
absolute -top-3 -right-3 w-8 h-8 rounded-full
flex items-center justify-center text-sm font-medium
${isActive ? 'bg-blue-500 text-white' : 'bg-white/10 text-white/60'}
`}>
{index + 1}
</div>
</motion.div>
);
})}
</motion.div>
</div>
</div>
);
}

View File

@ -12,19 +12,14 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(true); const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
return ( return (
<div className="min-h-screen"> <div className="min-h-screen bg-background">
{/* <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} /> */} <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />
{/* <div className={`transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-64'}`}> */} <div className="w-full">
{/* <div className={`transition-all duration-300`}> <TopBar collapsed={sidebarCollapsed} onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} />
<TopBar collapsed={sidebarCollapsed} /> <main className="mt-16">
<main className="p-6 mt-16">
{children}
</main>
</div> */}
<TopBar collapsed={sidebarCollapsed} />
<main className="mt-16 h-[calc(100vh-4rem)] overflow-hidden">
{children} {children}
</main> </main>
</div> </div>
</div>
); );
} }

View File

@ -16,6 +16,9 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Video, Video,
PanelsLeftBottom,
ArrowLeftToLine,
X
} from 'lucide-react'; } from 'lucide-react';
interface SidebarProps { interface SidebarProps {
@ -30,67 +33,53 @@ const navigationItems = [
{ name: 'Home', href: '/', icon: Home }, { name: 'Home', href: '/', icon: Home },
{ name: 'Media Library', href: '/media', icon: FolderOpen }, { name: 'Media Library', href: '/media', icon: FolderOpen },
{ name: 'Actors Library', href: '/actors', icon: Users }, { name: 'Actors Library', href: '/actors', icon: Users },
],
},
{
title: 'Plugins',
items: [
{ name: 'Text to Clip', href: '/plugins/text-to-clip', icon: Type },
{ name: 'Text to Image', href: '/plugins/text-to-image', icon: Image },
],
},
{
title: 'History',
items: [
{ name: 'Task History', href: '/history', icon: History }, { name: 'Task History', href: '/history', icon: History },
], ],
}, }
]; ];
export function Sidebar({ collapsed, onToggle }: SidebarProps) { export function Sidebar({ collapsed, onToggle }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<>
{/* Backdrop */}
{!collapsed && (
<div
className="fixed inset-0 bg-[#000000bf] z-40"
onClick={() => onToggle(true)}
/>
)}
{/* Sidebar */}
<div <div
className={cn( className={cn(
'fixed left-0 top-0 z-50 h-full transition-all duration-300', 'fixed left-0 top-0 z-50 h-full w-64 bg-[#131416] transition-transform duration-300',
collapsed ? 'w-16' : 'w-64' collapsed ? '-translate-x-full' : 'translate-x-0'
)} )}
> >
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Logo */} <div className="flex h-16 items-center justify-between px-4">
<div className="flex h-16 items-center justify-between px-4 border-border">
{!collapsed && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Video className="h-8 w-8 text-primary" /> <Video className="h-8 w-8 text-primary" />
<span className="text-xl font-bold bg-gradient-to-r from-primary to-blue-600 bg-clip-text text-transparent"> <span className="text-xl font-bold text-primary">
Movie Flow Movie Flow
</span> </span>
</div> </div>
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onToggle(!collapsed)} onClick={() => onToggle(true)}
className="h-8 w-8 p-0" className="button-NxtqWZ"
> >
{collapsed ? ( <ArrowLeftToLine className="h-4 w-4" />
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button> </Button>
</div> </div>
{/* Navigation */} {/* Navigation */}
<div className="flex-1 overflow-y-auto py-4"> <div className="flex-1 overflow-y-auto">
{navigationItems.map((section, index) => ( {navigationItems.map((section, index) => (
<div key={section.title} className="px-3 py-2"> <div key={section.title}>
{!collapsed && (
<h2 className="mb-2 px-4 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{section.title}
</h2>
)}
<div className="space-y-1"> <div className="space-y-1">
{section.items.map((item) => { {section.items.map((item) => {
const isActive = pathname === item.href; const isActive = pathname === item.href;
@ -99,25 +88,22 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
<Button <Button
variant={isActive ? 'secondary' : 'ghost'} variant={isActive ? 'secondary' : 'ghost'}
className={cn( className={cn(
'w-full justify-start', 'w-full justify-start px-4',
collapsed ? 'px-2' : 'px-4',
isActive && 'bg-primary/10 text-primary hover:bg-primary/20' isActive && 'bg-primary/10 text-primary hover:bg-primary/20'
)} )}
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
{!collapsed && <span className="ml-2">{item.name}</span>} <span className="ml-2">{item.name}</span>
</Button> </Button>
</Link> </Link>
); );
})} })}
</div> </div>
{index < navigationItems.length - 1 && (
<Separator className="mt-4" />
)}
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }

View File

@ -16,19 +16,24 @@ import {
Settings, Settings,
LogOut, LogOut,
Bell, Bell,
PanelsLeftBottom
} from 'lucide-react'; } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
export function TopBar({ collapsed }: { collapsed: boolean }) { export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onToggleSidebar: () => void }) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const router = useRouter(); const router = useRouter();
return ( return (
// <div className={`fixed right-0 top-0 transition-all duration-300 h-16 border-border backdrop-blur ${collapsed ? 'left-16' : 'left-64'}`}> <div className="fixed right-0 top-0 left-0 h-16">
<div className={`fixed right-0 top-0 transition-all duration-300 h-16 border-border backdrop-blur left-0`}> <div className="h-full flex items-center justify-between pr-6 pl-2">
<div className="h-full flex items-center justify-between px-6"> <div className="flex items-center space-x-4">
<Button className='button-NxtqWZ' variant="ghost" size="sm" onClick={onToggleSidebar}>
<PanelsLeftBottom className="h-4 w-4" />
</Button>
<div className={`flex items-center space-x-4 cursor-pointer`} onClick={() => router.push('/')}> <div className={`flex items-center space-x-4 cursor-pointer`} onClick={() => router.push('/')}>
<h1 className={`text-2xl font-bold bg-gradient-to-r from-primary to-blue-600 bg-clip-text text-transparent ${collapsed ? '' : 'hidden'}`}>Movie Flow</h1> <h1 className="text-2xl font-bold bg-gradient-to-r from-primary to-white bg-clip-text text-transparent">Movie Flow</h1>
</div>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">

View File

@ -0,0 +1,373 @@
"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 } 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';
// 添加自定义滚动条样式
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 [isExpanded, setIsExpanded] = useState(false);
const [videoUrl, setVideoUrl] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useState('script');
const [isFocus, setIsFocus] = useState(false);
const [selectedMode, setSelectedMode] = useState('auto');
const [selectedResolution, setSelectedResolution] = useState('720P');
const [inputText, setInputText] = useState('');
const editorRef = useRef<HTMLDivElement>(null);
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 handleCreateVideo = async () => {
if (videoUrl || inputText) {
if (activeTab === 'script') {
router.push('/create/work-flow');
}
}
}
// 下拉菜单项配置
const modeItems: MenuProps['items'] = [
{
type: 'group',
label: (
<div className="text-white/50 text-xs px-2 pb-2">Mode</div>
),
children: [
{
key: 'auto',
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: 'stable',
label: (
<div className="flex flex-col gap-1 p-1">
<div className="flex items-center gap-2">
<span className="text-base font-medium">Stable</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>
),
},
{
key: 'inspire',
label: (
<div className="flex flex-col gap-1 p-1">
<div className="flex items-center gap-2">
<span className="text-base font-medium">Inspire</span>
<Crown className="w-4 h-4 text-yellow-500" />
</div>
<span className="text-sm text-gray-400">Unleash your creativity with diverse outputs</span>
</div>
),
},
],
},
];
// 分辨率选项配置
const resolutionItems: MenuProps['items'] = [
{
type: 'group',
label: (
<div className="text-white/50 text-xs px-2 pb-2">Resolution</div>
),
children: [
{
key: '720P',
label: (
<div className="flex items-center justify-between p-1">
<span className="text-base">720P</span>
</div>
),
},
{
key: '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: '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: '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);
};
// 处理分辨率选择
const handleResolutionSelect: MenuProps['onClick'] = ({ key }) => {
setSelectedResolution(key as string);
};
const handleStartCreating = () => {
setActiveTab('script');
setInputText(ideaText);
}
// 处理编辑器聚焦
const handleEditorFocus = () => {
setIsFocus(true);
if (editorRef.current && inputText) {
// 创建范围对象
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(inputText));
// 设置范围到文本末尾
range.setStart(textNode, inputText.length);
range.setEnd(textNode, inputText.length);
// 应用选择
selection?.removeAllRanges();
selection?.addRange(range);
}
};
// 处理编辑器内容变化
const handleEditorChange = (e: React.FormEvent<HTMLDivElement>) => {
const newText = e.currentTarget.textContent || '';
setInputText(newText);
};
return (
<div
ref={containerRef}
className="container mx-auto overflow-hidden custom-scrollbar"
style={isExpanded ? {height: 'calc(100vh - 12rem)'} : {height: 'calc(100vh - 20rem)'}}
>
<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-sm opacity-60'></span>
</div>
<div className={`tab-item ${activeTab === 'clone' ? 'active' : ''} cursor-pointer`} onClick={() => setActiveTab('clone')}>
<span className='text-sm opacity-60'></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
>
{inputText}
</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] ${inputText ? '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],
}}
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.charAt(0).toUpperCase() + selectedMode.slice(1)}
</span>
<Crown className='w-4 h-4 text-yellow-500' />
</div>
</Dropdown>
<Dropdown
menu={{
items: resolutionItems,
onClick: handleResolutionSelect,
selectedKeys: [selectedResolution],
}}
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}</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 || inputText ? '' : 'disabled'}`} onClick={handleCreateVideo}>
<ArrowUp className='w-4 h-4' />Create
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,24 +1,66 @@
"use client"; "use client";
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { ArrowRight, Plus, StretchHorizontal, Table } from "lucide-react"; import { ArrowRight, Plus, Table, AlignHorizontalSpaceAround } from "lucide-react";
import "./style/home-page2.css"; import "./style/home-page2.css";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { VideoScreenLayout } from '@/components/video-screen-layout';
import { VideoGridLayout } from '@/components/video-grid-layout';
import LiquidGlass from '@/plugins/liquid-glass';
export function HomePage2() { export function HomePage2() {
const router = useRouter(); const router = useRouter();
const [activeTool, setActiveTool] = useState("stretch"); const [activeTool, setActiveTool] = useState("stretch");
const containerRef = useRef<HTMLDivElement>(null);
// 示例视频数据
const videos = [
{
id: '1',
url: 'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4',
title: '视频标题 1'
},
{
id: '2',
url: 'https://cdn.qikongjian.com/videos/1750389908_37d4fffa-8516-43a3-a423-fc0274f40e8a_text_to_video_0.mp4',
title: '视频标题 2'
},
{
id: '3',
url: 'https://cdn.qikongjian.com/videos/1750384661_d8e30b79-828e-48cd-9025-ab62a996717c_text_to_video_0.mp4',
title: '视频标题 3'
},
{
id: '4',
url: 'https://cdn.qikongjian.com/videos/1750320040_4b47996e-7c70-490e-8433-80c7df990fdd_text_to_video_0.mp4',
title: '视频标题 4'
},
];
// 处理编辑视频
const handleEdit = (id: string) => {
console.log('Edit video:', id);
// TODO: 实现编辑功能
};
// 处理删除视频
const handleDelete = (id: string) => {
console.log('Delete video:', id);
// TODO: 实现删除功能
};
return ( return (
<div className="min-h-screen bg-[var(--background)]" ref={containerRef}>
<div className="min-h-[100%] flex relative"> <div className="min-h-[100%] flex relative">
{/* 工具栏-列表形式切换 */} {/* 工具栏-列表形式切换 */}
<div className="absolute top-0 right-6 w-[8rem] flex z-index-2 justify-end"> <div className="absolute top-[1rem] right-6 w-[8rem] flex z-[100] justify-end">
<div role="group" className="flex p-1 bg-white/20 backdrop-blur-[15px] w-full rounded-[3rem]"> <div role="group" className="flex p-1 bg-white/20 backdrop-blur-[15px] w-full rounded-[3rem]">
<button <button
className={`flex items-center justify-center h-10 transition-all duration-300 w-1/2 rounded-[3rem] className={`flex items-center justify-center h-10 transition-all duration-300 w-1/2 rounded-[3rem]
${activeTool === "stretch" ? "bg-white/20 text-white" : "hover:bg-white/10 text-white/30"}`} ${activeTool === "stretch" ? "bg-white/20 text-white" : "hover:bg-white/10 text-white/30"}`}
onClick={() => setActiveTool("stretch")} onClick={() => setActiveTool("stretch")}
> >
<StretchHorizontal className="w-5 h-5" /> <AlignHorizontalSpaceAround className="w-5 h-5" />
</button> </button>
<button <button
className={`flex items-center justify-center h-10 transition-all duration-300 w-1/2 rounded-[3rem] className={`flex items-center justify-center h-10 transition-all duration-300 w-1/2 rounded-[3rem]
@ -30,12 +72,56 @@ export function HomePage2() {
</div> </div>
</div> </div>
<div className="fixed bottom-[3.5rem] left-[50%] translate-x-[-50%] bg-white/10 backdrop-blur-lg rounded-[32px]"> {/* 屏风式视频布局 */}
<button className="add-project-btn" onClick={() => router.push("/create")}> <div
{/* 添加图标 */} className={`absolute top-[4rem] w-full transition-all duration-500
${activeTool === "stretch" ? "opacity-100 translate-x-0" : "opacity-0 translate-x-[-100%] pointer-events-none"}
`}
>
<VideoScreenLayout videos={videos} />
</div>
{/* 网格式视频布局 */}
<div
className={`absolute top-[4rem] w-full transition-all duration-500 max-h-[calc(100vh-8rem)] overflow-y-auto hide-scrollbar
${activeTool === "table" ? "opacity-100 translate-x-0" : "opacity-0 translate-x-[100%] pointer-events-none"}
`}
>
<VideoGridLayout
videos={videos}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
{/* Create Project Button */}
<div className="fixed bottom-[10rem] left-[50%]">
<LiquidGlass
displacementScale={58}
blurAmount={0.5}
saturation={130}
aberrationIntensity={2}
elasticity={0.35}
cornerRadius={32}
mouseContainer={containerRef}
overLight={false}
mode="standard"
padding="unset"
onClick={() => {
console.log("Create Project")
router.push("/create")
}}
style={{
position: "absolute"
}}
>
<div className="add-project-btn">
<Plus className="w-6 h-6 icon" /> <Plus className="w-6 h-6 icon" />
<div className="btn-text">Create Project</div> <div className="btn-text">Create Project</div>
</button> </div>
</LiquidGlass>
</div>
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,243 @@
"use client";
import { useState } from 'react';
import { motion } from 'framer-motion';
import { DragDropContext, DropResult } from 'react-beautiful-dnd';
import { ScriptMetaInfo } from '../script-overview/script-meta-info';
import { SceneFilmstrip } from '../script-overview/scene-filmstrip';
import { SceneCardList } from '../script-overview/scene-card-list';
import { FloatingToolbar } from '../script-overview/floating-toolbar';
// 场景数据结构
export interface Scene {
id: string;
name: string;
description: string;
plot: string;
dialogue: string;
narration: string;
imageUrl: string;
}
// 剧本元信息数据结构
export interface ScriptMeta {
title: string;
type: string;
genre: string;
tags: string[];
duration: string;
uploadTime: string;
}
export default function ScriptOverview() {
// Example Data
const [scriptMeta] = useState<ScriptMeta>({
title: "Lost Stars",
type: "Short Drama",
genre: "Sci-Fi/Mystery",
tags: ["Space", "Psychology", "Future"],
duration: "20-25 minutes",
uploadTime: "2024-03-20"
});
const [scenes, setScenes] = useState<Scene[]>([
{
id: "scene-1",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-2",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-3",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-5",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-6",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-7",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-8",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-9",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-10",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-11",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-12",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-13",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-14",
name: "Space Station Interior",
description: "Main control room of the International Space Station",
plot: "Protagonist discovers an anomalous signal",
dialogue: "Captain: This signal... it's not from Earth",
narration: "In the darkness of space, a faint light blinks",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
}
// ... 更多场景数据
]);
const [selectedSceneId, setSelectedSceneId] = useState<string>();
// Handle scene drag and drop sorting
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(scenes);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setScenes(items);
};
// Handle scene updates
const handleSceneUpdate = (sceneId: string, updates: Partial<Scene>) => {
setScenes(scenes.map(scene =>
scene.id === sceneId ? { ...scene, ...updates } : scene
));
};
// Handle scene deletion
const handleSceneDelete = (sceneId: string) => {
setScenes(scenes.filter(scene => scene.id !== sceneId));
if (selectedSceneId === sceneId) {
setSelectedSceneId(undefined);
}
};
// Handle scene duplication
const handleSceneDuplicate = (sceneId: string) => {
const sceneToDuplicate = scenes.find(scene => scene.id === sceneId);
if (!sceneToDuplicate) return;
const newScene = {
...sceneToDuplicate,
id: `scene-${Date.now()}`,
name: `${sceneToDuplicate.name} (Copy)`
};
setScenes([...scenes, newScene]);
};
return (
<div className="h-full bg-[#0C0E11] text-white">
<div className="h-full flex">
{/* Left: Script Meta Info */}
<div className="flex-shrink-0 h-full">
<ScriptMetaInfo meta={scriptMeta} />
</div>
{/* Right: Scene Content */}
<div className="flex-grow min-w-0 h-full overflow-hidden flex flex-col">
{/* Scene Card List */}
<div className="flex-grow overflow-y-auto px-8">
<DragDropContext onDragEnd={handleDragEnd}>
<SceneCardList
scenes={scenes}
selectedSceneId={selectedSceneId}
onSceneUpdate={handleSceneUpdate}
onSceneDelete={handleSceneDelete}
onSceneDuplicate={handleSceneDuplicate}
/>
</DragDropContext>
</div>
{/* Filmstrip Preview */}
<div className="flex-shrink-0 py-6 px-8">
<SceneFilmstrip
scenes={scenes}
selectedSceneId={selectedSceneId}
onSceneSelect={(sceneId: string) => {
setSelectedSceneId(sceneId);
}}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,197 @@
"use client";
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { FilmstripStepper } from '@/components/filmstrip-stepper';
import { AISuggestionBar } from '@/components/ai-suggestion-bar';
import ScriptOverview from '@/components/pages/script-overview';
import StoryboardView from '@/components/pages/storyboard-view';
// 定义工作流程阶段
const WORKFLOW_STAGES = [
{
id: 'overview',
title: 'Script Overview',
subtitle: 'Script Overview',
description: 'Extract script structure and key elements'
},
{
id: 'storyboard',
title: 'Storyboard',
subtitle: 'Storyboard',
description: 'Visualize scene design and transitions'
},
{
id: 'character',
title: 'Character Design',
subtitle: 'Character Design',
description: 'Customize character appearance and personality'
},
{
id: 'post',
title: 'Post Production',
subtitle: 'Post Production',
description: 'Sound effects, music and special effects'
},
{
id: 'output',
title: 'Final Output',
subtitle: 'Final Output',
description: 'Preview and export works'
}
];
export default function ScriptWorkFlow() {
const router = useRouter();
const [currentStep, setCurrentStep] = useState('overview');
const [loading, setLoading] = useState(true);
// 根据当前步骤获取智能预设词条
const getSmartSuggestions = (stepId: string): string[] => {
const suggestions = {
overview: [
"Analyze core themes and emotions",
"Extract character relationship map",
"Generate scene and plot outline",
"Identify key turning points",
"Optimize story structure and pacing"
],
storyboard: [
"Design opening shot sequence",
"Plan transitions and visual effects",
"Generate key scene storyboards",
"Optimize shot language and rhythm",
"Add camera movement notes"
],
character: [
"Design protagonist appearance",
"Generate supporting character references",
"Create character relationship map",
"Add costume and prop designs",
"Optimize character actions"
],
post: [
"Plan sound and music style",
"Design visual effects solution",
"Add subtitles and graphics",
"Optimize color and lighting",
"Plan post-production workflow"
],
output: [
"Generate preview version",
"Optimize output parameters",
"Add opening and ending design",
"Export different formats",
"Create release plan"
]
};
return suggestions[stepId as keyof typeof suggestions] || [];
};
useEffect(() => {
// 模拟加载效果
const timer = setTimeout(() => {
setLoading(false);
}, 1500);
return () => clearTimeout(timer);
}, []);
// 处理步骤切换
const handleStepChange = async (stepId: string) => {
setLoading(true);
setCurrentStep(stepId);
// 模拟加载效果
await new Promise(resolve => setTimeout(resolve, 800));
setLoading(false);
};
// 处理 AI 建议点击
const handleSuggestionClick = (suggestion: string) => {
console.log('选择了建议:', suggestion);
// TODO: 处理建议点击逻辑
};
// 处理输入提交
const handleSubmit = (text: string) => {
console.log('提交了文本:', text);
// TODO: 处理文本提交逻辑
};
return (
<div className="h-full bg-[#0C0E11] text-white overflow-hidden">
{/* Navigation Tabs */}
<div className="fixed top-0 left-0 right-0 z-50">
{/* Glass Effect Background */}
<div className="absolute inset-0 bg-[#0C0E11]/80 backdrop-blur-md" />
{/* Bottom Border */}
<div className="absolute bottom-0 left-0 right-0 h-[1px] bg-white/10" />
{/* Navigation Content */}
<div className="relative h-16 flex items-center">
<div className="w-full max-w-screen-xl mx-auto px-6">
<div className="flex items-center justify-center gap-2">
{WORKFLOW_STAGES.map(stage => (
<motion.button
key={stage.id}
onClick={() => handleStepChange(stage.id)}
className={`
flex-shrink-0 px-6 py-2 rounded-lg transition-all duration-300
${currentStep === stage.id
? 'bg-white/10 text-white shadow-[0_0_15px_rgba(255,255,255,0.15)]'
: 'hover:bg-white/5 text-white/60 hover:text-white/80'
}
`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<span className="text-sm font-medium whitespace-nowrap">{stage.title}</span>
</motion.button>
))}
</div>
</div>
</div>
</div>
{/* Main Content Area */}
<main className="pt-[3.5rem] px-6 h-[calc(100vh-9rem)] overflow-hidden">
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="w-full h-full overflow-hidden"
>
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div
key={i}
className="h-24 bg-white/5 rounded-lg animate-pulse"
/>
))}
</div>
) : (
<>
{currentStep === 'overview' && <ScriptOverview />}
{currentStep === 'storyboard' && <StoryboardView />}
</>
)}
</motion.div>
</AnimatePresence>
</main>
{/* AI Suggestion Bar */}
<AISuggestionBar
suggestions={getSmartSuggestions(currentStep)}
onSuggestionClick={handleSuggestionClick}
onSubmit={handleSubmit}
placeholder={`What would you like AI to help you with in the ${WORKFLOW_STAGES.find(stage => stage.id === currentStep)?.title} stage?`}
/>
</div>
);
}

View File

@ -0,0 +1,223 @@
"use client";
import { useState } from 'react';
import { motion } from 'framer-motion';
import { DragDropContext, DropResult } from 'react-beautiful-dnd';
import { ScriptMetaInfo } from '../script-overview/script-meta-info';
import { SceneFilmstrip } from '../script-overview/scene-filmstrip';
import { StoryboardCardList } from '../storyboard/storyboard-card-list';
// 分镜场景数据结构
export interface StoryboardScene {
id: string;
name: string;
description: string;
shot: string;
frame: string;
atmosphere: string;
imageUrl: string;
}
// 剧本元信息数据结构
export interface ScriptMeta {
title: string;
type: string;
genre: string;
tags: string[];
duration: string;
uploadTime: string;
}
export default function StoryboardView() {
// 示例数据
const [scriptMeta] = useState<ScriptMeta>({
title: "Lost Stars",
type: "Short Drama",
genre: "Sci-Fi/Mystery",
tags: ["Space", "Psychology", "Future"],
duration: "20-25 minutes",
uploadTime: "2024-03-20"
});
const [scenes, setScenes] = useState<StoryboardScene[]>([
{
id: "scene-1",
name: "Opening Shot",
description: "Establishing shot of the space station",
shot: "Wide angle, slow pan from right to left",
frame: "Space station silhouetted against Earth",
atmosphere: "Mysterious, serene, with soft starlight",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-2",
name: "Control Room",
description: "Interior of the main control center",
shot: "Medium close-up, steady cam",
frame: "Monitors displaying unusual signal patterns",
atmosphere: "Tense, with blinking lights and shadows",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-3",
name: "Control Room",
description: "Interior of the main control center",
shot: "Medium close-up, steady cam",
frame: "Monitors displaying unusual signal patterns",
atmosphere: "Tense, with blinking lights and shadows",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-4",
name: "Control Room",
description: "Interior of the main control center",
shot: "Medium close-up, steady cam",
frame: "Monitors displaying unusual signal patterns",
atmosphere: "Tense, with blinking lights and shadows",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-5",
name: "Control Room",
description: "Interior of the main control center",
shot: "Medium close-up, steady cam",
frame: "Monitors displaying unusual signal patterns",
atmosphere: "Tense, with blinking lights and shadows",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-6",
name: "Control Room",
description: "Interior of the main control center",
shot: "Medium close-up, steady cam",
frame: "Monitors displaying unusual signal patterns",
atmosphere: "Tense, with blinking lights and shadows",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-7",
name: "Control Room",
description: "Interior of the main control center",
shot: "Medium close-up, steady cam",
frame: "Monitors displaying unusual signal patterns",
atmosphere: "Tense, with blinking lights and shadows",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-8",
name: "Control Room",
description: "Interior of the main control center",
shot: "Medium close-up, steady cam",
frame: "Monitors displaying unusual signal patterns",
atmosphere: "Tense, with blinking lights and shadows",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-9",
name: "Control Room",
description: "Interior of the main control center",
shot: "Medium close-up, steady cam",
frame: "Monitors displaying unusual signal patterns",
atmosphere: "Tense, with blinking lights and shadows",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-10",
name: "Control Room",
description: "Interior of the main control center",
shot: "Medium close-up, steady cam",
frame: "Monitors displaying unusual signal patterns",
atmosphere: "Tense, with blinking lights and shadows",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
},
{
id: "scene-11",
name: "Control Room",
description: "Interior of the main control center",
shot: "Medium close-up, steady cam",
frame: "Monitors displaying unusual signal patterns",
atmosphere: "Tense, with blinking lights and shadows",
imageUrl: "https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
}
]);
const [selectedSceneId, setSelectedSceneId] = useState<string>();
// 处理场景拖拽排序
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(scenes);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setScenes(items);
};
// 处理场景更新
const handleSceneUpdate = (sceneId: string, updates: Partial<StoryboardScene>) => {
setScenes(scenes.map(scene =>
scene.id === sceneId ? { ...scene, ...updates } : scene
));
};
// 处理场景删除
const handleSceneDelete = (sceneId: string) => {
setScenes(scenes.filter(scene => scene.id !== sceneId));
if (selectedSceneId === sceneId) {
setSelectedSceneId(undefined);
}
};
// 处理场景复制
const handleSceneDuplicate = (sceneId: string) => {
const sceneToDuplicate = scenes.find(scene => scene.id === sceneId);
if (!sceneToDuplicate) return;
const newScene = {
...sceneToDuplicate,
id: `scene-${Date.now()}`,
name: `${sceneToDuplicate.name} (Copy)`
};
setScenes([...scenes, newScene]);
};
return (
<div className="h-full bg-[#0C0E11] text-white">
<div className="h-full flex">
{/* Left: Script Meta Info */}
<div className="flex-shrink-0 h-full">
<ScriptMetaInfo meta={scriptMeta} />
</div>
{/* Right: Scene Content */}
<div className="flex-grow min-w-0 h-full overflow-hidden flex flex-col">
{/* Scene Card List */}
<div className="flex-grow overflow-y-auto">
<DragDropContext onDragEnd={handleDragEnd}>
<StoryboardCardList
scenes={scenes}
selectedSceneId={selectedSceneId}
onSceneUpdate={handleSceneUpdate}
onSceneDelete={handleSceneDelete}
onSceneDuplicate={handleSceneDuplicate}
/>
</DragDropContext>
</div>
{/* Filmstrip Preview */}
<div className="flex-shrink-0 py-6 px-8">
<SceneFilmstrip
scenes={scenes}
selectedSceneId={selectedSceneId}
onSceneSelect={(sceneId: string) => {
setSelectedSceneId(sceneId);
}}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,132 @@
.video-tool-component {
position: fixed;
left: 50%;
bottom: 1rem;
--tw-translate-x: calc(-50% + 34.5px);
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.video-storyboard-tools {
border: 1px solid rgba(255, 255, 255, .2);
box-shadow: 0 4px 20px #0009;
}
.video-storyboard-tools .tool-submit-button {
display: flex;
height: 36px;
width: 120px;
cursor: pointer;
align-items: center;
justify-content: center;
gap: 2px;
border-radius: 10px;
font-size: .875rem;
line-height: 1.25rem;
font-weight: 600;
--tw-text-opacity: 1;
color: rgb(29 33 41 / var(--tw-text-opacity));
background-color: #fff;
}
.video-storyboard-tools .tool-submit-button.disabled {
background-color: #fff;
opacity: .3;
cursor: not-allowed;
}
.storyboard-tools-tab {
border-radius: 16px 16px 0 0;
background: #ffffff0d;
}
.storyboard-tools-tab .tab-item {
position: relative;
cursor: pointer;
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
line-height: 32px;
}
.storyboard-tools-tab .tab-item.active,
.storyboard-tools-tab .tab-item.active span {
opacity: 1;
}
.storyboard-tools-tab .tab-item.active:after {
content: "";
position: absolute;
bottom: -8px;
left: 50%;
width: 30px;
height: 2px;
border-radius: 2px;
background-color: #fff;
transform: translate(-50%);
}
.video-prompt-editor .editor-content {
line-height: 26px;
outline: none;
white-space: pre-wrap;
}
.video-prompt-editor .editor-content[contenteditable] {
display: inline-block;
width: 100%;
}
.video-prompt-editor.focus {
background-color: #ffffff0d;
}
.underline {
text-decoration-line: underline;
}
.tool-scroll-box-content {
scrollbar-width: none;
-ms-overflow-style: none;
}
.tool-operation-button {
display: flex;
height: 36px;
cursor: pointer;
align-items: center;
gap: 6px;
border-radius: 20px;
padding-left: 16px;
padding-right: 16px;
font-size: .875rem;
line-height: 1.25rem;
background-color: #ffffff0d;
}
.tool-operation-button:hover {
background-color: #ffffff1a;
}
/* 下拉菜单样式 */
.mode-dropdown.ant-dropdown .ant-dropdown-menu {
background: rgba(25, 27, 30, 0.95);
backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 8px;
min-width: 280px;
}
.mode-dropdown.ant-dropdown .ant-dropdown-menu-item {
padding: 12px;
border-radius: 8px;
color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.mode-dropdown.ant-dropdown .ant-dropdown-menu-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.mode-dropdown.ant-dropdown .ant-dropdown-menu-item-selected {
background: rgba(255, 255, 255, 0.1);
}

View File

@ -1,43 +1,15 @@
.add-project-btn {
display: flex;
height: 40px;
min-width: 168px;
flex-direction: row;
align-items: center;
justify-content: center;
--tw-bg-opacity: 0.1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
padding-left: 16px;
padding-right: 16px;
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
--tw-backdrop-blur: blur(10px);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
gap: 6px;
overflow: hidden;
border-radius: 10px;
}
.add-project-btn .icon {
/* --tw-text-opacity: 1;
color: rgb(29 33 41 / var(--tw-text-opacity)); */
transform: translate(-100px);
transition-property: transform;
transition-duration: 0.3s;
}
.add-project-btn .btn-text { .add-project-btn .btn-text {
transform: translate(-14px);
transition-property: transform;
transition-duration: 0.3s;
font-weight: 600; font-weight: 600;
} }
.add-project-btn:hover {
--tw-bg-opacity: 0.2;
}
.add-project-btn:hover .icon,
.add-project-btn:hover .btn-text {
transform: translate(0px);
}
.add-project-btn { .add-project-btn {
height: 8rem; display: flex;
border-radius: 32px; align-items: center;
justify-content: center;
font-size: 1rem;
font-style: normal;
font-weight: 400;
line-height: 1.5rem;
width: 190px;
height: 128px;
color: rgba(255, 255, 255, 0.75);
} }

View File

@ -0,0 +1,133 @@
.container-H2sRZG {
box-sizing: border-box;
padding-inline: 24px;
border-radius: 12px;
flex-direction: column;
width: 100%;
max-width: 1000px;
height: fit-content;
display: flex;
overflow: hidden;
}
@media (width >= 1024px) {
.splashContainer-otuV_A {
box-sizing: border-box;
grid-template-rows: auto 1fr;
gap: 24px;
height: 100%;
display: grid;
}
}
.content-vPGYx8 {
box-sizing: border-box;
position: relative;
}
.info-UUGkPJ {
box-sizing: border-box;
flex-direction: column;
gap: 12px;
display: flex
;
}
.title-JtMejk {
box-sizing: border-box;
color: var(--text-primary);
text-align: center;
letter-spacing: -.32px;
font-size: 32px;
font-weight: 600;
line-height: 36px;
}
.subtitle-had8uE {
color: var(--text-secondary);
text-align: center;
}
.normalS400 {
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.media-Ocdu1O {
flex-direction: column;
gap: 12px;
height: 100%;
display: flex
;
overflow: hidden;
}
.videoContainer-qteKNi {
flex: 3;
min-height: 0;
display: flex
;
position: relative;
}
.heroVideo-FIzuK1 {
object-fit: cover;
object-position: center;
background-color: #0003;
border-radius: 8px;
width: 100%;
height: 100%;
}
.container-kIPoeH {
box-sizing: border-box;
cursor: pointer;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
border: none;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 8px;
min-width: 0;
padding: 0;
display: flex
;
overflow: hidden;
}
.secondary-_HxO1W {
color: #fff;
background: #1d1e23;
}
.large-_aHMgD {
letter-spacing: .01em;
border-radius: 8px;
height: 40px;
padding-inline: 12px;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.videoPlaybackButton-uFNO1b {
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
background-color: #ffffff80;
border-radius: 50%;
width: 32px;
height: 32px;
position: absolute;
bottom: 12px;
right: 12px;
}
@media (height >= 880px) {
.imageGrid-ymZV9z {
flex: 1;
gap: 12px;
min-height: 0;
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(25%, 1fr);
overflow-x: auto;
}
}
.image-x5Y2Sg {
object-fit: cover;
object-position: center;
background-color: #0003;
border-radius: 8px;
width: 100%;
height: 100%;
}

View File

@ -0,0 +1,332 @@
"use client"
import { useEffect, useState, useRef } from "react";
import { Play, ChevronUp, Loader2 } from "lucide-react";
import "./style/work-flow.css";
import LiquidGlass from '@/plugins/liquid-glass';
import { Skeleton } from "@/components/ui/skeleton";
import { AISuggestionBar } from "@/components/ai-suggestion-bar";
import { motion, AnimatePresence } from "framer-motion";
import { debounce } from "lodash";
const MOCK_SKETCH_URLS = [
'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 MOCK_SKETCH_SCRIPT = [
'script-123',
'script-123',
'script-123',
'script-123',
];
const MOCK_SKETCH_COUNT = 8;
export default function WorkFlow() {
const [taskObject, setTaskObject] = useState<any>(null);
const [projectObject, setProjectObject] = useState<any>(null);
const [taskSketch, setTaskSketch] = useState<any[]>([]);
const [sketchCount, setSketchCount] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [isAIBarVisible, setIsAIBarVisible] = useState(true);
const [currentStep, setCurrentStep] = useState('0');
const [currentSketchIndex, setCurrentSketchIndex] = useState(0);
const [isGeneratingSketch, setIsGeneratingSketch] = useState(false);
const thumbnailsRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
// 模拟 AI 建议
const mockSuggestions = [
"优化场景转场效果",
"调整画面构图",
"改进角色动作设计",
"增加环境氛围",
"调整镜头语言"
];
useEffect(() => {
const taskId = localStorage.getItem("taskId") || "taskId-123";
getTaskDetail(taskId).then((data) => {
setTaskObject(data);
setIsLoading(false);
setCurrentStep('1');
});
// 轮询获取分镜草图 防抖 1000ms
const debouncedGetTaskSketch = debounce(() => {
getTaskSketch(taskId);
}, 1000);
debouncedGetTaskSketch();
}, []);
// 监听当前选中索引变化,自动滚动到对应位置
useEffect(() => {
if (thumbnailsRef.current && taskSketch.length > 0) {
const container = thumbnailsRef.current;
const thumbnailWidth = container.offsetWidth / 4; // 每个缩略图宽度(包含间距)
const scrollPosition = currentSketchIndex * thumbnailWidth;
container.scrollTo({
left: scrollPosition,
behavior: 'smooth'
});
}
}, [currentSketchIndex, taskSketch.length]);
// 处理鼠标/触摸拖动事件
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true);
setStartX(e.pageX - thumbnailsRef.current!.offsetLeft);
setScrollLeft(thumbnailsRef.current!.scrollLeft);
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging) return;
e.preventDefault();
const x = e.pageX - thumbnailsRef.current!.offsetLeft;
const walk = (x - startX) * 2;
thumbnailsRef.current!.scrollLeft = scrollLeft - walk;
};
const handleMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(false);
if (!isDragging) return;
const container = thumbnailsRef.current!;
const thumbnailWidth = container.offsetWidth / 4;
const currentScroll = container.scrollLeft;
const nearestIndex = Math.round(currentScroll / thumbnailWidth);
// 只有在拖动距离较小时才触发选中
const x = e.pageX - container.offsetLeft;
const walk = Math.abs(x - startX);
if (walk < 10) {
return; // 如果拖动距离太小,保持原有的点击选中逻辑
}
setCurrentSketchIndex(Math.min(Math.max(0, nearestIndex), taskSketch.length - 1));
};
// 模拟接口请求 获取任务详情
const getTaskDetail = async (taskId: string) => {
// const response = await fetch(`/api/task/${taskId}`);
// const data = await response.json();
// mock data
const data = {
projectId: 'projectId-123',
projectName: "Project 1",
taskId: taskId,
taskName: "Task 1",
taskDescription: "Task 1 Description",
taskStatus: "1", // '1' 绘制分镜、'2' 绘制角色、'3' 生成分镜视频、'4' 视频后期制作、'5' 最终成品
taskProgress: 0,
taskCreatedAt: new Date().toISOString(),
taskUpdatedAt: new Date().toISOString(),
};
return data;
}
// 模拟接口请求 每次获取一个分镜草图 轮询获取
const getTaskSketch = async (taskId: string) => {
setIsGeneratingSketch(true);
setTaskSketch([]);
// 模拟分批获取分镜草图
for (let i = 0; i < MOCK_SKETCH_COUNT; i++) {
await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟2秒延迟
const newSketch = {
id: `sketch-${i}`,
url: MOCK_SKETCH_URLS[i % MOCK_SKETCH_URLS.length],
script: MOCK_SKETCH_SCRIPT[i % MOCK_SKETCH_SCRIPT.length],
status: 'done'
};
setTaskSketch(prev => [...prev, newSketch]);
setCurrentSketchIndex(i);
setSketchCount(i + 1);
}
setIsGeneratingSketch(false);
}
const handleSuggestionClick = (suggestion: string) => {
console.log('Selected suggestion:', suggestion);
};
const handleSubmit = (text: string) => {
console.log('Submitted text:', text);
};
// 渲染分镜草图或加载动画
const renderSketchContent = () => {
if (!taskSketch[currentSketchIndex]) {
return (
<div className="w-full h-full flex items-center justify-center bg-black/20 backdrop-blur-sm rounded-lg">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
<p className="text-sm text-white/70"> {sketchCount + 1}/{MOCK_SKETCH_COUNT}</p>
</div>
</div>
);
}
return (
<motion.img
key={currentSketchIndex}
src={taskSketch[currentSketchIndex].url}
alt={`分镜草图 ${currentSketchIndex + 1}`}
className="w-full h-full object-contain rounded-lg"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
);
};
return (
<div className="w-full h-full overflow-hidden">
<div className="flex h-full flex-col p-6 justify-center items-center">
<div className="container-H2sRZG">
<div className="splashContainer-otuV_A">
<div className="content-vPGYx8">
<div className="info-UUGkPJ">
{isLoading ? (
<>
<Skeleton className="h-8 w-64 mb-2" />
<Skeleton className="h-4 w-96" />
</>
) : (
<>
<div className="title-JtMejk">{taskObject?.projectName}{taskObject?.taskName}</div>
<p className="normalS400 subtitle-had8uE">{taskObject?.taskDescription}</p>
</>
)}
</div>
</div>
<div className="media-Ocdu1O">
<div className="videoContainer-qteKNi" ref={containerRef}>
{isLoading ? (
<Skeleton className="w-full aspect-video rounded-lg" />
) : (
<>
{
currentStep === '1' ? (
<div className="heroVideo-FIzuK1" style={{ aspectRatio: "16 / 9" }}>
{renderSketchContent()}
</div>
) : (
<video
className="heroVideo-FIzuK1"
src="https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome.mp4"
style={{
aspectRatio: "16 / 9"
}}
autoPlay
muted
loop
></video>
)
}
<button className="container-kIPoeH secondary-_HxO1W large-_aHMgD videoPlaybackButton-uFNO1b">
<Play className="w-6 h-6 icon" />
</button>
</>
)}
</div>
<div className="imageGrid-ymZV9z">
{isLoading ? (
<>
<Skeleton className="w-[128px] h-[128px] rounded-lg" />
<Skeleton className="w-[128px] h-[128px] rounded-lg" />
<Skeleton className="w-[128px] h-[128px] rounded-lg" />
<Skeleton className="w-[128px] h-[128px] rounded-lg" />
</>
) : (
<div
ref={thumbnailsRef}
className="w-full grid grid-flow-col auto-cols-[25%] gap-4 overflow-x-auto hidden-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={() => setIsDragging(false)}
>
{currentStep === '1' ? (
<>
{taskSketch.map((sketch, index) => (
<motion.div
key={sketch.id}
className={`relative aspect-video rounded-lg overflow-hidden
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`}
onClick={() => !isDragging && setCurrentSketchIndex(index)}
initial={false}
animate={{
scale: currentSketchIndex === index ? 1.05 : 1,
rotateY: currentSketchIndex === index ? 5 : 0,
rotateX: currentSketchIndex === index ? -5 : 0,
translateZ: currentSketchIndex === index ? '20px' : '0px',
transition: {
type: "spring",
stiffness: 300,
damping: 20
}
}}
style={{
transformStyle: 'preserve-3d',
perspective: '1000px'
}}
>
<motion.img
className="w-full h-full object-cover"
src={sketch.url}
alt={`缩略图 ${index + 1}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
<span className="text-xs text-white/90"> {index + 1}</span>
</div>
</motion.div>
))}
{isGeneratingSketch && sketchCount < MOCK_SKETCH_COUNT && (
<div className="relative aspect-video rounded-lg bg-black/20 backdrop-blur-sm
flex items-center justify-center">
<Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
</div>
)}
</>
) : (
<>
</>
)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* AI 建议栏 */}
<AnimatePresence>
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="mb-16"
>
<AISuggestionBar
suggestions={mockSuggestions}
onSuggestionClick={handleSuggestionClick}
onSubmit={handleSubmit}
placeholder="请输入你的想法,或点击预设词条获取 AI 建议..."
/>
</motion.div>
</AnimatePresence>
</div>
)
}

View File

@ -0,0 +1,60 @@
import { motion } from 'framer-motion';
import { Save, Download, Trash2 } from 'lucide-react';
interface FloatingToolbarProps {
onSave: () => void;
onExport: () => void;
onClear: () => void;
}
export function FloatingToolbar({
onSave,
onExport,
onClear
}: FloatingToolbarProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 flex items-center gap-2
bg-white/5 backdrop-blur-sm rounded-full p-2 shadow-lg"
>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onSave}
className="p-2 hover:bg-white/10 rounded-full transition-colors
flex items-center gap-2"
>
<Save className="w-5 h-5" />
<span></span>
</motion.button>
<div className="w-px h-6 bg-white/10" />
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onExport}
className="p-2 hover:bg-white/10 rounded-full transition-colors
flex items-center gap-2"
>
<Download className="w-5 h-5" />
<span></span>
</motion.button>
<div className="w-px h-6 bg-white/10" />
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onClear}
className="p-2 hover:bg-red-500/20 text-red-500 rounded-full
transition-colors flex items-center gap-2"
>
<Trash2 className="w-5 h-5" />
<span></span>
</motion.button>
</motion.div>
);
}

View File

@ -0,0 +1,48 @@
import { Droppable } from 'react-beautiful-dnd';
import { Scene } from '../pages/script-overview';
import { SceneCard } from './scene-card';
interface SceneCardListProps {
scenes: Scene[];
selectedSceneId?: string;
onSceneUpdate: (sceneId: string, updates: Partial<Scene>) => void;
onSceneDelete: (sceneId: string) => void;
onSceneDuplicate: (sceneId: string) => void;
}
export function SceneCardList({
scenes,
selectedSceneId,
onSceneUpdate,
onSceneDelete,
onSceneDuplicate
}: SceneCardListProps) {
return (
<Droppable droppableId="scenes" direction="horizontal">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`
flex gap-6 overflow-x-auto hide-scrollbar h-full overflow-y-hidden
${snapshot.isDraggingOver ? 'bg-white/5' : ''}
transition-colors duration-300 rounded-xl p-2
`}
>
{scenes.map((scene, index) => (
<SceneCard
key={scene.id}
scene={scene}
index={index}
isSelected={scene.id === selectedSceneId}
onUpdate={(updates) => onSceneUpdate(scene.id, updates)}
onDelete={() => onSceneDelete(scene.id)}
onDuplicate={() => onSceneDuplicate(scene.id)}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
);
}

View File

@ -0,0 +1,310 @@
import { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Draggable } from 'react-beautiful-dnd';
import { Trash2, Copy, RefreshCw, GripVertical } from 'lucide-react';
import { Scene } from '../pages/script-overview';
import Image from 'next/image';
interface SceneCardProps {
scene: Scene;
index: number;
isSelected?: boolean;
onUpdate: (updates: Partial<Scene>) => void;
onDelete: () => void;
onDuplicate: () => void;
}
export function SceneCard({
scene,
index,
isSelected = false,
onUpdate,
onDelete,
onDuplicate
}: SceneCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showSaveIndicator, setShowSaveIndicator] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<NodeJS.Timeout>();
// 处理自动保存
const handleContentChange = (
field: keyof Scene,
value: string,
element: HTMLElement
) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
onUpdate({ [field]: value });
setShowSaveIndicator(true);
setTimeout(() => setShowSaveIndicator(false), 2000);
}, 500);
};
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// 选中效果
useEffect(() => {
if (isSelected && cardRef.current) {
cardRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
});
}
}, [isSelected]);
return (
<Draggable draggableId={scene.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<motion.div
id={`scene-card-${scene.id}`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: 1,
scale: 1,
x: snapshot.isDragging ? 5 : 0,
y: snapshot.isDragging ? 5 : 0,
rotate: snapshot.isDragging ? 2 : 0
}}
transition={{
type: "spring",
stiffness: 200,
damping: 20
}}
className={`
relative flex-shrink-0 w-[400px] bg-white/5 backdrop-blur-sm rounded-xl overflow-hidden h-full
flex flex-col group cursor-grab active:cursor-grabbing
${snapshot.isDragging ? 'ring-2 ring-blue-500/50 shadow-lg z-50' : ''}
${isEditing ? 'ring-2 ring-yellow-500/50' : ''}
${isSelected ? 'ring-2 ring-purple-500/50' : ''}
transition-all duration-300
`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => {
setIsHovered(false);
setShowDeleteConfirm(false);
}}
>
{/* Scene Image */}
<div className="relative w-full h-[200px] flex-shrink-0">
<Image
src={scene.imageUrl}
alt={scene.name}
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#0C0E11] via-transparent to-transparent" />
</div>
{/* Content Area */}
<div className="flex flex-col h-full">
{/* Scrollable Content */}
<div className="flex-grow overflow-y-auto custom-scrollbar p-6 -mt-12">
{/* Scene Name */}
<motion.div
animate={{ scale: 0.8 }}
transition={{ duration: 0.3 }}
className="text-xl font-semibold mb-3"
>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('name', e.currentTarget.textContent || '', e.currentTarget);
}}
className="outline-none focus:bg-white/5 rounded px-2 py-1
transition-colors"
dangerouslySetInnerHTML={{ __html: scene.name }}
/>
</motion.div>
{/* Scene Description */}
<motion.div
animate={{ opacity: 1 }}
className="text-sm text-white/60 mb-4"
>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('description', e.currentTarget.textContent || '', e.currentTarget);
}}
className="outline-none focus:bg-white/5 rounded px-2 py-1
transition-colors"
dangerouslySetInnerHTML={{ __html: scene.description }}
/>
</motion.div>
{/* Plot Description */}
<div className="space-y-2 mb-4">
<label className="text-sm text-white/60">Plot Description</label>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('plot', e.currentTarget.textContent || '', e.currentTarget);
}}
className="outline-none focus:bg-white/5 rounded px-2 py-1
transition-colors text-white/80"
dangerouslySetInnerHTML={{ __html: scene.plot }}
/>
</div>
{/* Character Dialogue */}
<div className="space-y-2 mb-4">
<label className="text-sm text-white/60">Character Dialogue</label>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('dialogue', e.currentTarget.textContent || '', e.currentTarget);
}}
className="outline-none focus:bg-white/5 rounded px-2 py-1
transition-colors text-white/80"
dangerouslySetInnerHTML={{ __html: scene.dialogue }}
/>
</div>
{/* Narration */}
<div className="space-y-2">
<label className="text-sm text-white/60">Narration</label>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('narration', e.currentTarget.textContent || '', e.currentTarget);
}}
className="outline-none focus:bg-white/5 rounded px-2 py-1
transition-colors text-white/80"
dangerouslySetInnerHTML={{ __html: scene.narration }}
/>
</div>
</div>
</div>
{/* Floating Action Bar */}
<motion.div
initial={false}
animate={{
y: isHovered ? 0 : 100,
opacity: isHovered ? 1 : 0
}}
transition={{
type: "spring",
stiffness: 300,
damping: 30
}}
className="absolute bottom-0 left-0 right-0 p-4 bg-black/30 backdrop-blur-sm
border-t border-white/10 flex items-center justify-end gap-2"
>
{/* Delete Button */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="p-2 rounded-lg hover:bg-red-500/20 text-red-500
transition-colors relative group"
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(true);
}}
>
<Trash2 className="w-5 h-5" />
{/* Delete Confirmation */}
{showDeleteConfirm && (
<div
className="absolute bottom-full right-0 mb-2 p-2
bg-red-500 rounded-lg whitespace-nowrap flex items-center gap-2"
onClick={e => e.stopPropagation()}
>
<span>Confirm delete?</span>
<button
className="px-2 py-1 rounded bg-red-600 hover:bg-red-700
transition-colors text-white text-sm"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
Confirm
</button>
<button
className="px-2 py-1 rounded bg-white/20 hover:bg-white/30
transition-colors text-white text-sm"
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(false);
}}
>
Cancel
</button>
</div>
)}
</motion.button>
{/* Duplicate Button */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<Copy className="w-5 h-5" />
</motion.button>
{/* Regenerate Button */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={e => e.stopPropagation()}
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<RefreshCw className="w-5 h-5" />
</motion.button>
</motion.div>
{/* Save Indicator */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: showSaveIndicator ? 1 : 0, y: showSaveIndicator ? 0 : 20 }}
className="absolute bottom-20 right-4 px-3 py-1.5 rounded-full
bg-green-500/20 text-green-400 text-sm"
>
Saved
</motion.div>
</motion.div>
</div>
)}
</Draggable>
);
}

View File

@ -0,0 +1,129 @@
import { useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Scene } from '../pages/script-overview';
import Image from 'next/image';
interface SceneFilmstripProps {
scenes: Scene[];
selectedSceneId?: string;
onSceneSelect: (sceneId: string) => void;
}
export function SceneFilmstrip({
scenes,
selectedSceneId,
onSceneSelect
}: SceneFilmstripProps) {
const [selectedSceneIdState, setSelectedSceneIdState] = useState<string | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 处理滚动
const handleScroll = (direction: 'left' | 'right') => {
if (!scrollContainerRef.current) return;
const scrollAmount = 300;
const container = scrollContainerRef.current;
container.scrollTo({
left: container.scrollLeft + (direction === 'left' ? -scrollAmount : scrollAmount),
behavior: 'smooth'
});
};
// 处理场景选择
const handleSceneSelect = (sceneId: string) => {
setSelectedSceneIdState(sceneId);
onSceneSelect(sceneId);
// 滚动到对应的场景卡片
const sceneCard = document.getElementById(`scene-card-${sceneId}`);
if (sceneCard) {
sceneCard.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
});
}
};
return (
<div className="relative">
{/* 滚动按钮 */}
<button
onClick={() => handleScroll('left')}
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full
bg-black/50 hover:bg-black/70 backdrop-blur-sm flex items-center justify-center
transition-colors"
>
<ChevronLeft className="w-6 h-6" />
</button>
<button
onClick={() => handleScroll('right')}
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full
bg-black/50 hover:bg-black/70 backdrop-blur-sm flex items-center justify-center
transition-colors"
>
<ChevronRight className="w-6 h-6" />
</button>
{/* 胶片打孔效果 */}
<div className="absolute -top-2 left-0 right-0 flex justify-between px-4">
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
className="w-3 h-3 rounded-full bg-black/50 border border-white/10"
/>
))}
</div>
<div className="absolute -bottom-2 left-0 right-0 flex justify-between px-4">
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
className="w-3 h-3 rounded-full bg-black/50 border border-white/10"
/>
))}
</div>
{/* 场景缩略图 */}
<div
ref={scrollContainerRef}
className="flex gap-4 overflow-x-auto py-4 px-2 hide-scrollbar"
style={{ perspective: '1000px' }}
>
{scenes.map((scene, index) => (
<motion.div
key={scene.id}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
animate={{
scale: selectedSceneId === scene.id ? 1.1 : 1,
opacity: selectedSceneId === scene.id ? 1 : 0.7,
}}
onClick={() => handleSceneSelect(scene.id)}
className={`
relative flex-shrink-0 w-32 h-20 rounded-lg overflow-hidden cursor-pointer
${selectedSceneId === scene.id ? 'ring-2 ring-purple-500' : 'hover:ring-1 hover:ring-white/20'}
transition-shadow duration-200
`}
>
<Image
src={scene.imageUrl}
alt={scene.name}
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<div className="absolute bottom-1 left-2 right-2 text-xs font-medium truncate">
{scene.name}
</div>
</motion.div>
))}
</div>
{/* 胶片装饰线 */}
<div className="absolute top-0 left-16 right-16 h-1 bg-white/5" />
<div className="absolute bottom-0 left-16 right-16 h-1 bg-white/5" />
</div>
);
}

View File

@ -0,0 +1,122 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronLeft, ChevronRight, Clock, Calendar, Tag, Film, BookOpen, Info } from 'lucide-react';
import { ScriptMeta } from '../pages/script-overview';
interface ScriptMetaInfoProps {
meta: ScriptMeta;
}
export function ScriptMetaInfo({ meta }: ScriptMetaInfoProps) {
const [isExpanded, setIsExpanded] = useState(true);
return (
<div className="relative h-full">
{/* 收起时的触发按钮 */}
<AnimatePresence initial={false}>
{!isExpanded && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={() => setIsExpanded(true)}
className="absolute left-0 top-8 z-10 p-3 bg-white/5 hover:bg-white/10
backdrop-blur-sm rounded-r-xl border-l-2 border-blue-500/50
shadow-[0_0_15px_rgba(59,130,246,0.2)] transition-colors group"
>
<Info className="w-6 h-6 text-blue-400 group-hover:text-blue-300 transition-colors" />
</motion.button>
)}
</AnimatePresence>
{/* 主内容区域 */}
<motion.div
initial={false}
animate={{
width: isExpanded ? 'auto' : 0,
opacity: isExpanded ? 1 : 0,
x: isExpanded ? 0 : -20,
}}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className={`
bg-white/5 backdrop-blur-sm rounded-xl overflow-hidden h-full
${isExpanded ? 'border border-white/10' : ''}
`}
>
<div className="w-[320px] h-full">
<div className="p-6">
{/* 标题和展开/收起按钮 */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold"></h2>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsExpanded(false)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors
text-white/60 hover:text-white"
>
<ChevronLeft className="w-5 h-5" />
</motion.button>
</div>
{/* 剧本标题 */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-white/60">
<BookOpen className="w-4 h-4" />
<span></span>
</div>
<h1 className="text-2xl font-bold">{meta.title}</h1>
</div>
{/* 类型和体裁 */}
<div className="mt-6 space-y-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Film className="w-4 h-4 text-white/60" />
<span className="text-white/60"></span>
</div>
<span>{meta.type}</span>
</div>
<div>
<span className="text-white/60 mr-4"></span>
<span>{meta.genre}</span>
</div>
</div>
{/* 标签 */}
<div className="mt-6 space-y-2">
<div className="flex items-center gap-2 text-white/60">
<Tag className="w-4 h-4" />
<span></span>
</div>
<div className="flex flex-wrap gap-2">
{meta.tags.map(tag => (
<span
key={tag}
className="px-3 py-1 bg-white/10 rounded-full text-sm"
>
{tag}
</span>
))}
</div>
</div>
{/* 时长和上传时间 */}
<div className="mt-6 space-y-4 pt-4 border-t border-white/10">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-white/60" />
<span className="text-white/60"></span>
<span className="ml-2">{meta.duration}</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-white/60" />
<span className="text-white/60"></span>
<span className="ml-2">{meta.uploadTime}</span>
</div>
</div>
</div>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,44 @@
import { Droppable } from 'react-beautiful-dnd';
import { StoryboardScene } from '../pages/storyboard-view';
import { StoryboardCard } from './storyboard-card';
interface StoryboardCardListProps {
scenes: StoryboardScene[];
selectedSceneId?: string;
onSceneUpdate: (sceneId: string, updates: Partial<StoryboardScene>) => void;
onSceneDelete: (sceneId: string) => void;
onSceneDuplicate: (sceneId: string) => void;
}
export function StoryboardCardList({
scenes,
selectedSceneId,
onSceneUpdate,
onSceneDelete,
onSceneDuplicate
}: StoryboardCardListProps) {
return (
<Droppable droppableId="storyboard-scenes">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className="flex gap-6 overflow-x-auto hide-scrollbar h-full overflow-y-hidden p-8"
>
{scenes.map((scene, index) => (
<StoryboardCard
key={scene.id}
scene={scene}
index={index}
isSelected={selectedSceneId === scene.id}
onUpdate={(updates) => onSceneUpdate(scene.id, updates)}
onDelete={() => onSceneDelete(scene.id)}
onDuplicate={() => onSceneDuplicate(scene.id)}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
);
}

View File

@ -0,0 +1,310 @@
import { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Draggable } from 'react-beautiful-dnd';
import { Trash2, Copy, RefreshCw } from 'lucide-react';
import { StoryboardScene } from '../pages/storyboard-view';
import Image from 'next/image';
interface StoryboardCardProps {
scene: StoryboardScene;
index: number;
isSelected?: boolean;
onUpdate: (updates: Partial<StoryboardScene>) => void;
onDelete: () => void;
onDuplicate: () => void;
}
export function StoryboardCard({
scene,
index,
isSelected = false,
onUpdate,
onDelete,
onDuplicate
}: StoryboardCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showSaveIndicator, setShowSaveIndicator] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<NodeJS.Timeout>();
// 处理自动保存
const handleContentChange = (
field: keyof StoryboardScene,
value: string,
element: HTMLElement
) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
onUpdate({ [field]: value });
setShowSaveIndicator(true);
setTimeout(() => setShowSaveIndicator(false), 2000);
}, 500);
};
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// 选中效果
useEffect(() => {
if (isSelected && cardRef.current) {
cardRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
});
}
}, [isSelected]);
return (
<Draggable draggableId={scene.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<motion.div
id={`scene-card-${scene.id}`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: 1,
scale: 1,
x: snapshot.isDragging ? 5 : 0,
y: snapshot.isDragging ? 5 : 0,
rotate: snapshot.isDragging ? 2 : 0
}}
transition={{
type: "spring",
stiffness: 200,
damping: 20
}}
className={`
relative flex-shrink-0 w-[400px] bg-white/5 backdrop-blur-sm rounded-xl overflow-hidden h-full
flex flex-col group cursor-grab active:cursor-grabbing
${snapshot.isDragging ? 'ring-2 ring-blue-500/50 shadow-lg z-50' : ''}
${isEditing ? 'ring-2 ring-yellow-500/50' : ''}
${isSelected ? 'ring-2 ring-purple-500/50' : ''}
transition-all duration-300
`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => {
setIsHovered(false);
setShowDeleteConfirm(false);
}}
>
{/* Scene Image */}
<div className="relative w-full h-[200px] flex-shrink-0">
<Image
src={scene.imageUrl}
alt={scene.name}
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#0C0E11] via-transparent to-transparent" />
</div>
{/* Content Area */}
<div className="flex flex-col h-full">
{/* Scrollable Content */}
<div className="flex-grow overflow-y-auto custom-scrollbar p-6 -mt-12">
{/* Scene Name */}
<motion.div
animate={{ scale: 0.8 }}
transition={{ duration: 0.3 }}
className="text-xl font-semibold mb-3"
>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('name', e.currentTarget.textContent || '', e.currentTarget);
}}
className="outline-none focus:bg-white/5 rounded px-2 py-1
transition-colors"
dangerouslySetInnerHTML={{ __html: scene.name }}
/>
</motion.div>
{/* Scene Description */}
<motion.div
animate={{ opacity: 1 }}
className="text-sm text-white/60 mb-4"
>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('description', e.currentTarget.textContent || '', e.currentTarget);
}}
className="outline-none focus:bg-white/5 rounded px-2 py-1
transition-colors"
dangerouslySetInnerHTML={{ __html: scene.description }}
/>
</motion.div>
{/* Shot Description */}
<div className="space-y-2 mb-4">
<label className="text-sm text-white/60">Shot Description</label>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('shot', e.currentTarget.textContent || '', e.currentTarget);
}}
className="outline-none focus:bg-white/5 rounded px-2 py-1
transition-colors text-white/80"
dangerouslySetInnerHTML={{ __html: scene.shot }}
/>
</div>
{/* Frame Description */}
<div className="space-y-2 mb-4">
<label className="text-sm text-white/60">Frame Description</label>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('frame', e.currentTarget.textContent || '', e.currentTarget);
}}
className="outline-none focus:bg-white/5 rounded px-2 py-1
transition-colors text-white/80"
dangerouslySetInnerHTML={{ __html: scene.frame }}
/>
</div>
{/* Atmosphere */}
<div className="space-y-2">
<label className="text-sm text-white/60">Atmosphere</label>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('atmosphere', e.currentTarget.textContent || '', e.currentTarget);
}}
className="outline-none focus:bg-white/5 rounded px-2 py-1
transition-colors text-white/80"
dangerouslySetInnerHTML={{ __html: scene.atmosphere }}
/>
</div>
</div>
</div>
{/* Floating Action Bar */}
<motion.div
initial={false}
animate={{
y: isHovered ? 0 : 100,
opacity: isHovered ? 1 : 0
}}
transition={{
type: "spring",
stiffness: 300,
damping: 30
}}
className="absolute bottom-0 left-0 right-0 p-4 bg-black/30 backdrop-blur-sm
border-t border-white/10 flex items-center justify-end gap-2"
>
{/* Delete Button */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="p-2 rounded-lg hover:bg-red-500/20 text-red-500
transition-colors relative group"
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(true);
}}
>
<Trash2 className="w-5 h-5" />
{/* Delete Confirmation */}
{showDeleteConfirm && (
<div
className="absolute bottom-full right-0 mb-2 p-2
bg-red-500 rounded-lg whitespace-nowrap flex items-center gap-2"
onClick={e => e.stopPropagation()}
>
<span>Confirm delete?</span>
<button
className="px-2 py-1 rounded bg-red-600 hover:bg-red-700
transition-colors text-white text-sm"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
Confirm
</button>
<button
className="px-2 py-1 rounded bg-white/20 hover:bg-white/30
transition-colors text-white text-sm"
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(false);
}}
>
Cancel
</button>
</div>
)}
</motion.button>
{/* Duplicate Button */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<Copy className="w-5 h-5" />
</motion.button>
{/* Regenerate Button */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={e => e.stopPropagation()}
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<RefreshCw className="w-5 h-5" />
</motion.button>
</motion.div>
{/* Save Indicator */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: showSaveIndicator ? 1 : 0, y: showSaveIndicator ? 0 : 20 }}
className="absolute bottom-20 right-4 px-3 py-1.5 rounded-full
bg-green-500/20 text-green-400 text-sm"
>
Saved
</motion.div>
</motion.div>
</div>
)}
</Draggable>
);
}

View File

@ -5,5 +5,5 @@ import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types" import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider defaultTheme="light" {...props}>{children}</NextThemesProvider> return <NextThemesProvider defaultTheme="dark" {...props}>{children}</NextThemesProvider>
} }

View File

@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { Edit2, Trash2, Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface VideoGridLayoutProps {
videos: {
id: string;
url: string;
title: string;
date?: string;
}[];
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
}
export function VideoGridLayout({ videos, onEdit, onDelete }: VideoGridLayoutProps) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState<{ [key: string]: boolean }>({});
const handleMouseEnter = (id: string) => {
setHoveredId(id);
};
const handleMouseLeave = (id: string) => {
setHoveredId(null);
// 暂停视频
const video = document.getElementById(`video-${id}`) as HTMLVideoElement;
if (video) {
video.pause();
setIsPlaying(prev => ({ ...prev, [id]: false }));
}
};
const togglePlay = (id: string) => {
const video = document.getElementById(`video-${id}`) as HTMLVideoElement;
if (video) {
if (video.paused) {
video.play();
setIsPlaying(prev => ({ ...prev, [id]: true }));
} else {
video.pause();
setIsPlaying(prev => ({ ...prev, [id]: false }));
}
}
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6">
{videos.map((video) => (
<div
key={video.id}
className="group relative bg-white/5 rounded-lg overflow-hidden transition-all duration-300 hover:bg-white/10"
onMouseEnter={() => handleMouseEnter(video.id)}
onMouseLeave={() => handleMouseLeave(video.id)}
>
{/* 视频容器 */}
<div className="relative aspect-video">
<video
id={`video-${video.id}`}
src={video.url}
className="w-full h-full object-cover"
loop
muted
playsInline
/>
{/* 播放按钮遮罩 */}
<div
className={`absolute inset-0 flex items-center justify-center bg-black/40 transition-opacity duration-300
${hoveredId === video.id ? 'opacity-100' : 'opacity-0'}
`}
onClick={() => togglePlay(video.id)}
>
<Play className={`w-12 h-12 text-white/90 transition-transform duration-300
${isPlaying[video.id] ? 'scale-90 opacity-0' : 'scale-100 opacity-100'}`}
/>
</div>
{/* 操作按钮 */}
<div
className={`absolute top-4 right-4 flex gap-2 transition-all duration-300 transform
${hoveredId === video.id ? 'translate-y-0 opacity-100' : 'translate-y-[-10px] opacity-0'}
`}
>
<Button
variant="ghost"
size="icon"
className="w-8 h-8 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm"
onClick={() => onEdit?.(video.id)}
>
<Edit2 className="w-4 h-4 text-white" />
</Button>
<Button
variant="ghost"
size="icon"
className="w-8 h-8 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm"
onClick={() => onDelete?.(video.id)}
>
<Trash2 className="w-4 h-4 text-white" />
</Button>
</div>
</div>
{/* 视频信息 */}
<div className="p-4">
<h3 className="text-white text-lg font-medium mb-1">{video.title}</h3>
{video.date && (
<p className="text-white/60 text-sm">{video.date}</p>
)}
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,125 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface VideoScreenLayoutProps {
videos: {
id: string;
url: string;
title: string;
}[];
}
export function VideoScreenLayout({ videos }: VideoScreenLayoutProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// 计算每个面板的样式
const getPanelStyle = (index: number) => {
const position = index - currentIndex;
const scale = Math.max(0.6, 1 - Math.abs(position) * 0.2);
const zIndex = 10 - Math.abs(position);
const opacity = Math.max(0.4, 1 - Math.abs(position) * 0.3);
let transform = `
perspective(1000px)
scale(${scale})
translateX(${position * 100}%)
`;
// 添加侧面板的 3D 旋转效果
if (position !== 0) {
const rotateY = position > 0 ? -15 : 15;
transform += ` rotateY(${rotateY}deg)`;
}
return {
transform,
zIndex,
opacity,
};
};
// 处理切换
const handleSlide = (direction: 'prev' | 'next') => {
if (isAnimating) return;
setIsAnimating(true);
const newIndex = direction === 'next'
? (currentIndex + 1) % videos.length
: (currentIndex - 1 + videos.length) % videos.length;
setCurrentIndex(newIndex);
// 动画结束后重置状态
setTimeout(() => setIsAnimating(false), 500);
};
return (
<div className="relative w-full h-[600px] overflow-hidden bg-[var(--background)]">
{/* 视频面板容器 */}
<div
ref={containerRef}
className="absolute inset-0 flex items-center justify-center"
style={{
perspective: '1000px',
transformStyle: 'preserve-3d',
}}
>
{videos.map((video, index) => (
<div
key={video.id}
className="absolute w-[640px] h-[360px] transition-all duration-500 ease-out"
style={getPanelStyle(index)}
>
<div className="relative w-full h-full overflow-hidden rounded-lg">
{/* 视频 */}
<video
src={video.url}
className="w-full h-full object-cover"
autoPlay
loop
muted
playsInline
/>
{/* 视频标题 - 只在中间面板显示 */}
{index === currentIndex && (
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent">
<h3 className="text-white text-lg font-medium">{video.title}</h3>
</div>
)}
{/* 玻璃态遮罩 - 侧面板半透明效果 */}
{index !== currentIndex && (
<div className="absolute inset-0 bg-black/20 backdrop-blur-sm" />
)}
</div>
</div>
))}
</div>
{/* 切换按钮 */}
<Button
variant="ghost"
size="icon"
className="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white/10 hover:bg-white/20 backdrop-blur-sm transition-all"
onClick={() => handleSlide('prev')}
disabled={isAnimating}
>
<ChevronLeft className="w-6 h-6 text-white" />
</Button>
<Button
variant="ghost"
size="icon"
className="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white/10 hover:bg-white/20 backdrop-blur-sm transition-all"
onClick={() => handleSlide('next')}
disabled={isAnimating}
>
<ChevronRight className="w-6 h-6 text-white" />
</Button>
</div>
);
}

1327
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,9 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@next/swc-wasm-nodejs": "13.5.1", "@next/swc-wasm-nodejs": "13.5.1",
"@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-accordion": "^1.2.0",
@ -40,7 +43,9 @@
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.2",
"@types/node": "20.6.2", "@types/node": "20.6.2",
"@types/react": "18.2.22", "@types/react": "18.2.22",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "18.2.7", "@types/react-dom": "18.2.7",
"antd": "^5.26.2",
"autoprefixer": "10.4.15", "autoprefixer": "10.4.15",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -49,22 +54,26 @@
"embla-carousel-react": "^8.3.0", "embla-carousel-react": "^8.3.0",
"eslint": "8.49.0", "eslint": "8.49.0",
"eslint-config-next": "13.5.1", "eslint-config-next": "13.5.1",
"framer-motion": "^12.18.1", "framer-motion": "^12.19.1",
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"lodash": "^4.17.21",
"lucide-react": "^0.446.0", "lucide-react": "^0.446.0",
"motion": "^12.18.1", "motion": "^12.18.1",
"next": "13.5.1", "next": "13.5.1",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"postcss": "8.4.30", "postcss": "8.4.30",
"react": "18.2.0", "react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-grid-layout": "^1.5.1", "react-grid-layout": "^1.5.1",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"react-resizable": "^3.0.5", "react-resizable": "^3.0.5",
"react-resizable-panels": "^2.1.3", "react-resizable-panels": "^2.1.3",
"react-textarea-autosize": "^8.5.9",
"recharts": "^2.12.7", "recharts": "^2.12.7",
"sonner": "^1.5.0", "sonner": "^1.5.0",
"swiper": "^11.2.8",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwindcss": "3.3.3", "tailwindcss": "3.3.3",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",

View File

@ -1,27 +0,0 @@
interface LiquidGlassProps {
children: React.ReactNode;
displacementScale?: number;
blurAmount?: number;
saturation?: number;
aberrationIntensity?: number;
elasticity?: number;
cornerRadius?: number;
globalMousePos?: {
x: number;
y: number;
};
mouseOffset?: {
x: number;
y: number;
};
mouseContainer?: React.RefObject<HTMLElement | null> | null;
className?: string;
padding?: string;
style?: React.CSSProperties;
overLight?: boolean;
mode?: "standard" | "polar" | "prominent" | "shader";
onClick?: () => void;
}
export default function LiquidGlass({ children, displacementScale, blurAmount, saturation, aberrationIntensity, elasticity, cornerRadius, globalMousePos: externalGlobalMousePos, mouseOffset: externalMouseOffset, mouseContainer, className, padding, overLight, style, mode, onClick, }: LiquidGlassProps): import("react/jsx-runtime").JSX.Element;
export {};
//# sourceMappingURL=index.d.ts.map

View File

@ -1 +0,0 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAwPA,UAAU,gBAAgB;IACxB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,cAAc,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACzC,WAAW,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACtC,cAAc,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,GAAG,IAAI,CAAA;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAA;IAC3B,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,IAAI,CAAC,EAAE,UAAU,GAAG,OAAO,GAAG,WAAW,GAAG,QAAQ,CAAA;IACpD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;CACrB;AAED,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,QAAQ,EACR,iBAAsB,EACtB,UAAmB,EACnB,UAAgB,EAChB,mBAAuB,EACvB,UAAiB,EACjB,YAAkB,EAClB,cAAc,EAAE,sBAAsB,EACtC,WAAW,EAAE,mBAAmB,EAChC,cAAqB,EACrB,SAAc,EACd,OAAqB,EACrB,SAAiB,EACjB,KAAU,EACV,IAAiB,EACjB,OAAO,GACR,EAAE,gBAAgB,2CAuUlB"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,612 @@
import { type CSSProperties, forwardRef, useCallback, useEffect, useId, useRef, useState } from "react"
import { ShaderDisplacementGenerator, fragmentShaders } from "./shader-utils"
import { displacementMap, polarDisplacementMap, prominentDisplacementMap } from "./utils"
// Generate shader-based displacement map using shaderUtils
const generateShaderDisplacementMap = (width: number, height: number): string => {
const generator = new ShaderDisplacementGenerator({
width,
height,
fragment: fragmentShaders.liquidGlass,
})
const dataUrl = generator.updateShader()
generator.destroy()
return dataUrl
}
const getMap = (mode: "standard" | "polar" | "prominent" | "shader", shaderMapUrl?: string) => {
switch (mode) {
case "standard":
return displacementMap
case "polar":
return polarDisplacementMap
case "prominent":
return prominentDisplacementMap
case "shader":
return shaderMapUrl || displacementMap
default:
throw new Error(`Invalid mode: ${mode}`)
}
}
/* ---------- SVG filter (edge-only displacement) ---------- */
const GlassFilter: React.FC<{ id: string; displacementScale: number; aberrationIntensity: number; width: number; height: number; mode: "standard" | "polar" | "prominent" | "shader"; shaderMapUrl?: string }> = ({
id,
displacementScale,
aberrationIntensity,
width,
height,
mode,
shaderMapUrl,
}) => (
<svg style={{ position: "absolute", width, height }} aria-hidden="true">
<defs>
<radialGradient id={`${id}-edge-mask`} cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="black" stopOpacity="0" />
<stop offset={`${Math.max(30, 80 - aberrationIntensity * 2)}%`} stopColor="black" stopOpacity="0" />
<stop offset="100%" stopColor="white" stopOpacity="1" />
</radialGradient>
<filter id={id} x="-35%" y="-35%" width="170%" height="170%" colorInterpolationFilters="sRGB">
<feImage id="feimage" x="0" y="0" width="100%" height="100%" result="DISPLACEMENT_MAP" href={getMap(mode, shaderMapUrl)} preserveAspectRatio="xMidYMid slice" />
{/* Create edge mask using the displacement map itself */}
<feColorMatrix
in="DISPLACEMENT_MAP"
type="matrix"
values="0.3 0.3 0.3 0 0
0.3 0.3 0.3 0 0
0.3 0.3 0.3 0 0
0 0 0 1 0"
result="EDGE_INTENSITY"
/>
<feComponentTransfer in="EDGE_INTENSITY" result="EDGE_MASK">
<feFuncA type="discrete" tableValues={`0 ${aberrationIntensity * 0.05} 1`} />
</feComponentTransfer>
{/* Original undisplaced image for center */}
<feOffset in="SourceGraphic" dx="0" dy="0" result="CENTER_ORIGINAL" />
{/* Red channel displacement with slight offset */}
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={displacementScale * (mode === "shader" ? 1 : -1)} xChannelSelector="R" yChannelSelector="B" result="RED_DISPLACED" />
<feColorMatrix
in="RED_DISPLACED"
type="matrix"
values="1 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 1 0"
result="RED_CHANNEL"
/>
{/* Green channel displacement */}
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={displacementScale * ((mode === "shader" ? 1 : -1) - aberrationIntensity * 0.05)} xChannelSelector="R" yChannelSelector="B" result="GREEN_DISPLACED" />
<feColorMatrix
in="GREEN_DISPLACED"
type="matrix"
values="0 0 0 0 0
0 1 0 0 0
0 0 0 0 0
0 0 0 1 0"
result="GREEN_CHANNEL"
/>
{/* Blue channel displacement with slight offset */}
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={displacementScale * ((mode === "shader" ? 1 : -1) - aberrationIntensity * 0.1)} xChannelSelector="R" yChannelSelector="B" result="BLUE_DISPLACED" />
<feColorMatrix
in="BLUE_DISPLACED"
type="matrix"
values="0 0 0 0 0
0 0 0 0 0
0 0 1 0 0
0 0 0 1 0"
result="BLUE_CHANNEL"
/>
{/* Combine all channels with screen blend mode for chromatic aberration */}
<feBlend in="GREEN_CHANNEL" in2="BLUE_CHANNEL" mode="screen" result="GB_COMBINED" />
<feBlend in="RED_CHANNEL" in2="GB_COMBINED" mode="screen" result="RGB_COMBINED" />
{/* Add slight blur to soften the aberration effect */}
<feGaussianBlur in="RGB_COMBINED" stdDeviation={Math.max(0.1, 0.5 - aberrationIntensity * 0.1)} result="ABERRATED_BLURRED" />
{/* Apply edge mask to aberration effect */}
<feComposite in="ABERRATED_BLURRED" in2="EDGE_MASK" operator="in" result="EDGE_ABERRATION" />
{/* Create inverted mask for center */}
<feComponentTransfer in="EDGE_MASK" result="INVERTED_MASK">
<feFuncA type="table" tableValues="1 0" />
</feComponentTransfer>
<feComposite in="CENTER_ORIGINAL" in2="INVERTED_MASK" operator="in" result="CENTER_CLEAN" />
{/* Combine edge aberration with clean center */}
<feComposite in="EDGE_ABERRATION" in2="CENTER_CLEAN" operator="over" />
</filter>
</defs>
</svg>
)
/* ---------- container ---------- */
const GlassContainer = forwardRef<
HTMLDivElement,
React.PropsWithChildren<{
className?: string
style?: React.CSSProperties
displacementScale?: number
blurAmount?: number
saturation?: number
aberrationIntensity?: number
mouseOffset?: { x: number; y: number }
onMouseLeave?: () => void
onMouseEnter?: () => void
onMouseDown?: () => void
onMouseUp?: () => void
active?: boolean
overLight?: boolean
cornerRadius?: number
padding?: string
glassSize?: { width: number; height: number }
onClick?: () => void
mode?: "standard" | "polar" | "prominent" | "shader"
}>
>(
(
{
children,
className = "",
style,
displacementScale = 25,
blurAmount = 12,
saturation = 180,
aberrationIntensity = 2,
onMouseEnter,
onMouseLeave,
onMouseDown,
onMouseUp,
active = false,
overLight = false,
cornerRadius = 999,
padding = "24px 32px",
glassSize = { width: 270, height: 69 },
onClick,
mode = "standard",
},
ref,
) => {
const filterId = useId()
const [shaderMapUrl, setShaderMapUrl] = useState<string>("")
const isFirefox = navigator.userAgent.toLowerCase().includes("firefox")
// Generate shader displacement map when in shader mode
useEffect(() => {
if (mode === "shader") {
const url = generateShaderDisplacementMap(glassSize.width, glassSize.height)
setShaderMapUrl(url)
}
}, [mode, glassSize.width, glassSize.height])
const backdropStyle = {
filter: isFirefox ? null : `url(#${filterId})`,
backdropFilter: `blur(${(overLight ? 12 : 4) + blurAmount * 32}px) saturate(${saturation}%)`,
}
return (
<div ref={ref} className={`relative ${className} ${active ? "active" : ""} ${Boolean(onClick) ? "cursor-pointer" : ""}`} style={style} onClick={onClick}>
<GlassFilter mode={mode} id={filterId} displacementScale={displacementScale} aberrationIntensity={aberrationIntensity} width={glassSize.width} height={glassSize.height} shaderMapUrl={shaderMapUrl} />
<div
className="glass"
style={{
borderRadius: `${cornerRadius}px`,
position: "relative",
display: "inline-flex",
alignItems: "center",
gap: "24px",
padding,
overflow: "hidden",
transition: "all 0.2s ease-in-out",
boxShadow: overLight ? "0px 16px 70px rgba(0, 0, 0, 0.75)" : "0px 12px 40px rgba(0, 0, 0, 0.25)",
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
>
{/* backdrop layer that gets wiggly */}
<span
className="glass__warp"
style={
{
...backdropStyle,
position: "absolute",
inset: "0",
} as CSSProperties
}
/>
{/* user content stays sharp */}
<div
className="transition-all duration-150 ease-in-out text-white"
style={{
position: "relative",
zIndex: 1,
font: "500 20px/1 system-ui",
textShadow: overLight ? "0px 2px 12px rgba(0, 0, 0, 0)" : "0px 2px 12px rgba(0, 0, 0, 0.4)",
}}
>
{children}
</div>
</div>
</div>
)
},
)
GlassContainer.displayName = "GlassContainer"
interface LiquidGlassProps {
children: React.ReactNode
displacementScale?: number
blurAmount?: number
saturation?: number
aberrationIntensity?: number
elasticity?: number
cornerRadius?: number
globalMousePos?: { x: number; y: number }
mouseOffset?: { x: number; y: number }
mouseContainer?: React.RefObject<HTMLElement | null> | null
className?: string
padding?: string
style?: React.CSSProperties
overLight?: boolean
mode?: "standard" | "polar" | "prominent" | "shader"
onClick?: () => void
}
export default function LiquidGlass({
children,
displacementScale = 70,
blurAmount = 0.0625,
saturation = 140,
aberrationIntensity = 2,
elasticity = 0.15,
cornerRadius = 999,
globalMousePos: externalGlobalMousePos,
mouseOffset: externalMouseOffset,
mouseContainer = null,
className = "",
padding = "24px 32px",
overLight = false,
style = {},
mode = "standard",
onClick,
}: LiquidGlassProps) {
const glassRef = useRef<HTMLDivElement>(null)
const [isHovered, setIsHovered] = useState(false)
const [isActive, setIsActive] = useState(false)
const [glassSize, setGlassSize] = useState({ width: 270, height: 69 })
const [internalGlobalMousePos, setInternalGlobalMousePos] = useState({ x: 0, y: 0 })
const [internalMouseOffset, setInternalMouseOffset] = useState({ x: 0, y: 0 })
// Use external mouse position if provided, otherwise use internal
const globalMousePos = externalGlobalMousePos || internalGlobalMousePos
const mouseOffset = externalMouseOffset || internalMouseOffset
// Internal mouse tracking
const handleMouseMove = useCallback(
(e: MouseEvent) => {
const container = mouseContainer?.current || glassRef.current
if (!container) {
return
}
const rect = container.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
setInternalMouseOffset({
x: ((e.clientX - centerX) / rect.width) * 100,
y: ((e.clientY - centerY) / rect.height) * 100,
})
setInternalGlobalMousePos({
x: e.clientX,
y: e.clientY,
})
},
[mouseContainer],
)
// Set up mouse tracking if no external mouse position is provided
useEffect(() => {
if (externalGlobalMousePos && externalMouseOffset) {
// External mouse tracking is provided, don't set up internal tracking
return
}
const container = mouseContainer?.current || glassRef.current
if (!container) {
return
}
container.addEventListener("mousemove", handleMouseMove)
return () => {
container.removeEventListener("mousemove", handleMouseMove)
}
}, [handleMouseMove, mouseContainer, externalGlobalMousePos, externalMouseOffset])
// Calculate directional scaling based on mouse position
const calculateDirectionalScale = useCallback(() => {
if (!globalMousePos.x || !globalMousePos.y || !glassRef.current) {
return "scale(1)"
}
const rect = glassRef.current.getBoundingClientRect()
const pillCenterX = rect.left + rect.width / 2
const pillCenterY = rect.top + rect.height / 2
const pillWidth = glassSize.width
const pillHeight = glassSize.height
const deltaX = globalMousePos.x - pillCenterX
const deltaY = globalMousePos.y - pillCenterY
// Calculate distance from mouse to pill edges (not center)
const edgeDistanceX = Math.max(0, Math.abs(deltaX) - pillWidth / 2)
const edgeDistanceY = Math.max(0, Math.abs(deltaY) - pillHeight / 2)
const edgeDistance = Math.sqrt(edgeDistanceX * edgeDistanceX + edgeDistanceY * edgeDistanceY)
// Activation zone: 200px from edges
const activationZone = 200
// If outside activation zone, no effect
if (edgeDistance > activationZone) {
return "scale(1)"
}
// Calculate fade-in factor (1 at edge, 0 at activation zone boundary)
const fadeInFactor = 1 - edgeDistance / activationZone
// Normalize the deltas for direction
const centerDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (centerDistance === 0) {
return "scale(1)"
}
const normalizedX = deltaX / centerDistance
const normalizedY = deltaY / centerDistance
// Calculate stretch factors with fade-in
const stretchIntensity = Math.min(centerDistance / 300, 1) * elasticity * fadeInFactor
// X-axis scaling: stretch horizontally when moving left/right, compress when moving up/down
const scaleX = 1 + Math.abs(normalizedX) * stretchIntensity * 0.3 - Math.abs(normalizedY) * stretchIntensity * 0.15
// Y-axis scaling: stretch vertically when moving up/down, compress when moving left/right
const scaleY = 1 + Math.abs(normalizedY) * stretchIntensity * 0.3 - Math.abs(normalizedX) * stretchIntensity * 0.15
return `scaleX(${Math.max(0.8, scaleX)}) scaleY(${Math.max(0.8, scaleY)})`
}, [globalMousePos, elasticity, glassSize])
// Helper function to calculate fade-in factor based on distance from element edges
const calculateFadeInFactor = useCallback(() => {
if (!globalMousePos.x || !globalMousePos.y || !glassRef.current) {
return 0
}
const rect = glassRef.current.getBoundingClientRect()
const pillCenterX = rect.left + rect.width / 2
const pillCenterY = rect.top + rect.height / 2
const pillWidth = glassSize.width
const pillHeight = glassSize.height
const edgeDistanceX = Math.max(0, Math.abs(globalMousePos.x - pillCenterX) - pillWidth / 2)
const edgeDistanceY = Math.max(0, Math.abs(globalMousePos.y - pillCenterY) - pillHeight / 2)
const edgeDistance = Math.sqrt(edgeDistanceX * edgeDistanceX + edgeDistanceY * edgeDistanceY)
const activationZone = 200
return edgeDistance > activationZone ? 0 : 1 - edgeDistance / activationZone
}, [globalMousePos, glassSize])
// Helper function to calculate elastic translation
const calculateElasticTranslation = useCallback(() => {
if (!glassRef.current) {
return { x: 0, y: 0 }
}
const fadeInFactor = calculateFadeInFactor()
const rect = glassRef.current.getBoundingClientRect()
const pillCenterX = rect.left + rect.width / 2
const pillCenterY = rect.top + rect.height / 2
return {
x: (globalMousePos.x - pillCenterX) * elasticity * 0.1 * fadeInFactor,
y: (globalMousePos.y - pillCenterY) * elasticity * 0.1 * fadeInFactor,
}
}, [globalMousePos, elasticity, calculateFadeInFactor])
// Update glass size whenever component mounts or window resizes
useEffect(() => {
const updateGlassSize = () => {
if (glassRef.current) {
const rect = glassRef.current.getBoundingClientRect()
setGlassSize({ width: rect.width, height: rect.height })
}
}
updateGlassSize()
window.addEventListener("resize", updateGlassSize)
return () => window.removeEventListener("resize", updateGlassSize)
}, [])
const transformStyle = `translate(calc(-50% + ${calculateElasticTranslation().x}px), calc(-50% + ${calculateElasticTranslation().y}px)) ${isActive && Boolean(onClick) ? "scale(0.96)" : calculateDirectionalScale()}`
const baseStyle = {
...style,
transform: transformStyle,
transition: "all ease-out 0.2s",
}
const positionStyles = {
position: baseStyle.position || "relative",
top: baseStyle.top || "50%",
left: baseStyle.left || "50%",
}
return (
<>
{/* Over light effect */}
<div
className={`bg-black transition-all duration-150 ease-in-out pointer-events-none ${overLight ? "opacity-20" : "opacity-0"}`}
style={{
...positionStyles,
height: glassSize.height,
width: glassSize.width,
borderRadius: `${cornerRadius}px`,
transform: baseStyle.transform,
transition: baseStyle.transition,
}}
/>
<div
className={`bg-black transition-all duration-150 ease-in-out pointer-events-none mix-blend-overlay ${overLight ? "opacity-100" : "opacity-0"}`}
style={{
...positionStyles,
height: glassSize.height,
width: glassSize.width,
borderRadius: `${cornerRadius}px`,
transform: baseStyle.transform,
transition: baseStyle.transition,
}}
/>
<GlassContainer
ref={glassRef}
className={className}
style={baseStyle}
cornerRadius={cornerRadius}
displacementScale={overLight ? displacementScale * 0.5 : displacementScale}
blurAmount={blurAmount}
saturation={saturation}
aberrationIntensity={aberrationIntensity}
glassSize={glassSize}
padding={padding}
mouseOffset={mouseOffset}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onMouseDown={() => setIsActive(true)}
onMouseUp={() => setIsActive(false)}
active={isActive}
overLight={overLight}
onClick={onClick}
mode={mode}
>
{children}
</GlassContainer>
{/* Border layer 1 - extracted from glass container */}
<span
style={{
...positionStyles,
height: glassSize.height,
width: glassSize.width,
borderRadius: `${cornerRadius}px`,
transform: baseStyle.transform,
transition: baseStyle.transition,
pointerEvents: "none",
mixBlendMode: "screen",
opacity: 0.2,
padding: "1.5px",
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
maskComposite: "exclude",
boxShadow: "0 0 0 0.5px rgba(255, 255, 255, 0.5) inset, 0 1px 3px rgba(255, 255, 255, 0.25) inset, 0 1px 4px rgba(0, 0, 0, 0.35)",
background: `linear-gradient(
${135 + mouseOffset.x * 1.2}deg,
rgba(255, 255, 255, 0.0) 0%,
rgba(255, 255, 255, ${0.12 + Math.abs(mouseOffset.x) * 0.008}) ${Math.max(10, 33 + mouseOffset.y * 0.3)}%,
rgba(255, 255, 255, ${0.4 + Math.abs(mouseOffset.x) * 0.012}) ${Math.min(90, 66 + mouseOffset.y * 0.4)}%,
rgba(255, 255, 255, 0.0) 100%
)`,
}}
/>
{/* Border layer 2 - duplicate with mix-blend-overlay */}
<span
style={{
...positionStyles,
height: glassSize.height,
width: glassSize.width,
borderRadius: `${cornerRadius}px`,
transform: baseStyle.transform,
transition: baseStyle.transition,
pointerEvents: "none",
mixBlendMode: "overlay",
padding: "1.5px",
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
maskComposite: "exclude",
boxShadow: "0 0 0 0.5px rgba(255, 255, 255, 0.5) inset, 0 1px 3px rgba(255, 255, 255, 0.25) inset, 0 1px 4px rgba(0, 0, 0, 0.35)",
background: `linear-gradient(
${135 + mouseOffset.x * 1.2}deg,
rgba(255, 255, 255, 0.0) 0%,
rgba(255, 255, 255, ${0.32 + Math.abs(mouseOffset.x) * 0.008}) ${Math.max(10, 33 + mouseOffset.y * 0.3)}%,
rgba(255, 255, 255, ${0.6 + Math.abs(mouseOffset.x) * 0.012}) ${Math.min(90, 66 + mouseOffset.y * 0.4)}%,
rgba(255, 255, 255, 0.0) 100%
)`,
}}
/>
{/* Hover effects */}
{Boolean(onClick) && (
<>
<div
style={{
...positionStyles,
height: glassSize.height,
width: glassSize.width + 1,
borderRadius: `${cornerRadius}px`,
transform: baseStyle.transform,
pointerEvents: "none",
transition: "all 0.2s ease-out",
opacity: isHovered || isActive ? 0.5 : 0,
backgroundImage: "radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%)",
mixBlendMode: "overlay",
}}
/>
<div
style={{
...positionStyles,
height: glassSize.height,
width: glassSize.width + 1,
borderRadius: `${cornerRadius}px`,
transform: baseStyle.transform,
pointerEvents: "none",
transition: "all 0.2s ease-out",
opacity: isActive ? 0.5 : 0,
backgroundImage: "radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 80%)",
mixBlendMode: "overlay",
}}
/>
<div
style={{
...baseStyle,
height: glassSize.height,
width: glassSize.width + 1,
borderRadius: `${cornerRadius}px`,
position: baseStyle.position,
top: baseStyle.top,
left: baseStyle.left,
pointerEvents: "none",
transition: "all 0.2s ease-out",
opacity: isHovered ? 0.4 : isActive ? 0.8 : 0,
backgroundImage: "radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%)",
mixBlendMode: "overlay",
}}
/>
</>
)}
</>
)
}

View File

@ -1,25 +0,0 @@
export interface Vec2 {
x: number;
y: number;
}
export interface ShaderOptions {
width: number;
height: number;
fragment: (uv: Vec2, mouse?: Vec2) => Vec2;
mousePosition?: Vec2;
}
export declare const fragmentShaders: {
liquidGlass: (uv: Vec2) => Vec2;
};
export type FragmentShaderType = keyof typeof fragmentShaders;
export declare class ShaderDisplacementGenerator {
private options;
private canvas;
private context;
private canvasDPI;
constructor(options: ShaderOptions);
updateShader(mousePosition?: Vec2): string;
destroy(): void;
getScale(): number;
}
//# sourceMappingURL=shader-utils.d.ts.map

View File

@ -1 +0,0 @@
{"version":3,"file":"shader-utils.d.ts","sourceRoot":"","sources":["../src/shader-utils.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,IAAI;IACnB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACV;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,IAAI,KAAK,IAAI,CAAA;IAC1C,aAAa,CAAC,EAAE,IAAI,CAAA;CACrB;AAsBD,eAAO,MAAM,eAAe;sBACR,IAAI,KAAG,IAAI;CAQ9B,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG,MAAM,OAAO,eAAe,CAAA;AAE7D,qBAAa,2BAA2B;IAK1B,OAAO,CAAC,OAAO;IAJ3B,OAAO,CAAC,MAAM,CAAmB;IACjC,OAAO,CAAC,OAAO,CAA0B;IACzC,OAAO,CAAC,SAAS,CAAI;gBAED,OAAO,EAAE,aAAa;IAa1C,YAAY,CAAC,aAAa,CAAC,EAAE,IAAI,GAAG,MAAM;IA6D1C,OAAO,IAAI,IAAI;IAIf,QAAQ,IAAI,MAAM;CAGnB"}

View File

@ -0,0 +1,134 @@
// Adapted from https://github.com/shuding/liquid-glass
export interface Vec2 {
x: number
y: number
}
export interface ShaderOptions {
width: number
height: number
fragment: (uv: Vec2, mouse?: Vec2) => Vec2
mousePosition?: Vec2
}
function smoothStep(a: number, b: number, t: number): number {
t = Math.max(0, Math.min(1, (t - a) / (b - a)))
return t * t * (3 - 2 * t)
}
function length(x: number, y: number): number {
return Math.sqrt(x * x + y * y)
}
function roundedRectSDF(x: number, y: number, width: number, height: number, radius: number): number {
const qx = Math.abs(x) - width + radius
const qy = Math.abs(y) - height + radius
return Math.min(Math.max(qx, qy), 0) + length(Math.max(qx, 0), Math.max(qy, 0)) - radius
}
function texture(x: number, y: number): Vec2 {
return { x, y }
}
// Shader fragment functions for different effects
export const fragmentShaders = {
liquidGlass: (uv: Vec2): Vec2 => {
const ix = uv.x - 0.5
const iy = uv.y - 0.5
const distanceToEdge = roundedRectSDF(ix, iy, 0.3, 0.2, 0.6)
const displacement = smoothStep(0.8, 0, distanceToEdge - 0.15)
const scaled = smoothStep(0, 1, displacement)
return texture(ix * scaled + 0.5, iy * scaled + 0.5)
},
}
export type FragmentShaderType = keyof typeof fragmentShaders
export class ShaderDisplacementGenerator {
private canvas: HTMLCanvasElement
private context: CanvasRenderingContext2D
private canvasDPI = 1
constructor(private options: ShaderOptions) {
this.canvas = document.createElement("canvas")
this.canvas.width = options.width * this.canvasDPI
this.canvas.height = options.height * this.canvasDPI
this.canvas.style.display = "none"
const context = this.canvas.getContext("2d")
if (!context) {
throw new Error("Could not get 2D context")
}
this.context = context
}
updateShader(mousePosition?: Vec2): string {
const w = this.options.width * this.canvasDPI
const h = this.options.height * this.canvasDPI
let maxScale = 0
const rawValues: number[] = []
// Calculate displacement values
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const uv: Vec2 = { x: x / w, y: y / h }
const pos = this.options.fragment(uv, mousePosition)
const dx = pos.x * w - x
const dy = pos.y * h - y
maxScale = Math.max(maxScale, Math.abs(dx), Math.abs(dy))
rawValues.push(dx, dy)
}
}
// Improved normalization to prevent artifacts while maintaining intensity
if (maxScale > 0) {
maxScale = Math.max(maxScale, 1) // Ensure minimum scale to prevent over-normalization
} else {
maxScale = 1
}
// Create ImageData and fill it
const imageData = this.context.createImageData(w, h)
const data = imageData.data
// Convert to image data with smoother normalization
let rawIndex = 0
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const dx = rawValues[rawIndex++]
const dy = rawValues[rawIndex++]
// Smooth the displacement values at edges to prevent hard transitions
const edgeDistance = Math.min(x, y, w - x - 1, h - y - 1)
const edgeFactor = Math.min(1, edgeDistance / 2) // Smooth within 2 pixels of edge
const smoothedDx = dx * edgeFactor
const smoothedDy = dy * edgeFactor
const r = smoothedDx / maxScale + 0.5
const g = smoothedDy / maxScale + 0.5
const pixelIndex = (y * w + x) * 4
data[pixelIndex] = Math.max(0, Math.min(255, r * 255)) // Red channel (X displacement)
data[pixelIndex + 1] = Math.max(0, Math.min(255, g * 255)) // Green channel (Y displacement)
data[pixelIndex + 2] = Math.max(0, Math.min(255, g * 255)) // Blue channel (Y displacement for SVG filter compatibility)
data[pixelIndex + 3] = 255 // Alpha channel
}
}
this.context.putImageData(imageData, 0, 0)
return this.canvas.toDataURL()
}
destroy(): void {
this.canvas.remove()
}
getScale(): number {
return this.canvasDPI
}
}

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,eAAe,40LAC+yL,CAAA;AAE30L,eAAO,MAAM,oBAAoB,4pLAC0nL,CAAA;AAE3pL,eAAO,MAAM,wBAAwB,uj5BACih5B,CAAA"}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB