@hyperframes/studio 0.7.75 → 0.7.77

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 (134) hide show
  1. package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-DzDajONI.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. package/src/utils/keyframeSelection.ts +0 -29
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The diamond row's data + prop contract, split out of TimelineClipDiamonds so
3
+ * that file stays under the 600-line studio gate. Types and the shared
4
+ * keyframe-identity helper live here; the rendering lives there.
5
+ */
6
+ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
7
+
8
+ export interface TimelineDiamondKeyframe {
9
+ percentage: number;
10
+ /** Tween-relative percentage (the retime mutation keys on this, not clip %). */
11
+ tweenPercentage?: number;
12
+ propertyGroup?: string;
13
+ animationId?: string;
14
+ properties: Record<string, number | string>;
15
+ ease?: string;
16
+ /** Set when 2+ source animations collide at this percentage (a single inline
17
+ * ease button can't target one): the collapsed row hides the button here. */
18
+ easeAmbiguous?: boolean;
19
+ }
20
+
21
+ interface KeyframeCacheEntry {
22
+ format: string;
23
+ keyframes: TimelineDiamondKeyframe[];
24
+ ease?: string;
25
+ easeEach?: string;
26
+ }
27
+
28
+ export interface TimelineClipDiamondsProps {
29
+ keyframesData: KeyframeCacheEntry;
30
+ clipWidthPx: number;
31
+ clipHeightPx: number;
32
+ /** Beat-dot strip is shown on this track → shrink diamonds + drop them into
33
+ * the bottom half so they clear the strip at the top. */
34
+ beatsActive?: boolean;
35
+ accentColor: string;
36
+ isSelected: boolean;
37
+ currentPercentage: number;
38
+ elementId: string;
39
+ selectedKeyframes: ReadonlySet<string>;
40
+ onClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
41
+ onShiftClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
42
+ onContextMenuKeyframe?: (
43
+ e: React.MouseEvent,
44
+ elementId: string,
45
+ keyframe: TimelineKeyframeTarget,
46
+ ) => void;
47
+ /** Drag-to-retime: move a keyframe to a new time, preserving its value + ease.
48
+ * `keyframe` identifies the dragged keyframe (clip-relative percentage plus
49
+ * whatever animation identity the row carries); `toClipPercentage` is the
50
+ * neighbour-clamped drop position, also clip-relative. The handler decides
51
+ * move (within the tween) vs resize (past its boundary). */
52
+ onMoveKeyframe?: (
53
+ elementId: string,
54
+ keyframe: TimelineKeyframeTarget,
55
+ toClipPercentage: number,
56
+ ) => Promise<boolean>;
57
+ /** Open the segment ease editor for the hovered mid-point button — available on
58
+ * the inline clip row too, not just the expanded lanes. */
59
+ onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
60
+ /** Set while resolving a diamond press so the ancestor clip's onClick (which
61
+ * toggles selection off when already selected) ignores the native "click"
62
+ * the browser auto-synthesizes after this button's pointerdown+pointerup. */
63
+ suppressClickRef?: React.RefObject<boolean>;
64
+ }
65
+
66
+ export interface TimelineDiamondLaneProps extends Omit<
67
+ TimelineClipDiamondsProps,
68
+ | "onClickKeyframe"
69
+ | "onShiftClickKeyframe"
70
+ | "onContextMenuKeyframe"
71
+ | "onMoveKeyframe"
72
+ | "onSelectSegment"
73
+ > {
74
+ groupAware?: boolean;
75
+ globalEase?: string;
76
+ onSelectSegment?: (target: TimelineKeyframeTarget) => void;
77
+ onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
78
+ onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
79
+ onContextMenuKeyframe?: (e: React.MouseEvent, target: TimelineKeyframeTarget) => void;
80
+ onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
81
+ }
82
+
83
+ export const DIAMOND_RATIO = 0.8;
84
+ // Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
85
+ // slightly outside [0, 100] (from rounding or stale cache during the async
86
+ // persist → reload cycle) are still rendered (the clip is overflow-visible) at
87
+ // their true position rather than hidden.
88
+ export const KF_MIN_PCT = -5;
89
+ export const KF_MAX_PCT = 105;
90
+
91
+ export type DragState = {
92
+ kfKey: string;
93
+ startX: number;
94
+ fromClipPct: number;
95
+ moved: boolean;
96
+ /** Latest pointer x, flushed to the preview once per frame. */
97
+ lastX: number;
98
+ /** Index in the sorted row, needed by the neighbour clamp off the render path. */
99
+ index: number;
100
+ /** Escape was pressed: the drag is dead, and the pointerup that follows is
101
+ * swallowed rather than falling through to the click branch. */
102
+ cancelled?: boolean;
103
+ };
104
+
105
+ /**
106
+ * The full identity of a diamond, used by every callback and by the selection
107
+ * key. Collapsed clip rows and expanded property lanes read the same cache, so
108
+ * they must hash a shared keyframe to the same key: dropping the group here for
109
+ * the collapsed row would leave a diamond selected in one view and unselected in
110
+ * the other, and would strip the animation id the retime/delete mutations use to
111
+ * pick between two animations that collide at one percentage.
112
+ */
113
+ export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyframeTarget {
114
+ return {
115
+ percentage: keyframe.percentage,
116
+ tweenPercentage: keyframe.tweenPercentage,
117
+ propertyGroup: keyframe.propertyGroup,
118
+ animationId: keyframe.animationId,
119
+ };
120
+ }
@@ -5,7 +5,7 @@ import {
5
5
  TIMELINE_COMPOSITION_MIME,
6
6
  } from "../../utils/timelineCompositionDrop";
