diff --git a/packages/engine/src/services/screenshotService.test.ts b/packages/engine/src/services/screenshotService.test.ts index 9fb4a2416..e6f96d4c6 100644 --- a/packages/engine/src/services/screenshotService.test.ts +++ b/packages/engine/src/services/screenshotService.test.ts @@ -202,6 +202,17 @@ describe("video-frame injection respects ancestor visibility", () => { // 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 '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; @@ -212,9 +223,19 @@ describe("video-frame injection respects ancestor visibility", () => { zIndex?: string; }; - function setupHostHiddenScenario(hostStyle: StyleLike) { + type HostAttribute = "data-composition-src" | "data-composition-file" | "data-start"; + + function setupHostHiddenScenario( + hostStyle: StyleLike, + options: { hostAttribute?: HostAttribute } = {}, + ) { + 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( - '
', + `
`, ); Object.defineProperty(window.HTMLImageElement.prototype, "decode", { @@ -382,4 +403,54 @@ describe("video-frame injection respects ancestor visibility", () => { 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 '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("syncVideoFrameVisibility shows the replacement 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 + // to `visibility: visible` so it overrides the ancestor. + expect(seededImg.style.visibility).toBe("visible"); + }); }); diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts index 2219c36f6..a73423f87 100644 --- a/packages/engine/src/services/screenshotService.ts +++ b/packages/engine/src/services/screenshotService.ts @@ -386,19 +386,40 @@ export async function injectVideoFramesBatch( "bottom", "inset", ]); - // Walk ancestors looking for a host that the page has hidden via - // `display:none` or `visibility:hidden`. The runtime hides - // `[data-composition-src]` and `[data-start]` hosts that fall outside - // their time window using exactly these properties; a nested - // `