feat(results): replace video playback with detection image preview and map
This commit is contained in:
51
package-lock.json
generated
51
package-lock.json
generated
@@ -28,6 +28,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"libphonenumber-js": "^1.13.7",
|
"libphonenumber-js": "^1.13.7",
|
||||||
"lightgallery": "^2.9.0",
|
"lightgallery": "^2.9.0",
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.79.0",
|
"react-hook-form": "^7.79.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.9",
|
"@tailwindcss/postcss": "^4.1.9",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -2890,6 +2893,17 @@
|
|||||||
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
|
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -3377,6 +3391,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -3391,6 +3412,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.15",
|
"version": "22.19.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
@@ -6857,6 +6888,12 @@
|
|||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -8068,6 +8105,20 @@
|
|||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"libphonenumber-js": "^1.13.7",
|
"libphonenumber-js": "^1.13.7",
|
||||||
"lightgallery": "^2.9.0",
|
"lightgallery": "^2.9.0",
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.79.0",
|
"react-hook-form": "^7.79.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.9",
|
"@tailwindcss/postcss": "^4.1.9",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
|||||||
import { Geist_Mono, Poppins } from 'next/font/google';
|
import { Geist_Mono, Poppins } from 'next/font/google';
|
||||||
import '@vidstack/react/player/styles/default/theme.css';
|
import '@vidstack/react/player/styles/default/theme.css';
|
||||||
import '@vidstack/react/player/styles/default/layouts/video.css';
|
import '@vidstack/react/player/styles/default/layouts/video.css';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
import 'lightgallery/css/lightgallery.css';
|
import 'lightgallery/css/lightgallery.css';
|
||||||
import 'lightgallery/css/lg-thumbnail.css';
|
import 'lightgallery/css/lg-thumbnail.css';
|
||||||
import 'lightgallery/css/lg-zoom.css';
|
import 'lightgallery/css/lg-zoom.css';
|
||||||
|
|||||||
66
src/components/annotation/boundingBoxOverlay.tsx
Normal file
66
src/components/annotation/boundingBoxOverlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/components/map/detectionLocationMap.tsx
Normal file
113
src/components/map/detectionLocationMap.tsx
Normal 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="© 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/video/annotatedDetectionImage.tsx
Normal file
69
src/components/video/annotatedDetectionImage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { RefObject } from 'react';
|
import { RefObject, useEffect } from 'react';
|
||||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||||
import type { DetectionResultLog } from '@/types';
|
import type { DetectionResultLog } from '@/types';
|
||||||
import { useDetectionThumbnails } from './useDetectionThumbnails';
|
import { useDetectionThumbnails } from './useDetectionThumbnails';
|
||||||
|
import BoundingBoxOverlay from '@/components/annotation/boundingBoxOverlay';
|
||||||
import {
|
import {
|
||||||
MediaPlayer,
|
MediaPlayer,
|
||||||
MediaProvider,
|
MediaProvider,
|
||||||
Poster,
|
Poster,
|
||||||
|
isVideoProvider,
|
||||||
type MediaPlayerInstance,
|
type MediaPlayerInstance,
|
||||||
|
useMediaProvider,
|
||||||
} from '@vidstack/react';
|
} from '@vidstack/react';
|
||||||
import {
|
import {
|
||||||
DefaultVideoLayout,
|
DefaultVideoLayout,
|
||||||
@@ -18,22 +21,54 @@ import {
|
|||||||
interface AnnotatedVideoPlayerProps {
|
interface AnnotatedVideoPlayerProps {
|
||||||
videoRef: RefObject<MediaPlayerInstance | null>;
|
videoRef: RefObject<MediaPlayerInstance | null>;
|
||||||
logs: DetectionResultLog[];
|
logs: DetectionResultLog[];
|
||||||
|
visibleLogs: DetectionResultLog[];
|
||||||
|
videoWidth: number;
|
||||||
|
videoHeight: number;
|
||||||
videoUrl: string | null;
|
videoUrl: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
onTimeUpdate: () => void;
|
onTimeUpdate: () => void;
|
||||||
|
onVideoFrame: (mediaTime: number) => void;
|
||||||
onSeeked: () => 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({
|
export default function AnnotatedVideoPlayer({
|
||||||
videoRef,
|
videoRef,
|
||||||
logs,
|
logs,
|
||||||
|
visibleLogs,
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
videoUrl,
|
videoUrl,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
onRetry,
|
onRetry,
|
||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
|
onVideoFrame,
|
||||||
onSeeked,
|
onSeeked,
|
||||||
}: AnnotatedVideoPlayerProps) {
|
}: AnnotatedVideoPlayerProps) {
|
||||||
const thumbnails = useDetectionThumbnails(logs);
|
const thumbnails = useDetectionThumbnails(logs);
|
||||||
@@ -55,6 +90,12 @@ export default function AnnotatedVideoPlayer({
|
|||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<Poster className="vds-poster" />
|
<Poster className="vds-poster" />
|
||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
|
<VideoFrameSync onVideoFrame={onVideoFrame} />
|
||||||
|
<BoundingBoxOverlay
|
||||||
|
detections={visibleLogs}
|
||||||
|
videoWidth={videoWidth}
|
||||||
|
videoHeight={videoHeight}
|
||||||
|
/>
|
||||||
<DefaultVideoLayout
|
<DefaultVideoLayout
|
||||||
thumbnails={thumbnails}
|
thumbnails={thumbnails}
|
||||||
icons={defaultLayoutIcons}
|
icons={defaultLayoutIcons}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { DetectionResultLog } from '@/types';
|
|||||||
interface DetectionLogsProps {
|
interface DetectionLogsProps {
|
||||||
logs: DetectionResultLog[];
|
logs: DetectionResultLog[];
|
||||||
activeLogId?: string;
|
activeLogId?: string;
|
||||||
onSeek: (log: DetectionResultLog) => void;
|
onSelect: (log: DetectionResultLog) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatVideoTime = (seconds: number) => {
|
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);
|
const activeLogRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,7 +100,7 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
|
|||||||
key={`${log.id}-${log.frame.number}`}
|
key={`${log.id}-${log.frame.number}`}
|
||||||
ref={isActive ? activeLogRef : null}
|
ref={isActive ? activeLogRef : null}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSeek(log)}
|
onClick={() => onSelect(log)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full rounded-md border bg-card p-3 text-left transition-colors hover:border-primary',
|
'w-full rounded-md border bg-card p-3 text-left transition-colors hover:border-primary',
|
||||||
isActive && 'border-primary bg-primary/5',
|
isActive && 'border-primary bg-primary/5',
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { RefObject, useMemo, useRef, useState } from 'react';
|
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import type { MediaPlayerInstance } from '@vidstack/react';
|
import type { MediaPlayerInstance } from '@vidstack/react';
|
||||||
import { CompletedVideoResult, DetectionResultLog } from '@/types';
|
import { CompletedVideoResult, DetectionResultLog } from '@/types';
|
||||||
|
|
||||||
interface UseDetectionPlaybackParams {
|
interface UseDetectionPlaybackParams {
|
||||||
logs: CompletedVideoResult['logs'];
|
logs: CompletedVideoResult['logs'];
|
||||||
|
fps: number;
|
||||||
videoRef: RefObject<MediaPlayerInstance | null>;
|
videoRef: RefObject<MediaPlayerInstance | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDetectionPlayback({
|
export function useDetectionPlayback({
|
||||||
logs,
|
logs,
|
||||||
|
fps,
|
||||||
videoRef,
|
videoRef,
|
||||||
}: UseDetectionPlaybackParams) {
|
}: UseDetectionPlaybackParams) {
|
||||||
const shouldPauseAfterSeekRef = useRef(false);
|
const shouldPauseAfterSeekRef = useRef(false);
|
||||||
@@ -24,6 +26,36 @@ export function useDetectionPlayback({
|
|||||||
const [activeLog, setActiveLog] = useState<DetectionResultLog | undefined>(
|
const [activeLog, setActiveLog] = useState<DetectionResultLog | undefined>(
|
||||||
sortedLogs[0],
|
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 handleSeek = (log: DetectionResultLog) => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -33,6 +65,7 @@ export function useDetectionPlayback({
|
|||||||
video.pause();
|
video.pause();
|
||||||
video.currentTime = log.frame.timestamp_seconds;
|
video.currentTime = log.frame.timestamp_seconds;
|
||||||
setActiveLog(log);
|
setActiveLog(log);
|
||||||
|
updateVisibleLogs(log.frame.timestamp_seconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSeeked = () => {
|
const handleSeeked = () => {
|
||||||
@@ -58,9 +91,11 @@ export function useDetectionPlayback({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
activeLog,
|
activeLog,
|
||||||
|
visibleLogs,
|
||||||
sortedLogs,
|
sortedLogs,
|
||||||
handleSeek,
|
handleSeek,
|
||||||
handleSeeked,
|
handleSeeked,
|
||||||
handleTimeUpdate,
|
handleTimeUpdate,
|
||||||
|
handleVideoFrame: updateVisibleLogs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Film } from 'lucide-react';
|
import { ImageIcon } from 'lucide-react';
|
||||||
import type { MediaPlayerInstance } from '@vidstack/react';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -11,30 +10,27 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { CompletedVideoResult } from '@/types';
|
import { CompletedVideoResult } from '@/types';
|
||||||
import { useAnnotatedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults';
|
import DetectionLocationMap from './map/detectionLocationMap';
|
||||||
import AnnotatedVideoPlayer from './video/annotatedVideoPlayer';
|
import AnnotatedDetectionImage from './video/annotatedDetectionImage';
|
||||||
import CurrentDetectionBar from './video/currentDetectionBar';
|
import CurrentDetectionBar from './video/currentDetectionBar';
|
||||||
import DetectionLogs from './video/detectionLogs';
|
import DetectionLogs from './video/detectionLogs';
|
||||||
import ResultStatsGrid from './video/resultStatsGrid';
|
import ResultStatsGrid from './video/resultStatsGrid';
|
||||||
import { useDetectionPlayback } from './video/useDetectionPlayback';
|
|
||||||
|
|
||||||
type VideoPlayerSectionProps = {
|
type VideoPlayerSectionProps = {
|
||||||
data: CompletedVideoResult;
|
data: CompletedVideoResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
|
export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
|
||||||
const videoRef = useRef<MediaPlayerInstance>(null);
|
const sortedLogs = useMemo(
|
||||||
const { activeLog, sortedLogs, handleSeek, handleSeeked, handleTimeUpdate } =
|
() =>
|
||||||
useDetectionPlayback({
|
[...data.logs].sort(
|
||||||
logs: data.logs,
|
(a, b) => a.frame.timestamp_seconds - b.frame.timestamp_seconds,
|
||||||
videoRef,
|
),
|
||||||
});
|
[data.logs],
|
||||||
const {
|
);
|
||||||
videoUrl,
|
const [selectedLogId, setSelectedLogId] = useState(sortedLogs[0]?.id);
|
||||||
isLoading: isVideoLoading,
|
const activeLog =
|
||||||
isError: isVideoError,
|
sortedLogs.find((log) => log.id === selectedLogId) ?? sortedLogs[0];
|
||||||
refetch: refetchVideo,
|
|
||||||
} = useAnnotatedVideoQuery(data.processed_video_url || '');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -42,14 +38,14 @@ export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
|
|||||||
<CardHeader className="border-b pb-4">
|
<CardHeader className="border-b pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="rounded bg-secondary p-2">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg font-bold">
|
<CardTitle className="text-lg font-bold">
|
||||||
Detection Playback
|
Detection Preview
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
Annotated video with backend detection logs
|
Selected detection image with its bounding box
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,15 +53,16 @@ export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
|
|||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
<AnnotatedVideoPlayer
|
<AnnotatedDetectionImage
|
||||||
videoRef={videoRef}
|
detection={activeLog}
|
||||||
logs={sortedLogs}
|
videoWidth={data.summary.video_width}
|
||||||
videoUrl={videoUrl}
|
videoHeight={data.summary.video_height}
|
||||||
isLoading={isVideoLoading}
|
/>
|
||||||
isError={isVideoError}
|
|
||||||
onRetry={() => void refetchVideo()}
|
<DetectionLocationMap
|
||||||
onTimeUpdate={handleTimeUpdate}
|
latitude={activeLog?.location.latitude ?? null}
|
||||||
onSeeked={handleSeeked}
|
longitude={activeLog?.location.longitude ?? null}
|
||||||
|
label={activeLog?.detection.display_name ?? 'Detection'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CurrentDetectionBar activeLog={activeLog} data={data} />
|
<CurrentDetectionBar activeLog={activeLog} data={data} />
|
||||||
@@ -74,7 +71,7 @@ export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
|
|||||||
<DetectionLogs
|
<DetectionLogs
|
||||||
logs={sortedLogs}
|
logs={sortedLogs}
|
||||||
activeLogId={activeLog?.id}
|
activeLogId={activeLog?.id}
|
||||||
onSeek={handleSeek}
|
onSelect={(log) => setSelectedLogId(log.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
|
|
||||||
type DefectVisualConfig = {
|
type DefectVisualConfig = {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
boundingBoxColor: string;
|
||||||
colorClassName: string;
|
colorClassName: string;
|
||||||
iconClassName: string;
|
iconClassName: string;
|
||||||
cardClassName: string;
|
cardClassName: string;
|
||||||
@@ -20,30 +21,35 @@ type DefectVisualConfig = {
|
|||||||
const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
|
const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
|
||||||
manhole_cover: {
|
manhole_cover: {
|
||||||
icon: CircleDot,
|
icon: CircleDot,
|
||||||
|
boundingBoxColor: '#71717a',
|
||||||
colorClassName: 'text-zinc-500',
|
colorClassName: 'text-zinc-500',
|
||||||
iconClassName: 'bg-zinc-500/10 text-zinc-500',
|
iconClassName: 'bg-zinc-500/10 text-zinc-500',
|
||||||
cardClassName: 'bg-zinc-500/5',
|
cardClassName: 'bg-zinc-500/5',
|
||||||
},
|
},
|
||||||
pothole: {
|
pothole: {
|
||||||
icon: Construction,
|
icon: Construction,
|
||||||
|
boundingBoxColor: '#f97316',
|
||||||
colorClassName: 'text-orange-500',
|
colorClassName: 'text-orange-500',
|
||||||
iconClassName: 'bg-orange-500/10 text-orange-500',
|
iconClassName: 'bg-orange-500/10 text-orange-500',
|
||||||
cardClassName: 'bg-orange-500/5',
|
cardClassName: 'bg-orange-500/5',
|
||||||
},
|
},
|
||||||
road_crack: {
|
road_crack: {
|
||||||
icon: Spline,
|
icon: Spline,
|
||||||
|
boundingBoxColor: '#f43f5e',
|
||||||
colorClassName: 'text-rose-500',
|
colorClassName: 'text-rose-500',
|
||||||
iconClassName: 'bg-rose-500/10 text-rose-500',
|
iconClassName: 'bg-rose-500/10 text-rose-500',
|
||||||
cardClassName: 'bg-rose-500/5',
|
cardClassName: 'bg-rose-500/5',
|
||||||
},
|
},
|
||||||
sign_board: {
|
sign_board: {
|
||||||
icon: SignpostBig,
|
icon: SignpostBig,
|
||||||
|
boundingBoxColor: '#3b82f6',
|
||||||
colorClassName: 'text-blue-500',
|
colorClassName: 'text-blue-500',
|
||||||
iconClassName: 'bg-blue-500/10 text-blue-500',
|
iconClassName: 'bg-blue-500/10 text-blue-500',
|
||||||
cardClassName: 'bg-blue-500/5',
|
cardClassName: 'bg-blue-500/5',
|
||||||
},
|
},
|
||||||
water_puddle: {
|
water_puddle: {
|
||||||
icon: Droplets,
|
icon: Droplets,
|
||||||
|
boundingBoxColor: '#06b6d4',
|
||||||
colorClassName: 'text-cyan-500',
|
colorClassName: 'text-cyan-500',
|
||||||
iconClassName: 'bg-cyan-500/10 text-cyan-500',
|
iconClassName: 'bg-cyan-500/10 text-cyan-500',
|
||||||
cardClassName: 'bg-cyan-500/5',
|
cardClassName: 'bg-cyan-500/5',
|
||||||
@@ -52,6 +58,7 @@ const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
|
|||||||
|
|
||||||
const DEFAULT_VISUAL: DefectVisualConfig = {
|
const DEFAULT_VISUAL: DefectVisualConfig = {
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
|
boundingBoxColor: '#22c55e',
|
||||||
colorClassName: 'text-green-500',
|
colorClassName: 'text-green-500',
|
||||||
iconClassName: 'bg-green-500/10 text-green-500',
|
iconClassName: 'bg-green-500/10 text-green-500',
|
||||||
cardClassName: 'bg-green-500/5',
|
cardClassName: 'bg-green-500/5',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type CompletedVideoResult = {
|
|||||||
summary: {
|
summary: {
|
||||||
fps: number;
|
fps: number;
|
||||||
duration_seconds: number;
|
duration_seconds: number;
|
||||||
|
video_width: number;
|
||||||
|
video_height: number;
|
||||||
total_detections: number;
|
total_detections: number;
|
||||||
};
|
};
|
||||||
defect_counts: DetectionClassCount[];
|
defect_counts: DetectionClassCount[];
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ export type DetectionClassCount = {
|
|||||||
unique_count: number;
|
unique_count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DetectionBoundingBox = {
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type DetectionResultLog = {
|
export type DetectionResultLog = {
|
||||||
id: string;
|
id: string;
|
||||||
detection: {
|
detection: {
|
||||||
@@ -13,10 +22,13 @@ export type DetectionResultLog = {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
context_image_url: string;
|
context_image_url: string;
|
||||||
|
bounding_box: DetectionBoundingBox;
|
||||||
};
|
};
|
||||||
frame: {
|
frame: {
|
||||||
number: number;
|
number: number;
|
||||||
timestamp_seconds: number;
|
timestamp_seconds: number;
|
||||||
|
start_timestamp_seconds?: number;
|
||||||
|
end_timestamp_seconds?: number;
|
||||||
};
|
};
|
||||||
location: {
|
location: {
|
||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user