@hyperframes/studio 0.7.60 → 0.7.62

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 (110) hide show
  1. package/dist/assets/hyperframes-player-CWpmBjzv.js +459 -0
  2. package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
  3. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  4. package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
  5. package/dist/assets/index-vDRVQtTK.js +426 -0
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4392 -3314
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -15
  12. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  13. package/src/components/EditorShell.tsx +12 -0
  14. package/src/components/StudioHeader.tsx +2 -3
  15. package/src/components/StudioLeftSidebar.tsx +3 -0
  16. package/src/components/StudioRightPanel.tsx +34 -26
  17. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  18. package/src/components/editor/PromotableControl.tsx +4 -2
  19. package/src/components/editor/PropertyPanel.tsx +151 -149
  20. package/src/components/editor/PropertyPanelFlat.tsx +50 -42
  21. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  22. package/src/components/editor/domEditing.test.ts +34 -0
  23. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  24. package/src/components/editor/manualEditingAvailability.ts +5 -3
  25. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  26. package/src/components/editor/propertyPanelColor.tsx +150 -28
  27. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
  29. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
  30. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  31. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
  32. package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
  33. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  34. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
  35. package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
  36. package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
  37. package/src/components/editor/propertyPanelSections.tsx +2 -2
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  40. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  41. package/src/components/nle/TimelinePane.tsx +6 -0
  42. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  43. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  44. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  45. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  46. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  47. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  48. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  49. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  50. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  51. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  52. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  53. package/src/components/storyboard/useFrameComments.ts +22 -6
  54. package/src/contexts/StudioContext.tsx +4 -0
  55. package/src/contexts/ViewModeContext.tsx +60 -7
  56. package/src/hooks/timelineEditingHelpers.ts +10 -4
  57. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  58. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  59. package/src/hooks/usePreviewPersistence.ts +7 -1
  60. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  61. package/src/hooks/useRazorSplit.test.tsx +20 -9
  62. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  63. package/src/hooks/useRazorSplit.ts +78 -350
  64. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  65. package/src/hooks/useSlideshowTabState.ts +61 -0
  66. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  67. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  68. package/src/hooks/useTimelineEditing.ts +19 -17
  69. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  70. package/src/player/components/Timeline.tsx +12 -1
  71. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  72. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  73. package/src/player/components/timelineCallbacks.ts +4 -0
  74. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  75. package/src/player/components/timelineClipDragCommit.ts +77 -105
  76. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  77. package/src/player/components/timelineClipDragPreview.ts +2 -1
  78. package/src/player/components/timelineCollision.test.ts +18 -0
  79. package/src/player/components/timelineCollision.ts +12 -9
  80. package/src/player/components/timelineDragDrop.ts +28 -7
  81. package/src/player/components/timelineEditCapabilities.ts +4 -1
  82. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  83. package/src/player/components/timelineGroupEditing.ts +4 -3
  84. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  85. package/src/player/components/timelineLayout.ts +3 -1
  86. package/src/player/components/timelineZMirror.ts +1 -1
  87. package/src/player/components/useTimelineClipDrag.ts +5 -0
  88. package/src/player/components/useTimelineEditPinning.ts +12 -0
  89. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  90. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  91. package/src/player/lib/playbackTypes.ts +9 -0
  92. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  93. package/src/player/lib/timelineDOM.test.ts +58 -0
  94. package/src/player/lib/timelineDOM.ts +26 -12
  95. package/src/player/lib/timelineElementHelpers.ts +21 -11
  96. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  97. package/src/player/store/playerStore.ts +2 -1
  98. package/src/utils/razorSplitTransaction.test.ts +175 -0
  99. package/src/utils/razorSplitTransaction.ts +200 -0
  100. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  101. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  102. package/src/utils/timelineCompositionDrop.ts +16 -0
  103. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  104. package/src/utils/timelineCompositionInsert.ts +90 -0
  105. package/src/utils/timelineElementSplit.test.ts +14 -2
  106. package/src/utils/timelineElementSplit.ts +5 -1
  107. package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
  108. package/dist/assets/index-DXbu6IPT.css +0 -1
  109. package/dist/assets/index-cH6NfVV_.js +0 -426
  110. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -5,13 +5,18 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
