@hyperframes/studio 0.8.86 → 0.8.88
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-w3Bbv0kd.js +473 -0
- package/dist/assets/{index-Cz3E_ZSj.js → index-B6PGJTzp.js} +1 -1
- package/dist/assets/{index-d5DKK15Q.js → index-DE6cZYR5.js} +199 -199
- package/dist/assets/{index-8Fgryj7q.js → index-DldknZPp.js} +1 -1
- package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
- package/dist/{chunk-7PLTC6SV.js → chunk-KU4F7QZ3.js} +9 -1
- package/dist/chunk-KU4F7QZ3.js.map +1 -0
- package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
- package/dist/chunk-PQFEGZWM.js.map +1 -0
- package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
- package/dist/index.d.ts +206 -160
- package/dist/index.html +2 -2
- package/dist/index.js +2121 -1702
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/TimelineHistoryButtons.dom.test.tsx +14 -3
- package/src/components/TimelineToolPicker.tsx +90 -0
- package/src/components/TimelineToolbar.tsx +2 -27
- package/src/components/editor/DomEditOverlay.tsx +1 -2
- package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
- package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
- package/src/components/editor/LayersPanel.test.ts +14 -0
- package/src/components/editor/LayersPanel.tsx +2 -2
- package/src/components/editor/TopologyLens.tsx +2 -2
- package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
- package/src/components/editor/domEditOverlayGestures.ts +0 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
- package/src/components/editor/domEditing.test.ts +29 -0
- package/src/components/editor/domEditingDom.ts +1 -0
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/marqueeCommit.test.tsx +84 -0
- package/src/components/editor/marqueeCommit.ts +13 -1
- package/src/components/editor/useColorGradingController.test.ts +24 -0
- package/src/components/editor/useColorGradingController.ts +3 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
- package/src/components/nle/useCompositionStack.test.tsx +53 -0
- package/src/components/nle/useCompositionStack.ts +4 -8
- package/src/hooks/appHotkeysDispatch.ts +8 -0
- package/src/hooks/domStyleCommit.ts +129 -0
- package/src/hooks/gsapEditOutcome.ts +2 -2
- package/src/hooks/gsapElementMatch.ts +38 -0
- package/src/hooks/gsapResizeIntercept.ts +10 -31
- package/src/hooks/gsapResizePreflight.ts +56 -0
- package/src/hooks/gsapRuntimeBridge.ts +66 -26
- package/src/hooks/keyframeCacheAstLoad.ts +4 -1
- package/src/hooks/useAppHotkeys.test.ts +58 -1
- package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
- package/src/hooks/useDomEditCommits.ts +17 -182
- package/src/hooks/useDomEditPersist.ts +225 -0
- package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
- package/src/hooks/useDomEditPreviewSync.ts +2 -4
- package/src/hooks/useDomEditSession.test.tsx +51 -1
- package/src/hooks/useDomEditSession.ts +11 -2
- package/src/hooks/useDomEditTextCommits.ts +15 -99
- package/src/hooks/useDomStyleCommit.test.tsx +155 -0
- package/src/hooks/useDomStyleCommit.ts +74 -0
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
- package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
- package/src/hooks/useGsapAwareEditing.ts +1 -1
- package/src/hooks/useGsapTweenCache.ts +2 -48
- package/src/index.ts +10 -1
- package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
- package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
- package/src/player/components/Timeline.playheadRemount.test.tsx +3 -27
- package/src/player/components/Timeline.showKeyframes.test.tsx +91 -0
- package/src/player/components/TimelineGroupHeader.tsx +21 -10
- package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
- package/src/player/components/TimelineGroupRow.test.tsx +24 -33
- package/src/player/components/TimelineGroupRow.tsx +4 -1
- package/src/player/components/TimelineLanes.tsx +5 -5
- package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
- package/src/player/components/TimelineTrackHeader.tsx +6 -3
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineLaneProps.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +29 -0
- package/src/player/components/useTimelineKeyframeData.ts +14 -0
- package/src/player/components/useTimelineLabelColumn.ts +18 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
- package/src/player/components/useTimelineProviderState.tsx +10 -14
- package/src/player/components/useTimelineRangeSelection.ts +13 -12
- package/src/player/hooks/useShadowPreviewReload.ts +2 -0
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
- package/src/player/hooks/useTimelinePlayer.ts +29 -7
- package/src/player/lib/runtimeProtocol.test.ts +1 -0
- package/src/player/sceneSwap.test.ts +95 -0
- package/src/player/sceneSwap.ts +53 -0
- package/src/player/store/playerStore.ts +16 -0
- package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
- package/dist/chunk-3IOCVWB6.js.map +0 -1
- package/dist/chunk-7PLTC6SV.js.map +0 -1
- /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
|
@@ -140,6 +140,7 @@ export function useShadowPreviewReload({
|
|
|
140
140
|
|
|
141
141
|
const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
|
|
142
142
|
const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
|
|
143
|
+
const previewGeneration = useCallback(() => shadowGenRef.current, []);
|
|
143
144
|
const markAdapterReady = useCallback(
|
|
144
145
|
(iframe: HTMLIFrameElement | null, gen: number | undefined, commit: () => void) => {
|
|
145
146
|
if (gen == null || gen !== shadowGenRef.current) return;
|
|
@@ -261,5 +262,6 @@ export function useShadowPreviewReload({
|
|
|
261
262
|
setShadowIframeNode,
|
|
262
263
|
beginShadowReload,
|
|
263
264
|
resetPreviewSlots,
|
|
265
|
+
previewGeneration,
|
|
264
266
|
};
|
|
265
267
|
}
|
|
@@ -649,3 +649,194 @@ function stubVisibility(initial: DocumentVisibilityState) {
|
|
|
649
649
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
650
650
|
};
|
|
651
651
|
}
|
|
652
|
+
|
|
653
|
+
describe("useTimelinePlayer scene swap", () => {
|
|
654
|
+
function liveFilm(swap?: (html: string) => Promise<void>) {
|
|
655
|
+
const { adapter, win } = makeAdapterWindow();
|
|
656
|
+
const iframe = makeFakeIframe(swap ? { ...win, __hfSwapScenes: swap } : win);
|
|
657
|
+
iframe.src = "http://localhost/api/projects/demo/preview";
|
|
658
|
+
const harness = renderTimelinePlayerHarness();
|
|
659
|
+
act(() => {
|
|
660
|
+
harness.getApi().iframeRef.current = iframe;
|
|
661
|
+
harness.getApi().onIframeLoad();
|
|
662
|
+
});
|
|
663
|
+
return { ...harness, adapter };
|
|
664
|
+
}
|
|
665
|
+
function playingFilm(swap: (html: string) => Promise<void>) {
|
|
666
|
+
const film = liveFilm(swap);
|
|
667
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
668
|
+
act(() => film.getApi().play());
|
|
669
|
+
film.adapter.pause.mockClear();
|
|
670
|
+
return film;
|
|
671
|
+
}
|
|
672
|
+
const roles = (api: ReturnType<ReturnType<typeof liveFilm>["getApi"]>) =>
|
|
673
|
+
api.previewSlots.map((slot) => slot.role);
|
|
674
|
+
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
|
675
|
+
|
|
676
|
+
it("swaps the rebuilt preview's scenes into the live preview with no shadow reload", async () => {
|
|
677
|
+
const swap = vi.fn(async () => {});
|
|
678
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
679
|
+
const { getApi } = liveFilm(swap);
|
|
680
|
+
act(() => getApi().refreshPlayer());
|
|
681
|
+
await settle();
|
|
682
|
+
expect(swap).toHaveBeenCalledWith("<html>v2</html>", expect.anything());
|
|
683
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("falls back to the full reload when the preview refuses the swap", async () => {
|
|
687
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
688
|
+
const { getApi } = liveFilm(async () => {
|
|
689
|
+
throw new Error("the film changed outside its scenes");
|
|
690
|
+
});
|
|
691
|
+
act(() => getApi().refreshPlayer());
|
|
692
|
+
await settle();
|
|
693
|
+
expect(roles(getApi())).toEqual(["live", "shadow"]);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("reloads the whole film at once when the preview cannot swap", () => {
|
|
697
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
698
|
+
const { getApi } = liveFilm();
|
|
699
|
+
act(() => getApi().refreshPlayer());
|
|
700
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
701
|
+
expect(roles(getApi())).toEqual(["live", "shadow"]);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("never swaps in an older document that arrives after a newer reload started", async () => {
|
|
705
|
+
const swap = vi.fn(async () => {});
|
|
706
|
+
const replies: Array<(r: Response) => void> = [];
|
|
707
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
708
|
+
() => new Promise<Response>((resolve) => replies.push(resolve)),
|
|
709
|
+
);
|
|
710
|
+
const { getApi } = liveFilm(swap);
|
|
711
|
+
act(() => getApi().refreshPlayer());
|
|
712
|
+
act(() => getApi().refreshPlayer());
|
|
713
|
+
replies[1]!(new Response("<html>v3</html>"));
|
|
714
|
+
await settle();
|
|
715
|
+
replies[0]!(new Response("<html>v2</html>"));
|
|
716
|
+
await settle();
|
|
717
|
+
expect(swap.mock.calls).toEqual([["<html>v3</html>", expect.anything()]]);
|
|
718
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("cancels an older swap still waiting when a newer edit starts, so it cannot land after that edit's reload", async () => {
|
|
722
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("<html>v</html>"));
|
|
723
|
+
let captionsArrive = () => {};
|
|
724
|
+
const captions = new Promise<void>((resolve) => (captionsArrive = resolve));
|
|
725
|
+
const landed: string[] = [];
|
|
726
|
+
let calls = 0;
|
|
727
|
+
// As the runtime does: the first waits for caption overrides, then refuses if cancelled; the second is refused.
|
|
728
|
+
const swap = async (_html: string, signal?: AbortSignal) => {
|
|
729
|
+
if (++calls > 1) throw new Error("the film changed outside its scenes");
|
|
730
|
+
await captions;
|
|
731
|
+
if (signal?.aborted) throw new Error("the swap was cancelled");
|
|
732
|
+
landed.push("edit 1");
|
|
733
|
+
};
|
|
734
|
+
const { getApi } = liveFilm(swap);
|
|
735
|
+
act(() => getApi().refreshPlayer());
|
|
736
|
+
await settle();
|
|
737
|
+
act(() => getApi().refreshPlayer());
|
|
738
|
+
await settle();
|
|
739
|
+
expect(roles(getApi())).toContain("shadow");
|
|
740
|
+
captionsArrive();
|
|
741
|
+
await settle();
|
|
742
|
+
expect(landed).toEqual([]);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("drops a pending swap's fallback once the preview was replaced, e.g. by a composition switch", async () => {
|
|
746
|
+
let refuse!: (e: Error) => void;
|
|
747
|
+
const swap = vi.fn(() => new Promise<void>((_, reject) => (refuse = reject)));
|
|
748
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
749
|
+
const { getApi } = liveFilm(swap);
|
|
750
|
+
act(() => getApi().refreshPlayer());
|
|
751
|
+
await settle();
|
|
752
|
+
expect(swap).toHaveBeenCalledTimes(1);
|
|
753
|
+
act(() => getApi().resetPreviewSlots());
|
|
754
|
+
const next = makeFakeIframe(makeAdapterWindow().win);
|
|
755
|
+
next.src = "http://localhost/api/projects/demo/preview/comp/compositions/intro.html";
|
|
756
|
+
act(() => {
|
|
757
|
+
getApi().iframeRef.current = next;
|
|
758
|
+
getApi().onIframeLoad();
|
|
759
|
+
});
|
|
760
|
+
refuse(new Error("the preview was torn down during the swap"));
|
|
761
|
+
await settle();
|
|
762
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("skips the swap for an edit made while a full reload is in flight", async () => {
|
|
766
|
+
const fetchSpy = vi
|
|
767
|
+
.spyOn(globalThis, "fetch")
|
|
768
|
+
.mockResolvedValue(new Response("<html>v2</html>"));
|
|
769
|
+
const { getApi } = liveFilm(async () => {
|
|
770
|
+
throw new Error("the film changed outside its scenes");
|
|
771
|
+
});
|
|
772
|
+
act(() => getApi().refreshPlayer());
|
|
773
|
+
await settle();
|
|
774
|
+
expect(roles(getApi())).toEqual(["live", "shadow"]);
|
|
775
|
+
const firstShadow = getApi().previewSlots[1]!.gen;
|
|
776
|
+
fetchSpy.mockClear();
|
|
777
|
+
act(() => getApi().refreshPlayer());
|
|
778
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
779
|
+
expect(getApi().previewSlots[1]!.gen).toBeGreaterThan(firstShadow);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("keeps a playing film playing through a swap, ready to swap the next edit", async () => {
|
|
783
|
+
const fetchSpy = vi
|
|
784
|
+
.spyOn(globalThis, "fetch")
|
|
785
|
+
.mockImplementation(async () => new Response("<html>v2</html>"));
|
|
786
|
+
const swap = vi.fn(async () => {});
|
|
787
|
+
const { getApi, adapter } = playingFilm(swap);
|
|
788
|
+
act(() => getApi().refreshPlayer());
|
|
789
|
+
await settle();
|
|
790
|
+
expect(adapter.pause).not.toHaveBeenCalled();
|
|
791
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
792
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
793
|
+
act(() => getApi().refreshPlayer());
|
|
794
|
+
await settle();
|
|
795
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
796
|
+
expect(swap).toHaveBeenCalledTimes(2);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("shows a playing film stopped at its new end when a swap cuts it short of the playhead", async () => {
|
|
800
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
801
|
+
let length = 6;
|
|
802
|
+
// As the runtime's swap does when the edit ends the film at or before the playhead.
|
|
803
|
+
const cutShort = async () => {
|
|
804
|
+
length = 5;
|
|
805
|
+
adapter.pause();
|
|
806
|
+
adapter.seek(5);
|
|
807
|
+
};
|
|
808
|
+
const { getApi, adapter } = playingFilm(cutShort);
|
|
809
|
+
adapter.getDuration = () => length;
|
|
810
|
+
adapter.seek(5.5);
|
|
811
|
+
act(() => getApi().refreshPlayer());
|
|
812
|
+
await settle();
|
|
813
|
+
await act(async () => void (await new Promise((r) => setTimeout(r, 50))));
|
|
814
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
815
|
+
expect(usePlayerStore.getState().currentTime).toBe(5);
|
|
816
|
+
expect(adapter.play).toHaveBeenCalledTimes(1);
|
|
817
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it("keeps a playing film playing through a refused swap, from where the live frame had reached", async () => {
|
|
821
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
822
|
+
const { getApi, adapter } = playingFilm(async () => {
|
|
823
|
+
throw new Error("the film changed outside its scenes");
|
|
824
|
+
});
|
|
825
|
+
adapter.seek(3);
|
|
826
|
+
act(() => getApi().refreshPlayer());
|
|
827
|
+
// The live frame plays on while the swap is tried; the fallback reload starts from here.
|
|
828
|
+
adapter.seek(4.5);
|
|
829
|
+
await settle();
|
|
830
|
+
expect(adapter.pause).not.toHaveBeenCalled();
|
|
831
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
832
|
+
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
833
|
+
const shadow = makeShadowWithSpies();
|
|
834
|
+
act(() => {
|
|
835
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
836
|
+
getApi().onShadowIframeLoad(gen);
|
|
837
|
+
getApi().onShadowReadyChange(gen, true);
|
|
838
|
+
});
|
|
839
|
+
expect(shadow.adapter.getTime()).toBe(4.5);
|
|
840
|
+
expect(shadow.adapter.isPlaying()).toBe(true);
|
|
841
|
+
});
|
|
842
|
+
});
|
|
@@ -38,6 +38,7 @@ import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
|
|
38
38
|
import { createPreviewMessageHandler } from "./previewMessageRouter";
|
|
39
39
|
import { timelineElementsChanged } from "./timelinePlayerSync";
|
|
40
40
|
import { safeContentDocument } from "./timelineSyncHydration";
|
|
41
|
+
import { sceneSwapFor } from "../sceneSwap";
|
|
41
42
|
|
|
42
43
|
export interface UseTimelinePlayerOptions {
|
|
43
44
|
/** Runs right after a reloaded preview becomes the live iframe. */
|
|
@@ -391,6 +392,7 @@ export function useTimelinePlayer({
|
|
|
391
392
|
setShadowIframeNode,
|
|
392
393
|
beginShadowReload,
|
|
393
394
|
resetPreviewSlots,
|
|
395
|
+
previewGeneration,
|
|
394
396
|
} = useShadowPreviewReload({
|
|
395
397
|
iframeRef,
|
|
396
398
|
getAdapter,
|
|
@@ -443,19 +445,39 @@ export function useTimelinePlayer({
|
|
|
443
445
|
setIsPlaying(false);
|
|
444
446
|
return false;
|
|
445
447
|
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
448
|
+
const reloadWholeFilm = useCallback(
|
|
449
|
+
(url: string) => {
|
|
450
|
+
// The old iframe is no longer navigated away, so stop its playback (and audio) here.
|
|
451
|
+
if (!saveSeekPosition()) getAdapter()?.pause();
|
|
452
|
+
// The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
|
|
453
|
+
beginShadowReload(url);
|
|
454
|
+
},
|
|
455
|
+
[saveSeekPosition, getAdapter, beginShadowReload],
|
|
456
|
+
);
|
|
457
|
+
const refreshGenRef = useRef(0);
|
|
458
|
+
const swapCancelRef = useRef<AbortController | null>(null);
|
|
446
459
|
const refreshPlayer = useCallback(() => {
|
|
447
460
|
const iframe = iframeRef.current;
|
|
448
461
|
if (!iframe) return;
|
|
462
|
+
swapCancelRef.current?.abort(new Error("superseded by a newer edit"));
|
|
463
|
+
const cancel = new AbortController();
|
|
464
|
+
swapCancelRef.current = cancel;
|
|
449
465
|
logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
|
|
450
|
-
|
|
451
|
-
if (!saveSeekPosition()) getAdapter()?.pause();
|
|
452
|
-
// The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
|
|
453
|
-
const src = iframe.src;
|
|
454
|
-
const url = new URL(src, window.location.origin);
|
|
466
|
+
const url = new URL(iframe.src, window.location.origin);
|
|
455
467
|
url.searchParams.set("_t", String(Date.now()));
|
|
456
468
|
applyPreviewVariablesToUrl(url);
|
|
457
|
-
|
|
458
|
-
|
|
469
|
+
const gen = ++refreshGenRef.current;
|
|
470
|
+
const slot = previewGeneration();
|
|
471
|
+
// A newer edit, or anything replacing the live preview (a reload, a composition switch), wins.
|
|
472
|
+
const isCurrent = () => gen === refreshGenRef.current && slot === previewGeneration();
|
|
473
|
+
const swap = sceneSwapFor(iframe);
|
|
474
|
+
if (!swap || isRefreshingRef.current) return reloadWholeFilm(url.toString());
|
|
475
|
+
swap(url.toString(), isCurrent, cancel.signal).catch((error: unknown) => {
|
|
476
|
+
if (!isCurrent()) return;
|
|
477
|
+
logReload("scene-swap-refused", { reason: String(error) });
|
|
478
|
+
reloadWholeFilm(url.toString());
|
|
479
|
+
});
|
|
480
|
+
}, [reloadWholeFilm, previewGeneration]);
|
|
459
481
|
const pauseRef = useRef(pause);
|
|
460
482
|
pauseRef.current = pause;
|
|
461
483
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { SCENE_SWAP_MS, sceneSwapFor } from "./sceneSwap";
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.useRealTimers();
|
|
7
|
+
vi.restoreAllMocks();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
type Swap = (html: string, signal?: AbortSignal) => Promise<void>;
|
|
11
|
+
|
|
12
|
+
function swappableIframe(swap: Swap = vi.fn(async () => {})) {
|
|
13
|
+
const iframe = document.createElement("iframe");
|
|
14
|
+
document.body.append(iframe);
|
|
15
|
+
Object.assign(iframe.contentWindow as object, { __hfSwapScenes: swap });
|
|
16
|
+
return { iframe, swap };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("sceneSwapFor", () => {
|
|
20
|
+
it("gives up on a preview download that never finishes, so the caller can reload", async () => {
|
|
21
|
+
vi.useFakeTimers();
|
|
22
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
23
|
+
(_url, init) =>
|
|
24
|
+
new Promise((_resolve, reject) =>
|
|
25
|
+
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason)),
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
const { iframe, swap } = swappableIframe();
|
|
29
|
+
const swapping = sceneSwapFor(iframe)!("/preview", () => true);
|
|
30
|
+
const outcome = expect(swapping).rejects.toThrow("took too long");
|
|
31
|
+
await vi.advanceTimersByTimeAsync(SCENE_SWAP_MS);
|
|
32
|
+
await outcome;
|
|
33
|
+
expect(swap).not.toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it.each([
|
|
37
|
+
["a body that never finishes", async () => new Response(new ReadableStream())],
|
|
38
|
+
["a runtime swap that never finishes", async () => new Response("<html></html>")],
|
|
39
|
+
])("gives up on %s, so the caller can reload", async (_stall, respond) => {
|
|
40
|
+
vi.useFakeTimers();
|
|
41
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(respond);
|
|
42
|
+
const { iframe } = swappableIframe(vi.fn(() => new Promise<void>(() => {})));
|
|
43
|
+
const swapped = vi.fn();
|
|
44
|
+
iframe.addEventListener("hf-scenes-swapped", swapped);
|
|
45
|
+
const outcome = expect(sceneSwapFor(iframe)!("/preview", () => true)).rejects.toThrow(
|
|
46
|
+
"took too long",
|
|
47
|
+
);
|
|
48
|
+
await vi.advanceTimersByTimeAsync(SCENE_SWAP_MS);
|
|
49
|
+
await outcome;
|
|
50
|
+
expect(swapped).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("cancels the runtime's swap as it gives up, so captions arriving later replace nothing", async () => {
|
|
54
|
+
vi.useFakeTimers();
|
|
55
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("<html></html>"));
|
|
56
|
+
let captionsArrive = () => {};
|
|
57
|
+
const captions = new Promise<void>((resolve) => (captionsArrive = resolve));
|
|
58
|
+
let replaced = false;
|
|
59
|
+
// As the runtime does: it waits for the caption overrides, then refuses a cancelled swap.
|
|
60
|
+
const { iframe } = swappableIframe(async (_html, signal) => {
|
|
61
|
+
await captions;
|
|
62
|
+
if (signal?.aborted) throw new Error("the swap was cancelled");
|
|
63
|
+
replaced = true;
|
|
64
|
+
});
|
|
65
|
+
const outcome = expect(sceneSwapFor(iframe)!("/preview", () => true)).rejects.toThrow(
|
|
66
|
+
"took too long",
|
|
67
|
+
);
|
|
68
|
+
await vi.advanceTimersByTimeAsync(SCENE_SWAP_MS);
|
|
69
|
+
await outcome;
|
|
70
|
+
captionsArrive();
|
|
71
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
72
|
+
expect(replaced).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not start a swap whose cancel signal was aborted before the call", async () => {
|
|
76
|
+
const fetchSpy = vi
|
|
77
|
+
.spyOn(globalThis, "fetch")
|
|
78
|
+
.mockImplementation(async () => new Response("<html></html>"));
|
|
79
|
+
const { iframe, swap } = swappableIframe();
|
|
80
|
+
const cancel = new AbortController();
|
|
81
|
+
cancel.abort(new Error("superseded by a newer edit"));
|
|
82
|
+
await expect(sceneSwapFor(iframe)!("/preview", () => true, cancel.signal)).rejects.toThrow(
|
|
83
|
+
"superseded",
|
|
84
|
+
);
|
|
85
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
86
|
+
expect(swap).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("swaps a document that arrives in time", async () => {
|
|
90
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("<html></html>"));
|
|
91
|
+
const { iframe, swap } = swappableIframe();
|
|
92
|
+
await sceneSwapFor(iframe)!("/preview", () => true);
|
|
93
|
+
expect(swap).toHaveBeenCalledWith("<html></html>", expect.anything());
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
type SwapWindow = Window & {
|
|
2
|
+
__hfSwapScenes?: (html: string, signal?: AbortSignal) => Promise<void>;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
const SCENES_SWAPPED = "hf-scenes-swapped";
|
|
6
|
+
export const SCENE_SWAP_MS = 5000;
|
|
7
|
+
|
|
8
|
+
export function onPreviewContentReplaced(
|
|
9
|
+
iframe: HTMLIFrameElement,
|
|
10
|
+
onReplaced: () => void,
|
|
11
|
+
): () => void {
|
|
12
|
+
iframe.addEventListener("load", onReplaced);
|
|
13
|
+
iframe.addEventListener(SCENES_SWAPPED, onReplaced);
|
|
14
|
+
return () => {
|
|
15
|
+
iframe.removeEventListener("load", onReplaced);
|
|
16
|
+
iframe.removeEventListener(SCENES_SWAPPED, onReplaced);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Null when the preview cannot swap; rejects when superseded, cancelled or a full reload is needed. */
|
|
21
|
+
export function sceneSwapFor(
|
|
22
|
+
iframe: HTMLIFrameElement,
|
|
23
|
+
): ((url: string, isCurrent: () => boolean, cancel?: AbortSignal) => Promise<void>) | null {
|
|
24
|
+
const win = iframe.contentWindow as SwapWindow | null;
|
|
25
|
+
const swap = win?.__hfSwapScenes;
|
|
26
|
+
if (typeof swap !== "function") return null;
|
|
27
|
+
return async (url, isCurrent, cancel) => {
|
|
28
|
+
if (cancel?.aborted) throw cancel.reason;
|
|
29
|
+
const deadline = new AbortController();
|
|
30
|
+
const timer = setTimeout(
|
|
31
|
+
() => deadline.abort(new Error("the preview took too long to swap")),
|
|
32
|
+
SCENE_SWAP_MS,
|
|
33
|
+
);
|
|
34
|
+
cancel?.addEventListener("abort", () => deadline.abort(cancel.reason), { once: true });
|
|
35
|
+
const swapping = (async () => {
|
|
36
|
+
const response = await fetch(url, { signal: deadline.signal });
|
|
37
|
+
if (!response.ok) throw new Error(`preview request failed with ${response.status}`);
|
|
38
|
+
const html = await response.text();
|
|
39
|
+
if (!isCurrent()) throw new Error("superseded by a newer reload");
|
|
40
|
+
// Aborted with the deadline, so a swap still waiting cannot land after the reload has begun.
|
|
41
|
+
await swap.call(win, html, deadline.signal);
|
|
42
|
+
})();
|
|
43
|
+
const expired = new Promise<never>((_resolve, reject) =>
|
|
44
|
+
deadline.signal.addEventListener("abort", () => reject(deadline.signal.reason)),
|
|
45
|
+
);
|
|
46
|
+
try {
|
|
47
|
+
await Promise.race([swapping, expired]);
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
iframe.dispatchEvent(new Event(SCENES_SWAPPED));
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -23,6 +23,7 @@ import { createRangeSelectionSlice, type RangeSelectionSlice } from "./rangeSele
|
|
|
23
23
|
import { createTimelineResetState } from "./timelineResetState";
|
|
24
24
|
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
|
25
25
|
export { liveTime } from "./liveTime";
|
|
26
|
+
import { liveTime } from "./liveTime";
|
|
26
27
|
export { createTimelineResetState };
|
|
27
28
|
|
|
28
29
|
import type {
|
|
@@ -96,6 +97,8 @@ interface PlayerState extends PlayerStoreSlices {
|
|
|
96
97
|
|
|
97
98
|
activeTool: TimelineTool;
|
|
98
99
|
setActiveTool: (tool: TimelineTool) => void;
|
|
100
|
+
selectLeftward: () => void;
|
|
101
|
+
selectRightward: () => void;
|
|
99
102
|
|
|
100
103
|
/** Tween-relative percentage of the last-clicked keyframe diamond. Operations
|
|
101
104
|
* (drag, resize, rotate) target this instead of recomputing from playhead. */
|
|
@@ -250,6 +253,17 @@ interface BeatHistoryEntry {
|
|
|
250
253
|
label: string;
|
|
251
254
|
}
|
|
252
255
|
|
|
256
|
+
/** Selects like the marquee: the primary first, so its resets run, then the whole set. */
|
|
257
|
+
function selectAroundPlayhead(
|
|
258
|
+
state: PlayerState,
|
|
259
|
+
keep: (start: number, playhead: number) => boolean,
|
|
260
|
+
): void {
|
|
261
|
+
const playhead = state.isPlaying ? liveTime.latest() : state.currentTime;
|
|
262
|
+
const ids = state.elements.filter((el) => keep(el.start, playhead)).map((el) => el.key ?? el.id);
|
|
263
|
+
state.setSelectedElementId(ids[0] ?? null);
|
|
264
|
+
state.setSelectedElementIds(new Set(ids));
|
|
265
|
+
}
|
|
266
|
+
|
|
253
267
|
export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
254
268
|
isPlaying: false,
|
|
255
269
|
currentTime: 0,
|
|
@@ -273,6 +287,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
273
287
|
|
|
274
288
|
activeTool: "select",
|
|
275
289
|
setActiveTool: (tool) => set({ activeTool: tool }),
|
|
290
|
+
selectLeftward: () => selectAroundPlayhead(get(), (start, playhead) => start < playhead),
|
|
291
|
+
selectRightward: () => selectAroundPlayhead(get(), (start, playhead) => start >= playhead),
|
|
276
292
|
|
|
277
293
|
...createKeyframeSlice(set, () => ({
|
|
278
294
|
timelineProjectId: get().timelineProjectId,
|