video-flow-b/components/ui/person-detection.tsx

403 lines
13 KiB
TypeScript

import React, { useEffect, useRef, useState, RefObject } from "react";
import { motion, useAnimation, AnimatePresence } from "framer-motion";
import { TriangleAlert } from "lucide-react";
// 人物框组件
const PersonBox = React.forwardRef<HTMLDivElement, {
person: PersonDetection;
}>(({ person }, ref) => {
return (
<motion.div
ref={ref}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.6 }}
className="absolute border-2 border-cyan-400/70 rounded-md shadow-lg z-30"
style={{
top: `${person.position.top}px`,
left: `${person.position.left}px`,
width: `${person.position.width}px`,
height: `${person.position.height}px`,
boxShadow: "0 0 20px rgba(6, 182, 212, 0.3)"
}}
>
</motion.div>
);
});
PersonBox.displayName = 'PersonBox';
export type PersonDetection = {
id: string;
name: string;
position: {
top: number;
left: number;
width: number;
height: number;
};
};
type Props = {
backgroundImage?: string;
videoSrc?: string;
detections: PersonDetection[];
triggerScan: boolean;
onScanStart?: (currentTime?: number) => void;
onScanTimeout?: () => void;
onScanExit?: () => void; // 扫描退出回调
scanTimeout?: number;
isScanFailed?: boolean; // 外部传入的失败状态
onDetectionsChange?: (detections: PersonDetection[]) => void;
onPersonClick?: (person: PersonDetection) => void;
};
export const PersonDetectionScene: React.FC<Props> = ({
backgroundImage,
videoSrc,
detections,
triggerScan,
onScanStart,
onScanTimeout,
onScanExit,
scanTimeout = 10000,
isScanFailed = false,
onDetectionsChange,
onPersonClick
}) => {
const scanControls = useAnimation();
const videoRef = useRef<HTMLVideoElement>(null);
const [scanStatus, setScanStatus] = useState<'idle' | 'scanning' | 'timeout' | 'failed'>('idle');
const timeoutRef = useRef<NodeJS.Timeout>();
const exitTimeoutRef = useRef<NodeJS.Timeout>();
// 处理扫描状态
useEffect(() => {
if (triggerScan && detections.length === 0) {
setScanStatus('scanning');
// 设置超时定时器
timeoutRef.current = setTimeout(() => {
setScanStatus('timeout');
onScanTimeout?.();
}, scanTimeout);
} else {
// 清除所有定时器
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (exitTimeoutRef.current) {
clearTimeout(exitTimeoutRef.current);
}
setScanStatus('idle');
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (exitTimeoutRef.current) {
clearTimeout(exitTimeoutRef.current);
}
};
}, [triggerScan, detections.length, scanTimeout, onScanTimeout]);
// 处理外部失败状态
useEffect(() => {
if (isScanFailed && scanStatus === 'scanning') {
setScanStatus('failed');
}
}, [isScanFailed, scanStatus]);
// 处理失败/超时自动退出
useEffect(() => {
if (scanStatus === 'timeout' || scanStatus === 'failed') {
exitTimeoutRef.current = setTimeout(() => {
setScanStatus('idle');
onScanExit?.();
}, 3000);
}
return () => {
if (exitTimeoutRef.current) {
clearTimeout(exitTimeoutRef.current);
}
};
}, [scanStatus, onScanExit]);
// 处理视频/图片源变化
useEffect(() => {
setScanStatus('idle');
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (exitTimeoutRef.current) {
clearTimeout(exitTimeoutRef.current);
}
}, [videoSrc, backgroundImage]);
useEffect(() => {
if (scanStatus !== 'idle') {
if (videoRef.current) {
videoRef.current.pause();
onScanStart?.(videoRef.current.currentTime);
} else {
onScanStart?.();
}
scanControls.start({
opacity: [0, 1, 1, 0],
scale: [0.98, 1, 1, 0.98],
y: ["-120%", "120%"],
transition: {
duration: 3,
repeat: Infinity,
repeatType: "loop",
times: [0, 0.2, 0.8, 1],
ease: "easeInOut"
}
});
} else {
scanControls.stop();
}
}, [triggerScan, scanStatus, scanControls, onScanStart]);
// 监听检测结果变化
useEffect(() => {
onDetectionsChange?.(detections);
}, [detections, onDetectionsChange]);
const isShowError = scanStatus === 'timeout' || scanStatus === 'failed';
const isShowScan = scanStatus !== 'idle';
return (
<div className="relative w-full aspect-video overflow-hidden rounded-xl border border-white/10 bg-black">
{/* 背景层 */}
{backgroundImage && (
<div
className="absolute inset-0 z-0"
style={{
backgroundImage: `url(${backgroundImage})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
)}
{videoSrc && (
<video
ref={videoRef}
id="person-detection-video"
src={videoSrc}
className="absolute inset-0 w-full h-full object-cover z-0"
autoPlay
muted={false}
loop
controls
/>
)}
{/* 暗化层 */}
<AnimatePresence>
{isShowScan && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/40 backdrop-blur-sm z-10"
/>
)}
</AnimatePresence>
{/* 扫描状态提示 */}
<AnimatePresence>
{isShowScan && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute top-1/2 left-1/2 z-50 flex flex-col items-center -translate-y-1/2 -translate-x-1/2"
>
{/* 状态图标 */}
<div className={`relative w-12 h-12 mb-2 ${isShowError ? 'scale-110' : ''}`}>
{!isShowError ? (
<>
<motion.div
className="absolute inset-0 rounded-full"
style={{
border: '2px solid rgba(6, 182, 212, 0.5)',
borderTopColor: 'rgb(6, 182, 212)',
}}
animate={{ rotate: 360 }}
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
/>
<motion.div
className="absolute inset-2 rounded-full"
style={{
border: '2px solid rgba(6, 182, 212, 0.3)',
borderRightColor: 'rgb(6, 182, 212)',
}}
animate={{ rotate: -360 }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
<div className="absolute inset-4 bg-cyan-400 rounded-full" />
</>
) : (
<motion.div
initial={{ scale: 0.8 }}
animate={{
scale: [0.8, 1.1, 1],
borderColor: ['rgb(239, 68, 68)', 'rgb(239, 68, 68)', 'rgb(239, 68, 68)']
}}
transition={{ duration: 0.5 }}
className="w-full h-full rounded-full flex items-center justify-center"
>
<TriangleAlert className="w-12 h-12 text-red-500" />
</motion.div>
)}
</div>
{/* 状态文字 */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2">
{!isShowError ? (
<span className="text-cyan-400 text-sm font-medium tracking-wider">
</span>
) : (
<span className="text-red-400 text-sm font-medium tracking-wider">
{scanStatus === 'timeout' ? '识别超时,请重试' : '识别失败,请重试'}
</span>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 扫描动画效果 */}
<AnimatePresence>
{isShowScan && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0"
>
{/* 主扫描线 */}
<motion.div
className="absolute left-0 w-full h-[150px] z-20"
animate={scanControls}
initial={{ y: "-120%" }}
exit={{ opacity: 0 }}
style={{
background: `
linear-gradient(
180deg,
transparent 0%,
rgba(6, 182, 212, 0.1) 20%,
rgba(6, 182, 212, 0.3) 50%,
rgba(6, 182, 212, 0.1) 80%,
transparent 100%
)
`,
boxShadow: '0 0 30px rgba(6, 182, 212, 0.3)'
}}
>
{/* 扫描线中心光束 */}
<motion.div
className="absolute top-1/2 left-0 w-full h-[2px]"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgb(6, 182, 212) 50%, transparent 100%)',
boxShadow: '0 0 20px rgb(6, 182, 212)'
}}
/>
</motion.div>
{/* 扫描网格 */}
<div className="absolute inset-0 z-15">
<motion.div
className="w-full h-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
style={{
backgroundImage: `
linear-gradient(90deg, rgba(6, 182, 212, 0.1) 1px, transparent 1px),
linear-gradient(0deg, rgba(6, 182, 212, 0.1) 1px, transparent 1px)
`,
backgroundSize: '40px 40px'
}}
/>
<motion.div
className="absolute inset-0"
initial={{ opacity: 0 }}
animate={{ opacity: 0.5 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
style={{
backgroundImage: `
linear-gradient(90deg, rgba(6, 182, 212, 0.05) 1px, transparent 1px),
linear-gradient(0deg, rgba(6, 182, 212, 0.05) 1px, transparent 1px)
`,
backgroundSize: '20px 20px'
}}
/>
</div>
{/* 四角扫描框 */}
<div className="absolute inset-4 z-30 pointer-events-none">
<motion.div
className="w-full h-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{/* 左上角 */}
<div className="absolute top-0 left-0 w-8 h-8 border-l-2 border-t-2 border-cyan-400/70" />
{/* 右上角 */}
<div className="absolute top-0 right-0 w-8 h-8 border-r-2 border-t-2 border-cyan-400/70" />
{/* 左下角 */}
<div className="absolute bottom-0 left-0 w-8 h-8 border-l-2 border-b-2 border-cyan-400/70" />
{/* 右下角 */}
<div className="absolute bottom-0 right-0 w-8 h-8 border-r-2 border-b-2 border-cyan-400/70" />
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 人物识别框和浮签 */}
<AnimatePresence>
{detections.map((person, index) => {
return (
<div key={person.id} className="cursor-pointer" onClick={() => {
onPersonClick?.(person);
}}>
<PersonBox person={person} />
<motion.div
className="absolute z-50 px-3 py-1 text-white text-xs bg-cyan-500/20 border border-cyan-400/30 rounded-md backdrop-blur-md whitespace-nowrap"
style={{
top: `${person.position.top}px`,
left: `${person.position.left}px`
}}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
{person.name}
</motion.div>
</div>
);
})}
</AnimatePresence>
</div>
);
};