@hyperframes/studio 0.6.97 → 0.6.98

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 (120) hide show
  1. package/dist/assets/hyperframes-player-DgsMQSvV.js +418 -0
  2. package/dist/assets/index-B62bDCQv.css +1 -0
  3. package/dist/assets/index-Ce3pBm_I.js +252 -0
  4. package/dist/assets/{index-HveJ0MuV.js → index-D-ET9M0b.js} +1 -1
  5. package/dist/assets/index-D-bS9Dxx.js +1 -0
  6. package/dist/index.html +2 -2
  7. package/package.json +7 -5
  8. package/src/App.tsx +182 -177
  9. package/src/captions/store.ts +11 -11
  10. package/src/components/StudioHeader.tsx +4 -4
  11. package/src/components/StudioLeftSidebar.tsx +2 -2
  12. package/src/components/StudioPreviewArea.tsx +225 -183
  13. package/src/components/StudioRightPanel.tsx +3 -3
  14. package/src/components/TimelineToolbar.tsx +25 -0
  15. package/src/components/editor/DomEditOverlay.tsx +2 -5
  16. package/src/components/editor/EaseCurveSection.tsx +2 -3
  17. package/src/components/editor/GestureTrailOverlay.tsx +4 -3
  18. package/src/components/editor/LayersPanel.tsx +3 -9
  19. package/src/components/editor/PropertyPanel.tsx +20 -61
  20. package/src/components/editor/colorValue.ts +3 -1
  21. package/src/components/editor/domEditOverlayGestures.ts +54 -1
  22. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  23. package/src/components/editor/gradientValue.ts +3 -3
  24. package/src/components/editor/keyframeMove.test.ts +101 -0
  25. package/src/components/editor/keyframeMove.ts +151 -0
  26. package/src/components/editor/manualEditsDom.ts +0 -12
  27. package/src/components/editor/propertyPanelHelpers.ts +10 -38
  28. package/src/components/editor/propertyPanelMediaSection.tsx +1 -5
  29. package/src/components/editor/propertyPanelTimingSection.tsx +1 -6
  30. package/src/components/editor/propertyPanelTransformCommit.ts +129 -0
  31. package/src/components/editor/studioMotionOps.test.ts +1 -1
  32. package/src/components/editor/studioMotionOps.ts +2 -1
  33. package/src/components/editor/useDomEditOverlayGestures.ts +1 -46
  34. package/src/components/nle/NLELayout.tsx +1 -24
  35. package/src/components/sidebar/BlocksTab.tsx +2 -2
  36. package/src/contexts/DomEditContext.tsx +134 -31
  37. package/src/contexts/StudioContext.tsx +90 -40
  38. package/src/contexts/TimelineEditContext.tsx +47 -0
  39. package/src/hooks/domEditCommitTypes.ts +14 -0
  40. package/src/hooks/gsapDragCommit.ts +9 -24
  41. package/src/hooks/gsapKeyframeCacheHelpers.ts +2 -1
  42. package/src/hooks/gsapKeyframeCommit.ts +5 -15
  43. package/src/hooks/gsapRuntimeBridge.ts +18 -52
  44. package/src/hooks/gsapRuntimeKeyframes.ts +8 -57
  45. package/src/hooks/gsapRuntimeReaders.ts +19 -26
  46. package/src/hooks/gsapScriptCommitHelpers.ts +1 -11
  47. package/src/hooks/gsapScriptCommitTypes.ts +58 -0
  48. package/src/hooks/gsapShared.ts +157 -0
  49. package/src/hooks/timelineEditingHelpers.ts +63 -2
  50. package/src/hooks/useAnimatedPropertyCommit.ts +3 -25
  51. package/src/hooks/useAppHotkeys.ts +299 -377
  52. package/src/hooks/useConsoleErrorCapture.ts +33 -5
  53. package/src/hooks/useDomEditCommits.ts +35 -293
  54. package/src/hooks/useDomEditPositionPatchCommit.ts +1 -1
  55. package/src/hooks/useDomEditSession.ts +78 -249
  56. package/src/hooks/useDomEditTextCommits.ts +1 -1
  57. package/src/hooks/useDomEditWiring.ts +255 -0
  58. package/src/hooks/useDomGeometryCommits.ts +181 -0
  59. package/src/hooks/useDomSelection.ts +10 -27
  60. package/src/hooks/useEditorSave.ts +82 -0
  61. package/src/hooks/useElementLifecycleOps.ts +177 -0
  62. package/src/hooks/useEnableKeyframes.ts +10 -15
  63. package/src/hooks/useFileManager.ts +32 -114
  64. package/src/hooks/useFileTree.ts +80 -0
  65. package/src/hooks/useGestureCommit.ts +7 -5
  66. package/src/hooks/useGestureRecording.ts +1 -1
  67. package/src/hooks/useGsapAnimationOps.ts +122 -0
  68. package/src/hooks/useGsapArcPathOps.ts +61 -0
  69. package/src/hooks/useGsapAwareEditing.ts +242 -0
  70. package/src/hooks/useGsapKeyframeOps.ts +167 -0
  71. package/src/hooks/useGsapPropertyDebounce.ts +135 -0
  72. package/src/hooks/useGsapScriptCommits.ts +58 -570
  73. package/src/hooks/useGsapSelectionHandlers.ts +22 -9
  74. package/src/hooks/useGsapTweenCache.ts +35 -29
  75. package/src/hooks/useLintModal.ts +7 -0
  76. package/src/hooks/useMusicBeatAnalysis.ts +152 -0
  77. package/src/hooks/useRazorSplit.ts +1 -1
  78. package/src/hooks/useRenderClipContent.ts +46 -21
  79. package/src/hooks/useTimelineEditing.ts +48 -4
  80. package/src/player/components/AudioWaveform.tsx +29 -4
  81. package/src/player/components/BeatStrip.tsx +166 -0
  82. package/src/player/components/Timeline.tsx +39 -18
  83. package/src/player/components/TimelineCanvas.tsx +52 -12
  84. package/src/player/components/TimelineClipDiamonds.tsx +130 -20
  85. package/src/player/components/TimelinePropertyRows.tsx +8 -2
  86. package/src/player/components/TimelineRuler.tsx +36 -2
  87. package/src/player/components/timelineEditing.ts +30 -5
  88. package/src/player/components/useTimelineClipDrag.ts +155 -4
  89. package/src/player/components/useTimelinePlayhead.ts +30 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +47 -45
  91. package/src/player/lib/mediaProbe.ts +46 -3
  92. package/src/player/lib/playbackScrub.ts +16 -0
  93. package/src/player/lib/timelineDOM.ts +10 -2
  94. package/src/player/lib/timelineIframeHelpers.ts +89 -0
  95. package/src/player/store/playerStore.ts +92 -33
  96. package/src/utils/beatEditActions.ts +109 -0
  97. package/src/utils/beatEditing.ts +136 -0
  98. package/src/utils/clipboardPayload.ts +3 -2
  99. package/src/utils/compositionPatterns.ts +2 -0
  100. package/src/utils/keyframeSelection.test.ts +45 -0
  101. package/src/utils/keyframeSelection.ts +29 -0
  102. package/src/utils/rounding.ts +9 -0
  103. package/src/utils/studioHelpers.ts +5 -2
  104. package/src/utils/studioUrlState.ts +2 -1
  105. package/src/utils/timelineAssetDrop.ts +6 -5
  106. package/src/utils/timelineInspector.ts +15 -100
  107. package/dist/assets/hyperframes-player-Daj5djxa.js +0 -418
  108. package/dist/assets/index-B0twsRu0.css +0 -1
  109. package/dist/assets/index-Cfye9xzo.js +0 -251
  110. package/src/components/editor/DopesheetStrip.tsx +0 -141
  111. package/src/components/editor/StaggerControls.tsx +0 -61
  112. package/src/components/editor/TimelineLayerPanel.test.ts +0 -42
  113. package/src/components/editor/TimelineLayerPanel.tsx +0 -15
  114. package/src/components/nle/TimelineEditorNotice.tsx +0 -133
  115. package/src/hooks/gsapRuntimePreview.ts +0 -19
  116. package/src/player/components/timelineUtils.ts +0 -211
  117. package/src/utils/audioBeatDetection.ts +0 -58
  118. package/src/utils/keyframeSnapping.test.ts +0 -74
  119. package/src/utils/keyframeSnapping.ts +0 -63
  120. package/src/utils/timelineInspector.test.ts +0 -79
