Skip to content

Commit 7fa3696

Browse files
fix(runtime): respect hidden ancestor clips in Studio preview (#1387) (#1395)
* fix(runtime): respect hidden ancestor clips in Studio preview (#1387) Studio-stamped GSAP tween targets inside timed clips were getting visibility:visible for the full composition, overriding hidden parent panels. Skip stamping descendants of authored clips and suppress visibility on children when an ancestor timed clip is hidden. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(runtime): scope ancestor visibility walk to Studio iframe only Address review feedback: the hierarchical visibility guard now runs only when window.parent !== window, matching the Studio-only stamping fix. Render mode keeps prior per-element visibility semantics. Adds a render-mode regression test and documents the null rootComp case in findTimedClipAncestor. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8eac7e1 commit 7fa3696

2 files changed

Lines changed: 296 additions & 39 deletions

File tree

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

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ function createManualRaf() {
7171
};
7272
}
7373

74+
function withStudioIframe(run: () => void): void {
75+
const originalParent = window.parent;
76+
Object.defineProperty(window, "parent", {
77+
configurable: true,
78+
value: {},
79+
});
80+
try {
81+
run();
82+
} finally {
83+
Object.defineProperty(window, "parent", {
84+
configurable: true,
85+
value: originalParent,
86+
});
87+
}
88+
}
89+
7490
describe("initSandboxRuntimeModular", () => {
7591
const originalRequestAnimationFrame = window.requestAnimationFrame;
7692
const originalCancelAnimationFrame = window.cancelAnimationFrame;
@@ -413,6 +429,207 @@ describe("initSandboxRuntimeModular", () => {
413429
expect(sceneB.style.visibility).toBe("visible");
414430
});
415431

432+
it("hides GSAP tween targets inside a hidden timed clip (issue #1387)", () => {
433+
withStudioIframe(() => {
434+
const root = document.createElement("div");
435+
root.setAttribute("data-composition-id", "main");
436+
root.setAttribute("data-root", "true");
437+
root.setAttribute("data-start", "0");
438+
root.setAttribute("data-duration", "8");
439+
root.setAttribute("data-width", "1920");
440+
root.setAttribute("data-height", "1080");
441+
document.body.appendChild(root);
442+
443+
const captionOne = document.createElement("div");
444+
captionOne.id = "t01";
445+
captionOne.setAttribute("data-start", "0");
446+
captionOne.setAttribute("data-duration", "4");
447+
root.appendChild(captionOne);
448+
449+
const lineOne = document.createElement("div");
450+
lineOne.className = "line";
451+
// Studio stamps full-duration pseudo-clips on GSAP tween targets.
452+
lineOne.setAttribute("data-start", "0");
453+
lineOne.setAttribute("data-duration", "8");
454+
captionOne.appendChild(lineOne);
455+
456+
const captionTwo = document.createElement("div");
457+
captionTwo.id = "t02";
458+
captionTwo.setAttribute("data-start", "4");
459+
captionTwo.setAttribute("data-duration", "4");
460+
root.appendChild(captionTwo);
461+
462+
const lineTwo = document.createElement("div");
463+
lineTwo.className = "line";
464+
lineTwo.setAttribute("data-start", "0");
465+
lineTwo.setAttribute("data-duration", "8");
466+
captionTwo.appendChild(lineTwo);
467+
468+
window.__timelines = {
469+
main: createMockTimeline(8),
470+
};
471+
472+
initSandboxRuntimeModular();
473+
474+
const player = window.__player;
475+
expect(player).toBeDefined();
476+
477+
player?.seek(1);
478+
479+
expect(captionOne.style.visibility).toBe("visible");
480+
expect(lineOne.style.visibility).toBe("visible");
481+
expect(captionTwo.style.visibility).toBe("hidden");
482+
expect(lineTwo.style.visibility).toBe("hidden");
483+
484+
player?.seek(5);
485+
486+
expect(captionOne.style.visibility).toBe("hidden");
487+
expect(lineOne.style.visibility).toBe("hidden");
488+
expect(captionTwo.style.visibility).toBe("visible");
489+
expect(lineTwo.style.visibility).toBe("visible");
490+
});
491+
});
492+
493+
it("does not suppress descendant visibility in render mode (top-level page)", () => {
494+
const root = document.createElement("div");
495+
root.setAttribute("data-composition-id", "main");
496+
root.setAttribute("data-root", "true");
497+
root.setAttribute("data-start", "0");
498+
root.setAttribute("data-duration", "8");
499+
root.setAttribute("data-width", "1920");
500+
root.setAttribute("data-height", "1080");
501+
document.body.appendChild(root);
502+
503+
const panel = document.createElement("div");
504+
panel.id = "panel";
505+
panel.setAttribute("data-start", "0");
506+
panel.setAttribute("data-duration", "2");
507+
root.appendChild(panel);
508+
509+
const headline = document.createElement("h1");
510+
headline.className = "headline";
511+
// Authored child window outlives the parent clip — render keeps legacy behavior.
512+
headline.setAttribute("data-start", "0");
513+
headline.setAttribute("data-duration", "8");
514+
panel.appendChild(headline);
515+
516+
window.__timelines = {
517+
main: createMockTimeline(8),
518+
};
519+
520+
initSandboxRuntimeModular();
521+
522+
const player = window.__player;
523+
expect(player).toBeDefined();
524+
525+
player?.seek(3);
526+
527+
expect(panel.style.visibility).toBe("hidden");
528+
expect(headline.style.visibility).toBe("visible");
529+
});
530+
531+
it("does not stamp Studio timing on GSAP targets inside authored timed clips", () => {
532+
withStudioIframe(() => {
533+
const root = document.createElement("div");
534+
root.setAttribute("data-composition-id", "main");
535+
root.setAttribute("data-root", "true");
536+
root.setAttribute("data-start", "0");
537+
root.setAttribute("data-duration", "8");
538+
root.setAttribute("data-width", "1920");
539+
root.setAttribute("data-height", "1080");
540+
document.body.appendChild(root);
541+
542+
const caption = document.createElement("div");
543+
caption.id = "t01";
544+
caption.setAttribute("data-start", "0");
545+
caption.setAttribute("data-duration", "4");
546+
root.appendChild(caption);
547+
548+
const line = document.createElement("div");
549+
line.className = "line";
550+
caption.appendChild(line);
551+
552+
const tweenTarget = {
553+
targets: () => [line],
554+
};
555+
const timeline = createMockTimeline(8) as RuntimeTimelineLike & {
556+
getChildren: (nested?: boolean) => Array<{ targets: () => Element[] }>;
557+
};
558+
timeline.getChildren = () => [tweenTarget];
559+
560+
window.__timelines = {
561+
main: timeline,
562+
};
563+
564+
initSandboxRuntimeModular();
565+
566+
expect(line.hasAttribute("data-start")).toBe(false);
567+
expect(line.hasAttribute("data-duration")).toBe(false);
568+
});
569+
});
570+
571+
it("hides tween targets inside inactive multi-panel beats (niemmo panel stack)", () => {
572+
withStudioIframe(() => {
573+
const root = document.createElement("div");
574+
root.setAttribute("data-composition-id", "niemmo-launch-50");
575+
root.setAttribute("data-root", "true");
576+
root.setAttribute("data-start", "0");
577+
root.setAttribute("data-duration", "50");
578+
root.setAttribute("data-width", "1280");
579+
root.setAttribute("data-height", "720");
580+
document.body.appendChild(root);
581+
582+
const panelA = document.createElement("div");
583+
panelA.className = "panel clip";
584+
panelA.setAttribute("data-composition-id", "cold-open");
585+
panelA.setAttribute("data-start", "0");
586+
panelA.setAttribute("data-duration", "2");
587+
root.appendChild(panelA);
588+
589+
const headlineA = document.createElement("h1");
590+
headlineA.className = "co-headline";
591+
headlineA.setAttribute("data-start", "0");
592+
headlineA.setAttribute("data-duration", "50");
593+
panelA.appendChild(headlineA);
594+
595+
const panelB = document.createElement("div");
596+
panelB.className = "panel clip";
597+
panelB.setAttribute("data-composition-id", "problem-dev-beat");
598+
panelB.setAttribute("data-start", "2");
599+
panelB.setAttribute("data-duration", "2.5");
600+
root.appendChild(panelB);
601+
602+
const headlineB = document.createElement("h1");
603+
headlineB.className = "pb-headline";
604+
headlineB.setAttribute("data-start", "0");
605+
headlineB.setAttribute("data-duration", "50");
606+
panelB.appendChild(headlineB);
607+
608+
window.__timelines = {
609+
"niemmo-launch-50": createMockTimeline(50),
610+
};
611+
612+
initSandboxRuntimeModular();
613+
614+
const player = window.__player;
615+
expect(player).toBeDefined();
616+
617+
player?.seek(1);
618+
619+
expect(panelA.style.visibility).toBe("visible");
620+
expect(headlineA.style.visibility).toBe("visible");
621+
expect(panelB.style.visibility).toBe("hidden");
622+
expect(headlineB.style.visibility).toBe("hidden");
623+
624+
player?.seek(3);
625+
626+
expect(panelA.style.visibility).toBe("hidden");
627+
expect(headlineA.style.visibility).toBe("hidden");
628+
expect(panelB.style.visibility).toBe("visible");
629+
expect(headlineB.style.visibility).toBe("visible");
630+
});
631+
});
632+
416633
it("clamps nested media to the authored host window on seek", () => {
417634
const root = document.createElement("div");
418635
root.setAttribute("data-composition-id", "main");

packages/core/src/runtime/init.ts

Lines changed: 79 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,67 @@ export function initSandboxRuntimeModular(): void {
397397
return resolveStartForElement(element, fallback);
398398
};
399399

400+
const findTimedClipAncestor = (
401+
element: HTMLElement,
402+
rootComp: HTMLElement | null,
403+
): HTMLElement | null => {
404+
let node = element.parentElement;
405+
while (node) {
406+
// rootComp may be null when no composition is mounted; the walk still
407+
// terminates via `while (node)` — node === null is never true here.
408+
if (node === rootComp) break;
409+
if (node.hasAttribute("data-start")) {
410+
return node;
411+
}
412+
node = node.parentElement;
413+
}
414+
return null;
415+
};
416+
417+
const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => {
418+
const tag = rawNode.tagName.toLowerCase();
419+
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") {
420+
return false;
421+
}
422+
423+
const start =
424+
tag === "video" || tag === "audio"
425+
? resolveMediaStartSeconds(rawNode, 0)
426+
: resolveStartForElement(rawNode, 0);
427+
let duration = resolveDurationForElement(rawNode);
428+
const compId = rawNode.getAttribute("data-composition-id");
429+
if (compId) {
430+
const compTimeline = (window.__timelines ?? {})[compId];
431+
let liveDuration: number | null = null;
432+
if (compTimeline && typeof compTimeline.duration === "function") {
433+
const compDur = Number(compTimeline.duration());
434+
if (Number.isFinite(compDur) && compDur > 0) {
435+
liveDuration = compDur;
436+
}
437+
}
438+
439+
const usesExternalCompositionSlot =
440+
rawNode.hasAttribute("data-composition-src") ||
441+
rawNode.hasAttribute("data-composition-file");
442+
443+
if (
444+
duration != null &&
445+
duration > 0 &&
446+
liveDuration != null &&
447+
!usesExternalCompositionSlot
448+
) {
449+
duration = Math.min(duration, liveDuration);
450+
} else if ((duration == null || duration <= 0) && liveDuration != null) {
451+
duration = liveDuration;
452+
}
453+
}
454+
const computedEnd =
455+
duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
456+
return (
457+
currentTime >= start && (Number.isFinite(computedEnd) ? currentTime <= computedEnd : true)
458+
);
459+
};
460+
400461
const hasExternalCompositions = !!document.querySelector("[data-composition-src]");
401462
let hasInlineTemplateCompositions = false;
402463
{
@@ -1008,6 +1069,7 @@ export function initSandboxRuntimeModular(): void {
10081069
if (!(target instanceof HTMLElement)) continue;
10091070
if (target === rootComp) continue;
10101071
if (target.hasAttribute("data-start")) continue;
1072+
if (findTimedClipAncestor(target, rootComp)) continue;
10111073
if (seen.has(target)) continue;
10121074
seen.add(target);
10131075
target.setAttribute("data-start", "0");
@@ -1027,6 +1089,7 @@ export function initSandboxRuntimeModular(): void {
10271089
if (!(el instanceof HTMLElement)) continue;
10281090
if (el === rootComp) continue;
10291091
if (el.hasAttribute("data-start")) continue;
1092+
if (findTimedClipAncestor(el, rootComp)) continue;
10301093
if (seen.has(el)) continue;
10311094
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
10321095
seen.add(el);
@@ -1434,51 +1497,28 @@ export function initSandboxRuntimeModular(): void {
14341497
},
14351498
});
14361499
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
1500+
const rootComp = resolveRootCompositionElement();
14371501
for (const rawNode of visibilityNodes) {
14381502
if (!(rawNode instanceof HTMLElement)) continue;
1439-
const tag = rawNode.tagName.toLowerCase();
1440-
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue;
14411503

1442-
const start =
1443-
tag === "video" || tag === "audio"
1444-
? resolveMediaStartSeconds(rawNode, 0)
1445-
: resolveStartForElement(rawNode, 0);
1446-
let duration = resolveDurationForElement(rawNode);
1447-
const compId = rawNode.getAttribute("data-composition-id");
1448-
if (compId) {
1449-
const compTimeline = (window.__timelines ?? {})[compId];
1450-
let liveDuration: number | null = null;
1451-
if (compTimeline && typeof compTimeline.duration === "function") {
1452-
const compDur = Number(compTimeline.duration());
1453-
if (Number.isFinite(compDur) && compDur > 0) {
1454-
liveDuration = compDur;
1504+
let isVisibleNow = isTimedElementVisibleAt(rawNode, state.currentTime);
1505+
// Studio-only defense-in-depth: pseudo-clips stamped on tween targets can
1506+
// get visibility:visible for the full composition. Render mode never stamps
1507+
// those targets, so keep the prior per-element visibility semantics there.
1508+
if (isVisibleNow && window.parent !== window) {
1509+
// Descendants must not override a hidden ancestor clip.
1510+
let ancestor = rawNode.parentElement;
1511+
while (ancestor) {
1512+
if (ancestor === rootComp) break;
1513+
if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) {
1514+
if (!isTimedElementVisibleAt(ancestor, state.currentTime)) {
1515+
isVisibleNow = false;
1516+
break;
1517+
}
14551518
}
1456-
}
1457-
1458-
const usesExternalCompositionSlot =
1459-
rawNode.hasAttribute("data-composition-src") ||
1460-
rawNode.hasAttribute("data-composition-file");
1461-
1462-
// Generic child compositions retain legacy behavior and respect both
1463-
// the authored parent clip window and the live child timeline duration.
1464-
// External composition hosts render into an authored slot, so a shorter
1465-
// child timeline should hold its final state through that slot.
1466-
if (
1467-
duration != null &&
1468-
duration > 0 &&
1469-
liveDuration != null &&
1470-
!usesExternalCompositionSlot
1471-
) {
1472-
duration = Math.min(duration, liveDuration);
1473-
} else if ((duration == null || duration <= 0) && liveDuration != null) {
1474-
duration = liveDuration;
1519+
ancestor = ancestor.parentElement;
14751520
}
14761521
}
1477-
const computedEnd =
1478-
duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
1479-
const isVisibleNow =
1480-
state.currentTime >= start &&
1481-
(Number.isFinite(computedEnd) ? state.currentTime <= computedEnd : true);
14821522
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
14831523
}
14841524
};

0 commit comments

Comments
 (0)