@hyperframes/studio 0.8.73 → 0.8.75
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-WbBVbWuO.js +464 -0
- package/dist/assets/{index-DKInxDDv.js → index-BiFKcNjs.js} +1 -1
- package/dist/assets/{index-DxIHGgGZ.js → index-BlUYw1MB.js} +164 -164
- package/dist/assets/{index-CMFPPAGq.js → index-HjexPsny.js} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +3591 -3454
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/hooks/timelineEditingHelpers.ts +11 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +85 -2
- package/src/hooks/useTimelineDeleteOps.ts +8 -7
- package/src/player/components/PlayheadIndicator.tsx +4 -1
- package/src/player/components/Timeline.test.ts +1 -3
- package/src/player/components/Timeline.virtualization.test.tsx +21 -0
- package/src/player/components/TimelineCanvas.tsx +14 -12
- package/src/player/components/TimelinePlayheadLayer.tsx +33 -0
- package/src/player/components/TimelineRuler.test.tsx +98 -0
- package/src/player/components/TimelineRuler.tsx +66 -2
- package/src/player/components/TimelineRulerPart.tsx +1 -0
- package/src/player/components/useSettledScrollLeft.ts +38 -0
- package/src/player/components/useTimelineScrollViewport.ts +4 -1
- package/src/player/hooks/useShadowPreviewReload.ts +19 -1
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +85 -0
- package/src/player/hooks/useTimelinePlayer.ts +14 -3
- package/dist/assets/hyperframes-player-BUauoSsb.js +0 -464
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "./useTimelineSyncCallbacks";
|
|
15
15
|
import type { PlaybackAdapter } from "../lib/playbackTypes";
|
|
16
16
|
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
17
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
17
18
|
|
|
18
19
|
// One wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
|
|
19
20
|
// leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
|
|
@@ -35,6 +36,8 @@ type UseShadowPreviewReloadParams = Omit<
|
|
|
35
36
|
onPromoted?: () => void;
|
|
36
37
|
/** A shadow that never became ready was dropped; the live preview is unchanged. */
|
|
37
38
|
onReloadFailed?: (message: string) => void;
|
|
39
|
+
/** Puts the promoted document at the live frame's time, playing if the live frame was. */
|
|
40
|
+
handOverPlayback: (time: number, playing: boolean) => void;
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
export function useShadowPreviewReload({
|
|
@@ -51,6 +54,7 @@ export function useShadowPreviewReload({
|
|
|
51
54
|
applyPreviewAudioState,
|
|
52
55
|
onPromoted,
|
|
53
56
|
onReloadFailed,
|
|
57
|
+
handOverPlayback,
|
|
54
58
|
}: UseShadowPreviewReloadParams) {
|
|
55
59
|
const shadowIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
56
60
|
const shadowProbeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
|
@@ -62,6 +66,8 @@ export function useShadowPreviewReload({
|
|
|
62
66
|
onPromotedRef.current = onPromoted;
|
|
63
67
|
const onReloadFailedRef = useRef(onReloadFailed);
|
|
64
68
|
onReloadFailedRef.current = onReloadFailed;
|
|
69
|
+
const handOverPlaybackRef = useRef(handOverPlayback);
|
|
70
|
+
handOverPlaybackRef.current = handOverPlayback;
|
|
65
71
|
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
66
72
|
const budgetsSpentRef = useRef(0);
|
|
67
73
|
// The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
|
|
@@ -102,6 +108,11 @@ export function useShadowPreviewReload({
|
|
|
102
108
|
const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
|
|
103
109
|
if (!shadow || !pending || !ready || gen !== shadowGenRef.current) return;
|
|
104
110
|
stopPendingShadow();
|
|
111
|
+
// The live frame kept playing, stopped at the end or was seeked while the shadow loaded.
|
|
112
|
+
const live = getAdapter();
|
|
113
|
+
const liveTime = live?.getTime();
|
|
114
|
+
const playing = usePlayerStore.getState().isPlaying;
|
|
115
|
+
live?.pause();
|
|
105
116
|
// The store takes the new document's timeline only now that it is the one on screen.
|
|
106
117
|
pending.commit();
|
|
107
118
|
iframeRef.current = shadow;
|
|
@@ -109,10 +120,17 @@ export function useShadowPreviewReload({
|
|
|
109
120
|
attachIframeShortcutListeners();
|
|
110
121
|
applyPreviewAudioState();
|
|
111
122
|
setPreviewSlots((prev) => planShadowPromotion(prev, gen));
|
|
123
|
+
if (liveTime != null) handOverPlaybackRef.current(liveTime, playing);
|
|
112
124
|
onPromotedRef.current?.();
|
|
113
125
|
thumbnailScheduler.setPreviewReloading(false);
|
|
114
126
|
},
|
|
115
|
-
[
|
|
127
|
+
[
|
|
128
|
+
stopPendingShadow,
|
|
129
|
+
getAdapter,
|
|
130
|
+
iframeRef,
|
|
131
|
+
attachIframeShortcutListeners,
|
|
132
|
+
applyPreviewAudioState,
|
|
133
|
+
],
|
|
116
134
|
);
|
|
117
135
|
|
|
118
136
|
const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
|
|
@@ -224,6 +224,91 @@ describe("shadow reload readiness and failure", () => {
|
|
|
224
224
|
unmount(root);
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
+
it("keeps a playing film playing through a reload, from where the live frame had reached", () => {
|
|
228
|
+
const { getApi, root } = renderTimelinePlayerHarness();
|
|
229
|
+
const { adapter: liveAdapter, win } = makeAdapterWindow();
|
|
230
|
+
act(() => {
|
|
231
|
+
getApi().iframeRef.current = makeFakeIframe(win);
|
|
232
|
+
getApi().onIframeLoad();
|
|
233
|
+
});
|
|
234
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
235
|
+
act(() => getApi().play());
|
|
236
|
+
liveAdapter.pause.mockClear();
|
|
237
|
+
liveAdapter.seek(4.2);
|
|
238
|
+
|
|
239
|
+
act(() => getApi().refreshPlayer());
|
|
240
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
241
|
+
expect(liveAdapter.pause).not.toHaveBeenCalled();
|
|
242
|
+
|
|
243
|
+
liveAdapter.seek(5.5);
|
|
244
|
+
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
245
|
+
const shadow = makeShadowWithSpies();
|
|
246
|
+
act(() => {
|
|
247
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
248
|
+
getApi().onShadowIframeLoad(gen);
|
|
249
|
+
getApi().onShadowReadyChange(gen, true);
|
|
250
|
+
});
|
|
251
|
+
expect(getApi().iframeRef.current).toBe(shadow.iframe);
|
|
252
|
+
expect(liveAdapter.pause).toHaveBeenCalled();
|
|
253
|
+
expect(shadow.adapter.getTime()).toBe(5.5);
|
|
254
|
+
expect(shadow.adapter.isPlaying()).toBe(true);
|
|
255
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
256
|
+
unmount(root);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("keeps where a film was paused while the reload loaded", () => {
|
|
260
|
+
const { getApi, root } = renderTimelinePlayerHarness();
|
|
261
|
+
const { adapter: liveAdapter, win } = makeAdapterWindow();
|
|
262
|
+
act(() => {
|
|
263
|
+
getApi().iframeRef.current = makeFakeIframe(win);
|
|
264
|
+
getApi().onIframeLoad();
|
|
265
|
+
});
|
|
266
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
267
|
+
act(() => getApi().play());
|
|
268
|
+
liveAdapter.seek(4.2);
|
|
269
|
+
act(() => getApi().refreshPlayer());
|
|
270
|
+
|
|
271
|
+
liveAdapter.seek(7);
|
|
272
|
+
act(() => getApi().pause());
|
|
273
|
+
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
274
|
+
const shadow = makeShadowWithSpies();
|
|
275
|
+
act(() => {
|
|
276
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
277
|
+
getApi().onShadowIframeLoad(gen);
|
|
278
|
+
getApi().onShadowReadyChange(gen, true);
|
|
279
|
+
});
|
|
280
|
+
expect(getApi().iframeRef.current).toBe(shadow.iframe);
|
|
281
|
+
expect(shadow.adapter.getTime()).toBe(7);
|
|
282
|
+
expect(shadow.adapter.isPlaying()).toBe(false);
|
|
283
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
284
|
+
unmount(root);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("stops a playing film at the new end when the edit cut it short of the live time", () => {
|
|
288
|
+
const { getApi, root } = renderTimelinePlayerHarness();
|
|
289
|
+
const { adapter: liveAdapter, win } = makeAdapterWindow();
|
|
290
|
+
act(() => {
|
|
291
|
+
getApi().iframeRef.current = makeFakeIframe(win);
|
|
292
|
+
getApi().onIframeLoad();
|
|
293
|
+
});
|
|
294
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
295
|
+
act(() => getApi().play());
|
|
296
|
+
act(() => getApi().refreshPlayer());
|
|
297
|
+
|
|
298
|
+
liveAdapter.seek(12);
|
|
299
|
+
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
300
|
+
const shorter = makeAdapterWindow({ duration: 10 });
|
|
301
|
+
act(() => {
|
|
302
|
+
getApi().setShadowIframeNode(makeFakeIframe(shorter.win));
|
|
303
|
+
getApi().onShadowIframeLoad(gen);
|
|
304
|
+
getApi().onShadowReadyChange(gen, true);
|
|
305
|
+
});
|
|
306
|
+
expect(shorter.adapter.getTime()).toBe(10);
|
|
307
|
+
expect(shorter.adapter.isPlaying()).toBe(false);
|
|
308
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
309
|
+
unmount(root);
|
|
310
|
+
});
|
|
311
|
+
|
|
227
312
|
it("does not promote a shadow whose loader is still up, and promotes once it clears", () => {
|
|
228
313
|
const { getApi, live, gen, root } = beginReload();
|
|
229
314
|
const shadow = makeShadowWithSpies();
|
|
@@ -400,6 +400,13 @@ export function useTimelinePlayer({
|
|
|
400
400
|
applyPreviewAudioState,
|
|
401
401
|
onPromoted: onShadowPromoted,
|
|
402
402
|
onReloadFailed: onPreviewReloadFailed,
|
|
403
|
+
handOverPlayback: (time, playing) => {
|
|
404
|
+
// keepPlaying: move the playhead without the paused-seek audio scrub.
|
|
405
|
+
seek(time, { keepPlaying: true });
|
|
406
|
+
const adapter = getAdapter();
|
|
407
|
+
// An edit that cut the film short of the live time stops it at the new end, as playback does.
|
|
408
|
+
if (playing && adapter && adapter.getTime() < adapter.getDuration()) play();
|
|
409
|
+
},
|
|
403
410
|
});
|
|
404
411
|
|
|
405
412
|
const saveSeekPosition = useCallback(() => {
|
|
@@ -422,17 +429,21 @@ export function useTimelinePlayer({
|
|
|
422
429
|
}
|
|
423
430
|
}
|
|
424
431
|
isRefreshingRef.current = true;
|
|
425
|
-
|
|
432
|
+
// Forward playback runs on in the live frame; the promotion hands it to the new document.
|
|
433
|
+
const keepPlaying =
|
|
434
|
+
usePlayerStore.getState().isPlaying && shuttleDirectionRef.current !== "backward";
|
|
426
435
|
stopReverseLoop();
|
|
436
|
+
if (keepPlaying) return true;
|
|
437
|
+
stopRAFLoop();
|
|
427
438
|
setIsPlaying(false);
|
|
439
|
+
return false;
|
|
428
440
|
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
429
441
|
const refreshPlayer = useCallback(() => {
|
|
430
442
|
const iframe = iframeRef.current;
|
|
431
443
|
if (!iframe) return;
|
|
432
444
|
logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
|
|
433
|
-
saveSeekPosition();
|
|
434
445
|
// The old iframe is no longer navigated away, so stop its playback (and audio) here.
|
|
435
|
-
getAdapter()?.pause();
|
|
446
|
+
if (!saveSeekPosition()) getAdapter()?.pause();
|
|
436
447
|
// The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
|
|
437
448
|
const src = iframe.src;
|
|
438
449
|
const url = new URL(src, window.location.origin);
|