@hyperframes/studio 0.6.73 → 0.6.75

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 (63) hide show
  1. package/dist/assets/index-DcyZuBcU.css +1 -0
  2. package/dist/assets/index-uB_W2GDl.js +140 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +30 -24
  6. package/src/components/StudioPreviewArea.tsx +101 -26
  7. package/src/components/StudioRightPanel.tsx +3 -0
  8. package/src/components/StudioToast.tsx +18 -0
  9. package/src/components/TimelineToolbar.tsx +230 -4
  10. package/src/components/editor/AnimationCard.tsx +68 -4
  11. package/src/components/editor/DomEditOverlay.tsx +70 -1
  12. package/src/components/editor/GridOverlay.tsx +50 -0
  13. package/src/components/editor/KeyframeDiamond.tsx +49 -0
  14. package/src/components/editor/KeyframeNavigation.tsx +139 -0
  15. package/src/components/editor/LayersPanel.test.ts +135 -0
  16. package/src/components/editor/LayersPanel.tsx +151 -15
  17. package/src/components/editor/PropertyPanel.tsx +293 -140
  18. package/src/components/editor/SnapGuideOverlay.tsx +166 -0
  19. package/src/components/editor/SnapToolbar.tsx +163 -0
  20. package/src/components/editor/SpringEaseEditor.tsx +256 -0
  21. package/src/components/editor/domEditOverlayGestures.ts +7 -0
  22. package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
  23. package/src/components/editor/gsapAnimationConstants.ts +42 -0
  24. package/src/components/editor/gsapAnimationHelpers.ts +2 -1
  25. package/src/components/editor/manualEditingAvailability.ts +6 -0
  26. package/src/components/editor/manualEditsDom.ts +56 -2
  27. package/src/components/editor/manualOffsetDrag.ts +19 -3
  28. package/src/components/editor/propertyPanelHelpers.ts +90 -0
  29. package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
  30. package/src/components/editor/snapEngine.test.ts +657 -0
  31. package/src/components/editor/snapEngine.ts +575 -0
  32. package/src/components/editor/snapTargetCollection.ts +147 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
  34. package/src/components/editor/useLayerDrag.ts +213 -0
  35. package/src/components/nle/NLELayout.tsx +18 -0
  36. package/src/contexts/DomEditContext.tsx +27 -0
  37. package/src/hooks/gsapRuntimeBridge.ts +585 -0
  38. package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
  39. package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
  40. package/src/hooks/useAppHotkeys.ts +63 -1
  41. package/src/hooks/useDomEditCommits.ts +88 -4
  42. package/src/hooks/useDomEditSession.ts +179 -65
  43. package/src/hooks/useGsapScriptCommits.ts +144 -7
  44. package/src/hooks/useGsapSelectionHandlers.ts +202 -0
  45. package/src/hooks/useGsapTweenCache.ts +174 -3
  46. package/src/hooks/useTimelineEditing.ts +93 -0
  47. package/src/icons/SystemIcons.tsx +2 -0
  48. package/src/player/components/ClipContextMenu.tsx +99 -0
  49. package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
  50. package/src/player/components/Timeline.test.ts +2 -1
  51. package/src/player/components/Timeline.tsx +108 -68
  52. package/src/player/components/TimelineCanvas.tsx +47 -1
  53. package/src/player/components/TimelineClip.tsx +8 -3
  54. package/src/player/components/TimelineClipDiamonds.tsx +174 -0
  55. package/src/player/components/timelineDragDrop.ts +103 -0
  56. package/src/player/components/timelineLayout.ts +1 -1
  57. package/src/player/store/playerStore.ts +42 -0
  58. package/src/utils/editHistory.ts +1 -1
  59. package/src/utils/optimisticUpdate.test.ts +53 -0
  60. package/src/utils/optimisticUpdate.ts +18 -0
  61. package/src/utils/studioUiPreferences.ts +17 -0
  62. package/dist/assets/index-CrxThtSJ.css +0 -1
  63. package/dist/assets/index-Dc2HfqON.js +0 -140
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-Dc2HfqON.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-CrxThtSJ.css">
8
+ <script type="module" crossorigin src="/assets/index-uB_W2GDl.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DcyZuBcU.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.73",
3
+ "version": "0.6.75",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,8 +31,8 @@
31
31
  "@codemirror/view": "6.40.0",
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "mediabunny": "^1.45.3",
34
- "@hyperframes/core": "0.6.73",
35
- "@hyperframes/player": "0.6.73"
34
+ "@hyperframes/core": "0.6.75",
35
+ "@hyperframes/player": "0.6.75"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/react": "19",
@@ -46,7 +46,7 @@
46
46
  "vite": "^6.4.2",
