@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.
- package/dist/assets/hyperframes-player-DgsMQSvV.js +418 -0
- package/dist/assets/index-B62bDCQv.css +1 -0
- package/dist/assets/index-Ce3pBm_I.js +252 -0
- package/dist/assets/{index-HveJ0MuV.js → index-D-ET9M0b.js} +1 -1
- package/dist/assets/index-D-bS9Dxx.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +7 -5
- package/src/App.tsx +182 -177
- package/src/captions/store.ts +11 -11
- package/src/components/StudioHeader.tsx +4 -4
- package/src/components/StudioLeftSidebar.tsx +2 -2
- package/src/components/StudioPreviewArea.tsx +225 -183
- package/src/components/StudioRightPanel.tsx +3 -3
- package/src/components/TimelineToolbar.tsx +25 -0
- package/src/components/editor/DomEditOverlay.tsx +2 -5
- package/src/components/editor/EaseCurveSection.tsx +2 -3
- package/src/components/editor/GestureTrailOverlay.tsx +4 -3
- package/src/components/editor/LayersPanel.tsx +3 -9
- package/src/components/editor/PropertyPanel.tsx +20 -61
- package/src/components/editor/colorValue.ts +3 -1
- package/src/components/editor/domEditOverlayGestures.ts +54 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
- package/src/components/editor/gradientValue.ts +3 -3
- package/src/components/editor/keyframeMove.test.ts +101 -0
- package/src/components/editor/keyframeMove.ts +151 -0
- package/src/components/editor/manualEditsDom.ts +0 -12
- package/src/components/editor/propertyPanelHelpers.ts +10 -38
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -5
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -6
- package/src/components/editor/propertyPanelTransformCommit.ts +129 -0
- package/src/components/editor/studioMotionOps.test.ts +1 -1
- package/src/components/editor/studioMotionOps.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +1 -46
- package/src/components/nle/NLELayout.tsx +1 -24
- package/src/components/sidebar/BlocksTab.tsx +2 -2
- package/src/contexts/DomEditContext.tsx +134 -31
- package/src/contexts/StudioContext.tsx +90 -40
- package/src/contexts/TimelineEditContext.tsx +47 -0
- package/src/hooks/domEditCommitTypes.ts +14 -0
- package/src/hooks/gsapDragCommit.ts +9 -24
- package/src/hooks/gsapKeyframeCacheHelpers.ts +2 -1
- package/src/hooks/gsapKeyframeCommit.ts +5 -15
- package/src/hooks/gsapRuntimeBridge.ts +18 -52
- package/src/hooks/gsapRuntimeKeyframes.ts +8 -57
- package/src/hooks/gsapRuntimeReaders.ts +19 -26
- package/src/hooks/gsapScriptCommitHelpers.ts +1 -11
- package/src/hooks/gsapScriptCommitTypes.ts +58 -0
- package/src/hooks/gsapShared.ts +157 -0
- package/src/hooks/timelineEditingHelpers.ts +63 -2
- package/src/hooks/useAnimatedPropertyCommit.ts +3 -25
- package/src/hooks/useAppHotkeys.ts +299 -377
- package/src/hooks/useConsoleErrorCapture.ts +33 -5
- package/src/hooks/useDomEditCommits.ts +35 -293
- package/src/hooks/useDomEditPositionPatchCommit.ts +1 -1
- package/src/hooks/useDomEditSession.ts +78 -249
- package/src/hooks/useDomEditTextCommits.ts +1 -1
- package/src/hooks/useDomEditWiring.ts +255 -0
- package/src/hooks/useDomGeometryCommits.ts +181 -0
- package/src/hooks/useDomSelection.ts +10 -27
- package/src/hooks/useEditorSave.ts +82 -0
- package/src/hooks/useElementLifecycleOps.ts +177 -0
- package/src/hooks/useEnableKeyframes.ts +10 -15
- package/src/hooks/useFileManager.ts +32 -114
- package/src/hooks/useFileTree.ts +80 -0
- package/src/hooks/useGestureCommit.ts +7 -5
- package/src/hooks/useGestureRecording.ts +1 -1
- package/src/hooks/useGsapAnimationOps.ts +122 -0
- package/src/hooks/useGsapArcPathOps.ts +61 -0
- package/src/hooks/useGsapAwareEditing.ts +242 -0
- package/src/hooks/useGsapKeyframeOps.ts +167 -0
- package/src/hooks/useGsapPropertyDebounce.ts +135 -0
- package/src/hooks/useGsapScriptCommits.ts +58 -570
- package/src/hooks/useGsapSelectionHandlers.ts +22 -9
- package/src/hooks/useGsapTweenCache.ts +35 -29
- package/src/hooks/useLintModal.ts +7 -0
- package/src/hooks/useMusicBeatAnalysis.ts +152 -0
- package/src/hooks/useRazorSplit.ts +1 -1
- package/src/hooks/useRenderClipContent.ts +46 -21
- package/src/hooks/useTimelineEditing.ts +48 -4
- package/src/player/components/AudioWaveform.tsx +29 -4
- package/src/player/components/BeatStrip.tsx +166 -0
- package/src/player/components/Timeline.tsx +39 -18
- package/src/player/components/TimelineCanvas.tsx +52 -12
- package/src/player/components/TimelineClipDiamonds.tsx +130 -20
- package/src/player/components/TimelinePropertyRows.tsx +8 -2
- package/src/player/components/TimelineRuler.tsx +36 -2
- package/src/player/components/timelineEditing.ts +30 -5
- package/src/player/components/useTimelineClipDrag.ts +155 -4
- package/src/player/components/useTimelinePlayhead.ts +30 -1
- package/src/player/hooks/useTimelinePlayer.ts +47 -45
- package/src/player/lib/mediaProbe.ts +46 -3
- package/src/player/lib/playbackScrub.ts +16 -0
- package/src/player/lib/timelineDOM.ts +10 -2
- package/src/player/lib/timelineIframeHelpers.ts +89 -0
- package/src/player/store/playerStore.ts +92 -33
- package/src/utils/beatEditActions.ts +109 -0
- package/src/utils/beatEditing.ts +136 -0
- package/src/utils/clipboardPayload.ts +3 -2
- package/src/utils/compositionPatterns.ts +2 -0
- package/src/utils/keyframeSelection.test.ts +45 -0
- package/src/utils/keyframeSelection.ts +29 -0
- package/src/utils/rounding.ts +9 -0
- package/src/utils/studioHelpers.ts +5 -2
- package/src/utils/studioUrlState.ts +2 -1
- package/src/utils/timelineAssetDrop.ts +6 -5
- package/src/utils/timelineInspector.ts +15 -100
- package/dist/assets/hyperframes-player-Daj5djxa.js +0 -418
- package/dist/assets/index-B0twsRu0.css +0 -1
- package/dist/assets/index-Cfye9xzo.js +0 -251
- package/src/components/editor/DopesheetStrip.tsx +0 -141
- package/src/components/editor/StaggerControls.tsx +0 -61
- package/src/components/editor/TimelineLayerPanel.test.ts +0 -42
- package/src/components/editor/TimelineLayerPanel.tsx +0 -15
- package/src/components/nle/TimelineEditorNotice.tsx +0 -133
- package/src/hooks/gsapRuntimePreview.ts +0 -19
- package/src/player/components/timelineUtils.ts +0 -211
- package/src/utils/audioBeatDetection.ts +0 -58
- package/src/utils/keyframeSnapping.test.ts +0 -74
- package/src/utils/keyframeSnapping.ts +0 -63
- 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
|
-
|
|
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
|
|
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,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
|
+
}
|