forked from 77media/video-flow
415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import React, { useRef, useEffect, useState } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Trash2, RefreshCw, Play, Pause, Volume2, VolumeX, Upload, Library, Video, User, MapPin, Settings, Loader2, X, Plus } from 'lucide-react';
|
|
import { GlassIconButton } from './glass-icon-button';
|
|
import { cn } from '@/public/lib/utils';
|
|
import { ReplaceVideoModal } from './replace-video-modal';
|
|
import { MediaPropertiesModal } from './media-properties-modal';
|
|
import { DramaLineChart } from './drama-line-chart';
|
|
import { PersonDetection, PersonDetectionScene } from './person-detection';
|
|
import ShotEditor from './shot-editor/ShotEditor';
|
|
import { CharacterLibrarySelector } from './character-library-selector';
|
|
import FloatingGlassPanel from './FloatingGlassPanel';
|
|
import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel';
|
|
|
|
interface ShotTabContentProps {
|
|
taskSketch: any[];
|
|
currentSketchIndex: number;
|
|
onSketchSelect: (index: number) => void;
|
|
isPlaying?: boolean;
|
|
}
|
|
|
|
export function ShotTabContent({
|
|
taskSketch = [],
|
|
currentSketchIndex = 0,
|
|
onSketchSelect,
|
|
isPlaying: externalIsPlaying = true
|
|
}: ShotTabContentProps) {
|
|
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
|
const editorRef = useRef<any>(null);
|
|
const videosRef = useRef<HTMLDivElement>(null);
|
|
const videoPlayerRef = useRef<HTMLVideoElement>(null);
|
|
const [isPlaying, setIsPlaying] = React.useState(externalIsPlaying);
|
|
const [isMediaPropertiesModalOpen, setIsMediaPropertiesModalOpen] = React.useState(false);
|
|
|
|
const [detections, setDetections] = useState<PersonDetection[]>([]);
|
|
const [scanState, setScanState] = useState<'idle' | 'scanning' | 'detected'>('idle');
|
|
|
|
const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false);
|
|
const [isReplacePanelOpen, setIsReplacePanelOpen] = useState(false);
|
|
|
|
const [shots, setShots] = useState<any[]>([]);
|
|
|
|
|
|
// 监听外部播放状态变化
|
|
useEffect(() => {
|
|
setIsPlaying(externalIsPlaying);
|
|
}, [externalIsPlaying]);
|
|
|
|
// 确保 taskSketch 是数组
|
|
const sketches = Array.isArray(taskSketch) ? taskSketch : [];
|
|
|
|
// 自动滚动到选中项
|
|
useEffect(() => {
|
|
if (thumbnailsRef.current && videosRef.current) {
|
|
const thumbnailContainer = thumbnailsRef.current;
|
|
const videoContainer = videosRef.current;
|
|
|
|
const thumbnailWidth = thumbnailContainer.children[0]?.clientWidth ?? 0;
|
|
const thumbnailGap = 16; // gap-4 = 16px
|
|
const thumbnailScrollPosition = (thumbnailWidth + thumbnailGap) * currentSketchIndex;
|
|
|
|
const videoElement = videoContainer.children[currentSketchIndex] as HTMLElement;
|
|
const videoScrollPosition = videoElement?.offsetLeft ?? 0;
|
|
|
|
thumbnailContainer.scrollTo({
|
|
left: thumbnailScrollPosition - thumbnailContainer.clientWidth / 2 + thumbnailWidth / 2,
|
|
behavior: 'smooth'
|
|
});
|
|
|
|
videoContainer.scrollTo({
|
|
left: videoScrollPosition - videoContainer.clientWidth / 2 + videoElement?.clientWidth / 2,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
}, [currentSketchIndex]);
|
|
|
|
// 视频播放控制
|
|
useEffect(() => {
|
|
if (videoPlayerRef.current) {
|
|
if (isPlaying) {
|
|
videoPlayerRef.current.play().catch(() => {
|
|
// 处理自动播放策略限制
|
|
setIsPlaying(false);
|
|
});
|
|
} else {
|
|
// videoPlayerRef.current.pause();
|
|
}
|
|
}
|
|
}, [isPlaying, currentSketchIndex]);
|
|
|
|
|
|
// 处理扫描开始
|
|
const handleScan = () => {
|
|
if (scanState === 'detected') {
|
|
// 如果已经有检测结果,点击按钮退出检测状态
|
|
setScanState('idle');
|
|
setDetections([]); // 清除检测结果
|
|
return;
|
|
}
|
|
setScanState('scanning');
|
|
// 模拟检测过程
|
|
setTimeout(() => {
|
|
const mockDetections: PersonDetection[] = [
|
|
{
|
|
id: '1',
|
|
name: '人物1',
|
|
position: { top: 0, left: 100, width: 100, height: 200 }
|
|
}
|
|
];
|
|
setDetections(mockDetections);
|
|
}, 5000);
|
|
};
|
|
|
|
// 处理扫描超时/失败
|
|
const handleScanTimeout = () => {
|
|
setScanState('idle');
|
|
setDetections([]);
|
|
};
|
|
|
|
// 处理检测到结果
|
|
const handleDetectionsChange = (newDetections: PersonDetection[]) => {
|
|
if (newDetections.length > 0 && scanState === 'scanning') {
|
|
setScanState('detected');
|
|
}
|
|
};
|
|
|
|
// 处理人物点击 打开角色库
|
|
const handlePersonClick = (person: PersonDetection) => {
|
|
console.log('person', person);
|
|
setIsReplaceLibraryOpen(true);
|
|
};
|
|
|
|
// 从角色库中选择角色
|
|
const handleSelectCharacter = (index: number) => {
|
|
console.log('index', index);
|
|
setIsReplaceLibraryOpen(false);
|
|
// 模拟打开替换面板
|
|
setTimeout(() => {
|
|
setIsReplacePanelOpen(true);
|
|
}, 1000);
|
|
};
|
|
|
|
// 确认替换角色
|
|
const handleConfirmReplace = (selectedShots: string[], addToLibrary: boolean) => {
|
|
|
|
};
|
|
|
|
// 如果没有数据,显示空状态
|
|
if (sketches.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
|
|
<p>No sketch 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">
|
|
<div
|
|
ref={thumbnailsRef}
|
|
className="flex gap-4 overflow-x-auto p-2 hide-scrollbar"
|
|
>
|
|
{sketches.map((sketch, index) => (
|
|
<motion.div
|
|
key={sketch.id || index}
|
|
className={cn(
|
|
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group',
|
|
currentSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
|
)}
|
|
onClick={() => onSketchSelect(index)}
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
>
|
|
<video
|
|
src={sketch.url}
|
|
className="w-full h-full object-cover"
|
|
muted
|
|
loop
|
|
playsInline
|
|
onMouseEnter={(e) => e.currentTarget.play()}
|
|
onMouseLeave={(e) => e.currentTarget.pause()}
|
|
/>
|
|
<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">Shot {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>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 视频描述行 - 单行滚动 */}
|
|
<div className="relative group">
|
|
<div
|
|
ref={videosRef}
|
|
className="flex overflow-x-auto hide-scrollbar py-2 gap-1"
|
|
>
|
|
{sketches.map((video, index) => {
|
|
const isActive = currentSketchIndex === index;
|
|
return (
|
|
<motion.div
|
|
key={video.id || index}
|
|
className={cn(
|
|
'flex-shrink-0 cursor-pointer transition-all duration-300',
|
|
isActive ? 'text-white' : 'text-white/50 hover:text-white/80'
|
|
)}
|
|
onClick={() => onSketchSelect(index)}
|
|
initial={false}
|
|
animate={{
|
|
scale: isActive ? 1.02 : 1,
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm whitespace-nowrap">
|
|
Shot {index + 1}
|
|
</span>
|
|
{index < sketches.length - 1 && (
|
|
<span className="text-white/20">|</span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 渐变遮罩 */}
|
|
<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">
|
|
{/* 选中的视频预览 */}
|
|
<motion.div
|
|
className="aspect-video rounded-lg overflow-hidden relative group"
|
|
layoutId={`video-preview-${currentSketchIndex}`}
|
|
>
|
|
<PersonDetectionScene
|
|
videoSrc={sketches[currentSketchIndex]?.url}
|
|
detections={detections}
|
|
triggerScan={scanState === 'scanning'}
|
|
onScanTimeout={handleScanTimeout}
|
|
onScanExit={handleScanTimeout}
|
|
onDetectionsChange={handleDetectionsChange}
|
|
onPersonClick={handlePersonClick}
|
|
/>
|
|
|
|
{/* <video
|
|
ref={videoPlayerRef}
|
|
src={sketches[currentSketchIndex]?.url}
|
|
className="w-full h-full object-cover"
|
|
loop
|
|
autoPlay={false}
|
|
playsInline
|
|
controls
|
|
muted={isMuted}
|
|
onTimeUpdate={handleTimeUpdate}
|
|
/> */}
|
|
|
|
<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.button
|
|
onClick={() => console.log('Replace scene')}
|
|
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 }}
|
|
>
|
|
<MapPin className="w-4 h-4" />
|
|
</motion.button> */}
|
|
{/* 运镜按钮 */}
|
|
{/* <motion.button
|
|
onClick={() => console.log('Replace shot')}
|
|
disabled={true}
|
|
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 }}
|
|
>
|
|
<Video className="w-4 h-4" />
|
|
</motion.button> */}
|
|
|
|
{/* 更多设置 点击打开 More properties 弹窗 */}
|
|
{/* <motion.button
|
|
className='p-2 bg-black/50 hover:bg-black/70
|
|
text-white rounded-full backdrop-blur-sm transition-colors z-10'
|
|
style={{textDecorationLine: 'underline'}}
|
|
onClick={() => setIsMediaPropertiesModalOpen(true)}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<Settings className="w-4 h-4" />
|
|
</motion.button> */}
|
|
</motion.div>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* 基础配置 */}
|
|
<div className='space-y-4 col-span-1'>
|
|
<ShotEditor
|
|
ref={editorRef}
|
|
onAddSegment={() => {
|
|
// 可以在这里添加其他逻辑
|
|
console.log('分镜添加成功');
|
|
}}
|
|
onCharacterClick={(attrs) => {
|
|
console.log('attrs', attrs);
|
|
setIsReplaceLibraryOpen(true);
|
|
}}
|
|
/>
|
|
|
|
{/* 重新生成按钮、新增分镜按钮 */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<motion.button
|
|
onClick={() => editorRef.current?.addSegment()}
|
|
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={() => 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>
|
|
|
|
{/* Media Properties 弹窗 */}
|
|
<MediaPropertiesModal
|
|
isOpen={isMediaPropertiesModalOpen}
|
|
onClose={() => setIsMediaPropertiesModalOpen(false)}
|
|
taskSketch={taskSketch}
|
|
currentSketchIndex={currentSketchIndex}
|
|
onSketchSelect={onSketchSelect}
|
|
/>
|
|
|
|
<FloatingGlassPanel
|
|
open={isReplacePanelOpen}
|
|
width='66vw'
|
|
onClose={() => setIsReplacePanelOpen(false)}
|
|
>
|
|
<ReplaceCharacterPanel
|
|
shots={mockShots}
|
|
character={mockCharacter}
|
|
showAddToLibrary={false}
|
|
onClose={() => setIsReplacePanelOpen(false)}
|
|
onConfirm={handleConfirmReplace}
|
|
/>
|
|
</FloatingGlassPanel>
|
|
|
|
<CharacterLibrarySelector
|
|
isReplaceLibraryOpen={isReplaceLibraryOpen}
|
|
setIsReplaceLibraryOpen={setIsReplaceLibraryOpen}
|
|
onSelect={handleSelectCharacter}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|