@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
@@ -19,8 +19,11 @@ import {
19
19
  shouldAutoScrollTimeline,
20
20
  } from "./Timeline";
21
21
  import {
22
+ CLIP_Y,
22
23
  FIT_ZOOM_HEADROOM,
23
24
  GUTTER,
25
+ LABEL_COL_W,
26
+ LANE_H,
24
27
  MIN_TIMELINE_EXTENT_S,
25
28
  PLAYHEAD_HEAD_W,
26
29
  RULER_H,
@@ -28,6 +31,7 @@ import {
28
31
  TRACKS_LEFT_PAD,
29
32
  getTimelineDisplayContentWidth,
30
33
  getTimelineFitPps,
34
+ getTimelineLaneTop,
31
35
  } from "./timelineLayout";
32
36
  import { formatTime } from "../lib/time";
33
37
  import { usePlayerStore } from "../store/playerStore";
@@ -40,52 +44,216 @@ afterEach(() => {
40
44
  usePlayerStore.getState().reset();
41
45
  });
42
46
 
43
- describe("Timeline provider boundary", () => {
44
- // fallow-ignore-next-line code-duplication
45
- it("renders the public Timeline export without TimelineEditProvider", () => {
46
- const host = document.createElement("div");
47
- document.body.append(host);
48
- Object.defineProperty(host, "clientWidth", {
49
- configurable: true,
50
- value: 640,
51
- });
47
+ function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
48
+ const clip = host.querySelector<HTMLElement>(`[data-el-id="${clipId}"]`);
49
+ if (!clip) throw new Error(`Missing timeline clip ${clipId}`);
50
+ const trackContent = clip.parentElement;
51
+ if (!trackContent) throw new Error(`Missing content row for ${clipId}`);
52
+ const trackHeader = trackContent.previousElementSibling;
53
+ if (!(trackHeader instanceof HTMLElement)) throw new Error(`Missing track header for ${clipId}`);
54
+ const rulerTickLabel = Array.from(host.querySelectorAll("span")).find(
55
+ (node) => node.textContent === tickLabel,
56
+ );
57
+ const rulerTick = rulerTickLabel?.parentElement;
58
+ if (!rulerTick) throw new Error(`Missing ruler tick ${tickLabel}`);
59
+ const ruler = rulerTick.parentElement;
60
+ if (!ruler) throw new Error("Missing timeline ruler");
61
+ const rulerOrigin = ruler.previousElementSibling;
62
+ if (!(rulerOrigin instanceof HTMLElement)) throw new Error("Missing timeline ruler origin");
63
+ const playhead = Array.from(host.querySelectorAll<HTMLElement>("div")).find(
64
+ (node) => node.style.zIndex === "100",
65
+ );
66
+ if (!playhead) throw new Error("Missing timeline playhead");
67
+ return { clip, trackHeader, rulerTick, rulerOrigin, playhead };
68
+ }
69
+
70
+ function renderTimelineGeometry(clipId: string) {
71
+ const host = document.createElement("div");
72
+ document.body.append(host);
73
+ const root = createRoot(host);
74
+ act(() => {
75
+ root.render(React.createElement(Timeline));
76
+ });
77
+ return { host, root, ...getHorizontalGeometry(host, clipId, "00:10") };
78
+ }
79
+
80
+ function createSizedTimelineHost(width: number): HTMLDivElement {
81
+ const host = document.createElement("div");
82
+ document.body.append(host);
83
+ Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
84
+ return host;
85
+ }
86
+
87
+ function expectTrackExpansion(
88
+ row: HTMLElement | null | undefined,
89
+ expandedClipIds: string[],
90
+ height: number,
91
+ ) {
92
+ expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(expandedClipIds));
93
+ expect(row?.style.height).toBe(`${height}px`);
94
+ }
95
+
96
+ function renderBasicTimeline() {
97
+ const host = createSizedTimelineHost(640);
98
+ usePlayerStore.setState({
99
+ duration: 4,
100
+ timelineReady: true,
101
+ elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
102
+ });
103
+ const root = createRoot(host);
104
+ act(() => {
105
+ root.render(React.createElement(Timeline));
106
+ });
107
+ return { host, root };
108
+ }
52
109
 
110
+ describe("Timeline provider boundary", () => {
111
+ it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => {
53
112
  usePlayerStore.setState({
54
- duration: 4,
113
+ duration: 11,
55
114
  timelineReady: true,
56
- elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
115
+ currentTime: 10,
116
+ zoomMode: "manual",
117
+ manualZoomPercent: 100,
118
+ elements: [{ id: "clip-1", tag: "div", start: 10, duration: 1, track: 0 }],
57
119
  });
58
120
 
59
- const root = createRoot(host);
60
-
61
- expect(() => {
62
- act(() => {
63
- root.render(React.createElement(Timeline));
64
- });
65
- }).not.toThrow();
121
+ const { root, clip, trackHeader, rulerTick, rulerOrigin, playhead } =
122
+ renderTimelineGeometry("clip-1");
123
+
124
+ expect(trackHeader.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
125
+ expect(clip.style.left).toBe("1000px");
126
+ expect(clip.style.height).toBe("");
127
+ expect(clip.style.bottom).toBe(`${CLIP_Y}px`);
128
+ expect(rulerOrigin.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
129
+ expect(rulerTick.style.left).toBe("999.5px");
130
+ expect(playhead.style.left).toBe(`${GUTTER + TRACKS_LEFT_PAD + 1000 - PLAYHEAD_HEAD_W / 2}px`);
131
+ expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`);
132
+ expect(
133
+ resolveTimelineAssetDrop(
134
+ {
135
+ rectLeft: 100,
136
+ rectTop: 0,
137
+ scrollLeft: 0,
138
+ scrollTop: 0,
139
+ contentOrigin: GUTTER,
140
+ pixelsPerSecond: 100,
141
+ duration: 60,
142
+ trackOrder: [0],
143
+ },
144
+ 1132,
145
+ 100,
146
+ ).start,
147
+ ).toBe(10);
148
+ expect(getTimelineFitPps(640, 11, GUTTER)).toBe(10.1);
66
149
 
67
150
  act(() => root.unmount());
68
151
  });
69
152
 
70
- // fallow-ignore-next-line code-duplication
71
- it("renders the gutter without legacy icons or hue dots", () => {
72
- const host = document.createElement("div");
73
- document.body.append(host);
74
- Object.defineProperty(host, "clientWidth", {
75
- configurable: true,
76
- value: 640,
77
- });
78
-
153
+ it("reserves the label column and keeps expanded keyframes aligned with ruler time", () => {
79
154
  usePlayerStore.setState({
80
- duration: 4,
155
+ duration: 20,
81
156
  timelineReady: true,
82
- elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
157
+ currentTime: 10,
158
+ zoomMode: "manual",
159
+ manualZoomPercent: 100,
160
+ selectedElementId: "clip-1",
161
+ expandedClipIds: new Set(["clip-1"]),
162
+ elements: [
163
+ { id: "clip-1", label: "Hero card", tag: "div", start: 0, duration: 20, track: 0 },
164
+ { id: "clip-2", label: "Outro", tag: "div", start: 2, duration: 1, track: 1 },
165
+ ],
166
+ gsapAnimations: new Map([
167
+ [
168
+ "clip-1",
169
+ [
170
+ {
171
+ id: "position-tween",
172
+ targetSelector: "#clip-1",
173
+ method: "to",
174
+ position: 0,
175
+ duration: 20,
176
+ properties: {},
177
+ propertyGroup: "position",
178
+ keyframes: {
179
+ format: "percentage",
180
+ keyframes: [{ percentage: 50, properties: { x: 100 } }],
181
+ },
182
+ },
183
+ ],
184
+ ],
185
+ ]),
83
186
  });
84
187
 
85
- const root = createRoot(host);
86
- act(() => {
87
- root.render(React.createElement(Timeline));
88
- });
188
+ const { host, root, clip, trackHeader, rulerTick, rulerOrigin, playhead } =
189
+ renderTimelineGeometry("clip-1");
190
+ const { trackHeader: collapsedHeader } = getHorizontalGeometry(host, "clip-2", "00:10");
191
+ const diamond = host.querySelector<HTMLElement>(
192
+ '[data-keyframe-group="position"][data-keyframe-percentage="50"]',
193
+ );
194
+ if (!diamond) throw new Error("Missing expanded position keyframe");
195
+ const propertyLane = diamond.closest<HTMLElement>("[data-timeline-property-lane]");
196
+ if (!propertyLane) throw new Error("Missing flat position property lane");
197
+ const headerLane = trackHeader.querySelector<HTMLElement>('[data-property-group="position"]');
198
+ if (!headerLane) throw new Error("Missing position property header");
199
+ // Absolute x rebuilds from the content origin (the ruler-origin spacer),
200
+ // which now insets a GUTTER past the LABEL_COL_W label column so a 0%
201
+ // diamond has room to its left. The content row reaches that same origin via
202
+ // header (LABEL_COL_W) + its gutter margin, so ruler tick and diamond still
203
+ // coincide on the shared time x.
204
+ const contentOrigin = Number.parseFloat(rulerOrigin.style.width);
205
+ const rulerX = contentOrigin + Number.parseFloat(rulerTick.style.left) + 0.5;
206
+ const diamondX =
207
+ contentOrigin +
208
+ Number.parseFloat(propertyLane.style.left) +
209
+ Number.parseFloat(diamond.style.left) +
210
+ Number.parseFloat(diamond.style.width) / 2;
211
+
212
+ expect(clip.contains(propertyLane)).toBe(false);
213
+ expect(clip.style.height).toBe(`${TRACK_H - 2 * CLIP_Y}px`);
214
+ expect(clip.style.bottom).toBe("");
215
+ expect(propertyLane.style.top).toBe(`${getTimelineLaneTop(0)}px`);
216
+ expect(propertyLane.style.top).toBe(headerLane.style.top);
217
+ expect(propertyLane.style.background).toBe("");
218
+ expect(propertyLane.style.border).toBe("");
219
+ expect(propertyLane.style.borderRadius).toBe("");
220
+ expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`);
221
+ expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`);
222
+ expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`);
223
+ expect(diamondX).toBe(rulerX);
224
+ expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000);
225
+ expect(collapsedHeader.textContent).toContain("Outro");
226
+ expect(getTimelineFitPps(640, 20, LABEL_COL_W + GUTTER)).toBeCloseTo(
227
+ (640 - (LABEL_COL_W + GUTTER) - 2) / MIN_TIMELINE_EXTENT_S,
228
+ );
229
+ expect(
230
+ resolveTimelineAssetDrop(
231
+ {
232
+ rectLeft: 100,
233
+ rectTop: 0,
234
+ scrollLeft: 0,
235
+ scrollTop: 0,
236
+ contentOrigin: LABEL_COL_W + GUTTER,
237
+ pixelsPerSecond: 100,
238
+ duration: 60,
239
+ trackOrder: [0],
240
+ },
241
+ 100 + LABEL_COL_W + GUTTER + 1000,
242
+ 100,
243
+ ).start,
244
+ ).toBe(10);
245
+
246
+ act(() => root.unmount());
247
+ });
248
+
249
+ it("renders the public Timeline export without TimelineEditProvider", () => {
250
+ const { root } = renderBasicTimeline();
251
+
252
+ act(() => root.unmount());
253
+ });
254
+
255
+ it("renders the gutter without legacy icons or hue dots", () => {
256
+ const { host, root } = renderBasicTimeline();
89
257
 
90
258
  const hueDot = Array.from(host.querySelectorAll("div")).find(
91
259
  (node) =>
@@ -99,14 +267,8 @@ describe("Timeline provider boundary", () => {
99
267
  act(() => root.unmount());
100
268
  });
101
269
 
102
- // fallow-ignore-next-line code-duplication
103
270
  it("requests persisted track visibility from the gutter without seeking", () => {
104
- const host = document.createElement("div");
105
- document.body.append(host);
106
- Object.defineProperty(host, "clientWidth", {
107
- configurable: true,
108
- value: 640,
109
- });
271
+ const host = createSizedTimelineHost(640);
110
272
 
111
273
  usePlayerStore.setState({
112
274
  duration: 4,
@@ -153,8 +315,8 @@ describe("Timeline provider boundary", () => {
153
315
  });
154
316
 
155
317
  const row = button.parentElement?.parentElement;
156
- // Row children: [sticky gutter, TRACKS_LEFT_PAD spacer, time-mapped content].
157
- const trackContent = row?.children.item(2);
318
+ // Row children: [TimelineTrackHeader (sticky column), time-mapped content].
319
+ const trackContent = row?.children.item(1);
158
320
  expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
159
321
  expect(trackContent).toBeInstanceOf(HTMLElement);
160
322
  if (!(trackContent instanceof HTMLElement)) {
@@ -165,14 +327,49 @@ describe("Timeline provider boundary", () => {
165
327
  act(() => root.unmount());
166
328
  });
167
329
 
168
- it("opens the keyframe context menu without seeking to that keyframe", () => {
169
- const host = document.createElement("div");
170
- document.body.append(host);
171
- Object.defineProperty(host, "clientWidth", {
172
- configurable: true,
173
- value: 720,
330
+ it("splits all tracks once when shift-clicking the timeline with the razor", () => {
331
+ const host = createSizedTimelineHost(640);
332
+ usePlayerStore.setState({
333
+ activeTool: "razor",
334
+ duration: 4,
335
+ timelineReady: true,
336
+ elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
174
337
  });
175
338
 
339
+ const onRazorSplitAll = vi.fn();
340
+ const root = createRoot(host);
341
+ act(() => {
342
+ root.render(
343
+ React.createElement(
344
+ TimelineEditProvider,
345
+ { value: { onRazorSplitAll } },
346
+ React.createElement(Timeline),
347
+ ),
348
+ );
349
+ });
350
+
351
+ const viewport = host.querySelector('[aria-label="Timeline"]')?.firstElementChild;
352
+ expect(viewport).toBeInstanceOf(HTMLElement);
353
+ act(() => {
354
+ viewport?.dispatchEvent(
355
+ new MouseEvent("pointerdown", {
356
+ bubbles: true,
357
+ cancelable: true,
358
+ button: 0,
359
+ clientX: 240,
360
+ shiftKey: true,
361
+ }),
362
+ );
363
+ });
364
+
365
+ expect(onRazorSplitAll).toHaveBeenCalledTimes(1);
366
+ expect(onRazorSplitAll).toHaveBeenCalledWith(expect.any(Number));
367
+ act(() => root.unmount());
368
+ });
369
+
370
+ it("opens the keyframe context menu without seeking to that keyframe", () => {
371
+ const host = createSizedTimelineHost(720);
372
+
176
373
  usePlayerStore.setState({
177
374
  duration: 4,
178
375
  timelineReady: true,
@@ -215,14 +412,101 @@ describe("Timeline provider boundary", () => {
215
412
  act(() => root.unmount());
216
413
  });
217
414
 
218
- it("marks every clip in selectedElementIds as selected", () => {
219
- const host = document.createElement("div");
220
- document.body.append(host);
221
- Object.defineProperty(host, "clientWidth", {
222
- configurable: true,
223
- value: 720,
415
+ it("shows a disclosure only for grouped keyframes and toggles the track height", () => {
416
+ const host = createSizedTimelineHost(720);
417
+
418
+ usePlayerStore.setState({
419
+ duration: 4,
420
+ timelineReady: true,
421
+ elements: [
422
+ { id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 },
423
+ { id: "clip-2", tag: "div", start: 2, duration: 2, track: 1 },
424
+ ],
425
+ keyframeCache: new Map([
426
+ [
427
+ "clip-1",
428
+ {
429
+ format: "percentage",
430
+ keyframes: [
431
+ { percentage: 0, properties: { x: 0 }, propertyGroup: "position" },
432
+ { percentage: 50, properties: { x: 100 }, propertyGroup: "position" },
433
+ { percentage: 100, properties: { opacity: 0 }, propertyGroup: "visual" },
434
+ ],
435
+ },
436
+ ],
437
+ ]),
438
+ gsapAnimations: new Map([
439
+ [
440
+ "clip-1",
441
+ [
442
+ {
443
+ id: "clip-1-position",
444
+ targetSelector: "#clip-1",
445
+ method: "to",
446
+ position: 0,
447
+ duration: 2,
448
+ properties: {},
449
+ propertyGroup: "position",
450
+ keyframes: {
451
+ format: "percentage",
452
+ keyframes: [
453
+ { percentage: 0, properties: { x: 0 } },
454
+ { percentage: 50, properties: { x: 100 } },
455
+ ],
456
+ },
457
+ },
458
+ {
459
+ id: "clip-1-visual",
460
+ targetSelector: "#clip-1",
461
+ method: "to",
462
+ position: 0,
463
+ duration: 2,
464
+ properties: {},
465
+ propertyGroup: "visual",
466
+ keyframes: {
467
+ format: "percentage",
468
+ keyframes: [{ percentage: 100, properties: { opacity: 0 } }],
469
+ },
470
+ },
471
+ ],
472
+ ],
473
+ ]),
224
474
  });
225
475
 
476
+ const root = createRoot(host);
477
+ act(() => {
478
+ root.render(React.createElement(Timeline));
479
+ });
480
+
481
+ // Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure
482
+ // lives in the left column. clip-2 has no keyframes so it never shows one.
483
+ const collapseButton = host.querySelector<HTMLButtonElement>(
484
+ 'button[aria-label="Collapse clip-1 keyframes"]',
485
+ );
486
+ expect(collapseButton).not.toBeNull();
487
+ expect(host.querySelector('button[aria-label="Expand clip-2 keyframes"]')).toBeNull();
488
+ expect(host.querySelector('button[aria-label="Collapse clip-2 keyframes"]')).toBeNull();
489
+
490
+ const clip = host.querySelector<HTMLElement>('[data-el-id="clip-1"]');
491
+ const row = clip?.parentElement?.parentElement;
492
+ expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H);
493
+
494
+ // Collapsing sticks (does not bounce back open via auto-expand).
495
+ act(() => collapseButton?.click());
496
+ expectTrackExpansion(row, [], TRACK_H);
497
+
498
+ const expandButton = host.querySelector<HTMLButtonElement>(
499
+ 'button[aria-label="Expand clip-1 keyframes"]',
500
+ );
501
+ expect(expandButton).not.toBeNull();
502
+ act(() => expandButton?.click());
503
+ expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H);
504
+ act(() => root.unmount());
505
+ });
506
+
507
+ it("marks every clip in selectedElementIds as selected", () => {
508
+ const host = createSizedTimelineHost(720);
509
+
226
510
  usePlayerStore.setState({
227
511
  duration: 6,
228
512
  timelineReady: true,
@@ -461,23 +745,29 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
461
745
  it("computes fit pps against the 60s floor for short compositions", () => {
462
746
  // A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
463
747
  // (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
464
- const pps = getTimelineFitPps(viewport, 10);
465
- expect(pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S);
466
- expect(10 * pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / 6);
748
+ const pps = getTimelineFitPps(viewport, 10, GUTTER + TRACKS_LEFT_PAD);
749
+ expect(pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S);
750
+ expect(10 * pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / 6);
467
751
  });
468
752
 
469
753
  it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
470
- expect(getTimelineFitPps(viewport, 60)).toBeCloseTo(
471
- (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (60 * FIT_ZOOM_HEADROOM),
754
+ expect(getTimelineFitPps(viewport, 60, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo(
755
+ (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / (60 * FIT_ZOOM_HEADROOM),
756
+ );
757
+ expect(getTimelineFitPps(viewport, 120, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo(
758
+ (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / (120 * FIT_ZOOM_HEADROOM),
472
759
  );
473
- expect(getTimelineFitPps(viewport, 120)).toBeCloseTo(
474
- (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (120 * FIT_ZOOM_HEADROOM),
760
+ });
761
+
762
+ it("subtracts the expanded keyframe label column before fitting headroom", () => {
763
+ expect(getTimelineFitPps(viewport, 120, LABEL_COL_W)).toBeCloseTo(
764
+ (viewport - LABEL_COL_W - 2) / (120 * FIT_ZOOM_HEADROOM),
475
765
  );
476
766
  });
477
767
 
478
768
  it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => {
479
- const usable = viewport - GUTTER - TRACKS_LEFT_PAD - 2;
480
- const pps = getTimelineFitPps(viewport, 120);
769
+ const usable = viewport - (GUTTER + TRACKS_LEFT_PAD) - 2;
770
+ const pps = getTimelineFitPps(viewport, 120, GUTTER + TRACKS_LEFT_PAD);
481
771
  // Composition content occupies usable/1.2 px; the remaining ~17% is empty
482
772
  // droppable ruler/lane surface past the end.
483
773
  expect(120 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM);
@@ -485,17 +775,17 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
485
775
  });
486
776
 
487
777
  it("falls back to 100 pps before the viewport is measured", () => {
488
- expect(getTimelineFitPps(0, 10)).toBe(100);
489
- expect(getTimelineFitPps(GUTTER + TRACKS_LEFT_PAD, 10)).toBe(100);
490
- expect(getTimelineFitPps(Number.NaN, 10)).toBe(100);
778
+ expect(getTimelineFitPps(0, 10, GUTTER)).toBe(100);
779
+ expect(getTimelineFitPps(GUTTER, 10, GUTTER)).toBe(100);
780
+ expect(getTimelineFitPps(Number.NaN, 10, GUTTER)).toBe(100);
491
781
  });
492
782
 
493
783
  it("uses the floor for zero/invalid durations", () => {
494
- expect(getTimelineFitPps(viewport, 0)).toBeCloseTo(
495
- (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S,
784
+ expect(getTimelineFitPps(viewport, 0, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo(
785
+ (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S,
496
786
  );
497
- expect(getTimelineFitPps(viewport, Number.NaN)).toBeCloseTo(
498
- (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S,
787
+ expect(getTimelineFitPps(viewport, Number.NaN, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo(
788
+ (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S,
499
789
  );
500
790
  });
501
791
  });
@@ -504,14 +794,24 @@ describe("getTimelineDisplayContentWidth", () => {
504
794
  it("always spans at least MIN_TIMELINE_EXTENT_S seconds of content", () => {
505
795
  // 10s of content at 20 pps = 200px; the floor keeps 60s (1200px) rendered.
506
796
  expect(
507
- getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 400, pps: 20 }),
797
+ getTimelineDisplayContentWidth({
798
+ trackContentWidth: 200,
799
+ viewportWidth: 400,
800
+ contentOrigin: GUTTER,
801
+ pps: 20,
802
+ }),
508
803
  ).toBe(MIN_TIMELINE_EXTENT_S * 20);
509
804
  });
510
805
 
511
806
  it("still fills the viewport when that is larger than the 60s floor", () => {
512
807
  expect(
513
- getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 2000, pps: 5 }),
514
- ).toBe(2000 - GUTTER - TRACKS_LEFT_PAD - 2);
808
+ getTimelineDisplayContentWidth({
809
+ trackContentWidth: 200,
810
+ viewportWidth: 2000,
811
+ contentOrigin: GUTTER + TRACKS_LEFT_PAD,
812
+ pps: 5,
813
+ }),
814
+ ).toBe(2000 - (GUTTER + TRACKS_LEFT_PAD) - 2);
515
815
  });
516
816
 
517
817
  it("tracks a drag ghost past every other bound (drag-to-extend)", () => {
@@ -519,6 +819,7 @@ describe("getTimelineDisplayContentWidth", () => {
519
819
  getTimelineDisplayContentWidth({
520
820
  trackContentWidth: 500,
521
821
  viewportWidth: 400,
822
+ contentOrigin: GUTTER,
522
823
  pps: 5,
523
824
  dragGhostEndPx: 5000,
524
825
  }),
@@ -530,6 +831,7 @@ describe("getTimelineDisplayContentWidth", () => {
530
831
  getTimelineDisplayContentWidth({
531
832
  trackContentWidth: 500,
532
833
  viewportWidth: 400,
834
+ contentOrigin: GUTTER,
533
835
  pps: 5,
534
836
  resizeGhostEndPx: 4200,
535
837
  }),
@@ -538,7 +840,12 @@ describe("getTimelineDisplayContentWidth", () => {
538
840
 
539
841
  it("keeps long content authoritative", () => {
540
842
  expect(
541
- getTimelineDisplayContentWidth({ trackContentWidth: 9000, viewportWidth: 400, pps: 50 }),
843
+ getTimelineDisplayContentWidth({
844
+ trackContentWidth: 9000,
845
+ viewportWidth: 400,
846
+ contentOrigin: GUTTER,
847
+ pps: 50,
848
+ }),
542
849
  ).toBe(9000);
543
850
  });
544
851
  });
@@ -565,7 +872,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
565
872
  getTimelineScrollLeftForZoomAnchor({
566
873
  pointerX: 300,
567
874
  currentScrollLeft: 200,
568
- gutter: 32,
875
+ contentOrigin: GUTTER,
569
876
  currentPixelsPerSecond: 10,
570
877
  nextPixelsPerSecond: 20,
571
878
  duration: 120,
@@ -578,7 +885,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
578
885
  getTimelineScrollLeftForZoomAnchor({
579
886
  pointerX: 300,
580
887
  currentScrollLeft: 0,
581
- gutter: 32,
888
+ contentOrigin: GUTTER,
582
889
  currentPixelsPerSecond: 20,
583
890
  nextPixelsPerSecond: 5,
584
891
  duration: 120,
@@ -591,7 +898,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
591
898
  getTimelineScrollLeftForZoomAnchor({
592
899
  pointerX: 300,
593
900
  currentScrollLeft: 120,
594
- gutter: 32,
901
+ contentOrigin: GUTTER,
595
902
  currentPixelsPerSecond: 0,
596
903
  nextPixelsPerSecond: 20,
597
904
  duration: 120,
@@ -601,38 +908,59 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
601
908
  });
602
909
 
603
910
  describe("getTimelinePlayheadLeft", () => {
604
- it("offsets the wrapper by half the head width so the line CENTER = GUTTER + TRACKS_LEFT_PAD + t*pps", () => {
911
+ it("offsets the wrapper by half the head width so the line CENTER = contentOrigin + t*pps", () => {
605
912
  // Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must
606
- // equal GUTTER + TRACKS_LEFT_PAD + t*pps at any zoom.
607
- expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(
913
+ // equal contentOrigin + t*pps at any zoom, for both the padded default
914
+ // origin and the plain gutter origin.
915
+ expect(getTimelinePlayheadLeft(4, 20, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe(
608
916
  GUTTER + TRACKS_LEFT_PAD + 4 * 20,
609
917
  );
610
- expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(
918
+ expect(getTimelinePlayheadLeft(10, 7.5, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe(
611
919
  GUTTER + TRACKS_LEFT_PAD + 75,
612
920
  );
921
+ expect(getTimelinePlayheadLeft(4, 20, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20);
922
+ expect(getTimelinePlayheadLeft(10, 7.5, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75);
923
+ });
924
+
925
+ it("uses the expanded keyframe label column as the playhead origin", () => {
926
+ expect(getTimelinePlayheadLeft(4, 20, LABEL_COL_W) + PLAYHEAD_HEAD_W / 2).toBe(
927
+ LABEL_COL_W + 4 * 20,
928
+ );
613
929
  });
614
930
 
615
931
  it("centers the line exactly on the left pad's end (the 00:00 tick) at t = 0", () => {
616
- expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + TRACKS_LEFT_PAD);
932
+ expect(getTimelinePlayheadLeft(0, 20, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe(
933
+ GUTTER + TRACKS_LEFT_PAD,
934
+ );
935
+ });
936
+
937
+ it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => {
938
+ expect(getTimelinePlayheadLeft(0, 20, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER);
617
939
  });
618
940
 
619
941
  it("guards invalid input", () => {
620
- expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(
942
+ expect(getTimelinePlayheadLeft(Number.NaN, 20, GUTTER + TRACKS_LEFT_PAD)).toBe(
621
943
  GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2,
622
944
  );
623
- expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(
945
+ expect(getTimelinePlayheadLeft(4, Number.NaN, GUTTER + TRACKS_LEFT_PAD)).toBe(
624
946
  GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2,
625
947
  );
948
+ expect(getTimelinePlayheadLeft(Number.NaN, 20, GUTTER)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
949
+ expect(getTimelinePlayheadLeft(4, Number.NaN, LABEL_COL_W)).toBe(
950
+ LABEL_COL_W - PLAYHEAD_HEAD_W / 2,
951
+ );
626
952
  });
627
953
  });
628
954
 
629
955
  describe("getTimelineCanvasHeight", () => {
630
956
  it("includes bottom scroll buffer below the last track", () => {
631
- expect(getTimelineCanvasHeight(3)).toBeGreaterThan(RULER_H + 3 * TRACK_H);
957
+ expect(getTimelineCanvasHeight([TRACK_H, TRACK_H, TRACK_H])).toBeGreaterThan(
958
+ RULER_H + 3 * TRACK_H,
959
+ );
632
960
  });
633
961
 
634
962
  it("still keeps ruler space when there are no tracks", () => {
635
- expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
963
+ expect(getTimelineCanvasHeight([])).toBeGreaterThan(24);
636
964
  });
637
965
  });
638
966
 
@@ -686,14 +1014,15 @@ describe("resolveTimelineAssetDrop", () => {
686
1014
  rectTop: 200,
687
1015
  scrollLeft: 0,
688
1016
  scrollTop: 0,
1017
+ contentOrigin: GUTTER,
689
1018
  pixelsPerSecond: 100,
690
1019
  duration: 10,
691
1020
  trackHeight: 72,
692
1021
  trackOrder: [0, 3, 7],
693
1022
  },
694
- 480, // rectLeft(100) + GUTTER + TRACKS_LEFT_PAD + 3s*100pps
695
- // clientY updated for TRACKS_TOP_PAD=72: rectTop(200) + RULER_H(24) +
696
- // TRACKS_TOP_PAD(72) + TRACK_H(48) + TRACK_H/2(24) = 368 → row 1 → track 3.
1023
+ 432, // rectLeft(100) + GUTTER(32) + 3s*100pps (contentOrigin = GUTTER)
1024
+ // clientY: rectTop(200) + RULER_H(24) + TRACKS_TOP_PAD(72) + TRACK_H(48)
1025
+ // + TRACK_H/2(24) = 368 → row 1 → track 3.
697
1026
  368,
698
1027
  ),
699
1028
  ).toEqual({ start: 3, track: 3 });
@@ -707,12 +1036,13 @@ describe("resolveTimelineAssetDrop", () => {
707
1036
  rectTop: 200,
708
1037
  scrollLeft: 0,
709
1038
  scrollTop: 0,
1039
+ contentOrigin: GUTTER,
710
1040
  pixelsPerSecond: 100,
711
1041
  duration: 10,
712
1042
  trackHeight: 72,
713
1043
  trackOrder: [0, 3, 7],
714
1044
  },
715
- 250 + TRACKS_LEFT_PAD,
1045
+ 250, // rectLeft(100) + GUTTER(32) + 1.18s*100pps (contentOrigin = GUTTER)
716
1046
  600,
717
1047
  ),
718
1048
  ).toEqual({ start: 1.18, track: 8 });