9660cdc21c
Deploy / deploy (push) Successful in 3m23s
Worker 1.7.0 (preview.highQuality, default on) now, after the AE render: - encodes a High quality MP4 from the delivery MOV (full-res H.264 High, CRF 14 capped at 100 Mbps) -> Version.hqFileUrl - uploads the MOV itself -> Version.highResKey/Filename/Size Both best-effort (the preview never fails over them), with a lease keep-alive during the long encode/uploads; the export timeline records what was attached. Player: HQ switch when a High quality source exists; keeps frame, play state, speed and zoom, remembered per browser, falls back to Standard if the file won't load. ProRes itself stays download-only (browser support). Downloads: the client portal's High Res button serves the version's ProRes (client-code filename), falling back to the shot's manual file; staff get a ProRes button on the review page. Housekeeping (lib/render-pipeline/high-res.ts): HQ MP4 + ProRes are kept while a version is latest, client-shared, approved or younger than preview.highResRetentionDays (14); otherwise deleted from storage. Runs per task after finalize and hourly from the reaper; deleting a version or a reslate removes its files. The Standard MP4 is never touched. Also: ProRes/Handles top-bar buttons go icon-only below lg so they don't overlap the version selector. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
433 lines
13 KiB
TypeScript
433 lines
13 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;
|
|
/** Current quality, or null when the version has no High quality source. */
|
|
quality?: "standard" | "high" | null;
|
|
onQualityChange?: (quality: "standard" | "high") => void;
|
|
}
|
|
|
|
export function PlaybackControls({
|
|
videoRef,
|
|
fps,
|
|
isReversing,
|
|
onStepBackward,
|
|
onStepForward,
|
|
onReverse,
|
|
onTogglePlay,
|
|
onToggleFullscreen,
|
|
onAddComment,
|
|
quality = null,
|
|
onQualityChange,
|
|
}: 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>
|
|
|
|
{/* Quality — only when the version has a High quality source */}
|
|
{quality && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn(
|
|
"h-7 px-1.5 font-mono text-[11px] font-bold tracking-wide",
|
|
quality === "high"
|
|
? "text-sky-400 bg-sky-400/10 hover:text-sky-300"
|
|
: "text-zinc-500 hover:text-white"
|
|
)}
|
|
onClick={() => onQualityChange?.(quality === "high" ? "standard" : "high")}
|
|
aria-pressed={quality === "high"}
|
|
aria-label="High quality"
|
|
>
|
|
HQ
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{quality === "high"
|
|
? "High quality — click for Standard (smaller, loads faster)"
|
|
: "Switch to High quality (full resolution, needs a fast connection)"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{/* 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>
|
|
);
|
|
}
|