video-flow-b/components/ui/shot-tab-content.tsx

419 lines
15 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.

'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';
interface ShotTabContentProps {
currentSketchIndex: number;
roles?: any[];
}
export function ShotTabContent({
currentSketchIndex = 0,
roles = []
}: ShotTabContentProps) {
const {
loading,
shotData,
setSelectedSegment,
regenerateVideoSegment,
filterRole,
fetchUserRoleLibrary,
userRoleLibrary,
fetchRoleShots,
shotSelectionList,
applyRoleToSelectedShots,
calculateRecognitionBoxes
} = useEditData('shot');
const [selectedIndex, setSelectedIndex] = useState(currentSketchIndex);
const [detections, setDetections] = useState<PersonDetection[]>([]);
const [scanState, setScanState] = useState<'idle' | 'scanning' | 'detected'>('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 videoRef = useRef<HTMLVideoElement>(null);
// 监听当前选中index变化
useEffect(() => {
console.log('shotTabContent-----shotData', shotData);
if (shotData.length > 0) {
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;
const roleRecognitionResponse = await filterRole(containerElement);
console.log('roleRecognitionResponse', roleRecognitionResponse);
if (roleRecognitionResponse && roleRecognitionResponse.recognition_result.code === 200) {
const recognitionBoxes = calculateRecognitionBoxes(containerElement, roleRecognitionResponse.recognition_result.data.matched_persons);
console.log('recognitionBoxes', recognitionBoxes);
setDetections(recognitionBoxes.map((person: any) => ({
id: person.person_id,
name: person.person_id,
position: { top: person.top, left: person.left, width: person.width, height: person.height }
})));
setScanState('detected');
} else {
setIsScanFailed(true);
}
};
// 处理扫描超时/失败
const handleScanTimeout = () => {
setIsScanFailed(true);
setScanState('detected');
setDetections([]);
};
// 处理退出扫描
const handleScanExit = () => {
setScanState('idle');
setDetections([]);
};
// 处理检测到结果
const handleDetectionsChange = (newDetections: PersonDetection[]) => {
if (newDetections.length > 0 && scanState === 'scanning') {
setScanState('detected');
}
};
// 处理人物点击 打开角色库
const handlePersonClick = async (person: PersonDetection) => {
console.log('person', person);
setSelectedCharacter(person);
setIsLoadingLibrary(true);
setIsReplaceLibraryOpen(true);
await fetchUserRoleLibrary();
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 = () => {
console.log('regenerate');
const shotInfo = shotsEditorRef.current.getShotInfo();
console.log('shotTabContent-----shotInfo', shotInfo);
setSelectedSegment({
...shotData[selectedIndex],
lens: shotInfo
});
regenerateVideoSegment();
};
// 新增分镜
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 && (
<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 && (
<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 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>
{/* 下部分 */}
<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>
)}
{shotData[selectedIndex]?.status === 1 && (
<motion.div
className="aspect-video rounded-lg overflow-hidden relative group"
layoutId={`video-preview-${selectedIndex}`}
>
<PersonDetectionScene
videoSrc={shotData[selectedIndex]?.videoUrl[0]}
detections={detections}
triggerScan={scanState === 'scanning'}
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>
)}
{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"></span>
</div>
)}
</>
</div>
{/* 基础配置 */}
<div className='space-y-4 col-span-1' key={selectedIndex}>
<ShotsEditor
ref={shotsEditorRef}
roles={roles}
shotInfo={shotData[selectedIndex].lens}
style={{height: 'calc(100% - 4rem)'}}
/>
{/* 重新生成按钮、新增分镜按钮 */}
<div className="grid grid-cols-2 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"
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='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>
);
}