@hyperframes/studio 0.7.54 → 0.7.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/assets/{index-pRhCpGPz.js → index-BRwkMj0w.js} +108 -108
  2. package/dist/assets/{index-uBY329wb.js → index-BXaqaVKt.js} +1 -1
  3. package/dist/assets/{index-CMHYjEZ5.js → index-CPetwHFV.js} +1 -1
  4. package/dist/index.html +1 -1
  5. package/dist/index.js +48 -3
  6. package/dist/index.js.map +1 -1
  7. package/package.json +7 -7
  8. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  9. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  10. package/src/components/editor/anchoredResizeReleaseShift.test.ts +112 -0
  11. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  12. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  13. package/src/hooks/useRazorSplit.history.test.tsx +173 -0
  14. package/src/player/components/timelineCollision.test.ts +437 -0
  15. package/src/player/components/timelineCollision.ts +263 -0
  16. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  17. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  18. package/src/player/components/timelineSnapping.test.ts +134 -0
  19. package/src/player/components/timelineSnapping.ts +110 -0
  20. package/src/player/components/timelineStackingSync.test.ts +244 -0
  21. package/src/player/components/timelineStackingSync.ts +331 -0
  22. package/src/player/components/timelineZones.test.ts +425 -0
  23. package/src/player/components/timelineZones.ts +198 -0
  24. package/src/player/components/timelineZoom.test.ts +67 -0
  25. package/src/player/components/timelineZoom.ts +51 -0
  26. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  27. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  28. package/src/utils/assetClickBehavior.test.ts +104 -0
  29. package/src/utils/assetClickBehavior.ts +75 -0
  30. package/src/utils/canvasNudgeGate.test.ts +31 -0
  31. package/src/utils/canvasNudgeGate.ts +32 -0
  32. package/src/utils/studioUiPreferences.test.ts +38 -0
  33. package/src/utils/studioUiPreferences.ts +27 -0
  34. package/src/utils/timelineInspector.test.ts +121 -0
  35. package/src/utils/timelineInspector.ts +32 -1