7
7
  import { usePlayerStore } from "../store/playerStore";
8
- import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
8
+ import { resolveTimelineAssetDrop } from "./timelineLayout";
9
9
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
10
10
 
11
11
  interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
@@ -13,6 +13,8 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
13
13
  ppsRef: RefObject<number>;
14
14
  durationRef: RefObject<number>;
15
15
  trackOrderRef: RefObject<number[]>;
16
+ rowHeightsRef: RefObject<readonly number[]>;
17
+ contentOrigin: number;
16
18
  }
17
19
 
18
20
  type TimelinePlacement = { start: number; track: number };
@@ -54,6 +56,8 @@ export function useTimelineAssetDrop({
54
56
  ppsRef,
55
57
  durationRef,
56
58
  trackOrderRef,
59
+ rowHeightsRef,
60
+ contentOrigin,
57
61
  onFileDrop,
58
62
  onAssetDrop,
59
63
  onBlockDrop,
@@ -85,10 +89,11 @@ export function useTimelineAssetDrop({
85
89
  rectTop: rect?.top ?? 0,
86
90
  scrollLeft: scroll?.scrollLeft ?? 0,
87
91
  scrollTop: scroll?.scrollTop ?? 0,
92
+ contentOrigin,
88
93
  pixelsPerSecond: ppsRef.current,
89
94
  duration: durationRef.current,
90
95
  clampStartToDuration: !usePointerStart,
91
- trackHeight: TRACK_H,
96
+ rowHeights: rowHeightsRef.current,
92
97
  trackOrder: trackOrderRef.current,
93
98
  },
94
99
  clientX,
@@ -99,7 +104,7 @@ export function useTimelineAssetDrop({
99
104
  track: pointer.track,
100
105
  };
101
106
  },
102
- [scrollRef, ppsRef, durationRef, trackOrderRef],
107
+ [scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, contentOrigin],
103
108
  );
104
109
 
105
110
  const handleAssetDrop = useCallback(
@@ -27,14 +27,13 @@ describe("resolveTimelineMove", () => {
27
27
  track: 2,
28
28
  duration: 2,
29
29
  originClientX: 100,
30
- originClientY: 200,
30
+ originRow: 0,
31
31
  pixelsPerSecond: 100,
32
- trackHeight: 72,
33
32
  maxStart: 8,
34
33
  trackOrder: [0, 1, 2, 3, 4],
35
34
  },
36
35
  245,
37
- 200,
36
+ 0,
38
37
  ),
39
38
  ).toEqual({ start: 2.7, track: 2 });
40
39
  });
@@ -47,14 +46,13 @@ describe("resolveTimelineMove", () => {
47
46
  track: 1,
48
47
  duration: 3,
49
48
  originClientX: 200,
50
- originClientY: 200,
49
+ originRow: 0,
51
50
  pixelsPerSecond: 100,
52
- trackHeight: 72,
53
51
  maxStart: 10,
54
52
  trackOrder: [0, 1, 5, 9],
55
53
  },
56
54
  150,
57
- 390,
55
+ 190 / 72,
58
56
  ),