47
47
  "vitest": "^3.2.4",
48
48
  "zustand": "^5.0.0",
49
- "@hyperframes/producer": "0.6.73"
49
+ "@hyperframes/producer": "0.6.75"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "19",
package/src/App.tsx CHANGED
@@ -44,6 +44,7 @@ import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
44
44
  import { FileManagerProvider } from "./contexts/FileManagerContext";
45
45
  import { DomEditProvider } from "./contexts/DomEditContext";
46
46
  import { StudioSplash } from "./components/StudioSplash";
47
+ import { StudioToast } from "./components/StudioToast";
47
48
  import { useServerConnection } from "./hooks/useServerConnection";
48
49
  import {
49
50
  normalizeStudioCompositionPath,
@@ -266,8 +267,10 @@ export function StudioApp() {
266
267
  const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise<void>>(
267
268
  async () => {},
268
269
  );
269
- const domEditDeleteBridge = async (s: DomEditSelection) =>
270
- handleDomEditElementDeleteRef.current(s);
270
+ const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
271
+ const resetKeyframesRef = useRef<() => boolean>(() => false);
272
+ const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
273
+ const invalidateGsapCacheRef = useRef<() => void>(() => {});
271
274
  const { handleCopy, handlePaste, handleCut } = useClipboard({
272
275
  projectId,
273
276
  activeCompPath,
@@ -284,6 +287,7 @@ export function StudioApp() {
284
287
  const appHotkeys = useAppHotkeys({
285
288
  toggleTimelineVisibility,
286
289
  handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
290
+ handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit,
287
291
  handleDomEditElementDelete: domEditDeleteBridge,
288
292
  domEditSelectionRef: domEditSelectionBridgeRef,
289
293
  clearDomSelectionRef,
@@ -299,8 +303,10 @@ export function StudioApp() {
299
303
  handleCopy,
300
304
  handlePaste,
301
305
  handleCut,
306
+ onResetKeyframes: () => resetKeyframesRef.current(),
307
+ onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
308
+ onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
302
309
  });
303
-
304
310
  const selectSidebarTabStable = useCallback(
305
311
  (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
306
312
  [],
@@ -345,11 +351,20 @@ export function StudioApp() {
345
351
  selectSidebarTab: selectSidebarTabStable,
346
352
  getSidebarTab: getSidebarTabStable,
347
353
  });
348
-
349
354
  domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
350
355
  clearDomSelectionRef.current = domEditSession.clearDomSelection;
351
356
  handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;
352
-
357
+ resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
358
+ invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
359
+ deleteSelectedKeyframesRef.current = () => {
360
+ const sk = usePlayerStore.getState().selectedKeyframes;
361
+ const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes);
362
+ if (!a || sk.size === 0) return;
363
+ sk.forEach((k) => {
364
+ const p = Number(k.split(":")[1]);
365
+ if (Number.isFinite(p)) domEditSession.handleGsapRemoveKeyframe(a.id, p);
366
+ });
367
+ };
353
368
  useCaptionDetection({
354
369
  projectId,
355
370
  activeCompPath,
@@ -470,12 +485,15 @@ export function StudioApp() {
470
485
  timelineVisible,
471
486
  toggleTimelineVisibility,
472
487
  });
473
-
474
- if (resolving || waitingForServer || !projectId) {
488
+ if (resolving || waitingForServer || !projectId)
475
489
  return <StudioSplash waiting={waitingForServer} />;
476
- }
477
-
478
- const timelineToolbar = <TimelineToolbar toggleTimelineVisibility={toggleTimelineVisibility} />;
490
+ const timelineToolbar = (
491
+ <TimelineToolbar
492
+ toggleTimelineVisibility={toggleTimelineVisibility}
493
+ domEditSession={domEditSession}
494
+ onSplitElement={timelineEditing.handleTimelineElementSplit}
495
+ />
496
+ );
479
497
  return (
480
498
  <StudioProvider value={studioCtxValue}>
481
499
  <PanelLayoutProvider value={panelLayout}>
@@ -517,6 +535,7 @@ export function StudioApp() {
517
535
  handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
518
536
  handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
519
537
  handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
538
+ handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
520
539
  setCompIdToSrc={setCompIdToSrc}
521
540
  setCompositionLoading={setCompositionLoading}
522
541
  shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
@@ -540,7 +559,6 @@ export function StudioApp() {
540
559
  {lintModal !== null && (
541
560
  <LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
542
561
  )}
543
-
544
562
  {consoleErrors !== null && consoleErrors.length > 0 && (
545
563
  <LintModal
546
564
  findings={consoleErrors}
@@ -548,7 +566,6 @@ export function StudioApp() {
548
566
  onClose={() => setConsoleErrors(null)}
549
567
  />
550
568
  )}
551
-
552
569
  {domEditSession.agentModalOpen && domEditSession.domEditSelection && (
553
570
  <AskAgentModal
554
571
  selectionLabel={domEditSession.domEditSelection.label}
@@ -567,18 +584,7 @@ export function StudioApp() {
567
584
  )}
568
585
 
569
586
  {dragOverlay.active && <StudioGlobalDragOverlay />}
570
-
571
- {appToast && (
572
- <div
573
- className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
574
- appToast.tone === "error"
575
- ? "bg-red-900/90 border-red-700/50 text-red-200"
576
- : "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
577
- }`}
578
- >
579
- {appToast.message}
580
- </div>
581
- )}
587
+ {appToast && <StudioToast message={appToast.message} tone={appToast.tone} />}
582
588
  </div>
583
589
  </DomEditProvider>
584
590
  </FileManagerProvider>
@@ -1,10 +1,12 @@
1
- import type { ReactNode } from "react";
1
+ import { useState, type ReactNode } from "react";
2
2
  import { NLELayout } from "./nle/NLELayout";
3
3
  import { CaptionOverlay } from "../captions/components/CaptionOverlay";
4
4
  import { CaptionTimeline } from "../captions/components/CaptionTimeline";
5
5
  import { DomEditOverlay } from "./editor/DomEditOverlay";
6
+ import { SnapToolbar } from "./editor/SnapToolbar";
6
7
  import { StudioFeedbackBar } from "./StudioFeedbackBar";
7
8
  import type { TimelineElement } from "../player";
9
+ import { usePlayerStore } from "../player/store/playerStore";
8
10
  import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
9
11
  import {
10
12
  STUDIO_INSPECTOR_PANELS_ENABLED,
@@ -14,6 +16,7 @@ import {
14
16
  import { useStudioContext } from "../contexts/StudioContext";
15
17
  import { useDomEditContext } from "../contexts/DomEditContext";
16
18
  import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
19
+ import { readStudioUiPreferences } from "../utils/studioUiPreferences";
17
20
 
18
21
  export interface StudioPreviewAreaProps {
19
22
  timelineToolbar: ReactNode;
@@ -48,6 +51,7 @@ export interface StudioPreviewAreaProps {
48
51
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
49
52
  ) => Promise<void> | void;
50
53
  handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
54
+ handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
51
55
  setCompIdToSrc: (map: Map<string, string>) => void;
52
56
  setCompositionLoading: (loading: boolean) => void;
53
57
  shouldShowSelectedDomBounds: boolean;
@@ -66,6 +70,7 @@ export function StudioPreviewArea({
66
70
  handleTimelineElementMove,
67
71
  handleTimelineElementResize,
68
72
  handleBlockedTimelineEdit,
73
+ handleTimelineElementSplit,
69
74
  setCompIdToSrc,
70
75
  setCompositionLoading,
71
76
  shouldShowSelectedDomBounds,
@@ -101,8 +106,24 @@ export function StudioPreviewArea({
101
106
  handleDomGroupPathOffsetCommit,
102
107
  handleDomBoxSizeCommit,
103
108
  handleDomRotationCommit,
109
+ selectedGsapAnimations,
110
+ handleGsapRemoveKeyframe,
111
+ handleGsapUpdateMeta,
112
+ handleGsapAddKeyframe,
113
+ handleGsapConvertToKeyframes,
114
+ handleGsapDeleteAnimation,
104
115
  } = useDomEditContext();
105
116
 
117
+ const [snapPrefs, setSnapPrefs] = useState(() => {
118
+ const p = readStudioUiPreferences();
119
+ return {
120
+ snapEnabled: p.snapEnabled ?? true,
121
+ gridVisible: p.gridVisible ?? false,
122
+ gridSpacing: p.gridSpacing ?? 50,
123
+ snapToGrid: p.snapToGrid ?? false,
124
+ };
125
+ });
126
+
106
127
  return (
107
128
  <div className="flex-1 flex flex-col relative min-w-0">
108
129
  <div className="flex-1 min-h-0 relative">
@@ -120,7 +141,56 @@ export function StudioPreviewArea({
120
141
  onMoveElement={handleTimelineElementMove}
121
142
  onResizeElement={handleTimelineElementResize}
122
143
  onBlockedEditAttempt={handleBlockedTimelineEdit}
144
+ onSplitElement={handleTimelineElementSplit}
123
145
  onSelectTimelineElement={handleTimelineElementSelect}
146
+ onDeleteAllKeyframes={(_elId) => {
147
+ const anim =
148
+ selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
149
+ if (anim) handleGsapDeleteAnimation(anim.id);
150
+ }}
151
+ onDeleteKeyframe={(_elId, pct) => {
152
+ const anim = selectedGsapAnimations.find((a) => a.keyframes);
153
+ if (anim) handleGsapRemoveKeyframe(anim.id, pct);
154
+ }}
155
+ onChangeKeyframeEase={(_elId, _pct, ease) => {
156
+ const anim = selectedGsapAnimations.find((a) => a.keyframes);
157
+ if (anim) handleGsapUpdateMeta(anim.id, { ease });
158
+ }}
159
+ // fallow-ignore-next-line complexity
160
+ onMoveKeyframe={(_el, oldPct, newPct) => {
161
+ const anim = selectedGsapAnimations.find((a) => a.keyframes);
162
+ if (!anim?.keyframes) return;
163
+ const kf = anim.keyframes.keyframes.find((k) => k.percentage === oldPct);
164
+ if (!kf) return;
165
+ handleGsapRemoveKeyframe(anim.id, oldPct);
166
+ for (const [prop, val] of Object.entries(kf.properties)) {
167
+ handleGsapAddKeyframe(anim.id, newPct, prop, val);
168
+ }
169
+ }}
170
+ onToggleKeyframeAtPlayhead={(el) => {
171
+ const currentTime = usePlayerStore.getState().currentTime;
172
+ const pct =
173
+ el.duration > 0
174
+ ? Math.max(
175
+ 0,
176
+ Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)),
177
+ )
178
+ : 0;
179
+ const anim = selectedGsapAnimations.find((a) => a.keyframes);
180
+ if (anim?.keyframes) {
181
+ const existing = anim.keyframes.keyframes.find(
182
+ (k) => Math.abs(k.percentage - pct) <= 1,
183
+ );
184
+ if (existing) {
185
+ handleGsapRemoveKeyframe(anim.id, existing.percentage);
186
+ } else {
187
+ handleGsapAddKeyframe(anim.id, pct, "x", 0);
188
+ }
189
+ } else {
190
+ const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
191
+ if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
192
+ }
193
+ }}
124
194
  onCompIdToSrcChange={setCompIdToSrc}
125
195
  onCompositionLoadingChange={setCompositionLoading}
126
196
  onCompositionChange={(compPath) => {
@@ -157,31 +227,36 @@ export function StudioPreviewArea({
157
227
  ) : captionEditMode ? (
158
228
  <CaptionOverlay iframeRef={previewIframeRef} />
159
229
  ) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
160
- <DomEditOverlay
161
- iframeRef={previewIframeRef}
162
- activeCompositionPath={activeCompPath}
163
- hoverSelection={
164
- STUDIO_PREVIEW_SELECTION_ENABLED &&
165
- !captionEditMode &&
166
- !compositionLoading &&
167
- !isPlaying
168
- ? domEditHoverSelection
169
- : null
170
- }
171
- selection={shouldShowSelectedDomBounds ? domEditSelection : null}
172
- groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
173
- allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
174
- onCanvasMouseDown={handlePreviewCanvasMouseDown}
175
- onCanvasPointerMove={handlePreviewCanvasPointerMove}
176
- onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
177
- onSelectionChange={applyDomSelection}
178
- onBlockedMove={handleBlockedDomMove}
179
- onManualDragStart={handleDomManualDragStart}
180
- onPathOffsetCommit={handleDomPathOffsetCommit}
181
- onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
182
- onBoxSizeCommit={handleDomBoxSizeCommit}
183
- onRotationCommit={handleDomRotationCommit}
184
- />
230
+ <>
231
+ <DomEditOverlay
232
+ iframeRef={previewIframeRef}
233
+ activeCompositionPath={activeCompPath}
234
+ hoverSelection={
235
+ STUDIO_PREVIEW_SELECTION_ENABLED &&
236
+ !captionEditMode &&
237
+ !compositionLoading &&
238
+ !isPlaying
239
+ ? domEditHoverSelection
240
+ : null
241
+ }
242
+ selection={shouldShowSelectedDomBounds ? domEditSelection : null}
243
+ groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
244
+ allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
245
+ onCanvasMouseDown={handlePreviewCanvasMouseDown}
246
+ onCanvasPointerMove={handlePreviewCanvasPointerMove}
247
+ onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
248
+ onSelectionChange={applyDomSelection}
249
+ onBlockedMove={handleBlockedDomMove}
250
+ onManualDragStart={handleDomManualDragStart}
251
+ onPathOffsetCommit={handleDomPathOffsetCommit}
252
+ onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
253
+ onBoxSizeCommit={handleDomBoxSizeCommit}
254
+ onRotationCommit={handleDomRotationCommit}
255
+ gridVisible={snapPrefs.gridVisible}
256
+ gridSpacing={snapPrefs.gridSpacing}
257
+ />
258
+ <SnapToolbar onSnapChange={setSnapPrefs} />
259
+ </>
185
260
  ) : null
186
261
  }
187
262
  timelineFooter={
@@ -91,6 +91,7 @@ export function StudioRightPanel({
91
91
  handleGsapUpdateFromProperty,
92
92
  handleGsapAddFromProperty,
93
93
  handleGsapRemoveFromProperty,
94
+ commitAnimatedProperty,
94
95
  } = useDomEditContext();
95
96
 
96
97
  const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
@@ -211,6 +212,7 @@ export function StudioRightPanel({
211
212
  onImportAssets={handleImportFiles}
212
213
  fontAssets={fontAssets}
213
214
  onImportFonts={handleImportFonts}
215
+ previewIframeRef={previewIframeRef}
214
216
  gsapAnimations={selectedGsapAnimations}
215
217
  gsapMultipleTimelines={gsapMultipleTimelines}
216
218
  gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
@@ -223,6 +225,7 @@ export function StudioRightPanel({
223
225
  onAddGsapFromProperty={handleGsapAddFromProperty}
224
226
  onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
225
227
  onAddGsapAnimation={handleGsapAddAnimation}
228
+ onCommitAnimatedProperty={commitAnimatedProperty}
226
229
  />
227
230
  ) : motionPanelActive ? (
228
231
  <MotionPanel
@@ -0,0 +1,18 @@
1
+ interface StudioToastProps {
2
+ message: string;
3
+ tone?: "error" | "info";
4
+ }
5
+
6
+ export function StudioToast({ message, tone }: StudioToastProps) {
7
+ return (
8
+ <div
9
+ className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
10
+ tone === "error"
11
+ ? "bg-red-900/90 border-red-700/50 text-red-200"
12
+ : "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
13
+ }`}
14
+ >
15
+ {message}
16
+ </div>
17
+ );
18
+ }
@@ -3,25 +3,251 @@ import {
3
3
  getTimelineZoomPercent,
4
4
  } from "../player/components/timelineZoom";
5
5
  import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
6
- import { usePlayerStore } from "../player";
6
+ import { usePlayerStore, type TimelineElement } from "../player";
7
+ import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
7
8
  import { Tooltip } from "./ui";
9
+ import { Scissors } from "../icons/SystemIcons";
10
+ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
11
+ import type { DomEditSelection } from "./editor/domEditingTypes";
12
+
13
+ function interpolateKeyframeProperties(
14
+ keyframes: GsapPercentageKeyframe[],
15
+ pct: number,
16
+ ): Record<string, number> {
17
+ const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
18
+ const allProps = new Set<string>();
19
+ for (const kf of sorted) {
20
+ for (const p of Object.keys(kf.properties)) {
21
+ if (typeof kf.properties[p] === "number") allProps.add(p);
22
+ }
23
+ }
24
+ const result: Record<string, number> = {};
25
+ for (const prop of allProps) {
26
+ let prev: { pct: number; val: number } | null = null;
27
+ let next: { pct: number; val: number } | null = null;
28
+ for (const kf of sorted) {
29
+ const v = kf.properties[prop];
30
+ if (typeof v !== "number") continue;
31
+ if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v };
32
+ if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v };
33
+ }
34
+ if (prev && next && prev.pct !== next.pct) {
35
+ const t = (pct - prev.pct) / (next.pct - prev.pct);
36
+ result[prop] = Math.round(prev.val + t * (next.val - prev.val));
37
+ } else if (prev) {
38
+ result[prop] = Math.round(prev.val);
39
+ } else if (next) {
40
+ result[prop] = Math.round(next.val);
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+
46
+ function readRuntimeKeyframeValues(
47
+ iframe: HTMLIFrameElement | null,
48
+ sel: DomEditSelection,
49
+ keyframes: GsapPercentageKeyframe[],
50
+ ): Record<string, number> {
51
+ if (!iframe?.contentWindow) return {};
52
+ let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
53
+ try {
54
+ gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
55
+ } catch {
56
+ return {};
57
+ }
58
+ if (!gsap?.getProperty) return {};
59
+ const selector = sel.id ? `#${sel.id}` : sel.selector;
60
+ if (!selector) return {};
61
+ let doc: Document | null = null;
62
+ try {
63
+ doc = iframe.contentDocument;
64
+ } catch {
65
+ return {};
66
+ }
67
+ const element = doc?.querySelector(selector);
68
+ if (!element) return {};
69
+ const allProps = new Set<string>();
70
+ for (const kf of keyframes) {
71
+ for (const p of Object.keys(kf.properties)) {
72
+ if (typeof kf.properties[p] === "number") allProps.add(p);
73
+ }
74
+ }
75
+ const result: Record<string, number> = {};
76
+ for (const prop of allProps) {
77
+ const val = Number(gsap.getProperty(element, prop));
78
+ if (Number.isFinite(val)) result[prop] = Math.round(val);
79
+ }
80
+ return result;
81
+ }
82
+
83
+ interface DomEditSessionSlice {
84
+ domEditSelection: DomEditSelection | null;
85
+ selectedGsapAnimations: GsapAnimation[];
86
+ handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
87
+ handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
88
+ handleGsapConvertToKeyframes: (animId: string) => void;
89
+ handleGsapMaterializeKeyframes?: (animId: string) => Promise<void>;
90
+ handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
91
+ previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
92
+ }
8
93
 
9
94
  interface TimelineToolbarProps {
10
95
  toggleTimelineVisibility: () => void;
96
+ domEditSession?: DomEditSessionSlice;
97
+ onSplitElement?: (element: TimelineElement, splitTime: number) => void;
11
98
  }
12
99
 
13
- export function TimelineToolbar({ toggleTimelineVisibility }: TimelineToolbarProps) {
100
+ // fallow-ignore-next-line complexity
101
+ function useKeyframeToggle(session?: DomEditSessionSlice) {
102
+ const currentTime = usePlayerStore((s) => s.currentTime);
103
+ if (!session) return { state: "none" as const, onToggle: undefined };
104
+
105
+ const sel = session.domEditSelection;
106
+ const anims = session.selectedGsapAnimations;
107
+ const kfAnim = anims.find((a) => a.keyframes);
108
+ const flatAnim = anims.find((a) => !a.keyframes);
109
+
110
+ let state: "active" | "inactive" | "none" = "none";
111
+ if (kfAnim?.keyframes && sel) {
112
+ const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
113
+ const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
114
+ const pct =
115
+ elDuration > 0
116
+ ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
117
+ : 0;
118
+ state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
119
+ ? "active"
120
+ : "inactive";
121
+ }
122
+
123
+ // fallow-ignore-next-line complexity
124
+ const onToggle = sel
125
+ ? async () => {
126
+ const t = usePlayerStore.getState().currentTime;
127
+ if (kfAnim?.keyframes) {
128
+ if (kfAnim.hasUnresolvedKeyframes) {
129
+ await session.handleGsapMaterializeKeyframes?.(kfAnim.id);
130
+ }
131
+ const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
132
+ const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
133
+ const pct =
134
+ elDuration > 0
135
+ ? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
136
+ : 0;
137
+ const existing = kfAnim.keyframes.keyframes.find(
138
+ (k) => Math.abs(k.percentage - pct) <= 1,
139
+ );
140
+ if (existing) {
141
+ session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
142
+ } else {
143
+ const runtimeValues = readRuntimeKeyframeValues(
144
+ session.previewIframeRef?.current ?? null,
145
+ sel,
146
+ kfAnim.keyframes.keyframes,
147
+ );
148
+ const values =
149
+ Object.keys(runtimeValues).length > 0
150
+ ? runtimeValues
151
+ : interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct);
152
+ for (const [prop, val] of Object.entries(values)) {
153
+ session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val);
154
+ }
155
+ }
156
+ } else if (flatAnim) {
157
+ session.handleGsapConvertToKeyframes(flatAnim.id);
158
+ } else {
159
+ session.handleGsapAddAnimation("to");
160
+ }
161
+ }
162
+ : undefined;
163
+
164
+ return { state, onToggle };
165
+ }
166
+
167
+ export function TimelineToolbar({
168
+ toggleTimelineVisibility,
169
+ domEditSession,
170
+ onSplitElement,
171
+ }: TimelineToolbarProps) {
14
172
  const zoomMode = usePlayerStore((s) => s.zoomMode);
15
173
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
16
174
  const setZoomMode = usePlayerStore((s) => s.setZoomMode);
17
175
  const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
18
176
  const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
177
+ const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
19
178
 
20
179
  return (
21
180
  <div className="border-b border-neutral-800/40 bg-neutral-950/96">
22
181
  <div className="flex items-center justify-between px-3 py-2">
23
- <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
24
- Timeline
182
+ <div className="flex items-center gap-3">
183
+ <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
184
+ Timeline
185
+ </div>
186
+ {STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
187
+ <Tooltip
188
+ label={
189
+ keyframeState === "active"
190
+ ? "Remove keyframe at playhead"
191
+ : keyframeState === "inactive"
192
+ ? "Add keyframe at playhead"
193
+ : "Enable keyframes"
194
+ }
195
+ >
196
+ <button
197
+ type="button"
198
+ onClick={onToggleKeyframe}
199
+ className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
200
+ keyframeState === "active"
201
+ ? "text-studio-accent"
202
+ : keyframeState === "inactive"
203
+ ? "text-neutral-400 hover:text-studio-accent"
204
+ : "text-neutral-600 hover:text-neutral-400"
205
+ }`}
206
+ >
207
+ <svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
208
+ {keyframeState === "active" ? (
209
+ <path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
210
+ ) : (
211
+ <path
212
+ d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
213
+ fill="none"
214
+ stroke="currentColor"
215
+ strokeWidth="1.2"
216
+ />
217
+ )}
218
+ </svg>
219
+ </button>
220
+ </Tooltip>
221
+ )}
222
+ {onSplitElement &&
223
+ (() => {
224
+ const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
225
+ const el = selectedElementId
226
+ ? elements.find((e) => (e.key ?? e.id) === selectedElementId)
227
+ : null;
228
+ const splittable =
229
+ el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag);
230
+ if (!splittable) return null;
231
+ const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
232
+ return (
233
+ <Tooltip label="Split clip at playhead (S)">
234
+ <button
235
+ type="button"
236
+ disabled={!canSplit}
237
+ onClick={() => {
238
+ if (canSplit) onSplitElement(el, currentTime);
239
+ }}
240
+ className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
241
+ canSplit
242
+ ? "text-neutral-500 hover:text-neutral-200"
243
+ : "text-neutral-700 cursor-not-allowed"
244
+ }`}
245
+ >
246
+ <Scissors size={15} />
247
+ </button>
248
+ </Tooltip>
249
+ );
250
+ })()}
25
251
  </div>
26
252
  <div className="flex items-center gap-1">
27
253
  <Tooltip label="Fit timeline to width">