feat(review): flag handles in the review player (metadata only)
Deploy / deploy (push) Successful in 3m31s
Deploy / deploy (push) Successful in 3m31s
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>
This commit is contained in:
@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getInitials, formatRelativeDate } from "@/lib/utils";
|
||||
import { frameToTimecode } from "@/lib/frame-utils";
|
||||
import { HandleBadge } from "@/components/player/HandleBadge";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
@@ -151,6 +152,7 @@ export function CommentPanel({
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{frameToTimecode(activeCommentFrame, fps)}
|
||||
</span>
|
||||
<HandleBadge frame={activeCommentFrame} />
|
||||
</div>
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
@@ -348,6 +350,7 @@ function CommentThread({
|
||||
>
|
||||
F{String(comment.frameNumber).padStart(4, "0")}
|
||||
</button>
|
||||
<HandleBadge frame={comment.frameNumber} />
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatRelativeDate(comment.createdAt)}
|
||||
</span>
|
||||
|
||||
@@ -16,7 +16,7 @@ interface FrameTimelineProps {
|
||||
export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSeek }: FrameTimelineProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
const { currentFrame, totalFrames } = useReviewStore();
|
||||
const { currentFrame, totalFrames, editRange } = useReviewStore();
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -32,6 +32,42 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
|
||||
ctx.fillStyle = "hsl(0 0% 6%)";
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Slate and handles — under the ruler so ticks still read across them
|
||||
const regions: { from: number; to: number; kind: "slate" | "handle" }[] = [];
|
||||
if (editRange && totalFrames > 0) {
|
||||
const { pictureStart, cutIn, tailStart, pictureEnd } = editRange;
|
||||
if (pictureStart > 0) regions.push({ from: 0, to: pictureStart, kind: "slate" });
|
||||
if (cutIn > pictureStart) regions.push({ from: pictureStart, to: cutIn, kind: "handle" });
|
||||
if (pictureEnd > tailStart) regions.push({ from: tailStart, to: pictureEnd, kind: "handle" });
|
||||
}
|
||||
const xOf = (f: number) => Math.round((f / totalFrames) * W);
|
||||
for (const r of regions) {
|
||||
const x0 = xOf(r.from);
|
||||
const x1 = xOf(r.to);
|
||||
if (x1 <= x0) continue;
|
||||
if (r.kind === "slate") {
|
||||
ctx.fillStyle = "hsl(0 0% 2%)";
|
||||
ctx.fillRect(x0, 0, x1 - x0, H);
|
||||
continue;
|
||||
}
|
||||
ctx.fillStyle = "hsl(38 92% 50% / 0.14)";
|
||||
ctx.fillRect(x0, 0, x1 - x0, H);
|
||||
// Diagonal hatching, clipped to the region
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(x0, 0, x1 - x0, H);
|
||||
ctx.clip();
|
||||
ctx.strokeStyle = "hsl(38 92% 50% / 0.28)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = x0 - H; x < x1; x += 7) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, H);
|
||||
ctx.lineTo(x + H, 0);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Frame ruler ticks
|
||||
if (totalFrames > 0) {
|
||||
const tickInterval = Math.max(1, Math.floor(totalFrames / (W / 40)));
|
||||
@@ -59,6 +95,31 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
|
||||
}
|
||||
}
|
||||
|
||||
// Region labels and cut points, over the ruler
|
||||
for (const r of regions) {
|
||||
const x0 = xOf(r.from);
|
||||
const x1 = xOf(r.to);
|
||||
const label = r.kind === "slate" ? "SLATE" : "HANDLE";
|
||||
ctx.font = "bold 9px monospace";
|
||||
if (x1 - x0 >= ctx.measureText(label).width + 8) {
|
||||
ctx.fillStyle = r.kind === "slate" ? "hsl(0 0% 45%)" : "hsl(38 92% 60%)";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(label, (x0 + x1) / 2, H / 2 + 8);
|
||||
}
|
||||
}
|
||||
if (editRange && totalFrames > 0) {
|
||||
ctx.strokeStyle = "hsl(38 92% 55%)";
|
||||
ctx.lineWidth = 1;
|
||||
for (const f of [editRange.cutIn, editRange.tailStart]) {
|
||||
if (f <= editRange.pictureStart || f >= editRange.pictureEnd) continue;
|
||||
const x = xOf(f) + 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, H);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Annotation markers — amber diamonds at top
|
||||
const seenAnnotationFrames = new Set<number>();
|
||||
annotations.forEach((ann) => {
|
||||
@@ -121,7 +182,7 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
}, [currentFrame, totalFrames, comments, annotations]);
|
||||
}, [currentFrame, totalFrames, comments, annotations, editRange]);
|
||||
|
||||
// Resize observer to match canvas to container
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { isHandleFrame } from "@/lib/handles";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* "HANDLE" tag for a note left on a handle frame, so whoever picks it up can
|
||||
* see at a glance that it's about a frame outside the edit.
|
||||
*/
|
||||
export function HandleBadge({ frame, className }: { frame: number; className?: string }) {
|
||||
const editRange = useReviewStore((s) => s.editRange);
|
||||
if (!isHandleFrame(editRange, frame)) return null;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded border border-amber-400/40 bg-amber-400/10 px-1 py-px font-mono text-[9px] font-bold tracking-wide text-amber-400",
|
||||
className
|
||||
)}
|
||||
title="This frame is a handle — outside the edit"
|
||||
>
|
||||
HANDLE
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import type { FrameRegion } from "@/lib/handles";
|
||||
|
||||
/**
|
||||
* Flags a handle frame on the picture: an amber frame round the player and a
|
||||
* "HANDLE · NOT IN EDIT" tag. Shown only while the playhead is in a handle so
|
||||
* a reviewer can't mistake a glitch there for a problem in the cut.
|
||||
*
|
||||
* Purely a viewing aid over the player — it never reaches the media, passes
|
||||
* every pointer event through, and sits outside the zoom layer so it stays
|
||||
* readable at any zoom.
|
||||
*/
|
||||
export function HandleOverlay({ region }: { region: FrameRegion }) {
|
||||
if (region !== "head" && region !== "tail") return null;
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 ring-4 ring-inset ring-amber-400/80"
|
||||
data-testid="handle-overlay"
|
||||
>
|
||||
<div className="absolute left-1/2 top-3 -translate-x-1/2 whitespace-nowrap rounded bg-amber-400 px-2.5 py-1 font-mono text-[11px] font-bold tracking-wide text-black shadow">
|
||||
{region === "head" ? "HEAD" : "TAIL"} HANDLE · NOT IN EDIT
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
RectangleHorizontal,
|
||||
Scissors,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -74,8 +75,20 @@ export function PlaybackControls({
|
||||
setShowAnnotations,
|
||||
setAspectPresetId,
|
||||
setBlankingMode,
|
||||
editRange,
|
||||
editRangeOnly,
|
||||
setEditRangeOnly,
|
||||
} = useReviewStore();
|
||||
|
||||
// Keeping to the edit, the ends of the transport are the cut points.
|
||||
const keepToEdit = editRangeOnly && !!editRange;
|
||||
const goToFrame = (frame: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.pause();
|
||||
video.currentTime = frame / fps;
|
||||
};
|
||||
|
||||
const handleRateChange = (val: string) => {
|
||||
const rate = parseFloat(val);
|
||||
if (videoRef.current) videoRef.current.playbackRate = rate;
|
||||
@@ -115,13 +128,14 @@ export function PlaybackControls({
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={() => {
|
||||
if (videoRef.current) videoRef.current.currentTime = 0;
|
||||
if (keepToEdit && editRange) goToFrame(editRange.cutIn);
|
||||
else if (videoRef.current) videoRef.current.currentTime = 0;
|
||||
}}
|
||||
>
|
||||
<ChevronFirst className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Go to start</TooltipContent>
|
||||
<TooltipContent>{keepToEdit ? "Go to cut in" : "Go to start"}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
@@ -198,14 +212,15 @@ export function PlaybackControls({
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={() => {
|
||||
if (videoRef.current)
|
||||
if (keepToEdit && editRange) goToFrame(editRange.tailStart - 1);
|
||||
else if (videoRef.current)
|
||||
videoRef.current.currentTime = videoRef.current.duration;
|
||||
}}
|
||||
>
|
||||
<ChevronLast className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Go to end</TooltipContent>
|
||||
<TooltipContent>{keepToEdit ? "Go to cut out" : "Go to end"}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,6 +297,32 @@ export function PlaybackControls({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit range only — only for versions with handles marked */}
|
||||
{editRange && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"text-zinc-400 hover:text-white",
|
||||
editRangeOnly && "text-amber-400 bg-amber-400/10"
|
||||
)}
|
||||
onClick={() => setEditRangeOnly(!editRangeOnly)}
|
||||
aria-pressed={editRangeOnly}
|
||||
aria-label="Edit range only"
|
||||
>
|
||||
<Scissors className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{editRangeOnly
|
||||
? "Playing the edit only — click to include handles"
|
||||
: "Play the edit only (skip handles)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Annotation toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -15,10 +15,12 @@ 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 {
|
||||
@@ -40,6 +42,10 @@ interface ReviewPlayerProps {
|
||||
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>(
|
||||
@@ -55,6 +61,8 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
onAnnotationSaved,
|
||||
saveAnnotationFn,
|
||||
addCommentFn,
|
||||
handles,
|
||||
defaultEditRangeOnly = false,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
@@ -66,6 +74,7 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
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 {
|
||||
@@ -81,8 +90,38 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
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,
|
||||
});
|
||||
@@ -102,6 +141,7 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
setDuration(0);
|
||||
setTotalFrames(0);
|
||||
resetZoom();
|
||||
openedAtCut.current = false;
|
||||
}, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames, resetZoom]);
|
||||
|
||||
// ── Playback state sync ──────────────────────────────────────────────────
|
||||
@@ -121,6 +161,57 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
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]);
|
||||
|
||||
@@ -132,7 +223,7 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
video.currentTime = frameToTime(frame, fps);
|
||||
if (!video.paused) video.pause();
|
||||
},
|
||||
play: () => videoRef.current?.play(),
|
||||
play: () => startPlayback(),
|
||||
pause: () => videoRef.current?.pause(),
|
||||
}));
|
||||
|
||||
@@ -163,11 +254,14 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
video.pause();
|
||||
setIsReversing(true);
|
||||
reverseIntervalRef.current = setInterval(() => {
|
||||
if (!videoRef.current || videoRef.current.currentTime <= 0) {
|
||||
// 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(0, videoRef.current.currentTime - 1 / fps);
|
||||
videoRef.current.currentTime = Math.max(floor, videoRef.current.currentTime - 1 / fps);
|
||||
}, 1000 / fps);
|
||||
}, [fps, stopReverse]);
|
||||
|
||||
@@ -176,11 +270,11 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
if (!video) return;
|
||||
stopReverse();
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
startPlayback();
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [stopReverse]);
|
||||
}, [stopReverse, startPlayback]);
|
||||
|
||||
// ── Keyboard shortcuts (JKL + arrows + space + F) ───────────────────────
|
||||
useEffect(() => {
|
||||
@@ -213,7 +307,7 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
case "l":
|
||||
e.preventDefault();
|
||||
stopReverse();
|
||||
if (videoRef.current?.paused) videoRef.current.play();
|
||||
if (videoRef.current?.paused) startPlayback();
|
||||
break;
|
||||
case "arrowleft":
|
||||
e.preventDefault();
|
||||
@@ -250,11 +344,13 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
stopReverse();
|
||||
};
|
||||
}, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback, zoomBy, resetZoom]);
|
||||
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 = () => {
|
||||
@@ -350,6 +446,9 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Handle flag — drawn over the player, never into the media */}
|
||||
<HandleOverlay region={frameRegion(editRange, currentFrame)} />
|
||||
|
||||
{/* Zoom badge — only while zoomed */}
|
||||
{zoom.zoomed && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Scissors } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { updateVersionHandles } from "@/actions/versions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VersionHandlesButtonProps {
|
||||
versionId: string;
|
||||
handleHead: number | null;
|
||||
handleTail: number | null;
|
||||
pictureStartFrame: number | null;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_HANDLES = 8;
|
||||
|
||||
function toInt(value: string): number | null {
|
||||
if (value.trim() === "") return null;
|
||||
const n = Number(value);
|
||||
return Number.isInteger(n) && n >= 0 ? n : NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-bar control showing a version's handles, and a dialog to set them —
|
||||
* for manual uploads, or to correct a render. Review metadata only.
|
||||
*/
|
||||
export function VersionHandlesButton({
|
||||
versionId,
|
||||
handleHead,
|
||||
handleTail,
|
||||
pictureStartFrame,
|
||||
onSaved,
|
||||
}: VersionHandlesButtonProps) {
|
||||
const marked = (handleHead ?? 0) > 0 || (handleTail ?? 0) > 0;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [includes, setIncludes] = useState(marked);
|
||||
const [head, setHead] = useState(String(handleHead ?? DEFAULT_HANDLES));
|
||||
const [tail, setTail] = useState(String(handleTail ?? DEFAULT_HANDLES));
|
||||
const [slate, setSlate] = useState(pictureStartFrame == null ? "" : String(pictureStartFrame));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const openDialog = () => {
|
||||
setIncludes(marked);
|
||||
setHead(String(handleHead ?? DEFAULT_HANDLES));
|
||||
setTail(String(handleTail ?? DEFAULT_HANDLES));
|
||||
setSlate(pictureStartFrame == null ? "" : String(pictureStartFrame));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const headN = toInt(head);
|
||||
const tailN = toInt(tail);
|
||||
const slateN = toInt(slate);
|
||||
const invalid =
|
||||
(includes && (headN == null || Number.isNaN(headN) || tailN == null || Number.isNaN(tailN))) ||
|
||||
Number.isNaN(slateN);
|
||||
|
||||
const save = async () => {
|
||||
if (invalid) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateVersionHandles({
|
||||
versionId,
|
||||
handleHead: includes ? headN! : 0,
|
||||
handleTail: includes ? tailN! : 0,
|
||||
pictureStartFrame: slateN,
|
||||
});
|
||||
setOpen(false);
|
||||
onSaved?.();
|
||||
} catch {
|
||||
toast({ title: "Couldn't save handles", variant: "destructive" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const label = marked
|
||||
? handleHead === handleTail
|
||||
? `Handles ${handleHead}`
|
||||
: `Handles ${handleHead ?? 0}/${handleTail ?? 0}`
|
||||
: "No handles";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-7 gap-1 text-xs", marked ? "text-amber-400" : "text-muted-foreground")}
|
||||
onClick={openDialog}
|
||||
title="Frames past the cut that aren't in the edit"
|
||||
>
|
||||
<Scissors className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Handles</DialogTitle>
|
||||
<DialogDescription>
|
||||
Frames rendered past the cut at each end. The player marks them “Handle · Not in
|
||||
edit” so reviewers don't flag them. Nothing is burned in.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includes}
|
||||
onChange={(e) => setIncludes(e.target.checked)}
|
||||
className="h-4 w-4 accent-amber-500"
|
||||
/>
|
||||
This version includes handles
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="handle-head" className="text-xs">Head (frames)</Label>
|
||||
<Input
|
||||
id="handle-head"
|
||||
inputMode="numeric"
|
||||
value={head}
|
||||
disabled={!includes}
|
||||
onChange={(e) => setHead(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="handle-tail" className="text-xs">Tail (frames)</Label>
|
||||
<Input
|
||||
id="handle-tail"
|
||||
inputMode="numeric"
|
||||
value={tail}
|
||||
disabled={!includes}
|
||||
onChange={(e) => setTail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="handle-slate" className="text-xs">Slate frames before the picture</Label>
|
||||
<Input
|
||||
id="handle-slate"
|
||||
inputMode="numeric"
|
||||
placeholder="Auto"
|
||||
value={slate}
|
||||
onChange={(e) => setSlate(e.target.value)}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Leave blank to detect it (pipeline previews have a 1-frame slate).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving || invalid}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user