@hyperframes/studio 0.8.90 → 0.8.91

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 (87) hide show
  1. package/dist/assets/{hyperframes-player-DDjER_ii.js → hyperframes-player-zT2Bdx_l.js} +1 -1
  2. package/dist/assets/{index-5SFeUXP8.js → index-DSOLcE8X.js} +1 -1
  3. package/dist/assets/{index-CVsCxnlz.js → index-DV-z19zB.js} +1 -1
  4. package/dist/assets/{index-BKE3_N4v.js → index-V-0rfhe6.js} +168 -168
  5. package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
  6. package/dist/chunk-QPSOTK6M.js.map +1 -0
  7. package/dist/index.d.ts +12 -3
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1298 -1003
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/components/TimelineToolbar.test.tsx +12 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +5 -1
  15. package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
  16. package/src/components/editor/elementPositionOffset.test.ts +62 -0
  17. package/src/components/editor/elementPositionOffset.ts +64 -0
  18. package/src/components/editor/motionPathCommit.test.ts +25 -5
  19. package/src/components/editor/motionPathSelection.ts +10 -3
  20. package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
  21. package/src/components/editor/useLayerRevealOverride.ts +2 -1
  22. package/src/components/nle/NLEContext.tsx +1 -1
  23. package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
  24. package/src/hooks/domEditPersistFailure.ts +8 -6
  25. package/src/hooks/elementOffsetStager.ts +65 -0
  26. package/src/hooks/gestureTransaction.ts +4 -2
  27. package/src/hooks/gsapDragCommit.ts +1 -0
  28. package/src/hooks/gsapEditOutcome.ts +2 -1
  29. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +50 -14
  31. package/src/hooks/gsapShared.test.ts +1 -1
  32. package/src/hooks/gsapShared.ts +16 -12
  33. package/src/hooks/gsapTweenReach.ts +30 -0
  34. package/src/hooks/timelineEditingHelpers.ts +2 -1
  35. package/src/hooks/useClipboard.source.test.ts +58 -0
  36. package/src/hooks/useClipboard.ts +12 -3
  37. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  38. package/src/hooks/useDomEditCommits.ts +2 -0
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
  40. package/src/hooks/useDomEditSession.ts +2 -0
  41. package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
  42. package/src/hooks/useDomGeometryCommit.ts +42 -1
  43. package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
  44. package/src/hooks/useDomGeometryCommits.ts +14 -1
  45. package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
  46. package/src/hooks/useGsapAwareEditing.ts +44 -7
  47. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
  48. package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
  49. package/src/hooks/useRazorSplit.test.ts +5 -5
  50. package/src/hooks/useTimelineEditing.test.tsx +384 -0
  51. package/src/hooks/useTimelineEditing.ts +12 -12
  52. package/src/hooks/useTimelineGroupEditing.ts +16 -7
  53. package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
  54. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  55. package/src/player/components/TimelineProvider.tsx +1 -0
  56. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  57. package/src/player/components/TimelineTypes.ts +1 -1
  58. package/src/player/components/timelineClipDragCommit.test.ts +18 -8
  59. package/src/player/components/timelineClipDragCommit.ts +5 -0
  60. package/src/player/components/timelineClipDragPreview.test.ts +70 -2
  61. package/src/player/components/timelineClipDragPreview.ts +15 -12
  62. package/src/player/components/timelineEditing.ts +2 -1
  63. package/src/player/components/timelineGroupEditing.ts +7 -2
  64. package/src/player/components/timelineMultiDragPreview.test.ts +10 -8
  65. package/src/player/components/timelineMultiDragPreview.ts +8 -5
  66. package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
  67. package/src/player/components/timelineProviderStateBuilders.ts +7 -2
  68. package/src/player/components/timelineZMirror.test.ts +2 -2
  69. package/src/player/components/timelineZMirror.ts +1 -1
  70. package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
  71. package/src/player/components/useTimelineClipDrag.ts +3 -2
  72. package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
  73. package/src/player/components/useTimelinePlayhead.ts +56 -27
  74. package/src/player/components/useTimelineProviderState.tsx +1 -0
  75. package/src/player/components/useTimelineZoom.ts +9 -1
  76. package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
  77. package/src/player/hooks/useTimelinePlayer.ts +7 -4
  78. package/src/player/lib/timelineDOM.test.ts +79 -0
  79. package/src/player/lib/timelineDOM.ts +16 -7
  80. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  81. package/src/player/lib/timelineIframeHelpers.ts +12 -3
  82. package/src/player/store/liveTime.ts +6 -0
  83. package/src/player/store/playerStore.ts +2 -0
  84. package/src/player/store/timelineElement.ts +28 -3
  85. package/src/utils/razorSplitTransaction.test.ts +1 -1
  86. package/src/utils/razorSplitTransaction.ts +3 -3
  87. package/dist/chunk-KU4F7QZ3.js.map +0 -1
