forked from 77media/video-flow
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
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;
|
|
skinTone: string;
|
|
facialFeatures: string;
|
|
bodyType: string;
|
|
}
|
|
|
|
interface Role {
|
|
name: string;
|
|
url: string;
|
|
sound: string;
|
|
soundDescription: string;
|
|
roleDescription: string;
|
|
age: number;
|
|
gender: 'male' | 'female' | 'other';
|
|
ethnicity: string;
|
|
appearance: Appearance;
|
|
// 新增标签数组
|
|
tags: string[];
|
|
}
|
|
|
|
// Mock 数据
|
|
const mockRole: Role = {
|
|
name: "青春女学生",
|
|
url: "/assets/3dr_chihiro.png",
|
|
sound: "",
|
|
soundDescription: "",
|
|
roleDescription: "一位充满活力和梦想的高中女生,蓝色长发随风飘扬,眼神中透露着对未来的憧憬。她身着整洁的校服,举止优雅而不失活力。",
|
|
age: 16,
|
|
gender: 'female',
|
|
ethnicity: '亚洲人',
|
|
appearance: {
|
|
hairStyle: "鲜艳蓝色长发",
|
|
skinTone: "白皙",
|
|
facialFeatures: "大眼睛,清秀五官",
|
|
bodyType: "苗条"
|
|
},
|
|
tags: ['高中生', '校服', '蓝色长发', '大眼睛', '清秀五官', '苗条']
|
|
};
|
|
|
|
interface CharacterTabContentProps {
|
|
taskSketch: any[];
|
|
currentRoleIndex: number;
|
|
onSketchSelect: (index: number) => void;
|
|
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,
|
|
onSketchSelect,
|
|
roles = [mockRole]
|
|
}: CharacterTabContentProps) {
|
|
const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false);
|
|
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 handleReplaceCharacter = (url: string) => {
|
|
setCurrentRole({
|
|
...currentRole,
|
|
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 handleChangeRole = (index: number) => {
|
|
if (currentRole.url !== roles[currentRoleIndex].url && !ignoreReplace) {
|
|
// 提示 角色已修改,弹出替换角色面板
|
|
if (isReplacePanelOpen) {
|
|
setReplacePanelKey(replacePanelKey + 1);
|
|
} else {
|
|
setIsReplacePanelOpen(true);
|
|
}
|
|
return;
|
|
}
|
|
onSketchSelect(index);
|
|
setCurrentRole(roles[index]);
|
|
};
|
|
|
|
// 如果没有角色数据,显示占位内容
|
|
if (!roles || roles.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
|
|
<Users className="w-16 h-16 mb-4" />
|
|
<p>No character data</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
{/* 上部分:角色缩略图 */}
|
|
<motion.div
|
|
className="space-y-6"
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
>
|
|
<div className="relative">
|
|
<div className="flex gap-4 overflow-x-auto p-2 hide-scrollbar">
|
|
{roles.map((role, index) => (
|
|
<motion.div
|
|
key={`role-${index}`}
|
|
className={cn(
|
|
'relative flex-shrink-0 w-24 rounded-lg overflow-hidden cursor-pointer',
|
|
'aspect-[9/16]',
|
|
currentRoleIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
|
)}
|
|
onClick={() => handleChangeRole(index)}
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
>
|
|
<img
|
|
src={role.url}
|
|
alt={role.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
|
|
<span className="text-xs text-white/90 line-clamp-1">{role.name}</span>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* 下部分:角色详情 */}
|
|
<motion.div
|
|
className="grid grid-cols-2 gap-6 p-4"
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.2 }}
|
|
>
|
|
|
|
{/* 左列:角色预览 */}
|
|
<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)}
|
|
>
|
|
<Library className="w-4 h-4" />
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
{/* 右列:角色信息 */}
|
|
<div className="space-y-4">
|
|
<CharacterEditor
|
|
initialDescription={localRole.roleDescription}
|
|
onDescriptionChange={(description) => {
|
|
setLocalRole({
|
|
...localRole,
|
|
roleDescription: description
|
|
});
|
|
}}
|
|
onAttributesChange={(attributes) => {
|
|
const newRole = { ...localRole };
|
|
|
|
attributes.forEach(attr => {
|
|
switch (attr.key) {
|
|
case 'age':
|
|
newRole.age = parseInt(attr.value);
|
|
break;
|
|
case 'gender':
|
|
if (attr.value === '男性') {
|
|
newRole.gender = 'male';
|
|
} else if (attr.value === '女性') {
|
|
newRole.gender = 'female';
|
|
} else {
|
|
newRole.gender = 'other';
|
|
}
|
|
break;
|
|
case 'hair':
|
|
newRole.appearance.hairStyle = attr.value;
|
|
break;
|
|
case 'skin':
|
|
newRole.appearance.skinTone = attr.value;
|
|
break;
|
|
case 'build':
|
|
newRole.appearance.bodyType = attr.value;
|
|
break;
|
|
}
|
|
});
|
|
|
|
setLocalRole(newRole);
|
|
}}
|
|
onReplaceCharacter={(url) => {
|
|
handleReplaceCharacter(url);
|
|
}}
|
|
/>
|
|
{/* 重新生成按钮、替换形象按钮 */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<motion.button
|
|
onClick={() => console.log('Replace')}
|
|
className="flex items-center justify-center gap-2 px-4 py-3 bg-pink-500/10 hover:bg-pink-500/20
|
|
text-pink-500 rounded-lg transition-colors"
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<ReplaceAll className="w-4 h-4" />
|
|
<span>Replace</span>
|
|
</motion.button>
|
|
<motion.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"
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
<span>Regenerate</span>
|
|
</motion.button>
|
|
</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>
|
|
);
|
|
}
|