@hyperframes/studio 0.8.90 → 0.8.92

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 (102) hide show
  1. package/dist/assets/{hyperframes-player-DDjER_ii.js → hyperframes-player-DKJPDHUp.js} +1 -1
  2. package/dist/assets/{index-BKE3_N4v.js → index-C5_j5-3u.js} +178 -178
  3. package/dist/assets/{index-5SFeUXP8.js → index-CHZ4kFWm.js} +1 -1
  4. package/dist/assets/{index-CVsCxnlz.js → index-DpzpqJ_j.js} +1 -1
  5. package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
  6. package/dist/chunk-QPSOTK6M.js.map +1 -0
  7. package/dist/index.d.ts +18 -14
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +9190 -8916
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/components/TimelineToolbar.test.tsx +12 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +5 -1
  15. package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
  16. package/src/components/editor/elementPositionOffset.test.ts +62 -0
  17. package/src/components/editor/elementPositionOffset.ts +64 -0
  18. package/src/components/editor/motionPathCommit.test.ts +25 -5
  19. package/src/components/editor/motionPathSelection.ts +10 -3
  20. package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
  21. package/src/components/editor/useLayerRevealOverride.ts +2 -1
  22. package/src/components/nle/NLEContext.tsx +1 -1
  23. package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
  24. package/src/hooks/domEditPersistFailure.ts +8 -6
  25. package/src/hooks/elementOffsetStager.ts +65 -0
  26. package/src/hooks/gestureTransaction.ts +4 -2
  27. package/src/hooks/gsapDragCommit.ts +1 -0
  28. package/src/hooks/gsapEditOutcome.ts +2 -1
  29. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +50 -14
  31. package/src/hooks/gsapShared.test.ts +1 -1
  32. package/src/hooks/gsapShared.ts +16 -12
  33. package/src/hooks/gsapTweenReach.ts +30 -0
  34. package/src/hooks/timelineEditingHelpers.ts +2 -1
  35. package/src/hooks/useClipboard.source.test.ts +58 -0
  36. package/src/hooks/useClipboard.ts +12 -3
  37. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  38. package/src/hooks/useDomEditCommits.ts +2 -0
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
  40. package/src/hooks/useDomEditSession.ts +2 -0
  41. package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
  42. package/src/hooks/useDomGeometryCommit.ts +42 -1
  43. package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
  44. package/src/hooks/useDomGeometryCommits.ts +14 -1
  45. package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
  46. package/src/hooks/useGsapAwareEditing.ts +44 -7
  47. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
  48. package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
  49. package/src/hooks/useRazorSplit.test.ts +5 -5
  50. package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
  51. package/src/hooks/useTimelineAssetDropOps.ts +40 -18
  52. package/src/hooks/useTimelineEditing.test.tsx +384 -0
  53. package/src/hooks/useTimelineEditing.ts +12 -12
  54. package/src/hooks/useTimelineGroupEditing.ts +16 -7
  55. package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
  56. package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
  57. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  58. package/src/player/components/TimelineProvider.tsx +1 -0
  59. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  60. package/src/player/components/TimelineTypes.ts +1 -1
  61. package/src/player/components/timelineClipDragCommit.test.ts +32 -66
  62. package/src/player/components/timelineClipDragCommit.ts +19 -37
  63. package/src/player/components/timelineClipDragPreview.test.ts +283 -24
  64. package/src/player/components/timelineClipDragPreview.ts +51 -83
  65. package/src/player/components/timelineClipDragTypes.ts +3 -8
  66. package/src/player/components/timelineCollision.test.ts +100 -302
  67. package/src/player/components/timelineCollision.ts +73 -160
  68. package/src/player/components/timelineDragDrop.test.tsx +19 -5
  69. package/src/player/components/timelineDragDrop.ts +7 -9
  70. package/src/player/components/timelineDragLanding.ts +0 -49
  71. package/src/player/components/timelineEditing.ts +2 -1
  72. package/src/player/components/timelineGroupEditing.ts +7 -2
  73. package/src/player/components/timelineLayout.test.ts +0 -25
  74. package/src/player/components/timelineLayout.ts +0 -16
  75. package/src/player/components/timelineMultiDragPreview.test.ts +34 -23
  76. package/src/player/components/timelineMultiDragPreview.ts +26 -25
  77. package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
  78. package/src/player/components/timelineProviderStateBuilders.ts +14 -6
  79. package/src/player/components/timelineShellMeta.tsx +99 -0
  80. package/src/player/components/timelineZMirror.test.ts +2 -2
  81. package/src/player/components/timelineZMirror.ts +1 -1
  82. package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
  83. package/src/player/components/useTimelineClipDrag.ts +3 -2
  84. package/src/player/components/useTimelineDragPreviewState.ts +34 -0
  85. package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
  86. package/src/player/components/useTimelinePlayhead.ts +56 -27
  87. package/src/player/components/useTimelineProviderState.tsx +29 -66
  88. package/src/player/components/useTimelineZoom.ts +9 -1
  89. package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -4
  91. package/src/player/lib/timelineDOM.test.ts +79 -0
  92. package/src/player/lib/timelineDOM.ts +16 -7
  93. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  94. package/src/player/lib/timelineIframeHelpers.ts +12 -3
  95. package/src/player/store/liveTime.ts +6 -0
  96. package/src/player/store/playerStore.ts +2 -0
  97. package/src/player/store/timelineElement.ts +28 -3
  98. package/src/utils/razorSplitTransaction.test.ts +1 -1
  99. package/src/utils/razorSplitTransaction.ts +3 -3
  100. package/src/utils/timelineDropTrackInsert.test.ts +60 -1
  101. package/src/utils/timelineDropTrackInsert.ts +36 -5
  102. package/dist/chunk-KU4F7QZ3.js.map +0 -1
