-
-
-
-
- {session && (
-
-
-
-
-
-
- Project
-
-
- {session.projectName}
-
-
-
-
- Package
-
-
- {session.packageName}
-
-
-
-
- Segment
-
-
- {session.chainageName}
-
-
-
-
-
-
-
- )}
-
- {detectionData && videoId && (
-
- )}
-
-
+
+
+
+ Loading results...
+
);
}
diff --git a/src/app/(modules)/results/queries/videoKeys.ts b/src/app/(modules)/results/queries/videoKeys.ts
new file mode 100644
index 0000000..f1d0441
--- /dev/null
+++ b/src/app/(modules)/results/queries/videoKeys.ts
@@ -0,0 +1,8 @@
+export const videoKeys = {
+ all: ['videos'] as const,
+ results: () => [...videoKeys.all, 'results'] as const,
+ result: (videoId: string) => [...videoKeys.results(), videoId] as const,
+ annotatedVideos: () => [...videoKeys.all, 'annotated'] as const,
+ annotatedVideo: (url: string) =>
+ [...videoKeys.annotatedVideos(), url] as const,
+};
diff --git a/src/components/video-player-section.tsx b/src/components/video-player-section.tsx
index 7bd379f..b0032ce 100644
--- a/src/components/video-player-section.tsx
+++ b/src/components/video-player-section.tsx
@@ -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
(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),
+ 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(
+ 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 (
@@ -336,7 +137,7 @@ export default function VideoPlayerSection({
Detection Playback
- Real-time object detection analysis
+ Annotated video with backend detection logs
@@ -344,52 +145,137 @@ export default function VideoPlayerSection({
@@ -20,7 +29,7 @@ const DetectionLogs = ({ logs, onSeek }: DetectionLogsProps) => {
Detection Logs
- Live Logs
+ Backend Logs
@@ -28,55 +37,62 @@ const DetectionLogs = ({ logs, onSeek }: DetectionLogsProps) => {
{logs.length === 0 ? (
-
Playback to see logs
+
No detections found
) : (
- logs.map((log, i) => (
- onSeek(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) => {
- 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 (
-
-
- {label} 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)}
-
- )}
+ return (
+
+
+
+ {formatVideoTime(log.timestamp_seconds)}
+
+
+ {log.timestamp_seconds.toFixed(2)}s
+
+
+
+
+
+ {typeof log.confidence === 'number' && (
+
Confidence: {(log.confidence * 100).toFixed(1)}%
+ )}
+ {typeof log.latitude === 'number' &&
+ typeof log.longitude === 'number' && (
+
+
+
+ {log.latitude.toFixed(8)}, {log.longitude.toFixed(8)}
+
+
+ )}
+
+
+ );
+ })
)}
diff --git a/src/components/video/detection-stats-bar.tsx b/src/components/video/detection-stats-bar.tsx
deleted file mode 100644
index bd132fa..0000000
--- a/src/components/video/detection-stats-bar.tsx
+++ /dev/null
@@ -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 (
-
-
- {enabledTypes.map((type) => (
-
-
- {type.label}:
-
-
- {(currentFrameCounts as any)[type.frameCountKey] || 0}
-
-
- ))}
-
-
-
-
-
- FRAME:
-
-
- {currentFrame}
-
-
- {lastDetectedLat && (
- <>
-
-
- LAT:
-
-
- {lastDetectedLat.toFixed(7)}
-
-
-
-
- LNG:
-
-
- {lastDetectedLng?.toFixed(7)}
-
-
- >
- )}
-
-
- );
- },
-);
-
-DetectionStatsBar.displayName = 'DetectionStatsBar';
-
-export default DetectionStatsBar;
diff --git a/src/components/video/summary-section.tsx b/src/components/video/summary-section.tsx
deleted file mode 100644
index abd8f36..0000000
--- a/src/components/video/summary-section.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
- Quick Stats
-
- Detection analysis overview
-
-
-
-
-
-
- {stats.map((stat) => {
- const Icon = stat.icon;
- return (
-
-
-
-
-
- {stat.value}
-
-
- {stat.label}
-
-
- );
- })}
-
-
-
- );
-};
-
-export default SummarySection;
diff --git a/src/components/video/video-canvas-player.tsx b/src/components/video/video-canvas-player.tsx
deleted file mode 100644
index c1905b0..0000000
--- a/src/components/video/video-canvas-player.tsx
+++ /dev/null
@@ -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
;
- canvasRef: React.RefObject;
- 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(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 (
-
- {videoError && (
-
- {videoError}
-
- )}
-
-
-
-
-
- );
- },
-);
-
-VideoCanvasPlayer.displayName = 'VideoCanvasPlayer';
-
-export default VideoCanvasPlayer;
diff --git a/src/constants/apiRoutes.ts b/src/constants/apiRoutes.ts
index 80f9fa4..53173e1 100644
--- a/src/constants/apiRoutes.ts
+++ b/src/constants/apiRoutes.ts
@@ -54,6 +54,6 @@ export const API_ROUTES = {
LIST: '/videos',
UPLOAD: '/biz/api/v1/upload',
STATUS: (id: string) => `/status/${id}`,
- RESULTS: (id: string) => `/results/${id}`,
+ RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`,
},
} as const;
diff --git a/src/constants/detectionModeConfig.ts b/src/constants/detectionModeConfig.ts
index a4e9753..1e3a882 100644
--- a/src/constants/detectionModeConfig.ts
+++ b/src/constants/detectionModeConfig.ts
@@ -6,9 +6,6 @@ export interface DetectionTypeConfig {
id: string;
label: string;
color: string;
- countKey: string; // The key in summary object from API (e.g., unique_pothole)
- listKey: string; // The key for the list of detections in DetectionData (e.g., pothole_list)
- frameCountKey: string; // The key in per-frame count object (e.g., pothole)
}
export interface DetectionModeConfig {
@@ -22,65 +19,41 @@ export const DETECTION_TYPES: Record = {
id: 'pothole',
label: 'Pothole',
color: 'var(--chart-1)',
- countKey: 'unique_pothole',
- listKey: 'pothole_list',
- frameCountKey: 'pothole',
},
defected_sign_board: {
id: 'defected_sign_board',
label: 'Defect Sign Board',
color: 'var(--chart-2)',
- countKey: 'unique_defected_sign_board',
- listKey: 'defected_sign_board_list',
- frameCountKey: 'defected_sign_board',
},
road_crack: {
id: 'road_crack',
label: 'Road Crack',
color: 'var(--chart-3)',
- countKey: 'unique_road_crack',
- listKey: 'road_crack_list',
- frameCountKey: 'road_crack',
},
damaged_road_marking: {
id: 'damaged_road_marking',
label: 'Damage Road Mark',
color: 'var(--chart-4)',
- countKey: 'unique_damaged_road_marking',
- listKey: 'damaged_road_marking_list',
- frameCountKey: 'damaged_road_marking',
},
good_sign_board: {
id: 'good_sign_board',
label: 'Good Sign Board',
color: 'var(--chart-5)',
- countKey: 'unique_good_sign_board',
- listKey: 'good_sign_board_list',
- frameCountKey: 'good_sign_board',
},
drain_issue: {
id: 'drain_issue',
label: 'Drain Issue',
color: 'var(--chart-6)',
- countKey: 'unique_drain_issue',
- listKey: 'drain_issue_list',
- frameCountKey: 'drain_issue',
},
good_culvert: {
id: 'good_culvert',
label: 'Good Culvert',
color: 'var(--chart-5)',
- countKey: 'unique_good_culvert',
- listKey: 'good_culvert_list',
- frameCountKey: 'good_culvert',
},
defective_culvert: {
id: 'defective_culvert',
label: 'Defective Culvert',
color: 'var(--chart-8)',
- countKey: 'unique_defective_culvert',
- listKey: 'defective_culvert_list',
- frameCountKey: 'defective_culvert',
},
};
diff --git a/src/hooks/use-cumulative-counts.ts b/src/hooks/use-cumulative-counts.ts
deleted file mode 100644
index c68d5d6..0000000
--- a/src/hooks/use-cumulative-counts.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useMemo, useCallback } from 'react';
-import { DetectionCounts } from '@/types';
-
-export const useCumulativeCounts = (frames: any[]) => {
- const result = useMemo(() => {
- const map = new Map();
- let lastCounts = {} as DetectionCounts;
- let indices: number[] = [];
-
- if (frames && Array.isArray(frames)) {
- const sortedFrames = [...frames].sort(
- (a, b) => (a.frame_id || 0) - (b.frame_id || 0),
- );
- 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 || {};
-
- // Dynamically compute cumulative max for all keys present in counts
- const nextCounts = { ...lastCounts };
- Object.keys(frameCounts).forEach((key) => {
- const currentVal = (nextCounts as any)[key] || 0;
- const newVal = (frameCounts as any)[key] || 0;
- (nextCounts as any)[key] = Math.max(currentVal, newVal);
- });
- lastCounts = nextCounts;
- }
- map.set(frameId, { ...lastCounts });
- });
- }
- return { map, indices };
- }, [frames]);
-
- const getStickyCounts = useCallback(
- (frameNumber: number) => {
- const { map, indices } = result;
- if (indices.length === 0) return {} as DetectionCounts;
-
- let targetFrameId = -1;
- let low = 0,
- high = indices.length - 1;
-
- while (low <= high) {
- let mid = Math.floor((low + high) / 2);
- if (indices[mid] <= frameNumber) {
- targetFrameId = indices[mid];
- low = mid + 1;
- } else {
- high = mid - 1;
- }
- }
-
- return targetFrameId !== -1
- ? map.get(targetFrameId) || ({} as DetectionCounts)
- : ({} as DetectionCounts);
- },
- [result],
- );
-
- return { getStickyCounts, sortedFrameIndices: result.indices };
-};
diff --git a/src/hooks/use-frame-detection-map.ts b/src/hooks/use-frame-detection-map.ts
deleted file mode 100644
index 3db2e40..0000000
--- a/src/hooks/use-frame-detection-map.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { useMemo } from 'react';
-import { DetectionData } from '@/types';
-import {
- getEnabledDetectionTypes,
- DETECTION_TYPES,
-} from '@/constants/detectionModeConfig';
-
-export const useFrameDetectionMap = (
- data: DetectionData,
- detectionType: string,
-) => {
- const frameDetectionMap = useMemo(() => {
- const map = new Map();
- const detectionMode = data.detection_mode || detectionType;
- const enabledTypes = getEnabledDetectionTypes(detectionMode);
- const enabledKeys = new Set(enabledTypes.map((t) => t.id));
-
- 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)) {
- const filteredDetections = flatDetections
- .filter((d: any) => {
- const type = (d.type || '').toLowerCase();
- return enabledKeys.has(type);
- })
- .map((d: any) => ({
- ...d,
- _detType: (d.type || '').toLowerCase(),
- // Map old ID fields and new ID fields dynamically for backward compatibility
- [`${(d.type || '').toLowerCase()}_id`]: d.detection_id,
- }));
-
- if (filteredDetections.length > 0) {
- map.set(frameId, filteredDetections);
- }
- } else {
- // Legacy format: separate arrays (potholes, signboards, etc.)
- let detections: any[] = [];
-
- enabledTypes.forEach((type) => {
- // Try common plural naming conventions for legacy support
- const possibleKeys = [
- `${type.id}s`,
- type.id.endsWith('y')
- ? `${type.id.slice(0, -1)}ies`
- : `${type.id}s`,
- type.listKey.replace('_list', 's'),
- type.listKey,
- ];
-
- for (const listKey of possibleKeys) {
- if ((frameData as any)[listKey]) {
- detections = [
- ...detections,
- ...(frameData as any)[listKey].map((item: any) => ({
- ...item,
- _detType: type.id,
- type: type.id,
- [`${type.id.toLowerCase()}_id`]:
- (item as any).detection_id ??
- (item as any)[`${type.id.toLowerCase()}_id`] ??
- item.id,
- })),
- ];
- break;
- }
- }
- });
-
- if (detections.length > 0) map.set(frameId, detections);
- }
- });
- }
- return map;
- }, [data, detectionType]);
-
- const sortedDetectionIndices = useMemo(() => {
- return Array.from(frameDetectionMap.keys()).sort((a, b) => a - b);
- }, [frameDetectionMap]);
-
- const getNearestDetections = (
- frame: number,
- sortedIndices: number[],
- maxSkip = 3,
- ) => {
- const exact = frameDetectionMap.get(frame);
- if (exact) return exact;
-
- let low = 0,
- high = sortedIndices.length - 1,
- targetIndex = -1;
-
- while (low <= high) {
- const mid = Math.floor((low + high) / 2);
- if (sortedIndices[mid] <= frame) {
- targetIndex = sortedIndices[mid];
- low = mid + 1;
- } else {
- high = mid - 1;
- }
- }
-
- return targetIndex !== -1 && frame - targetIndex <= maxSkip
- ? frameDetectionMap.get(targetIndex)
- : undefined;
- };
-
- return { frameDetectionMap, getNearestDetections, sortedDetectionIndices };
-};
diff --git a/src/hooks/use-gps-map.ts b/src/hooks/use-gps-map.ts
deleted file mode 100644
index 5f83557..0000000
--- a/src/hooks/use-gps-map.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { useMemo } from 'react';
-import { DetectionData } from '@/types';
-import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
-
-export const useGpsMap = (data: DetectionData) => {
- const gpsMap = useMemo(() => {
- 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 });
- }
- });
- };
-
- // Dynamically add items from all configured detection lists
- Object.values(DETECTION_TYPES).forEach((type) => {
- if (type.listKey) {
- addItemsToMap((data as any)[type.listKey]);
- }
- });
-
- // Backward compatibility for generic signboard_list
- if ((data as any).signboard_list) {
- addItemsToMap((data as any).signboard_list);
- }
-
- return map;
- }, [data]);
-
- return gpsMap;
-};
diff --git a/src/hooks/use-video-detection-loop.ts b/src/hooks/use-video-detection-loop.ts
deleted file mode 100644
index 794a5e0..0000000
--- a/src/hooks/use-video-detection-loop.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { useEffect, useRef } from 'react';
-
-export const useVideoDetectionLoop = (
- videoRef: React.RefObject,
- fps: number,
- onFrameUpdate: (frame: number) => void,
-) => {
- const lastProcessedFrame = useRef(-1);
- const animId = useRef(-1);
-
- useEffect(() => {
- const update = () => {
- const video = videoRef.current;
- if (video && !video.paused) {
- const frame = Math.round(video.currentTime * fps);
- if (frame !== lastProcessedFrame.current) {
- lastProcessedFrame.current = frame;
- onFrameUpdate(frame);
- }
- }
- animId.current = requestAnimationFrame(update);
- };
-
- animId.current = requestAnimationFrame(update);
-
- return () => {
- if (animId.current !== -1) {
- cancelAnimationFrame(animId.current);
- }
- };
- }, [fps, onFrameUpdate, videoRef]);
-
- return { lastProcessedFrame };
-};
diff --git a/src/services/api/video.service.ts b/src/services/api/video.service.ts
index de44855..b010b80 100644
--- a/src/services/api/video.service.ts
+++ b/src/services/api/video.service.ts
@@ -1,6 +1,6 @@
import axiosClient from '../axios/axios';
import { API_ROUTES } from '@/constants/apiRoutes';
-import { Video, PaginationParams } from '@/types';
+import { CompletedVideoResult, Video, PaginationParams } from '@/types';
/**
* Video Service
@@ -77,8 +77,23 @@ export const videoService = {
/**
* Get analysis results for a video
*/
- getVideoResults: async (videoId: string): Promise => {
+ getVideoResults: async (videoId: string): Promise => {
const response = await axiosClient.get(API_ROUTES.VIDEOS.RESULTS(videoId));
return response.data;
},
+
+ /**
+ * Fetch the annotated video as a blob through the authenticated axios client.
+ *
+ * The browser's native `