@hyperframes/studio 0.8.138 → 0.8.140

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 (135) hide show
  1. package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
  5. package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
  6. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  7. package/dist/chunk-TFLU2MGI.js.map +1 -0
  8. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  9. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  10. package/dist/index.d.ts +20 -3
  11. package/dist/index.html +1 -1
  12. package/dist/index.js +7327 -6265
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/package.json +8 -8
  16. package/src/App.tsx +9 -4
  17. package/src/components/TimelineToolbar.test.tsx +79 -1
  18. package/src/components/TimelineToolbar.tsx +14 -29
  19. package/src/components/editor/DomEditOverlay.tsx +11 -7
  20. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  21. package/src/components/editor/PropertyPanel.tsx +1 -0
  22. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  23. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  24. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  25. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  26. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  27. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  28. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  29. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  30. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  31. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  32. package/src/components/editor/manualEdits.ts +1 -1
  33. package/src/components/editor/manualEditsDom.ts +7 -7
  34. package/src/components/editor/manualEditsParsing.ts +14 -2
  35. package/src/components/editor/manualEditsTypes.ts +5 -0
  36. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  37. package/src/components/editor/manualOffsetDrag.ts +5 -0
  38. package/src/components/editor/previewGestureStarts.ts +284 -0
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  40. package/src/components/editor/propertyPanelFill.tsx +36 -13
  41. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  42. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  43. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  44. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  45. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  46. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  47. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  48. package/src/components/nle/NLEContext.test.ts +26 -0
  49. package/src/components/nle/NLEContext.tsx +8 -3
  50. package/src/components/nle/NLEPreview.test.ts +44 -1
  51. package/src/components/nle/NLEPreview.tsx +7 -2
  52. package/src/hooks/draggedGsapPosition.ts +8 -0
  53. package/src/hooks/editMoment.ts +13 -0
  54. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  55. package/src/hooks/gsapDragCommit.ts +2 -1
  56. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  57. package/src/hooks/gsapPositionDetection.ts +5 -3
  58. package/src/hooks/gsapResizeIntercept.ts +11 -4
  59. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  60. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  61. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  62. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  63. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  64. package/src/hooks/timelineEditingHelpers.ts +18 -2
  65. package/src/hooks/useDomSelection.test.ts +21 -2
  66. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  67. package/src/hooks/useEditHistoryActions.ts +2 -0
  68. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  69. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  70. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  71. package/src/hooks/useGsapAwareEditing.ts +148 -136
  72. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  73. package/src/hooks/useGsapScriptCommits.ts +5 -4
  74. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  75. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  76. package/src/hooks/usePreviewPersistence.ts +1 -4
  77. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  78. package/src/hooks/useSdkSession.ts +4 -1
  79. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  80. package/src/hooks/useThumbnailStripSize.ts +23 -1
  81. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  82. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  83. package/src/index.ts +2 -0
  84. package/src/player/components/AudioWaveform.test.tsx +153 -1
  85. package/src/player/components/AudioWaveform.tsx +93 -37
  86. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  87. package/src/player/components/Timeline.test.ts +3 -1
  88. package/src/player/components/TimelineCanvas.tsx +4 -0
  89. package/src/player/components/TimelineClip.tsx +20 -6
  90. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  91. package/src/player/components/TimelineClipFades.tsx +4 -4
  92. package/src/player/components/TimelineClipJoins.tsx +23 -18
  93. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  94. package/src/player/components/TimelineGroupRow.tsx +1 -0
  95. package/src/player/components/TimelineLaneClip.tsx +127 -0
  96. package/src/player/components/TimelineLanes.test.tsx +51 -6
  97. package/src/player/components/TimelineLanes.tsx +166 -178
  98. package/src/player/components/TimelineRuler.tsx +2 -0
  99. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  100. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  101. package/src/player/components/VideoThumbnail.tsx +37 -9
  102. package/src/player/components/thumbnailUtils.test.ts +17 -3
  103. package/src/player/components/thumbnailUtils.ts +17 -1
  104. package/src/player/components/timelineLayout.test.ts +3 -3
  105. package/src/player/components/timelineMotion.test.ts +27 -0
  106. package/src/player/components/timelineMotion.ts +62 -0
  107. package/src/player/components/timelineZoomInput.test.ts +438 -0
  108. package/src/player/components/timelineZoomInput.ts +456 -0
  109. package/src/player/components/useTimelineGeometry.ts +0 -3
  110. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  111. package/src/player/components/useTimelinePlayhead.ts +53 -59
  112. package/src/player/components/useTimelineProviderState.tsx +16 -21
  113. package/src/player/components/useTimelineZoom.ts +7 -12
  114. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  115. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  116. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  117. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  118. package/src/player/lib/thumbnailScheduler.ts +1 -1
  119. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  120. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  121. package/src/player/lib/timelineElementHelpers.ts +0 -11
  122. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  123. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  124. package/src/player/previewReloading.test.ts +48 -0
  125. package/src/player/previewReloading.ts +48 -0
  126. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  127. package/src/utils/rootDuration.ts +5 -4
  128. package/src/utils/studioPendingEdits.test.ts +27 -0
  129. package/src/utils/studioPendingEdits.ts +9 -0
  130. package/src/utils/timelineAssetDrop.test.ts +13 -4
  131. package/src/utils/timelineAssetDrop.ts +6 -2
  132. package/dist/chunk-TDWGXBUT.js.map +0 -1
  133. /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
  134. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  135. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
