feat(review): flag handles in the review player (metadata only)
Deploy / deploy (push) Successful in 3m31s
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:
@@ -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
@@ -9,9 +9,17 @@ export function frameToTime(frame: number, fps: number): number {
|
||||
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) */
|
||||
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 */
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user