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>
359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { RefObject } from "react";
|
|
import {
|
|
Play,
|
|
Pause,
|
|
SkipBack,
|
|
SkipForward,
|
|
ChevronFirst,
|
|
ChevronLast,
|
|
Maximize2,
|
|
MessageSquarePlus,
|
|
Pencil,
|
|
Eye,
|
|
EyeOff,
|
|
RectangleHorizontal,
|
|
} 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,
|
|
} = useReviewStore();
|
|
|
|
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 (videoRef.current) videoRef.current.currentTime = 0;
|
|
}}
|
|
>
|
|
<ChevronFirst className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>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 (videoRef.current)
|
|
videoRef.current.currentTime = videoRef.current.duration;
|
|
}}
|
|
>
|
|
<ChevronLast className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>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>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|