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
+21 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { renderOutputBase } from "./exports";
import { manifestHandles, renderOutputBase } from "./exports";
import type { RenderManifest } from "./manifest";
import { nextTaskTitle } from "./preview";
describe("renderOutputBase", () => {
@@ -17,3 +18,22 @@ describe("renderOutputBase", () => {
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}`;
}
/**
* 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 {
manifest: unknown;
submittedByEmail?: string | null;
@@ -210,6 +222,7 @@ export async function createExport(input: CreateExportInput) {
: manifest.outputPattern;
const resolvedManifest: RenderManifest = { ...manifest, outputDir, outputPattern };
const handles = manifestHandles(manifest);
// Supersede older non-terminal exports (T1/T17); cancel their open jobs.
const toSupersede = await tx.export.findMany({
@@ -242,6 +255,8 @@ export async function createExport(input: CreateExportInput) {
vfxScope,
submissionNote,
skipPreview: input.skipPreview === true,
handleHead: handles?.head ?? null,
handleTail: handles?.tail ?? null,
submittedById: submittedBy?.id ?? null,
submittedByName: submittedBy?.name ?? input.submittedByEmail ?? null,
},
+12
View File
@@ -91,6 +91,18 @@ export const renderManifestSchema = z
burnins: z.boolean().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, {
message: "frameEnd must be >= frameStart",
+20 -2
View File
@@ -297,7 +297,15 @@ export interface FinalizeInput {
logFileKey?: string;
};
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 inheritedApproval: string | null = null;
if (artifacts.previewMovKey) {
const fps = media.fps ?? exportRow.fps;
const mediaFields = {
fileUrl: `/api/files/${artifacts.previewMovKey}`,
fileName: media.fileName ?? `${exportRow.versionString}.mp4`,
mimeType: "video/mp4",
thumbnailUrl: artifacts.thumbnailKey ? `/api/files/${artifacts.thumbnailKey}` : undefined,
fps: media.fps ?? exportRow.fps,
fps,
frameCount: media.frameCount ?? exportRow.frameEnd - exportRow.frameStart + 1,
width: media.width ?? exportRow.width,
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