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
50 changes: 41 additions & 9 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
});

Expand Down Expand Up @@ -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");
Expand All @@ -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),
Expand All @@ -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", () => {
Expand Down
33 changes: 13 additions & 20 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions packages/producer/tests/timed-descendant-visibility/meta.json
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head><style data-hyperframes-text-rendering="true">html,body,*{text-rendering:geometricPrecision}</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=640, height=360">
<style>
* {
box-sizing: border-box;
}

html,
body {
width: 640px;
height: 360px;
margin: 0;
overflow: hidden;
background: #f5fafd;
}

#root {
position: relative;
width: 640px;
height: 360px;
overflow: hidden;
background: #f5fafd;
}

.scene {
position: absolute;
inset: 0;
overflow: hidden;
}

#scene-a {
background: #f8fbff;
}

#scene-b {
background: #f5fafd;
}

.panel {
position: absolute;
inset: 22px 42px 48px;
border: 3px solid #333b44;
border-radius: 8px;
background: #fffdfa;
}

.panel::before {
position: absolute;
top: 40px;
left: 34px;
width: 170px;
height: 18px;
border-radius: 999px;
background: #333b44;
content: "";
}

.panel::after {
position: absolute;
top: 84px;
left: 34px;
width: 310px;
height: 10px;
border-radius: 999px;
background: #9aa7b5;
box-shadow:
0 24px 0 #c3ccd7,
0 48px 0 #d6dde7;
content: "";
}

#leaky-band {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 10;
height: 31px;
background: #daebff;
}
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-root="true" data-width="640" data-height="360" data-start="0" data-duration="9">
<section id="scene-a" class="scene clip" data-start="0" data-duration="6">
<div class="panel"></div>
<div id="leaky-band" class="clip" data-start="0" data-duration="9"></div>
</section>
<section id="scene-b" class="scene clip" data-start="6" data-duration="3">
<div class="panel"></div>
</section>
</div>


<script>window.__timelines = window.__timelines || {};
window.__timelines.main = {
duration: function () {
return 9;
},
totalDuration: function () {
return 9;
},
pause: function () {},
seek: function () {},
};</script></body>
</html>
Git LFS file not shown
119 changes: 119 additions & 0 deletions packages/producer/tests/timed-descendant-visibility/src/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=640, height=360" />
<style>
* {
box-sizing: border-box;
}

html,
body {
width: 640px;
height: 360px;
margin: 0;
overflow: hidden;
background: #f5fafd;
}

#root {
position: relative;
width: 640px;
height: 360px;
overflow: hidden;
background: #f5fafd;
}

.scene {
position: absolute;
inset: 0;
overflow: hidden;
}

#scene-a {
background: #f8fbff;
}

#scene-b {
background: #f5fafd;
}

.panel {
position: absolute;
inset: 22px 42px 48px;
border: 3px solid #333b44;
border-radius: 8px;
background: #fffdfa;
}

.panel::before {
position: absolute;
top: 40px;
left: 34px;
width: 170px;
height: 18px;
border-radius: 999px;
background: #333b44;
content: "";
}

.panel::after {
position: absolute;
top: 84px;
left: 34px;
width: 310px;
height: 10px;
border-radius: 999px;
background: #9aa7b5;
box-shadow:
0 24px 0 #c3ccd7,
0 48px 0 #d6dde7;
content: "";
}

#leaky-band {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 10;
height: 31px;
background: #daebff;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-root="true"
data-width="640"
data-height="360"
data-start="0"
data-duration="9"
>
<section id="scene-a" class="scene clip" data-start="0" data-duration="6">
<div class="panel"></div>
<div id="leaky-band" class="clip" data-start="0" data-duration="9"></div>
</section>
<section id="scene-b" class="scene clip" data-start="6" data-duration="3">
<div class="panel"></div>
</section>
</div>

<script>
window.__timelines = window.__timelines || {};
window.__timelines.main = {
duration: function () {
return 9;
},
totalDuration: function () {
return 9;
},
pause: function () {},
seek: function () {},
};
</script>
</body>
</html>
Loading