diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 1b034cf9e9c..afc1e36ee53 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -2870,7 +2870,12 @@ export function initSandboxRuntimeModular(): void { rootComp: HTMLElement | null, ) => { for (let node = from; node && node !== rootComp; node = node.parentElement) - if (isStylableElement(node) && node.hasAttribute("data-start") && !visibleAt(node, time)) + if ( + isStylableElement(node) && + node.hasAttribute("data-start") && + !isDiscoveryStamped(node) && + !visibleAt(node, time) + ) return false; return true; }; diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index 94da4663c00..f14d868cb4d 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_IMAGE_TIMELINE_DURATION_SECONDS } from "@hyperframes/parsers/media-duration"; import { describe, it, expect, afterEach } from "vitest"; -import { collectRuntimeTimelinePayload } from "./timeline"; +import { collectRuntimeTimelinePayload, runtimeElementWindow } from "./timeline"; +import { createRuntimeStartTimeResolver } from "./startResolver"; type TimelineTestWindow = Window & { __timelines?: Record number }>; @@ -1111,3 +1112,25 @@ describe("collectRuntimeTimelinePayload", () => { }); }); }); + +describe("runtimeElementWindow", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + // A root with no data-duration and a 2 s timeline under a 6 s video went black from 2 s. + it("ends a nested scene with its timeline, but never the root", () => { + document.body.innerHTML = `
+
`; + const timelineRegistry = { main: { duration: () => 2 }, scene: { duration: () => 2 } }; + const options = { timelineRegistry, resolver: createRuntimeStartTimeResolver({}) }; + expect(runtimeElementWindow(document.getElementById("main")!, options)).toEqual({ + start: 0, + end: Number.POSITIVE_INFINITY, + }); + expect(runtimeElementWindow(document.getElementById("scene")!, options)).toEqual({ + start: 0, + end: 2, + }); + }); +}); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 648eb8a0216..87628702e72 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -66,9 +66,10 @@ export function runtimeElementWindow( duration = liveDuration; } } + const isRoot = rawNode === findRootCompositionElement(rawNode.ownerDocument); return { start, - end: duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY, + end: !isRoot && duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY, }; } diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index c232674262c..2202318ce9a 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -491,6 +491,62 @@ describe("core runtime browser contract", () => { 30_000, ); + it("keeps a root with no data-duration on screen after its GSAP timeline ends", async () => { + const filmPage = await browser.newPage(); + try { + await filmPage.setViewport({ width: 320, height: 180 }); + await filmPage.setContent(` + +
+ +
`); + await filmPage.addScriptTag({ + path: resolve(import.meta.dirname, "../../../core/node_modules/gsap/dist/gsap.min.js"), + }); + await filmPage.addScriptTag({ + content: + "window.__timelines = { root: gsap.timeline({ paused: true }).to({}, { duration: 2 }) };", + }); + await filmPage.addScriptTag({ content: readFileSync(RUNTIME_PATH, "utf8") }); + await filmPage.waitForFunction(() => window.__playerReady && window.__renderReady); + const shown = await filmPage.evaluate(() => { + window.__player!.renderSeek!(4); + return window.__player!.getDuration(); + }); + expect(shown).toBeGreaterThan(4); + expect(await redPixels(filmPage)).toBe(320 * 180); + } finally { + await filmPage.close(); + } + }, 30_000); + + // In a frame the runtime stamps GSAP targets with the timeline's length at bind, for Studio's + // timeline list; video metadata can make the composition longer afterwards. + it("keeps a clip inside a discovery-stamped wrapper on screen past the stamp's end", async () => { + const hostPage = await browser.newPage(); + try { + await hostPage.setViewport({ width: 320, height: 180 }); + await hostPage.setContent(` + `); + const frame = (await (await hostPage.waitForSelector("iframe"))!.contentFrame())!; + await frame.addScriptTag({ content: readFileSync(RUNTIME_PATH, "utf8") }); + await frame.waitForFunction(() => window.__playerReady && window.__renderReady); + await frame.evaluate(() => window.__player!.seek(4)); + expect(await redPixels(hostPage)).toBe(320 * 180); + } finally { + await hostPage.close(); + } + }, 30_000); + it("paints nothing of a hidden scene, even a child or SVG shape set visible inline", async () => { const filmPage = await openFilm( `