@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
@@ -3,9 +3,8 @@ import { liveTime, type ZoomMode } from "../store/playerStore";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { getPinchTimelineZoomPercent } from "./timelineZoom";
5
5
  import {
6
- GUTTER,
7
- TRACKS_LEFT_PAD,
8
6
  getTimelinePlayheadLeft,
7
+ getTimelineScrubTime,
9
8
  getTimelineScrollLeftForZoomTransition,
10
9
  getTimelineScrollLeftForZoomAnchor,
11
10
  shouldAutoScrollTimeline,
@@ -31,6 +30,7 @@ interface UseTimelinePlayheadInput {
31
30
  setZoomMode: (mode: ZoomMode) => void;
32
31
  setManualZoomPercent: (percent: number) => void;
33
32
  onSeek?: (time: number) => void;
33
+ contentOrigin: number;
34
34
  }
35
35
 
36
36
  export function useTimelinePlayhead({
@@ -52,6 +52,7 @@ export function useTimelinePlayhead({
52
52
  setZoomMode,
53
53
  setManualZoomPercent,
54
54
  onSeek,
55
+ contentOrigin,
55
56
  }: UseTimelinePlayheadInput) {
56
57
  const dragScrollRaf = useRef(0);
57
58
  const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
@@ -60,6 +61,8 @@ export function useTimelinePlayhead({
60
61
  // anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`.
61
62
  const previousAnchorPpsRef = useRef(pps);
62
63
  const skipCenterAnchorRef = useRef(false);
64
+ const contentOriginRef = useRef(contentOrigin);
65
+ contentOriginRef.current = contentOrigin;
63
66
 
64
67
  useLayoutEffect(() => {
65
68
  const scroll = scrollRef.current;
@@ -74,21 +77,21 @@ export function useTimelinePlayhead({
74
77
  const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
75
78
  pointerX: scroll.clientWidth / 2,
76
79
  currentScrollLeft: scroll.scrollLeft,
77
- gutter: GUTTER + TRACKS_LEFT_PAD,
80
+ contentOrigin,
78
81
  currentPixelsPerSecond: prevPps,
79
82
  nextPixelsPerSecond: pps,
80
83
  duration: durationRef.current,
81
84
  });
82
85
  const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
83
86
  scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
84
- }, [pps, scrollRef, durationRef]);
87
+ }, [pps, scrollRef, durationRef, contentOrigin]);
85
88
 
86
89
  const syncPlayheadPosition = useCallback(
87
90
  (time: number) => {
88
91
  if (!playheadRef.current || durationRef.current <= 0) return;
89
- playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current)}px`;
92
+ playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current, contentOrigin)}px`;
90
93
  },
91
- [playheadRef, durationRef, ppsRef],
94
+ [playheadRef, durationRef, ppsRef, contentOrigin],
92
95
  );
93
96
 
94
97
  useEffect(() => {
@@ -120,7 +123,7 @@ export function useTimelinePlayhead({
120
123
  if (!playheadRef.current || durationRef.current <= 0) return;
121
124
  // Playback deliberately does NOT scroll the viewport to chase the playhead —
122
125
  // the user's scroll position is theirs; the playhead may run off-screen.
123
- playheadRef.current.style.left = `${getTimelinePlayheadLeft(t, ppsRef.current)}px`;
126
+ playheadRef.current.style.left = `${getTimelinePlayheadLeft(t, ppsRef.current, contentOriginRef.current)}px`;
124
127
  });
125
128
  return unsub;
126
129
  });
@@ -130,13 +133,18 @@ export function useTimelinePlayhead({
130
133
  const el = scrollRef.current;
131
134
  if (!el || effectiveDuration <= 0) return;
132
135
  const rect = el.getBoundingClientRect();
133
- const x = clientX - rect.left + el.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
134
- if (x < 0) return;
135
- const time = Math.max(0, Math.min(effectiveDuration, x / pps));
136
+ const time = getTimelineScrubTime({
137
+ clientX,
138
+ viewportLeft: rect.left,
139
+ scrollLeft: el.scrollLeft,
140
+ contentOrigin,
141
+ pixelsPerSecond: pps,
142
+ duration: effectiveDuration,
143
+ });
136
144
  liveTime.notify(time);
137
145
  onSeek?.(time);
138
146
  },
139
- [scrollRef, effectiveDuration, pps, onSeek],
147
+ [scrollRef, effectiveDuration, pps, onSeek, contentOrigin],
140
148
  );
141
149
 
142
150
  const autoScrollDuringDrag = useCallback(
@@ -186,7 +194,7 @@ export function useTimelinePlayhead({
186
194
  const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
187
195
  pointerX: e.clientX - rect.left,
188
196
  currentScrollLeft: scroll.scrollLeft,
189
- gutter: GUTTER + TRACKS_LEFT_PAD,
197
+ contentOrigin,
190
198
  currentPixelsPerSecond: ppsRef.current,
191
199
  nextPixelsPerSecond: nextPps,
192
200
  duration: durationRef.current,
@@ -209,6 +217,7 @@ export function useTimelinePlayhead({
209
217
  manualZoomPercentRef,
210
218
  setManualZoomPercent,
211
219
  setZoomMode,
220
+ contentOrigin,
212
221
  ],
213
222
  );
214
223
 
@@ -7,7 +7,7 @@ import {
7
7
  } from "./timelineEditing";
8
8
  import type { TimelineElement } from "../store/playerStore";
9
9
  import { liveTime, usePlayerStore } from "../store/playerStore";
10
- import { GUTTER, TRACKS_LEFT_PAD } from "./timelineLayout";
10
+ import { getTimelineScrubTime } from "./timelineLayout";
11
11
  import {
12
12
  computeMarqueeSelection,
13
13
  getMarqueeRect,
@@ -30,7 +30,9 @@ interface UseTimelineRangeSelectionInput {
30
30
  setShowPopover: (v: boolean) => void;
31
31
  elementsRef: React.RefObject<TimelineElement[]>;
32
32
  trackOrderRef: React.RefObject<number[]>;
33
+ rowHeightsRef: React.RefObject<readonly number[]>;
33
34
  onSelectElement?: (element: TimelineElement | null) => void;
35
+ contentOrigin: number;
34
36
  }
35
37
 
36
38
  interface MarqueeDragState {
@@ -72,12 +74,16 @@ function commitMarqueeSelection(
72
74
  marquee: MarqueeDragState,
73
75
  elements: TimelineElement[],
74
76
  trackOrder: number[],
77
+ rowHeights: readonly number[],
75
78
  pps: number,
79
+ contentOrigin: number,
76
80
  ): void {
77
81
  const { ids, primaryId } = computeMarqueeSelection({
78
82
  clips: toMarqueeClips(elements),
79
83
  trackOrder,
84
+ rowHeights,
80
85
  pps,
86
+ contentOrigin,
81
87
  marquee: rect,
82
88
  baseSelection: additive ? marquee.baseIds : undefined,
83
89
  });
@@ -101,7 +107,9 @@ export function useTimelineRangeSelection({
101
107
  setShowPopover,
102
108
  elementsRef,
103
109
  trackOrderRef,
110
+ rowHeightsRef,
104
111
  onSelectElement,
112
+ contentOrigin,
105
113
  }: UseTimelineRangeSelectionInput) {
106
114
  const isRangeSelecting = useRef(false);
107
115
  const rangeAnchorTime = useRef(0);
@@ -168,10 +176,12 @@ export function useTimelineRangeSelection({
168
176
  marquee,
169
177
  elementsRef.current ?? [],
170
178
  trackOrderRef.current ?? [],
179
+ rowHeightsRef.current,
171
180
  ppsRef.current,
181
+ contentOrigin,
172
182
  );
173
183
  },
174
- [toContentPoint, elementsRef, trackOrderRef, ppsRef],
184
+ [toContentPoint, elementsRef, trackOrderRef, rowHeightsRef, ppsRef, contentOrigin],
175
185
  );
176
186
 
177
187
  const stopMarqueeAutoScroll = useCallback(() => {
@@ -228,14 +238,13 @@ export function useTimelineRangeSelection({
228
238
  setShowPopover(false);
229
239
  const rect = scrollRef.current?.getBoundingClientRect();
230
240
  if (rect) {
231
- const x =
232
- e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
241
+ const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - contentOrigin;
233
242
  const time = Math.max(0, x / pps);
234
243
  rangeAnchorTime.current = time;
235
244
  setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
236
245
  }
237
246
  },
238
- [scrollRef, pps, setShowPopover],
247
+ [scrollRef, pps, setShowPopover, contentOrigin],
239
248
  );
240
249
 
241
250
  const handlePointerDown = useCallback(
@@ -286,11 +295,16 @@ export function useTimelineRangeSelection({
286
295
  const el = scrollRef.current;
287
296
  if (el) {
288
297
  const rect = el.getBoundingClientRect();
289
- const x = clientX - rect.left + el.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
290
- if (x >= 0) {
291
- const dur = el.scrollWidth / pps;
292
- liveTime.notify(Math.max(0, Math.min(dur, x / pps)));
293
- }
298
+ liveTime.notify(
299
+ getTimelineScrubTime({
300
+ clientX,
301
+ viewportLeft: rect.left,
302
+ scrollLeft: el.scrollLeft,
303
+ contentOrigin,
304
+ pixelsPerSecond: pps,
305
+ duration: el.scrollWidth / pps,
306
+ }),
307
+ );
294
308
  }
295
309
  if (!seekRafRef.current) {
296
310
  seekRafRef.current = requestAnimationFrame(() => {
@@ -302,7 +316,7 @@ export function useTimelineRangeSelection({
302
316
  });
303
317
  }
304
318
  },
305
- [scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging],
319
+ [scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging, contentOrigin],
306
320
  );
307
321
 
308
322
  const handlePointerMove = useCallback(
@@ -310,8 +324,7 @@ export function useTimelineRangeSelection({
310
324
  if (isRangeSelecting.current) {
311
325
  const rect = scrollRef.current?.getBoundingClientRect();
312
326
  if (rect) {
313
- const x =
314
- e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
327
+ const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - contentOrigin;
315
328
  setRangeSelection((prev) =>
316
329
  prev
317
330
  ? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
@@ -331,7 +344,15 @@ export function useTimelineRangeSelection({
331
344
  if (!isDragging.current) return;
332
345
  updateScrubDrag(e.clientX);
333
346
  },
334
- [pps, scrollRef, isDragging, applyMarqueeAtClient, syncMarqueeAutoScroll, updateScrubDrag],
347
+ [
348
+ pps,
349
+ scrollRef,
350
+ isDragging,
351
+ applyMarqueeAtClient,
352
+ syncMarqueeAutoScroll,
353
+ updateScrubDrag,
354
+ contentOrigin,
355
+ ],
335
356
  );
336
357
 
337
358
  // Release of a shift time-range gesture: keep a real range (or a shift-click
@@ -0,0 +1,53 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
+ import { afterEach, describe, expect, it } from "vitest";
7
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
8
+ import { LANE_H, TRACK_H } from "./timelineLayout";
9
+ import { useTimelineTrackLayout } from "./useTimelineTrackLayout";
10
+
11
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ afterEach(() => {
14
+ usePlayerStore.getState().reset();
15
+ });
16
+
17
+ describe("useTimelineTrackLayout", () => {
18
+ it("counts a flat tween lane and reserves its expanded row height", () => {
19
+ const elements: TimelineElement[] = [
20
+ { id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
21
+ ];
22
+ const animations = new Map<string, GsapAnimation[]>([
23
+ [
24
+ "clip-1",
25
+ [
26
+ {
27
+ id: "position-tween",
28
+ targetSelector: "#clip-1",
29
+ method: "to",
30
+ position: 0,
31
+ duration: 1,
32
+ properties: { x: 420 },
33
+ propertyGroup: "position",
34
+ },
35
+ ],
36
+ ],
37
+ ]);
38
+ usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
39
+
40
+ let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
41
+ function Probe() {
42
+ layout = useTimelineTrackLayout(elements, animations, null, new Set());
43
+ return null;
44
+ }
45
+
46
+ const root = createRoot(document.createElement("div"));
47
+ act(() => root.render(React.createElement(Probe)));
48
+
49
+ expect(layout?.laneCounts.get("clip-1")).toBe(1);
50
+ expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]);
51
+ act(() => root.unmount());
52
+ });
53
+ });
@@ -0,0 +1,162 @@
1
+ import { useMemo, useRef } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import { animationContributesLane } from "./TimelinePropertyLanes";
4
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
5
+ import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
6
+ import type { DraggedClipState } from "./timelineClipDragTypes";
7
+ import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
8
+ import {
9
+ TRACK_H,
10
+ getTimelineCanvasHeight,
11
+ getTimelineRowHeight,
12
+ trackHeights,
13
+ type TimelineTrackHeightClip,
14
+ } from "./timelineLayout";
15
+
16
+ export { getTrackStyle } from "./timelineIcons";
17
+
18
+ /**
19
+ * The single keyframed element whose property lanes a track shows when expanded.
20
+ * A track can hold several elements (same z-index is common), but keyframes are
21
+ * per-element, so we scope to ONE active element — the selected one if it's on
22
+ * this track, otherwise the element with the most lanes. Selecting a clip is how
23
+ * you switch which element you're keyframing. Returns null when no element on the
24
+ * track has keyframes.
25
+ */
26
+ export function resolveTrackKeyframeClip(
27
+ elements: readonly TimelineElement[],
28
+ laneCounts: ReadonlyMap<string, number>,
29
+ selectedElementId: string | null,
30
+ selectedElementIds: ReadonlySet<string>,
31
+ ): TimelineElement | null {
32
+ const keyframed = elements.filter(
33
+ (element) => (laneCounts.get(element.key ?? element.id) ?? 0) >= 1,
34
+ );
35
+ if (keyframed.length === 0) return null;
36
+ const selected = keyframed.find((element) => {
37
+ const key = element.key ?? element.id;
38
+ return key === selectedElementId || selectedElementIds.has(key);
39
+ });
40
+ if (selected) return selected;
41
+ // Most lanes wins, first one on a tie (same as the old stable sort), but as a
42
+ // reduce over the already non-empty list so there's no index to assert on.
43
+ const lanesOf = (element: TimelineElement) => laneCounts.get(element.key ?? element.id) ?? 0;
44
+ return keyframed.reduce((best, element) => (lanesOf(element) > lanesOf(best) ? element : best));
45
+ }
46
+
47
+ /** Lanes per clip: the count of distinct property groups whose tween contributes
48
+ * a lane (real keyframes or a synthesizable flat tween). */
49
+ function computeLaneCounts(
50
+ tracks: [number, TimelineElement[]][],
51
+ gsapAnimations: Map<string, GsapAnimation[]>,
52
+ ): Map<string, number> {
53
+ const laneCounts = new Map<string, number>();
54
+ for (const [, elements] of tracks) {
55
+ for (const element of elements) {
56
+ const clipId = element.key ?? element.id;
57
+ const propertyGroups = new Set<string>();
58
+ for (const animation of gsapAnimations.get(clipId) ?? []) {
59
+ if (animation.propertyGroup && animationContributesLane(animation)) {
60
+ propertyGroups.add(animation.propertyGroup);
61
+ }
62
+ }
63
+ laneCounts.set(clipId, propertyGroups.size);
64
+ }
65
+ }
66
+ return laneCounts;
67
+ }
68
+
69
+ function useTimelineRowHeights(
70
+ tracks: [number, TimelineElement[]][],
71
+ gsapAnimations: Map<string, GsapAnimation[]>,
72
+ selectedElementId: string | null,
73
+ selectedElementIds: ReadonlySet<string>,
74
+ ) {
75
+ const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
76
+ const { laneCounts, rowHeights } = useMemo(() => {
77
+ const laneCounts = computeLaneCounts(tracks, gsapAnimations);
78
+ // Row height follows only the active keyframe clip, so a track with several
79
+ // keyframed elements never reserves empty lanes for the ones not shown.
80
+ const heightTracks: TimelineTrackHeightClip[][] = tracks.map(([, elements]) => {
81
+ const active = resolveTrackKeyframeClip(
82
+ elements,
83
+ laneCounts,
84
+ selectedElementId,
85
+ selectedElementIds,
86
+ );
87
+ if (!active) return [];
88
+ const clipId = active.key ?? active.id;
89
+ return [{ clipId, laneCount: laneCounts.get(clipId) ?? 0 }];
90
+ });
91
+ return {
92
+ laneCounts,
93
+ rowHeights: trackHeights(
94
+ heightTracks,
95
+ STUDIO_KEYFRAMES_ENABLED ? expandedClipIds : undefined,
96
+ ),
97
+ };
98
+ }, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
99
+ const rowHeightsRef = useRef<readonly number[]>(rowHeights);
100
+ rowHeightsRef.current = rowHeights;
101
+ return { laneCounts, rowHeights, rowHeightsRef };
102
+ }
103
+
104
+ export function useTimelineTrackLayout(
105
+ expandedElements: TimelineElement[],
106
+ gsapAnimations: Map<string, GsapAnimation[]>,
107
+ selectedElementId: string | null,
108
+ selectedElementIds: ReadonlySet<string>,
109
+ ) {
110
+ const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
111
+ const trackOrderRef = useRef(trackOrder);
112
+ trackOrderRef.current = trackOrder;
113
+ const { laneCounts, rowHeights, rowHeightsRef } = useTimelineRowHeights(
114
+ tracks,
115
+ gsapAnimations,
116
+ selectedElementId,
117
+ selectedElementIds,
118
+ );
119
+
120
+ return {
121
+ tracks,
122
+ trackStyles,
123
+ trackOrder,
124
+ trackOrderRef,
125
+ laneCounts,
126
+ rowHeights,
127
+ rowHeightsRef,
128
+ };
129
+ }
130
+
131
+ function useDisplayRowHeights(
132
+ displayTrackOrder: readonly number[],
133
+ trackOrder: readonly number[],
134
+ rowHeights: readonly number[],
135
+ ) {
136
+ return useMemo(
137
+ () =>
138
+ displayTrackOrder.map((track) => {
139
+ const row = trackOrder.indexOf(track);
140
+ return row < 0 ? TRACK_H : getTimelineRowHeight(row, rowHeights);
141
+ }),
142
+ [displayTrackOrder, trackOrder, rowHeights],
143
+ );
144
+ }
145
+
146
+ function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
147
+ return useMemo(() => {
148
+ if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
149
+ return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
150
+ }, [draggedClip, trackOrder]);
151
+ }
152
+
153
+ export function useTimelineDisplayLayout(
154
+ draggedClip: DraggedClipState | null,
155
+ trackOrder: number[],
156
+ rowHeights: readonly number[],
157
+ ) {
158
+ const displayTrackOrder = useDisplayTrackOrder(draggedClip, trackOrder);
159
+ const displayRowHeights = useDisplayRowHeights(displayTrackOrder, trackOrder, rowHeights);
160
+ const totalH = getTimelineCanvasHeight(displayRowHeights);
161
+ return { displayTrackOrder, displayRowHeights, totalH };
162
+ }
@@ -278,6 +278,49 @@ describe("resolveTimelineExpansionRawId", () => {
278
278
  ).toBe("scene");
279
279
  });
280
280
 
281
+ it("THE BUG: keeps a composition expanded with the playhead parked on its end", () => {
282
+ // Clip windows are half-open, so at the very end of the timeline the
283
+ // playhead was inside nothing and every expanded row collapsed.
284
+ const manifest = [
285
+ clip({ id: "scene", start: 0, duration: 12 }),
286
+ clip({ id: "headline", start: 0, duration: 12 }),
287
+ ];
288
+ const parentMap = new Map([["headline", "scene"]]);
289
+
290
+ expect(
291
+ resolveTimelineExpansionRawId({
292
+ selectedElementId: null,
293
+ isPlaying: false,
294
+ currentTime: 12,
295
+ manifest,
296
+ parentMap,
297
+ }),
298
+ ).toBe("scene");
299
+ });
300
+
301
+ it("prefers the starting clip over the ending one on a shared seam", () => {
302
+ const manifest = [
303
+ clip({ id: "first", start: 0, duration: 5 }),
304
+ clip({ id: "first-child", start: 0, duration: 5 }),
305
+ clip({ id: "second", start: 5, duration: 5 }),
306
+ clip({ id: "second-child", start: 5, duration: 5 }),
307
+ ];
308
+ const parentMap = new Map([
309
+ ["first-child", "first"],
310
+ ["second-child", "second"],
311
+ ]);
312
+
313
+ expect(
314
+ resolveTimelineExpansionRawId({
315
+ selectedElementId: null,
316
+ isPlaying: false,
317
+ currentTime: 5,
318
+ manifest,
319
+ parentMap,
320
+ }),
321
+ ).toBe("second");
322
+ });
323
+
281
324
  it("auto-expands the innermost active nested composition when paused", () => {
282
325
  const manifest = [
283
326
  clip({ id: "outer", start: 0, duration: 10 }),
@@ -2,7 +2,7 @@ import { useMemo } from "react";
2
2
  import { usePlayerStore, type TimelineElement, type DomClipChild } from "../store/playerStore";
3
3
  import type { ClipManifestClip } from "../lib/playbackTypes";
4
4
  import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
5
- import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
5
+ import { buildTimelineElementKey, splitTimelineElementKey } from "../lib/timelineElementHelpers";
6
6
 
7
7
  function findTopLevelAncestor(id: string, parentMap: Map<string, string>): string | null {
8
8
  let current = parentMap.get(id);
@@ -19,18 +19,13 @@ function findTopLevelAncestor(id: string, parentMap: Map<string, string>): strin
19
19
  return current;
20
20
  }
21
21
 
22
- function extractDomId(key: string): string {
23
- const hashIdx = key.lastIndexOf("#");
24
- return hashIdx >= 0 ? key.slice(hashIdx + 1) : key;
25
- }
26
-
27
22
  function resolveRawId(
28
23
  selectedId: string | null,
29
24
  manifest: ClipManifestClip[],
30
25
  parentMap: Map<string, string>,
31
26
  ): string | null {
32
27
  if (!selectedId) return null;
33
- const rawId = extractDomId(selectedId);
28
+ const rawId = splitTimelineElementKey(selectedId).domId;
34
29
  if (parentMap.has(rawId)) return rawId;
35
30
  if (parentMap.has(selectedId)) return selectedId;
36
31
  const clip = manifest.find((c) => c.label === selectedId || c.label === rawId);
@@ -50,6 +45,11 @@ function clipContainsTime(clip: ClipManifestClip, time: number): boolean {
50
45
  return Number.isFinite(time) && time >= clip.start && time < clip.start + clip.duration;
51
46
  }
52
47
 
48
+ /** Half-open containment plus the closing boundary — see the fallback below. */
49
+ function clipTouchesTime(clip: ClipManifestClip, time: number): boolean {
50
+ return Number.isFinite(time) && time >= clip.start && time <= clip.start + clip.duration;
51
+ }
52
+
53
53
  function getActiveParentDepth(id: string, parentMap: Map<string, string>, activeIds: Set<string>) {
54
54
  let depth = 0;
55
55
  let parent = parentMap.get(id);
@@ -70,11 +70,20 @@ function findActiveExpandableCompositionId(
70
70
  parentMap: Map<string, string>,
71
71
  ): string | null {
72
72
  const parentIds = new Set(parentMap.values());
73
- const activeIds = new Set<string>();
74
- for (const clip of manifest) {
75
- if (!clip.id || !parentIds.has(clip.id) || !clipContainsTime(clip, currentTime)) continue;
76
- activeIds.add(clip.id);
77
- }
73
+ const collect = (matches: (clip: ClipManifestClip, time: number) => boolean) => {
74
+ const ids = new Set<string>();
75
+ for (const clip of manifest) {
76
+ if (clip.id && parentIds.has(clip.id) && matches(clip, currentTime)) ids.add(clip.id);
77
+ }
78
+ return ids;
79
+ };
80
+ // Clip windows are half-open, so a playhead parked exactly on a clip's end is
81
+ // inside nothing — at the end of the timeline that collapsed every expanded
82
+ // sub-composition row and its keyframe lanes. Only when the strict pass finds
83
+ // nothing do we accept the closing boundary, so a playhead landing on the seam
84
+ // between two adjacent clips still expands the one that is starting.
85
+ const strict = collect(clipContainsTime);
86
+ const activeIds = strict.size > 0 ? strict : collect(clipTouchesTime);
78
87
  let bestId: string | null = null;
79
88
  let bestDepth = -1;
80
89
  for (const id of activeIds) {
@@ -298,6 +298,20 @@ export function buildTimelineElementKey(params: {
298
298
  return `${scope}:${params.id}:${params.fallbackIndex}`;
299
299
  }
300
300
 
301
+ /**
302
+ * Inverse of {@link buildTimelineElementKey} for the `sourceFile#domId` form.
303
+ * A key with no `#` is a bare dom id and carries no source file of its own, so
304
+ * the caller supplies the scope it wants to look that id up in.
305
+ */
306
+ export function splitTimelineElementKey(key: string): {
307
+ sourceFile: string | null;
308
+ domId: string;
309
+ } {
310
+ const hashIndex = key.lastIndexOf("#");
311
+ if (hashIndex < 0) return { sourceFile: null, domId: key };
312
+ return { sourceFile: key.slice(0, hashIndex), domId: key.slice(hashIndex + 1) };
313
+ }
314
+
301
315
  export function buildTimelineElementIdentity(params: {
302
316
  preferredId?: string | null;
303
317
  label: string;
@@ -366,20 +380,21 @@ function numbersNearlyEqual(a: number, b: number): boolean {
366
380
  return Math.abs(a - b) < 0.001;
367
381
  }
368
382
 
383
+ const MANIFEST_CLIP_ATTRS: ReadonlyArray<[string, (clip: ClipManifestClip) => number]> = [
384
+ ["data-start", (clip) => clip.start],
385
+ ["data-duration", (clip) => clip.duration],
386
+ ["data-track-index", (clip) => clip.track],
387
+ ];
388
+
369
389
  function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
370
390
  const tagName = clip.tagName?.toLowerCase();
371
391
  if (tagName && node.tagName.toLowerCase() !== tagName) return false;
372
-
373
- const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
374
- if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
375
-
376
- const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
377
- if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
378
-
379
- const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
380
- if (Number.isFinite(track) && track !== clip.track) return false;
381
-
382
- return true;
392
+ // An attribute only constrains the match when it parses to a finite number:
393
+ // missing or garbled reads as "unknown", not "mismatch".
394
+ return MANIFEST_CLIP_ATTRS.every(([attr, expected]) => {
395
+ const actual = Number.parseFloat(node.getAttribute(attr) ?? "");
396
+ return !Number.isFinite(actual) || numbersNearlyEqual(actual, expected(clip));
397
+ });
383
398
  }
384
399
 
385
400
  function findTimelineDomNode(doc: Document, id: string): Element | null {