Files
vfxreview/components/player/ReviewPlayer.tsx
T
twotalesanimation 2e9fa45580
Deploy / deploy (push) Successful in 3m31s
feat(review): flag handles in the review player (metadata only)
Renders run 8 frames past the cut at each end. Directors were flagging
glitches in unfixed handles as shot problems, so the player now marks
them — without trimming or burning anything into the EXRs/MOV/MP4.

- Version.handleHead/handleTail/pictureStartFrame, Export.handleHead/
  handleTail; migration backfills pipeline exports and their preview
  versions from the manifest's handleFrames (else 8)
- AE panel: "Includes handles [8] frames each end" -> manifest.review;
  older manifests fall back to source.handleFrames
- finalize copies handles onto the Version; worker 1.6.4 reports the
  head slate length (pictureStartSeconds), else the player infers it
  as MP4 frames - EXR frameCount
- Player: hatched HANDLE / SLATE regions and cut marks on the timeline,
  "HEAD/TAIL HANDLE · NOT IN EDIT" overlay on handle frames, HANDLE badge
  on notes, "Edit range only" toggle (default on in the client portal)
- Staff Handles editor on the review page for manual uploads/corrections

Also fixes two player bugs found while testing: timeToFrame floored
float error (every seek landed a frame early), and J/reverse never ran
(the keyboard effect's cleanup stopped it on the next render).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:07:29 +02:00

524 lines
18 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 { HandleOverlay } from "./HandleOverlay";
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 { computeEditRange, frameRegion, type VersionHandles } from "@/lib/handles";
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>;
/** The version's handle metadata — frames past the cut get flagged. */
handles?: VersionHandles | null;
/** Start with playback kept to the edit (the client portal's default). */
defaultEditRangeOnly?: boolean;
}
export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
function ReviewPlayer(
{
videoUrl,
versionId,
fps = 24,
comments = [],
annotations,
className,
onAddComment,
onAnnotationSaved,
saveAnnotationFn,
addCommentFn,
handles,
defaultEditRangeOnly = false,
},
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 openedAtCut = useRef(false);
const [isReversing, setIsReversing] = useState(false);
const {
isPlaying,
currentFrame,
isAnnotating,
showAnnotations,
aspectPresetId,
blankingMode,
setPlaying,
setCurrentFrame,
setCurrentTime,
setDuration,
setFps,
setTotalFrames,
totalFrames,
editRange,
editRangeOnly,
setEditRange,
setEditRangeOnly,
} = useReviewStore();
// ── Handles ──────────────────────────────────────────────────────────────
// Where the cut sits in this media. Recomputed from primitives so a parent
// re-render with a fresh `handles` object does not churn the store.
const handleHead = handles?.handleHead ?? null;
const handleTail = handles?.handleTail ?? null;
const pictureStartFrame = handles?.pictureStartFrame ?? null;
const handleFrameCount = handles?.frameCount ?? null;
useEffect(() => {
setEditRange(
computeEditRange(
{ handleHead, handleTail, pictureStartFrame, frameCount: handleFrameCount },
totalFrames
)
);
}, [handleHead, handleTail, pictureStartFrame, handleFrameCount, totalFrames, setEditRange]);
useEffect(() => () => setEditRange(null), [setEditRange]);
useEffect(() => {
setEditRangeOnly(defaultEditRangeOnly);
}, [defaultEditRangeOnly, setEditRangeOnly]);
// Read inside event handlers / intervals without re-binding them.
const keepToEdit = useRef<{ range: typeof editRange; on: boolean }>({ range: null, on: false });
keepToEdit.current = { range: editRange, on: editRangeOnly && !!editRange };
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();
openedAtCut.current = false;
}, [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]);
/**
* Play — when keeping to the edit, from the cut in-point if the playhead
* is outside the cut (slate, a handle, or parked on the last cut frame).
*/
const startPlayback = useCallback(() => {
const video = videoRef.current;
if (!video) return;
const { range, on } = keepToEdit.current;
if (on && range) {
const frame = timeToFrame(video.currentTime, fps);
if (frame < range.cutIn || frame >= range.tailStart - 1) {
video.currentTime = frameToTime(range.cutIn, fps);
}
}
// A pause() or power-saving pause before playback starts rejects this;
// that's expected, not an error.
video.play().catch(() => {});
}, [fps]);
// Keeping to the edit: stop on the last frame of the cut rather than
// playing into the tail handle. Checked every animation frame — the
// video's own timeupdate only fires ~4x a second.
useEffect(() => {
if (!isPlaying || !editRangeOnly || !editRange) return;
const lastCutFrame = editRange.tailStart - 1;
let raf = 0;
const tick = () => {
const video = videoRef.current;
if (!video || video.paused) return;
if (timeToFrame(video.currentTime, fps) >= editRange.tailStart) {
video.pause();
video.currentTime = frameToTime(lastCutFrame, fps);
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [isPlaying, editRangeOnly, editRange, fps]);
// Keeping to the edit: open on the first frame of the cut rather than on
// the slate. Once per video, so it never moves the playhead afterwards.
useEffect(() => {
const video = videoRef.current;
if (!video || openedAtCut.current || !editRangeOnly || !editRange) return;
openedAtCut.current = true;
if (video.paused && timeToFrame(video.currentTime, fps) < editRange.cutIn) {
video.currentTime = frameToTime(editRange.cutIn, fps);
}
}, [editRange, editRangeOnly, fps]);
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: () => startPlayback(),
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(() => {
// Keeping to the edit, reverse stops at the cut in-point.
const { range, on } = keepToEdit.current;
const floor = on && range ? frameToTime(range.cutIn, fps) : 0;
if (!videoRef.current || videoRef.current.currentTime <= floor) {
stopReverse();
return;
}
videoRef.current.currentTime = Math.max(floor, videoRef.current.currentTime - 1 / fps);
}, 1000 / fps);
}, [fps, stopReverse]);
const togglePlayback = useCallback(() => {
const video = videoRef.current;
if (!video) return;
stopReverse();
if (video.paused) {
startPlayback();
} else {
video.pause();
}
}, [stopReverse, startPlayback]);
// ── 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) startPlayback();
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);
}, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback, startPlayback, zoomBy, resetZoom]);
// Stop a running reverse only on unmount. This used to live in the
// cleanup above, which re-runs whenever isReversing changes — so starting
// reverse immediately stopped it, and J never did anything.
useEffect(() => stopReverse, [stopReverse]);
// ── 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>
{/* Handle flag — drawn over the player, never into the media */}
<HandleOverlay region={frameRegion(editRange, currentFrame)} />
{/* 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>
);
}
);