/** * 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"; }