feat(player): aspect-ratio blanking overlay in the review player
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>
This commit is contained in:
twotalesanimation
2026-09-02 07:21:58 +02:00
parent 269a5cbfda
commit 75f9258b69
6 changed files with 445 additions and 1 deletions
+131
View File
@@ -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<HTMLVideoElement | null>;
}
/**
* 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.
<div
className="pointer-events-none absolute inset-0"
aria-hidden="true"
data-testid="aspect-overlay"
>
{horizontal ? (
<>
<div
className={barClass}
style={{ left: rect.left, top: rect.top, width: rect.width, height: box.top }}
/>
<div
className={barClass}
style={{
left: rect.left,
top: rect.top + rect.height - box.bottom,
width: rect.width,
height: box.bottom,
}}
/>
</>
) : (
<>
<div
className={barClass}
style={{ left: rect.left, top: rect.top, width: box.left, height: rect.height }}
/>
<div
className={barClass}
style={{
left: rect.left + rect.width - box.right,
top: rect.top,
width: box.right,
height: rect.height,
}}
/>
</>
)}
<div className="absolute bottom-3 right-3 rounded bg-black/70 px-2 py-1 font-mono text-[11px] text-zinc-300">
{preset.label}
</div>
</div>
);
}
+67 -1
View File
@@ -13,6 +13,7 @@ import {
Pencil,
Eye,
EyeOff,
RectangleHorizontal,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -20,9 +21,17 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useReviewStore } from "@/hooks/use-review-player";
import { frameToTimecode } from "@/lib/frame-utils";
import { ASPECT_OFF, ASPECT_PRESETS } from "@/lib/aspect-overlays";
import { cn } from "@/lib/utils";
const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
@@ -58,9 +67,13 @@ export function PlaybackControls({
playbackRate,
isAnnotating,
showAnnotations,
aspectPresetId,
blankingMode,
setPlaybackRate,
setAnnotating,
setShowAnnotations,
setAspectPresetId,
setBlankingMode,
} = useReviewStore();
const handleRateChange = (val: string) => {
@@ -69,6 +82,8 @@ export function PlaybackControls({
setPlaybackRate(rate);
};
const blankingOn = aspectPresetId !== ASPECT_OFF;
const timecode = frameToTimecode(currentFrame, fps);
return (
@@ -216,6 +231,57 @@ export function PlaybackControls({
</Select>
</div>
{/* Delivery blanking — hidden on mobile */}
<div className="hidden sm:contents">
<Select value={aspectPresetId} onValueChange={setAspectPresetId}>
<SelectTrigger
className={cn(
"h-7 w-24 text-xs border-0 bg-white/5 px-2",
blankingOn ? "text-amber-400" : "text-zinc-300"
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ASPECT_OFF} className="text-xs">
No blanking
</SelectItem>
<SelectSeparator />
{ASPECT_PRESETS.map((p) => (
<SelectItem key={p.id} value={p.id} className="text-xs">
<span className="font-mono">{p.label}</span>
<span className="ml-2 text-zinc-500">{p.note}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{blankingOn && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className={cn(
"text-zinc-400 hover:text-white",
blankingMode === "guide" && "text-amber-400 bg-amber-400/10"
)}
onClick={() =>
setBlankingMode(blankingMode === "solid" ? "guide" : "solid")
}
>
<RectangleHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{blankingMode === "solid"
? "Dim the crop instead of hiding it"
: "Hide the crop behind solid bars"}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Annotation toggle */}
<Tooltip>
<TooltipTrigger asChild>
+11
View File
@@ -12,6 +12,7 @@ import {
import { useReviewStore } from "@/hooks/use-review-player";
import { AnnotationCanvas } from "@/components/annotations/AnnotationCanvas";
import { AnnotationTools } from "@/components/annotations/AnnotationTools";
import { AspectOverlay } from "./AspectOverlay";
import { FrameTimeline } from "./FrameTimeline";
import { PlaybackControls } from "./PlaybackControls";
import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils";
@@ -68,6 +69,8 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
currentFrame,
isAnnotating,
showAnnotations,
aspectPresetId,
blankingMode,
setPlaying,
setCurrentFrame,
setCurrentTime,
@@ -283,6 +286,14 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
playsInline
/>
{/* Delivery blanking — under the annotation canvas, so a reviewer
can still draw over a blanked area if they need to */}
<AspectOverlay
presetId={aspectPresetId}
mode={blankingMode}
videoRef={videoRef}
/>
{/* Annotation canvas overlay */}
<AnnotationCanvas
versionId={versionId}
+11
View File
@@ -1,5 +1,6 @@
import { create } from "zustand";
import type { AnnotationTool } from "@/types";
import { ASPECT_OFF, type BlankingMode } from "@/lib/aspect-overlays";
interface ReviewPlayerStore {
// Playback
@@ -19,6 +20,10 @@ interface ReviewPlayerStore {
strokeWidth: number;
showAnnotations: boolean;
// Delivery blanking — a viewing aid only, never part of the saved review
aspectPresetId: string;
blankingMode: BlankingMode;
// Config (set from version data)
fps: number;
totalFrames: number;
@@ -37,6 +42,8 @@ interface ReviewPlayerStore {
setSelectedColor: (color: string) => void;
setStrokeWidth: (width: number) => void;
setShowAnnotations: (show: boolean) => void;
setAspectPresetId: (id: string) => void;
setBlankingMode: (mode: BlankingMode) => void;
setFps: (fps: number) => void;
setTotalFrames: (frames: number) => void;
reset: () => void;
@@ -56,6 +63,8 @@ const initialState = {
selectedColor: "#ef4444",
strokeWidth: 2,
showAnnotations: true,
aspectPresetId: ASPECT_OFF,
blankingMode: "solid" as BlankingMode,
fps: 24,
totalFrames: 0,
};
@@ -75,6 +84,8 @@ export const useReviewStore = create<ReviewPlayerStore>((set) => ({
setSelectedColor: (selectedColor) => set({ selectedColor }),
setStrokeWidth: (strokeWidth) => set({ strokeWidth }),
setShowAnnotations: (showAnnotations) => set({ showAnnotations }),
setAspectPresetId: (aspectPresetId) => set({ aspectPresetId }),
setBlankingMode: (blankingMode) => set({ blankingMode }),
setFps: (fps) => set({ fps }),
setTotalFrames: (totalFrames) => set({ totalFrames }),
reset: () => set(initialState),
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
ASPECT_PRESETS,
blankingInsets,
findAspectPreset,
isFullFrame,
letterboxRect,
} from "./aspect-overlays";
describe("letterboxRect", () => {
it("pillarboxes a 16:9 source in a squarer box", () => {
const rect = letterboxRect(1000, 1000, 1920, 1080)!;
expect(rect.width).toBeCloseTo(1000, 6);
expect(rect.height).toBeCloseTo(562.5, 6);
expect(rect.top).toBeCloseTo(218.75, 6);
expect(rect.left).toBeCloseTo(0, 6);
});
it("letterboxes a 16:9 source in a wider box", () => {
const rect = letterboxRect(2000, 500, 1920, 1080)!;
expect(rect.height).toBeCloseTo(500, 6);
expect(rect.width).toBeCloseTo(888.888, 2);
expect(rect.left).toBeCloseTo(555.555, 2);
expect(rect.top).toBeCloseTo(0, 6);
});
it("fills exactly when the box matches the source shape", () => {
const rect = letterboxRect(1280, 720, 1920, 1080)!;
expect(rect).toEqual({ left: 0, top: 0, width: 1280, height: 720 });
});
it("returns null before metadata gives the video a size", () => {
expect(letterboxRect(1000, 500, 0, 0)).toBeNull();
expect(letterboxRect(0, 0, 1920, 1080)).toBeNull();
});
});
describe("blankingInsets", () => {
const hd = { left: 0, top: 0, width: 1920, height: 1080 };
it("blanks top and bottom for a wider delivery ratio", () => {
const insets = blankingInsets(hd, 2.39);
// 1920 / 2.39 = 803.35 tall, so 138.32 above and below.
expect(insets.top).toBeCloseTo(138.3, 1);
expect(insets.bottom).toBeCloseTo(138.3, 1);
expect(insets.left).toBe(0);
expect(insets.right).toBe(0);
});
it("blanks left and right for a taller delivery ratio", () => {
const insets = blankingInsets(hd, 4 / 3);
// 1080 * 1.333 = 1440 wide, so 240 either side.
expect(insets.left).toBeCloseTo(240, 4);
expect(insets.right).toBeCloseTo(240, 4);
expect(insets.top).toBe(0);
expect(insets.bottom).toBe(0);
});
it("draws nothing when the target matches the source", () => {
expect(isFullFrame(blankingInsets(hd, 16 / 9))).toBe(true);
});
it("suppresses a sub-pixel hairline rather than drawing one", () => {
// 1.7777... vs a 1.778 label: a real ratio difference, but under half a pixel.
expect(isFullFrame(blankingInsets(hd, 1.778))).toBe(true);
});
it("keeps the blanked area at the requested ratio", () => {
const insets = blankingInsets(hd, 2.39);
const visibleHeight = hd.height - insets.top - insets.bottom;
expect(hd.width / visibleHeight).toBeCloseTo(2.39, 3);
});
it("blanks a letterboxed picture rect, not the element box", () => {
// 16:9 source shown in a tall panel: the picture is 1000x562.5 at y=218.75.
const rect = letterboxRect(1000, 1000, 1920, 1080)!;
const insets = blankingInsets(rect, 2.39);
const visibleHeight = rect.height - insets.top - insets.bottom;
expect(rect.width / visibleHeight).toBeCloseTo(2.39, 3);
// Bars stay inside the picture, never spilling into the letterbox.
expect(insets.top).toBeLessThan(rect.height / 2);
});
it("is inert for a zero-size rect", () => {
expect(isFullFrame(blankingInsets({ left: 0, top: 0, width: 0, height: 0 }, 2.39))).toBe(true);
});
});
describe("ASPECT_PRESETS", () => {
it("has unique ids and positive ratios", () => {
const ids = ASPECT_PRESETS.map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
for (const p of ASPECT_PRESETS) expect(p.ratio).toBeGreaterThan(0);
});
it("runs widest to tallest", () => {
const ratios = ASPECT_PRESETS.map((p) => p.ratio);
expect([...ratios].sort((a, b) => b - a)).toEqual(ratios);
});
it("looks up by id and ignores unknown ids", () => {
expect(findAspectPreset("2.39")?.ratio).toBe(2.39);
expect(findAspectPreset("off")).toBeNull();
expect(findAspectPreset("nonsense")).toBeNull();
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* 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;
}