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
@@ -139,6 +139,26 @@ describe("persistTimelineBatchEdit", () => {
);
}
function moveMember(
id: string,
start: number,
fromTrack: number,
toTrack: number,
): PersistTimelineBatchChange {
return {
element: el({ id, tag: "video", domId: id, start, track: fromTrack }),
buildPatches: (original, target) =>
buildTimelineMoveTimingPatch(original, target, start, 5, toTrack),
};
}
async function runBatch(changes: PersistTimelineBatchChange[]) {
stubReadFileContent(SOURCE);
const writes: Array<[string, string]> = [];
await persistTimelineBatchEdit(batchInput(changes, writes));
return writes;
}
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -147,28 +167,12 @@ describe("persistTimelineBatchEdit", () => {
// A track-insert renumber can include a member whose attributes already
// hold the target values — its patch is string-identical. The batch must
// skip it and still persist the members that DID change.
stubReadFileContent(SOURCE);
const writes: Array<[string, string]> = [];
await persistTimelineBatchEdit(
batchInput(
[
{
// no-op: data-start already "1", track already 0
element: el({ id: "a", tag: "video", domId: "a", start: 1, track: 0 }),
buildPatches: (original, target) =>
buildTimelineMoveTimingPatch(original, target, 1, 5, 0),
},
{
// real change: track 1 -> 2
element: el({ id: "b", tag: "video", domId: "b", start: 2, track: 1 }),
buildPatches: (original, target) =>
buildTimelineMoveTimingPatch(original, target, 2, 5, 2),
},
],
writes,
),
);
const writes = await runBatch([
// no-op: data-start already "1", track already 0
moveMember("a", 1, 0, 0),
// real change: track 1 -> 2
moveMember("b", 2, 1, 2),
]);
expect(writes).toHaveLength(1);
expect(writes[0]![0]).toBe("index.html");
@@ -176,21 +180,7 @@ describe("persistTimelineBatchEdit", () => {
});
it("saves nothing when every member is a no-op", async () => {
stubReadFileContent(SOURCE);
const writes: Array<[string, string]> = [];
await persistTimelineBatchEdit(
batchInput(
[
{
element: el({ id: "a", tag: "video", domId: "a", start: 1, track: 0 }),
buildPatches: (original, target) =>
buildTimelineMoveTimingPatch(original, target, 1, 5, 0),
},
],
writes,
),
);
const writes = await runBatch([moveMember("a", 1, 0, 0)]);
expect(writes).toHaveLength(0);
});