@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,145 @@
1
+ import { Fragment, useRef } from "react";
2
+ import { KEYFRAME_DRAG_THRESHOLD_PX } from "../../components/editor/keyframeDrag";
3
+ import { MiniCurveSvg } from "../../components/editor/EaseCurveSection";
4
+ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
5
+ import type { TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
6
+
7
+ /** One diamond's geometry within its row, as computed by the lane. */
8
+ export interface TimelineDiamondMarker {
9
+ keyframe: TimelineDiamondKeyframe;
10
+ centerX: number;
11
+ hitWidth: number;
12
+ visualSize: number;
13
+ }
14
+
15
+ /**
16
+ * The line between each pair of adjacent diamonds, plus the ease control that
17
+ * sits at the segment's midpoint. Split out of TimelineClipDiamonds only to keep
18
+ * that file inside the repo's file-size cap; it has no state of its own beyond
19
+ * the press-position guard below.
20
+ */
21
+ export function TimelineDiamondConnectors({
22
+ markers,
23
+ centerY,
24
+ baseColor,
25
+ baseOpacity,
26
+ groupAware,
27
+ globalEase,
28
+ keyframeTarget,
29
+ onSelectSegment,
30
+ }: {
31
+ markers: readonly TimelineDiamondMarker[];
32
+ centerY: number;
33
+ baseColor: string;
34
+ baseOpacity: number;
35
+ groupAware: boolean;
36
+ globalEase: string;
37
+ keyframeTarget: (keyframe: TimelineDiamondKeyframe) => TimelineKeyframeTarget;
38
+ onSelectSegment?: (target: TimelineKeyframeTarget) => void;
39
+ }) {
40
+ // The ease button sits dead centre of its segment, which on a two-keyframe clip
41
+ // is the centre of the clip bar — the natural place to grab a clip and drag it.
42
+ // Swallowing pointerdown there made that grab a no-op. Instead the press falls
43
+ // through to the clip (so the drag starts normally) and the button keeps only
44
+ // the click, which we drop if the pointer actually travelled.
45
+ const pressXRef = useRef<number | null>(null);
46
+
47
+ return (
48
+ <>
49
+ {markers.map((marker, i) => {
50
+ const previous = markers[i - 1];
51
+ if (!previous) return null;
52
+ const kf = marker.keyframe;
53
+ const x1 = previous.centerX;
54
+ const x2 = marker.centerX;
55
+ if (x2 - x1 < 1) return null;
56
+ const connectorLeft = x1 + previous.visualSize / 2;
57
+ const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.visualSize / 2;
58
+ // The ease button targets one segment, so it needs the keyframe's own
59
+ // animationId/tweenPercentage. On a merged inline row the button is
60
+ // hidden where the segment is ambiguous (two source animations collide
61
+ // at this % with different eases; see easeAmbiguous) or the keyframe has
62
+ // no source animation id (runtime-scanned) so there is no tween to target.
63
+ const target = keyframeTarget(kf);
64
+ const ease = kf.ease ?? globalEase;
65
+ return (
66
+ <Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
67
+ <div
68
+ className="absolute"
69
+ data-keyframe-connector={groupAware ? "" : undefined}
70
+ style={{
71
+ left: connectorLeft,
72
+ top: centerY,
73
+ width: Math.max(0, connectorWidth),
74
+ height: 2,
75
+ transform: "translateY(-1px)",
76
+ background: baseColor,
77
+ opacity: baseOpacity,
78
+ borderRadius: 1,
79
+ }}
80
+ />
81
+ {onSelectSegment && !kf.easeAmbiguous && kf.animationId !== undefined && (
82
+ <div
83
+ className="group absolute"
84
+ data-keyframe-ease-segment=""
85
+ style={{
86
+ left: x1,
87
+ top: centerY,
88
+ width: x2 - x1,
89
+ height: 18,
90
+ transform: "translateY(-50%)",
91
+ // Own a stacking context above the diamond buttons. At fit
92
+ // zoom the 16px ease control can overlap its neighbouring
93
+ // diamond; without a z-index here the later diamond wins the
94
+ // hit test even though the child button has z-index 3.
95
+ zIndex: 3,
96
+ // Only the centered control is interactive. The transparent
97
+ // segment wrapper must not swallow connector/clip gestures.
98
+ pointerEvents: "none",
99
+ }}
100
+ >
101
+ <button
102
+ type="button"
103
+ data-keyframe-ease-button=""
104
+ aria-label={`Edit ${ease} easing`}
105
+ title={`Edit ${ease} easing`}
106
+ className="absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
107
+ style={{
108
+ left: "50%",
109
+ top: "50%",
110
+ width: 16,
111
+ height: 16,
112
+ transform: "translate(-50%, -50%)",
113
+ zIndex: 3,
114
+ pointerEvents: "auto",
115
+ padding: 0,
116
+ border: "1px solid rgba(255, 255, 255, 0.14)",
117
+ background: "#171717",
118
+ cursor: "pointer",
119
+ }}
120
+ onPointerDown={(e) => {
121
+ pressXRef.current = e.clientX;
122
+ }}
123
+ onClick={(e) => {
124
+ e.stopPropagation();
125
+ const pressX = pressXRef.current;
126
+ pressXRef.current = null;
127
+ if (
128
+ pressX !== null &&
129
+ Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX
130
+ ) {
131
+ return;
132
+ }
133
+ onSelectSegment(target);
134
+ }}
135
+ >
136
+ <MiniCurveSvg ease={ease} active size={12} />
137
+ </button>
138
+ </div>
139
+ )}
140
+ </Fragment>
141
+ );
142
+ })}
143
+ </>
144
+ );
145
+ }
@@ -1,12 +1,16 @@
1
1
  import { type ReactNode } from "react";
2
- import { Eye, EyeSlash } from "@phosphor-icons/react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
4
4
  import { TimelineClip } from "./TimelineClip";
5
5
  import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
6
+ import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
7
+ import { TimelineTrackHeader } from "./TimelineTrackHeader";
8
+ import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
9
+ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
6
10
  import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
7
11
  import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
8
12
  import type { TimelineTheme } from "./timelineTheme";
9
- import { GUTTER, TRACK_H, TRACKS_LEFT_PAD, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
13
+ import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
10
14
  import {
11
15
  usePlayerStore,
12
16
  type TimelineElement,
@@ -21,9 +25,9 @@ import {
21
25
  import type { TrackVisualStyle } from "./timelineIcons";
22
26
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
23
27
  import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
28
+ import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
24
29
  import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
25
30
  import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
26
- import { Music } from "../../icons/SystemIcons";
27
31
  import { renderClipChildren } from "./timelineClipChildren";
28
32
 
29
33
  /**
@@ -34,12 +38,16 @@ import { renderClipChildren } from "./timelineClipChildren";
34
38
  */
35
39
  export interface TimelineLaneBaseProps {
36
40
  pps: number;
41
+ contentOrigin: number;
42
+ contentGutter: number;
37
43
  trackContentWidth: number;
38
44
  theme: TimelineTheme;
39
45
  displayTrackOrder: number[];
46
+ rowHeights: readonly number[];
40
47
  trackOrder: number[];
41
48
  tracks: [number, TimelineElement[]][];
42
49
  trackStyles: Map<number, TrackVisualStyle>;
50
+ laneCounts: ReadonlyMap<string, number>;
43
51
  selectedElementId: string | null;
44
52
  selectedElementIds: Set<string>;
45
53
  hoveredClip: string | null;
@@ -69,16 +77,26 @@ export interface TimelineLaneBaseProps {
69
77
  getPreviewElement: (element: TimelineElement) => TimelineElement;
70
78
  getTrackStyle: (tag: string) => TrackVisualStyle;
71
79
  keyframeCache?: Map<string, KeyframeCacheEntry>;
80
+ gsapAnimations: Map<string, GsapAnimation[]>;
72
81
  selectedKeyframes: Set<string>;
73
82
  currentTime: number;
74
- onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
75
- onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
76
- onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
83
+ onSeek?: (time: number) => void;
84
+ onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
85
+ onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
86
+ onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void;
87
+ onContextMenuKeyframe?: (
88
+ e: React.MouseEvent,
89
+ elementId: string,
90
+ target: TimelineKeyframeTarget,
91
+ ) => void;
77
92
  onMoveKeyframe?: (
78
93
  elementId: string,
79
- fromClipPercentage: number,
94
+ keyframe: TimelineKeyframeTarget,
80
95
  toClipPercentage: number,
81
- ) => void;
96
+ propertyGroup?: string,
97
+ tweenPercentage?: number,
98
+ animationId?: string,
99
+ ) => Promise<boolean>;
82
100
  onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
83
101
  /**
84
102
  * Right-click on EMPTY lane space (not on a clip — those preventDefault
@@ -94,6 +112,7 @@ interface TimelineLanesProps extends TimelineLaneBaseProps {
94
112
  draggedElement: TimelineElement | null;
95
113
  multiDragPreview: MultiDragPreviewInput | null;
96
114
  onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
115
+ onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
97
116
  onResizeElement: TimelineEditCallbacks["onResizeElement"];
98
117
  onMoveElement: TimelineEditCallbacks["onMoveElement"];
99
118
  onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
@@ -102,12 +121,16 @@ interface TimelineLanesProps extends TimelineLaneBaseProps {
102
121
 
103
122
  export function TimelineLanes({
104
123
  pps,
124
+ contentOrigin,
125
+ contentGutter,
105
126
  trackContentWidth,
106
127
  theme,
107
128
  displayTrackOrder,
129
+ rowHeights,
108
130
  trackOrder,
109
131
  tracks,
110
132
  trackStyles,
133
+ laneCounts,
111
134
  selectedElementId,
112
135
  selectedElementIds,
113
136
  hoveredClip,
@@ -132,8 +155,11 @@ export function TimelineLanes({
132
155
  getPreviewElement,
133
156
  getTrackStyle,
134
157
  keyframeCache,
158
+ gsapAnimations,
135
159
  selectedKeyframes,
136
160
  currentTime,
161
+ onSeek,
162
+ onSelectSegment,
137
163
  onClickKeyframe,
138
164
  onShiftClickKeyframe,
139
165
  onContextMenuKeyframe,
@@ -142,11 +168,19 @@ export function TimelineLanes({
142
168
  onContextMenuLane,
143
169
  beatAnalysis,
144
170
  onToggleTrackHidden,
171
+ onTogglePropertyGroupKeyframe,
145
172
  onResizeElement,
146
173
  onMoveElement,
147
174
  onRazorSplit,
148
175
  onRazorSplitAll,
149
176
  }: TimelineLanesProps) {
177
+ const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
178
+ const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
179
+ const toggleClipExpandedTracked = (key: string) => {
180
+ const willExpand = !expandedClipIds.has(key);
181
+ trackStudioKeyframeLaneExpand({ expanded: willExpand });
182
+ toggleClipExpanded(key);
183
+ };
150
184
  return (
151
185
  <>
152
186
  {
@@ -156,7 +190,8 @@ export function TimelineLanes({
156
190
  // bounded and virtualization's complexity isn't worth it. TODO: revisit and swap
157
191
  // in a virtualizer if editorial workflows ever push very high clip counts.
158
192
  // fallow-ignore-next-line complexity
159
- displayTrackOrder.map((trackNum) => {
193
+ displayTrackOrder.map((trackNum, row) => {
194
+ const rowHeight = getTimelineRowHeight(row, rowHeights);
160
195
  const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
161
196
  const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
162
197
  const isPendingTrack =
@@ -173,58 +208,50 @@ export function TimelineLanes({
173
208
  : els.some(isMusicTrack));
174
209
  const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
175
210
  const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
211
+ // The one keyframed element this track shows lanes for (selected, else
212
+ // most lanes). A track can hold several elements; scoping to one keeps
213
+ // their keyframes from cramming into a single row.
214
+ const keyframeClip = STUDIO_KEYFRAMES_ENABLED
215
+ ? resolveTrackKeyframeClip(els, laneCounts, selectedElementId, selectedElementIds)
216
+ : null;
217
+ const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
218
+ const keyframeClipExpanded =
219
+ keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
176
220
  return (
177
- <div key={trackNum} className="relative flex" style={{ height: TRACK_H }}>
178
- <div
179
- className="sticky left-0 z-[12] flex-shrink-0 flex flex-col items-center justify-center gap-0.5"
180
- style={{
181
- width: GUTTER,
182
- background: theme.gutterBackground,
183
- borderRight: `1px solid ${theme.gutterBorder}`,
184
- borderBottom: `1px solid ${theme.rowBorder}`,
221
+ <div
222
+ key={trackNum}
223
+ className="relative flex"
224
+ style={{
225
+ height: rowHeight,
226
+ background: rowBackground,
227
+ borderBottom: `1px solid ${theme.rowBorder}`,
228
+ }}
229
+ >
230
+ <TimelineTrackHeader
231
+ trackNumber={trackNum}
232
+ trackLabel={els[0]?.label ?? els[0]?.domId ?? els[0]?.id ?? `Track ${trackNum}`}
233
+ contentOrigin={contentOrigin}
234
+ keyframeClip={keyframeClip}
235
+ clipCount={els.length}
236
+ isExpanded={keyframeClipExpanded}
237
+ animations={keyframeClipKey ? (gsapAnimations.get(keyframeClipKey) ?? []) : []}
238
+ currentTime={currentTime}
239
+ isTrackHidden={isTrackHidden}
240
+ isAudioTrack={isAudioTrack}
241
+ theme={theme}
242
+ onToggleClipExpanded={() => {
243
+ if (keyframeClipKey) {
244
+ toggleClipExpandedTracked(keyframeClipKey);
245
+ }
185
246
  }}
186
- >
187
- {isAudioTrack && (
188
- <Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
189
- )}
190
- <button
191
- type="button"
192
- aria-label={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
193
- title={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
194
- className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
195
- isTrackHidden
196
- ? "text-[#3CE6AC] hover:text-white"
197
- : "text-white/35 hover:text-white/75"
198
- }`}
199
- onPointerDown={(e) => {
200
- e.stopPropagation();
201
- }}
202
- onClick={(e) => {
203
- e.stopPropagation();
204
- void onToggleTrackHidden?.(trackNum, !isTrackHidden);
205
- }}
206
- >
207
- {isTrackHidden ? (
208
- <EyeSlash size={14} weight="bold" aria-hidden="true" />
209
- ) : (
210
- <Eye size={14} weight="bold" aria-hidden="true" />
211
- )}
212
- </button>
213
- </div>
214
- {/* Left breathing pad — empty lane surface before t=0, scrolling
215
- with the content (the horizontal TRACKS_TOP_PAD). Sits OUTSIDE
216
- the time-mapped content div so clip/beat/menu math stays
217
- content-relative (clip left = t·pps). */}
218
- <div
219
- aria-hidden="true"
220
- className="flex-shrink-0"
221
- style={{ width: TRACKS_LEFT_PAD }}
247
+ onToggleTrackHidden={onToggleTrackHidden}
248
+ onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
249
+ onSeek={onSeek}
222
250
  />
223
251
  <div
224
252
  style={{
225
253
  width: trackContentWidth,
226
- background: rowBackground,
227
- borderBottom: `1px solid ${theme.rowBorder}`,
254
+ marginLeft: contentGutter, // room for a 0% diamond left of t=0
228
255
  opacity: isTrackHidden ? 0.35 : 1,
229
256
  transition: "opacity 120ms ease",
230
257
  }}
@@ -282,6 +309,13 @@ export function TimelineLanes({
282
309
  els.map((el) => {
283
310
  const clipStyle = getTrackStyle(el.tag);
284
311
  const elementKey = el.key ?? el.id;
312
+ // Only the track's active keyframe clip shows expanded lanes;
313
+ // other clips (incl. siblings on a shared track) show compact
314
+ // diamonds on their own bar instead.
315
+ const showsLanes =
316
+ STUDIO_KEYFRAMES_ENABLED &&
317
+ elementKey === keyframeClipKey &&
318
+ keyframeClipExpanded;
285
319
  const capabilities = getTimelineEditCapabilities(el);
286
320
  const isSelected =
287
321
  selectedElementId === elementKey || selectedElementIds.has(elementKey);
@@ -315,6 +349,7 @@ export function TimelineLanes({
315
349
  el={previewElement}
316
350
  pps={pps}
317
351
  clipY={CLIP_Y}
352
+ clipHeight={showsLanes ? TRACK_H - 2 * CLIP_Y : undefined}
318
353
  isSelected={isSelected}
319
354
  isHovered={hoveredClip === clipKey}
320
355
  isDragging={false}
@@ -457,32 +492,70 @@ export function TimelineLanes({
457
492
  renderClipContent,
458
493
  renderClipOverlay,
459
494
  )}
460
- {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
461
- <TimelineClipDiamonds
462
- keyframesData={keyframeCache.get(elementKey)!}
463
- clipWidthPx={Math.max(previewElement.duration * pps, 4)}
464
- clipHeightPx={TRACK_H - 2 * CLIP_Y}
465
- beatsActive={beatStripOnTrack}
466
- accentColor={clipStyle.accent}
467
- isSelected={isSelected}
468
- currentPercentage={
469
- previewElement.duration > 0
470
- ? ((currentTime - previewElement.start) / previewElement.duration) *
471
- 100
472
- : 0
473
- }
474
- elementId={elementKey}
475
- selectedKeyframes={selectedKeyframes}
476
- onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
477
- onShiftClickKeyframe={onShiftClickKeyframe}
478
- onContextMenuKeyframe={onContextMenuKeyframe}
479
- onMoveKeyframe={onMoveKeyframe}
480
- suppressClickRef={suppressClickRef}
481
- />
482
- )}
495
+ {STUDIO_KEYFRAMES_ENABLED &&
496
+ !showsLanes &&
497
+ keyframeCache?.get(elementKey) && (
498
+ <TimelineClipDiamonds
499
+ keyframesData={keyframeCache.get(elementKey)!}
500
+ clipWidthPx={Math.max(previewElement.duration * pps, 4)}
501
+ clipHeightPx={rowHeight - 2 * CLIP_Y}
502
+ beatsActive={beatStripOnTrack}
503
+ accentColor={clipStyle.accent}
504
+ isSelected={isSelected}
505
+ currentPercentage={
506
+ previewElement.duration > 0
507
+ ? ((currentTime - previewElement.start) /
508
+ previewElement.duration) *
509
+ 100
510
+ : 0
511
+ }
512
+ elementId={elementKey}
513
+ selectedKeyframes={selectedKeyframes}
514
+ onClickKeyframe={(_elId, target) =>
515
+ onClickKeyframe?.(previewElement, target)
516
+ }
517
+ onShiftClickKeyframe={onShiftClickKeyframe}
518
+ onContextMenuKeyframe={onContextMenuKeyframe}
519
+ onMoveKeyframe={onMoveKeyframe}
520
+ onSelectSegment={onSelectSegment}
521
+ suppressClickRef={suppressClickRef}
522
+ />
523
+ )}
483
524
  </TimelineClip>
484
525
  );
485
- if (!isPassenger) return clip;
526
+ const propertyLanes = showsLanes && (
527
+ <TimelinePropertyLanes
528
+ key={`${clipKey}-property-lanes`}
529
+ animations={gsapAnimations.get(elementKey) ?? []}
530
+ clipStart={previewElement.start}
531
+ clipDuration={previewElement.duration}
532
+ clipLeftPx={previewElement.start * pps}
533
+ clipWidthPx={Math.max(previewElement.duration * pps, 4)}
534
+ accentColor={clipStyle.accent}
535
+ isSelected={isSelected}
536
+ currentPercentage={
537
+ previewElement.duration > 0
538
+ ? ((currentTime - previewElement.start) / previewElement.duration) * 100
539
+ : 0
540
+ }
541
+ elementId={elementKey}
542
+ selectedKeyframes={selectedKeyframes}
543
+ onSelectSegment={(target) => onSelectSegment?.(elementKey, target)}
544
+ onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)}
545
+ onShiftClickKeyframe={(target) =>
546
+ onShiftClickKeyframe?.(elementKey, target)
547
+ }
548
+ onContextMenuKeyframe={(e, target) =>
549
+ onContextMenuKeyframe?.(e, elementKey, target)
550
+ }
551
+ onMoveKeyframe={(target, toClipPercentage) =>
552
+ onMoveKeyframe?.(elementKey, target, toClipPercentage) ??
553
+ Promise.resolve(false)
554
+ }
555
+ suppressClickRef={suppressClickRef}
556
+ />
557
+ );
558
+ if (!isPassenger) return [clip, propertyLanes];
486
559
  return (
487
560
  <div
488
561
  key={clipKey}
@@ -495,6 +568,7 @@ export function TimelineLanes({
495
568
  }}
496
569
  >
497
570
  {clip}
571
+ {propertyLanes}
498
572
  </div>
499
573
  );
500
574
  })
@@ -1,4 +1,4 @@
1
- import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
1
+ import type { TimelineElement } from "../store/playerStore";
2
2
  import type { TimelineTheme } from "./timelineTheme";
3
3
  import type { TimelineRangeSelection } from "./timelineEditing";
4
4
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -38,9 +38,7 @@ interface TimelineOverlaysProps {
38
38
  setKfContextMenu: (value: KeyframeDiamondContextMenuState | null) => void;
39
39
  onDeleteKeyframe: TimelineEditCallbacks["onDeleteKeyframe"];
40
40
  onDeleteAllKeyframes: TimelineEditCallbacks["onDeleteAllKeyframes"];
41
- onChangeKeyframeEase: TimelineEditCallbacks["onChangeKeyframeEase"];
42
41
  onMoveKeyframeToPlayhead: TimelineEditCallbacks["onMoveKeyframeToPlayhead"];
43
- keyframeCache: Map<string, KeyframeCacheEntry>;
44
42
  clipContextMenu: ClipContextMenuState | null;
45
43
  setClipContextMenu: (value: ClipContextMenuState | null) => void;
46
44
  currentTime: number;
@@ -68,9 +66,7 @@ export function TimelineOverlays({
68
66
  setKfContextMenu,
69
67
  onDeleteKeyframe,
70
68
  onDeleteAllKeyframes,
71
- onChangeKeyframeEase,
72
69
  onMoveKeyframeToPlayhead,
73
- keyframeCache,
74
70
  clipContextMenu,
75
71
  setClipContextMenu,
76
72
  currentTime,
@@ -106,21 +102,11 @@ export function TimelineOverlays({
106
102
  <KeyframeDiamondContextMenu
107
103
  state={kfContextMenu}
108
104
  onClose={() => setKfContextMenu(null)}
109
- onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)}
110
- onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
111
- onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)}
105
+ onDelete={(elId, keyframe) => onDeleteKeyframe?.(elId, keyframe)}
106
+ onDeleteAll={(element) => onDeleteAllKeyframes?.(element)}
112
107
  onMoveToPlayhead={
113
- onMoveKeyframeToPlayhead
114
- ? (elId, pct) => onMoveKeyframeToPlayhead(elId, pct)
115
- : undefined
108
+ onMoveKeyframeToPlayhead ? (...args) => onMoveKeyframeToPlayhead(...args) : undefined
116
109
  }
117
- onCopyProperties={(elId, pct) => {
118
- const kfData = keyframeCache.get(elId);
119
- const kf = kfData?.keyframes.find((k) => k.percentage === pct);
120
- if (kf) {
121
- void navigator.clipboard.writeText(JSON.stringify(kf.properties, null, 2));
122
- }
123
- }}
124
110
  />
125
111
  )}
126
112