From 070ee91694d341310d8541a3370fcf05827b6e7e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 7 Jul 2026 22:05:59 -0400 Subject: [PATCH] feat(studio): expose resolved z-index and stacking context on timeline clips --- packages/core/src/runtime/timeline.test.ts | 67 +++++++++++++++++++ packages/core/src/runtime/timeline.ts | 19 ++++++ packages/core/src/runtime/types.ts | 2 + .../hooks/useExpandedTimelineElements.ts | 3 + .../studio/src/player/lib/playbackTypes.ts | 3 + packages/studio/src/player/lib/timelineDOM.ts | 61 +++++++++++++++++ .../studio/src/player/store/playerStore.ts | 8 +++ 7 files changed, 163 insertions(+) diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index f4946afbe..f1d08dfa7 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -57,9 +57,76 @@ describe("collectRuntimeTimelinePayload", () => { 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("assigns stacking context ids from root and nearest sub-composition", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + + const rootClip = document.createElement("div"); + rootClip.id = "root-layer"; + rootClip.setAttribute("data-start", "0"); + rootClip.setAttribute("data-duration", "5"); + root.appendChild(rootClip); + + const scene = document.createElement("div"); + scene.id = "scene-host"; + scene.setAttribute("data-composition-id", "scene"); + scene.setAttribute("data-start", "0"); + scene.setAttribute("data-duration", "5"); + root.appendChild(scene); + + const nestedClip = document.createElement("div"); + nestedClip.id = "nested-layer"; + nestedClip.setAttribute("data-start", "0"); + nestedClip.setAttribute("data-duration", "2"); + scene.appendChild(nestedClip); + + const result = collectRuntimeTimelinePayload(defaultParams); + const rootLayer = result.clips.find((clip) => clip.id === "root-layer"); + const nestedLayer = result.clips.find((clip) => clip.id === "nested-layer"); + + expect(rootLayer?.stackingContextId).toBe("main"); + expect(nestedLayer?.stackingContextId).toBe("scene"); + }); + it("identifies video clips by tag", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 550690c55..d7ea927f7 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -32,6 +32,19 @@ function parseElementEndAttr(element: Element): number | null { ); } +function readInlineZIndex(element: Element): number { + try { + const inline = (element as HTMLElement).style?.zIndex; + if (inline && inline !== "auto") { + const parsed = parseInt(inline, 10); + if (Number.isFinite(parsed)) return parsed; + } + return 0; + } catch { + return 0; + } +} + function maxDefinedNumber(...values: Array): number | null { const finite = values.filter((value): value is number => Number.isFinite(value ?? null)); if (finite.length === 0) return null; @@ -434,6 +447,8 @@ export function collectRuntimeTimelinePayload(params: { node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i), 10, ) || 0, + zIndex: readInlineZIndex(node), + stackingContextId: compositionContext.parentCompositionId ?? rootCompositionId, kind, tagName: tag, compositionId: node.getAttribute("data-composition-id"), @@ -545,6 +560,8 @@ export function collectRuntimeTimelinePayload(params: { el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "", 10, ) || gsapTrack, + zIndex: readInlineZIndex(el), + stackingContextId: rootCompositionIdForGsap, kind: "element", tagName: el.tagName.toLowerCase(), compositionId: el.getAttribute("data-composition-id"), @@ -602,6 +619,8 @@ export function collectRuntimeTimelinePayload(params: { el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "", 10, ) || overlayTrack, + zIndex: readInlineZIndex(el), + stackingContextId: rootCompositionIdForGsap, kind: "element", tagName: tag, compositionId: el.getAttribute("data-composition-id"), diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index a50efae9c..c2745d215 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -51,6 +51,8 @@ export type RuntimeTimelineClip = { start: number; duration: number; track: number; + zIndex: number; + stackingContextId: string | null; kind: "video" | "audio" | "image" | "element" | "composition"; tagName: string | null; compositionId: string | null; diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 8ddbb2150..473fc88d8 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -192,9 +192,12 @@ function domSiblingClips( start: host.start, duration: host.duration, track: host.track, + zIndex: 0, + stackingContextId: host.stackingContextId ?? host.domId ?? host.id ?? null, kind: "element", tagName: null, compositionId: null, + compositionAncestors: host.compositionAncestors ?? [], parentCompositionId: host.id ?? null, compositionSrc: host.compositionSrc ?? null, assetUrl: null, diff --git a/packages/studio/src/player/lib/playbackTypes.ts b/packages/studio/src/player/lib/playbackTypes.ts index e86b67667..f4b9a60dc 100644 --- a/packages/studio/src/player/lib/playbackTypes.ts +++ b/packages/studio/src/player/lib/playbackTypes.ts @@ -38,9 +38,12 @@ export interface ClipManifestClip { start: number; duration: number; track: number; + zIndex?: number; + stackingContextId?: string | null; kind: "video" | "audio" | "image" | "element" | "composition"; tagName: string | null; compositionId: string | null; + compositionAncestors?: string[]; parentCompositionId: string | null; compositionSrc: string | null; assetUrl: string | null; diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index 952a5016c..a90faec9d 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -10,6 +10,7 @@ import type { TimelineElement } from "../store/playerStore"; import type { ClipManifestClip } from "./playbackTypes"; +import { getElementZIndex } from "./layerOrdering"; import { resolveMediaElement, applyMediaMetadataFromElement, @@ -65,6 +66,40 @@ function resolveClipTag(clip: ClipManifestClip): string { return clip.tagName || clip.kind || "div"; } +function resolveDomCompositionContext( + element: Element, + root: Element | null, +): { + parentCompositionId: string | null; + compositionAncestors: string[]; + stackingContextId: string | null; +} { + const ancestors: string[] = []; + let parentCompositionId: string | null = null; + let cursor = element.parentElement; + while (cursor) { + const compositionId = cursor.getAttribute("data-composition-id"); + if (compositionId) { + ancestors.push(compositionId); + if (!parentCompositionId && cursor !== root) { + parentCompositionId = compositionId; + } + } + cursor = cursor.parentElement; + } + const compositionAncestors = ancestors.reverse(); + return { + parentCompositionId, + compositionAncestors, + stackingContextId: parentCompositionId ?? compositionAncestors[0] ?? null, + }; +} + +function getTimelineElementZIndex(element: Element | null): number | undefined { + if (!element || !("style" in element)) return undefined; + return getElementZIndex(element as HTMLElement); +} + // fallow-ignore-next-line complexity export function createTimelineElementFromManifestClip(params: { clip: ClipManifestClip; @@ -86,6 +121,14 @@ export function createTimelineElementFromManifestClip(params: { let sourceFile: string | undefined; let hfId: string | undefined; + const domContext = hostEl + ? resolveDomCompositionContext(hostEl, doc?.querySelector("[data-composition-id]") ?? null) + : null; + const compositionAncestors = clip.compositionAncestors ?? domContext?.compositionAncestors; + const parentCompositionId = clip.parentCompositionId ?? domContext?.parentCompositionId; + const stackingContextId = + clip.stackingContextId ?? parentCompositionId ?? compositionAncestors?.[0] ?? null; + if (hostEl) { domId = hostEl.id || undefined; hfId = hostEl.getAttribute("data-hf-id") || undefined; @@ -112,6 +155,10 @@ export function createTimelineElementFromManifestClip(params: { start: clip.start, duration: clip.duration, track: clip.track, + zIndex: clip.zIndex ?? getTimelineElementZIndex(hostEl), + stackingContextId, + parentCompositionId, + compositionAncestors, domId, hfId, selector, @@ -206,6 +253,7 @@ export function createImplicitTimelineLayersFromDOM( }); if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue; + const compositionContext = resolveDomCompositionContext(child, rootComp); layers.push({ domId: child.id || undefined, hfId: child.getAttribute("data-hf-id") || undefined, @@ -217,6 +265,10 @@ export function createImplicitTimelineLayersFromDOM( selectorIndex, sourceFile, start: 0, + zIndex: getTimelineElementZIndex(child), + stackingContextId: compositionContext.stackingContextId, + parentCompositionId: compositionContext.parentCompositionId, + compositionAncestors: compositionContext.compositionAncestors, tag: child.tagName.toLowerCase(), timingSource: "implicit", track: maxTrack + 1 + layers.length, @@ -278,6 +330,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel selectorIndex, sourceFile, }); + const compositionContext = resolveDomCompositionContext(el, rootComp); const entry: TimelineElement = { id: identity.id, label, @@ -286,6 +339,10 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel start, duration: dur, track: isNaN(track) ? 0 : track, + zIndex: getTimelineElementZIndex(el), + stackingContextId: compositionContext.stackingContextId, + parentCompositionId: compositionContext.parentCompositionId, + compositionAncestors: compositionContext.compositionAncestors, domId: el.id || undefined, hfId: el.getAttribute("data-hf-id") || undefined, selector, @@ -406,6 +463,10 @@ export function buildStandaloneRootTimelineElement(params: { start: 0, duration: params.rootDuration, track: 0, + zIndex: 0, + stackingContextId: params.compositionId, + parentCompositionId: null, + compositionAncestors: [params.compositionId], compositionSrc, selector: params.selector, selectorIndex: params.selectorIndex, diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 91d9f5839..0271fad66 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -28,6 +28,14 @@ export interface TimelineElement { start: number; duration: number; track: number; + /** Resolved z-index for stacking-aware timeline ordering. */ + zIndex?: number; + /** Stacking context this element belongs to; root clips use the root composition id. */ + stackingContextId?: string | null; + /** Nearest parent composition context, matching RuntimeTimelineClip. */ + parentCompositionId?: string | null; + /** Composition ancestry from root to nearest parent, matching RuntimeTimelineClip. */ + compositionAncestors?: string[]; domId?: string; /** Stable `data-hf-id` attribute value — used as primary patch target when present */ hfId?: string;