Skip to content

Commit 11d6227

Browse files
fix: hide descendants of inactive render clips (#1662)
1 parent b651a9d commit 11d6227

6 files changed

Lines changed: 298 additions & 29 deletions

File tree

packages/core/src/runtime/init.test.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ describe("initSandboxRuntimeModular", () => {
116116
window.cancelAnimationFrame = originalCancelAnimationFrame;
117117
});
118118

119-
it("uses the shorter live child timeline when the authored window is longer", () => {
119+
it("keeps authored composition hosts visible when the live child timeline is shorter", () => {
120120
const root = document.createElement("div");
121121
root.setAttribute("data-composition-id", "main");
122122
root.setAttribute("data-root", "true");
@@ -143,6 +143,37 @@ describe("initSandboxRuntimeModular", () => {
143143

144144
player?.renderSeek(9);
145145

146+
expect(child.style.visibility).toBe("visible");
147+
});
148+
149+
it("uses live child timeline duration when a composition host has no authored duration", () => {
150+
const root = document.createElement("div");
151+
root.setAttribute("data-composition-id", "main");
152+
root.setAttribute("data-root", "true");
153+
root.setAttribute("data-start", "0");
154+
root.setAttribute("data-width", "1920");
155+
root.setAttribute("data-height", "1080");
156+
document.body.appendChild(root);
157+
158+
const child = document.createElement("div");
159+
child.setAttribute("data-composition-id", "slide-1");
160+
child.setAttribute("data-start", "0");
161+
root.appendChild(child);
162+
163+
window.__timelines = {
164+
main: createMockTimeline(20),
165+
"slide-1": createMockTimeline(8),
166+
};
167+
168+
initSandboxRuntimeModular();
169+
170+
const player = window.__player;
171+
expect(player).toBeDefined();
172+
173+
player?.renderSeek(7);
174+
expect(child.style.visibility).toBe("visible");
175+
176+
player?.renderSeek(9);
146177
expect(child.style.visibility).toBe("hidden");
147178
});
148179

@@ -491,7 +522,7 @@ describe("initSandboxRuntimeModular", () => {
491522
});
492523
});
493524

494-
it("does not suppress descendant visibility in render mode (top-level page)", () => {
525+
it("hides timed descendants inside a hidden timed clip in render mode", () => {
495526
const root = document.createElement("div");
496527
root.setAttribute("data-composition-id", "main");
497528
root.setAttribute("data-root", "true");
@@ -507,12 +538,13 @@ describe("initSandboxRuntimeModular", () => {
507538
panel.setAttribute("data-duration", "2");
508539
root.appendChild(panel);
509540

510-
const headline = document.createElement("h1");
511-
headline.className = "headline";
512-
// Authored child window outlives the parent clip — render keeps legacy behavior.
513-
headline.setAttribute("data-start", "0");
514-
headline.setAttribute("data-duration", "8");
515-
panel.appendChild(headline);
541+
const bottomBand = document.createElement("div");
542+
bottomBand.className = "bottom-band";
543+
// Regression shape: a child strip outlives its parent scene. Without
544+
// ancestor suppression it can paint through after the parent has ended.
545+
bottomBand.setAttribute("data-start", "0");
546+
bottomBand.setAttribute("data-duration", "8");
547+
panel.appendChild(bottomBand);
516548

517549
window.__timelines = {
518550
main: createMockTimeline(8),
@@ -526,7 +558,7 @@ describe("initSandboxRuntimeModular", () => {
526558
player?.seek(3);
527559

528560
expect(panel.style.visibility).toBe("hidden");
529-
expect(headline.style.visibility).toBe("visible");
561+
expect(bottomBand.style.visibility).toBe("hidden");
530562
});
531563

532564
it("does not stamp Studio timing on GSAP targets inside authored timed clips", () => {

packages/core/src/runtime/init.ts

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -432,18 +432,13 @@ export function initSandboxRuntimeModular(): void {
432432
}
433433
}
434434

435-
const usesExternalCompositionSlot =
436-
rawNode.hasAttribute("data-composition-src") ||
437-
rawNode.hasAttribute("data-composition-file");
435+
const hasAuthoredTiming =
436+
rawNode.hasAttribute("data-duration") ||
437+
rawNode.hasAttribute("data-end") ||
438+
rawNode.hasAttribute(AUTHORED_DURATION_ATTR) ||
439+
rawNode.hasAttribute(AUTHORED_END_ATTR);
438440

439-
if (
440-
duration != null &&
441-
duration > 0 &&
442-
liveDuration != null &&
443-
!usesExternalCompositionSlot
444-
) {
445-
duration = Math.min(duration, liveDuration);
446-
} else if ((duration == null || duration <= 0) && liveDuration != null) {
441+
if (!hasAuthoredTiming && (duration == null || duration <= 0) && liveDuration != null) {
447442
duration = liveDuration;
448443
}
449444
}
@@ -1481,10 +1476,9 @@ export function initSandboxRuntimeModular(): void {
14811476
const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => {
14821477
const compositionRoot = element.closest("[data-composition-id]");
14831478
const inheritedStart = compositionRoot ? resolveStartForElement(compositionRoot, 0) : null;
1484-
// Media sync intentionally uses the authored host window here instead of
1485-
// the live child timeline duration. Visibility prefers live truth so a
1486-
// shrinking child composition hides early, but nested media needs a
1487-
// stable authored window so seeks clamp against the host clip timing.
1479+
// Media sync follows the authored host window, matching visibility for
1480+
// authored composition hosts. Live child timeline duration only fills in
1481+
// when no authored timing exists, so seeks clamp against host clip timing.
14881482
const inheritedDuration = compositionRoot
14891483
? resolveDurationForElement(compositionRoot, { includeAuthoredTimingAttrs: true })
14901484
: null;
@@ -1566,11 +1560,10 @@ export function initSandboxRuntimeModular(): void {
15661560
if (!(rawNode instanceof HTMLElement)) continue;
15671561

15681562
let isVisibleNow = isTimedElementVisibleAt(rawNode, state.currentTime);
1569-
// Studio-only defense-in-depth: pseudo-clips stamped on tween targets can
1570-
// get visibility:visible for the full composition. Render mode never stamps
1571-
// those targets, so keep the prior per-element visibility semantics there.
1572-
if (isVisibleNow && window.parent !== window) {
1573-
// Descendants must not override a hidden ancestor clip.
1563+
// Descendants must not override a hidden ancestor clip. CSS visibility can
1564+
// otherwise leak child pixels through inactive scenes because a descendant
1565+
// with visibility:visible escapes an ancestor's visibility:hidden.
1566+
if (isVisibleNow) {
15741567
let ancestor = rawNode.parentElement;
15751568
while (ancestor) {
15761569
if (ancestor === rootComp) break;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "Timed descendant visibility",
3+
"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.",
4+
"tags": ["visibility", "regression", "runtime"],
5+
"minPsnr": 45,
6+
"maxFrameFailures": 0,
7+
"minAudioCorrelation": 0,
8+
"maxAudioLagWindows": 1,
9+
"renderConfig": {
10+
"fps": 30,
11+
"workers": 1
12+
}
13+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head><style data-hyperframes-text-rendering="true">html,body,*{text-rendering:geometricPrecision}</style>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=640, height=360">
6+
<style>
7+
* {
8+
box-sizing: border-box;
9+
}
10+
11+
html,
12+
body {
13+
width: 640px;
14+
height: 360px;
15+
margin: 0;
16+
overflow: hidden;
17+
background: #f5fafd;
18+
}
19+
20+
#root {
21+
position: relative;
22+
width: 640px;
23+
height: 360px;
24+
overflow: hidden;
25+
background: #f5fafd;
26+
}
27+
28+
.scene {
29+
position: absolute;
30+
inset: 0;
31+
overflow: hidden;
32+
}
33+
34+
#scene-a {
35+
background: #f8fbff;
36+
}
37+
38+
#scene-b {
39+
background: #f5fafd;
40+
}
41+
42+
.panel {
43+
position: absolute;
44+
inset: 22px 42px 48px;
45+
border: 3px solid #333b44;
46+
border-radius: 8px;
47+
background: #fffdfa;
48+
}
49+
50+
.panel::before {
51+
position: absolute;
52+
top: 40px;
53+
left: 34px;
54+
width: 170px;
55+
height: 18px;
56+
border-radius: 999px;
57+
background: #333b44;
58+
content: "";
59+
}
60+
61+
.panel::after {
62+
position: absolute;
63+
top: 84px;
64+
left: 34px;
65+
width: 310px;
66+
height: 10px;
67+
border-radius: 999px;
68+
background: #9aa7b5;
69+
box-shadow:
70+
0 24px 0 #c3ccd7,
71+
0 48px 0 #d6dde7;
72+
content: "";
73+
}
74+
75+
#leaky-band {
76+
position: fixed;
77+
right: 0;
78+
bottom: 0;
79+
left: 0;
80+
z-index: 10;
81+
height: 31px;
82+
background: #daebff;
83+
}
84+
</style>
85+
</head>
86+
<body>
87+
<div id="root" data-composition-id="main" data-root="true" data-width="640" data-height="360" data-start="0" data-duration="9">
88+
<section id="scene-a" class="scene clip" data-start="0" data-duration="6">
89+
<div class="panel"></div>
90+
<div id="leaky-band" class="clip" data-start="0" data-duration="9"></div>
91+
</section>
92+
<section id="scene-b" class="scene clip" data-start="6" data-duration="3">
93+
<div class="panel"></div>
94+
</section>
95+
</div>
96+
97+
98+
<script>window.__timelines = window.__timelines || {};
99+
window.__timelines.main = {
100+
duration: function () {
101+
return 9;
102+
},
103+
totalDuration: function () {
104+
return 9;
105+
},
106+
pause: function () {},
107+
seek: function () {},
108+
};</script></body>
109+
</html>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:c5aac54303c4422aed7b0264c007dcfb314fca3dc74a16905eafff3914fb95e7
3+
size 21971
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=640, height=360" />
6+
<style>
7+
* {
8+
box-sizing: border-box;
9+
}
10+
11+
html,
12+
body {
13+
width: 640px;
14+
height: 360px;
15+
margin: 0;
16+
overflow: hidden;
17+
background: #f5fafd;
18+
}
19+
20+
#root {
21+
position: relative;
22+
width: 640px;
23+
height: 360px;
24+
overflow: hidden;
25+
background: #f5fafd;
26+
}
27+
28+
.scene {
29+
position: absolute;
30+
inset: 0;
31+
overflow: hidden;
32+
}
33+
34+
#scene-a {
35+
background: #f8fbff;
36+
}
37+
38+
#scene-b {
39+
background: #f5fafd;
40+
}
41+
42+
.panel {
43+
position: absolute;
44+
inset: 22px 42px 48px;
45+
border: 3px solid #333b44;
46+
border-radius: 8px;
47+
background: #fffdfa;
48+
}
49+
50+
.panel::before {
51+
position: absolute;
52+
top: 40px;
53+
left: 34px;
54+
width: 170px;
55+
height: 18px;
56+
border-radius: 999px;
57+
background: #333b44;
58+
content: "";
59+
}
60+
61+
.panel::after {
62+
position: absolute;
63+
top: 84px;
64+
left: 34px;
65+
width: 310px;
66+
height: 10px;
67+
border-radius: 999px;
68+
background: #9aa7b5;
69+
box-shadow:
70+
0 24px 0 #c3ccd7,
71+
0 48px 0 #d6dde7;
72+
content: "";
73+
}
74+
75+
#leaky-band {
76+
position: fixed;
77+
right: 0;
78+
bottom: 0;
79+
left: 0;
80+
z-index: 10;
81+
height: 31px;
82+
background: #daebff;
83+
}
84+
</style>
85+
</head>
86+
<body>
87+
<div
88+
id="root"
89+
data-composition-id="main"
90+
data-root="true"
91+
data-width="640"
92+
data-height="360"
93+
data-start="0"
94+
data-duration="9"
95+
>
96+
<section id="scene-a" class="scene clip" data-start="0" data-duration="6">
97+
<div class="panel"></div>
98+
<div id="leaky-band" class="clip" data-start="0" data-duration="9"></div>
99+
</section>
100+
<section id="scene-b" class="scene clip" data-start="6" data-duration="3">
101+
<div class="panel"></div>
102+
</section>
103+
</div>
104+
105+
<script>
106+
window.__timelines = window.__timelines || {};
107+
window.__timelines.main = {
108+
duration: function () {
109+
return 9;
110+
},
111+
totalDuration: function () {
112+
return 9;
113+
},
114+
pause: function () {},
115+
seek: function () {},
116+
};
117+
</script>
118+
</body>
119+
</html>

0 commit comments

Comments
 (0)