Files
hyperframes/packages/studio/src/player/components/timelineTrackOrder.ts
T
Miguel Angel Simon Sierra e5ed512529 feat(studio): timeline track = stacking layer (NLE-style layering)
Re-architect the timeline row model from data-track-index rows to stacking
layers. Rows represent stacking layers per context: explicit-z clips merge onto
one track when they share a z and don't overlap in time; auto-z clips stay one
row each (DOM order); audio is pulled into its own bottom lanes. Rows keyed by a
stable layer id, not data-track-index.

Vertical drag always writes z-index, never track: drop onto a layer joins it
(same z), between layers interpolates a new z, past the ends creates a new
front/back layer. data-track-index is never rewritten; #958 holds. Adds
hasExplicitZIndex capture (computed z != auto).

L1: hasExplicitZIndex on the element model
L2: buildStackingTimelineLayers (layer-based rows)
L3: layer-aware vertical drag (join / interpolate / new-extreme)
2026-07-08 23:46:25 -04:00

170 lines
5.3 KiB
TypeScript

import { type TimelineElement } from "../store/playerStore";
import { resolveContextOrder, resolveStackingContextKey } from "../lib/layerOrdering";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { toStackingOrderItem, type TimelineStackingOrderItem } from "./timelineStacking";
export type TimelineLayerId = string;
export interface StackingTimelineLayer {
id: TimelineLayerId;
kind: "visual" | "audio";
contextKey: string;
zIndex: number;
placementTrack: number;
elements: TimelineElement[];
}
export interface StackingTimelineLayerGroups {
visualLayers: StackingTimelineLayer[];
audioLayers: StackingTimelineLayer[];
rows: StackingTimelineLayer[];
}
type TimelineLayerOrderItem = TimelineStackingOrderItem & {
start: number;
duration: number;
index: number;
hasExplicitZIndex: boolean;
element: TimelineElement;
};
type BuildLayer = StackingTimelineLayer & {
hasExplicitZIndex: boolean;
};
function toTimelineLayerOrderItem(element: TimelineElement, index: number): TimelineLayerOrderItem {
return {
...toStackingOrderItem(element),
start: element.start,
duration: element.duration,
index,
hasExplicitZIndex: element.hasExplicitZIndex === true,
element,
};
}
function timelineElementsOverlap(
a: Pick<TimelineElement, "start" | "duration">,
b: Pick<TimelineElement, "start" | "duration">,
): boolean {
return a.start < b.start + b.duration && b.start < a.start + a.duration;
}
function compareLayerItems(a: TimelineLayerOrderItem, b: TimelineLayerOrderItem): number {
if (a.zIndex !== b.zIndex) return b.zIndex - a.zIndex;
if (a.hasExplicitZIndex && b.hasExplicitZIndex && a.track !== b.track) {
return a.track - b.track;
}
return a.index - b.index;
}
function buildLayerId(
prefix: string,
contextKey: string,
element: TimelineElement,
): TimelineLayerId {
return `${prefix}:${contextKey}:${getTimelineElementIdentity(element)}`;
}
function getOrderedContextKeys(items: readonly TimelineLayerOrderItem[]): string[] {
const keys: string[] = [];
for (const item of resolveContextOrder(items)) {
const key = resolveStackingContextKey(item);
if (!keys.includes(key)) keys.push(key);
}
return keys;
}
function canJoinLayer(layer: BuildLayer, item: TimelineLayerOrderItem): boolean {
return (
layer.hasExplicitZIndex &&
item.hasExplicitZIndex &&
layer.contextKey === resolveStackingContextKey(item) &&
layer.zIndex === item.zIndex &&
layer.elements.every((element) => !timelineElementsOverlap(element, item.element))
);
}
function buildVisualLayerRows(items: readonly TimelineLayerOrderItem[]): StackingTimelineLayer[] {
const byContext = new Map<string, TimelineLayerOrderItem[]>();
for (const item of items) {
const key = resolveStackingContextKey(item);
const list = byContext.get(key);
if (list) list.push(item);
else byContext.set(key, [item]);
}
const rows: StackingTimelineLayer[] = [];
for (const contextKey of getOrderedContextKeys(items)) {
const contextRows: BuildLayer[] = [];
const contextItems = [...(byContext.get(contextKey) ?? [])].sort(compareLayerItems);
for (const item of contextItems) {
if (!item.hasExplicitZIndex) {
contextRows.push({
id: buildLayerId("auto", contextKey, item.element),
kind: "visual",
contextKey,
zIndex: item.zIndex,
placementTrack: item.element.track,
elements: [item.element],
hasExplicitZIndex: false,
});
continue;
}
const existing = contextRows.find((row) => canJoinLayer(row, item));
if (existing) {
existing.elements.push(item.element);
continue;
}
contextRows.push({
id: buildLayerId("layer", contextKey, item.element),
kind: "visual",
contextKey,
zIndex: item.zIndex,
placementTrack: item.element.track,
elements: [item.element],
hasExplicitZIndex: true,
});
}
rows.push(...contextRows);
}
return rows;
}
function buildAudioLayerRows(items: readonly TimelineLayerOrderItem[]): StackingTimelineLayer[] {
return items.map((item) => ({
id: buildLayerId("audio", resolveStackingContextKey(item), item.element),
kind: "audio",
contextKey: resolveStackingContextKey(item),
zIndex: item.zIndex,
placementTrack: item.element.track,
elements: [item.element],
}));
}
export function buildStackingTimelineLayers(
elements: readonly TimelineElement[],
): StackingTimelineLayerGroups {
const items = elements.map(toTimelineLayerOrderItem);
const visualItems = items.filter((item) => item.element.tag !== "audio");
const audioItems = items.filter((item) => item.element.tag === "audio");
const visualLayers = buildVisualLayerRows(visualItems);
const audioLayers = buildAudioLayerRows(audioItems);
return {
visualLayers,
audioLayers,
rows: [...visualLayers, ...audioLayers],
};
}
export function insertPreviewTrackOrder(
layerOrder: readonly TimelineLayerId[],
previewLayerId: TimelineLayerId,
previewIndex: number,
): TimelineLayerId[] {
if (layerOrder.includes(previewLayerId)) return [...layerOrder];
const index = Math.max(0, Math.min(layerOrder.length, Math.round(previewIndex)));
return [...layerOrder.slice(0, index), previewLayerId, ...layerOrder.slice(index)];
}