mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user