Files
hyperframes/packages/core/src/runtime/timeline.test.ts
T
Miguel Ángel 2b65b4efce fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity

* feat(studio): drag compositions into the timeline

* fix(studio): collapse expanded composition move aliases

* fix(studio): make timeline cuts atomic

* fix(studio): group inspector gesture history

* test(studio): cover masked text selection

* fix(studio): harden composition timeline reliability

* fix(studio): satisfy CI source gates

* fix(studio): harden composition mutation requests
2026-07-17 14:15:30 -04:00

909 lines
31 KiB
TypeScript

import { describe, it, expect, afterEach } from "vitest";
import { collectRuntimeTimelinePayload } from "./timeline";
describe("collectRuntimeTimelinePayload", () => {
afterEach(() => {
document.body.innerHTML = "";
delete (window as any).__timelines;
});
const defaultParams = { canonicalFps: 30 };
it("returns minimal payload for empty document", () => {
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.source).toBe("hf-preview");
expect(result.type).toBe("timeline");
expect(result.clips).toEqual([]);
expect(result.scenes).toEqual([]);
expect(result.durationInFrames).toBeGreaterThanOrEqual(1);
expect(result.compositionWidth).toBe(1920);
expect(result.compositionHeight).toBe(1080);
expect(result).toMatchObject({
protocolVersion: 1,
compositionContractVersion: 1,
durationSeconds: 1,
fps: { numerator: 30, denominator: 1 },
});
});
// Regression: id-less timed elements (root index.html children carry
// data-hf-id, not id) must get their data-hf-id as the clip id — not null —
// so the manifest aligns with __clipTree and inline expansion can join them.
it("ids an id-less clip by its data-hf-id", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const clip = document.createElement("h1");
clip.setAttribute("data-hf-id", "hf-headline");
clip.setAttribute("data-start", "1");
clip.setAttribute("data-duration", "3");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].id).toBe("hf-headline");
});
// Regression: the authored data-track-index must round-trip verbatim, even
// when clips of DIFFERENT kinds (video vs element) share a track. The old
// mixed-kind renumber split them onto separate tracks, which made the
// written track drift from the displayed one on every editor move.
it("honors authored track indices verbatim for mixed-kind tracks", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const video = document.createElement("video");
video.id = "clip-video";
video.setAttribute("data-start", "1");
video.setAttribute("data-duration", "3");
video.setAttribute("data-track-index", "1");
root.appendChild(video);
const caption = document.createElement("div");
caption.id = "clip-caption";
caption.setAttribute("data-start", "8");
caption.setAttribute("data-duration", "3");
caption.setAttribute("data-track-index", "1");
root.appendChild(caption);
const other = document.createElement("div");
other.id = "clip-other";
other.setAttribute("data-start", "0");
other.setAttribute("data-duration", "3");
other.setAttribute("data-track-index", "2");
root.appendChild(other);
const result = collectRuntimeTimelinePayload(defaultParams);
const trackOf = (id: string) => result.clips.find((c) => c.id === id)?.track;
expect(trackOf("clip-video")).toBe(1);
expect(trackOf("clip-caption")).toBe(1);
expect(trackOf("clip-other")).toBe(2);
});
it("collects clips from elements with data-start and data-duration", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const clip = document.createElement("div");
clip.id = "text-1";
clip.setAttribute("data-start", "1");
clip.setAttribute("data-duration", "3");
clip.setAttribute("data-track-index", "0");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips).toHaveLength(1);
expect(result.clips[0].id).toBe("text-1");
expect(result.clips[0].start).toBe(1);
expect(result.clips[0].duration).toBe(3);
expect(result.clips[0].track).toBe(0);
expect(result.clips[0].kind).toBe("element");
});
it("parses inline z-index for timeline clips", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const clip = document.createElement("div");
clip.id = "layered";
clip.style.zIndex = "11";
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "4");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].zIndex).toBe(11);
});
it("uses zero z-index sentinel when a timeline clip has no inline z-index", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const clip = document.createElement("div");
clip.id = "auto-layer";
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "4");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].zIndex).toBe(0);
});
it("partitions clips by their nearest real CSS stacking context", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const contextA = document.createElement("div");
contextA.style.transform = "translateX(0px)";
const contextB = document.createElement("div");
contextB.style.opacity = "0.9";
root.append(contextA, contextB);
const addClip = (parent: Element, id: string) => {
const clip = document.createElement("div");
clip.id = id;
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "2");
parent.appendChild(clip);
};
addClip(contextA, "a-1");
addClip(contextA, "a-2");
addClip(contextB, "b-1");
const result = collectRuntimeTimelinePayload(defaultParams);
const contextOf = (id: string) =>
result.clips.find((clip) => clip.id === id)?.stackingContextId;
expect(contextOf("a-1")).toBe(contextOf("a-2"));
expect(contextOf("a-1")).not.toBe(contextOf("b-1"));
expect(contextOf("a-1")).not.toBe("css:root");
});
it("keeps clips without a stacking-context ancestor in the shared root scope", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const plainWrapper = document.createElement("div");
root.appendChild(plainWrapper);
for (const [parent, id] of [
[root, "root-layer"],
[plainWrapper, "wrapped-layer"],
] as const) {
const clip = document.createElement("div");
clip.id = id;
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "2");
parent.appendChild(clip);
}
const result = collectRuntimeTimelinePayload(defaultParams);
const contextOf = (id: string) =>
result.clips.find((clip) => clip.id === id)?.stackingContextId;
expect(contextOf("root-layer")).toBe("css:root");
expect(contextOf("wrapped-layer")).toBe("css:root");
});
it("scopes an element's own z-index in its parent context, not a context it creates", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
for (const id of ["layer-a", "layer-b"]) {
const clip = document.createElement("div");
clip.id = id;
clip.style.cssText = "position: relative; z-index: 1";
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "2");
root.appendChild(clip);
}
const result = collectRuntimeTimelinePayload(defaultParams);
const contexts = result.clips.map((clip) => clip.stackingContextId);
expect(contexts).toEqual(["css:root", "css:root"]);
});
it.each([
["positioned z-index", "", "position: relative; z-index: 1"],
["fixed position", "", "position: fixed"],
["sticky position", "", "position: sticky"],
["transform", "", "transform: translateX(0px)"],
["opacity", "", "opacity: 0.9"],
["isolation", "", "isolation: isolate"],
["filter", "", "filter: blur(1px)"],
["perspective", "", "perspective: 100px"],
["mix blend mode", "", "mix-blend-mode: multiply"],
["contain", "", "contain: paint"],
["container type", "", "container-type: inline-size"],
["will change", "", "will-change: transform"],
["z-indexed flex item", "display: flex", "z-index: 1"],
["z-indexed grid item", "display: grid", "z-index: 1"],
])("recognizes a %s stacking-context ancestor", (_label, rootCssText, contextCssText) => {
const root = document.createElement("div");
root.style.cssText = rootCssText;
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const context = document.createElement("div");
context.style.cssText = contextCssText;
root.appendChild(context);
const clip = document.createElement("div");
clip.id = "nested-layer";
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "2");
context.appendChild(clip);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips.find((item) => item.id === clip.id)?.stackingContextId).not.toBe(
"css:root",
);
});
it("identifies video clips by tag", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const video = document.createElement("video");
video.id = "v1";
video.setAttribute("data-start", "0");
video.setAttribute("data-duration", "5");
root.appendChild(video);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].kind).toBe("video");
});
it("identifies audio clips by tag", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const audio = document.createElement("audio");
audio.id = "a1";
audio.setAttribute("data-start", "0");
audio.setAttribute("data-duration", "5");
root.appendChild(audio);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].kind).toBe("audio");
});
it("identifies image clips by tag", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const img = document.createElement("img");
img.id = "img1";
img.setAttribute("data-start", "0");
img.setAttribute("data-duration", "5");
root.appendChild(img);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].kind).toBe("image");
});
it("identifies composition clips", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const comp = document.createElement("div");
comp.id = "scene-1";
comp.setAttribute("data-composition-id", "scene-1");
comp.setAttribute("data-start", "0");
comp.setAttribute("data-duration", "10");
comp.setAttribute("data-playback-start", "1.5");
comp.setAttribute("data-playback-rate", "2");
root.appendChild(comp);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].kind).toBe("composition");
expect(result.clips[0].playbackStart).toBe(1.5);
expect(result.clips[0].playbackRate).toBe(2);
});
it("defaults a legacy composition host playback window to zero at unit rate", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const comp = document.createElement("div");
comp.id = "scene-legacy";
comp.setAttribute("data-composition-id", "scene-legacy");
comp.setAttribute("data-start", "0");
comp.setAttribute("data-duration", "10");
root.appendChild(comp);
const clip = collectRuntimeTimelinePayload(defaultParams).clips[0];
expect(clip.playbackStart).toBe(0);
expect(clip.playbackRate).toBe(1);
});
it("collects scenes from composition nodes", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const scene = document.createElement("div");
scene.setAttribute("data-composition-id", "scene-intro");
scene.setAttribute("data-start", "0");
scene.setAttribute("data-duration", "10");
scene.setAttribute("data-label", "Intro");
root.appendChild(scene);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.scenes).toHaveLength(1);
expect(result.scenes[0].id).toBe("scene-intro");
expect(result.scenes[0].label).toBe("Intro");
expect(result.scenes[0].start).toBe(0);
expect(result.scenes[0].duration).toBe(10);
});
it("skips caption and ambient compositions from scenes", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const caption = document.createElement("div");
caption.setAttribute("data-composition-id", "caption-1");
caption.setAttribute("data-start", "0");
caption.setAttribute("data-duration", "5");
root.appendChild(caption);
const ambient = document.createElement("div");
ambient.setAttribute("data-composition-id", "ambient-bg");
ambient.setAttribute("data-start", "0");
ambient.setAttribute("data-duration", "5");
root.appendChild(ambient);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.scenes).toHaveLength(0);
});
it("reads composition dimensions from root", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-width", "3840");
root.setAttribute("data-height", "2160");
root.setAttribute("data-duration", "5");
document.body.appendChild(root);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.compositionWidth).toBe(3840);
expect(result.compositionHeight).toBe(2160);
});
it("defaults composition dimensions to 1920x1080", () => {
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.compositionWidth).toBe(1920);
expect(result.compositionHeight).toBe(1080);
});
it("computes durationInFrames from max clip end", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const clip = document.createElement("div");
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "10");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.durationInFrames).toBe(300); // 10s * 30fps
});
it("ceil durationInFrames to match render frame count", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "1.01");
document.body.appendChild(root);
const clip = document.createElement("div");
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "1.01");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.durationInFrames).toBe(31); // ceil(1.01s * 30fps), same as render.
});
it("preserves the authored root duration when clips end earlier", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "7");
document.body.appendChild(root);
const clip = document.createElement("div");
clip.id = "trimmed";
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "5");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.durationInFrames).toBe(210); // 7s * 30fps
});
it("respects long composition durations without capping", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "5000");
document.body.appendChild(root);
const clip = document.createElement("div");
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "5000");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload({ canonicalFps: 30 });
expect(result.durationInFrames).toBe(5000 * 30);
});
it("skips script/style/meta nodes", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const script = document.createElement("script");
script.setAttribute("data-start", "0");
script.setAttribute("data-duration", "5");
root.appendChild(script);
const style = document.createElement("style");
style.setAttribute("data-start", "0");
style.setAttribute("data-duration", "5");
root.appendChild(style);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips).toHaveLength(0);
});
it("resolves asset URLs from src attribute", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const img = document.createElement("img");
img.id = "hero";
img.setAttribute("src", "https://example.com/hero.jpg");
img.setAttribute("data-start", "0");
img.setAttribute("data-duration", "5");
root.appendChild(img);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].assetUrl).toBe("https://example.com/hero.jpg");
});
it("uses label from data-timeline-label", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const clip = document.createElement("div");
clip.id = "clip-1";
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "5");
clip.setAttribute("data-timeline-label", "Hero Shot");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].label).toBe("Hero Shot");
});
it("uses a friendly label and null id for anonymous clips", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const clip = document.createElement("div");
clip.className = "clip hero-card";
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "5");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].id).toBeNull();
expect(result.clips[0].label).toBe("Hero Card");
});
it("falls back to a readable ordinal label instead of a node index id", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const clip = document.createElement("div");
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "5");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].id).toBeNull();
expect(result.clips[0].label).toBe("Element 1");
});
it("handles timeline registry for composition duration", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
document.body.appendChild(root);
const comp = document.createElement("div");
comp.setAttribute("data-composition-id", "scene-1");
comp.setAttribute("data-start", "0");
root.appendChild(comp);
(window as any).__timelines = {
main: {
duration: () => 15,
time: () => 0,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
},
"scene-1": {
duration: () => 8,
time: () => 0,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
},
};
const result = collectRuntimeTimelinePayload(defaultParams);
// scene-1 should get duration 8 from timeline registry
const sceneClip = result.clips.find((c) => c.compositionId === "scene-1");
expect(sceneClip).toBeDefined();
expect(sceneClip?.duration).toBe(8);
});
it("keeps composition clips sequential when authored durations were preserved privately", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
document.body.appendChild(root);
const slide1 = document.createElement("div");
slide1.id = "slide-1";
slide1.setAttribute("data-composition-id", "slide-1");
slide1.setAttribute("data-start", "0");
slide1.setAttribute("data-hf-authored-duration", "14");
root.appendChild(slide1);
const slide2 = document.createElement("div");
slide2.id = "slide-2";
slide2.setAttribute("data-composition-id", "slide-2");
slide2.setAttribute("data-start", "slide-1");
slide2.setAttribute("data-hf-authored-duration", "12");
root.appendChild(slide2);
const slide3 = document.createElement("div");
slide3.id = "slide-3";
slide3.setAttribute("data-composition-id", "slide-3");
slide3.setAttribute("data-start", "slide-2");
slide3.setAttribute("data-hf-authored-duration", "16");
root.appendChild(slide3);
const result = collectRuntimeTimelinePayload(defaultParams);
const starts = Object.fromEntries(result.clips.map((clip) => [clip.id, clip.start]));
expect(starts["slide-1"]).toBe(0);
expect(starts["slide-2"]).toBe(14);
expect(starts["slide-3"]).toBe(26);
expect(result.durationInFrames).toBe(42 * 30);
});
it("discovers GSAP-animated scene elements via timeline introspection", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "12");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const scene1 = document.createElement("div");
scene1.id = "scene1";
root.appendChild(scene1);
const scene2 = document.createElement("div");
scene2.id = "scene2";
root.appendChild(scene2);
// Mock GSAP timeline with getChildren that returns tweens targeting scene children
const mockTweens = [
{
targets: () => [scene1],
startTime: () => 0,
duration: () => 3,
parent: null,
},
{
targets: () => [scene2],
startTime: () => 3,
duration: () => 3,
parent: null,
},
];
(window as any).__timelines = {
main: {
duration: () => 12,
time: () => 0,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
getChildren: () => mockTweens,
},
};
const result = collectRuntimeTimelinePayload(defaultParams);
const s1 = result.clips.find((c) => c.id === "scene1");
const s2 = result.clips.find((c) => c.id === "scene2");
expect(s1).toBeDefined();
expect(s1?.start).toBe(0);
expect(s1?.duration).toBe(3);
expect(s2).toBeDefined();
expect(s2?.start).toBe(3);
expect(s2?.duration).toBe(3);
});
it("does not offset GSAP scene clips by the master timeline start time", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const scene = document.createElement("div");
scene.id = "scene-1";
root.appendChild(scene);
const masterTimeline = {
duration: () => 20,
time: () => 1.25,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
startTime: () => 1.25,
getChildren: () => [
{
targets: () => [scene],
startTime: () => 4,
duration: () => 3,
parent: masterTimeline,
},
],
};
(window as any).__timelines = {
main: masterTimeline,
};
const result = collectRuntimeTimelinePayload(defaultParams);
const clip = result.clips.find((c) => c.id === "scene-1");
expect(clip).toBeDefined();
expect(clip?.start).toBe(4);
expect(clip?.duration).toBe(3);
});
it("keeps nested GSAP timeline offsets below the master timeline", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const scene = document.createElement("div");
scene.id = "scene-1";
root.appendChild(scene);
const masterTimeline = {
duration: () => 20,
time: () => 1.25,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
startTime: () => 1.25,
getChildren: () => [] as unknown[],
};
const nestedTimeline = {
startTime: () => 6,
parent: masterTimeline,
};
const nestedTween = {
targets: () => [scene],
startTime: () => 2,
duration: () => 3,
parent: nestedTimeline,
};
masterTimeline.getChildren = () => [nestedTween];
(window as any).__timelines = {
main: masterTimeline,
};
const result = collectRuntimeTimelinePayload(defaultParams);
const clip = result.clips.find((c) => c.id === "scene-1");
expect(clip).toBeDefined();
expect(clip?.start).toBe(8);
expect(clip?.duration).toBe(3);
});
it("bubbles child tween ranges up to scene-level ancestors", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const scene = document.createElement("div");
scene.id = "my-scene";
root.appendChild(scene);
const child = document.createElement("div");
child.id = "child-elem";
scene.appendChild(child);
const mockTweens = [
{
targets: () => [child],
startTime: () => 1,
duration: () => 2,
parent: null,
},
{
targets: () => [scene],
startTime: () => 4,
duration: () => 0.5,
parent: null,
},
];
(window as any).__timelines = {
main: {
duration: () => 10,
time: () => 0,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
getChildren: () => mockTweens,
},
};
const result = collectRuntimeTimelinePayload(defaultParams);
const clip = result.clips.find((c) => c.id === "my-scene");
expect(clip).toBeDefined();
// Range should span from child tween (1) to scene tween end (4.5)
expect(clip?.start).toBe(1);
expect(clip?.duration).toBeCloseTo(3.5);
});
it("includes persistent overlays as full-duration clips only when opted in", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "12");
document.body.appendChild(root);
const overlay = document.createElement("div");
overlay.id = "grid-overlay";
overlay.setAttribute("data-timeline-role", "overlay");
root.appendChild(overlay);
(window as any).__timelines = {
main: {
duration: () => 12,
time: () => 0,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
getChildren: () => [],
},
};
const result = collectRuntimeTimelinePayload(defaultParams);
const clip = result.clips.find((c) => c.id === "grid-overlay");
expect(clip).toBeDefined();
expect(clip?.start).toBe(0);
expect(clip?.duration).toBe(12);
});
it("does not include persistent overlays by default", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "12");
document.body.appendChild(root);
const overlay = document.createElement("div");
overlay.id = "grid-overlay";
root.appendChild(overlay);
(window as any).__timelines = {
main: {
duration: () => 12,
time: () => 0,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
getChildren: () => [],
},
};
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips.find((c) => c.id === "grid-overlay")).toBeUndefined();
});
it("does not include script/style elements as persistent overlays", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const script = document.createElement("script");
script.id = "my-script";
root.appendChild(script);
(window as any).__timelines = {
main: {
duration: () => 10,
time: () => 0,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
getChildren: () => [],
},
};
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips.find((c) => c.id === "my-script")).toBeUndefined();
});
});