@hyperframes/studio 0.8.131 → 0.8.132
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-BpOOAOcm.js → hyperframes-player-Cp8e_Zst.js} +1 -1
- package/dist/assets/{index-DASBNWML.js → index-CASEBNy2.js} +1 -1
- package/dist/assets/{index-DbSXe5_2.js → index-DMTt9nTf.js} +1 -1
- package/dist/assets/{index-BEcHKkGY.js → index-oE1pOdAy.js} +55 -55
- package/dist/{chunk-DRVWEPO5.js → chunk-FOEID72M.js} +3 -1
- package/dist/{chunk-DRVWEPO5.js.map → chunk-FOEID72M.js.map} +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.html +1 -1
- package/dist/index.js +19 -10
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/player/hooks/usePlaybackKeyboard.ts +1 -0
- package/src/player/hooks/useTimelinePlayer.playLock.test.ts +137 -0
- package/src/player/hooks/useTimelinePlayer.ts +15 -6
- package/src/player/hooks/useTimelinePlayerLoop.ts +1 -1
- package/src/player/lib/playbackSeek.ts +2 -0
- package/src/player/store/readinessSlice.ts +4 -0
package/dist/player.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.132",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/studio-server": "0.8.
|
|
63
|
+
"@hyperframes/core": "0.8.132",
|
|
64
|
+
"@hyperframes/player": "0.8.132",
|
|
65
|
+
"@hyperframes/sdk": "0.8.132",
|
|
66
|
+
"@hyperframes/parsers": "0.8.132",
|
|
67
|
+
"@hyperframes/shader-transitions": "0.8.132",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.132"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.132"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -60,6 +60,7 @@ export function usePlaybackKeyboard({
|
|
|
60
60
|
|
|
61
61
|
const shuttle = useCallback(
|
|
62
62
|
(direction: "forward" | "backward") => {
|
|
63
|
+
if (usePlayerStore.getState().playLocked) return;
|
|
63
64
|
if (shuttleDirectionRef.current === direction) {
|
|
64
65
|
shuttleSpeedIndexRef.current = Math.min(
|
|
65
66
|
shuttleSpeedIndexRef.current + 1,
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
makeAdapterWindow,
|
|
7
|
+
makeFakeIframe,
|
|
8
|
+
renderTimelinePlayerHarness,
|
|
9
|
+
resetPlayerStore,
|
|
10
|
+
} from "./timelinePlayerTestHarness";
|
|
11
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
12
|
+
import { scrubMusicAtSeek } from "../lib/playbackScrub";
|
|
13
|
+
|
|
14
|
+
vi.mock("../lib/playbackScrub", async (importOriginal) => ({
|
|
15
|
+
...(await importOriginal<typeof import("../lib/playbackScrub")>()),
|
|
16
|
+
scrubMusicAtSeek: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
document.body.innerHTML = "";
|
|
23
|
+
resetPlayerStore();
|
|
24
|
+
usePlayerStore.setState({ playLocked: false, loopEnabled: false, playbackRate: 1 });
|
|
25
|
+
vi.mocked(scrubMusicAtSeek).mockClear();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function readyPlayer() {
|
|
29
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
30
|
+
const { adapter, win } = makeAdapterWindow();
|
|
31
|
+
act(() => {
|
|
32
|
+
api.iframeRef.current = makeFakeIframe(win);
|
|
33
|
+
api.onIframeLoad();
|
|
34
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
35
|
+
});
|
|
36
|
+
return { api, root, adapter };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const press = (key: string) =>
|
|
40
|
+
act(() => {
|
|
41
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key, code: `Key${key.toUpperCase()}` }));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("starts nothing while the host holds playback: Play, a playback request or play backward", () => {
|
|
45
|
+
const { api, root, adapter } = readyPlayer();
|
|
46
|
+
act(() => usePlayerStore.getState().setPlayLocked(true));
|
|
47
|
+
act(() => api.play());
|
|
48
|
+
act(() => usePlayerStore.getState().requestPlayback(true));
|
|
49
|
+
press("j");
|
|
50
|
+
expect(adapter.play).not.toHaveBeenCalled();
|
|
51
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
52
|
+
act(() => root.unmount());
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("J and L keep the chosen speed while the host holds playback", () => {
|
|
56
|
+
const { root } = readyPlayer();
|
|
57
|
+
act(() => usePlayerStore.getState().setPlaybackRate(0.5));
|
|
58
|
+
act(() => usePlayerStore.getState().setPlayLocked(true));
|
|
59
|
+
press("l");
|
|
60
|
+
press("j");
|
|
61
|
+
expect(usePlayerStore.getState().playbackRate).toBe(0.5);
|
|
62
|
+
act(() => root.unmount());
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("a looping film reaching its end while held stops instead of starting again", () => {
|
|
66
|
+
const frames: FrameRequestCallback[] = [];
|
|
67
|
+
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation((tick) => {
|
|
68
|
+
frames.push(tick);
|
|
69
|
+
return frames.length;
|
|
70
|
+
});
|
|
71
|
+
const { api, root, adapter } = readyPlayer();
|
|
72
|
+
act(() => usePlayerStore.setState({ loopEnabled: true }));
|
|
73
|
+
act(() => api.play());
|
|
74
|
+
// Held with the store already reading stopped, so nothing pauses the frame loop first.
|
|
75
|
+
act(() => usePlayerStore.setState({ playLocked: true, isPlaying: false }));
|
|
76
|
+
adapter.seek(30);
|
|
77
|
+
act(() => frames.at(-1)!(0));
|
|
78
|
+
expect(adapter.play).toHaveBeenCalledTimes(1);
|
|
79
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
80
|
+
raf.mockRestore();
|
|
81
|
+
act(() => root.unmount());
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("turning the hold on before the preview has a player still clears playing", () => {
|
|
85
|
+
const { root } = renderTimelinePlayerHarness();
|
|
86
|
+
act(() => usePlayerStore.setState({ isPlaying: true }));
|
|
87
|
+
act(() => usePlayerStore.getState().setPlayLocked(true));
|
|
88
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
89
|
+
act(() => root.unmount());
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("a seek that would keep playing does not resume while the host holds playback", () => {
|
|
93
|
+
const { api, root, adapter } = readyPlayer();
|
|
94
|
+
act(() => usePlayerStore.getState().setPlayLocked(true));
|
|
95
|
+
// The store still reads playing (a host that set the hold before the player caught up): only the hold stops it.
|
|
96
|
+
act(() => usePlayerStore.setState({ isPlaying: true }));
|
|
97
|
+
act(() => {
|
|
98
|
+
api.seek(5, { keepPlaying: true });
|
|
99
|
+
});
|
|
100
|
+
expect(adapter.play).not.toHaveBeenCalled();
|
|
101
|
+
act(() => root.unmount());
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("a seek while the host holds playback moves the picture without scrub audio", () => {
|
|
105
|
+
const { api, root, adapter } = readyPlayer();
|
|
106
|
+
act(() => {
|
|
107
|
+
api.seek(2);
|
|
108
|
+
});
|
|
109
|
+
expect(scrubMusicAtSeek).toHaveBeenCalledTimes(1);
|
|
110
|
+
act(() => usePlayerStore.getState().setPlayLocked(true));
|
|
111
|
+
act(() => {
|
|
112
|
+
api.seek(4);
|
|
113
|
+
});
|
|
114
|
+
expect(adapter.getTime()).toBe(4);
|
|
115
|
+
expect(scrubMusicAtSeek).toHaveBeenCalledTimes(1);
|
|
116
|
+
act(() => root.unmount());
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("a timeline reset keeps the hold: the host owns it", () => {
|
|
120
|
+
act(() => usePlayerStore.getState().setPlayLocked(true));
|
|
121
|
+
act(() => usePlayerStore.getState().reset());
|
|
122
|
+
expect(usePlayerStore.getState().playLocked).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("turning the hold on pauses a playing film; turning it off lets Play start again", () => {
|
|
126
|
+
const { api, root, adapter } = readyPlayer();
|
|
127
|
+
act(() => api.play());
|
|
128
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
129
|
+
act(() => usePlayerStore.getState().setPlayLocked(true));
|
|
130
|
+
expect(adapter.pause).toHaveBeenCalled();
|
|
131
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
132
|
+
act(() => usePlayerStore.getState().setPlayLocked(false));
|
|
133
|
+
act(() => api.play());
|
|
134
|
+
expect(adapter.play).toHaveBeenCalledTimes(2);
|
|
135
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
136
|
+
act(() => root.unmount());
|
|
137
|
+
});
|
|
@@ -200,7 +200,8 @@ export function useTimelinePlayer({
|
|
|
200
200
|
applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
|
|
201
201
|
}, []);
|
|
202
202
|
const play = useCallback(() => {
|
|
203
|
-
|
|
203
|
+
const { timelineReady, playLocked } = usePlayerStore.getState();
|
|
204
|
+
if (!timelineReady || playLocked) return;
|
|
204
205
|
stopRAFLoop();
|
|
205
206
|
stopReverseLoop();
|
|
206
207
|
stopScrubPreviewAudio();
|
|
@@ -286,9 +287,8 @@ export function useTimelinePlayer({
|
|
|
286
287
|
const pause = useCallback(() => {
|
|
287
288
|
stopReverseLoop();
|
|
288
289
|
const adapter = getAdapter();
|
|
289
|
-
|
|
290
|
-
adapter.
|
|
291
|
-
setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
|
|
290
|
+
adapter?.pause();
|
|
291
|
+
if (adapter) setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
|
|
292
292
|
setIsPlaying(false);
|
|
293
293
|
shuttleDirectionRef.current = null;
|
|
294
294
|
shuttleSpeedIndexRef.current = 0;
|
|
@@ -306,17 +306,19 @@ export function useTimelinePlayer({
|
|
|
306
306
|
const duration = Math.max(0, adapter.getDuration());
|
|
307
307
|
const nextTime = clampToDuration(time, duration);
|
|
308
308
|
const keepPlaying = options?.keepPlaying === true;
|
|
309
|
+
const { playLocked, isPlaying: storeWasPlaying } = usePlayerStore.getState();
|
|
309
310
|
const shouldResumeAfterSeek = shouldResumeForwardPlaybackAfterSeek({
|
|
310
311
|
keepPlaying,
|
|
311
312
|
wasReverseShuttle,
|
|
312
|
-
storeWasPlaying
|
|
313
|
+
storeWasPlaying,
|
|
313
314
|
duration,
|
|
314
315
|
nextTime,
|
|
316
|
+
playLocked,
|
|
315
317
|
});
|
|
316
318
|
adapter.seek(nextTime, options);
|
|
317
319
|
publishSeek(nextTime, options); // Direct DOM updates (playhead, timecode, progress) — no re-render
|
|
318
320
|
setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
|
|
319
|
-
if (!
|
|
321
|
+
if (!keepPlaying && !playLocked) scrubMusicAtSeek(iframeRef.current, nextTime);
|
|
320
322
|
if (shouldResumeAfterSeek) {
|
|
321
323
|
stopRAFLoop();
|
|
322
324
|
applyPlaybackRate(usePlayerStore.getState().playbackRate);
|
|
@@ -349,6 +351,13 @@ export function useTimelinePlayer({
|
|
|
349
351
|
],
|
|
350
352
|
);
|
|
351
353
|
|
|
354
|
+
useEffect(
|
|
355
|
+
() =>
|
|
356
|
+
usePlayerStore.subscribe((state, prev) => {
|
|
357
|
+
if (state.playLocked && !prev.playLocked && state.isPlaying) pause();
|
|
358
|
+
}),
|
|
359
|
+
[pause],
|
|
360
|
+
);
|
|
352
361
|
useEffect(() => {
|
|
353
362
|
return usePlayerStore.subscribe((state, prev) => {
|
|
354
363
|
if (state.requestedSeekTime !== null && state.requestedSeekTime !== prev.requestedSeekTime) {
|
|
@@ -60,7 +60,7 @@ export function useTimelinePlayerLoop({
|
|
|
60
60
|
const looping = usePlayerStore.getState().loopEnabled && dur > 0;
|
|
61
61
|
setLoopStart(looping ? loopStart : null);
|
|
62
62
|
if (time >= loopEnd) {
|
|
63
|
-
if (looping) {
|
|
63
|
+
if (looping && !usePlayerStore.getState().playLocked) {
|
|
64
64
|
// keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
|
|
65
65
|
adapter.seek(loopStart, { keepPlaying: true });
|
|
66
66
|
liveTime.notify(loopStart);
|
|
@@ -4,8 +4,10 @@ export function shouldResumeForwardPlaybackAfterSeek(input: {
|
|
|
4
4
|
storeWasPlaying: boolean;
|
|
5
5
|
duration: number;
|
|
6
6
|
nextTime: number;
|
|
7
|
+
playLocked: boolean;
|
|
7
8
|
}): boolean {
|
|
8
9
|
return (
|
|
10
|
+
!input.playLocked &&
|
|
9
11
|
input.keepPlaying &&
|
|
10
12
|
!input.wasReverseShuttle &&
|
|
11
13
|
input.storeWasPlaying &&
|
|
@@ -13,6 +13,8 @@ export interface PlaybackReadinessSlice {
|
|
|
13
13
|
previewBooted: boolean;
|
|
14
14
|
setTimelineReady: (ready: boolean) => void;
|
|
15
15
|
markPreviewBooted: () => void;
|
|
16
|
+
playLocked: boolean;
|
|
17
|
+
setPlayLocked: (locked: boolean) => void;
|
|
16
18
|
/** Sets timelineReady once doc's readiness inputs settle, or immediately
|
|
17
19
|
* if doc is null. A wait a later call supersedes never wins the race.
|
|
18
20
|
* Waits for doc's load step first: it pauses and rewinds the preview, so a
|
|
@@ -47,6 +49,8 @@ export function createPlaybackReadinessSlice(
|
|
|
47
49
|
timelineReady: false,
|
|
48
50
|
previewBooted: false,
|
|
49
51
|
markPreviewBooted: () => set({ previewBooted: true }),
|
|
52
|
+
playLocked: false,
|
|
53
|
+
setPlayLocked: (locked) => set({ playLocked: locked }),
|
|
50
54
|
setTimelineReady: (ready) => {
|
|
51
55
|
timelineReadyGeneration++;
|
|
52
56
|
set({ timelineReady: ready });
|