feat(video): base of video refactoring

This commit is contained in:
2026-06-18 02:23:25 +05:30
parent d7c4027328
commit c6894bfea0
20 changed files with 432 additions and 1412 deletions

View File

@@ -1,13 +1,16 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import {
useMemo,
useState,
useCallback,
useRef,
useReducer,
useEffect,
} from 'react';
Activity,
AlertTriangle,
Clock,
Film,
Gauge,
Loader2,
MapPin,
SignpostBig,
} from 'lucide-react';
import {
Card,
CardContent,
@@ -15,313 +18,111 @@ import {
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 { CompletedVideoResult, DetectionResultLog } from '@/types';
import { useAnnotatedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults';
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;
data: CompletedVideoResult;
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);
const formatDuration = (seconds: number) => {
const safeSeconds = Number.isFinite(seconds) ? seconds : 0;
const minutes = Math.floor(safeSeconds / 60);
const secs = Math.floor(safeSeconds % 60);
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
export default function VideoPlayerSection({
data,
videoId,
videoFile,
detectionType,
projectId,
}: VideoPlayerSectionProps) {
// Refs
const playerRef = useRef<VideoCanvasPlayerRef>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const loggedFrames = useRef<Set<number>>(new Set());
// State
const [currentFrame, setCurrentFrame] = useState(0);
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 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<DetectionCounts>(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<number, any>();
// 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<string, number> = {};
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),
const sortedLogs = useMemo(
() =>
[...(data.logs || [])].sort(
(a, b) => a.timestamp_seconds - b.timestamp_seconds,
),
};
}, [data, enabledTypes]);
// Custom Hooks
const gpsMap = useGpsMap(normalizedData);
const { getNearestDetections, sortedDetectionIndices } = useFrameDetectionMap(
normalizedData,
detectionType,
[data.logs],
);
const { getStickyCounts, sortedFrameIndices } = useCumulativeCounts(
normalizedData.frames || [],
const [activeLog, setActiveLog] = useState<DetectionResultLog | undefined>(
sortedLogs[0],
);
// 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]);
const {
videoUrl,
isLoading: isVideoLoading,
isError: isVideoError,
refetch: refetchVideo,
} = useAnnotatedVideoQuery(data.annotated_video_url);
// 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 handleSeek = (log: DetectionResultLog) => {
const video = videoRef.current;
if (!video) return;
const fps = data.video_info.fps || 30;
const currentFrame = Math.round(video.currentTime * fps);
video.currentTime = log.timestamp_seconds;
setActiveLog(log);
void video.play();
};
// Snap to the next available frame index that has detections
const nextDetectionFrame = sortedDetectionIndices.find(
(f) => f >= currentFrame,
const handleTimeUpdate = () => {
const video = videoRef.current;
if (!video || sortedLogs.length === 0) return;
const currentLog = sortedLogs.findLast(
(log) => log.timestamp_seconds <= video.currentTime,
);
if (
nextDetectionFrame !== undefined &&
nextDetectionFrame !== currentFrame
) {
video.currentTime = nextDetectionFrame / fps;
return;
if (currentLog && currentLog.id !== activeLog?.id) {
setActiveLog(currentLog);
}
};
handleFrameUpdate(currentFrame);
}, [data.video_info.fps, handleFrameUpdate, sortedDetectionIndices]);
const currentCounts = activeLog?.cumulative_counts || data.summary;
const seekToFrame = useCallback(
(frame: number) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = frame / data.video_info.fps;
const stats = [
{
label: 'Total Detections',
value: data.summary.total_detections || 0,
icon: Activity,
color: 'text-green-500',
bgColor: 'bg-green-500/10',
},
[data.video_info.fps],
);
{
label: 'Potholes',
value: data.summary.unique_potholes || 0,
icon: AlertTriangle,
color: 'text-orange-500',
bgColor: 'bg-orange-500/10',
},
{
label: 'Signboards',
value: data.summary.unique_signboards || 0,
icon: SignpostBig,
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
},
{
label: 'FPS',
value: data.fps.toFixed(1),
icon: Gauge,
color: 'text-purple-500',
bgColor: 'bg-purple-500/10',
},
{
label: 'Duration',
value: formatDuration(data.duration_seconds),
icon: Clock,
color: 'text-cyan-500',
bgColor: 'bg-cyan-500/10',
},
];
return (
<div className="space-y-6">
@@ -336,7 +137,7 @@ export default function VideoPlayerSection({
Detection Playback
</CardTitle>
<CardDescription className="text-xs">
Real-time object detection analysis
Annotated video with backend detection logs
</CardDescription>
</div>
</div>
@@ -344,52 +145,137 @@ export default function VideoPlayerSection({
<CardContent className="pt-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<VideoCanvasPlayer
ref={playerRef}
videoRef={videoRef}
canvasRef={canvasRef}
videoUrl={videoUrl}
videoWidth={data.video_info.width}
videoHeight={data.video_info.height}
videoError={videoError}
onLoadedData={handleLoadedData}
onEnded={handleVideoEnded}
onSeeked={handleVideoSeeked}
currentDetections={
getNearestDetections(currentFrame, sortedDetectionIndices) ||
[]
}
/>
<div className="relative overflow-hidden rounded-lg border bg-black">
{videoUrl ? (
<video
ref={videoRef}
src={videoUrl}
controls
preload="metadata"
onTimeUpdate={handleTimeUpdate}
className="block w-full aspect-video"
/>
) : (
<div className="flex aspect-video w-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
{isVideoError ? (
<>
<AlertTriangle className="h-7 w-7 text-destructive" />
<p className="text-sm font-medium text-destructive">
Failed to load the annotated video.
</p>
<button
type="button"
onClick={() => void refetchVideo()}
className="text-xs font-semibold uppercase tracking-wider text-primary hover:underline"
>
Retry
</button>
</>
) : (
<>
<Loader2 className="h-7 w-7 animate-spin text-primary" />
<p className="text-xs font-medium uppercase tracking-widest">
{isVideoLoading
? 'Loading annotated video...'
: 'Preparing video...'}
</p>
</>
)}
</div>
)}
</div>
<DetectionStatsBar
currentFrameCounts={currentFrameCounts}
currentFrame={currentFrame}
lastDetectedLat={lastDetectedLat}
lastDetectedLng={lastDetectedLng}
detectionMode={detectionMode}
/>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.label}
className="p-4 rounded-lg bg-muted/30 border border-muted flex flex-col items-center text-center"
>
<div className={`p-2 rounded-md mb-2 ${stat.bgColor}`}>
<Icon className={`h-5 w-5 ${stat.color}`} />
</div>
<div className={`text-xl font-bold ${stat.color}`}>
{stat.value}
</div>
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mt-1">
{stat.label}
</div>
</div>
);
})}
</div>
<div className="flex flex-wrap items-center gap-x-8 gap-y-3 py-3 px-6 bg-card border rounded-lg">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<div className="flex items-center gap-1.5 whitespace-nowrap">
<span className="text-[11px] font-bold uppercase tracking-tight text-orange-500">
Potholes:
</span>
<span className="text-sm font-bold text-orange-500">
{currentCounts.unique_potholes || 0}
</span>
</div>
<div className="flex items-center gap-1.5 whitespace-nowrap">
<span className="text-[11px] font-bold uppercase tracking-tight text-blue-500">
Signboards:
</span>
<span className="text-sm font-bold text-blue-500">
{currentCounts.unique_signboards || 0}
</span>
</div>
<div className="flex items-center gap-1.5 whitespace-nowrap">
<span className="text-[11px] font-bold uppercase tracking-tight text-green-500">
Total:
</span>
<span className="text-sm font-bold text-green-500">
{currentCounts.total_detections || 0}
</span>
</div>
</div>
{activeLog && (
<div className="flex flex-wrap items-center gap-6 border-l pl-6 border-border/80 ml-auto">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Frame:
</span>
<span className="text-xs font-bold font-mono bg-secondary/50 px-2.5 py-1 rounded border border-border/60">
{activeLog.frame}
</span>
</div>
{typeof activeLog.latitude === 'number' &&
typeof activeLog.longitude === 'number' && (
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-bold font-mono bg-secondary/50 px-2.5 py-1 rounded border border-border/60">
{activeLog.latitude.toFixed(7)},{' '}
{activeLog.longitude.toFixed(7)}
</span>
</div>
)}
</div>
)}
</div>
</div>
<DetectionLogs logs={logs} onSeek={seekToFrame} />
<DetectionLogs
logs={sortedLogs}
activeLogId={activeLog?.id}
onSeek={handleSeek}
/>
</div>
</CardContent>
</Card>
{showSummary && (
<div className="space-y-6 pt-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<SummarySection
data={data}
show={showSummary}
detectionType={detectionType}
/>
<DetailedSummarySection
projectId={projectId || ''}
videoId={videoId}
show={showSummary}
detectionType={detectionType}
/>
</div>
)}
<DetailedSummarySection
projectId={projectId || ''}
videoId={videoId}
show={Boolean(projectId)}
detectionType={detectionType}
/>
</div>
);
}

