diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index f15b2a22a..4957b6ae3 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -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", () => { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 0bcaf6b25..d7db8c778 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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; diff --git a/packages/producer/tests/timed-descendant-visibility/meta.json b/packages/producer/tests/timed-descendant-visibility/meta.json new file mode 100644 index 000000000..f4f6c2fac --- /dev/null +++ b/packages/producer/tests/timed-descendant-visibility/meta.json @@ -0,0 +1,13 @@ +{ + "name": "Timed descendant visibility", + "description": "Regression for hidden timed clips leaking visible descendants during production render. A bottom band inside the first scene intentionally outlives its parent; render-time visibility sync must hide it once the parent scene becomes inactive.", + "tags": ["visibility", "regression", "runtime"], + "minPsnr": 45, + "maxFrameFailures": 0, + "minAudioCorrelation": 0, + "maxAudioLagWindows": 1, + "renderConfig": { + "fps": 30, + "workers": 1 + } +} diff --git a/packages/producer/tests/timed-descendant-visibility/output/compiled.html b/packages/producer/tests/timed-descendant-visibility/output/compiled.html new file mode 100644 index 000000000..fdb726ec3 --- /dev/null +++ b/packages/producer/tests/timed-descendant-visibility/output/compiled.html @@ -0,0 +1,109 @@ + + +
+ + + + + +