From 2e9fa45580b4bc9e49e69ccbdb3c5d3843d2b4fc Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:07:29 +0200 Subject: [PATCH] feat(review): flag handles in the review player (metadata only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- RenderPipeline2.md | 10 + RenderWorker/VFXReviewWorker/JobExecutor.cs | 3 + .../VFXReviewWorker/VFXReviewWorker.csproj | 2 +- VFXReviewConnector.jsx | 40 ++++ actions/versions.ts | 32 ++++ app/(dashboard)/playlist/PlaylistClient.tsx | 5 + app/api/playlist/route.ts | 8 + .../[token]/review/[versionId]/page.tsx | 13 +- app/review/[versionId]/ReviewPageClient.tsx | 19 ++ components/comments/CommentPanel.tsx | 3 + components/player/FrameTimeline.tsx | 65 ++++++- components/player/HandleBadge.tsx | 25 +++ components/player/HandleOverlay.tsx | 26 +++ components/player/PlaybackControls.tsx | 49 ++++- components/player/ReviewPlayer.tsx | 121 ++++++++++-- components/versions/VersionHandlesButton.tsx | 178 ++++++++++++++++++ hooks/use-review-player.ts | 12 ++ lib/frame-utils.test.ts | 22 +++ lib/frame-utils.ts | 10 +- lib/handles.test.ts | 94 +++++++++ lib/handles.ts | 103 ++++++++++ lib/render-pipeline/exports.test.ts | 22 ++- lib/render-pipeline/exports.ts | 15 ++ lib/render-pipeline/manifest.ts | 12 ++ lib/render-pipeline/preview.ts | 22 ++- .../migration.sql | 37 ++++ prisma/schema.prisma | 13 ++ 27 files changed, 938 insertions(+), 23 deletions(-) create mode 100644 components/player/HandleBadge.tsx create mode 100644 components/player/HandleOverlay.tsx create mode 100644 components/versions/VersionHandlesButton.tsx create mode 100644 lib/frame-utils.test.ts create mode 100644 lib/handles.test.ts create mode 100644 lib/handles.ts create mode 100644 prisma/migrations/20260922000000_version_handles/migration.sql diff --git a/RenderPipeline2.md b/RenderPipeline2.md index 3c7c18f..a18c8d6 100644 --- a/RenderPipeline2.md +++ b/RenderPipeline2.md @@ -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. +### 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 diff --git a/RenderWorker/VFXReviewWorker/JobExecutor.cs b/RenderWorker/VFXReviewWorker/JobExecutor.cs index 50c1c24..4e320f9 100644 --- a/RenderWorker/VFXReviewWorker/JobExecutor.cs +++ b/RenderWorker/VFXReviewWorker/JobExecutor.cs @@ -461,6 +461,9 @@ public sealed class JobExecutor ["frameCount"] = result.Build?.FrameCount, ["fps"] = manifest.Fps, ["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, }, }); diff --git a/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj b/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj index ed9a71b..bae8a92 100644 --- a/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj +++ b/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj @@ -15,7 +15,7 @@ every change that gets published. publish.ps1 appends the git SHA via SourceRevisionId, giving "1.1.0+feb1c7b". --> - 1.6.3 + 1.6.4 diff --git a/VFXReviewConnector.jsx b/VFXReviewConnector.jsx index e6dffde..7851172 100644 --- a/VFXReviewConnector.jsx +++ b/VFXReviewConnector.jsx @@ -45,6 +45,8 @@ var PANEL_VERSION = "2.6.2"; pipelineStatusText: null, urgentCheckbox: null, skipPreviewCheckbox: null, + handlesCheckbox: null, + handleFramesField: null, submissionNoteField: null, qcList: null, qcNotesField: null, @@ -3476,6 +3478,9 @@ var PANEL_VERSION = "2.6.2"; CONFIG.bannerImagePath = trimText(fBanner.text); CONFIG.artistEmail = trimText(fEmail.text); CONFIG.handleFrames = parseInt(fHandles.text, 10) || 8; + if (uiState.handleFramesField) { + uiState.handleFramesField.text = String(CONFIG.handleFrames); + } CONFIG.pathMappings = trimText(fMappings.text); CONFIG.sequenceStart = parseInt(fSeqStart.text, 10) || 1001; saveConfig(); @@ -4196,7 +4201,23 @@ var PANEL_VERSION = "2.6.2"; 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) { + manifest.review = reviewHandles(); var body = { manifest: manifest, 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" + "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"); slateNoteGroup.orientation = "row"; slateNoteGroup.alignChildren = ["fill", "top"]; diff --git a/actions/versions.ts b/actions/versions.ts index f223388..a065259 100644 --- a/actions/versions.ts +++ b/actions/versions.ts @@ -259,3 +259,35 @@ export async function getVersionComments(versionId: string) { 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) { + 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}`); +} diff --git a/app/(dashboard)/playlist/PlaylistClient.tsx b/app/(dashboard)/playlist/PlaylistClient.tsx index 1ce25f9..1528d9d 100644 --- a/app/(dashboard)/playlist/PlaylistClient.tsx +++ b/app/(dashboard)/playlist/PlaylistClient.tsx @@ -42,6 +42,10 @@ interface PlaylistVersion { thumbnailUrl: string | null; posterUrl: string | null; fps: number; + frameCount?: number | null; + handleHead?: number | null; + handleTail?: number | null; + pictureStartFrame?: number | null; approvalStatus: string; taskTitle: string; } @@ -276,6 +280,7 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) { fps={activeShot.latestVersion.fps} comments={[]} annotations={[]} + handles={activeShot.latestVersion} /> ) : (
diff --git a/app/api/playlist/route.ts b/app/api/playlist/route.ts index 9ed52ff..1f9caf7 100644 --- a/app/api/playlist/route.ts +++ b/app/api/playlist/route.ts @@ -50,6 +50,10 @@ export async function GET(req: NextRequest) { thumbnailUrl: true, posterUrl: true, fps: true, + frameCount: true, + handleHead: true, + handleTail: true, + pictureStartFrame: true, approvalStatus: true, mimeType: true, createdAt: true, @@ -85,6 +89,10 @@ export async function GET(req: NextRequest) { thumbnailUrl: latest.thumbnailUrl, posterUrl: latest.posterUrl, fps: latest.fps, + frameCount: latest.frameCount, + handleHead: latest.handleHead, + handleTail: latest.handleTail, + pictureStartFrame: latest.pictureStartFrame, approvalStatus: latest.approvalStatus, taskTitle: latest.taskTitle, }, diff --git a/app/client/[token]/review/[versionId]/page.tsx b/app/client/[token]/review/[versionId]/page.tsx index f6168ef..1c234d5 100644 --- a/app/client/[token]/review/[versionId]/page.tsx +++ b/app/client/[token]/review/[versionId]/page.tsx @@ -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({ {frameToTimecode(comment.frameNumber, version.fps)} +

{comment.text}

{comment.canEdit && ( @@ -692,8 +702,9 @@ export default function ClientReviewPage({ {commentsOpen && commentFrame !== null ? (
-

+

Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)} +