feat(results): replace video playback with detection image preview and map

This commit is contained in:
2026-07-07 15:36:27 +05:30
parent d88978a830
commit fb9a654aea
13 changed files with 433 additions and 36 deletions

View File

@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import { Geist_Mono, Poppins } from 'next/font/google';
import '@vidstack/react/player/styles/default/theme.css';
import '@vidstack/react/player/styles/default/layouts/video.css';
import 'leaflet/dist/leaflet.css';
import 'lightgallery/css/lightgallery.css';
import 'lightgallery/css/lg-thumbnail.css';
import 'lightgallery/css/lg-zoom.css';

View File

@@ -0,0 +1,66 @@
import { getDefectVisual } from '@/constants/defectVisualConfig';
import type { DetectionResultLog } from '@/types';
interface BoundingBoxOverlayProps {
detections: DetectionResultLog[];
videoWidth: number;
videoHeight: number;
}
export default function BoundingBoxOverlay({
detections,
videoWidth,
videoHeight,
}: BoundingBoxOverlayProps) {
if (videoWidth <= 0 || videoHeight <= 0 || detections.length === 0) {
return null;
}
return (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[2] size-full"
viewBox={`0 0 ${videoWidth} ${videoHeight}`}
preserveAspectRatio="xMidYMid meet"
>
{detections.map(({ id, detection }) => {
const { bounding_box: box } = detection;
const color = getDefectVisual(detection.class_name).boundingBoxColor;
const label = `${detection.display_name} ${(detection.confidence * 100).toFixed(0)}%`;
const labelY = Math.max(box.y1 - 34, 0);
const labelWidth = Math.max(150, label.length * 17);
return (
<g key={id}>
<rect
x={box.x1}
y={box.y1}
width={box.x2 - box.x1}
height={box.y2 - box.y1}
fill="none"
stroke={color}
strokeWidth="4"
vectorEffect="non-scaling-stroke"
/>
<rect
x={box.x1}
y={labelY}
width={labelWidth}
height="34"
fill={color}
/>
<text
x={box.x1 + 8}
y={labelY + 23}
fill="white"
fontSize="20"
fontWeight="600"
>
{label}
</text>
</g>
);
})}
</svg>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import dynamic from 'next/dynamic';
import { MapPin } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
type DetectionLocationMapProps = {
latitude: number | null;
longitude: number | null;
label: string;
};
type ClientDetectionLocationMapProps = {
latitude: number;
longitude: number;
label: string;
};
const ClientDetectionLocationMap = dynamic<ClientDetectionLocationMapProps>(
async () => {
const { CircleMarker, MapContainer, Popup, TileLayer } =
await import('react-leaflet');
return function ClientDetectionLocationMapInner({
latitude,
longitude,
label,
}: ClientDetectionLocationMapProps) {
return (
<MapContainer
key={`${latitude}:${longitude}`}
center={[latitude, longitude]}
zoom={17}
scrollWheelZoom={false}
className="h-full w-full"
>
<TileLayer
attribution="&copy; OpenStreetMap contributors"
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<CircleMarker
center={[latitude, longitude]}
radius={10}
pathOptions={{
color: '#dc2626',
fillColor: '#dc2626',
fillOpacity: 0.75,
weight: 2,
}}
>
<Popup>{label}</Popup>
</CircleMarker>
</MapContainer>
);
};
},
{
ssr: false,
loading: () => <div className="h-full w-full animate-pulse bg-muted" />,
},
);
export default function DetectionLocationMap({
latitude,
longitude,
label,
}: DetectionLocationMapProps) {
return (
<Card className="overflow-hidden">
<CardHeader className="border-b pb-4">
<div className="flex items-center gap-3">
<div className="rounded bg-secondary p-2">
<MapPin className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base font-semibold">
Detection Location
</CardTitle>
<CardDescription className="text-xs">
Selected log GPS coordinates
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 pt-6">
{latitude == null || longitude == null ? (
<div className="rounded-lg border border-dashed bg-muted/30 px-4 py-8 text-sm text-muted-foreground">
Coordinates are unavailable for the selected log.
</div>
) : (
<>
<div className="text-sm text-muted-foreground">
{latitude.toFixed(6)}, {longitude.toFixed(6)}
</div>
<div className="h-72 overflow-hidden rounded-lg border bg-muted">
<ClientDetectionLocationMap
latitude={latitude}
longitude={longitude}
label={label}
/>
</div>
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { AlertTriangle, ImageIcon, Loader2 } from 'lucide-react';
import Image from 'next/image';
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
import type { DetectionResultLog } from '@/types';
import BoundingBoxOverlay from '@/components/annotation/boundingBoxOverlay';
interface AnnotatedDetectionImageProps {
detection?: DetectionResultLog;
videoWidth: number;
videoHeight: number;
}
export default function AnnotatedDetectionImage({
detection,
videoWidth,
videoHeight,
}: AnnotatedDetectionImageProps) {
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(
detection?.detection.context_image_url,
);
const aspectRatio =
videoWidth > 0 && videoHeight > 0
? `${videoWidth} / ${videoHeight}`
: '16 / 9';
return (
<div
className="relative flex w-full items-center justify-center overflow-hidden rounded-lg border bg-black"
style={{ aspectRatio }}
>
{!detection ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ImageIcon className="size-7" />
<p className="text-sm">Select a detection to view its image.</p>
</div>
) : isLoading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="size-7 animate-spin text-primary" />
<p className="text-sm">Loading detection image...</p>
</div>
) : isError || !objectUrl ? (
<div className="flex flex-col items-center gap-2 text-destructive">
<AlertTriangle className="size-7" />
<p className="text-sm">Failed to load the detection image.</p>
</div>
) : (
<>
<Image
src={objectUrl}
alt={`${detection.detection.display_name} detection`}
fill
unoptimized
className="object-contain"
/>
<BoundingBoxOverlay
detections={[detection]}
videoWidth={videoWidth}
videoHeight={videoHeight}
/>
</>
)}
</div>
);
}

View File

@@ -1,14 +1,17 @@
'use client';
import { RefObject } from 'react';
import { RefObject, useEffect } from 'react';
import { AlertTriangle, Loader2 } from 'lucide-react';
import type { DetectionResultLog } from '@/types';
import { useDetectionThumbnails } from './useDetectionThumbnails';
import BoundingBoxOverlay from '@/components/annotation/boundingBoxOverlay';
import {
MediaPlayer,
MediaProvider,
Poster,
isVideoProvider,
type MediaPlayerInstance,
useMediaProvider,
} from '@vidstack/react';
import {
DefaultVideoLayout,
@@ -18,22 +21,54 @@ import {
interface AnnotatedVideoPlayerProps {
videoRef: RefObject<MediaPlayerInstance | null>;
logs: DetectionResultLog[];
visibleLogs: DetectionResultLog[];
videoWidth: number;
videoHeight: number;
videoUrl: string | null;
isLoading: boolean;
isError: boolean;
onRetry: () => void;
onTimeUpdate: () => void;
onVideoFrame: (mediaTime: number) => void;
onSeeked: () => void;
}
function VideoFrameSync({
onVideoFrame,
}: Pick<AnnotatedVideoPlayerProps, 'onVideoFrame'>) {
const provider = useMediaProvider();
useEffect(() => {
if (!isVideoProvider(provider)) return;
const video = provider.video;
let callbackId: number;
const update = (_now: number, metadata: VideoFrameCallbackMetadata) => {
onVideoFrame(metadata.mediaTime);
callbackId = video.requestVideoFrameCallback(update);
};
callbackId = video.requestVideoFrameCallback(update);
return () => video.cancelVideoFrameCallback(callbackId);
}, [onVideoFrame, provider]);
return null;
}
export default function AnnotatedVideoPlayer({
videoRef,
logs,
visibleLogs,
videoWidth,
videoHeight,
videoUrl,
isLoading,
isError,
onRetry,
onTimeUpdate,
onVideoFrame,
onSeeked,
}: AnnotatedVideoPlayerProps) {
const thumbnails = useDetectionThumbnails(logs);
@@ -55,6 +90,12 @@ export default function AnnotatedVideoPlayer({
<MediaProvider>
<Poster className="vds-poster" />
</MediaProvider>
<VideoFrameSync onVideoFrame={onVideoFrame} />
<BoundingBoxOverlay
detections={visibleLogs}
videoWidth={videoWidth}
videoHeight={videoHeight}
/>
<DefaultVideoLayout
thumbnails={thumbnails}
icons={defaultLayoutIcons}

View File

@@ -12,7 +12,7 @@ import type { DetectionResultLog } from '@/types';
interface DetectionLogsProps {
logs: DetectionResultLog[];
activeLogId?: string;
onSeek: (log: DetectionResultLog) => void;
onSelect: (log: DetectionResultLog) => void;
}
const formatVideoTime = (seconds: number) => {
@@ -63,7 +63,7 @@ function DetectionLogThumbnail({
);
}
const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
const DetectionLogs = ({ logs, activeLogId, onSelect }: DetectionLogsProps) => {
const activeLogRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
@@ -100,7 +100,7 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
key={`${log.id}-${log.frame.number}`}
ref={isActive ? activeLogRef : null}
type="button"
onClick={() => onSeek(log)}
onClick={() => onSelect(log)}
className={cn(
'w-full rounded-md border bg-card p-3 text-left transition-colors hover:border-primary',
isActive && 'border-primary bg-primary/5',

View File

@@ -1,16 +1,18 @@
'use client';
import { RefObject, useMemo, useRef, useState } from 'react';
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import type { MediaPlayerInstance } from '@vidstack/react';
import { CompletedVideoResult, DetectionResultLog } from '@/types';
interface UseDetectionPlaybackParams {
logs: CompletedVideoResult['logs'];
fps: number;
videoRef: RefObject<MediaPlayerInstance | null>;
}
export function useDetectionPlayback({
logs,
fps,
videoRef,
}: UseDetectionPlaybackParams) {
const shouldPauseAfterSeekRef = useRef(false);
@@ -24,6 +26,36 @@ export function useDetectionPlayback({
const [activeLog, setActiveLog] = useState<DetectionResultLog | undefined>(
sortedLogs[0],
);
const [visibleLogs, setVisibleLogs] = useState<DetectionResultLog[]>([]);
const visibleLogIdsRef = useRef('');
const getVisibleLogs = useCallback(
(currentTime: number) => {
const frameDuration = fps > 0 ? 1 / fps : 0;
return sortedLogs.filter((log) => {
const start =
log.frame.start_timestamp_seconds ?? log.frame.timestamp_seconds;
const end = log.frame.end_timestamp_seconds ?? start + frameDuration;
return currentTime >= start && currentTime < end;
});
},
[fps, sortedLogs],
);
const updateVisibleLogs = useCallback(
(currentTime: number) => {
const nextLogs = getVisibleLogs(currentTime);
const nextIds = nextLogs.map((log) => log.id).join('|');
if (nextIds === visibleLogIdsRef.current) return;
visibleLogIdsRef.current = nextIds;
setVisibleLogs(nextLogs);
},
[getVisibleLogs],
);
const handleSeek = (log: DetectionResultLog) => {
const video = videoRef.current;
@@ -33,6 +65,7 @@ export function useDetectionPlayback({
video.pause();
video.currentTime = log.frame.timestamp_seconds;
setActiveLog(log);
updateVisibleLogs(log.frame.timestamp_seconds);
};
const handleSeeked = () => {
@@ -58,9 +91,11 @@ export function useDetectionPlayback({
return {
activeLog,
visibleLogs,
sortedLogs,
handleSeek,
handleSeeked,
handleTimeUpdate,
handleVideoFrame: updateVisibleLogs,
};
}

View File

@@ -1,8 +1,7 @@
'use client';
import { useRef } from 'react';
import { Film } from 'lucide-react';
import type { MediaPlayerInstance } from '@vidstack/react';
import { useMemo, useState } from 'react';
import { ImageIcon } from 'lucide-react';
import {
Card,
CardContent,
@@ -11,30 +10,27 @@ import {
CardTitle,
} from '@/components/ui/card';
import { CompletedVideoResult } from '@/types';
import { useAnnotatedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults';
import AnnotatedVideoPlayer from './video/annotatedVideoPlayer';
import DetectionLocationMap from './map/detectionLocationMap';
import AnnotatedDetectionImage from './video/annotatedDetectionImage';
import CurrentDetectionBar from './video/currentDetectionBar';
import DetectionLogs from './video/detectionLogs';
import ResultStatsGrid from './video/resultStatsGrid';
import { useDetectionPlayback } from './video/useDetectionPlayback';
type VideoPlayerSectionProps = {
data: CompletedVideoResult;
};
export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
const videoRef = useRef<MediaPlayerInstance>(null);
const { activeLog, sortedLogs, handleSeek, handleSeeked, handleTimeUpdate } =
useDetectionPlayback({
logs: data.logs,
videoRef,
});
const {
videoUrl,
isLoading: isVideoLoading,
isError: isVideoError,
refetch: refetchVideo,
} = useAnnotatedVideoQuery(data.processed_video_url || '');
const sortedLogs = useMemo(
() =>
[...data.logs].sort(
(a, b) => a.frame.timestamp_seconds - b.frame.timestamp_seconds,
),
[data.logs],
);
const [selectedLogId, setSelectedLogId] = useState(sortedLogs[0]?.id);
const activeLog =
sortedLogs.find((log) => log.id === selectedLogId) ?? sortedLogs[0];
return (
<div className="space-y-6">
@@ -42,14 +38,14 @@ export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
<CardHeader className="border-b pb-4">
<div className="flex items-center gap-3">
<div className="rounded bg-secondary p-2">
<Film className="h-5 w-5 text-primary" />
<ImageIcon className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg font-bold">
Detection Playback
Detection Preview
</CardTitle>
<CardDescription className="text-xs">
Annotated video with backend detection logs
Selected detection image with its bounding box
</CardDescription>
</div>
</div>
@@ -57,15 +53,16 @@ export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
<CardContent className="pt-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<AnnotatedVideoPlayer
videoRef={videoRef}
logs={sortedLogs}
videoUrl={videoUrl}
isLoading={isVideoLoading}
isError={isVideoError}
onRetry={() => void refetchVideo()}
onTimeUpdate={handleTimeUpdate}
onSeeked={handleSeeked}
<AnnotatedDetectionImage
detection={activeLog}
videoWidth={data.summary.video_width}
videoHeight={data.summary.video_height}
/>
<DetectionLocationMap
latitude={activeLog?.location.latitude ?? null}
longitude={activeLog?.location.longitude ?? null}
label={activeLog?.detection.display_name ?? 'Detection'}
/>
<CurrentDetectionBar activeLog={activeLog} data={data} />
@@ -74,7 +71,7 @@ export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
<DetectionLogs
logs={sortedLogs}
activeLogId={activeLog?.id}
onSeek={handleSeek}
onSelect={(log) => setSelectedLogId(log.id)}
/>
</div>

View File

@@ -12,6 +12,7 @@ import {
type DefectVisualConfig = {
icon: LucideIcon;
boundingBoxColor: string;
colorClassName: string;
iconClassName: string;
cardClassName: string;
@@ -20,30 +21,35 @@ type DefectVisualConfig = {
const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
manhole_cover: {
icon: CircleDot,
boundingBoxColor: '#71717a',
colorClassName: 'text-zinc-500',
iconClassName: 'bg-zinc-500/10 text-zinc-500',
cardClassName: 'bg-zinc-500/5',
},
pothole: {
icon: Construction,
boundingBoxColor: '#f97316',
colorClassName: 'text-orange-500',
iconClassName: 'bg-orange-500/10 text-orange-500',
cardClassName: 'bg-orange-500/5',
},
road_crack: {
icon: Spline,
boundingBoxColor: '#f43f5e',
colorClassName: 'text-rose-500',
iconClassName: 'bg-rose-500/10 text-rose-500',
cardClassName: 'bg-rose-500/5',
},
sign_board: {
icon: SignpostBig,
boundingBoxColor: '#3b82f6',
colorClassName: 'text-blue-500',
iconClassName: 'bg-blue-500/10 text-blue-500',
cardClassName: 'bg-blue-500/5',
},
water_puddle: {
icon: Droplets,
boundingBoxColor: '#06b6d4',
colorClassName: 'text-cyan-500',
iconClassName: 'bg-cyan-500/10 text-cyan-500',
cardClassName: 'bg-cyan-500/5',
@@ -52,6 +58,7 @@ const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
const DEFAULT_VISUAL: DefectVisualConfig = {
icon: Activity,
boundingBoxColor: '#22c55e',
colorClassName: 'text-green-500',
iconClassName: 'bg-green-500/10 text-green-500',
cardClassName: 'bg-green-500/5',

View File

@@ -8,6 +8,8 @@ export type CompletedVideoResult = {
summary: {
fps: number;
duration_seconds: number;
video_width: number;
video_height: number;
total_detections: number;
};
defect_counts: DetectionClassCount[];

View File

@@ -5,6 +5,15 @@ export type DetectionClassCount = {
unique_count: number;
};
export type DetectionBoundingBox = {
x1: number;
y1: number;
x2: number;
y2: number;
width: number;
height: number;
};
export type DetectionResultLog = {
id: string;
detection: {
@@ -13,10 +22,13 @@ export type DetectionResultLog = {
display_name: string;
confidence: number;
context_image_url: string;
bounding_box: DetectionBoundingBox;
};
frame: {
number: number;
timestamp_seconds: number;
start_timestamp_seconds?: number;
end_timestamp_seconds?: number;
};
location: {
latitude: number | null;