2e9fa45580
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>
400 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|