feat(results): replace video playback with detection image preview and map
This commit is contained in:
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';
|
||||
|
||||
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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user