5
5
  import type { ZMirrorLaneMove } from "./timelineZMirror";
6
6
  import { classifyZone, normalizeToZones } from "./timelineZones";
7
7
  import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
8
- import { getTimelineEditCapabilities } from "./timelineEditing";
8
+ import {
9
+ canMoveTimelineElement as canMoveElement,
10
+ resolveExpandedHostAlias,
11
+ } from "./timelineAuthoredMoveTarget";
9
12
  import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
10
13
  import {
11
14
  beginTimelineOptimisticGesture,
12
15
  isLatestTimelineOptimisticGesture,
13
16
  } from "./timelineOptimisticRevision";
14
17
  import { runLaneZGesture } from "../../components/nle/zLaneGesture";
18
+ import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
19
+ import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
15
20
 
16
21
  type StartTrack = Pick<TimelineElement, "start" | "track">;
17
22
  export interface TimelineMoveEdit {
@@ -68,30 +73,15 @@ export interface DragCommitDeps {
68
73
  * research/STAGE3-NEEDED-WIRING.md.
69
74
  */
70
75
  onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise<unknown> | void;
76
+ /** Converge the preview manifest after the complete lane + z transaction. */
77
+ refreshAfterLaneMove?: () => void;
71
78
  }
72
79
 
73
80
  const keyOf = (e: TimelineElement) => e.key ?? e.id;
74
81
  const round3 = (v: number) => Math.round(v * 1000) / 1000;
75
-
76
82
  // One deterministic coalesce key shared by both records in a lane-change gesture.
77
83
  let laneChangeGestureSeq = 0;
78
84
 
79
- /** Whether Studio may write timing to this clip (false for locked/implicit rows). */
80
- function canMoveElement(element: TimelineElement): boolean {
81
- return getTimelineEditCapabilities({
82
- tag: element.tag,
83
- duration: element.duration,
84
- domId: element.domId,
85
- selector: element.selector,
86
- compositionSrc: element.compositionSrc,
87
- playbackStart: element.playbackStart,
88
- playbackStartAttr: element.playbackStartAttr,
89
- sourceDuration: element.sourceDuration,
90
- timingSource: element.timingSource,
91
- timelineLocked: element.timelineLocked,
92
- }).canMove;
93
- }
94
-
95
85
  /**
96
86
  * Optimistically apply + persist a batch of moves with rollback on failure.
97
87
  *
@@ -139,14 +129,15 @@ export function persistMoveEdits(
139
129
  // that written value into the store's `authoredTrack` so a SECOND drag before
140
130
  // any reload resolves authored tracks from what the file now says, not stale
141
131
  // pre-edit data. Pure time-moves leave authoredTrack untouched.
142
- for (const e of edits) {
132
+ const applyEdit = (e: TimelineMoveEdit) => {
143
133
  const writtenTrack =
144
134
  e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
145
135
  updateElement(
146
136
  keyOf(e.element),
147
137
  writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
148
138
  );
149
- }
139
+ };
140
+ for (const e of edits) applyEdit(e);
150
141
  // The store above gets DISPLAY lanes; the file below gets the authored-space
151
142
  // track when one was resolved (see TimelineMoveEdit.persistTrack).
152
143
  const persistEdits = edits.map((e) =>
@@ -158,7 +149,17 @@ export function persistMoveEdits(
158
149
  ? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs)
159
150
  : Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
160
151
  return Promise.resolve(persisted).then(
161
- () => true,
152
+ () => {
153
+ // Runtime timeline messages can arrive while the save is in flight and
154
+ // restore the preview manifest's pre-gesture lane. Reassert the durable
155
+ // result after persistence, but only while this remains the latest
156
+ // optimistic gesture so an older save can never clobber a newer drag.
157
+ for (const e of edits) {
158
+ const key = keyOf(e.element);
159
+ if (isLatestTimelineOptimisticGesture(updateElement, revision, key)) applyEdit(e);
160
+ }
161
+ return true;
162
+ },
162
163
  (error) => {
163
164
  for (const p of prev) {
164
165
  if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
@@ -177,59 +178,6 @@ export function persistMoveEdits(
177
178
  * then compacts it to a distinct integer lane between its neighbours, and the
178
179
  * clips at/below the insert shift down by one — the sanctioned index-renumber.
179
180
  */
180
- /** Same-source-file predicate: authored track numbers only compare within ONE
181
- * file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
182
- * file, not the host timeline's). `undefined` means the active composition. */
183
- export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
184
- (a.sourceFile ?? null) === (b.sourceFile ?? null);
185
-
186
- /**
187
- * Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
188
- * `dragged`. Occupants are consulted ONLY from the dragged clip's own source
189
- * file — an occupant from a different file (e.g. an expanded sub-comp child, or
190
- * a host clip next to expanded rows) carries authored values in a different
191
- * coordinate space, and borrowing them would write a foreign file's numbering.
192
- *
193
- * Lane semantics after normalizeToZones: each distinct authored track owns one
194
- * base lane, and time-overlapping same-track clips spill onto adjacent display
195
- * sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
196
- * trackOrder lists it): its occupants share the base lane's authored track by
197
- * construction, so the same-file occupant lookup returns that authored track and
198
- * the drop persists as a same-track join. The clip may then DISPLAY on a
199
- * different sub-lane than it was dropped on — the spill re-packs
200
- * deterministically by stable id, first-fit — but the persisted track is
201
- * correct.
202
- *
203
- * Fallbacks when the lane has no same-file occupant (e.g. an expanded child
204
- * dropped on a lane holding only other files' clips — the display-lane integer
205
- * must NOT be persisted into a sparse file):
206
- * 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
207
- * preserving "one lane up = one authored track up" in the clip's own file.
208
- * 2. No same-file peers at all → the lane value itself (single-clip files:
209
- * display and authored spaces coincide for want of any other anchor).
210
- * Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
211
- * never here.
212
- */
213
- export function authoredTrackForLane(
214
- lane: number,
215
- elements: TimelineElement[],
216
- dragged: TimelineElement,
217
- ): number {
218
- const dragKey = keyOf(dragged);
219
- const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
220
- const occupant = peers.find((e) => e.track === lane);
221
- if (occupant) return occupant.authoredTrack ?? occupant.track;
222
- let nearest: TimelineElement | null = null;
223
- for (const p of peers) {
224
- if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
225
- }
226
- if (!nearest) return lane;
227
- // Rounded: expanded children live on FRACTIONAL synthetic display rows (see
228
- // buildChildElements), so a lane distance measured against one can carry a
229
- // fraction — an authored data-track-index must stay an integer.
230
- return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
231
- }
232
-
233
181
  function insertTrackValue(trackOrder: number[], insertRow: number): number {
234
182
  if (trackOrder.length === 0) return 0;
235
183
  if (insertRow <= 0) return trackOrder[0] - 0.5;
@@ -284,6 +232,12 @@ function resolveMultiSelection(
284
232
  */
285
233
  // fallow-ignore-next-line complexity
286
234
  export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
235
+ const hostAlias = resolveExpandedHostAlias(drag, deps);
236
+ if (hostAlias) {
237
+ commitDraggedClipMove(hostAlias.drag, { ...deps, selectedKeys: hostAlias.selectedKeys });
238
+ return;
239
+ }
240
+
287
241
  const { elements, updateElement, onMoveElement } = deps;
288
242
  const dragKey = keyOf(drag.element);
289
243
  const isInsert = drag.insertRow != null;
@@ -362,22 +316,28 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
362
316
  });
363
317
  const multiKeys = multi ? multi.keys : null;
364
318
  if (!isVertical || !deps.readZIndex || !deps.onStackingPatches) {
365
- void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder");
319
+ void refreshAfterDurableLaneMove(
320
+ persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
321
+ deps,
322
+ );
366
323
  return;
367
324
  }
368
- void runLaneZGesture({
369
- commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
370
- commitZ: () =>
371
- syncStackingForEdit(
372
- candidate,
373
- dragKey,
374
- drag.element.track,
375
- drag.previewTrack,
376
- multiKeys,
377
- deps,
378
- coalesceKey,
379
- ),
380
- }).catch(() => undefined);
325
+ void refreshAfterDurableLaneMove(
326
+ runLaneZGesture({
327
+ commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
328
+ commitZ: () =>
329
+ syncStackingForEdit(
330
+ candidate,
331
+ dragKey,
332
+ drag.element.track,
333
+ drag.previewTrack,
334
+ multiKeys,
335
+ deps,
336
+ coalesceKey,
337
+ ),
338
+ }),
339
+ deps,
340
+ ).catch(() => undefined);
381
341
  }
