forked from 77media/video-flow
517 lines
17 KiB
TypeScript
517 lines
17 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { ImageUp, Library, Play, Pause, RefreshCw, Wand2, Users, CircleX, ReplaceAll, X, TriangleAlert, Loader2 } 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';
|
||
import { RoleEntity } from '@/app/service/domain/Entities';
|
||
|
||
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 mockReplaceVideoData = {
|
||
count: 10,
|
||
completed: 2, // 已完成个数
|
||
data: [
|
||
{
|
||
id: '1',
|
||
name: '替换视频1',
|
||
videoUrl: ['https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1_76a6cd5d-e4dd-4bed-8d5f-65306f146caf-20250812112433.mp4'],
|
||
status: 1
|
||
}, {
|
||
id: '2',
|
||
name: '替换视频1',
|
||
videoUrl: [],
|
||
status: 2
|
||
},
|
||
{ id: '3', name: '替换视频1', videoUrl: [], status: 0 },
|
||
{ id: '4', name: '替换视频1', videoUrl: [], status: 0 },
|
||
{ id: '5', name: '替换视频1', videoUrl: [], status: 0 },
|
||
{ id: '6', name: '替换视频1', videoUrl: [], status: 0 },
|
||
{ id: '7', name: '替换视频1', videoUrl: [], status: 0 },
|
||
{ id: '8', name: '替换视频1', videoUrl: [], status: 0 },
|
||
{ id: '9', name: '替换视频1', videoUrl: [], status: 0 },
|
||
{ id: '10', name: '替换视频1', videoUrl: [], status: 0 },
|
||
]
|
||
}
|
||
|
||
interface CharacterTabContentProps {
|
||
taskSketch: any[];
|
||
currentRoleIndex: number;
|
||
onSketchSelect: (index: number) => void;
|
||
roles: Role[];
|
||
}
|
||
|
||
export function CharacterTabContent({
|
||
taskSketch,
|
||
currentRoleIndex,
|
||
onSketchSelect,
|
||
roles = []
|
||
}: 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 [isLoadingShots, setIsLoadingShots] = useState(false);
|
||
const [isLoadingLibrary, setIsLoadingLibrary] = useState(false);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
|
||
const {
|
||
loading,
|
||
roleData,
|
||
selectRole,
|
||
selectedRole,
|
||
userRoleLibrary,
|
||
optimizeRoleText,
|
||
updateRoleText,
|
||
regenerateRole,
|
||
fetchUserRoleLibrary,
|
||
uploadImageAndUpdateRole,
|
||
// role shot
|
||
shotSelectionList,
|
||
fetchRoleShots,
|
||
applyRoleToSelectedShots,
|
||
saveRoleToLibrary
|
||
} = 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('获取shotSelectionList数据', shotSelectionList);
|
||
}, [shotSelectionList]);
|
||
|
||
useEffect(() => {
|
||
console.log('获取角色库数据', userRoleLibrary);
|
||
}, [userRoleLibrary]);
|
||
|
||
const handleSmartPolish = (text: string) => {
|
||
// 然后调用优化角色文本
|
||
optimizeRoleText(text);
|
||
};
|
||
|
||
const handleStartReplaceCharacter = async () => {
|
||
setIsLoadingShots(true);
|
||
setIsReplacePanelOpen(true);
|
||
// 获取当前角色对应的视频片段
|
||
await fetchRoleShots(selectedRole?.name || '');
|
||
// 打开替换角色面板
|
||
setIsLoadingShots(false);
|
||
};
|
||
|
||
const handleConfirmGotoReplace = () => {
|
||
setIsRemindReplacePanelOpen(false);
|
||
setIsReplacePanelOpen(true);
|
||
};
|
||
|
||
const handleCloseRemindReplacePanel = () => {
|
||
setIsRemindReplacePanelOpen(false);
|
||
setIgnoreReplace(true);
|
||
};
|
||
|
||
// President Alfred King Samuel Ryan
|
||
const handleConfirmReplace = (selectedShots: string[], addToLibrary: boolean) => {
|
||
// 处理替换确认逻辑
|
||
console.log('Selected shots:', selectedShots);
|
||
console.log('Add to library:', addToLibrary);
|
||
applyRoleToSelectedShots(selectedRole || {} as RoleEntity);
|
||
setIsReplacePanelOpen(false);
|
||
if(addToLibrary){
|
||
saveRoleToLibrary();
|
||
}
|
||
};
|
||
|
||
// 取消替换
|
||
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]);
|
||
};
|
||
|
||
// 从角色库中选择角色
|
||
const handleSelectCharacter = (index: number) => {
|
||
console.log('选择的角色索引:', index);
|
||
console.log('选择的角色数据:', userRoleLibrary[index]);
|
||
|
||
setIsReplaceLibraryOpen(false);
|
||
setShowAddToLibrary(false);
|
||
|
||
// 使用真实的角色数据
|
||
const role = userRoleLibrary[index];
|
||
if (role) {
|
||
selectRole({
|
||
...role,
|
||
name: selectedRole?.name || ''
|
||
});
|
||
handleStartReplaceCharacter();
|
||
}
|
||
};
|
||
|
||
const handleOpenReplaceLibrary = async () => {
|
||
setIsLoadingLibrary(true);
|
||
setIsReplaceLibraryOpen(true);
|
||
setShowAddToLibrary(true);
|
||
await fetchUserRoleLibrary();
|
||
setIsLoadingLibrary(false);
|
||
};
|
||
|
||
const handleRegenerate = async () => {
|
||
console.log('Regenerate');
|
||
setIsRegenerate(true);
|
||
// const text = characterEditorRef.current.getRoleText();
|
||
// console.log('-==========text===========-', text);
|
||
// // 重生前 更新 当前项 generateText
|
||
// updateRoleText(text);
|
||
// 然后调用重新生成角色
|
||
await regenerateRole();
|
||
setIsRegenerate(false);
|
||
handleStartReplaceCharacter();
|
||
};
|
||
|
||
const handleUploadClick = () => {
|
||
fileInputRef.current?.click();
|
||
};
|
||
|
||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) {
|
||
setIsUploading(false);
|
||
return;
|
||
};
|
||
|
||
// 检查文件类型
|
||
if (!file.type.startsWith('image/')) {
|
||
alert('请选择图片文件');
|
||
setIsUploading(false);
|
||
return;
|
||
}
|
||
setIsUploading(true);
|
||
|
||
uploadImageAndUpdateRole(file).then((data) => {
|
||
console.log('上传图片成功', data);
|
||
// 清空input的值,这样同一个文件可以重复选择
|
||
event.target.value = '';
|
||
setIsUploading(false);
|
||
});
|
||
};
|
||
|
||
// 如果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}
|
||
disabled={isUploading}
|
||
>
|
||
{isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <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>
|
||
|
||
{/* 替换视频进度 预览区域 */}
|
||
<motion.div
|
||
className="grid grid-cols-1 gap-6"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.2 }}
|
||
>
|
||
<div className="flex flex-col gap-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-gray-500">替换视频进度</span>
|
||
<span className="text-sm text-blue-500">
|
||
{mockReplaceVideoData.completed}/{mockReplaceVideoData.count}
|
||
</span>
|
||
</div>
|
||
|
||
<HorizontalScroller
|
||
itemWidth={128}
|
||
gap={0}
|
||
selectedIndex={-1}
|
||
onItemClick={() => {}}
|
||
>
|
||
{mockReplaceVideoData.data.map((shot) => (
|
||
<div key={shot.id} className="flex items-center w-[128px] h-full rounded-sm overflow-hidden">
|
||
{shot.status === 0 && (
|
||
<div className="w-full h-full flex items-center justify-center bg-black/10">
|
||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||
</div>
|
||
)}
|
||
{shot.status === 1 && (
|
||
<video
|
||
src={shot.videoUrl[0]}
|
||
className="w-full h-full object-cover"
|
||
muted
|
||
loop
|
||
playsInline
|
||
onMouseEnter={(e) => e.currentTarget.play()}
|
||
onMouseLeave={(e) => e.currentTarget.pause()}
|
||
/>
|
||
)}
|
||
{/* 任务失败 */}
|
||
{shot.status === 2 && (
|
||
<div className="w-full h-full flex items-center justify-center bg-red-500/10">
|
||
<CircleX className="w-4 h-4 text-red-500" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</HorizontalScroller>
|
||
</div>
|
||
</motion.div>
|
||
|
||
<FloatingGlassPanel
|
||
open={isReplacePanelOpen}
|
||
width='66vw'
|
||
r_key={replacePanelKey}
|
||
onClose={() => handleCloseReplacePanel()}
|
||
>
|
||
<ReplaceCharacterPanel
|
||
isLoading={isLoadingShots}
|
||
shots={shotSelectionList}
|
||
role={selectedRole}
|
||
showAddToLibrary={showAddToLibrary}
|
||
onClose={() => handleCloseReplacePanel()}
|
||
onConfirm={handleConfirmReplace}
|
||
/>
|
||
</FloatingGlassPanel>
|
||
|
||
{/* 从角色库中选择角色 */}
|
||
<CharacterLibrarySelector
|
||
isLoading={isLoadingLibrary}
|
||
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>
|
||
);
|
||
}
|