Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
25 changes: 24 additions & 1 deletion packages/core/src/runtime/timeline.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { duration: () => number }>;
Expand Down Expand Up @@ -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 = `<div id="main" data-composition-id="main" data-start="0">
<div id="scene" data-composition-id="scene" data-start="0"></div></div>`;
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,
});
});
});
3 changes: 2 additions & 1 deletion packages/core/src/runtime/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ export function runtimeElementWindow(
duration = liveDuration;
}
}
const isRoot = rawNode === findRootCompositionElement(rawNode.ownerDocument);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important — This calls findRootCompositionElement (one or two document-wide querySelectors) on every runtimeElementWindow call. That includes each timed node and each timed ancestor walked by clipChainVisibleAt, on every pass. When the page has no [data-root="true"], the first query scans the whole document each time, so a full pass (every renderSeek frame) grows roughly as timed nodes × DOM size. That cost is why init.ts caches the root in resolveRootCompositionElement. Only a data-composition-id element can be the root, so computing this inside the compId branch removes the cost for every other node:

Suggested change
const isRoot = rawNode === findRootCompositionElement(rawNode.ownerDocument);
const isRoot = compId !== null && rawNode === findRootCompositionElement(rawNode.ownerDocument);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied your suggestion in 9588f71: the root lookup now runs only for data-composition-id elements, so no other node pays for it. Both browser tests still pass 3 in a row; timeline.test.ts 72/72.

return {
start,
end: duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY,
end: !isRoot && duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY,
};
}

Expand Down
56 changes: 56 additions & 0 deletions packages/producer/src/services/coreRuntimeBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<!doctype html>
<style>html, body { margin: 0; background: rgb(0, 0, 0); }</style>
<div data-composition-id="root" data-width="320" data-height="180"
style="position:relative;width:320px;height:180px;background:rgb(255,0,0)">
<audio data-start="0" data-duration="6"></audio>
</div>`);
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(`<style>html, body { margin: 0; } iframe { border: 0; }</style>
<iframe width="320" height="180" srcdoc="<style>html, body { margin: 0; background: rgb(0, 0, 0); }</style>
<div data-composition-id='root' data-duration='6' data-width='320' data-height='180'
style='position:relative;width:320px;height:180px'>
<div id='wrap' data-start='0' data-duration='2' data-hf-autostamped='1'
style='position:absolute;inset:0'>
<div class='clip' data-start='0' data-duration='6'
style='position:absolute;inset:0;background:rgb(255,0,0)'></div>
</div>
</div>"></iframe>`);
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(
`<div class="clip" data-start="2" data-duration="1" style="position:absolute;inset:0">
Expand Down
Loading