@@ -170,6 +170,95 @@ export function resolveIframe(el: Element | null): HTMLIFrameElement | null {
170
170
  return el.shadowRoot?.querySelector("iframe") ?? el.querySelector("iframe") ?? null;
171
171
  }
172
172
 
173
+ // ---------------------------------------------------------------------------
174
+ // Audio scrubbing
175
+ // ---------------------------------------------------------------------------
176
+ // Plays a brief slice of the music track while the user drags the playhead,
177
+ // like an NLE scrub. Repeated calls keep playback alive; it auto-pauses shortly
178
+ // after scrubbing stops and restores the element's prior muted state.
179
+
180
+ const SCRUB_VOLUME = 0.25;
181
+
182
+ let scrubAudioEl: HTMLAudioElement | null = null;
183
+ let scrubStopTimer: ReturnType<typeof setTimeout> | null = null;
184
+ let scrubPrevMuted: boolean | null = null;
185
+ let scrubPrevVolume: number | null = null;
186
+
187
+ // Resolve the SAME element the store identified as music: prefer its id, then
188
+ // the role attribute, and only fall back to the first <audio> (which could be a
189
+ // voiceover, so the id hint matters).
190
+ function resolveScrubAudioEl(doc: Document, musicId?: string | null): HTMLAudioElement | null {
191
+ if (musicId) {
192
+ const byId = doc.getElementById(musicId);
193
+ if (byId instanceof HTMLAudioElement) return byId;
194
+ }
195
+ return (
196
+ doc.querySelector<HTMLAudioElement>("audio[data-timeline-role='music']") ??
197
+ doc.querySelector<HTMLAudioElement>("audio")
198
+ );
199
+ }
200
+
201
+ function applyScrub(el: HTMLAudioElement, audioFileTime: number): void {
202
+ if (scrubAudioEl && scrubAudioEl !== el) stopScrubPreviewAudio();
203
+ if (scrubPrevMuted === null) scrubPrevMuted = el.muted;
204
+ if (scrubPrevVolume === null) scrubPrevVolume = el.volume;
205
+ scrubAudioEl = el;
206
+ try {
207
+ el.muted = false;
208
+ el.volume = SCRUB_VOLUME;
209
+ if (Math.abs(el.currentTime - audioFileTime) > 0.04) el.currentTime = audioFileTime;
210
+ if (el.paused) void el.play().catch(() => {});
211
+ } catch {
212
+ /* element not ready */
213
+ }
214
+ if (scrubStopTimer) clearTimeout(scrubStopTimer);
215
+ scrubStopTimer = setTimeout(stopScrubPreviewAudio, 140);
216
+ }
217
+
218
+ /**
219
+ * Scrub the preview music audio to `audioFileTime` (seconds into the source
220
+ * file). Pass `null` to stop. Safe to call rapidly during a playhead drag.
221
+ */
222
+ export function scrubPreviewAudio(
223
+ iframe: HTMLIFrameElement | null,
224
+ audioFileTime: number | null,
225
+ musicId?: string | null,
226
+ ): void {
227
+ if (!iframe) return;
228
+ if (audioFileTime === null) {
229
+ stopScrubPreviewAudio();
230
+ return;
231
+ }
232
+ let doc: Document | null = null;
233
+ try {
234
+ doc = iframe.contentDocument;
235
+ } catch {
236
+ return;
237
+ }
238
+ if (!doc) return;
239
+ const el = resolveScrubAudioEl(doc, musicId);
240
+ if (el) applyScrub(el, audioFileTime);
241
+ }
242
+
243
+ export function stopScrubPreviewAudio(): void {
244
+ if (scrubStopTimer) {
245
+ clearTimeout(scrubStopTimer);
246
+ scrubStopTimer = null;
247
+ }
248
+ const el = scrubAudioEl;
249
+ scrubAudioEl = null;
250
+ if (!el) return;
251
+ try {
252
+ el.pause();
253
+ if (scrubPrevMuted !== null) el.muted = scrubPrevMuted;
254
+ if (scrubPrevVolume !== null) el.volume = scrubPrevVolume;
255
+ } catch {
256
+ /* ignore */
257
+ }
258
+ scrubPrevMuted = null;
259
+ scrubPrevVolume = null;
260
+ }
261
+
173
262
  // ---------------------------------------------------------------------------
