feat(results): add live annotation overlay on raw video playback

This commit is contained in:
2026-07-08 17:24:20 +05:30
parent 23186ffd29
commit 7a82e17bbb
13 changed files with 540 additions and 57 deletions

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { videoService } from '@/services/api';
import type { VideoAnnotationFramesParams } from '@/types';
import { videoKeys } from '../queries/videoKeys';
@@ -18,6 +19,20 @@ export function useVideoResultsQuery(videoId: string | undefined) {
});
}
export function useVideoAnnotationFramesQuery(
videoId: string | undefined,
params: VideoAnnotationFramesParams,
) {
return useQuery({
queryKey: videoKeys.annotationFrames(videoId ?? '', params),
queryFn: () =>
videoService.getVideoAnnotationFrames(videoId as string, params),
enabled: Boolean(videoId),
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
});
}
/**
* Download the annotated video through the authenticated axios client and
* expose a local object URL that the native `<video>` element can play.
@@ -26,10 +41,10 @@ export function useVideoResultsQuery(videoId: string | undefined) {
* directly is what fixes the 401. The browser cannot attach the Bearer token to
* a media request, but axios can.
*/
export function useAnnotatedVideoQuery(url: string | undefined) {
export function useProtectedVideoQuery(url: string | undefined) {
const query = useQuery({
queryKey: videoKeys.annotatedVideo(url ?? ''),
queryFn: () => videoService.getAnnotatedVideo(url as string),
queryFn: () => videoService.getProtectedVideo(url as string),
enabled: Boolean(url),
staleTime: Infinity,
gcTime: 1000 * 60 * 30,
@@ -59,3 +74,5 @@ export function useAnnotatedVideoQuery(url: string | undefined) {
refetch: query.refetch,
};
}
export const useAnnotatedVideoQuery = useProtectedVideoQuery;

View File

@@ -2,6 +2,10 @@ export const videoKeys = {
all: ['videos'] as const,
results: () => [...videoKeys.all, 'results'] as const,
result: (videoId: string) => [...videoKeys.results(), videoId] as const,
annotationFrames: (
videoId: string,
params: { start_time_ms: number; end_time_ms: number },
) => [...videoKeys.result(videoId), 'annotation-frames', params] as const,
annotatedVideos: () => [...videoKeys.all, 'annotated'] as const,
annotatedVideo: (url: string) =>
[...videoKeys.annotatedVideos(), url] as const,

View File

@@ -184,9 +184,9 @@ export function TicketClassDetectionPreview({
size="sm"
onClick={() => setCurrentIndex((index) => Math.max(index - 1, 0))}
disabled={isPreviousDisabled}
aria-label="Previous detection"
>
<ChevronLeft className="size-4" />
Previous
</Button>
<div className="min-w-16 text-center text-sm font-medium text-foreground">
{detectionCount === 0
@@ -199,8 +199,8 @@ export function TicketClassDetectionPreview({
size="sm"
onClick={() => setCurrentIndex((index) => index + 1)}
disabled={isNextDisabled}
aria-label="Next detection"
>
Next
<ChevronRight className="size-4" />
</Button>
</div>

View File

@@ -0,0 +1,68 @@
'use client';
import { useRef } from 'react';
import type { MediaPlayerInstance } from '@vidstack/react';
import { useProtectedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults';
import AnnotatedVideoPlayer from '@/components/video/annotatedVideoPlayer';
import { useVideoAnnotationPlayback } from '@/components/video/useVideoAnnotationPlayback';
import type { TicketOverviewDetail } from '@/types';
type TicketInlineAnalysisVideoProps = {
ticket: TicketOverviewDetail;
};
export function TicketInlineAnalysisVideo({
ticket,
}: TicketInlineAnalysisVideoProps) {
const videoRef = useRef<MediaPlayerInstance | null>(null);
const videoId = ticket.video_id ?? ticket.video?.id ?? undefined;
const sourceUrl = ticket.video?.url ?? undefined;
const fps = ticket.video_metadata?.fps ?? 0;
const videoWidth = ticket.video_metadata?.resolution.width ?? 0;
const videoHeight = ticket.video_metadata?.resolution.height ?? 0;
const {
visibleDetections,
handleSeeked,
handleTimeUpdate,
handleVideoFrame,
} = useVideoAnnotationPlayback({
videoId,
logs: [],
fps,
videoRef,
});
const {
videoUrl,
isLoading: isVideoLoading,
isError: isVideoError,
refetch: refetchVideo,
} = useProtectedVideoQuery(sourceUrl);
if (!videoId || !sourceUrl) {
return (
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-8 text-sm text-muted-foreground">
Analysis video is unavailable for this ticket.
</div>
);
}
return (
<div className="space-y-2">
<AnnotatedVideoPlayer
videoRef={videoRef}
logs={[]}
visibleDetections={visibleDetections}
videoWidth={videoWidth}
videoHeight={videoHeight}
videoUrl={videoUrl}
isLoading={isVideoLoading}
isError={isVideoError}
onRetry={() => void refetchVideo()}
onTimeUpdate={handleTimeUpdate}
onVideoFrame={handleVideoFrame}
onSeeked={handleSeeked}
/>
</div>
);
}

View File

@@ -2,7 +2,6 @@
import {
Activity,
ArrowRight,
CalendarDays,
Clock3,
Gauge,
@@ -10,7 +9,7 @@ import {
Monitor,
UserRound,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import {
Card,
@@ -20,12 +19,20 @@ import {
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { cn } from '@/lib/utils';
import type { TicketOverviewDetail } from '@/types';
import { formatDate } from '@/utils/date';
import { TicketInlineAnalysisVideo } from './TicketInlineAnalysisVideo';
type IconComponent = React.ComponentType<{ className?: string }>;
@@ -105,7 +112,7 @@ export function TicketOverviewCard({
}: {
ticket: TicketOverviewDetail;
}) {
const router = useRouter();
const [isAnalysisOpen, setIsAnalysisOpen] = useState(false);
const videoMetadata = ticket.video_metadata;
const duration =
typeof videoMetadata?.duration_seconds === 'number'
@@ -125,11 +132,9 @@ export function TicketOverviewCard({
const uploaderName = ticket.uploader?.name || null;
const analysisVideoId = ticket.video_id || ticket.video?.id || null;
const videoName = ticket.video?.name || null;
const analysisTitle = videoName || ticket.ticket_name || 'Analysis Video';
const hasVideoMetrics = Boolean(
videoName ||
videoMetadata?.fps ||
duration ||
videoMetadata?.resolution?.label,
videoMetadata?.fps || duration || videoMetadata?.resolution?.label,
);
return (
@@ -178,10 +183,9 @@ export function TicketOverviewCard({
variant="ghost"
size="sm"
className="text-primary hover:bg-primary/10 hover:text-primary"
onClick={() => router.push(`/results/${analysisVideoId}`)}
onClick={() => setIsAnalysisOpen(true)}
>
View Analysis
<ArrowRight className="size-3.5" />
</Button>
</PermissionGuard>
</CardAction>
@@ -210,9 +214,6 @@ export function TicketOverviewCard({
</div>
{hasVideoMetrics ? (
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
{videoName ? (
<VideoMetric icon={Monitor} label={videoName} />
) : null}
{videoMetadata?.fps ? (
<VideoMetric icon={Gauge} label={`${videoMetadata.fps} FPS`} />
) : null}
@@ -229,6 +230,20 @@ export function TicketOverviewCard({
) : null}
</CardContent>
</Card>
<Dialog open={isAnalysisOpen} onOpenChange={setIsAnalysisOpen}>
<DialogContent className="gap-3 p-3 sm:max-w-5xl sm:p-4">
<DialogHeader className="min-w-0 pr-8">
<DialogTitle className="truncate text-left text-base">
{analysisTitle}
</DialogTitle>
<DialogDescription className="truncate text-xs">
Video analysis with annotation overlay
</DialogDescription>
</DialogHeader>
<TicketInlineAnalysisVideo ticket={ticket} />
</DialogContent>
</Dialog>
</div>
);
}