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
+15 -13
View File
@@ -160,23 +160,27 @@ export function useTimelineEditing({
if (!startChanged) return reorderDone;
// needsExtension gates the SDK path (setTiming can't grow the root duration),
// so read the store BEFORE the readout sync below optimistically updates it.
// needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
// Optimistic duration readout: content-driven (grow AND shrink), read from
// the just-patched live DOM. See syncPreviewContentDuration.
// Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
syncPreviewContentDuration(previewIframeRef.current);
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
// Persist lane changes too — data-start-only writes let reload snap the lane back.
const track = updates.track !== element.track ? updates.track : undefined;
return buildTimelineMoveTimingPatch(
original,
target,
updates.start,
element.duration,
track,
);
};
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
const moveFallback = () =>
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
// Soft-reload with the server's rewritten GSAP script instead of a full
// iframe reload — a timing-only move already patched the DOM + store, so
// swapping the script in place avoids the all-clips flash. Falls back to
// reloadPreview() when the soft path can't apply. (See timelineTimingSync.)
// Soft-reload with the server's rewritten GSAP script — the timing-only move already patched
// DOM + store, so swapping the script avoids the all-clips flash; falls back to reloadPreview().
finishClipTimingFallback({
iframe: previewIframeRef.current,
reloadPreview,
@@ -249,11 +253,9 @@ export function useTimelineEditing({
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
}
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
// needsExtension gates the SDK path (setTiming can't grow the root duration),
// so read the store BEFORE the readout sync below optimistically updates it.
// needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
// Optimistic duration readout: content-driven (grow AND shrink), read from
// the just-patched live DOM. See syncPreviewContentDuration.
// Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
syncPreviewContentDuration(previewIframeRef.current);
const targetPath = element.sourceFile || activeCompPath || "index.html";
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {