Skip to content

Commit c927be5

Browse files
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>
1 parent 1ea0ed5 commit c927be5

2 files changed

Lines changed: 246 additions & 39 deletions

File tree

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

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,176 @@ describe("initSandboxRuntimeModular", () => {
413413
expect(sceneB.style.visibility).toBe("visible");
414414
});
415415

416+
it("hides GSAP tween targets inside a hidden timed clip (issue #1387)", () => {
417+
const root = document.createElement("div");
418+
root.setAttribute("data-composition-id", "main");
419+
root.setAttribute("data-root", "true");
420+
root.setAttribute("data-start", "0");
421+
root.setAttribute("data-duration", "8");
422+
root.setAttribute("data-width", "1920");
423+
root.setAttribute("data-height", "1080");
424+
document.body.appendChild(root);
425+
426+
const captionOne = document.createElement("div");
427+
captionOne.id = "t01";
428+
captionOne.setAttribute("data-start", "0");
429+
captionOne.setAttribute("data-duration", "4");
430+
root.appendChild(captionOne);
431+
432+
const lineOne = document.createElement("div");
433+
lineOne.className = "line";
434+
// Studio stamps full-duration pseudo-clips on GSAP tween targets.
435+
lineOne.setAttribute("data-start", "0");
436+
lineOne.setAttribute("data-duration", "8");
437+
captionOne.appendChild(lineOne);
438+
439+
const captionTwo = document.createElement("div");
440+
captionTwo.id = "t02";
441+
captionTwo.setAttribute("data-start", "4");
442+
captionTwo.setAttribute("data-duration", "4");
443+
root.appendChild(captionTwo);
444+
445+
const lineTwo = document.createElement("div");
446+
lineTwo.className = "line";
447+
lineTwo.setAttribute("data-start", "0");
448+
lineTwo.setAttribute("data-duration", "8");
449+
captionTwo.appendChild(lineTwo);
450+
451+
window.__timelines = {
452+
main: createMockTimeline(8),
453+
};
454+
455+
initSandboxRuntimeModular();
456+
457+
const player = window.__player;
458+
expect(player).toBeDefined();
459+
460+
player?.seek(1);
461+
462+
expect(captionOne.style.visibility).toBe("visible");
463+
expect(lineOne.style.visibility).toBe("visible");
464+
expect(captionTwo.style.visibility).toBe("hidden");
465+
expect(lineTwo.style.visibility).toBe("hidden");
466+
467+
player?.seek(5);
468+
469+
expect(captionOne.style.visibility).toBe("hidden");
470+
expect(lineOne.style.visibility).toBe("hidden");
471+
expect(captionTwo.style.visibility).toBe("visible");
472+
expect(lineTwo.style.visibility).toBe("visible");
473+
});
474+
475+
it("does not stamp Studio timing on GSAP targets inside authored timed clips", () => {
476+
const originalParent = window.parent;
477+
Object.defineProperty(window, "parent", {
478+
configurable: true,
479+
value: {},
480+
});
481+
482+
try {
483+
const root = document.createElement("div");
484+
root.setAttribute("data-composition-id", "main");
485+
root.setAttribute("data-root", "true");
486+
root.setAttribute("data-start", "0");
487+
root.setAttribute("data-duration", "8");
488+
root.setAttribute("data-width", "1920");
489+
root.setAttribute("data-height", "1080");
490+
document.body.appendChild(root);
491+
492+
const caption = document.createElement("div");
493+
caption.id = "t01";
494+
caption.setAttribute("data-start", "0");
495+
caption.setAttribute("data-duration", "4");
496+
root.appendChild(caption);
497+
498+
const line = document.createElement("div");
499+
line.className = "line";
500+
caption.appendChild(line);
501+
502+
const tweenTarget = {
503+
targets: () => [line],
504+
};
505+
const timeline = createMockTimeline(8) as RuntimeTimelineLike & {
506+
getChildren: (nested?: boolean) => Array<{ targets: () => Element[] }>;
507+
};
508+
timeline.getChildren = () => [tweenTarget];
509+
510+
window.__timelines = {
511+
main: timeline,
512+
};
513+
514+
initSandboxRuntimeModular();
515+
516+
expect(line.hasAttribute("data-start")).toBe(false);
517+
expect(line.hasAttribute("data-duration")).toBe(false);
518+
} finally {
519+
Object.defineProperty(window, "parent", {
520+
configurable: true,
521+
value: originalParent,
522+
});
523+
}
524+
});
525+
526+
it("hides tween targets inside inactive multi-panel beats (niemmo panel stack)", () => {
527+
const root = document.createElement("div");
528+
root.setAttribute("data-composition-id", "niemmo-launch-50");
529+
root.setAttribute("data-root", "true");
530+
root.setAttribute("data-start", "0");
531+
root.setAttribute("data-duration", "50");
532+
root.setAttribute("data-width", "1280");
533+
root.setAttribute("data-height", "720");
534+
document.body.appendChild(root);
535+
536+
const panelA = document.createElement("div");
537+
panelA.className = "panel clip";
538+
panelA.setAttribute("data-composition-id", "cold-open");
539+
panelA.setAttribute("data-start", "0");
540+
panelA.setAttribute("data-duration", "2");
541+
root.appendChild(panelA);
542+
543+
const headlineA = document.createElement("h1");
544+
headlineA.className = "co-headline";
545+
headlineA.setAttribute("data-start", "0");
546+
headlineA.setAttribute("data-duration", "50");
547+
panelA.appendChild(headlineA);
548+
549+
const panelB = document.createElement("div");
550+
panelB.className = "panel clip";
551+
panelB.setAttribute("data-composition-id", "problem-dev-beat");
552+
panelB.setAttribute("data-start", "2");
553+
panelB.setAttribute("data-duration", "2.5");
554+
root.appendChild(panelB);
555+
556+
const headlineB = document.createElement("h1");
557+
headlineB.className = "pb-headline";
558+
headlineB.setAttribute("data-start", "0");
559+
headlineB.setAttribute("data-duration", "50");
560+
panelB.appendChild(headlineB);
561+
562+
window.__timelines = {
563+
"niemmo-launch-50": createMockTimeline(50),
564+
};
565+
566+
initSandboxRuntimeModular();
567+
568+
const player = window.__player;
569+
expect(player).toBeDefined();
570+
571+
player?.seek(1);
572+
573+
expect(panelA.style.visibility).toBe("visible");
574+
expect(headlineA.style.visibility).toBe("visible");
575+
expect(panelB.style.visibility).toBe("hidden");
576+
expect(headlineB.style.visibility).toBe("hidden");
577+
578+
player?.seek(3);
579+
580+
expect(panelA.style.visibility).toBe("hidden");
581+
expect(headlineA.style.visibility).toBe("hidden");
582+
expect(panelB.style.visibility).toBe("visible");
583+
expect(headlineB.style.visibility).toBe("visible");
584+
});
585+
416586
it("clamps nested media to the authored host window on seek", () => {
417587
const root = document.createElement("div");
418588
root.setAttribute("data-composition-id", "main");

packages/core/src/runtime/init.ts

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,65 @@ 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+
if (node === rootComp) break;
407+
if (node.hasAttribute("data-start")) {
408+
return node;
409+
}
410+
node = node.parentElement;
411+
}
412+
return null;
413+
};
414+
415+
const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => {
416+
const tag = rawNode.tagName.toLowerCase();
417+
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") {
418+
return false;
419+
}
420+
421+
const start =
422+
tag === "video" || tag === "audio"
423+
? resolveMediaStartSeconds(rawNode, 0)
424+
: resolveStartForElement(rawNode, 0);
425+
let duration = resolveDurationForElement(rawNode);
426+
const compId = rawNode.getAttribute("data-composition-id");
427+
if (compId) {
428+
const compTimeline = (window.__timelines ?? {})[compId];
429+
let liveDuration: number | null = null;
430+
if (compTimeline && typeof compTimeline.duration === "function") {
431+
const compDur = Number(compTimeline.duration());
432+
if (Number.isFinite(compDur) && compDur > 0) {
433+
liveDuration = compDur;
434+
}
435+
}
436+
437+
const usesExternalCompositionSlot =
438+
rawNode.hasAttribute("data-composition-src") ||
439+
rawNode.hasAttribute("data-composition-file");
440+
441+
if (
442+
duration != null &&
443+
duration > 0 &&
444+
liveDuration != null &&
445+
!usesExternalCompositionSlot
446+
) {
447+
duration = Math.min(duration, liveDuration);
448+
} else if ((duration == null || duration <= 0) && liveDuration != null) {
449+
duration = liveDuration;
450+
}
451+
}
452+
const computedEnd =
453+
duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
454+
return (
455+
currentTime >= start && (Number.isFinite(computedEnd) ? currentTime <= computedEnd : true)
456+
);
457+
};
458+
400459
const hasExternalCompositions = !!document.querySelector("[data-composition-src]");
401460
let hasInlineTemplateCompositions = false;
402461
{
@@ -1008,6 +1067,7 @@ export function initSandboxRuntimeModular(): void {
10081067
if (!(target instanceof HTMLElement)) continue;
10091068
if (target === rootComp) continue;
10101069
if (target.hasAttribute("data-start")) continue;
1070+
if (findTimedClipAncestor(target, rootComp)) continue;
10111071
if (seen.has(target)) continue;
10121072
seen.add(target);
10131073
target.setAttribute("data-start", "0");
@@ -1027,6 +1087,7 @@ export function initSandboxRuntimeModular(): void {
10271087
if (!(el instanceof HTMLElement)) continue;
10281088
if (el === rootComp) continue;
10291089
if (el.hasAttribute("data-start")) continue;
1090+
if (findTimedClipAncestor(el, rootComp)) continue;
10301091
if (seen.has(el)) continue;
10311092
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
10321093
seen.add(el);
@@ -1434,51 +1495,27 @@ export function initSandboxRuntimeModular(): void {
14341495
},
14351496
});
14361497
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
1498+
const rootComp = resolveRootCompositionElement();
14371499
for (const rawNode of visibilityNodes) {
14381500
if (!(rawNode instanceof HTMLElement)) continue;
1439-
const tag = rawNode.tagName.toLowerCase();
1440-
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue;
14411501

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;
1502+
let isVisibleNow = isTimedElementVisibleAt(rawNode, state.currentTime);
1503+
if (isVisibleNow) {
1504+
// Descendants must not override a hidden ancestor clip. Studio stamps
1505+
// GSAP tween targets with full-duration pseudo-clips whose inline
1506+
// visibility:visible would otherwise defeat parent visibility:hidden.
1507+
let ancestor = rawNode.parentElement;
1508+
while (ancestor) {
1509+
if (ancestor === rootComp) break;
1510+
if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) {
1511+
if (!isTimedElementVisibleAt(ancestor, state.currentTime)) {
1512+
isVisibleNow = false;
1513+
break;
1514+
}
14551515
}
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;
1516+
ancestor = ancestor.parentElement;
14751517
}
14761518
}
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);
14821519
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
14831520
}
14841521
};

0 commit comments

Comments
 (0)