fix(producer): keep overlay layers in their scene during shader transitions

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-28 04:35:08 -04:00
parent 5329c2ec59
commit dc87490fd1
5 changed files with 77 additions and 2 deletions
+1
View File
@@ -174,6 +174,7 @@ export {
captureAlphaPng,
applyDomLayerMask,
removeDomLayerMask,
layerUnitsInside,
DOM_LAYER_MASK_STYLE_ID,
type BeginFrameResult,
} from "./services/screenshotService.js";
@@ -565,6 +565,25 @@ export async function applyDomLayerMask(
* we strip opacity here and then seek to the same time for the next layer,
* GSAP won't put it back and the wrapper will render fully opaque.
*/
/** Layer units inside the elements named by `ids`; their ids are minted per frame, so earlier id lists miss them. */
export async function layerUnitsInside(page: Page, ids: string[]): Promise<string[]> {
return page.evaluate(
(args: { ids: string[]; attr: string }) => {
const owners = new Set(args.ids);
const inside = (unit: Element) => {
for (let node: Element | null = unit; node; node = node.parentElement) {
if (owners.has(node.id)) return true;
}
return false;
};
return Array.from(document.querySelectorAll(`[${args.attr}]`))
.filter(inside)
.map((unit) => unit.getAttribute(args.attr) ?? "");
},
{ ids, attr: HDR_LAYER_UNIT_ATTR },
);
}
export async function removeDomLayerMask(page: Page, _extraHideIds: string[]): Promise<void> {
await page.evaluate(
(args: {
@@ -3,7 +3,12 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import puppeteer, { type Browser, type Page } from "puppeteer-core";
import { groupIntoLayers } from "../utils/layerCompositor.js";
import { resolveHeadlessShellPath } from "./browserManager.js";
import { HDR_LAYER_UNIT_ATTR, applyDomLayerMask, removeDomLayerMask } from "./screenshotService.js";
import {
HDR_LAYER_UNIT_ATTR,
applyDomLayerMask,
layerUnitsInside,
removeDomLayerMask,
} from "./screenshotService.js";
import { queryElementStacking } from "./videoFrameInjector.js";
const chromePath = resolveHeadlessShellPath();
@@ -49,6 +54,18 @@ describe.skipIf(!hasBrowser)("HDR stacking of content inside an HDR element's ho
);
});
it("finds a layer unit inside the elements that hold it, and only there", async () => {
await queryElementStacking(page, new Set(["hdr"]));
const unitId = await page.$eval(
"#title",
(el, attr) => el.getAttribute(attr),
HDR_LAYER_UNIT_ATTR,
);
expect(await layerUnitsInside(page, ["host"])).toEqual([unitId]);
expect(await layerUnitsInside(page, ["hdr"])).toEqual([]);
});
it("captures the overlay only in its own layer", async () => {
const stacking = await queryElementStacking(page, new Set(["hdr"]));
const ids = stacking.map((entry) => entry.id);
@@ -5,6 +5,7 @@ import {
blitRgba8OverRgb48le,
captureAlphaPng,
decodePng,
layerUnitsInside,
removeDomLayerMask,
} from "@hyperframes/engine";
import type { ProducerLogger } from "../../../logger.js";
@@ -19,6 +20,7 @@ vi.mock("@hyperframes/engine", async (importOriginal) => {
captureAlphaPng: vi.fn(async () => Buffer.from("png")),
decodePng: vi.fn(() => ({ data: Buffer.alloc(4), width: 1, height: 1 })),
removeDomLayerMask: vi.fn(async () => undefined),
layerUnitsInside: vi.fn(async () => []),
blitRgba8OverRgb48le: vi.fn(),
};
});
@@ -85,6 +87,37 @@ describe("captureSceneIntoBuffer", () => {
vi.mocked(blitRgba8OverRgb48le).mockClear();
});
it("keeps a layer unit in the scene that holds it", async () => {
const page = { evaluate: vi.fn(async () => undefined) };
vi.mocked(layerUnitsInside).mockResolvedValueOnce(["hf-layer-unit-1"]);
await captureSceneIntoBuffer({
session: { page, onBeforeCapture: null } as never,
sceneBuf: Buffer.alloc(6),
sceneIds: new Set(["scene-a", "title"]),
stackingInfo: [makeEl("scene-a"), makeEl("hf-layer-unit-1"), makeEl("scene-b")],
time: 0.5,
width: 1,
height: 1,
nativeHdrIds: new Set(),
nativeHdrImageIds: new Set(),
beforeCaptureHook: null,
hdrCompositeCtx: makeContext(),
compositeTransfer: "srgb",
hdrTargetTransfer: undefined,
hdrPerf: undefined,
log: makeLogger(),
frameIdx: 15,
});
expect(layerUnitsInside).toHaveBeenCalledWith(page, ["scene-a", "title"]);
expect(applyDomLayerMask).toHaveBeenCalledWith(
page,
["scene-a", "hf-layer-unit-1"],
["scene-b"],
);
});
it("does not re-show hidden transition-scene members in the DOM mask", async () => {
const page = { evaluate: vi.fn(async () => undefined) };
@@ -17,6 +17,7 @@ import {
blitRgba8OverRgb48le,
captureAlphaPng,
decodePng,
layerUnitsInside,
queryElementStacking,
removeDomLayerMask,
} from "@hyperframes/engine";
@@ -180,7 +181,7 @@ export async function captureSceneIntoBuffer(a: CaptureSceneArgs): Promise<void>
const {
session,
sceneBuf,
sceneIds,
sceneIds: sceneElementIds,
stackingInfo,
time,
width,
@@ -195,6 +196,10 @@ export async function captureSceneIntoBuffer(a: CaptureSceneArgs): Promise<void>
log,
frameIdx,
} = a;
const sceneIds = new Set([
...sceneElementIds,
...(await layerUnitsInside(session.page, [...sceneElementIds])),
]);
await seekAndInject(
session.page,
time,