feat(review): flag handles in the review player (metadata only)
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:
twotalesanimation
2026-09-22 15:07:29 +02:00
parent 2817c7e815
commit 2e9fa45580
27 changed files with 938 additions and 23 deletions
+10
View File
@@ -1179,6 +1179,16 @@ Not every export is framed like its review. A deliverable rendered unscaled or w
With no earlier export to inherit from, the export still reaches READY_FOR_QC and the timeline records that it has no review media. With no earlier export to inherit from, the export still reaches READY_FOR_QC and the timeline records that it has no review media.
### 9.5 Handles in review (metadata only)
Renders run past the cut at each end (8 + 8 by house convention). Handles ship with the EXRs and stay in the preview, but reviewers must be able to tell them from the cut, or a glitch in a handle gets flagged as a problem in the shot. **Nothing is trimmed or burned in** — this is deliberately not the reverted picture-lock trim (which desynced the overlay timecode); the render and every delivered file are unchanged.
- **Panel:** "Includes handles [8] frames each end" (default on, prefilled from Handle Frames) sends `manifest.review = { handleHead, handleTail }`; unticked sends 0/0. Manifests without `review` (older panels) fall back to `source.handleFrames` (`manifestHandles`, exports.ts).
- **Export → Version:** `createExport` stores `Export.handleHead/handleTail`; `finalizePreview` copies them onto the Version, plus `pictureStartFrame` = the head slate length from the build's `pictureStartSeconds` (worker ≥ 1.6.4). Left null, the player infers the slate as MP4 frames − `frameCount` (the EXR count), within 0–5 frames.
- **Player** (`lib/handles.ts` → `computeEditRange`): the timeline hatches the handles amber and labels them HANDLE (the slate, SLATE); while the playhead is in a handle the picture gets an amber frame and a "HEAD/TAIL HANDLE · NOT IN EDIT" tag; notes on handle frames carry a HANDLE badge. "Edit range only" (scissors) keeps play, reverse and go-to-start/end inside the cut — on by default in the client portal, off for staff.
- **Staff override:** the review page's Handles button edits a version's head/tail/slate — for manual uploads, or to correct a render.
- **Backfill** (`20260922000000_version_handles`): every existing export takes its manifest's `source.handleFrames` (else 8), and the Versions they produced take the same.
--- ---
## 10. QC Workflow ## 10. QC Workflow
@@ -461,6 +461,9 @@ public sealed class JobExecutor
["frameCount"] = result.Build?.FrameCount, ["frameCount"] = result.Build?.FrameCount,
["fps"] = manifest.Fps, ["fps"] = manifest.Fps,
["fileName"] = Path.GetFileName(result.Mp4Path!), ["fileName"] = Path.GetFileName(result.Mp4Path!),
// Slate length, so the review player can place the handle
// markers exactly instead of inferring it (server ≥ 2026-09-22).
["pictureStartSeconds"] = result.Build?.PictureStartSeconds,
}, },
}); });
@@ -15,7 +15,7 @@
every change that gets published. publish.ps1 appends the git SHA via every change that gets published. publish.ps1 appends the git SHA via
SourceRevisionId, giving "1.1.0+feb1c7b". SourceRevisionId, giving "1.1.0+feb1c7b".
--> -->
<Version>1.6.3</Version> <Version>1.6.4</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+40
View File
@@ -45,6 +45,8 @@ var PANEL_VERSION = "2.6.2";
pipelineStatusText: null, pipelineStatusText: null,
urgentCheckbox: null, urgentCheckbox: null,
skipPreviewCheckbox: null, skipPreviewCheckbox: null,
handlesCheckbox: null,
handleFramesField: null,
submissionNoteField: null, submissionNoteField: null,
qcList: null, qcList: null,
qcNotesField: null, qcNotesField: null,
@@ -3476,6 +3478,9 @@ var PANEL_VERSION = "2.6.2";
CONFIG.bannerImagePath = trimText(fBanner.text); CONFIG.bannerImagePath = trimText(fBanner.text);
CONFIG.artistEmail = trimText(fEmail.text); CONFIG.artistEmail = trimText(fEmail.text);
CONFIG.handleFrames = parseInt(fHandles.text, 10) || 8; CONFIG.handleFrames = parseInt(fHandles.text, 10) || 8;
if (uiState.handleFramesField) {
uiState.handleFramesField.text = String(CONFIG.handleFrames);
}
CONFIG.pathMappings = trimText(fMappings.text); CONFIG.pathMappings = trimText(fMappings.text);
CONFIG.sequenceStart = parseInt(fSeqStart.text, 10) || 1001; CONFIG.sequenceStart = parseInt(fSeqStart.text, 10) || 1001;
saveConfig(); saveConfig();
@@ -4196,7 +4201,23 @@ var PANEL_VERSION = "2.6.2";
return manifest; return manifest;
} }
// Handles the render carries past the cut. Review metadata only: the web
// player marks these frames as "not in edit". Nothing in the comp or the
// render changes — unticked just means the frames are all in the edit.
function reviewHandles() {
var frames;
if (!uiState.handlesCheckbox || !uiState.handlesCheckbox.value) {
return { handleHead: 0, handleTail: 0 };
}
frames = parseInt(uiState.handleFramesField ? uiState.handleFramesField.text : "", 10);
if (isNaN(frames) || frames < 0) {
frames = CONFIG.handleFrames;
}
return { handleHead: frames, handleTail: frames };
}
function submitExport(manifest, urgent, force) { function submitExport(manifest, urgent, force) {
manifest.review = reviewHandles();
var body = { var body = {
manifest: manifest, manifest: manifest,
force: force ? true : false force: force ? true : false
@@ -4803,6 +4824,25 @@ var PANEL_VERSION = "2.6.2";
"un-punched-in EXRs — where a preview rebuilt from these frames\n" + "un-punched-in EXRs — where a preview rebuilt from these frames\n" +
"would be the wrong picture."; "would be the wrong picture.";
var handlesGroup = pipelinePanel.add("group");
handlesGroup.orientation = "row";
handlesGroup.alignChildren = ["left", "center"];
handlesGroup.spacing = 6;
uiState.handlesCheckbox = handlesGroup.add("checkbox", undefined, "Includes handles");
uiState.handlesCheckbox.value = true;
uiState.handleFramesField = handlesGroup.add("edittext", undefined, String(CONFIG.handleFrames));
uiState.handleFramesField.characters = 3;
handlesGroup.add("statictext", undefined, "frames each end");
uiState.handlesCheckbox.helpTip =
"The render runs past the cut at both ends (handles). The review\n" +
"player marks those frames HANDLE / NOT IN EDIT so a glitch in a\n" +
"handle isn't mistaken for a problem in the shot.\n\n" +
"Review-only: nothing is burned in and the EXRs are unchanged.\n" +
"Untick for a render cut exactly to the edit.";
uiState.handlesCheckbox.onClick = function () {
uiState.handleFramesField.enabled = uiState.handlesCheckbox.value;
};
var slateNoteGroup = pipelinePanel.add("group"); var slateNoteGroup = pipelinePanel.add("group");
slateNoteGroup.orientation = "row"; slateNoteGroup.orientation = "row";
slateNoteGroup.alignChildren = ["fill", "top"]; slateNoteGroup.alignChildren = ["fill", "top"];
+32
View File
@@ -259,3 +259,35 @@ export async function getVersionComments(versionId: string) {
orderBy: { frameNumber: "asc" }, orderBy: { frameNumber: "asc" },
}); });
} }
const updateVersionHandlesSchema = z.object({
versionId: z.string().cuid(),
/** 0/0 = no handles (every frame is in the edit). */
handleHead: z.number().int().min(0).max(1000),
handleTail: z.number().int().min(0).max(1000),
/** Slate frames before the picture; null = infer from the media. */
pictureStartFrame: z.number().int().min(0).max(1000).nullable(),
});
/**
* Marks which frames of a version are handles (past the cut at each end), so
* the review player can flag them. Review metadata only — the media and any
* delivered files are untouched. Staff only: a client can't redefine the cut.
*/
export async function updateVersionHandles(data: z.infer<typeof updateVersionHandlesSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (session.user.role === "CLIENT") throw new Error("Forbidden");
const parsed = updateVersionHandlesSchema.parse(data);
const none = parsed.handleHead === 0 && parsed.handleTail === 0;
await db.version.update({
where: { id: parsed.versionId },
data: {
handleHead: none ? null : parsed.handleHead,
handleTail: none ? null : parsed.handleTail,
pictureStartFrame: parsed.pictureStartFrame,
},
});
revalidatePath(`/review/${parsed.versionId}`);
}
@@ -42,6 +42,10 @@ interface PlaylistVersion {
thumbnailUrl: string | null; thumbnailUrl: string | null;
posterUrl: string | null; posterUrl: string | null;
fps: number; fps: number;
frameCount?: number | null;
handleHead?: number | null;
handleTail?: number | null;
pictureStartFrame?: number | null;
approvalStatus: string; approvalStatus: string;
taskTitle: string; taskTitle: string;
} }
@@ -276,6 +280,7 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
fps={activeShot.latestVersion.fps} fps={activeShot.latestVersion.fps}
comments={[]} comments={[]}
annotations={[]} annotations={[]}
handles={activeShot.latestVersion}
/> />
) : ( ) : (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-zinc-600"> <div className="flex-1 flex flex-col items-center justify-center gap-3 text-zinc-600">
+8
View File
@@ -50,6 +50,10 @@ export async function GET(req: NextRequest) {
thumbnailUrl: true, thumbnailUrl: true,
posterUrl: true, posterUrl: true,
fps: true, fps: true,
frameCount: true,
handleHead: true,
handleTail: true,
pictureStartFrame: true,
approvalStatus: true, approvalStatus: true,
mimeType: true, mimeType: true,
createdAt: true, createdAt: true,
@@ -85,6 +89,10 @@ export async function GET(req: NextRequest) {
thumbnailUrl: latest.thumbnailUrl, thumbnailUrl: latest.thumbnailUrl,
posterUrl: latest.posterUrl, posterUrl: latest.posterUrl,
fps: latest.fps, fps: latest.fps,
frameCount: latest.frameCount,
handleHead: latest.handleHead,
handleTail: latest.handleTail,
pictureStartFrame: latest.pictureStartFrame,
approvalStatus: latest.approvalStatus, approvalStatus: latest.approvalStatus,
taskTitle: latest.taskTitle, taskTitle: latest.taskTitle,
}, },
+12 -1
View File
@@ -38,6 +38,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useReviewStore } from "@/hooks/use-review-player"; import { useReviewStore } from "@/hooks/use-review-player";
import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate"; import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate";
import { HandleBadge } from "@/components/player/HandleBadge";
interface Comment { interface Comment {
id: string; id: string;
@@ -58,6 +59,10 @@ interface Version {
fileUrl: string; fileUrl: string;
fps: number; fps: number;
duration: number | null; duration: number | null;
frameCount?: number | null;
handleHead?: number | null;
handleTail?: number | null;
pictureStartFrame?: number | null;
approvalStatus: string; approvalStatus: string;
notes: string | null; notes: string | null;
// Top-level high-res flags (normalised from either shot or task.shot) // Top-level high-res flags (normalised from either shot or task.shot)
@@ -513,6 +518,10 @@ export default function ClientReviewPage({
comments={comments as any} comments={comments as any}
versionId={version.id} versionId={version.id}
onAddComment={handlePlayerAddComment} onAddComment={handlePlayerAddComment}
handles={version}
// The director watches the cut as cut; handles are still one
// toggle away, and flagged whenever the playhead is in one.
defaultEditRangeOnly
saveAnnotationFn={async (data) => { saveAnnotationFn={async (data) => {
const res = await fetch(`/api/client/${token}/annotation`, { const res = await fetch(`/api/client/${token}/annotation`, {
method: "POST", method: "POST",
@@ -644,6 +653,7 @@ export default function ClientReviewPage({
<span className="ml-auto font-mono text-xs text-amber-400/80"> <span className="ml-auto font-mono text-xs text-amber-400/80">
{frameToTimecode(comment.frameNumber, version.fps)} {frameToTimecode(comment.frameNumber, version.fps)}
</span> </span>
<HandleBadge frame={comment.frameNumber} />
</div> </div>
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p> <p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
{comment.canEdit && ( {comment.canEdit && (
@@ -692,8 +702,9 @@ export default function ClientReviewPage({
{commentsOpen && commentFrame !== null ? ( {commentsOpen && commentFrame !== null ? (
<div className="border-t border-zinc-800 p-3 space-y-2"> <div className="border-t border-zinc-800 p-3 space-y-2">
<p className="text-xs text-zinc-500 font-mono"> <p className="flex items-center gap-2 text-xs text-zinc-500 font-mono">
Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)} Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)}
<HandleBadge frame={commentFrame} />
</p> </p>
<Textarea <Textarea
placeholder="Add your note..." placeholder="Add your note..."
@@ -28,6 +28,7 @@ import { submitApproval } from "@/actions/approvals";
import { internallyApproveShot } from "@/actions/shots"; import { internallyApproveShot } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { ShareWithClientButton } from "@/components/versions/ShareWithClientButton"; import { ShareWithClientButton } from "@/components/versions/ShareWithClientButton";
import { VersionHandlesButton } from "@/components/versions/VersionHandlesButton";
import { import {
CheckCircle2, CheckCircle2,
XCircle, XCircle,
@@ -50,6 +51,9 @@ interface ReviewPageClientProps {
fps: number; fps: number;
duration: number | null; duration: number | null;
frameCount: number | null; frameCount: number | null;
handleHead: number | null;
handleTail: number | null;
pictureStartFrame: number | null;
approvalStatus: string; approvalStatus: string;
notes: string | null; notes: string | null;
isClientVisible: boolean; isClientVisible: boolean;
@@ -283,6 +287,20 @@ export function ReviewPageClient({
</DropdownMenu> </DropdownMenu>
</div> </div>
{/* Handles — outside the breadcrumb so it never gets squeezed */}
{userRole !== "CLIENT" && (
<div className="shrink-0">
<VersionHandlesButton
key={version.id}
versionId={version.id}
handleHead={version.handleHead}
handleTail={version.handleTail}
pictureStartFrame={version.pictureStartFrame}
onSaved={() => router.refresh()}
/>
</div>
)}
{/* Approval status */} {/* Approval status */}
<span <span
className={cn( className={cn(
@@ -361,6 +379,7 @@ export function ReviewPageClient({
versionId={version.id} versionId={version.id}
onAddComment={handleAddComment} onAddComment={handleAddComment}
onAnnotationSaved={handleAnnotationSaved} onAnnotationSaved={handleAnnotationSaved}
handles={version}
/> />
</div> </div>
+3
View File
@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getInitials, formatRelativeDate } from "@/lib/utils"; import { getInitials, formatRelativeDate } from "@/lib/utils";
import { frameToTimecode } from "@/lib/frame-utils"; import { frameToTimecode } from "@/lib/frame-utils";
import { HandleBadge } from "@/components/player/HandleBadge";
import { import {
CheckCircle2, CheckCircle2,
Circle, Circle,
@@ -151,6 +152,7 @@ export function CommentPanel({
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{frameToTimecode(activeCommentFrame, fps)} {frameToTimecode(activeCommentFrame, fps)}
</span> </span>
<HandleBadge frame={activeCommentFrame} />
</div> </div>
<Textarea <Textarea
ref={inputRef} ref={inputRef}
@@ -348,6 +350,7 @@ function CommentThread({
> >
F{String(comment.frameNumber).padStart(4, "0")} F{String(comment.frameNumber).padStart(4, "0")}
</button> </button>
<HandleBadge frame={comment.frameNumber} />
<span className="text-xs text-muted-foreground ml-auto"> <span className="text-xs text-muted-foreground ml-auto">
{formatRelativeDate(comment.createdAt)} {formatRelativeDate(comment.createdAt)}
</span> </span>
+63 -2
View File
@@ -16,7 +16,7 @@ interface FrameTimelineProps {
export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSeek }: FrameTimelineProps) { export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSeek }: FrameTimelineProps) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const isDragging = useRef(false); const isDragging = useRef(false);
const { currentFrame, totalFrames } = useReviewStore(); const { currentFrame, totalFrames, editRange } = useReviewStore();
const draw = useCallback(() => { const draw = useCallback(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -32,6 +32,42 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
ctx.fillStyle = "hsl(0 0% 6%)"; ctx.fillStyle = "hsl(0 0% 6%)";
ctx.fillRect(0, 0, W, H); 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 // Frame ruler ticks
if (totalFrames > 0) { if (totalFrames > 0) {
const tickInterval = Math.max(1, Math.floor(totalFrames / (W / 40))); 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 // Annotation markers — amber diamonds at top
const seenAnnotationFrames = new Set<number>(); const seenAnnotationFrames = new Set<number>();
annotations.forEach((ann) => { annotations.forEach((ann) => {
@@ -121,7 +182,7 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
ctx.closePath(); ctx.closePath();
ctx.fill(); ctx.fill();
} }
}, [currentFrame, totalFrames, comments, annotations]); }, [currentFrame, totalFrames, comments, annotations, editRange]);
// Resize observer to match canvas to container // Resize observer to match canvas to container
useEffect(() => { useEffect(() => {
+25
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+45 -4
View File
@@ -14,6 +14,7 @@ import {
Eye, Eye,
EyeOff, EyeOff,
RectangleHorizontal, RectangleHorizontal,
Scissors,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -74,8 +75,20 @@ export function PlaybackControls({
setShowAnnotations, setShowAnnotations,
setAspectPresetId, setAspectPresetId,
setBlankingMode, setBlankingMode,
editRange,
editRangeOnly,
setEditRangeOnly,
} = useReviewStore(); } = 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 handleRateChange = (val: string) => {
const rate = parseFloat(val); const rate = parseFloat(val);
if (videoRef.current) videoRef.current.playbackRate = rate; if (videoRef.current) videoRef.current.playbackRate = rate;
@@ -115,13 +128,14 @@ export function PlaybackControls({
size="icon-sm" size="icon-sm"
className="text-zinc-400 hover:text-white" className="text-zinc-400 hover:text-white"
onClick={() => { 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" /> <ChevronFirst className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Go to start</TooltipContent> <TooltipContent>{keepToEdit ? "Go to cut in" : "Go to start"}</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
@@ -198,14 +212,15 @@ export function PlaybackControls({
size="icon-sm" size="icon-sm"
className="text-zinc-400 hover:text-white" className="text-zinc-400 hover:text-white"
onClick={() => { onClick={() => {
if (videoRef.current) if (keepToEdit && editRange) goToFrame(editRange.tailStart - 1);
else if (videoRef.current)
videoRef.current.currentTime = videoRef.current.duration; videoRef.current.currentTime = videoRef.current.duration;
}} }}
> >
<ChevronLast className="h-4 w-4" /> <ChevronLast className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Go to end</TooltipContent> <TooltipContent>{keepToEdit ? "Go to cut out" : "Go to end"}</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
@@ -282,6 +297,32 @@ export function PlaybackControls({
)} )}
</div> </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 */} {/* Annotation toggle */}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
+110 -11
View File
@@ -15,10 +15,12 @@ import { AnnotationTools } from "@/components/annotations/AnnotationTools";
import { AspectOverlay } from "./AspectOverlay"; import { AspectOverlay } from "./AspectOverlay";
import { FrameTimeline } from "./FrameTimeline"; import { FrameTimeline } from "./FrameTimeline";
import { PlaybackControls } from "./PlaybackControls"; import { PlaybackControls } from "./PlaybackControls";
import { HandleOverlay } from "./HandleOverlay";
import { useZoomPan } from "@/hooks/use-zoom-pan"; import { useZoomPan } from "@/hooks/use-zoom-pan";
import { Minus, Plus } from "lucide-react"; import { Minus, Plus } from "lucide-react";
import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils"; import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { computeEditRange, frameRegion, type VersionHandles } from "@/lib/handles";
import type { CommentWithReplies } from "@/types"; import type { CommentWithReplies } from "@/types";
export interface ReviewPlayerRef { export interface ReviewPlayerRef {
@@ -40,6 +42,10 @@ interface ReviewPlayerProps {
saveAnnotationFn?: (data: { versionId: string; frameNumber: number; drawingData: unknown; color: string }) => Promise<void>; saveAnnotationFn?: (data: { versionId: string; frameNumber: number; drawingData: unknown; color: string }) => Promise<void>;
/** Override the default server action used to create the companion comment */ /** Override the default server action used to create the companion comment */
addCommentFn?: (data: { versionId: string; frameNumber: number; timestamp: number; text: string }) => Promise<void>; 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>( export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
@@ -55,6 +61,8 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
onAnnotationSaved, onAnnotationSaved,
saveAnnotationFn, saveAnnotationFn,
addCommentFn, addCommentFn,
handles,
defaultEditRangeOnly = false,
}, },
ref ref
) { ) {
@@ -66,6 +74,7 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
const stageRef = useRef<HTMLDivElement>(null); const stageRef = useRef<HTMLDivElement>(null);
const zoomLayerRef = useRef<HTMLDivElement>(null); const zoomLayerRef = useRef<HTMLDivElement>(null);
const reverseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const reverseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const openedAtCut = useRef(false);
const [isReversing, setIsReversing] = useState(false); const [isReversing, setIsReversing] = useState(false);
const { const {
@@ -81,8 +90,38 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
setDuration, setDuration,
setFps, setFps,
setTotalFrames, setTotalFrames,
totalFrames,
editRange,
editRangeOnly,
setEditRange,
setEditRangeOnly,
} = useReviewStore(); } = 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, { const zoom = useZoomPan(stageRef, zoomLayerRef, {
gesturesEnabled: !isAnnotating, gesturesEnabled: !isAnnotating,
}); });
@@ -102,6 +141,7 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
setDuration(0); setDuration(0);
setTotalFrames(0); setTotalFrames(0);
resetZoom(); resetZoom();
openedAtCut.current = false;
}, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames, resetZoom]); }, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames, resetZoom]);
// ── Playback state sync ────────────────────────────────────────────────── // ── Playback state sync ──────────────────────────────────────────────────
@@ -121,6 +161,57 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
setTotalFrames(frames); setTotalFrames(frames);
}, [fps, setDuration, setTotalFrames]); }, [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 handlePlay = useCallback(() => setPlaying(true), [setPlaying]);
const handlePause = useCallback(() => setPlaying(false), [setPlaying]); const handlePause = useCallback(() => setPlaying(false), [setPlaying]);
@@ -132,7 +223,7 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
video.currentTime = frameToTime(frame, fps); video.currentTime = frameToTime(frame, fps);
if (!video.paused) video.pause(); if (!video.paused) video.pause();
}, },
play: () => videoRef.current?.play(), play: () => startPlayback(),
pause: () => videoRef.current?.pause(), pause: () => videoRef.current?.pause(),
})); }));
@@ -163,11 +254,14 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
video.pause(); video.pause();
setIsReversing(true); setIsReversing(true);
reverseIntervalRef.current = setInterval(() => { 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(); stopReverse();
return; 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); }, 1000 / fps);
}, [fps, stopReverse]); }, [fps, stopReverse]);
@@ -176,11 +270,11 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
if (!video) return; if (!video) return;
stopReverse(); stopReverse();
if (video.paused) { if (video.paused) {
video.play(); startPlayback();
} else { } else {
video.pause(); video.pause();
} }
}, [stopReverse]); }, [stopReverse, startPlayback]);
// ── Keyboard shortcuts (JKL + arrows + space + F) ─────────────────────── // ── Keyboard shortcuts (JKL + arrows + space + F) ───────────────────────
useEffect(() => { useEffect(() => {
@@ -213,7 +307,7 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
case "l": case "l":
e.preventDefault(); e.preventDefault();
stopReverse(); stopReverse();
if (videoRef.current?.paused) videoRef.current.play(); if (videoRef.current?.paused) startPlayback();
break; break;
case "arrowleft": case "arrowleft":
e.preventDefault(); e.preventDefault();
@@ -250,11 +344,13 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => { return () => window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keydown", handleKeyDown); }, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback, startPlayback, zoomBy, resetZoom]);
stopReverse();
}; // Stop a running reverse only on unmount. This used to live in the
}, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback, zoomBy, resetZoom]); // cleanup above, which re-runs whenever isReversing changes — so starting
// reverse immediately stopped it, and J never did anything.
useEffect(() => stopReverse, [stopReverse]);
// ── Fullscreen ─────────────────────────────────────────────────────────── // ── Fullscreen ───────────────────────────────────────────────────────────
const toggleFullscreen = () => { const toggleFullscreen = () => {
@@ -350,6 +446,9 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
/> />
</div> </div>
{/* Handle flag — drawn over the player, never into the media */}
<HandleOverlay region={frameRegion(editRange, currentFrame)} />
{/* Zoom badge — only while zoomed */} {/* Zoom badge — only while zoomed */}
{zoom.zoomed && ( {zoom.zoomed && (
<div <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&apos;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>
</>
);
}
+12
View File
@@ -1,6 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import type { AnnotationTool } from "@/types"; import type { AnnotationTool } from "@/types";
import { ASPECT_OFF, type BlankingMode } from "@/lib/aspect-overlays"; import { ASPECT_OFF, type BlankingMode } from "@/lib/aspect-overlays";
import type { EditRange } from "@/lib/handles";
interface ReviewPlayerStore { interface ReviewPlayerStore {
// Playback // Playback
@@ -28,6 +29,11 @@ interface ReviewPlayerStore {
fps: number; fps: number;
totalFrames: number; totalFrames: number;
// Handles — where the cut sits in the media (null: no handles marked), and
// whether playback keeps to it. Viewing aids only; the media is untouched.
editRange: EditRange | null;
editRangeOnly: boolean;
// Actions // Actions
setPlaying: (playing: boolean) => void; setPlaying: (playing: boolean) => void;
setCurrentFrame: (frame: number) => void; setCurrentFrame: (frame: number) => void;
@@ -46,6 +52,8 @@ interface ReviewPlayerStore {
setBlankingMode: (mode: BlankingMode) => void; setBlankingMode: (mode: BlankingMode) => void;
setFps: (fps: number) => void; setFps: (fps: number) => void;
setTotalFrames: (frames: number) => void; setTotalFrames: (frames: number) => void;
setEditRange: (range: EditRange | null) => void;
setEditRangeOnly: (on: boolean) => void;
reset: () => void; reset: () => void;
} }
@@ -67,6 +75,8 @@ const initialState = {
blankingMode: "solid" as BlankingMode, blankingMode: "solid" as BlankingMode,
fps: 24, fps: 24,
totalFrames: 0, totalFrames: 0,
editRange: null as EditRange | null,
editRangeOnly: false,
}; };
export const useReviewStore = create<ReviewPlayerStore>((set) => ({ export const useReviewStore = create<ReviewPlayerStore>((set) => ({
@@ -88,5 +98,7 @@ export const useReviewStore = create<ReviewPlayerStore>((set) => ({
setBlankingMode: (blankingMode) => set({ blankingMode }), setBlankingMode: (blankingMode) => set({ blankingMode }),
setFps: (fps) => set({ fps }), setFps: (fps) => set({ fps }),
setTotalFrames: (totalFrames) => set({ totalFrames }), setTotalFrames: (totalFrames) => set({ totalFrames }),
setEditRange: (editRange) => set({ editRange }),
setEditRangeOnly: (editRangeOnly) => set({ editRangeOnly }),
reset: () => set(initialState), reset: () => set(initialState),
})); }));
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { frameToTime, timeToFrame } from "./frame-utils";
describe("timeToFrame", () => {
it("round-trips every frame through frameToTime", () => {
for (const fps of [23.976, 24, 25, 29.97, 30]) {
for (let f = 0; f < 5000; f++) {
expect(timeToFrame(frameToTime(f, fps), fps)).toBe(f);
}
}
});
it("reads a seek target rounded to microseconds as that frame", () => {
// Browsers can report currentTime rounded, e.g. 4/24 as 0.166666
expect(timeToFrame(0.166666, 24)).toBe(4);
});
it("still floors positions inside a frame", () => {
expect(timeToFrame(4.5 / 24, 24)).toBe(4);
expect(timeToFrame(4.99 / 24, 24)).toBe(4);
});
});
+9 -1
View File
@@ -9,9 +9,17 @@ export function frameToTime(frame: number, fps: number): number {
return frame / fps; return frame / fps;
} }
/**
* Tolerance (in frames) for float error at a frame boundary. Seeking to
* frameToTime(4, 24) = 0.1666… reads back as 3.9999… frames; without this it
* floors to frame 3, so every seek landed one frame early. Far smaller than
* any real position inside a frame.
*/
const FRAME_EPSILON = 1e-3;
/** Convert video currentTime to frame number (0-based) */ /** Convert video currentTime to frame number (0-based) */
export function timeToFrame(time: number, fps: number): number { export function timeToFrame(time: number, fps: number): number {
return Math.floor(time * fps); return Math.floor(time * fps + FRAME_EPSILON);
} }
/** Format a frame number as a timecode string: MM:SS:FF */ /** Format a frame number as a timecode string: MM:SS:FF */
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { computeEditRange, frameRegion, isHandleFrame } from "./handles";
describe("computeEditRange", () => {
it("is null when no handles are marked", () => {
expect(computeEditRange(null, 100)).toBeNull();
expect(computeEditRange({}, 100)).toBeNull();
expect(computeEditRange({ handleHead: 0, handleTail: 0 }, 100)).toBeNull();
});
it("is null until the media length is known", () => {
expect(computeEditRange({ handleHead: 8, handleTail: 8 }, 0)).toBeNull();
});
it("marks 8+8 on media with no slate", () => {
expect(computeEditRange({ handleHead: 8, handleTail: 8, frameCount: 100 }, 100)).toEqual({
pictureStart: 0,
cutIn: 8,
tailStart: 92,
pictureEnd: 100,
});
});
it("infers a 1-frame head slate from the media being one frame longer than the EXRs", () => {
// Pipeline preview: 100 EXR frames + 1 slate frame = 101 in the MP4
expect(computeEditRange({ handleHead: 8, handleTail: 8, frameCount: 100 }, 101)).toEqual({
pictureStart: 1,
cutIn: 9,
tailStart: 93,
pictureEnd: 101,
});
});
it("uses a recorded picture start over inference", () => {
const r = computeEditRange(
{ handleHead: 8, handleTail: 8, frameCount: 100, pictureStartFrame: 2 },
102
);
expect(r).toEqual({ pictureStart: 2, cutIn: 10, tailStart: 94, pictureEnd: 102 });
});
it("anchors the tail on the picture when the container rounds a frame short", () => {
// Recorded 1-frame slate, 100 EXRs, but the MP4 duration floors to 100
const r = computeEditRange(
{ handleHead: 8, handleTail: 8, frameCount: 100, pictureStartFrame: 1 },
100
);
expect(r).toEqual({ pictureStart: 1, cutIn: 9, tailStart: 92, pictureEnd: 100 });
});
it("doesn't mistake a large unexplained length difference for a slate", () => {
const r = computeEditRange({ handleHead: 8, handleTail: 8, frameCount: 50 }, 100);
expect(r?.pictureStart).toBe(0);
});
it("supports unequal and one-sided handles", () => {
expect(computeEditRange({ handleHead: 12, handleTail: 0, frameCount: 100 }, 100)).toEqual({
pictureStart: 0,
cutIn: 12,
tailStart: 100,
pictureEnd: 100,
});
});
it("never lets the handles overlap on a very short shot", () => {
const r = computeEditRange({ handleHead: 8, handleTail: 8, frameCount: 10 }, 10)!;
expect(r.cutIn).toBe(8);
expect(r.tailStart).toBe(8);
});
});
describe("frameRegion", () => {
const range = computeEditRange({ handleHead: 8, handleTail: 8, frameCount: 100 }, 101);
it("classifies slate, handles and edit", () => {
expect(frameRegion(range, 0)).toBe("slate");
expect(frameRegion(range, 1)).toBe("head");
expect(frameRegion(range, 8)).toBe("head");
expect(frameRegion(range, 9)).toBe("edit");
expect(frameRegion(range, 92)).toBe("edit");
expect(frameRegion(range, 93)).toBe("tail");
expect(frameRegion(range, 100)).toBe("tail");
});
it("treats everything as edit when handles are unknown", () => {
expect(frameRegion(null, 0)).toBe("edit");
expect(isHandleFrame(null, 0)).toBe(false);
});
it("does not count the slate as a handle", () => {
expect(isHandleFrame(range, 0)).toBe(false);
expect(isHandleFrame(range, 1)).toBe(true);
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* Handles: the extra frames rendered and delivered at each end of a shot that
* are not in the edit (8 + 8 by house convention). Reviewers need to see them
* — they ship — but must be able to tell them apart from the cut, so a glitch
* in a handle isn't flagged as a problem with the shot.
*
* Nothing here touches the media: the player draws the distinction on top.
*
* All frame numbers are 0-based frames of the review media (the MP4), which
* may open with a slate before the picture:
*
* [ slate ][ head handle ][ ——— in edit ——— ][ tail handle ]
* 0 pictureStart cutIn tailStart totalFrames
*/
/** What a Version records about its handles (all nullable: unknown/unmarked). */
export interface VersionHandles {
handleHead?: number | null;
handleTail?: number | null;
/**
* Frames of slate before the picture. Null = not recorded; derived from the
* media length vs `frameCount` (see below).
*/
pictureStartFrame?: number | null;
/** Picture frames (the rendered EXR range), excluding any slate. */
frameCount?: number | null;
}
export interface EditRange {
/** First picture frame (frames before it are slate). */
pictureStart: number;
/** First frame in the edit — the cut in-point. */
cutIn: number;
/** First frame of the tail handle — one past the cut out-point. */
tailStart: number;
/** One past the last picture frame. */
pictureEnd: number;
}
/**
* The largest slate we will infer from a length mismatch. Anything bigger is
* not a slate but a mismatch we can't explain, and guessing would put the
* handle markers on the wrong frames.
*/
const MAX_INFERRED_SLATE = 5;
/**
* Where the slate, handles and edit fall in the review media, or null when the
* version has no handles marked (or the media length isn't known yet).
*
* The slate length is taken from `pictureStartFrame` when recorded. Otherwise
* it is inferred as `totalFrames - frameCount`: pipeline previews record the
* EXR frame count, and the MP4 is that plus the head slate.
*/
export function computeEditRange(
handles: VersionHandles | null | undefined,
totalFrames: number
): EditRange | null {
if (!handles || !(totalFrames > 0)) return null;
const head = Math.max(0, Math.floor(handles.handleHead ?? 0));
const tail = Math.max(0, Math.floor(handles.handleTail ?? 0));
if (head === 0 && tail === 0) return null;
const frameCount =
handles.frameCount != null && handles.frameCount > 0 ? Math.floor(handles.frameCount) : null;
let pictureStart: number;
if (handles.pictureStartFrame != null && handles.pictureStartFrame >= 0) {
pictureStart = Math.floor(handles.pictureStartFrame);
} else if (frameCount != null) {
const lead = totalFrames - frameCount;
pictureStart = lead > 0 && lead <= MAX_INFERRED_SLATE ? lead : 0;
} else {
pictureStart = 0;
}
pictureStart = Math.min(pictureStart, totalFrames);
// Anchor the tail on the picture, not on the media's end: a container's
// duration can round a frame either way, the EXR count cannot.
const pictureEnd =
frameCount != null ? Math.min(totalFrames, pictureStart + frameCount) : totalFrames;
const cutIn = Math.min(pictureStart + head, pictureEnd);
const tailStart = Math.max(cutIn, pictureEnd - tail);
return { pictureStart, cutIn, tailStart, pictureEnd };
}
export type FrameRegion = "slate" | "head" | "edit" | "tail";
export function frameRegion(range: EditRange | null, frame: number): FrameRegion {
if (!range) return "edit";
if (frame < range.pictureStart) return "slate";
if (frame < range.cutIn) return "head";
if (frame >= range.tailStart) return "tail";
return "edit";
}
/** True for a frame in either handle (not the slate, not the edit). */
export function isHandleFrame(range: EditRange | null, frame: number): boolean {
const r = frameRegion(range, frame);
return r === "head" || r === "tail";
}
+21 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { renderOutputBase } from "./exports"; import { manifestHandles, renderOutputBase } from "./exports";
import type { RenderManifest } from "./manifest";
import { nextTaskTitle } from "./preview"; import { nextTaskTitle } from "./preview";
describe("renderOutputBase", () => { describe("renderOutputBase", () => {
@@ -17,3 +18,22 @@ describe("renderOutputBase", () => {
expect(nextTaskTitle(title, "v003")).toBe(title); expect(nextTaskTitle(title, "v003")).toBe(title);
}); });
}); });
describe("manifestHandles", () => {
const base = {} as RenderManifest;
it("uses the panel's explicit handles, including an explicit none", () => {
expect(manifestHandles({ ...base, review: { handleHead: 8, handleTail: 12 } })).toEqual({ head: 8, tail: 12 });
expect(
manifestHandles({ ...base, review: { handleHead: 0, handleTail: 0 }, source: { handleFrames: 8 } })
).toEqual({ head: 0, tail: 0 });
});
it("falls back to the handle length older panels sent with the plates", () => {
expect(manifestHandles({ ...base, source: { handleFrames: 8 } })).toEqual({ head: 8, tail: 8 });
});
it("is unknown when the manifest says nothing", () => {
expect(manifestHandles(base)).toBeNull();
});
});
+15
View File
@@ -53,6 +53,18 @@ export function renderOutputBase(shotCode: string, versionString: string): strin
return `${shotCode}_cmp_TT_${versionString}`; return `${shotCode}_cmp_TT_${versionString}`;
} }
/**
* The handles an export's render carries: the panel's explicit `review` block,
* else the `source.handleFrames` older panels always sent (the house handle
* length the plates were cut with), else unknown.
*/
export function manifestHandles(manifest: RenderManifest): { head: number; tail: number } | null {
if (manifest.review) return { head: manifest.review.handleHead, tail: manifest.review.handleTail };
const legacy = manifest.source?.handleFrames;
if (typeof legacy === "number" && legacy >= 0) return { head: legacy, tail: legacy };
return null;
}
export interface CreateExportInput { export interface CreateExportInput {
manifest: unknown; manifest: unknown;
submittedByEmail?: string | null; submittedByEmail?: string | null;
@@ -210,6 +222,7 @@ export async function createExport(input: CreateExportInput) {
: manifest.outputPattern; : manifest.outputPattern;
const resolvedManifest: RenderManifest = { ...manifest, outputDir, outputPattern }; const resolvedManifest: RenderManifest = { ...manifest, outputDir, outputPattern };
const handles = manifestHandles(manifest);
// Supersede older non-terminal exports (T1/T17); cancel their open jobs. // Supersede older non-terminal exports (T1/T17); cancel their open jobs.
const toSupersede = await tx.export.findMany({ const toSupersede = await tx.export.findMany({
@@ -242,6 +255,8 @@ export async function createExport(input: CreateExportInput) {
vfxScope, vfxScope,
submissionNote, submissionNote,
skipPreview: input.skipPreview === true, skipPreview: input.skipPreview === true,
handleHead: handles?.head ?? null,
handleTail: handles?.tail ?? null,
submittedById: submittedBy?.id ?? null, submittedById: submittedBy?.id ?? null,
submittedByName: submittedBy?.name ?? input.submittedByEmail ?? null, submittedByName: submittedBy?.name ?? input.submittedByEmail ?? null,
}, },
+12
View File
@@ -91,6 +91,18 @@ export const renderManifestSchema = z
burnins: z.boolean().optional(), burnins: z.boolean().optional(),
}) })
.optional(), .optional(),
/**
* Handles the render carries past the cut, from the panel's "Includes
* handles" setting (0/0 = none). Review metadata only — the player flags
* these frames; nothing is trimmed. Absent (older panels): falls back to
* `source.handleFrames`, which every panel export has sent.
*/
review: z
.object({
handleHead: z.number().int().min(0).max(1000),
handleTail: z.number().int().min(0).max(1000),
})
.optional(),
}) })
.refine((m) => m.frameEnd >= m.frameStart, { .refine((m) => m.frameEnd >= m.frameStart, {
message: "frameEnd must be >= frameStart", message: "frameEnd must be >= frameStart",
+20 -2
View File
@@ -297,7 +297,15 @@ export interface FinalizeInput {
logFileKey?: string; logFileKey?: string;
}; };
renderStats?: { renderSeconds?: number; previewSeconds?: number }; renderStats?: { renderSeconds?: number; previewSeconds?: number };
media?: { width?: number; height?: number; frameCount?: number; fps?: number; fileName?: string }; media?: {
width?: number;
height?: number;
frameCount?: number;
fps?: number;
fileName?: string;
/** Where the picture starts after the head slate (worker ≥ 1.6.4). */
pictureStartSeconds?: number | null;
};
} }
/** /**
@@ -350,15 +358,25 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi
let versionCreated = false; let versionCreated = false;
let inheritedApproval: string | null = null; let inheritedApproval: string | null = null;
if (artifacts.previewMovKey) { if (artifacts.previewMovKey) {
const fps = media.fps ?? exportRow.fps;
const mediaFields = { const mediaFields = {
fileUrl: `/api/files/${artifacts.previewMovKey}`, fileUrl: `/api/files/${artifacts.previewMovKey}`,
fileName: media.fileName ?? `${exportRow.versionString}.mp4`, fileName: media.fileName ?? `${exportRow.versionString}.mp4`,
mimeType: "video/mp4", mimeType: "video/mp4",
thumbnailUrl: artifacts.thumbnailKey ? `/api/files/${artifacts.thumbnailKey}` : undefined, thumbnailUrl: artifacts.thumbnailKey ? `/api/files/${artifacts.thumbnailKey}` : undefined,
fps: media.fps ?? exportRow.fps, fps,
frameCount: media.frameCount ?? exportRow.frameEnd - exportRow.frameStart + 1, frameCount: media.frameCount ?? exportRow.frameEnd - exportRow.frameStart + 1,
width: media.width ?? exportRow.width, width: media.width ?? exportRow.width,
height: media.height ?? exportRow.height, height: media.height ?? exportRow.height,
// Review metadata for the player's handle markers. The slate length is
// measured by the build when the worker reports it; left null the
// player infers it from the MP4 length vs frameCount.
handleHead: exportRow.handleHead,
handleTail: exportRow.handleTail,
pictureStartFrame:
typeof media.pictureStartSeconds === "number" && media.pictureStartSeconds >= 0
? Math.round(media.pictureStartSeconds * fps)
: null,
}; };
// Regenerating a preview (T11b) re-delivers the *same* export version, so // Regenerating a preview (T11b) re-delivers the *same* export version, so
@@ -0,0 +1,37 @@
-- Handles: frames rendered past the cut at each end of a shot. They ship with
-- the EXRs but are not in the edit, so the review player flags them. Purely
-- metadata — nothing renders differently.
ALTER TABLE "versions" ADD COLUMN "handleHead" INTEGER;
ALTER TABLE "versions" ADD COLUMN "handleTail" INTEGER;
ALTER TABLE "versions" ADD COLUMN "pictureStartFrame" INTEGER;
ALTER TABLE "exports" ADD COLUMN "handleHead" INTEGER;
ALTER TABLE "exports" ADD COLUMN "handleTail" INTEGER;
-- Backfill pipeline exports. Every render so far was queued by the panel with
-- its Handle Frames setting (8 unless changed); the value is in the render
-- manifest when the panel found a timing plate, otherwise use the house 8.
UPDATE "exports" e
SET "handleHead" = h.frames, "handleTail" = h.frames
FROM (
SELECT e2."id",
COALESCE(
(SELECT (rj."manifest"->'source'->>'handleFrames')::int
FROM "render_jobs" rj
WHERE rj."exportId" = e2."id"
AND rj."type" = 'AE_RENDER'
AND jsonb_typeof(rj."manifest"->'source'->'handleFrames') = 'number'
ORDER BY rj."createdAt" DESC
LIMIT 1),
8
) AS frames
FROM "exports" e2
) h
WHERE h."id" = e."id";
-- ...and the preview Versions they produced. pictureStartFrame stays null:
-- the player infers the head slate from the MP4 length vs the EXR frameCount.
UPDATE "versions" v
SET "handleHead" = e."handleHead", "handleTail" = e."handleTail"
FROM "exports" e
WHERE e."versionId" = v."id";
+13
View File
@@ -541,6 +541,13 @@ model Version {
frameCount Int? frameCount Int?
width Int? width Int?
height Int? height Int?
/// Handles: frames rendered past the cut at each end (not in the edit). Null
/// = not marked. The player flags them; the media is never changed.
handleHead Int?
handleTail Int?
/// Frames of slate before the picture in this media. Null = not recorded
/// (the player infers it from the media length vs frameCount).
pictureStartFrame Int?
notes String? @db.Text notes String? @db.Text
approvalStatus ApprovalStatus @default(PENDING_REVIEW) approvalStatus ApprovalStatus @default(PENDING_REVIEW)
reviewStatus ReviewStatus @default(PENDING) reviewStatus ReviewStatus @default(PENDING)
@@ -924,6 +931,12 @@ model Export {
/// correct preview, and rebuilding it from these EXRs would be wrong. /// correct preview, and rebuilding it from these EXRs would be wrong.
skipPreview Boolean @default(false) skipPreview Boolean @default(false)
/// Handles the render carries (frames past the cut at each end), from the
/// panel's "Includes handles" setting. Copied onto the preview Version so
/// the player can flag them; never used to trim or alter any render.
handleHead Int?
handleTail Int?
/// Skip picture-lock trimming and the picture-lock reference render for this /// Skip picture-lock trimming and the picture-lock reference render for this
/// export — set when picture lock is missing or wrong for the shot, so the /// export — set when picture lock is missing or wrong for the shot, so the
/// preview build falls back to the untrimmed full render range instead of /// preview build falls back to the untrimmed full render range instead of