@@ -1,6 +1,4 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
- import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
3
- import { isMainTrackElement } from "./timelineZones";
4
2
 
5
3
  /**
6
4
  * Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
@@ -38,26 +36,6 @@ export function isInsertAllowedForZone(
38
36
  return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
39
37
  }
40
38
 
41
- /**
42
- * The full drop-placement decision for a dragged clip — one pure, testable unit.
43
- * Enforces: NO time-overlap on a single track; a clip stays in its kind-zone;
44
- * a new track is created only when needed. Order of resolution:
45
- * 1. Deliberate boundary insert (pointer near a lane edge), if it's in the
46
- * clip's own zone → create a new track there.
47
- * 2. Otherwise land on a lane: clamp the aimed track to the clip's zone, take it
48
- * if free at [start, start+duration), else the nearest FREE lane in the zone
49
- * (prefer up), else auto-create a new track right below the aimed lane.
50
- * `audioTracks` = the set of track indices that currently hold audio (so the fn
51
- * needs no element-kind import). Returns the landing `track` and, when a new track
52
- * should be created, the `insertRow` boundary (else null).
53
- *
54
- * `preferInsertAbove` biases the auto-created track (occupied-aim → new adjacent
55
- * track) toward the boundary ABOVE the aimed row instead of below it, so the new
56
- * lane opens on whichever side of the aimed clip the pointer is nearer (the drag
57
- * preview passes the pointer's sub-row half). A clip whose aimed span is occupied
58
- * never snaps back to its origin — it relocates to a free lane, or (none free)
59
- * gets a fresh track next to the aim. Default (below) preserves prior behaviour.
60
- */
61
39
  /**
62
40
  * Insert-row boundary for an out-of-range aim — a `desired` track that isn't a
63
41
  * real lane: the sentinel minTrack-1 an upward create-drag emits (#2214-adjacent
@@ -84,6 +62,50 @@ function outOfRangeZoneInsertRow(
84
62
  return desired < Math.min(...zoneTracks) ? zoneTop : zoneBottom;
85
63
  }
86
64
 
65
+ const floorCenti = (v: number) => Math.floor(v * 100 + 1e-6) / 100;
66
+ const ceilCenti = (v: number) => Math.ceil(v * 100 - 1e-6) / 100;
67
+
68
+ /** Whether `candidate` is nearer `start` than `best`; a tie goes to the later time. */
69
+ const isNearer = (candidate: number, best: number, start: number) => {
70
+ const gap = Math.abs(candidate - start) - Math.abs(best - start);
71
+ return gap < 0 || (gap === 0 && candidate > best);
72
+ };
73
+
74
+ /** The start nearest `start`, not below `minStart`, at which [start, start + duration) overlaps
75
+ * no clip on `track`. Ties go to the later time. The gap after the row's last clip always fits,
76
+ * so a row with no gap long enough puts the clip right after its last clip. `origin` is the
77
+ * clip's own start on this track: keeping it rewrites nothing, so it fits between any edges. */
78
+ export function resolveNearestFreeStart(
79
+ elements: readonly TimelineElement[],
80
+ track: number,
81
+ start: number,
82
+ duration: number,
83
+ excludeKey: string | null,
84
+ minStart = 0,
85
+ origin: number | null = null,
86
+ ): number {
87
+ const busy = elements
88
+ .filter((el) => (el.key ?? el.id) !== excludeKey && el.track === track)
89
+ .sort((a, b) => a.start - b.start);
90
+ let best = Number.POSITIVE_INFINITY;
91
+ let gapStart = minStart;
92
+ for (const el of [...busy, null]) {
93
+ const latest = el ? floorCenti(el.start - duration) : Number.POSITIVE_INFINITY;
94
+ if (latest >= gapStart) {
95
+ const candidate = Math.min(Math.max(start, gapStart), latest);
96
+ if (Math.abs(candidate - start) <= Math.abs(best - start)) best = candidate;
97
+ }
98
+ if (el) gapStart = Math.max(gapStart, ceilCenti(el.start + el.duration));
99
+ }
100
+ if (origin === null || origin < minStart || !isNearer(origin, best, start)) return best;
101
+ // Float slack at both ends: 0.333 + 1.733 is 2.0660000000000003, a hair past a neighbour at 2.066.
102
+ const [from, to] = [origin + 1e-6, origin + duration - 1e-6];
103
+ const clear = busy.every((el) => !timeRangesOverlap(from, to, el.start, el.start + el.duration));
104
+ return clear ? origin : best;
105
+ }
106
+
107
+ // Where a dragged clip lands: on the aimed row of its kind, at the nearest free time there. Only an aim outside all
108
+ // rows, or into a kind with no row yet, opens a track (`insertRow`).
87
109
  export function resolveZoneDropPlacement(input: {
88
110
  order: number[];
89
111
  audioTracks: ReadonlySet<number>;
@@ -94,99 +116,54 @@ export function resolveZoneDropPlacement(input: {
94
116
  duration: number;
95
117
  dragKey: string;
96
118
  isAudio: boolean;
97
- preferInsertAbove?: boolean;
98
- }): { track: number; insertRow: number | null } {
119
+ /** Lowest start the clip may take (every moving clip stays at or after its host's start). */
120
+ minStart?: number;
121
+ /** Where the dragged clip sits now. */
122
+ origin?: { track: number; start: number };
123
+ }): { track: number; insertRow: number | null; start: number } {
99
124
  const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
100
- const { start, duration, dragKey, isAudio, preferInsertAbove } = input;
125
+ const { start, duration, dragKey, isAudio, minStart } = input;
101
126
  const audioRow = order.findIndex((t) => audioTracks.has(t));
102
127
 
103
128
  if (
104
129
  deliberateInsertRow !== null &&
105
130
  isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
106
131
  ) {
107
- return { track: desiredTrack, insertRow: deliberateInsertRow };
132
+ return { track: desiredTrack, insertRow: deliberateInsertRow, start };
108
133
  }
109
134
 
110
135
  const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
111
136
  const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
112
- const placement = resolvePlacement({
137
+ // Only when the aim is outside the rows, or the clip's zone has no row yet.
138
+ if (!zoneTracks.includes(desired)) {
139
+ const desiredRow = order.indexOf(desired);
140
+ const insertRow =
141
+ desiredRow < 0
142
+ ? outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired)
143
+ : desiredRow + 1;
144
+ return { track: desired, insertRow, start };
145
+ }
146
+ const origin = input.origin?.track === desired ? input.origin.start : null;
147
+ const freeStart = resolveNearestFreeStart(
113
148
  elements,
114
- desiredTrack: desired,
149
+ desired,
115
150
  start,
116
151
  duration,
117
- trackOrder: zoneTracks,
118
- excludeKey: dragKey,
119
- });
120
- const originTrack = elements.find((element) => (element.key ?? element.id) === dragKey)?.track;
121
- const snappedBackToOrigin =
122
- originTrack != null && desired !== originTrack && placement.track === originTrack;
123
- if (placement.needsInsert || snappedBackToOrigin) {
124
- const desiredRow = order.indexOf(desired);
125
- if (desiredRow < 0) {
126
- return {
127
- track: desired,
128
- insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
129
- };
130
- }
131
- // When collision fallback found only the origin lane, insert on the far side
132
- // of the aimed lane so normalization cannot turn the gesture into a no-op.
133
- // Otherwise prefer the gap nearest the pointer, preserving normal insertion.
134
- const originRow = originTrack == null ? -1 : order.indexOf(originTrack);
135
- const insertAbove = snappedBackToOrigin
136
- ? originRow > desiredRow
137
- : preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio);
138
- const insertRow = insertAbove ? desiredRow : desiredRow + 1;
139
- return { track: desired, insertRow };
140
- }
141
- return { track: placement.track, insertRow: null };
142
- }
143
-
144
- function landsOnMainTrack(landingTrack: number, isAudio: boolean): boolean {
145
- return isMainTrackElement({
146
- id: "",
147
- tag: isAudio ? "audio" : "video",
148
- start: 0,
149
- duration: 0,
150
- track: landingTrack,
151
- });
152
- }
153
-
154
- /** An existing clip newly landing on an empty main track commits at start=0.
155
- * No-op once the main track holds another clip, off the main track, or for
156
- * a clip already resident there. `others` excludes the landing clip itself. */
157
- export function resolveMainTrackDropStart(
158
- others: readonly TimelineElement[],
159
- originTrack: number,
160
- landingTrack: number,
161
- isAudio: boolean,
162
- desiredStart: number,
163
- ): number {
164
- if (originTrack === landingTrack || !landsOnMainTrack(landingTrack, isAudio)) {
165
- return desiredStart;
166
- }
167
- return others.some(isMainTrackElement) ? desiredStart : 0;
152
+ dragKey,
153
+ minStart,
154
+ origin,
155
+ );
156
+ return { track: desired, insertRow: null, start: freeStart };
168
157
  }
