From 75f9258b6958d9c30399cb8a6a016d8cd195ce53 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:21:58 +0200 Subject: [PATCH] feat(player): aspect-ratio blanking overlay in the review player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/player/AspectOverlay.tsx | 131 +++++++++++++++++++++++++ components/player/PlaybackControls.tsx | 68 ++++++++++++- components/player/ReviewPlayer.tsx | 11 +++ hooks/use-review-player.ts | 11 +++ lib/aspect-overlays.test.ts | 106 ++++++++++++++++++++ lib/aspect-overlays.ts | 119 ++++++++++++++++++++++ 6 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 components/player/AspectOverlay.tsx create mode 100644 lib/aspect-overlays.test.ts create mode 100644 lib/aspect-overlays.ts diff --git a/components/player/AspectOverlay.tsx b/components/player/AspectOverlay.tsx new file mode 100644 index 0000000..729fee2 --- /dev/null +++ b/components/player/AspectOverlay.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useEffect, useState, type RefObject } from "react"; +import { + blankingInsets, + findAspectPreset, + isFullFrame, + letterboxRect, + type BlankingMode, +} from "@/lib/aspect-overlays"; + +interface AspectOverlayProps { + /** Preset id, or "off" — anything unknown draws nothing. */ + presetId: string; + mode: BlankingMode; + videoRef: RefObject; +} + +/** + * Delivery blanking drawn over the video. Purely visual: it never touches the + * video element, sits under the annotation canvas, and passes every pointer + * event through so click-to-play still works underneath it. + */ +export function AspectOverlay({ presetId, mode, videoRef }: AspectOverlayProps) { + const preset = findAspectPreset(presetId); + const [box, setBox] = useState<{ + top: number; + bottom: number; + left: number; + right: number; + rect: { left: number; top: number; width: number; height: number }; + } | null>(null); + + useEffect(() => { + const video = videoRef.current; + if (!video || !preset) { + setBox(null); + return; + } + + const measure = () => { + const rect = letterboxRect( + video.clientWidth, + video.clientHeight, + video.videoWidth, + video.videoHeight + ); + // Before metadata lands videoWidth is 0 and there is nothing to measure. + if (!rect) { + setBox(null); + return; + } + const insets = blankingInsets(rect, preset.ratio); + setBox(isFullFrame(insets) ? null : { ...insets, rect }); + }; + + measure(); + + // The picture rect moves on layout changes (panel resize, fullscreen) and + // on metadata, which is when intrinsic dimensions first exist. + const observer = new ResizeObserver(measure); + observer.observe(video); + video.addEventListener("loadedmetadata", measure); + + return () => { + observer.disconnect(); + video.removeEventListener("loadedmetadata", measure); + }; + }, [preset, videoRef]); + + if (!preset || !box) return null; + + const { rect } = box; + const barClass = + mode === "solid" + ? "absolute bg-black" + : "absolute bg-black/60 ring-1 ring-inset ring-white/25"; + + // Each bar is positioned against the picture rect, not the element box, so + // blanking lands on the image itself whatever shape the player is. + const horizontal = box.top > 0; + + return ( + // No z-index: the annotation canvas is also absolutely positioned and comes + // later in the DOM, so it stays on top and marks drawn over a blanked area + // remain visible. +