@hyperframes/studio 0.8.47 → 0.8.49

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 (123) hide show
  1. package/dist/assets/hyperframes-player-CqON2Rzo.js +464 -0
  2. package/dist/assets/index-B9LfWNyM.css +1 -0
  3. package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
  4. package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
  5. package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
  6. package/dist/index.d.ts +70 -26
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1452 -918
  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/useBlockHandlers.ts +7 -2
  39. package/src/hooks/useDomEditCommits.ts +13 -15
  40. package/src/hooks/useDomEditSession.test.tsx +90 -1
  41. package/src/hooks/useDomEditSession.ts +31 -34
  42. package/src/hooks/useDomSelectionTypes.ts +7 -2
  43. package/src/hooks/useElementLifecycleOps.ts +0 -8
  44. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  45. package/src/hooks/useFileManager.ts +0 -2
  46. package/src/hooks/useFileTree.test.tsx +214 -0
  47. package/src/hooks/useFileTree.ts +66 -24
  48. package/src/hooks/useGroupCommits.test.ts +98 -0
  49. package/src/hooks/useGroupCommits.ts +66 -9
  50. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  51. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  52. package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
  53. package/src/hooks/useTimelineAssetDropOps.ts +24 -18
  54. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  55. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  56. package/src/hooks/useTimelineEditing.ts +1 -0
  57. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  58. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  59. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  60. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  61. package/src/index.ts +10 -0
  62. package/src/player/components/Timeline.test.ts +0 -4
  63. package/src/player/components/Timeline.tsx +0 -1
  64. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  65. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  66. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  67. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  68. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  69. package/src/player/components/automationLaneData.test.ts +11 -3
  70. package/src/player/components/automationLaneData.ts +5 -4
  71. package/src/player/components/automationLaneGeometry.ts +15 -0
  72. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  73. package/src/player/components/timelineClipDragCommit.ts +18 -49
  74. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  75. package/src/player/components/timelineClipDragPreview.ts +8 -1
  76. package/src/player/components/timelineCollision.test.ts +23 -0
  77. package/src/player/components/timelineCollision.ts +27 -0
  78. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  79. package/src/player/components/timelineDragDrop.ts +9 -25
  80. package/src/player/components/timelineDragLanding.ts +108 -0
  81. package/src/player/components/timelineDropReveal.test.ts +34 -0
  82. package/src/player/components/timelineDropReveal.ts +12 -0
  83. package/src/player/components/timelineGapCommit.test.ts +96 -0
  84. package/src/player/components/timelineGapCommit.ts +56 -11
  85. package/src/player/components/timelineGaps.ts +2 -2
  86. package/src/player/components/timelineLayout.test.ts +0 -1
  87. package/src/player/components/timelineLayout.ts +1 -6
  88. package/src/player/components/timelineZones.test.ts +20 -1
  89. package/src/player/components/timelineZones.ts +10 -3
  90. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  91. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  92. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  93. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  94. package/src/player/components/useTimelineGeometry.ts +7 -2
  95. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  96. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  98. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  99. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  100. package/src/player/lib/timelineElementHelpers.ts +8 -0
  101. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  103. package/src/player/store/playerStore.ts +16 -21
  104. package/src/player/store/readinessSlice.ts +4 -3
  105. package/src/utils/blockInstaller.test.ts +4 -0
  106. package/src/utils/blockInstaller.ts +9 -3
  107. package/src/utils/razorSplitTransaction.test.ts +10 -0
  108. package/src/utils/razorSplitTransaction.ts +5 -0
  109. package/src/utils/retryBudget.ts +20 -0
  110. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  111. package/src/utils/sdkCutover.test.ts +51 -0
  112. package/src/utils/sdkCutover.ts +7 -4
  113. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  114. package/src/utils/sdkCutoverEligibility.ts +34 -11
  115. package/src/utils/studioHelpers.test.ts +18 -0
  116. package/src/utils/studioHelpers.ts +41 -5
  117. package/src/utils/studioUiPreferences.test.ts +14 -0
  118. package/src/utils/studioUiPreferences.ts +6 -0
  119. package/src/utils/studioUrlState.ts +15 -7
  120. package/src/utils/timelineCompositionInsert.ts +2 -1
  121. package/src/utils/timelineInspector.ts +10 -0
  122. package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
  123. package/dist/assets/index-DnRfAiK2.css +0 -1