59
57
  ).toEqual({ start: 1.5, track: 9 });
60
58
  });
@@ -67,14 +65,13 @@ describe("resolveTimelineMove", () => {
67
65
  track: 0,
68
66
  duration: 4,
69
67
  originClientX: 300,
70
- originClientY: 200,
68
+ originRow: 0,
71
69
  pixelsPerSecond: 100,
72
- trackHeight: 72,
73
70
  maxStart: 6,
74
71
  trackOrder: [0, 10, 20],
75
72
  },
76
73
  -100,
77
- -200,
74
+ -400 / 72,
78
75
  ),
79
76
  ).toEqual({ start: 0, track: -1 });
80
77
 
@@ -85,14 +82,13 @@ describe("resolveTimelineMove", () => {
85
82
  track: 10,
86
83
  duration: 4,
87
84
  originClientX: 300,
88
- originClientY: 200,
85
+ originRow: 0,
89
86
  pixelsPerSecond: 100,
90
- trackHeight: 72,
91
87
  maxStart: 6,
92
88
  trackOrder: [0, 10, 20],
93
89
  },
94
90
  500,
95
- 200,
91
+ 0,
96
92
  ),
97
93
  ).toEqual({ start: 6, track: 10 });
98
94
  });
@@ -105,14 +101,13 @@ describe("resolveTimelineMove", () => {
105
101
  track: 0,
106
102
  duration: 2,
107
103
  originClientX: 100,
108
- originClientY: 200,
104
+ originRow: 0,
109
105
  pixelsPerSecond: 100,
110
- trackHeight: 72,
111
106
  maxStart: 8,
112
107
  trackOrder: [0, 10, 20],
113
108
  },
114
109
  100,
115
- 150,
110
+ -50 / 72,
116
111
  ),
117
112
  ).toEqual({ start: 1, track: -1 });
118
113
  });
@@ -125,19 +120,18 @@ describe("resolveTimelineMove", () => {
125
120
  track: 20,
126
121
  duration: 2,
127
122
  originClientX: 100,
128
- originClientY: 200,
123
+ originRow: 0,
129
124
  pixelsPerSecond: 100,
130
- trackHeight: 72,
131
125
  maxStart: 8,
132
126
  trackOrder: [0, 10, 20],
133
127
  },
134
128
  100,
135
- 250,
129
+ 50 / 72,
136
130
  ),
137
131
  ).toEqual({ start: 1, track: 21 });
138
132
  });
139
133
 
