@hyperframes/studio 0.8.130 → 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-Cm1Cj3TU.js → hyperframes-player-Cp8e_Zst.js} +1 -1
- package/dist/assets/{index-DAp0QmWa.js → index-CASEBNy2.js} +1 -1
- package/dist/assets/{index--Z5JgizA.js → index-DMTt9nTf.js} +1 -1
- package/dist/assets/{index-DDsV6l9T.js → index-oE1pOdAy.js} +81 -81
- 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 +3 -0
- package/dist/index.html +1 -1
- package/dist/index.js +23 -12
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/hooks/usePersistentEditHistory.test.ts +25 -1
- package/src/hooks/usePersistentEditHistory.ts +4 -2
- 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/
|
|
64
|
-
"@hyperframes/player": "0.8.
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/shader-transitions": "0.8.
|
|
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",
|
|
@@ -62,7 +62,7 @@ async function studio({ withHistory = true } = {}) {
|
|
|
62
62
|
const file = () => readFileSync(join(dir, "index.html"), "utf8");
|
|
63
63
|
const save = (content: string) => writeFileSync(join(dir, "index.html"), content);
|
|
64
64
|
const readFile = async (path: string) => readFileSync(join(dir, path), "utf8");
|
|
65
|
-
return { dir, hook: () => hook, file, save, readFile };
|
|
65
|
+
return { dir, history, hook: () => hook, file, save, readFile };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
it("an edit Studio saved is undone and redone by the project's history, with the preview's before and after", async () => {
|
|
@@ -411,6 +411,30 @@ it("a key one page holds does not join another page's edit under the same key",
|
|
|
411
411
|
expect(file()).toBe("B");
|
|
412
412
|
});
|
|
413
413
|
|
|
414
|
+
it("a host's write claimed under claimKey joins Studio's save under the same key: one undo takes back both", async () => {
|
|
415
|
+
const { dir, history, hook, file, save, readFile } = await studio();
|
|
416
|
+
const key = "drop:1";
|
|
417
|
+
writeFileSync(join(dir, "clip.mp4"), "media");
|
|
418
|
+
await history.claim({ kind: "person", name: "You" }, "Dropped on timeline", ["clip.mp4"], {
|
|
419
|
+
coalesceKey: hook().claimKey(key),
|
|
420
|
+
idleMs: Infinity,
|
|
421
|
+
});
|
|
422
|
+
save("B");
|
|
423
|
+
await act(() =>
|
|
424
|
+
hook().recordEdit({
|
|
425
|
+
label: "Dropped on timeline",
|
|
426
|
+
coalesceKey: key,
|
|
427
|
+
coalesceMs: Infinity,
|
|
428
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
await act(() => hook().undo({ readFile }));
|
|
433
|
+
|
|
434
|
+
expect(file()).toBe("A");
|
|
435
|
+
expect(existsSync(join(dir, "clip.mp4"))).toBe(false);
|
|
436
|
+
});
|
|
437
|
+
|
|
414
438
|
it("an undo before the history view shows a drag's held claim still waits on the files it wrote", async () => {
|
|
415
439
|
const { hook, save, readFile } = await studio();
|
|
416
440
|
const server = globalThis.fetch;
|
|
@@ -209,6 +209,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
209
209
|
projectIdRef.current = projectId;
|
|
210
210
|
const [own] = useState(createOwnHistory);
|
|
211
211
|
const [pageKeyScope] = useState(generateId);
|
|
212
|
+
const claimKey = useCallback((key: string) => `${pageKeyScope}:${key}`, [pageKeyScope]);
|
|
212
213
|
|
|
213
214
|
const refresh = useCallback(async () => {
|
|
214
215
|
if (!projectId) return;
|
|
@@ -246,7 +247,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
246
247
|
paths,
|
|
247
248
|
overwrote: await overwroteVersions(files),
|
|
248
249
|
...(coalesceKey && {
|
|
249
|
-
coalesceKey:
|
|
250
|
+
coalesceKey: claimKey(coalesceKey),
|
|
250
251
|
idleMs: coalesceMs ?? DEFAULT_COALESCE_MS,
|
|
251
252
|
}),
|
|
252
253
|
});
|
|
@@ -258,7 +259,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
258
259
|
heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
|
|
259
260
|
await refresh();
|
|
260
261
|
},
|
|
261
|
-
[projectId, refresh, own,
|
|
262
|
+
[projectId, refresh, own, claimKey],
|
|
262
263
|
);
|
|
263
264
|
|
|
264
265
|
const step = useCallback(
|
|
@@ -341,6 +342,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
341
342
|
redoPaths: view.forward?.paths ?? [],
|
|
342
343
|
state,
|
|
343
344
|
recordEdit,
|
|
345
|
+
claimKey,
|
|
344
346
|
undo,
|
|
345
347
|
redo,
|
|
346
348
|
predict: own.predict,
|
|
@@ -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 });
|