75f9258b69
Deploy / deploy (push) Successful in 5m39s
A shot is reviewed at the framing it was rendered at but usually delivered cropped, so add a dropdown beside the playback-speed control that lays delivery blanking over the video: 2.39 through 9:16, widest to tallest, with "No blanking" as the default. Purely a viewing aid — nothing is written to the review, and the video element is untouched. The geometry is the awkward part. The video is object-contain, so the picture is letterboxed inside the element and bars drawn against the element's edges would be wrong on any panel whose shape does not match the source. letterboxRect works out where the picture is actually painted and the bars are positioned against that, kept current by a ResizeObserver and a loadedmetadata listener so resizes and fullscreen stay correct. Ratios within half a pixel of the source draw nothing rather than a hairline of rounding error. Two modes, since blanking and crop bars are different jobs: solid black hides the crop the way the master will, guide dims it so the excluded area is still readable. The overlay sits under the annotation canvas — deliberately no z-index, since the canvas sets none either and DOM order alone gives marks drawn over a blanked area the right precedence. Verified against real browser layout, not just unit tests: the computed picture rect lands exactly on the image edges across three panel shapes, and the visible area measures the requested ratio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
120 lines
4.0 KiB
TypeScript
120 lines
4.0 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|