382
342
 
383
343
  /** Build the one sanctioned multi-clip write: atomically insert and compact a
@@ -486,23 +446,29 @@ function commitTrackInsert(
486
446
 
487
447
  const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
488
448
  if (!deps.readZIndex || !deps.onStackingPatches) {
489
- void persistMoveEdits(edits, deps, coalesceKey, "track-insert");
449
+ void refreshAfterDurableLaneMove(
450
+ persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
451
+ deps,
452
+ );
490
453
  return;
491
454
  }
492
- void runLaneZGesture({
493
- commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
494
- commitZ: () =>
495
- // Sync from the fractional drop intent, not the normalized persisted lanes.
496
- syncStackingForEdit(
497
- candidate,
498
- dragKey,
499
- drag.element.track,
500
- drag.insertRow!,
501
- multi ? multi.keys : null,
502
- deps,
503
- coalesceKey,
504
- ),
505
- }).catch(() => undefined);
455
+ void refreshAfterDurableLaneMove(
456
+ runLaneZGesture({
457
+ commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
458
+ commitZ: () =>
459
+ // Sync from the fractional drop intent, not the normalized persisted lanes.
460
+ syncStackingForEdit(
461
+ candidate,
462
+ dragKey,
463
+ drag.element.track,
464
+ drag.insertRow!,
465
+ multi ? multi.keys : null,
466
+ deps,
467
+ coalesceKey,
468
+ ),
469
+ }),
470
+ deps,
471
+ ).catch(() => undefined);
506
472
  }
507
473
 
508
474
  /**
@@ -544,11 +510,17 @@ export function commitZMirrorLaneMove(
544
510
  updates: { start: element.start, track: move.displayTrack },
545
511
  persistTrack: move.persistTrack,
546
512
  };
547
- return persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs);
513
+ return refreshAfterDurableLaneMove(
514
+ persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs),
515
+ deps,
516
+ );
548
517
  }
549
518
  const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
550
519
  if (!built || built.edits.length === 0) return Promise.resolve(false);
551
- return persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs);
520
+ return refreshAfterDurableLaneMove(
521
+ persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs),
522
+ deps,
523
+ );
552
524
  }
553
525
 
554
526
  /**
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
- import { computeDragPreview, type DragPreviewContext } from "./timelineClipDragPreview";
3
+ import {
4
+ computeDragPreview,
5
+ computeResizePreview,
6
+ type DragPreviewContext,
7
+ } from "./timelineClipDragPreview";
4
8
  import type { DraggedClipState } from "./timelineClipDragTypes";
5
9
  import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
6
10
 
@@ -142,3 +146,31 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
142
146
  expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
143
147
  });
144
148
  });
149
+
150
+ describe("computeResizePreview — composition source continuity", () => {
151
+ it("seeds a legacy composition offset and advances it at playback rate", () => {
152
+ const element = {
153
+ ...clip("comp", 0, 2, 4, 0, "div"),
154
+ kind: "composition" as const,
155
+ playbackRate: 2,
156
+ };
157
+ const result = computeResizePreview(
158
+ {
159
+ element,
160
+ edge: "start",
161
+ originClientX: 0,
162
+ previewStart: 2,
163
+ previewDuration: 4,
164
+ started: true,
165
+ },
166
+ 100,
167
+ { scroll: fakeScroll(), pps: 100, buildSnapTargets: () => [] },
168
+ );
169
+
170
+ expect(result).toMatchObject({
171
+ previewStart: 3,
172
+ previewDuration: 3,
173
+ previewPlaybackStart: 2,
174
+ });
175
+ });
176
+ });
@@ -221,7 +221,8 @@ export function computeResizePreview(
221
221
  )
222
222
  : Number.POSITIVE_INFINITY;
223
223
  const normalizedTag = resize.element.tag.toLowerCase();
224
- const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
224
+ const canSeedPlaybackStart =
225
+ resize.element.kind === "composition" || normalizedTag === "audio" || normalizedTag === "video";
225
226
  const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
226
227
  // Trim limit = available source media only — NOT the composition length.
227
228
  // Duration is content-driven (the comp grows/shrinks to fit on commit), so
@@ -308,6 +308,24 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
308
308
  ).toEqual({ track: 2, insertRow: null });
309
309
  });
310
310
 
311
+ it("crosses an occupied aim instead of snapping the dragged clip back to its origin", () => {
312
+ expect(
313
+ resolveZoneDropPlacement({
314
+ ...base,
315
+ elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("x", 2, 0, 5)],
316
+ desiredTrack: 1,
317
+ }),
318
+ ).toEqual({ track: 1, insertRow: 1 });
319
+
320
+ expect(
321
+ resolveZoneDropPlacement({
322
+ ...base,
323
+ elements: [el("x", 0, 0, 5), el("b", 1, 0, 5), el("a", 2, 0, 5)],
324
+ desiredTrack: 1,
325
+ }),
326
+ ).toEqual({ track: 1, insertRow: 2 });
327
+ });
328
+
311
329
  it("auto-creates a new track when EVERY lane in the zone is occupied at that time", () => {
312
330
  expect(
313
331
  resolveZoneDropPlacement({
@@ -115,7 +115,10 @@ export function resolveZoneDropPlacement(input: {
115
115
  trackOrder: zoneTracks,
116
116
  excludeKey: dragKey,
117
117
  });
118
- if (placement.needsInsert) {
118
+ const originTrack = elements.find((element) => (element.key ?? element.id) === dragKey)?.track;
119
+ const snappedBackToOrigin =
120
+ originTrack != null && desired !== originTrack && placement.track === originTrack;
121
+ if (placement.needsInsert || snappedBackToOrigin) {
119
122
  const desiredRow = order.indexOf(desired);
120
123
  if (desiredRow < 0) {
121
124
  return {
@@ -123,14 +126,14 @@ export function resolveZoneDropPlacement(input: {
123
126
  insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
124
127
  };
125
128
  }
126
- // Prefer the gap NEAREST the pointer: insert above the aimed row when the
127
- // pointer sits in its upper half AND that boundary is in the clip's own zone
128
- // (else the visual/audio split would be crossed) otherwise fall to below.
129
- // `desired` is clamped into the zone, so both boundaries stay in-zone.
130
- const insertRow =
131
- preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio)
132
- ? desiredRow
133
- : desiredRow + 1;
129
+ // When collision fallback found only the origin lane, insert on the far side
130
+ // of the aimed lane so normalization cannot turn the gesture into a no-op.
131
+ // Otherwise prefer the gap nearest the pointer, preserving normal insertion.
132
+ const originRow = originTrack == null ? -1 : order.indexOf(originTrack);
133
+ const insertAbove = snappedBackToOrigin
134
+ ? originRow > desiredRow
135
+ : preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio);
136
+ const insertRow = insertAbove ? desiredRow : desiredRow + 1;
134
137
  return { track: desired, insertRow };
135
138
  }
136
139
  return { track: placement.track, insertRow: null };
@@ -1,5 +1,9 @@
1
1
  import { useCallback, useState, type RefObject } from "react";
2
2
  import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
3
+ import {
4
+ parseTimelineCompositionPayload,
5
+ TIMELINE_COMPOSITION_MIME,
6
+ } from "../../utils/timelineCompositionDrop";
3
7
  import { usePlayerStore } from "../store/playerStore";
4
8
  import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
5
9
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
@@ -32,6 +36,11 @@ function applyJsonDropPayload(
32
36
  }
33
37
  }
34
38
 
39
+ function resolveDropStart(usePointerStart: boolean, pointerStart: number): number {
40
+ if (usePointerStart) return pointerStart;
41
+ return Math.max(0, usePlayerStore.getState().currentTime);
42
+ }
43
+
35
44
  /**
36
45
  * Dropping an asset/file/block onto the timeline places it at the PLAYHEAD —
37
46
  * start is the current playhead time, only the track comes from the drop y.
@@ -48,6 +57,7 @@ export function useTimelineAssetDrop({
48
57
  onFileDrop,
49
58
  onAssetDrop,
50
59
  onBlockDrop,
60
+ onCompositionDrop,
51
61
  }: UseTimelineAssetDropOptions) {
52
62
  const [isDragOver, setIsDragOver] = useState(false);
53
63
 
@@ -56,7 +66,8 @@ export function useTimelineAssetDrop({
56
66
  const hasFiles = types.includes("Files");
57
67
  const hasAsset = types.includes(TIMELINE_ASSET_MIME);
58
68
  const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
59
- if (!hasFiles && !hasAsset && !hasBlock) return;
69
+ const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
70
+ if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
60
71
  e.preventDefault();
61
72
  e.dataTransfer.dropEffect = "copy";
62
73
  setIsDragOver(true);
@@ -65,11 +76,10 @@ export function useTimelineAssetDrop({
65
76
  const clearDropPreview = useCallback(() => setIsDragOver(false), []);
66
77
 
67
78
  const resolveDropPlacement = useCallback(
68
- (clientX: number, clientY: number): TimelinePlacement => {
79
+ (clientX: number, clientY: number, usePointerStart = false): TimelinePlacement => {
69
80
  const scroll = scrollRef.current;
70
81
  const rect = scroll?.getBoundingClientRect();
71
- // Track comes from the vertical drop position; start is the playhead.
72
- const { track } = resolveTimelineAssetDrop(
82
+ const pointer = resolveTimelineAssetDrop(
73
83
  {
74
84
  rectLeft: rect?.left ?? 0,
75
85
  rectTop: rect?.top ?? 0,
@@ -77,14 +87,17 @@ export function useTimelineAssetDrop({
77
87
  scrollTop: scroll?.scrollTop ?? 0,
78
88
  pixelsPerSecond: ppsRef.current,
79
89
  duration: durationRef.current,
90
+ clampStartToDuration: !usePointerStart,
80
91
  trackHeight: TRACK_H,
81
92
  trackOrder: trackOrderRef.current,
82
93
  },
83
94
  clientX,
84
95
  clientY,
85
96
  );
86
- const start = Math.max(0, usePlayerStore.getState().currentTime);
87
- return { start, track };
97
+ return {
98
+ start: resolveDropStart(usePointerStart, pointer.start),
99
+ track: pointer.track,
100
+ };
88
101
  },
89
102
  [scrollRef, ppsRef, durationRef, trackOrderRef],
90
103
  );
@@ -93,6 +106,14 @@ export function useTimelineAssetDrop({
93
106
  (e: React.DragEvent) => {
94
107
  e.preventDefault();
95
108
  setIsDragOver(false);
109
+ const compositionPayload = parseTimelineCompositionPayload(
110
+ e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
111
+ );
112
+ if (compositionPayload && onCompositionDrop) {
113
+ const placement = resolveDropPlacement(e.clientX, e.clientY, true);
114
+ void onCompositionDrop(compositionPayload.sourcePath, placement);
115
+ return;
116
+ }
96
117
  const placement = resolveDropPlacement(e.clientX, e.clientY);
97
118
 
98
119
  if (onFileDrop && e.dataTransfer.files.length > 0) {
@@ -109,7 +130,7 @@ export function useTimelineAssetDrop({
109
130
  applyJsonDropPayload(blockPayload, (p) => p.name, onBlockDrop, placement);
110
131
  }
111
132
  },
112
- [resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop],
133
+ [resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop, onCompositionDrop],
113
134
  );
114
135
 
115
136
  return { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview };
@@ -6,11 +6,13 @@ export interface TimelineEditCapabilities {
6
6
 
7
7
  function isDeterministicTimelineWindow(input: {
8
8
  tag: string;
9
+ kind?: "video" | "audio" | "image" | "element" | "composition";
9
10
  compositionSrc?: string;
10
11
  playbackStartAttr?: "media-start" | "playback-start";
11
12
  sourceDuration?: number;
12
13
  }): boolean {
13
- if (input.compositionSrc || input.playbackStartAttr != null) return true;
14
+ if (input.kind === "composition" || input.compositionSrc || input.playbackStartAttr != null)
15
+ return true;
14
16
  if (
15
17
  input.sourceDuration != null &&
16
18
  Number.isFinite(input.sourceDuration) &&
@@ -27,6 +29,7 @@ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: s
27
29
 
28
30
  export function getTimelineEditCapabilities(input: {
29
31
  tag: string;
32
+ kind?: "video" | "audio" | "image" | "element" | "composition";
30
33
  duration: number;
31
34
  domId?: string;
32
35
  selector?: string;
@@ -53,6 +53,16 @@ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
53
53
  ]);
54
54
  });
55
55
 
56
+ it("seeds legacy composition offsets and advances them at playback rate", () => {
57
+ const a = el("a", { kind: "composition", tag: "div", start: 2, playbackRate: 2 });
58
+ const b = el("b", { kind: "composition", tag: "div", start: 5, playbackRate: 0.5 });
59
+ const members = buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "a", "start")!;
60
+
61
+ expect(members.map((member) => member.playbackStart)).toEqual([0, 0]);
62
+ const changes = resolveTimelineGroupResizeChanges(members, "start", 1);
63
+ expect(changes.map((change) => change.playbackStart)).toEqual([2, 0.5]);
64
+ });
65
+
56
66
  it("does not seed playbackStart on the END edge", () => {
57
67
  const grabbed = el("a", { tag: "audio", start: 0, duration: 2 });
58
68
  const b = el("b", { tag: "audio", start: 3, duration: 2 });
@@ -181,14 +181,15 @@ function elementKey(element: TimelineElement): string {
181
181
  return element.key ?? element.id;
182
182
  }
183
183
 
184
- function isMediaTimelineElement(element: TimelineElement): boolean {
184
+ function hasSourcePlaybackOffset(element: TimelineElement): boolean {
185
185
  const tag = element.tag.toLowerCase();
186
- return tag === "audio" || tag === "video";
186
+ return element.kind === "composition" || tag === "audio" || tag === "video";
187
187
  }
188
188
 
189
189
  function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean {
190
190
  const caps = getTimelineEditCapabilities({
191
191
  tag: element.tag,
192
+ kind: element.kind,
192
193
  duration: element.duration,
193
194
  domId: element.domId,
194
195
  selector: element.selector,
@@ -228,7 +229,7 @@ export function buildTimelineGroupResizeMembers(
228
229
  start: element.start,
229
230
  duration: element.duration,
230
231
  playbackStart:
231
- edge === "start" && isMediaTimelineElement(element)
232
+ edge === "start" && hasSourcePlaybackOffset(element)
232
233
  ? (element.playbackStart ?? 0)
233
234
  : element.playbackStart,
234
235
  playbackRate: element.playbackRate,
@@ -0,0 +1,14 @@
1
+ export interface LaneMoveRefreshDeps {
2
+ refreshAfterLaneMove?: () => void;
3
+ }
4
+
5
+ /** Refresh only after the complete lane transaction persisted successfully. */
6
+ export function refreshAfterDurableLaneMove(
7
+ pending: Promise<boolean>,
8
+ deps: LaneMoveRefreshDeps,
9
+ ): Promise<boolean> {
10
+ return pending.then((persisted) => {
11
+ if (persisted) deps.refreshAfterLaneMove?.();
12
+ return persisted;
13
+ });
14
+ }
@@ -383,6 +383,7 @@ export function resolveTimelineAssetDrop(
383
383
  scrollTop: number;
384
384
  pixelsPerSecond: number;
385
385
  duration: number;
386
+ clampStartToDuration?: boolean;
386
387
  trackHeight: number;
387
388
  trackOrder: number[];
388
389
  },