View File

@@ -1,17 +1,26 @@
'use client';
import { Activity, Film } from 'lucide-react';
import { Activity, Film, MapPin } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { DetectionLogEntry } from '@/types';
import { DetectionResultLog } from '@/types';
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
import { cn } from '@/lib/utils';
interface DetectionLogsProps {
logs: DetectionLogEntry[];
onSeek: (frame: number) => void;
logs: DetectionResultLog[];
activeLogId?: string;
onSeek: (log: DetectionResultLog) => void;
}
const DetectionLogs = ({ logs, onSeek }: DetectionLogsProps) => {
const formatVideoTime = (seconds: number) => {
const safeSeconds = Number.isFinite(seconds) ? seconds : 0;
const minutes = Math.floor(safeSeconds / 60);
const secs = Math.floor(safeSeconds % 60);
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -20,7 +29,7 @@ const DetectionLogs = ({ logs, onSeek }: DetectionLogsProps) => {
Detection Logs
</h4>
<Badge variant="secondary" className="text-[10px] font-bold uppercase">
Live Logs
Backend Logs
</Badge>
</div>
<ScrollArea className="h-[430px] rounded-lg border bg-muted/20 p-4">
@@ -28,55 +37,62 @@ const DetectionLogs = ({ logs, onSeek }: DetectionLogsProps) => {
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Film className="h-8 w-8 mb-2 opacity-20" />
<p className="text-xs">Playback to see logs</p>
<p className="text-xs">No detections found</p>
</div>
) : (
logs.map((log, i) => (
<div
key={`${log.frame}-${i}`}
onClick={() => onSeek(log.frame)}
className="p-4 rounded border bg-card hover:border-primary transition-all cursor-pointer group"
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-bold">Frame: {log.frame}</span>
<span className="text-xs text-muted-foreground/80">
{log.videoTime}
</span>
</div>
<div className="space-y-4">
{log.detections.map((d, j) => {
const typeId = (d.type || '').toLowerCase();
const typeConfig = DETECTION_TYPES[typeId];
const label = typeConfig
? typeConfig.label
: typeId.replace(/_/g, ' ');
logs.map((log) => {
const typeId = (log.type || '').toLowerCase();
const typeConfig = DETECTION_TYPES[typeId];
const label =
log.label || typeConfig?.label || typeId.replace(/_/g, ' ');
const isActive = activeLogId === log.id;
return (
<div key={`${d.id}-${j}`} className="space-y-1">
<div className="text-[13px] font-bold">
{label} ID: {d.id} | Confidence:{' '}
{(d.confidence * 100).toFixed(1)}%
</div>
{d.bbox && (
<div className="text-[12px] text-muted-foreground/80 font-medium">
Coordinates: ({Math.round(d.bbox.x1)},{' '}
{Math.round(d.bbox.y1)}){' '}
<span className="text-muted-foreground/40"></span>{' '}
({Math.round(d.bbox.x2)}, {Math.round(d.bbox.y2)})
</div>
)}
{d.latitude && (
<div className="text-[12px] text-muted-foreground/80 font-medium">
GPS: {d.latitude.toFixed(8)},{' '}
{d.longitude?.toFixed(8)}
</div>
)}
return (
<button
key={`${log.id}-${log.frame}`}
type="button"
onClick={() => onSeek(log)}
className={cn(
'w-full text-left p-4 rounded border bg-card hover:border-primary transition-all cursor-pointer',
isActive && 'border-primary ring-1 ring-primary/30',
)}
>
<div className="flex items-start justify-between gap-4 mb-3">
<div>
<div className="text-[13px] font-bold">
{label} ID: {log.id}
</div>
);
})}
</div>
</div>
))
<div className="text-[11px] text-muted-foreground font-medium">
Frame {log.frame}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-xs font-bold">
{formatVideoTime(log.timestamp_seconds)}
</div>
<div className="text-[11px] text-muted-foreground">
{log.timestamp_seconds.toFixed(2)}s
</div>
</div>
</div>
<div className="space-y-2 text-[12px] text-muted-foreground/90 font-medium">
{typeof log.confidence === 'number' && (
<div>Confidence: {(log.confidence * 100).toFixed(1)}%</div>
)}
{typeof log.latitude === 'number' &&
typeof log.longitude === 'number' && (
<div className="flex items-center gap-1.5">
<MapPin className="h-3 w-3" />
<span>
{log.latitude.toFixed(8)}, {log.longitude.toFixed(8)}
</span>
</div>
)}
</div>
</button>
);
})
)}
</div>
</ScrollArea>

View File

@@ -1,83 +0,0 @@
'use client';
import { memo } from 'react';
import { DetectionCounts } from '@/types';
import { getEnabledDetectionTypes } from '@/constants/detectionModeConfig';
interface DetectionStatsBarProps {
currentFrameCounts: DetectionCounts;
currentFrame: number;
lastDetectedLat: number | null;
lastDetectedLng: number | null;
detectionMode?: string;
}
const DetectionStatsBar = memo(
({
currentFrameCounts,
currentFrame,
lastDetectedLat,
lastDetectedLng,
detectionMode,
}: DetectionStatsBarProps) => {
const enabledTypes = getEnabledDetectionTypes(detectionMode);
return (
<div className="flex flex-wrap items-center gap-x-8 gap-y-3 py-3 px-6 bg-card border rounded-lg">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
{enabledTypes.map((type) => (
<div
key={type.id}
className="flex items-center gap-1.5 whitespace-nowrap"
>
<span
className="text-[11px] font-bold uppercase tracking-tight"
style={{ color: type.color }}
>
{type.label}:
</span>
<span className="text-sm font-bold" style={{ color: type.color }}>
{(currentFrameCounts as any)[type.frameCountKey] || 0}
</span>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-6 border-l pl-6 border-border/80 ml-auto">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
FRAME:
</span>
<span className="text-xs font-bold font-mono bg-secondary/50 px-2.5 py-1 rounded border border-border/60">
{currentFrame}
</span>
</div>
{lastDetectedLat && (
<>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
LAT:
</span>
<span className="text-xs font-bold font-mono bg-secondary/50 px-2.5 py-1 rounded border border-border/60">
{lastDetectedLat.toFixed(7)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
LNG:
</span>
<span className="text-xs font-bold font-mono bg-secondary/50 px-2.5 py-1 rounded border border-border/60">
{lastDetectedLng?.toFixed(7)}
</span>
</div>
</>
)}
</div>
</div>
);
},
);
DetectionStatsBar.displayName = 'DetectionStatsBar';
export default DetectionStatsBar;

View File

@@ -1,141 +0,0 @@
'use client';
import {
Target,
SignpostBig,
AlertTriangle,
Activity,
Gauge,
Monitor,
Film,
} from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { DetectionData } from '@/types';
import { cn } from '@/lib/utils';
import {
getDetectionModeConfig,
getEnabledDetectionTypes,
} from '@/constants/detectionModeConfig';
interface SummarySectionProps {
data: DetectionData;
show: boolean;
detectionType: string;
}
const SummarySection = ({ data, show, detectionType }: SummarySectionProps) => {
if (!show) return null;
const detectionMode = data.detection_mode || detectionType;
const modeConfig = getDetectionModeConfig(detectionMode);
const enabledTypes = getEnabledDetectionTypes(detectionMode);
const detectionStats = enabledTypes.map((type) => ({
label: type.label,
value: (data.summary as any)[type.countKey] || 0,
icon: type.id.includes('sign')
? SignpostBig
: type.id.includes('culvert')
? Target
: AlertTriangle,
color: `text-[${type.color}]`,
customColor: type.color,
bgColor: 'bg-muted/30',
}));
const globalStats = [
{
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',
},
];
const stats = [...detectionStats, ...globalStats];
return (
<Card className="overflow-hidden">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-secondary">
<Activity className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base font-bold">Quick Stats</CardTitle>
<CardDescription className="text-xs">
Detection analysis overview
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.label}
className="p-4 rounded-lg bg-muted/30 border border-muted flex flex-col items-center text-center"
>
<div className={cn('p-2 rounded-md mb-2', stat.bgColor)}>
<Icon
className={cn('h-5 w-5', stat.color)}
style={
(stat as any).customColor
? { color: (stat as any).customColor }
: {}
}
/>
</div>
<div
className={cn('text-xl font-bold', stat.color)}
style={
(stat as any).customColor
? { color: (stat as any).customColor }
: {}
}
>
{stat.value}
</div>
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mt-1">
{stat.label}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
};
export default SummarySection;

View File

@@ -1,141 +0,0 @@
'use client';
import {
useRef,
useEffect,
forwardRef,
useImperativeHandle,
useCallback,
} from 'react';
import { Badge } from '@/components/ui/badge';
import { drawBoundingBoxes } from '@/utils/canvas-drawing';
interface VideoCanvasPlayerProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
canvasRef: React.RefObject<HTMLCanvasElement | null>;
videoUrl: string;
videoWidth: number;
videoHeight: number;
videoError: string | null;
onLoadedData: () => void;
onEnded: () => void;
onSeeked: () => void;
currentDetections: any[];
}
export interface VideoCanvasPlayerRef {
resize: () => void;
drawDetections: (detections: any[]) => void;
}
const VideoCanvasPlayer = forwardRef<
VideoCanvasPlayerRef,
VideoCanvasPlayerProps
>(
(
{
videoRef,
canvasRef,
videoUrl,
videoWidth,
videoHeight,
videoError,
onLoadedData,
onEnded,
onSeeked,
currentDetections,
},
ref,
) => {
const containerRef = useRef<HTMLDivElement>(null);
const detectionsRef = useRef(currentDetections);
useEffect(() => {
detectionsRef.current = currentDetections;
}, [currentDetections]);
const resize = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return;
const rect = videoRef.current.getBoundingClientRect();
canvasRef.current.width = rect.width;
canvasRef.current.height = rect.height;
// Draw immediately on resize
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
drawBoundingBoxes(
ctx,
detectionsRef.current,
canvasRef.current.width,
canvasRef.current.height,
videoWidth,
videoHeight,
);
}
}, [videoRef, canvasRef, videoWidth, videoHeight]);
useImperativeHandle(ref, () => ({
resize,
drawDetections: (detections) => {
if (!canvasRef.current) return;
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
drawBoundingBoxes(
ctx,
detections,
canvasRef.current.width,
canvasRef.current.height,
videoWidth,
videoHeight,
);
}
},
}));
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver(() => {
resize();
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [resize]);
// Reacting to detections to ensure correct draw on resize
return (
<div className="space-y-6">
{videoError && (
<Badge variant="destructive" className="mb-4">
{videoError}
</Badge>
)}
<div
ref={containerRef}
className="relative bg-black rounded-lg overflow-hidden border shadow-inner"
style={{ aspectRatio: `${videoWidth}/${videoHeight}` }}
>
<video
ref={videoRef}
src={videoUrl}
controls
className="w-full h-full"
onLoadedData={onLoadedData}
onEnded={onEnded}
onSeeked={onSeeked}
/>
<canvas
ref={canvasRef}
className="absolute top-0 left-0 pointer-events-none w-full h-full"
/>
</div>
</div>
);
},
);
VideoCanvasPlayer.displayName = 'VideoCanvasPlayer';
export default VideoCanvasPlayer;