@hyperframes/studio 0.8.47 → 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 (95) hide show
  1. package/dist/assets/{hyperframes-player-B7fsB8C2.js → hyperframes-player-CtVX6jL_.js} +1 -1
  2. package/dist/assets/{index-ryL7q1QG.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-B50xQfSG.js → index-D_j-0PD3.js} +1 -1
  6. package/dist/index.d.ts +66 -23
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1015 -621
  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/propertyPanelAutomation.test.ts +82 -1
  19. package/src/components/editor/propertyPanelAutomation.ts +15 -0
  20. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
  21. package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
  22. package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
  23. package/src/components/editor/useVolumeAutomation.ts +23 -2
  24. package/src/components/panels/VariablesPanel.tsx +5 -8
  25. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +15 -0
  27. package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
  28. package/src/components/sidebar/LeftSidebar.tsx +7 -0
  29. package/src/contexts/FileManagerContext.tsx +0 -3
  30. package/src/hooks/appHotkeysDispatch.ts +256 -0
  31. package/src/hooks/domEditDeleteMembers.test.ts +30 -0
  32. package/src/hooks/domEditDeleteMembers.ts +19 -0
  33. package/src/hooks/useActiveComposition.ts +69 -0
  34. package/src/hooks/useAppHotkeys.test.ts +23 -1
  35. package/src/hooks/useAppHotkeys.ts +6 -239
  36. package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
  37. package/src/hooks/useAutoOpenRootComposition.ts +33 -0
  38. package/src/hooks/useDomEditCommits.ts +13 -15
  39. package/src/hooks/useDomEditSession.test.tsx +90 -1
  40. package/src/hooks/useDomEditSession.ts +31 -34
  41. package/src/hooks/useDomSelectionTypes.ts +7 -2
  42. package/src/hooks/useElementLifecycleOps.ts +0 -8
  43. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  44. package/src/hooks/useFileManager.ts +0 -2
  45. package/src/hooks/useFileTree.test.tsx +214 -0
  46. package/src/hooks/useFileTree.ts +66 -24
  47. package/src/hooks/useGroupCommits.test.ts +98 -0
  48. package/src/hooks/useGroupCommits.ts +66 -9
  49. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  50. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  51. package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
  52. package/src/hooks/useTimelineAssetDropOps.ts +20 -16
  53. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  54. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  55. package/src/hooks/useTimelineEditing.ts +1 -0
  56. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  57. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  58. package/src/index.ts +10 -0
  59. package/src/player/components/Timeline.test.ts +0 -4
  60. package/src/player/components/Timeline.tsx +0 -1
  61. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  62. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  63. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  64. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  65. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  66. package/src/player/components/automationLaneData.test.ts +11 -3
  67. package/src/player/components/automationLaneData.ts +5 -4
  68. package/src/player/components/automationLaneGeometry.ts +15 -0
  69. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  70. package/src/player/components/timelineDragDrop.ts +9 -25
  71. package/src/player/components/timelineGapCommit.test.ts +96 -0
  72. package/src/player/components/timelineGapCommit.ts +56 -11
  73. package/src/player/components/timelineGaps.ts +2 -2
  74. package/src/player/components/timelineLayout.test.ts +0 -1
  75. package/src/player/components/timelineLayout.ts +1 -6
  76. package/src/player/components/timelineZones.test.ts +20 -1
  77. package/src/player/components/timelineZones.ts +10 -3
  78. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  79. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  80. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  81. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  82. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  83. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  84. package/src/player/store/playerStore.ts +16 -21
  85. package/src/utils/razorSplitTransaction.test.ts +10 -0
  86. package/src/utils/razorSplitTransaction.ts +5 -0
  87. package/src/utils/sdkCutover.test.ts +51 -0
  88. package/src/utils/studioHelpers.test.ts +18 -0
  89. package/src/utils/studioHelpers.ts +41 -5
  90. package/src/utils/studioUiPreferences.test.ts +14 -0
  91. package/src/utils/studioUiPreferences.ts +6 -0
  92. package/src/utils/studioUrlState.ts +15 -7
  93. package/src/utils/timelineInspector.ts +10 -0
  94. package/dist/assets/index-DnRfAiK2.css +0 -1
  95. package/dist/assets/index-j-4hCrZU.js +0 -441
