Files
hyperframes/packages/engine/src/services/screenshotService.test.ts
T
Miguel Ángel 2be8a62c00 fix(engine): stop compositing phantom duplicates on captureBeyondViewport (#2607)
* fix(core): stop the async media-metadata rebind once render capture starts seeking

scheduleMetadataDurationHydration re-resolves and can swap the captured
GSAP timeline off a debounced loadedmetadata/durationchange event, fully
uncoordinated with the producer's own per-frame renderSeek calls. When a
full-length <video>'s metadata resolves after capture has already begun
(slow I/O, Docker), this races the deterministic BeginFrame capture loop
and can reflow sub-composition state mid-render, producing phase-offset
duplicate content in captured frames (#2550).

Render-mode duration correction already happens deterministically during
the probe stage before capture starts, so once renderSeek has been called
once there is nothing left for this self-correction to do — gate it off
for the rest of the session.

* fix(core): scope the metadata-rebind guard to actual render/export pages

renderSeek isn't capture-exclusive — Studio's own preview iframe falls
back to it for compositions whose timeline overhangs every native
adapter's duration. Gating the HF#2550 fix on renderCaptureSeekStarted
alone silently disabled the metadata-driven duration self-correction for
that live-scrub case too, where it's still needed. Require the render/
export page signal (window.__HF_EXPORT_RENDER_SEEK_CONFIG, set only by
the producer's fileServer.ts) alongside it, and add a regression test
covering the Studio-preview case.

* fix(engine): stop requesting beyond-viewport capture for video comps that don't need it

Root-caused HF#2550 by reproducing the reporter's public repro end-to-end
(not just the timeline-rebind mechanism from the earlier commits in this
branch) on native Linux: instrumented the actual DOM state during a real
capture session and confirmed the sub-composition never double-mounts —
getBoundingClientRect and the timeline's own local time both match the
single, correct DOM tree throughout. The phantom second copy only exists
in the captured screenshot pixels.

Bisected it to captureBeyondViewport: resolveVideoCaptureBeyondViewport
(#1094's tall-portrait fix) forces `Page.captureScreenshot`'s beyond-viewport
path on for any render with a native <video>, regardless of whether the
page's content actually overflows the declared capture height. On
SwiftShader that beyond-viewport path can composite a stale, vertically
offset paint of the page alongside the fresh one for content that fits
entirely within the viewport — producing exactly the reported phase-offset
duplicate. Disabling captureBeyondViewport (repro's video still present)
eliminates the duplicate outright; re-enabling it reproduces the duplicate
byte-for-byte, isolating it as the actual cause.

Adds pageContentExceedsCaptureHeight, a ground-truth measurement of the
page's actual scrollHeight against the requested capture height, and wires
it into initializeSession to downgrade captureBeyondViewport back to false
once the page is settled and it's confirmed unnecessary — the "reliable
clip predictor" the original #1094 fix's ponytail comment flagged as
missing. This keeps #1094's fix intact for content that genuinely
overflows while closing the SwiftShader ghosting hazard for the (common)
case of video that fits inside its own viewport.

* test(producer): add HF#2550 video+sub-composition regression fixture

Checks in the reporter's confirmed real-world reproduction (media
regenerated via ffmpeg testsrc2, matching their public repro repo) as a
regression fixture, with a golden baseline rendered against the fix.

Verified end-to-end via the project's own Docker regression harness:
- Rendering this fixture with the fix produces the golden baseline
  (clean, single flowchart instance, captureBeyondViewport correctly
  downgraded).
- Direct CLI renders (not through this harness) against unpatched code
  reproduce the reported phantom-duplicate artifact reliably (10/10).

Caveat documented in meta.json: the underlying bug is timing-dependent.
Two harness runs against unpatched code, using this same fixture, did
not reproduce the artifact (0/2) — the harness's in-process render path
apparently doesn't hit the same race window a direct CLI process does on
this host. This fixture is a best-effort regression guard and a
preserved real-world repro, not the sole protection — the deterministic
guard is packages/engine/src/services/screenshotService.test.ts's
pageContentExceedsCaptureHeight unit tests, which exercise the actual
fix logic directly.

Also adds an .gitattributes LFS rule for this fixture's source
index.html (744 KB — carries the real project's embedded base64
assets, over the largefiles hook's 500 KB non-LFS limit).

* fix: route HF#2550 fixture binaries through LFS (were committed raw)

filter.lfs.clean/smudge were locally configured as a no-op "cat" in
this repo's shared .git/config, silently disabling LFS filtering for
every worktree. The previous commit's large binaries (output.mp4,
compiled.html, source index.html, source video) landed as raw blobs
instead of LFS pointers as a result. Ran `git lfs install --local
--force` to restore the correct filter commands, then re-staged the
affected files so they commit as proper LFS pointers.

* fix(engine): address capture viewport review feedback
2026-07-17 11:59:45 -04:00

867 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment node
// fallow-ignore-file code-duplication
import { describe, it, expect, vi } from "vitest";
import { parseHTML } from "linkedom";
import { type Page } from "puppeteer-core";
import {
pageScreenshotCapture,
pageContentExceedsCaptureHeight,
cdpSessionCache,
ensureRenderFrameSiblings,
applyDomLayerMask,
removeDomLayerMask,
injectVideoFramesBatch,
syncVideoFrameVisibility,
shouldDefaultCaptureBeyondViewport,
DOM_LAYER_MASK_STYLE_ID,
} from "./screenshotService.js";
// Stub a Page + CDPSession just enough that pageScreenshotCapture can call
// `client.send("Page.captureScreenshot", ...)` and we can inspect the args.
function makeFakePageWithCdp(send: (method: string, params: object) => Promise<{ data: string }>) {
const fakeSession = { send } as unknown as import("puppeteer-core").CDPSession;
// Stub a Page object — the WeakMap cache is the only Page-thing used in the
// path under test, so we can pre-seed it and skip page.createCDPSession().
const fakePage = {} as Page;
cdpSessionCache.set(fakePage, fakeSession);
return fakePage;
}
describe("pageScreenshotCapture supersample plumbing", () => {
// Minimal 1×1 transparent PNG, base64. The function returns Buffer.from(data, "base64")
// and we never inspect the bytes — only the params we pass to client.send.
const ONE_PIXEL_PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
it("passes `clip` with scale 1 when deviceScaleFactor is undefined (default 1)", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
const page = makeFakePageWithCdp(send);
await pageScreenshotCapture(page, {
width: 1920,
height: 1080,
fps: { num: 30, den: 1 },
format: "jpeg",
quality: 80,
});
expect(send).toHaveBeenCalledWith(
"Page.captureScreenshot",
expect.objectContaining({
captureBeyondViewport: false,
clip: { x: 0, y: 0, width: 1920, height: 1080, scale: 1 },
}),
);
});
it("uses captureBeyondViewport only when callers opt in", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
const page = makeFakePageWithCdp(send);
await pageScreenshotCapture(page, {
width: 1080,
height: 1920,
fps: { num: 30, den: 1 },
format: "jpeg",
captureBeyondViewport: true,
});
expect(send).toHaveBeenCalledWith(
"Page.captureScreenshot",
expect.objectContaining({
captureBeyondViewport: true,
clip: { x: 0, y: 0, width: 1080, height: 1920, scale: 1 },
}),
);
});
it("passes `clip` with scale 1 when deviceScaleFactor is exactly 1", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
const page = makeFakePageWithCdp(send);
await pageScreenshotCapture(page, {
width: 1920,
height: 1080,
fps: { num: 30, den: 1 },
format: "jpeg",
deviceScaleFactor: 1,
});
const params = send.mock.calls[0]?.[1] as { clip?: { scale: number } };
expect(params.clip).toEqual({ x: 0, y: 0, width: 1920, height: 1080, scale: 1 });
});
it("passes `clip` with `scale = dpr` when deviceScaleFactor > 1 (the supersample contract)", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
const page = makeFakePageWithCdp(send);
await pageScreenshotCapture(page, {
width: 1920,
height: 1080,
fps: { num: 30, den: 1 },
format: "jpeg",
deviceScaleFactor: 2,
});
expect(send).toHaveBeenCalledWith(
"Page.captureScreenshot",
expect.objectContaining({
clip: { x: 0, y: 0, width: 1920, height: 1080, scale: 2 },
}),
);
});
it("propagates a non-2 supersample factor (e.g. 720p → 4K = 3×)", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
const page = makeFakePageWithCdp(send);
await pageScreenshotCapture(page, {
width: 1280,
height: 720,
fps: { num: 30, den: 1 },
format: "jpeg",
deviceScaleFactor: 3,
});
const params = send.mock.calls[0]?.[1] as { clip?: { scale: number } };
expect(params.clip?.scale).toBe(3);
});
});
describe("shouldDefaultCaptureBeyondViewport", () => {
it("guards regular Chrome on macOS", () => {
expect(shouldDefaultCaptureBeyondViewport("Chrome/149.0.7827.155", "darwin")).toBe(true);
});
it("keeps chrome-headless-shell on the faster viewport-bound path", () => {
expect(shouldDefaultCaptureBeyondViewport("HeadlessChrome/148.0.7778.97", "darwin")).toBe(
false,
);
});
it("does not change regular Chrome defaults on non-macOS platforms", () => {
expect(shouldDefaultCaptureBeyondViewport("Chrome/149.0.7827.155", "linux")).toBe(false);
});
});
describe("pageContentExceedsCaptureHeight", () => {
function makeFakePageWithScrollHeight(scrollHeight: number): Page {
return { evaluate: vi.fn().mockResolvedValue(scrollHeight) } as unknown as Page;
}
it("is false when the page fits exactly within the requested height", async () => {
const page = makeFakePageWithScrollHeight(1920);
await expect(pageContentExceedsCaptureHeight(page, 1920)).resolves.toBe(false);
});
it("tolerates subpixel rounding just past the requested height", async () => {
const page = makeFakePageWithScrollHeight(1920.5);
await expect(pageContentExceedsCaptureHeight(page, 1920)).resolves.toBe(false);
});
it("tolerates exactly one CSS pixel past the requested height", async () => {
const page = makeFakePageWithScrollHeight(1921);
await expect(pageContentExceedsCaptureHeight(page, 1920)).resolves.toBe(false);
});
it("detects content just beyond the rounding tolerance", async () => {
const page = makeFakePageWithScrollHeight(1922);
await expect(pageContentExceedsCaptureHeight(page, 1920)).resolves.toBe(true);
});
it("is true when the page genuinely overflows the requested height", async () => {
const page = makeFakePageWithScrollHeight(2007);
await expect(pageContentExceedsCaptureHeight(page, 1920)).resolves.toBe(true);
});
});
describe("injectVideoFramesBatch replacement layout", () => {
it("does not copy opposing inset constraints onto the injected frame image", async () => {
const { window, document } = parseHTML(
'<html><body><div id="root"><video id="clip" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover"></video></div></body></html>',
);
Object.defineProperty(window.HTMLImageElement.prototype, "decode", {
configurable: true,
value: () => Promise.resolve(),
});
const video = document.getElementById("clip") as HTMLVideoElement;
Object.defineProperties(video, {
offsetLeft: { configurable: true, get: () => 0 },
offsetTop: { configurable: true, get: () => 0 },
offsetWidth: { configurable: true, get: () => 1920 },
offsetHeight: { configurable: true, get: () => 1080 },
});
video.getBoundingClientRect = () =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: 1920,
bottom: 1080,
width: 1920,
height: 1080,
toJSON: () => ({}),
}) as DOMRect;
const computedStyle = document.createElement("div").style;
computedStyle.position = "absolute";
computedStyle.width = "1920px";
computedStyle.height = "1080px";
computedStyle.top = "0px";
computedStyle.left = "0px";
computedStyle.right = "0px";
computedStyle.bottom = "0px";
computedStyle.inset = "0px";
computedStyle.objectFit = "cover";
computedStyle.objectPosition = "center center";
computedStyle.zIndex = "3";
computedStyle.opacity = "1";
Object.defineProperty(window, "getComputedStyle", {
configurable: true,
value: () => computedStyle,
});
const globals = globalThis as unknown as {
window?: typeof window;
document?: Document;
};
const previousWindow = globals.window;
const previousDocument = globals.document;
globals.window = window;
globals.document = document;
try {
const page = {
evaluate: async (
fn: (
updates: Array<{ videoId: string; dataUri: string }>,
visualProperties: string[],
) => Promise<void>,
updates: Array<{ videoId: string; dataUri: string }>,
visualProperties: string[],
) => fn(updates, visualProperties),
} as unknown as Page;
await injectVideoFramesBatch(page, [
{
videoId: "clip",
dataUri:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
},
]);
} finally {
globals.window = previousWindow;
globals.document = previousDocument;
}
const img = video.nextElementSibling as HTMLImageElement | null;
expect(img).not.toBeNull();
expect(img?.style.position).toBe("absolute");
expect(img?.style.left).toBe("0px");
expect(img?.style.top).toBe("0px");
expect(img?.style.width).toBe("1920px");
expect(img?.style.height).toBe("1080px");
expect(img?.style.right).toBe("auto");
expect(img?.style.bottom).toBe("auto");
expect(img?.style.inset).toBe("auto");
});
});
describe("video-frame injection respects ancestor visibility", () => {
// Regression guard: the runtime's `[data-start]` lifecycle hides
// out-of-window sub-composition hosts with `visibility:hidden`, but the
// injector used to ignore that and paint a replacement <img> for every
// active `<video data-start>` element. Inner-PIP videos inside *other*
// moments still appear active in the raw time-window check (their auto-
// injected `data-start="0"` + probed full-source duration cover the
// whole timeline), so the bug produced one full-bleed speaker overlay
// per inactive sub-comp — covering whichever moment was actually visible.
//
// The skip is intentionally narrow: `visibility:hidden` on a regular
// `[data-start]` container must NOT skip injection, because the
// replacement <img>'s explicit `visibility:visible` overrides the
// ancestor (CSS spec) and consumers rely on that to hold the final
// GSAP-driven frame when an authored `data-duration` outlives the
// composition's GSAP timeline. We therefore only treat
// `visibility:hidden` as a skip signal on sub-composition hosts
// (`[data-composition-src]` / `[data-composition-file]`). `display:none`,
// by contrast, takes the whole subtree out of layout regardless of any
// child override, so it always triggers the skip.
type StyleLike = {
display?: string;
visibility?: string;
opacity?: string;
objectFit?: string;
objectPosition?: string;
zIndex?: string;
};
type HostAttribute = "data-composition-src" | "data-composition-file" | "data-start";
function setupHostHiddenScenario(
hostStyle: StyleLike,
options: { hostAttribute?: HostAttribute; videoStyle?: StyleLike } = {},
) {
const hostAttribute = options.hostAttribute ?? "data-composition-src";
const hostAttrMarkup =
hostAttribute === "data-start"
? 'data-start="0" data-duration="10"'
: `${hostAttribute}="sub.html"`;
const { window, document } = parseHTML(
`<html><body><div id="host" ${hostAttrMarkup}><div id="pip-frame"><video id="pip" data-start="0" data-duration="10"></video></div></div></body></html>`,
);
Object.defineProperty(window.HTMLImageElement.prototype, "decode", {
configurable: true,
value: () => Promise.resolve(),
});
const host = document.getElementById("host") as HTMLElement;
const pipFrame = document.getElementById("pip-frame") as HTMLElement;
const video = document.getElementById("pip") as HTMLVideoElement;
Object.defineProperties(video, {
offsetLeft: { configurable: true, get: () => 0 },
offsetTop: { configurable: true, get: () => 0 },
offsetWidth: { configurable: true, get: () => 1080 },
offsetHeight: { configurable: true, get: () => 1920 },
});
video.getBoundingClientRect = () =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: 1080,
bottom: 1920,
width: 1080,
height: 1920,
toJSON: () => ({}),
}) as DOMRect;
const styles = new Map<Element, StyleLike>();
styles.set(host, hostStyle);
styles.set(pipFrame, {});
styles.set(video, {
opacity: "1",
objectFit: "cover",
objectPosition: "center",
zIndex: "1",
...options.videoStyle,
});
Object.defineProperty(window, "getComputedStyle", {
configurable: true,
value: (el: Element) => {
const declared = styles.get(el) ?? {};
return {
display: declared.display ?? "block",
visibility: declared.visibility ?? "visible",
opacity: declared.opacity ?? "1",
objectFit: declared.objectFit ?? "fill",
objectPosition: declared.objectPosition ?? "50% 50%",
zIndex: declared.zIndex ?? "auto",
getPropertyValue: (prop: string) => {
const camel = prop.replace(/-([a-z])/g, (_, c: string) =>
c.toUpperCase(),
) as keyof StyleLike;
return declared[camel] ?? "";
},
};
},
});
return { window, document, video, host, pipFrame };
}
function withGlobals<T extends { window: Window; document: Document; video: HTMLVideoElement }>(
setup: T,
): { teardown: () => void; setup: T } {
const globals = globalThis as unknown as { window?: Window; document?: Document };
const previousWindow = globals.window;
const previousDocument = globals.document;
globals.window = setup.window;
globals.document = setup.document;
return {
setup,
teardown: () => {
globals.window = previousWindow;
globals.document = previousDocument;
},
};
}
function passthroughPage(): Page {
return {
evaluate: async (fn: (...args: unknown[]) => unknown, ...args: unknown[]) =>
// The implementation is built to run inside the page sandbox via
// `page.evaluate`, but linkedom gives us a DOM compatible enough to
// execute the function body directly in Node.
Promise.resolve((fn as (...a: unknown[]) => unknown)(...args)),
} as unknown as Page;
}
function installDomMaskGlobals(setup: { window: Window; document: Document }): () => void {
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 = setup.window;
globals.document = setup.document;
globals.HTMLElement = setup.window.HTMLElement;
globals.CSS = { escape: (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "\\$&") } as CSS;
return () => {
globals.window = previousWindow;
globals.document = previousDocument;
globals.HTMLElement = previousHTMLElement;
globals.CSS = previousCSS;
};
}
it("skips replacement-frame creation when the video's host has visibility:hidden", async () => {
const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
try {
await injectVideoFramesBatch(passthroughPage(), [
{
videoId: "pip",
dataUri:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
},
]);
} finally {
teardown();
}
// No replacement <img> should be injected next to the video — the host is
// currently hidden, so painting a frame over it would bleed onto whichever
// sibling host is actually visible on this seek.
const sibling = setup.video.nextElementSibling as HTMLElement | null;
expect(sibling).toBeNull();
});
it("skips replacement-frame creation when the video's host has display:none", async () => {
const { teardown, setup } = withGlobals(setupHostHiddenScenario({ display: "none" }));
try {
await injectVideoFramesBatch(passthroughPage(), [
{
videoId: "pip",
dataUri:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
},
]);
} finally {
teardown();
}
const sibling = setup.video.nextElementSibling as HTMLElement | null;
expect(sibling).toBeNull();
});
it("hides an existing replacement <img> when the host becomes visibility:hidden", async () => {
// First seed an existing __render_frame__ <img> next to the video (the
// state the page is in after a previous seek when the host was visible).
const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
const seededImg = setup.document.createElement("img");
seededImg.classList.add("__render_frame__");
seededImg.style.visibility = "visible";
setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
try {
await injectVideoFramesBatch(passthroughPage(), [
{
videoId: "pip",
dataUri:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
},
]);
} finally {
teardown();
}
expect(seededImg.style.visibility).toBe("hidden");
});
it("syncVideoFrameVisibility hides the replacement <img> for ancestor-hidden actives", async () => {
const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
const seededImg = setup.document.createElement("img");
seededImg.classList.add("__render_frame__");
seededImg.style.visibility = "visible";
setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
try {
// "pip" IS in the active set (per the raw time-window check) but the
// host is hidden. sync must keep the <img> hidden, not flip it to
// `visibility: visible`.
await syncVideoFrameVisibility(passthroughPage(), ["pip"]);
} finally {
teardown();
}
expect(seededImg.style.visibility).toBe("hidden");
});
it("still injects when a plain [data-start] host is visibility:hidden (CSS-escapable)", async () => {
// Regression guard for the style-9-prod symptom: a regular
// `[data-start]` container whose GSAP timeline is shorter than its
// authored `data-duration` ends up `visibility: hidden` past the
// timeline end. The replacement <img>'s explicit `visibility: visible`
// correctly overrides that per CSS spec, so the injector must NOT
// short-circuit — it would otherwise drop the final-state frame and
// produce blank tail frames.
const { teardown, setup } = withGlobals(
setupHostHiddenScenario({ visibility: "hidden" }, { hostAttribute: "data-start" }),
);
try {
await injectVideoFramesBatch(passthroughPage(), [
{
videoId: "pip",
dataUri:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
},
]);
} finally {
teardown();
}
const sibling = setup.video.nextElementSibling as HTMLElement | null;
expect(sibling).not.toBeNull();
expect(sibling?.classList.contains("__render_frame__")).toBe(true);
expect(sibling?.style.visibility).toBe("visible");
});
it("does not copy color-grading source suppression opacity to the injected frame", async () => {
const { teardown, setup } = withGlobals(
setupHostHiddenScenario({}, { videoStyle: { opacity: "0" } }),
);
setup.video.setAttribute("data-hf-color-grading-source-hidden", "true");
try {
await injectVideoFramesBatch(passthroughPage(), [
{
videoId: "pip",
dataUri:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
},
]);
} finally {
teardown();
}
const sibling = setup.video.nextElementSibling as HTMLElement | null;
expect(sibling?.classList.contains("__render_frame__")).toBe(true);
expect(sibling?.style.opacity).toBe("1");
});
it("repairs stale injected-frame opacity while syncing color-graded active videos", async () => {
const { teardown, setup } = withGlobals(setupHostHiddenScenario({}));
setup.video.setAttribute("data-hf-color-grading-source-hidden", "true");
const seededImg = setup.document.createElement("img");
seededImg.classList.add("__render_frame__");
seededImg.style.opacity = "0";
setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
try {
await syncVideoFrameVisibility(passthroughPage(), ["pip"]);
} finally {
teardown();
}
expect(seededImg.style.opacity).toBe("1");
expect(seededImg.style.visibility).toBe("visible");
});
it("syncVideoFrameVisibility shows the replacement <img> when a plain [data-start] host is visibility:hidden", async () => {
const { teardown, setup } = withGlobals(
setupHostHiddenScenario({ visibility: "hidden" }, { hostAttribute: "data-start" }),
);
const seededImg = setup.document.createElement("img");
seededImg.classList.add("__render_frame__");
seededImg.style.visibility = "hidden";
setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
try {
await syncVideoFrameVisibility(passthroughPage(), ["pip"]);
} finally {
teardown();
}
// The host's `visibility: hidden` is escapable; sync must flip the
// <img> to `visibility: visible` so it overrides the ancestor.
expect(seededImg.style.visibility).toBe("visible");
});
// Regression for the layered/HDR mask path: `applyDomLayerMask` writes an
// `!important` stylesheet rule `#${showId} *{visibility:visible !important}`
// which, if a sub-comp host id appears in the show set, would revive a
// plain (non-important) inline `visibility: hidden` on a descendant
// `__render_frame__` — the cascade rule is "important stylesheet author
// beats non-important inline author". To stay safe regardless of which
// layer ends up in `show`, the ancestor-hidden hide must be written with
// `!important` so inline `!important` beats stylesheet `!important`.
//
// linkedom strips `!important` from `cssText`/`getPropertyPriority`, so we
// pin the contract on the API call site instead: a `setProperty(name,
// value, "important")` invocation on the live `<img>`'s style.
it("injectVideoFramesBatch hides a stale <img> with !important so the layer mask cannot revive it", async () => {
const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
const seededImg = setup.document.createElement("img");
seededImg.classList.add("__render_frame__");
seededImg.style.visibility = "visible";
setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
const setPropertySpy = vi.spyOn(seededImg.style, "setProperty");
try {
await injectVideoFramesBatch(passthroughPage(), [
{
videoId: "pip",
dataUri:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
},
]);
} finally {
teardown();
}
expect(seededImg.style.visibility).toBe("hidden");
expect(setPropertySpy).toHaveBeenCalledWith("visibility", "hidden", "important");
});
it("syncVideoFrameVisibility hides an existing <img> with !important so the layer mask cannot revive it", async () => {
const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
const seededImg = setup.document.createElement("img");
seededImg.classList.add("__render_frame__");
seededImg.style.visibility = "visible";
setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
const setPropertySpy = vi.spyOn(seededImg.style, "setProperty");
try {
await syncVideoFrameVisibility(passthroughPage(), ["pip"]);
} finally {
teardown();
}
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 teardown = installDomMaskGlobals({ window, document });
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 {
teardown();
}
});
it("removeDomLayerMask keeps hidden timed descendants hidden when they are also extraHideIds", async () => {
const { window, document } = parseHTML(
`<html><head></head><body>
<div id="scene" data-start="0" data-duration="6">
<div id="caption" data-start="4.5" data-duration="1.5">late label</div>
</div>
</body></html>`,
);
const caption = document.getElementById("caption") as HTMLElement;
caption.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 teardown = installDomMaskGlobals({ window, document });
try {
await applyDomLayerMask(passthroughPage(), ["scene"], ["caption"]);
await removeDomLayerMask(passthroughPage(), ["caption"]);
expect(caption.style.visibility).toBe("hidden");
expect(caption.hasAttribute("data-hf-dom-layer-mask-hidden")).toBe(false);
} finally {
teardown();
}
});
it("removeDomLayerMask restores extraHideIds and render frames to previous visibility", async () => {
const { window, document } = parseHTML(
`<html><head></head><body>
<div id="visible-caption" data-start="0" data-duration="1">current</div>
<video id="clip" data-start="0" data-duration="1"></video>
<img id="__render_frame_clip__" />
</body></html>`,
);
const visibleCaption = document.getElementById("visible-caption") as HTMLElement;
const clip = document.getElementById("clip") as HTMLElement;
const renderFrame = document.getElementById("__render_frame_clip__") as HTMLElement;
visibleCaption.style.visibility = "visible";
clip.style.visibility = "hidden";
renderFrame.style.setProperty("visibility", "hidden", "important");
Object.defineProperty(window, "getComputedStyle", {
configurable: true,
value: (el: Element) => ({
display: (el as HTMLElement).style.display || "block",
visibility: (el as HTMLElement).style.visibility || "visible",
}),
});
const teardown = installDomMaskGlobals({ window, document });
try {
await applyDomLayerMask(passthroughPage(), [], ["visible-caption", "clip"]);
await removeDomLayerMask(passthroughPage(), ["visible-caption", "clip"]);
expect(visibleCaption.style.visibility).toBe("visible");
expect(clip.style.visibility).toBe("hidden");
expect(renderFrame.style.visibility).toBe("hidden");
} finally {
teardown();
}
});
it("applyDomLayerMask carries color grading canvases with their media element", async () => {
const { window, document } = parseHTML(
'<html><head></head><body><div id="root"><video id="pip"></video><canvas id="__hf_color_grading_pip"></canvas></div></body></html>',
);
const teardown = installDomMaskGlobals({ window, document });
try {
await applyDomLayerMask(passthroughPage(), ["pip"], []);
expect(document.getElementById(DOM_LAYER_MASK_STYLE_ID)?.textContent).toContain(
"#__hf_color_grading_pip",
);
await applyDomLayerMask(passthroughPage(), ["root"], ["pip"]);
const canvas = document.getElementById("__hf_color_grading_pip") as HTMLCanvasElement;
expect(canvas.style.visibility).toBe("hidden");
await removeDomLayerMask(passthroughPage(), ["pip"]);
expect(canvas.style.getPropertyValue("visibility") || "").toBe("");
} finally {
teardown();
}
});
});
describe("ensureRenderFrameSiblings", () => {
function passthroughPage(): Page {
return {
evaluate: async (fn: (...args: unknown[]) => unknown, ...args: unknown[]) =>
Promise.resolve((fn as (...a: unknown[]) => unknown)(...args)),
} as unknown as Page;
}
function withGlobalDom(setup: { window: Window; document: Document }): { teardown: () => void } {
const globals = globalThis as unknown as { window?: Window; document?: Document };
const previousWindow = globals.window;
const previousDocument = globals.document;
globals.window = setup.window;
globals.document = setup.document;
return {
teardown: () => {
globals.window = previousWindow;
globals.document = previousDocument;
},
};
}
it("creates a hidden __render_frame__ sibling for every video[data-start]", async () => {
const { window, document } = parseHTML(
`<html><body>
<div id="root">
<video id="v1" data-start="0" data-duration="2"></video>
<video id="v2" data-start="2" data-duration="2"></video>
</div>
</body></html>`,
);
const { teardown } = withGlobalDom({ window, document });
try {
await ensureRenderFrameSiblings(passthroughPage());
} finally {
teardown();
}
for (const id of ["v1", "v2"]) {
const video = document.getElementById(id) as HTMLVideoElement;
const sibling = video.nextElementSibling as HTMLElement | null;
expect(sibling).not.toBeNull();
expect(sibling?.tagName.toLowerCase()).toBe("img");
expect(sibling?.classList.contains("__render_frame__")).toBe(true);
expect(sibling?.id).toBe(`__render_frame_${id}__`);
expect(sibling?.style.visibility).toBe("hidden");
}
});
it("skips videos that already have a __render_frame__ sibling", async () => {
const { window, document } = parseHTML(
`<html><body>
<div id="root">
<video id="clip" data-start="0" data-duration="2"></video>
<img id="__render_frame_clip__" class="__render_frame__">
</div>
</body></html>`,
);
const preExisting = document.getElementById("__render_frame_clip__") as HTMLImageElement;
const { teardown } = withGlobalDom({ window, document });
try {
await ensureRenderFrameSiblings(passthroughPage());
} finally {
teardown();
}
const video = document.getElementById("clip") as HTMLVideoElement;
expect(video.nextElementSibling).toBe(preExisting);
expect(document.querySelectorAll(".__render_frame__").length).toBe(1);
});
it("does not touch <video> elements without data-start", async () => {
const { window, document } = parseHTML(
`<html><body><div id="root"><video id="raw"></video></div></body></html>`,
);
const { teardown } = withGlobalDom({ window, document });
try {
await ensureRenderFrameSiblings(passthroughPage());
} finally {
teardown();
}
const video = document.getElementById("raw") as HTMLVideoElement;
expect(video.nextElementSibling).toBeNull();
});
});