@hyperframes/studio 0.6.97 → 0.6.99

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-HveJ0MuV.js → index-C52IT_lp.js} +1 -1
  4. package/dist/assets/index-DOh7E1uj.js +1 -0
  5. package/dist/assets/index-DrwSRbsl.js +252 -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
@@ -1,5 +1,6 @@
1
1
  import { memo } from "react";
2
2
  import type { KeyframeCacheEntry } from "../store/playerStore";
3
+ import { KF_MIN_PCT, KF_MAX_PCT } from "./TimelineClipDiamonds";
3
4
 
4
5
  const SUB_TRACK_H = 24;
5
6
  const DIAMOND_SIZE = 6;
@@ -44,7 +45,9 @@ export const TimelinePropertyRows = memo(function TimelinePropertyRows({
44
45
  return (
45
46
  <div className="flex flex-col">
46
47
  {properties.map((prop) => {
47
- const propKeyframes = keyframesData.keyframes.filter((kf) => prop in kf.properties);
48
+ const propKeyframes = keyframesData.keyframes
49
+ .filter((kf) => prop in kf.properties)
50
+ .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT);
48
51
  if (propKeyframes.length === 0) return null;
49
52
 
50
53
  return (
@@ -67,7 +70,10 @@ export const TimelinePropertyRows = memo(function TimelinePropertyRows({
67
70
  strokeWidth={1}
68
71
  />
69
72
  {propKeyframes.map((kf) => {
70
- const x = (kf.percentage / 100) * clipWidthPx;
73
+ const x = Math.max(
74
+ HALF,
75
+ Math.min(clipWidthPx - HALF, (kf.percentage / 100) * clipWidthPx),
76
+ );
71
77
  const y = SUB_TRACK_H / 2;
72
78
  const key = `${elementId}:${kf.percentage}`;
73
79
  const isKfSelected = selectedKeyframes.has(key);
@@ -2,6 +2,7 @@ import { memo } from "react";
2
2
  import type { TimelineTheme } from "./timelineTheme";
3
3
  import type { TimelineRangeSelection } from "./timelineEditing";
4
4
  import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
5
+ import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
5
6
 
6
7
  interface TimelineRulerProps {
7
8
  major: number[];
@@ -14,6 +15,7 @@ interface TimelineRulerProps {
14
15
  shiftHeld: boolean;
15
16
  rangeSelection: TimelineRangeSelection | null;
16
17
  theme: TimelineTheme;
18
+ beatAnalysis?: MusicBeatAnalysis | null;
17
19
  }
18
20
 
19
21
  export const TimelineRuler = memo(function TimelineRuler({
@@ -27,13 +29,25 @@ export const TimelineRuler = memo(function TimelineRuler({
27
29
  shiftHeld,
28
30
  rangeSelection,
29
31
  theme,
32
+ beatAnalysis,
30
33
  }: TimelineRulerProps) {
34
+ const beatTimes = beatAnalysis?.beatTimes ?? [];
35
+ const beatStrengths = beatAnalysis?.beatStrengths ?? [];
36
+
37
+ // Only draw beat lines when they'd be at least 5px apart
38
+ const avgBeatInterval =
39
+ beatTimes.length > 1
40
+ ? (beatTimes[beatTimes.length - 1]! - beatTimes[0]!) / (beatTimes.length - 1)
41
+ : null;
42
+ const showBeats = avgBeatInterval !== null && avgBeatInterval * pps >= 5;
43
+
31
44
  return (
32
45
  <>
33
- {/* Grid lines */}
46
+ {/* Grid lines (major ticks + beat lines) — behind the tracks (background).
47
+ Opaque track rows hide them; only the beat dots show on tracks. */}
34
48
  <svg
35
49
  className="absolute pointer-events-none"
36
- style={{ left: GUTTER, width: trackContentWidth }}
50
+ style={{ left: GUTTER, width: trackContentWidth, zIndex: 0 }}
37
51
  height={totalH}
38
52
  >
39
53
  {major.map((t) => {
@@ -50,6 +64,24 @@ export const TimelineRuler = memo(function TimelineRuler({
50
64
  />
51
65
  );
52
66
  })}
67
+ {showBeats &&
68
+ beatTimes.map((t, i) => {
69
+ const x = t * pps;
70
+ // Louder beats → brighter line. Gamma curve widens the contrast.
71
+ const strength = Math.pow(Math.min(1, beatStrengths[i] ?? 0.5), 2.2);
72
+ const opacity = 0.08 + strength * 0.62;
73
+ return (
74
+ <line
75
+ key={`b-${t}-${i}`}
76
+ x1={x}
77
+ y1={0}
78
+ x2={x}
79
+ y2={totalH}
80
+ stroke={`rgba(34, 197, 94, ${opacity.toFixed(3)})`}
81
+ strokeWidth="1"
82
+ />
83
+ );
84
+ })}
53
85
  </svg>
54
86
 
55
87
  {/* Ruler */}
@@ -64,11 +96,13 @@ export const TimelineRuler = memo(function TimelineRuler({
64
96
  </span>
65
97
  </div>
66
98
  )}
99
+
67
100
  {minor.map((t) => (
68
101
  <div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
69
102
  <div className="w-px h-[3px]" style={{ background: theme.tickMinor }} />
70
103
  </div>
71
104
  ))}
105
+
72
106
  {major.map((t) => (
73
107
  <div
74
108
  key={`M-${t}`}
@@ -1,10 +1,7 @@
1
1
  import { formatTime } from "../lib/time";
2
+ import { roundToCenti } from "../../utils/rounding";
2
3
 
3
- const TIME_PRECISION = 100;
4
-
5
- function roundToCentiseconds(value: number): number {
6
- return Math.round(value * TIME_PRECISION) / TIME_PRECISION;
7
- }
4
+ const roundToCentiseconds = roundToCenti;
8
5
 
9
6
  function clamp(value: number, min: number, max: number): number {
10
7
  return Math.min(Math.max(value, min), max);
@@ -114,6 +111,34 @@ export function resolveTimelineMove(
114
111
  };
115
112
  }
116
113
 
114
+ /**
115
+ * Snap a keyframe's clip-relative percentage to the nearest beat within ~8px,
116
+ * mapping through composition time (pct → time → nearest beat → pct). Returns
117
+ * the percentage unchanged when no beat is in range, so dragging stays free
118
+ * between beats.
119
+ */
120
+ export function snapKeyframePctToBeat(
121
+ el: { start: number; duration: number },
122
+ pct: number,
123
+ beatTimes: number[] | undefined,
124
+ pixelsPerSecond: number,
125
+ ): number {
126
+ if (!beatTimes || beatTimes.length === 0 || el.duration <= 0) return pct;
127
+ const t = el.start + (pct / 100) * el.duration;
128
+ const snapSecs = 8 / Math.max(pixelsPerSecond, 1);
129
+ let best = t;
130
+ let bestDist = snapSecs;
131
+ for (const bt of beatTimes) {
132
+ const d = Math.abs(bt - t);
133
+ if (d < bestDist) {
134
+ bestDist = d;
135
+ best = bt;
136
+ }
137
+ }
138
+ if (best === t) return pct;
139
+ return Math.max(0, Math.min(100, ((best - el.start) / el.duration) * 100));
140
+ }
141
+
117
142
  export function resolveTimelineResize(
118
143
  input: TimelineResizeInput,
119
144
  edge: "start" | "end",
@@ -1,4 +1,4 @@
1
- import { useRef, useState, useCallback } from "react";
1
+ import { useRef, useState, useCallback, useMemo } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import {
4
4
  resolveTimelineMove,
@@ -9,6 +9,64 @@ import {
9
9
  import { usePlayerStore } from "../store/playerStore";
10
10
  import type { TimelineElement } from "../store/playerStore";
11
11
  import { TRACK_H } from "./timelineLayout";
12
+ import { isMusicTrack } from "../../utils/timelineInspector";
13
+ import { mergeUserBeats } from "../../utils/beatEditing";
14
+
15
+ const BEAT_SNAP_PX = 8;
16
+ const EMPTY_BEAT_TIMES: number[] = [];
17
+
18
+ function snapToNearestBeat(time: number, beatTimes: number[], thresholdSecs: number): number {
19
+ let best = time;
20
+ let bestDist = thresholdSecs;
21
+ for (const bt of beatTimes) {
22
+ const d = Math.abs(bt - time);
23
+ if (d < bestDist) {
24
+ bestDist = d;
25
+ best = bt;
26
+ }
27
+ }
28
+ return best;
29
+ }
30
+
31
+ /**
32
+ * Snap a moved clip so whichever edge (start or end) is nearest a beat lands on
33
+ * it, keeping the duration fixed. Returns the (clamped) start plus the beat time
34
+ * it snapped to (for the grid-line highlight), or `beat: null` when no edge is
35
+ * within threshold.
36
+ */
37
+ function snapMoveStartToBeat(
38
+ start: number,
39
+ duration: number,
40
+ beatTimes: number[],
41
+ pixelsPerSecond: number,
42
+ timelineDuration: number,
43
+ ): { start: number; beat: number | null } {
44
+ if (beatTimes.length === 0) return { start, beat: null };
45
+ const snapSecs = BEAT_SNAP_PX / Math.max(pixelsPerSecond, 1);
46
+ const snappedStart = snapToNearestBeat(start, beatTimes, snapSecs);
47
+ const snappedEnd = snapToNearestBeat(start + duration, beatTimes, snapSecs);
48
+ const startMoved = snappedStart !== start;
49
+ const endMoved = snappedEnd !== start + duration;
50
+
51
+ let candidate = start;
52
+ let beat: number | null = null;
53
+ if (
54
+ startMoved &&
55
+ (!endMoved || Math.abs(snappedStart - start) <= Math.abs(snappedEnd - (start + duration)))
56
+ ) {
57
+ candidate = snappedStart;
58
+ beat = snappedStart;
59
+ } else if (endMoved) {
60
+ candidate = snappedEnd - duration;
61
+ beat = snappedEnd;
62
+ }
63
+
64
+ const maxStart = Math.max(0, timelineDuration - duration);
65
+ const clamped = Math.max(0, Math.min(maxStart, Math.round(candidate * 1000) / 1000));
66
+ // If clamping pulled the clip off the snap target, drop the highlight.
67
+ if (beat != null && Math.abs(clamped - candidate) > 1e-6) beat = null;
68
+ return { start: clamped, beat };
69
+ }
12
70
 
13
71
  /* ── Shared state types ─────────────────────────────────────────── */
14
72
  export interface DraggedClipState {
@@ -23,6 +81,8 @@ export interface DraggedClipState {
23
81
  pointerOffsetY: number;
24
82
  previewStart: number;
25
83
  previewTrack: number;
84
+ /** Beat time the clip will snap to on drop, for the grid-line highlight. */
85
+ snapBeatTime: number | null;
26
86
  started: boolean;
27
87
  }
28
88
 
@@ -76,6 +136,36 @@ export function useTimelineClipDrag({
76
136
  setRangeSelectionRef,
77
137
  }: UseTimelineClipDragInput) {
78
138
  const updateElement = usePlayerStore((s) => s.updateElement);
139
+ const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
140
+ const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
141
+ const beatEdits = usePlayerStore((s) => s.beatEdits);
142
+ const musicStart = usePlayerStore((s) => s.elements.find(isMusicTrack)?.start ?? 0);
143
+ const musicPlaybackStart = usePlayerStore(
144
+ (s) => s.elements.find(isMusicTrack)?.playbackStart ?? 0,
145
+ );
146
+ const musicDuration = usePlayerStore((s) => s.elements.find(isMusicTrack)?.duration ?? 0);
147
+ const musicSrc = usePlayerStore((s) => s.elements.find(isMusicTrack)?.src ?? null);
148
+
149
+ const adjustedBeatTimes = useMemo(() => {
150
+ if (rawBeatTimes === EMPTY_BEAT_TIMES || musicDuration === 0) return EMPTY_BEAT_TIMES;
151
+ const merged = mergeUserBeats(rawBeatTimes, rawBeatStrengths, beatEdits, musicSrc);
152
+ const clipEnd = musicPlaybackStart + musicDuration;
153
+ const offset = musicStart - musicPlaybackStart;
154
+ return merged.times
155
+ .filter((t) => t >= musicPlaybackStart && t <= clipEnd)
156
+ .map((t) => Math.round((t + offset) * 1000) / 1000);
157
+ }, [
158
+ rawBeatTimes,
159
+ rawBeatStrengths,
160
+ beatEdits,
161
+ musicSrc,
162
+ musicStart,
163
+ musicPlaybackStart,
164
+ musicDuration,
165
+ ]);
166
+
167
+ const beatTimesRef = useRef<number[]>([]);
168
+ beatTimesRef.current = adjustedBeatTimes;
79
169
 
80
170
  const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
81
171
  const draggedClipRef = useRef<DraggedClipState | null>(null);
@@ -118,13 +208,24 @@ export function useTimelineClipDrag({
118
208
  clientX,
119
209
  clientY,
120
210
  );
211
+ // The music track defines the beats, so it must not snap to itself.
212
+ const snap = isMusicTrack(drag.element)
213
+ ? { start: nextMove.start, beat: null }
214
+ : snapMoveStartToBeat(
215
+ nextMove.start,
216
+ drag.element.duration,
217
+ beatTimesRef.current,
218
+ ppsRef.current,
219
+ durationRef.current,
220
+ );
121
221
  return {
122
222
  ...drag,
123
223
  started: true,
124
224
  pointerClientX: clientX,
125
225
  pointerClientY: clientY,
126
- previewStart: nextMove.start,
226
+ previewStart: snap.start,
127
227
  previewTrack: nextMove.track,
228
+ snapBeatTime: snap.beat,
128
229
  };
129
230
  },
130
231
  [scrollRef, ppsRef, durationRef, trackOrderRef],
@@ -220,14 +321,16 @@ export function useTimelineClipDrag({
220
321
  : Number.POSITIVE_INFINITY;
221
322
  const normalizedTag = resize.element.tag.toLowerCase();
222
323
  const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
223
- const nextResize = resolveTimelineResize(
324
+ const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
325
+ const maxEnd = Math.min(durationRef.current, resize.element.start + sourceRemaining);
326
+ let nextResize = resolveTimelineResize(
224
327
  {
225
328
  start: resize.element.start,
226
329
  duration: resize.element.duration,
227
330
  originClientX: resize.originClientX,
228
331
  pixelsPerSecond: ppsRef.current,
229
332
  minStart: 0,
230
- maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining),
333
+ maxEnd,
231
334
  playbackStart:
232
335
  resize.edge === "start" && canSeedPlaybackStart
233
336
  ? (resize.element.playbackStart ?? 0)
@@ -238,6 +341,54 @@ export function useTimelineClipDrag({
238
341
  e.clientX,
239
342
  );
240
343
 
344
+ // Snap edge to beat grid when beat analysis is available. The snap must
345
+ // stay inside the same limits resolveTimelineResize enforces, or it would
346
+ // push the edge past the available source media / composition end.
347
+ // The music track defines the beats, so it must not snap to itself.
348
+ const beatTimes = beatTimesRef.current;
349
+ if (beatTimes.length > 0 && !isMusicTrack(resize.element)) {
350
+ const snapSecs = BEAT_SNAP_PX / Math.max(ppsRef.current, 1);
351
+ if (resize.edge === "end") {
352
+ const edgeTime = nextResize.start + nextResize.duration;
353
+ const snapped = snapToNearestBeat(edgeTime, beatTimes, snapSecs);
354
+ // Stay within [start+minDuration, maxEnd] so the snap can't create a
355
+ // degenerate clip or run past the source/composition limit.
356
+ const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
357
+ if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
358
+ nextResize = { ...nextResize, duration: snappedDuration };
359
+ }
360
+ } else {
361
+ const snapped = snapToNearestBeat(nextResize.start, beatTimes, snapSecs);
362
+ const delta = nextResize.start - snapped; // >0 when snapping left
363
+ // Leftward snap reveals more source; cap so playbackStart can't go < 0.
364
+ const maxLeftDelta =
365
+ nextResize.playbackStart != null
366
+ ? nextResize.playbackStart / playbackRate
367
+ : Number.POSITIVE_INFINITY;
368
+ // Also require the resulting duration to stay >= minDuration so a
369
+ // rightward snap (delta < 0) can't collapse the clip to zero/negative.
370
+ const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
371
+ if (
372
+ snapped !== nextResize.start &&
373
+ snapped >= 0 &&
374
+ delta <= maxLeftDelta + 1e-6 &&
375
+ snappedDuration >= 0.05
376
+ ) {
377
+ nextResize = {
378
+ ...nextResize,
379
+ start: snapped,
380
+ duration: snappedDuration,
381
+ playbackStart:
382
+ nextResize.playbackStart != null
383
+ ? Math.round(
384
+ Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000,
385
+ ) / 1000
386
+ : undefined,
387
+ };
388
+ }
389
+ }
390
+ }
391
+
241
392
  setResizingClip((prev) =>
242
393
  prev
243
394
  ? {
@@ -1,4 +1,4 @@
1
- import { useRef, useCallback, useEffect } from "react";
1
+ import { useRef, useCallback, useEffect, useLayoutEffect } from "react";
2
2
  import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { getPinchTimelineZoomPercent } from "./timelineZoom";
@@ -54,6 +54,33 @@ export function useTimelinePlayhead({
54
54
  }: UseTimelinePlayheadInput) {
55
55
  const dragScrollRaf = useRef(0);
56
56
  const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
57
+ // Center-anchored magnify: keep the time at the viewport center fixed when
58
+ // the zoom level (pps) changes via the toolbar / slider. The pinch handler
59
+ // anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`.
60
+ const previousAnchorPpsRef = useRef(pps);
61
+ const skipCenterAnchorRef = useRef(false);
62
+
63
+ useLayoutEffect(() => {
64
+ const scroll = scrollRef.current;
65
+ const prevPps = previousAnchorPpsRef.current;
66
+ previousAnchorPpsRef.current = pps;
67
+ // Always consume the skip flag, even when pps didn't change — otherwise a
68
+ // pinch that produced no pps change (already at the zoom clamp) would strand
69
+ // it true and the next toolbar zoom would wrongly skip center-anchoring.
70
+ const skip = skipCenterAnchorRef.current;
71
+ skipCenterAnchorRef.current = false;
72
+ if (!scroll || pps === prevPps || skip) return;
73
+ const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
74
+ pointerX: scroll.clientWidth / 2,
75
+ currentScrollLeft: scroll.scrollLeft,
76
+ gutter: GUTTER,
77
+ currentPixelsPerSecond: prevPps,
78
+ nextPixelsPerSecond: pps,
79
+ duration: durationRef.current,
80
+ });
81
+ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
82
+ scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
83
+ }, [pps, scrollRef, durationRef]);
57
84
 
58
85
  const syncPlayheadPosition = useCallback(
59
86
  (time: number) => {
@@ -169,6 +196,8 @@ export function useTimelinePlayhead({
169
196
  nextPixelsPerSecond: nextPps,
170
197
  duration: durationRef.current,
171
198
  });
199
+ // Pinch anchors at the cursor (below), so skip the center-anchor effect.
200
+ skipCenterAnchorRef.current = true;
172
201
  setZoomMode("manual");
173
202
  setManualZoomPercent(nextZoomPercent);
174
203
  requestAnimationFrame(() => {
@@ -41,9 +41,30 @@ import {
41
41
  setPreviewPlaybackRate,
42
42
  shouldMutePreviewAudio,
43
43
  } from "../lib/timelineIframeHelpers";
44
- import { probeMediaUrl, getCachedProbe } from "../lib/mediaProbe";
44
+ import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
45
+ import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
45
46
  import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
46
47
 
48
+ /**
49
+ * Whether the derived elements differ from the current ones in any field that
50
+ * affects rendering (identity, timing, track, or source length) — used to skip
51
+ * redundant store writes.
52
+ */
53
+ function timelineElementsChanged(prev: TimelineElement[], next: TimelineElement[]): boolean {
54
+ if (next.length !== prev.length) return true;
55
+ return next.some((el, i) => {
56
+ const p = prev[i];
57
+ return (
58
+ !p ||
59
+ el.id !== p.id ||
60
+ el.start !== p.start ||
61
+ el.duration !== p.duration ||
62
+ el.track !== p.track ||
63
+ el.sourceDuration !== p.sourceDuration
64
+ );
65
+ });
66
+ }
67
+
47
68
  export function useTimelinePlayer() {
48
69
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
49
70
  const rafRef = useRef<number>(0);
@@ -65,27 +86,19 @@ export function useTimelinePlayer() {
65
86
  (elements: TimelineElement[], nextDuration?: number) => {
66
87
  const state = usePlayerStore.getState();
67
88
  const resolvedDuration = nextDuration ?? state.duration;
68
- const mergedElements = mergeTimelineElementsPreservingDowngrades(
69
- state.elements,
70
- elements,
71
- state.duration,
72
- resolvedDuration,
89
+ // applyCachedSourceDurations re-applies the cached probe duration: re-derived
90
+ // elements (e.g. after a clip move) can arrive without sourceDuration, which
91
+ // otherwise makes trimmed waveforms lose their window.
92
+ const mergedElements = applyCachedSourceDurations(
93
+ mergeTimelineElementsPreservingDowngrades(
94
+ state.elements,
95
+ elements,
96
+ state.duration,
97
+ resolvedDuration,
98
+ ),
73
99
  );
74
100
 
75
- const elementsChanged =
76
- mergedElements.length !== state.elements.length ||
77
- mergedElements.some((el, i) => {
78
- const prev = state.elements[i];
79
- return (
80
- !prev ||
81
- el.id !== prev.id ||
82
- el.start !== prev.start ||
83
- el.duration !== prev.duration ||
84
- el.track !== prev.track
85
- );
86
- });
87
-
88
- if (elementsChanged) {
101
+ if (timelineElementsChanged(state.elements, mergedElements)) {
89
102
  setElements(mergedElements);
90
103
  }
91
104
  if (
@@ -99,31 +112,17 @@ export function useTimelinePlayer() {
99
112
  setTimelineReady(true);
100
113
  }
101
114
 
102
- // Asynchronously enrich media elements missing sourceDuration via mediabunny.
103
- // The probe reads file headers only no full decode — so this is cheap.
104
- const needsProbe = mergedElements.filter(
105
- (el) =>
106
- el.src &&
107
- el.sourceDuration == null &&
108
- ["video", "audio"].includes(el.tag.toLowerCase()) &&
109
- !getCachedProbe(el.src),
110
- );
111
- if (needsProbe.length > 0) {
112
- void Promise.allSettled(
113
- needsProbe.map(async (el) => {
114
- const result = await probeMediaUrl(el.src!);
115
- if (!result) return;
116
- const key = el.key ?? el.id;
117
- usePlayerStore.setState((state) => {
118
- const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
119
- if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
120
- const patched = state.elements.slice();
121
- patched[idx] = { ...state.elements[idx], sourceDuration: result.duration };
122
- return { elements: patched };
123
- });
124
- }),
125
- );
126
- }
115
+ // Asynchronously enrich media elements still missing sourceDuration
116
+ // (header-only probe, cheap), applying each resolved value to the store.
117
+ void probeMissingSourceDurations(mergedElements, (key, durationSeconds) => {
118
+ usePlayerStore.setState((state) => {
119
+ const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
120
+ if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
121
+ const patched = state.elements.slice();
122
+ patched[idx] = { ...state.elements[idx], sourceDuration: durationSeconds };
123
+ return { elements: patched };
124
+ });
125
+ });
127
126
  },
128
127
  [setElements, setTimelineReady, setDuration],
129
128
  );
@@ -280,6 +279,7 @@ export function useTimelinePlayer() {
280
279
  const play = useCallback(() => {
281
280
  stopRAFLoop();
282
281
  stopReverseLoop();
282
+ stopScrubPreviewAudio();
283
283
  const adapter = getAdapter();
284
284
  if (!adapter) return;
285
285
  if (adapter.getTime() >= adapter.getDuration()) {
@@ -392,6 +392,7 @@ export function useTimelinePlayer() {
392
392
  adapter.seek(nextTime, options);
393
393
  liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
394
394
  setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
395
+ if (!shouldResumeAfterSeek && !keepPlaying) scrubMusicAtSeek(iframeRef.current, nextTime);
395
396
  if (shouldResumeAfterSeek) {
396
397
  stopRAFLoop();
397
398
  applyPlaybackRate(usePlayerStore.getState().playbackRate);
@@ -557,6 +558,7 @@ export function useTimelinePlayer() {
557
558
  document.removeEventListener("visibilitychange", handleVisibilityChange);
558
559
  stopRAFLoop();
559
560
  stopReverseLoop();
561
+ stopScrubPreviewAudio();
560
562
  releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
561
563
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
562
564
  };
@@ -1,4 +1,4 @@
1
- export interface MediaProbeResult {
1
+ interface MediaProbeResult {
2
2
  duration: number;
3
3
  width?: number;
4
4
  height?: number;
@@ -61,11 +61,54 @@ async function probeOne(url: string): Promise<MediaProbeResult | null> {
61
61
  }
62
62
  }
63
63
 
64
- export function getCachedProbe(url: string): MediaProbeResult | undefined {
64
+ function getCachedProbe(url: string): MediaProbeResult | undefined {
65
65
  return cache.get(normalizeUrl(url));
66
66
  }
67
67
 
68
- export async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
68
+ /**
69
+ * Re-apply the cached probe `sourceDuration` to media elements that arrive
70
+ * without it. Re-deriving the timeline (e.g. after a clip move) produces fresh
71
+ * objects whose duration the DOM scan may not have, and the async probe skips
72
+ * already-cached srcs — so without this, trimmed waveforms lose their window.
73
+ */
74
+ export function applyCachedSourceDurations<
75
+ T extends { src?: string; tag: string; sourceDuration?: number },
76
+ >(elements: T[]): T[] {
77
+ return elements.map((el) => {
78
+ const tag = el.tag.toLowerCase();
79
+ if (!el.src || el.sourceDuration != null || (tag !== "audio" && tag !== "video")) return el;
80
+ const cached = getCachedProbe(el.src);
81
+ return cached?.duration && cached.duration > 0
82
+ ? { ...el, sourceDuration: cached.duration }
83
+ : el;
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Probe (header-only, cheap) any media elements still missing sourceDuration
89
+ * after the cache pass, applying each resolved duration via `apply(key, secs)`.
90
+ * Skips already-cached srcs.
91
+ */
92
+ export async function probeMissingSourceDurations<
93
+ T extends { src?: string; tag: string; sourceDuration?: number; key?: string; id: string },
94
+ >(elements: T[], apply: (key: string, durationSeconds: number) => void): Promise<void> {
95
+ const needs = elements.filter(
96
+ (el) =>
97
+ el.src &&
98
+ el.sourceDuration == null &&
99
+ ["video", "audio"].includes(el.tag.toLowerCase()) &&
100
+ !getCachedProbe(el.src),
101
+ );
102
+ if (needs.length === 0) return;
103
+ await Promise.allSettled(
104
+ needs.map(async (el) => {
105
+ const result = await probeMediaUrl(el.src!);
106
+ if (result) apply(el.key ?? el.id, result.duration);
107
+ }),
108
+ );
109
+ }
110
+
111
+ async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
69
112
  const key = normalizeUrl(url);
70
113
  const cached = cache.get(key);
71
114
  if (cached) return cached;
@@ -0,0 +1,16 @@
1
+ import { usePlayerStore } from "../store/playerStore";
2
+ import { isMusicTrack } from "../../utils/timelineInspector";
3
+ import { scrubPreviewAudio, stopScrubPreviewAudio } from "./timelineIframeHelpers";
4
+
5
+ export { stopScrubPreviewAudio };
6
+
7
+ // Scrub the music track's audio at a seeked composition time (paused-seek only).
8
+ // Skipped when audio is muted or the time falls outside the music clip.
9
+ export function scrubMusicAtSeek(iframe: HTMLIFrameElement | null, nextTime: number): void {
10
+ const s = usePlayerStore.getState();
11
+ const music = s.elements.find(isMusicTrack);
12
+ if (!music || s.audioMuted) return;
13
+ const rel = nextTime - music.start;
14
+ const audioFileTime = rel >= 0 && rel <= music.duration ? (music.playbackStart ?? 0) + rel : null;
15
+ scrubPreviewAudio(iframe, audioFileTime, music.domId ?? music.id);
16
+ }
@@ -115,6 +115,8 @@ export function createTimelineElementFromManifestClip(params: {
115
115
 
116
116
  if (hostEl) {
117
117
  applyMediaMetadataFromElement(entry, hostEl);
118
+ const timelineRole = hostEl.getAttribute("data-timeline-role");
119
+ if (timelineRole) entry.timelineRole = timelineRole;
118
120
  }
119
121
  if (clip.assetUrl) entry.src = clip.assetUrl;
120
122
  if (clip.kind === "composition" && clip.compositionId) {
@@ -286,17 +288,23 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
286
288
  if (mediaEl.tagName === "IMG") {
287
289
  entry.tag = "img";
288
290
  }
289
- const src = mediaEl.getAttribute("src");
290
- if (src) entry.src = src;
291
291
  const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
292
292
  if (vol) entry.volume = parseFloat(vol);
293
293
  applyMediaMetadataFromElement(entry, el);
294
+ // Override AFTER the helper (which sets the raw relative attribute) so the
295
+ // resolved absolute URL wins — the Studio can then fetch the asset
296
+ // regardless of whether the attribute value was relative or absolute.
297
+ const resolvedSrc = (mediaEl as HTMLMediaElement | HTMLImageElement).src || undefined;
298
+ if (resolvedSrc) entry.src = resolvedSrc;
294
299
  }
295
300
 
296
301
  if (el.hasAttribute("data-timeline-locked")) {
297
302
  entry.timelineLocked = true;
298
303
  }
299
304
 
305
+ const timelineRole = el.getAttribute("data-timeline-role");
306
+ if (timelineRole) entry.timelineRole = timelineRole;
307
+
300
308
  // Sub-compositions
301
309
  const compSrc =
302
310
  el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");