@hyperframes/studio 0.8.91 → 0.8.93

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 (37) hide show
  1. package/dist/assets/{hyperframes-player-zT2Bdx_l.js → hyperframes-player-CkV9KK6A.js} +3 -3
  2. package/dist/assets/{index-DV-z19zB.js → index-BlLFipOk.js} +1 -1
  3. package/dist/assets/{index-V-0rfhe6.js → index-CYk0NwSv.js} +165 -165
  4. package/dist/assets/{index-DSOLcE8X.js → index-V3fzEfOD.js} +1 -1
  5. package/dist/index.d.ts +6 -11
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +8792 -8803
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
  11. package/src/hooks/useTimelineAssetDropOps.ts +40 -18
  12. package/src/player/components/Timeline.rulerSeek.test.tsx +7 -7
  13. package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
  14. package/src/player/components/timelineClipDragCommit.test.ts +14 -58
  15. package/src/player/components/timelineClipDragCommit.ts +14 -37
  16. package/src/player/components/timelineClipDragPreview.test.ts +213 -22
  17. package/src/player/components/timelineClipDragPreview.ts +46 -81
  18. package/src/player/components/timelineClipDragTypes.ts +3 -8
  19. package/src/player/components/timelineCollision.test.ts +100 -302
  20. package/src/player/components/timelineCollision.ts +73 -160
  21. package/src/player/components/timelineDragDrop.test.tsx +19 -5
  22. package/src/player/components/timelineDragDrop.ts +7 -9
  23. package/src/player/components/timelineDragLanding.ts +0 -49
  24. package/src/player/components/timelineLayout.test.ts +0 -25
  25. package/src/player/components/timelineLayout.ts +0 -16
  26. package/src/player/components/timelineMultiDragPreview.test.ts +34 -25
  27. package/src/player/components/timelineMultiDragPreview.ts +25 -27
  28. package/src/player/components/timelineProviderStateBuilders.ts +7 -4
  29. package/src/player/components/timelineShellMeta.tsx +99 -0
  30. package/src/player/components/useTimelineDragPreviewState.ts +34 -0
  31. package/src/player/components/useTimelineProviderState.tsx +29 -67
  32. package/src/player/hooks/useTimelinePlayer.seek.test.ts +29 -1
  33. package/src/player/hooks/useTimelinePlayer.ts +6 -0
  34. package/src/player/hooks/useTimelinePlayerLoop.ts +6 -2
  35. package/src/player/lib/playbackTypes.ts +1 -0
  36. package/src/utils/timelineDropTrackInsert.test.ts +60 -1
  37. package/src/utils/timelineDropTrackInsert.ts +36 -5
@@ -5,12 +5,10 @@ import {
5
5
  isInsertAllowedForZone,
6
6
  isLaneFree,
7
7
  resolveInsertRow,
8
- resolveMainTrackDropStart,
9
- resolvePlacement,
8
+ resolveNearestFreeStart,
10
9
  resolveZoneDropPlacement,
11
10
  timeRangesOverlap,
12
11
  } from "./timelineCollision";
13
- import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
14
12
 
15
13
  function el(id: string, track: number, start: number, duration: number): TimelineElement {
16
14
  return { id, tag: "video", start, duration, track };
@@ -41,196 +39,84 @@ describe("isLaneFree", () => {
41
39
  });
42
40
  });
43
41
 
