forked from 77media/video-flow
398 lines
13 KiB
TypeScript
398 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;
|
|
};
|
|
|
|
export const PersonDetectionScene: React.FC<Props> = ({
|
|
backgroundImage,
|
|
videoSrc,
|
|
detections,
|
|
triggerScan,
|
|
onScanStart,
|
|
onScanTimeout,
|
|
onScanExit,
|
|
scanTimeout = 10000,
|
|
isScanFailed = false,
|
|
onDetectionsChange
|
|
}) => {
|
|
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}
|
|
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 (
|
|
<React.Fragment key={person.id}>
|
|
<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>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
};
|