@hyperframes/studio 0.8.46 → 0.8.48

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 (112) hide show
  1. package/dist/assets/hyperframes-player-CtVX6jL_.js +464 -0
  2. package/dist/assets/{index-CbchRscN.js → index-B3-orIjW.js} +1 -1
  3. package/dist/assets/index-B9LfWNyM.css +1 -0
  4. package/dist/assets/index-C9gJKpqQ.js +441 -0
  5. package/dist/assets/{index-BywY2OM3.js → index-D_j-0PD3.js} +1 -1
  6. package/dist/index.d.ts +83 -26
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1118 -661
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +17 -26
  12. package/src/components/AskAgentModal.tsx +9 -7
  13. package/src/components/TimelineToolbar.tsx +26 -1
  14. package/src/components/editor/PropertyPanelFlat.tsx +8 -8
  15. package/src/components/editor/domEditing.ts +7 -1
  16. package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
  17. package/src/components/editor/domEditingAgentPrompt.ts +136 -34
  18. package/src/components/editor/manualEditingAvailability.test.ts +19 -0
  19. package/src/components/editor/manualEditingAvailability.ts +9 -5
  20. package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
  21. package/src/components/editor/propertyPanelAutomation.ts +15 -0
  22. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
  23. package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
  24. package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
  25. package/src/components/editor/useVolumeAutomation.ts +23 -2
  26. package/src/components/panels/VariablesPanel.tsx +5 -8
  27. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
  28. package/src/components/sidebar/CompositionsTab.tsx +15 -0
  29. package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
  30. package/src/components/sidebar/LeftSidebar.tsx +7 -0
  31. package/src/contexts/FileManagerContext.tsx +0 -3
  32. package/src/hooks/appHotkeysDispatch.ts +256 -0
  33. package/src/hooks/domEditDeleteMembers.test.ts +30 -0
  34. package/src/hooks/domEditDeleteMembers.ts +19 -0
  35. package/src/hooks/useActiveComposition.ts +69 -0
  36. package/src/hooks/useAppHotkeys.test.ts +23 -1
  37. package/src/hooks/useAppHotkeys.ts +6 -239
  38. package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
  39. package/src/hooks/useAutoOpenRootComposition.ts +33 -0
  40. package/src/hooks/useDomEditCommits.ts +13 -15
  41. package/src/hooks/useDomEditSession.test.tsx +90 -1
  42. package/src/hooks/useDomEditSession.ts +31 -34
  43. package/src/hooks/useDomSelectionTypes.ts +7 -2
  44. package/src/hooks/useElementLifecycleOps.ts +0 -8
  45. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  46. package/src/hooks/useFileManager.ts +0 -2
  47. package/src/hooks/useFileTree.test.tsx +214 -0
  48. package/src/hooks/useFileTree.ts +66 -24
  49. package/src/hooks/useGroupCommits.test.ts +98 -0
  50. package/src/hooks/useGroupCommits.ts +66 -9
  51. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  52. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  53. package/src/hooks/useSdkSession.lifecycle.test.tsx +76 -0
  54. package/src/hooks/useSdkSession.ts +21 -3
  55. package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
  56. package/src/hooks/useTimelineAssetDropOps.ts +20 -16
  57. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  58. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  59. package/src/hooks/useTimelineEditing.ts +1 -0
  60. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  61. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  62. package/src/index.ts +10 -0
  63. package/src/player/components/Timeline.test.ts +0 -4
  64. package/src/player/components/Timeline.tsx +0 -1
  65. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  66. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  67. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  68. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  69. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  70. package/src/player/components/automationLaneData.test.ts +11 -3
  71. package/src/player/components/automationLaneData.ts +5 -4
  72. package/src/player/components/automationLaneGeometry.ts +15 -0
  73. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  74. package/src/player/components/timelineDragDrop.ts +9 -25
  75. package/src/player/components/timelineGapCommit.test.ts +96 -0
  76. package/src/player/components/timelineGapCommit.ts +56 -11
  77. package/src/player/components/timelineGaps.ts +2 -2
  78. package/src/player/components/timelineLayout.test.ts +0 -1
  79. package/src/player/components/timelineLayout.ts +1 -6
  80. package/src/player/components/timelineZones.test.ts +20 -1
  81. package/src/player/components/timelineZones.ts +10 -3
  82. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  83. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  84. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  85. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  86. package/src/player/hooks/useTimelinePlayer.ts +9 -4
  87. package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
  88. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  89. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  90. package/src/player/store/playerStore.test.ts +42 -0
  91. package/src/player/store/playerStore.ts +25 -28
  92. package/src/player/store/readinessSlice.ts +42 -0
  93. package/src/utils/razorSplitTransaction.test.ts +10 -0
  94. package/src/utils/razorSplitTransaction.ts +5 -0
  95. package/src/utils/sdkCutover.gate.test.ts +5 -4
  96. package/src/utils/sdkCutover.test.ts +93 -0
  97. package/src/utils/sdkCutover.ts +46 -25
  98. package/src/utils/sdkCutoverEligibility.ts +21 -0
  99. package/src/utils/sdkCutoverPolicy.test.ts +11 -2
  100. package/src/utils/sdkCutoverPolicy.ts +5 -3
  101. package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
  102. package/src/utils/sdkEditTransaction.ts +22 -1
  103. package/src/utils/sdkResolverShadow.ts +10 -1
  104. package/src/utils/studioHelpers.test.ts +18 -0
  105. package/src/utils/studioHelpers.ts +41 -5
  106. package/src/utils/studioUiPreferences.test.ts +14 -0
  107. package/src/utils/studioUiPreferences.ts +6 -0
  108. package/src/utils/studioUrlState.ts +15 -7
  109. package/src/utils/timelineInspector.ts +10 -0
  110. package/dist/assets/hyperframes-player-CsV2rywT.js +0 -464
  111. package/dist/assets/index-DCblmTX3.js +0 -441
  112. package/dist/assets/index-DnRfAiK2.css +0 -1
