fix: hide descendants of inactive render clips (#1662)

This commit is contained in:
Miguel Ángel
2026-06-22 23:05:08 -04:00
committed by GitHub
parent b651a9d218
commit 11d6227d06
6 changed files with 298 additions and 29 deletions
+41 -9
View File
@@ -116,7 +116,7 @@ describe("initSandboxRuntimeModular", () => {
window.cancelAnimationFrame = originalCancelAnimationFrame;
});
it("uses the shorter live child timeline when the authored window is longer", () => {
it("keeps authored composition hosts visible when the live child timeline is shorter", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
@@ -143,6 +143,37 @@ describe("initSandboxRuntimeModular", () => {
player?.renderSeek(9);
expect(child.style.visibility).toBe("visible");
});
it("uses live child timeline duration when a composition host has no authored duration", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "slide-1");
child.setAttribute("data-start", "0");
root.appendChild(child);
window.__timelines = {
main: createMockTimeline(20),
"slide-1": createMockTimeline(8),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
player?.renderSeek(7);
expect(child.style.visibility).toBe("visible");
player?.renderSeek(9);
expect(child.style.visibility).toBe("hidden");
});
@@ -491,7 +522,7 @@ describe("initSandboxRuntimeModular", () => {
});
});
it("does not suppress descendant visibility in render mode (top-level page)", () => {
it("hides timed descendants inside a hidden timed clip in render mode", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
@@ -507,12 +538,13 @@ describe("initSandboxRuntimeModular", () => {
panel.setAttribute("data-duration", "2");
root.appendChild(panel);
const headline = document.createElement("h1");
headline.className = "headline";
// Authored child window outlives the parent clip — render keeps legacy behavior.
headline.setAttribute("data-start", "0");
headline.setAttribute("data-duration", "8");
panel.appendChild(headline);
const bottomBand = document.createElement("div");
bottomBand.className = "bottom-band";
// Regression shape: a child strip outlives its parent scene. Without
// ancestor suppression it can paint through after the parent has ended.
bottomBand.setAttribute("data-start", "0");
bottomBand.setAttribute("data-duration", "8");
panel.appendChild(bottomBand);
window.__timelines = {
main: createMockTimeline(8),
@@ -526,7 +558,7 @@ describe("initSandboxRuntimeModular", () => {
player?.seek(3);
expect(panel.style.visibility).toBe("hidden");
expect(headline.style.visibility).toBe("visible");
expect(bottomBand.style.visibility).toBe("hidden");
});
it("does not stamp Studio timing on GSAP targets inside authored timed clips", () => {
+13 -20
View File
@@ -432,18 +432,13 @@ export function initSandboxRuntimeModular(): void {
}
}
const usesExternalCompositionSlot =
rawNode.hasAttribute("data-composition-src") ||
rawNode.hasAttribute("data-composition-file");
const hasAuthoredTiming =
rawNode.hasAttribute("data-duration") ||
rawNode.hasAttribute("data-end") ||
rawNode.hasAttribute(AUTHORED_DURATION_ATTR) ||
rawNode.hasAttribute(AUTHORED_END_ATTR);
if (
duration != null &&
duration > 0 &&
liveDuration != null &&
!usesExternalCompositionSlot
) {
duration = Math.min(duration, liveDuration);
} else if ((duration == null || duration <= 0) && liveDuration != null) {
if (!hasAuthoredTiming && (duration == null || duration <= 0) && liveDuration != null) {
duration = liveDuration;
}
}
@@ -1481,10 +1476,9 @@ export function initSandboxRuntimeModular(): void {
const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => {
const compositionRoot = element.closest("[data-composition-id]");
const inheritedStart = compositionRoot ? resolveStartForElement(compositionRoot, 0) : null;
// Media sync intentionally uses the authored host window here instead of
// the live child timeline duration. Visibility prefers live truth so a
// shrinking child composition hides early, but nested media needs a
// stable authored window so seeks clamp against the host clip timing.
// Media sync follows the authored host window, matching visibility for
// authored composition hosts. Live child timeline duration only fills in
// when no authored timing exists, so seeks clamp against host clip timing.
const inheritedDuration = compositionRoot
? resolveDurationForElement(compositionRoot, { includeAuthoredTimingAttrs: true })
: null;
@@ -1566,11 +1560,10 @@ export function initSandboxRuntimeModular(): void {
if (!(rawNode instanceof HTMLElement)) continue;
let isVisibleNow = isTimedElementVisibleAt(rawNode, state.currentTime);
// Studio-only defense-in-depth: pseudo-clips stamped on tween targets can
// get visibility:visible for the full composition. Render mode never stamps
// those targets, so keep the prior per-element visibility semantics there.
if (isVisibleNow && window.parent !== window) {
// Descendants must not override a hidden ancestor clip.
// Descendants must not override a hidden ancestor clip. CSS visibility can
// otherwise leak child pixels through inactive scenes because a descendant
// with visibility:visible escapes an ancestor's visibility:hidden.
if (isVisibleNow) {
let ancestor = rawNode.parentElement;
while (ancestor) {
if (ancestor === rootComp) break;