video-flow-b/components/ui/character-tab-content.tsx
2025-08-12 17:39:12 +08:00

439 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ImageUp, Library, Play, Pause, RefreshCw, Wand2, Users, Check, ReplaceAll, X, TriangleAlert } from 'lucide-react';
import { cn } from '@/public/lib/utils';
import { CharacterEditor } from './character-editor';
import ImageBlurTransition from './ImageBlurTransition';
import FloatingGlassPanel from './FloatingGlassPanel';
import { ReplaceCharacterPanel } from './replace-character-panel';
import { CharacterLibrarySelector } from './character-library-selector';
import HorizontalScroller from './HorizontalScroller';
import { useEditData } from '@/components/pages/work-flow/use-edit-data';
import { useSearchParams } from 'next/navigation';
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[];
}
export function CharacterTabContent({
taskSketch,
currentRoleIndex,
onSketchSelect,
roles = [mockRole]
}: CharacterTabContentProps) {
const [isReplacePanelOpen, setIsReplacePanelOpen] = useState(false);
const [replacePanelKey, setReplacePanelKey] = useState(0);
const [ignoreReplace, setIgnoreReplace] = useState(false);
const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false);
const [isRemindReplacePanelOpen, setIsRemindReplacePanelOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [enableAnimation, setEnableAnimation] = useState(true);
const [showAddToLibrary, setShowAddToLibrary] = useState(true);
const characterEditorRef = useRef<any>(null);
const [isInitialized, setIsInitialized] = useState(false);
const [isRegenerate, setIsRegenerate] = useState(false);
const {
loading,
roleData,
selectRole,
selectedRole,
userRoleLibrary,
optimizeRoleText,
updateRoleText,
regenerateRole,
// role shot
shotSelectionList,
selectedRoleId,
isAllVideoSegmentSelected,
selectedVideoSegmentCount,
fetchRoleShots,
toggleSelectAllShots,
toggleShotSelection,
applyRoleToSelectedShots,
clearShotSelection
} = useEditData('role');
const searchParams = useSearchParams();
const episodeId = searchParams.get('episodeId');
useEffect(() => {
console.log('-==========roleData===========-', roleData);
// 只在初始化且有角色数据时执行
if (!isInitialized && roleData.length > 0) {
selectRole(roleData[0]);
setIsInitialized(true);
}
}, [roleData, isInitialized]);
useEffect(() => {
console.log('获取选中项数据', selectedRole);
}, [selectedRole]);
const handleSmartPolish = (text: string) => {
// 然后调用优化角色文本
optimizeRoleText(text);
};
const handleStartReplaceCharacter = () => {
// 获取当前角色对应的视频片段
fetchRoleShots(selectedRole?.id || '');
// 打开替换角色面板
setIsReplacePanelOpen(true);
};
const handleConfirmGotoReplace = () => {
setIsRemindReplacePanelOpen(false);
setIsReplacePanelOpen(true);
};
const handleCloseRemindReplacePanel = () => {
setIsRemindReplacePanelOpen(false);
setIgnoreReplace(true);
};
const handleReplaceCharacter = (url: string) => {
setEnableAnimation(true);
// 替换角色
setIsReplacePanelOpen(true);
};
// President Alfred King Samuel Ryan
const handleConfirmReplace = (selectedShots: string[], addToLibrary: boolean) => {
// 处理替换确认逻辑
console.log('Selected shots:', selectedShots);
console.log('Add to library:', addToLibrary);
setIsReplacePanelOpen(false);
};
// 取消替换
const handleCloseReplacePanel = () => {
setIsReplacePanelOpen(false);
};
const handleChangeRole = (index: number) => {
const oldRole = roleData.find(role => role.id === selectedRole?.id);
console.log('切换角色前对比', selectedRole?.imageUrl, oldRole?.imageUrl);
if (selectedRole?.imageUrl !== oldRole?.imageUrl && !ignoreReplace) {
// 提示 角色已修改,弹出替换角色面板
setIsRemindReplacePanelOpen(true);
return;
}
// 重置替换规则
setEnableAnimation(false);
setIgnoreReplace(false);
setIsRegenerate(false);
selectRole(roleData[index].id);
};
// 从角色库中选择角色
const handleSelectCharacter = (index: number) => {
console.log('选择的角色索引:', index);
console.log('选择的角色数据:', userRoleLibrary[index]);
setIsReplaceLibraryOpen(false);
setShowAddToLibrary(false);
// 使用真实的角色数据
const selectedRole = userRoleLibrary[index];
if (selectedRole) {
handleReplaceCharacter(selectedRole.imageUrl);
}
};
const handleOpenReplaceLibrary = () => {
setIsReplaceLibraryOpen(true);
setShowAddToLibrary(true);
};
const handleRegenerate = async () => {
console.log('Regenerate');
setIsRegenerate(true);
// const text = characterEditorRef.current.getRoleText();
// console.log('-==========text===========-', text);
// // 重生前 更新 当前项 generateText
// updateRoleText(text);
// 然后调用重新生成角色
await regenerateRole();
setIsRegenerate(false);
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// 检查文件类型
if (!file.type.startsWith('image/')) {
alert('请选择图片文件');
return;
}
// 创建本地预览URL
const imageUrl = URL.createObjectURL(file);
setShowAddToLibrary(false);
handleReplaceCharacter(imageUrl);
// 清空input的值这样同一个文件可以重复选择
event.target.value = '';
};
// 如果loading 显示loading状态
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
<div className="w-12 h-12 mb-4 animate-spin rounded-full border-b-2 border-blue-600" />
<p>Loading...</p>
</div>
);
}
// 如果没有角色数据,显示占位内容
if (roleData.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 role data</p>
</div>
);
}
return (
<div className="flex flex-col gap-6">
{/* 隐藏的文件输入框 */}
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileChange}
/>
{/* 上部分:角色缩略图 */}
<motion.div
className="space-y-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="relative">
<HorizontalScroller
itemWidth={96}
gap={0}
selectedIndex={roleData.findIndex(role => role.id === selectedRole?.id)}
onItemClick={(i: number) => handleChangeRole(i)}
>
{roleData.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]',
role.id === selectedRole?.id ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<img
src={role.imageUrl}
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>
))}
</HorizontalScroller>
</div>
</motion.div>
{/* 下部分:角色详情 */}
<motion.div
className="grid grid-cols-2 gap-6"
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={selectedRole?.imageUrl || ''}
alt={selectedRole?.name || ''}
width='100%'
height='100%'
enableAnimation={enableAnimation}
/>
{/* 应用角色按钮 */}
<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={handleUploadClick}
>
<ImageUp className="w-4 h-4" />
</motion.button>
<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={() => handleOpenReplaceLibrary()}
>
<Library className="w-4 h-4" />
</motion.button>
</div>
</div>
</div>
{/* 右列:角色信息 */}
<div className="space-y-4">
<CharacterEditor
ref={characterEditorRef}
className="min-h-[calc(100%-4rem)]"
description={selectedRole?.generateText || ''}
highlight={selectedRole?.tags || []}
onSmartPolish={handleSmartPolish}
onUpdateText={(text: string) => updateRoleText(text)}
/>
{/* 重新生成按钮、替换形象按钮 */}
<div className="grid grid-cols-2 gap-2">
<motion.button
onClick={() => handleStartReplaceCharacter()}
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={() => handleRegenerate()}
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 disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
disabled={isRegenerate}
>
<RefreshCw className="w-4 h-4" />
<span>{isRegenerate ? 'Regenerating...' : 'Regenerate'}</span>
</motion.button>
</div>
</div>
</motion.div>
<FloatingGlassPanel
open={isReplacePanelOpen}
width='66vw'
r_key={replacePanelKey}
onClose={() => handleCloseReplacePanel()}
>
<ReplaceCharacterPanel
shots={shotSelectionList}
character={selectedRole}
showAddToLibrary={showAddToLibrary}
onClose={() => handleCloseReplacePanel()}
onConfirm={handleConfirmReplace}
/>
</FloatingGlassPanel>
{/* 从角色库中选择角色 */}
<CharacterLibrarySelector
isReplaceLibraryOpen={isReplaceLibraryOpen}
setIsReplaceLibraryOpen={setIsReplaceLibraryOpen}
onSelect={handleSelectCharacter}
userRoleLibrary={userRoleLibrary}
/>
{/* 提醒用户角色已修改 是否需要替换 */}
<FloatingGlassPanel
open={isRemindReplacePanelOpen}
width='500px'
clickMaskClose={false}
>
<div className="flex flex-col items-center gap-4 text-white py-4">
<div className="flex items-center gap-3">
<TriangleAlert className="w-6 h-6 text-yellow-400" />
<p className="text-lg font-medium"></p>
</div>
<div className="flex gap-3 mt-2">
<button
onClick={() => handleConfirmGotoReplace()}
data-alt="confirm-replace-button"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-md transition-colors duration-200 flex items-center gap-2"
>
<ReplaceAll className="w-4 h-4" />
</button>
<button
onClick={() => handleCloseRemindReplacePanel()}
data-alt="ignore-button"
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-md transition-colors duration-200 flex items-center gap-2"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</FloatingGlassPanel>
</div>
);
}