Files
vfxreview/components/player/PlaybackControls.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

400 lines
12 KiB
TypeScript

"use client";
import { RefObject } from "react";
import {
Play,
Pause,
SkipBack,
SkipForward,
ChevronFirst,
ChevronLast,
Maximize2,
MessageSquarePlus,
Pencil,
Eye,
EyeOff,
RectangleHorizontal,
Scissors,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useReviewStore } from "@/hooks/use-review-player";
import { frameToTimecode } from "@/lib/frame-utils";
import { ASPECT_OFF, ASPECT_PRESETS } from "@/lib/aspect-overlays";
import { cn } from "@/lib/utils";
const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
interface PlaybackControlsProps {
videoRef: RefObject<HTMLVideoElement | null>;
fps: number;
isReversing: boolean;
onStepBackward: () => void;
onStepForward: () => void;
onReverse: () => void;
onTogglePlay: () => void;
onToggleFullscreen: () => void;
onAddComment: () => void;
}
export function PlaybackControls({
videoRef,
fps,
isReversing,
onStepBackward,
onStepForward,
onReverse,
onTogglePlay,
onToggleFullscreen,
onAddComment,
}: PlaybackControlsProps) {
const {
isPlaying,
currentFrame,
currentTime,
totalFrames,
playbackRate,
isAnnotating,
showAnnotations,
aspectPresetId,
blankingMode,
setPlaybackRate,
setAnnotating,
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;
setPlaybackRate(rate);
};
const blankingOn = aspectPresetId !== ASPECT_OFF;
const timecode = frameToTimecode(currentFrame, fps);
return (
<div className="flex items-center gap-2 bg-black/90 px-3 py-2 border-t border-white/5" style={{ touchAction: "manipulation" }}>
{/* Frame info */}
<div className="flex items-center gap-3 font-mono text-xs text-zinc-300 min-w-0">
<span className="hidden sm:block text-zinc-500">
{timecode}
</span>
<span className="text-white font-semibold">
F{String(currentFrame).padStart(4, "0")}
</span>
<span className="text-zinc-600 hidden md:block">
/ {totalFrames}
</span>
</div>
{/* Divider */}
<div className="h-5 w-px bg-white/10 mx-1" />
{/* Transport Controls */}
<div className="flex items-center gap-1">
{/* Go to start + Reverse — hidden on mobile */}
<div className="hidden sm:contents">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={() => {
if (keepToEdit && editRange) goToFrame(editRange.cutIn);
else if (videoRef.current) videoRef.current.currentTime = 0;
}}
>
<ChevronFirst className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{keepToEdit ? "Go to cut in" : "Go to start"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className={cn(
"text-zinc-400 hover:text-white",
isReversing && "text-amber-400 bg-amber-400/10"
)}
onClick={onReverse}
>
<SkipBack className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Reverse (J)</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={onStepBackward}
>
<SkipBack className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Step back (←)</TooltipContent>
</Tooltip>
{/* Play/Pause — slightly larger */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-white hover:text-white hover:bg-white/10 h-9 w-9"
onClick={onTogglePlay}
>
{isPlaying ? (
<Pause className="h-5 w-5 fill-current" />
) : (
<Play className="h-5 w-5 fill-current" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Play / Pause (K or Space)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={onStepForward}
>
<SkipForward className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Step forward (→)</TooltipContent>
</Tooltip>
{/* Go to end — hidden on mobile */}
<div className="hidden sm:contents">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={() => {
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>{keepToEdit ? "Go to cut out" : "Go to end"}</TooltipContent>
</Tooltip>
</div>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Right Controls */}
<div className="flex items-center gap-2">
{/* Playback speed — hidden on mobile */}
<div className="hidden sm:contents">
<Select value={String(playbackRate)} onValueChange={handleRateChange}>
<SelectTrigger className="h-7 w-16 text-xs border-0 bg-white/5 text-zinc-300 px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLAYBACK_RATES.map((r) => (
<SelectItem key={r} value={String(r)} className="text-xs">
{r}x
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Delivery blanking — hidden on mobile */}
<div className="hidden sm:contents">
<Select value={aspectPresetId} onValueChange={setAspectPresetId}>
<SelectTrigger
className={cn(
"h-7 w-24 text-xs border-0 bg-white/5 px-2",
blankingOn ? "text-amber-400" : "text-zinc-300"
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ASPECT_OFF} className="text-xs">
No blanking
</SelectItem>
<SelectSeparator />
{ASPECT_PRESETS.map((p) => (
<SelectItem key={p.id} value={p.id} className="text-xs">
<span className="font-mono">{p.label}</span>
<span className="ml-2 text-zinc-500">{p.note}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{blankingOn && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className={cn(
"text-zinc-400 hover:text-white",
blankingMode === "guide" && "text-amber-400 bg-amber-400/10"
)}
onClick={() =>
setBlankingMode(blankingMode === "solid" ? "guide" : "solid")
}
>
<RectangleHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{blankingMode === "solid"
? "Dim the crop instead of hiding it"
: "Hide the crop behind solid bars"}
</TooltipContent>
</Tooltip>
)}
</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>
<Button
variant="ghost"
size="icon-sm"
className={cn(
"text-zinc-400 hover:text-white",
isAnnotating && "text-amber-400 bg-amber-400/10"
)}
onClick={() => setAnnotating(!isAnnotating)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{isAnnotating ? "Stop drawing" : "Draw annotation"}
</TooltipContent>
</Tooltip>
{/* Show/hide annotations */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={() => setShowAnnotations(!showAnnotations)}
>
{showAnnotations ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{showAnnotations ? "Hide annotations" : "Show annotations"}
</TooltipContent>
</Tooltip>
{/* Add Comment */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-primary"
onClick={onAddComment}
>
<MessageSquarePlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add comment at frame {currentFrame}</TooltipContent>
</Tooltip>
{/* Fullscreen */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={onToggleFullscreen}
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Fullscreen (F)</TooltipContent>
</Tooltip>
</div>
</div>
);
}