44
- describe("resolvePlacement", () => {
45
- const trackOrder = [0, 1, 2, 3];
42
+ describe("resolveNearestFreeStart", () => {
43
+ const row = [el("a", 0, 2, 3), el("b", 0, 8, 2)]; // busy [2,5) and [8,10)
46
44
 
47
- it("keeps the desired lane when it is free", () => {
48
- const els = [el("a", 2, 0, 4)];
49
- expect(
50
- resolvePlacement({
51
- elements: els,
52
- desiredTrack: 1,
53
- start: 0,
54
- duration: 4,
55
- trackOrder,
56
- excludeKey: null,
57
- }),
58
- ).toEqual({
59
- track: 1,
60
- needsInsert: false,
61
- });
45
+ it("keeps a start whose span is already free", () => {
46
+ expect(resolveNearestFreeStart(row, 0, 5, 3, null)).toBe(5);
47
+ expect(resolveNearestFreeStart(row, 1, 3, 4, null)).toBe(3); // another row
62
48
  });
63
49
 
64
- it("pushes up to the nearest free lane above when the target is occupied", () => {
65
- // desired = 2 occupied; 1 free above → land on 1
66
- const els = [el("blocker", 2, 0, 4)];
67
- expect(
68
- resolvePlacement({
69
- elements: els,
70
- desiredTrack: 2,
71
- start: 1,
72
- duration: 2,
73
- trackOrder,
74
- excludeKey: null,
75
- }),
76
- ).toEqual({ track: 1, needsInsert: false });
50
+ it("keeps the clip's own start between edges no centisecond start fits", () => {
51
+ const tight = [el("a", 0, 0, 3.333), el("b", 0, 6.666, 4)];
52
+ expect(resolveNearestFreeStart(tight, 0, 3.333, 3.333, null)).toBe(10.67);
53
+ expect(resolveNearestFreeStart(tight, 0, 3.333, 3.333, null, 0, 3.333)).toBe(3.333);
54
+ expect(resolveNearestFreeStart(tight, 0, 3.4, 3.333, null, 0, 3.333)).toBe(3.333);
77
55
  });
78
56
 
79
- it("prefers up even when a lane below is also free", () => {
80
- // desired 2 occupied; both 1 (up) and 3 (down) free → up wins
81
- const els = [el("blocker", 2, 0, 5)];
82
- expect(
83
- resolvePlacement({
84
- elements: els,
85
- desiredTrack: 2,
86
- start: 0,
87
- duration: 3,
88
- trackOrder,
89
- excludeKey: null,
90
- }),
91
- ).toEqual({ track: 1, needsInsert: false });
57
+ it("keeps the clip's own start when float sums land a hair past a neighbour's edge", () => {
58
+ const after = [el("a", 0, 0, 0.333), el("b", 0, 2.066, 1)]; // 0.333 + 1.733 = 2.0660000000000003
59
+ expect(resolveNearestFreeStart(after, 0, 0.333, 1.733, null, 0, 0.333)).toBe(0.333);
60
+ const before = [el("a", 0, 0.1, 0.2), el("b", 0, 1.3, 1)]; // a ends at 0.30000000000000004
61
+ expect(resolveNearestFreeStart(before, 0, 0.3, 1, null, 0, 0.3)).toBe(0.3);
92
62
  });
93
63
 
94
- it("falls to a lane below when every lane above is occupied", () => {
95
- // desired 1 occupied; 0 occupied above; 2 free below → land on 2
96
- const els = [el("x", 0, 0, 5), el("y", 1, 0, 5)];
97
- expect(
98
- resolvePlacement({
99
- elements: els,
100
- desiredTrack: 1,
101
- start: 1,
102
- duration: 2,
103
- trackOrder,
104
- excludeKey: null,
105
- }),
106
- ).toEqual({ track: 2, needsInsert: false });
64
+ it("takes the clip's own start only when it is free, not below minStart, and nearest", () => {
65
+ expect(resolveNearestFreeStart(row, 0, 4, 2, null, 0, 3.5)).toBe(5); // nearer, but its own spot overlaps a
66
+ expect(resolveNearestFreeStart(row, 0, 0, 1, null, 1, 0.5)).toBe(1); // below the floor
67
+ expect(resolveNearestFreeStart(row, 0, 6, 1, null, 0, 5.5)).toBe(6); // the release time is nearer
68
+ expect(resolveNearestFreeStart(row, 0, 6.5, 1, null, 0, 6)).toBe(6.5); // nearer again, not the origin
107
69
  });
108
70
 
109
- it("signals needsInsert when no lane is free", () => {
110
- const els = [el("a", 0, 0, 9), el("b", 1, 0, 9), el("c", 2, 0, 9), el("d", 3, 0, 9)];
111
- expect(
112
- resolvePlacement({
113
- elements: els,
114
- desiredTrack: 2,
115
- start: 1,
116
- duration: 2,
117
- trackOrder,
118
- excludeKey: null,
119
- }),
120
- ).toEqual({ track: 2, needsInsert: true });
71
+ it("moves an overlapping span to the nearest time it fits", () => {
72
+ expect(resolveNearestFreeStart(row, 0, 4, 2, null)).toBe(5); // 1 s later beats 2 s earlier
73
+ expect(resolveNearestFreeStart(row, 0, 2.2, 1.5, null)).toBe(0.5); // 1.7 s earlier beats 2.8 s later
121
74
  });
122
75
 
123
- it("signals needsInsert when the desired track is occupied but absent from the zone's lanes (#2195)", () => {
124
- // desiredTrack 5 is occupied yet not in trackOrder (its kind-zone has no lane
125
- // here). Landing on it would overlap, so the empty-zone branch must insert —
126
- // not silently land on the occupied track (the placement hole).
127
- const els = [el("blocker", 5, 0, 5)];
128
- expect(
129
- resolvePlacement({
130
- elements: els,
131
- desiredTrack: 5,
132
- start: 1,
133
- duration: 2,
134
- trackOrder: [],
135
- excludeKey: null,
136
- }),
137
- ).toEqual({ track: 5, needsInsert: true });
76
+ it("sends a tie to the later time", () => {
77
+ expect(resolveNearestFreeStart([el("a", 0, 2, 2)], 0, 2, 2, null)).toBe(4); // 0 and 4 both 2 s away
138
78
  });
139
79
 
140
- it("signals needsInsert when the desired track is FREE but absent from the zone's lanes (#2195 free-span hole)", () => {
141
- // desiredTrack 5 is FREE (no overlap) yet not in trackOrder — its kind-zone has
142
- // no lane here. The old code short-circuited on isLaneFree and landed on the
143
- // foreign-zone lane; the zone check must win and signal an insert.
144
- const els = [el("elsewhere", 9, 0, 5)];
145
- expect(
146
- resolvePlacement({
147
- elements: els,
148
- desiredTrack: 5,
149
- start: 1,
150
- duration: 2,
151
- trackOrder: [],
152
- excludeKey: null,
153
- }),
154
- ).toEqual({ track: 5, needsInsert: true });
80
+ it("skips a gap too short for the clip and lands after the row's last clip", () => {
81
+ expect(resolveNearestFreeStart(row, 0, 5, 4, null)).toBe(10); // the [5,8) gap is 3 s
82
+ expect(resolveNearestFreeStart([el("a", 0, 0, 30)], 0, 12, 5, null)).toBe(30);
155
83
  });
156
84
 
157
- it("placeholder-scenario excludes the dragged clip so it does not collide with itself", () => {
158
- const els = [el("self", 1, 0, 5)];
159
- expect(
160
- resolvePlacement({
161
- elements: els,
162
- desiredTrack: 1,
163
- start: 0,
164
- duration: 5,
165
- trackOrder,
166
- excludeKey: "self",
167
- }),
168
- ).toEqual({ track: 1, needsInsert: false });
169
- });
170
- });
171
-
172
- describe("resolveInsertRow", () => {
173
- const n = 3; // three lanes: rows 0,1,2
174
-
175
- it("targets the lane (null) when over its middle band", () => {
176
- expect(resolveInsertRow(1.5, n, 0.22)).toBe(null); // dead center of lane 1
177
- });
178
-
179
- it("inserts at the top boundary of a lane when near its top edge", () => {
180
- expect(resolveInsertRow(1.1, n, 0.22)).toBe(1); // just into lane 1 → boundary above it
181
- });
182
-
183
- it("inserts at the bottom boundary of a lane when near its bottom edge", () => {
184
- expect(resolveInsertRow(1.9, n, 0.22)).toBe(2); // near bottom of lane 1 → boundary below
85
+ it("ignores the excluded (dragged) clip", () => {
86
+ expect(resolveNearestFreeStart(row, 0, 2, 3, "a")).toBe(2);
185
87
  });
186
88
 
187
- it("inserts above the top lane when the pointer is above everything", () => {
188
- expect(resolveInsertRow(-0.5, n, 0.22)).toBe(0);
89
+ it("never goes below the lowest allowed start", () => {
90
+ const blocked = [el("c", 0, 6.4, 3.6)]; // busy [6.4,10)
91
+ expect(resolveNearestFreeStart(blocked, 0, 6.5, 2, null)).toBe(4.4);
92
+ expect(resolveNearestFreeStart(blocked, 0, 6.5, 2, null, 6.5)).toBe(10);
93
+ expect(resolveNearestFreeStart([], 0, 6.667, 2, null, 6.667)).toBe(6.667); // not rounded up
189
94
  });
190
95
 
191
- it("inserts below the bottom lane when the pointer is past the last lane", () => {
192
- expect(resolveInsertRow(3.4, n, 0.22)).toBe(3);
96
+ it("keeps a gap-bound start on centiseconds so a written start cannot overlap", () => {
97
+ const start = resolveNearestFreeStart([el("a", 0, 7.1, 2)], 0, 3, 5.045333, null);
98
+ expect(start).toBe(2.05);
99
+ expect(start + 5.045333).toBeLessThanOrEqual(7.1);
193
100
  });
194
101
  });
195
102
 
196
- describe("resolveInsertRow — production band only arms in the inter-clip gutter (UX rule 1)", () => {
197
- // The production band equals the clip inset (CLIP_Y / TRACK_H): a clip body fills
198
- // [band, 1 − band] of its row, so an insert must ONLY arm in the thin gutter that
199
- // straddles a boundary — never while the pointer is over a clip body. This is the
200
- // regression for the plain-horizontal-drag misfire (the old 0.32 band armed an
201
- // insert across ~64% of every row).
202
- const n = 3;
203
- const b = INSERT_BOUNDARY_BAND;
204
-
205
- it("the band is the clip inset (3/48), not the old feel-tuned 0.32", () => {
206
- expect(b).toBeCloseTo(3 / 48, 10);
207
- expect(b).toBeLessThan(0.1);
208
- });
209
-
210
- it("returns null across the WHOLE clip body of every lane (no insert = move-to-lane)", () => {
211
- // Sweep each lane's body [band+ε, 1−band−ε] in fine steps → always a move.
212
- for (let lane = 0; lane < n; lane++) {
213
- for (let frac = b + 0.01; frac <= 1 - b - 0.01 + 1e-9; frac += 0.02) {
214
- expect(resolveInsertRow(lane + frac, n, b)).toBeNull();
215
- }
103
+ describe("resolveInsertRow (only empty space outside the rows opens a track)", () => {
104
+ const n = 3; // three rows: 0, 1, 2
105
+
106
+ it("stays on the row everywhere over it, edges included", () => {
107
+ for (let rowFloat = 0; rowFloat < n; rowFloat += 0.01) {
108
+ expect(resolveInsertRow(rowFloat, n)).toBeNull();
216
109
  }
217
110
  });
218
111
 
219
- it("arms an insert in the gutter straddling every internal boundary (dead-zone-free)", () => {
220
- // Just under a boundary → insert BELOW the upper lane; just over → ABOVE the
221
- // lower lane. Both resolve to the same boundary row, so the gutter has no dead
222
- // spot that neither moves nor inserts.
223
- expect(resolveInsertRow(1 - b / 2, n, b)).toBe(1); // bottom gutter of lane 0
224
- expect(resolveInsertRow(1 + b / 2, n, b)).toBe(1); // top gutter of lane 1
225
- expect(resolveInsertRow(2 - b / 2, n, b)).toBe(2);
226
- expect(resolveInsertRow(2 + b / 2, n, b)).toBe(2);
112
+ it("opens a track above the first row and below the last", () => {
113
+ expect(resolveInsertRow(-0.4, n)).toBe(0);
114
+ expect(resolveInsertRow(n, n)).toBe(n);
115
+ expect(resolveInsertRow(n + 0.4, n)).toBe(n);
227
116
  });
228
117
 
229
- it("still arms a top / bottom insert above the first / below the last lane", () => {
230
- expect(resolveInsertRow(b / 2, n, b)).toBe(0); // top gutter of lane 0 → insert above top
231
- expect(resolveInsertRow(-0.4, n, b)).toBe(0); // in the top breathing pad
232
- expect(resolveInsertRow(n - b / 2, n, b)).toBe(n); // bottom gutter of last lane
233
- expect(resolveInsertRow(n + 0.4, n, b)).toBe(n); // in the bottom breathing pad
118
+ it("opens the first track on an empty timeline", () => {
119
+ expect(resolveInsertRow(0.5, 0)).toBe(0);
234
120
  });
235
121
  });
236
122
 
@@ -273,7 +159,7 @@ describe("isInsertAllowedForZone", () => {
273
159
  });
274
160
  });
275
161
 
276
- describe("resolveZoneDropPlacement (the whole drop decision, no same-track overlap)", () => {
162
+ describe("resolveZoneDropPlacement (a drop on a row stays on that row, no overlap)", () => {
277
163
  // order [0,1,2] visual + [3] audio. audioRow = 3.
278
164
  const order = [0, 1, 2, 3];
279
165
  const audioTracks = new Set([3]);
@@ -286,90 +172,49 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
286
172
  dragKey: "x",
287
173
  isAudio: false,
288
174
  };
175
+ const allVisualOccupied = () => [el("a", 0, 0, 10), el("b", 1, 0, 10), el("c", 2, 0, 10)];
289
176
 
290
177
  it("lands on the aimed track when it is free at that time", () => {
291
178
  expect(
292
179
  resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 10, 3)], desiredTrack: 1 }),
293
- ).toEqual({ track: 1, insertRow: null });
180
+ ).toEqual({ track: 1, insertRow: null, start: 2 });
294
181
  });
295
182
 
296
- it("relocates UP to the nearest free track when the aimed spot overlaps a clip", () => {
183
+ it("stays on the aimed row at its nearest free time when the aimed span is occupied", () => {
297
184
  expect(
298
185
  resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 0, 5)], desiredTrack: 1 }),
299
- ).toEqual({ track: 0, insertRow: null });
186
+ ).toEqual({ track: 1, insertRow: null, start: 5 });
300
187
  });
301
188
 
302
- it("relocates DOWN when the tracks above are also occupied", () => {
189
+ it("never hops to another row or opens a track when every row is occupied", () => {
303
190
  expect(
304
- resolveZoneDropPlacement({
305
- ...base,
306
- elements: [el("a", 0, 0, 5), el("b", 1, 0, 5)],
307
- desiredTrack: 1,
308
- }),
309
- ).toEqual({ track: 2, insertRow: null });
191
+ resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: 1 }),
192
+ ).toEqual({ track: 1, insertRow: null, start: 10 });
310
193
  });
311
194
 
312
- it("crosses an occupied aim instead of snapping the dragged clip back to its origin", () => {
195
+ it("does not fall back to the dragged clip's origin row", () => {
313
196
  expect(
314
197
  resolveZoneDropPlacement({
315
198
  ...base,
316
199
  elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("x", 2, 0, 5)],
317
200
  desiredTrack: 1,
318
201
  }),
319
- ).toEqual({ track: 1, insertRow: 1 });
320
-
321
- expect(
322
- resolveZoneDropPlacement({
323
- ...base,
324
- elements: [el("x", 0, 0, 5), el("b", 1, 0, 5), el("a", 2, 0, 5)],
325
- desiredTrack: 1,
326
- }),
327
- ).toEqual({ track: 1, insertRow: 2 });
328
- });
329
-
330
- it("auto-creates a new track when EVERY lane in the zone is occupied at that time", () => {
331
- expect(
332
- resolveZoneDropPlacement({
333
- ...base,
334
- elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)],
335
- desiredTrack: 1,
336
- }),
337
- ).toEqual({ track: 1, insertRow: 2 });
202
+ ).toEqual({ track: 1, insertRow: null, start: 5 });
338
203
  });
339
204
 
340
- // Every visual lane (0,1,2) occupied across the drop span → no free lane exists.
341
- const allVisualOccupied = () => [el("a", 0, 0, 10), el("b", 1, 0, 10), el("c", 2, 0, 10)];
342
-
343
- it("occupied aim with NO free lane creates an adjacent track — never snaps back (UX rule 3)", () => {
344
- // The clip must NOT return to its origin; it gets a fresh track adjacent to the
345
- // aim. Default bias = below the aimed row.
346
- const result = resolveZoneDropPlacement({
347
- ...base,
348
- elements: allVisualOccupied(),
349
- desiredTrack: 1,
205
+ it("Tag (2 s, 5 s long) onto the Subtitle row (0-6 s) lands at 6 s on that row", () => {
206
+ const elements = [el("title", 0, 0, 10), el("subtitle", 1, 0, 6), el("tag", 2, 2, 5)];
207
+ const drop = { ...base, elements, start: 2, duration: 5, dragKey: "tag" };
208
+ expect(resolveZoneDropPlacement({ ...drop, desiredTrack: 1 })).toEqual({
209
+ track: 1,
210
+ insertRow: null,
211
+ start: 6,
212
+ });
213
+ expect(resolveZoneDropPlacement({ ...drop, desiredTrack: 0 })).toEqual({
214
+ track: 0,
215
+ insertRow: null,
216
+ start: 10,
350
217
  });
351
- expect(result.insertRow).not.toBeNull(); // a track is created, not an origin snap-back
352
- expect(result).toEqual({ track: 1, insertRow: 2 }); // adjacent, below the aimed row
353
- });
354
-
355
- it("opens the new adjacent track ABOVE the aimed row when the pointer is in its upper half (UX rule 3)", () => {
356
- expect(
357
- resolveZoneDropPlacement({
358
- ...base,
359
- elements: allVisualOccupied(),
360
- desiredTrack: 1,
361
- preferInsertAbove: true,
362
- }),
363
- ).toEqual({ track: 1, insertRow: 1 }); // boundary ABOVE lane 1
364
- // Same aim, pointer in the lower half → the track opens BELOW the aimed row.
365
- expect(
366
- resolveZoneDropPlacement({
367
- ...base,
368
- elements: allVisualOccupied(),
369
- desiredTrack: 1,
370
- preferInsertAbove: false,
371
- }),
372
- ).toEqual({ track: 1, insertRow: 2 });
373
218
  });
374
219
 
375
220
  it("shares a track for sequential (non-overlapping) clips", () => {
@@ -380,45 +225,37 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
380
225
  desiredTrack: 1,
381
226
  start: 2,
382
227
  }),
383
- ).toEqual({ track: 1, insertRow: null });
228
+ ).toEqual({ track: 1, insertRow: null, start: 2 });
384
229
  });
385
230
 
386
231
  it("clamps a visual clip OUT of the audio zone before placing", () => {
387
232
  expect(resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 3 })).toEqual({
388
233
  track: 2,
389
234
  insertRow: null,
235
+ start: 2,
390
236
  });
391
237
  });
392
238
 
393
239
  it("clamps an audio clip INTO the audio zone before placing", () => {
394
240
  expect(
395
241
  resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 0, isAudio: true }),
396
- ).toEqual({ track: 3, insertRow: null });
242
+ ).toEqual({ track: 3, insertRow: null, start: 2 });
397
243
  });
398
244
 
399
245
  it("upward create-drag off the top lane inserts at the TOP of the visual zone, never below audio (reviewer repro)", () => {
400
- // 3-visual + 1-audio timeline. Dragging a top-lane visual clip up past the
401
- // create threshold emits the sentinel desiredTrack = minTrack-1 = -1. The
402
- // old hoist did order.indexOf(-1) = -1 and fell to below = order.length = 4,
403
- // creating the new track BELOW the audio zone — repro {track:-1, insertRow:4}.
404
- // It must now anchor to the top boundary of the visual zone.
246
+ // The sentinel desiredTrack = minTrack-1 = -1 must anchor to the visual zone's top boundary.
405
247
  expect(
406
248
  resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: -1 }),
407
- ).toEqual({ track: -1, insertRow: 0 });
249
+ ).toEqual({ track: -1, insertRow: 0, start: 2 });
408
250
  });
409
251
 
410
252
  it("downward create-drag off the bottom visual lane inserts at the audio boundary, never below it", () => {
411
- // Sentinel desiredTrack = maxTrack+1 = 4 for a downward create-drag. A visual
412
- // clip must land at the bottom of the visual zone (row 3 = just above audio),
413
- // not past the audio lanes.
414
253
  expect(
415
254
  resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: 4 }),
416
- ).toEqual({ track: 4, insertRow: 3 });
255
+ ).toEqual({ track: 4, insertRow: 3, start: 2 });
417
256
  });
418
257
 
419
258
  it("audio create-drag with an out-of-range aim inserts inside the audio zone", () => {
420
- // An audio clip aimed above its zone (sentinel below its own min lane) anchors
421
- // to the audio zone's top boundary (row 3), not into the visual zone.
422
259
  expect(
423
260
  resolveZoneDropPlacement({
424
261
  ...base,
@@ -426,22 +263,22 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
426
263
  desiredTrack: -1,
427
264
  isAudio: true,
428
265
  }),
429
- ).toEqual({ track: -1, insertRow: 3 });
266
+ ).toEqual({ track: -1, insertRow: 3, start: 2 });
430
267
  });
431
268
 
432
- it("honors a deliberate boundary insert in the clip's own zone", () => {
269
+ it("honors an insert aimed at the space outside the rows in the clip's own zone", () => {
433
270
  expect(
434
- resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 1, deliberateInsertRow: 1 }),
435
- ).toEqual({ track: 1, insertRow: 1 });
271
+ resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 0, deliberateInsertRow: 0 }),
272
+ ).toEqual({ track: 0, insertRow: 0, start: 2 });
436
273
  });
437
274
 
438
- it("ignores a deliberate insert that lands in the WRONG zone (visual into audio)", () => {
275
+ it("ignores an insert that lands in the WRONG zone (visual into audio)", () => {
439
276
  expect(
440
277
  resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 1, deliberateInsertRow: 4 }),
441
- ).toEqual({ track: 1, insertRow: null });
278
+ ).toEqual({ track: 1, insertRow: null, start: 2 });
442
279
  });
443
280
 
444
- it("lets an AUDIO clip create a new audio track via a boundary insert", () => {
281
+ it("lets an AUDIO clip open a new audio track below the last row", () => {
445
282
  expect(
446
283
  resolveZoneDropPlacement({
447
284
  ...base,
@@ -450,69 +287,30 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
450
287
  isAudio: true,
451
288
  deliberateInsertRow: 4,
452
289
  }),
453
- ).toEqual({ track: 3, insertRow: 4 });
290
+ ).toEqual({ track: 3, insertRow: 4, start: 2 });
454
291
  });
455
292
 
456
- it("Abhai repro: audio dropped on a visual-only timeline over an occupied span → insert, no overlap (#2195)", () => {
457
- // No audio zone exists yet (audioTracks empty). The audio clip is aimed at the
458
- // sole visual lane, which is occupied at the drop time. The zone-drop must
459
- // create the audio zone's first lane (insertRow set) rather than stacking the
460
- // audio clip onto the occupied visual track — the no-overlap invariant.
461
- const result = resolveZoneDropPlacement({
293
+ it("audio dropped on a visual-only timeline opens its zone's first row, no overlap (#2195)", () => {
294
+ const drop = {
462
295
  order: [0],
463
296
  audioTracks: new Set<number>(),
464
297
  elements: [el("v", 0, 0, 5)],
465
298
  desiredTrack: 0,
466
299
  deliberateInsertRow: null,
467
- start: 1,
468
300
  duration: 2,
469
301
  dragKey: "audio",
470
302
  isAudio: true,
303
+ };
304
+ // Occupied and free spans alike: the visual row is not a row an audio clip may land on.
305
+ expect(resolveZoneDropPlacement({ ...drop, start: 1 })).toEqual({
306
+ track: 0,
307
+ insertRow: 1,
308
+ start: 1,
471
309
  });
472
- expect(result.insertRow).not.toBeNull();
473
- expect(result).toEqual({ track: 0, insertRow: 1 });
474
- });
475
-
476
- it("free-span repro: audio dropped on a FREE stretch of a visual-only timeline → insert, not a visual lane (#2195)", () => {
477
- // Same visual-only timeline, but the audio clip is aimed at a stretch the sole
478
- // visual lane is FREE at. The zone check must still fire an insert (create the
479
- // audio zone) rather than short-circuiting on isLaneFree and dropping the audio
480
- // clip onto the visual lane — the kind-zone hole where the free-lane fast path
481
- // beat the wrong-zone check.
482
- const result = resolveZoneDropPlacement({
483
- order: [0],
484
- audioTracks: new Set<number>(),
485
- elements: [el("v", 0, 0, 5)],
486
- desiredTrack: 0,
487
- deliberateInsertRow: null,
488
- start: 10, // past the visual clip's end → the visual lane is FREE here
489
- duration: 2,
490
- dragKey: "audio",
491
- isAudio: true,
310
+ expect(resolveZoneDropPlacement({ ...drop, start: 10 })).toEqual({
311
+ track: 0,
312
+ insertRow: 1,
313
+ start: 10,
492
314
  });
493
- expect(result.insertRow).not.toBeNull();
494
- expect(result).toEqual({ track: 0, insertRow: 1 });
495
- });
496
- });
497
-
498
- describe("resolveMainTrackDropStart (magnetic first clip on an empty main track)", () => {
499
- it("snaps to 0 when landing on an empty main track", () => {
500
- expect(resolveMainTrackDropStart([], 1, 0, false, 7)).toBe(0);
501
- });
502
-
503
- it("leaves the drop start alone once the main track already holds another clip", () => {
504
- expect(resolveMainTrackDropStart([el("other", 0, 0, 3)], 1, 0, false, 7)).toBe(7);
505
- });
506
-
507
- it("leaves the drop start alone when landing off the main track", () => {
508
- expect(resolveMainTrackDropStart([], 1, 2, false, 7)).toBe(7);
509
- });
510
-
511
- it("a clip already resident on the main track doing a horizontal move is a no-op", () => {
512
- expect(resolveMainTrackDropStart([], 0, 0, false, 7)).toBe(7);
513
- });
514
-
515
- it("an audio clip landing on track 0 is not the main track (visual zone only)", () => {
516
- expect(resolveMainTrackDropStart([], 1, 0, true, 7)).toBe(7);
517
315
  });
518
316
  });