75f9258b69
Deploy / deploy (push) Successful in 5m39s
A shot is reviewed at the framing it was rendered at but usually delivered cropped, so add a dropdown beside the playback-speed control that lays delivery blanking over the video: 2.39 through 9:16, widest to tallest, with "No blanking" as the default. Purely a viewing aid — nothing is written to the review, and the video element is untouched. The geometry is the awkward part. The video is object-contain, so the picture is letterboxed inside the element and bars drawn against the element's edges would be wrong on any panel whose shape does not match the source. letterboxRect works out where the picture is actually painted and the bars are positioned against that, kept current by a ResizeObserver and a loadedmetadata listener so resizes and fullscreen stay correct. Ratios within half a pixel of the source draw nothing rather than a hairline of rounding error. Two modes, since blanking and crop bars are different jobs: solid black hides the crop the way the master will, guide dims it so the excluded area is still readable. The overlay sits under the annotation canvas — deliberately no z-index, since the canvas sets none either and DOM order alone gives marks drawn over a blanked area the right precedence. Verified against real browser layout, not just unit tests: the computed picture rect lands exactly on the image edges across three panel shapes, and the visible area measures the requested ratio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
346 lines
11 KiB
TypeScript
346 lines
11 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 { 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 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();
|
|
|
|
// 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);
|
|
}, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames]);
|
|
|
|
// ── 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;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
stopReverse();
|
|
};
|
|
}, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback]);
|
|
|
|
// ── 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) */}
|
|
<div
|
|
className="relative flex-1 min-h-0 overflow-hidden"
|
|
onClick={!isAnnotating ? togglePlayback : undefined}
|
|
>
|
|
<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}
|
|
frameNumber={currentFrame}
|
|
fps={fps}
|
|
isAnnotating={isAnnotating && !isPlaying}
|
|
showAnnotations={showAnnotations}
|
|
existingAnnotations={stableAnnotations}
|
|
onAnnotationSaved={onAnnotationSaved}
|
|
saveAnnotationFn={saveAnnotationFn as any}
|
|
addCommentFn={addCommentFn}
|
|
/>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|
|
);
|