Files
vfxreview/lib/frame-utils.ts
T
twotalesanimation 2e9fa45580
Deploy / deploy (push) Successful in 3m31s
feat(review): flag handles in the review player (metadata only)
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>
2026-09-22 15:07:29 +02:00

87 lines
3.0 KiB
TypeScript

/**
* Frame-accurate utilities for VFX review.
* All frame calculations use 0-based frame numbers
* unless documented otherwise.
*/
/** Convert frame number to video currentTime in seconds */
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 + FRAME_EPSILON);
}
/** Format a frame number as a timecode string: MM:SS:FF */
export function frameToTimecode(frame: number, fps: number): string {
const totalSeconds = frame / fps;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const frames = frame % Math.round(fps);
if (hours > 0) {
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}:${String(frames).padStart(2, "0")}`;
}
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}:${String(frames).padStart(2, "0")}`;
}
/** Format a raw time (seconds) as MM:SS.mmm */
export function timeToDisplay(time: number): string {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
const ms = Math.floor((time % 1) * 1000);
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(ms).padStart(3, "0")}`;
}
/** Given duration and fps, compute total frame count */
export function durationToFrameCount(duration: number, fps: number): number {
return Math.floor(duration * fps);
}
/** Clamp a frame number to [0, totalFrames - 1] */
export function clampFrame(frame: number, totalFrames: number): number {
return Math.max(0, Math.min(totalFrames - 1, frame));
}
/** Step a single frame forward or backward */
export function stepFrame(
currentFrame: number,
delta: number,
totalFrames: number
): number {
return clampFrame(currentFrame + delta, totalFrames);
}
/** Convert a timeline position (0-1) to frame number */
export function positionToFrame(position: number, totalFrames: number): number {
return clampFrame(Math.floor(position * totalFrames), totalFrames);
}
/** Convert a frame number to timeline position (0-1) */
export function frameToPosition(frame: number, totalFrames: number): number {
if (totalFrames === 0) return 0;
return frame / totalFrames;
}
/** Common VFX frame rates */
export const COMMON_FPS = [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60] as const;
export type CommonFPS = (typeof COMMON_FPS)[number];
/** Human-readable fps label */
export function fpsLabel(fps: number): string {
if (fps === 23.976) return "23.976";
return String(fps);
}