fix(engine): skip video frame injection when a visual ancestor is hidden

`injectVideoFramesBatch` and `syncVideoFrameVisibility` iterate every
`video[data-start]` whose raw time window covers the current seek.
Inner `<video>` elements inside `[data-composition-src]`
sub-compositions get `data-start="0"` auto-injected by
`compileTimingAttrs` and probed-duration cover the entire timeline,
so they look "active" even when their host has not yet started.

When the runtime then hides the host with `visibility: hidden` (its
out-of-window lifecycle), the inner video inherits hidden via the CSS
cascade — but our injector responded by painting a replacement
`<img class="__render_frame__" style="visibility: visible">` next to
the video. `visibility: visible` on the descendant defeats the parent
`visibility: hidden` cascade, and because the host has not been
morphed by GSAP yet the video's bounding box is its CSS default
(usually full-bleed). The result is one full-bleed frame per inactive
sub-comp painted over whichever moment is *actually* visible — the
overlay symptom the upstream agentic-finecut project saw.

Walk ancestors in both functions; if any has `display: none` or
`visibility: hidden`, skip the inject and hide any stale
`__render_frame__` sibling. The render is now correctly empty for
hidden hosts, which is what the surrounding CSS cascade already
intends.

Tests:
- `screenshotService.test.ts`: cover the new guard for both
  visibility:hidden and display:none hosts, both for the fresh-img and
  the stale-img paths, plus `syncVideoFrameVisibility` for the case
  where the time window calls a video "active" but a hidden ancestor
  still requires its frame to stay hidden. Each test fails against
  pre-fix `screenshotService.ts`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lirian Su
2026-05-26 00:37:13 -04:00
committed by Miguel Ángel
co-authored by Claude Opus 4.7
parent 60cb9552e4
commit 3700cc2a16
2 changed files with 240 additions and 4 deletions
@@ -386,12 +386,39 @@ 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
// `<video data-start>` inside such a host still appears "active" in the
// raw time-window check (its own `data-start`/`data-end` cover the
// whole clip), so without this guard we would paint a full-bleed
// replacement frame over a sibling host that *is* visible.
const isVisualAncestorHidden = (el: HTMLElement): boolean => {
let parent = el.parentElement;
while (parent !== null && parent !== document.documentElement) {
const computed = window.getComputedStyle(parent);
if (computed.display === "none" || computed.visibility === "hidden") return true;
parent = parent.parentElement;
}
return false;
};
for (const item of items) {
const video = document.getElementById(item.videoId) as HTMLVideoElement | null;
if (!video) continue;
let img = video.nextElementSibling as HTMLImageElement | null;
const isNewImage = !img || !img.classList.contains("__render_frame__");
const hasImg = img !== null && img.classList.contains("__render_frame__");
if (isVisualAncestorHidden(video)) {
// Don't paint a frame over a hidden host — if an existing replacement
// <img> is still around from when the host was visible, hide it so it
// doesn't bleed through a sibling host that *is* visible on this seek.
if (hasImg && img) img.style.visibility = "hidden";
continue;
}
const isNewImage = !hasImg;
const computedStyle = window.getComputedStyle(video);
// Read the GSAP-controlled opacity directly from the native <video>.
// We hide the <video> below with `visibility: hidden` only (never
@@ -489,12 +516,28 @@ export async function syncVideoFrameVisibility(
activeVideoIds: string[],
): Promise<void> {
await page.evaluate((ids: string[]) => {
// Mirror the ancestor-visibility guard from `injectVideoFramesBatch`: a
// video whose host is `display:none` / `visibility:hidden` (e.g., a
// sub-composition that the runtime has marked out-of-window) must not
// have its replacement <img> reach `visibility:visible` here, otherwise
// it would paint through the hidden host onto whichever sibling host is
// currently visible.
const isVisualAncestorHidden = (el: HTMLElement): boolean => {
let parent = el.parentElement;
while (parent !== null && parent !== document.documentElement) {
const computed = window.getComputedStyle(parent);
if (computed.display === "none" || computed.visibility === "hidden") return true;
parent = parent.parentElement;
}
return false;
};
const active = new Set(ids);
const videos = Array.from(document.querySelectorAll("video[data-start]")) as HTMLVideoElement[];
for (const video of videos) {
const img = video.nextElementSibling as HTMLElement | null;
const hasImg = img && img.classList.contains("__render_frame__");
if (active.has(video.id)) {
const ancestorHidden = isVisualAncestorHidden(video);
if (active.has(video.id) && !ancestorHidden) {
// Active video: show injected <img>, hide native <video>.
// Do NOT clobber inline opacity here — GSAP-controlled opacity must
// survive until injectVideoFramesBatch reads it via getComputedStyle.
@@ -506,8 +549,8 @@ export async function syncVideoFrameVisibility(
img.style.visibility = "visible";
}
} else {
// Inactive video: hide both. Use visibility only (never opacity) so we
// never clobber GSAP-controlled inline opacity.
// Inactive (or ancestor-hidden) video: hide both. Use visibility only
// (never opacity) so we never clobber GSAP-controlled inline opacity.
video.style.removeProperty("display");
video.style.setProperty("visibility", "hidden", "important");
video.style.setProperty("pointer-events", "none", "important");