forked from 77media/video-flow
设计角色、场景tab交互
This commit is contained in:
parent
39a3de215c
commit
12e9aeb88e
20
app/model/types.ts
Normal file
20
app/model/types.ts
Normal file
@ -0,0 +1,20 @@
|
||||
// 基础类型
|
||||
export interface Shot {
|
||||
id: string;
|
||||
videoUrl?: string;
|
||||
thumbnailUrl: string;
|
||||
isGenerating: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
60
components/ui/FloatingGlassPanel.tsx
Normal file
60
components/ui/FloatingGlassPanel.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
type FloatingGlassPanelProps = {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
children: ReactNode;
|
||||
width?: string;
|
||||
r_key?: string | number;
|
||||
};
|
||||
|
||||
export default function FloatingGlassPanel({ open, onClose, children, width = '320px', r_key }: FloatingGlassPanelProps) {
|
||||
// 定义弹出动画
|
||||
const bounceAnimation = {
|
||||
scale: [0.95, 1.02, 0.98, 1],
|
||||
rotate: [0, -1, 1, -1, 0],
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key={r_key}
|
||||
className="fixed top-1/2 left-1/2 z-50 cursor-grab active:cursor-grabbing"
|
||||
style={{ transform: 'translate(-50%, -50%)', pointerEvents: 'auto' }}
|
||||
drag
|
||||
dragElastic={0.2}
|
||||
dragMomentum={false}
|
||||
initial={{ opacity: 0, scale: 0.95, rotate: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
...bounceAnimation,
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 0.95, rotate: 0 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.19, 1, 0.22, 1],
|
||||
scale: {
|
||||
duration: 0.4,
|
||||
times: [0, 0.3, 0.6, 1]
|
||||
},
|
||||
rotate: {
|
||||
duration: 0.4,
|
||||
times: [0, 0.2, 0.4, 0.6, 1]
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ width }}
|
||||
className="rounded-xl backdrop-blur-md bg-white/10 border border-white/20 shadow-xl text-white p-4"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
68
components/ui/ImageBlurTransition.tsx
Normal file
68
components/ui/ImageBlurTransition.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
// Image3DFlipper.tsx
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type ImageBlurTransitionProps = {
|
||||
className?: string;
|
||||
src: string;
|
||||
alt?: string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
};
|
||||
|
||||
export default function ImageBlurTransition({ src, alt = '', width = 480, height = 300, className }: ImageBlurTransitionProps) {
|
||||
const [current, setCurrent] = useState(src);
|
||||
const [isFlipping, setIsFlipping] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (src !== current) {
|
||||
setIsFlipping(true);
|
||||
const timeout = setTimeout(() => {
|
||||
setCurrent(src);
|
||||
setIsFlipping(false);
|
||||
}, 150); // 时长 = exit 动画时长
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [src, current]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative rounded-xl shadow-xl ${className}`}
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
perspective: 1000, // 关键:提供 3D 深度
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.img
|
||||
key={current}
|
||||
src={current}
|
||||
alt={alt}
|
||||
className="absolute w-full h-auto object-cover rounded-xl"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
filter: 'blur(8px)',
|
||||
scale: 1.02,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)',
|
||||
scale: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
filter: 'blur(4px)',
|
||||
scale: 0.98,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -18,6 +18,8 @@ interface ImageWaveProps {
|
||||
autoAnimate?: boolean;
|
||||
// 自动动画间隔时间(ms)
|
||||
autoAnimateInterval?: number;
|
||||
// 是否开启点击事件
|
||||
onClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div<{ width?: string; height?: string }>`
|
||||
@ -122,6 +124,10 @@ const Item = styled.div<{ width?: string; height?: string }>`
|
||||
transform: translateZ(calc(var(--index) * 7.8));
|
||||
margin: 0.45vw;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
filter: inherit;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ImageWave: React.FC<ImageWaveProps> = ({
|
||||
@ -133,8 +139,10 @@ export const ImageWave: React.FC<ImageWaveProps> = ({
|
||||
gap,
|
||||
autoAnimate = false,
|
||||
autoAnimateInterval = 2000,
|
||||
onClick,
|
||||
}) => {
|
||||
const [currentExpandedItem, setCurrentExpandedItem] = useState<number | null>(null);
|
||||
const [currentSelectedIndex, setCurrentSelectedIndex] = useState<number | null>(null);
|
||||
const itemsRef = useRef<HTMLDivElement>(null);
|
||||
const autoAnimateRef = useRef<number | null>(null);
|
||||
const currentIndexRef = useRef<number>(0);
|
||||
@ -144,6 +152,8 @@ export const ImageWave: React.FC<ImageWaveProps> = ({
|
||||
setCurrentExpandedItem(null);
|
||||
} else {
|
||||
setCurrentExpandedItem(index);
|
||||
setCurrentSelectedIndex(index);
|
||||
onClick?.(index);
|
||||
}
|
||||
};
|
||||
|
||||
@ -186,7 +196,7 @@ export const ImageWave: React.FC<ImageWaveProps> = ({
|
||||
key={index}
|
||||
width={itemWidth}
|
||||
height={itemHeight}
|
||||
className={`item ${currentExpandedItem === index ? 'expanded' : ''}`}
|
||||
className={`item ${currentExpandedItem === index ? 'expanded' : ''} ${currentSelectedIndex === index ? 'selected' : ''}`}
|
||||
style={{ backgroundImage: `url(${image})` }}
|
||||
onClick={() => handleItemClick(index)}
|
||||
tabIndex={0}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sparkles, X, Plus } from 'lucide-react';
|
||||
import { Sparkles, X, Plus, RefreshCw } from 'lucide-react';
|
||||
import { cn } from "@/public/lib/utils";
|
||||
import ContentEditable from 'react-contenteditable';
|
||||
|
||||
@ -17,6 +17,7 @@ interface CharacterEditorProps {
|
||||
initialDescription?: string;
|
||||
onDescriptionChange?: (description: string) => void;
|
||||
onAttributesChange?: (attributes: CharacterAttribute[]) => void;
|
||||
onReplaceCharacter?: (url: string) => void;
|
||||
}
|
||||
|
||||
const mockParse = (text: string): CharacterAttribute[] => {
|
||||
@ -37,9 +38,11 @@ export default function CharacterEditor({
|
||||
initialDescription = "一个银白短发的精灵女性,大约20岁,肤色白皙,身材高挑,身着白色连衣裙",
|
||||
onDescriptionChange,
|
||||
onAttributesChange,
|
||||
onReplaceCharacter,
|
||||
}: CharacterEditorProps) {
|
||||
const [inputText, setInputText] = useState(initialDescription);
|
||||
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [customTags, setCustomTags] = useState<string[]>([]);
|
||||
const [newTag, setNewTag] = useState("");
|
||||
const attributesRef = useRef<CharacterAttribute[]>(mockParse(initialDescription));
|
||||
@ -96,8 +99,16 @@ export default function CharacterEditor({
|
||||
onAttributesChange?.(attributesRef.current);
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
setIsRegenerating(true);
|
||||
setTimeout(() => {
|
||||
onReplaceCharacter?.("https://c.huiying.video/images/0411ac7b-ab7e-4a17-ab4f-6880a28f8915.jpg");
|
||||
setIsRegenerating(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 border border-white/10 relative p-2 pb-12 rounded-[0.5rem]">
|
||||
{/* 自由输入区域 */}
|
||||
<div className="relative">
|
||||
<ContentEditable
|
||||
@ -105,7 +116,7 @@ export default function CharacterEditor({
|
||||
html={formatTextToHtml(inputText)}
|
||||
onChange={handleTextChange}
|
||||
className="block w-full min-h-[120px] bg-white/5 backdrop-blur-md p-4 text-white/90
|
||||
rounded-lg border border-white/10 focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
rounded-lg border-unset outline-none pb-12
|
||||
whitespace-pre-wrap break-words"
|
||||
placeholder="用自然语言描述角色,比如:一个身穿红袍的精灵女性..."
|
||||
/>
|
||||
@ -154,60 +165,19 @@ export default function CharacterEditor({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 自定义标签区域 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{customTags.map((tag) => (
|
||||
<motion.div
|
||||
key={tag}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="group flex items-center gap-1 px-3 py-1.5 bg-white/5
|
||||
rounded-full hover:bg-white/10 transition-colors"
|
||||
{/* 重新生成按钮 */}
|
||||
<motion.button
|
||||
onClick={handleRegenerate}
|
||||
disabled={isRegenerating}
|
||||
className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-1.5
|
||||
bg-blue-500/10 hover:bg-blue-500/20 text-blue-500 rounded-full
|
||||
transition-colors text-xs disabled:opacity-50"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<span className="text-sm text-white/90">{tag}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCustomTags(tags => tags.filter(t => t !== tag));
|
||||
setInputText((text: string) => text.replace(new RegExp(`[,。]?${tag}[,。]?`), ""));
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3 text-white/70" />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && newTag.trim()) {
|
||||
setCustomTags(tags => [...tags, newTag.trim()]);
|
||||
setInputText((text: string) => text + (text.endsWith("。") ? "" : ",") + newTag.trim());
|
||||
setNewTag("");
|
||||
}
|
||||
}}
|
||||
placeholder="添加自定义标签..."
|
||||
className="flex-1 px-3 py-2 bg-white/5 border-none rounded-lg text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-white/30"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newTag.trim()) {
|
||||
setCustomTags(tags => [...tags, newTag.trim()]);
|
||||
setInputText((text: string) => text + (text.endsWith("。") ? "" : ",") + newTag.trim());
|
||||
setNewTag("");
|
||||
}
|
||||
}}
|
||||
className="p-2 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<span>{isRegenerating ? "生成中..." : "重新生成"}</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Upload, Library, Play, Pause, RefreshCw, Wand2, Users, Check, Sparkles, Plus, X } from 'lucide-react';
|
||||
import { Upload, Library, Play, Pause, RefreshCw, Wand2, Users, Check, ReplaceAll, X } from 'lucide-react';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import { GlassIconButton } from './glass-icon-button';
|
||||
import { ReplaceCharacterModal } from './replace-character-modal';
|
||||
import { Slider } from './slider';
|
||||
import CharacterEditor from './character-editor';
|
||||
import ImageBlurTransition from './ImageBlurTransition';
|
||||
import FloatingGlassPanel from './FloatingGlassPanel';
|
||||
import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel';
|
||||
import { ImageWave } from '@/components/ui/ImageWave';
|
||||
|
||||
interface Appearance {
|
||||
hairStyle: string;
|
||||
@ -54,6 +58,27 @@ interface CharacterTabContentProps {
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
const imageUrls = [
|
||||
'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',
|
||||
'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',
|
||||
'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',
|
||||
'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',
|
||||
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-1.jpg',
|
||||
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-2.jpg',
|
||||
];
|
||||
|
||||
export function CharacterTabContent({
|
||||
taskSketch,
|
||||
currentRoleIndex,
|
||||
@ -64,67 +89,48 @@ export function CharacterTabContent({
|
||||
const [activeReplaceMethod, setActiveReplaceMethod] = useState('upload');
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [localRole, setLocalRole] = useState(mockRole);
|
||||
const [currentRole, setCurrentRole] = useState(roles[currentRoleIndex]);
|
||||
const [isReplacePanelOpen, setIsReplacePanelOpen] = useState(false);
|
||||
const [replacePanelKey, setReplacePanelKey] = useState(0);
|
||||
const [ignoreReplace, setIgnoreReplace] = useState(false);
|
||||
const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false);
|
||||
const [replaceLibraryKey, setReplaceLibraryKey] = useState(0);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// 处理标签添加
|
||||
const handleAddTag = () => {
|
||||
if (newTag.trim() && !localRole.tags.includes(newTag.trim())) {
|
||||
const newTagText = newTag.trim();
|
||||
// 更新标签数组
|
||||
const updatedTags = [...localRole.tags, newTagText];
|
||||
// 更新角色描述文本
|
||||
const updatedDescription = localRole.roleDescription + (localRole.roleDescription ? ',' : '') + newTagText;
|
||||
|
||||
setLocalRole({
|
||||
...localRole,
|
||||
tags: updatedTags,
|
||||
roleDescription: updatedDescription
|
||||
const handleReplaceCharacter = (url: string) => {
|
||||
setCurrentRole({
|
||||
...currentRole,
|
||||
url: url
|
||||
});
|
||||
setNewTag('');
|
||||
|
||||
// 自动调整文本框高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
||||
setIsReplacePanelOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmReplace = (selectedShots: string[], addToLibrary: boolean) => {
|
||||
// 处理替换确认逻辑
|
||||
console.log('Selected shots:', selectedShots);
|
||||
console.log('Add to library:', addToLibrary);
|
||||
setIsReplacePanelOpen(false);
|
||||
};
|
||||
|
||||
const handleCloseReplacePanel = () => {
|
||||
setIsReplacePanelOpen(false);
|
||||
setIgnoreReplace(true);
|
||||
};
|
||||
|
||||
const handleChangeRole = (index: number) => {
|
||||
if (currentRole.url !== roles[currentRoleIndex].url && !ignoreReplace) {
|
||||
// 提示 角色已修改,弹出替换角色面板
|
||||
if (isReplacePanelOpen) {
|
||||
setReplacePanelKey(replacePanelKey + 1);
|
||||
} else {
|
||||
setIsReplacePanelOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理标签删除
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setLocalRole({
|
||||
...localRole,
|
||||
tags: localRole.tags.filter(tag => tag !== tagToRemove)
|
||||
});
|
||||
};
|
||||
|
||||
// 处理年龄滑块变化
|
||||
const handleAgeChange = (value: number[]) => {
|
||||
setLocalRole({
|
||||
...localRole,
|
||||
age: value[0]
|
||||
});
|
||||
};
|
||||
|
||||
// 处理描述更新
|
||||
const handleDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setLocalRole({
|
||||
...localRole,
|
||||
roleDescription: e.target.value
|
||||
});
|
||||
|
||||
// 自动调整文本框高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
// 新增智能优化处理函数
|
||||
const handleSmartOptimize = () => {
|
||||
console.log('Optimizing character description...');
|
||||
// TODO: 调用 AI 优化接口
|
||||
onSketchSelect(index);
|
||||
setCurrentRole(roles[index]);
|
||||
};
|
||||
|
||||
// 如果没有角色数据,显示占位内容
|
||||
@ -137,9 +143,6 @@ export function CharacterTabContent({
|
||||
);
|
||||
}
|
||||
|
||||
// 获取当前选中的角色
|
||||
const currentRole = roles[currentRoleIndex];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 上部分:角色缩略图 */}
|
||||
@ -158,7 +161,7 @@ export function CharacterTabContent({
|
||||
'aspect-[9/16]',
|
||||
currentRoleIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
||||
)}
|
||||
onClick={() => onSketchSelect(index)}
|
||||
onClick={() => handleChangeRole(index)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
@ -183,48 +186,7 @@ export function CharacterTabContent({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{/* 左列:角色预览 */}
|
||||
<div className="space-y-4">
|
||||
{/* 角色预览图 */}
|
||||
<div className="w-full mx-auto rounded-lg overflow-hidden relative group">
|
||||
<img
|
||||
src={currentRole.url}
|
||||
alt={currentRole.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent
|
||||
opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<GlassIconButton
|
||||
icon={Wand2}
|
||||
onClick={() => console.log('regenerate character')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => console.log('Apply changes')}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-green-500/10 hover:bg-green-500/20
|
||||
text-green-500 rounded-lg transition-colors"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
<span>应用</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => console.log('Regenerate')}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20
|
||||
text-blue-500 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>重新生成</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右列:角色信息 */}
|
||||
{/* 左列:角色信息 */}
|
||||
<div className="space-y-4">
|
||||
<CharacterEditor
|
||||
initialDescription={localRole.roleDescription}
|
||||
@ -265,9 +227,86 @@ export function CharacterTabContent({
|
||||
|
||||
setLocalRole(newRole);
|
||||
}}
|
||||
onReplaceCharacter={(url) => {
|
||||
handleReplaceCharacter(url);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右列:角色预览 */}
|
||||
<div className="space-y-4">
|
||||
{/* 角色预览图 */}
|
||||
<div className="w-full h-full mx-auto rounded-lg relative group">
|
||||
<ImageBlurTransition
|
||||
src={currentRole.url}
|
||||
alt={currentRole.name}
|
||||
width='100%'
|
||||
height='100%'
|
||||
/>
|
||||
{/* 应用角色按钮 */}
|
||||
<div className='absolute top-3 right-3 flex gap-2'>
|
||||
<motion.button
|
||||
className="p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setIsReplaceLibraryOpen(true)}
|
||||
>
|
||||
<ReplaceAll className="w-4 h-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
|
||||
|
||||
<FloatingGlassPanel open={isReplacePanelOpen} width='500px' r_key={replacePanelKey}>
|
||||
<ReplaceCharacterPanel
|
||||
shots={mockShots}
|
||||
character={mockCharacter}
|
||||
onClose={() => handleCloseReplacePanel()}
|
||||
onConfirm={handleConfirmReplace}
|
||||
/>
|
||||
</FloatingGlassPanel>
|
||||
|
||||
{/* 从角色库中选择角色 */}
|
||||
<FloatingGlassPanel open={isReplaceLibraryOpen} width='90vw' r_key={replaceLibraryKey}>
|
||||
{/* 标题 从角色库中选择角色 */}
|
||||
<div className="text-2xl font-semibold text-white text-center">Role Library</div>
|
||||
{/* 内容 */}
|
||||
<ImageWave
|
||||
images={imageUrls}
|
||||
containerWidth="90vw"
|
||||
containerHeight="calc(var(--index) * 15)"
|
||||
itemWidth="calc(var(--index) * 2)"
|
||||
itemHeight="calc(var(--index) * 12)"
|
||||
gap="0.1rem"
|
||||
autoAnimate={true}
|
||||
autoAnimateInterval={100}
|
||||
onClick={(index) => {
|
||||
console.log('index', index);
|
||||
}}
|
||||
/>
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
onClick={() => setIsReplaceLibraryOpen(false)}
|
||||
className="px-4 py-2 rounded-lg bg-white/10 text-white hover:bg-white/20 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('replace');
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
替换
|
||||
</button>
|
||||
</div>
|
||||
</FloatingGlassPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
components/ui/replace-character-panel.tsx
Normal file
64
components/ui/replace-character-panel.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { ReplacePanel } from './replace-panel';
|
||||
import { Shot, Character } from '@/app/model/types';
|
||||
|
||||
interface ReplaceCharacterPanelProps {
|
||||
shots: Shot[];
|
||||
character: Character;
|
||||
onClose: () => void;
|
||||
onConfirm: (selectedShots: string[], addToLibrary: boolean) => void;
|
||||
}
|
||||
|
||||
// Mock数据
|
||||
export const mockShots: Shot[] = [
|
||||
{
|
||||
id: '1',
|
||||
thumbnailUrl: '/assets/3dr_chihiro.png',
|
||||
videoUrl: 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1-0-20250725023719.mp4',
|
||||
isGenerating: false,
|
||||
isSelected: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
thumbnailUrl: '/assets/3dr_mono.png',
|
||||
isGenerating: true,
|
||||
isSelected: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
thumbnailUrl: '/assets/3dr_howlcastle.png',
|
||||
videoUrl: 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ3-0-20250725023725.mp4',
|
||||
isGenerating: false,
|
||||
isSelected: true,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
thumbnailUrl: '/assets/3dr_spirited.jpg',
|
||||
isGenerating: true,
|
||||
isSelected: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockCharacter: Character = {
|
||||
id: '1',
|
||||
name: '千寻',
|
||||
avatarUrl: '/assets/3dr_chihiro.png',
|
||||
};
|
||||
|
||||
export function ReplaceCharacterPanel({
|
||||
shots = mockShots,
|
||||
character = mockCharacter,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ReplaceCharacterPanelProps) {
|
||||
return (
|
||||
<ReplacePanel
|
||||
title="替换新形象"
|
||||
shots={shots}
|
||||
item={character}
|
||||
showAddToLibrary={true}
|
||||
addToLibraryText="新形象同步添加至角色库"
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
195
components/ui/replace-panel.tsx
Normal file
195
components/ui/replace-panel.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check, X, CircleAlert } from 'lucide-react';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
|
||||
// 定义类型
|
||||
interface Shot {
|
||||
id: string;
|
||||
videoUrl?: string;
|
||||
thumbnailUrl: string;
|
||||
isGenerating: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
interface ReplacePanelProps {
|
||||
title: string;
|
||||
shots: Shot[];
|
||||
item: Item;
|
||||
showAddToLibrary?: boolean;
|
||||
addToLibraryText?: string;
|
||||
onClose: () => void;
|
||||
onConfirm: (selectedShots: string[], addToLibrary: boolean) => void;
|
||||
}
|
||||
|
||||
export function ReplacePanel({
|
||||
title,
|
||||
shots,
|
||||
item,
|
||||
showAddToLibrary = false,
|
||||
addToLibraryText = "同步添加至库",
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ReplacePanelProps) {
|
||||
const [selectedShots, setSelectedShots] = useState(
|
||||
shots.filter(shot => shot.isSelected).map(shot => shot.id)
|
||||
);
|
||||
const [addToLibrary, setAddToLibrary] = useState(true);
|
||||
const [hoveredVideoId, setHoveredVideoId] = useState<string | null>(null);
|
||||
const videoRefs = useRef<{ [key: string]: HTMLVideoElement }>({});
|
||||
|
||||
const handleShotToggle = (shotId: string) => {
|
||||
setSelectedShots(prev =>
|
||||
prev.includes(shotId)
|
||||
? prev.filter(id => id !== shotId)
|
||||
: [...prev, shotId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAllShots = (checked: boolean) => {
|
||||
setSelectedShots(checked ? shots.map(shot => shot.id) : []);
|
||||
};
|
||||
|
||||
const handleMouseEnter = (shotId: string) => {
|
||||
setHoveredVideoId(shotId);
|
||||
if (videoRefs.current[shotId]) {
|
||||
videoRefs.current[shotId].play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (shotId: string) => {
|
||||
setHoveredVideoId(null);
|
||||
if (videoRefs.current[shotId]) {
|
||||
videoRefs.current[shotId].pause();
|
||||
videoRefs.current[shotId].currentTime = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selectedShots, addToLibrary);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 w-full max-w-5xl">
|
||||
{/* 标题 */}
|
||||
<div className="text-2xl font-semibold text-white">{title}</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-red-400">
|
||||
<CircleAlert className="w-4 h-4" />
|
||||
该内容出现在 <span className="text-blue-500">{shots.length}</span> 个分镜中,替换后将影响如下分镜
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedShots.length === shots.length}
|
||||
onChange={(e) => handleSelectAllShots(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-white/20"
|
||||
/>
|
||||
<label className="text-white/80">全选</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分镜展示区 */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-white/80 text-sm">选择需要替换的分镜:</div>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 hide-scrollbar">
|
||||
{shots.map((shot) => (
|
||||
<motion.div
|
||||
key={shot.id}
|
||||
className={cn(
|
||||
'relative flex-shrink-0 rounded-lg overflow-hidden cursor-pointer',
|
||||
'aspect-video border-2',
|
||||
hoveredVideoId === shot.id ? 'w-64' : 'w-32',
|
||||
selectedShots.includes(shot.id)
|
||||
? 'border-blue-500'
|
||||
: 'border-transparent hover:border-blue-500/50'
|
||||
)}
|
||||
onClick={() => handleShotToggle(shot.id)}
|
||||
onMouseEnter={() => handleMouseEnter(shot.id)}
|
||||
onMouseLeave={() => handleMouseLeave(shot.id)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{shot.videoUrl && (
|
||||
<video
|
||||
ref={el => {
|
||||
if (el) videoRefs.current[shot.id] = el;
|
||||
}}
|
||||
src={shot.videoUrl}
|
||||
className="w-full h-full object-cover"
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
{!shot.videoUrl && (
|
||||
<img
|
||||
src={shot.thumbnailUrl}
|
||||
alt={`Shot ${shot.id}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{shot.isGenerating && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<div className="text-white text-sm">生成中...</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedShots.includes(shot.id) && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<Check className="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览信息 */}
|
||||
<div className="flex items-center gap-4 bg-white/5 rounded-lg p-4">
|
||||
<img
|
||||
src={item.avatarUrl}
|
||||
alt={item.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="text-white">{item.name}</div>
|
||||
</div>
|
||||
|
||||
{/* 同步到库选项 */}
|
||||
{showAddToLibrary && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addToLibrary}
|
||||
onChange={(e) => setAddToLibrary(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-white/20"
|
||||
/>
|
||||
<label className="text-white/80">{addToLibraryText}</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg bg-white/10 text-white hover:bg-white/20 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
替换
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
components/ui/replace-scene-panel.tsx
Normal file
76
components/ui/replace-scene-panel.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { ReplacePanel } from './replace-panel';
|
||||
|
||||
interface Shot {
|
||||
id: string;
|
||||
videoUrl?: string;
|
||||
thumbnailUrl: string;
|
||||
isGenerating: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
interface Scene {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
interface ReplaceScenePanelProps {
|
||||
shots: Shot[];
|
||||
scene: Scene;
|
||||
onClose: () => void;
|
||||
onConfirm: (selectedShots: string[], addToLibrary: boolean) => void;
|
||||
}
|
||||
|
||||
// Mock数据
|
||||
export const mockShots: Shot[] = [
|
||||
{
|
||||
id: '1',
|
||||
thumbnailUrl: '/assets/3dr_chihiro.png',
|
||||
videoUrl: 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1-0-20250725023719.mp4',
|
||||
isGenerating: false,
|
||||
isSelected: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
thumbnailUrl: '/assets/3dr_mono.png',
|
||||
isGenerating: true,
|
||||
isSelected: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
thumbnailUrl: '/assets/3dr_howlcastle.png',
|
||||
videoUrl: 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ3-0-20250725023725.mp4',
|
||||
isGenerating: false,
|
||||
isSelected: true,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
thumbnailUrl: '/assets/3dr_spirited.jpg',
|
||||
isGenerating: true,
|
||||
isSelected: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockScene: Scene = {
|
||||
id: '1',
|
||||
name: '场景 1',
|
||||
avatarUrl: '/assets/3dr_howlbg.jpg',
|
||||
};
|
||||
|
||||
export function ReplaceScenePanel({
|
||||
shots = mockShots,
|
||||
scene = mockScene,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ReplaceScenePanelProps) {
|
||||
return (
|
||||
<ReplacePanel
|
||||
title="替换新场景"
|
||||
shots={shots}
|
||||
item={scene}
|
||||
showAddToLibrary={false}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sparkles, X, Plus, Clock, MapPin, Sun, Moon, Cloud, CloudRain, CloudSnow, CloudLightning, Palette } from 'lucide-react';
|
||||
import { Sparkles, X, Plus, Clock, MapPin, Sun, Moon, Cloud, CloudRain, CloudSnow, CloudLightning, Palette, RefreshCw } from 'lucide-react';
|
||||
import { cn } from "@/public/lib/utils";
|
||||
import ContentEditable from 'react-contenteditable';
|
||||
|
||||
@ -38,6 +38,7 @@ interface SceneEditorProps {
|
||||
onDescriptionChange?: (description: string) => void;
|
||||
onAttributesChange?: (attributes: SceneAttribute[]) => void;
|
||||
onEnvironmentChange?: (environment: SceneEnvironment) => void;
|
||||
onReplaceScene?: (url: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@ -117,10 +118,12 @@ export default function SceneEditor({
|
||||
onDescriptionChange,
|
||||
onAttributesChange,
|
||||
onEnvironmentChange,
|
||||
onReplaceScene,
|
||||
className
|
||||
}: SceneEditorProps) {
|
||||
const [inputText, setInputText] = useState(initialDescription);
|
||||
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [customTags, setCustomTags] = useState<string[]>([]);
|
||||
const [newTag, setNewTag] = useState("");
|
||||
const parseResult = useRef(mockParse(initialDescription));
|
||||
@ -190,8 +193,16 @@ export default function SceneEditor({
|
||||
onEnvironmentChange?.(newParseResult.environment);
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
setIsRegenerating(true);
|
||||
setTimeout(() => {
|
||||
onReplaceScene?.("https://c.huiying.video/images/0411ac7b-ab7e-4a17-ab4f-6880a28f8915.jpg");
|
||||
setIsRegenerating(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<div className={cn("space-y-2 border border-white/10 relative p-2 pb-12 rounded-[0.5rem]", className)}>
|
||||
{/* 自由输入区域 */}
|
||||
<div className="relative">
|
||||
<ContentEditable
|
||||
@ -199,7 +210,7 @@ export default function SceneEditor({
|
||||
html={formatTextToHtml(inputText)}
|
||||
onChange={handleTextChange}
|
||||
className="block w-full min-h-[120px] bg-white/5 backdrop-blur-md p-4 text-white/90
|
||||
rounded-lg border border-white/10 focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
rounded-lg border-unset outline-none pb-12
|
||||
whitespace-pre-wrap break-words"
|
||||
placeholder="用自然语言描述场景,比如:阳光透过窗户洒在教室的地板上..."
|
||||
/>
|
||||
@ -249,60 +260,19 @@ export default function SceneEditor({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 自定义标签区域 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{customTags.map((tag) => (
|
||||
<motion.div
|
||||
key={tag}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="group flex items-center gap-1 px-3 py-1.5 bg-white/5
|
||||
rounded-full hover:bg-white/10 transition-colors"
|
||||
{/* 重新生成按钮 */}
|
||||
<motion.button
|
||||
onClick={handleRegenerate}
|
||||
disabled={isRegenerating}
|
||||
className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-1.5
|
||||
bg-blue-500/10 hover:bg-blue-500/20 text-blue-500 rounded-full
|
||||
transition-colors text-xs disabled:opacity-50"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<span className="text-sm text-white/90">{tag}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCustomTags(tags => tags.filter(t => t !== tag));
|
||||
setInputText((text: string) => text.replace(new RegExp(`[,。]?${tag}[,。]?`), ""));
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3 text-white/70" />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && newTag.trim()) {
|
||||
setCustomTags(tags => [...tags, newTag.trim()]);
|
||||
setInputText((text: string) => text + (text.endsWith("。") ? "" : ",") + newTag.trim());
|
||||
setNewTag("");
|
||||
}
|
||||
}}
|
||||
placeholder="添加自定义标签..."
|
||||
className="flex-1 px-3 py-2 bg-white/5 border-none rounded-lg text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-white/30"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newTag.trim()) {
|
||||
setCustomTags(tags => [...tags, newTag.trim()]);
|
||||
setInputText((text: string) => text + (text.endsWith("。") ? "" : ",") + newTag.trim());
|
||||
setNewTag("");
|
||||
}
|
||||
}}
|
||||
className="p-2 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<span>{isRegenerating ? "生成中..." : "重新生成"}</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Trash2, RefreshCw, Sun, Moon, Cloud, CloudRain, CloudSnow, CloudLightning, Sparkles, Clock, MapPin, Palette, Check, Plus } from 'lucide-react';
|
||||
import { Trash2, RefreshCw, Sun, Moon, Cloud, CloudRain, CloudSnow, CloudLightning, Sparkles, Clock, MapPin, Palette, Check, Plus, ReplaceAll } from 'lucide-react';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import SceneEditor from './scene-editor';
|
||||
import FloatingGlassPanel from './FloatingGlassPanel';
|
||||
import { ReplaceScenePanel, mockShots } from './replace-scene-panel';
|
||||
|
||||
interface SceneEnvironment {
|
||||
time: {
|
||||
@ -74,6 +76,10 @@ export function SceneTabContent({
|
||||
|
||||
const [localSketch, setLocalSketch] = useState(mockSketch);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isReplacePanelOpen, setIsReplacePanelOpen] = useState(false);
|
||||
const [replacePanelKey, setReplacePanelKey] = useState(0);
|
||||
const [ignoreReplace, setIgnoreReplace] = useState(false);
|
||||
const [currentScene, setCurrentScene] = useState(taskSketch[currentSketchIndex]);
|
||||
|
||||
// 天气图标映射
|
||||
const weatherIcons = {
|
||||
@ -136,6 +142,41 @@ export function SceneTabContent({
|
||||
}
|
||||
}, [currentSketchIndex]);
|
||||
|
||||
const handleReplaceScene = (url: string) => {
|
||||
setCurrentScene({
|
||||
...currentScene,
|
||||
url: url
|
||||
});
|
||||
|
||||
setIsReplacePanelOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmReplace = (selectedShots: string[], addToLibrary: boolean) => {
|
||||
// 处理替换确认逻辑
|
||||
console.log('Selected shots:', selectedShots);
|
||||
console.log('Add to library:', addToLibrary);
|
||||
setIsReplacePanelOpen(false);
|
||||
};
|
||||
|
||||
const handleCloseReplacePanel = () => {
|
||||
setIsReplacePanelOpen(false);
|
||||
setIgnoreReplace(true);
|
||||
};
|
||||
|
||||
const handleChangeScene = (index: number) => {
|
||||
if (currentScene?.url !== taskSketch[currentSketchIndex]?.url && !ignoreReplace) {
|
||||
// 提示 场景已修改,弹出替换场景面板
|
||||
if (isReplacePanelOpen) {
|
||||
setReplacePanelKey(replacePanelKey + 1);
|
||||
} else {
|
||||
setIsReplacePanelOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSketchSelect(index);
|
||||
setCurrentScene(taskSketch[index]);
|
||||
};
|
||||
|
||||
// 如果没有数据,显示空状态
|
||||
if (sketches.length === 0) {
|
||||
return (
|
||||
@ -166,7 +207,7 @@ export function SceneTabContent({
|
||||
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group',
|
||||
currentSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
||||
)}
|
||||
onClick={() => onSketchSelect(index)}
|
||||
onClick={() => handleChangeScene(index)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
@ -194,7 +235,7 @@ export function SceneTabContent({
|
||||
))}
|
||||
</div>
|
||||
{/* 新增占位符 */}
|
||||
<motion.div
|
||||
{/* <motion.div
|
||||
className={cn(
|
||||
'relative flex-shrink-0 w-32 aspect-video rounded-lg cursor-pointer',
|
||||
'bg-white/5 hover:bg-white/10 transition-colors',
|
||||
@ -209,7 +250,7 @@ export function SceneTabContent({
|
||||
<Plus className="w-6 h-6" />
|
||||
<span className="text-xs">添加场景</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -228,7 +269,7 @@ export function SceneTabContent({
|
||||
'flex-shrink-0 cursor-pointer transition-all duration-300',
|
||||
isActive ? 'text-white' : 'text-white/50 hover:text-white/80'
|
||||
)}
|
||||
onClick={() => onSketchSelect(index)}
|
||||
onClick={() => handleChangeScene(index)}
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: isActive ? 1.02 : 1,
|
||||
@ -262,41 +303,6 @@ export function SceneTabContent({
|
||||
>
|
||||
{/* 左列:脚本编辑器 */}
|
||||
<div className="space-y-4">
|
||||
{/* 选中的分镜预览 */}
|
||||
<motion.div
|
||||
className="aspect-video rounded-lg overflow-hidden"
|
||||
layoutId={`sketch-preview-${currentSketchIndex}`}
|
||||
>
|
||||
<img
|
||||
src={sketches[currentSketchIndex]?.url}
|
||||
alt={`Sketch ${currentSketchIndex + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
{/* 操作按钮 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => console.log('Apply changes')}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-green-500/10 hover:bg-green-500/20
|
||||
text-green-500 rounded-lg transition-colors"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
<span>应用</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => console.log('Regenerate')}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20
|
||||
text-blue-500 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Regenerate</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右列:环境设置 */}
|
||||
<div className="space-y-4">
|
||||
{/* 使用新的场景编辑器组件 */}
|
||||
<SceneEditor
|
||||
initialDescription={localSketch.script}
|
||||
onDescriptionChange={(description) => {
|
||||
@ -305,10 +311,39 @@ export function SceneTabContent({
|
||||
script: description
|
||||
});
|
||||
}}
|
||||
onReplaceScene={handleReplaceScene}
|
||||
className="min-h-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右列:场景预览 */}
|
||||
<div className="space-y-4">
|
||||
<motion.div
|
||||
className="aspect-video rounded-lg overflow-hidden relative"
|
||||
layoutId={`sketch-preview-${currentSketchIndex}`}
|
||||
>
|
||||
<img
|
||||
src={currentScene?.url || sketches[currentSketchIndex]?.url}
|
||||
alt={`Scene ${currentSketchIndex + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 替换场景面板 */}
|
||||
<FloatingGlassPanel open={isReplacePanelOpen} width='500px' r_key={replacePanelKey}>
|
||||
<ReplaceScenePanel
|
||||
shots={mockShots}
|
||||
scene={{
|
||||
id: currentSketchIndex.toString(),
|
||||
name: `场景 ${currentSketchIndex + 1}`,
|
||||
avatarUrl: currentScene?.url || sketches[currentSketchIndex]?.url
|
||||
}}
|
||||
onClose={handleCloseReplacePanel}
|
||||
onConfirm={handleConfirmReplace}
|
||||
/>
|
||||
</FloatingGlassPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user