@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.
Files changed (72) hide show
  1. package/dist/assets/{hyperframes-player-CckOoCJ8.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DsEkE5_G.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-BmIPrb8I.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-Br1Whc0f.js → index-VAHDM_zL.js} +204 -204
  5. package/dist/{chunk-E422JDF5.js → chunk-H2DUOXTF.js} +2 -1
  6. package/dist/index.d.ts +19 -3
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +6498 -5813
  9. package/dist/index.js.map +1 -1
  10. package/dist/player.js +1 -1
  11. package/package.json +8 -8
  12. package/src/components/TimelineToolbar.test.tsx +79 -1
  13. package/src/components/TimelineToolbar.tsx +14 -29
  14. package/src/components/editor/DomEditOverlay.tsx +2 -2
  15. package/src/components/editor/PropertyPanel.tsx +1 -0
  16. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  17. package/src/components/editor/manualEdits.ts +1 -1
  18. package/src/components/editor/manualEditsParsing.ts +14 -2
  19. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  20. package/src/components/editor/propertyPanelFill.tsx +36 -13
  21. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  22. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  23. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  24. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  25. package/src/components/nle/NLEPreview.test.ts +44 -1
  26. package/src/components/nle/NLEPreview.tsx +7 -2
  27. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  28. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  29. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  30. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  31. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  32. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  33. package/src/hooks/useSdkSession.ts +4 -1
  34. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  35. package/src/hooks/useThumbnailStripSize.ts +23 -1
  36. package/src/index.ts +1 -0
  37. package/src/player/components/AudioWaveform.test.tsx +153 -1
  38. package/src/player/components/AudioWaveform.tsx +93 -37
  39. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  40. package/src/player/components/Timeline.test.ts +3 -1
  41. package/src/player/components/TimelineCanvas.tsx +4 -0
  42. package/src/player/components/TimelineClip.tsx +20 -6
  43. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  44. package/src/player/components/TimelineClipFades.tsx +4 -4
  45. package/src/player/components/TimelineClipJoins.tsx +23 -18
  46. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  47. package/src/player/components/TimelineGroupRow.tsx +1 -0
  48. package/src/player/components/TimelineLaneClip.tsx +127 -0
  49. package/src/player/components/TimelineLanes.test.tsx +51 -6
  50. package/src/player/components/TimelineLanes.tsx +166 -178
  51. package/src/player/components/TimelineRuler.tsx +2 -0
  52. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  53. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  54. package/src/player/components/VideoThumbnail.tsx +37 -9
  55. package/src/player/components/thumbnailUtils.test.ts +17 -3
  56. package/src/player/components/thumbnailUtils.ts +17 -1
  57. package/src/player/components/timelineLayout.test.ts +3 -3
  58. package/src/player/components/timelineMotion.test.ts +27 -0
  59. package/src/player/components/timelineMotion.ts +62 -0
  60. package/src/player/components/timelineZoomInput.test.ts +438 -0
  61. package/src/player/components/timelineZoomInput.ts +456 -0
  62. package/src/player/components/useTimelineGeometry.ts +0 -3
  63. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  64. package/src/player/components/useTimelinePlayhead.ts +53 -59
  65. package/src/player/components/useTimelineProviderState.tsx +16 -21
  66. package/src/player/components/useTimelineZoom.ts +7 -12
  67. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  68. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  69. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  70. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  71. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  72. /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
- pps: number;
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
- pps,
34
- scroll,
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, percent: 100 })({ pps: 200, percent: 200 }, true);
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, percent: 100 })({ pps: 250, percent: 250 }, true);
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, percent: 100 })({ pps: 200, percent: 200 }, true);
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, percent: 200 });
150
- update({ percent: 100 });
151
- update({ pps: 130, percent: 100 });
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, percent: 150 });
159
- update({ pps: 90, percent: 150 });
160
- update({ pps: 101, percent: 168 });
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
+ });