140
- it("accounts for scroll displacement while dragging", () => {
134
+ it("accounts for horizontal scroll displacement while dragging", () => {
141
135
  expect(
142
136
  resolveTimelineMove(
143
137
  {
@@ -145,18 +139,17 @@ describe("resolveTimelineMove", () => {
145
139
  track: 0,
146
140
  duration: 2,
147
141
  originClientX: 100,
148
- originClientY: 200,
142
+ originRow: 0,
149
143
  originScrollLeft: 0,
150
- originScrollTop: 0,
151
144
  currentScrollLeft: 100,
152
- currentScrollTop: 144,
153
145
  pixelsPerSecond: 100,
154
- trackHeight: 72,
155
146
  maxStart: 8,
147
+ // Vertical scroll never reaches here: the drag preview folds it into the
148
+ // row index it passes, because rows no longer share one pixel height.
156
149
  trackOrder: [0, 1, 2, 3],
157
150
  },
158
151
  100,
159
- 200,
152
+ 2,
160
153
  ),
161
154
  ).toEqual({ start: 2, track: 2 });
162
155
  });
@@ -195,9 +188,8 @@ describe("resolveTimelineMove", () => {
195
188
  track: 1,
196
189
  duration: 2,
197
190
  originClientX: 0,
198
- originClientY: 0,
191
+ originRow: 0,
199
192
  pixelsPerSecond: 100,
200
- trackHeight: 72,
201
193
  maxStart: 8,
202
194
  trackOrder: [0, 1],
203
195
  layerOrder: layers.map((layer) => layer.id),
@@ -206,7 +198,7 @@ describe("resolveTimelineMove", () => {
206
198
  stackingElements,
207
199
  },
208
200
  0,
209
- -72,
201
+ -1,
210
202
  );
211
203
 
212
204
  expect(result).toEqual({
@@ -46,13 +46,11 @@ export interface TimelineMoveInput {
46
46
  track: number;
47
47
  duration: number;
48
48
  originClientX: number;
49
- originClientY: number;
49
+ /** Vertical position as a track-row index, not pixels: rows vary in height. */
50
+ originRow: number;
50
51
  originScrollLeft?: number;
51
- originScrollTop?: number;
52
52
  currentScrollLeft?: number;
53
- currentScrollTop?: number;
54
53
  pixelsPerSecond: number;
55
- trackHeight: number;
56
54
  maxStart: number;
57
55
  trackOrder: number[];
58
56
  layerOrder?: TimelineLayerId[];
@@ -108,7 +106,7 @@ export function resolveTimelineAutoScroll(
108
106
  export function resolveTimelineMove(
109
107
  input: TimelineMoveInput,
110
108
  clientX: number,
111
- clientY: number,
109
+ currentRow: number,
112
110
  ): {
113
111
  start: number;
114
112
  track: number;
@@ -117,11 +115,12 @@ export function resolveTimelineMove(
117
115
  stackingReorder?: TimelineStackingReorderIntent | null;
118
116
  } {
119
117
  const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0);
120
- const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
121
118
  const deltaTime =
122
119
  (clientX - input.originClientX + scrollDeltaX) / Math.max(input.pixelsPerSecond, 1);
123
- const trackDeltaRaw =
124
- (clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1);
120
+ // Rows, so vertical scroll and per-row heights are the caller's problem: rows
121
+ // have varied in height since lanes expand, and a single trackHeight can't
122
+ // describe them.
123
+ const trackDeltaRaw = currentRow - input.originRow;
125
124
  const deltaTrack = Math.round(trackDeltaRaw);
126
125
  const nextStart = clamp(
127
126
  roundToCentiseconds(input.start + deltaTime),
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ timelineKeyframeSelectionKey,
4
+ timelineKeyframeTargetFromSelectionKey,
5
+ } from "./timelineKeyframeIdentity";
6
+
7
+ describe("timeline keyframe selection identity", () => {
8
+ it("round-trips an expanded lane with colon-bearing identities", () => {
9
+ const key = timelineKeyframeSelectionKey("comp#a:child", {
10
+ percentage: 75,
11
+ tweenPercentage: 40,
12
+ propertyGroup: "position",
13
+ animationId: "child:position",
14
+ });
15
+
16
+ expect(timelineKeyframeTargetFromSelectionKey("comp#a:child", key)).toEqual({
17
+ percentage: 75,
18
+ tweenPercentage: 40,
19
+ propertyGroup: "position",
20
+ animationId: "child:position",
21
+ });
22
+ });
23
+
24
+ it("does not confuse an expanded lane whose element id extends the active id", () => {
25
+ const key = timelineKeyframeSelectionKey("comp#a:child", {
26
+ percentage: 75,
27
+ tweenPercentage: 40,
28
+ propertyGroup: "position",
29
+ animationId: "child-position",
30
+ });
31
+
32
+ expect(timelineKeyframeTargetFromSelectionKey("comp#a", key)).toBeNull();
33
+ });
34
+
35
+ // Timeline.tsx still writes the collapsed form for clip-lane shift-clicks, and
36
+ // an element id can itself contain a colon — the split has to be the LAST one.
37
+ it("splits the collapsed key at the last colon so a colon-bearing id survives", () => {
38
+ expect(timelineKeyframeTargetFromSelectionKey("a:b", "a:b:40")).toEqual({ percentage: 40 });
39
+ expect(timelineKeyframeTargetFromSelectionKey("a", "a:b:40")).toBeNull();
40
+ });
41
+
42
+ it("retains the collapsed key fallback and rejects malformed percentages", () => {
43
+ expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#a:30")).toEqual({
44
+ percentage: 30,
45
+ });
46
+ expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#a:NaN")).toBeNull();
47
+ expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#b:30")).toBeNull();
48
+ });
49
+ });
@@ -0,0 +1,66 @@
1
+ export interface TimelineKeyframeTarget {
2
+ percentage: number;
3
+ tweenPercentage?: number;
4
+ propertyGroup?: string;
5
+ animationId?: string;
6
+ }
7
+
8
+ /**
9
+ * Note the asymmetry: `tweenPercentage` is optional here and defaults to
10
+ * `percentage`, but the reader treats both slots as authoritative. Callers must
11
+ * therefore keep the pair consistent — writing a new `tweenPercentage` without
12
+ * the matching `percentage` hashes two logically-equal selections to different
13
+ * keys.
14
+ */
15
+ export function timelineKeyframeSelectionKey(
16
+ elementId: string,
17
+ target: TimelineKeyframeTarget,
18
+ ): string {
19
+ if (!target.propertyGroup) return `${elementId}:${target.percentage}`;
20
+ return JSON.stringify([
21
+ elementId,
22
+ target.propertyGroup,
23
+ target.animationId ?? "",
24
+ target.percentage,
25
+ target.tweenPercentage ?? target.percentage,
26
+ ]);
27
+ }
28
+
29
+ export function timelineKeyframeTargetFromSelectionKey(
30
+ elementId: string,
31
+ key: string,
32
+ ): TimelineKeyframeTarget | null {
33
+ if (key.startsWith("[")) {
34
+ let decoded: unknown;
35
+ try {
36
+ decoded = JSON.parse(key);
37
+ } catch {
38
+ return null;
39
+ }
40
+ if (!Array.isArray(decoded) || decoded.length !== 5) return null;
41
+ const [selectedElementId, propertyGroup, animationId, percentage, tweenPercentage] = decoded;
42
+ if (
43
+ selectedElementId !== elementId ||
44
+ typeof propertyGroup !== "string" ||
45
+ propertyGroup.length === 0 ||
46
+ typeof animationId !== "string" ||
47
+ typeof percentage !== "number" ||
48
+ !Number.isFinite(percentage) ||
49
+ typeof tweenPercentage !== "number" ||
50
+ !Number.isFinite(tweenPercentage)
51
+ ) {
52
+ return null;
53
+ }
54
+ return {
55
+ propertyGroup,
56
+ animationId: animationId || undefined,
57
+ percentage,
58
+ tweenPercentage,
59
+ };
60
+ }
61
+
62
+ const separator = key.lastIndexOf(":");
63
+ if (separator < 0 || key.slice(0, separator) !== elementId) return null;
64
+ const percentage = Number(key.slice(separator + 1));
65
+ return Number.isFinite(percentage) ? { percentage } : null;
66
+ }
@@ -1,17 +1,99 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
+ CLIP_Y,
4
+ INSERT_BOUNDARY_BAND,
5
+ getTimelineInsertBoundaryBand,
3
6
  RULER_H,
4
7
  TRACK_H,
8
+ LANE_H,
5
9
  TRACKS_TOP_PAD,
6
10
  TRACKS_BOTTOM_PAD,
7
11
  GUTTER,
8
12
  TRACKS_LEFT_PAD,
9
13
  getTimelineRowTop,
14
+ getTimelineScrubTime,
10
15
  getTimelineRowFromY,
16
+ getTimelineRowOffsets,
11
17
  getTimelineCanvasHeight,
18
+ trackHeights,
12
19
  resolveTimelineAssetDrop,
13
20
  } from "./timelineLayout";
14
21
 
22
+ /** N collapsed rows, the shape every caller passes when nothing is expanded. */
23
+ const baseRows = (count: number) => Array.from({ length: count }, () => TRACK_H);
24
+
25
+ describe("variable timeline row geometry", () => {
26
+ const tracks = [
27
+ [{ clipId: "a", laneCount: 0 }],
28
+ [{ clipId: "b", laneCount: 2 }],
29
+ [{ clipId: "c", laneCount: 1 }],
30
+ ];
31
+
32
+ it("resolves every row to the base height when no clip is expanded", () => {
33
+ expect(trackHeights(tracks)).toEqual([TRACK_H, TRACK_H, TRACK_H]);
34
+ expect(trackHeights([[], [], []])).toEqual([TRACK_H, TRACK_H, TRACK_H]);
35
+ });
36
+
37
+ it("adds one lane height per lane on an expanded clip", () => {
38
+ expect(trackHeights(tracks, new Set(["b"]))).toEqual([TRACK_H, TRACK_H + 2 * LANE_H, TRACK_H]);
39
+ });
40
+
41
+ it("derives row tops from cumulative offsets", () => {
42
+ const heights = trackHeights(tracks, new Set(["b"]));
43
+ expect(getTimelineRowOffsets(heights)).toEqual([
44
+ 0,
45
+ TRACK_H,
46
+ 2 * TRACK_H + 2 * LANE_H,
47
+ 3 * TRACK_H + 2 * LANE_H,
48
+ ]);
49
+ expect(getTimelineRowTop(2, heights)).toBe(RULER_H + TRACKS_TOP_PAD + 2 * TRACK_H + 2 * LANE_H);
50
+ });
51
+
52
+ it("maps y inside an expanded lane region back to the expanded track", () => {
53
+ const heights = trackHeights(tracks, new Set(["b"]));
54
+ const yInSecondExpandedLane = getTimelineRowTop(1, heights) + TRACK_H + LANE_H * 1.5;
55
+ const row = getTimelineRowFromY(yInSecondExpandedLane, heights);
56
+ expect(Math.floor(row)).toBe(1);
57
+ expect(row).toBeGreaterThan(1.5);
58
+ expect(row).toBeLessThan(2);
59
+ });
60
+
61
+ it("sums resolved row heights into the canvas height", () => {
62
+ const heights = trackHeights(tracks, new Set(["b"]));
63
+ expect(getTimelineCanvasHeight(heights)).toBe(
64
+ RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + 2 * LANE_H + TRACKS_BOTTOM_PAD,
65
+ );
66
+ });
67
+ });
68
+
69
+ describe("collapsed timeline row geometry characterization", () => {
70
+ it.each([
71
+ [0, 74],
72
+ [1, 122],
73
+ [4, 266],
74
+ ])("keeps row %i at content y=%i", (row, expectedTop) => {
75
+ expect(getTimelineRowTop(row)).toBe(expectedTop);
76
+ });
77
+
78
+ it.each([
79
+ [74, 0],
80
+ [86, 0.25],
81
+ [146, 1.5],
82
+ [290, 4.5],
83
+ ])("maps content y=%i to fractional row %f", (contentY, expectedRow) => {
84
+ expect(getTimelineRowFromY(contentY)).toBe(expectedRow);
85
+ });
86
+
87
+ it.each([
88
+ [0, 146],
89
+ [1, 194],
90
+ [3, 290],
91
+ [5, 386],
92
+ ])("keeps the %i-track canvas height at %i", (trackCount, expectedHeight) => {
93
+ expect(getTimelineCanvasHeight(baseRows(trackCount))).toBe(expectedHeight);
94
+ });
95
+ });
96
+
15
97
  describe("track-area breathing pad y-math", () => {
16
98
  describe("getTimelineRowTop", () => {
17
99
  it("offsets the first lane below the ruler by the top pad", () => {
@@ -53,20 +135,16 @@ describe("track-area breathing pad y-math", () => {
53
135
 
54
136
  describe("getTimelineCanvasHeight", () => {
55
137
  it("reserves ruler + top pad + lanes + bottom pad", () => {
56
- expect(getTimelineCanvasHeight(0)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
57
- expect(getTimelineCanvasHeight(3)).toBe(
138
+ expect(getTimelineCanvasHeight([])).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
139
+ expect(getTimelineCanvasHeight(baseRows(3))).toBe(
58
140
  RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + TRACKS_BOTTOM_PAD,
59
141
  );
60
142
  });
61
143
 
62
- it("clamps a negative track count to zero lanes", () => {
63
- expect(getTimelineCanvasHeight(-4)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
64
- });
65
-
66
144
  it("leaves room below the last lane for a drag-into-void new track", () => {
67
145
  // The gap below the final lane must be at least a full track height so a
68
146
  // clip can be dropped there to create a new bottom track.
69
- const oneLane = getTimelineCanvasHeight(1);
147
+ const oneLane = getTimelineCanvasHeight(baseRows(1));
70
148
  const lastLaneBottom = getTimelineRowTop(0) + TRACK_H;
71
149
  expect(oneLane - lastLaneBottom).toBeGreaterThanOrEqual(TRACK_H);
72
150
  });
@@ -78,15 +156,16 @@ describe("track-area breathing pad y-math", () => {
78
156
  rectTop: 0,
79
157
  scrollLeft: 0,
80
158
  scrollTop: 0,
159
+ contentOrigin: GUTTER,
81
160
  pixelsPerSecond: 100,
82
161
  duration: 60,
83
- trackHeight: TRACK_H,
162
+ rowHeights: baseRows(3),
84
163
  trackOrder: [0, 1, 2],
85
164
  };
86
165
 
87
166
  it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
88
167
  const clientY = getTimelineRowTop(0) + TRACK_H / 2;
89
- const clientX = GUTTER + TRACKS_LEFT_PAD + 100; // t = 1s
168
+ const clientX = GUTTER + 100; // t = 1s (contentOrigin = GUTTER)
90
169
  const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
91
170
  expect(track).toBe(0);
92
171
  expect(start).toBe(1);
@@ -103,5 +182,79 @@ describe("track-area breathing pad y-math", () => {
103
182
  const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
104
183
  expect(track).toBe(3); // max(trackOrder)+1
105
184
  });
185
+
186
+ it("keeps a drop in an expanded lane region on that track", () => {
187
+ const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
188
+ const clientY = getTimelineRowTop(0, rowHeights) + TRACK_H + LANE_H;
189
+ const { track } = resolveTimelineAssetDrop({ ...base, rowHeights }, GUTTER, clientY);
190
+ expect(track).toBe(0);
191
+ });
192
+ });
193
+ });
194
+
195
+ describe("getTimelineScrubTime", () => {
196
+ const at = (clientX: number, duration = 10) =>
197
+ getTimelineScrubTime({
198
+ clientX,
199
+ viewportLeft: 0,
200
+ scrollLeft: 0,
201
+ contentOrigin: GUTTER + TRACKS_LEFT_PAD,
202
+ pixelsPerSecond: 100,
203
+ duration,
204
+ });
205
+ const origin = GUTTER + TRACKS_LEFT_PAD;
206
+
207
+ it("maps the content origin to t=0", () => {
208
+ expect(at(origin)).toBe(0);
209
+ expect(at(origin + 250)).toBe(2.5);
210
+ });
211
+
212
+ // The bug: a pointer left of the origin used to abort the scrub instead of
213
+ // clamping, so dragging the playhead to the start only worked if a sample
214
+ // happened to land in the few px before t=0.
215
+ it("clamps a pointer left of the origin to 0 instead of dropping the scrub", () => {
216
+ expect(at(origin - 1)).toBe(0);
217
+ expect(at(origin - 500)).toBe(0);
218
+ expect(at(0)).toBe(0);
219
+ });
220
+
221
+ it("clamps past the end to the duration", () => {
222
+ expect(at(origin + 5000)).toBe(10);
223
+ });
224
+
225
+ it("returns 0 for a degenerate zoom or duration", () => {
226
+ expect(
227
+ getTimelineScrubTime({
228
+ clientX: 500,
229
+ viewportLeft: 0,
230
+ scrollLeft: 0,
231
+ contentOrigin: GUTTER + TRACKS_LEFT_PAD,
232
+ pixelsPerSecond: 0,
233
+ duration: 10,
234
+ }),
235
+ ).toBe(0);
236
+ expect(at(origin + 250, Number.NaN)).toBe(0);
237
+ });
238
+ });
239
+
240
+ // The only production hook keeping resolveInsertRow's band aligned with the
241
+ // rendered clip inset once rows can be taller than TRACK_H. Pinned directly so a
242
+ // change to CLIP_Y or the invalid-height fallback can't silently drift it.
243
+ describe("getTimelineInsertBoundaryBand", () => {
244
+ it("matches the fixed band for a plain track row", () => {
245
+ expect(getTimelineInsertBoundaryBand(TRACK_H)).toBe(INSERT_BOUNDARY_BAND);
246
+ expect(getTimelineInsertBoundaryBand(TRACK_H)).toBe(CLIP_Y / TRACK_H);
247
+ });
248
+
249
+ it("shrinks as the row grows, so the band stays CLIP_Y pixels tall", () => {
250
+ const expanded = TRACK_H + 2 * LANE_H;
251
+ expect(getTimelineInsertBoundaryBand(expanded)).toBeCloseTo(CLIP_Y / expanded, 10);
252
+ expect(getTimelineInsertBoundaryBand(expanded)).toBeLessThan(INSERT_BOUNDARY_BAND);
253
+ });
254
+
255
+ it("falls back to the plain-track band for a height that is not usable", () => {
256
+ for (const height of [0, -10, Number.NaN]) {
257
+ expect(getTimelineInsertBoundaryBand(height)).toBe(INSERT_BOUNDARY_BAND);
258
+ }
106
259
  });
107
260
  });