/** * Aspect-ratio blanking for the review player. * * A shot is reviewed at the framing it was rendered at, but it is usually * *delivered* cropped — a 16:9 render shown 2.39:1, say. These helpers work out * where the delivered frame's edges fall so the player can lay bars over * everything outside them, without touching the video itself. */ export type BlankingMode = "solid" | "guide"; export interface AspectPreset { id: string; /** What the reviewer picks from the list, e.g. "2.39:1". */ label: string; /** Where it comes from, e.g. "Scope". Shown beside the label. */ note: string; ratio: number; } /** * Delivery ratios in wide-to-tall order, which is how a reviewer thinks about * them. Values are the real delivery numbers, not the rounded label: 2.39:1 is * 2.39, but 16:9 is 1.7778 rather than the 1.78 on the button. */ export const ASPECT_PRESETS: AspectPreset[] = [ { id: "2.39", label: "2.39:1", note: "Scope", ratio: 2.39 }, { id: "2.35", label: "2.35:1", note: "Classic scope", ratio: 2.35 }, { id: "2.20", label: "2.20:1", note: "70mm", ratio: 2.2 }, { id: "2.00", label: "2.00:1", note: "Univisium", ratio: 2 }, { id: "1.90", label: "1.90:1", note: "IMAX digital", ratio: 1.9 }, { id: "1.85", label: "1.85:1", note: "Flat", ratio: 1.85 }, { id: "1.78", label: "16:9", note: "1.78:1", ratio: 16 / 9 }, { id: "1.66", label: "1.66:1", note: "Super 16", ratio: 5 / 3 }, { id: "1.50", label: "3:2", note: "1.50:1", ratio: 1.5 }, { id: "1.33", label: "4:3", note: "1.33:1", ratio: 4 / 3 }, { id: "1.00", label: "1:1", note: "Square", ratio: 1 }, { id: "0.80", label: "4:5", note: "Social", ratio: 0.8 }, { id: "0.5625", label: "9:16", note: "Vertical", ratio: 9 / 16 }, ]; export const ASPECT_OFF = "off"; export function findAspectPreset(id: string): AspectPreset | null { return ASPECT_PRESETS.find((p) => p.id === id) ?? null; } export interface Rect { left: number; top: number; width: number; height: number; } /** * Where the picture actually sits inside an `object-contain` video element. * The element fills its box, but the image is letterboxed within it, so bars * drawn against the element's own edges would be wrong on any player whose * shape does not match the source. */ export function letterboxRect( containerWidth: number, containerHeight: number, videoWidth: number, videoHeight: number ): Rect | null { if ( !(containerWidth > 0) || !(containerHeight > 0) || !(videoWidth > 0) || !(videoHeight > 0) ) { return null; } const scale = Math.min(containerWidth / videoWidth, containerHeight / videoHeight); const width = videoWidth * scale; const height = videoHeight * scale; return { left: (containerWidth - width) / 2, top: (containerHeight - height) / 2, width, height, }; } export interface BlankingInsets { top: number; bottom: number; left: number; right: number; } /** * How thick the bars are on each edge, in pixels, for a target ratio inside a * given picture rect. A target wider than the source is blanked top and bottom; * a taller one is blanked left and right. Ratios within a pixel of the source * produce no bars at all rather than a hairline of rounding error. */ export function blankingInsets(rect: Rect, targetRatio: number): BlankingInsets { const none = { top: 0, bottom: 0, left: 0, right: 0 }; if (!(targetRatio > 0) || !(rect.width > 0) || !(rect.height > 0)) return none; const sourceRatio = rect.width / rect.height; if (targetRatio > sourceRatio) { const bar = (rect.height - rect.width / targetRatio) / 2; return bar < 0.5 ? none : { top: bar, bottom: bar, left: 0, right: 0 }; } const bar = (rect.width - rect.height * targetRatio) / 2; return bar < 0.5 ? none : { top: 0, bottom: 0, left: bar, right: bar }; } /** True when the target crops nothing off this source — nothing to draw. */ export function isFullFrame(insets: BlankingInsets): boolean { return !insets.top && !insets.bottom && !insets.left && !insets.right; }