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
+12 -1
View File
@@ -38,6 +38,7 @@ import {
} from "lucide-react";
import { useReviewStore } from "@/hooks/use-review-player";
import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate";
import { HandleBadge } from "@/components/player/HandleBadge";
interface Comment {
id: string;
@@ -58,6 +59,10 @@ interface Version {
fileUrl: string;
fps: number;
duration: number | null;
frameCount?: number | null;
handleHead?: number | null;
handleTail?: number | null;
pictureStartFrame?: number | null;
approvalStatus: string;
notes: string | null;
// Top-level high-res flags (normalised from either shot or task.shot)
@@ -513,6 +518,10 @@ export default function ClientReviewPage({
comments={comments as any}
versionId={version.id}
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) => {
const res = await fetch(`/api/client/${token}/annotation`, {
method: "POST",
@@ -644,6 +653,7 @@ export default function ClientReviewPage({
<span className="ml-auto font-mono text-xs text-amber-400/80">
{frameToTimecode(comment.frameNumber, version.fps)}
</span>
<HandleBadge frame={comment.frameNumber} />
</div>
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
{comment.canEdit && (
@@ -692,8 +702,9 @@ export default function ClientReviewPage({
{commentsOpen && commentFrame !== null ? (
<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)}
<HandleBadge frame={commentFrame} />
</p>
<Textarea
placeholder="Add your note..."