Files
hyperframes/packages/engine/src/utils/layerCompositor.ts
T
Vance IngallsandClaude Opus 4.6 0cc79a35b0 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>
2026-04-19 16:29:24 -07:00

59 lines
2.2 KiB
TypeScript

/**
* Layer Compositor — z-order analysis for multi-layer HDR compositing.
*
* Groups timed elements into z-ordered layers (DOM or HDR) for the
* per-frame compositing loop. Adjacent DOM elements merge into a single
* layer to minimize Chrome screenshots.
*/
import type { ElementStackingInfo } from "../services/videoFrameInjector.js";
export type { ElementStackingInfo };
export type CompositeLayer =
| { type: "dom"; elementIds: string[] }
| { type: "hdr"; element: ElementStackingInfo };
/**
* Group z-sorted elements into composite layers. Adjacent DOM elements merge
* into a single layer; each HDR video/image is its own layer.
*
* Elements are sorted by \`zIndex\` ascending (back to front). Ties fall
* through to V8's stable sort, which preserves \`querySelectorAll\` DOM order —
* this is the same order Chrome uses for equal-z elements in a stacking
* context, so the blit order matches what the user sees in-browser.
*
* The DOM merge doesn't lose information: DOM layers are rendered via a
* full-page screenshot with non-layer elements hidden, so within-layer
* z-order is handled by Chrome itself.
*
* Invisible elements ARE included (video elements are hidden by the frame
* injector, but their injected \`<img>\` replacements are visible — they must
* stay in the correct z-ordered layer so sibling layers' DOM screenshots
* hide them).
*/
export function groupIntoLayers(elements: ElementStackingInfo[]): CompositeLayer[] {
// Include ALL elements regardless of visibility. Video elements are hidden by
// the frame injector (HEVC can't decode in headless Chrome) but their injected
// <img> replacements ARE visible. We need them in the correct z-ordered layer
// so they get hidden from other layers' DOM screenshots.
const sorted = [...elements].sort((a, b) => a.zIndex - b.zIndex);
const layers: CompositeLayer[] = [];
for (const el of sorted) {
if (el.isHdr) {
layers.push({ type: "hdr", element: el });
} else {
const last = layers[layers.length - 1];
if (last && last.type === "dom") {
last.elementIds.push(el.id);
} else {
layers.push({ type: "dom", elementIds: [el.id] });
}
}
}
return layers;
}