"use client" import { useEffect, useRef, useState, useCallback } from "react" import dynamic from "next/dynamic" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { ScrollArea } from "@/components/ui/scroll-area" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Target, AlertTriangle, Film, Activity, Gauge, Monitor, SignpostBig, Map as MapIcon } from "lucide-react" // Dynamically import MapModal with SSR disabled (Leaflet requires window object) const MapModal = dynamic(() => import("@/components/map-modal"), { ssr: false }) import { DetectionData, DetectionType } from "@/types" import { cn } from "@/lib/utils" import { projectService } from "@/services/api" type VideoPlayerSectionProps = { data: DetectionData videoId: string videoFile: File | null // null when loading from server (results page) detectionType: DetectionType projectId?: string // Optional project ID for fetching detailed summary } type DetectionLog = { frame: number detections: Array<{ id: number type?: string // For signboards bbox: { x1: number; y1: number; x2: number; y2: number } confidence: number latitude?: number longitude?: number }> videoTime: string // Video timestamp (MM:SS) } type ChainageSummaryData = { project: { id: string name: string corridor_name: string | null state: string | null } packages: { [packageName: string]: { package_id: string region: string | null chainages: { [chainageName: string]: { chainage_id: string chainage: string | null detection_count: number detections: Array<{ id: number video_id: string type: string class: string confidence: number latitude: number longitude: number frame_number: number timestamp_ms: number bounding_box: { x1: number y1: number x2: number y2: number } }> } } } } } function DetailedSummarySection({ projectId, videoId, show, detectionType }: { projectId: string videoId: string show: boolean detectionType: DetectionType }) { const [summaryData, setSummaryData] = useState(null) const [loading, setLoading] = useState(false) const [showMap, setShowMap] = useState(false) useEffect(() => { if (!show || !videoId || !projectId) return const fetchSummary = async () => { setLoading(true) try { const data = await projectService.getProjectSummaryByVideo(projectId, videoId); setSummaryData(data) } catch (err) { console.error("Failed to fetch summary:", err) } finally { setLoading(false) } } fetchSummary() }, [show, videoId, projectId]) if (!show || loading || !summaryData) return null const isCombined = detectionType === "pot-sign-detection" const isPothole = detectionType === "pothole-detection" || isCombined // Flatten all detections for the scrollable list const allDetections: Array<{ detection: any chainageName: string packageName: string }> = [] Object.entries(summaryData.packages || {}).forEach(([packageName, packageData]) => { Object.entries(packageData?.chainages || {}).forEach(([chainageName, chainageData]) => { chainageData?.detections?.forEach(detection => { allDetections.push({ detection, chainageName, packageName }) }) }) }) return (
Chainages {isCombined ? "Potholes & Signboards" : isPothole ? "Potholes" : "Signboards"} detected
{/* Project Info */}
Project {summaryData.project.name}
{summaryData.project.corridor_name && (
Corridor {summaryData.project.corridor_name}
)}
{/* Packages and Chainages */} {Object.entries(summaryData.packages).map(([packageName, packageData]) => (
{packageName}
{Object.entries(packageData.chainages).map(([chainageName, chainageData]) => (
{chainageName}
{chainageData.detection_count}
))}
))}
All Detections Complete list with GPS coordinates
{allDetections.map(({ detection, chainageName }, idx) => (
{detection.type === "pothole" ? "Pothole" : (detection.class || detection.type || "").replace(/_/g, " ")} #{detection.id} Frame {detection.frame_number}
Chainage: {chainageName}
Confidence: {(detection.confidence * 100).toFixed(1)}%
GPS: {detection.latitude}, {detection.longitude}
))}
{/* Map Modal */} setShowMap(false)} detections={allDetections.map(({ detection }) => detection)} detectionType={detectionType} />
) } function SummarySection({ data, show, detectionType }: { data: DetectionData; show: boolean; detectionType: DetectionType }) { if (!show) return null const isCombined = detectionType === "pot-sign-detection" const isPothole = detectionType === "pothole-detection" || isCombined const stats = [ { label: "Total Damage", value: data.summary.total_road_damage || 0, icon: Target, color: "text-purple-500", bgColor: "bg-purple-500/10" }, { label: "Defected Signs", value: data.summary.unique_defected_sign_board || 0, icon: SignpostBig, color: "text-blue-500", bgColor: "bg-blue-500/10" }, { label: "Unique Potholes", value: data.summary.unique_pothole || 0, icon: AlertTriangle, color: "text-red-500", bgColor: "bg-red-500/10" }, { label: "Road Cracks", value: data.summary.unique_road_crack || 0, icon: AlertTriangle, color: "text-orange-500", bgColor: "bg-orange-500/10" }, { label: "Markings", value: data.summary.unique_damaged_road_marking || 0, icon: Activity, color: "text-indigo-500", bgColor: "bg-indigo-500/10" }, { label: "Good Signs", value: data.summary.unique_good_sign_board || 0, icon: SignpostBig, color: "text-emerald-500", bgColor: "bg-emerald-500/10" }, { label: "Rate", value: `${(data.summary.detection_rate || 0).toFixed(1)}%`, icon: Activity, color: "text-green-500", bgColor: "bg-green-500/10" }, { label: "Video FPS", value: (data.video_info.fps || 0).toFixed(1), icon: Gauge, color: "text-orange-500", bgColor: "bg-orange-500/10" }, { label: "Resolution", value: `${data.video_info.width}×${data.video_info.height}`, icon: Monitor, color: "text-blue-500", bgColor: "bg-blue-500/10" }, { label: "Total Frames", value: data.summary.total_frames || data.video_info.total_frames, icon: Film, color: "text-purple-500", bgColor: "bg-purple-500/10" }, ] return (
Quick Stats Detection analysis overview
{stats.map((stat) => { const Icon = stat.icon return (
{stat.value}
{stat.label}
) })}
) } export default function VideoPlayerSection({ data, videoId, videoFile, detectionType, projectId }: VideoPlayerSectionProps) { const videoRef = useRef(null) const canvasRef = useRef(null) const containerRef = useRef(null) const [currentFrame, setCurrentFrame] = useState(0) const [detectionsCount, setDetectionsCount] = useState(0) const [logs, setLogs] = useState([]) 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 [currentFrameCounts, setCurrentFrameCounts] = useState>({ defected_sign_board: 0, pothole: 0, road_crack: 0, damaged_road_marking: 0, good_sign_board: 0 }) const frameDetectionMap = useRef>(new Map()) const lastProcessedFrame = useRef(-1) const loggedFrames = useRef>(new Set()) const cumulativeCountsMap = useRef>>(new Map()) const sortedFrameIndices = useRef([]) const detectedFrameSkip = useRef(3) const MAX_LOGS = 50 const isCombined = detectionType === "pot-sign-detection" const isPothole = detectionType === "pothole-detection" || isCombined const isSignboard = detectionType === "sign-board-detection" || isCombined const gpsMap = useRef>(new Map()) useEffect(() => { const map = new Map() const addItemsToMap = (list?: any[]) => { if (!list || !Array.isArray(list)) return list.forEach(item => { const id = (item as any).pothole_id ?? (item as any).signboard_id ?? (item as any).detection_id if (item.lat !== undefined && item.lng !== undefined && id !== undefined) { map.set(id, { lat: item.lat, lng: item.lng }) } }) } addItemsToMap(data.pothole_list) addItemsToMap(data.signboard_list) addItemsToMap(data.defected_sign_board_list) addItemsToMap(data.road_crack_list) addItemsToMap(data.damaged_road_marking_list) addItemsToMap(data.good_sign_board_list) gpsMap.current = map }, [data]) const formatVideoTime = useCallback((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')}` }, []) const seekToFrame = useCallback((frame: number) => { const video = videoRef.current if (!video) return video.currentTime = frame / data.video_info.fps }, [data.video_info.fps]) useEffect(() => { const map = new Map() if (data.frames && Array.isArray(data.frames)) { data.frames.forEach((frameData) => { const frameId = frameData.frame_id const flatDetections = (frameData as any).detections if (flatDetections && Array.isArray(flatDetections)) { map.set(frameId, flatDetections.map((d: any) => ({ ...d, _detType: d.type === 'pothole' ? 'pothole' : 'signboard', pothole_id: d.type === 'pothole' ? d.detection_id : undefined, signboard_id: d.type !== 'pothole' ? d.detection_id : undefined, }))) } else { let detections: any[] = [] if (isPothole && frameData.potholes) detections = [...detections, ...frameData.potholes.map((p: any) => ({ ...p, _detType: 'pothole' }))] if (isSignboard && frameData.signboards) detections = [...detections, ...frameData.signboards.map((s: any) => ({ ...s, _detType: 'signboard' }))] if (detections.length > 0) map.set(frameId, detections) } }) } frameDetectionMap.current = map }, [data, isPothole, isSignboard]) useEffect(() => { const map = new Map>() let lastCounts = { defected_sign_board: 0, pothole: 0, road_crack: 0, damaged_road_marking: 0, good_sign_board: 0 } if (data.frames && Array.isArray(data.frames)) { const sortedFrames = [...data.frames].sort((a, b) => (a.frame_id || 0) - (b.frame_id || 0)) const indices: number[] = [] sortedFrames.forEach((frameData) => { const frameId = frameData.frame_id indices.push(frameId) const detections = (frameData as any).detections if (detections && detections.length > 0) { const frameCounts = detections[0].count || { defected_sign_board: 0, pothole: 0, road_crack: 0, damaged_road_marking: 0, good_sign_board: 0 } lastCounts = { defected_sign_board: Math.max(lastCounts.defected_sign_board, frameCounts.defected_sign_board || 0), pothole: Math.max(lastCounts.pothole, frameCounts.pothole || 0), road_crack: Math.max(lastCounts.road_crack, frameCounts.road_crack || 0), damaged_road_marking: Math.max(lastCounts.damaged_road_marking, frameCounts.damaged_road_marking || 0), good_sign_board: Math.max(lastCounts.good_sign_board, frameCounts.good_sign_board || 0), } } map.set(frameId, { ...lastCounts }) }) sortedFrameIndices.current = indices } cumulativeCountsMap.current = map }, [data.frames]) const getStickyCounts = useCallback((frame: number) => { const indices = sortedFrameIndices.current let targetIndex = -1 let low = 0, high = indices.length - 1 while (low <= high) { let mid = Math.floor((low + high) / 2) if (indices[mid] <= frame) { targetIndex = indices[mid] low = mid + 1 } else { high = mid - 1 } } return targetIndex !== -1 ? (cumulativeCountsMap.current.get(targetIndex) || { defected_sign_board: 0, pothole: 0, road_crack: 0, damaged_road_marking: 0, good_sign_board: 0 }) : { defected_sign_board: 0, pothole: 0, road_crack: 0, damaged_road_marking: 0, good_sign_board: 0 } }, []) const getNearestDetections = useCallback((frame: number): any[] | undefined => { const exact = frameDetectionMap.current.get(frame) if (exact) return exact const indices = sortedFrameIndices.current let low = 0, high = indices.length - 1, targetIndex = -1 while (low <= high) { const mid = Math.floor((low + high) / 2) if (indices[mid] <= frame) { targetIndex = indices[mid]; low = mid + 1 } else { high = mid - 1 } } return (targetIndex !== -1 && (frame - targetIndex) <= detectedFrameSkip.current) ? frameDetectionMap.current.get(targetIndex) : undefined }, []) const drawBoundingBoxes = useCallback((detections: any[]) => { const canvas = canvasRef.current const video = videoRef.current if (!canvas || !video) return const ctx = canvas.getContext('2d') if (!ctx) return ctx.clearRect(0, 0, canvas.width, canvas.height) if (!detections || detections.length === 0) return const scaleX = canvas.width / data.video_info.width const scaleY = canvas.height / data.video_info.height detections.forEach((detection) => { const bbox = detection.bbox if (!bbox) return const x1 = bbox.x1 * scaleX, y1 = bbox.y1 * scaleY, x2 = bbox.x2 * scaleX, y2 = bbox.y2 * scaleY const type = (detection.type || detection._detType || '').toLowerCase() const colors: Record = { 'pothole': '#ef4444', 'defected_sign_board': '#3b82f6', 'road_crack': '#f59e0b', 'damaged_road_marking': '#6366f1', 'good_sign_board': '#10b981' } const boxColor = colors[type] || '#3b82f6' ctx.strokeStyle = boxColor ctx.lineWidth = 3 ctx.strokeRect(x1, y1, x2 - x1, y2 - y1) ctx.fillStyle = boxColor + '20' ctx.fillRect(x1, y1, x2 - x1, y2 - y1) const id = detection.pothole_id ?? detection.signboard_id ?? detection.detection_id const label = `${type.replace(/_/g, ' ')} #${id} ${(detection.confidence * 100).toFixed(0)}%` ctx.font = 'bold 12px sans-serif' const metrics = ctx.measureText(label) ctx.fillStyle = boxColor ctx.fillRect(x1, y1 - 20, metrics.width + 10, 20) ctx.fillStyle = '#fff' ctx.fillText(label, x1 + 5, y1 - 6) }) }, [data.video_info]) const resizeCanvas = useCallback(() => { if (!videoRef.current || !canvasRef.current) return const rect = videoRef.current.getBoundingClientRect() canvasRef.current.width = rect.width canvasRef.current.height = rect.height const detections = getNearestDetections(Math.round(videoRef.current.currentTime * data.video_info.fps)) if (detections) drawBoundingBoxes(detections) }, [data.video_info.fps, drawBoundingBoxes, getNearestDetections]) useEffect(() => { window.addEventListener('resize', resizeCanvas) return () => window.removeEventListener('resize', resizeCanvas) }, [resizeCanvas]) useEffect(() => { const video = videoRef.current if (!video) return const videoUrl = videoFile ? URL.createObjectURL(videoFile) : (videoId ? `${process.env.NEXT_PUBLIC_API_URL}/video/${videoId}` : '') if (videoUrl) { video.src = videoUrl video.onloadeddata = resizeCanvas video.onerror = () => setVideoError("Failed to load video") } return () => { if (videoFile) URL.revokeObjectURL(videoUrl) } }, [videoFile, videoId, resizeCanvas]) const addDetectionLog = useCallback((frame: number, detections: any[]) => { if (loggedFrames.current.has(frame)) return loggedFrames.current.add(frame) if (detections.length > 0) { const coords = gpsMap.current.get(detections[0].pothole_id ?? detections[0].signboard_id ?? detections[0].detection_id) if (coords) { setLastDetectedLat(coords.lat); setLastDetectedLng(coords.lng) } } setLogs(prev => [{ frame, videoTime: formatVideoTime(frame, data.video_info.fps), detections: detections.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.current.get(d.pothole_id ?? d.signboard_id ?? d.detection_id)?.lat, longitude: gpsMap.current.get(d.pothole_id ?? d.signboard_id ?? d.detection_id)?.lng, })) }, ...prev].slice(0, MAX_LOGS)) }, [data.video_info.fps, formatVideoTime]) useEffect(() => { let animId: number const update = () => { if (videoRef.current && !videoRef.current.paused) { const frame = Math.round(videoRef.current.currentTime * data.video_info.fps) if (frame !== lastProcessedFrame.current) { lastProcessedFrame.current = frame setCurrentFrame(frame) const dets = getNearestDetections(frame) setDetectionsCount(dets?.length || 0) drawBoundingBoxes(dets || []) setCurrentFrameCounts(getStickyCounts(frame)) if (dets?.length) addDetectionLog(frame, dets) } } animId = requestAnimationFrame(update) } animId = requestAnimationFrame(update) return () => cancelAnimationFrame(animId) }, [data.video_info.fps, addDetectionLog, drawBoundingBoxes, getNearestDetections, getStickyCounts]) useEffect(() => { const v = videoRef.current if (!v) return v.onended = () => { if (!hasPlayedOnce) { setHasPlayedOnce(true); setShowSummary(true) } } v.onseeked = () => { const frame = Math.round(v.currentTime * data.video_info.fps) setCurrentFrame(frame) const dets = frameDetectionMap.current.get(frame) || [] drawBoundingBoxes(dets) setCurrentFrameCounts(getStickyCounts(frame)) } }, [data.video_info.fps, drawBoundingBoxes, getStickyCounts, hasPlayedOnce]) return (
Detection Playback Real-time object detection analysis
{videoError && {videoError}}
POTHOLE: {currentFrameCounts.pothole}
DEFECT SIGN BOARD: {currentFrameCounts.defected_sign_board}
DAMAGE ROAD MARK: {currentFrameCounts.damaged_road_marking}
ROADCRACK: {currentFrameCounts.road_crack}
FRAME: {currentFrame}
{lastDetectedLat && ( <>
LAT: {lastDetectedLat.toFixed(7)}
LNG: {lastDetectedLng?.toFixed(7)}
)}

Detection Logs

Live Logs
{logs.length === 0 ? (

Playback to see logs

) : ( logs.map((log, i) => (
seekToFrame(log.frame)} className="p-4 rounded border bg-card hover:border-primary transition-all cursor-pointer group">
Frame: {log.frame} {log.videoTime}
{log.detections.map((d, j) => (
{(d.type || "").replace(/ /g, "_")} ID: {d.id} | Confidence: {(d.confidence * 100).toFixed(1)}%
{d.bbox && (
Coordinates: ({Math.round(d.bbox.x1)}, {Math.round(d.bbox.y1)}) ({Math.round(d.bbox.x2)}, {Math.round(d.bbox.y2)})
)} {d.latitude && (
GPS: {d.latitude.toFixed(8)}, {d.longitude?.toFixed(8)}
)}
))}
)) )}
{showSummary && (
)}
) }