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)}
+