forked from 77media/video-flow
470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import React, { useRef, useEffect, useState } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { RefreshCw, User, Loader2, X, Plus, Video, CircleX } from 'lucide-react';
|
|
import { cn } from '@/public/lib/utils';
|
|
import { PersonDetection, PersonDetectionScene } from './person-detection';
|
|
import { ShotsEditor } from './shot-editor/ShotsEditor';
|
|
import { CharacterLibrarySelector } from './character-library-selector';
|
|
import FloatingGlassPanel from './FloatingGlassPanel';
|
|
import { ReplaceCharacterPanel } from './replace-character-panel';
|
|
import HorizontalScroller from './HorizontalScroller';
|
|
import { useEditData } from '@/components/pages/work-flow/use-edit-data';
|
|
import { RoleEntity } from '@/app/service/domain/Entities';
|
|
|
|
interface ShotTabContentProps {
|
|
currentSketchIndex: number;
|
|
roles?: any[];
|
|
}
|
|
|
|
export const ShotTabContent = (props: ShotTabContentProps) => {
|
|
const { currentSketchIndex = 0, roles = [] } = props;
|
|
const {
|
|
loading,
|
|
shotData,
|
|
scriptRoles,
|
|
setSelectedSegment,
|
|
regenerateVideoSegment,
|
|
filterRole,
|
|
fetchUserRoleLibrary,
|
|
userRoleLibrary,
|
|
fetchRoleShots,
|
|
shotSelectionList,
|
|
applyRoleToSelectedShots,
|
|
calculateRecognitionBoxes,
|
|
setSelectedRole
|
|
} = useEditData('shot');
|
|
const [selectedIndex, setSelectedIndex] = useState(currentSketchIndex);
|
|
|
|
const [detections, setDetections] = useState<PersonDetection[]>([]);
|
|
const [scanState, setScanState] = useState<'idle' | 'scanning' | 'detected' | 'failed' | 'timeout'>('idle');
|
|
const [isScanFailed, setIsScanFailed] = useState(false);
|
|
const [isLoadingLibrary, setIsLoadingLibrary] = useState(false);
|
|
const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false);
|
|
const [isReplacePanelOpen, setIsReplacePanelOpen] = useState(false);
|
|
const [selectedCharacter, setSelectedCharacter] = useState<any>(null);
|
|
const [selectedLibaryRole, setSelectedLibaryRole] = useState<any>(null);
|
|
const [isLoadingShots, setIsLoadingShots] = useState(false);
|
|
const shotsEditorRef = useRef<any>(null);
|
|
const [isRegenerate, setIsRegenerate] = useState(false);
|
|
const [pendingRegeneration, setPendingRegeneration] = useState(false);
|
|
|
|
useEffect(() => {
|
|
console.log('shotTabContent-----shotData', shotData);
|
|
}, [shotData]);
|
|
|
|
useEffect(() => {
|
|
if (pendingRegeneration) {
|
|
console.log('pendingRegeneration', pendingRegeneration, shotData[selectedIndex]?.lens);
|
|
regenerateVideoSegment().then(() => {
|
|
setPendingRegeneration(false);
|
|
setIsRegenerate(false);
|
|
});
|
|
}
|
|
}, [pendingRegeneration]);
|
|
|
|
// 监听当前选中index变化
|
|
useEffect(() => {
|
|
console.log('shotTabContent-----shotData', shotData);
|
|
if (shotData.length > 0) {
|
|
// 清空检测状态 和 检测结果
|
|
setScanState('idle');
|
|
setDetections([]);
|
|
setSelectedSegment(shotData[selectedIndex]);
|
|
}
|
|
}, [selectedIndex, shotData]);
|
|
|
|
// 处理扫描开始
|
|
const handleScan = async () => {
|
|
if (scanState === 'detected') {
|
|
// 如果已经有检测结果,点击按钮退出检测状态
|
|
setScanState('idle');
|
|
setDetections([]); // 清除检测结果
|
|
return;
|
|
}
|
|
setScanState('scanning');
|
|
const containerElement = document.getElementById('person-detection-video') as HTMLVideoElement;
|
|
try {
|
|
const roleRecognitionResponse = await filterRole(containerElement);
|
|
console.log('roleRecognitionResponse', roleRecognitionResponse);
|
|
if (roleRecognitionResponse && roleRecognitionResponse.recognition_result.code === 200) {
|
|
const recognitionBoxes = calculateRecognitionBoxes(containerElement, roleRecognitionResponse.recognition_result.data);
|
|
console.log('recognitionBoxes', recognitionBoxes);
|
|
setDetections(recognitionBoxes.map((person: any) => ({
|
|
id: person.person_id,
|
|
name: person.person_id,
|
|
description: roleRecognitionResponse.characters_used.find((character: any) => character.character_name === person.person_id)?.character_description || '',
|
|
imageUrl: roleRecognitionResponse.characters_used.find((character: any) => character.character_name === person.person_id)?.avatar || '',
|
|
position: { top: person.top, left: person.left, width: person.width, height: person.height }
|
|
})));
|
|
} else {
|
|
setIsScanFailed(true);
|
|
}
|
|
setScanState('detected');
|
|
} catch (error) {
|
|
setIsScanFailed(true);
|
|
}
|
|
};
|
|
|
|
// 处理扫描超时
|
|
const handleScanTimeout = () => {
|
|
setIsScanFailed(true);
|
|
setScanState('timeout');
|
|
setDetections([]);
|
|
};
|
|
|
|
// 处理退出扫描
|
|
const handleScanExit = () => {
|
|
setScanState('idle');
|
|
setDetections([]);
|
|
};
|
|
|
|
// 处理检测到结果
|
|
const handleDetectionsChange = (newDetections: PersonDetection[]) => {
|
|
// console.log('handleDetectionsChange', newDetections);
|
|
// if (newDetections.length > 0 && scanState === 'scanning') {
|
|
// setScanState('detected');
|
|
// }
|
|
};
|
|
|
|
// 处理人物点击 打开角色库
|
|
const handlePersonClick = async (person: PersonDetection) => {
|
|
console.log('person', person);
|
|
const role: RoleEntity = {
|
|
id: person.id,
|
|
name: person.name,
|
|
generateText: person.description,
|
|
tags: [], // 由于 person 对象中没有标签信息,我们设置为空数组
|
|
imageUrl: person.imageUrl,
|
|
fromDraft: false, // 默认不是来自草稿箱
|
|
isChangeRole: true, // 默认没有发生角色形象的变更
|
|
};
|
|
console.log('role', role);
|
|
setSelectedRole(role);
|
|
setSelectedCharacter(person);
|
|
setIsLoadingLibrary(true);
|
|
setIsReplaceLibraryOpen(true);
|
|
await fetchUserRoleLibrary(role.generateText);
|
|
setIsLoadingLibrary(false);
|
|
};
|
|
|
|
// 从角色库中选择角色
|
|
const handleSelectCharacter = (index: number) => {
|
|
console.log('index', index);
|
|
setSelectedLibaryRole(userRoleLibrary[index]);
|
|
setIsReplaceLibraryOpen(false);
|
|
handleStartReplaceCharacter();
|
|
};
|
|
|
|
const handleStartReplaceCharacter = async () => {
|
|
setIsLoadingShots(true);
|
|
setIsReplacePanelOpen(true);
|
|
// 获取当前角色对应的视频片段
|
|
await fetchRoleShots(selectedCharacter?.name || '');
|
|
// 打开替换角色面板
|
|
setIsLoadingShots(false);
|
|
};
|
|
|
|
// 确认替换角色
|
|
const handleConfirmReplace = () => {
|
|
applyRoleToSelectedShots(selectedLibaryRole);
|
|
setIsReplacePanelOpen(false);
|
|
};
|
|
|
|
// 点击按钮重新生成
|
|
const handleRegenerate = async () => {
|
|
console.log('regenerate');
|
|
setIsRegenerate(true);
|
|
const shotInfo = shotsEditorRef.current.getShotInfo();
|
|
console.log('shotInfo', shotInfo);
|
|
setSelectedSegment({
|
|
...shotData[selectedIndex],
|
|
lens: shotInfo
|
|
});
|
|
setTimeout(() => {
|
|
setPendingRegeneration(true);
|
|
}, 1000);
|
|
};
|
|
|
|
// 新增分镜
|
|
const handleAddShot = () => {
|
|
console.log('add shot');
|
|
shotsEditorRef.current.addShot();
|
|
};
|
|
|
|
// 切换选择分镜
|
|
const handleSelectShot = (index: number) => {
|
|
// 切换前 判断数据是否发生变化
|
|
setSelectedIndex(index);
|
|
};
|
|
|
|
// 如果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 (shotData.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
|
|
<Video className="w-16 h-16 mb-4" />
|
|
<p>No video data</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
{/* 上部分 */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
>
|
|
{/* 分镜缩略图行 */}
|
|
<div className="relative">
|
|
<HorizontalScroller
|
|
itemWidth={128}
|
|
gap={0}
|
|
selectedIndex={selectedIndex}
|
|
onItemClick={(i: number) => handleSelectShot(i)}
|
|
>
|
|
{shotData.map((shot, index) => (
|
|
<motion.div
|
|
key={shot.id || index}
|
|
className={cn(
|
|
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group',
|
|
selectedIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
|
)}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
{(shot.status === 0 || shot.videoUrl.length === 0) && (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
|
</div>
|
|
)}
|
|
{shot.status === 1 && shot.videoUrl[0] && (
|
|
<video
|
|
src={shot.videoUrl[0].video_url}
|
|
key={shot.videoUrl[0].video_url}
|
|
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 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">{index + 1}</span>
|
|
</div>
|
|
{/* 鼠标悬浮/移出 显示/隐藏 删除图标 */}
|
|
{/* <div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
|
<button
|
|
onClick={() => console.log('Delete sketch')}
|
|
className="text-red-500"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div> */}
|
|
</motion.div>
|
|
))}
|
|
</HorizontalScroller>
|
|
</div>
|
|
|
|
{/* 视频描述行 - 单行滚动 */}
|
|
<div className="relative group">
|
|
<HorizontalScroller
|
|
itemWidth={'auto'}
|
|
gap={0}
|
|
selectedIndex={selectedIndex}
|
|
onItemClick={(i: number) => handleSelectShot(i)}
|
|
>
|
|
{shotData.map((shot, index) => {
|
|
const isActive = selectedIndex === index;
|
|
return (
|
|
<motion.div
|
|
key={shot.id || index}
|
|
className={cn(
|
|
'flex-shrink-0 cursor-pointer transition-all duration-300',
|
|
isActive ? 'text-white' : 'text-white/50 hover:text-white/80'
|
|
)}
|
|
initial={false}
|
|
animate={{
|
|
scale: isActive ? 1.02 : 1,
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm whitespace-nowrap flex items-center gap-1">
|
|
<span>Segment {index + 1}</span>
|
|
{shot.status === 0 && (
|
|
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
|
)}
|
|
{shot.status === 2 && (
|
|
<CircleX className="w-4 h-4 text-red-500" />
|
|
)}
|
|
</span>
|
|
{index < shotData.length - 1 && (
|
|
<span className="text-white/20">|</span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</HorizontalScroller>
|
|
|
|
{/* 渐变遮罩 */}
|
|
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
</div>
|
|
</motion.div>
|
|
|
|
|
|
{/* 下部分 */}
|
|
{shotData[selectedIndex] && (
|
|
<motion.div
|
|
className="grid grid-cols-2 gap-4 w-full"
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.2 }}
|
|
>
|
|
{/* 视频预览和操作 */}
|
|
<div className="space-y-4 col-span-1">
|
|
{/* 选中的视频预览 */}
|
|
<>
|
|
{(shotData[selectedIndex]?.status === 0) && (
|
|
<div className="w-full h-full flex items-center gap-1 justify-center rounded-lg bg-black/30">
|
|
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
|
<span className="text-white/50">Loading...</span>
|
|
</div>
|
|
)}
|
|
<AnimatePresence mode="wait">
|
|
{shotData[selectedIndex]?.status === 1 && shotData[selectedIndex]?.videoUrl.length && (
|
|
<motion.div
|
|
className="aspect-video rounded-lg overflow-hidden relative group"
|
|
key={`video-preview-${selectedIndex}`}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
>
|
|
<PersonDetectionScene
|
|
videoSrc={shotData[selectedIndex]?.videoUrl[0].video_url}
|
|
detections={detections}
|
|
scanState={scanState}
|
|
triggerScan={scanState === 'scanning'}
|
|
triggerSuccess={scanState === 'detected'}
|
|
onScanTimeout={handleScanTimeout}
|
|
onScanExit={handleScanExit}
|
|
onDetectionsChange={handleDetectionsChange}
|
|
onPersonClick={handlePersonClick}
|
|
/>
|
|
<motion.div className='absolute top-4 right-4 flex gap-2'>
|
|
{/* 人物替换按钮 */}
|
|
<motion.button
|
|
onClick={() => handleScan()}
|
|
className={`p-2 backdrop-blur-sm transition-colors z-10 rounded-full
|
|
${scanState === 'detected'
|
|
? 'bg-cyan-500/50 hover:bg-cyan-500/70 text-white'
|
|
: 'bg-black/50 hover:bg-black/70 text-white'
|
|
}`}
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
>
|
|
{scanState === 'scanning' ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : scanState === 'detected' ? (
|
|
<X className="w-4 h-4" />
|
|
) : (
|
|
<User className="w-4 h-4" />
|
|
)}
|
|
</motion.button>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
{(shotData[selectedIndex]?.status === 2) && (
|
|
<div className="w-full h-full flex gap-1 items-center justify-center rounded-lg bg-red-500/10">
|
|
<CircleX className="w-4 h-4 text-red-500" />
|
|
<span className="text-white/50">Failed, click to regenerate</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
</div>
|
|
|
|
{/* 基础配置 */}
|
|
<div className='space-y-4 col-span-1' key={selectedIndex}>
|
|
<ShotsEditor
|
|
ref={shotsEditorRef}
|
|
roles={scriptRoles}
|
|
shotInfo={shotData[selectedIndex].lens}
|
|
style={{height: 'calc(100% - 4rem)'}}
|
|
/>
|
|
|
|
{/* 重新生成按钮、新增分镜按钮 */}
|
|
<div className="grid grid-cols-1 gap-2">
|
|
{/* <motion.button
|
|
onClick={() => handleAddShot()}
|
|
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 }}
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
<span>Add Shot</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'
|
|
onClose={() => setIsReplacePanelOpen(false)}
|
|
>
|
|
<ReplaceCharacterPanel
|
|
isLoading={isLoadingShots}
|
|
shots={shotSelectionList}
|
|
role={selectedLibaryRole}
|
|
showAddToLibrary={false}
|
|
onClose={() => setIsReplacePanelOpen(false)}
|
|
onConfirm={handleConfirmReplace}
|
|
/>
|
|
</FloatingGlassPanel>
|
|
|
|
<CharacterLibrarySelector
|
|
isLoading={isLoadingLibrary}
|
|
userRoleLibrary={userRoleLibrary}
|
|
isReplaceLibraryOpen={isReplaceLibraryOpen}
|
|
setIsReplaceLibraryOpen={setIsReplaceLibraryOpen}
|
|
onSelect={handleSelectCharacter}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|