feat(hdr): z-ordered multi-layer compositing with PQ support (#289)

* feat(hdr): add z-ordered multi-layer compositing with PQ support

Per-frame z-order analysis groups elements into DOM and HDR layers,
composited bottom-to-top. Adjacent DOM elements merge into single
screenshots. PQ (HDR10/smpte2084) support via sRGB-to-PQ LUT with
203-nit SDR reference white. queryElementStacking walks DOM for
effective z-index, groupIntoLayers splits on HDR/DOM boundaries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(hdr): address review feedback across stack

- Document groupIntoLayers tie-break (V8 stable sort → DOM order).
- Expand layerCompositor docstring: merge rationale, visibility inclusion.
- Add tests: empty input, negative z-index, stable tie-break at equal z.
- Document getEffectiveZIndex CSS stacking-context limitations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-04-19 16:29:24 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a21a62b574
commit 0cc79a35b0
5 changed files with 411 additions and 69 deletions
@@ -225,3 +225,95 @@ export async function queryVideoElementBounds(
});
}, videoIds);
}
/**
* Stacking info for a single timed element, used by the z-ordered layer compositor.
*/
export interface ElementStackingInfo {
id: string;
zIndex: number;
x: number;
y: number;
width: number;
height: number;
opacity: number;
visible: boolean;
isHdr: boolean;
}
/**
* Query Chrome for ALL timed elements' stacking context.
* Returns z-index, bounds, opacity, and whether each element is a native HDR video.
*
* Queries every element with `data-start` (not just videos) so the layer compositor
* can determine z-ordering between DOM content and HDR video elements.
*/
export async function queryElementStacking(
page: Page,
nativeHdrVideoIds: Set<string>,
): Promise<ElementStackingInfo[]> {
const hdrIds = Array.from(nativeHdrVideoIds);
return page.evaluate((hdrIdList: string[]): ElementStackingInfo[] => {
const hdrSet = new Set(hdrIdList);
const elements = document.querySelectorAll("[data-start]");
const results: ElementStackingInfo[] = [];
// Walk up the DOM to find the effective z-index from the nearest
// positioned ancestor with a z-index. CSS z-index only applies to
// positioned elements; video elements inside positioned wrappers
// inherit the wrapper's stacking context.
//
// ## Supported subset
//
// This implementation looks for explicit `z-index` on positioned
// (non-static) ancestors. It does NOT detect the CSS stacking contexts
// created implicitly by other properties — including `opacity < 1`,
// `transform`, `filter`, `will-change`, `isolation: isolate`, and
// `mix-blend-mode`. GSAP routinely sets `transform` on wrappers, which
// creates an implicit stacking context with auto z-index; an HDR video
// inside such a wrapper with no explicit z-index will return the
// wrapper-of-the-wrapper's z-index here, potentially reordering layers
// incorrectly relative to sibling stacking contexts.
//
// The workaround is to set explicit `z-index` on the positioned wrapper
// when you want it treated as a compositing layer root. This matches
// what compositions need to do anyway for deterministic z-ordering.
function getEffectiveZIndex(node: Element): number {
let current: Element | null = node;
while (current) {
const cs = window.getComputedStyle(current);
const pos = cs.position;
const z = parseInt(cs.zIndex);
if (!Number.isNaN(z) && pos !== "static") return z;
current = current.parentElement;
}
return 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;
const visible =
style.visibility !== "hidden" &&
style.display !== "none" &&
rect.width > 0 &&
rect.height > 0;
results.push({
id,
zIndex,
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
opacity,
visible,
isHdr: hdrSet.has(id),
});
}
return results;
}, hdrIds);
}