@@ -1,12 +1,15 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { act, createRef } from "react";
4
- import { createRoot } from "react-dom/client";
5
- import { describe, expect, it } from "vitest";
3
+ import { act, useRef } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
6
7
  import { useTimelinePlayhead } from "./useTimelinePlayhead";
7
8
 
8
9
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
9
10
 
11
+ const ORIGIN = 32;
12
+
10
13
  function scrollBox(scrollLeft: number) {
11
14
  const el = document.createElement("div");
12
15
  let left = scrollLeft;
@@ -18,18 +21,34 @@ function scrollBox(scrollLeft: number) {
18
21
  return el;
19
22
  }
20
23
 
21
- function Harness({ pps, scroll }: { pps: number; scroll: HTMLDivElement }) {
24
+ interface HarnessProps {
25
+ pps: number;
26
+ scroll: HTMLDivElement;
27
+ percent?: number;
28
+ dragging?: boolean;
29
+ zoomMode?: ZoomMode;
30
+ }
31
+
32
+ function Harness({
33
+ pps,
34
+ scroll,
35
+ percent = 100,
36
+ dragging = false,
37
+ zoomMode = "manual",
38
+ }: HarnessProps) {
39
+ const scrollRef = useRef(scroll);
40
+ const durationRef = useRef(60);
22
41
  useTimelinePlayhead({
23
- playheadRef: createRef(),
24
- scrollRef: { current: scroll },
42
+ playheadRef: { current: document.createElement("div") },
43
+ scrollRef,
25
44
  ppsRef: { current: pps },
26
- durationRef: { current: 60 },
27
- isDragging: { current: false },
45
+ durationRef,
46
+ isDragging: { current: dragging },
28
47
  currentTime: 0,
29
- zoomMode: "manual",
30
- manualZoomPercent: 100,
31
- zoomModeRef: { current: "manual" },
32
- manualZoomPercentRef: { current: 100 },
48
+ zoomMode,
49
+ manualZoomPercent: percent,
50
+ zoomModeRef: { current: zoomMode },
51
+ manualZoomPercentRef: { current: percent },
33
52
  fitPps: pps,
34
53
  fitPpsRef: { current: pps },
35
54
  effectiveDuration: 60,
@@ -38,28 +57,198 @@ function Harness({ pps, scroll }: { pps: number; scroll: HTMLDivElement }) {
38
57
  elementsLength: 1,
39
58
  setZoomMode: () => {},
40
59
  setManualZoomPercent: () => {},
41
- contentOrigin: 32,
60
+ contentOrigin: ORIGIN,
42
61
  });
43
62
  return null;
44
63
  }
45
64
 
46
- function zoom(scrollLeft: number, fromPps: number, toPps: number) {
47
- const scroll = scrollBox(scrollLeft);
48
- const host = document.createElement("div");
49
- const root = createRoot(host);
50
- act(() => root.render(<Harness pps={fromPps} scroll={scroll} />));
51
- act(() => root.render(<Harness pps={toPps} scroll={scroll} />));
52
- act(() => root.unmount());
53
- return scroll.scrollLeft;
65
+ const roots: Root[] = [];
66
+ function mount(props: HarnessProps) {
67
+ const root = createRoot(document.createElement("div"));
68
+ roots.push(root);
69
+ act(() => root.render(<Harness {...props} />));
70
+ return (next: Partial<HarnessProps>, byPerson = false) =>
71
+ act(() => {
72
+ if (byPerson) usePlayerStore.setState((s) => ({ userZoomCount: s.userZoomCount + 1 }));
73
+ root.render(<Harness {...props} {...next} />);
74
+ });
54
75
  }
55
76
 
56
- describe("useTimelinePlayhead centre anchor", () => {
57
- it("keeps a view at the start at the start when the scale changes", () => {
58
- expect(zoom(0, 100, 114)).toBe(0);
77
+ beforeEach(() => {
78
+ usePlayerStore.setState({ currentTime: 0, isPlaying: false, beatDragging: false });
79
+ });
80
+ afterEach(() => {
81
+ for (const root of roots.splice(0)) act(() => root.unmount());
82
+ });
83
+
84
+ /** Where the playhead at `time` sits inside the 800px viewport. */
85
+ const onScreenX = (scroll: HTMLDivElement, time: number, pps: number) =>
86
+ ORIGIN + time * pps - scroll.scrollLeft;
87
+ function expectVisible(scroll: HTMLDivElement, time: number, pps: number) {
88
+ const x = onScreenX(scroll, time, pps);
89
+ expect(x).toBeGreaterThanOrEqual(ORIGIN);
90
+ expect(x).toBeLessThanOrEqual(800);
91
+ }
92
+
93
+ describe("useTimelinePlayhead zoom anchor", () => {
94
+ it("keeps a view at the start at the start when the window resizes", () => {
95
+ const scroll = scrollBox(0);
96
+ mount({ pps: 100, scroll })({ pps: 114 });
97
+ expect(scroll.scrollLeft).toBe(0);
59
98
  });
60
99
 
61
- it("keeps the time at the viewport centre when the view is scrolled", () => {
100
+ it("keeps the time at the viewport centre when a scrolled window resizes", () => {
101
+ const scroll = scrollBox(400);
62
102
  // Centre time (400 + 400 - 32) / 100 = 7.68s lands at 32 + 7.68 * 200 - 400.
63
- expect(zoom(400, 100, 200)).toBe(1168);
103
+ mount({ pps: 100, scroll })({ pps: 200 });
104
+ expect(scroll.scrollLeft).toBe(1168);
105
+ });
106
+
107
+ it("keeps a view at 00:00 on a resize even when the playhead is mid-film", () => {
108
+ usePlayerStore.setState({ currentTime: 6 });
109
+ const scroll = scrollBox(0);
110
+ mount({ pps: 100, scroll })({ pps: 150 });
111
+ expect(scroll.scrollLeft).toBe(0);
112
+ });
113
+
114
+ it("keeps the playhead where it is on screen when the toolbar zooms", () => {
115
+ usePlayerStore.setState({ currentTime: 6 });
116
+ const scroll = scrollBox(400);
117
+ const before = onScreenX(scroll, 6, 100);
118
+ mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }, true);
119
+ expect(onScreenX(scroll, 6, 200)).toBeCloseTo(before);
120
+ });
121
+
122
+ it("stays at 00:00 when a zoom is set with the playhead at 0, as a zoom restored on open is", () => {
123
+ const scroll = scrollBox(0);
124
+ mount({ pps: 100, scroll, percent: 100 })({ pps: 250, percent: 250 }, true);
125
+ expect(scroll.scrollLeft).toBe(0);
126
+ });
127
+
128
+ it("keeps the centre on a resize after a toolbar zoom that hit the zoom limit", () => {
129
+ usePlayerStore.setState({ currentTime: 6 });
130
+ const scroll = scrollBox(400);
131
+ const update = mount({ pps: 100, scroll });
132
+ update({}, true);
133
+ update({ pps: 200 });
134
+ expect(scroll.scrollLeft).toBe(1168);
135
+ });
136
+
137
+ it("brings an off-screen playhead into view when the toolbar zooms", () => {
138
+ usePlayerStore.setState({ currentTime: 30 });
139
+ const scroll = scrollBox(0);
140
+ mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }, true);
141
+ expectVisible(scroll, 30, 200);
142
+ });
143
+ });
144
+
145
+ describe("useTimelinePlayhead zoom anchor, percent written by Studio itself", () => {
146
+ it("keeps 00:00 when the window resizes after an edit pinned the zoom", () => {
147
+ usePlayerStore.setState({ currentTime: 6 });
148
+ const scroll = scrollBox(0);
149
+ const update = mount({ pps: 100, scroll, percent: 200 });
150
+ update({ percent: 100 });
151
+ update({ pps: 130, percent: 100 });
152
+ expect(scroll.scrollLeft).toBe(0);
153
+ });
154
+
155
+ it("leaves an off-screen playhead alone when a length change re-pins the zoom", () => {
156
+ usePlayerStore.setState({ currentTime: 30 });
157
+ const scroll = scrollBox(400);
158
+ const update = mount({ pps: 100, scroll, percent: 150 });
159
+ update({ pps: 90, percent: 150 });
160
+ update({ pps: 101, percent: 168 });
161
+ const x = onScreenX(scroll, 30, 101);
162
+ expect(x > 800 || x < ORIGIN).toBe(true);
163
+ });
164
+
165
+ it("anchors a person's zoom that lands on the percent already stored", () => {
166
+ usePlayerStore.setState({ currentTime: 30 });
167
+ const scroll = scrollBox(0);
168
+ mount({ pps: 100, scroll, percent: 200 })({ pps: 200, percent: 200 }, true);
169
+ expectVisible(scroll, 30, 200);
170
+ });
171
+ });
172
+
173
+ describe("useTimelinePlayhead follow while paused", () => {
174
+ it("scrolls a paused seek that lands off screen into view", () => {
175
+ const scroll = scrollBox(0);
176
+ mount({ pps: 100, scroll });
177
+ act(() => liveTime.notifySeek(30));
178
+ expectVisible(scroll, 30, 100);
179
+ });
180
+
181
+ it("leaves the view alone when a paused seek lands on screen, even past the follow line", () => {
182
+ const scroll = scrollBox(0);
183
+ mount({ pps: 100, scroll });
184
+ act(() => liveTime.notifySeek(7));
185
+ expect(scroll.scrollLeft).toBe(0);
186
+ });
187
+
188
+ it("scrolls back to the playhead when a person seeks to the time it already has", () => {
189
+ const scroll = scrollBox(0);
190
+ mount({ pps: 100, scroll });
191
+ act(() => liveTime.notifySeek(30));
192
+ scroll.scrollLeft = 0;
193
+ act(() => liveTime.notifySeek(30));
194
+ expectVisible(scroll, 30, 100);
195
+ });
196
+
197
+ it("keeps a person's scroll when a reload republishes the seek rounded to a frame", () => {
198
+ const scroll = scrollBox(0);
199
+ mount({ pps: 100, scroll });
200
+ act(() => liveTime.notifySeek(12.3456));
201
+ scroll.scrollLeft = 0;
202
+ act(() => liveTime.notify(Math.floor(12.3456 * 30) / 30));
203
+ expect(scroll.scrollLeft).toBe(0);
204
+ });
205
+
206
+ it("keeps a person's scroll when a reload follows a keyboard pause", () => {
207
+ const scroll = scrollBox(0);
208
+ mount({ pps: 100, scroll });
209
+ act(() => usePlayerStore.setState({ isPlaying: true }));
210
+ act(() => liveTime.notify(30));
211
+ act(() => {
212
+ usePlayerStore.getState().setCurrentTime(30.012);
213
+ usePlayerStore.setState({ isPlaying: false });
214
+ });
215
+ scroll.scrollLeft = 0;
216
+ act(() => liveTime.notify(30.012));
217
+ expect(scroll.scrollLeft).toBe(0);
218
+ });
219
+
220
+ it("keeps a person's scroll when a reload stops a reverse shuttle", () => {
221
+ usePlayerStore.setState({ currentTime: 30 });
222
+ const scroll = scrollBox(0);
223
+ mount({ pps: 100, scroll });
224
+ act(() => usePlayerStore.setState({ isPlaying: true }));
225
+ act(() => liveTime.notify(29));
226
+ act(() => liveTime.notify(20));
227
+ act(() => usePlayerStore.setState({ isPlaying: false }));
228
+ scroll.scrollLeft = 0;
229
+ act(() => liveTime.notify(20));
230
+ expect(scroll.scrollLeft).toBe(0);
231
+ });
232
+
233
+ it("does not scroll while the playhead is being dragged", () => {
234
+ const scroll = scrollBox(0);
235
+ mount({ pps: 100, scroll, dragging: true });
236
+ act(() => liveTime.notifySeek(30));
237
+ expect(scroll.scrollLeft).toBe(0);
238
+ });
239
+
240
+ it("does not scroll while a beat is being dragged past the edge", () => {
241
+ usePlayerStore.setState({ beatDragging: true });
242
+ const scroll = scrollBox(0);
243
+ mount({ pps: 100, scroll });
244
+ act(() => liveTime.notifySeek(30));
245
+ expect(scroll.scrollLeft).toBe(0);
246
+ });
247
+
248
+ it("does not scroll in Fit", () => {
249
+ const scroll = scrollBox(0);
250
+ mount({ pps: 100, scroll, zoomMode: "fit" });
251
+ act(() => liveTime.notifySeek(30));
252
+ expect(scroll.scrollLeft).toBe(0);
64
253
  });
65
254
  });
@@ -12,6 +12,22 @@ import {
12
12
  import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
13
13
  import { applyTimelineHorizontalAutoScrollStep } from "./timelineEditing";
14
14
 
15
+ function revealPlayheadScrollLeft(
16
+ scroll: HTMLDivElement,
17
+ playheadX: number,
18
+ contentOrigin: number,
19
+ from = scroll.scrollLeft,
20
+ ): number {
21
+ if (playheadX >= from + contentOrigin && playheadX <= from + scroll.clientWidth) return from;
22
+ return getTimelinePlaybackFollowScrollLeft({
23
+ playheadX,
24
+ currentScrollLeft: from,
25
+ viewportWidth: scroll.clientWidth,
26
+ contentOrigin,
27
+ maxScrollLeft: scroll.scrollWidth - scroll.clientWidth,
28
+ });
29
+ }
30
+
15
31
  interface UseTimelinePlayheadInput {
16
32
  playheadRef: React.RefObject<HTMLDivElement | null>;
17
33
  scrollRef: React.RefObject<HTMLDivElement | null>;
@@ -58,10 +74,13 @@ export function useTimelinePlayhead({
58
74
  }: UseTimelinePlayheadInput) {
59
75
  const dragScrollRaf = useRef(0);
60
76
  const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
61
- // Center-anchored magnify: keep the time at the viewport center fixed when
62
- // the zoom level (pps) changes via the toolbar / slider. The pinch handler
63
- // anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`.
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`.
64
79
  const previousAnchorPpsRef = useRef(pps);
80
+ const userZoomCount = usePlayerStore((s) => s.userZoomCount);
81
+ const previousZoomCountRef = useRef(userZoomCount);
82
+ const lastLiveTimeRef = useRef(usePlayerStore.getState().currentTime);
83
+ const lastSeekCountRef = useRef(liveTime.seekCount());
65
84
  const skipCenterAnchorRef = useRef(false);
66
85
  const contentOriginRef = useRef(contentOrigin);
67
86
  contentOriginRef.current = contentOrigin;
@@ -70,15 +89,22 @@ export function useTimelinePlayhead({
70
89
  const scroll = scrollRef.current;
71
90
  const prevPps = previousAnchorPpsRef.current;
72
91
  previousAnchorPpsRef.current = pps;
92
+ const prevZoomCount = previousZoomCountRef.current;
93
+ previousZoomCountRef.current = userZoomCount;
73
94
  // Always consume the skip flag, even when pps didn't change — otherwise a
74
95
  // pinch that produced no pps change (already at the zoom clamp) would strand
75
96
  // it true and the next toolbar zoom would wrongly skip center-anchoring.
76
97
  const skip = skipCenterAnchorRef.current;
77
98
  skipCenterAnchorRef.current = false;
78
- // A view at the start stays there: a resize or a host zoom must not hide 00:00.
79
- if (!scroll || pps === prevPps || skip || scroll.scrollLeft < 1) return;
99
+ if (!scroll || pps === prevPps || skip) return;
100
+ const zoomed = userZoomCount !== prevZoomCount;
101
+ if (!zoomed && scroll.scrollLeft < 1) return;
102
+ const time = Math.max(0, lastLiveTimeRef.current);
103
+ const playheadX = contentOrigin + time * prevPps;
104
+ const onScreen =
105
+ revealPlayheadScrollLeft(scroll, playheadX, contentOrigin) === scroll.scrollLeft;
80
106
  const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
81
- pointerX: scroll.clientWidth / 2,
107
+ pointerX: zoomed && onScreen ? playheadX - scroll.scrollLeft : scroll.clientWidth / 2,
82
108
  currentScrollLeft: scroll.scrollLeft,
83
109
  contentOrigin,
84
110
  currentPixelsPerSecond: prevPps,
@@ -86,8 +112,11 @@ export function useTimelinePlayhead({
86
112
  duration: durationRef.current,
87
113
  });
88
114
  const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
89
- scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
90
- }, [pps, scrollRef, durationRef, contentOrigin]);
115
+ const anchored = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
116
+ scroll.scrollLeft = zoomed
117
+ ? revealPlayheadScrollLeft(scroll, contentOrigin + time * pps, contentOrigin, anchored)
118
+ : anchored;
119
+ }, [pps, userZoomCount, scrollRef, durationRef, contentOrigin]);
91
120
 
92
121
  const syncPlayheadPosition = useCallback(
93
122
  (time: number) => {
@@ -127,7 +156,6 @@ export function useTimelinePlayhead({
127
156
  }, [zoomMode, scrollRef]);
128
157
 
129
158
  useMountEffect(() => {
130
- let lastLiveTime = usePlayerStore.getState().currentTime;
131
159
  const place = (t: number, atRest: boolean) => {
132
160
  if (!playheadRef.current || durationRef.current <= 0) return false;
133
161
  playheadRef.current.style.transform = getTimelinePlayheadTransform(
@@ -138,29 +166,30 @@ export function useTimelinePlayhead({
138
166
  );
139
167
  return true;
140
168
  };
169
+ const dragging = () => isDragging.current || usePlayerStore.getState().beatDragging;
141
170
  const unsubPlaying = usePlayerStore.subscribe((state, prev) => {
142
- if (prev.isPlaying && !state.isPlaying) place(lastLiveTime, true);
171
+ if (prev.isPlaying && !state.isPlaying) place(lastLiveTimeRef.current, true);
143
172
  });
173
+ lastSeekCountRef.current = liveTime.seekCount();
144
174
  const unsub = liveTime.subscribe((t) => {
145
- lastLiveTime = t;
146
- if (!place(t, !usePlayerStore.getState().isPlaying)) return;
175
+ const sought = liveTime.seekCount() !== lastSeekCountRef.current;
176
+ lastSeekCountRef.current = liveTime.seekCount();
177
+ lastLiveTimeRef.current = t;
178
+ const playing = usePlayerStore.getState().isPlaying;
179
+ if (!place(t, !playing)) return;
147
180
  const playheadX = contentOriginRef.current + Math.max(0, t) * ppsRef.current;
148
181
  const scroll = scrollRef.current;
149
- if (
150
- !scroll ||
151
- !usePlayerStore.getState().isPlaying ||
152
- isDragging.current ||
153
- zoomModeRef.current === "fit"
154
- ) {
155
- return;
156
- }
157
- const nextScrollLeft = getTimelinePlaybackFollowScrollLeft({
158
- playheadX,
159
- currentScrollLeft: scroll.scrollLeft,
160
- viewportWidth: scroll.clientWidth,
161
- contentOrigin: contentOriginRef.current,
162
- maxScrollLeft: scroll.scrollWidth - scroll.clientWidth,
163
- });
182
+ // Paused, only a seek scrolls: a reload's republish, frame-rounded, must not undo a person's scroll.
183
+ if (!scroll || dragging() || zoomModeRef.current === "fit" || (!playing && !sought)) return;
184
+ const nextScrollLeft = playing
185
+ ? getTimelinePlaybackFollowScrollLeft({
186
+ playheadX,
187
+ currentScrollLeft: scroll.scrollLeft,
188
+ viewportWidth: scroll.clientWidth,
189
+ contentOrigin: contentOriginRef.current,
190
+ maxScrollLeft: scroll.scrollWidth - scroll.clientWidth,
191
+ })
192
+ : revealPlayheadScrollLeft(scroll, playheadX, contentOriginRef.current);
164
193
  if (Math.abs(nextScrollLeft - scroll.scrollLeft) >= 0.5) {
165
194
  scroll.scrollLeft = nextScrollLeft;
166
195
  }
@@ -548,6 +548,7 @@ export function useTimelineProviderState({
548
548
  ref: setScrollRef,
549
549
  tabIndex: -1,
550
550
  labelMode,
551
+ contentOrigin,
551
552
  zoomMode,
552
553
  onScroll: (e) => {
553
554
  lastScrollLeftRef.current = e.currentTarget.scrollLeft;
@@ -1,4 +1,5 @@
1
1
  // fallow-ignore-file dead-code
2
+ import { useCallback } from "react";
2
3
  import { usePlayerStore, type ZoomMode } from "../store/playerStore";
3
4
 
4
5
  export interface TimelineZoomState {
@@ -13,6 +14,13 @@ export function useTimelineZoom(): TimelineZoomState {
13
14
  const zoomMode = usePlayerStore((s) => s.zoomMode);
14
15
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
15
16
  const setZoomMode = usePlayerStore((s) => s.setZoomMode);
16
- const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
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
+ );
17
25
  return { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent };
18
26
  }
@@ -6,6 +6,8 @@ import type { useTimelinePlayer } from "./useTimelinePlayer";
6
6
  import {
7
7
  attachIframeAdapter,
8
8
  attachIframeWindow,
9
+ makeAdapterWindow,
10
+ makeFakeIframe,
9
11
  renderTimelinePlayerHarness,
10
12
  resetPlayerStore,
11
13
  } from "./timelinePlayerTestHarness";
@@ -263,6 +265,48 @@ describe("useTimelinePlayer audio controls (#835)", () => {
263
265
  });
264
266
 
265
267
  describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
268
+ it("publishes a seek as a person's seek, so a paused timeline follows it", () => {
269
+ const { root, api } = renderAttachedTimelinePlayer();
270
+ const before = liveTime.seekCount();
271
+ seekWithAct(api, 5);
272
+ expect(liveTime.seekCount()).toBe(before + 1);
273
+ expect(liveTime.latest()).toBe(5);
274
+ unmountWithAct(root);
275
+ });
276
+
277
+ it("does not count a reload's hand-over as a person's seek", () => {
278
+ const { getApi, root } = renderTimelinePlayerHarness();
279
+ act(() => {
280
+ getApi().iframeRef.current = makeFakeIframe(makeAdapterWindow().win);
281
+ getApi().onIframeLoad();
282
+ });
283
+ seekWithAct(getApi(), 7);
284
+ act(() => getApi().refreshPlayer());
285
+ const seeks = liveTime.seekCount();
286
+ const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
287
+ const shadow = makeFakeIframe(makeAdapterWindow().win);
288
+ shadow.src = "http://localhost/api/projects/demo/preview?_t=1";
289
+ act(() => {
290
+ getApi().setShadowIframeNode(shadow);
291
+ getApi().onShadowIframeLoad(gen);
292
+ getApi().onShadowReadyChange(gen, true);
293
+ });
294
+ expect(getApi().iframeRef.current).toBe(shadow);
295
+ expect(liveTime.latest()).toBe(7);
296
+ expect(liveTime.seekCount()).toBe(seeks);
297
+ unmountWithAct(root);
298
+ });
299
+
300
+ it("does not count the audition's return to the paused time as a person's seek", () => {
301
+ const { api, root } = renderAttachedTimelinePlayer();
302
+ seekWithAct(api, 4);
303
+ const seeks = liveTime.seekCount();
304
+ act(() => usePlayerStore.getState().requestPlayback(false, 2));
305
+ expect(liveTime.latest()).toBe(2);
306
+ expect(liveTime.seekCount()).toBe(seeks);
307
+ unmountWithAct(root);
308
+ });
309
+
266
310
  it("default seek() clears isPlaying when the store reports playing", () => {
267
311
  const { api, root } = renderAttachedTimelinePlayer();
268
312
  setStorePlaying();
@@ -47,6 +47,9 @@ export interface UseTimelinePlayerOptions {
47
47
  onPreviewReloadFailed?: (message: string) => void;
48
48
  }
49
49
 
50
+ const publishSeek = (time: number, options?: { follow?: boolean }) =>
51
+ options?.follow === false ? liveTime.notify(time) : liveTime.notifySeek(time);
52
+
50
53
  export function useTimelinePlayer({
51
54
  onShadowPromoted,
52
55
  onPreviewReloadFailed,
@@ -276,7 +279,7 @@ export function useTimelinePlayer({
276
279
  stopRAFLoop();
277
280
  }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
278
281
  const seek = useCallback(
279
- (time: number, options?: { keepPlaying?: boolean }) => {
282
+ (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => {
280
283
  const wasReverseShuttle = shuttleDirectionRef.current === "backward";
281
284
  stopReverseLoop();
282
285
  const adapter = getAdapter();
@@ -295,7 +298,7 @@ export function useTimelinePlayer({
295
298
  nextTime,
296
299
  });
297
300
  adapter.seek(nextTime, options);
298
- liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
301
+ publishSeek(nextTime, options); // Direct DOM updates (playhead, timecode, progress) — no re-render
299
302
  setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
300
303
  if (!shouldResumeAfterSeek && !keepPlaying) scrubMusicAtSeek(iframeRef.current, nextTime);
301
304
  if (shouldResumeAfterSeek) {
@@ -344,7 +347,7 @@ export function useTimelinePlayer({
344
347
  if (request.playing) play();
345
348
  else {
346
349
  pause();
347
- if (request.returnTo !== null) seek(request.returnTo);
350
+ if (request.returnTo !== null) seek(request.returnTo, { follow: false });
348
351
  }
349
352
  usePlayerStore.getState().clearPlaybackRequest();
350
353
  }
@@ -409,7 +412,7 @@ export function useTimelinePlayer({
409
412
  onReloadFailed: onPreviewReloadFailed,
410
413
  handOverPlayback: (time, playing) => {
411
414
  // keepPlaying: move the playhead without the paused-seek audio scrub.
412
- seek(time, { keepPlaying: true });
415
+ seek(time, { keepPlaying: true, follow: false });
413
416
  const adapter = getAdapter();
414
417
  // An edit that cut the film short of the live time stops it at the new end, as playback does.
415
418
  if (playing && adapter && adapter.getTime() < adapter.getDuration()) play();
@@ -7,6 +7,8 @@ import {
7
7
  mergeTimelineElementsPreservingDowngrades,
8
8
  } from "./timelineDOM";
9
9
  import { isTimelineIgnoredElement } from "./timelineElementHelpers";
10
+ import { clipTimingStart, resolveClipTimingBasis } from "../../hooks/gsapShared";
11
+ import { toAuthoredStart } from "../store/timelineElement";
10
12
  import { computeResizePreview } from "../components/timelineClipDragPreview";
11
13
  import type { TimelineElement } from "../store/playerStore";
12
14
  import {
@@ -25,6 +27,83 @@ function makeDoc(html: string): Document {
25
27
  return d;
26
28
  }
27
29
 
30
+ describe("parseTimelineFromDOM — nested master time", () => {
31
+ it("adds every enclosing host's start to a clip inside a sub-composition", () => {
32
+ const doc = makeDoc(`
33
+ <div data-composition-id="main" data-start="0" data-duration="20">
34
+ <div id="intro" data-composition-id="intro" data-start="2" data-duration="10">
35
+ <div data-composition-id="intro">
36
+ <div id="logo" data-composition-id="logo" data-start="3" data-duration="5">
37
+ <div data-composition-id="logo">
38
+ <div id="badge" class="clip" data-start="1" data-duration="2"></div>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ `);
45
+ const starts = parseTimelineFromDOM(doc, 20).map((e) => [
46
+ e.domId,
47
+ e.start,
48
+ e.parentCompositionStart,
49
+ ]);
50
+ expect(starts).toEqual([
51
+ ["intro", 2, 0],
52
+ ["logo", 5, 2],
53
+ ["badge", 6, 5],
54
+ ]);
55
+ });
56
+
57
+ it("places a clip inside a referenced scene after the scene's authored length", () => {
58
+ const doc = makeDoc(`
59
+ <div data-composition-id="main" data-start="0" data-duration="20">
60
+ <div id="s1" data-composition-id="s1" data-start="0" data-hf-authored-duration="8"></div>
61
+ <div id="s2" data-composition-id="s2" data-start="s1 + 1" data-duration="6">
62
+ <div data-composition-id="s2">
63
+ <div id="c" class="clip" data-start="1" data-duration="2"></div>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ `);
68
+ const timelines = { s1: { duration: () => 6 } } as never;
69
+ const c = parseTimelineFromDOM(doc, 20, timelines).find((e) => e.domId === "c");
70
+ expect(c?.start).toBe(10);
71
+ });
72
+ });
73
+
74
+ describe("parseTimelineFromDOM — nested rows' keyframe basis", () => {
75
+ const doc = () =>
76
+ makeDoc(`
77
+ <div data-composition-id="main" data-start="0" data-duration="20">
78
+ <div id="intro" data-composition-id="intro" data-start="2" data-duration="10">
79
+ <div data-composition-id="intro">
80
+ <video id="vo" data-start="7" data-duration="2" data-hf-media-start-basis="global"></video>
81
+ <div id="logo" data-composition-id="logo" data-start="3" data-duration="5">
82
+ <div data-composition-id="logo">
83
+ <div id="badge" class="clip" data-start="1" data-duration="2"></div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ `);
90
+
91
+ it("measures diamonds and keyframe percentages against the local tween clock", () => {
92
+ const rows = parseTimelineFromDOM(doc(), 20);
93
+ const basis = (id: string) => resolveClipTimingBasis(id, "index.html", rows, []).elStart;
94
+ const at = (id: string) => rows.find((e) => e.domId === id)!;
95
+ expect([clipTimingStart(at("logo")), basis("logo")]).toEqual([3, 3]);
96
+ expect([clipTimingStart(at("badge")), basis("badge")]).toEqual([1, 1]);
97
+ });
98
+
99
+ it("keys a legacy root-time video on its host's clock but writes its start as master time", () => {
100
+ const vo = parseTimelineFromDOM(doc(), 20).find((e) => e.domId === "vo")!;
101
+ expect(vo.start).toBe(7);
102
+ expect(clipTimingStart(vo)).toBe(5);
103
+ expect(toAuthoredStart(vo, 8)).toBe(8);
104
+ });
105
+ });
106
+
28
107
  describe("parseTimelineFromDOM — media in-point", () => {
29
108
  it("reads a negative in-point as 0, as the runtime does, so a head trim keeps the clip", () => {
30
109
  const doc = makeDoc(`