396 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|