feat(studio): expose resolved z-index and stacking context on timeline clips

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 23:46:24 -04:00
parent dae26e72b2
commit 070ee91694
7 changed files with 163 additions and 0 deletions
@@ -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");
+19
View File
@@ -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>): 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"),
+2
View File
@@ -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;
@@ -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,
@@ -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;
@@ -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,
@@ -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;