@@ -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
  );
@@ -35,12 +35,16 @@ function Harness({
35
35
  mountedId,
36
36
  logicalRows = rows,
37
37
  projectId = "project-a",
38
+ lastScrollLeftRef: sharedLastScrollLeftRef,
38
39
  }: {
39
40
  mountedId?: string;
40
41
  logicalRows?: readonly TimelineLogicalRow[];
41
42
  projectId?: string;
43
+ lastScrollLeftRef?: React.RefObject<number>;
42
44
  }) {
43
45
  const scrollRef = useRef<HTMLDivElement>(null);
46
+ const ownLastScrollLeftRef = useRef(0);
47
+ const lastScrollLeftRef = sharedLastScrollLeftRef ?? ownLastScrollLeftRef;
44
48
  const rowGeometry = useMemo(() => {
45
49
  const rowKeys = [...new Set(logicalRows.map((row) => row.physicalTrackKey))];
46
50
  return createTimelineRowGeometry(
@@ -60,6 +64,7 @@ function Harness({
60
64
  projectId,
61
65
  sessionEpoch: 1,
62
66
  syncScrollViewport,
67
+ lastScrollLeftRef,
63
68
  });
64
69
  return (
65
70
  <div
@@ -171,4 +176,65 @@ describe("useTimelineFocusCoordinator", () => {
171
176
  expect(usePlayerStore.getState().timelineFocus?.id).toBe(nextRowId);
172
177
  expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(nextRowId);
173
178
  });
179
+
180
+ it("keeps a request alive across a few renders where its target hasn't landed yet", async () => {
181
+ // A request issued the same tick as its own element's creation (a fresh
182
+ // drop) can race logicalRows by a render or two; it must not be dropped
183
+ // before the element actually appears.
184
+ usePlayerStore.getState().requestTimelineFocus(clipId);
185
+ for (let attempt = 0; attempt < 2; attempt++) {
186
+ const notYetRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
187
+ await act(async () => root.render(<Harness logicalRows={notYetRows} />));
188
+ }
189
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
190
+
191
+ await act(async () => root.render(<Harness logicalRows={rows} mountedId={clipId} />));
192
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
193
+ expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(clipId);
194
+ });
195
+
196
+ it("gives up once a target never lands after its retry budget", async () => {
197
+ usePlayerStore.getState().requestTimelineFocus(clipId);
198
+ for (let attempt = 0; attempt < 6; attempt++) {
199
+ // A fresh array reference each render, like a real re-render caused by
200
+ // unrelated state, or the memoized resolution never recomputes at all.
201
+ const neverRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
202
+ await act(async () => root.render(<Harness logicalRows={neverRows} />));
203
+ }
204
+ expect(usePlayerStore.getState().timelineFocus).toBeNull();
205
+ });
206
+
207
+ it("clears an unresolved request on a timeout even if nothing else ever re-renders", async () => {
208
+ // The render-count retry budget can't advance without a rerun; if the
209
+ // target never lands and nothing else re-renders, only a wall-time
210
+ // backstop clears it — otherwise it sits forever, eligible to resolve
211
+ // against an unrelated element that later reuses its id.
212
+ vi.useFakeTimers();
213
+ try {
214
+ usePlayerStore.getState().requestTimelineFocus(clipId);
215
+ const neverRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
216
+ await act(async () => root.render(<Harness logicalRows={neverRows} />));
217
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
218
+
219
+ await act(async () => {
220
+ vi.advanceTimersByTime(4001);
221
+ });
222
+
223
+ expect(usePlayerStore.getState().timelineFocus).toBeNull();
224
+ } finally {
225
+ vi.useRealTimers();
226
+ }
227
+ });
228
+
229
+ it("keeps lastScrollLeftRef in sync with a reveal so a sibling restore effect doesn't undo it", async () => {
230
+ // Timeline.tsx's own "restore scroll after an edit re-derives the elements"
231
+ // effect reads this ref to decide what to scroll back to. A reveal that
232
+ // doesn't update it looks, to that effect, like scroll drift to undo.
233
+ const lastScrollLeftRef = { current: 999 };
234
+ usePlayerStore.getState().requestTimelineFocus(clipId);
235
+ await act(async () => root.render(<Harness lastScrollLeftRef={lastScrollLeftRef} />));
236
+ const scroll = host.firstElementChild as HTMLDivElement;
237
+
238
+ expect(lastScrollLeftRef.current).toBe(scroll.scrollLeft);
239
+ });
174
240
  });
@@ -11,6 +11,7 @@ import {
11
11
  type TimelineLogicalTarget,
12
12
  } from "./timelineKeyboardNavigation";
13
13
  import { computeRevealScroll } from "./timelineRevealScroll";
14
+ import { recordRetryAttempt, type RetryBudgetState } from "../../utils/retryBudget";
14
15
 
15
16
  interface TimelineFocusCoordinatorInput {
16
17
  scrollRef: RefObject<HTMLDivElement | null>;
@@ -24,6 +25,7 @@ interface TimelineFocusCoordinatorInput {
24
25
  projectId: string | null;
25
26
  sessionEpoch: number;
26
27
  syncScrollViewport: (element: HTMLDivElement) => void;
28
+ lastScrollLeftRef: RefObject<number>;
27
29
  }
28
30
 
29
31
  export interface TimelineFocusCoordinatorState {
@@ -77,6 +79,7 @@ function scrollToTarget(
77
79
  pixelsPerSecond: number,
78
80
  contentOrigin: number,
79
81
  allowHorizontal: boolean,
82
+ lastScrollLeftRef: RefObject<number>,
80
83
  ): boolean {
81
84
  const rowIndex = rowGeometry.getRowIndex(resolution.row.physicalTrackKey);
82
85
  if (rowIndex < 0) return false;
@@ -103,11 +106,26 @@ function scrollToTarget(
103
106
  stickyTop: RULER_H,
104
107
  allowHorizontal: allowHorizontal && left !== null,
105
108
  });
106
- if (target.left !== null) container.scrollLeft = target.left;
109
+ if (target.left !== null) {
110
+ container.scrollLeft = target.left;
111
+ // Keep the geometry effect's "restore scroll after an edit re-derives the
112
+ // elements" in sync, or it reads the pre-reveal position on the next tick
113
+ // and scrolls the freshly-revealed clip back out of view.
114
+ lastScrollLeftRef.current = target.left;
115
+ }
107
116
  if (target.top !== null) container.scrollTop = target.top;
108
117
  return target.left !== null || target.top !== null;
109
118
  }
110
119
 
120
+ // A request racing a fresh element's own creation (drop/paste) resolves within
121
+ // a render or two; past this many misses on the same nonce it truly never will.
122
+ const MAX_UNRESOLVED_FOCUS_RETRIES = 5;
123
+
124
+ // Backstop for a request whose target never appears and nothing else ever
125
+ // re-renders: the retry budget above can't advance without a rerun, so this
126
+ // bounds it by wall time instead, independent of re-renders.
127
+ const UNRESOLVED_FOCUS_TIMEOUT_MS = 4000;
128
+
111
129
  /** Model-first focus actor; mounting is a consequence of its returned pins. */
112
130
  // Resolution, fallback, reveal, and focus form one ordered state machine.
113
131
  // fallow-ignore-next-line complexity
@@ -123,11 +141,13 @@ export function useTimelineFocusCoordinator({
123
141
  projectId,
124
142
  sessionEpoch,
125
143
  syncScrollViewport,
144
+ lastScrollLeftRef,
126
145
  }: TimelineFocusCoordinatorInput): TimelineFocusCoordinatorState {
127
146
  const request = usePlayerStore((state) => state.timelineFocus);
128
147
  const previousRowsRef = useRef(logicalRows);
129
148
  const resolvedRef = useRef<{ nonce: number; id: string } | null>(null);
130
149
  const appliedRef = useRef<{ nonce: number; id: string } | null>(null);
150
+ const unresolvedFocusAttemptsRef = useRef<RetryBudgetState<number>>({ id: -1, count: 0 });
131
151
  const resolution = useMemo<ResolvedFocus | null>(() => {
132
152
  if (isCurrentRequest(request, projectId, sessionEpoch)) {
133
153
  if (resolvedRef.current?.nonce !== request.nonce) {
@@ -163,8 +183,26 @@ export function useTimelineFocusCoordinator({
163
183
  useEffect(() => {
164
184
  if (!isCurrentRequest(request, projectId, sessionEpoch)) return;
165
185
  if (!resolution) {
166
- usePlayerStore.getState().clearTimelineFocus(request.nonce);
167
- return;
186
+ // A request issued the same tick as its element (a fresh drop/paste) can
187
+ // outrun logicalRows by a render or two; only give up once it clearly
188
+ // never resolves, so the reveal isn't lost to that ordinary race.
189
+ const withinBudget = recordRetryAttempt(
190
+ unresolvedFocusAttemptsRef,
191
+ request.nonce,
192
+ MAX_UNRESOLVED_FOCUS_RETRIES,
193
+ );
194
+ if (!withinBudget) {
195
+ usePlayerStore.getState().clearTimelineFocus(request.nonce);
196
+ return;
197
+ }
198
+ const nonce = request.nonce;
199
+ const timeout = setTimeout(() => {
200
+ usePlayerStore.getState().clearTimelineFocus(nonce);
201
+ }, UNRESOLVED_FOCUS_TIMEOUT_MS);
202
+ return () => clearTimeout(timeout);
203
+ }
204
+ if (unresolvedFocusAttemptsRef.current.count !== 0) {
205
+ unresolvedFocusAttemptsRef.current = { id: -1, count: 0 };
168
206
  }
169
207
  if (resolution.target.id !== request.id) {
170
208
  usePlayerStore.getState().requestTimelineFocus(resolution.target.id);
@@ -187,6 +225,7 @@ export function useTimelineFocusCoordinator({
187
225
  pixelsPerSecond,
188
226
  contentOrigin,
189
227
  allowHorizontal,
228
+ lastScrollLeftRef,
190
229
  )
191
230
  ) {
192
231
  syncScrollViewport(container);
@@ -197,6 +236,7 @@ export function useTimelineFocusCoordinator({
197
236
  allowHorizontal,
198
237
  contentOrigin,
199
238
  elements,
239
+ lastScrollLeftRef,
200
240
  pixelsPerSecond,
201
241
  projectId,
202
242
  request,
@@ -0,0 +1,117 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useRef } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
+ import { useTimelineGeometry } from "./useTimelineGeometry";
8
+
9
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10
+
11
+ const element: TimelineElement = { id: "hero", tag: "div", start: 20, duration: 2, track: 1 };
12
+
13
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
14
+
15
+ function Harness({
16
+ expandedElements,
17
+ lastScrollLeftRef,
18
+ scrollWidth = 20_000,
19
+ }: {
20
+ expandedElements: TimelineElement[];
21
+ lastScrollLeftRef: React.RefObject<number>;
22
+ scrollWidth?: number;
23
+ }) {
24
+ const scrollRef = useRef<HTMLDivElement>(null);
25
+ const isDragging = useRef(false);
26
+ const ppsRef = useRef(0);
27
+ const fitPpsRef = useRef(0);
28
+ useTimelineGeometry({
29
+ viewportWidth: 1600,
30
+ effectiveDuration: 60,
31
+ zoomMode: "manual",
32
+ manualZoomPercent: 100,
33
+ ppsRef,
34
+ fitPpsRef,
35
+ draggedClip: null,
36
+ resizingClip: null,
37
+ expandedElements,
38
+ isDragging,
39
+ scrollRef,
40
+ lastScrollLeftRef,
41
+ contentOrigin: 32,
42
+ });
43
+ return (
44
+ <div
45
+ ref={(node) => {
46
+ scrollRef.current = node;
47
+ if (node) {
48
+ Object.defineProperty(node, "clientWidth", { configurable: true, value: 1600 });
49
+ Object.defineProperty(node, "scrollWidth", { configurable: true, value: scrollWidth });
50
+ }
51
+ }}
52
+ />
53
+ );
54
+ }
55
+
56
+ let host: HTMLDivElement;
57
+ let root: Root;
58
+ beforeEach(() => {
59
+ usePlayerStore.setState({ timelineProjectId: "project-a" });
60
+ host = document.createElement("div");
61
+ document.body.append(host);
62
+ root = createRoot(host);
63
+ });
64
+ afterEach(() => {
65
+ act(() => root.unmount());
66
+ usePlayerStore.getState().reset();
67
+ document.body.replaceChildren();
68
+ });
69
+
70
+ describe("useTimelineGeometry restore-scroll effect", () => {
71
+ it("reads lastScrollLeftRef at frame time, not effect time, so a same-commit reveal isn't undone", async () => {
72
+ // A sibling effect in the same commit (a fresh timeline-focus reveal) can
73
+ // update lastScrollLeftRef and the DOM scrollLeft together after this
74
+ // effect has already scheduled its frame. The frame must see that update,
75
+ // not the value that was current when it was scheduled.
76
+ const lastScrollLeftRef = { current: 132 };
77
+ act(() => {
78
+ root.render(<Harness expandedElements={[]} lastScrollLeftRef={lastScrollLeftRef} />);
79
+ });
80
+ const scroll = host.firstElementChild as HTMLDivElement;
81
+ scroll.scrollLeft = 132;
82
+
83
+ // Flush this effect (it schedules a frame, capturing whatever it reads
84
+ // right now) BEFORE the sibling mutates the ref, exactly as in a real
85
+ // commit where this effect registers and runs ahead of the reveal's.
86
+ act(() => {
87
+ root.render(<Harness expandedElements={[element]} lastScrollLeftRef={lastScrollLeftRef} />);
88
+ });
89
+ // Simulate the sibling reveal effect: it runs after this one in the same
90
+ // commit and updates both the DOM and the ref before the frame fires.
91
+ lastScrollLeftRef.current = 450;
92
+ scroll.scrollLeft = 450;
93
+ await act(async () => {
94
+ await nextFrame();
95
+ });
96
+
97
+ expect(scroll.scrollLeft).toBe(450);
98
+ });
99
+
100
+ it("still restores the pre-edit position when nothing else changed the ref mid-frame", async () => {
101
+ const lastScrollLeftRef = { current: 300 };
102
+ act(() => {
103
+ root.render(<Harness expandedElements={[]} lastScrollLeftRef={lastScrollLeftRef} />);
104
+ });
105
+ const scroll = host.firstElementChild as HTMLDivElement;
106
+ scroll.scrollLeft = 0;
107
+
108
+ act(() => {
109
+ root.render(<Harness expandedElements={[element]} lastScrollLeftRef={lastScrollLeftRef} />);
110
+ });
111
+ await act(async () => {
112
+ await nextFrame();
113
+ });
114
+
115
+ expect(scroll.scrollLeft).toBe(300);
116
+ });
117
+ });