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

@@ -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>
);
}