169
158
 
170
159
  /**
171
- * Decide whether a vertical drag is inserting a new track at a lane boundary.
172
- * `rowFloat` is the pointer's position in track-height units from the top of the
173
- * first lane (0 = top of lane 0). Returns the boundary row to insert at
174
- * (0 = above the top lane, `trackCount` = below the bottom), or null when the
175
- * pointer is over a lane's middle band (a normal move/target). The default band
176
- * preserves collapsed-row behavior; production passes the concrete row's band.
160
+ * The row boundary a pointer opens a new track at, or null over a row. `rowFloat` is the pointer's
161
+ * position in row units from the top of the first row. Only the empty space above the first row
162
+ * (0) or below the last (`trackCount`) opens one: a drop anywhere on a row stays on that row.
177
163
  */
178
- export function resolveInsertRow(
179
- rowFloat: number,
180
- trackCount: number,
181
- band: number = INSERT_BOUNDARY_BAND,
182
- ): number | null {
183
- if (trackCount === 0) return 0;
184
- if (rowFloat <= 0) return 0;
164
+ export function resolveInsertRow(rowFloat: number, trackCount: number): number | null {
165
+ if (trackCount === 0 || rowFloat < 0) return 0;
185
166
  if (rowFloat >= trackCount) return trackCount;
186
- const lane = Math.floor(rowFloat);
187
- const frac = rowFloat - lane;
188
- if (frac < band) return lane;
189
- if (frac > 1 - band) return lane + 1;
190
167
  return null;
191
168
  }
192
169
 
@@ -218,67 +195,3 @@ export function isLaneFree(
218
195
  timeRangesOverlap(start, end, el.start, el.start + el.duration),
219
196
  );
220
197
  }
