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

396 lines
12 KiB
TypeScript

'use client';
import {
useMemo,
useState,
useCallback,
useRef,
useReducer,
useEffect,
} from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Film } from 'lucide-react';
import {
DetectionData,
DetectionType,
DetectionCounts,
DetectionLogEntry,
} from '@/types';
import { useGpsMap } from '@/hooks/use-gps-map';
import { useFrameDetectionMap } from '@/hooks/use-frame-detection-map';
import { useCumulativeCounts } from '@/hooks/use-cumulative-counts';
import { useVideoDetectionLoop } from '@/hooks/use-video-detection-loop';
import VideoCanvasPlayer, {
VideoCanvasPlayerRef,
} from './video/video-canvas-player';
import DetectionStatsBar from './video/detection-stats-bar';
import DetectionLogs from './video/detection-logs';
import SummarySection from './video/summary-section';
import DetailedSummarySection from './video/detailed-summary-section';
import {
getDetectionModeConfig,
getEnabledDetectionTypes,
} from '@/constants/detectionModeConfig';
type VideoPlayerSectionProps = {
data: DetectionData;
videoId: string;
videoFile: File | null;
detectionType: string;
projectId?: string;
};
const MAX_LOGS = 50;
type LogAction =
| { type: 'ADD_LOG'; payload: DetectionLogEntry }
| { type: 'CLEAR' };
function logsReducer(
state: DetectionLogEntry[],
action: LogAction,
): DetectionLogEntry[] {
switch (action.type) {
case 'ADD_LOG':
// Move to top if already exists, or just add to top
const filtered = state.filter(
(log) => log.frame !== action.payload.frame,
);
return [action.payload, ...filtered].slice(0, MAX_LOGS);
case 'CLEAR':
return [];
default:
return state;
}
}
const formatVideoTime = (frame: number, fps: number): string => {
const seconds = frame / fps;
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
export default function VideoPlayerSection({
data,
videoId,
videoFile,
detectionType,
projectId,
}: VideoPlayerSectionProps) {
// Refs
const playerRef = useRef<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),
),
};
}, [data, enabledTypes]);
// Custom Hooks
const gpsMap = useGpsMap(normalizedData);
const { getNearestDetections, sortedDetectionIndices } = useFrameDetectionMap(
normalizedData,
detectionType,
);
const { getStickyCounts, sortedFrameIndices } = useCumulativeCounts(
normalizedData.frames || [],
);
// Memoized video URL
const videoUrl = useMemo(() => {
if (videoFile) return URL.createObjectURL(videoFile);
if (videoId) return `${process.env.NEXT_PUBLIC_API_URL}/video/${videoId}`;
return '';
}, [videoFile, videoId]);
// Clean up Object URL
useEffect(() => {
return () => {
if (videoFile && videoUrl) URL.revokeObjectURL(videoUrl);
};
}, [videoFile, videoUrl]);
// Handle detection updates
const handleFrameUpdate = useCallback(
(frame: number) => {
setCurrentFrame(frame);
// Optimized lookup
const dets = getNearestDetections(frame, sortedFrameIndices);
const counts = getStickyCounts(frame);
// Update visuals via ref to avoid state-induced slow re-renders in heavy loops
playerRef.current?.drawDetections(dets || []);
// Frame stats
setCurrentFrameCounts(counts);
// Logging logic
if (dets && dets.length > 0) {
const firstDetId =
dets[0].pothole_id ?? dets[0].signboard_id ?? dets[0].detection_id;
const coords = gpsMap.get(firstDetId);
if (coords) {
setLastDetectedLat(coords.lat);
setLastDetectedLng(coords.lng);
}
dispatchLogs({
type: 'ADD_LOG',
payload: {
frame,
videoTime: formatVideoTime(frame, data.video_info.fps),
detections: dets.map((d) => ({
id: d.pothole_id ?? d.signboard_id ?? d.detection_id,
type: d.type || d._detType,
bbox: d.bbox,
confidence: d.confidence,
latitude: gpsMap.get(
d.pothole_id ?? d.signboard_id ?? d.detection_id,
)?.lat,
longitude: gpsMap.get(
d.pothole_id ?? d.signboard_id ?? d.detection_id,
)?.lng,
})),
},
});
}
},
[
data.video_info.fps,
getNearestDetections,
getStickyCounts,
gpsMap,
sortedDetectionIndices,
],
);
// Playback loop hook
useVideoDetectionLoop(videoRef, data.video_info.fps, handleFrameUpdate);
// Callbacks
const handleLoadedData = useCallback(() => {
playerRef.current?.resize();
}, []);
const handleVideoError = useCallback(() => {
setVideoError('Failed to load video');
}, []);
const handleVideoEnded = useCallback(() => {
if (!hasPlayedOnce) {
setHasPlayedOnce(true);
setShowSummary(true);
}
}, [hasPlayedOnce]);
const handleVideoSeeked = useCallback(() => {
const video = videoRef.current;
if (!video) return;
const fps = data.video_info.fps || 30;
const currentFrame = Math.round(video.currentTime * fps);
// Snap to the next available frame index that has detections
const nextDetectionFrame = sortedDetectionIndices.find(
(f) => f >= currentFrame,
);
if (
nextDetectionFrame !== undefined &&
nextDetectionFrame !== currentFrame
) {
video.currentTime = nextDetectionFrame / fps;
return;
}
handleFrameUpdate(currentFrame);
}, [data.video_info.fps, handleFrameUpdate, sortedDetectionIndices]);
const seekToFrame = useCallback(
(frame: number) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = frame / data.video_info.fps;
},
[data.video_info.fps],
);
return (
<div className="space-y-6">
<Card className="overflow-hidden">
<CardHeader className="pb-4 border-b">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-secondary">
<Film className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg font-bold">
Detection Playback
</CardTitle>
<CardDescription className="text-xs">
Real-time object detection analysis
</CardDescription>
</div>
</div>
</CardHeader>
<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) ||
[]
}
/>
<DetectionStatsBar
currentFrameCounts={currentFrameCounts}
currentFrame={currentFrame}
lastDetectedLat={lastDetectedLat}
lastDetectedLng={lastDetectedLng}
detectionMode={detectionMode}
/>
</div>
<DetectionLogs logs={logs} onSeek={seekToFrame} />
</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>
)}
</div>
);
}