forked from 77media/video-flow
第三版ing
This commit is contained in:
parent
bb65acfe5e
commit
d9ed81ca3b
@ -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 />;
|
||||||
}
|
}
|
||||||
5
app/create/work-flow/page.tsx
Normal file
5
app/create/work-flow/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import WorkFlow from '@/components/pages/work-flow';
|
||||||
|
|
||||||
|
export default function ScriptWorkFlowPage() {
|
||||||
|
return <WorkFlow />;
|
||||||
|
}
|
||||||
@ -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%;
|
||||||
|
}
|
||||||
@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
142
components/ai-suggestion-bar.tsx
Normal file
142
components/ai-suggestion-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
components/filmstrip-stepper.tsx
Normal file
224
components/filmstrip-stepper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div> */}
|
</div>
|
||||||
<TopBar collapsed={sidebarCollapsed} />
|
|
||||||
<main className="mt-16 h-[calc(100vh-4rem)] overflow-hidden">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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,94 +33,77 @@ 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 (
|
||||||
<div
|
<>
|
||||||
className={cn(
|
{/* Backdrop */}
|
||||||
'fixed left-0 top-0 z-50 h-full transition-all duration-300',
|
{!collapsed && (
|
||||||
collapsed ? 'w-16' : 'w-64'
|
<div
|
||||||
|
className="fixed inset-0 bg-[#000000bf] z-40"
|
||||||
|
onClick={() => onToggle(true)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<div className="flex h-full flex-col">
|
{/* Sidebar */}
|
||||||
{/* Logo */}
|
<div
|
||||||
<div className="flex h-16 items-center justify-between px-4 border-border">
|
className={cn(
|
||||||
{!collapsed && (
|
'fixed left-0 top-0 z-50 h-full w-64 bg-[#131416] transition-transform duration-300',
|
||||||
|
collapsed ? '-translate-x-full' : 'translate-x-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex h-16 items-center justify-between px-4">
|
||||||
<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(true)}
|
||||||
onClick={() => onToggle(!collapsed)}
|
className="button-NxtqWZ"
|
||||||
className="h-8 w-8 p-0"
|
>
|
||||||
>
|
<ArrowLeftToLine className="h-4 w-4" />
|
||||||
{collapsed ? (
|
</Button>
|
||||||
<ChevronRight className="h-4 w-4" />
|
</div>
|
||||||
) : (
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</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 && (
|
<div className="space-y-1">
|
||||||
<h2 className="mb-2 px-4 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
{section.items.map((item) => {
|
||||||
{section.title}
|
const isActive = pathname === item.href;
|
||||||
</h2>
|
return (
|
||||||
)}
|
<Link key={item.name} href={item.href}>
|
||||||
<div className="space-y-1">
|
<Button
|
||||||
{section.items.map((item) => {
|
variant={isActive ? 'secondary' : 'ghost'}
|
||||||
const isActive = pathname === item.href;
|
className={cn(
|
||||||
return (
|
'w-full justify-start px-4',
|
||||||
<Link key={item.name} href={item.href}>
|
isActive && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||||
<Button
|
)}
|
||||||
variant={isActive ? 'secondary' : 'ghost'}
|
>
|
||||||
className={cn(
|
<item.icon className="h-4 w-4" />
|
||||||
'w-full justify-start',
|
<span className="ml-2">{item.name}</span>
|
||||||
collapsed ? 'px-2' : 'px-4',
|
</Button>
|
||||||
isActive && 'bg-primary/10 text-primary hover:bg-primary/20'
|
</Link>
|
||||||
)}
|
);
|
||||||
>
|
})}
|
||||||
<item.icon className="h-4 w-4" />
|
</div>
|
||||||
{!collapsed && <span className="ml-2">{item.name}</span>}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
{index < navigationItems.length - 1 && (
|
))}
|
||||||
<Separator className="mt-4" />
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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">
|
||||||
<div className={`flex items-center space-x-4 cursor-pointer`} onClick={() => router.push('/')}>
|
<Button className='button-NxtqWZ' variant="ghost" size="sm" onClick={onToggleSidebar}>
|
||||||
<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>
|
<PanelsLeftBottom className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<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-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">
|
||||||
|
|||||||
@ -461,27 +461,27 @@ export function CreateToVideo() {
|
|||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{generateObj.video_info ? (
|
{generateObj.video_info ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{generateObj.video_info.roles.map((role: any, index: number) => (
|
{generateObj.video_info.roles.map((role: any, index: number) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className='flex items-start gap-3 p-3 rounded-lg bg-white/[0.03] hover:bg-white/[0.05] transition-colors duration-200 group'
|
className='flex items-start gap-3 p-3 rounded-lg bg-white/[0.03] hover:bg-white/[0.05] transition-colors duration-200 group'
|
||||||
>
|
>
|
||||||
<div className='flex-shrink-0'>
|
<div className='flex-shrink-0'>
|
||||||
<div className='w-[48px] h-[48px] rounded-full overflow-hidden border-2 border-white/10 group-hover:border-white/20 transition-colors duration-200'>
|
<div className='w-[48px] h-[48px] rounded-full overflow-hidden border-2 border-white/10 group-hover:border-white/20 transition-colors duration-200'>
|
||||||
<img
|
<img
|
||||||
src={role.avatar}
|
src={role.avatar}
|
||||||
alt={role.name}
|
alt={role.name}
|
||||||
className='w-full h-full object-cover'
|
className='w-full h-full object-cover'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className='flex-1 min-w-0'>
|
|
||||||
<div className='text-base font-medium mb-1 text-white/90'>{role.name}</div>
|
|
||||||
<div className='text-sm text-white/60 line-clamp-2'>{role.core_identity}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='text-base font-medium mb-1 text-white/90'>{role.name}</div>
|
||||||
|
<div className='text-sm text-white/60 line-clamp-2'>{role.core_identity}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[1, 2].map((i) => (
|
{[1, 2].map((i) => (
|
||||||
@ -492,7 +492,7 @@ export function CreateToVideo() {
|
|||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-24" />
|
||||||
<Skeleton className="h-3 w-full" />
|
<Skeleton className="h-3 w-full" />
|
||||||
<Skeleton className="h-3 w-3/4" />
|
<Skeleton className="h-3 w-3/4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -553,14 +553,14 @@ export function CreateToVideo() {
|
|||||||
{generateObj.video_info ? (
|
{generateObj.video_info ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'>场景</span>
|
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'>场景</span>
|
||||||
<p className='text-sm text-white/80 leading-relaxed'>{generateObj.video_info.sence}</p>
|
<p className='text-sm text-white/80 leading-relaxed'>{generateObj.video_info.sence}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'>风格</span>
|
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'>风格</span>
|
||||||
<p className='text-sm text-white/80 leading-relaxed'>{generateObj.video_info.style}</p>
|
<p className='text-sm text-white/80 leading-relaxed'>{generateObj.video_info.style}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@ -570,9 +570,9 @@ export function CreateToVideo() {
|
|||||||
<div>
|
<div>
|
||||||
<Skeleton className="h-4 w-16 mb-2" />
|
<Skeleton className="h-4 w-16 mb-2" />
|
||||||
<Skeleton className="h-20 w-full" />
|
<Skeleton className="h-20 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@ -594,7 +594,7 @@ export function CreateToVideo() {
|
|||||||
Scene {selectedVideoIndex + 1}
|
Scene {selectedVideoIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -626,14 +626,14 @@ export function CreateToVideo() {
|
|||||||
onClick={() => handleScriptClick('shot', generateObj.scripts[selectedVideoIndex].shot)}
|
onClick={() => handleScriptClick('shot', generateObj.scripts[selectedVideoIndex].shot)}
|
||||||
>
|
>
|
||||||
{generateObj.scripts[selectedVideoIndex].shot}
|
{generateObj.scripts[selectedVideoIndex].shot}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className='text-sm font-medium text-blue-400/90'>场景</span>
|
<span className='text-sm font-medium text-blue-400/90'>场景</span>
|
||||||
</div>
|
</div>
|
||||||
{editingField.type === 'frame' ? (
|
{editingField.type === 'frame' ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
value={editingField.value}
|
value={editingField.value}
|
||||||
@ -651,12 +651,12 @@ export function CreateToVideo() {
|
|||||||
{generateObj.scripts[selectedVideoIndex].frame}
|
{generateObj.scripts[selectedVideoIndex].frame}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className='text-sm font-medium text-blue-400/90'>氛围</span>
|
<span className='text-sm font-medium text-blue-400/90'>氛围</span>
|
||||||
</div>
|
</div>
|
||||||
{editingField.type === 'atmosphere' ? (
|
{editingField.type === 'atmosphere' ? (
|
||||||
<Input
|
<Input
|
||||||
value={editingField.value}
|
value={editingField.value}
|
||||||
@ -672,10 +672,10 @@ export function CreateToVideo() {
|
|||||||
onClick={() => handleScriptClick('atmosphere', generateObj.scripts[selectedVideoIndex].atmosphere)}
|
onClick={() => handleScriptClick('atmosphere', generateObj.scripts[selectedVideoIndex].atmosphere)}
|
||||||
>
|
>
|
||||||
{generateObj.scripts[selectedVideoIndex].atmosphere}
|
{generateObj.scripts[selectedVideoIndex].atmosphere}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@ -685,9 +685,9 @@ export function CreateToVideo() {
|
|||||||
<div>
|
<div>
|
||||||
<Skeleton className="h-4 w-16 mb-2" />
|
<Skeleton className="h-4 w-16 mb-2" />
|
||||||
<Skeleton className="h-20 w-full" />
|
<Skeleton className="h-20 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@ -698,48 +698,48 @@ export function CreateToVideo() {
|
|||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="p-4 border-b border-white/10">
|
<div className="p-4 border-b border-white/10">
|
||||||
<h3 className="text-base font-medium">分镜视频</h3>
|
<h3 className="text-base font-medium">分镜视频</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div
|
<div
|
||||||
ref={videosContainerRef}
|
ref={videosContainerRef}
|
||||||
className='flex gap-4'
|
className='flex gap-4'
|
||||||
>
|
>
|
||||||
{generateObj.scripts ? (
|
{generateObj.scripts ? (
|
||||||
generateObj.scripts.map((script: any, index: number) => {
|
generateObj.scripts.map((script: any, index: number) => {
|
||||||
const video = generateObj.scene_videos?.find((v: any) => v.id === index);
|
const video = generateObj.scene_videos?.find((v: any) => v.id === index);
|
||||||
const isSelected = selectedVideoIndex === index;
|
const isSelected = selectedVideoIndex === index;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
id={`video-${index}`}
|
id={`video-${index}`}
|
||||||
className={`flex-shrink-0 w-[320px] aspect-video rounded-lg overflow-hidden relative cursor-pointer transition-all duration-300
|
className={`flex-shrink-0 w-[320px] aspect-video rounded-lg overflow-hidden relative cursor-pointer transition-all duration-300
|
||||||
${isSelected ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-[#191B1E]' : 'hover:ring-2 hover:ring-white/20'}`}
|
${isSelected ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-[#191B1E]' : 'hover:ring-2 hover:ring-white/20'}`}
|
||||||
onClick={() => handleVideoSelect(index)}
|
onClick={() => handleVideoSelect(index)}
|
||||||
>
|
>
|
||||||
{video ? (
|
{video ? (
|
||||||
<>
|
<>
|
||||||
<video
|
<video
|
||||||
src={video.video_url}
|
src={video.video_url}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
controls={isSelected}
|
controls={isSelected}
|
||||||
/>
|
/>
|
||||||
{!isSelected && (
|
{!isSelected && (
|
||||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||||
<Play className="w-8 h-8 text-white/90" />
|
<Play className="w-8 h-8 text-white/90" />
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full bg-white/5 flex items-center justify-center">
|
|
||||||
<Loader2 className="w-8 h-8 text-white/40 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute top-2 right-2 px-2 py-1 rounded-full bg-black/60 text-xs text-white/80">
|
|
||||||
Scene {index + 1}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-white/5 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 text-white/40 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-2 right-2 px-2 py-1 rounded-full bg-black/60 text-xs text-white/80">
|
||||||
|
Scene {index + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
@ -747,12 +747,12 @@ export function CreateToVideo() {
|
|||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<Skeleton key={i} className="w-[320px] aspect-video rounded-lg" />
|
<Skeleton key={i} className="w-[320px] aspect-video rounded-lg" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
@ -767,7 +767,7 @@ export function CreateToVideo() {
|
|||||||
Scene {selectedVideoIndex + 1}
|
Scene {selectedVideoIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Sheet>
|
<Sheet>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-white/5">
|
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-white/5">
|
||||||
@ -779,7 +779,7 @@ export function CreateToVideo() {
|
|||||||
<SheetTitle className="text-xl font-semibold text-white">高级设置</SheetTitle>
|
<SheetTitle className="text-xl font-semibold text-white">高级设置</SheetTitle>
|
||||||
<div className="text-sm text-white/60">
|
<div className="text-sm text-white/60">
|
||||||
{selectedVideoIndex !== null ? `Scene ${selectedVideoIndex + 1} 的高级设置选项` : '全局高级设置选项'}
|
{selectedVideoIndex !== null ? `Scene ${selectedVideoIndex + 1} 的高级设置选项` : '全局高级设置选项'}
|
||||||
</div>
|
</div>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="mt-6 space-y-6">
|
<div className="mt-6 space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -788,12 +788,12 @@ export function CreateToVideo() {
|
|||||||
<div className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
<div className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
||||||
<div className="text-sm font-medium mb-1">色调</div>
|
<div className="text-sm font-medium mb-1">色调</div>
|
||||||
<div className="text-xs text-white/60">调整画面整体色调</div>
|
<div className="text-xs text-white/60">调整画面整体色调</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
<div className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
||||||
<div className="text-sm font-medium mb-1">对比度</div>
|
<div className="text-sm font-medium mb-1">对比度</div>
|
||||||
<div className="text-xs text-white/60">调整画面明暗对比</div>
|
<div className="text-xs text-white/60">调整画面明暗对比</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -802,13 +802,13 @@ export function CreateToVideo() {
|
|||||||
<div className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
<div className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
||||||
<div className="text-sm font-medium mb-1">转场类型</div>
|
<div className="text-sm font-medium mb-1">转场类型</div>
|
||||||
<div className="text-xs text-white/60">选择画面切换效果</div>
|
<div className="text-xs text-white/60">选择画面切换效果</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
<div className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
||||||
<div className="text-sm font-medium mb-1">转场时长</div>
|
<div className="text-sm font-medium mb-1">转场时长</div>
|
||||||
<div className="text-xs text-white/60">设置转场持续时间</div>
|
<div className="text-xs text-white/60">设置转场持续时间</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-sm font-medium text-white/90">音频设置</h4>
|
<h4 className="text-sm font-medium text-white/90">音频设置</h4>
|
||||||
@ -816,17 +816,17 @@ export function CreateToVideo() {
|
|||||||
<div className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
<div className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
||||||
<div className="text-sm font-medium mb-1">背景音乐</div>
|
<div className="text-sm font-medium mb-1">背景音乐</div>
|
||||||
<div className="text-xs text-white/60">添加或调整背景音乐</div>
|
<div className="text-xs text-white/60">添加或调整背景音乐</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
<div className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
||||||
<div className="text-sm font-medium mb-1">音量调节</div>
|
<div className="text-sm font-medium mb-1">音量调节</div>
|
||||||
<div className="text-xs text-white/60">调整各音轨音量</div>
|
<div className="text-xs text-white/60">调整各音轨音量</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{generateObj.scripts && selectedVideoIndex !== null ? (
|
{generateObj.scripts && selectedVideoIndex !== null ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -863,7 +863,7 @@ export function CreateToVideo() {
|
|||||||
/>
|
/>
|
||||||
<div className="mt-2 text-right text-sm text-white/60">
|
<div className="mt-2 text-right text-sm text-white/60">
|
||||||
{volume[selectedVideoIndex] || 100}%
|
{volume[selectedVideoIndex] || 100}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 转场效果 */}
|
{/* 转场效果 */}
|
||||||
@ -881,7 +881,7 @@ export function CreateToVideo() {
|
|||||||
onClick={() => handleTransitionChange(selectedVideoIndex, effect)}
|
onClick={() => handleTransitionChange(selectedVideoIndex, effect)}
|
||||||
>
|
>
|
||||||
{effect}
|
{effect}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -907,13 +907,13 @@ export function CreateToVideo() {
|
|||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||||
<Play className="w-8 h-8 text-white/90" />
|
<Play className="w-8 h-8 text-white/90" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Skeleton className="h-8 w-full" />
|
<Skeleton className="h-8 w-full" />
|
||||||
@ -921,7 +921,7 @@ export function CreateToVideo() {
|
|||||||
<Skeleton className="h-8 w-1/2" />
|
<Skeleton className="h-8 w-1/2" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
@ -943,7 +943,7 @@ export function CreateToVideo() {
|
|||||||
}}
|
}}
|
||||||
onMouseDown={handleDragStart}
|
onMouseDown={handleDragStart}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={`
|
className={`
|
||||||
flex items-center justify-center w-12 h-12 rounded-full
|
flex items-center justify-center w-12 h-12 rounded-full
|
||||||
bg-gradient-to-br from-blue-400/20 to-blue-600/20 backdrop-blur-lg
|
bg-gradient-to-br from-blue-400/20 to-blue-600/20 backdrop-blur-lg
|
||||||
@ -959,8 +959,8 @@ export function CreateToVideo() {
|
|||||||
<Pause className="w-5 h-5 text-white/70 hover:text-white/90 transition-colors" />
|
<Pause className="w-5 h-5 text-white/70 hover:text-white/90 transition-colors" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* 工具栏 */}
|
{/* 工具栏 */}
|
||||||
|
|||||||
373
components/pages/create-to-video2.tsx
Normal file
373
components/pages/create-to-video2.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,41 +1,127 @@
|
|||||||
"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");
|
||||||
return (
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
<div className="min-h-[100%] flex relative">
|
|
||||||
{/* 工具栏-列表形式切换 */}
|
// 示例视频数据
|
||||||
<div className="absolute top-0 right-6 w-[8rem] flex z-index-2 justify-end">
|
const videos = [
|
||||||
<div role="group" className="flex p-1 bg-white/20 backdrop-blur-[15px] w-full rounded-[3rem]">
|
{
|
||||||
<button
|
id: '1',
|
||||||
className={`flex items-center justify-center h-10 transition-all duration-300 w-1/2 rounded-[3rem]
|
url: 'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4',
|
||||||
${activeTool === "stretch" ? "bg-white/20 text-white" : "hover:bg-white/10 text-white/30"}`}
|
title: '视频标题 1'
|
||||||
onClick={() => setActiveTool("stretch")}
|
},
|
||||||
>
|
{
|
||||||
<StretchHorizontal className="w-5 h-5" />
|
id: '2',
|
||||||
</button>
|
url: 'https://cdn.qikongjian.com/videos/1750389908_37d4fffa-8516-43a3-a423-fc0274f40e8a_text_to_video_0.mp4',
|
||||||
<button
|
title: '视频标题 2'
|
||||||
className={`flex items-center justify-center h-10 transition-all duration-300 w-1/2 rounded-[3rem]
|
},
|
||||||
${activeTool === "table" ? "bg-white/20 text-white" : "hover:bg-white/10 text-white/30"}`}
|
{
|
||||||
onClick={() => setActiveTool("table")}
|
id: '3',
|
||||||
>
|
url: 'https://cdn.qikongjian.com/videos/1750384661_d8e30b79-828e-48cd-9025-ab62a996717c_text_to_video_0.mp4',
|
||||||
<Table className="w-5 h-5" />
|
title: '视频标题 3'
|
||||||
</button>
|
},
|
||||||
</div>
|
{
|
||||||
</div>
|
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 (
|
||||||
|
<div className="min-h-screen bg-[var(--background)]" ref={containerRef}>
|
||||||
|
<div className="min-h-[100%] flex relative">
|
||||||
|
{/* 工具栏-列表形式切换 */}
|
||||||
|
<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]">
|
||||||
|
<button
|
||||||
|
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"}`}
|
||||||
|
onClick={() => setActiveTool("stretch")}
|
||||||
|
>
|
||||||
|
<AlignHorizontalSpaceAround className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex items-center justify-center h-10 transition-all duration-300 w-1/2 rounded-[3rem]
|
||||||
|
${activeTool === "table" ? "bg-white/20 text-white" : "hover:bg-white/10 text-white/30"}`}
|
||||||
|
onClick={() => setActiveTool("table")}
|
||||||
|
>
|
||||||
|
<Table className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 屏风式视频布局 */}
|
||||||
|
<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" />
|
||||||
|
<div className="btn-text">Create Project</div>
|
||||||
|
</div>
|
||||||
|
</LiquidGlass>
|
||||||
|
</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")}>
|
|
||||||
{/* 添加图标 */}
|
|
||||||
<Plus className="w-6 h-6 icon" />
|
|
||||||
<div className="btn-text">Create Project</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
243
components/pages/script-overview.tsx
Normal file
243
components/pages/script-overview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
components/pages/script-work-flow.tsx
Normal file
197
components/pages/script-work-flow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
components/pages/storyboard-view.tsx
Normal file
223
components/pages/storyboard-view.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
components/pages/style/create-to-video2.css
Normal file
132
components/pages/style/create-to-video2.css
Normal 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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
133
components/pages/style/work-flow.css
Normal file
133
components/pages/style/work-flow.css
Normal 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%;
|
||||||
|
}
|
||||||
332
components/pages/work-flow.tsx
Normal file
332
components/pages/work-flow.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
components/script-overview/floating-toolbar.tsx
Normal file
60
components/script-overview/floating-toolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
components/script-overview/scene-card-list.tsx
Normal file
48
components/script-overview/scene-card-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
components/script-overview/scene-card.tsx
Normal file
310
components/script-overview/scene-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
components/script-overview/scene-filmstrip.tsx
Normal file
129
components/script-overview/scene-filmstrip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
components/script-overview/script-meta-info.tsx
Normal file
122
components/script-overview/script-meta-info.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
components/storyboard/storyboard-card-list.tsx
Normal file
44
components/storyboard/storyboard-card-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
components/storyboard/storyboard-card.tsx
Normal file
310
components/storyboard/storyboard-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
}
|
}
|
||||||
115
components/video-grid-layout.tsx
Normal file
115
components/video-grid-layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
components/video-screen-layout.tsx
Normal file
125
components/video-screen-layout.tsx
Normal 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
1327
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -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",
|
||||||
|
|||||||
27
plugins/liquid-glass/index.d.ts
vendored
27
plugins/liquid-glass/index.d.ts
vendored
@ -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
|
|
||||||
@ -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
612
plugins/liquid-glass/index.tsx
Normal file
612
plugins/liquid-glass/index.tsx
Normal 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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
plugins/liquid-glass/shader-utils.d.ts
vendored
25
plugins/liquid-glass/shader-utils.d.ts
vendored
@ -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
|
|
||||||
@ -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"}
|
|
||||||
134
plugins/liquid-glass/shader-utils.ts
Normal file
134
plugins/liquid-glass/shader-utils.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
4
plugins/liquid-glass/utils.d.ts
vendored
4
plugins/liquid-glass/utils.d.ts
vendored
File diff suppressed because one or more lines are too long
@ -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"}
|
|
||||||
8
plugins/liquid-glass/utils.ts
Normal file
8
plugins/liquid-glass/utils.ts
Normal file
File diff suppressed because one or more lines are too long
BIN
public/assets/empty_video.png
Normal file
BIN
public/assets/empty_video.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Loading…
x
Reference in New Issue
Block a user