@@ -22,7 +22,7 @@ import {
22
22
  type HfAutomationLane,
23
23
  } from "@hyperframes/core/audio-automation";
24
24
  import { parseAudioFxChain, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
25
- import { isAudioTimelineElement } from "../../utils/timelineInspector";
25
+ import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
26
26
  import type { TimelineElement } from "../store/playerStore";
27
27
 
28
28
  const EMPTY: HfAutomation = { version: 1, lanes: [] };
@@ -245,13 +245,14 @@ export interface AutomationLaneGroup {
245
245
  * spectrum, top down), so the first clip to carry a property fixes its row and
246
246
  * later clips only append properties nobody has shown yet.
247
247
  *
248
- * Non-audio elements contribute nothing, matching `automationLaneCountOf` — the
249
- * row's reserved height and its drawn lanes have to count the same clips.
248
+ * Gated on `isAudioOrVideoTimelineElement`, the same predicate `automationLaneCountOf`
249
+ * and `TimelineAutomationLaneSlot`'s clip filter use — the row's reserved height
250
+ * and its drawn lanes have to count the same clips.
250
251
  */
251
252
  export function groupAutomationLanes(elements: readonly TimelineElement[]): AutomationLaneGroup[] {
252
253
  const groups = new Map<string, AutomationLaneGroup>();
253
254
  for (const element of elements) {
254
- if (!isAudioTimelineElement(element)) continue;
255
+ if (!isAudioOrVideoTimelineElement(element)) continue;
255
256
  const chain = elementFxChain(element);
256
257
  for (const lane of elementAutomationLanes(element)) {
257
258
  const key = laneGroupKey(lane.target, chain);
@@ -23,6 +23,21 @@ import { getAudioFxDef, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
23
23
  /** Points nearer than this in clip seconds are the same point, not two. */
24
24
  export const POINT_MERGE_SEC = 0.02;
25
25
 
26
+ /** Insert or replace the point nearest `t` — the one merge/seed rule every
27
+ * hand-editing gesture on a lane uses (timeline double-click, panel control). */
28
+ export function mergeInsertPoint(
29
+ points: readonly HfAutomationLane["points"][number][],
30
+ t: number,
31
+ v: number,
32
+ ): HfAutomationLane["points"] {
33
+ const kept = points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC);
34
+ // A lane's first point alone would be a constant, which is not what
35
+ // committing to an empty lane means: seed the far end at the same value so
36
+ // the envelope has somewhere to go.
37
+ const seeded = points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : [];
38
+ return [...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t);
39
+ }
40
+
26
41
  /**
27
42
  * Closest two breakpoints may sit in clip seconds while still being two points.
28
43
  *
@@ -56,7 +56,6 @@ function renderHarness(
56
56
  api = useTimelineAssetDrop({
57
57
  scrollRef: { current: scroll },
58
58
  ppsRef: { current: 40 },
59
- durationRef: { current: 120 },
60
59
  trackOrderRef: { current: tracks },
61
60
  rowGeometryRef: { current: geometry },
62
61
  contentOrigin: 0,
@@ -138,7 +137,6 @@ describe("useTimelineAssetDrop", () => {
138
137
  it("drops once on a model row outside the mounted window and appends below the last row", () => {
139
138
  const onAssetDrop = vi.fn();
140
139
  const view = renderHarness(onAssetDrop);
141
- usePlayerStore.getState().setCurrentTime(12.5);
142
140
  view.scroll.scrollTop = view.scroll.scrollHeight - view.scroll.clientHeight;
143
141
  const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
144
142
 
@@ -148,11 +146,29 @@ describe("useTimelineAssetDrop", () => {
148
146
  });
149
147
 
150
148
  expect(onAssetDrop).toHaveBeenCalledTimes(1);
151
- expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 12.5, track: 100 });
149
+ // pps=40, clientX=400 -> 10s at the pointer, not the playhead.
150
+ expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 10, track: 100 });
152
151
  expect(view.api.isDragOver).toBe(false);
153
152
  act(() => view.root.unmount());
154
153
  });
155
154
 
155
+ it("places the drop at the pointer x, ignoring the playhead", () => {
156
+ const onAssetDrop = vi.fn();
157
+ const view = renderHarness(onAssetDrop);
158
+ usePlayerStore.getState().setCurrentTime(50);
159
+ const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
160
+
161
+ act(() => {
162
+ view.api.handleAssetDragOver(dragEvent(transfer, 80, 100));
163
+ view.api.handleAssetDrop(dragEvent(transfer, 80, 100));
164
+ });
165
+
166
+ // pps=40, clientX=80 -> 2s, far from the 50s playhead: proves start tracks
167
+ // the drop position, not usePlayerStore.currentTime.
168
+ expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 2, track: 0 });
169
+ act(() => view.root.unmount());
170
+ });
171
+
156
172
  it("ignores malformed payloads and clears the actor on project reset", () => {
157
173
  const onAssetDrop = vi.fn();
158
174
  const view = renderHarness(onAssetDrop, 1);
@@ -190,7 +206,8 @@ describe("useTimelineAssetDrop", () => {
190
206
  });
191
207
 
192
208
  expect(onAssetDrop).not.toHaveBeenCalled();
193
- expect(onBlockDrop).toHaveBeenCalledExactlyOnceWith("title-card", { start: 0, track: 0 });
209
+ // pps=40, clientX=400 -> 10s at the pointer.
210
+ expect(onBlockDrop).toHaveBeenCalledExactlyOnceWith("title-card", { start: 10, track: 0 });
194
211
  act(() => view.root.unmount());
195
212
  });
196
213
 
@@ -4,7 +4,6 @@ import {
4
4
  parseTimelineCompositionPayload,
5
5
  TIMELINE_COMPOSITION_MIME,
6
6
  } from "../../utils/timelineCompositionDrop";
7
- import { usePlayerStore } from "../store/playerStore";
8
7
  import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
9
8
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
10
9
  import {
@@ -15,7 +14,6 @@ import {
15
14
  interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
16
15
  scrollRef: RefObject<HTMLDivElement | null>;
17
16
  ppsRef: RefObject<number>;
18
- durationRef: RefObject<number>;
19
17
  trackOrderRef: RefObject<number[]>;
20
18
  rowGeometryRef: RefObject<TimelineRowGeometry>;
21
19
  contentOrigin: number;
@@ -53,11 +51,6 @@ function invokeDropCallback(callback: () => Promise<void> | void): void {
53
51
  }
54
52
  }
55
53
 
56
- function resolveDropStart(usePointerStart: boolean, pointerStart: number): number {
57
- if (usePointerStart) return pointerStart;
58
- return Math.max(0, usePlayerStore.getState().currentTime);
59
- }
60
-
61
54
  function applyFileDrop(
62
55
  transfer: DataTransfer,
63
56
  onFileDrop: TimelineDropCallbacks["onFileDrop"],
@@ -87,17 +80,14 @@ function applyTypedJsonDrop(
87
80
  }
88
81
 
89
82
  /**
90
- * Dropping an asset/file/block onto the timeline places it at the PLAYHEAD —
91
- * start is the current playhead time, only the track comes from the drop y.
92
- * Deliberate product choice (user preference, 2026-07-09): every add lands at
93
- * the playhead regardless of drop x, like CapCut's add-to-timeline. External
94
- * OS file drops and internal asset drops share this same placement path, so
95
- * both land identically.
83
+ * Dropping an asset/file/block/composition onto the timeline places it at the
84
+ * exact time and track it was dropped on, like CapCut (pointer placement on
85
+ * every track but the magnetic main track). Supersedes the prior playhead
86
+ * decision (#2291); playhead adds stay available, see useAddAssetAtPlayhead.
96
87
  */
97
88
  export function useTimelineAssetDrop({
98
89
  scrollRef,
99
90
  ppsRef,
100
- durationRef,
101
91
  trackOrderRef,
102
92
  rowGeometryRef,
103
93
  contentOrigin,
@@ -185,10 +175,10 @@ export function useTimelineAssetDrop({
185
175
  );
186
176
 
187
177
  const resolveDropPlacement = useCallback(
188
- (clientX: number, clientY: number, usePointerStart = false): TimelinePlacement => {
178
+ (clientX: number, clientY: number): TimelinePlacement => {
189
179
  const scroll = scrollRef.current;
190
180
  const rect = scroll?.getBoundingClientRect();
191
- const pointer = resolveTimelineAssetDrop(
181
+ return resolveTimelineAssetDrop(
192
182
  {
193
183
  rectLeft: rect?.left ?? 0,
194
184
  rectTop: rect?.top ?? 0,
@@ -196,20 +186,14 @@ export function useTimelineAssetDrop({
196
186
  scrollTop: scroll?.scrollTop ?? 0,
197
187
  contentOrigin,
198
188
  pixelsPerSecond: ppsRef.current,
199
- duration: durationRef.current,
200
- clampStartToDuration: !usePointerStart,
201
189
  rowHeights: rowGeometryRef.current.rowHeights,
202
190
  trackOrder: trackOrderRef.current,
203
191
  },
204
192
  clientX,
205
193
  clientY,
206
194
  );
207
- return {
208
- start: resolveDropStart(usePointerStart, pointer.start),
209
- track: pointer.track,
210
- };
211
195
  },
212
- [scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, contentOrigin],
196
+ [scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
213
197
  );
214
198
 
215
199
  const handleAssetDrop = useCallback(
@@ -218,15 +202,15 @@ export function useTimelineAssetDrop({
218
202
  const canCommit = activeDropEpochRef.current === sessionEpoch;
219
203
  clearDropPreview();
220
204
  if (!canCommit) return;
205
+ const placement = resolveDropPlacement(e.clientX, e.clientY);
206
+
221
207
  const compositionPayload = parseTimelineCompositionPayload(
222
208
  e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
223
209
  );
224
210
  if (compositionPayload && onCompositionDrop) {
225
- const placement = resolveDropPlacement(e.clientX, e.clientY, true);
226
211
  invokeDropCallback(() => onCompositionDrop(compositionPayload.sourcePath, placement));
227
212
  return;
228
213
  }
229
- const placement = resolveDropPlacement(e.clientX, e.clientY);
230
214
 
231
215
  if (applyFileDrop(e.dataTransfer, onFileDrop, placement)) return;
232
216
  if (applyTypedJsonDrop(e.dataTransfer, TIMELINE_ASSET_MIME, "path", onAssetDrop, placement)) {
@@ -5,6 +5,8 @@ import {
5
5
  canShiftTrackGapClips,
6
6
  commitCloseAllTrackGaps,
7
7
  commitCloseTrackGap,
8
+ resolveMainTrackDeleteRippleShifts,
9
+ resolveShiftedElements,
8
10
  } from "./timelineGapCommit";
9
11
  import { resolveAllTrackGaps } from "./timelineGaps";
10
12
 
@@ -150,6 +152,25 @@ describe("commitCloseAllTrackGaps", () => {
150
152
  });
151
153
  });
152
154
 
155
+ describe("resolveShiftedElements", () => {
156
+ it("maps each shift to its element and new start", () => {
157
+ const elements = [el("a", 0, 2), el("c", 8, 1)];
158
+ const resolved = resolveShiftedElements(elements, [{ key: "c", newStart: 2 }]);
159
+ expect(resolved).toEqual([{ element: elements[1], start: 2 }]);
160
+ });
161
+
162
+ it("resolves by key when present, falling back to id", () => {
163
+ const withKey: TimelineElement = { ...el("a", 0, 2), key: "a-key" };
164
+ const resolved = resolveShiftedElements([withKey], [{ key: "a-key", newStart: 5 }]);
165
+ expect(resolved).toEqual([{ element: withKey, start: 5 }]);
166
+ });
167
+
168
+ it("drops a shift whose key matches no element", () => {
169
+ const elements = [el("a", 0, 2)];
170
+ expect(resolveShiftedElements(elements, [{ key: "ghost", newStart: 0 }])).toEqual([]);
171
+ });
172
+ });
173
+
153
174
  describe("canShiftTrackGapClips", () => {
154
175
  it("is true only when every named clip is movable", () => {
155
176
  const lane = [el("a", 1, 2), lockedEl("b", 5, 3)];
@@ -161,3 +182,78 @@ describe("canShiftTrackGapClips", () => {
161
182
  expect(canShiftTrackGapClips([el("a", 0, 1)], [{ key: "ghost", newStart: 0 }])).toBe(false);
162
183
  });
163
184
  });
185
+
186
+ describe("resolveMainTrackDeleteRippleShifts", () => {
187
+ it("closes the gap left by a deleted main-track clip", () => {
188
+ const deleted = [el("b", 2, 2)];
189
+ const survivors = [el("a", 0, 2), el("c", 8, 1)]; // "b" already removed from this set
190
+ // "c" shifts left by exactly "b"'s 2s duration (8 -> 6), not all the way
191
+ // to 2: the 4-6..8 span was already an open gap before the delete, and
192
+ // ripple must not also swallow it.
193
+ expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toEqual([
194
+ { key: "c", newStart: 6 },
195
+ ]);
196
+ });
197
+
198
+ it("does not also close a pre-existing gap the delete never touched", () => {
199
+ // a(0-2), b(2-4)[deleted], GAP(4-6), c(6-7): deleting b should slide c
200
+ // left by b's 2s only, landing at 4 — flush against a, the pre-existing
201
+ // gap collapsed only because it's now adjacent, not doubly-counted.
202
+ const deleted = [el("b", 2, 2)];
203
+ const survivors = [el("a", 0, 2), el("c", 6, 1)];
204
+ expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toEqual([
205
+ { key: "c", newStart: 4 },
206
+ ]);
207
+ });
208
+
209
+ it("leaves an untouched clip before the delete point exactly where it was", () => {
210
+ // a(0-2), GAP(2-5), b(5-7)[deleted]: nothing follows b, so nothing
211
+ // shifts — a keeps its own leading position, ripple included.
212
+ const deleted = [el("b", 5, 2)];
213
+ const survivors = [el("a", 0, 2)];
214
+ expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toBeNull();
215
+ });
216
+
217
+ it("returns null when ripple is off", () => {
218
+ const deleted = [el("b", 2, 2)];
219
+ const survivors = [el("a", 0, 2), el("c", 8, 1)];
220
+ expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, false)).toBeNull();
221
+ });
222
+
223
+ it("returns null when nothing deleted was on the main track", () => {
224
+ const deletedOverlay = [el("o", 2, 2, 1)]; // track 1, not main
225
+ const survivors = [el("a", 0, 2), el("c", 8, 1)];
226
+ expect(resolveMainTrackDeleteRippleShifts(survivors, deletedOverlay, true)).toBeNull();
227
+ });
228
+
229
+ it("returns null when the survivors are already gapless", () => {
230
+ const deleted = [el("b", 100, 1)]; // deleted from far off the end
231
+ const survivors = [el("a", 0, 2), el("c", 2, 1)]; // already contiguous
232
+ expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toBeNull();
233
+ });
234
+
235
+ it("refuses the whole ripple (not a partial one) when a shifting survivor is locked", () => {
236
+ const deleted = [el("b", 2, 2)];
237
+ const survivors = [el("a", 0, 2), lockedEl("c", 8, 1)];
238
+ expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toBeNull();
239
+ });
240
+
241
+ it("ignores audio and non-main-track survivors even when a main-track clip was deleted", () => {
242
+ const deleted = [el("b", 2, 2)];
243
+ const survivors = [el("a", 0, 2), el("c", 8, 1), el("overlay", 3, 1, 1)];
244
+ const shifts = resolveMainTrackDeleteRippleShifts(survivors, deleted, true);
245
+ expect(shifts).toEqual([{ key: "c", newStart: 6 }]); // "overlay" (track 1) untouched
246
+ });
247
+
248
+ it("sums only the deleted spans that precede each survivor, for a multi-select delete", () => {
249
+ // a(0-2), b(2-4)[deleted], c(4-6), d(6-8)[deleted], e(8-9): a marquee
250
+ // delete of b and d in one batch. "c" sits before d, so it shifts by
251
+ // b's 2s alone; "e" sits after both, so it shifts by their 4s combined.
252
+ const deleted = [el("b", 2, 2), el("d", 6, 2)];
253
+ const survivors = [el("a", 0, 2), el("c", 4, 2), el("e", 8, 1)];
254
+ expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toEqual([
255
+ { key: "c", newStart: 2 },
256
+ { key: "e", newStart: 4 },
257
+ ]);
258
+ });
259
+ });
@@ -10,8 +10,11 @@ import {
10
10
  resolveAllTrackGaps,
11
11
  resolveCloseGapShifts,
12
12
  resolveTrackGapAt,
13
+ round3,
14
+ TRACK_GAP_EPSILON_S,
13
15
  type TrackGapShift,
14
16
  } from "./timelineGaps";
17
+ import { isMainTrackElement } from "./timelineZones";
15
18
 
16
19
  /**
17
20
  * Commit layer for the track-gap context menu ("Close gap" / "Close all gaps").
@@ -35,16 +38,29 @@ const keyOf = (e: TimelineElement) => e.key ?? e.id;
35
38
  // timelineClipDragCommit.ts.
36
39
  let gapCloseGestureSeq = 0;
37
40
 
38
- /** True when every clip named in `shifts` may be time-moved. */
41
+ /** Resolve each shift to its named element, dropping any shift whose key
42
+ * matches nothing — the type stays honest without a non-null assertion. */
43
+ export function resolveShiftedElements(
44
+ elements: readonly TimelineElement[],
45
+ shifts: readonly TrackGapShift[],
46
+ ): Array<{ element: TimelineElement; start: number }> {
47
+ const byKey = new Map(elements.map((e) => [keyOf(e), e]));
48
+ return shifts.flatMap((s) => {
49
+ const element = byKey.get(s.key);
50
+ return element ? [{ element, start: s.newStart }] : [];
51
+ });
52
+ }
53
+
54
+ /** True when every shift names a real clip that may be time-moved. */
39
55
  export function canShiftTrackGapClips(
40
56
  laneElements: readonly TimelineElement[],
41
57
  shifts: readonly TrackGapShift[],
42
58
  ): boolean {
43
- const byKey = new Map(laneElements.map((e) => [keyOf(e), e]));
44
- return shifts.every((s) => {
45
- const element = byKey.get(s.key);
46
- return element != null && getTimelineEditCapabilities(element).canMove;
47
- });
59
+ const resolved = resolveShiftedElements(laneElements, shifts);
60
+ return (
61
+ resolved.length === shifts.length &&
62
+ resolved.every((r) => getTimelineEditCapabilities(r.element).canMove)
63
+ );
48
64
  }
49
65
 
50
66
  function buildShiftEdits(
@@ -52,11 +68,10 @@ function buildShiftEdits(
52
68
  shifts: readonly TrackGapShift[],
53
69
  ): TimelineMoveEdit[] | null {
54
70
  if (shifts.length === 0 || !canShiftTrackGapClips(laneElements, shifts)) return null;
55
- const byKey = new Map(laneElements.map((e) => [keyOf(e), e]));
56
- return shifts.map((s) => {
57
- const element = byKey.get(s.key)!;
58
- return { element, updates: { start: s.newStart, track: element.track } };
59
- });
71
+ return resolveShiftedElements(laneElements, shifts).map(({ element, start }) => ({
72
+ element,
73
+ updates: { start, track: element.track },
74
+ }));
60
75
  }
61
76
 
62
77
  function commitShifts(
@@ -101,3 +116,33 @@ export function commitCloseAllTrackGaps(
101
116
  deps,
102
117
  );
103
118
  }
119
+
120
+ /** What a ripple-edit delete must additionally shift on the main track. Pure
121
+ * — no IO; the caller persists the shifts itself. Null means nothing to
122
+ * ripple: off, nothing deleted was on the main track, already gapless, or a
123
+ * shifting clip is locked (same whole-action refusal as the gap-close menu). */
124
+ export function resolveMainTrackDeleteRippleShifts(
125
+ survivingElements: readonly TimelineElement[],
126
+ deletedElements: readonly TimelineElement[],
127
+ rippleEnabled: boolean,
128
+ ): TrackGapShift[] | null {
129
+ const deletedMainTrack = deletedElements.filter(isMainTrackElement);
130
+ if (!rippleEnabled || deletedMainTrack.length === 0) return null;
131
+ const survivors = survivingElements.filter(isMainTrackElement);
132
+ // Shift each survivor left by the duration of the deleted clips before it —
133
+ // the removed span itself, not a gap re-resolved on the survivors, which
134
+ // can't tell an untouched adjacent gap from the one just opened.
135
+ const shifts: TrackGapShift[] = [];
136
+ for (const survivor of survivors) {
137
+ const width = deletedMainTrack
138
+ .filter((d) => d.start < survivor.start)
139
+ .reduce((sum, d) => sum + d.duration, 0);
140
+ if (width <= 0) continue;
141
+ const newStart = round3(Math.max(laneGapFloor(survivors), survivor.start - width));
142
+ if (Math.abs(newStart - survivor.start) > TRACK_GAP_EPSILON_S) {
143
+ shifts.push({ key: keyOf(survivor), newStart });
144
+ }
145
+ }
146
+ if (shifts.length === 0 || !canShiftTrackGapClips(survivors, shifts)) return null;
147
+ return shifts;
148
+ }
@@ -13,7 +13,7 @@ import type { TimelineElement } from "../store/playerStore";
13
13
  * - Computed starts are rounded to millisecond precision, matching the drag
14
14
  * commit's `round3`.
15
15
  */
16
- const TRACK_GAP_EPSILON_S = 1e-3;
16
+ export const TRACK_GAP_EPSILON_S = 1e-3;
17
17
 
18
18
  const keyOf = (e: TimelineElement) => e.key ?? e.id;
19
19
 
@@ -28,7 +28,7 @@ const keyOf = (e: TimelineElement) => e.key ?? e.id;
28
28
  export function laneGapFloor(elements: readonly TimelineElement[]): number {
29
29
  return Math.max(0, ...elements.map((e) => e.expandedParentStart ?? 0));
30
30
  }
31
- const round3 = (v: number) => Math.round(v * 1000) / 1000;
31
+ export const round3 = (v: number) => Math.round(v * 1000) / 1000;
32
32
  const endOf = (e: TimelineElement) => e.start + e.duration;
33
33
 
34
34
  /** Lane clips sorted by start (key as a deterministic tie-break). */
@@ -217,7 +217,6 @@ describe("track-area breathing pad y-math", () => {
217
217
  scrollTop: 0,
218
218
  contentOrigin: GUTTER,
219
219
  pixelsPerSecond: 100,
220
- duration: 60,
221
220
  rowHeights: baseRows(3),
222
221
  trackOrder: [0, 1, 2],
223
222
  };
@@ -568,8 +568,6 @@ export function resolveTimelineAssetDrop(
568
568
  scrollTop: number;
569
569
  contentOrigin: number;
570
570
  pixelsPerSecond: number;
571
- duration: number;
572
- clampStartToDuration?: boolean;
573
571
  rowHeights?: readonly number[];
574
572
  trackOrder: number[];
575
573
  },
@@ -584,10 +582,7 @@ export function resolveTimelineAssetDrop(
584
582
  });
585
583
  const contentY = clientY - input.rectTop + input.scrollTop;
586
584
  const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
587
- const start = Math.max(
588
- 0,
589
- input.clampStartToDuration === false ? pointerStart : Math.min(input.duration, pointerStart),
590
- );
585
+ const start = Math.max(0, pointerStart);
591
586
  // Row from the shared row→y inverse so the top pad is honoured; a drop in the
592
587
  // pad above the first lane floors to row 0, a drop in the bottom pad rounds
593
588
  // past the last lane (getDefaultDroppedTrack then appends a new track).
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
- import { classifyZone, normalizeToZones } from "./timelineZones";
3
+ import { classifyZone, isMainTrackElement, normalizeToZones } from "./timelineZones";
4
4
 
5
5
  function el(id: string, tag: string, track: number, duration = 2): TimelineElement {
6
6
  return { id, tag, start: 0, duration, track };
@@ -55,6 +55,25 @@ describe("classifyZone", () => {
55
55
  });
56
56
  });
57
57
 
58
+ describe("isMainTrackElement", () => {
59
+ it("is true for a visual clip on display track 0", () => {
60
+ expect(isMainTrackElement(el("v", "video", 0))).toBe(true);
61
+ });
62
+
63
+ it("is false for a visual clip on any other track", () => {
64
+ expect(isMainTrackElement(el("v", "video", 1))).toBe(false);
65
+ });
66
+
67
+ it("is false for an audio clip even on track 0 (audio-only project has no main track)", () => {
68
+ expect(isMainTrackElement(el("m", "audio", 0))).toBe(false);
69
+ });
70
+
71
+ it("is false for an inline-expanded sub-composition child on track 0", () => {
72
+ const child: TimelineElement = { ...el("c", "video", 0), expandedParentStart: 4 };
73
+ expect(isMainTrackElement(child)).toBe(false);
74
+ });
75
+ });
76
+
58
77
  describe("normalizeToZones — CapCut-stable lanes follow the track-index (never z)", () => {
59
78
  it("orders visual lanes by authored track-index (ascending), audio at the bottom", () => {
60
79
  // img (track 0), vid (track 2), mus (audio, track 5). Lanes follow the track
@@ -2,9 +2,9 @@ import type { TimelineElement } from "../store/playerStore";
2
2
  import { isAudioTimelineElement } from "../../utils/timelineInspector";
3
3
 
4
4
  /**
5
- * Free-form vertical zones, top → bottom: visual, audio. There is no "main track"
6
- * — canvas layering is CSS z-index (the renderer ignores track index), so the
7
- * timeline's only job is to keep visual clips grouped above audio clips.
5
+ * Free-form vertical zones, top → bottom: visual, audio. Canvas layering is
6
+ * CSS z-index (the renderer ignores track index), so a track's only structural
7
+ * job is to keep visual clips grouped above audio clips.
8
8
  */
9
9
  export type TrackZone = "visual" | "audio";
10
10
 
@@ -14,6 +14,13 @@ export function classifyZone(el: TimelineElement): TrackZone {
14
14
  return isAudioTimelineElement(el) ? "audio" : "visual";
15
15
  }
16
16
 
17
+ /** The "main track" is a convention, not a schema field: the first
18
+ * visual-zone display lane, matched only when it actually holds a visual
19
+ * clip and isn't an inline-expanded sub-composition child. */
20
+ export function isMainTrackElement(el: TimelineElement): boolean {
21
+ return el.track === 0 && classifyZone(el) === "visual" && el.expandedParentStart == null;
22
+ }
23
+
17
24
  const keyOf = (el: TimelineElement) => el.key ?? el.id;
18
25
 
19
26
  const EPS = 1e-6;
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
15
15
  import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
16
- import { curveForDrag, formatValue, GRAB_PX, POINT_MERGE_SEC } from "./automationLaneGeometry";
16
+ import { curveForDrag, formatValue, GRAB_PX, mergeInsertPoint } from "./automationLaneGeometry";
17
17
  import { pointInSelection } from "./automationLaneSelection";
18
18
  import { capturePointer } from "./automationLanePointer";
19
19
  import { useAutomationEdgeStretch } from "./useAutomationEdgeStretch";
@@ -548,15 +548,7 @@ export function useAutomationLaneGestures({
548
548
  return;
549
549
  }
550
550
  const { t, v } = pointAt(e.clientX, e.clientY);
551
- const kept = lane.points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC);
552
- // A lane's first point alone would be a constant, which is not what
553
- // clicking an empty lane means: seed the far end at the same value so the
554
- // envelope has somewhere to go.
555
- const seeded = lane.points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : [];
556
- commitPoints(
557
- [...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t),
558
- true,
559
- );
551
+ commitPoints(mergeInsertPoint(lane.points, t, v), true);
560
552
  },
561
553
  [lane, pointAt, commitPoints, readOnly, hitIndex, segmentIndex],
562
554
  );
@@ -1,7 +1,7 @@
1
1
  import { useMemo, useRef } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { animationLaneGroups } from "./TimelinePropertyLanes";
4
- import { isAudioTimelineElement } from "../../utils/timelineInspector";
4
+ import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
5
5
  import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneData";
6
6
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
7
  import type { DraggedClipState } from "./timelineClipDragTypes";
@@ -44,14 +44,15 @@ export function trackShowsBeatStrip(
44
44
  }
45
45
 
46
46
  /**
47
- * Automation lanes on one clip, or 0 for anything that is not audio.
47
+ * Automation lanes on one clip.
48
48
  *
49
- * An audio clip can be worth expanding without carrying a single tween, so this
50
- * counts toward whether a track has anything to disclose. A function rather than
51
- * a map so every caller reads the same cached parse and none can drift.
49
+ * A clip can be worth expanding without carrying a single tween, so this counts
50
+ * toward whether a track has anything to disclose. Gated on
51
+ * `isAudioOrVideoTimelineElement`, matching `groupAutomationLanes`. A function
52
+ * rather than a map so every caller reads the same cached parse and none can drift.
52
53
  */
53
54
  function automationLaneCountOf(element: TimelineElement): number {
54
- return isAudioTimelineElement(element) ? elementAutomationLanes(element).length : 0;
55
+ return isAudioOrVideoTimelineElement(element) ? elementAutomationLanes(element).length : 0;
55
56
  }
56
57
 
57
58
  /**
@@ -1,4 +1,5 @@
1
1
  // @vitest-environment happy-dom
2
+ // fallow-ignore-file code-duplication
2
3
 
3
4
  import React, { act, useEffect } from "react";
4
5
  import { createRoot } from "react-dom/client";
@@ -171,6 +172,39 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => {
171
172
 
172
173
  expect(spies.play).toHaveBeenCalledTimes(1);
173
174
  });
175
+
176
+ it("'A' does not seek to the in-point while the razor tool is armed, so it's free for the razor's own return-to-select binding", () => {
177
+ const { dispatch, spies } = setupHook();
178
+ usePlayerStore.setState({ inPoint: 1.5, activeTool: "razor" });
179
+
180
+ act(() => {
181
+ dispatch(keydown({ code: "KeyA", key: "a" }));
182
+ });
183
+
184
+ expect(spies.seek).not.toHaveBeenCalled();
185
+ });
186
+
187
+ it("'A' still seeks to the in-point when the razor tool isn't armed", () => {
188
+ const { dispatch, spies } = setupHook();
189
+ usePlayerStore.setState({ inPoint: 1.5, activeTool: "select" });
190
+
191
+ act(() => {
192
+ dispatch(keydown({ code: "KeyA", key: "a" }));
193
+ });
194
+
195
+ expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true });
196
+ });
197
+
198
+ it("Shift+A still seeks to the in-point while the razor is armed, since only plain A exits the razor", () => {
199
+ const { dispatch, spies } = setupHook();
200
+ usePlayerStore.setState({ inPoint: 1.5, activeTool: "razor" });
201
+
202
+ act(() => {
203
+ dispatch(keydown({ code: "KeyA", key: "a", shiftKey: true }));
204
+ });
205
+
206
+ expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true });
207
+ });
174
208
  });
175
209
 
176
210
  describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {