@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.
- package/dist/assets/{hyperframes-player-DDjER_ii.js → hyperframes-player-zT2Bdx_l.js} +1 -1
- package/dist/assets/{index-5SFeUXP8.js → index-DSOLcE8X.js} +1 -1
- package/dist/assets/{index-CVsCxnlz.js → index-DV-z19zB.js} +1 -1
- package/dist/assets/{index-BKE3_N4v.js → index-V-0rfhe6.js} +168 -168
- package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
- package/dist/chunk-QPSOTK6M.js.map +1 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.html +1 -1
- package/dist/index.js +1298 -1003
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +12 -0
- package/src/components/editor/MotionPathOverlay.tsx +5 -1
- package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
- package/src/components/editor/elementPositionOffset.test.ts +62 -0
- package/src/components/editor/elementPositionOffset.ts +64 -0
- package/src/components/editor/motionPathCommit.test.ts +25 -5
- package/src/components/editor/motionPathSelection.ts +10 -3
- package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
- package/src/components/editor/useLayerRevealOverride.ts +2 -1
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
- package/src/hooks/domEditPersistFailure.ts +8 -6
- package/src/hooks/elementOffsetStager.ts +65 -0
- package/src/hooks/gestureTransaction.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +1 -0
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
- package/src/hooks/gsapRuntimeBridge.ts +50 -14
- package/src/hooks/gsapShared.test.ts +1 -1
- package/src/hooks/gsapShared.ts +16 -12
- package/src/hooks/gsapTweenReach.ts +30 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -1
- package/src/hooks/useClipboard.source.test.ts +58 -0
- package/src/hooks/useClipboard.ts +12 -3
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
- package/src/hooks/useDomGeometryCommit.ts +42 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
- package/src/hooks/useDomGeometryCommits.ts +14 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
- package/src/hooks/useGsapAwareEditing.ts +44 -7
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
- package/src/hooks/useRazorSplit.test.ts +5 -5
- package/src/hooks/useTimelineEditing.test.tsx +384 -0
- package/src/hooks/useTimelineEditing.ts +12 -12
- package/src/hooks/useTimelineGroupEditing.ts +16 -7
- package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTypes.ts +1 -1
- package/src/player/components/timelineClipDragCommit.test.ts +18 -8
- package/src/player/components/timelineClipDragCommit.ts +5 -0
- package/src/player/components/timelineClipDragPreview.test.ts +70 -2
- package/src/player/components/timelineClipDragPreview.ts +15 -12
- package/src/player/components/timelineEditing.ts +2 -1
- package/src/player/components/timelineGroupEditing.ts +7 -2
- package/src/player/components/timelineMultiDragPreview.test.ts +10 -8
- package/src/player/components/timelineMultiDragPreview.ts +8 -5
- package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
- package/src/player/components/timelineProviderStateBuilders.ts +7 -2
- package/src/player/components/timelineZMirror.test.ts +2 -2
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
- package/src/player/components/useTimelineClipDrag.ts +3 -2
- package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
- package/src/player/components/useTimelinePlayhead.ts +56 -27
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/player/components/useTimelineZoom.ts +9 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
- package/src/player/hooks/useTimelinePlayer.ts +7 -4
- package/src/player/lib/timelineDOM.test.ts +79 -0
- package/src/player/lib/timelineDOM.ts +16 -7
- package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
- package/src/player/lib/timelineIframeHelpers.ts +12 -3
- package/src/player/store/liveTime.ts +6 -0
- package/src/player/store/playerStore.ts +2 -0
- package/src/player/store/timelineElement.ts +28 -3
- package/src/utils/razorSplitTransaction.test.ts +1 -1
- package/src/utils/razorSplitTransaction.ts +3 -3
- package/dist/chunk-KU4F7QZ3.js.map +0 -1
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import { act,
|
|
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
|
-
|
|
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:
|
|
24
|
-
scrollRef
|
|
42
|
+
playheadRef: { current: document.createElement("div") },
|
|
43
|
+
scrollRef,
|
|
25
44
|
ppsRef: { current: pps },
|
|
26
|
-
durationRef
|
|
27
|
-
isDragging: { current:
|
|
45
|
+
durationRef,
|
|
46
|
+
isDragging: { current: dragging },
|
|
28
47
|
currentTime: 0,
|
|
29
|
-
zoomMode
|
|
30
|
-
manualZoomPercent:
|
|
31
|
-
zoomModeRef: { current:
|
|
32
|
-
manualZoomPercentRef: { current:
|
|
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:
|
|
60
|
+
contentOrigin: ORIGIN,
|
|
42
61
|
});
|
|
43
62
|
return null;
|
|
44
63
|
}
|
|
45
64
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
act(() => root.render(<Harness
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
62
|
-
//
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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(
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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(`
|