diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 89a26cbeb..8df56e23a 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -71,6 +71,22 @@ function createManualRaf() { }; } +function withStudioIframe(run: () => void): void { + const originalParent = window.parent; + Object.defineProperty(window, "parent", { + configurable: true, + value: {}, + }); + try { + run(); + } finally { + Object.defineProperty(window, "parent", { + configurable: true, + value: originalParent, + }); + } +} + describe("initSandboxRuntimeModular", () => { const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; @@ -413,6 +429,207 @@ describe("initSandboxRuntimeModular", () => { expect(sceneB.style.visibility).toBe("visible"); }); + it("hides GSAP tween targets inside a hidden timed clip (issue #1387)", () => { + withStudioIframe(() => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "8"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const captionOne = document.createElement("div"); + captionOne.id = "t01"; + captionOne.setAttribute("data-start", "0"); + captionOne.setAttribute("data-duration", "4"); + root.appendChild(captionOne); + + const lineOne = document.createElement("div"); + lineOne.className = "line"; + // Studio stamps full-duration pseudo-clips on GSAP tween targets. + lineOne.setAttribute("data-start", "0"); + lineOne.setAttribute("data-duration", "8"); + captionOne.appendChild(lineOne); + + const captionTwo = document.createElement("div"); + captionTwo.id = "t02"; + captionTwo.setAttribute("data-start", "4"); + captionTwo.setAttribute("data-duration", "4"); + root.appendChild(captionTwo); + + const lineTwo = document.createElement("div"); + lineTwo.className = "line"; + lineTwo.setAttribute("data-start", "0"); + lineTwo.setAttribute("data-duration", "8"); + captionTwo.appendChild(lineTwo); + + window.__timelines = { + main: createMockTimeline(8), + }; + + initSandboxRuntimeModular(); + + const player = window.__player; + expect(player).toBeDefined(); + + player?.seek(1); + + expect(captionOne.style.visibility).toBe("visible"); + expect(lineOne.style.visibility).toBe("visible"); + expect(captionTwo.style.visibility).toBe("hidden"); + expect(lineTwo.style.visibility).toBe("hidden"); + + player?.seek(5); + + expect(captionOne.style.visibility).toBe("hidden"); + expect(lineOne.style.visibility).toBe("hidden"); + expect(captionTwo.style.visibility).toBe("visible"); + expect(lineTwo.style.visibility).toBe("visible"); + }); + }); + + it("does not suppress descendant visibility in render mode (top-level page)", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "8"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const panel = document.createElement("div"); + panel.id = "panel"; + panel.setAttribute("data-start", "0"); + 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); + + window.__timelines = { + main: createMockTimeline(8), + }; + + initSandboxRuntimeModular(); + + const player = window.__player; + expect(player).toBeDefined(); + + player?.seek(3); + + expect(panel.style.visibility).toBe("hidden"); + expect(headline.style.visibility).toBe("visible"); + }); + + it("does not stamp Studio timing on GSAP targets inside authored timed clips", () => { + withStudioIframe(() => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "8"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const caption = document.createElement("div"); + caption.id = "t01"; + caption.setAttribute("data-start", "0"); + caption.setAttribute("data-duration", "4"); + root.appendChild(caption); + + const line = document.createElement("div"); + line.className = "line"; + caption.appendChild(line); + + const tweenTarget = { + targets: () => [line], + }; + const timeline = createMockTimeline(8) as RuntimeTimelineLike & { + getChildren: (nested?: boolean) => Array<{ targets: () => Element[] }>; + }; + timeline.getChildren = () => [tweenTarget]; + + window.__timelines = { + main: timeline, + }; + + initSandboxRuntimeModular(); + + expect(line.hasAttribute("data-start")).toBe(false); + expect(line.hasAttribute("data-duration")).toBe(false); + }); + }); + + it("hides tween targets inside inactive multi-panel beats (niemmo panel stack)", () => { + withStudioIframe(() => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "niemmo-launch-50"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "50"); + root.setAttribute("data-width", "1280"); + root.setAttribute("data-height", "720"); + document.body.appendChild(root); + + const panelA = document.createElement("div"); + panelA.className = "panel clip"; + panelA.setAttribute("data-composition-id", "cold-open"); + panelA.setAttribute("data-start", "0"); + panelA.setAttribute("data-duration", "2"); + root.appendChild(panelA); + + const headlineA = document.createElement("h1"); + headlineA.className = "co-headline"; + headlineA.setAttribute("data-start", "0"); + headlineA.setAttribute("data-duration", "50"); + panelA.appendChild(headlineA); + + const panelB = document.createElement("div"); + panelB.className = "panel clip"; + panelB.setAttribute("data-composition-id", "problem-dev-beat"); + panelB.setAttribute("data-start", "2"); + panelB.setAttribute("data-duration", "2.5"); + root.appendChild(panelB); + + const headlineB = document.createElement("h1"); + headlineB.className = "pb-headline"; + headlineB.setAttribute("data-start", "0"); + headlineB.setAttribute("data-duration", "50"); + panelB.appendChild(headlineB); + + window.__timelines = { + "niemmo-launch-50": createMockTimeline(50), + }; + + initSandboxRuntimeModular(); + + const player = window.__player; + expect(player).toBeDefined(); + + player?.seek(1); + + expect(panelA.style.visibility).toBe("visible"); + expect(headlineA.style.visibility).toBe("visible"); + expect(panelB.style.visibility).toBe("hidden"); + expect(headlineB.style.visibility).toBe("hidden"); + + player?.seek(3); + + expect(panelA.style.visibility).toBe("hidden"); + expect(headlineA.style.visibility).toBe("hidden"); + expect(panelB.style.visibility).toBe("visible"); + expect(headlineB.style.visibility).toBe("visible"); + }); + }); + it("clamps nested media to the authored host window on seek", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 641301b52..f7822c7c2 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -397,6 +397,67 @@ export function initSandboxRuntimeModular(): void { return resolveStartForElement(element, fallback); }; + const findTimedClipAncestor = ( + element: HTMLElement, + rootComp: HTMLElement | null, + ): HTMLElement | null => { + let node = element.parentElement; + while (node) { + // rootComp may be null when no composition is mounted; the walk still + // terminates via `while (node)` — node === null is never true here. + if (node === rootComp) break; + if (node.hasAttribute("data-start")) { + return node; + } + node = node.parentElement; + } + return null; + }; + + const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => { + const tag = rawNode.tagName.toLowerCase(); + if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") { + return false; + } + + const start = + tag === "video" || tag === "audio" + ? resolveMediaStartSeconds(rawNode, 0) + : resolveStartForElement(rawNode, 0); + let duration = resolveDurationForElement(rawNode); + const compId = rawNode.getAttribute("data-composition-id"); + if (compId) { + const compTimeline = (window.__timelines ?? {})[compId]; + let liveDuration: number | null = null; + if (compTimeline && typeof compTimeline.duration === "function") { + const compDur = Number(compTimeline.duration()); + if (Number.isFinite(compDur) && compDur > 0) { + liveDuration = compDur; + } + } + + const usesExternalCompositionSlot = + rawNode.hasAttribute("data-composition-src") || + rawNode.hasAttribute("data-composition-file"); + + if ( + duration != null && + duration > 0 && + liveDuration != null && + !usesExternalCompositionSlot + ) { + duration = Math.min(duration, liveDuration); + } else if ((duration == null || duration <= 0) && liveDuration != null) { + duration = liveDuration; + } + } + const computedEnd = + duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY; + return ( + currentTime >= start && (Number.isFinite(computedEnd) ? currentTime <= computedEnd : true) + ); + }; + const hasExternalCompositions = !!document.querySelector("[data-composition-src]"); let hasInlineTemplateCompositions = false; { @@ -1008,6 +1069,7 @@ export function initSandboxRuntimeModular(): void { if (!(target instanceof HTMLElement)) continue; if (target === rootComp) continue; if (target.hasAttribute("data-start")) continue; + if (findTimedClipAncestor(target, rootComp)) continue; if (seen.has(target)) continue; seen.add(target); target.setAttribute("data-start", "0"); @@ -1027,6 +1089,7 @@ export function initSandboxRuntimeModular(): void { if (!(el instanceof HTMLElement)) continue; if (el === rootComp) continue; if (el.hasAttribute("data-start")) continue; + if (findTimedClipAncestor(el, rootComp)) continue; if (seen.has(el)) continue; if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue; seen.add(el); @@ -1434,51 +1497,28 @@ export function initSandboxRuntimeModular(): void { }, }); const visibilityNodes = Array.from(document.querySelectorAll("[data-start]")); + const rootComp = resolveRootCompositionElement(); for (const rawNode of visibilityNodes) { if (!(rawNode instanceof HTMLElement)) continue; - const tag = rawNode.tagName.toLowerCase(); - if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue; - const start = - tag === "video" || tag === "audio" - ? resolveMediaStartSeconds(rawNode, 0) - : resolveStartForElement(rawNode, 0); - let duration = resolveDurationForElement(rawNode); - const compId = rawNode.getAttribute("data-composition-id"); - if (compId) { - const compTimeline = (window.__timelines ?? {})[compId]; - let liveDuration: number | null = null; - if (compTimeline && typeof compTimeline.duration === "function") { - const compDur = Number(compTimeline.duration()); - if (Number.isFinite(compDur) && compDur > 0) { - liveDuration = compDur; + 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. + let ancestor = rawNode.parentElement; + while (ancestor) { + if (ancestor === rootComp) break; + if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) { + if (!isTimedElementVisibleAt(ancestor, state.currentTime)) { + isVisibleNow = false; + break; + } } - } - - const usesExternalCompositionSlot = - rawNode.hasAttribute("data-composition-src") || - rawNode.hasAttribute("data-composition-file"); - - // Generic child compositions retain legacy behavior and respect both - // the authored parent clip window and the live child timeline duration. - // External composition hosts render into an authored slot, so a shorter - // child timeline should hold its final state through that slot. - if ( - duration != null && - duration > 0 && - liveDuration != null && - !usesExternalCompositionSlot - ) { - duration = Math.min(duration, liveDuration); - } else if ((duration == null || duration <= 0) && liveDuration != null) { - duration = liveDuration; + ancestor = ancestor.parentElement; } } - const computedEnd = - duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY; - const isVisibleNow = - state.currentTime >= start && - (Number.isFinite(computedEnd) ? state.currentTime <= computedEnd : true); rawNode.style.visibility = isVisibleNow ? "visible" : "hidden"; } };