@@ -0,0 +1,425 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { classifyZone, normalizeToZones } from "./timelineZones";
4
+ import { computeStackingPatches, type StackingElement } from "./timelineStackingSync";
5
+
6
+ function el(id: string, tag: string, track: number, duration = 2): TimelineElement {
7
+ return { id, tag, start: 0, duration, track };
8
+ }
9
+
10
+ function zClip(
11
+ id: string,
12
+ start: number,
13
+ duration: number,
14
+ track: number,
15
+ zIndex: number,
16
+ tag = "video",
17
+ ): TimelineElement {
18
+ return { id, tag, start, duration, track, zIndex };
19
+ }
20
+
21
+ function trackOf(els: TimelineElement[], id: string): number {
22
+ return els.find((e) => e.id === id)!.track;
23
+ }
24
+
25
+ /** Assert normalizeToZones is idempotent: re-zoning keeps every clip's lane. */
26
+ function expectZoningIdempotent(input: TimelineElement[]): void {
27
+ const once = normalizeToZones(input);
28
+ const twice = normalizeToZones(once);
29
+ for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
30
+ }
31
+
32
+ /** The exact qa-clean live repro (array order = DOM order); fresh objects per call. */
33
+ function qaCleanRepro(): TimelineElement[] {
34
+ return [
35
+ zClip("blue-logo", 6.37, 3, 0, 3, "img"),
36
+ zClip("ralu", 6.37, 3, 0, 0, "img"),
37
+ zClip("black-logo", 11.92, 3, 1, 1, "img"),
38
+ zClip("video", 0.84, 20, 3, 2, "video"),
39
+ ];
40
+ }
41
+
42
+ describe("classifyZone", () => {
43
+ it("audio → audio; video / image / everything else → visual", () => {
44
+ expect(classifyZone(el("m", "audio", 3))).toBe("audio");
45
+ expect(classifyZone(el("v", "video", 1))).toBe("visual");
46
+ expect(classifyZone(el("i", "img", 0))).toBe("visual");
47
+ });
48
+
49
+ it("zone identity invariant: normalizeToZones preserves each clip's zone (mixed input)", () => {
50
+ // normalizeToZones only remaps lanes — it must never reclassify a clip's zone.
51
+ const input = [
52
+ el("v", "video", 0),
53
+ el("a1", "audio", 1),
54
+ el("i", "img", 2),
55
+ el("a2", "audio", 3),
56
+ ];
57
+ const out = normalizeToZones(input);
58
+ for (const e of input) {
59
+ expect(classifyZone(out.find((o) => o.id === e.id)!)).toBe(classifyZone(e));
60
+ }
61
+ // And the partition holds: every visual lane sits above every audio lane.
62
+ const laneOf = (id: string) => trackOf(out, id);
63
+ const maxVisual = Math.max(laneOf("v"), laneOf("i"));
64
+ const minAudio = Math.min(laneOf("a1"), laneOf("a2"));
65
+ expect(maxVisual).toBeLessThan(minAudio);
66
+ });
67
+ });
68
+
69
+ describe("normalizeToZones", () => {
70
+ it("orders visual (top) → audio (bottom); equal-z overlap stacks by DOM order", () => {
71
+ // img and vid are both z=0, start=0 → they OVERLAP in time. CSS paints
72
+ // equal-z siblings by DOM order (later paints on top), so the later-in-array
73
+ // `vid` must own the upper lane. (Was: pinned img=0/vid=1 to authored order,
74
+ // which contradicts the canvas — updated for per-clip DOM-order tie-break.)
75
+ const out = normalizeToZones([
76
+ el("img", "img", 0),
77
+ el("vid", "video", 2),
78
+ el("mus", "audio", 5),
79
+ ]);
80
+ expect(trackOf(out, "vid")).toBe(0); // later in DOM → paints on top → upper lane
81
+ expect(trackOf(out, "img")).toBe(1); // earlier in DOM → below
82
+ expect(trackOf(out, "mus")).toBe(2); // audio (bottom)
83
+ });
84
+
85
+ it("keeps all visual lanes together on top; equal-z overlap stacks by DOM order", () => {
86
+ // All three z=0, start=0 → mutually overlapping. DOM order (later on top)
87
+ // decides the stack: v3 (last) top, then i1, then v0. (Was: pinned to authored
88
+ // order v0=0/i1=1/v3=2, which the canvas contradicts for overlapping equal-z.)
89
+ const out = normalizeToZones([el("v0", "video", 0), el("i1", "img", 1), el("v3", "video", 3)]);
90
+ expect(trackOf(out, "v3")).toBe(0);
91
+ expect(trackOf(out, "i1")).toBe(1);
92
+ expect(trackOf(out, "v0")).toBe(2);
93
+ });
94
+
95
+ it("drops audio below the visual lanes even when sharing a track index", () => {
96
+ const out = normalizeToZones([el("v", "video", 0), el("a", "audio", 0)]);
97
+ expect(trackOf(out, "v")).toBe(0); // visual
98
+ expect(trackOf(out, "a")).toBe(1); // audio, below
99
+ });
100
+
101
+ it("groups multiple audio tracks at the bottom preserving relative order", () => {
102
+ const out = normalizeToZones([el("v", "video", 0), el("a1", "audio", 1), el("a2", "audio", 4)]);
103
+ expect(trackOf(out, "v")).toBe(0);
104
+ expect(trackOf(out, "a1")).toBe(1);
105
+ expect(trackOf(out, "a2")).toBe(2);
106
+ });
107
+
108
+ it("returns the same array (identity) when already zoned", () => {
109
+ // A fixed-point layout: the two overlapping equal-z visual clips are already
110
+ // in DOM-order-consistent lanes (later-in-array `v` on the upper lane 0), so
111
+ // re-zoning is a no-op and the SAME array reference comes back. (Was: i=0/v=1,
112
+ // which the new per-clip DOM-order pack would flip — so it wasn't a fixed
113
+ // point under the corrected canvas semantics; swapped to the stable order.)
114
+ const input = [el("v", "video", 1), el("i", "img", 0), el("a", "audio", 2)];
115
+ expect(normalizeToZones(input)).toBe(input);
116
+ });
117
+
118
+ it("is idempotent (no drift on re-zoning)", () => {
119
+ const input = [
120
+ el("img", "img", 1),
121
+ el("v", "video", 3),
122
+ el("a1", "audio", 2),
123
+ el("a2", "audio", 6),
124
+ ];
125
+ expectZoningIdempotent(input);
126
+ });
127
+
128
+ it("splits time-overlapping clips on one track onto separate lanes (no visible overlap)", () => {
129
+ const clip = (id: string, start: number, duration: number): TimelineElement => ({
130
+ id,
131
+ tag: "video",
132
+ start,
133
+ duration,
134
+ track: 1, // all authored on the SAME track, some overlapping in time
135
+ });
136
+ // a [0,5), b [2,7) overlaps a, c [6,9) fits after a. All equal-z (absent).
137
+ // Per-clip pack (DOM-order tie-break): c is last in DOM so it places on the
138
+ // top lane 0; b overlaps c and lands on lane 1; a overlaps b (and is earlier
139
+ // in DOM than b, so it must paint BELOW b) → lane 2. a can NOT drop onto c's
140
+ // lane 0 even though it doesn't overlap c, because that would place a ABOVE b
141
+ // in lane order while a paints below b — the canvas-correct constraint the old
142
+ // whole-track packer ignored (it shared a/c on lane 0, contradicting paint).
143
+ const out = normalizeToZones([clip("a", 0, 5), clip("b", 2, 5), clip("c", 6, 3)]);
144
+ expect(trackOf(out, "c")).toBe(0); // last in DOM → top lane
145
+ expect(trackOf(out, "b")).toBe(1); // overlaps c → below it
146
+ expect(trackOf(out, "a")).toBe(2); // paints below b (earlier DOM, equal z) → lane below b
147
+
148
+ // No two time-overlapping clips share a lane (the real NLE invariant).
149
+ expect(trackOf(out, "a")).not.toBe(trackOf(out, "b"));
150
+ expect(trackOf(out, "b")).not.toBe(trackOf(out, "c"));
151
+
152
+ // Idempotent: re-laying the split result changes nothing.
153
+ const twice = normalizeToZones(out);
154
+ for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
155
+ });
156
+ });
157
+
158
+ describe("normalizeToZones — reverse z→lane mapping", () => {
159
+ it("orders overlapping same-zone clips by z: higher z → higher (upper) lane", () => {
160
+ // lo (z=1) and hi (z=9) fully overlap in time on the same authored track.
161
+ const out = normalizeToZones([zClip("lo", 0, 10, 0, 1), zClip("hi", 0, 10, 0, 9)]);
162
+ expect(trackOf(out, "hi")).toBe(0); // higher z → upper lane (top)
163
+ expect(trackOf(out, "lo")).toBe(1); // lower z → below
164
+ });
165
+
166
+ it("orders three overlapping clips strictly by descending z", () => {
167
+ const out = normalizeToZones([
168
+ zClip("mid", 0, 10, 0, 5),
169
+ zClip("top", 0, 10, 0, 8),
170
+ zClip("bot", 0, 10, 0, 2),
171
+ ]);
172
+ expect(trackOf(out, "top")).toBe(0);
173
+ expect(trackOf(out, "mid")).toBe(1);
174
+ expect(trackOf(out, "bot")).toBe(2);
175
+ });
176
+
177
+ it("does NOT reorder non-overlapping (sequential) clips by z — they share a lane", () => {
178
+ // a [0,5) z=1 then c [6,9) z=9 — no time overlap, so z is irrelevant.
179
+ const out = normalizeToZones([zClip("a", 0, 5, 0, 1), zClip("c", 6, 3, 0, 9)]);
180
+ expect(trackOf(out, "a")).toBe(0);
181
+ expect(trackOf(out, "c")).toBe(0); // shares the lane regardless of higher z
182
+ });
183
+
184
+ it("leaves the audio zone unaffected by z", () => {
185
+ const out = normalizeToZones([
186
+ zClip("v", 0, 10, 0, 1),
187
+ zClip("m1", 0, 10, 1, 99, "audio"),
188
+ zClip("m2", 0, 10, 1, 0, "audio"),
189
+ ]);
190
+ // Two overlapping audio clips split onto lanes below the visual clip; their
191
+ // relative z does not lift one above a visual clip.
192
+ expect(trackOf(out, "v")).toBe(0);
193
+ expect(trackOf(out, "m1")).toBeGreaterThan(trackOf(out, "v"));
194
+ expect(trackOf(out, "m2")).toBeGreaterThan(trackOf(out, "v"));
195
+ });
196
+
197
+ it("treats missing / auto z as 0 (undefined z clip sinks below a positive-z overlap)", () => {
198
+ const out = normalizeToZones([
199
+ { id: "noz", tag: "video", start: 0, duration: 10, track: 0 }, // no zIndex
200
+ zClip("pos", 0, 10, 0, 3),
201
+ ]);
202
+ expect(trackOf(out, "pos")).toBe(0); // z=3 → upper
203
+ expect(trackOf(out, "noz")).toBe(1); // absent z ⇒ 0 → below
204
+ });
205
+
206
+ it("tie-breaks equal-z overlapping clips on the STABLE id, not the mutated lane", () => {
207
+ // Equal z + full overlap: order must be deterministic (id asc) and survive
208
+ // re-normalization — the historical oscillation bug tie-broke on the track.
209
+ const out = normalizeToZones([zClip("b", 0, 10, 0, 5), zClip("a", 0, 10, 0, 5)]);
210
+ expect(trackOf(out, "a")).toBe(0); // "a" < "b"
211
+ expect(trackOf(out, "b")).toBe(1);
212
+ const twice = normalizeToZones(out);
213
+ for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
214
+ });
215
+
216
+ it("FIXED POINT: normalizeToZones(normalizeToZones(x)) === normalizeToZones(x) with z present", () => {
217
+ const input = [
218
+ zClip("hi", 0, 10, 0, 9),
219
+ zClip("lo", 0, 10, 0, 1),
220
+ zClip("mid", 2, 6, 0, 5),
221
+ zClip("seq", 12, 4, 0, 7),
222
+ zClip("music", 0, 16, 1, 3, "audio"),
223
+ ];
224
+ expectZoningIdempotent(input);
225
+ });
226
+
227
+ it("reload simulation: re-deriving lanes from the SAME z values yields identical lanes", () => {
228
+ // Simulate two independent discovery passes producing fresh element objects
229
+ // carrying the same z — lane assignment must be stable across reloads.
230
+ const build = (): TimelineElement[] => [
231
+ zClip("hi", 0, 10, 0, 9),
232
+ zClip("lo", 0, 10, 0, 1),
233
+ zClip("mid", 3, 5, 0, 5),
234
+ ];
235
+ const first = normalizeToZones(build());
236
+ const second = normalizeToZones(build());
237
+ for (const e of first) expect(trackOf(second, e.id)).toBe(e.track);
238
+ });
239
+ });
240
+
241
+ describe("normalizeToZones — cross-track z→lane (real qa-clean shape)", () => {
242
+ // Derived from /tmp/hf-dnd-qa/qa-clean: a full-length video on authored track 0
243
+ // (z=0), two logo SVGs on track 1 (z=26 and z=0), an icon on track 3 (z=5), and
244
+ // background music on track 2. In the canvas the z=26 / z=5 icons paint ON TOP of
245
+ // the z=0 video; the timeline must agree — the higher-z tracks sit on upper lanes.
246
+ const realProject = (): TimelineElement[] => [
247
+ zClip("ralu", 6.14, 3, 3, 5, "img"),
248
+ zClip("video", 1, 20, 0, 0, "video"),
249
+ zClip("blueLogo", 5.93, 3, 1, 26, "img"),
250
+ zClip("blackLogo", 1, 3, 1, 0, "img"),
251
+ zClip("music", 8.93, 8, 2, 0, "audio"),
252
+ ];
253
+
254
+ it("stacks a higher-z track ABOVE a lower-z track on a different authored track", () => {
255
+ const out = normalizeToZones(realProject());
256
+ // Track 1 (max z 26) tops the visual zone, then track 3 (z 5), then track 0 (z 0).
257
+ expect(trackOf(out, "blueLogo")).toBe(0);
258
+ expect(trackOf(out, "blackLogo")).toBe(0); // sequential to blueLogo → shares lane
259
+ expect(trackOf(out, "ralu")).toBe(1);
260
+ expect(trackOf(out, "video")).toBe(2);
261
+ // Audio stays at the very bottom regardless of its authored track index.
262
+ expect(trackOf(out, "music")).toBe(3);
263
+ });
264
+
265
+ it("the video (z=0) no longer sits above the z=26 / z=5 icons — canvas & timeline agree", () => {
266
+ const out = normalizeToZones(realProject());
267
+ expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "blueLogo"));
268
+ expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "ralu"));
269
+ });
270
+
271
+ it("is idempotent on the real-project shape (no lane drift on re-discovery)", () => {
272
+ const once = normalizeToZones(realProject());
273
+ const twice = normalizeToZones(once);
274
+ for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
275
+ });
276
+
277
+ it("re-derives identical lanes from fresh objects carrying the same z (reload-stable)", () => {
278
+ const first = normalizeToZones(realProject());
279
+ const second = normalizeToZones(realProject());
280
+ for (const e of first) expect(trackOf(second, e.id)).toBe(e.track);
281
+ });
282
+
283
+ it("all-equal-z overlapping clips stack by DOM order (later on top), lanes contiguous", () => {
284
+ // Was: "keeps ascending authored track order when all tracks share z" — that
285
+ // pinned t0=0/t1=1/t3=2 to the authored track index. But these three all
286
+ // start at 0 and OVERLAP, all z=0, so CSS paints them by DOM order: t3 (last)
287
+ // on top. The per-clip pack now reflects that (t3 lane 0, then t1, then t0),
288
+ // which is what the canvas actually shows. Lanes stay contiguous 0..2.
289
+ const out = normalizeToZones([
290
+ zClip("t0", 0, 2, 0, 0),
291
+ zClip("t1", 0, 2, 1, 0),
292
+ zClip("t3", 0, 2, 3, 0),
293
+ ]);
294
+ expect(trackOf(out, "t3")).toBe(0); // last in DOM → paints on top
295
+ expect(trackOf(out, "t1")).toBe(1);
296
+ expect(trackOf(out, "t0")).toBe(2);
297
+ });
298
+ });
299
+
300
+ describe("normalizeToZones — EXACT qa-clean repro (per-clip constrained pack)", () => {
301
+ // The live repro from /tmp/hf-dnd-qa/qa-clean/index.html:
302
+ // blue-logo authored track 0, z=3, 6.37–9.37
303
+ // ralu image authored track 0, z=0, 6.37–9.37 (shares track 0 with blue-logo)
304
+ // black-logo authored track 1, z=1, 11.92–14.92
305
+ // video authored track 3, z=2, 0.84–20.84
306
+ // Canvas truth: video (z=2) covers ralu (z=0). The OLD whole-track packer
307
+ // ordered track 0 by its MAX z (3, from blue-logo), so ralu rode above the
308
+ // z=2 video — the timeline↔canvas contradiction. Array order below = DOM order.
309
+
310
+ it("ACCEPTANCE: lane order top→bottom is blue-logo, video, black-logo, ralu", () => {
311
+ const out = normalizeToZones(qaCleanRepro());
312
+ expect(trackOf(out, "blue-logo")).toBe(0); // z=3 → top
313
+ expect(trackOf(out, "video")).toBe(1); // z=2, overlaps blue-logo → below it
314
+ expect(trackOf(out, "black-logo")).toBe(2); // z=1, overlaps video (11.92–14.92 ∩ 0.84–20.84)
315
+ expect(trackOf(out, "ralu")).toBe(3); // z=0, overlaps blue-logo AND video → bottom
316
+ });
317
+
318
+ it("REGRESSION: a low-z clip must not ride its authored trackmate's high z above a clip that covers it", () => {
319
+ const out = normalizeToZones(qaCleanRepro());
320
+ // ralu (z=0) shares authored track 0 with blue-logo (z=3) but must sink BELOW
321
+ // the video (z=2) that overlaps and paints over it — the whole-track bug.
322
+ expect(trackOf(out, "ralu")).toBeGreaterThan(trackOf(out, "video"));
323
+ // black-logo (z=1) below video (z=2) because they overlap in time.
324
+ expect(trackOf(out, "black-logo")).toBeGreaterThan(trackOf(out, "video"));
325
+ });
326
+
327
+ it("FIXED POINT: running the NEW pack on its own output changes nothing", () => {
328
+ const once = normalizeToZones(qaCleanRepro());
329
+ const twice = normalizeToZones(once);
330
+ for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
331
+ // And a third pass, to be sure convergence is genuine.
332
+ const thrice = normalizeToZones(twice);
333
+ for (const e of twice) expect(trackOf(thrice, e.id)).toBe(e.track);
334
+ });
335
+ });
336
+
337
+ describe("z ↔ lane round-trip convergence (both directions agree)", () => {
338
+ // Project a normalized TimelineElement onto the StackingElement view the
339
+ // forward (lane→z) mapping reasons over.
340
+ const toStacking = (els: TimelineElement[]): StackingElement[] =>
341
+ els.map((e, domIndex) => ({
342
+ key: e.key ?? e.id,
343
+ start: e.start,
344
+ duration: e.duration,
345
+ track: e.track,
346
+ zIndex: Number.isFinite(e.zIndex) ? (e.zIndex as number) : 0,
347
+ isAudio: classifyZone(e) === "audio",
348
+ domIndex,
349
+ }));
350
+
351
+ it("lane-move → z patch → re-discovery orders lanes by that same z → identical lanes (no oscillation)", () => {
352
+ // Two fully-overlapping visual clips. Authored: a below (z=1), b above (z=5).
353
+ const authored: TimelineElement[] = [zClip("a", 0, 10, 0, 1), zClip("b", 0, 10, 0, 5)];
354
+ const normalized = normalizeToZones(authored);
355
+ // z→lane placed b (z=5) on the upper lane 0, a on lane 1.
356
+ expect(trackOf(normalized, "b")).toBe(0);
357
+ expect(trackOf(normalized, "a")).toBe(1);
358
+
359
+ // USER lane-move: drag a to the TOP (lane 0) and push b down (lane 1).
360
+ const afterMove = normalized.map((e) =>
361
+ e.id === "a" ? { ...e, track: 0 } : e.id === "b" ? { ...e, track: 1 } : e,
362
+ );
363
+
364
+ // FORWARD: a lane-move writes the minimal z patch for the edited clip.
365
+ const patches = computeStackingPatches(toStacking(afterMove), ["a"]);
366
+ expect(patches).toEqual([{ key: "a", zIndex: 6 }]); // lifted above b (5)
367
+
368
+ // Apply the z patch back onto the elements (what handleDomZIndexReorderCommit
369
+ // persists; next discovery re-reads it as TimelineElement.zIndex).
370
+ const rediscovered = afterMove.map((e) => {
371
+ const p = patches.find((pp) => pp.key === (e.key ?? e.id));
372
+ return p ? { ...e, zIndex: p.zIndex } : e;
373
+ });
374
+
375
+ // REVERSE: re-normalize from the new z. a (z=6) must now own the upper lane —
376
+ // the same lane the user moved it to. Directions converge, they do not fight.
377
+ const renormalized = normalizeToZones(rediscovered);
378
+ expect(trackOf(renormalized, "a")).toBe(0);
379
+ expect(trackOf(renormalized, "b")).toBe(1);
380
+
381
+ // FIXED POINT: forward on the converged state produces NO further patch, and
382
+ // reverse is idempotent — the round-trip is stable.
383
+ expect(computeStackingPatches(toStacking(renormalized), ["a"])).toEqual([]);
384
+ const twice = normalizeToZones(renormalized);
385
+ for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track);
386
+ });
387
+
388
+ it("qa-clean: drag video BELOW ralu → z patch → re-pack keeps it below, no oscillation", () => {
389
+ // EXACT repro fixture (array order = DOM order).
390
+ const normalized = normalizeToZones(qaCleanRepro());
391
+ // Baseline lanes: blue-logo 0, video 1, black-logo 2, ralu 3.
392
+ expect(trackOf(normalized, "video")).toBe(1);
393
+ expect(trackOf(normalized, "ralu")).toBe(3);
394
+
395
+ // USER lane-move: drag video to the lane BELOW ralu (bottom). ralu is at
396
+ // lane 3, so video goes to a lane strictly greater — model it as lane 4.
397
+ const afterMove = normalized.map((e) => (e.id === "video" ? { ...e, track: 4 } : e));
398
+
399
+ // FORWARD: video (z=2) must drop below ralu (z=0). No integer z ≥ 0 fits
400
+ // strictly below 0, so the tie-aware sync cascades: video→0 and the clips that
401
+ // must stay above it (ralu z=0, black-logo z=1, blue-logo z=3) are bumped as
402
+ // needed so video paints below ralu with all z ≥ 0.
403
+ const patches = computeStackingPatches(toStacking(afterMove), ["video"]);
404
+ const patchByKey = new Map(patches.map((p) => [p.key, p.zIndex]));
405
+ // Video was moved; it must now be strictly below ralu in paint order.
406
+ const zAfter = (id: string): number =>
407
+ patchByKey.get(id) ?? (afterMove.find((e) => e.id === id)!.zIndex as number);
408
+ expect(zAfter("video")).toBeLessThan(zAfter("ralu"));
409
+ expect(zAfter("video")).toBeGreaterThanOrEqual(0);
410
+ expect(patchByKey.size).toBeGreaterThan(0);
411
+
412
+ // Apply patches and re-pack: video's lane must now be BELOW ralu's.
413
+ const rediscovered = afterMove.map((e) => {
414
+ const z = patchByKey.get(e.id);
415
+ return z != null ? { ...e, zIndex: z } : e;
416
+ });
417
+ const renormalized = normalizeToZones(rediscovered);
418
+ expect(trackOf(renormalized, "video")).toBeGreaterThan(trackOf(renormalized, "ralu"));
419
+
420
+ // FIXED POINT: re-running BOTH directions on the converged state is a no-op.
421
+ expect(computeStackingPatches(toStacking(renormalized), ["video"])).toEqual([]);
422
+ const twice = normalizeToZones(renormalized);
423
+ for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track);
424
+ });
425
+ });
@@ -0,0 +1,198 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import { isAudioTimelineElement } from "../../utils/timelineInspector";
3
+
4
+ /**
5
+ * Free-form vertical zones, top → bottom: visual, audio. There is no "main track"
6
+ * — layering is CSS z-index (the renderer ignores track index), so the timeline's
7
+ * only job is to keep visual clips grouped above audio clips.
8
+ */
9
+ export type TrackZone = "visual" | "audio";
10
+
11
+ /** Which zone a clip belongs to: audio elements sink to the bottom, everything
12
+ * else (video / image / text / sub-comp) is a visual lane on top. */
13
+ export function classifyZone(el: TimelineElement): TrackZone {
14
+ return isAudioTimelineElement(el) ? "audio" : "visual";
15
+ }
16
+
17
+ const keyOf = (el: TimelineElement) => el.key ?? el.id;
18
+
19
+ /** Stacking order for a clip: missing / "auto" z is treated as 0. */
20
+ const zOf = (el: TimelineElement) => (Number.isFinite(el.zIndex) ? (el.zIndex as number) : 0);
21
+
22
+ const EPS = 1e-6;
23
+
24
+ /** Two clips overlap when their half-open [start, end) intervals intersect. */
25
+ function overlaps(a: TimelineElement, b: TimelineElement): boolean {
26
+ return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
27
+ }
28
+
29
+ /** A clip paired with its position in the discovery/document (input) order. */
30
+ interface IndexedClip {
31
+ el: TimelineElement;
32
+ /** Index in the input `elements` array = discovery/DOM order. */
33
+ domIndex: number;
34
+ }
35
+
36
+ /** One display lane: the clips packed onto it, in placement order. */
37
+ interface Lane {
38
+ occupants: IndexedClip[];
39
+ /** The single authored track all occupants share, or null once mixed (never
40
+ * happens — we only ever add same-track clips to an existing lane). */
41
+ track: number;
42
+ }
43
+
44
+ /**
45
+ * Lowest lane index a clip may occupy: strictly above every already-placed lane
46
+ * holding a clip it overlaps in time (all of which out-stack it by the z-desc
47
+ * placement order).
48
+ */
49
+ function lowestAllowedLane(lanes: Lane[], item: IndexedClip): number {
50
+ let minLane = 0;
51
+ for (let i = 0; i < lanes.length; i++) {
52
+ if (lanes[i].occupants.some((o) => overlaps(o.el, item.el))) minLane = i + 1;
53
+ }
54
+ return minLane;
55
+ }
56
+
57
+ /**
58
+ * First lane at index ≥ minLane that holds solely this clip's authored track and
59
+ * nothing overlapping (so sequential same-track clips share a lane); -1 when none
60
+ * qualifies and a fresh lane must open.
61
+ */
62
+ function findReusableLane(lanes: Lane[], minLane: number, item: IndexedClip): number {
63
+ for (let i = minLane; i < lanes.length; i++) {
64
+ const lane = lanes[i];
65
+ if (lane.track !== item.el.track) continue;
66
+ if (lane.occupants.some((o) => overlaps(o.el, item.el))) continue;
67
+ return i;
68
+ }
69
+ return -1;
70
+ }
71
+
72
+ /**
73
+ * Pack a WHOLE zone's clips onto display lanes with a single constrained pass so
74
+ * that, for EVERY pair of time-overlapping clips, lane order (upper = lower index)
75
+ * equals canvas stacking order. This replaces the old two-stage
76
+ * `orderTrackBlocksByZ` + per-track `packTrackLanes`, which ordered whole authored
77
+ * tracks by their MAX z and so lifted a low-z clip above a clip that covers it
78
+ * whenever it shared a track with a high-z clip (the qa-clean ralu/video bug — a
79
+ * low-z image rode its z=3 trackmate above the z=2 video that paints over it). No
80
+ * whole-track mapping can fix that; the mapping must be per-clip.
81
+ *
82
+ * Algorithm:
83
+ * 1. Order clips by z DESC; z-tie → INPUT-ARRAY-INDEX (DOM order) DESC (CSS
84
+ * paints equal-z siblings by DOM order — LATER in DOM paints on top, so it
85
+ * must place first / upper); final tie → stable key. NEVER tie-break on the
86
+ * mutated lane/track index (historic oscillation bug — the tie-break must be
87
+ * a stable function of the input, not of the output being computed).
88
+ * 2. Place each clip at lane ≥ (1 + highest lane among already-placed clips it
89
+ * OVERLAPS IN TIME). By z-desc placement every already-placed overlapping
90
+ * clip out-stacks this one (higher z, or equal z but later in DOM), so this
91
+ * guarantees lane order == stacking order for every overlapping pair.
92
+ * 3. To preserve the "distinct authored tracks stay distinct / sequential
93
+ * same-track clips share a lane" feel, reuse an existing lane at index ≥ that
94
+ * minimum ONLY when the lane's occupants are all from the SAME authored track
95
+ * AND none overlaps this clip in time; otherwise open a fresh lane.
96
+ *
97
+ * Writes each clip's absolute display lane (`base + laneIndex`) into `laneOf` and
98
+ * returns the number of lanes used (≥ 1 when non-empty).
99
+ */
100
+ function packZoneLanes(clips: IndexedClip[], base: number, laneOf: Map<string, number>): number {
101
+ const ordered = [...clips].sort(
102
+ (a, b) =>
103
+ zOf(b.el) - zOf(a.el) || b.domIndex - a.domIndex || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
104
+ );
105
+ const lanes: Lane[] = [];
106
+ for (const item of ordered) {
107
+ const minLane = lowestAllowedLane(lanes, item);
108
+ let placed = findReusableLane(lanes, minLane, item);
109
+ if (placed === -1) {
110
+ placed = lanes.length;
111
+ lanes.push({ occupants: [], track: item.el.track });
112
+ }
113
+ lanes[placed].occupants.push(item);
114
+ laneOf.set(keyOf(item.el), base + placed);
115
+ }
116
+ return lanes.length;
117
+ }
118
+
119
+ /**
120
+ * Legacy per-track interval packing for the AUDIO zone (no z semantics): pack one
121
+ * authored track's clips onto sub-lanes so no two overlap in time — sequential
122
+ * clips share a lane, overlapping ones spill onto the next (first-fit). Ordered by
123
+ * start (then stable key) so the layout is deterministic and idempotent. Returns
124
+ * the number of lanes used (≥ 1 when non-empty).
125
+ */
126
+ function packAudioTrackLanes(
127
+ clips: IndexedClip[],
128
+ base: number,
129
+ laneOf: Map<string, number>,
130
+ ): number {
131
+ const ordered = [...clips].sort(
132
+ (a, b) => a.el.start - b.el.start || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
133
+ );
134
+ const lanes: IndexedClip[][] = [];
135
+ for (const item of ordered) {
136
+ let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o.el, item.el)));
137
+ if (sub === -1) {
138
+ sub = lanes.length;
139
+ lanes.push([]);
140
+ }
141
+ lanes[sub].push(item);
142
+ laneOf.set(keyOf(item.el), base + sub);
143
+ }
144
+ return Math.max(1, lanes.length);
145
+ }
146
+
147
+ /**
148
+ * Assign display lanes for the timeline: visual lanes on top, audio lanes below.
149
+ *
150
+ * The VISUAL zone is packed per-clip (packZoneLanes) so the timeline's vertical
151
+ * order matches the canvas's CSS stacking for EVERY time-overlapping pair — a
152
+ * low-z clip sinks below a clip that covers it even if it shares an authored track
153
+ * with a higher-z clip. Time-overlapping clips still split onto separate lanes
154
+ * (standard NLE), sequential same-track clips still share a lane, and distinct
155
+ * authored tracks stay distinct.
156
+ *
157
+ * The AUDIO zone keeps the original behavior — authored-track order, per-track
158
+ * interval packing — because audio has no z / stacking semantics.
159
+ *
160
+ * Pure — returns a new array; unchanged clips keep their identity. Display-only
161
+ * (runs on discovery); it does not rewrite the source. Idempotent (running it on
162
+ * its own output is a fixed point).
163
+ */
164
+ export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] {
165
+ if (elements.length === 0) return elements;
166
+
167
+ const laneOf = new Map<string, number>();
168
+ let nextLane = 0;
169
+
170
+ const visual: IndexedClip[] = [];
171
+ const audio: IndexedClip[] = [];
172
+ elements.forEach((el, domIndex) => {
173
+ (classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex });
174
+ });
175
+
176
+ nextLane += packZoneLanes(visual, nextLane, laneOf);
177
+
178
+ // Audio: preserve legacy behavior — group by authored track (ascending), pack
179
+ // each track's overlapping clips onto sub-lanes.
180
+ const audioByTrack = new Map<number, IndexedClip[]>();
181
+ for (const item of audio) {
182
+ const list = audioByTrack.get(item.el.track);
183
+ if (list) list.push(item);
184
+ else audioByTrack.set(item.el.track, [item]);
185
+ }
186
+ for (const track of [...audioByTrack.keys()].sort((a, b) => a - b)) {
187
+ nextLane += packAudioTrackLanes(audioByTrack.get(track)!, nextLane, laneOf);
188
+ }
189
+
190
+ let changed = false;
191
+ const remapped = elements.map((el) => {
192
+ const lane = laneOf.get(keyOf(el));
193
+ if (lane == null || lane === el.track) return el;
194
+ changed = true;
195
+ return { ...el, track: lane };
196
+ });
197
+ return changed ? remapped : elements;
198
+ }