@@ -2,6 +2,17 @@ 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";
5
+ import {
6
+ currentTimelineZoomPercent,
7
+ requestTimelineZoom,
8
+ registerTimelineZoomViewport,
9
+ redrawTimelineZoomPreview,
10
+ settleTimelineZoom,
11
+ subscribeTimelineZoomPreview,
12
+ takeTimelineZoomAnchor,
13
+ timelineTimeAtX,
14
+ timelineZoomMapping,
15
+ } from "./timelineZoomInput";
5
16
  import {
6
17
  getTimelinePlaybackFollowScrollLeft,
7
18
  getTimelineScrubTime,
@@ -36,17 +47,13 @@ interface UseTimelinePlayheadInput {
36
47
  isDragging: React.RefObject<boolean>;
37
48
  currentTime: number;
38
49
  zoomMode: ZoomMode;
39
- manualZoomPercent: number;
40
50
  zoomModeRef: React.RefObject<ZoomMode>;
41
- manualZoomPercentRef: React.RefObject<number>;
42
51
  fitPps: number;
43
52
  fitPpsRef: React.RefObject<number>;
44
53
  effectiveDuration: number;
45
54
  pps: number;
46
55
  timelineReady: boolean;
47
56
  elementsLength: number;
48
- setZoomMode: (mode: ZoomMode) => void;
49
- setManualZoomPercent: (percent: number) => void;
50
57
  onSeek?: (time: number) => void;
51
58
  contentOrigin: number;
52
59
  }
@@ -60,28 +67,23 @@ export function useTimelinePlayhead({
60
67
  currentTime,
61
68
  zoomMode,
62
69
  zoomModeRef,
63
- manualZoomPercentRef,
64
70
  fitPps: _fitPps,
65
71
  fitPpsRef,
66
72
  effectiveDuration,
67
73
  pps,
68
74
  timelineReady,
69
75
  elementsLength,
70
- setZoomMode,
71
- setManualZoomPercent,
72
76
  onSeek,
73
77
  contentOrigin,
74
78
  }: UseTimelinePlayheadInput) {
75
79
  const dragScrollRaf = useRef(0);
76
80
  const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
77
- // A toolbar zoom keeps the playhead in place; a resize keeps the centre, or 00:00 at the start.
78
- // The pinch handler anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`.
81
+ // A zoom keeps its anchor (pinch: pointer, else playhead) in place; a resize keeps the centre.
79
82
  const previousAnchorPpsRef = useRef(pps);
80
83
  const userZoomCount = usePlayerStore((s) => s.userZoomCount);
81
84
  const previousZoomCountRef = useRef(userZoomCount);
82
85
  const lastLiveTimeRef = useRef(usePlayerStore.getState().currentTime);
83
86
  const lastSeekCountRef = useRef(liveTime.seekCount());
84
- const skipCenterAnchorRef = useRef(false);
85
87
  const contentOriginRef = useRef(contentOrigin);
86
88
  contentOriginRef.current = contentOrigin;
87
89
 
@@ -91,12 +93,15 @@ export function useTimelinePlayhead({
91
93
  previousAnchorPpsRef.current = pps;
92
94
  const prevZoomCount = previousZoomCountRef.current;
93
95
  previousZoomCountRef.current = userZoomCount;
94
- // Always consume the skip flag, even when pps didn't change — otherwise a
95
- // pinch that produced no pps change (already at the zoom clamp) would strand
96
- // it true and the next toolbar zoom would wrongly skip center-anchoring.
97
- const skip = skipCenterAnchorRef.current;
98
- skipCenterAnchorRef.current = false;
99
- if (!scroll || pps === prevPps || skip) return;
96
+ // Consumed even when pps didn't change (at the clamp), so it never lingers for a later zoom.
97
+ const anchor = takeTimelineZoomAnchor();
98
+ if (!scroll || pps === prevPps) return;
99
+ if (anchor) {
100
+ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
101
+ const left = anchor.time * pps + contentOrigin - anchor.x;
102
+ scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, left));
103
+ return;
104
+ }
100
105
  const zoomed = userZoomCount !== prevZoomCount;
101
106
  if (!zoomed && scroll.scrollLeft < 1) return;
102
107
  const time = Math.max(0, lastLiveTimeRef.current);
@@ -121,10 +126,11 @@ export function useTimelinePlayhead({
121
126
  const syncPlayheadPosition = useCallback(
122
127
  (time: number) => {
123
128
  if (!playheadRef.current || durationRef.current <= 0) return;
129
+ const at = timelineZoomMapping(ppsRef.current, contentOrigin);
124
130
  playheadRef.current.style.transform = getTimelinePlayheadTransform(
125
131
  time,
126
- ppsRef.current,
127
- contentOrigin,
132
+ at.pps,
133
+ at.contentOrigin,
128
134
  !usePlayerStore.getState().isPlaying,
129
135
  );
130
136
  },
@@ -158,14 +164,18 @@ export function useTimelinePlayhead({
158
164
  useMountEffect(() => {
159
165
  const place = (t: number, atRest: boolean) => {
160
166
  if (!playheadRef.current || durationRef.current <= 0) return false;
167
+ const at = timelineZoomMapping(ppsRef.current, contentOriginRef.current);
161
168
  playheadRef.current.style.transform = getTimelinePlayheadTransform(
162
169
  t,
163
- ppsRef.current,
164
- contentOriginRef.current,
170
+ at.pps,
171
+ at.contentOrigin,
165
172
  atRest,
166
173
  );
167
174
  return true;
168
175
  };
176
+ const unsubPreview = subscribeTimelineZoomPreview(() =>
177
+ place(lastLiveTimeRef.current, !usePlayerStore.getState().isPlaying),
178
+ );
169
179
  const dragging = () => isDragging.current || usePlayerStore.getState().beatDragging;
170
180
  const unsubPlaying = usePlayerStore.subscribe((state, prev) => {
171
181
  if (prev.isPlaying && !state.isPlaying) place(lastLiveTimeRef.current, true);
@@ -177,7 +187,8 @@ export function useTimelinePlayhead({
177
187
  lastLiveTimeRef.current = t;
178
188
  const playing = usePlayerStore.getState().isPlaying;
179
189
  if (!place(t, !playing)) return;
180
- const playheadX = contentOriginRef.current + Math.max(0, t) * ppsRef.current;
190
+ const at = timelineZoomMapping(ppsRef.current, contentOriginRef.current);
191
+ const playheadX = at.contentOrigin + Math.max(0, t) * at.pps;
181
192
  const scroll = scrollRef.current;
182
193
  // Paused, only a seek scrolls: a reload's republish, frame-rounded, must not undo a person's scroll.
183
194
  if (!scroll || dragging() || zoomModeRef.current === "fit" || (!playing && !sought)) return;
@@ -197,6 +208,7 @@ export function useTimelinePlayhead({
197
208
  return () => {
198
209
  unsub();
199
210
  unsubPlaying();
211
+ unsubPreview();
200
212
  };
201
213
  });
202
214
 
@@ -237,62 +249,44 @@ export function useTimelinePlayhead({
237
249
  [scrollRef, isDragging, zoomModeRef, seekFromX],
238
250
  );
239
251
 
252
+ // Trackpad pinch arrives as ctrl+wheel; Cmd+wheel zooms too, as Mac editors do.
240
253
  const handlePinchWheel = useCallback(
241
254
  (e: WheelEvent) => {
242
- if (!e.ctrlKey) return;
255
+ if (!e.ctrlKey && !e.metaKey) return;
243
256
  const scroll = scrollRef.current;
244
257
  if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0)
245
258
  return;
246
259
  e.preventDefault();
247
260
  e.stopPropagation();
248
- const rect = scroll.getBoundingClientRect();
249
- const nextZoomPercent = getPinchTimelineZoomPercent(
250
- e.deltaY,
251
- zoomModeRef.current,
252
- manualZoomPercentRef.current,
253
- fitPpsRef.current,
261
+ const x = e.clientX - scroll.getBoundingClientRect().left;
262
+ requestTimelineZoom(
263
+ getPinchTimelineZoomPercent(
264
+ e.deltaY,
265
+ "manual",
266
+ currentTimelineZoomPercent(),
267
+ fitPpsRef.current,
268
+ ),
269
+ { time: Math.max(0, timelineTimeAtX(x) ?? 0), x },
254
270
  );
255
- if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual")
256
- return;
257
- const nextPps = fitPpsRef.current * (nextZoomPercent / 100);
258
- const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
259
- pointerX: e.clientX - rect.left,
260
- currentScrollLeft: scroll.scrollLeft,
261
- contentOrigin,
262
- currentPixelsPerSecond: ppsRef.current,
263
- nextPixelsPerSecond: nextPps,
264
- duration: durationRef.current,
265
- });
266
- // Pinch anchors at the cursor (below), so skip the center-anchor effect.
267
- skipCenterAnchorRef.current = true;
268
- setZoomMode("manual");
269
- setManualZoomPercent(nextZoomPercent);
270
- requestAnimationFrame(() => {
271
- const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
272
- scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft);
273
- });
274
271
  },
275
- [
276
- scrollRef,
277
- durationRef,
278
- fitPpsRef,
279
- ppsRef,
280
- zoomModeRef,
281
- manualZoomPercentRef,
282
- setManualZoomPercent,
283
- setZoomMode,
284
- contentOrigin,
285
- ],
272
+ [scrollRef, durationRef, fitPpsRef, ppsRef],
286
273
  );
287
274
 
288
275
  useEffect(() => {
289
276
  const scroll = scrollRef.current;
290
277
  if (!scroll) return;
291
278
  scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
279
+ // A press meets the zoom it sees, not the one still waiting to be laid out.
280
+ scroll.addEventListener("pointerdown", settleTimelineZoom, { capture: true });
281
+ scroll.addEventListener("scroll", redrawTimelineZoomPreview, { passive: true });
282
+ const unregisterZoomViewport = registerTimelineZoomViewport({ scroll, contentOrigin });
292
283
  return () => {
293
284
  scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
285
+ scroll.removeEventListener("pointerdown", settleTimelineZoom, { capture: true });
286
+ scroll.removeEventListener("scroll", redrawTimelineZoomPreview);
287
+ unregisterZoomViewport();
294
288
  };
295
- }, [handlePinchWheel, scrollRef, timelineReady, elementsLength]);
289
+ }, [handlePinchWheel, scrollRef, timelineReady, elementsLength, contentOrigin]);
296
290
 
297
291
  return { seekFromX, autoScrollDuringDrag, dragScrollRaf };
298
292
  }
@@ -131,7 +131,7 @@ export function useTimelineProviderState({
131
131
  const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
132
132
  const sessionEpoch = sessionEpochProp ?? timelineSessionEpoch;
133
133
  const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment);
134
- const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
134
+ const { zoomMode, manualZoomPercent } = useTimelineZoom();
135
135
  const playheadRef = useRef<HTMLDivElement>(null);
136
136
  const containerRef = useRef<HTMLDivElement>(null);
137
137
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -258,22 +258,21 @@ export function useTimelineProviderState({
258
258
  timelineElements.length,
259
259
  displayLayout.totalH,
260
260
  ]);
261
- const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
262
- useTimelineGeometry({
263
- viewportWidth: viewport.clientWidth,
264
- effectiveDuration,
265
- zoomMode,
266
- manualZoomPercent,
267
- ppsRef,
268
- fitPpsRef,
269
- draggedClip,
270
- resizingClip,
271
- expandedElements: timelineElements,
272
- isDragging,
273
- scrollRef,
274
- lastScrollLeftRef,
275
- contentOrigin,
276
- });
261
+ const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef } = useTimelineGeometry({
262
+ viewportWidth: viewport.clientWidth,
263
+ effectiveDuration,
264
+ zoomMode,
265
+ manualZoomPercent,
266
+ ppsRef,
267
+ fitPpsRef,
268
+ draggedClip,
269
+ resizingClip,
270
+ expandedElements: timelineElements,
271
+ isDragging,
272
+ scrollRef,
273
+ lastScrollLeftRef,
274
+ contentOrigin,
275
+ });
277
276
  const timelineFocus = useTimelineLogicalFocus({
278
277
  scrollRef,
279
278
  tracks,
@@ -342,17 +341,13 @@ export function useTimelineProviderState({
342
341
  isDragging,
343
342
  currentTime,
344
343
  zoomMode,
345
- manualZoomPercent,
346
344
  zoomModeRef,
347
- manualZoomPercentRef,
348
345
  fitPps,
349
346
  fitPpsRef,
350
347
  effectiveDuration,
351
348
  pps,
352
349
  timelineReady,
353
350
  elementsLength: timelineElements.length,
354
- setZoomMode,
355
- setManualZoomPercent,
356
351
  onSeek,
357
352
  contentOrigin,
358
353
  });
@@ -1,6 +1,6 @@
1
1
  // fallow-ignore-file dead-code
2
- import { useCallback } from "react";
3
2
  import { usePlayerStore, type ZoomMode } from "../store/playerStore";
3
+ import { cancelTimelineZoom, requestTimelineZoom } from "./timelineZoomInput";
4
4
 
5
5
  export interface TimelineZoomState {
6
6
  zoomMode: ZoomMode;
@@ -9,18 +9,13 @@ export interface TimelineZoomState {
9
9
  setManualZoomPercent: (percent: number) => void;
10
10
  }
11
11
 
12
- /** Shared zoom-related store selectors used by Timeline and TimelineToolbar. */
13
12
  export function useTimelineZoom(): TimelineZoomState {
14
13
  const zoomMode = usePlayerStore((s) => s.zoomMode);
15
14
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
16
- const setZoomMode = usePlayerStore((s) => s.setZoomMode);
17
- const setStorePercent = usePlayerStore((s) => s.setManualZoomPercent);
18
- const setManualZoomPercent = useCallback(
19
- (percent: number) => {
20
- usePlayerStore.setState((s) => ({ userZoomCount: s.userZoomCount + 1 }));
21
- setStorePercent(percent);
22
- },
23
- [setStorePercent],
24
- );
25
- return { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent };
15
+ const setStoreZoomMode = usePlayerStore((s) => s.setZoomMode);
16
+ const setZoomMode = (mode: ZoomMode) => {
17
+ cancelTimelineZoom();
18
+ setStoreZoomMode(mode);
19
+ };
20
+ return { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent: requestTimelineZoom };
26
21
  }
@@ -18,7 +18,7 @@ import {
18
18
  type UseTimelineSyncCallbacksParams,
19
19
  } from "./useTimelineSyncCallbacks";
20
20
  import type { IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
21
- import { thumbnailScheduler } from "../lib/thumbnailScheduler";
21
+ import { setPreviewReloading } from "../previewReloading";
22
22
  import { announcePreviewPromoted } from "../sceneSwap";
23
23
  import { afterStudioPendingEdits, isStudioEditSaving } from "../../utils/studioPendingEdits";
24
24
  import { usePlayerStore } from "../store/playerStore";
@@ -110,7 +110,7 @@ export function useShadowPreviewReload({
110
110
  isRefreshingRef.current = false;
111
111
  pendingSeekRef.current = null;
112
112
  setPreviewSlots(planShadowDiscard);
113
- thumbnailScheduler.setPreviewReloading(false);
113
+ setPreviewReloading(false);
114
114
  const message = `The preview did not reload (${cause}). The previous preview is still showing.`;
115
115
  logReload("shadow-failed", { cause });
116
116
  console.error(`[studio] ${message}`);
@@ -188,7 +188,7 @@ export function useShadowPreviewReload({
188
188
  if (liveTime != null) handOverPlaybackRef.current(liveTime, playing);
189
189
  announcePreviewPromoted({ retired, live: shadow });
190
190
  onPromotedRef.current?.();
191
- thumbnailScheduler.setPreviewReloading(false);
191
+ setPreviewReloading(false);
192
192
  },
193
193
  [
194
194
  stopPendingShadow,
@@ -285,7 +285,7 @@ export function useShadowPreviewReload({
285
285
  armReadyTimer(gen);
286
286
  setPreviewSlots((prev) => planShadowReload(prev, gen, url));
287
287
  // Thumbnails of the edit wait for the new preview instead of competing with it.
288
- thumbnailScheduler.setPreviewReloading(true);
288
+ setPreviewReloading(true);
289
289
  },
290
290
  [stopPendingShadow, armReadyTimer, gestureSavesOnScreen],
291
291
  );
@@ -300,7 +300,7 @@ export function useShadowPreviewReload({
300
300
  pendingSeekRef.current = null;
301
301
  usePlayerStore.getState().setTimelineReady(false);
302
302
  setPreviewSlots(planShadowDiscard);
303
- thumbnailScheduler.setPreviewReloading(false);
303
+ setPreviewReloading(false);
304
304
  }, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
305
305
 
306
306
  // Hiding the tab pauses the budget; showing it again restarts the full budget for a pending shadow.
@@ -317,7 +317,7 @@ export function useShadowPreviewReload({
317
317
  return () => {
318
318
  document.removeEventListener("visibilitychange", onVisibilityChange);
319
319
  stopPendingShadow();
320
- thumbnailScheduler.setPreviewReloading(false);
320
+ setPreviewReloading(false);
321
321
  };
322
322
  });
323
323
 
@@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client";
6
6
  import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { SHADOW_READY_BUDGETS, SHADOW_READY_TIMEOUT_MS } from "./useShadowPreviewReload";
8
8
  import { usePlayerStore } from "../store/playerStore";
9
+ import { isPreviewChanging } from "../previewReloading";
9
10
  import { NLEProvider, useNLEContext, type NLEContextValue } from "../../components/nle/NLEContext";
10
11
  import {
11
12
  makeAdapterWindow,
@@ -752,6 +753,19 @@ describe("useTimelinePlayer scene swap", () => {
752
753
  expect(roles(getApi())).toEqual(["live"]);
753
754
  });
754
755
 
756
+ it("counts as a preview change until the swap lands, so a canvas press waits for the new scenes", async () => {
757
+ let reply: (r: Response) => void = () => {};
758
+ vi.spyOn(globalThis, "fetch").mockImplementation(
759
+ () => new Promise<Response>((resolve) => (reply = resolve)),
760
+ );
761
+ const { getApi } = liveFilm(vi.fn(async () => {}));
762
+ act(() => getApi().refreshPlayer());
763
+ expect(isPreviewChanging()).toBe(true);
764
+ reply(new Response("<html>v2</html>"));
765
+ await settle();
766
+ expect(isPreviewChanging()).toBe(false);
767
+ });
768
+
755
769
  it("falls back to the full reload when the preview refuses the swap", async () => {
756
770
  vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
757
771
  const { getApi } = liveFilm(async () => {
@@ -43,6 +43,7 @@ import { createPreviewMessageHandler } from "./previewMessageRouter";
43
43
  import { timelineElementsChanged } from "./timelinePlayerSync";
44
44
  import { safeContentDocument } from "./timelineSyncHydration";
45
45
  import { sceneSwapFor } from "../sceneSwap";
46
+ import { whileScriptWrites } from "../previewReloading";
46
47
 
47
48
  export interface UseTimelinePlayerOptions {
48
49
  /** Runs right after a reloaded preview becomes the live iframe. */
@@ -504,11 +505,13 @@ export function useTimelinePlayer({
504
505
  !!iframe.contentDocument && isStudioManualEditGestureLiveIn(iframe.contentDocument);
505
506
  if (!swap || isRefreshingRef.current || swapWouldReplaceGestureNode)
506
507
  return reloadWholeFilm(url.toString());
507
- swap(url.toString(), isCurrent, cancel.signal).catch((error: unknown) => {
508
- if (!isCurrent()) return;
509
- logReload("scene-swap-refused", { reason: String(error) });
510
- reloadWholeFilm(url.toString());
511
- });
508
+ whileScriptWrites(() => swap(url.toString(), isCurrent, cancel.signal)).catch(
509
+ (error: unknown) => {
510
+ if (!isCurrent()) return;
511
+ logReload("scene-swap-refused", { reason: String(error) });
512
+ reloadWholeFilm(url.toString());
513
+ },
514
+ );
512
515
  }, [reloadWholeFilm, previewGeneration]);
513
516
  const pauseRef = useRef(pause);
514
517
  pauseRef.current = pause;
@@ -219,6 +219,26 @@ describe("ThumbnailScheduler", () => {
219
219
  expect(scheduler.getDiagnostics()).toMatchObject({ queued: 0, active: 0, leases: 0 });
220
220
  });
221
221
 
222
+ it("starts nothing that a release later in the same batch drops", async () => {
223
+ const scheduler = new ThumbnailScheduler(
224
+ resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1 }),
225
+ );
226
+ const video = (key: string, load: ThumbnailRequest["load"]) =>
227
+ scheduler.acquire(request(key, load, "visible", { kind: "video" }), vi.fn());
228
+ const activeLease = video("active", () => deferred<ThumbnailLoadedResult>().promise);
229
+ const droppedLoad = vi.fn(async () => result("dropped"));
230
+ const droppedLease = video("dropped", droppedLoad);
231
+ const keptLoad = vi.fn(async () => result("kept"));
232
+ video("kept", keptLoad);
233
+
234
+ // A timeline jump unmounts its clips in one commit, the loading one first.
235
+ activeLease.release();
236
+ droppedLease.release();
237
+ await flush();
238
+ expect(droppedLoad).not.toHaveBeenCalled();
239
+ expect(keptLoad).toHaveBeenCalledTimes(1);
240
+ });
241
+
222
242
  it("frees an aborted job's slot at once, even when its loader ignores the abort", async () => {
223
243
  const scheduler = new ThumbnailScheduler(
224
244
  resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1 }),
@@ -231,7 +251,6 @@ describe("ThumbnailScheduler", () => {
231
251
  lease.release();
232
252
  const nextLoad = vi.fn(async () => result("next"));
233
253
  scheduler.acquire(request("next", nextLoad, "visible", { kind: "video" }), vi.fn());
234
- await flush();
235
254
  expect(nextLoad).toHaveBeenCalledTimes(1);
236
255
  });
237
256
 
@@ -316,7 +316,7 @@ export class ThumbnailScheduler {
316
316
  slotFree = true;
317
317
  this.activeByBucket[bucket]--;
318
318
  this.activeByKind[entry.request.kind]--;
319
- this.pump();
319
+ queueMicrotask(() => this.pump());
320
320
  };
321
321
  controller.signal.addEventListener("abort", freeSlot, { once: true });
322
322
  this.notify(entry);