'use client'; import { useMemo, useState, useCallback, useRef, useReducer, useEffect, } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { Film } from 'lucide-react'; import { DetectionData, DetectionType, DetectionCounts, DetectionLogEntry, } from '@/types'; import { useGpsMap } from '@/hooks/use-gps-map'; import { useFrameDetectionMap } from '@/hooks/use-frame-detection-map'; import { useCumulativeCounts } from '@/hooks/use-cumulative-counts'; import { useVideoDetectionLoop } from '@/hooks/use-video-detection-loop'; import VideoCanvasPlayer, { VideoCanvasPlayerRef, } from './video/video-canvas-player'; import DetectionStatsBar from './video/detection-stats-bar'; import DetectionLogs from './video/detection-logs'; import SummarySection from './video/summary-section'; import DetailedSummarySection from './video/detailed-summary-section'; import { getDetectionModeConfig, getEnabledDetectionTypes, } from '@/constants/detectionModeConfig'; type VideoPlayerSectionProps = { data: DetectionData; videoId: string; videoFile: File | null; detectionType: string; projectId?: string; }; const MAX_LOGS = 50; type LogAction = | { type: 'ADD_LOG'; payload: DetectionLogEntry } | { type: 'CLEAR' }; function logsReducer( state: DetectionLogEntry[], action: LogAction, ): DetectionLogEntry[] { switch (action.type) { case 'ADD_LOG': // Move to top if already exists, or just add to top const filtered = state.filter( (log) => log.frame !== action.payload.frame, ); return [action.payload, ...filtered].slice(0, MAX_LOGS); case 'CLEAR': return []; default: return state; } } const formatVideoTime = (frame: number, fps: number): string => { const seconds = frame / fps; const minutes = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${minutes}:${secs.toString().padStart(2, '0')}`; }; export default function VideoPlayerSection({ data, videoId, videoFile, detectionType, projectId, }: VideoPlayerSectionProps) { // Refs const playerRef = useRef(null); const videoRef = useRef(null); const canvasRef = useRef(null); const loggedFrames = useRef>(new Set()); // State const [currentFrame, setCurrentFrame] = useState(0); const [showSummary, setShowSummary] = useState(false); const [hasPlayedOnce, setHasPlayedOnce] = useState(false); const [videoError, setVideoError] = useState(null); const [lastDetectedLat, setLastDetectedLat] = useState(null); const [lastDetectedLng, setLastDetectedLng] = useState(null); const detectionMode = data.detection_mode || detectionType; const modeConfig = getDetectionModeConfig(detectionMode); const enabledTypes = getEnabledDetectionTypes(detectionMode); const initialCounts = useMemo(() => { const counts: any = {}; enabledTypes.forEach((type) => { counts[type.frameCountKey] = 0; }); return counts as DetectionCounts; }, [enabledTypes]); const [currentFrameCounts, setCurrentFrameCounts] = useState(initialCounts); // Logs Reducer const [logs, dispatchLogs] = useReducer(logsReducer, []); // Memoize a unified data object with frames generated if missing const normalizedData = useMemo(() => { if (data.frames && Array.isArray(data.frames) && data.frames.length > 0) { return data; } // Synthesize frames from lists if not present (specifically for gemini_video) // Other models like YOLO return data.frames directly const framesMap = new Map(); // Sort all detections by their first_detected_frame const allDetections: any[] = []; enabledTypes.forEach((type) => { const list = (data as any)[type.listKey]; if (list && Array.isArray(list)) { list.forEach((item: any) => { allDetections.push({ ...item, _detType: type.id }); }); } }); allDetections.sort( (a, b) => (a.first_detected_frame || 0) - (b.first_detected_frame || 0), ); // Current counts for sticky stats const currentCounts: Record = {}; enabledTypes.forEach((t) => { currentCounts[t.frameCountKey] = 0; }); allDetections.forEach((det) => { const frameId = det.first_detected_frame || det.frame_number || 0; // Spread detection across multiple frames so it stays visible (persistence) // Gemini detections are sparse, so showing them for ~1 second (30 frames) helps const persistenceFrames = 30; for (let i = 0; i < persistenceFrames; i++) { const targetFrame = frameId + i; if (!framesMap.has(targetFrame)) { framesMap.set(targetFrame, { frame_id: targetFrame, detections: [] }); } const frameData = framesMap.get(targetFrame); // Update cumulative counts only on the first detected frame if (i === 0) { const typeConfig = enabledTypes.find((t) => t.id === det._detType); if (typeConfig) { currentCounts[typeConfig.frameCountKey] = (currentCounts[typeConfig.frameCountKey] || 0) + 1; } } const countCopy = { ...currentCounts }; frameData.detections.push({ ...det, type: det.type || det._detType, detection_id: det.detection_id || det.id, count: countCopy, }); } }); return { ...data, frames: Array.from(framesMap.values()).sort( (a, b) => (a.frame_id || 0) - (b.frame_id || 0), ), }; }, [data, enabledTypes]); // Custom Hooks const gpsMap = useGpsMap(normalizedData); const { getNearestDetections, sortedDetectionIndices } = useFrameDetectionMap( normalizedData, detectionType, ); const { getStickyCounts, sortedFrameIndices } = useCumulativeCounts( normalizedData.frames || [], ); // Memoized video URL const videoUrl = useMemo(() => { if (videoFile) return URL.createObjectURL(videoFile); if (videoId) return `${process.env.NEXT_PUBLIC_API_URL}/video/${videoId}`; return ''; }, [videoFile, videoId]); // Clean up Object URL useEffect(() => { return () => { if (videoFile && videoUrl) URL.revokeObjectURL(videoUrl); }; }, [videoFile, videoUrl]); // Handle detection updates const handleFrameUpdate = useCallback( (frame: number) => { setCurrentFrame(frame); // Optimized lookup const dets = getNearestDetections(frame, sortedFrameIndices); const counts = getStickyCounts(frame); // Update visuals via ref to avoid state-induced slow re-renders in heavy loops playerRef.current?.drawDetections(dets || []); // Frame stats setCurrentFrameCounts(counts); // Logging logic if (dets && dets.length > 0) { const firstDetId = dets[0].pothole_id ?? dets[0].signboard_id ?? dets[0].detection_id; const coords = gpsMap.get(firstDetId); if (coords) { setLastDetectedLat(coords.lat); setLastDetectedLng(coords.lng); } dispatchLogs({ type: 'ADD_LOG', payload: { frame, videoTime: formatVideoTime(frame, data.video_info.fps), detections: dets.map((d) => ({ id: d.pothole_id ?? d.signboard_id ?? d.detection_id, type: d.type || d._detType, bbox: d.bbox, confidence: d.confidence, latitude: gpsMap.get( d.pothole_id ?? d.signboard_id ?? d.detection_id, )?.lat, longitude: gpsMap.get( d.pothole_id ?? d.signboard_id ?? d.detection_id, )?.lng, })), }, }); } }, [ data.video_info.fps, getNearestDetections, getStickyCounts, gpsMap, sortedDetectionIndices, ], ); // Playback loop hook useVideoDetectionLoop(videoRef, data.video_info.fps, handleFrameUpdate); // Callbacks const handleLoadedData = useCallback(() => { playerRef.current?.resize(); }, []); const handleVideoError = useCallback(() => { setVideoError('Failed to load video'); }, []); const handleVideoEnded = useCallback(() => { if (!hasPlayedOnce) { setHasPlayedOnce(true); setShowSummary(true); } }, [hasPlayedOnce]); const handleVideoSeeked = useCallback(() => { const video = videoRef.current; if (!video) return; const fps = data.video_info.fps || 30; const currentFrame = Math.round(video.currentTime * fps); // Snap to the next available frame index that has detections const nextDetectionFrame = sortedDetectionIndices.find( (f) => f >= currentFrame, ); if ( nextDetectionFrame !== undefined && nextDetectionFrame !== currentFrame ) { video.currentTime = nextDetectionFrame / fps; return; } handleFrameUpdate(currentFrame); }, [data.video_info.fps, handleFrameUpdate, sortedDetectionIndices]); const seekToFrame = useCallback( (frame: number) => { const video = videoRef.current; if (!video) return; video.currentTime = frame / data.video_info.fps; }, [data.video_info.fps], ); return (
Detection Playback Real-time object detection analysis
{showSummary && (
)}
); }