2817c7e815
Deploy / deploy (push) Successful in 3m7s
Points were normalised to the whole player box, but the video is drawn object-contain inside it, so any change in the player's shape (window or panel resize, fullscreen, phone rotation, the annotation toolbar opening) moved the picture under the marks. The canvas now sits exactly over the displayed picture (same letterbox measurement as AspectOverlay), so points are fractions of the image and new saves are tagged version "2.0". Legacy "1.0" annotations are mapped onto the picture at load time from their stored canvasWidth/canvasHeight and the video's intrinsic size; no data migration. The save action now accepts both versions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useRef,
|
|
useEffect,
|
|
useState,
|
|
useCallback,
|
|
useMemo,
|
|
forwardRef,
|
|
useImperativeHandle,
|
|
} from "react";
|
|
import { useReviewStore } from "@/hooks/use-review-player";
|
|
import { AnnotationCanvas } from "@/components/annotations/AnnotationCanvas";
|
|
import { AnnotationTools } from "@/components/annotations/AnnotationTools";
|
|
import { AspectOverlay } from "./AspectOverlay";
|
|
import { FrameTimeline } from "./FrameTimeline";
|
|
import { PlaybackControls } from "./PlaybackControls";
|
|
import { useZoomPan } from "@/hooks/use-zoom-pan";
|
|
import { Minus, Plus } from "lucide-react";
|
|
import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils";
|
|
import { cn } from "@/lib/utils";
|
|
import type { CommentWithReplies } from "@/types";
|
|
|
|
export interface ReviewPlayerRef {
|
|
seekToFrame: (frame: number) => void;
|
|
play: () => void;
|
|
pause: () => void;
|
|
}
|
|
|
|
interface ReviewPlayerProps {
|
|
videoUrl: string;
|
|
versionId: string;
|
|
fps?: number;
|
|
comments?: CommentWithReplies[];
|
|
annotations?: unknown[];
|
|
className?: string;
|
|
onAddComment?: (frameNumber: number, timestamp: number) => void;
|
|
onAnnotationSaved?: (frameNumber: number) => void;
|
|
/** Override the default server action used to persist annotations (e.g. for client-token routes) */
|
|
saveAnnotationFn?: (data: { versionId: string; frameNumber: number; drawingData: unknown; color: string }) => Promise<void>;
|
|
/** Override the default server action used to create the companion comment */
|
|
addCommentFn?: (data: { versionId: string; frameNumber: number; timestamp: number; text: string }) => Promise<void>;
|
|
}
|
|
|
|
export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
|
function ReviewPlayer(
|
|
{
|
|
videoUrl,
|
|
versionId,
|
|
fps = 24,
|
|
comments = [],
|
|
annotations,
|
|
className,
|
|
onAddComment,
|
|
onAnnotationSaved,
|
|
saveAnnotationFn,
|
|
addCommentFn,
|
|
},
|
|
ref
|
|
) {
|
|
// Stabilise the annotations reference so AnnotationCanvas's sync effect
|
|
// only fires when the actual content changes, not on every parent re-render.
|
|
const stableAnnotations = useMemo(() => annotations ?? [], [annotations]);
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const stageRef = useRef<HTMLDivElement>(null);
|
|
const zoomLayerRef = useRef<HTMLDivElement>(null);
|
|
const reverseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const [isReversing, setIsReversing] = useState(false);
|
|
|
|
const {
|
|
isPlaying,
|
|
currentFrame,
|
|
isAnnotating,
|
|
showAnnotations,
|
|
aspectPresetId,
|
|
blankingMode,
|
|
setPlaying,
|
|
setCurrentFrame,
|
|
setCurrentTime,
|
|
setDuration,
|
|
setFps,
|
|
setTotalFrames,
|
|
} = useReviewStore();
|
|
|
|
const zoom = useZoomPan(stageRef, zoomLayerRef, {
|
|
gesturesEnabled: !isAnnotating,
|
|
});
|
|
const { reset: resetZoom, zoomBy } = zoom;
|
|
|
|
// Initialize player config from props
|
|
useEffect(() => {
|
|
setFps(fps);
|
|
}, [fps, setFps]);
|
|
|
|
// Reset playback state when the source changes so the timeline and
|
|
// timecode don't briefly show the previous clip's values while the new
|
|
// video is loading its metadata.
|
|
useEffect(() => {
|
|
setCurrentFrame(0);
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
setTotalFrames(0);
|
|
resetZoom();
|
|
}, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames, resetZoom]);
|
|
|
|
// ── Playback state sync ──────────────────────────────────────────────────
|
|
const handleTimeUpdate = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
const frame = timeToFrame(video.currentTime, fps);
|
|
setCurrentFrame(frame);
|
|
setCurrentTime(video.currentTime);
|
|
}, [fps, setCurrentFrame, setCurrentTime]);
|
|
|
|
const handleLoadedMetadata = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
const frames = durationToFrameCount(video.duration, fps);
|
|
setDuration(video.duration);
|
|
setTotalFrames(frames);
|
|
}, [fps, setDuration, setTotalFrames]);
|
|
|
|
const handlePlay = useCallback(() => setPlaying(true), [setPlaying]);
|
|
const handlePause = useCallback(() => setPlaying(false), [setPlaying]);
|
|
|
|
// ── Exposed ref API ──────────────────────────────────────────────────────
|
|
useImperativeHandle(ref, () => ({
|
|
seekToFrame: (frame: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.currentTime = frameToTime(frame, fps);
|
|
if (!video.paused) video.pause();
|
|
},
|
|
play: () => videoRef.current?.play(),
|
|
pause: () => videoRef.current?.pause(),
|
|
}));
|
|
|
|
// ── Frame step functions ─────────────────────────────────────────────────
|
|
const stepFrame = useCallback(
|
|
(delta: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.pause();
|
|
stopReverse();
|
|
const newTime = Math.max(0, Math.min(video.duration, video.currentTime + delta / fps));
|
|
video.currentTime = newTime;
|
|
},
|
|
[fps]
|
|
);
|
|
|
|
const stopReverse = useCallback(() => {
|
|
if (reverseIntervalRef.current) {
|
|
clearInterval(reverseIntervalRef.current);
|
|
reverseIntervalRef.current = null;
|
|
}
|
|
setIsReversing(false);
|
|
}, []);
|
|
|
|
const startReverse = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.pause();
|
|
setIsReversing(true);
|
|
reverseIntervalRef.current = setInterval(() => {
|
|
if (!videoRef.current || videoRef.current.currentTime <= 0) {
|
|
stopReverse();
|
|
return;
|
|
}
|
|
videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 1 / fps);
|
|
}, 1000 / fps);
|
|
}, [fps, stopReverse]);
|
|
|
|
const togglePlayback = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
stopReverse();
|
|
if (video.paused) {
|
|
video.play();
|
|
} else {
|
|
video.pause();
|
|
}
|
|
}, [stopReverse]);
|
|
|
|
// ── Keyboard shortcuts (JKL + arrows + space + F) ───────────────────────
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
if (
|
|
target.tagName === "INPUT" ||
|
|
target.tagName === "TEXTAREA" ||
|
|
target.isContentEditable
|
|
)
|
|
return;
|
|
|
|
switch (e.key.toLowerCase()) {
|
|
case "j":
|
|
e.preventDefault();
|
|
if (isReversing) {
|
|
stopReverse();
|
|
} else {
|
|
startReverse();
|
|
}
|
|
break;
|
|
case "k":
|
|
e.preventDefault();
|
|
if (isReversing) {
|
|
stopReverse();
|
|
} else {
|
|
togglePlayback();
|
|
}
|
|
break;
|
|
case "l":
|
|
e.preventDefault();
|
|
stopReverse();
|
|
if (videoRef.current?.paused) videoRef.current.play();
|
|
break;
|
|
case "arrowleft":
|
|
e.preventDefault();
|
|
stepFrame(-1);
|
|
break;
|
|
case "arrowright":
|
|
e.preventDefault();
|
|
stepFrame(1);
|
|
break;
|
|
case " ":
|
|
e.preventDefault();
|
|
stopReverse();
|
|
togglePlayback();
|
|
break;
|
|
case "f":
|
|
e.preventDefault();
|
|
toggleFullscreen();
|
|
break;
|
|
case "=":
|
|
case "+":
|
|
e.preventDefault();
|
|
zoomBy(1.25);
|
|
break;
|
|
case "-":
|
|
case "_":
|
|
e.preventDefault();
|
|
zoomBy(0.8);
|
|
break;
|
|
case "0":
|
|
e.preventDefault();
|
|
resetZoom();
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
stopReverse();
|
|
};
|
|
}, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback, zoomBy, resetZoom]);
|
|
|
|
// ── Fullscreen ───────────────────────────────────────────────────────────
|
|
const toggleFullscreen = () => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
if (!document.fullscreenElement) {
|
|
el.requestFullscreen().catch(() => {});
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
};
|
|
|
|
const handleSeek = useCallback(
|
|
(frame: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
stopReverse();
|
|
video.currentTime = frameToTime(frame, fps);
|
|
video.pause();
|
|
},
|
|
[fps, stopReverse]
|
|
);
|
|
|
|
const handleAddComment = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.pause();
|
|
onAddComment?.(currentFrame, video.currentTime);
|
|
}, [currentFrame, onAddComment]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
"review-player-container flex flex-col bg-black select-none h-full",
|
|
className
|
|
)}
|
|
>
|
|
{/* Video — tap anywhere to play/pause (pointer-events pass through canvas when not annotating).
|
|
Wheel / pinch zooms, drag pans when zoomed; a drag never counts as a tap. */}
|
|
<div
|
|
ref={stageRef}
|
|
className="relative flex-1 min-h-0 overflow-hidden"
|
|
style={zoom.stageStyle}
|
|
{...zoom.stageHandlers}
|
|
onClick={
|
|
!isAnnotating
|
|
? () => {
|
|
if (!zoom.consumeDrag()) togglePlayback();
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{/* Zoomed as one layer so blanking and annotations stay pinned to the
|
|
picture — annotation coords are normalised from the canvas's
|
|
transformed bounding rect, so drawing while zoomed lands correctly. */}
|
|
<div
|
|
ref={zoomLayerRef}
|
|
className="absolute inset-0 origin-top-left will-change-transform"
|
|
>
|
|
<video
|
|
ref={videoRef}
|
|
src={videoUrl}
|
|
className="block w-full h-full object-contain"
|
|
onTimeUpdate={handleTimeUpdate}
|
|
onLoadedMetadata={handleLoadedMetadata}
|
|
onPlay={handlePlay}
|
|
onPause={handlePause}
|
|
preload="metadata"
|
|
playsInline
|
|
/>
|
|
|
|
{/* Delivery blanking — under the annotation canvas, so a reviewer
|
|
can still draw over a blanked area if they need to */}
|
|
<AspectOverlay
|
|
presetId={aspectPresetId}
|
|
mode={blankingMode}
|
|
videoRef={videoRef}
|
|
/>
|
|
|
|
{/* Annotation canvas overlay */}
|
|
<AnnotationCanvas
|
|
versionId={versionId}
|
|
videoRef={videoRef}
|
|
frameNumber={currentFrame}
|
|
fps={fps}
|
|
isAnnotating={isAnnotating && !isPlaying}
|
|
showAnnotations={showAnnotations}
|
|
existingAnnotations={stableAnnotations}
|
|
onAnnotationSaved={onAnnotationSaved}
|
|
saveAnnotationFn={saveAnnotationFn as any}
|
|
addCommentFn={addCommentFn}
|
|
/>
|
|
</div>
|
|
|
|
{/* Zoom badge — only while zoomed */}
|
|
{zoom.zoomed && (
|
|
<div
|
|
className="absolute top-3 right-3 flex items-center gap-0.5 rounded bg-black/70 p-0.5 text-xs text-white"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="rounded p-1.5 hover:bg-white/15"
|
|
onClick={() => zoomBy(0.8)}
|
|
aria-label="Zoom out"
|
|
>
|
|
<Minus className="h-3.5 w-3.5" />
|
|
</button>
|
|
<span className="w-10 text-center font-mono tabular-nums">
|
|
{Math.round(zoom.scale * 100)}%
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="rounded p-1.5 hover:bg-white/15"
|
|
onClick={() => zoomBy(1.25)}
|
|
aria-label="Zoom in"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded px-2 py-1 hover:bg-white/15"
|
|
onClick={resetZoom}
|
|
>
|
|
Fit
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* JKL hint overlay — shown briefly when reversing */}
|
|
{isReversing && (
|
|
<div className="absolute top-3 left-3 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white">
|
|
◄◄ REVERSE
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Annotation Tools (visible when annotation mode is active) */}
|
|
<AnnotationTools />
|
|
|
|
{/* Frame Timeline */}
|
|
<FrameTimeline
|
|
fps={fps}
|
|
comments={comments}
|
|
annotations={annotations as { frameNumber: number }[]}
|
|
videoRef={videoRef}
|
|
onSeek={handleSeek}
|
|
/>
|
|
|
|
{/* Playback Controls */}
|
|
<PlaybackControls
|
|
videoRef={videoRef}
|
|
fps={fps}
|
|
isReversing={isReversing}
|
|
onStepBackward={() => stepFrame(-1)}
|
|
onStepForward={() => stepFrame(1)}
|
|
onReverse={isReversing ? stopReverse : startReverse}
|
|
onTogglePlay={togglePlayback}
|
|
onToggleFullscreen={toggleFullscreen}
|
|
onAddComment={handleAddComment}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
);
|