@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
@@ -0,0 +1,166 @@
1
+ import { memo, useRef, useState } from "react";
2
+ import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions";
3
+ import { usePlayerStore } from "../store/playerStore";
4
+ import { CLIP_Y } from "./timelineLayout";
5
+
6
+ export const BEAT_BAND_H = 14; // dark band height at top of track
7
+ const BEAT_HIT_W = 12; // grab width per beat (px)
8
+
9
+ /** Hide both layers when beats are packed tighter than this (px) — too dense to read. */
10
+ function beatsTooDense(beatTimes: number[], pps: number): boolean {
11
+ if (beatTimes.length < 2) return true;
12
+ const avgInterval = (beatTimes[beatTimes.length - 1]! - beatTimes[0]!) / (beatTimes.length - 1);
13
+ return avgInterval * pps < 5;
14
+ }
15
+
16
+ /**
17
+ * Faint full-height beat lines painted into a track lane's background. Rendered
18
+ * behind the clips so they only show through the empty track area (the dots in
19
+ * BeatStrip mark beats on the clips themselves). Brightness scales with beat
20
+ * loudness. Drawn on every track lane for a global beat grid.
21
+ */
22
+ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
23
+ beatTimes,
24
+ beatStrengths,
25
+ pps,
26
+ highlightTime,
27
+ }: {
28
+ beatTimes: number[] | undefined;
29
+ beatStrengths: number[] | undefined;
30
+ pps: number;
31
+ /** Beat time a dragged clip will snap to — drawn as a bright neon line. */
32
+ highlightTime?: number | null;
33
+ }) {
34
+ if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
35
+ return (
36
+ <div className="absolute inset-0 pointer-events-none" style={{ zIndex: 0 }}>
37
+ {beatTimes.map((t, i) => {
38
+ const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3;
39
+ const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
40
+ const opacity = isHighlight ? 1 : 0.06 + strength * 0.16;
41
+ return (
42
+ <div
43
+ key={`${t}-${i}`}
44
+ className="absolute top-0 bottom-0"
45
+ style={{
46
+ left: t * pps,
47
+ width: isHighlight ? 2 : 1,
48
+ background: `rgba(34,197,94,${opacity.toFixed(3)})`,
49
+ boxShadow: isHighlight ? "0 0 6px rgba(34,197,94,0.9)" : undefined,
50
+ zIndex: isHighlight ? 1 : undefined,
51
+ }}
52
+ />
53
+ );
54
+ })}
55
+ </div>
56
+ );
57
+ });
58
+
59
+ /**
60
+ * Green beat dots on the music track's row. Drag a dot to move its beat,
61
+ * double-click to delete; both scrub the audio. Dot size/brightness scale with
62
+ * beat loudness (gamma-curved for contrast).
63
+ */
64
+ export const BeatStrip = memo(function BeatStrip({
65
+ beatTimes,
66
+ beatStrengths,
67
+ pps,
68
+ }: {
69
+ beatTimes: number[] | undefined;
70
+ beatStrengths: number[] | undefined;
71
+ pps: number;
72
+ }) {
73
+ // Active drag: which beat and how far (px) it's been dragged.
74
+ const [drag, setDrag] = useState<{ index: number; dx: number } | null>(null);
75
+ const dragRef = useRef<{ index: number; startX: number; origTime: number } | null>(null);
76
+
77
+ if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
78
+ const cy = BEAT_BAND_H / 2;
79
+
80
+ return (
81
+ <div
82
+ className="absolute left-0 right-0 pointer-events-none"
83
+ style={{ top: CLIP_Y, height: BEAT_BAND_H, background: "rgba(0,0,0,0.28)", zIndex: 11 }}
84
+ >
85
+ {beatTimes.map((t, i) => {
86
+ // Louder beats → larger, brighter dot. Gamma curve widens the contrast.
87
+ const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
88
+ const r = 1.5 + strength * 2.5;
89
+ const opacity = 0.25 + strength * 0.75;
90
+ const dxPx = drag?.index === i ? drag.dx : 0;
91
+ const x = t * pps + dxPx;
92
+ return (
93
+ <div
94
+ key={`${t}-${i}`}
95
+ className="absolute select-none"
96
+ title="Drag to move · double-click to delete"
97
+ draggable={false}
98
+ style={{
99
+ left: x - BEAT_HIT_W / 2,
100
+ top: 0,
101
+ width: BEAT_HIT_W,
102
+ height: BEAT_BAND_H,
103
+ cursor: "ew-resize",
104
+ pointerEvents: "auto",
105
+ touchAction: "none",
106
+ }}
107
+ onPointerDown={(e) => {
108
+ // preventDefault stops the browser starting a native text/drag
109
+ // selection (which otherwise "selects" the whole panel mid-drag).
110
+ e.preventDefault();
111
+ e.stopPropagation();
112
+ e.currentTarget.setPointerCapture(e.pointerId);
113
+ dragRef.current = { index: i, startX: e.clientX, origTime: t };
114
+ setDrag({ index: i, dx: 0 });
115
+ usePlayerStore.getState().setBeatDragging(true); // hide the playhead guideline
116
+ usePlayerStore.getState().requestSeek(Math.max(0, t)); // scrub audio at beat
117
+ }}
118
+ onPointerMove={(e) => {
119
+ const d = dragRef.current;
120
+ if (!d || d.index !== i) return;
121
+ e.preventDefault();
122
+ const dx = e.clientX - d.startX;
123
+ setDrag({ index: i, dx });
124
+ // Scrub the audio (and move the playhead) to follow the dragged beat.
125
+ usePlayerStore.getState().requestSeek(Math.max(0, d.origTime + dx / pps));
126
+ }}
127
+ onPointerUp={(e) => {
128
+ const d = dragRef.current;
129
+ dragRef.current = null;
130
+ setDrag(null);
131
+ usePlayerStore.getState().setBeatDragging(false);
132
+ if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {
133
+ e.currentTarget.releasePointerCapture(e.pointerId);
134
+ }
135
+ if (!d || d.index !== i) return;
136
+ const dx = e.clientX - d.startX;
137
+ if (Math.abs(dx) > 2) {
138
+ const newTime = Math.max(0, d.origTime + dx / pps);
139
+ moveBeatCompositionTime(d.origTime, newTime);
140
+ usePlayerStore.getState().requestSeek(newTime); // park scrubber at new beat
141
+ }
142
+ }}
143
+ onDoubleClick={(e) => {
144
+ e.stopPropagation();
145
+ deleteBeatAtCompositionTime(t);
146
+ usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat
147
+ }}
148
+ >
149
+ <div
150
+ className="absolute"
151
+ style={{
152
+ left: BEAT_HIT_W / 2 - r,
153
+ top: cy - r,
154
+ width: r * 2,
155
+ height: r * 2,
156
+ borderRadius: "50%",
157
+ background: `rgba(34,197,94,${opacity.toFixed(3)})`,
158
+ pointerEvents: "none",
159
+ }}
160
+ />
161
+ </div>
162
+ );
163
+ })}
164
+ </div>
165
+ );
166
+ });
@@ -1,4 +1,7 @@
1
1
  import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode } from "react";