@@ -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)", () => {
@@ -26,6 +26,14 @@ interface UsePlaybackKeyboardParams {
26
26
  seek: (time: number, options?: { keepPlaying?: boolean }) => void;
27
27
  }
28
28
 
29
+ // The razor tool owns plain "a" (return to select) while it's armed; this
30
+ // can't rely on defaultPrevented since both listeners register on window and
31
+ // fire in an order this hook doesn't control. Shift+A and Alt+A are left for
32
+ // the in-point seek below, since only plain A means "exit the razor".
33
+ function shouldRazorClaimPlainA(e: KeyboardEvent): boolean {
34
+ return !e.shiftKey && !e.altKey && usePlayerStore.getState().activeTool === "razor";
35
+ }
36
+
29
37
  export function usePlaybackKeyboard({
30
38
  iframeRef,
31
39
  shuttleDirectionRef,
@@ -79,6 +87,10 @@ export function usePlaybackKeyboard({
79
87
  }
80
88
  }, [play, pause]);
81
89
 
90
+ // Pre-existing dispatcher, already over the complexity gate before this
91
+ // branch's one-line "a" fix touched it. A real fix is a dispatch-table
92
+ // rewrite, out of scope here; the branch this PR changed is its own function.
93
+ // fallow-ignore-next-line complexity
82
94
  const handlePlaybackKeyDown = useCallback(
83
95
  (e: KeyboardEvent) => {
84
96
  if (e.defaultPrevented) return;
@@ -161,6 +173,7 @@ export function usePlaybackKeyboard({
161
173
  return;
162
174
  }
163
175
  if (key === "a") {
176
+ if (shouldRazorClaimPlainA(e)) return;
164
177
  e.preventDefault();
165
178
  seek(usePlayerStore.getState().inPoint ?? 0, { keepPlaying: true });
166
179
  return;
@@ -1,4 +1,5 @@
1
1
  // @vitest-environment jsdom
2
+ // fallow-ignore-file code-duplication
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
  import {
4
5
  applyPreviewAudioFlags,
@@ -54,6 +55,27 @@ describe("buildMissingCompositionElements — hfId (R7)", () => {
54
55
  expect(entry).toBeDefined();
55
56
  expect(entry?.hfId).toBeUndefined();
56
57
  });
58
+
59
+ it("carries the resolved track onto authoredTrack, so splitting a recovered composition host can't drift to a new row", () => {
60
+ const doc = makeDoc(`
61
+ <div data-composition-id="root">
62
+ <div
63
+ data-composition-id="scene-c"
64
+ data-composition-src="scenes/c.html"
65
+ data-track-index="2"
66
+ data-start="0"
67
+ data-duration="5"
68
+ ></div>
69
+ </div>
70
+ `);
71
+
72
+ const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
73
+ const entry = missing[0];
74
+
75
+ expect(entry).toBeDefined();
76
+ expect(entry?.track).toBe(2);
77
+ expect(entry?.authoredTrack).toBe(2);
78
+ });
57
79
  });
58
80
 
59
81
  describe("setPreviewMediaVolume", () => {
@@ -452,6 +452,7 @@ function buildMissingCompositionEntry(params: {
452
452
  start: window.start,
453
453
  duration: window.duration,
454
454
  track: timing.trackIndex,
455
+ authoredTrack: timing.trackIndex,
455
456
  domId: optionalNonEmpty(element.id),
456
457
  hfId: optionalNonEmpty(element.getAttribute("data-hf-id")),
457
458
  selector,