Files
road-monitoring-ui/components/video-player-section.tsx

1199 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 "@/lib/types"
const API_URL = process.env.NEXT_PUBLIC_API_URL
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 LocationSummaryData = {
project: {
id: string
name: string
corridor_name: string | null
state: string | null
}
packages: {
[packageName: string]: {
package_id: string
region: string | null
locations: {
[locationName: string]: {
location_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<LocationSummaryData | null>(null)
const [loading, setLoading] = useState(false)
const [showMap, setShowMap] = useState(false)
useEffect(() => {
if (!show || !videoId || !projectId) return
const fetchSummary = async () => {
setLoading(true)
try {
const response = await fetch(`${API_URL}/summary/projects/${projectId}?video_id=${videoId}`, {
headers: { "ngrok-skip-browser-warning": "true" }
})
if (response.ok) {
const data = await response.json()
setSummaryData(data)
} else {
console.error(`Failed to fetch summary: ${response.status}`)
}
} 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
locationName: string
packageName: string
}> = []
Object.entries(summaryData.packages).forEach(([packageName, packageData]) => {
Object.entries(packageData.locations).forEach(([locationName, locationData]) => {
locationData.detections.forEach(detection => {
allDetections.push({ detection, locationName, packageName })
})
})
})
return (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-2">
<Card className="bg-white/60 backdrop-blur-md border border-gray-100 rounded-xl overflow-hidden flex flex-col lg:col-span-2">
<CardHeader className="pb-3 ">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-linear-to-b from-[#225999] to-[#56A5FF] flex items-center justify-center relative overflow-hidden group">
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" className="min-h-8 min-w-8 text-white relative z-10">
<path d="M50 20L85 80H15L50 20Z" stroke="currentColor" strokeWidth="6" strokeLinejoin="round" />
<path d="M40 80L50 55L60 80" stroke="currentColor" strokeWidth="6" />
</svg>
</div>
<div>
<CardTitle className="text-base font-bold text-gray-900 dark:text-white">
Detection Locations
</CardTitle>
<CardDescription className="text-xs">
{isCombined ? "Potholes & Signboards" : isPothole ? "Potholes" : "Signboards"} detected across project locations
</CardDescription>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowMap(true)}
className="gap-2 btn-blue-gradient"
>
<MapIcon className="h-4 w-4 text-white" />
Show Map
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden">
<ScrollArea className="h-[300px] p-6 pt-3">
<div className="space-y-3">
{/* Project Info */}
<div className="pb-3 border-b mb-3">
<div className="flex flex-col mb-3">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-0.5">Project</span>
<span className="text-base font-bold text-gray-900 dark:text-white">
{summaryData.project.name}
</span>
</div>
{summaryData.project.corridor_name && (
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-0.5">Corridor</span>
<span className="text-sm font-bold text-gray-700 dark:text-gray-300">
{summaryData.project.corridor_name}
</span>
</div>
)}
</div>
{/* Packages and Locations */}
{Object.entries(summaryData.packages).map(([packageName, packageData]) => (
<div key={packageData.package_id} className="space-y-2">
<div className="text-xs font-medium">{packageName}</div>
<div className="pl-3 space-y-2">
{Object.entries(packageData.locations).map(([locationName, locationData]) => (
<div key={locationData.location_id} className="text-xs p-2 rounded bg-muted/30 flex items-center justify-between gap-4">
<div className="font-medium truncate">{locationName}</div>
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
{locationData.detection_count} detection{locationData.detection_count !== 1 ? "s" : ""} found
</div>
</div>
))}
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="bg-white/60 backdrop-blur-md rounded-md overflow-hidden flex flex-col lg:col-span-3">
<CardHeader className="pb-3 ">
<div className="flex items-center gap-3">
<div className="p-2 rounded-md bg-linear-to-b from-[#225999] to-[#56A5FF] flex items-center justify-center relative overflow-hidden group">
<div className="absolute inset-0 bg-white/10 opacity-40"></div>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-white relative z-10">
<path d="M3 17L9 11L13 15L21 7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15 7H21V13" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div>
<CardTitle className="text-base font-bold text-gray-900 dark:text-white">
All Detections
</CardTitle>
<CardDescription className="text-xs">
Complete list of {isCombined ? "potholes & signboards" : isPothole ? "potholes" : "signboards"} with GPS coordinates
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden">
<ScrollArea className="h-[300px] p-4">
<div className="space-y-2">
{allDetections.map(({ detection, locationName, packageName }, idx) => (
<div
key={`${detection.id}-${idx}`}
className="text-xs p-2 bg-white border border-slate-200 rounded-md "
>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold">
{detection.type === "pothole" ? "Pothole" : (detection.class || detection.type || "").replace(/_/g, " ")} #{detection.id}
</span>
<span className="text-[10px] text-muted-foreground">
Frame {detection.frame_number}
</span>
</div>
<div className="space-y-0.5 text-[10px] text-muted-foreground">
<div>Location: {locationName}</div>
<div>Confidence: {(detection.confidence * 100).toFixed(1)}%</div>
<div className="font-mono">
GPS: {detection.latitude}, {detection.longitude}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Map Modal */}
<MapModal
open={showMap}
onClose={() => setShowMap(false)}
detections={allDetections.map(({ detection }) => detection)}
detectionType={detectionType}
/>
</div>
)
}
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 isSignboard = detectionType === "sign-board-detection" || isCombined
const stats = [
{
label: "Total Road Damage",
value: data.summary.total_road_damage || 0,
icon: Target,
color: "text-purple-500",
bgColor: "bg-purple-50 dark:bg-purple-950/30",
},
{
label: "Defected Signboards",
value: data.summary.unique_defected_sign_board || 0,
icon: SignpostBig,
color: "text-blue-500",
bgColor: "bg-blue-50 dark:bg-blue-950/30",
},
{
label: "Unique Potholes",
value: data.summary.unique_pothole || 0,
icon: AlertTriangle,
color: "text-red-500",
bgColor: "bg-red-50 dark:bg-red-950/30",
},
{
label: "Road Cracks",
value: data.summary.unique_road_crack || 0,
icon: AlertTriangle,
color: "text-orange-500",
bgColor: "bg-orange-50 dark:bg-orange-950/30",
},
{
label: "Damaged Markings",
value: data.summary.unique_damaged_road_marking || 0,
icon: Activity,
color: "text-indigo-500",
bgColor: "bg-indigo-50 dark:bg-indigo-950/30",
},
{
label: "Good Signboards",
value: data.summary.unique_good_sign_board || 0,
icon: SignpostBig,
color: "text-emerald-500",
bgColor: "bg-emerald-50 dark:bg-emerald-950/30",
},
{
label: "Detection Rate",
value: `${(data.summary.detection_rate || 0).toFixed(1)}%`,
icon: Activity,
color: "text-green-500",
bgColor: "bg-green-50 dark:bg-green-950/30",
},
{
label: "Video FPS",
value: (data.video_info.fps || 0).toFixed(1),
icon: Gauge,
color: "text-orange-500",
bgColor: "bg-orange-50 dark:bg-orange-950/30",
},
{
label: "Resolution",
value: `${data.video_info.width}×${data.video_info.height}`,
icon: Monitor,
color: "text-blue-500",
bgColor: "bg-blue-50 dark:bg-blue-950/30",
},
{
label: "Total Frames",
value: data.summary.total_frames || data.video_info.total_frames,
icon: Film,
color: "text-purple-500",
bgColor: "bg-purple-50 dark:bg-purple-950/30",
},
]
return (
<Card className="bg-white/60 backdrop-blur-md rounded-xl overflow-hidden">
<CardHeader className="">
<div className="flex items-center gap-3">
<div className="p-2 rounded-md bg-linear-to-b from-[#225999] to-[#56A5FF] flex items-center justify-center relative overflow-hidden group">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-white relative z-10">
<path d="M3 17L9 11L13 15L21 7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15 7H21V13" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div>
<CardTitle className="text-base font-bold text-gray-900 dark:text-white">
Quick Stats
</CardTitle>
<CardDescription className="text-xs">
Overview of {isCombined ? "pothole & signboard" : isPothole ? "pothole" : "signboard"} detection results
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{stats.map((stat, index) => {
const Icon = stat.icon
return (
<div
key={stat.label}
className="flex flex-col items-center justify-center p-3 rounded-xl bg-white/80 backdrop-blur-md hover:scale-105 cursor-pointer transition-all duration-300 ease-in-out"
>
<div className={`${stat.bgColor} p-3 rounded-lg mb-2 transition-all`}>
<Icon className={`h-6 w-6 ${stat.color}`} />
</div>
<div className={`text-xl font-bold ${stat.color} mb-1`}>{stat.value}</div>
<div className="text-[10px] text-muted-foreground text-center leading-tight font-medium uppercase tracking-wide">{stat.label}</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}
export default function VideoPlayerSection({ data, videoId, videoFile, detectionType, projectId }: VideoPlayerSectionProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [currentFrame, setCurrentFrame] = useState(0)
const [detectionsCount, setDetectionsCount] = useState(0)
const [logs, setLogs] = useState<DetectionLog[]>([])
const [showSummary, setShowSummary] = useState(false)
const [hasPlayedOnce, setHasPlayedOnce] = useState(false)
const [videoError, setVideoError] = useState<string | null>(null)
const [lastDetectedLat, setLastDetectedLat] = useState<number | null>(null)
const [lastDetectedLng, setLastDetectedLng] = useState<number | null>(null)
const [currentFrameCounts, setCurrentFrameCounts] = useState<Record<string, number>>({
defected_sign_board: 0,
pothole: 0,
road_crack: 0,
damaged_road_marking: 0,
good_sign_board: 0
})
const frameDetectionMap = useRef<Map<number, any[]>>(new Map())
const lastProcessedFrame = useRef(-1)
const loggedFrames = useRef<Set<number>>(new Set())
const cumulativeCountsMap = useRef<Map<number, Record<string, number>>>(new Map())
const sortedFrameIndices = useRef<number[]>([])
const detectedFrameSkip = useRef<number>(3) // Computed from data, fallback to 3
const MAX_LOGS = 50
const isCombined = detectionType === "pot-sign-detection"
const isPothole = detectionType === "pothole-detection" || isCombined
const isSignboard = detectionType === "sign-board-detection" || isCombined
// Create GPS coordinate map from signboard_list or pothole_list
const gpsMap = useRef<Map<number, { lat: number; lng: number }>>(new Map())
// Build GPS map from all available detection lists
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
console.log(`[VideoPlayer] GPS map built with ${map.size} entries mapping to IDs`)
}, [data])
// Helper function to format video time from frame number
const formatVideoTime = useCallback((frame: number, fps: number): string => {
const seconds = frame / fps
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return `${minutes}:${secs.toString().padStart(2, '0')}`
}, [])
// Function to seek video to a specific frame
const seekToFrame = useCallback((frame: number) => {
const video = videoRef.current
if (!video) return
// Calculate time from frame number
const time = frame / data.video_info.fps
// Set video currentTime (this will trigger seeked event)
video.currentTime = time
console.log(`[SeekToFrame] Jumping to frame ${frame} at ${time.toFixed(2)}s`)
}, [data.video_info.fps])
// Build optimized frame detection map
useEffect(() => {
const map = new Map()
if (data.frames && Array.isArray(data.frames)) {
console.log(`[VideoPlayer] Building frame map from ${data.frames.length} frames`)
data.frames.forEach((frameData) => {
const frameId = frameData.frame_id
// Handle flat detections array format (used by pot-sign-detection API)
const flatDetections = (frameData as any).detections
if (flatDetections && Array.isArray(flatDetections)) {
const tagged = 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,
}))
if (tagged.length > 0) {
map.set(frameId, tagged)
}
} else {
// Handle separate potholes/signboards arrays format
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)
}
}
})
console.log(`[VideoPlayer] Frame map built: ${map.size} frames with detections`)
}
frameDetectionMap.current = map
console.log(`[VideoPlayer] Frame map built: ${map.size} frames with detections`)
}, [data, isPothole, isSignboard])
// Build cumulative counts map (Sticky Counts)
useEffect(() => {
const map = new Map<number, Record<string, number>>()
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)) {
// Sort frames by ID to ensure we process them in order
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 det0 = detections[0]
let frameCounts: Record<string, number>
if (det0.count) {
frameCounts = { ...det0.count }
} else {
// Manual count if missing
frameCounts = {
defected_sign_board: 0,
pothole: 0,
road_crack: 0,
damaged_road_marking: 0,
good_sign_board: 0
}
detections.forEach((d: any) => {
const type = (d.type || '').split(' ')[0].toLowerCase() // Handle potential spaces
if (frameCounts.hasOwnProperty(type)) {
frameCounts[type]++
} else if (type === 'pothole') {
frameCounts.pothole++
} else if (type.includes('defected')) {
frameCounts.defected_sign_board++
}
})
}
// Carry over and update max (Sticky logic)
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
// Compute the frame skip gap from the data (minimum gap between consecutive detection frames)
// This tells us how far apart processed frames are, so we only carry forward within that gap
if (indices.length >= 2) {
let minGap = Infinity
for (let i = 1; i < indices.length; i++) {
const gap = indices[i] - indices[i - 1]
if (gap > 0 && gap < minGap) minGap = gap
}
detectedFrameSkip.current = minGap === Infinity ? 3 : minGap
console.log(`[VideoPlayer] Detected frame skip gap: ${detectedFrameSkip.current}`)
}
}
cumulativeCountsMap.current = map
console.log(`[VideoPlayer] Sticky cumulative counts map built for ${map.size} frames`)
}, [data.frames])
// Helper to get sticky counts for any frame
const getStickyCounts = useCallback((frame: number) => {
// Find the largest frame index in our map that is <= current frame
const indices = sortedFrameIndices.current
let targetIndex = -1
// Quick binary search for the latest frame with detection data
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
}
}
if (targetIndex !== -1) {
return cumulativeCountsMap.current.get(targetIndex) || {
defected_sign_board: 0,
pothole: 0,
road_crack: 0,
damaged_road_marking: 0,
good_sign_board: 0
}
}
return {
defected_sign_board: 0,
pothole: 0,
road_crack: 0,
damaged_road_marking: 0,
good_sign_board: 0
}
}, [])
// Helper to get nearest detections for any frame (handles frame skipping)
// Uses binary search to find the closest processed frame <= current frame,
// but only carries forward within the frame-skip gap to avoid stale bounding boxes
const getNearestDetections = useCallback((frame: number): any[] | undefined => {
// First try exact match (fastest path)
const exact = frameDetectionMap.current.get(frame)
if (exact) return exact
// Binary search for the nearest processed frame <= current frame
const indices = sortedFrameIndices.current
if (indices.length === 0) return undefined
let low = 0, high = indices.length - 1
let 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
}
}
if (targetIndex !== -1) {
// Only carry forward if we're within the frame-skip gap
// This prevents bounding boxes from persisting after the detection has ended
const gap = frame - targetIndex
if (gap <= detectedFrameSkip.current) {
return frameDetectionMap.current.get(targetIndex)
}
}
return undefined
}, [])
// Function to draw bounding boxes on canvas
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
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (!detections || detections.length === 0) return
// Calculate scale between video natural size and displayed size
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
// Scale coordinates to match displayed video size
const x1 = bbox.x1 * scaleX
const y1 = bbox.y1 * scaleY
const x2 = bbox.x2 * scaleX
const y2 = bbox.y2 * scaleY
const width = x2 - x1
const height = y2 - y1
// Map individual detection types to colors
const type = (detection.type || detection._detType || '').toLowerCase()
const detectionColors: Record<string, string> = {
'pothole': '#ef4444',
'defected_sign_board': '#3b82f6',
'road_crack': '#f59e0b',
'damaged_road_marking': '#6366f1',
'good_sign_board': '#10b981',
'signboard': '#3b82f6' // fallback
}
const boxColor = detectionColors[type] || '#3b82f6'
const textBgColor = boxColor + 'e6' // Adding alpha
// Draw bounding box
ctx.strokeStyle = boxColor
ctx.lineWidth = 3
ctx.strokeRect(x1, y1, width, height)
// Draw semi-transparent fill
ctx.fillStyle = type === 'pothole' ? 'rgba(239, 68, 68, 0.15)' : 'rgba(59, 130, 246, 0.15)'
ctx.fillRect(x1, y1, width, height)
// Prepare label text
const id = detection.pothole_id ?? detection.signboard_id ?? detection.detection_id
const confidence = (detection.confidence * 100).toFixed(1)
let labelText = type === 'pothole'
? `Pothole #${id}`
: `${(detection.type || 'Sign').replace(/_/g, ' ')} #${id}`
labelText += ` ${confidence}%`
// Draw label background
ctx.font = 'bold 14px system-ui'
const textMetrics = ctx.measureText(labelText)
const textWidth = textMetrics.width + 16
const textHeight = 26
ctx.fillStyle = textBgColor
ctx.fillRect(x1, y1 - textHeight - 2, textWidth, textHeight)
// Draw label text
ctx.fillStyle = '#ffffff'
ctx.fillText(labelText, x1 + 8, y1 - 8)
})
}, [data.video_info.width, data.video_info.height])
// Resize canvas to match video display size
const resizeCanvas = useCallback(() => {
const video = videoRef.current
const canvas = canvasRef.current
const container = containerRef.current
if (!video || !canvas || !container) return
// Get the displayed size of the video
const rect = video.getBoundingClientRect()
canvas.width = rect.width
canvas.height = rect.height
// Redraw current frame's detections (using nearest frame for skipped frames)
const frame = Math.round(video.currentTime * data.video_info.fps)
const detections = getNearestDetections(frame)
if (detections) {
drawBoundingBoxes(detections)
}
}, [data.video_info.fps, drawBoundingBoxes, getNearestDetections])
// Handle window resize
useEffect(() => {
window.addEventListener('resize', resizeCanvas)
return () => window.removeEventListener('resize', resizeCanvas)
}, [resizeCanvas])
// Initialize canvas size when video loads
useEffect(() => {
const video = videoRef.current
if (!video) return
const handleLoadedMetadata = () => {
resizeCanvas()
}
video.addEventListener('loadedmetadata', handleLoadedMetadata)
return () => video.removeEventListener('loadedmetadata', handleLoadedMetadata)
}, [resizeCanvas])
// Load video from uploaded file or from server
useEffect(() => {
const video = videoRef.current
if (!video) return
let videoUrl: string
if (videoFile) {
// Load from local file (upload page)
videoUrl = URL.createObjectURL(videoFile)
console.log(`[VideoPlayer] Loading video from uploaded file`)
} else if (videoId) {
// Load from server (results page)
videoUrl = `${API_URL}/video/${videoId}`
console.log(`[VideoPlayer] Loading video from server: ${videoUrl}`)
} else {
return
}
video.src = videoUrl
// Handle video load errors
const handleError = () => {
console.error("[VideoPlayer] Failed to load video")
setVideoError("Failed to load video. Please try refreshing the page.")
}
const handleLoaded = () => {
console.log("[VideoPlayer] Video loaded successfully")
setVideoError(null)
resizeCanvas()
}
video.addEventListener("error", handleError)
video.addEventListener("loadeddata", handleLoaded)
return () => {
video.removeEventListener("error", handleError)
video.removeEventListener("loadeddata", handleLoaded)
// Revoke object URL only if it was created from a file
if (videoFile) {
URL.revokeObjectURL(videoUrl)
}
}
}, [videoFile, videoId, resizeCanvas])
// Add detection log with deduplication and size limit
const addDetectionLog = useCallback((frame: number, detections: any[]) => {
if (loggedFrames.current.has(frame)) return
loggedFrames.current.add(frame)
// Update last detected GPS coordinates if available
if (detections.length > 0) {
const det0 = detections[0]
const isDetPothole = det0._detType === 'pothole' || det0.type === 'pothole' || (det0.pothole_id !== undefined && !det0.signboard_id)
const detectionId = det0.pothole_id ?? det0.signboard_id ?? det0.detection_id
const gpsCoords = gpsMap.current.get(detectionId)
if (gpsCoords) {
setLastDetectedLat(gpsCoords.lat)
setLastDetectedLng(gpsCoords.lng)
}
}
setLogs((prev) => {
const newLog: DetectionLog = {
frame,
detections: detections.map((det) => {
const isDetPothole = det._detType === 'pothole' || det.type === 'pothole' || (det.pothole_id !== undefined && !det.signboard_id)
const detectionId = det.pothole_id ?? det.signboard_id ?? det.detection_id
const gpsCoords = gpsMap.current.get(detectionId)
return {
id: detectionId,
type: isDetPothole ? 'pothole' : (det.type || 'signboard'),
bbox: det.bbox,
confidence: det.confidence,
latitude: gpsCoords?.lat,
longitude: gpsCoords?.lng,
}
}),
videoTime: formatVideoTime(frame, data.video_info.fps),
}
const updated = [newLog, ...prev].slice(0, MAX_LOGS)
return updated
})
}, [isPothole, isSignboard, formatVideoTime, data.video_info.fps])
// Track current frame and log detections
const updateFrameInfo = useCallback(() => {
const video = videoRef.current
if (!video) return
const frame = Math.round(video.currentTime * data.video_info.fps)
if (!video.paused && !video.ended && frame !== lastProcessedFrame.current) {
lastProcessedFrame.current = frame
setCurrentFrame(frame)
// Use nearest processed frame's detections to avoid flickering from frame skipping
const detections = getNearestDetections(frame)
setDetectionsCount(detections?.length || 0)
// Draw bounding boxes for current frame (carried forward from nearest processed frame)
drawBoundingBoxes(detections || [])
// Update counts using sticky logic
setCurrentFrameCounts(getStickyCounts(frame))
if (detections && detections.length > 0) {
addDetectionLog(frame, detections)
}
}
}, [data.video_info.fps, addDetectionLog, drawBoundingBoxes, getNearestDetections])
// Video playback monitoring
useEffect(() => {
const video = videoRef.current
if (!video) return
let animationId: number
const animate = () => {
updateFrameInfo()
animationId = requestAnimationFrame(animate)
}
animationId = requestAnimationFrame(animate)
return () => {
cancelAnimationFrame(animationId)
}
}, [updateFrameInfo])
// Handle video end - show summary
useEffect(() => {
const video = videoRef.current
if (!video) return
const handleEnded = () => {
if (!hasPlayedOnce) {
setHasPlayedOnce(true)
setShowSummary(true)
}
}
video.addEventListener("ended", handleEnded)
return () => {
video.removeEventListener("ended", handleEnded)
}
}, [hasPlayedOnce])
// Handle seeking and time updates
useEffect(() => {
const video = videoRef.current
if (!video) return
const handleTimeUpdate = () => {
const frame = Math.round(video.currentTime * data.video_info.fps)
setCurrentFrame(frame)
const detections = frameDetectionMap.current.get(frame)
setDetectionsCount(detections?.length || 0)
// Draw bounding boxes for current frame
drawBoundingBoxes(detections || [])
}
const handleSeeked = () => {
// Clear logged frames to allow re-logging if seeking back
loggedFrames.current.clear()
// Immediately update frame info
const frame = Math.round(video.currentTime * data.video_info.fps)
setCurrentFrame(frame)
const detections = frameDetectionMap.current.get(frame)
setDetectionsCount(detections?.length || 0)
// Update sticky counts immediately on seek
setCurrentFrameCounts(getStickyCounts(frame))
if (detections && detections.length > 0) {
const det0 = detections[0]
const isDetPothole = det0._detType === 'pothole' || det0.type === 'pothole' || (det0.pothole_id !== undefined && !det0.signboard_id)
const detectionId = det0.pothole_id ?? det0.signboard_id ?? det0.detection_id
const gpsCoords = gpsMap.current.get(detectionId)
if (gpsCoords) {
setLastDetectedLat(gpsCoords.lat)
setLastDetectedLng(gpsCoords.lng)
console.log(`[Seek] Updated GPS: ${gpsCoords.lat}, ${gpsCoords.lng} at frame ${frame}`)
}
// Add detection log for the seeked frame
addDetectionLog(frame, detections)
}
// Draw bounding boxes for seeked frame
drawBoundingBoxes(detections || [])
}
const handlePlay = () => {
if (video.currentTime < 0.1) {
setLogs([])
loggedFrames.current.clear()
lastProcessedFrame.current = -1
}
}
video.addEventListener("timeupdate", handleTimeUpdate)
video.addEventListener("seeked", handleSeeked)
video.addEventListener("play", handlePlay)
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate)
video.removeEventListener("seeked", handleSeeked)
video.removeEventListener("play", handlePlay)
}
}, [data.video_info.fps, drawBoundingBoxes, isPothole, addDetectionLog])
return (
<div className="space-y-6">
<Card className="bg-white/60 backdrop-blur-lg rounded-xl overflow-hidden">
<CardHeader className="">
<div className="flex items-center gap-3">
<div className="p-2 rounded-md bg-linear-to-b from-[#225999] to-[#56A5FF]">
<Film className="h-8 w-8 text-white" />
</div>
<div>
<CardTitle className="text-lg font-bold text-gray-900 dark:text-white">
Video Playback with Detection
</CardTitle>
<CardDescription>
Watch the video with real-time {isCombined ? "pothole & signboard" : isPothole ? "pothole" : "signboard"} detection overlays
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Video Player with Canvas Overlay */}
<div className="lg:col-span-2 space-y-4">
{videoError && (
<div className="bg-destructive/10 text-destructive p-4 rounded-lg mb-4">
{videoError}
</div>
)}
<div
ref={containerRef}
className="relative bg-black rounded-lg overflow-hidden"
style={{
aspectRatio: `${data.video_info.width} / ${data.video_info.height}`,
}}
>
<video
ref={videoRef}
controls
className="w-full h-full"
>
Your browser does not support the video tag.
</video>
{/* Canvas overlay for bounding boxes */}
<canvas
ref={canvasRef}
className="absolute top-0 left-0 pointer-events-none"
style={{ width: '100%', height: '100%' }}
/>
</div>
<div className="flex items-center gap-x-4 gap-y-2 text-[10px] font-medium overflow-hidden">
{/* Detection Counts - Single line Flexbox */}
<div className="flex items-center gap-3 shrink-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-red-500 uppercase tracking-tight">Pothole:</span>
<Badge variant="secondary" className="text-[10px] h-5 px-1 bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400 border-none">{currentFrameCounts.pothole}</Badge>
</div>
<div className="flex items-center gap-1.5">
<span className="font-bold text-blue-500 uppercase tracking-tight whitespace-nowrap">Defect Sign Board:</span>
<Badge variant="secondary" className="text-[10px] h-5 px-1 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 border-none">{currentFrameCounts.defected_sign_board}</Badge>
</div>
<div className="flex items-center gap-1.5">
<span className="font-bold text-indigo-500 uppercase tracking-tight whitespace-nowrap">Damage Road Mark:</span>
<Badge variant="secondary" className="text-[10px] h-5 px-1 bg-indigo-50 text-indigo-700 dark:bg-indigo-950/30 dark:text-indigo-400 border-none">{currentFrameCounts.damaged_road_marking}</Badge>
</div>
<div className="flex items-center gap-1.5">
<span className="font-bold text-yellow-500 uppercase tracking-tight whitespace-nowrap">RoadCrack:</span>
<Badge variant="secondary" className="text-[10px] h-5 px-1 bg-orange-50 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400 border-none">{currentFrameCounts.road_crack}</Badge>
</div>
{/* <div className="flex items-center gap-1.5">
<span className="font-bold text-emerald-500 uppercase tracking-tight whitespace-nowrap">Good Sign Board:</span>
<Badge variant="secondary" className="text-[10px] h-5 px-1 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 border-none">{currentFrameCounts.good_sign_board}</Badge>
</div> */}
</div>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-800 shrink-0" />
<div className="flex items-center gap-4 shrink-0">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground whitespace-nowrap">FRAME:</span>
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 py-0">{currentFrame}</Badge>
</div>
{lastDetectedLat !== null && lastDetectedLng !== null && (
<>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground uppercase font-semibold">LAT:</span>
<Badge variant="outline" className="font-mono text-[10px] h-5 px-1.5 py-0 border-blue-200 dark:border-blue-800">
{lastDetectedLat}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground uppercase font-semibold">LNG:</span>
<Badge variant="outline" className="font-mono text-[10px] h-5 px-1.5 py-0 border-blue-200 dark:border-blue-800">
{lastDetectedLng}
</Badge>
</div>
</>
)}
</div>
</div>
</div>
{/* Detection Logs */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold mb-2">Detection Logs</h4>
<p className="text-xs text-muted-foreground mb-3">
Real-time frame-by-frame {isCombined ? "pothole & signboard" : isPothole ? "pothole" : "signboard"} tracking (last {MAX_LOGS})
</p>
</div>
<ScrollArea className="h-[400px] rounded-md border bg-muted/30 p-4">
<div className="space-y-2">
{logs.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
Play the video to see detection logs
</p>
) : (
logs.map((log, index) => (
<div
key={`${log.frame}-${index}`}
onClick={() => seekToFrame(log.frame)}
className="text-xs p-3 rounded-md border border-slate-200 bg-white cursor-pointer transition-colors"
>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-foreground">Frame: {log.frame}</span>
<span className="text-muted-foreground text-[10px]">{log.videoTime}</span>
</div>
{log.detections.length === 0 ? (
<div className="text-muted-foreground">No detections</div>
) : (
<div className="space-y-2">
{log.detections.map((det, idx) => (
<div key={idx} className="space-y-1">
<div className="font-medium text-foreground">
{det.type === 'pothole' ? (
<>Pothole ID: {det.id}</>
) : (
<>{det.type || 'Signboard'} ID: {det.id}</>
)}
{" "}| Confidence: {(det.confidence * 100).toFixed(1)}%
</div>
<div className="text-muted-foreground font-mono text-[10px]">
Coordinates: ({Math.round(det.bbox.x1)}, {Math.round(det.bbox.y1)}) ({Math.round(det.bbox.x2)}, {Math.round(det.bbox.y2)})
</div>
{det.latitude !== undefined && det.longitude !== undefined ? (
<div className="text-muted-foreground font-mono text-[10px]">
GPS: {det.latitude}, {det.longitude}
</div>
) : (
<div className="text-destructive/70 font-mono text-[10px]">
GPS: Data Unavail.
</div>
)}
</div>
))}
</div>
)}
</div>
))
)}
</div>
</ScrollArea>
</div>
</div>
</CardContent>
</Card>
{/* Summary Sections - Show after first complete playback and persist */}
{showSummary && (
<div className="space-y-4">
<SummarySection data={data} show={showSummary} detectionType={detectionType} />
<DetailedSummarySection projectId={projectId || ""} videoId={videoId} show={showSummary} detectionType={detectionType} />
</div>
)}
</div>
)
}