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>
107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
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();
|
|
});
|
|
});
|