feat(hdr): GSAP transforms and border-radius masks on HDR video (#290)

## Summary

HDR video elements with GSAP animations (position, scale, rotation, opacity) and CSS border-radius rendered without any transforms applied — the video just sat at (0,0) full-size. This PR adds affine transform support and rounded-corner masking for natively-composited HDR video.

## What it does

**Affine blit with bilinear interpolation:**
- `blitRgb48leAffine()` — Takes a 4x4 DOMMatrix and maps each destination pixel back to source coordinates via the inverse transform. Bilinear interpolation between the 4 nearest source pixels produces smooth edges under rotation and non-integer scaling. Optional opacity and border-radius parameters.
- `parseTransformMatrix()` — Parses CSS `matrix(a,b,c,d,e,f)` strings into `[a,b,c,d,e,f]` tuples.

**Accumulated viewport matrix:**
- `getViewportMatrix()` — Walks the `offsetParent` chain from element to viewport, accumulating position offsets and CSS transforms at each level. Correctly handles `transform-origin` using the CSS sandwich: `translate(origin) × M × translate(-origin)`. This is critical because GSAP animates transforms on wrapper divs, not directly on the video element.

**Effective opacity:**
- `getEffectiveOpacity()` — Multiplies opacity values walking up the ancestor chain. Uses `Number.isNaN()` (not `|| 1`) so opacity:0 isn't incorrectly treated as 1.

**Border-radius masks:**
- `roundedRectAlpha()` — Per-pixel anti-aliased rounded-rectangle mask with support for independent corner radii.
- `getEffectiveBorderRadius()` — Walks ancestors for `overflow:hidden` + border-radius. Resolves percentage values (e.g., `50%` for circles) via `offsetWidth`/`offsetHeight`.

**Layout dimensions for extraction:**
- Uses `offsetWidth`/`offsetHeight` (unaffected by CSS transforms) instead of `getBoundingClientRect()` (which returns the transformed bounding box and wobbles under rotation).

## Files changed

| File | What changed |
|------|-------------|
| `packages/engine/src/utils/alphaBlit.ts` | `blitRgb48leAffine()`, `parseTransformMatrix()`, `roundedRectAlpha()`, `cornerAlpha()` |
| `packages/engine/src/services/videoFrameInjector.ts` | `getViewportMatrix()`, `getEffectiveOpacity()`, `getEffectiveBorderRadius()`, `layoutWidth`/`layoutHeight` on `ElementStackingInfo` |
| `packages/producer/src/services/renderOrchestrator.ts` | Affine blit path, extraction at layout dimensions, border-radius parameter passing |

## How to test

Render a composition with an HDR video that has GSAP scale + rotation animation and a `border-radius: 50%` wrapper (circle mask). The video should rotate smoothly with round edges — no wobble, no sharp corners.

## Stack position

**5 of 6** — Stacked on #289 (z-ordered layers). Adds transform and masking support to the HDR blit that the layer compositor uses.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Vance Ingalls
2026-04-19 17:03:29 -07:00
committed by GitHub
parent f622d39962
commit 8548a17771
4 changed files with 284 additions and 26 deletions
@@ -236,9 +236,14 @@ export interface ElementStackingInfo {
y: number;
width: number;
height: number;
/** Layout dimensions before CSS transforms (offsetWidth/offsetHeight). */
layoutWidth: number;
layoutHeight: number;
opacity: number;
visible: boolean;
isHdr: boolean;
transform: string; // CSS transform matrix string, e.g. "matrix(1,0,0,1,0,0)" or "none"
borderRadius: [number, number, number, number]; // [tl, tr, br, bl] in CSS px from nearest clipping ancestor
}
/**
@@ -290,18 +295,148 @@ export async function queryElementStacking(
return 0;
}
// Find border-radius that clips the element. Replaced elements like <video>
// clip to their own border-radius; ancestors need overflow !== visible.
function getEffectiveBorderRadius(node: Element): [number, number, number, number] {
// Resolve a CSS border-radius value to pixels. Chrome's getComputedStyle
// returns percentages as-is (e.g. "50%"), not resolved to px.
// Uses offsetWidth/offsetHeight (layout dimensions before CSS transforms)
// because CSS resolves percentages against the padding box, not the
// transformed bounding box.
function resolveRadius(value: string, el: Element): number {
if (value.includes("%")) {
const pct = parseFloat(value) / 100;
const htmlEl = el as HTMLElement;
const w = htmlEl.offsetWidth || 0;
const h = htmlEl.offsetHeight || 0;
return pct * Math.min(w, h);
}
return parseFloat(value) || 0;
}
// Check element itself (replaced elements clip to own border-radius)
const selfCs = window.getComputedStyle(node);
const selfRadii: [number, number, number, number] = [
resolveRadius(selfCs.borderTopLeftRadius, node),
resolveRadius(selfCs.borderTopRightRadius, node),
resolveRadius(selfCs.borderBottomRightRadius, node),
resolveRadius(selfCs.borderBottomLeftRadius, node),
];
if (selfRadii[0] > 0 || selfRadii[1] > 0 || selfRadii[2] > 0 || selfRadii[3] > 0) {
return selfRadii;
}
// Walk ancestors looking for clipping container
let current: Element | null = node.parentElement;
while (current) {
const cs = window.getComputedStyle(current);
if (cs.overflow !== "visible") {
const tl = resolveRadius(cs.borderTopLeftRadius, current);
const tr = resolveRadius(cs.borderTopRightRadius, current);
const brr = resolveRadius(cs.borderBottomRightRadius, current);
const bl = resolveRadius(cs.borderBottomLeftRadius, current);
if (tl > 0 || tr > 0 || brr > 0 || bl > 0) {
return [tl, tr, brr, bl];
}
}
current = current.parentElement;
}
return [0, 0, 0, 0];
}
// Walk up the DOM multiplying each ancestor's opacity. GSAP animates
// opacity on wrapper divs, not directly on the video element, so the
// element's own opacity is often 1.0. Multiplying ancestors gives the
// true effective opacity.
function getEffectiveOpacity(node: Element): number {
let opacity = 1;
let current: Element | null = node;
while (current) {
const cs = window.getComputedStyle(current);
const val = parseFloat(cs.opacity);
// Note: `val || 1` would turn opacity:0 into 1 (0 is falsy)
opacity *= Number.isNaN(val) ? 1 : val;
current = current.parentElement;
}
return opacity;
}
// Compute the full CSS transform matrix from element-local coords to
// viewport coords by walking the offsetParent chain and accumulating
// position offsets + CSS transforms. This correctly handles GSAP
// animations on wrapper divs (rotation, scale) that getBoundingClientRect
// conflates into an axis-aligned bounding box.
function getViewportMatrix(node: Element): string {
const chain: HTMLElement[] = [];
let current: Element | null = node;
while (current instanceof HTMLElement) {
chain.push(current);
const next: Element | null =
(current.offsetParent as Element | null) ?? current.parentElement;
if (next === current) break;
current = next;
}
let mat = new DOMMatrix();
for (let i = chain.length - 1; i >= 0; i--) {
const htmlEl = chain[i];
if (!htmlEl) continue;
mat = mat.translate(htmlEl.offsetLeft, htmlEl.offsetTop);
const cs = window.getComputedStyle(htmlEl);
if (cs.transform && cs.transform !== "none") {
const origin = cs.transformOrigin.split(" ");
const ox = resolveLength(origin[0] ?? "0", htmlEl.offsetWidth);
const oy = resolveLength(origin[1] ?? "0", htmlEl.offsetHeight);
try {
const t = new DOMMatrix(cs.transform);
if (
Number.isFinite(t.a) &&
Number.isFinite(t.b) &&
Number.isFinite(t.c) &&
Number.isFinite(t.d) &&
Number.isFinite(t.e) &&
Number.isFinite(t.f)
) {
mat = mat.translate(ox, oy).multiply(t).translate(-ox, -oy);
}
} catch {
// DOMMatrix constructor throws on malformed input — skip ancestor.
}
}
}
return mat.toString();
}
function resolveLength(value: string, basis: number): number {
if (value.endsWith("%")) {
const pct = parseFloat(value) / 100;
return Number.isFinite(pct) ? pct * basis : 0;
}
const n = parseFloat(value);
return Number.isFinite(n) ? n : 0;
}
for (const el of elements) {
const id = el.id;
if (!id) continue;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
const zIndex = getEffectiveZIndex(el);
const opacity = parseFloat(style.opacity) || 1;
// For HDR video elements, the frame injector sets `opacity: 0 !important`
// on the element itself. Start the opacity walk from the parent to get the
// real GSAP-animated opacity from wrapper divs.
const isHdrEl = hdrSet.has(id);
const opacityStartNode = isHdrEl ? el.parentElement : el;
const opacity = opacityStartNode ? getEffectiveOpacity(opacityStartNode) : 1;
const visible =
style.visibility !== "hidden" &&
style.display !== "none" &&
rect.width > 0 &&
rect.height > 0;
// offsetWidth/offsetHeight only exist on HTMLElement (not on
// SVGElement, MathMLElement, etc.). Fall back to the bounding rect
// dimensions for non-HTML elements so callers always get sensible
// layout numbers.
const htmlEl = el instanceof HTMLElement ? el : null;
results.push({
id,
zIndex,
@@ -309,9 +444,16 @@ export async function queryElementStacking(
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
layoutWidth: htmlEl?.offsetWidth || Math.round(rect.width),
layoutHeight: htmlEl?.offsetHeight || Math.round(rect.height),
opacity,
visible,
isHdr: hdrSet.has(id),
// For HDR elements, use the full accumulated viewport matrix so the
// affine blit can apply rotation/scale/translate properly. For DOM
// elements, the element-level transform is sufficient for reference.
transform: isHdrEl ? getViewportMatrix(el) : style.transform || "none",
borderRadius: isHdrEl ? getEffectiveBorderRadius(el) : [0, 0, 0, 0],
});
}
return results;