From aa4a71fe417401b5d8a69cff36f56319defd3ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 28 Oct 2026 18:48:57 +0000 Subject: [PATCH] fix(core): graded media honors object-position vertically instead of mirroring it (#6691) * fix(core): graded media honors object-position vertically instead of mirroring it * fix(core): name the bottom-up position instead of commenting it --- .../core/src/runtime/colorGrading.test.ts | 47 +++++++++++++++++++ packages/core/src/runtime/colorGrading.ts | 5 +- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts index 3ac1a8ee1b..a097bff37f 100644 --- a/packages/core/src/runtime/colorGrading.test.ts +++ b/packages/core/src/runtime/colorGrading.test.ts @@ -8,6 +8,7 @@ import { } from "../colorGrading"; import { HF_COLOR_GROUP_ATTR, HF_COLOR_GROUP_TAG } from "../colorGroups"; import { + calculateObjectFitUv, createColorGradingRuntime, installAuthoredOpacityCapture, type RuntimeColorGradingApi, @@ -2619,3 +2620,49 @@ describe("installAuthoredOpacityCapture", () => { el.remove(); }); }); + +describe("calculateObjectFitUv", () => { + // The shader reads uv = (v_uv - offset) / scale; v_uv.y and the flipped source + // texture both run bottom-up, so this reports the CSS-facing fraction from the top. + function sampledFromSourceTop( + uv: ReturnType, + boxEdgeY: 0 | 1, + ): number { + return 1 - (boxEdgeY - uv.offsetY) / uv.scaleY; + } + + function sampledFromSourceLeft( + uv: ReturnType, + boxEdgeX: 0 | 1, + ): number { + return (boxEdgeX - uv.offsetX) / uv.scaleX; + } + + it("shows the top of a covered portrait source at object-position 50% 6%", () => { + const uv = calculateObjectFitUv(1920, 1080, 1080, 1920, "cover", "50% 6%"); + + expect(sampledFromSourceTop(uv, 1)).toBeCloseTo(0.041, 3); + expect(sampledFromSourceTop(uv, 0)).toBeCloseTo(0.3574, 3); + }); + + it("shows the bottom of a covered portrait source at object-position 50% 94%", () => { + const uv = calculateObjectFitUv(1920, 1080, 1080, 1920, "cover", "50% 94%"); + + expect(sampledFromSourceTop(uv, 1)).toBeCloseTo(0.6426, 3); + expect(sampledFromSourceTop(uv, 0)).toBeCloseTo(0.959, 3); + }); + + it("shows the left of a covered landscape source at object-position 6% 50%", () => { + const uv = calculateObjectFitUv(1080, 1920, 1920, 1080, "cover", "6% 50%"); + + expect(sampledFromSourceLeft(uv, 0)).toBeCloseTo(0.041, 3); + expect(sampledFromSourceLeft(uv, 1)).toBeCloseTo(0.3574, 3); + }); + + it("centers a covered source when no object-position is set", () => { + const uv = calculateObjectFitUv(1920, 1080, 1080, 1920, "cover", ""); + + expect(sampledFromSourceTop(uv, 1)).toBeCloseTo(0.3415, 3); + expect(sampledFromSourceTop(uv, 0)).toBeCloseTo(0.6585, 3); + }); +}); diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index 865f052e5b..a3b10e2f31 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -2851,7 +2851,7 @@ function parseObjectPosition(value: string): { x: number; y: number } { } // fallow-ignore-next-line complexity -function calculateObjectFitUv( +export function calculateObjectFitUv( boxWidth: number, boxHeight: number, sourceWidth: number, @@ -2882,7 +2882,8 @@ function calculateObjectFitUv( } const position = parseObjectPosition(objectPosition || "center"); const offsetX = ((boxWidth - drawWidth) * position.x) / boxWidth; - const offsetY = ((boxHeight - drawHeight) * position.y) / boxHeight; + const positionFromBottom = 1 - position.y; + const offsetY = ((boxHeight - drawHeight) * positionFromBottom) / boxHeight; return { scaleX: drawWidth / boxWidth, scaleY: drawHeight / boxHeight,