@hyperframes/studio 0.8.139 → 0.8.141
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-5Vx2AiUz.js +473 -0
- package/dist/assets/{index-DsEkE5_G.js → index-BAnh7cSQ.js} +1 -1
- package/dist/assets/{index-Br1Whc0f.js → index-C0thd4eM.js} +204 -204
- package/dist/assets/{index-BmIPrb8I.js → index-P8fgj_Xa.js} +1 -1
- 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 +6518 -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 +60 -1
- package/src/components/nle/NLEPreview.tsx +9 -2
- package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +43 -0
- package/src/components/sidebar/CompositionsTab.tsx +17 -0
- 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/runtimeProtocol.ts +6 -0
- 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/assets/hyperframes-player-CckOoCJ8.js +0 -473
- /package/dist/{chunk-E422JDF5.js.map → chunk-H2DUOXTF.js.map} +0 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
5
|
+
import { isTimelineMoving, subscribeTimelineMotion } from "./timelineMotion";
|
|
6
|
+
import {
|
|
7
|
+
cancelTimelineZoom,
|
|
8
|
+
isTimelineZoomPreviewing,
|
|
9
|
+
subscribeTimelineZoomPreview,
|
|
10
|
+
redrawTimelineZoomPreview,
|
|
11
|
+
currentTimelineRange,
|
|
12
|
+
currentTimelineZoomPercent,
|
|
13
|
+
settleTimelineZoom,
|
|
14
|
+
timelineTimeAtX,
|
|
15
|
+
requestTimelineZoom,
|
|
16
|
+
registerTimelineZoomViewport,
|
|
17
|
+
takeTimelineZoomAnchor,
|
|
18
|
+
timelineZoomMapping,
|
|
19
|
+
zoomTimelineStep,
|
|
20
|
+
zoomTimelineToRange,
|
|
21
|
+
} from "./timelineZoomInput";
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.useFakeTimers({
|
|
25
|
+
toFake: [
|
|
26
|
+
"requestAnimationFrame",
|
|
27
|
+
"cancelAnimationFrame",
|
|
28
|
+
"setTimeout",
|
|
29
|
+
"clearTimeout",
|
|
30
|
+
"performance",
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
usePlayerStore.setState({
|
|
34
|
+
zoomMode: "fit",
|
|
35
|
+
manualZoomPercent: 100,
|
|
36
|
+
timelineFitPps: 10,
|
|
37
|
+
timelinePps: 10,
|
|
38
|
+
userZoomCount: 0,
|
|
39
|
+
currentTime: 0,
|
|
40
|
+
duration: 0,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
// Run out any ease, preview and rest, so the next test starts with nothing scheduled.
|
|
45
|
+
for (let i = 0; i < 40; i++) vi.advanceTimersToNextFrame();
|
|
46
|
+
unregisterViewport();
|
|
47
|
+
takeTimelineZoomAnchor();
|
|
48
|
+
vi.useRealTimers();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
let unregisterViewport = () => {};
|
|
52
|
+
|
|
53
|
+
/** A 1080px timeline viewport with 32px of track headers, holding one scaled row. */
|
|
54
|
+
function viewport(scrollLeft = 0, scrollWidth = 20_000) {
|
|
55
|
+
const scroll = document.createElement("div");
|
|
56
|
+
Object.defineProperties(scroll, {
|
|
57
|
+
clientWidth: { value: 1080 },
|
|
58
|
+
scrollWidth: { value: scrollWidth },
|
|
59
|
+
scrollLeft: { value: scrollLeft, writable: true },
|
|
60
|
+
});
|
|
61
|
+
const row = scroll.appendChild(document.createElement("div"));
|
|
62
|
+
row.setAttribute("data-timeline-zoom-scale", "");
|
|
63
|
+
unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32 });
|
|
64
|
+
return { scroll, row };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Where `time` lands on screen once the committed zoom is laid out. */
|
|
68
|
+
const laidOutX = (time: number) => {
|
|
69
|
+
const anchor = takeTimelineZoomAnchor();
|
|
70
|
+
return anchor && anchor.x + (time - anchor.time) * usePlayerStore.getState().timelinePps;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
describe("requestTimelineZoom", () => {
|
|
74
|
+
it("scales the drawn rows during a gesture and lays the zoom out once it rests", () => {
|
|
75
|
+
const { row } = viewport();
|
|
76
|
+
const writes = vi.fn();
|
|
77
|
+
const unsubscribe = usePlayerStore.subscribe(writes);
|
|
78
|
+
requestTimelineZoom(120, { time: 4, x: 100 });
|
|
79
|
+
requestTimelineZoom(150, { time: 4, x: 100 });
|
|
80
|
+
vi.advanceTimersToNextFrame();
|
|
81
|
+
expect(writes).not.toHaveBeenCalled();
|
|
82
|
+
expect(currentTimelineZoomPercent()).toBe(150);
|
|
83
|
+
// 4 s stays at 100px: 32 + 4 * 15 - left = 100, left = -8 clamps to 0, translated by 0.
|
|
84
|
+
expect(row.style.transform).toBe("translateX(0px) scaleX(1.5)");
|
|
85
|
+
vi.advanceTimersByTime(150);
|
|
86
|
+
unsubscribe();
|
|
87
|
+
expect(writes).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(usePlayerStore.getState()).toMatchObject({
|
|
89
|
+
zoomMode: "manual",
|
|
90
|
+
manualZoomPercent: 150,
|
|
91
|
+
timelinePps: 15,
|
|
92
|
+
userZoomCount: 1,
|
|
93
|
+
});
|
|
94
|
+
expect(row.style.transform).toBe("");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps the time under the pointer while previewing, then lays it out there", () => {
|
|
98
|
+
usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 400, timelinePps: 40 });
|
|
99
|
+
const { scroll, row } = viewport(400);
|
|
100
|
+
// 432px in: (400 + 432 - 32) / 40 = 20 s.
|
|
101
|
+
requestTimelineZoom(600, { time: 20, x: 432 });
|
|
102
|
+
vi.advanceTimersToNextFrame();
|
|
103
|
+
const mapping = timelineZoomMapping(40, 32);
|
|
104
|
+
expect(mapping.pps).toBe(60);
|
|
105
|
+
expect(mapping.contentOrigin + 20 * mapping.pps - scroll.scrollLeft).toBeCloseTo(432);
|
|
106
|
+
expect(row.style.transform).toContain("scaleX(1.5)");
|
|
107
|
+
vi.advanceTimersByTime(150);
|
|
108
|
+
expect(laidOutX(20)).toBeCloseTo(432);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("never previews a scroll the laid-out zoom cannot reach, zooming in from below Fit", () => {
|
|
112
|
+
usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 50, timelinePps: 5 });
|
|
113
|
+
viewport();
|
|
114
|
+
// 90 s sits 482px in at 5 px/s. At 110% the content is 32 + 1046 * 1.1 wide,
|
|
115
|
+
// so the view can scroll at most 102.6px.
|
|
116
|
+
requestTimelineZoom(110, { time: 90, x: 482 });
|
|
117
|
+
expect(timelineTimeAtX(32)! * 11).toBeLessThanOrEqual(102.6 + 1e-6);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("lays a zoom-out about the left edge out at once, before it shows unmounted time", () => {
|
|
121
|
+
usePlayerStore.setState({ duration: 1000 });
|
|
122
|
+
viewport();
|
|
123
|
+
// Mounted to (1080 - 32 + 540) / 10 = 158.8 s; at 5 px/s the view reaches 209.6 s.
|
|
124
|
+
requestTimelineZoom(50, { time: 0, x: 32 });
|
|
125
|
+
vi.advanceTimersToNextFrame();
|
|
126
|
+
expect(usePlayerStore.getState().timelinePps).toBe(5);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("previews a zoom-out about the middle that stays within what is mounted", () => {
|
|
130
|
+
usePlayerStore.setState({
|
|
131
|
+
duration: 100,
|
|
132
|
+
zoomMode: "manual",
|
|
133
|
+
manualZoomPercent: 1000,
|
|
134
|
+
timelinePps: 100,
|
|
135
|
+
});
|
|
136
|
+
viewport(5000);
|
|
137
|
+
// Mounted 44.28..65.88 s; at 60 px/s about 55.24 s the view shows 46.5..63.97 s.
|
|
138
|
+
requestTimelineZoom(600, { time: 55.24, x: 556 });
|
|
139
|
+
vi.advanceTimersToNextFrame();
|
|
140
|
+
expect(usePlayerStore.getState().timelinePps).toBe(100);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("lays out a zoom-out before it shows past the window ruler ticks are drawn in", () => {
|
|
144
|
+
// 50 s of clips in content 1996 s wide: ticks are drawn to 157 s, a view and a half in.
|
|
145
|
+
usePlayerStore.setState({ duration: 50 });
|
|
146
|
+
viewport();
|
|
147
|
+
requestTimelineZoom(60, { time: 0, x: 32 });
|
|
148
|
+
vi.advanceTimersToNextFrame();
|
|
149
|
+
expect(usePlayerStore.getState().timelinePps).toBe(6);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("lays a zoom-out out at once before it shows past where the ruler is drawn", () => {
|
|
153
|
+
// 50 s of clips at Fit: the ruler and lanes are drawn to the viewport's edge, 104.8 s.
|
|
154
|
+
usePlayerStore.setState({ duration: 50 });
|
|
155
|
+
viewport(0, 1080);
|
|
156
|
+
requestTimelineZoom(50, { time: 0, x: 32 });
|
|
157
|
+
vi.advanceTimersToNextFrame();
|
|
158
|
+
expect(usePlayerStore.getState().timelinePps).toBe(5);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("previews a zoom-out at the content's end without laying out each frame", () => {
|
|
162
|
+
// At 330% the content is 32 + 1046 * 3.3 = 3483.8px, reported whole as 3483; the view is
|
|
163
|
+
// scrolled to its end, and a zoom-out lands that end a fraction of a pixel past it.
|
|
164
|
+
usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 330, timelinePps: 33 });
|
|
165
|
+
viewport(2403, 3483);
|
|
166
|
+
requestTimelineZoom(300, { time: 100, x: 800 });
|
|
167
|
+
vi.advanceTimersToNextFrame();
|
|
168
|
+
expect(usePlayerStore.getState().timelinePps).toBe(33);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("keeps previewing through a scroll, and scales the rows the scroll mounted", async () => {
|
|
172
|
+
const { scroll } = viewport();
|
|
173
|
+
requestTimelineZoom(150);
|
|
174
|
+
vi.advanceTimersToNextFrame();
|
|
175
|
+
// React mounts the row after the scroll's redraw frame; it is scaled before it paints.
|
|
176
|
+
redrawTimelineZoomPreview();
|
|
177
|
+
vi.advanceTimersToNextFrame();
|
|
178
|
+
const mountedRow = document.createElement("div");
|
|
179
|
+
mountedRow.setAttribute("data-timeline-zoom-scale", "");
|
|
180
|
+
scroll.appendChild(document.createElement("div")).appendChild(mountedRow);
|
|
181
|
+
await Promise.resolve();
|
|
182
|
+
expect(usePlayerStore.getState().timelinePps).toBe(10);
|
|
183
|
+
expect(mountedRow.style.transform).toContain("scaleX(1.5)");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("lays a pending zoom out at once when settled, as a press does", () => {
|
|
187
|
+
viewport();
|
|
188
|
+
requestTimelineZoom(150);
|
|
189
|
+
settleTimelineZoom();
|
|
190
|
+
expect(usePlayerStore.getState().timelinePps).toBe(15);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("tells listeners when a pending zoom is dropped", () => {
|
|
194
|
+
viewport();
|
|
195
|
+
requestTimelineZoom(150);
|
|
196
|
+
vi.advanceTimersToNextFrame();
|
|
197
|
+
let shownAfterDrop: boolean | null = null;
|
|
198
|
+
const unsubscribe = subscribeTimelineZoomPreview(() => {
|
|
199
|
+
shownAfterDrop = isTimelineZoomPreviewing();
|
|
200
|
+
});
|
|
201
|
+
cancelTimelineZoom();
|
|
202
|
+
unsubscribe();
|
|
203
|
+
expect(shownAfterDrop).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("drops a pending zoom when cancelled, as Fit does", () => {
|
|
207
|
+
const { row } = viewport();
|
|
208
|
+
requestTimelineZoom(150);
|
|
209
|
+
vi.advanceTimersToNextFrame();
|
|
210
|
+
cancelTimelineZoom();
|
|
211
|
+
vi.advanceTimersByTime(300);
|
|
212
|
+
expect(usePlayerStore.getState().timelinePps).toBe(10);
|
|
213
|
+
expect(row.style.transform).toBe("");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("lays the zoom out before the timeline counts as at rest", () => {
|
|
217
|
+
viewport();
|
|
218
|
+
let ppsAtRest = 0;
|
|
219
|
+
const unsubscribe = subscribeTimelineMotion(() => {
|
|
220
|
+
if (!isTimelineMoving()) ppsAtRest = usePlayerStore.getState().timelinePps;
|
|
221
|
+
});
|
|
222
|
+
requestTimelineZoom(150);
|
|
223
|
+
vi.advanceTimersToNextFrame();
|
|
224
|
+
vi.advanceTimersByTime(200);
|
|
225
|
+
unsubscribe();
|
|
226
|
+
expect(ppsAtRest).toBe(15);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("keeps a pending zoom when the timeline registers again, as a re-render does", async () => {
|
|
230
|
+
const { scroll } = viewport();
|
|
231
|
+
requestTimelineZoom(150);
|
|
232
|
+
unregisterViewport();
|
|
233
|
+
unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32 });
|
|
234
|
+
await Promise.resolve();
|
|
235
|
+
vi.advanceTimersByTime(200);
|
|
236
|
+
expect(usePlayerStore.getState().timelinePps).toBe(15);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("keeps a newer timeline's registration when an older one unmounts", () => {
|
|
240
|
+
const unregisterOlder = registerTimelineZoomViewport({
|
|
241
|
+
scroll: document.createElement("div"),
|
|
242
|
+
contentOrigin: 32,
|
|
243
|
+
});
|
|
244
|
+
viewport();
|
|
245
|
+
unregisterOlder();
|
|
246
|
+
expect(currentTimelineRange()).not.toBeNull();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("lays the zoom out mid-gesture once the preview has scaled too far", () => {
|
|
250
|
+
viewport();
|
|
251
|
+
requestTimelineZoom(300);
|
|
252
|
+
vi.advanceTimersToNextFrame();
|
|
253
|
+
expect(usePlayerStore.getState().timelinePps).toBe(10);
|
|
254
|
+
requestTimelineZoom(500);
|
|
255
|
+
vi.advanceTimersToNextFrame();
|
|
256
|
+
expect(usePlayerStore.getState().timelinePps).toBe(50);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("zoomTimelineToRange", () => {
|
|
261
|
+
it("fills the width with the range and puts its start at the left margin", () => {
|
|
262
|
+
viewport();
|
|
263
|
+
void zoomTimelineToRange(40, 90, { smooth: false });
|
|
264
|
+
// Laid out on the next frame, never inside the caller's own render or effect.
|
|
265
|
+
expect(usePlayerStore.getState().timelinePps).toBe(10);
|
|
266
|
+
vi.advanceTimersToNextFrame();
|
|
267
|
+
// 1080 - (32 + 24) - 24 = 1000px for 50s is 20 pps.
|
|
268
|
+
expect(usePlayerStore.getState().timelinePps).toBeCloseTo(20);
|
|
269
|
+
expect(laidOutX(40)).toBeCloseTo(56);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("eases there over several frames and ends exactly on the range", () => {
|
|
273
|
+
viewport();
|
|
274
|
+
void zoomTimelineToRange(10, 20);
|
|
275
|
+
vi.advanceTimersToNextFrame();
|
|
276
|
+
vi.advanceTimersToNextFrame();
|
|
277
|
+
expect(currentTimelineZoomPercent()).toBeGreaterThan(100);
|
|
278
|
+
expect(currentTimelineZoomPercent()).toBeLessThan(1000);
|
|
279
|
+
for (let i = 0; i < 30; i++) vi.advanceTimersToNextFrame();
|
|
280
|
+
expect(usePlayerStore.getState().timelinePps).toBeCloseTo(100);
|
|
281
|
+
expect(laidOutX(10)).toBeCloseTo(56);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("stops easing when a person zooms during it, and says it was cancelled", async () => {
|
|
285
|
+
viewport();
|
|
286
|
+
const result = zoomTimelineToRange(10, 20);
|
|
287
|
+
vi.advanceTimersToNextFrame();
|
|
288
|
+
vi.advanceTimersToNextFrame();
|
|
289
|
+
requestTimelineZoom(150);
|
|
290
|
+
for (let i = 0; i < 30; i++) vi.advanceTimersToNextFrame();
|
|
291
|
+
expect(usePlayerStore.getState().manualZoomPercent).toBe(150);
|
|
292
|
+
await expect(result).resolves.toBe("cancelled");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("resolves done once the range is laid out, without counting as a person's zoom", async () => {
|
|
296
|
+
const { row } = viewport();
|
|
297
|
+
let settled: string | null = null;
|
|
298
|
+
void zoomTimelineToRange(10, 20).then((r) => (settled = r));
|
|
299
|
+
for (let i = 0; i < 30 && settled === null; i++) {
|
|
300
|
+
vi.advanceTimersToNextFrame();
|
|
301
|
+
await Promise.resolve();
|
|
302
|
+
}
|
|
303
|
+
expect(settled).toBe("done");
|
|
304
|
+
expect(usePlayerStore.getState().timelinePps).toBeCloseTo(100);
|
|
305
|
+
expect(usePlayerStore.getState().userZoomCount).toBe(0);
|
|
306
|
+
expect(row.style.transform).toBe("");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("leaves a newer zoom alone when an older one's signal aborts", async () => {
|
|
310
|
+
viewport();
|
|
311
|
+
const abort = new AbortController();
|
|
312
|
+
const older = zoomTimelineToRange(10, 20, { signal: abort.signal });
|
|
313
|
+
const newer = zoomTimelineToRange(30, 40);
|
|
314
|
+
abort.abort();
|
|
315
|
+
for (let i = 0; i < 30; i++) vi.advanceTimersToNextFrame();
|
|
316
|
+
await expect(older).resolves.toBe("cancelled");
|
|
317
|
+
await expect(newer).resolves.toBe("done");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("cancels when the caller aborts", async () => {
|
|
321
|
+
viewport();
|
|
322
|
+
const abort = new AbortController();
|
|
323
|
+
const result = zoomTimelineToRange(10, 20, { signal: abort.signal });
|
|
324
|
+
vi.advanceTimersToNextFrame();
|
|
325
|
+
abort.abort();
|
|
326
|
+
await expect(result).resolves.toBe("cancelled");
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("currentTimelineRange", () => {
|
|
331
|
+
it("reads back the range zoomTimelineToRange filled", () => {
|
|
332
|
+
viewport();
|
|
333
|
+
void zoomTimelineToRange(40, 90, { smooth: false });
|
|
334
|
+
vi.advanceTimersToNextFrame();
|
|
335
|
+
const range = currentTimelineRange()!;
|
|
336
|
+
expect(range.start).toBeCloseTo(40);
|
|
337
|
+
expect(range.end).toBeCloseTo(90);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("reads the range a gesture shows before it is laid out", () => {
|
|
341
|
+
viewport();
|
|
342
|
+
requestTimelineZoom(150, { time: 0, x: 32 });
|
|
343
|
+
const range = currentTimelineRange()!;
|
|
344
|
+
// 1000px between the margins at 15 px/s, from 24px in.
|
|
345
|
+
expect(range.start).toBeCloseTo(24 / 15);
|
|
346
|
+
expect(range.end - range.start).toBeCloseTo(1000 / 15);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("is null with no timeline mounted", () => {
|
|
350
|
+
expect(currentTimelineRange()).toBeNull();
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("zoomTimelineStep", () => {
|
|
355
|
+
const run = () => {
|
|
356
|
+
for (let i = 0; i < 30; i++) vi.advanceTimersToNextFrame();
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
it("counts as a person's zoom", () => {
|
|
360
|
+
viewport();
|
|
361
|
+
zoomTimelineStep("in");
|
|
362
|
+
run();
|
|
363
|
+
expect(usePlayerStore.getState().userZoomCount).toBeGreaterThan(0);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("doubles the scale and keeps an on-screen playhead where it is", () => {
|
|
367
|
+
usePlayerStore.setState({ currentTime: 40 });
|
|
368
|
+
viewport();
|
|
369
|
+
// At 10 pps the playhead sits at 32 + 400 = 432px.
|
|
370
|
+
zoomTimelineStep("in");
|
|
371
|
+
run();
|
|
372
|
+
expect(usePlayerStore.getState().timelinePps).toBeCloseTo(20);
|
|
373
|
+
expect(laidOutX(40)).toBeCloseTo(432);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("lays a zoom-out step out once, at its start, then eases by scaling", () => {
|
|
377
|
+
usePlayerStore.setState({
|
|
378
|
+
currentTime: 110,
|
|
379
|
+
duration: 1000,
|
|
380
|
+
zoomMode: "manual",
|
|
381
|
+
manualZoomPercent: 400,
|
|
382
|
+
timelinePps: 40,
|
|
383
|
+
});
|
|
384
|
+
// 100..126 s on screen at 40 px/s, the playhead at 110 s among it.
|
|
385
|
+
const { row } = viewport(4000);
|
|
386
|
+
const laidOut: number[] = [];
|
|
387
|
+
const unsubscribe = usePlayerStore.subscribe((s, prev) => {
|
|
388
|
+
if (s.timelinePps !== prev.timelinePps) laidOut.push(s.timelinePps);
|
|
389
|
+
});
|
|
390
|
+
zoomTimelineStep("out");
|
|
391
|
+
// Laid out in the first frame, never inside the caller (a host's effect, a click).
|
|
392
|
+
expect(laidOut).toEqual([]);
|
|
393
|
+
vi.advanceTimersToNextFrame();
|
|
394
|
+
expect(laidOut).toEqual([20]);
|
|
395
|
+
// Near the old view, drawn in the first frame by scaling the new layout up.
|
|
396
|
+
expect(Number(/scaleX\(([\d.]+)\)/.exec(row.style.transform)?.[1])).toBeGreaterThan(1.5);
|
|
397
|
+
run();
|
|
398
|
+
unsubscribe();
|
|
399
|
+
expect(laidOut).toEqual([20]);
|
|
400
|
+
// Counted once as the person's zoom: the end of the ease writes nothing more.
|
|
401
|
+
expect(usePlayerStore.getState().userZoomCount).toBe(1);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("eases a zoom-out that pans away without laying its target out first", () => {
|
|
405
|
+
// 100..126 s on screen at 40 px/s; the playhead at 0 s is off screen, so the new view
|
|
406
|
+
// (centred on it) does not hold the old one.
|
|
407
|
+
usePlayerStore.setState({
|
|
408
|
+
duration: 1000,
|
|
409
|
+
zoomMode: "manual",
|
|
410
|
+
manualZoomPercent: 400,
|
|
411
|
+
timelinePps: 40,
|
|
412
|
+
});
|
|
413
|
+
viewport(4000);
|
|
414
|
+
const laidOut: number[] = [];
|
|
415
|
+
const unsubscribe = usePlayerStore.subscribe((s, prev) => {
|
|
416
|
+
if (s.timelinePps !== prev.timelinePps) laidOut.push(s.timelinePps);
|
|
417
|
+
});
|
|
418
|
+
zoomTimelineStep("out");
|
|
419
|
+
vi.advanceTimersToNextFrame();
|
|
420
|
+
unsubscribe();
|
|
421
|
+
expect(laidOut[0]).not.toBe(20);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("centres an off-screen playhead", () => {
|
|
425
|
+
usePlayerStore.setState({
|
|
426
|
+
currentTime: 60,
|
|
427
|
+
zoomMode: "manual",
|
|
428
|
+
manualZoomPercent: 400,
|
|
429
|
+
timelinePps: 40,
|
|
430
|
+
});
|
|
431
|
+
// 0..26.2 s on screen at 40 px/s.
|
|
432
|
+
viewport();
|
|
433
|
+
zoomTimelineStep("in");
|
|
434
|
+
run();
|
|
435
|
+
// The range's middle, 56 + 1000 / 2, is the playhead.
|
|
436
|
+
expect(laidOutX(60)).toBeCloseTo(556);
|
|
437
|
+
});
|
|
438
|
+
});
|