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
217 changes: 217 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ function createManualRaf() {
};
}

function withStudioIframe(run: () => void): void {
const originalParent = window.parent;
Object.defineProperty(window, "parent", {
configurable: true,
value: {},
});
try {
run();
} finally {
Object.defineProperty(window, "parent", {
configurable: true,
value: originalParent,
});
}
}

describe("initSandboxRuntimeModular", () => {
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
Expand Down Expand Up @@ -413,6 +429,207 @@ describe("initSandboxRuntimeModular", () => {
expect(sceneB.style.visibility).toBe("visible");
});

it("hides GSAP tween targets inside a hidden timed clip (issue #1387)", () => {
withStudioIframe(() => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "8");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);

const captionOne = document.createElement("div");
captionOne.id = "t01";
captionOne.setAttribute("data-start", "0");
captionOne.setAttribute("data-duration", "4");
root.appendChild(captionOne);

const lineOne = document.createElement("div");
lineOne.className = "line";
// Studio stamps full-duration pseudo-clips on GSAP tween targets.
lineOne.setAttribute("data-start", "0");
lineOne.setAttribute("data-duration", "8");
captionOne.appendChild(lineOne);

const captionTwo = document.createElement("div");
captionTwo.id = "t02";
captionTwo.setAttribute("data-start", "4");
captionTwo.setAttribute("data-duration", "4");
root.appendChild(captionTwo);

const lineTwo = document.createElement("div");
lineTwo.className = "line";
lineTwo.setAttribute("data-start", "0");
lineTwo.setAttribute("data-duration", "8");
captionTwo.appendChild(lineTwo);

window.__timelines = {
main: createMockTimeline(8),
};

initSandboxRuntimeModular();

const player = window.__player;
expect(player).toBeDefined();

player?.seek(1);

expect(captionOne.style.visibility).toBe("visible");
expect(lineOne.style.visibility).toBe("visible");
expect(captionTwo.style.visibility).toBe("hidden");
expect(lineTwo.style.visibility).toBe("hidden");

player?.seek(5);

expect(captionOne.style.visibility).toBe("hidden");
expect(lineOne.style.visibility).toBe("hidden");
expect(captionTwo.style.visibility).toBe("visible");
expect(lineTwo.style.visibility).toBe("visible");
});
});

it("does not suppress descendant visibility in render mode (top-level page)", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "8");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);

const panel = document.createElement("div");
panel.id = "panel";
panel.setAttribute("data-start", "0");
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);

window.__timelines = {
main: createMockTimeline(8),
};

initSandboxRuntimeModular();

const player = window.__player;
expect(player).toBeDefined();

player?.seek(3);

expect(panel.style.visibility).toBe("hidden");
expect(headline.style.visibility).toBe("visible");
});

it("does not stamp Studio timing on GSAP targets inside authored timed clips", () => {
withStudioIframe(() => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "8");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);

const caption = document.createElement("div");
caption.id = "t01";
caption.setAttribute("data-start", "0");
caption.setAttribute("data-duration", "4");
root.appendChild(caption);

const line = document.createElement("div");
line.className = "line";
caption.appendChild(line);

const tweenTarget = {
targets: () => [line],
};
const timeline = createMockTimeline(8) as RuntimeTimelineLike & {
getChildren: (nested?: boolean) => Array<{ targets: () => Element[] }>;
};
timeline.getChildren = () => [tweenTarget];

window.__timelines = {
main: timeline,
};

initSandboxRuntimeModular();

expect(line.hasAttribute("data-start")).toBe(false);
expect(line.hasAttribute("data-duration")).toBe(false);
});
});

it("hides tween targets inside inactive multi-panel beats (niemmo panel stack)", () => {
withStudioIframe(() => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "niemmo-launch-50");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "50");
root.setAttribute("data-width", "1280");
root.setAttribute("data-height", "720");
document.body.appendChild(root);

const panelA = document.createElement("div");
panelA.className = "panel clip";
panelA.setAttribute("data-composition-id", "cold-open");
panelA.setAttribute("data-start", "0");
panelA.setAttribute("data-duration", "2");
root.appendChild(panelA);

const headlineA = document.createElement("h1");
headlineA.className = "co-headline";
headlineA.setAttribute("data-start", "0");
headlineA.setAttribute("data-duration", "50");
panelA.appendChild(headlineA);

const panelB = document.createElement("div");
panelB.className = "panel clip";
panelB.setAttribute("data-composition-id", "problem-dev-beat");
panelB.setAttribute("data-start", "2");
panelB.setAttribute("data-duration", "2.5");
root.appendChild(panelB);

const headlineB = document.createElement("h1");
headlineB.className = "pb-headline";
headlineB.setAttribute("data-start", "0");
headlineB.setAttribute("data-duration", "50");
panelB.appendChild(headlineB);

window.__timelines = {
"niemmo-launch-50": createMockTimeline(50),
};

initSandboxRuntimeModular();

const player = window.__player;
expect(player).toBeDefined();

player?.seek(1);

expect(panelA.style.visibility).toBe("visible");
expect(headlineA.style.visibility).toBe("visible");
expect(panelB.style.visibility).toBe("hidden");
expect(headlineB.style.visibility).toBe("hidden");

player?.seek(3);

expect(panelA.style.visibility).toBe("hidden");
expect(headlineA.style.visibility).toBe("hidden");
expect(panelB.style.visibility).toBe("visible");
expect(headlineB.style.visibility).toBe("visible");
});
});

