@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
|
@@ -2,6 +2,17 @@ import { useRef, useCallback, useEffect, useLayoutEffect } from "react";
|
|
|
2
2
|
import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
4
|
import { getPinchTimelineZoomPercent } from "./timelineZoom";
|
|
5
|
+
import {
|
|
6
|
+
currentTimelineZoomPercent,
|
|
7
|
+
requestTimelineZoom,
|
|
8
|
+
registerTimelineZoomViewport,
|
|
9
|
+
redrawTimelineZoomPreview,
|
|
10
|
+
settleTimelineZoom,
|
|
11
|
+
subscribeTimelineZoomPreview,
|
|
12
|
+
takeTimelineZoomAnchor,
|
|
13
|
+
timelineTimeAtX,
|
|
14
|
+
timelineZoomMapping,
|
|
15
|
+
} from "./timelineZoomInput";
|
|
5
16
|
import {
|
|
6
17
|
getTimelinePlaybackFollowScrollLeft,
|
|
7
18
|
getTimelineScrubTime,
|
|
@@ -36,17 +47,13 @@ interface UseTimelinePlayheadInput {
|
|
|
36
47
|
isDragging: React.RefObject<boolean>;
|
|
37
48
|
currentTime: number;
|
|
38
49
|
zoomMode: ZoomMode;
|
|
39
|
-
manualZoomPercent: number;
|
|
40
50
|
zoomModeRef: React.RefObject<ZoomMode>;
|
|
41
|
-
manualZoomPercentRef: React.RefObject<number>;
|
|
42
51
|
fitPps: number;
|
|
43
52
|
fitPpsRef: React.RefObject<number>;
|
|
44
53
|
effectiveDuration: number;
|
|
45
54
|
pps: number;
|
|
46
55
|
timelineReady: boolean;
|
|
47
56
|
elementsLength: number;
|
|
48
|
-
setZoomMode: (mode: ZoomMode) => void;
|
|
49
|
-
setManualZoomPercent: (percent: number) => void;
|
|
50
57
|
onSeek?: (time: number) => void;
|
|
51
58
|
contentOrigin: number;
|
|
52
59
|
}
|
|
@@ -60,28 +67,23 @@ export function useTimelinePlayhead({
|
|
|
60
67
|
currentTime,
|
|
61
68
|
zoomMode,
|
|
62
69
|
zoomModeRef,
|
|
63
|
-
manualZoomPercentRef,
|
|
64
70
|
fitPps: _fitPps,
|
|
65
71
|
fitPpsRef,
|
|
66
72
|
effectiveDuration,
|
|
67
73
|
pps,
|
|
68
74
|
timelineReady,
|
|
69
75
|
elementsLength,
|
|
70
|
-
setZoomMode,
|
|
71
|
-
setManualZoomPercent,
|
|
72
76
|
onSeek,
|
|
73
77
|
contentOrigin,
|
|
74
78
|
}: UseTimelinePlayheadInput) {
|
|
75
79
|
const dragScrollRaf = useRef(0);
|
|
76
80
|
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
|
|
77
|
-
// A
|
|
78
|
-
// The pinch handler anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`.
|
|
81
|
+
// A zoom keeps its anchor (pinch: pointer, else playhead) in place; a resize keeps the centre.
|
|
79
82
|
const previousAnchorPpsRef = useRef(pps);
|
|
80
83
|
const userZoomCount = usePlayerStore((s) => s.userZoomCount);
|
|
81
84
|
const previousZoomCountRef = useRef(userZoomCount);
|
|
82
85
|
const lastLiveTimeRef = useRef(usePlayerStore.getState().currentTime);
|
|
83
86
|
const lastSeekCountRef = useRef(liveTime.seekCount());
|
|
84
|
-
const skipCenterAnchorRef = useRef(false);
|
|
85
87
|
const contentOriginRef = useRef(contentOrigin);
|
|
86
88
|
contentOriginRef.current = contentOrigin;
|
|
87
89
|
|
|
@@ -91,12 +93,15 @@ export function useTimelinePlayhead({
|
|
|
91
93
|
previousAnchorPpsRef.current = pps;
|
|
92
94
|
const prevZoomCount = previousZoomCountRef.current;
|
|
93
95
|
previousZoomCountRef.current = userZoomCount;
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
// Consumed even when pps didn't change (at the clamp), so it never lingers for a later zoom.
|
|
97
|
+
const anchor = takeTimelineZoomAnchor();
|
|
98
|
+
if (!scroll || pps === prevPps) return;
|
|
99
|
+
if (anchor) {
|
|
100
|
+
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
101
|
+
const left = anchor.time * pps + contentOrigin - anchor.x;
|
|
102
|
+
scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, left));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
100
105
|
const zoomed = userZoomCount !== prevZoomCount;
|
|
101
106
|
if (!zoomed && scroll.scrollLeft < 1) return;
|
|
102
107
|
const time = Math.max(0, lastLiveTimeRef.current);
|
|
@@ -121,10 +126,11 @@ export function useTimelinePlayhead({
|
|
|
121
126
|
const syncPlayheadPosition = useCallback(
|
|
122
127
|
(time: number) => {
|
|
123
128
|
if (!playheadRef.current || durationRef.current <= 0) return;
|
|
129
|
+
const at = timelineZoomMapping(ppsRef.current, contentOrigin);
|
|
124
130
|
playheadRef.current.style.transform = getTimelinePlayheadTransform(
|
|
125
131
|
time,
|
|
126
|
-
|
|
127
|
-
contentOrigin,
|
|
132
|
+
at.pps,
|
|
133
|
+
at.contentOrigin,
|
|
128
134
|
!usePlayerStore.getState().isPlaying,
|
|
129
135
|
);
|
|
130
136
|
},
|
|
@@ -158,14 +164,18 @@ export function useTimelinePlayhead({
|
|
|
158
164
|
useMountEffect(() => {
|
|
159
165
|
const place = (t: number, atRest: boolean) => {
|
|
160
166
|
if (!playheadRef.current || durationRef.current <= 0) return false;
|
|
167
|
+
const at = timelineZoomMapping(ppsRef.current, contentOriginRef.current);
|
|
161
168
|
playheadRef.current.style.transform = getTimelinePlayheadTransform(
|
|
162
169
|
t,
|
|
163
|
-
|
|
164
|
-
|
|
170
|
+
at.pps,
|
|
171
|
+
at.contentOrigin,
|
|
165
172
|
atRest,
|
|
166
173
|
);
|
|
167
174
|
return true;
|
|
168
175
|
};
|
|
176
|
+
const unsubPreview = subscribeTimelineZoomPreview(() =>
|
|
177
|
+
place(lastLiveTimeRef.current, !usePlayerStore.getState().isPlaying),
|
|
178
|
+
);
|
|
169
179
|
const dragging = () => isDragging.current || usePlayerStore.getState().beatDragging;
|
|
170
180
|
const unsubPlaying = usePlayerStore.subscribe((state, prev) => {
|
|
171
181
|
if (prev.isPlaying && !state.isPlaying) place(lastLiveTimeRef.current, true);
|
|
@@ -177,7 +187,8 @@ export function useTimelinePlayhead({
|
|
|
177
187
|
lastLiveTimeRef.current = t;
|
|
178
188
|
const playing = usePlayerStore.getState().isPlaying;
|
|
179
189
|
if (!place(t, !playing)) return;
|
|
180
|
-
const
|
|
190
|
+
const at = timelineZoomMapping(ppsRef.current, contentOriginRef.current);
|
|
191
|
+
const playheadX = at.contentOrigin + Math.max(0, t) * at.pps;
|
|
181
192
|
const scroll = scrollRef.current;
|
|
182
193
|
// Paused, only a seek scrolls: a reload's republish, frame-rounded, must not undo a person's scroll.
|
|
183
194
|
if (!scroll || dragging() || zoomModeRef.current === "fit" || (!playing && !sought)) return;
|
|
@@ -197,6 +208,7 @@ export function useTimelinePlayhead({
|
|
|
197
208
|
return () => {
|
|
198
209
|
unsub();
|
|
199
210
|
unsubPlaying();
|
|
211
|
+
unsubPreview();
|
|
200
212
|
};
|
|
201
213
|
});
|
|
202
214
|
|
|
@@ -237,62 +249,44 @@ export function useTimelinePlayhead({
|
|
|
237
249
|
[scrollRef, isDragging, zoomModeRef, seekFromX],
|
|
238
250
|
);
|
|
239
251
|
|
|
252
|
+
// Trackpad pinch arrives as ctrl+wheel; Cmd+wheel zooms too, as Mac editors do.
|
|
240
253
|
const handlePinchWheel = useCallback(
|
|
241
254
|
(e: WheelEvent) => {
|
|
242
|
-
if (!e.ctrlKey) return;
|
|
255
|
+
if (!e.ctrlKey && !e.metaKey) return;
|
|
243
256
|
const scroll = scrollRef.current;
|
|
244
257
|
if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0)
|
|
245
258
|
return;
|
|
246
259
|
e.preventDefault();
|
|
247
260
|
e.stopPropagation();
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
261
|
+
const x = e.clientX - scroll.getBoundingClientRect().left;
|
|
262
|
+
requestTimelineZoom(
|
|
263
|
+
getPinchTimelineZoomPercent(
|
|
264
|
+
e.deltaY,
|
|
265
|
+
"manual",
|
|
266
|
+
currentTimelineZoomPercent(),
|
|
267
|
+
fitPpsRef.current,
|
|
268
|
+
),
|
|
269
|
+
{ time: Math.max(0, timelineTimeAtX(x) ?? 0), x },
|
|
254
270
|
);
|
|
255
|
-
if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual")
|
|
256
|
-
return;
|
|
257
|
-
const nextPps = fitPpsRef.current * (nextZoomPercent / 100);
|
|
258
|
-
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
|
259
|
-
pointerX: e.clientX - rect.left,
|
|
260
|
-
currentScrollLeft: scroll.scrollLeft,
|
|
261
|
-
contentOrigin,
|
|
262
|
-
currentPixelsPerSecond: ppsRef.current,
|
|
263
|
-
nextPixelsPerSecond: nextPps,
|
|
264
|
-
duration: durationRef.current,
|
|
265
|
-
});
|
|
266
|
-
// Pinch anchors at the cursor (below), so skip the center-anchor effect.
|
|
267
|
-
skipCenterAnchorRef.current = true;
|
|
268
|
-
setZoomMode("manual");
|
|
269
|
-
setManualZoomPercent(nextZoomPercent);
|
|
270
|
-
requestAnimationFrame(() => {
|
|
271
|
-
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
272
|
-
scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft);
|
|
273
|
-
});
|
|
274
271
|
},
|
|
275
|
-
[
|
|
276
|
-
scrollRef,
|
|
277
|
-
durationRef,
|
|
278
|
-
fitPpsRef,
|
|
279
|
-
ppsRef,
|
|
280
|
-
zoomModeRef,
|
|
281
|
-
manualZoomPercentRef,
|
|
282
|
-
setManualZoomPercent,
|
|
283
|
-
setZoomMode,
|
|
284
|
-
contentOrigin,
|
|
285
|
-
],
|
|
272
|
+
[scrollRef, durationRef, fitPpsRef, ppsRef],
|
|
286
273
|
);
|
|
287
274
|
|
|
288
275
|
useEffect(() => {
|
|
289
276
|
const scroll = scrollRef.current;
|
|
290
277
|
if (!scroll) return;
|
|
291
278
|
scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
|
|
279
|
+
// A press meets the zoom it sees, not the one still waiting to be laid out.
|
|
280
|
+
scroll.addEventListener("pointerdown", settleTimelineZoom, { capture: true });
|
|
281
|
+
scroll.addEventListener("scroll", redrawTimelineZoomPreview, { passive: true });
|
|
282
|
+
const unregisterZoomViewport = registerTimelineZoomViewport({ scroll, contentOrigin });
|
|
292
283
|
return () => {
|
|
293
284
|
scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
|
|
285
|
+
scroll.removeEventListener("pointerdown", settleTimelineZoom, { capture: true });
|
|
286
|
+
scroll.removeEventListener("scroll", redrawTimelineZoomPreview);
|
|
287
|
+
unregisterZoomViewport();
|
|
294
288
|
};
|
|
295
|
-
}, [handlePinchWheel, scrollRef, timelineReady, elementsLength]);
|
|
289
|
+
}, [handlePinchWheel, scrollRef, timelineReady, elementsLength, contentOrigin]);
|
|
296
290
|
|
|
297
291
|
return { seekFromX, autoScrollDuringDrag, dragScrollRaf };
|
|
298
292
|
}
|
|
@@ -131,7 +131,7 @@ export function useTimelineProviderState({
|
|
|
131
131
|
const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
|
132
132
|
const sessionEpoch = sessionEpochProp ?? timelineSessionEpoch;
|
|
133
133
|
const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment);
|
|
134
|
-
const { zoomMode, manualZoomPercent
|
|
134
|
+
const { zoomMode, manualZoomPercent } = useTimelineZoom();
|
|
135
135
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
136
136
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
137
137
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
@@ -258,22 +258,21 @@ export function useTimelineProviderState({
|
|
|
258
258
|
timelineElements.length,
|
|
259
259
|
displayLayout.totalH,
|
|
260
260
|
]);
|
|
261
|
-
const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
});
|
|
261
|
+
const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef } = useTimelineGeometry({
|
|
262
|
+
viewportWidth: viewport.clientWidth,
|
|
263
|
+
effectiveDuration,
|
|
264
|
+
zoomMode,
|
|
265
|
+
manualZoomPercent,
|
|
266
|
+
ppsRef,
|
|
267
|
+
fitPpsRef,
|
|
268
|
+
draggedClip,
|
|
269
|
+
resizingClip,
|
|
270
|
+
expandedElements: timelineElements,
|
|
271
|
+
isDragging,
|
|
272
|
+
scrollRef,
|
|
273
|
+
lastScrollLeftRef,
|
|
274
|
+
contentOrigin,
|
|
275
|
+
});
|
|
277
276
|
const timelineFocus = useTimelineLogicalFocus({
|
|
278
277
|
scrollRef,
|
|
279
278
|
tracks,
|
|
@@ -342,17 +341,13 @@ export function useTimelineProviderState({
|
|
|
342
341
|
isDragging,
|
|
343
342
|
currentTime,
|
|
344
343
|
zoomMode,
|
|
345
|
-
manualZoomPercent,
|
|
346
344
|
zoomModeRef,
|
|
347
|
-
manualZoomPercentRef,
|
|
348
345
|
fitPps,
|
|
349
346
|
fitPpsRef,
|
|
350
347
|
effectiveDuration,
|
|
351
348
|
pps,
|
|
352
349
|
timelineReady,
|
|
353
350
|
elementsLength: timelineElements.length,
|
|
354
|
-
setZoomMode,
|
|
355
|
-
setManualZoomPercent,
|
|
356
351
|
onSeek,
|
|
357
352
|
contentOrigin,
|
|
358
353
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// fallow-ignore-file dead-code
|
|
2
|
-
import { useCallback } from "react";
|
|
3
2
|
import { usePlayerStore, type ZoomMode } from "../store/playerStore";
|
|
3
|
+
import { cancelTimelineZoom, requestTimelineZoom } from "./timelineZoomInput";
|
|
4
4
|
|
|
5
5
|
export interface TimelineZoomState {
|
|
6
6
|
zoomMode: ZoomMode;
|
|
@@ -9,18 +9,13 @@ export interface TimelineZoomState {
|
|
|
9
9
|
setManualZoomPercent: (percent: number) => void;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/** Shared zoom-related store selectors used by Timeline and TimelineToolbar. */
|
|
13
12
|
export function useTimelineZoom(): TimelineZoomState {
|
|
14
13
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
15
14
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
},
|
|
23
|
-
[setStorePercent],
|
|
24
|
-
);
|
|
25
|
-
return { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent };
|
|
15
|
+
const setStoreZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
16
|
+
const setZoomMode = (mode: ZoomMode) => {
|
|
17
|
+
cancelTimelineZoom();
|
|
18
|
+
setStoreZoomMode(mode);
|
|
19
|
+
};
|
|
20
|
+
return { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent: requestTimelineZoom };
|
|
26
21
|
}
|
|
@@ -39,10 +39,20 @@ function recordDecodes(decoded: number[][]): void {
|
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
const endOf = (start: number, duration: number) => start + Math.max(0, duration - 0.001);
|
|
43
|
+
|
|
44
|
+
// Decoded frames are shared per source for the module's life, so each test decodes its own file.
|
|
45
|
+
let sources = 0;
|
|
46
|
+
let source = "";
|
|
47
|
+
|
|
42
48
|
beforeEach(() => {
|
|
49
|
+
source = `/clip-${++sources}.mp4`;
|
|
43
50
|
vi.clearAllMocks();
|
|
44
51
|
getKeyPacket.mockImplementation(async () => null);
|
|
45
|
-
vi.spyOn(URL, "createObjectURL")
|
|
52
|
+
vi.spyOn(URL, "createObjectURL")
|
|
53
|
+
.mockReturnValueOnce("blob:one")
|
|
54
|
+
.mockReturnValueOnce("blob:two")
|
|
55
|
+
.mockReturnValueOnce("blob:end");
|
|
46
56
|
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
|
47
57
|
HTMLCanvasElement.prototype.toBlob = function toBlob(callback) {
|
|
48
58
|
callback(new Blob(["frame"], { type: "image/jpeg" }));
|
|
@@ -55,9 +65,15 @@ beforeEach(() => {
|
|
|
55
65
|
});
|
|
56
66
|
|
|
57
67
|
describe("videoThumbnailTimestamps", () => {
|
|
58
|
-
it("uses the midpoint for a poster and
|
|
68
|
+
it("uses the midpoint for a poster, and each slice's left edge then the end for a strip", () => {
|
|
59
69
|
expect(videoThumbnailTimestamps(2, 6, 1)).toEqual([5]);
|
|
60
|
-
expect(videoThumbnailTimestamps(2, 6, 4)).toEqual([2,
|
|
70
|
+
expect(videoThumbnailTimestamps(2, 6, 4)).toEqual([2, 3.5, 5, 6.5, endOf(2, 6)]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("nests each strip in the strip twice as long, and the poster in every strip", () => {
|
|
74
|
+
const strip = videoThumbnailTimestamps(0, 8, 4);
|
|
75
|
+
expect(videoThumbnailTimestamps(0, 8, 8).filter((_, i) => i % 2 === 0)).toEqual(strip);
|
|
76
|
+
expect(strip).toContain(videoThumbnailTimestamps(0, 8, 1)[0]);
|
|
61
77
|
});
|
|
62
78
|
|
|
63
79
|
it("clamps invalid source ranges", () => {
|
|
@@ -71,31 +87,31 @@ describe("decodeVideoThumbnail", () => {
|
|
|
71
87
|
const decoded: number[][] = [];
|
|
72
88
|
recordDecodes(decoded);
|
|
73
89
|
const result = await decodeVideoThumbnail(
|
|
74
|
-
{ source
|
|
90
|
+
{ source, sourceStart: 2, sourceRangeDuration: 6, frameCount: 2 },
|
|
75
91
|
new AbortController().signal,
|
|
76
92
|
);
|
|
77
93
|
|
|
78
|
-
expect(decoded).toEqual([[2,
|
|
94
|
+
expect(decoded).toEqual([[2, 5, endOf(2, 6)]]);
|
|
79
95
|
expect(result.value).toEqual({
|
|
80
96
|
kind: "filmstrip",
|
|
81
|
-
urls: ["blob:one", "blob:two"],
|
|
97
|
+
urls: ["blob:one", "blob:two", "blob:end"],
|
|
82
98
|
aspect: 9 / 16,
|
|
83
99
|
});
|
|
84
100
|
result.dispose?.();
|
|
85
101
|
result.dispose?.();
|
|
86
|
-
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(
|
|
102
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(3);
|
|
87
103
|
expect(dispose).toHaveBeenCalledTimes(1);
|
|
88
104
|
});
|
|
89
105
|
|
|
90
106
|
it("decodes each strip frame at its keyframe unless that keyframe is before the clip's range", async () => {
|
|
91
|
-
getKeyPacket.mockImplementation(async (time) => ({ timestamp:
|
|
107
|
+
getKeyPacket.mockImplementation(async (time) => ({ timestamp: time - 0.5 }));
|
|
92
108
|
const decoded: number[][] = [];
|
|
93
109
|
recordDecodes(decoded);
|
|
94
110
|
await decodeVideoThumbnail(
|
|
95
|
-
{ source
|
|
111
|
+
{ source, sourceStart: 2, sourceRangeDuration: 8, frameCount: 4 },
|
|
96
112
|
new AbortController().signal,
|
|
97
113
|
);
|
|
98
|
-
expect(decoded).toEqual([[2,
|
|
114
|
+
expect(decoded).toEqual([[2, 3.5, 5.5, 7.5, endOf(2, 8)]]);
|
|
99
115
|
});
|
|
100
116
|
|
|
101
117
|
it("keeps a slot's own time when its keyframe is more than half a slot earlier", async () => {
|
|
@@ -103,14 +119,10 @@ describe("decodeVideoThumbnail", () => {
|
|
|
103
119
|
const decoded: number[][] = [];
|
|
104
120
|
recordDecodes(decoded);
|
|
105
121
|
await decodeVideoThumbnail(
|
|
106
|
-
{ source
|
|
107
|
-
new AbortController().signal,
|
|
108
|
-
);
|
|
109
|
-
await decodeVideoThumbnail(
|
|
110
|
-
{ source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 1 },
|
|
122
|
+
{ source, sourceStart: 0, sourceRangeDuration: 10, frameCount: 4 },
|
|
111
123
|
new AbortController().signal,
|
|
112
124
|
);
|
|
113
|
-
expect(decoded).toEqual([[0, 5,
|
|
125
|
+
expect(decoded).toEqual([[0, 2.5, 5, 7.5, endOf(0, 10)]]);
|
|
114
126
|
});
|
|
115
127
|
|
|
116
128
|
it("looks up each keyframe just before decoding it", async () => {
|
|
@@ -126,10 +138,11 @@ describe("decodeVideoThumbnail", () => {
|
|
|
126
138
|
}
|
|
127
139
|
});
|
|
128
140
|
await decodeVideoThumbnail(
|
|
129
|
-
{ source
|
|
141
|
+
{ source, sourceStart: 0, sourceRangeDuration: 10, frameCount: 2 },
|
|
130
142
|
new AbortController().signal,
|
|
131
143
|
);
|
|
132
|
-
|
|
144
|
+
const end = endOf(0, 10);
|
|
145
|
+
expect(events).toEqual(["key 0", "frame 0", "key 5", "frame 5", `key ${end}`, `frame ${end}`]);
|
|
133
146
|
});
|
|
134
147
|
|
|
135
148
|
it("ends the decode times without throwing when cancelled during a keyframe lookup", async () => {
|
|
@@ -146,7 +159,7 @@ describe("decodeVideoThumbnail", () => {
|
|
|
146
159
|
});
|
|
147
160
|
await expect(
|
|
148
161
|
decodeVideoThumbnail(
|
|
149
|
-
{ source
|
|
162
|
+
{ source, sourceStart: 0, sourceRangeDuration: 10, frameCount: 3 },
|
|
150
163
|
controller.signal,
|
|
151
164
|
),
|
|
152
165
|
).rejects.toThrow("Aborted");
|
|
@@ -164,10 +177,198 @@ describe("decodeVideoThumbnail", () => {
|
|
|
164
177
|
const decoded: number[][] = [];
|
|
165
178
|
recordDecodes(decoded);
|
|
166
179
|
await decodeVideoThumbnail(
|
|
167
|
-
{ source
|
|
180
|
+
{ source, sourceStart: 5, sourceRangeDuration: 5, frameCount: 2 },
|
|
168
181
|
new AbortController().signal,
|
|
169
182
|
);
|
|
170
|
-
expect(decoded).toEqual([[5, 7.5,
|
|
183
|
+
expect(decoded).toEqual([[5, 7.5, endOf(5, 5)]]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("reuses the frames a strip of the same source shows, so a zoom decodes only new times", async () => {
|
|
187
|
+
vi.mocked(URL.createObjectURL).mockImplementation(() => `blob:${Math.random()}`);
|
|
188
|
+
const decoded: number[][] = [];
|
|
189
|
+
recordDecodes(decoded);
|
|
190
|
+
const range = { source, sourceStart: 0, sourceRangeDuration: 8 };
|
|
191
|
+
const signal = new AbortController().signal;
|
|
192
|
+
const four = await decodeVideoThumbnail({ ...range, frameCount: 4 }, signal);
|
|
193
|
+
// Twice the frames decodes only the new half; half the frames decodes nothing. All share the end.
|
|
194
|
+
const eight = await decodeVideoThumbnail({ ...range, frameCount: 8 }, signal);
|
|
195
|
+
const two = await decodeVideoThumbnail({ ...range, frameCount: 2 }, signal);
|
|
196
|
+
expect(decoded).toEqual([
|
|
197
|
+
[0, 2, 4, 6, endOf(0, 8)],
|
|
198
|
+
[1, 3, 5, 7],
|
|
199
|
+
]);
|
|
200
|
+
const urls = (result: typeof four) =>
|
|
201
|
+
result.value.kind === "filmstrip" ? result.value.urls : [];
|
|
202
|
+
expect(urls(eight).filter((_, i) => i % 2 === 0)).toEqual(urls(four));
|
|
203
|
+
expect(urls(two)).toEqual([urls(four)[0], urls(four)[2], urls(four)[4]]);
|
|
204
|
+
for (const result of [four, eight, two]) result.dispose?.();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("decodes a frame again when the one shared for its time sits too far before it", async () => {
|
|
208
|
+
// Every keyframe is 2 s before the time asked: a 2-frame strip of 8 s accepts that (its
|
|
209
|
+
// frames stand for 4 s each), a 4-frame strip does not, or its frames would run backwards.
|
|
210
|
+
getKeyPacket.mockImplementation(async (time) => ({ timestamp: time - 2 }));
|
|
211
|
+
const decoded: number[][] = [];
|
|
212
|
+
recordDecodes(decoded);
|
|
213
|
+
const range = { source, sourceStart: 0, sourceRangeDuration: 8 };
|
|
214
|
+
const signal = new AbortController().signal;
|
|
215
|
+
const two = await decodeVideoThumbnail({ ...range, frameCount: 2 }, signal);
|
|
216
|
+
const four = await decodeVideoThumbnail({ ...range, frameCount: 4 }, signal);
|
|
217
|
+
expect(decoded).toEqual([
|
|
218
|
+
[0, 2, endOf(0, 8)],
|
|
219
|
+
[2, 4, 6],
|
|
220
|
+
]);
|
|
221
|
+
two.dispose?.();
|
|
222
|
+
four.dispose?.();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("returns a strip whose frames are all shared without opening the file again", async () => {
|
|
226
|
+
recordDecodes([]);
|
|
227
|
+
const range = { source, sourceStart: 0, sourceRangeDuration: 8 };
|
|
228
|
+
const signal = new AbortController().signal;
|
|
229
|
+
const four = await decodeVideoThumbnail({ ...range, frameCount: 4 }, signal);
|
|
230
|
+
const two = await decodeVideoThumbnail({ ...range, frameCount: 2 }, signal);
|
|
231
|
+
expect(input.getPrimaryVideoTrack).toHaveBeenCalledTimes(1);
|
|
232
|
+
four.dispose?.();
|
|
233
|
+
two.dispose?.();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("reopens the same asset after a project round trip and keeps new-session frames separate", async () => {
|
|
237
|
+
let frame = 0;
|
|
238
|
+
vi.mocked(URL.createObjectURL)
|
|
239
|
+
.mockReset()
|
|
240
|
+
.mockImplementation(() => `blob:version-${++frame}`);
|
|
241
|
+
const decoded: number[][] = [];
|
|
242
|
+
recordDecodes(decoded);
|
|
243
|
+
const signal = new AbortController().signal;
|
|
244
|
+
const original = await decodeVideoThumbnail(
|
|
245
|
+
{ source, contentVersion: "A:1", frameCount: 1 },
|
|
246
|
+
signal,
|
|
247
|
+
);
|
|
248
|
+
const other = await decodeVideoThumbnail(
|
|
249
|
+
{ source: `${source}?B`, contentVersion: "B:2", frameCount: 1 },
|
|
250
|
+
signal,
|
|
251
|
+
);
|
|
252
|
+
input.getPrimaryVideoTrack.mockResolvedValue({
|
|
253
|
+
getDisplayWidth: vi.fn(async () => 1280),
|
|
254
|
+
getDisplayHeight: vi.fn(async () => 360),
|
|
255
|
+
getDurationFromMetadata: vi.fn(async () => 10),
|
|
256
|
+
});
|
|
257
|
+
const reopened = await decodeVideoThumbnail(
|
|
258
|
+
{ source, contentVersion: "A:3", frameCount: 1 },
|
|
259
|
+
signal,
|
|
260
|
+
);
|
|
261
|
+
const shared = await decodeVideoThumbnail(
|
|
262
|
+
{ source, contentVersion: "A:3", frameCount: 1 },
|
|
263
|
+
signal,
|
|
264
|
+
);
|
|
265
|
+
expect(reopened.value).toEqual({ kind: "image", url: "blob:version-3", aspect: 32 / 9 });
|
|
266
|
+
expect(shared.value).toEqual(reopened.value);
|
|
267
|
+
expect(decoded).toEqual([[5], [5], [5]]);
|
|
268
|
+
expect(input.getPrimaryVideoTrack).toHaveBeenCalledTimes(3);
|
|
269
|
+
original.dispose?.();
|
|
270
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:version-1");
|
|
271
|
+
expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:version-3");
|
|
272
|
+
other.dispose?.();
|
|
273
|
+
reopened.dispose?.();
|
|
274
|
+
expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:version-3");
|
|
275
|
+
shared.dispose?.();
|
|
276
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(3);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("keeps one copy of a frame two strips decode at once, and revokes the other", async () => {
|
|
280
|
+
vi.mocked(URL.createObjectURL).mockImplementation(() => `blob:${Math.random()}`);
|
|
281
|
+
// The first strip holds its first frame until the second is decoding too.
|
|
282
|
+
let release!: () => void;
|
|
283
|
+
const bothDecoding = new Promise<void>((resolve) => (release = resolve));
|
|
284
|
+
canvasesAtTimestamps.mockImplementation(async function* (timestamps: AsyncIterable<number>) {
|
|
285
|
+
const first = canvasesAtTimestamps.mock.calls.length === 1;
|
|
286
|
+
for await (const _time of timestamps) {
|
|
287
|
+
if (first) await bothDecoding;
|
|
288
|
+
else release();
|
|
289
|
+
yield { canvas: document.createElement("canvas") };
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
const range = { source, sourceStart: 0, sourceRangeDuration: 8 };
|
|
293
|
+
const signal = new AbortController().signal;
|
|
294
|
+
const decodingTwo = decodeVideoThumbnail({ ...range, frameCount: 2 }, signal);
|
|
295
|
+
await vi.waitFor(() => expect(canvasesAtTimestamps).toHaveBeenCalledOnce());
|
|
296
|
+
const [two, four] = await Promise.all([
|
|
297
|
+
decodingTwo,
|
|
298
|
+
decodeVideoThumbnail({ ...range, frameCount: 4 }, signal),
|
|
299
|
+
]);
|
|
300
|
+
// Both decoded the frames at 0 s, 4 s and the end; the second copy of each goes at once.
|
|
301
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(3);
|
|
302
|
+
two.dispose?.();
|
|
303
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(3);
|
|
304
|
+
four.dispose?.();
|
|
305
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(8);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("fills a frame that failed to decode from the one before it, so tiles stay on their times", async () => {
|
|
309
|
+
canvasesAtTimestamps.mockImplementation(async function* (timestamps: AsyncIterable<number>) {
|
|
310
|
+
let slot = 0;
|
|
311
|
+
for await (const _time of timestamps) {
|
|
312
|
+
yield slot++ === 2 ? null : { canvas: document.createElement("canvas") };
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
const signal = new AbortController().signal;
|
|
316
|
+
const result = await decodeVideoThumbnail(
|
|
317
|
+
{ source, sourceStart: 0, sourceRangeDuration: 8, frameCount: 2 },
|
|
318
|
+
signal,
|
|
319
|
+
);
|
|
320
|
+
expect(result.value).toMatchObject({
|
|
321
|
+
kind: "filmstrip",
|
|
322
|
+
urls: ["blob:one", "blob:two", "blob:two"],
|
|
323
|
+
});
|
|
324
|
+
// The cache is charged for the two decoded frames, not the filled copy.
|
|
325
|
+
recordDecodes([]);
|
|
326
|
+
const whole = await decodeVideoThumbnail(
|
|
327
|
+
{ source: `${source}?whole`, sourceStart: 0, sourceRangeDuration: 8, frameCount: 2 },
|
|
328
|
+
signal,
|
|
329
|
+
);
|
|
330
|
+
expect(result.weight * 3).toBe(whole.weight * 2);
|
|
331
|
+
result.dispose?.();
|
|
332
|
+
whole.dispose?.();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("snaps a poster up to a quarter of its range and a strip frame up to half a slice", async () => {
|
|
336
|
+
const decoded: number[][] = [];
|
|
337
|
+
recordDecodes(decoded);
|
|
338
|
+
const signal = new AbortController().signal;
|
|
339
|
+
getKeyPacket.mockImplementation(async (time) => ({ timestamp: time - 2.4 }));
|
|
340
|
+
await decodeVideoThumbnail(
|
|
341
|
+
{ source, sourceStart: 0, sourceRangeDuration: 10, frameCount: 1 },
|
|
342
|
+
signal,
|
|
343
|
+
);
|
|
344
|
+
getKeyPacket.mockImplementation(async (time) => ({ timestamp: time - 2.6 }));
|
|
345
|
+
await decodeVideoThumbnail(
|
|
346
|
+
{ source: `${source}?b`, sourceStart: 0, sourceRangeDuration: 10, frameCount: 1 },
|
|
347
|
+
signal,
|
|
348
|
+
);
|
|
349
|
+
// Four slices of 8 s: a keyframe 0.9 s early snaps, one 1.1 s early does not.
|
|
350
|
+
getKeyPacket.mockImplementation(async (time) => ({
|
|
351
|
+
timestamp: time - (time === 4 ? 0.9 : 1.1),
|
|
352
|
+
}));
|
|
353
|
+
await decodeVideoThumbnail(
|
|
354
|
+
{ source: `${source}?c`, sourceStart: 0, sourceRangeDuration: 8, frameCount: 4 },
|
|
355
|
+
signal,
|
|
356
|
+
);
|
|
357
|
+
expect(decoded).toEqual([[5 - 2.4], [5], [0, 2, 4 - 0.9, 6, endOf(0, 8)]]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("revokes a shared frame only once no strip shows it", async () => {
|
|
361
|
+
vi.mocked(URL.createObjectURL).mockImplementation(() => `blob:${Math.random()}`);
|
|
362
|
+
recordDecodes([]);
|
|
363
|
+
const range = { source, sourceStart: 0, sourceRangeDuration: 8 };
|
|
364
|
+
const signal = new AbortController().signal;
|
|
365
|
+
const four = await decodeVideoThumbnail({ ...range, frameCount: 4 }, signal);
|
|
366
|
+
const two = await decodeVideoThumbnail({ ...range, frameCount: 2 }, signal);
|
|
367
|
+
four.dispose?.();
|
|
368
|
+
// The three frames the 2-slice strip still shows stay; the other two go.
|
|
369
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);
|
|
370
|
+
two.dispose?.();
|
|
371
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(5);
|
|
171
372
|
});
|
|
172
373
|
|
|
173
374
|
it("releases input and degrades when the source has no video track", async () => {
|
|
@@ -181,13 +382,15 @@ describe("decodeVideoThumbnail", () => {
|
|
|
181
382
|
it("revokes partial results when cancellation lands during extraction", async () => {
|
|
182
383
|
const controller = new AbortController();
|
|
183
384
|
const canvas = document.createElement("canvas");
|
|
184
|
-
canvasesAtTimestamps.mockImplementation(async function* () {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
385
|
+
canvasesAtTimestamps.mockImplementation(async function* (timestamps: AsyncIterable<number>) {
|
|
386
|
+
let frames = 0;
|
|
387
|
+
for await (const timestamp of timestamps) {
|
|
388
|
+
if (frames++ === 1) controller.abort();
|
|
389
|
+
yield { canvas, timestamp, duration: 1 };
|
|
390
|
+
}
|
|
188
391
|
});
|
|
189
392
|
await expect(
|
|
190
|
-
decodeVideoThumbnail({ source
|
|
393
|
+
decodeVideoThumbnail({ source, frameCount: 2 }, controller.signal),
|
|
191
394
|
).rejects.toMatchObject({ name: "AbortError" });
|
|
192
395
|
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
193
396
|
expect(dispose).toHaveBeenCalledTimes(1);
|
|
@@ -207,10 +410,7 @@ describe("decodeVideoThumbnail", () => {
|
|
|
207
410
|
getDurationFromMetadata,
|
|
208
411
|
});
|
|
209
412
|
|
|
210
|
-
const decoding = decodeVideoThumbnail(
|
|
211
|
-
{ source: "/clip.mp4", frameCount: 1 },
|
|
212
|
-
controller.signal,
|
|
213
|
-
);
|
|
413
|
+
const decoding = decodeVideoThumbnail({ source, frameCount: 1 }, controller.signal);
|
|
214
414
|
await vi.waitFor(() => expect(getDisplayWidth).toHaveBeenCalledOnce());
|
|
215
415
|
controller.abort();
|
|
216
416
|
resolveWidth(1080);
|