+ className="detection-video-player aspect-video w-full"
+ >
+
{isError ? (
@@ -54,7 +80,9 @@ export default function AnnotatedVideoPlayer({
<>
- {isLoading ? 'Loading annotated video...' : 'Preparing video...'}
+ {isLoading
+ ? 'Loading annotated video...'
+ : 'Preparing video...'}
>
)}
diff --git a/src/components/video/currentDetectionBar.tsx b/src/components/video/currentDetectionBar.tsx
index e45556c..244ff85 100644
--- a/src/components/video/currentDetectionBar.tsx
+++ b/src/components/video/currentDetectionBar.tsx
@@ -4,7 +4,7 @@ import { Activity, MapPin } from 'lucide-react';
import type { CompletedVideoResult, DetectionResultLog } from '@/types';
-import { getDefectVisual } from './defectVisualConfig';
+import { getDefectVisual } from '@/constants/defectVisualConfig';
interface CurrentDetectionBarProps {
activeLog?: DetectionResultLog;
diff --git a/src/components/video/resultStatsGrid.tsx b/src/components/video/resultStatsGrid.tsx
index ff2efd7..047ba58 100644
--- a/src/components/video/resultStatsGrid.tsx
+++ b/src/components/video/resultStatsGrid.tsx
@@ -1,9 +1,10 @@
'use client';
import { Activity, Clock, Gauge } from 'lucide-react';
-import { CompletedVideoResult } from '@/types';
-import { getDefectVisual } from './defectVisualConfig';
+import { getDefectVisual } from '@/constants/defectVisualConfig';
+import { cn } from '@/lib/utils';
+import type { CompletedVideoResult } from '@/types';
const formatDuration = (seconds?: number) => {
if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return '-';
@@ -28,8 +29,8 @@ export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
label: item.display_name,
value: item.count,
icon: visual.icon,
- color: visual.colorClassName,
- bgColor: visual.iconClassName,
+ colorClassName: visual.colorClassName,
+ iconClassName: visual.iconClassName,
};
});
@@ -38,23 +39,23 @@ export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
label: 'Total Detections',
value: data.summary.total_detections,
icon: Activity,
- color: 'text-green-500',
- bgColor: 'bg-green-500/10 text-green-500',
+ colorClassName: 'text-green-500',
+ iconClassName: 'bg-green-500/10 text-green-500',
},
...defectStats,
{
label: 'FPS',
value: formatFps(data.summary.fps),
icon: Gauge,
- color: 'text-purple-500',
- bgColor: 'bg-purple-500/10 text-purple-500',
+ colorClassName: 'text-purple-500',
+ iconClassName: 'bg-purple-500/10 text-purple-500',
},
{
label: 'Duration',
value: formatDuration(data.summary.duration_seconds),
icon: Clock,
- color: 'text-cyan-500',
- bgColor: 'bg-cyan-500/10 text-cyan-500',
+ colorClassName: 'text-cyan-500',
+ iconClassName: 'bg-cyan-500/10 text-cyan-500',
},
];
@@ -66,17 +67,29 @@ export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
return (
-
-
+
-
- {stat.value}
-
-
+
);
})}
diff --git a/src/components/video/useDetectionPlayback.ts b/src/components/video/useDetectionPlayback.ts
index bb2dfa6..064706f 100644
--- a/src/components/video/useDetectionPlayback.ts
+++ b/src/components/video/useDetectionPlayback.ts
@@ -1,11 +1,12 @@
'use client';
import { RefObject, useMemo, useRef, useState } from 'react';
+import type { MediaPlayerInstance } from '@vidstack/react';
import { CompletedVideoResult, DetectionResultLog } from '@/types';
interface UseDetectionPlaybackParams {
logs: CompletedVideoResult['logs'];
- videoRef: RefObject
;
+ videoRef: RefObject;
}
export function useDetectionPlayback({
diff --git a/src/components/video/useDetectionThumbnails.ts b/src/components/video/useDetectionThumbnails.ts
new file mode 100644
index 0000000..68022a3
--- /dev/null
+++ b/src/components/video/useDetectionThumbnails.ts
@@ -0,0 +1,66 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import { useQueries } from '@tanstack/react-query';
+
+import { protectedMediaService } from '@/services/api';
+import type { DetectionResultLog } from '@/types';
+
+type DetectionThumbnail = {
+ url: string;
+ startTime: number;
+ endTime: number;
+};
+
+const PREVIEW_WINDOW_SECONDS = 0.5;
+
+export function useDetectionThumbnails(logs: DetectionResultLog[]) {
+ const imageUrls = useMemo(
+ () => logs.map((log) => log.detection.context_image_url),
+ [logs],
+ );
+
+ const blobs = useQueries({
+ queries: imageUrls.map((url) => ({
+ queryKey: ['protected-media', url],
+ queryFn: () => protectedMediaService.getBlob(url),
+ enabled: Boolean(url),
+ staleTime: Infinity,
+ gcTime: 1000 * 60 * 30,
+ })),
+ combine: (results) => results.map((result) => result.data),
+ });
+
+ const [objectUrls, setObjectUrls] = useState>([]);
+
+ useEffect(() => {
+ const nextUrls = blobs.map((blob) =>
+ blob ? URL.createObjectURL(blob) : null,
+ );
+ setObjectUrls(nextUrls);
+
+ return () => {
+ nextUrls.forEach((url) => {
+ if (url) URL.revokeObjectURL(url);
+ });
+ };
+ }, [blobs]);
+
+ return useMemo(
+ () =>
+ logs.flatMap((log, index) => {
+ const url = objectUrls[index];
+ if (!url) return [];
+
+ const timestamp = log.frame.timestamp_seconds;
+ return [
+ {
+ url,
+ startTime: Math.max(0, timestamp - PREVIEW_WINDOW_SECONDS),
+ endTime: timestamp + PREVIEW_WINDOW_SECONDS,
+ },
+ ];
+ }),
+ [logs, objectUrls],
+ );
+}
diff --git a/src/components/videoPlayerSection.tsx b/src/components/videoPlayerSection.tsx
index 28a663e..9ed1ec2 100644
--- a/src/components/videoPlayerSection.tsx
+++ b/src/components/videoPlayerSection.tsx
@@ -2,6 +2,7 @@
import { useRef } from 'react';
import { Film } from 'lucide-react';
+import type { MediaPlayerInstance } from '@vidstack/react';
import {
Card,
CardContent,
@@ -22,17 +23,12 @@ type VideoPlayerSectionProps = {
};
export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
- const videoRef = useRef(null);
- const {
- activeLog,
- sortedLogs,
- handleSeek,
- handleSeeked,
- handleTimeUpdate,
- } = useDetectionPlayback({
- logs: data.logs,
- videoRef,
- });
+ const videoRef = useRef(null);
+ const { activeLog, sortedLogs, handleSeek, handleSeeked, handleTimeUpdate } =
+ useDetectionPlayback({
+ logs: data.logs,
+ videoRef,
+ });
const {
videoUrl,
isLoading: isVideoLoading,
@@ -63,6 +59,7 @@ export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
= {
@@ -21,26 +22,31 @@ const DEFECT_VISUALS: Record = {
icon: CircleDot,
colorClassName: 'text-zinc-500',
iconClassName: 'bg-zinc-500/10 text-zinc-500',
+ cardClassName: 'bg-zinc-500/5',
},
pothole: {
- icon: AlertTriangle,
+ icon: Construction,
colorClassName: 'text-orange-500',
iconClassName: 'bg-orange-500/10 text-orange-500',
+ cardClassName: 'bg-orange-500/5',
},
road_crack: {
- icon: CircleAlert,
+ icon: Spline,
colorClassName: 'text-rose-500',
iconClassName: 'bg-rose-500/10 text-rose-500',
+ cardClassName: 'bg-rose-500/5',
},
sign_board: {
icon: SignpostBig,
colorClassName: 'text-blue-500',
iconClassName: 'bg-blue-500/10 text-blue-500',
+ cardClassName: 'bg-blue-500/5',
},
water_puddle: {
icon: Droplets,
colorClassName: 'text-cyan-500',
iconClassName: 'bg-cyan-500/10 text-cyan-500',
+ cardClassName: 'bg-cyan-500/5',
},
};
@@ -48,6 +54,7 @@ const DEFAULT_VISUAL: DefectVisualConfig = {
icon: Activity,
colorClassName: 'text-green-500',
iconClassName: 'bg-green-500/10 text-green-500',
+ cardClassName: 'bg-green-500/5',
};
export const getDefectVisual = (className: string) =>
diff --git a/src/types/ticket/detail.ts b/src/types/ticket/detail.ts
index be2430f..30d4ff4 100644
--- a/src/types/ticket/detail.ts
+++ b/src/types/ticket/detail.ts
@@ -80,12 +80,41 @@ export interface TicketTimestamps {
updated_at: string;
}
+export interface TicketLocationReference {
+ id: string;
+ name: string;
+}
+
+export interface TicketLocation {
+ project: TicketLocationReference;
+ package: TicketLocationReference;
+ chainage: TicketLocationReference;
+}
+
+export interface TicketVideoMetadata {
+ duration_seconds: number;
+ fps: number;
+ resolution: {
+ width: number;
+ height: number;
+ label: string;
+ };
+}
+
+export interface TicketDetectionClassCount {
+ class_name: string;
+ display_name: string;
+ count: number;
+}
+
export interface TicketOverviewDetail {
id: string;
ticket_name: string;
video_id: string;
chainage_id: string;
chainage_name: string;
+ location: TicketLocation;
+ video_metadata: TicketVideoMetadata;
uploader: {
user_id: number;
name: string;
@@ -95,6 +124,7 @@ export interface TicketOverviewDetail {
detection_count: number;
completed_at: string;
available_defect_classes: string[];
+ detections_by_class: TicketDetectionClassCount[];
};
timestamps: TicketTimestamps;
}