174
263
  // Enrich missing compositions from DOM
175
264
  // ---------------------------------------------------------------------------
@@ -1,4 +1,6 @@
1
1
  import { create } from "zustand";
2
+ import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
3
+ import type { BeatEditState } from "../../utils/beatEditing";
2
4
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
3
5
 
4
6
  /** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
@@ -46,6 +48,8 @@ export interface TimelineElement {
46
48
  timingSource?: "authored" | "implicit";
47
49
  /** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
48
50
  timelineLocked?: boolean;
51
+ /** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
52
+ timelineRole?: string;
49
53
  }
50
54
 
51
55
  export type ZoomMode = "fit" | "manual";
@@ -56,6 +60,8 @@ interface PlayerState {
56
60
  currentTime: number;
57
61
  duration: number;
58
62
  timelineReady: boolean;
63
+ /** True while a beat dot is being dragged — hides the playhead guideline. */
64
+ beatDragging: boolean;
59
65
  elements: TimelineElement[];
60
66
  selectedElementId: string | null;
61
67
  playbackRate: number;
@@ -88,18 +94,6 @@ interface PlayerState {
88
94
  toggleSelectedElementId: (id: string) => void;
89
95
  clearSelectedElementIds: () => void;
90
96
 
91
- /** Clipboard for keyframe copy/paste — stores keyframes with relative times. */
92
- keyframeClipboard: Array<{
93
- relativeTime: number;
94
- properties: Record<string, number | string>;
95
- ease?: string;
96
- }> | null;
97
- setKeyframeClipboard: (data: PlayerState["keyframeClipboard"]) => void;
98
-
99
- /** Elements with expanded property rows in the timeline. */
100
- expandedTimelineElements: Set<string>;
101
- toggleExpandedElement: (id: string) => void;
102
-
103
97
  /** Keyframe data per element id, populated from parsed GSAP animations. */
104
98
  keyframeCache: Map<string, KeyframeCacheEntry>;
105
99
  setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
@@ -111,6 +105,7 @@ interface PlayerState {
111
105
  setAudioMuted: (muted: boolean) => void;
112
106
  setLoopEnabled: (enabled: boolean) => void;
113
107
  setTimelineReady: (ready: boolean) => void;
108
+ setBeatDragging: (dragging: boolean) => void;
114
109
  setElements: (elements: TimelineElement[]) => void;
115
110
  setSelectedElementId: (id: string | null) => void;
116
111
  updateElement: (
@@ -131,11 +126,34 @@ interface PlayerState {
131
126
  requestSeek: (time: number) => void;
132
127
  clearSeekRequest: () => void;
133
128
 
134
- autoKeyframeEnabled: boolean;
135
- setAutoKeyframeEnabled: (enabled: boolean) => void;
136
-
137
129
  lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
138
130
  setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
131
+
132
+ beatAnalysis: MusicBeatAnalysis | null;
133
+ setBeatAnalysis: (analysis: MusicBeatAnalysis | null) => void;
134
+
135
+ /** User edits (add/move/delete) layered over the detected beat grid. */
136
+ beatEdits: BeatEditState | null;
137
+ setBeatEdits: (edits: BeatEditState | null) => void;
138
+ /** Undo/redo stacks for beat edits (in-memory, session-only). */
139
+ beatUndo: BeatHistoryEntry[];
140
+ beatRedo: BeatHistoryEntry[];
141
+ /** Apply a beat edit and record it for undo. */
142
+ commitBeatEdits: (next: BeatEditState | null, label: string) => void;
143
+ /** Undo/redo the most recent beat edit; returns its label or null if none. */
144
+ undoBeatEdits: () => string | null;
145
+ redoBeatEdits: () => string | null;
146
+ /** Clear beat edit history (e.g. when the music track changes). */
147
+ resetBeatHistory: () => void;
148
+ /** Callback that persists current beats to disk; registered by the analysis hook. */
149
+ beatPersist: (() => void) | null;
150
+ setBeatPersist: (fn: (() => void) | null) => void;
151
+ }
152
+
153
+ interface BeatHistoryEntry {
154
+ restore: BeatEditState | null; // state to restore when this entry is applied
155
+ at: number; // original edit timestamp (for global undo ordering)
156
+ label: string;
139
157
  }
140
158
 
141
159
  // Lightweight pub-sub for current time during playback.
@@ -151,11 +169,12 @@ export const liveTime = {
151
169
  },
152
170
  };
153
171
 
154
- export const usePlayerStore = create<PlayerState>((set) => ({
172
+ export const usePlayerStore = create<PlayerState>((set, get) => ({
155
173
  isPlaying: false,
156
174
  currentTime: 0,
157
175
  duration: 0,
158
176
  timelineReady: false,
177
+ beatDragging: false,
159
178
  elements: [],
160
179
  selectedElementId: null,
161
180
  playbackRate: readStudioUiPreferences().playbackRate ?? 1,
@@ -182,9 +201,6 @@ export const usePlayerStore = create<PlayerState>((set) => ({
182
201
  activeKeyframePct: null,
183
202
  setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
184
203
 
185
- keyframeClipboard: null,
186
- setKeyframeClipboard: (data) => set({ keyframeClipboard: data }),
187
-
188
204
  selectedElementIds: new Set<string>(),
189
205
  toggleSelectedElementId: (id: string) =>
190
206
  set((s) => {
@@ -195,15 +211,6 @@ export const usePlayerStore = create<PlayerState>((set) => ({
195
211
  }),
196
212
  clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
197
213
 
198
- expandedTimelineElements: new Set<string>(),
199
- toggleExpandedElement: (id: string) =>
200
- set((s) => {
201
- const next = new Set(s.expandedTimelineElements);
202
- if (next.has(id)) next.delete(id);
203
- else next.add(id);
204
- return { expandedTimelineElements: next };
205
- }),
206
-
207
214
  keyframeCache: new Map(),
208
215
  setKeyframeCache: (elementId, data) =>
209
216
  set((s) => {
@@ -217,13 +224,57 @@ export const usePlayerStore = create<PlayerState>((set) => ({
217
224
  requestSeek: (time) => set({ requestedSeekTime: time }),
218
225
  clearSeekRequest: () => set({ requestedSeekTime: null }),
219
226
 
220
- autoKeyframeEnabled: true,
221
- setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
222
-
223
227
  lintFindingsByElement: new Map(),
224
228
  setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
225
229
 
226
- setIsPlaying: (playing) => set({ isPlaying: playing }),
230
+ beatAnalysis: null,
231
+ setBeatAnalysis: (analysis) => set({ beatAnalysis: analysis }),
232
+
233
+ beatEdits: null,
234
+ setBeatEdits: (edits) => set({ beatEdits: edits }),
235
+
236
+ beatUndo: [],
237
+ beatRedo: [],
238
+ beatPersist: null,
239
+ setBeatPersist: (fn) => set({ beatPersist: fn }),
240
+ commitBeatEdits: (next, label) => {
241
+ set((s) => ({
242
+ beatEdits: next,
243
+ beatUndo: [...s.beatUndo, { restore: s.beatEdits, at: Date.now(), label }],
244
+ beatRedo: [],
245
+ }));
246
+ get().beatPersist?.();
247
+ },
248
+ undoBeatEdits: () => {
249
+ const s = get();
250
+ const entry = s.beatUndo[s.beatUndo.length - 1];
251
+ if (!entry) return null;
252
+ set({
253
+ beatEdits: entry.restore,
254
+ beatUndo: s.beatUndo.slice(0, -1),
255
+ beatRedo: [...s.beatRedo, { restore: s.beatEdits, at: entry.at, label: entry.label }],
256
+ });
257
+ get().beatPersist?.();
258
+ return entry.label;
259
+ },
260
+ resetBeatHistory: () => set({ beatUndo: [], beatRedo: [] }),
261
+ redoBeatEdits: () => {
262
+ const s = get();
263
+ const entry = s.beatRedo[s.beatRedo.length - 1];
264
+ if (!entry) return null;
265
+ set({
266
+ beatEdits: entry.restore,
267
+ beatRedo: s.beatRedo.slice(0, -1),
268
+ beatUndo: [...s.beatUndo, { restore: s.beatEdits, at: entry.at, label: entry.label }],
269
+ });
270
+ get().beatPersist?.();
271
+ return entry.label;
272
+ },
273
+
274
+ setIsPlaying: (playing) => {
275
+ if (get().isPlaying === playing) return;
276
+ set({ isPlaying: playing });
277
+ },
227
278
  setPlaybackRate: (rate) => {
228
279
  writeStudioUiPreferences({ playbackRate: rate });
229
280
  set({ playbackRate: rate });
@@ -260,6 +311,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
260
311
  setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
261
312
  setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
262
313
  setTimelineReady: (ready) => set({ timelineReady: ready }),
314
+ setBeatDragging: (dragging) => set({ beatDragging: dragging }),
263
315
  setElements: (elements) => set({ elements }),
264
316
  setSelectedElementId: (id) => set({ selectedElementId: id }),
265
317
  updateElement: (elementId, updates) =>
@@ -277,6 +329,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
277
329
  currentTime: 0,
278
330
  duration: 0,
279
331
  timelineReady: false,
332
+ beatDragging: false,
280
333
  elements: [],
281
334
  selectedElementId: null,
282
335
  inPoint: null,
@@ -284,7 +337,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
284
337
  activeTool: "select",
285
338
  selectedKeyframes: new Set(),
286
339
  selectedElementIds: new Set(),
287
- expandedTimelineElements: new Set(),
288
340
  keyframeCache: new Map(),
341
+ // Beat state is project-specific — clear it so a project switch can't
342
+ // apply the previous project's beats/undo/persist to the new one.
343
+ beatAnalysis: null,
344
+ beatEdits: null,
345
+ beatUndo: [],
346
+ beatRedo: [],
347
+ beatPersist: null,
289
348
  }),
290
349
  }));
@@ -0,0 +1,109 @@
1
+ // Imperative beat-edit operations driven by the player store. Times passed in
2
+ // are COMPOSITION coordinates (timeline seconds); they're converted to audio-file
3
+ // coordinates internally and strength is measured from the decoded audio.
4
+
5
+ import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
6
+ import { isMusicTrack } from "./timelineInspector";
7
+ import { strengthAtTime, type MusicBeatAnalysis } from "@hyperframes/core/beats";
8
+ import {
9
+ addUserBeat,
10
+ removeUserBeat,
11
+ moveUserBeat,
12
+ mergeUserBeats,
13
+ type BeatEditState,
14
+ } from "./beatEditing";
15
+
16
+ /**
17
+ * Merge user beat edits into the detected analysis and remap from audio-file to
18
+ * composition coordinates (filtered to the music clip's visible range). Returns
19
+ * null when there's no music element, so beats never paint at wrong positions.
20
+ */
21
+ export function remapBeatAnalysisToComposition(
22
+ beatAnalysis: MusicBeatAnalysis | null,
23
+ musicElement: Pick<TimelineElement, "src" | "start" | "playbackStart" | "duration"> | null,
24
+ beatEdits: BeatEditState | null,
25
+ ): MusicBeatAnalysis | null {
26
+ if (!beatAnalysis || !musicElement) return null;
27
+ const merged = mergeUserBeats(
28
+ beatAnalysis.beatTimes,
29
+ beatAnalysis.beatStrengths,
30
+ beatEdits,
31
+ musicElement.src ?? null,
32
+ );
33
+ const playbackStart = musicElement.playbackStart ?? 0;
34
+ const clipEnd = playbackStart + musicElement.duration;
35
+ const offset = musicElement.start - playbackStart;
36
+ const times: number[] = [];
37
+ const strengths: number[] = [];
38
+ merged.times.forEach((t, i) => {
39
+ if (t >= playbackStart && t <= clipEnd) {
40
+ times.push(Math.round((t + offset) * 1000) / 1000);
41
+ strengths.push(merged.strengths[i] ?? 1);
42
+ }
43
+ });
44
+ return { ...beatAnalysis, beatTimes: times, beatStrengths: strengths };
45
+ }
46
+
47
+ function ctx() {
48
+ const s = usePlayerStore.getState();
49
+ const music = s.elements.find(isMusicTrack);
50
+ const analysis = s.beatAnalysis;
51
+ if (!music || !analysis || !music.src) return null;
52
+ return { s, music, analysis, src: music.src };
53
+ }
54
+
55
+ function compToAudio(start: number, playbackStart: number, compT: number): number {
56
+ return playbackStart + (compT - start);
57
+ }
58
+
59
+ // Clip length on the timeline. Falls back to source/analysis length when the
60
+ // media duration hasn't been probed yet (0), so the add window isn't degenerate.
61
+ function clipDuration(music: { duration: number; sourceDuration?: number }): number {
62
+ if (music.duration > 0) return music.duration;
63
+ if (music.sourceDuration && music.sourceDuration > 0) return music.sourceDuration;
64
+ return Number.POSITIVE_INFINITY;
65
+ }
66
+
67
+ /** True when a music track with analysis exists and the time is inside the clip. */
68
+ export function canAddBeatAt(compT: number): boolean {
69
+ const c = ctx();
70
+ if (!c) return false;
71
+ return compT >= c.music.start && compT <= c.music.start + clipDuration(c.music);
72
+ }
73
+
74
+ export function addBeatAtCompositionTime(compT: number): void {
75
+ const c = ctx();
76
+ if (!c) return;
77
+ const playbackStart = c.music.playbackStart ?? 0;
78
+ const audioT = compToAudio(c.music.start, playbackStart, compT);
79
+ if (audioT < playbackStart || audioT > playbackStart + clipDuration(c.music)) return;
80
+ const strength = strengthAtTime(c.analysis, audioT);
81
+ const next = addUserBeat(c.s.beatEdits, c.src, { time: audioT, strength }, c.analysis.beatTimes);
82
+ // No-op when the beat lands on an existing one — skip the undo entry + write.
83
+ if (next !== c.s.beatEdits) c.s.commitBeatEdits(next, "add beat");
84
+ }
85
+
86
+ export function deleteBeatAtCompositionTime(compT: number): void {
87
+ const c = ctx();
88
+ if (!c) return;
89
+ const audioT = compToAudio(c.music.start, c.music.playbackStart ?? 0, compT);
90
+ const next = removeUserBeat(c.s.beatEdits, c.src, c.analysis.beatTimes, audioT);
91
+ // No-op when there was no beat to remove — skip the undo entry + write.
92
+ if (next !== c.s.beatEdits) c.s.commitBeatEdits(next, "delete beat");
93
+ }
94
+
95
+ export function moveBeatCompositionTime(fromCompT: number, toCompT: number): void {
96
+ const c = ctx();
97
+ if (!c) return;
98
+ const playbackStart = c.music.playbackStart ?? 0;
99
+ const fromAudio = compToAudio(c.music.start, playbackStart, fromCompT);
100
+ const toAudio = compToAudio(c.music.start, playbackStart, toCompT);
101
+ const clamped = Math.max(playbackStart, Math.min(playbackStart + clipDuration(c.music), toAudio));
102
+ const strength = strengthAtTime(c.analysis, clamped);
103
+ const next = moveUserBeat(c.s.beatEdits, c.src, c.analysis.beatTimes, fromAudio, {
104
+ time: clamped,
105
+ strength,
106
+ });
107
+ // No-op when the move resolves to no change — skip the undo entry + write.
108
+ if (next !== c.s.beatEdits) c.s.commitBeatEdits(next, "move beat");
109
+ }
@@ -0,0 +1,136 @@
1
+ // User edits to the detected beat grid. All times are in AUDIO-FILE coordinates
2
+ // (offsets into the music source), matching MusicBeatAnalysis.beatTimes, so edits
3
+ // survive moving/trimming the music clip on the timeline.
4
+
5
+ export interface UserBeat {
6
+ time: number; // audio-file seconds
7
+ strength: number; // 0–1, measured from audio
8
+ }
9
+
10
+ export interface BeatEditState {
11
+ /** Music src these edits apply to; edits reset when the src changes. */
12
+ src: string;
13
+ /** Beats the user added (audio-file coords). */
14
+ added: UserBeat[];
15
+ /** Audio-file times of detected beats the user removed. */
16
+ removed: number[];
17
+ }
18
+
19
+ // Two beat times within this many seconds are treated as the same beat.
20
+ const MATCH_EPS = 0.015;
21
+
22
+ function near(a: number, b: number): boolean {
23
+ return Math.abs(a - b) < MATCH_EPS;
24
+ }
25
+
26
+ function activeEdits(edits: BeatEditState | null, src: string | null): BeatEditState | null {
27
+ return edits && src && edits.src === src ? edits : null;
28
+ }
29
+
30
+ /** Merge detected beats with user edits → effective beats (audio-file coords). */
31
+ export function mergeUserBeats(
32
+ detectedTimes: number[],
33
+ detectedStrengths: number[],
34
+ edits: BeatEditState | null,
35
+ src: string | null,
36
+ ): { times: number[]; strengths: number[] } {
37
+ const e = activeEdits(edits, src);
38
+ const removed = e?.removed ?? [];
39
+ const merged: UserBeat[] = [];
40
+ for (let i = 0; i < detectedTimes.length; i++) {
41
+ const t = detectedTimes[i]!;
42
+ if (removed.some((r) => near(r, t))) continue;
43
+ merged.push({ time: t, strength: detectedStrengths[i] ?? 0.5 });
44
+ }
45
+ if (e) {
46
+ // Skip added beats that land on an already-present (detected) beat so an
47
+ // "add" near an existing beat doesn't create a near-duplicate.
48
+ for (const b of e.added) {
49
+ if (!merged.some((m) => near(m.time, b.time))) merged.push(b);
50
+ }
51
+ }
52
+ merged.sort((a, b) => a.time - b.time);
53
+ return { times: merged.map((b) => b.time), strengths: merged.map((b) => b.strength) };
54
+ }
55
+
56
+ function base(edits: BeatEditState | null, src: string): BeatEditState {
57
+ const e = activeEdits(edits, src);
58
+ return e
59
+ ? { ...e, added: [...e.added], removed: [...e.removed] }
60
+ : { src, added: [], removed: [] };
61
+ }
62
+
63
+ /**
64
+ * Add a beat at an audio-file time. `detectedTimes` lets us no-op when the beat
65
+ * lands on an existing (non-removed) detected beat — otherwise the merge would
66
+ * drop it anyway and we'd record a phantom edit/undo/write. Returns the SAME
67
+ * reference when nothing changed so callers can skip persisting.
68
+ */
69
+ export function addUserBeat(
70
+ edits: BeatEditState | null,
71
+ src: string,
72
+ beat: UserBeat,
73
+ detectedTimes: number[] = [],
74
+ ): BeatEditState | null {
75
+ const active = activeEdits(edits, src);
76
+ // Already covered by a surviving detected beat → nothing to do.
77
+ const onLiveDetected =
78
+ detectedTimes.some((t) => near(t, beat.time)) &&
79
+ !(active?.removed ?? []).some((r) => near(r, beat.time));
80
+ if (onLiveDetected) return edits;
81
+ // Already an added beat here → nothing to do.
82
+ if ((active?.added ?? []).some((b) => near(b.time, beat.time))) return edits;
83
+
84
+ const next = base(edits, src);
85
+ // If a detected beat here was previously removed, drop the removal instead of stacking.
86
+ const ri = next.removed.findIndex((r) => near(r, beat.time));
87
+ if (ri >= 0) {
88
+ next.removed.splice(ri, 1);
89
+ return next;
90
+ }
91
+ next.added.push(beat);
92
+ return next;
93
+ }
94
+
95
+ /**
96
+ * Remove the beat nearest `time` — drops a user-added beat or hides a detected
97
+ * one. Returns the SAME reference when nothing changed (no added beat near
98
+ * `time`, and no live detected beat to hide) so callers can skip persisting a
99
+ * phantom edit/undo/write.
100
+ */
101
+ export function removeUserBeat(
102
+ edits: BeatEditState | null,
103
+ src: string,
104
+ detectedTimes: number[],
105
+ time: number,
106
+ ): BeatEditState | null {
107
+ const active = activeEdits(edits, src);
108
+ const hasAdded = (active?.added ?? []).some((b) => near(b.time, time));
109
+ const detected = detectedTimes.find((t) => near(t, time));
110
+ const alreadyHidden =
111
+ detected !== undefined && (active?.removed ?? []).some((r) => near(r, detected));
112
+ if (!hasAdded && (detected === undefined || alreadyHidden)) return edits;
113
+
114
+ const next = base(edits, src);
115
+ const ai = next.added.findIndex((b) => near(b.time, time));
116
+ if (ai >= 0) {
117
+ next.added.splice(ai, 1);
118
+ return next;
119
+ }
120
+ if (detected !== undefined && !next.removed.some((r) => near(r, detected))) {
121
+ next.removed.push(detected);
122
+ }
123
+ return next;
124
+ }
125
+
126
+ /** Move the beat at `fromTime` to `toBeat` (delete original, add new). */
127
+ export function moveUserBeat(
128
+ edits: BeatEditState | null,
129
+ src: string,
130
+ detectedTimes: number[],
131
+ fromTime: number,
132
+ toBeat: UserBeat,
133
+ ): BeatEditState | null {
134
+ const removed = removeUserBeat(edits, src, detectedTimes, fromTime);
135
+ return addUserBeat(removed, src, toBeat, detectedTimes) ?? removed;
136
+ }
@@ -1,3 +1,5 @@
1
+ import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
2
+
1
3
  const CLIPBOARD_MARKER = "hyperframes-clipboard:v1";
2
4
 
3
5
  export interface ClipboardPayload {
@@ -99,8 +101,7 @@ export function insertAsSibling(
99
101
  }
100
102
 
101
103
  // Fallback: insert after composition root opening tag (same as timeline clips)
102
- const rootOpenTag = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
103
- const rootMatch = rootOpenTag.exec(source);
104
+ const rootMatch = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source);
104
105
  if (rootMatch && rootMatch.index != null) {
105
106
  const insertAt = rootMatch.index + rootMatch[0].length;
106
107
  return source.slice(0, insertAt) + newHtml + source.slice(insertAt);
@@ -0,0 +1,2 @@
1
+ /** Matches the opening tag of a composition root element (e.g. `<div data-composition-id="main">`). */
2
+ export const COMPOSITION_ROOT_OPEN_TAG_RE = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { selectedKeyframePercentagesForElement } from "./keyframeSelection";
3
+
4
+ describe("selectedKeyframePercentagesForElement", () => {
5
+ it("returns the percentages of keyframes on the active element", () => {
6
+ const selected = new Set(["comp#a:25", "comp#a:75"]);
7
+ expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([25, 75]);
8
+ });
9
+
10
+ it("drops keyframes that belong to other elements", () => {
11
+ // The bug: a stale shift-selection on `comp#b` would otherwise have its
12
+ // percentages applied to the now-active `comp#a`, deleting the wrong keyframes.
13
+ const selected = new Set(["comp#a:25", "comp#b:50", "comp#b:80"]);
14
+ expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([25]);
15
+ });
16
+
17
+ it("returns nothing when no key belongs to the active element", () => {
18
+ const selected = new Set(["comp#b:50"]);
19
+ expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([]);
20
+ });
21
+
22
+ it("returns nothing when there is no active element", () => {
23
+ const selected = new Set(["comp#a:25"]);
24
+ expect(selectedKeyframePercentagesForElement(selected, null)).toEqual([]);
25
+ });
26
+
27
+ it("returns nothing for an empty selection", () => {
28
+ expect(selectedKeyframePercentagesForElement(new Set(), "comp#a")).toEqual([]);
29
+ });
30
+
31
+ it("splits on the final colon so element ids containing ':' still match", () => {
32
+ const selected = new Set(["a:b:40"]);
33
+ expect(selectedKeyframePercentagesForElement(selected, "a:b")).toEqual([40]);
34
+ });
35
+
36
+ it("skips keys without a percentage separator", () => {
37
+ const selected = new Set(["comp#a"]);
38
+ expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([]);
39
+ });
40
+
41
+ it("skips keys whose percentage is not a finite number", () => {
42
+ const selected = new Set(["comp#a:abc", "comp#a:NaN", "comp#a:30"]);
43
+ expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([30]);
44
+ });
45
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Resolves which keyframe percentages a bulk operation should act on.
3
+ *
4
+ * `selectedKeyframes` holds `"<elementId>:<percentage>"` keys and can contain
5
+ * keyframes from more than one element — e.g. a shift-selection made before the
6
+ * active element changed (via a keyframe click, a clip click, the layers panel,
7
+ * or the keyframe context menu). A bulk delete only targets the active
8
+ * element's animation, so keys belonging to other elements must be dropped;
9
+ * otherwise their percentages get applied to the active element and remove
10
+ * keyframes the user never selected on it.
11
+ *
12
+ * The element id is everything before the final `:` so element ids that happen
13
+ * to contain `:` are handled correctly.
14
+ */
15
+ export function selectedKeyframePercentagesForElement(
16
+ selectedKeyframes: ReadonlySet<string>,
17
+ activeElementId: string | null,
18
+ ): number[] {
19
+ if (!activeElementId) return [];
20
+ const percentages: number[] = [];
21
+ for (const key of selectedKeyframes) {
22
+ const separator = key.lastIndexOf(":");
23
+ if (separator < 0) continue;
24
+ if (key.slice(0, separator) !== activeElementId) continue;
25
+ const percentage = Number(key.slice(separator + 1));
26
+ if (Number.isFinite(percentage)) percentages.push(percentage);
27
+ }
28
+ return percentages;
29
+ }
@@ -0,0 +1,9 @@
1
+ /** Round to 3 decimal places (millisecond precision for GSAP values). */
2
+ export function roundTo3(val: number): number {
3
+ return Math.round(val * 1000) / 1000;
4
+ }
5
+
6
+ /** Round to 2 decimal places (centisecond precision for timeline values). */
7
+ export function roundToCenti(val: number): number {
8
+ return Math.round(val * 100) / 100;
9
+ }