fix(studio): make vertical lane moves persist correctly and harden the z/lane pipeline

Vertical clip moves committed in the store but never survived: two persist
bugs plus a runtime renumber all fought the stable-track-lanes model.

- timelineMoveAdapter deliberately stripped the track from lane-reorder
  persists ('z-only reorder path' — the old z-driven lane model). Lane =
  authored data-track-index now: lane-reorder and track-insert both persist
  the track; plain timing moves omit it to stay SDK-fast-path eligible.
- Display lanes and file tracks are different coordinate spaces:
  normalizeToZones packs sparse authored tracks (1,2,... or gaps, or DOM-index
  fallbacks) onto contiguous display lanes, and lane edits persisted the LANE
  number — silently re-targeting the wrong row in any non-0-contiguous file.
  Elements now record their authoredTrack when remapped; a lane change
  persists the target lane's authored track (store stays in lane space).
- The runtime split same-track clips of different kinds (video vs caption
  div) onto separate renumbered tracks at discovery, so authored indices
  never round-tripped ('drop onto an existing track' bounced back). Removed:
  data-track-index is honored verbatim (render never reads it); kind-based
  row presentation belongs in the display layer if ever wanted.

Adversarial review fixes on the same pipeline:
- runtime: parseInt(attr) || fallback dropped authored track 0 for GSAP and
  overlay clips (parseAuthoredTrack helper honors 0)
- single-clip move fallback persisted only data-start — lane changes snapped
  back on reload (now passes the track to the patch builder)
- lane-change z-sync candidate ignored a multi-selection's time shift, so
  patches were computed against stale overlap sets
- track insert around a locked clip persisted a colliding renumber (the next
  normalize merged lanes); the insert is now refused with a warning
- computeStackingPatches compared leaf z across CSS stacking contexts, where
  ancestor z decides paint order; the sync now partitions by
  stackingContextId and never patches across contexts

Timeline geometry (user-reported):
- fit zoom leaves 20% trailing headroom (FIT_ZOOM_HEADROOM in
  timelineLayout.ts; single fit-pps source, so ruler/lanes/playhead/drag all
  inherit it)
- playhead line center now sits exactly on GUTTER + t*pps at every zoom
  (wrapper had shrink-wrapped to the 9px diamond, off-centering the line);
  ruler ticks center on their timestamp
- ruler: frame-mode steps snap to whole frames (no duplicate labels), hour
  steps added for far zoom-out, tick positions computed as exact multiples
  (no float drift)
This commit is contained in:
ukimsanov
2026-07-13 16:48:52 -07:00
parent 54f41b41b6
commit 19139b91ed
19 changed files with 547 additions and 236 deletions
@@ -39,6 +39,44 @@ describe("collectRuntimeTimelinePayload", () => {
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");
+17 -70
View File
@@ -52,57 +52,15 @@ function maxDefinedNumber(...values: Array<number | null>): number | null {
}
/**
* When multiple content kinds share the same track number, split them
* onto separate tracks so the timeline UI shows distinct rows.
*
* Preferred kind order (top → bottom): composition, video, image, element, audio.
* Tracks that contain only one kind are left untouched.
* Parse an authored track attribute, honoring 0 (a valid top-lane index).
* `parseInt(...) || fallback` silently replaced authored track 0 with the
* synthetic fallback, so track-0 clips drifted to the bottom of the timeline.
*/
const KIND_ORDER: Record<string, number> = {
composition: 0,
video: 1,
image: 2,
element: 3,
audio: 4,
};
function normalizeTrackAssignments(clips: RuntimeTimelineClip[]): void {
if (clips.length === 0) return;
// Group clips by their raw track number and detect which tracks have mixed kinds
const trackKinds = new Map<number, Set<string>>();
for (const clip of clips) {
const kinds = trackKinds.get(clip.track) ?? new Set();
kinds.add(clip.kind);
trackKinds.set(clip.track, kinds);
}
const hasMixedTracks = Array.from(trackKinds.values()).some((kinds) => kinds.size > 1);
if (!hasMixedTracks) return;
// Build new contiguous track numbers, splitting mixed tracks by kind
let nextTrack = 0;
const newTrackMap = new Map<string, number>(); // "origTrack:kind" → newTrack
const sortedTracks = [...trackKinds.keys()].sort((a, b) => a - b);
for (const track of sortedTracks) {
const kinds = trackKinds.get(track)!;
if (kinds.size === 1) {
newTrackMap.set(`${track}:${[...kinds][0]}`, nextTrack++);
} else {
// Split by kind in preferred order
const sorted = [...kinds].sort((a, b) => (KIND_ORDER[a] ?? 99) - (KIND_ORDER[b] ?? 99));
for (const kind of sorted) {
newTrackMap.set(`${track}:${kind}`, nextTrack++);
}
}
}
for (const clip of clips) {
const key = `${clip.track}:${clip.kind}`;
const newTrack = newTrackMap.get(key);
if (newTrack != null) clip.track = newTrack;
}
function parseAuthoredTrack(el: Element, fallback: number): number {
const raw = el.getAttribute("data-track-index") ?? el.getAttribute("data-track");
if (raw == null) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null {
@@ -441,11 +399,7 @@ export function collectRuntimeTimelinePayload(params: {
label: buildTimelineClipLabel(node, kind, clips.length),
start,
duration,
track:
Number.parseInt(
node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i),
10,
) || 0,
track: parseAuthoredTrack(node, i),
zIndex: readInlineZIndex(node),
stackingContextId: compositionContext.parentCompositionId ?? rootCompositionId,
kind,
@@ -554,11 +508,7 @@ export function collectRuntimeTimelinePayload(params: {
el.id,
start: range.start,
duration: clampedDuration,
track:
Number.parseInt(
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
10,
) || gsapTrack,
track: parseAuthoredTrack(el, gsapTrack),
zIndex: readInlineZIndex(el),
stackingContextId: rootCompositionIdForGsap,
kind: "element",
@@ -613,11 +563,7 @@ export function collectRuntimeTimelinePayload(params: {
el.id,
start: 0,
duration: clampedDuration,
track:
Number.parseInt(
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
10,
) || overlayTrack,
track: parseAuthoredTrack(el, overlayTrack),
zIndex: readInlineZIndex(el),
stackingContextId: rootCompositionIdForGsap,
kind: "element",
@@ -637,11 +583,12 @@ export function collectRuntimeTimelinePayload(params: {
}
}
// ── Track normalization ────────────────────────────────────────────────
// When multiple content kinds (composition, audio, video, …) share the same
// data-track-index value, split them onto separate tracks so the timeline UI
// shows distinct rows for each kind.
normalizeTrackAssignments(clips);
// Track assignment honors the authored data-track-index verbatim: a clip stays
// on the track it was placed on, regardless of kind. (Previously mixed-kind
// tracks were split onto separate rows, but that renumbered tracks — breaking
// "drop a clip onto an existing track" and causing the written track to drift
// from the displayed one on every move. Track index is display-only; render
// never reads it, so honoring it verbatim is the correct NLE behavior.)
for (const compositionNode of compositionNodes) {
if (compositionNode === root) continue;