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
+11 -1
View File
@@ -156,7 +156,15 @@ export {
export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
export { decodePng, decodePngToRgb48le, blitRgba8OverRgb48le } from "./utils/alphaBlit.js";
export {
decodePng,
decodePngToRgb48le,
blitRgba8OverRgb48le,
blitRgb48leRegion,
getSrgbToHdrLut,
} from "./utils/alphaBlit.js";
export { groupIntoLayers, type CompositeLayer } from "./utils/layerCompositor.js";
export {
initHdrReadback,
@@ -172,7 +180,9 @@ export {
hideVideoElements,
showVideoElements,
queryVideoElementBounds,
queryElementStacking,
type VideoElementBounds,
type ElementStackingInfo,
} from "./services/videoFrameInjector.js";
export {
@@ -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);
}
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import { groupIntoLayers } from "./layerCompositor.js";
import type { ElementStackingInfo } from "../services/videoFrameInjector.js";
function makeEl(id: string, zIndex: number, isHdr: boolean): ElementStackingInfo {
return {
id,
zIndex,
x: 0,
y: 0,
width: 1920,
height: 1080,
layoutWidth: 1920,
layoutHeight: 1080,
opacity: 1,
visible: true,
isHdr,
transform: "none",
borderRadius: [0, 0, 0, 0],
};
}
describe("groupIntoLayers", () => {
it("single DOM element → 1 DOM layer", () => {
const layers = groupIntoLayers([makeEl("text", 0, false)]);
expect(layers).toHaveLength(1);
expect(layers[0]!.type).toBe("dom");
});
it("single HDR element → 1 HDR layer", () => {
const layers = groupIntoLayers([makeEl("v-hdr", 0, true)]);
expect(layers).toHaveLength(1);
expect(layers[0]!.type).toBe("hdr");
});
it("merges adjacent DOM elements into one layer", () => {
const elements = [makeEl("bg", 0, false), makeEl("text", 1, false), makeEl("logo", 2, false)];
const layers = groupIntoLayers(elements);
expect(layers).toHaveLength(1);
expect(layers[0]!.type).toBe("dom");
if (layers[0]!.type === "dom") {
expect(layers[0]!.elementIds).toEqual(["bg", "text", "logo"]);
}
});
it("splits on HDR/DOM boundary: DOM → HDR → DOM = 3 layers", () => {
const elements = [makeEl("bg", 0, false), makeEl("v-hdr", 1, true), makeEl("title", 2, false)];
const layers = groupIntoLayers(elements);
expect(layers).toHaveLength(3);
expect(layers[0]!.type).toBe("dom");
expect(layers[1]!.type).toBe("hdr");
expect(layers[2]!.type).toBe("dom");
});
it("merges adjacent DOM around multiple HDR: DOM → HDR → HDR → DOM = 4 layers", () => {
const elements = [
makeEl("bg", 0, false),
makeEl("v-hdr1", 1, true),
makeEl("v-hdr2", 2, true),
makeEl("title", 3, false),
];
const layers = groupIntoLayers(elements);
expect(layers).toHaveLength(4);
expect(layers[0]!.type).toBe("dom");
expect(layers[1]!.type).toBe("hdr");
expect(layers[2]!.type).toBe("hdr");
expect(layers[3]!.type).toBe("dom");
});
it("complex case: DOM DOM HDR DOM HDR DOM = 5 layers (2 DOM merges)", () => {
const elements = [
makeEl("bg", 0, false),
makeEl("caption", 1, false),
makeEl("v-hdr1", 2, true),
makeEl("text", 3, false),
makeEl("v-hdr2", 4, true),
makeEl("logo", 5, false),
];
const layers = groupIntoLayers(elements);
expect(layers).toHaveLength(5);
expect(layers.map((l) => l.type)).toEqual(["dom", "hdr", "dom", "hdr", "dom"]);
if (layers[0]!.type === "dom") {
expect(layers[0]!.elementIds).toEqual(["bg", "caption"]);
}
});
it("sorts by zIndex before grouping", () => {
const elements = [makeEl("title", 5, false), makeEl("v-hdr", 2, true), makeEl("bg", 0, false)];
const layers = groupIntoLayers(elements);
expect(layers).toHaveLength(3);
expect(layers[0]!.type).toBe("dom"); // bg (z=0)
expect(layers[1]!.type).toBe("hdr"); // v-hdr (z=2)
expect(layers[2]!.type).toBe("dom"); // title (z=5)
});
it("includes invisible elements in correct z-position", () => {
const elements = [
makeEl("bg", 0, false),
{ ...makeEl("hidden-sdr", 1, false), visible: false },
{ ...makeEl("hidden-hdr", 2, true), visible: false },
makeEl("title", 3, false),
];
const layers = groupIntoLayers(elements);
// All elements included — invisible SDR videos need their injected
// <img> replacements hidden from other layers' screenshots
expect(layers).toHaveLength(3);
expect(layers[0]!.type).toBe("dom"); // bg + hidden-sdr (merged)
expect(layers[1]!.type).toBe("hdr"); // hidden-hdr
expect(layers[2]!.type).toBe("dom"); // title
if (layers[0]!.type === "dom") {
expect(layers[0]!.elementIds).toEqual(["bg", "hidden-sdr"]);
}
});
it("returns an empty array for empty input", () => {
expect(groupIntoLayers([])).toEqual([]);
});
it("handles negative z-index (valid CSS back layers)", () => {
const elements = [makeEl("fg", 1, false), makeEl("bg", -5, false)];
const layers = groupIntoLayers(elements);
expect(layers).toHaveLength(1);
expect(layers[0]!.type).toBe("dom");
if (layers[0]!.type === "dom") {
expect(layers[0]!.elementIds).toEqual(["bg", "fg"]);
}
});
it("preserves input order for equal z-index (stable tie-break)", () => {
const elements = [
makeEl("first", 0, false),
makeEl("second", 0, false),
makeEl("third", 0, false),
];
const layers = groupIntoLayers(elements);
expect(layers).toHaveLength(1);
if (layers[0]!.type === "dom") {
expect(layers[0]!.elementIds).toEqual(["first", "second", "third"]);
}
});
});
@@ -0,0 +1,58 @@
/**
* 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;
}
@@ -64,9 +64,11 @@ import {
decodePng,
decodePngToRgb48le,
blitRgba8OverRgb48le,
blitRgb48leRegion,
hideVideoElements,
showVideoElements,
queryVideoElementBounds,
queryElementStacking,
groupIntoLayers,
} from "@hyperframes/engine";
import { join, dirname, resolve } from "path";
import { randomUUID } from "crypto";
@@ -923,16 +925,13 @@ export async function executeRenderJob(
job.framesRendered = 0;
// ── HDR two-pass compositing path ────────────────────────────────────
// Pass 1: Capture DOM layer with alpha (Chrome, video elements hidden)
// Pass 2: Extract native HLG frames from video sources (FFmpeg)
// Composite: overlay DOM on top of HDR video in FFmpeg per-frame
//
// This preserves HDR luminance from video sources while correctly
// compositing DOM content (text, graphics, SDR overlays) on top.
// Video position is applied via queried bounds; transform/opacity lands in a later PR.
// ── HDR z-ordered multi-layer compositing ──────────────────────────────
// Per frame: query all elements' z-order, group into layers (DOM or HDR),
// composite bottom-to-top in Node.js memory. HDR layers use native
// pre-extracted HLG pixels; DOM layers use Chrome alpha screenshots
// with sRGB→HLG conversion. Video position/opacity applied via queried bounds.
if (hasHdrVideo) {
log.info("[Render] HDR two-pass: DOM layer + native HLG video compositing");
log.info("[Render] HDR layered composite: z-ordered DOM + native HLG video layers");
// Use NATIVE HDR IDs (probed before SDR→HDR conversion) so only originally-HDR
// videos are hidden + extracted natively. SDR videos stay in the DOM screenshot
@@ -1035,72 +1034,114 @@ export async function executeRenderJob(
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, time);
// Inject SDR video frames into the DOM (the hook handles all videos,
// but hideVideoElements below will hide the HDR ones before screenshot)
// Inject SDR video frames into the DOM
if (beforeCaptureHook) {
await beforeCaptureHook(domSession.page, time);
}
// Query video element positions BEFORE hiding (so GSAP has already moved them)
const bounds = await queryVideoElementBounds(domSession.page, hdrVideoIds);
const activeBounds = bounds.filter((b) => b.visible);
// Query ALL timed elements for z-order analysis
const stackingInfo = await queryElementStacking(domSession.page, nativeHdrVideoIds);
// Pass 1: Hide HDR videos (and their injected frames), capture DOM with alpha.
// SDR video frames remain visible in the screenshot.
await hideVideoElements(domSession.page, hdrVideoIds);
const domPng = await captureAlphaPng(domSession.page, width, height);
await showVideoElements(domSession.page, hdrVideoIds);
// Group into z-ordered layers
const layers = groupIntoLayers(stackingInfo);
// Pass 2: Read pre-extracted HDR frame and composite with DOM layer
const activeVideoId = activeBounds[0]?.videoId ?? hdrVideoIds[0];
const video = composition.videos.find((v) => v.id === activeVideoId);
const frameDir = activeVideoId ? hdrFrameDirs.get(activeVideoId) : undefined;
let composited: Buffer;
if (video && frameDir) {
// Frame index within the video (1-based for FFmpeg image2 output).
// Clamp against the highest extracted frame in the directory to
// avoid issuing an existsSync per requested time when the
// composition outlives the source clip.
const rawIndex = Math.round((time - video.start) * job.config.fps) + 1;
const maxIndex = getMaxFrameIndex(frameDir);
const inBounds = rawIndex >= 1 && (maxIndex === 0 || rawIndex <= maxIndex);
const framePath = inBounds
? join(frameDir, `frame_${String(rawIndex).padStart(4, "0")}.png`)
: null;
let hdrRgb: Buffer;
if (framePath !== null && existsSync(framePath)) {
try {
hdrRgb = decodePngToRgb48le(readFileSync(framePath)).data;
} catch (err) {
log.warn("Failed to decode pre-extracted HDR frame; using black", {
framePath,
error: err instanceof Error ? err.message : String(err),
});
hdrRgb = Buffer.alloc(width * height * 6);
}
} else {
hdrRgb = Buffer.alloc(width * height * 6);
}
// In-memory alpha composite: DOM PNG over HDR rgb48le (in-place)
try {
const { data: domRgba } = decodePng(domPng);
const hdrTransfer = effectiveHdr ? effectiveHdr.transfer : ("hlg" as HdrTransfer);
blitRgba8OverRgb48le(domRgba, hdrRgb, width, height, hdrTransfer);
} catch (err) {
log.warn("DOM layer decode/blit failed; skipping overlay for frame", {
frameIndex: i,
error: err instanceof Error ? err.message : String(err),
});
}
composited = hdrRgb;
} else {
composited = Buffer.alloc(width * height * 6);
if (i % 30 === 0) {
const hdrEl = stackingInfo.find((e) => e.isHdr);
const hdrInLayers = layers.some((l) => l.type === "hdr");
log.debug("[Render] HDR layer composite frame", {
frame: i,
time: time.toFixed(2),
hdrElement: hdrEl
? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width }
: null,
hdrLayerPresent: hdrInLayers,
layerCount: layers.length,
});
}
hdrEncoder.writeFrame(composited);
// Start with a black canvas
const canvas = Buffer.alloc(width * height * 6);
// Composite layers bottom-to-top
for (const layer of layers) {
if (layer.type === "hdr") {
const el = layer.element;
const frameDir = hdrFrameDirs.get(el.id);
const video = composition.videos.find((v) => v.id === el.id);
if (!frameDir || !video) continue;
// Frame index within the video (1-based for FFmpeg image2 output).
// Clamp against the highest extracted frame in the directory to
// avoid issuing an existsSync per requested time when the
// composition outlives the source clip.
const videoFrameIndex = Math.round((time - video.start) * job.config.fps) + 1;
const maxIndex = getMaxFrameIndex(frameDir);
const inBounds =
videoFrameIndex >= 1 && (maxIndex === 0 || videoFrameIndex <= maxIndex);
const framePath = inBounds
? join(frameDir, `frame_${String(videoFrameIndex).padStart(4, "0")}.png`)
: null;
if (framePath !== null && existsSync(framePath)) {
try {
const hdrRgb = decodePngToRgb48le(readFileSync(framePath)).data;
blitRgb48leRegion(
canvas,
hdrRgb,
el.x,
el.y,
el.width,
el.height,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
);
} catch (err) {
log.warn("HDR layer decode/blit failed; skipping layer for frame", {
frameIndex: i,
videoId: el.id,
framePath,
error: err instanceof Error ? err.message : String(err),
});
}
}
} else {
// DOM layer: hide elements NOT in this layer + all HDR videos.
// All elements (including invisible SDR videos) are in the stacking
// info so their injected <img> replacements get hidden from other layers.
const allElementIds = stackingInfo.map((e) => e.id);
const layerIds = new Set(layer.elementIds);
const hideIds = allElementIds.filter(
(id) => !layerIds.has(id) || nativeHdrVideoIds.has(id),
);
await hideVideoElements(domSession.page, hideIds);
const domPng = await captureAlphaPng(domSession.page, width, height);
await showVideoElements(domSession.page, hideIds);
// Re-seek GSAP to restore animated properties (opacity, transforms)
// that showVideoElements clobbered via removeProperty.
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, time);
try {
const { data: domRgba } = decodePng(domPng);
// We're inside `if (hasHdrVideo)` which already required `effectiveHdr` to be set,
// but be defensive: fall back to HLG so we always feed the encoder a valid transfer.
const hdrTransfer: HdrTransfer = effectiveHdr ? effectiveHdr.transfer : "hlg";
blitRgba8OverRgb48le(domRgba, canvas, width, height, hdrTransfer);
} catch (err) {
log.warn("DOM layer decode/blit failed; skipping overlay for frame", {
frameIndex: i,
layerIds: layer.elementIds,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
hdrEncoder.writeFrame(canvas);
job.framesRendered = i + 1;
if ((i + 1) % 10 === 0 || i + 1 === job.totalFrames!) {