@@ -391,9 +392,10 @@ export function resolveTimelineAssetDrop(
391
392
  ): { start: number; track: number } {
392
393
  const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
393
394
  const contentY = clientY - input.rectTop + input.scrollTop;
395
+ const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
394
396
  const start = Math.max(
395
397
  0,
396
- Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
398
+ input.clampStartToDuration === false ? pointerStart : Math.min(input.duration, pointerStart),
397
399
  );
398
400
  // Row from the shared row→y inverse so the top pad is honoured; a drop in the
399
401
  // pad above the first lane floors to row 0, a drop in the bottom pad rounds
@@ -1,7 +1,7 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
  import { classifyZone } from "./timelineZones";
3
3
  import { isLaneFree, timeRangesOverlap } from "./timelineCollision";
4
- import { authoredTrackForLane, sameSourceFile } from "./timelineClipDragCommit";
4
+ import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
5
5
  import { samePaintScope } from "./timelineStackingSync";
6
6
 
7
7
  /**
@@ -77,6 +77,7 @@ interface UseTimelineClipDragInput {
77
77
  */
78
78
  readZIndex?: (element: TimelineElement) => number;
79
79
  onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
80
+ refreshAfterLaneMove?: () => void;
80
81
  }
81
82
 
82
83
  export function useTimelineClipDrag({
@@ -93,6 +94,7 @@ export function useTimelineClipDrag({
93
94
  setRangeSelectionRef,
94
95
  readZIndex,
95
96
  onStackingPatches,
97
+ refreshAfterLaneMove,
96
98
  }: UseTimelineClipDragInput) {
97
99
  const updateElement = usePlayerStore((s) => s.updateElement);
98
100
  const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
@@ -213,6 +215,8 @@ export function useTimelineClipDrag({
213
215
  readZIndexRef.current = readZIndex;
214
216
  const onStackingPatchesRef = useRef(onStackingPatches);
215
217
  onStackingPatchesRef.current = onStackingPatches;
218
+ const refreshAfterLaneMoveRef = useRef(refreshAfterLaneMove);
219
+ refreshAfterLaneMoveRef.current = refreshAfterLaneMove;
216
220
 
217
221
  const clipDragScrollRaf = useRef(0);
218
222
  const clipDragPointerRef = useRef<{
@@ -499,6 +503,7 @@ export function useTimelineClipDrag({
499
503
  // deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync.
500
504
  readZIndex: readZIndexRef.current,
501
505
  onStackingPatches: onStackingPatchesRef.current,
506
+ refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
502
507
  });
503
508
  };
504
509
 
@@ -12,6 +12,7 @@ interface UseTimelineEditPinningInput {
12
12
  onFileDrop: TimelineDropCallbacks["onFileDrop"];
13
13
  onAssetDrop: TimelineDropCallbacks["onAssetDrop"];
14
14
  onBlockDrop: TimelineDropCallbacks["onBlockDrop"];
15
+ onCompositionDrop: TimelineDropCallbacks["onCompositionDrop"];
15
16
  }
16
17
 
17
18
  // Wrap every mutating timeline edit so the zoom pins to the current on-screen
@@ -29,6 +30,7 @@ export function useTimelineEditPinning({
29
30
  onFileDrop,
30
31
  onAssetDrop,
31
32
  onBlockDrop,
33
+ onCompositionDrop,
32
34
  }: UseTimelineEditPinningInput) {
33
35
  const pinTimelineZoom = usePlayerStore((s) => s.pinTimelineZoom);
34
36
  // Pin the timeline zoom to the current on-screen scale on the FIRST edit, so a
@@ -106,6 +108,15 @@ export function useTimelineEditPinning({
106
108
  }),
107
109
  [onBlockDrop, pinZoomBeforeEdit],
108
110
  );
111
+ const pinnedOnCompositionDrop = useMemo(
112
+ () =>
113
+ onCompositionDrop &&
114
+ ((...args: Parameters<typeof onCompositionDrop>) => {
115
+ pinZoomBeforeEdit();
116
+ return onCompositionDrop(...args);
117
+ }),
118
+ [onCompositionDrop, pinZoomBeforeEdit],
119
+ );
109
120
 
110
121
  return {
111
122
  pinZoomBeforeEdit,
@@ -117,5 +128,6 @@ export function useTimelineEditPinning({
117
128
  pinnedOnFileDrop,
118
129
  pinnedOnAssetDrop,
119
130
  pinnedOnBlockDrop,
131
+ pinnedOnCompositionDrop,
120
132
  };
121
133
  }