This commit is contained in:
海龙 2025-07-30 11:04:45 +08:00
commit 70b6ce5d4c
11 changed files with 773 additions and 257 deletions

20
app/model/types.ts Normal file
View 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;
}

View File

@ -0,0 +1,69 @@
'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 && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<motion.div
key={r_key}
className="cursor-grab active:cursor-grabbing"
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>
{/* 添加遮罩层,点击时关闭面板 */}
<motion.div
className="fixed inset-0 bg-black/20 backdrop-blur-sm -z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
</div>
)}
</AnimatePresence>
);
}

View 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>
);
}

View File

@ -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}

View File

@ -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"
>
<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>
{/* 重新生成按钮 */}
<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 }}
>
<RefreshCw className="w-3.5 h-3.5" />
<span>{isRegenerating ? "生成中..." : "重新生成"}</span>
</motion.button>
</div>
);
}

View File

@ -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
});
setNewTag('');
const handleReplaceCharacter = (url: string) => {
setCurrentRole({
...currentRole,
url: url
});
// 自动调整文本框高度
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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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}
/>
);
}

View File

@ -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"
>
<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>
{/* 重新生成按钮 */}
<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 }}
>
<RefreshCw className="w-3.5 h-3.5" />
<span>{isRegenerating ? "生成中..." : "重新生成"}</span>
</motion.button>
</div>
);
}

View File

@ -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>
);
}