fix(producer): avoid reviving hidden DOM in HDR layers (#1935)

* fix(producer): avoid reviving hidden DOM in HDR layers

* fix(producer): filter transition HDR DOM masks

* fix(producer): keep hidden timed descendants masked
This commit is contained in:
Miguel Ángel
2026-07-04 15:30:56 -07:00
committed by GitHub
parent 16fe1368ff
commit bb066077b4
9 changed files with 923 additions and 19 deletions
@@ -8,6 +8,8 @@ import {
injectVideoFramesBatch,
syncVideoFrameVisibility,
shouldDefaultCaptureBeyondViewport,
applyDomLayerMask,
removeDomLayerMask,
} from "./screenshotService.js";
// Stub a Page + CDPSession just enough that pageScreenshotCapture can call
@@ -546,4 +548,54 @@ describe("video-frame injection respects ancestor visibility", () => {
expect(seededImg.style.visibility).toBe("hidden");
expect(setPropertySpy).toHaveBeenCalledWith("visibility", "hidden", "important");
});
it("applyDomLayerMask does not revive hidden idless timed descendants of a shown layer", async () => {
const { window, document } = parseHTML(
`<html><head></head><body>
<div id="scene" data-start="0" data-duration="6">
<div class="label" data-start="4.5" data-duration="1.5">late label</div>
</div>
</body></html>`,
);
const scene = document.getElementById("scene") as HTMLElement;
const label = document.querySelector(".label") as HTMLElement;
label.style.visibility = "hidden";
Object.defineProperty(window, "getComputedStyle", {
configurable: true,
value: (el: Element) => ({
display: (el as HTMLElement).style.display || "block",
visibility: (el as HTMLElement).style.visibility || "visible",
}),
});
const globals = globalThis as unknown as {
window?: Window;
document?: Document;
HTMLElement?: typeof HTMLElement;
CSS?: typeof CSS;
};
const previousWindow = globals.window;
const previousDocument = globals.document;
const previousHTMLElement = globals.HTMLElement;
const previousCSS = globals.CSS;
globals.window = window;
globals.document = document;
globals.HTMLElement = window.HTMLElement;
globals.CSS = { escape: (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "\\$&") } as CSS;
try {
await applyDomLayerMask(passthroughPage(), ["scene"], []);
expect(scene.style.visibility || "").toBe("");
expect(label.style.visibility).toBe("hidden");
await removeDomLayerMask(passthroughPage(), []);
expect(label.style.visibility).toBe("hidden");
expect(label.hasAttribute("data-hf-dom-layer-mask-hidden")).toBe(false);
} finally {
globals.window = previousWindow;
globals.document = previousDocument;
globals.HTMLElement = previousHTMLElement;
globals.CSS = previousCSS;
}
});
});
@@ -261,6 +261,9 @@ export async function captureAlphaPng(page: Page, width: number, height: number)
* tests can assert presence/absence of the mask between captures.
*/
export const DOM_LAYER_MASK_STYLE_ID = "__hf_dom_layer_mask__";
const DOM_LAYER_MASK_HIDDEN_ATTR = "data-hf-dom-layer-mask-hidden";
const DOM_LAYER_MASK_PREV_VISIBILITY_ATTR = "data-hf-dom-layer-mask-prev-visibility";
const DOM_LAYER_MASK_PREV_PRIORITY_ATTR = "data-hf-dom-layer-mask-prev-priority";
/**
* Mask the DOM so a single layer screenshot captures ONLY the layer's pixels.
@@ -288,6 +291,9 @@ export const DOM_LAYER_MASK_STYLE_ID = "__hf_dom_layer_mask__";
* elements that are descendants of a container layer (for example HDR
* videos and other-layer SDR videos are descendants of `#root` when we
* capture the root DOM layer).
* 3. Inline-hide timed descendants of shown elements that were hidden before
* the mask was installed. This covers idless child clips and same-layer
* descendants that the `extraHideIds` id list cannot represent.
*
* Only `visibility` is set on extraHideIds — never `opacity`. CSS opacity is
* multiplicative through the descendant chain and a descendant cannot escape
@@ -315,12 +321,49 @@ export async function applyDomLayerMask(
extraHideIds: string[],
): Promise<void> {
await page.evaluate(
(args: { show: string[]; hide: string[]; styleId: string }) => {
(args: {
show: string[];
hide: string[];
styleId: string;
hiddenAttr: string;
prevVisibilityAttr: string;
prevPriorityAttr: string;
}) => {
const existing = document.getElementById(args.styleId);
if (existing) existing.remove();
const restoreMaskedTimedDescendants = () => {
const masked = document.querySelectorAll(`[${args.hiddenAttr}="1"]`);
for (const node of masked) {
if (!(node instanceof HTMLElement)) continue;
const prevVisibility = node.getAttribute(args.prevVisibilityAttr);
const prevPriority = node.getAttribute(args.prevPriorityAttr);
if (prevVisibility === null) {
node.style.removeProperty("visibility");
} else {
node.style.setProperty("visibility", prevVisibility, prevPriority ?? "");
}
node.removeAttribute(args.hiddenAttr);
node.removeAttribute(args.prevVisibilityAttr);
node.removeAttribute(args.prevPriorityAttr);
}
};
restoreMaskedTimedDescendants();
const hiddenTimedDescendants: HTMLElement[] = [];
const rememberHiddenTimedDescendants = (root: Element) => {
for (const node of root.querySelectorAll("[data-start]")) {
if (!(node instanceof HTMLElement)) continue;
const computed = window.getComputedStyle(node);
if (computed.visibility !== "hidden" && computed.display !== "none") continue;
hiddenTimedDescendants.push(node);
}
};
const showSelectors: string[] = [];
for (const id of args.show) {
const el = document.getElementById(id);
if (el) rememberHiddenTimedDescendants(el);
const escaped = CSS.escape(id);
showSelectors.push(`#${escaped}`, `#${escaped} *`);
const renderEscaped = CSS.escape(`__render_frame_${id}__`);
@@ -338,6 +381,27 @@ export async function applyDomLayerMask(
style.textContent = `${massHideRule}\n${showRule}`;
document.head.appendChild(style);
for (const el of hiddenTimedDescendants) {
if (el.getAttribute(args.hiddenAttr) === "1") continue;
const prevVisibility = el.style.getPropertyValue("visibility");
const prevPriority =
typeof el.style.getPropertyPriority === "function"
? el.style.getPropertyPriority("visibility")
: "";
if (prevVisibility) {
el.setAttribute(args.prevVisibilityAttr, prevVisibility);
} else {
el.removeAttribute(args.prevVisibilityAttr);
}
if (prevPriority) {
el.setAttribute(args.prevPriorityAttr, prevPriority);
} else {
el.removeAttribute(args.prevPriorityAttr);
}
el.setAttribute(args.hiddenAttr, "1");
el.style.setProperty("visibility", "hidden", "important");
}
for (const id of args.hide) {
const el = document.getElementById(id);
if (el) {
@@ -349,7 +413,14 @@ export async function applyDomLayerMask(
}
}
},
{ show: showIds, hide: extraHideIds, styleId: DOM_LAYER_MASK_STYLE_ID },
{
show: showIds,
hide: extraHideIds,
styleId: DOM_LAYER_MASK_STYLE_ID,
hiddenAttr: DOM_LAYER_MASK_HIDDEN_ATTR,
prevVisibilityAttr: DOM_LAYER_MASK_PREV_VISIBILITY_ATTR,
prevPriorityAttr: DOM_LAYER_MASK_PREV_PRIORITY_ATTR,
},
);
}
@@ -369,9 +440,29 @@ export async function applyDomLayerMask(
*/
export async function removeDomLayerMask(page: Page, extraHideIds: string[]): Promise<void> {
await page.evaluate(
(args: { hide: string[]; styleId: string }) => {
(args: {
hide: string[];
styleId: string;
hiddenAttr: string;
prevVisibilityAttr: string;
prevPriorityAttr: string;
}) => {
const style = document.getElementById(args.styleId);
if (style) style.remove();
const masked = document.querySelectorAll(`[${args.hiddenAttr}="1"]`);
for (const node of masked) {
if (!(node instanceof HTMLElement)) continue;
const prevVisibility = node.getAttribute(args.prevVisibilityAttr);
const prevPriority = node.getAttribute(args.prevPriorityAttr);
if (prevVisibility === null) {
node.style.removeProperty("visibility");
} else {
node.style.setProperty("visibility", prevVisibility, prevPriority ?? "");
}
node.removeAttribute(args.hiddenAttr);
node.removeAttribute(args.prevVisibilityAttr);
node.removeAttribute(args.prevPriorityAttr);
}
for (const id of args.hide) {
const el = document.getElementById(id);
if (el) {
@@ -381,7 +472,13 @@ export async function removeDomLayerMask(page: Page, extraHideIds: string[]): Pr
if (img) img.style.removeProperty("visibility");
}
},
{ hide: extraHideIds, styleId: DOM_LAYER_MASK_STYLE_ID },
{
hide: extraHideIds,
styleId: DOM_LAYER_MASK_STYLE_ID,
hiddenAttr: DOM_LAYER_MASK_HIDDEN_ATTR,
prevVisibilityAttr: DOM_LAYER_MASK_PREV_VISIBILITY_ATTR,
prevPriorityAttr: DOM_LAYER_MASK_PREV_PRIORITY_ATTR,
},
);
}
@@ -371,6 +371,13 @@ export interface ElementStackingInfo {
layoutHeight: number;
opacity: number;
visible: boolean;
/**
* True when the SDR video replacement image injected beside this element is
* currently paintable. Native videos are hidden during capture, so this lets
* the layered HDR compositor keep their replacement frames in the right DOM
* layer without reviving unrelated hidden elements.
*/
renderFrameVisible: 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
@@ -635,6 +642,17 @@ export async function queryElementStacking(
return Number.isFinite(n) ? n : 0;
}
function isElementPaintable(node: Element): boolean {
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
return (
style.visibility !== "hidden" &&
style.display !== "none" &&
rect.width > 0 &&
rect.height > 0
);
}
for (const el of elements) {
const id = el.id;
if (!id) continue;
@@ -647,11 +665,9 @@ export async function queryElementStacking(
// remains the GSAP-controlled value. Walk from the element itself to
// multiply through any ancestor opacity stacks.
const opacity = getEffectiveOpacity(el);
const visible =
style.visibility !== "hidden" &&
style.display !== "none" &&
rect.width > 0 &&
rect.height > 0;
const visible = isElementPaintable(el);
const renderFrame = document.getElementById(`__render_frame_${id}__`);
const renderFrameVisible = renderFrame ? isElementPaintable(renderFrame) : false;
// 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
@@ -668,6 +684,7 @@ export async function queryElementStacking(
layoutHeight: htmlEl?.offsetHeight || Math.round(rect.height),
opacity,
visible,
renderFrameVisible,
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