it("clamps nested media to the authored host window on seek", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
Expand Down
118 changes: 79 additions & 39 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,67 @@ export function initSandboxRuntimeModular(): void {
return resolveStartForElement(element, fallback);
};

const findTimedClipAncestor = (
element: HTMLElement,
rootComp: HTMLElement | null,
): HTMLElement | null => {
let node = element.parentElement;
while (node) {
// rootComp may be null when no composition is mounted; the walk still
// terminates via `while (node)` — node === null is never true here.
if (node === rootComp) break;
if (node.hasAttribute("data-start")) {
return node;
}
node = node.parentElement;
}
return null;
};

const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => {
const tag = rawNode.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") {
return false;
}

const start =
tag === "video" || tag === "audio"
? resolveMediaStartSeconds(rawNode, 0)
: resolveStartForElement(rawNode, 0);
let duration = resolveDurationForElement(rawNode);
const compId = rawNode.getAttribute("data-composition-id");
if (compId) {
const compTimeline = (window.__timelines ?? {})[compId];
let liveDuration: number | null = null;
if (compTimeline && typeof compTimeline.duration === "function") {
const compDur = Number(compTimeline.duration());
if (Number.isFinite(compDur) && compDur > 0) {
liveDuration = compDur;
}
}

const usesExternalCompositionSlot =
rawNode.hasAttribute("data-composition-src") ||
rawNode.hasAttribute("data-composition-file");

if (
duration != null &&
duration > 0 &&
liveDuration != null &&
!usesExternalCompositionSlot
) {
duration = Math.min(duration, liveDuration);
} else if ((duration == null || duration <= 0) && liveDuration != null) {
duration = liveDuration;
}
}
const computedEnd =
duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
return (
currentTime >= start && (Number.isFinite(computedEnd) ? currentTime <= computedEnd : true)
);
};

const hasExternalCompositions = !!document.querySelector("[data-composition-src]");
let hasInlineTemplateCompositions = false;
{
Expand Down Expand Up @@ -1008,6 +1069,7 @@ export function initSandboxRuntimeModular(): void {
if (!(target instanceof HTMLElement)) continue;
if (target === rootComp) continue;
if (target.hasAttribute("data-start")) continue;
if (findTimedClipAncestor(target, rootComp)) continue;
if (seen.has(target)) continue;
seen.add(target);
target.setAttribute("data-start", "0");
Expand All @@ -1027,6 +1089,7 @@ export function initSandboxRuntimeModular(): void {
if (!(el instanceof HTMLElement)) continue;
if (el === rootComp) continue;
if (el.hasAttribute("data-start")) continue;
if (findTimedClipAncestor(el, rootComp)) continue;
if (seen.has(el)) continue;
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
seen.add(el);
Expand Down Expand Up @@ -1434,51 +1497,28 @@ export function initSandboxRuntimeModular(): void {
},
});
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
const rootComp = resolveRootCompositionElement();
for (const rawNode of visibilityNodes) {
if (!(rawNode instanceof HTMLElement)) continue;
const tag = rawNode.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue;

const start =
tag === "video" || tag === "audio"
? resolveMediaStartSeconds(rawNode, 0)
: resolveStartForElement(rawNode, 0);
let duration = resolveDurationForElement(rawNode);
const compId = rawNode.getAttribute("data-composition-id");
if (compId) {
const compTimeline = (window.__timelines ?? {})[compId];
let liveDuration: number | null = null;
if (compTimeline && typeof compTimeline.duration === "function") {
const compDur = Number(compTimeline.duration());
if (Number.isFinite(compDur) && compDur > 0) {
liveDuration = compDur;
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.
let ancestor = rawNode.parentElement;
while (ancestor) {
if (ancestor === rootComp) break;
if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) {
if (!isTimedElementVisibleAt(ancestor, state.currentTime)) {
isVisibleNow = false;
break;
}
}
}

const usesExternalCompositionSlot =
rawNode.hasAttribute("data-composition-src") ||
rawNode.hasAttribute("data-composition-file");

// Generic child compositions retain legacy behavior and respect both
// the authored parent clip window and the live child timeline duration.
// External composition hosts render into an authored slot, so a shorter
// child timeline should hold its final state through that slot.
if (
duration != null &&
duration > 0 &&
liveDuration != null &&
!usesExternalCompositionSlot
) {
duration = Math.min(duration, liveDuration);
} else if ((duration == null || duration <= 0) && liveDuration != null) {
duration = liveDuration;
ancestor = ancestor.parentElement;
}
}
const computedEnd =
duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
const isVisibleNow =
state.currentTime >= start &&
(Number.isFinite(computedEnd) ? state.currentTime <= computedEnd : true);
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
}
};
Expand Down
Loading