2
+ import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
3
+ import { isMusicTrack } from "../../utils/timelineInspector";
4
+ import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
2
5
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
6
  import { useMountEffect } from "../../hooks/useMountEffect";
4
7
  import { EditPopover } from "./EditModal";
@@ -16,6 +19,7 @@ import {
16
19
  type KeyframeDiamondContextMenuState,
17
20
  } from "./KeyframeDiamondContextMenu";
18
21
  import { useTimelineClipDrag } from "./useTimelineClipDrag";
22
+ import { snapKeyframePctToBeat } from "./timelineEditing";
19
23
  import { ClipContextMenu } from "./ClipContextMenu";
20
24
  import {
21
25
  GUTTER,
@@ -23,7 +27,8 @@ import {
23
27
  getTimelineCanvasHeight,
24
28
  shouldShowTimelineShortcutHint,
25
29
  } from "./timelineLayout";
26
- import type { TimelineEditCallbacks, TimelineDropCallbacks } from "./timelineCallbacks";
30
+ import type { TimelineDropCallbacks } from "./timelineCallbacks";
31
+ import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
27
32
 
28
33
  // Re-export pure utilities so existing imports from "./Timeline" still resolve.
29
34
  export {
@@ -40,7 +45,7 @@ export {
40
45
  getDefaultDroppedTrack,
41
46
  } from "./timelineLayout";
42
47
 
43
- interface TimelineProps extends TimelineEditCallbacks, TimelineDropCallbacks {
48
+ interface TimelineProps extends TimelineDropCallbacks {
44
49
  onSeek?: (time: number) => void;
45
50
  onDrillDown?: (element: TimelineElement) => void;
46
51
  renderClipContent?: (
@@ -62,22 +67,32 @@ export const Timeline = memo(function Timeline({
62
67
  onAssetDrop,
63
68
  onBlockDrop,
64
69
  onDeleteElement: _onDeleteElement,
65
- onMoveElement,
66
- onResizeElement,
67
- onBlockedEditAttempt,
68
- onSplitElement,
69
- onRazorSplit,
70
- onRazorSplitAll,
71
70
  onSelectElement,
72
- onDeleteKeyframe,
73
- onDeleteAllKeyframes,
74
- onChangeKeyframeEase,
75
- onMoveKeyframe,
76
- onToggleKeyframeAtPlayhead,
77
71
  theme: themeOverrides,
78
72
  }: TimelineProps = {}) {
73
+ const {
74
+ onMoveElement,
75
+ onResizeElement,
76
+ onBlockedEditAttempt,
77
+ onSplitElement,
78
+ onRazorSplitAll,
79
+ onDeleteKeyframe,
80
+ onDeleteAllKeyframes,
81
+ onChangeKeyframeEase,
82
+ onMoveKeyframe,
83
+ } = useTimelineEditContext();
79
84
  const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
85
+ useMusicBeatAnalysis();
80
86
  const elements = usePlayerStore((s) => s.elements);
87
+ const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
88
+ const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null);
89
+
90
+ // Merge user edits + remap beats from audio-file → composition coordinates.
91
+ const beatEdits = usePlayerStore((s) => s.beatEdits);
92
+ const adjustedBeatAnalysis = useMemo(
93
+ () => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
94
+ [beatAnalysis, musicElement, beatEdits],
95
+ );
81
96
  const duration = usePlayerStore((s) => s.duration);
82
97
  const timelineReady = usePlayerStore((s) => s.timelineReady);
83
98
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
@@ -423,8 +438,6 @@ export const Timeline = memo(function Timeline({
423
438
  renderClipContent={renderClipContent}
424
439
  renderClipOverlay={renderClipOverlay}
425
440
  playheadRef={playheadRef}
426
- onResizeElement={onResizeElement}
427
- onMoveElement={onMoveElement}
428
441
  onDrillDown={onDrillDown}
429
442
  onSelectElement={onSelectElement}
430
443
  setHoveredClip={setHoveredClip}
@@ -440,7 +453,7 @@ export const Timeline = memo(function Timeline({
440
453
  keyframeCache={keyframeCache}
441
454
  selectedKeyframes={selectedKeyframes}
442
455
  currentTime={currentTime}
443
- onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead}
456
+ beatAnalysis={adjustedBeatAnalysis}
444
457
  onClickKeyframe={(el, pct) => {
445
458
  usePlayerStore.getState().clearSelectedKeyframes();
446
459
  const elKey = el.key ?? el.id;
@@ -458,6 +471,16 @@ export const Timeline = memo(function Timeline({
458
471
  onDragKeyframe={(el, oldPct, newPct) => {
459
472
  onMoveKeyframe?.(el, oldPct, newPct);
460
473
  }}
474
+ onSnapKeyframePct={(el, pct) =>
475
+ snapKeyframePctToBeat(el, pct, adjustedBeatAnalysis?.beatTimes, pps)
476
+ }
477
+ onPickKeyframeElement={(el) => {
478
+ const elKey = el.key ?? el.id;
479
+ if (selectedElementId !== elKey) {
480
+ setSelectedElementId(elKey);
481
+ onSelectElement?.(el);
482
+ }
483
+ }}
461
484
  onContextMenuKeyframe={(e, elId, pct) => {
462
485
  const el = elements.find((x) => (x.key ?? x.id) === elId);
463
486
  if (el) {
@@ -483,8 +506,6 @@ export const Timeline = memo(function Timeline({
483
506
  onSelectElement?.(el);
484
507
  setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
485
508
  }}
486
- onRazorSplit={onRazorSplit}
487
- onRazorSplitAll={onRazorSplitAll}
488
509
  />
489
510
  {activeTool === "razor" && razorGuideX !== null && (
490
511
  <div
@@ -1,7 +1,9 @@
1
1
  import { memo, type ReactNode } from "react";
2
+ import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
2
3
  import { TimelineClip } from "./TimelineClip";
3
4
  import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
4
5
  import { TimelineRuler } from "./TimelineRuler";
6
+ import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
5
7
  import { PlayheadIndicator } from "./PlayheadIndicator";
6
8
  import {
7
9
  getTimelineEditCapabilities,
@@ -19,6 +21,8 @@ import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./us
19
21
  import type { TrackVisualStyle } from "./timelineIcons";
20
22
  import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
21
23
  import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
24
+ import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
25
+ import { isMusicTrack } from "../../utils/timelineInspector";
22
26
 
23
27
  function ClipLabel({ element, color }: { element: TimelineElement; color: string }) {
24
28
  const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
@@ -66,8 +70,6 @@ interface TimelineCanvasProps {
66
70
  ) => ReactNode;
67
71
  renderClipOverlay?: (element: TimelineElement) => ReactNode;
68
72
  playheadRef: React.RefObject<HTMLDivElement | null>;
69
- onResizeElement?: unknown;
70
- onMoveElement?: unknown;
71
73
  onDrillDown?: (element: TimelineElement) => void;
72
74
  onSelectElement?: (element: TimelineElement | null) => void;
73
75
  setHoveredClip: (key: string | null) => void;
@@ -90,11 +92,13 @@ interface TimelineCanvasProps {
90
92
  onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
91
93
  onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
92
94
  onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
95
+ /** Snap a keyframe's clip-relative % to the nearest beat (returns unchanged when none in range). */
96
+ onSnapKeyframePct?: (element: TimelineElement, pct: number) => number;
97
+ /** Select the element when a keyframe drag starts (loads its GSAP session). */
98
+ onPickKeyframeElement?: (element: TimelineElement) => void;
93
99
  onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
94
100
  onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
95
- onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
96
- onRazorSplit?: (element: TimelineElement, splitTime: number) => void;
97
- onRazorSplitAll?: (splitTime: number) => void;
101
+ beatAnalysis?: MusicBeatAnalysis | null;
98
102
  }
99
103
 
100
104
  export const TimelineCanvas = memo(function TimelineCanvas({
@@ -122,8 +126,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
122
126
  renderClipContent,
123
127
  renderClipOverlay,
124
128
  playheadRef,
125
- onResizeElement,
126
- onMoveElement,
127
129
  onDrillDown,
128
130
  onSelectElement,
129
131
  setHoveredClip,
@@ -142,12 +144,15 @@ export const TimelineCanvas = memo(function TimelineCanvas({
142
144
  onClickKeyframe,
143
145
  onShiftClickKeyframe,
144
146
  onDragKeyframe,
147
+ onSnapKeyframePct,
148
+ onPickKeyframeElement,
145
149
  onContextMenuKeyframe,
146
150
  onContextMenuClip,
147
- onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead,
148
- onRazorSplit,
149
- onRazorSplitAll,
151
+ beatAnalysis,
150
152
  }: TimelineCanvasProps) {
153
+ const { onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll } =
154
+ useTimelineEditContext();
155
+ const beatDragging = usePlayerStore((s) => s.beatDragging);
151
156
  const draggedElement = draggedClip?.element ?? null;
152
157
  const activeDraggedElement =
153
158
  draggedClip?.started === true && draggedElement
@@ -204,6 +209,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
204
209
  shiftHeld={shiftHeld}
205
210
  rangeSelection={rangeSelection}
206
211
  theme={theme}
212
+ beatAnalysis={beatAnalysis}
207
213
  />
208
214
 
209
215
  {displayTrackOrder.map((trackNum) => {
@@ -211,6 +217,14 @@ export const TimelineCanvas = memo(function TimelineCanvas({
211
217
  const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
212
218
  const isPendingTrack =
213
219
  draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
220
+ // The beat-dot strip occupies the top of this track's lane (active track,
221
+ // or the music track when nothing is selected). When shown, keyframe
222
+ // diamonds shrink + drop to the bottom half so they don't collide with it.
223
+ const beatStripOnTrack =
224
+ (beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
225
+ (selectedElementId
226
+ ? els.some((e) => (e.key ?? e.id) === selectedElementId)
227
+ : els.some(isMusicTrack));
214
228
  return (
215
229
  <div
216
230
  key={trackNum}
@@ -244,6 +258,23 @@ export const TimelineCanvas = memo(function TimelineCanvas({
244
258
  </div>
245
259
  </div>
246
260
  <div style={{ width: trackContentWidth }} className="relative">
261
+ {/* Faint beat lines in every track's background (behind the clips);
262
+ the active move-snap target is highlighted. */}
263
+ <BeatBackgroundLines
264
+ beatTimes={beatAnalysis?.beatTimes}
265
+ beatStrengths={beatAnalysis?.beatStrengths}
266
+ pps={pps}
267
+ highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
268
+ />
269
+ {/* Beat dots on the active track (the one holding the selection),
270
+ falling back to the music track when nothing is selected. */}
271
+ {beatStripOnTrack && (
272
+ <BeatStrip
273
+ beatTimes={beatAnalysis?.beatTimes}
274
+ beatStrengths={beatAnalysis?.beatStrengths}
275
+ pps={pps}
276
+ />
277
+ )}
247
278
  {isPendingTrack && (
248
279
  <div
249
280
  className="absolute inset-0 flex items-center"
@@ -358,6 +389,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
358
389
  pointerOffsetY: e.clientY - rect.top,
359
390
  previewStart: el.start,
360
391
  previewTrack: el.track,
392
+ snapBeatTime: null,
361
393
  started: false,
362
394
  });
363
395
  syncClipDragAutoScroll(e.clientX, e.clientY);
@@ -402,6 +434,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
402
434
  keyframesData={keyframeCache.get(elementKey)!}
403
435
  clipWidthPx={Math.max(previewElement.duration * pps, 4)}
404
436
  clipHeightPx={TRACK_H - 2 * CLIP_Y}
437
+ beatsActive={beatStripOnTrack}
405
438
  accentColor={clipStyle.accent}
406
439
  isSelected={isSelected}
407
440
  currentPercentage={
@@ -416,6 +449,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({
416
449
  onDragKeyframe={(oldPct, newPct) =>
417
450
  onDragKeyframe?.(previewElement, oldPct, newPct)
418
451
  }
452
+ snapPct={(pct) => onSnapKeyframePct?.(previewElement, pct) ?? pct}
453
+ onPickForDrag={() => onPickKeyframeElement?.(previewElement)}
419
454
  onContextMenuKeyframe={onContextMenuKeyframe}
420
455
  />
421
456
  )}
@@ -479,11 +514,16 @@ export const TimelineCanvas = memo(function TimelineCanvas({
479
514
  />
480
515
  )}
481
516
 
482
- {/* Playhead */}
517
+ {/* Playhead — hidden while dragging a beat so its guideline doesn't
518
+ track the scrub and clutter the beat being moved. */}
483
519
  <div
484
520
  ref={playheadRef}
485
521
  className="absolute top-0 bottom-0 pointer-events-none"
486
- style={{ left: `${GUTTER}px`, zIndex: 100 }}
522
+ style={{
523
+ left: `${GUTTER}px`,
524
+ zIndex: 100,
525
+ display: beatDragging ? "none" : undefined,
526
+ }}
487
527
  >
488
528
  <PlayheadIndicator />
489
529
  </div>
@@ -1,4 +1,5 @@
1
- import { memo, useRef } from "react";
1
+ import { memo, useEffect, useRef, useState } from "react";
2
+ import { BEAT_BAND_H } from "./BeatStrip";
2
3
 
3
4
  interface KeyframeEntry {
4
5
  percentage: number;
@@ -17,6 +18,9 @@ interface TimelineClipDiamondsProps {
17
18
  keyframesData: KeyframeCacheEntry;
18
19
  clipWidthPx: number;
19
20
  clipHeightPx: number;
21
+ /** Beat-dot strip is shown on this track → shrink diamonds + drop them into
22
+ * the bottom half so they clear the strip at the top. */
23
+ beatsActive?: boolean;
20
24
  accentColor: string;
21
25
  isSelected: boolean;
22
26
  currentPercentage: number;
@@ -26,14 +30,31 @@ interface TimelineClipDiamondsProps {
26
30
  onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
27
31
  onDragKeyframe?: (percentage: number, newPercentage: number) => void;
28
32
  onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
33
+ /** Snap a clip-relative percentage to the nearest beat (returns it unchanged
34
+ * when no beat is within range). Drives live beat-snapping while dragging. */
35
+ snapPct?: (percentage: number) => number;
36
+ /** Select this element when a keyframe drag begins, so its GSAP session is
37
+ * loaded by the time the move commits (diamonds render on unselected clips
38
+ * too, and a drag suppresses the selecting click). */
39
+ onPickForDrag?: () => void;
29
40
  }
30
41
 
31
42
  const DIAMOND_RATIO = 0.8;
43
+ // Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
44
+ // slightly outside [0, 100] (from rounding or stale cache during the async
45
+ // persist → reload cycle) are clamped to the clip edge rather than hidden.
46
+ export const KF_MIN_PCT = -5;
47
+ export const KF_MAX_PCT = 105;
48
+
49
+ function clampDiamondLeft(rawLeft: number, diamondSize: number, clipWidth: number): number {
50
+ return Math.max(0, Math.min(clipWidth - diamondSize, rawLeft));
51
+ }
32
52
 
33
53
  export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
34
54
  keyframesData,
35
55
  clipWidthPx,
36
56
  clipHeightPx,
57
+ beatsActive,
37
58
  accentColor,
38
59
  isSelected,
39
60
  currentPercentage,
@@ -43,14 +64,62 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
43
64
  onShiftClickKeyframe,
44
65
  onDragKeyframe,
45
66
  onContextMenuKeyframe,
67
+ snapPct,
68
+ onPickForDrag,
46
69
  }: TimelineClipDiamondsProps) {
47
- const dragRef = useRef<{ startX: number; startPct: number } | null>(null);
70
+ // Live drag: which keyframe (by original %) is being dragged and its current
71
+ // (beat-snapped) %, so the diamond + its connecting lines follow the cursor.
72
+ const dragRef = useRef<{ origPct: number; pct: number; moved: boolean } | null>(null);
73
+ const [drag, setDrag] = useState<{ origPct: number; pct: number } | null>(null);
74
+ // Commit through the latest callback, not the one captured at pointer-down:
75
+ // selecting the element on drag-start loads its GSAP session asynchronously,
76
+ // and the commit must use the closure that sees the loaded session.
77
+ const onDragKeyframeRef = useRef(onDragKeyframe);
78
+ onDragKeyframeRef.current = onDragKeyframe;
79
+ // Optimistic hold: after a commit, keep the diamond at the dropped position
80
+ // until the cache reflects the change (the file round-trip rewrites
81
+ // keyframesData), so it doesn't flash back to the old spot in between.
82
+ const pendingRef = useRef(false);
83
+ const pendingHeldPctRef = useRef<number | null>(null);
84
+ const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
85
+ // Cleanup for an in-flight drag's document listeners, so an unmount mid-drag
86
+ // (clip deleted, comp switch, zoom-out → early return) doesn't leak them.
87
+ const dragCleanupRef = useRef<(() => void) | null>(null);
88
+
89
+ useEffect(() => {
90
+ if (!pendingRef.current) return;
91
+ // Only release the optimistic hold once the cache actually reflects the
92
+ // committed position (a keyframe near the held %). An unrelated cache
93
+ // rebuild (e.g. elementCount change) rebuilds keyframesData with the SAME
94
+ // percentages — releasing then would flash the diamond back to the old spot.
95
+ const held = pendingHeldPctRef.current;
96
+ if (held != null && !keyframesData.keyframes.some((k) => Math.abs(k.percentage - held) < 0.3)) {
97
+ return;
98
+ }
99
+ pendingRef.current = false;
100
+ pendingHeldPctRef.current = null;
101
+ if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
102
+ setDrag(null);
103
+ }, [keyframesData]);
104
+
105
+ useEffect(
106
+ () => () => {
107
+ clearTimeout(pendingTimerRef.current ?? undefined);
108
+ dragCleanupRef.current?.();
109
+ },
110
+ [],
111
+ );
48
112
 
49
113
  if (clipWidthPx < 20) return null;
50
114
 
51
- const diamondSize = Math.round(clipHeightPx * DIAMOND_RATIO);
115
+ // When the beat strip occupies the top band, shrink the diamonds and center
116
+ // them in the remaining bottom region so they don't collide with it.
117
+ const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO));
52
118
  const half = diamondSize / 2;
53
- const sorted = keyframesData.keyframes.slice().sort((a, b) => a.percentage - b.percentage);
119
+ const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
120
+ const sorted = keyframesData.keyframes
121
+ .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
122
+ .sort((a, b) => a.percentage - b.percentage);
54
123
  const baseColor = isSelected ? accentColor : "#a3a3a3";
55
124
  const baseOpacity = isSelected ? 0.4 : 0.25;
56
125
 
@@ -66,47 +135,84 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
66
135
  const handlePointerDown = (e: React.PointerEvent, pct: number) => {
67
136
  if (e.button !== 0) return;
68
137
  e.stopPropagation();
138
+ // Ignore a new drag while a prior drop is still settling: `pct` comes from
139
+ // props (the pre-drop position) but the diamond is held at its dropped spot
140
+ // via effPct(), so a re-grab would track from a stale origin and commit
141
+ // against the wrong tween. The hold clears on the cache round-trip (≤2s).
142
+ if (pendingRef.current) return;
143
+ // Select the element up front so its GSAP session loads during the drag and
144
+ // the commit (which resolves the animation from the selection) isn't a no-op.
145
+ onPickForDrag?.();
69
146
  const startX = e.clientX;
147
+ dragRef.current = { origPct: pct, pct, moved: false };
70
148
 
71
149
  const handleMove = (me: PointerEvent) => {
150
+ const d = dragRef.current;
151
+ if (!d) return;
72
152
  const dx = me.clientX - startX;
73
- if (Math.abs(dx) > 4) {
74
- dragRef.current = { startX, startPct: pct };
75
- }
153
+ // 4px dead zone so a click doesn't register as a drag.
154
+ if (!d.moved && Math.abs(dx) <= 4) return;
155
+ d.moved = true;
156
+ const rawPct = Math.max(0, Math.min(100, pct + (dx / clipWidthPx) * 100));
157
+ const snapped = snapPct ? snapPct(rawPct) : rawPct;
158
+ d.pct = snapped;
159
+ setDrag({ origPct: pct, pct: snapped });
76
160
  };
77
161
 
78
- const handleUp = (ue: PointerEvent) => {
162
+ const handleUp = () => {
79
163
  document.removeEventListener("pointermove", handleMove);
80
164
  document.removeEventListener("pointerup", handleUp);
81
- const start = dragRef.current;
165
+ dragCleanupRef.current = null;
166
+ const d = dragRef.current;
82
167
  dragRef.current = null;
83
- if (!start) return;
84
- const dx = ue.clientX - start.startX;
85
- const dPct = (dx / clipWidthPx) * 100;
86
- const newPct = Math.max(0, Math.min(100, Math.round(start.startPct + dPct)));
87
- if (Math.abs(newPct - start.startPct) > 0.5) {
88
- onDragKeyframe?.(start.startPct, newPct);
168
+ const willCommit = !!(d && d.moved && Math.abs(d.pct - d.origPct) > 0.5);
169
+ if (willCommit && d) {
170
+ // Hold the dropped position optimistically; the effect clears it once the
171
+ // cache round-trip lands (fallback timeout in case it never does).
172
+ pendingRef.current = true;
173
+ pendingHeldPctRef.current = d.pct;
174
+ setDrag({ origPct: d.origPct, pct: d.pct });
175
+ if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
176
+ pendingTimerRef.current = setTimeout(() => {
177
+ pendingRef.current = false;
178
+ pendingHeldPctRef.current = null;
179
+ setDrag(null);
180
+ }, 2000);
181
+ onDragKeyframeRef.current?.(d.origPct, d.pct);
182
+ } else {
183
+ setDrag(null);
89
184
  }
90
185
  };
91
186
 
187
+ dragCleanupRef.current = () => {
188
+ document.removeEventListener("pointermove", handleMove);
189
+ document.removeEventListener("pointerup", handleUp);
190
+ };
191
+
92
192
  document.addEventListener("pointermove", handleMove);
93
193
  document.addEventListener("pointerup", handleUp);
94
194
  };
95
195
 
196
+ const effPct = (p: number): number => (drag && drag.origPct === p ? drag.pct : p);
197
+
96
198
  return (
97
199
  <div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
98
200
  {sorted.map((kf, i) => {
99
201
  if (i === 0) return null;
100
202
  const prev = sorted[i - 1]!;
101
- const x1 = (prev.percentage / 100) * clipWidthPx;
102
- const x2 = (kf.percentage / 100) * clipWidthPx;
203
+ const x1 = Math.max(
204
+ 0,
205
+ Math.min(clipWidthPx, (effPct(prev.percentage) / 100) * clipWidthPx),
206
+ );
207
+ const x2 = Math.max(0, Math.min(clipWidthPx, (effPct(kf.percentage) / 100) * clipWidthPx));
208
+ if (x2 - x1 < 1) return null;
103
209
  return (
104
210
  <div
105
211
  key={`line-${i}-${prev.percentage}-${kf.percentage}`}
106
212
  className="absolute"
107
213
  style={{
108
214
  left: x1,
109
- top: "50%",
215
+ top: centerY,
110
216
  width: x2 - x1,
111
217
  height: 2,
112
218
  transform: "translateY(-1px)",
@@ -119,7 +225,11 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
119
225
  })}
120
226
 
121
227
  {sorted.map((kf, i) => {
122
- const leftPx = (kf.percentage / 100) * clipWidthPx - half;
228
+ const leftPx = clampDiamondLeft(
229
+ (effPct(kf.percentage) / 100) * clipWidthPx - half,
230
+ diamondSize,
231
+ clipWidthPx,
232
+ );
123
233
  const kfKey = `${elementId}:${kf.percentage}`;
124
234
  const isKfSelected = selectedKeyframes.has(kfKey);
125
235
  const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
@@ -132,7 +242,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
132
242
  className="absolute"
133
243
  style={{
134
244
  left: leftPx,
135
- top: "50%",
245
+ top: centerY,
136
246
  transform: "translateY(-50%)",
137
247
  width: diamondSize,
138
248
  height: diamondSize,