@hyperframes/studio 0.8.139 → 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.
- package/dist/assets/{hyperframes-player-CckOoCJ8.js → hyperframes-player-BLuyxnK7.js} +1 -1
- package/dist/assets/{index-DsEkE5_G.js → index-CWfuDCoA.js} +1 -1
- package/dist/assets/{index-BmIPrb8I.js → index-Dr3Bo9uH.js} +1 -1
- package/dist/assets/{index-Br1Whc0f.js → index-VAHDM_zL.js} +204 -204
- package/dist/{chunk-E422JDF5.js → chunk-H2DUOXTF.js} +2 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.html +1 -1
- package/dist/index.js +6498 -5813
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +79 -1
- package/src/components/TimelineToolbar.tsx +14 -29
- package/src/components/editor/DomEditOverlay.tsx +2 -2
- package/src/components/editor/PropertyPanel.tsx +1 -0
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/manualEdits.ts +1 -1
- package/src/components/editor/manualEditsParsing.ts +14 -2
- package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
- package/src/components/editor/propertyPanelFill.tsx +36 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
- package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
- package/src/components/nle/NLEPreview.test.ts +44 -1
- package/src/components/nle/NLEPreview.tsx +7 -2
- package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
- package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
- package/src/hooks/useMusicBeatAnalysis.ts +2 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
- package/src/hooks/useSdkSession.ts +4 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
- package/src/hooks/useThumbnailStripSize.ts +23 -1
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +153 -1
- package/src/player/components/AudioWaveform.tsx +93 -37
- package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +4 -0
- package/src/player/components/TimelineClip.tsx +20 -6
- package/src/player/components/TimelineClipFades.test.tsx +11 -0
- package/src/player/components/TimelineClipFades.tsx +4 -4
- package/src/player/components/TimelineClipJoins.tsx +23 -18
- package/src/player/components/TimelineGestureOverlay.tsx +30 -27
- package/src/player/components/TimelineGroupRow.tsx +1 -0
- package/src/player/components/TimelineLaneClip.tsx +127 -0
- package/src/player/components/TimelineLanes.test.tsx +51 -6
- package/src/player/components/TimelineLanes.tsx +166 -178
- package/src/player/components/TimelineRuler.tsx +2 -0
- package/src/player/components/TimelineTimeLayer.tsx +25 -0
- package/src/player/components/VideoThumbnail.test.tsx +204 -12
- package/src/player/components/VideoThumbnail.tsx +37 -9
- package/src/player/components/thumbnailUtils.test.ts +17 -3
- package/src/player/components/thumbnailUtils.ts +17 -1
- package/src/player/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineMotion.test.ts +27 -0
- package/src/player/components/timelineMotion.ts +62 -0
- package/src/player/components/timelineZoomInput.test.ts +438 -0
- package/src/player/components/timelineZoomInput.ts +456 -0
- package/src/player/components/useTimelineGeometry.ts +0 -3
- package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
- package/src/player/components/useTimelinePlayhead.ts +53 -59
- package/src/player/components/useTimelineProviderState.tsx +16 -21
- package/src/player/components/useTimelineZoom.ts +7 -12
- package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
- package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
- /package/dist/{chunk-E422JDF5.js.map → chunk-H2DUOXTF.js.map} +0 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { flushSync } from "react-dom";
|
|
3
|
+
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
4
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
5
|
+
import { markTimelineMotion, TIMELINE_REST_MS } from "./timelineMotion";
|
|
6
|
+
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
|
|
7
|
+
import {
|
|
8
|
+
clampTimelineZoomPercent,
|
|
9
|
+
getNextTimelineZoomPercent,
|
|
10
|
+
getTimelinePixelsPerSecond,
|
|
11
|
+
getTimelineZoomPercent,
|
|
12
|
+
} from "./timelineZoom";
|
|
13
|
+
|
|
14
|
+
/** Where a zoom holds its place: `time` stays `x` px from the viewport's left edge. */
|
|
15
|
+
export interface TimelineZoomAnchor {
|
|
16
|
+
time: number;
|
|
17
|
+
x: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The mounted timeline's scroll viewport, which every zoom measures and previews against. */
|
|
21
|
+
export interface TimelineZoomViewport {
|
|
22
|
+
scroll: HTMLDivElement;
|
|
23
|
+
contentOrigin: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** How an eased zoom ended: it reached its range, or a person's zoom or the caller stopped it. */
|
|
27
|
+
export type TimelineZoomResult = "done" | "cancelled";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A zoom drawn by scaling what is already laid out, on the compositor, until it is committed.
|
|
31
|
+
* `shift` is the scrollLeft minus the scrollLeft the zoom will have: the translate that places it.
|
|
32
|
+
*/
|
|
33
|
+
interface ZoomPreview {
|
|
34
|
+
percent: number;
|
|
35
|
+
pps: number;
|
|
36
|
+
shift: number;
|
|
37
|
+
basePps: number;
|
|
38
|
+
/** The times laid out (clips mounted, ruler drawn) when it began; past them it shows gaps. */
|
|
39
|
+
drawn: TimelineTimeRange;
|
|
40
|
+
byPerson: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const RANGE_MARGIN_PX = 24;
|
|
44
|
+
const SMOOTH_ZOOM_MS = 220;
|
|
45
|
+
/** Past this the scaled-up preview turns soft. */
|
|
46
|
+
const MAX_PREVIEW_SCALE = 4;
|
|
47
|
+
/** Rounding at a drawn edge (whole-pixel content width, the fit track's 2px) is not a gap. */
|
|
48
|
+
const EDGE_SLACK_PX = 2;
|
|
49
|
+
const SCALED = "[data-timeline-zoom-scale]";
|
|
50
|
+
|
|
51
|
+
let viewport: TimelineZoomViewport | null = null;
|
|
52
|
+
/** While a preview shows, scales rows and strips React mounts into the timeline before they paint. */
|
|
53
|
+
let mounts: MutationObserver | null = null;
|
|
54
|
+
let preview: ZoomPreview | null = null;
|
|
55
|
+
let frame = 0;
|
|
56
|
+
let restTimer: ReturnType<typeof setTimeout> | null = null;
|
|
57
|
+
let anchorForCommit: TimelineZoomAnchor | null = null;
|
|
58
|
+
let animation = 0;
|
|
59
|
+
let easingTo: number | null = null;
|
|
60
|
+
let resolveEase: ((result: TimelineZoomResult) => void) | null = null;
|
|
61
|
+
const previewListeners = new Set<() => void>();
|
|
62
|
+
|
|
63
|
+
export function currentTimelineZoomPercent(): number {
|
|
64
|
+
if (preview) return preview.percent;
|
|
65
|
+
const s = usePlayerStore.getState();
|
|
66
|
+
return getTimelineZoomPercent(s.zoomMode, s.manualZoomPercent, s.timelineFitPps);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The scale the timeline shows and the scrollLeft that scale would have. */
|
|
70
|
+
function shown(scroll: HTMLDivElement): { pps: number; left: number } {
|
|
71
|
+
if (preview) return { pps: preview.pps, left: scroll.scrollLeft - preview.shift };
|
|
72
|
+
return { pps: usePlayerStore.getState().timelinePps, left: scroll.scrollLeft };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The time under `x` px from the timeline viewport's left edge, as it is drawn now. */
|
|
76
|
+
export function timelineTimeAtX(x: number): number | null {
|
|
77
|
+
if (!viewport) return null;
|
|
78
|
+
const { pps, left } = shown(viewport.scroll);
|
|
79
|
+
return pps > 0 ? (left + x - viewport.contentOrigin) / pps : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The scale and time-zero position to place something at a time by, while a zoom is previewed. */
|
|
83
|
+
export function timelineZoomMapping(pps: number, contentOrigin: number) {
|
|
84
|
+
return preview
|
|
85
|
+
? { pps: preview.pps, contentOrigin: contentOrigin + preview.shift }
|
|
86
|
+
: { pps, contentOrigin };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether a zoom is drawn scaled right now, so boxes read off the page are scaled too. */
|
|
90
|
+
export const isTimelineZoomPreviewing = (): boolean => preview !== null;
|
|
91
|
+
|
|
92
|
+
/** Called each frame a zoom preview moves, and once when it is laid out or dropped. */
|
|
93
|
+
export function subscribeTimelineZoomPreview(listener: () => void): () => void {
|
|
94
|
+
previewListeners.add(listener);
|
|
95
|
+
return () => previewListeners.delete(listener);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const subscribeShownZoom = (listener: () => void) => {
|
|
99
|
+
const unsubscribePreview = subscribeTimelineZoomPreview(listener);
|
|
100
|
+
const unsubscribeStore = usePlayerStore.subscribe(listener);
|
|
101
|
+
return () => {
|
|
102
|
+
unsubscribePreview();
|
|
103
|
+
unsubscribeStore();
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** The zoom percent the timeline shows, kept current through a preview. */
|
|
108
|
+
export function useShownTimelineZoomPercent(): number {
|
|
109
|
+
return useSyncExternalStore(subscribeShownZoom, currentTimelineZoomPercent);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function writeZoom(percent: number, anchor: TimelineZoomAnchor | null, byPerson: boolean) {
|
|
113
|
+
anchorForCommit = anchor;
|
|
114
|
+
usePlayerStore.setState((s) => {
|
|
115
|
+
const clamped = clampTimelineZoomPercent(percent, s.timelineFitPps);
|
|
116
|
+
return {
|
|
117
|
+
zoomMode: "manual",
|
|
118
|
+
manualZoomPercent: clamped,
|
|
119
|
+
// Only a person's zoom counts: the timeline and its host read it as the person taking over.
|
|
120
|
+
userZoomCount: s.userZoomCount + (byPerson ? 1 : 0),
|
|
121
|
+
timelinePps: getTimelinePixelsPerSecond(s.timelineFitPps, "manual", clamped),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const emitPreview = () => previewListeners.forEach((listener) => listener());
|
|
127
|
+
|
|
128
|
+
/** Stops the pending preview frame, rest timer and mount watch. */
|
|
129
|
+
function cancelPending() {
|
|
130
|
+
mounts?.disconnect();
|
|
131
|
+
mounts = null;
|
|
132
|
+
if (restTimer) clearTimeout(restTimer);
|
|
133
|
+
restTimer = null;
|
|
134
|
+
cancelAnimationFrame(frame);
|
|
135
|
+
frame = 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function clearScaled(scroll: HTMLElement) {
|
|
139
|
+
scroll.querySelectorAll<HTMLElement>(SCALED).forEach((el) => {
|
|
140
|
+
el.style.transform = "";
|
|
141
|
+
el.style.transformOrigin = "";
|
|
142
|
+
el.style.willChange = "";
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether the preview shows time outside what was mounted, or is scaled up too far. */
|
|
147
|
+
function previewNeedsLayout(p: ZoomPreview, scroll: HTMLDivElement, contentOrigin: number) {
|
|
148
|
+
if (p.pps / p.basePps > MAX_PREVIEW_SCALE) return true;
|
|
149
|
+
const left = scroll.scrollLeft - p.shift;
|
|
150
|
+
const start = left / p.pps;
|
|
151
|
+
const end = (left + scroll.clientWidth - contentOrigin) / p.pps;
|
|
152
|
+
const slack = EDGE_SLACK_PX / p.pps;
|
|
153
|
+
return start < p.drawn.start - slack || end > p.drawn.end + slack;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The times laid out now: the render window clips, ruler ticks and beat lines are drawn in. */
|
|
157
|
+
function drawnRange(scroll: HTMLDivElement, pps: number, contentOrigin: number): TimelineTimeRange {
|
|
158
|
+
const contentEnd = (scroll.scrollWidth - contentOrigin) / pps;
|
|
159
|
+
return getTimelineRenderTimeRange(scroll, pps, contentOrigin, contentEnd);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function scalePreview(scroll: HTMLElement) {
|
|
163
|
+
if (!preview) return;
|
|
164
|
+
const transform = `translateX(${preview.shift}px) scaleX(${preview.pps / preview.basePps})`;
|
|
165
|
+
scroll.querySelectorAll<HTMLElement>(SCALED).forEach((el) => {
|
|
166
|
+
// The attribute's value is where time zero sits in the element, when not at its left edge.
|
|
167
|
+
el.style.transformOrigin = `${el.dataset.timelineZoomScale || 0}px 0`;
|
|
168
|
+
el.style.willChange = "transform";
|
|
169
|
+
el.style.transform = transform;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const holdsScaled = (node: Node) =>
|
|
174
|
+
node instanceof Element && (node.matches(SCALED) || node.querySelector(SCALED) !== null);
|
|
175
|
+
|
|
176
|
+
function watchMounts(scroll: HTMLElement): MutationObserver {
|
|
177
|
+
const observer = new MutationObserver((records) => {
|
|
178
|
+
if (records.some((record) => [...record.addedNodes].some(holdsScaled))) scalePreview(scroll);
|
|
179
|
+
});
|
|
180
|
+
observer.observe(scroll, { childList: true, subtree: true });
|
|
181
|
+
return observer;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function drawPreview() {
|
|
185
|
+
cancelAnimationFrame(frame);
|
|
186
|
+
frame = 0;
|
|
187
|
+
const view = viewport;
|
|
188
|
+
if (!preview || !view) return;
|
|
189
|
+
if (previewNeedsLayout(preview, view.scroll, view.contentOrigin)) {
|
|
190
|
+
commitPreview();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
scalePreview(view.scroll);
|
|
194
|
+
mounts ??= watchMounts(view.scroll);
|
|
195
|
+
emitPreview();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Lays the previewed zoom out for real and drops the scaling in the same frame. */
|
|
199
|
+
function commitPreview() {
|
|
200
|
+
cancelPending();
|
|
201
|
+
const view = viewport;
|
|
202
|
+
const done = preview;
|
|
203
|
+
if (!view || !done) return;
|
|
204
|
+
const left = view.scroll.scrollLeft - done.shift;
|
|
205
|
+
// Ending at the scale already laid out (an eased zoom-out) needs a scroll, not a layout.
|
|
206
|
+
if (done.pps !== done.basePps) {
|
|
207
|
+
flushSync(() =>
|
|
208
|
+
writeZoom(done.percent, { time: left / done.pps, x: view.contentOrigin }, done.byPerson),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
preview = null;
|
|
212
|
+
if (Math.abs(view.scroll.scrollLeft - left) >= 0.5) view.scroll.scrollLeft = left;
|
|
213
|
+
clearScaled(view.scroll);
|
|
214
|
+
emitPreview();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The playhead's place if on screen, else the middle with the playhead brought to it. */
|
|
218
|
+
function defaultAnchor(view: TimelineZoomViewport, pps: number, left: number): TimelineZoomAnchor {
|
|
219
|
+
const { currentTime } = usePlayerStore.getState();
|
|
220
|
+
const x = view.contentOrigin + currentTime * pps - left;
|
|
221
|
+
const onScreen = x >= view.contentOrigin && x <= view.scroll.clientWidth;
|
|
222
|
+
return {
|
|
223
|
+
time: currentTime,
|
|
224
|
+
x: onScreen ? x : (view.contentOrigin + view.scroll.clientWidth) / 2,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function request(percent: number, anchor: TimelineZoomAnchor | null, byPerson: boolean) {
|
|
229
|
+
const view = viewport;
|
|
230
|
+
const fitPps = usePlayerStore.getState().timelineFitPps;
|
|
231
|
+
if (!view || !(fitPps > 0)) {
|
|
232
|
+
writeZoom(percent, anchor, byPerson);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const clamped = clampTimelineZoomPercent(percent, fitPps);
|
|
236
|
+
const pps = getTimelinePixelsPerSecond(fitPps, "manual", clamped);
|
|
237
|
+
const { scroll, contentOrigin } = view;
|
|
238
|
+
const now = shown(scroll);
|
|
239
|
+
const at = anchor ?? defaultAnchor(view, now.pps, now.left);
|
|
240
|
+
preview ??= {
|
|
241
|
+
percent: clamped,
|
|
242
|
+
pps,
|
|
243
|
+
shift: 0,
|
|
244
|
+
basePps: now.pps,
|
|
245
|
+
drawn: drawnRange(scroll, now.pps, contentOrigin),
|
|
246
|
+
byPerson,
|
|
247
|
+
};
|
|
248
|
+
// The content's width at this scale: the fit track (getTimelineFitPps), never narrower.
|
|
249
|
+
const fitTrack = scroll.clientWidth - contentOrigin - 2;
|
|
250
|
+
const width = contentOrigin + fitTrack * Math.max(1, pps / fitPps);
|
|
251
|
+
const maxLeft = Math.max(0, width - scroll.clientWidth);
|
|
252
|
+
const left = Math.max(0, Math.min(maxLeft, at.time * pps + contentOrigin - at.x));
|
|
253
|
+
preview.percent = clamped;
|
|
254
|
+
preview.pps = pps;
|
|
255
|
+
preview.shift = scroll.scrollLeft - left;
|
|
256
|
+
preview.byPerson ||= byPerson;
|
|
257
|
+
if (!frame) frame = requestAnimationFrame(drawPreview);
|
|
258
|
+
if (restTimer) clearTimeout(restTimer);
|
|
259
|
+
restTimer = setTimeout(commitPreview, TIMELINE_REST_MS);
|
|
260
|
+
// After the commit timer of the same delay, so the zoom is laid out before the timeline rests.
|
|
261
|
+
markTimelineMotion();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A person's zoom input: previewed each frame, laid out once it rests. */
|
|
265
|
+
export function requestTimelineZoom(percent: number, anchor: TimelineZoomAnchor | null = null) {
|
|
266
|
+
stopEase();
|
|
267
|
+
request(percent, anchor, true);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Lays out a pending zoom now, so a press meets the real layout. */
|
|
271
|
+
export function settleTimelineZoom(): void {
|
|
272
|
+
if (preview) commitPreview();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** After a scroll: scales what it mounted, or lays the zoom out if it shows past what is drawn. */
|
|
276
|
+
export function redrawTimelineZoomPreview(): void {
|
|
277
|
+
if (preview && !frame) frame = requestAnimationFrame(drawPreview);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Drops any pending or easing zoom without laying it out, as Fit does. */
|
|
281
|
+
export function cancelTimelineZoom(): void {
|
|
282
|
+
stopEase();
|
|
283
|
+
dropPreview();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function dropPreview() {
|
|
287
|
+
cancelPending();
|
|
288
|
+
if (!preview) return;
|
|
289
|
+
if (viewport) clearScaled(viewport.scroll);
|
|
290
|
+
preview = null;
|
|
291
|
+
emitPreview();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function stopEase(result: TimelineZoomResult = "cancelled") {
|
|
295
|
+
cancelAnimationFrame(animation);
|
|
296
|
+
animation = 0;
|
|
297
|
+
easingTo = null;
|
|
298
|
+
const done = resolveEase;
|
|
299
|
+
resolveEase = null;
|
|
300
|
+
done?.(result);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The anchor of the zoom being committed; read once by the timeline as it lays the zoom out. */
|
|
304
|
+
export function takeTimelineZoomAnchor(): TimelineZoomAnchor | null {
|
|
305
|
+
const anchor = anchorForCommit;
|
|
306
|
+
anchorForCommit = null;
|
|
307
|
+
return anchor;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function registerTimelineZoomViewport(next: TimelineZoomViewport): () => void {
|
|
311
|
+
viewport = next;
|
|
312
|
+
return () => {
|
|
313
|
+
// Another timeline mounted since keeps its registration.
|
|
314
|
+
if (viewport !== next) return;
|
|
315
|
+
viewport = null;
|
|
316
|
+
// A re-render registers again in the same commit; only a real unmount drops the zoom.
|
|
317
|
+
queueMicrotask(() => {
|
|
318
|
+
if (!viewport) cancelTimelineZoom();
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const reducedMotion = () =>
|
|
324
|
+
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
325
|
+
|
|
326
|
+
/** The stretch a range zoom fills: from `x` px in, `width` px wide, inside the margins. */
|
|
327
|
+
function rangeBox(view: TimelineZoomViewport): { x: number; width: number } {
|
|
328
|
+
const x = view.contentOrigin + RANGE_MARGIN_PX;
|
|
329
|
+
return { x, width: Math.max(1, view.scroll.clientWidth - x - RANGE_MARGIN_PX) };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The seconds the timeline shows between its margins: what zoomTimelineToRange would fill. */
|
|
333
|
+
export function currentTimelineRange(): { start: number; end: number } | null {
|
|
334
|
+
const view = viewport;
|
|
335
|
+
const pps = view ? shown(view.scroll).pps : 0;
|
|
336
|
+
if (!view || !(pps > 0)) return null;
|
|
337
|
+
const { x, width } = rangeBox(view);
|
|
338
|
+
const start = timelineTimeAtX(x)!;
|
|
339
|
+
return { start, end: start + width / pps };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Zooms and scrolls so `start`..`end` (seconds) fills the timeline, eased unless `smooth` is false
|
|
344
|
+
* or motion is reduced. Resolves on landing; "cancelled" on a person's zoom, abort, or a newer one.
|
|
345
|
+
*/
|
|
346
|
+
export function zoomTimelineToRange(
|
|
347
|
+
start: number,
|
|
348
|
+
end: number,
|
|
349
|
+
options: { smooth?: boolean; signal?: AbortSignal } = {},
|
|
350
|
+
): Promise<TimelineZoomResult> {
|
|
351
|
+
return easeToRange(start, end, options, false);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function easeToRange(
|
|
355
|
+
start: number,
|
|
356
|
+
end: number,
|
|
357
|
+
{ smooth = true, signal }: { smooth?: boolean; signal?: AbortSignal },
|
|
358
|
+
byPerson: boolean,
|
|
359
|
+
): Promise<TimelineZoomResult> {
|
|
360
|
+
const view = viewport;
|
|
361
|
+
const fitPps = usePlayerStore.getState().timelineFitPps;
|
|
362
|
+
stopEase();
|
|
363
|
+
if (!view || !(end > start) || !(fitPps > 0) || signal?.aborted)
|
|
364
|
+
return Promise.resolve("cancelled");
|
|
365
|
+
let resolveThis: (result: TimelineZoomResult) => void = () => {};
|
|
366
|
+
const result = new Promise<TimelineZoomResult>((resolve) => (resolveThis = resolve));
|
|
367
|
+
resolveEase = resolveThis;
|
|
368
|
+
signal?.addEventListener("abort", () => resolveEase === resolveThis && stopEase(), {
|
|
369
|
+
once: true,
|
|
370
|
+
});
|
|
371
|
+
const { x, width } = rangeBox(view);
|
|
372
|
+
const toPercent = clampTimelineZoomPercent((width / (end - start) / fitPps) * 100, fitPps);
|
|
373
|
+
const finish = () => {
|
|
374
|
+
commitPreview();
|
|
375
|
+
stopEase("done");
|
|
376
|
+
};
|
|
377
|
+
if (!smooth || reducedMotion()) {
|
|
378
|
+
request(toPercent, { time: start, x }, byPerson);
|
|
379
|
+
// Laid out next frame, never inside a caller's render or effect.
|
|
380
|
+
animation = requestAnimationFrame(finish);
|
|
381
|
+
return result;
|
|
382
|
+
}
|
|
383
|
+
easeZoom(view, fitPps, toPercent, { time: start, x }, byPerson, finish);
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Eases from the zoom shown now to `toPercent`, landing `to.time` at `to.x`, then `finish`es. */
|
|
388
|
+
function easeZoom(
|
|
389
|
+
view: TimelineZoomViewport,
|
|
390
|
+
fitPps: number,
|
|
391
|
+
toPercent: number,
|
|
392
|
+
to: TimelineZoomAnchor,
|
|
393
|
+
byPerson: boolean,
|
|
394
|
+
finish: () => void,
|
|
395
|
+
) {
|
|
396
|
+
const { time: start, x } = to;
|
|
397
|
+
const fromPercent = currentTimelineZoomPercent();
|
|
398
|
+
const from = shown(view.scroll);
|
|
399
|
+
const toPps = getTimelinePixelsPerSecond(fitPps, "manual", toPercent);
|
|
400
|
+
const fromStart = timelineTimeAtX(x)!;
|
|
401
|
+
const old = currentTimelineRange()!;
|
|
402
|
+
// The one point both views put at the same place on screen; zooming about it, in log space,
|
|
403
|
+
// moves every frame straight from the old view to the new. Equal scales are a plain scroll.
|
|
404
|
+
const shift = from.pps === toPps ? null : (start - fromStart) / (1 / from.pps - 1 / toPps);
|
|
405
|
+
const anchorAt = (k: number): TimelineZoomAnchor =>
|
|
406
|
+
shift === null
|
|
407
|
+
? { time: fromStart + (start - fromStart) * k, x }
|
|
408
|
+
: { time: fromStart + shift / from.pps, x: x + shift };
|
|
409
|
+
// Zooming out to a view that holds the old one: lay the target out once, in the first frame,
|
|
410
|
+
// and ease by scaling it down from the old view, instead of laying out again partway.
|
|
411
|
+
const holdsOld =
|
|
412
|
+
old.start >= start && old.end <= start + (old.end - old.start) * (from.pps / toPps);
|
|
413
|
+
let layoutFirst = toPps < from.pps && from.pps / toPps <= MAX_PREVIEW_SCALE && holdsOld;
|
|
414
|
+
const began = performance.now();
|
|
415
|
+
easingTo = toPercent;
|
|
416
|
+
const step = (now: number) => {
|
|
417
|
+
if (layoutFirst) {
|
|
418
|
+
layoutFirst = false;
|
|
419
|
+
request(toPercent, to, byPerson);
|
|
420
|
+
commitPreview();
|
|
421
|
+
}
|
|
422
|
+
const t = Math.min(1, Math.max(0, (now - began) / SMOOTH_ZOOM_MS));
|
|
423
|
+
const k = 1 - (1 - t) ** 3;
|
|
424
|
+
request(fromPercent * (toPercent / fromPercent) ** k, anchorAt(k), byPerson);
|
|
425
|
+
// Drawn in this frame, not the next: the first one would otherwise paint a laid-out zoom-out bare.
|
|
426
|
+
drawPreview();
|
|
427
|
+
if (t < 1) animation = requestAnimationFrame(step);
|
|
428
|
+
else finish();
|
|
429
|
+
};
|
|
430
|
+
animation = requestAnimationFrame(step);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* A zoom button: twice or half the scale, eased, keeping the playhead where it is on screen,
|
|
435
|
+
* or centring it when it is off screen.
|
|
436
|
+
*/
|
|
437
|
+
export function zoomTimelineStep(direction: "in" | "out"): void {
|
|
438
|
+
const fitPps = usePlayerStore.getState().timelineFitPps;
|
|
439
|
+
const nextPercent = getNextTimelineZoomPercent(
|
|
440
|
+
direction,
|
|
441
|
+
"manual",
|
|
442
|
+
easingTo ?? currentTimelineZoomPercent(),
|
|
443
|
+
fitPps,
|
|
444
|
+
);
|
|
445
|
+
const view = viewport;
|
|
446
|
+
if (!view || !(fitPps > 0)) {
|
|
447
|
+
requestTimelineZoom(nextPercent);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const { x, width } = rangeBox(view);
|
|
451
|
+
const nextPps = getTimelinePixelsPerSecond(fitPps, "manual", nextPercent);
|
|
452
|
+
const now = shown(view.scroll);
|
|
453
|
+
const at = defaultAnchor(view, now.pps, now.left);
|
|
454
|
+
const start = at.time - (at.x - x) / nextPps;
|
|
455
|
+
void easeToRange(start, start + width / nextPps, {}, true);
|
|
456
|
+
}
|
|
@@ -77,8 +77,6 @@ export function useTimelineGeometry({
|
|
|
77
77
|
const displayDuration = pps > 0 ? displayContentWidth / pps : effectiveDuration;
|
|
78
78
|
const zoomModeRef = useRef(zoomMode);
|
|
79
79
|
zoomModeRef.current = zoomMode;
|
|
80
|
-
const manualZoomPercentRef = useRef(manualZoomPercent);
|
|
81
|
-
manualZoomPercentRef.current = manualZoomPercent;
|
|
82
80
|
fitPpsRef.current = fitPps;
|
|
83
81
|
|
|
84
82
|
// Restore the horizontal scroll offset after an edit re-derives the elements
|
|
@@ -133,6 +131,5 @@ export function useTimelineGeometry({
|
|
|
133
131
|
displayDuration,
|
|
134
132
|
clipStateVersion: expandedElements,
|
|
135
133
|
zoomModeRef,
|
|
136
|
-
manualZoomPercentRef,
|
|
137
134
|
};
|
|
138
135
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { act, useRef } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
|
|
7
7
|
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
|
8
8
|
|
|
@@ -22,20 +22,16 @@ function scrollBox(scrollLeft: number) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
interface HarnessProps {
|
|
25
|
-
|
|
25
|
+
/** Omitted: the scale the store publishes, as the timeline reads it. */
|
|
26
|
+
pps?: number;
|
|
26
27
|
scroll: HTMLDivElement;
|
|
27
|
-
percent?: number;
|
|
28
28
|
dragging?: boolean;
|
|
29
29
|
zoomMode?: ZoomMode;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
function Harness({
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
percent = 100,
|
|
36
|
-
dragging = false,
|
|
37
|
-
zoomMode = "manual",
|
|
38
|
-
}: HarnessProps) {
|
|
32
|
+
function Harness({ pps: fixedPps, scroll, dragging = false, zoomMode = "manual" }: HarnessProps) {
|
|
33
|
+
const storePps = usePlayerStore((s) => s.timelinePps);
|
|
34
|
+
const pps = fixedPps ?? storePps;
|
|
39
35
|
const scrollRef = useRef(scroll);
|
|
40
36
|
const durationRef = useRef(60);
|
|
41
37
|
useTimelinePlayhead({
|
|
@@ -46,17 +42,13 @@ function Harness({
|
|
|
46
42
|
isDragging: { current: dragging },
|
|
47
43
|
currentTime: 0,
|
|
48
44
|
zoomMode,
|
|
49
|
-
manualZoomPercent: percent,
|
|
50
45
|
zoomModeRef: { current: zoomMode },
|
|
51
|
-
manualZoomPercentRef: { current: percent },
|
|
52
46
|
fitPps: pps,
|
|
53
47
|
fitPpsRef: { current: pps },
|
|
54
48
|
effectiveDuration: 60,
|
|
55
49
|
pps,
|
|
56
50
|
timelineReady: true,
|
|
57
51
|
elementsLength: 1,
|
|
58
|
-
setZoomMode: () => {},
|
|
59
|
-
setManualZoomPercent: () => {},
|
|
60
52
|
contentOrigin: ORIGIN,
|
|
61
53
|
});
|
|
62
54
|
return null;
|
|
@@ -115,13 +107,13 @@ describe("useTimelinePlayhead zoom anchor", () => {
|
|
|
115
107
|
usePlayerStore.setState({ currentTime: 6 });
|
|
116
108
|
const scroll = scrollBox(400);
|
|
117
109
|
const before = onScreenX(scroll, 6, 100);
|
|
118
|
-
mount({ pps: 100, scroll
|
|
110
|
+
mount({ pps: 100, scroll })({ pps: 200 }, true);
|
|
119
111
|
expect(onScreenX(scroll, 6, 200)).toBeCloseTo(before);
|
|
120
112
|
});
|
|
121
113
|
|
|
122
114
|
it("stays at 00:00 when a zoom is set with the playhead at 0, as a zoom restored on open is", () => {
|
|
123
115
|
const scroll = scrollBox(0);
|
|
124
|
-
mount({ pps: 100, scroll
|
|
116
|
+
mount({ pps: 100, scroll })({ pps: 250 }, true);
|
|
125
117
|
expect(scroll.scrollLeft).toBe(0);
|
|
126
118
|
});
|
|
127
119
|
|
|
@@ -137,7 +129,7 @@ describe("useTimelinePlayhead zoom anchor", () => {
|
|
|
137
129
|
it("brings an off-screen playhead into view when the toolbar zooms", () => {
|
|
138
130
|
usePlayerStore.setState({ currentTime: 30 });
|
|
139
131
|
const scroll = scrollBox(0);
|
|
140
|
-
mount({ pps: 100, scroll
|
|
132
|
+
mount({ pps: 100, scroll })({ pps: 200 }, true);
|
|
141
133
|
expectVisible(scroll, 30, 200);
|
|
142
134
|
});
|
|
143
135
|
});
|
|
@@ -146,28 +138,21 @@ describe("useTimelinePlayhead zoom anchor, percent written by Studio itself", ()
|
|
|
146
138
|
it("keeps 00:00 when the window resizes after an edit pinned the zoom", () => {
|
|
147
139
|
usePlayerStore.setState({ currentTime: 6 });
|
|
148
140
|
const scroll = scrollBox(0);
|
|
149
|
-
const update = mount({ pps: 100, scroll
|
|
150
|
-
update({
|
|
151
|
-
update({ pps: 130
|
|
141
|
+
const update = mount({ pps: 100, scroll });
|
|
142
|
+
update({});
|
|
143
|
+
update({ pps: 130 });
|
|
152
144
|
expect(scroll.scrollLeft).toBe(0);
|
|
153
145
|
});
|
|
154
146
|
|
|
155
147
|
it("leaves an off-screen playhead alone when a length change re-pins the zoom", () => {
|
|
156
148
|
usePlayerStore.setState({ currentTime: 30 });
|
|
157
149
|
const scroll = scrollBox(400);
|
|
158
|
-
const update = mount({ pps: 100, scroll
|
|
159
|
-
update({ pps: 90
|
|
160
|
-
update({ pps: 101
|
|
150
|
+
const update = mount({ pps: 100, scroll });
|
|
151
|
+
update({ pps: 90 });
|
|
152
|
+
update({ pps: 101 });
|
|
161
153
|
const x = onScreenX(scroll, 30, 101);
|
|
162
154
|
expect(x > 800 || x < ORIGIN).toBe(true);
|
|
163
155
|
});
|
|
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
156
|
});
|
|
172
157
|
|
|
173
158
|
describe("useTimelinePlayhead follow while paused", () => {
|
|
@@ -252,3 +237,85 @@ describe("useTimelinePlayhead follow while paused", () => {
|
|
|
252
237
|
expect(scroll.scrollLeft).toBe(0);
|
|
253
238
|
});
|
|
254
239
|
});
|
|
240
|
+
|
|
241
|
+
describe("useTimelinePlayhead wheel zoom", () => {
|
|
242
|
+
beforeEach(() => {
|
|
243
|
+
vi.useFakeTimers({
|
|
244
|
+
toFake: ["requestAnimationFrame", "cancelAnimationFrame", "setTimeout", "clearTimeout"],
|
|
245
|
+
});
|
|
246
|
+
usePlayerStore.setState({
|
|
247
|
+
zoomMode: "manual",
|
|
248
|
+
manualZoomPercent: 100,
|
|
249
|
+
timelineFitPps: 100,
|
|
250
|
+
timelinePps: 100,
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
afterEach(() => {
|
|
254
|
+
vi.useRealTimers();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
/** A wheel step 432px in, previewed for one frame. */
|
|
258
|
+
const previewWheel = (scroll: HTMLDivElement, init: WheelEventInit) =>
|
|
259
|
+
act(() => {
|
|
260
|
+
const event = new WheelEvent("wheel", { deltaY: -50, cancelable: true, ...init });
|
|
261
|
+
// happy-dom's WheelEvent drops the MouseEvent fields of its init.
|
|
262
|
+
Object.defineProperties(event, {
|
|
263
|
+
clientX: { value: 432 },
|
|
264
|
+
ctrlKey: { value: Boolean(init.ctrlKey) },
|
|
265
|
+
metaKey: { value: Boolean(init.metaKey) },
|
|
266
|
+
});
|
|
267
|
+
scroll.dispatchEvent(event);
|
|
268
|
+
vi.advanceTimersToNextFrame();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/** A wheel step, then the rest at which its zoom is laid out. */
|
|
272
|
+
const wheel = (scroll: HTMLDivElement, init: WheelEventInit) => {
|
|
273
|
+
previewWheel(scroll, init);
|
|
274
|
+
act(() => vi.advanceTimersByTime(200));
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
it("zooms on Cmd+wheel as on a pinch, and leaves a plain wheel to scroll", () => {
|
|
278
|
+
const scroll = scrollBox(0);
|
|
279
|
+
mount({ scroll });
|
|
280
|
+
wheel(scroll, {});
|
|
281
|
+
expect(usePlayerStore.getState().manualZoomPercent).toBe(100);
|
|
282
|
+
wheel(scroll, { metaKey: true });
|
|
283
|
+
expect(usePlayerStore.getState().manualZoomPercent).toBeGreaterThan(100);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("keeps previewing a pinch through a scroll, laying it out only at rest", () => {
|
|
287
|
+
const scroll = scrollBox(0);
|
|
288
|
+
mount({ scroll });
|
|
289
|
+
previewWheel(scroll, { ctrlKey: true });
|
|
290
|
+
act(() => {
|
|
291
|
+
scroll.dispatchEvent(new Event("scroll"));
|
|
292
|
+
vi.advanceTimersToNextFrame();
|
|
293
|
+
});
|
|
294
|
+
expect(usePlayerStore.getState().timelinePps).toBe(100);
|
|
295
|
+
act(() => vi.advanceTimersByTime(200));
|
|
296
|
+
expect(usePlayerStore.getState().timelinePps).toBeGreaterThan(100);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("lays a pending pinch out at once when the timeline is pressed", () => {
|
|
300
|
+
const scroll = scrollBox(0);
|
|
301
|
+
mount({ scroll });
|
|
302
|
+
previewWheel(scroll, { ctrlKey: true });
|
|
303
|
+
expect(usePlayerStore.getState().timelinePps).toBe(100);
|
|
304
|
+
act(() => {
|
|
305
|
+
scroll.dispatchEvent(new Event("pointerdown"));
|
|
306
|
+
});
|
|
307
|
+
expect(usePlayerStore.getState().timelinePps).toBeGreaterThan(100);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("keeps the time under the pointer in place as a pinch lays out", () => {
|
|
311
|
+
// Zoomed to 150%, so a 300px scroll is one the content allows.
|
|
312
|
+
usePlayerStore.setState({ manualZoomPercent: 150, timelinePps: 150 });
|
|
313
|
+
const scroll = scrollBox(300);
|
|
314
|
+
mount({ scroll });
|
|
315
|
+
// Pointer 432px in: (300 + 432 - 32) / 150 s sits there before and after.
|
|
316
|
+
wheel(scroll, { ctrlKey: true });
|
|
317
|
+
const pps = usePlayerStore.getState().timelinePps;
|
|
318
|
+
expect(pps).toBeGreaterThan(150);
|
|
319
|
+
expect(ORIGIN + (700 / 150) * pps - scroll.scrollLeft).toBeCloseTo(432);
|
|
320
|
+
});
|
|
321
|
+
});
|