221
-
222
- export interface PlacementInput {
223
- elements: TimelineElement[];
224
- desiredTrack: number;
225
- start: number;
226
- duration: number;
227
- trackOrder: number[];
228
- excludeKey: string | null;
229
- }
230
-
231
- export interface PlacementResult {
232
- /** The lane the clip should land on. */
233
- track: number;
234
- /**
235
- * True when no existing lane was free and the caller should insert a new
236
- * track instead of landing on `track` (which is then the desired lane as a
237
- * last-resort fallback). Consumed in later stages (2b/2c); stage 2a ignores it.
238
- */
239
- needsInsert: boolean;
240
- }
241
-
242
- /**
243
- * Resolve where a dragged clip should land, avoiding overlap. If the desired
244
- * lane is free, keep it. Otherwise search the nearest free lane, **preferring
245
- * up** (all lanes above, nearest first), then down. If none is free, signal an
246
- * insert and fall back to the desired lane.
247
- */
248
- export function resolvePlacement({
249
- elements,
250
- desiredTrack,
251
- start,
252
- duration,
253
- trackOrder,
254
- excludeKey,
255
- }: PlacementInput): PlacementResult {
256
- const end = start + duration;
257
- const idx = trackOrder.indexOf(desiredTrack);
258
- // desiredTrack is not one of the zone's lanes — the clip's kind-zone has no lane
259
- // yet (e.g. an audio clip dropped on a visual-only timeline). This MUST be checked
260
- // BEFORE the isLaneFree short-circuit below: a free-aimed span on a foreign-zone
261
- // lane (an audio clip aimed at an empty stretch of a visual-only timeline) is
262
- // "free" only because that lane belongs to the wrong zone. Landing there would
263
- // put the clip in the wrong kind-zone, so signal an insert to create the zone's
264
- // first lane instead — regardless of whether the aimed span is occupied (#2195).
265
- if (idx === -1) return { track: desiredTrack, needsInsert: true };
266
-
267
- if (isLaneFree(elements, desiredTrack, start, end, excludeKey)) {
268
- return { track: desiredTrack, needsInsert: false };
269
- }
270
-
271
- // Prefer up: nearest lane above first, then the rest above.
272
- for (let up = idx - 1; up >= 0; up--) {
273
- if (isLaneFree(elements, trackOrder[up], start, end, excludeKey)) {
274
- return { track: trackOrder[up], needsInsert: false };
275
- }
276
- }
277
- // Then down: nearest lane below first.
278
- for (let down = idx + 1; down < trackOrder.length; down++) {
279
- if (isLaneFree(elements, trackOrder[down], start, end, excludeKey)) {
280
- return { track: trackOrder[down], needsInsert: false };
281
- }
282
- }
283
- return { track: desiredTrack, needsInsert: true };
284
- }
@@ -256,8 +256,10 @@ describe("useTimelineAssetDrop", () => {
256
256
 
257
257
  describe("resolveDropInsertRow", () => {
258
258
  const rows = [TRACK_H, TRACK_H, TRACK_H];
259
- it("arms a new track at the boundary between two lanes", () => {
260
- expect(resolveDropInsertRow(getTimelineRowTop(1, rows), rows, 3)).toBe(1);
259
+ it("stays on the row at the boundary between two rows", () => {
260
+ for (const dy of [-1, 0, 1]) {
261
+ expect(resolveDropInsertRow(getTimelineRowTop(1, rows) + dy, rows, 3)).toBeNull();
262
+ }
261
263
  });
262
264
  it("arms a new track above the first lane", () => {
263
265
  expect(resolveDropInsertRow(getTimelineRowTop(0, rows) - 4, rows, 3)).toBe(0);
@@ -274,20 +276,32 @@ describe("resolveDropInsertRow", () => {
274
276
  });
275
277
 
276
278
  describe("useTimelineAssetDrop new-track drops", () => {
277
- it("previews and commits an insert row for an asset dropped between lanes", () => {
279
+ it("drops an asset released at the top edge of a row onto that row, with no new track", () => {
278
280
  const onAssetDrop = vi.fn();
279
281
  const view = renderHarness(onAssetDrop);
280
282
  const transfer = assetTransfer(JSON.stringify({ path: "assets/a.png" }));
281
283
  const y = getTimelineRowTop(1) + 1;
282
284
 
283
285
  act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, y)));
284
- expect(view.api.dropPreview).toMatchObject({ start: 10, insertRow: 1 });
286
+ expect(view.api.dropPreview).toEqual({ start: 10, track: 1 });
287
+ act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, y)));
288
+ expect(onAssetDrop).toHaveBeenCalledWith("assets/a.png", { start: 10, track: 1 });
289
+ act(() => view.root.unmount());
290
+ });
291
+
292
+ it("opens a new track for an asset released in the empty space above the first row", () => {
293
+ const onAssetDrop = vi.fn();
294
+ const view = renderHarness(onAssetDrop);
295
+ const transfer = assetTransfer(JSON.stringify({ path: "assets/a.png" }));
296
+ const y = getTimelineRowTop(0) - 4;
297
+
298
+ act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, y)));
285
299
  act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, y)));
286
300
  expect(onAssetDrop).toHaveBeenCalledWith(
287
301
  "assets/a.png",
288
302
  expect.objectContaining({
289
303
  start: 10,
290
- insertRow: 1,
304
+ insertRow: 0,
291
305
  trackOrder: Array.from({ length: 100 }, (_, index) => index),
292
306
  }),
293
307
  );
@@ -5,8 +5,7 @@ import {
5
5
  TIMELINE_COMPOSITION_MIME,
6
6
  } from "../../utils/timelineCompositionDrop";
7
7
  import {
8
- getTimelineInsertBoundaryBand,
9
- getTimelineRowPositionFromY,
8
+ getTimelineRowFromY,
10
9
  resolveTimelineAssetDrop,
11
10
  type TimelineRowGeometry,
12
11
  } from "./timelineLayout";
@@ -52,17 +51,17 @@ function canInsertTrackFor(types: readonly string[]): boolean {
52
51
  return types.includes("Files") || types.includes(TIMELINE_ASSET_MIME);
53
52
  }
54
53
 
55
- /** The row boundary a pointer at `contentY` arms for a new track, or null over a lane's middle. */
54
+ /** The row boundary a pointer at `contentY` opens a new track at, or null over a row. */
56
55
  export function resolveDropInsertRow(
57
56
  contentY: number,
58
57
  rowHeights: readonly number[] | undefined,
59
58
  trackCount: number,
60
59
  ): number | null {
61
60
  if (trackCount === 0) return null;
62
- const { rowFloat, rowHeight } = getTimelineRowPositionFromY(contentY, rowHeights);
61
+ const rowFloat = getTimelineRowFromY(contentY, rowHeights);
63
62
  // Past the last lane the drop already appends a track (getDefaultDroppedTrack).
64
63
  if (rowFloat >= trackCount) return null;
65
- return resolveInsertRow(rowFloat, trackCount, getTimelineInsertBoundaryBand(rowHeight));
64
+ return resolveInsertRow(rowFloat, trackCount);
66
65
  }
67
66
 
68
67
  function placeDrop(
@@ -118,10 +117,9 @@ function applyTypedJsonDrop(
118
117
  }
119
118
 
120
119
  /**
121
- * Dropping an asset/file/block/composition onto the timeline places it at the
122
- * exact time and track it was dropped on, like CapCut (pointer placement on
123
- * every track but the magnetic main track). Supersedes the prior playhead
124
- * decision (#2291); playhead adds stay available, see useAddAssetAtPlayhead.
120
+ * Dropping an asset/file/block/composition onto the timeline places it on the row
121
+ * and at the time it was dropped on; an asset moves to that row's nearest free time.
122
+ * Supersedes the prior playhead decision (#2291); see useAddAssetAtPlayhead.
125
123
  */
126
124
  export function useTimelineAssetDrop({
127
125
  scrollRef,
@@ -1,12 +1,7 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
  import type { DragCommitDeps } from "./timelineClipDragCommit";
3
- import type { DraggedClipState } from "./timelineClipDragTypes";
4
3
  import { classifyZone, normalizeToZones } from "./timelineZones";
5
- import { resolveMainTrackDropStart } from "./timelineCollision";
6
- import { isAudioTimelineElement } from "../../utils/timelineInspector";
7
- import { resolveExpandedHostAlias } from "./timelineAuthoredMoveTarget";
8
4
  import { sameSourceFile } from "./timelineAuthoredTrack";
9
- import { round3 } from "./timelineGaps";
10
5
 
11
6
  const keyOf = (e: TimelineElement) => e.key ?? e.id;
12
7
 
@@ -58,47 +53,3 @@ export function layoutAfterTrackInsert(
58
53
  );
59
54
  return { normalized, targetTrack, writable };
60
55
  }
61
-
62
- /** The start a solo clip lands at, for both the ghost and the commit: 0 on an empty
63
- * main track, judged on the final lane (after the renumber and the host alias).
64
- * Returned in the dragged clip's own frame. */
65
- export function resolveDragLandingStart(
66
- drag: DraggedClipState,
67
- deps: Pick<DragCommitDeps, "elements" | "trackOrder" | "selectedKeys">,
68
- ): number {
69
- const alias = resolveExpandedHostAlias(drag, deps);
70
- const target = alias?.drag ?? drag;
71
- const keys = alias?.selectedKeys ?? deps.selectedKeys;
72
- const element = target.element;
73
- const key = keyOf(element);
74
- if (keys && keys.size > 1 && keys.has(key)) return drag.previewStart;
75
-
76
- let landingTrack = target.previewTrack;
77
- let renumbered = new Map<string, number>();
78
- if (target.insertRow != null) {
79
- const layout = layoutAfterTrackInsert(
80
- element,
81
- target.previewStart,
82
- target.insertRow,
83
- null,
84
- deps,
85
- );
86
- if (layout) {
87
- renumbered = new Map(layout.normalized.map((n) => [keyOf(n), n.track]));
88
- landingTrack = renumbered.get(key) ?? layout.targetTrack;
89
- }
90
- }
91
- const others = deps.elements
92
- .filter((e) => keyOf(e) !== key)
93
- .map((e) => (renumbered.has(keyOf(e)) ? { ...e, track: renumbered.get(keyOf(e))! } : e));
94
- const snapped = resolveMainTrackDropStart(
95
- others,
96
- element.track,
97
- landingTrack,
98
- isAudioTimelineElement(element),
99
- target.previewStart,
100
- );
101
- return snapped === target.previewStart
102
- ? drag.previewStart
103
- : round3(drag.previewStart + snapped - target.previewStart);
104
- }
@@ -51,6 +51,7 @@ export interface TimelineMoveInput {
51
51
  originScrollLeft?: number;
52
52
  currentScrollLeft?: number;
53
53
  pixelsPerSecond: number;
54
+ minStart?: number;
54
55
  maxStart: number;
55
56
  trackOrder: number[];
56
57
  layerOrder?: TimelineLayerId[];
@@ -127,7 +128,7 @@ export function resolveTimelineMove(
127
128
  const deltaTrack = Math.round(trackDeltaRaw);
128
129
  const nextStart = clamp(
129
130
  roundToCentiseconds(input.start + deltaTime),
130
- 0,
131
+ input.minStart ?? 0,
131
132
  Math.max(0, input.maxStart),
132
133
  );
133
134
 
@@ -1,6 +1,7 @@
1
1
  import { clampPlaybackRate } from "@hyperframes/parsers/media-duration";
2
2
  import { roundToCenti } from "../../utils/rounding";
3
3
  import type { TimelineElement } from "../store/playerStore";
4
+ import { clampToHostStart } from "../store/timelineElement";
4
5
  import { getTimelineEditCapabilities } from "./timelineEditCapabilities";
5
6
 
6
7
  const DEFAULT_TIMELINE_MIN_DURATION = 0.1;
@@ -88,6 +89,7 @@ export interface TimelineGroupTimingMember {
88
89
  duration: number;
89
90
  playbackStart?: number;
90
91
  playbackRate?: number;
92
+ minStart?: number;
91
93
  }
92
94
 
93
95
  export type TimelineGroupResizeEdge = "start" | "end";
@@ -107,7 +109,7 @@ function clampTimelineGroupMoveDelta(
107
109
  members: readonly TimelineGroupTimingMember[],
108
110
  ): number {
109
111
  if (members.length === 0) return 0;
110
- const minDelta = Math.max(...members.map((member) => -member.start));
112
+ const minDelta = Math.max(...members.map((member) => -(member.start - (member.minStart ?? 0))));
111
113
  return roundTimelineTime(Math.max(rawDelta, minDelta));
112
114
  }
113
115
 
@@ -139,7 +141,9 @@ export function clampTimelineGroupResizeDelta(
139
141
  }
140
142
 
141
143
  // Rigid group: the applied delta is bounded by the most-constrained member.
142
- const bounds = members.map((member) => clipStartTrimDeltaBounds(member, 0, minDuration));
144
+ const bounds = members.map((member) =>
145
+ clipStartTrimDeltaBounds(member, member.minStart ?? 0, minDuration),
146
+ );
143
147
  const minDelta = ceilTimelineTime(Math.max(...bounds.map((b) => b.minDelta)));
144
148
  const maxDelta = Math.min(...bounds.map((b) => b.maxDelta));
145
149
  const delta = roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
@@ -243,6 +247,7 @@ export function buildTimelineGroupResizeMembers(
243
247
  ? (element.playbackStart ?? 0)
244
248
  : element.playbackStart,
245
249
  playbackRate: element.playbackRate,
250
+ minStart: clampToHostStart(element, 0),
246
251
  }));
247
252
  }
248
253
 
@@ -1,8 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
- CLIP_Y,
4
- INSERT_BOUNDARY_BAND,
5
- getTimelineInsertBoundaryBand,
6
3
  RULER_H,
7
4
  TRACK_H,
8
5
  LANE_H,
@@ -294,25 +291,3 @@ describe("getTimelineScrubTime", () => {
294
291
  expect(at(origin + 250, Number.NaN)).toBe(0);
295
292
  });
296
293
  });
297
-
298
- // The only production hook keeping resolveInsertRow's band aligned with the
299
- // rendered clip inset once rows can be taller than TRACK_H. Pinned directly so a
300
- // change to CLIP_Y or the invalid-height fallback can't silently drift it.
301
- describe("getTimelineInsertBoundaryBand", () => {
302
- it("matches the fixed band for a plain track row", () => {
303
- expect(getTimelineInsertBoundaryBand(TRACK_H)).toBe(INSERT_BOUNDARY_BAND);
304
- expect(getTimelineInsertBoundaryBand(TRACK_H)).toBe(CLIP_Y / TRACK_H);
305
- });
306
-
307
- it("shrinks as the row grows, so the band stays CLIP_Y pixels tall", () => {
308
- const expanded = TRACK_H + 2 * LANE_H;
309
- expect(getTimelineInsertBoundaryBand(expanded)).toBeCloseTo(CLIP_Y / expanded, 10);
310
- expect(getTimelineInsertBoundaryBand(expanded)).toBeLessThan(INSERT_BOUNDARY_BAND);
311
- });
312
-
313
- it("falls back to the plain-track band for a height that is not usable", () => {
314
- for (const height of [0, -10, Number.NaN]) {
315
- expect(getTimelineInsertBoundaryBand(height)).toBe(INSERT_BOUNDARY_BAND);
316
- }
317
- });
318
- });
@@ -54,11 +54,6 @@ export function getTimelineBeatEntries(
54
54
  export function getTimelineLaneTop(laneIndex: number): number {
55
55
  return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
56
56
  }
57
- /**
58
- * Collapsed-row characterization value for the new-track INSERT band. Runtime
59
- * hit-testing uses getTimelineInsertBoundaryBand with the concrete row height.
60
- */
61
- export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
62
57
  /**
63
58
  * Breathing room INSIDE the scroll area (CapCut-style), threaded through every
64
59
  * track-row y computation via {@link getTimelineRowTop} — never inline a magic
@@ -263,17 +258,6 @@ export function getTimelineRowFromY(
263
258
  return getTimelineRowGeometry(rowHeights).getRowFromY(contentY);
264
259
  }
265
260
 
266
- export function getTimelineRowPositionFromY(
267
- contentY: number,
268
- rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
269
- ): { rowFloat: number; row: number; fraction: number; rowHeight: number } {
270
- return getTimelineRowGeometry(rowHeights).getRowPositionFromY(contentY);
271
- }
272
-
273
- /** Fractional insert band for the concrete row under a pointer. */
274
- export function getTimelineInsertBoundaryBand(rowHeight: number): number {
275
- return CLIP_Y / validRowHeight(rowHeight);
276
- }
277
261
  /**
278
262
  * While a clip drag is live, the rendered timeline extends this far past the
279
263
  * ghost's end so the right-edge auto-scroll zone always has room to keep
@@ -1,10 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
2
3
  import {
3
- clampGroupMoveDelta,
4
+ groupMoveFloor,
4
5
  isMultiDragActive,
5
6
  isMultiDragPassenger,
6
7
  multiDragDeltaSeconds,
7
8
  multiDragPassengerOffsetPx,
9
+ resolveGroupMovers,
8
10
  type MultiDragPreviewInput,
9
11
  } from "./timelineMultiDragPreview";
10
12
 
@@ -92,36 +94,45 @@ describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)",
92
94
  });
93
95
  });
94
96
 
95
- describe("clampGroupMoveDelta (rigid group move)", () => {
96
- it("passes a rightward delta through unchanged (no right wall)", () => {
97
- expect(clampGroupMoveDelta(3, [2, 5, 9])).toBe(3);
98
- expect(clampGroupMoveDelta(1000, [0, 4])).toBe(1000);
99
- });
97
+ const at = (id: string, start: number, locked = false): TimelineElement => ({
98
+ id,
99
+ key: id,
100
+ tag: "video",
101
+ start,
102
+ duration: 1,
103
+ track: 0,
104
+ domId: id,
105
+ ...(locked ? { timelineLocked: true } : {}),
106
+ });
100
107
 
101
- it("passes a leftward delta through when no member would cross 0", () => {
102
- // Leftmost member at 5, moving left by 3 → 2 ≥ 0, so unclamped.
103
- expect(clampGroupMoveDelta(-3, [5, 8, 12])).toBe(-3);
108
+ describe("groupMoveFloor (rigid group move)", () => {
109
+ it("lets the grabbed clip go as far left as the leftmost mover allows, and no further", () => {
110
+ expect(groupMoveFloor(at("g", 10), [at("g", 10), at("p", 1), at("q", 4)])).toBe(9);
111
+ expect(groupMoveFloor(at("g", 5), [at("g", 5), at("p", 8)])).toBe(0); // the grabbed clip is leftmost
104
112
  });
105
113
 
106
- it("clamps a leftward delta so the leftmost member stops exactly at 0", () => {
107
- // Leftmost at 2 → the furthest left the group can move is -2 (that member → 0).
108
- // A pointer asking for -5 is clamped to -2: the grabbed clip stops with the
109
- // formation instead of out-running it.
110
- expect(clampGroupMoveDelta(-5, [2, 6, 10])).toBe(-2);
114
+ it("forbids any leftward move once a mover sits at 0", () => {
115
+ expect(groupMoveFloor(at("g", 3), [at("g", 3), at("p", 0)])).toBe(3);
111
116
  });
112
117
 
113
- it("is bounded by the MOST-constrained (leftmost) member, not the grabbed one", () => {
114
- // Grabbed clip is at 10; a passenger at 1 is the constraint. Max left = -1.
115
- expect(clampGroupMoveDelta(-8, [10, 1, 4])).toBe(-1);
118
+ it("stops a nested mover at its host composition's start, and a lone clip at its own host", () => {
119
+ const nested = { ...at("p", 3), parentCompositionStart: 2 };
120
+ expect(groupMoveFloor(at("g", 10), [at("g", 10), nested])).toBe(9);
121
+ expect(groupMoveFloor(nested, [])).toBe(2);
116
122
  });
123
+ });
124
+
125
+ describe("resolveGroupMovers (the clips a group drag moves)", () => {
126
+ const elements = [at("g", 5), at("p", 2), at("locked", 0, true), at("other", 1)];
117
127
 
118
- it("already-at-0 member forbids any leftward move", () => {
119
- expect(clampGroupMoveDelta(-4, [0, 3, 7])).toBe(0);
120
- // rightward still allowed
121
- expect(clampGroupMoveDelta(2, [0, 3, 7])).toBe(2);
128
+ it("is every selected clip that can move, never a locked one or an unselected one", () => {
129
+ const movers = resolveGroupMovers(elements, new Set(["g", "p", "locked"]), "g");
130
+ expect(movers?.map((e) => e.id)).toEqual(["g", "p"]);
122
131
  });
123
132
 
124
- it("returns the raw delta for an empty formation", () => {
125
- expect(clampGroupMoveDelta(-9, [])).toBe(-9);
133
+ it("is null when the grabbed clip is not part of a multi-selection", () => {
134
+ expect(resolveGroupMovers(elements, new Set(["g"]), "g")).toBeNull();
135
+ expect(resolveGroupMovers(elements, new Set(["p", "other"]), "g")).toBeNull();
136
+ expect(resolveGroupMovers(elements, undefined, "g")).toBeNull();
126
137
  });
127
138
  });