Files
vfxreview/lib/handles.ts
T
twotalesanimation b596c97faf
Deploy / deploy (push) Successful in 3m12s
feat(playlist): seamless playback of selected shots, cut only
Check shots on the Playlist page and "Play selection" plays them back to
back without slate or handles. The cut comes from the version's handle and
slate metadata (new cutSpan in lib/handles); the media is never changed.

SequencePlayer double-buffers two videos: the next clip is parked a few
frames before its cut-in and pre-rolled hidden, the outgoing clip pauses on
its last cut frame, and the cut fires on the incoming clip's cut-in frame
(requestVideoFrameCallback), with the start time calibrated at each cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 08:55:46 +02:00

142 lines
5.1 KiB
TypeScript

/**
* 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 { pictureStart, pictureEnd } = pictureBounds(handles, totalFrames);
const cutIn = Math.min(pictureStart + head, pictureEnd);
const tailStart = Math.max(cutIn, pictureEnd - tail);
return { pictureStart, cutIn, tailStart, pictureEnd };
}
/**
* Where the picture sits in the media: after any slate, up to the end of the
* rendered frames. See computeEditRange for how the slate is found.
*/
function pictureBounds(
handles: VersionHandles,
totalFrames: number
): { pictureStart: number; pictureEnd: number } {
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;
return { pictureStart, pictureEnd };
}
/** A half-open frame range [start, end) of the review media. */
export interface FrameSpan {
start: number;
end: number;
}
/**
* The frames a seamless playlist plays for this version: the cut when handles
* are marked, otherwise the whole picture — never the slate. Falls back to
* the whole media rather than an empty span, so a clip never plays nothing.
*/
export function cutSpan(
handles: VersionHandles | null | undefined,
totalFrames: number
): FrameSpan | null {
if (!(totalFrames > 0)) return null;
const edit = computeEditRange(handles, totalFrames);
const span = edit
? { start: edit.cutIn, end: edit.tailStart }
: (() => {
const { pictureStart, pictureEnd } = pictureBounds(handles ?? {}, totalFrames);
return { start: pictureStart, end: pictureEnd };
})();
return span.end > span.start ? span : { start: 0, end: totalFrames };
}
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";
}