2e9fa45580
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>
104 lines
3.9 KiB
TypeScript
104 lines
3.9 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 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";
|
|
}
|