Skip to content

Commit f3bb6dc

Browse files
liriansu-opusmiguel-heygen
authored andcommitted
fix(engine): narrow visibility:hidden ancestor skip to sub-comp hosts
`isVisualAncestorHidden` was treating any `visibility: hidden` ancestor as a signal to skip injecting the replacement frame. That's too broad — for plain `[data-start]` containers, the replacement `<img>`'s explicit `visibility: visible` correctly overrides the ancestor per 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 (e.g. `style-9-prod`, where the runtime truncates the host to `visibility: hidden` after the timeline ends and the replacement frame must paint through). Restrict the `visibility: hidden` skip to ancestors that carry `data-composition-src` or `data-composition-file` — the actual sub-composition hosts this guard was added for. `display: none` keeps the broad behavior: it takes the whole subtree out of layout and a child override cannot escape. Update the existing regression suite to mark the host as a sub-composition, and add two new cases pinning the plain-`[data-start]` behavior: both `injectVideoFramesBatch` and `syncVideoFrameVisibility` must still produce a visible replacement `<img>` when the host is `visibility: hidden` but does not carry a sub-composition attribute.
1 parent 68ade66 commit f3bb6dc

2 files changed

Lines changed: 115 additions & 18 deletions

File tree

packages/engine/src/services/screenshotService.test.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,17 @@ describe("video-frame injection respects ancestor visibility", () => {
202202
// injected `data-start="0"` + probed full-source duration cover the
203203
// whole timeline), so the bug produced one full-bleed speaker overlay
204204
// per inactive sub-comp — covering whichever moment was actually visible.
205+
//
206+
// The skip is intentionally narrow: `visibility:hidden` on a regular
207+
// `[data-start]` container must NOT skip injection, because the
208+
// replacement <img>'s explicit `visibility:visible` overrides the
209+
// ancestor (CSS spec) and consumers rely on that to hold the final
210+
// GSAP-driven frame when an authored `data-duration` outlives the
211+
// composition's GSAP timeline. We therefore only treat
212+
// `visibility:hidden` as a skip signal on sub-composition hosts
213+
// (`[data-composition-src]` / `[data-composition-file]`). `display:none`,
214+
// by contrast, takes the whole subtree out of layout regardless of any
215+
// child override, so it always triggers the skip.
205216

206217
type StyleLike = {
207218
display?: string;
@@ -212,9 +223,19 @@ describe("video-frame injection respects ancestor visibility", () => {
212223
zIndex?: string;
213224
};
214225

215-
function setupHostHiddenScenario(hostStyle: StyleLike) {
226+
type HostAttribute = "data-composition-src" | "data-composition-file" | "data-start";
227+
228+
function setupHostHiddenScenario(
229+
hostStyle: StyleLike,
230+
options: { hostAttribute?: HostAttribute } = {},
231+
) {
232+
const hostAttribute = options.hostAttribute ?? "data-composition-src";
233+
const hostAttrMarkup =
234+
hostAttribute === "data-start"
235+
? 'data-start="0" data-duration="10"'
236+
: `${hostAttribute}="sub.html"`;
216237
const { window, document } = parseHTML(
217-
'<html><body><div id="host"><div id="pip-frame"><video id="pip" data-start="0" data-duration="10"></video></div></div></body></html>',
238+
`<html><body><div id="host" ${hostAttrMarkup}><div id="pip-frame"><video id="pip" data-start="0" data-duration="10"></video></div></div></body></html>`,
218239
);
219240

220241
Object.defineProperty(window.HTMLImageElement.prototype, "decode", {
@@ -382,4 +403,54 @@ describe("video-frame injection respects ancestor visibility", () => {
382403

383404
expect(seededImg.style.visibility).toBe("hidden");
384405
});
406+
407+
it("still injects when a plain [data-start] host is visibility:hidden (CSS-escapable)", async () => {
408+
// Regression guard for the style-9-prod symptom: a regular
409+
// `[data-start]` container whose GSAP timeline is shorter than its
410+
// authored `data-duration` ends up `visibility: hidden` past the
411+
// timeline end. The replacement <img>'s explicit `visibility: visible`
412+
// correctly overrides that per CSS spec, so the injector must NOT
413+
// short-circuit — it would otherwise drop the final-state frame and
414+
// produce blank tail frames.
415+
const { teardown, setup } = withGlobals(
416+
setupHostHiddenScenario({ visibility: "hidden" }, { hostAttribute: "data-start" }),
417+
);
418+
419+
try {
420+
await injectVideoFramesBatch(passthroughPage(), [
421+
{
422+
videoId: "pip",
423+
dataUri:
424+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
425+
},
426+
]);
427+
} finally {
428+
teardown();
429+
}
430+
431+
const sibling = setup.video.nextElementSibling as HTMLElement | null;
432+
expect(sibling).not.toBeNull();
433+
expect(sibling?.classList.contains("__render_frame__")).toBe(true);
434+
expect(sibling?.style.visibility).toBe("visible");
435+
});
436+
437+
it("syncVideoFrameVisibility shows the replacement <img> when a plain [data-start] host is visibility:hidden", async () => {
438+
const { teardown, setup } = withGlobals(
439+
setupHostHiddenScenario({ visibility: "hidden" }, { hostAttribute: "data-start" }),
440+
);
441+
const seededImg = setup.document.createElement("img");
442+
seededImg.classList.add("__render_frame__");
443+
seededImg.style.visibility = "hidden";
444+
setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
445+
446+
try {
447+
await syncVideoFrameVisibility(passthroughPage(), ["pip"]);
448+
} finally {
449+
teardown();
450+
}
451+
452+
// The host's `visibility: hidden` is escapable; sync must flip the
453+
// <img> to `visibility: visible` so it overrides the ancestor.
454+
expect(seededImg.style.visibility).toBe("visible");
455+
});
385456
});

packages/engine/src/services/screenshotService.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -386,19 +386,40 @@ export async function injectVideoFramesBatch(
386386
"bottom",
387387
"inset",
388388
]);
389-
// Walk ancestors looking for a host that the page has hidden via
390-
// `display:none` or `visibility:hidden`. The runtime hides
391-
// `[data-composition-src]` and `[data-start]` hosts that fall outside
392-
// their time window using exactly these properties; a nested
393-
// `<video data-start>` inside such a host still appears "active" in the
394-
// raw time-window check (its own `data-start`/`data-end` cover the
395-
// whole clip), so without this guard we would paint a full-bleed
396-
// replacement frame over a sibling host that *is* visible.
389+
// Walk ancestors looking for a host that the page has hidden. The
390+
// runtime hides `[data-composition-src]` and `[data-start]` hosts that
391+
// fall outside their time window; a nested `<video data-start>` inside
392+
// such a host still appears "active" in the raw time-window check (its
393+
// own `data-start`/`data-end` cover the whole clip), so without this
394+
// guard we would paint a full-bleed replacement frame over a sibling
395+
// host that *is* visible.
396+
//
397+
// `display: none` is always a skip signal — a `display: none` ancestor
398+
// takes its whole subtree out of layout, and a child `<img>` cannot
399+
// escape that. `visibility: hidden`, by contrast, is escapable: a
400+
// descendant with `visibility: visible` overrides an ancestor's
401+
// `visibility: hidden` per the CSS spec, and the replacement `<img>`
402+
// intentionally sets `visibility: visible`. We therefore only treat
403+
// `visibility: hidden` as a skip signal on sub-composition hosts
404+
// (`[data-composition-src]` / `[data-composition-file]`), which is the
405+
// scenario this guard exists for. Plain `[data-start]` containers may
406+
// be hidden with `visibility: hidden` while still wanting their inner
407+
// video's final-state frame to paint through (e.g. a GSAP timeline
408+
// shorter than the host's authored data-duration, where the runtime
409+
// truncates visibility but the replacement <img> must hold its last
410+
// frame) — those must NOT be skipped here.
397411
const isVisualAncestorHidden = (el: HTMLElement): boolean => {
398412
let parent = el.parentElement;
399413
while (parent !== null && parent !== document.documentElement) {
400414
const computed = window.getComputedStyle(parent);
401-
if (computed.display === "none" || computed.visibility === "hidden") return true;
415+
if (computed.display === "none") return true;
416+
if (
417+
computed.visibility === "hidden" &&
418+
(parent.hasAttribute("data-composition-src") ||
419+
parent.hasAttribute("data-composition-file"))
420+
) {
421+
return true;
422+
}
402423
parent = parent.parentElement;
403424
}
404425
return false;
@@ -516,17 +537,22 @@ export async function syncVideoFrameVisibility(
516537
activeVideoIds: string[],
517538
): Promise<void> {
518539
await page.evaluate((ids: string[]) => {
519-
// Mirror the ancestor-visibility guard from `injectVideoFramesBatch`: a
520-
// video whose host is `display:none` / `visibility:hidden` (e.g., a
521-
// sub-composition that the runtime has marked out-of-window) must not
522-
// have its replacement <img> reach `visibility:visible` here, otherwise
523-
// it would paint through the hidden host onto whichever sibling host is
524-
// currently visible.
540+
// Mirror the ancestor-visibility guard from `injectVideoFramesBatch`.
541+
// See that copy for the full rationale on why `visibility: hidden` is
542+
// narrowed to sub-composition hosts only — keep these two functions in
543+
// sync so the inactive-arm decision matches the inject-time decision.
525544
const isVisualAncestorHidden = (el: HTMLElement): boolean => {
526545
let parent = el.parentElement;
527546
while (parent !== null && parent !== document.documentElement) {
528547
const computed = window.getComputedStyle(parent);
529-
if (computed.display === "none" || computed.visibility === "hidden") return true;
548+
if (computed.display === "none") return true;
549+
if (
550+
computed.visibility === "hidden" &&
551+
(parent.hasAttribute("data-composition-src") ||
552+
parent.hasAttribute("data-composition-file"))
553+
) {
554+
return true;
555+
}
530556
parent = parent.parentElement;
531557
}
532558
return false;

0 commit comments

Comments
 (0)