@hyperframes/studio 0.8.56 → 0.8.57
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-c23wnIfc.js +464 -0
- package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-DFlILHJe.js → index-DgkAzx3n.js} +1 -1
- package/dist/assets/{index-CxR7F2YQ.js → index-IDNr3X1H.js} +1 -1
- package/dist/assets/{index-BlkJ_Axe.js → index-gbOCDCuZ.js} +194 -194
- package/dist/index.d.ts +95 -9
- package/dist/index.html +2 -2
- package/dist/index.js +15573 -14835
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -0
- package/src/components/EditorShell.tsx +3 -1
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +0 -15
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- package/src/components/editor/SnapToolbar.test.tsx +25 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +40 -4
- package/src/components/nle/NLEPreview.test.ts +66 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.tsx +4 -16
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/previewReadOnly.test.tsx +1 -0
- package/src/hooks/useCompositionDimensions.test.tsx +38 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
- package/src/hooks/useSdkSession.ts +46 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/index.ts +12 -0
- package/src/player/components/Player.test.ts +141 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -572
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
- package/src/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Shared test harness: mounts the real useTimelinePlayer and fakes an iframe
|
|
2
|
+
// whose window exposes a playback adapter.
|
|
3
|
+
import React, { act, useEffect } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { vi } from "vitest";
|
|
6
|
+
import { useTimelinePlayer, type UseTimelinePlayerOptions } from "./useTimelinePlayer";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
|
|
9
|
+
export type TimelinePlayerApi = ReturnType<typeof useTimelinePlayer>;
|
|
10
|
+
|
|
11
|
+
export function resetPlayerStore() {
|
|
12
|
+
usePlayerStore.getState().reset();
|
|
13
|
+
usePlayerStore.setState({ requestedSeekTime: null });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function TimelinePlayerHarness({
|
|
17
|
+
onValue,
|
|
18
|
+
options,
|
|
19
|
+
}: {
|
|
20
|
+
onValue: (value: TimelinePlayerApi) => void;
|
|
21
|
+
options?: UseTimelinePlayerOptions;
|
|
22
|
+
}) {
|
|
23
|
+
const value = useTimelinePlayer(options);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
onValue(value);
|
|
26
|
+
}, [onValue, value]);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `api` is the first render's snapshot; `getApi()` returns the latest render's value. */
|
|
31
|
+
export function renderTimelinePlayerHarness(options?: UseTimelinePlayerOptions) {
|
|
32
|
+
let latest: TimelinePlayerApi | null = null;
|
|
33
|
+
const host = document.createElement("div");
|
|
34
|
+
document.body.append(host);
|
|
35
|
+
const root = createRoot(host);
|
|
36
|
+
|
|
37
|
+
act(() => {
|
|
38
|
+
root.render(
|
|
39
|
+
React.createElement(TimelinePlayerHarness, { onValue: (value) => (latest = value), options }),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!latest) throw new Error("useTimelinePlayer did not mount");
|
|
44
|
+
const rerender = (next?: UseTimelinePlayerOptions) =>
|
|
45
|
+
act(() => {
|
|
46
|
+
root.render(
|
|
47
|
+
React.createElement(TimelinePlayerHarness, {
|
|
48
|
+
onValue: (value) => (latest = value),
|
|
49
|
+
options: next,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
api: latest as TimelinePlayerApi,
|
|
55
|
+
getApi: () => latest as TimelinePlayerApi,
|
|
56
|
+
root,
|
|
57
|
+
rerender,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function makeFakeIframe(iframeWindow: Record<string, unknown>): HTMLIFrameElement {
|
|
62
|
+
const iframe = document.createElement("iframe");
|
|
63
|
+
Object.defineProperty(iframe, "contentWindow", { value: iframeWindow, configurable: true });
|
|
64
|
+
Object.defineProperty(iframe, "contentDocument", {
|
|
65
|
+
value: document.implementation.createHTMLDocument("preview"),
|
|
66
|
+
configurable: true,
|
|
67
|
+
});
|
|
68
|
+
return iframe;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function attachIframeWindow(api: TimelinePlayerApi, iframeWindow: Record<string, unknown>) {
|
|
72
|
+
const iframe = makeFakeIframe(iframeWindow);
|
|
73
|
+
act(() => {
|
|
74
|
+
api.iframeRef.current = iframe;
|
|
75
|
+
api.onIframeLoad();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function makeAdapterWindow(
|
|
80
|
+
options: {
|
|
81
|
+
postMessage?: (message: unknown, targetOrigin: string) => void;
|
|
82
|
+
timelines?: Record<string, unknown>;
|
|
83
|
+
duration?: number;
|
|
84
|
+
} = {},
|
|
85
|
+
) {
|
|
86
|
+
let currentTime = 0;
|
|
87
|
+
let playing = false;
|
|
88
|
+
const adapter = {
|
|
89
|
+
play: vi.fn(() => {
|
|
90
|
+
playing = true;
|
|
91
|
+
}),
|
|
92
|
+
pause: vi.fn(() => {
|
|
93
|
+
playing = false;
|
|
94
|
+
}),
|
|
95
|
+
seek: (time: number) => {
|
|
96
|
+
currentTime = time;
|
|
97
|
+
},
|
|
98
|
+
getTime: () => currentTime,
|
|
99
|
+
getDuration: () => options.duration ?? 30,
|
|
100
|
+
isPlaying: () => playing,
|
|
101
|
+
};
|
|
102
|
+
const win = {
|
|
103
|
+
__player: adapter,
|
|
104
|
+
__timelines: options.timelines,
|
|
105
|
+
postMessage: options.postMessage ?? (() => {}),
|
|
106
|
+
scrollTo: () => {},
|
|
107
|
+
addEventListener: () => {},
|
|
108
|
+
removeEventListener: () => {},
|
|
109
|
+
};
|
|
110
|
+
return { adapter, win };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function attachIframeAdapter(
|
|
114
|
+
api: TimelinePlayerApi,
|
|
115
|
+
options: Parameters<typeof makeAdapterWindow>[0] = {},
|
|
116
|
+
) {
|
|
117
|
+
const { adapter, win } = makeAdapterWindow(options);
|
|
118
|
+
attachIframeWindow(api, win);
|
|
119
|
+
return adapter;
|
|
120
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import { parseTimelineFromDOM } from "../lib/timelineDOM";
|
|
3
|
+
import { readTimelineDurationFromDocument } from "../lib/timelineElementHelpers";
|
|
4
|
+
import type { PreviewIframeSlot } from "./useTimelineSyncCallbacks";
|
|
5
|
+
import { trackPreviewFirstFrame } from "../../telemetry/events";
|
|
6
|
+
import { getBrowserSystemMeta } from "../../telemetry/system";
|
|
7
|
+
|
|
8
|
+
export function readPreviewComplexity(doc: Document | null | undefined): {
|
|
9
|
+
clip_count: number;
|
|
10
|
+
media_clip_count: number;
|
|
11
|
+
} {
|
|
12
|
+
if (!doc) return { clip_count: 0, media_clip_count: 0 };
|
|
13
|
+
const elements = parseTimelineFromDOM(doc, readTimelineDurationFromDocument(doc));
|
|
14
|
+
return {
|
|
15
|
+
clip_count: elements.length,
|
|
16
|
+
media_clip_count: elements.filter(({ tag }) => tag === "video" || tag === "audio").length,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function usePreviewFirstFrameTelemetry(previewSlots: PreviewIframeSlot[]) {
|
|
21
|
+
const previewSlotsRef = useRef(previewSlots);
|
|
22
|
+
previewSlotsRef.current = previewSlots;
|
|
23
|
+
const reportedPaintsRef = useRef(new Set<string>());
|
|
24
|
+
|
|
25
|
+
return useCallback(
|
|
26
|
+
(
|
|
27
|
+
slot: PreviewIframeSlot,
|
|
28
|
+
{
|
|
29
|
+
iframe,
|
|
30
|
+
startedAt,
|
|
31
|
+
loadId,
|
|
32
|
+
}: { iframe: HTMLIFrameElement; startedAt: number; loadId: number },
|
|
33
|
+
) => {
|
|
34
|
+
const currentSlot = previewSlotsRef.current.find(
|
|
35
|
+
(candidate) => candidate.gen === slot.gen && candidate.role === slot.role,
|
|
36
|
+
);
|
|
37
|
+
if (!currentSlot) return;
|
|
38
|
+
const reportKey = `${slot.role}:${slot.gen}:${loadId}`;
|
|
39
|
+
if (reportedPaintsRef.current.has(reportKey)) return;
|
|
40
|
+
reportedPaintsRef.current.add(reportKey);
|
|
41
|
+
const complexity = readPreviewComplexity(iframe.contentDocument);
|
|
42
|
+
trackPreviewFirstFrame({
|
|
43
|
+
duration_ms: Math.max(0, Math.round(performance.now() - startedAt)),
|
|
44
|
+
composition_seconds: readTimelineDurationFromDocument(iframe.contentDocument),
|
|
45
|
+
...complexity,
|
|
46
|
+
studio_version: getBrowserSystemMeta().studio_version,
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
[],
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Loads a full-reload edit in a hidden shadow iframe and promotes it once painted,
|
|
2
|
+
// so the live iframe never shows a blank frame.
|
|
3
|
+
|
|
4
|
+
import { useCallback, useRef, useState } from "react";
|
|
5
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
6
|
+
import { logReload } from "../../utils/reloadDebug";
|
|
7
|
+
import {
|
|
8
|
+
useTimelineSyncCallbacks,
|
|
9
|
+
planShadowReload,
|
|
10
|
+
planShadowPromotion,
|
|
11
|
+
planShadowDiscard,
|
|
12
|
+
type PreviewIframeSlot,
|
|
13
|
+
type UseTimelineSyncCallbacksParams,
|
|
14
|
+
} from "./useTimelineSyncCallbacks";
|
|
15
|
+
import type { PlaybackAdapter } from "../lib/playbackTypes";
|
|
16
|
+
|
|
17
|
+
// The single wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
|
|
18
|
+
// leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
|
|
19
|
+
export const SHADOW_READY_TIMEOUT_MS = 15_000;
|
|
20
|
+
|
|
21
|
+
type UseShadowPreviewReloadParams = Omit<
|
|
22
|
+
UseTimelineSyncCallbacksParams,
|
|
23
|
+
"probeIntervalRef" | "onAdapterReady" | "getAdapter" | "isCurrent" | "onLoadGiveUp"
|
|
24
|
+
> & {
|
|
25
|
+
getAdapter: (overrideIframe?: HTMLIFrameElement | null) => PlaybackAdapter | null;
|
|
26
|
+
/** Runs right after a shadow becomes the live iframe (iframeRef already points at it). */
|
|
27
|
+
onPromoted?: () => void;
|
|
28
|
+
/** A shadow that never became ready was dropped; the live preview is unchanged. */
|
|
29
|
+
onReloadFailed?: (message: string) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function useShadowPreviewReload({
|
|
33
|
+
iframeRef,
|
|
34
|
+
getAdapter,
|
|
35
|
+
pendingSeekRef,
|
|
36
|
+
isRefreshingRef,
|
|
37
|
+
syncTimelineElements,
|
|
38
|
+
setDuration,
|
|
39
|
+
setCurrentTime,
|
|
40
|
+
requestTimelineReady,
|
|
41
|
+
setIsPlaying,
|
|
42
|
+
attachIframeShortcutListeners,
|
|
43
|
+
applyPreviewAudioState,
|
|
44
|
+
onPromoted,
|
|
45
|
+
onReloadFailed,
|
|
46
|
+
}: UseShadowPreviewReloadParams) {
|
|
47
|
+
const shadowIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
48
|
+
const shadowProbeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
|
49
|
+
// Invalidation counter, separate from the live slot's key: bumping it never remounts the live Player.
|
|
50
|
+
const shadowGenRef = useRef(0);
|
|
51
|
+
const pendingCommitRef = useRef<{ gen: number; commit: () => void } | null>(null);
|
|
52
|
+
const visuallyReadyGenRef = useRef<number | null>(null);
|
|
53
|
+
const onPromotedRef = useRef(onPromoted);
|
|
54
|
+
onPromotedRef.current = onPromoted;
|
|
55
|
+
const onReloadFailedRef = useRef(onReloadFailed);
|
|
56
|
+
onReloadFailedRef.current = onReloadFailed;
|
|
57
|
+
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
58
|
+
const cancelPendingLoadRef = useRef<() => void>(() => {});
|
|
59
|
+
const [previewSlots, setPreviewSlots] = useState<PreviewIframeSlot[]>([{ gen: 0, role: "live" }]);
|
|
60
|
+
|
|
61
|
+
const stopPendingShadow = useCallback(() => {
|
|
62
|
+
clearTimeout(readyTimerRef.current);
|
|
63
|
+
cancelPendingLoadRef.current();
|
|
64
|
+
pendingCommitRef.current = null;
|
|
65
|
+
visuallyReadyGenRef.current = null;
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const failShadow = useCallback(
|
|
69
|
+
(gen: number, cause: string) => {
|
|
70
|
+
if (gen !== shadowGenRef.current) return;
|
|
71
|
+
shadowGenRef.current += 1;
|
|
72
|
+
stopPendingShadow();
|
|
73
|
+
shadowIframeRef.current = null;
|
|
74
|
+
isRefreshingRef.current = false;
|
|
75
|
+
pendingSeekRef.current = null;
|
|
76
|
+
setPreviewSlots(planShadowDiscard);
|
|
77
|
+
const message = `The preview did not reload (${cause}). The previous preview is still showing.`;
|
|
78
|
+
logReload("shadow-failed", { cause });
|
|
79
|
+
console.error(`[studio] ${message}`);
|
|
80
|
+
onReloadFailedRef.current?.(message);
|
|
81
|
+
},
|
|
82
|
+
[stopPendingShadow, isRefreshingRef, pendingSeekRef],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const promoteWhenReady = useCallback(
|
|
86
|
+
(gen: number) => {
|
|
87
|
+
const shadow = shadowIframeRef.current;
|
|
88
|
+
const pending = pendingCommitRef.current;
|
|
89
|
+
const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
|
|
90
|
+
if (!shadow || !pending || !ready || gen !== shadowGenRef.current) return;
|
|
91
|
+
stopPendingShadow();
|
|
92
|
+
// The store takes the new document's timeline only now that it is the one on screen.
|
|
93
|
+
pending.commit();
|
|
94
|
+
iframeRef.current = shadow;
|
|
95
|
+
shadowIframeRef.current = null;
|
|
96
|
+
attachIframeShortcutListeners();
|
|
97
|
+
applyPreviewAudioState();
|
|
98
|
+
setPreviewSlots((prev) => planShadowPromotion(prev, gen));
|
|
99
|
+
onPromotedRef.current?.();
|
|
100
|
+
},
|
|
101
|
+
[stopPendingShadow, iframeRef, attachIframeShortcutListeners, applyPreviewAudioState],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
|
|
105
|
+
const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
|
|
106
|
+
const markAdapterReady = useCallback(
|
|
107
|
+
(_iframe: HTMLIFrameElement | null, gen: number | undefined, commit: () => void) => {
|
|
108
|
+
if (gen == null || gen !== shadowGenRef.current) return;
|
|
109
|
+
pendingCommitRef.current = { gen, commit };
|
|
110
|
+
promoteWhenReady(gen);
|
|
111
|
+
},
|
|
112
|
+
[promoteWhenReady],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const { onIframeLoad: onShadowIframeLoad, cancelPendingLoad } = useTimelineSyncCallbacks({
|
|
116
|
+
iframeRef: shadowIframeRef,
|
|
117
|
+
probeIntervalRef: shadowProbeIntervalRef,
|
|
118
|
+
pendingSeekRef,
|
|
119
|
+
isRefreshingRef,
|
|
120
|
+
getAdapter: getShadowAdapter,
|
|
121
|
+
syncTimelineElements,
|
|
122
|
+
setDuration,
|
|
123
|
+
setCurrentTime,
|
|
124
|
+
requestTimelineReady,
|
|
125
|
+
setIsPlaying,
|
|
126
|
+
// A hidden shadow gets neither: shortcuts and audio state apply once it is promoted.
|
|
127
|
+
attachIframeShortcutListeners: () => {},
|
|
128
|
+
applyPreviewAudioState: () => {},
|
|
129
|
+
onAdapterReady: markAdapterReady,
|
|
130
|
+
isCurrent: isCurrentShadow,
|
|
131
|
+
});
|
|
132
|
+
cancelPendingLoadRef.current = cancelPendingLoad;
|
|
133
|
+
|
|
134
|
+
// The Player reports whether loaders (shader transition, assets) are cleared right now.
|
|
135
|
+
const onShadowReadyChange = useCallback(
|
|
136
|
+
(gen: number, ready: boolean) => {
|
|
137
|
+
if (gen !== shadowGenRef.current) return;
|
|
138
|
+
visuallyReadyGenRef.current = ready ? gen : null;
|
|
139
|
+
if (ready) promoteWhenReady(gen);
|
|
140
|
+
},
|
|
141
|
+
[promoteWhenReady],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const setShadowIframeNode = useCallback((node: HTMLIFrameElement | null) => {
|
|
145
|
+
shadowIframeRef.current = node;
|
|
146
|
+
}, []);
|
|
147
|
+
|
|
148
|
+
const beginShadowReload = useCallback(
|
|
149
|
+
(url: string) => {
|
|
150
|
+
shadowGenRef.current += 1;
|
|
151
|
+
const gen = shadowGenRef.current;
|
|
152
|
+
stopPendingShadow();
|
|
153
|
+
readyTimerRef.current = setTimeout(
|
|
154
|
+
() => failShadow(gen, "it took too long to load"),
|
|
155
|
+
SHADOW_READY_TIMEOUT_MS,
|
|
156
|
+
);
|
|
157
|
+
setPreviewSlots((prev) => planShadowReload(prev, gen, url));
|
|
158
|
+
},
|
|
159
|
+
[stopPendingShadow, failShadow],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Composition switch (not an edit reload): drop any in-flight shadow.
|
|
163
|
+
const resetPreviewSlots = useCallback(() => {
|
|
164
|
+
shadowGenRef.current += 1;
|
|
165
|
+
stopPendingShadow();
|
|
166
|
+
shadowIframeRef.current = null;
|
|
167
|
+
isRefreshingRef.current = false;
|
|
168
|
+
pendingSeekRef.current = null;
|
|
169
|
+
setPreviewSlots(planShadowDiscard);
|
|
170
|
+
}, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
|
|
171
|
+
|
|
172
|
+
useMountEffect(() => stopPendingShadow);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
previewSlots,
|
|
176
|
+
onShadowIframeLoad,
|
|
177
|
+
onShadowReadyChange,
|
|
178
|
+
onShadowError: failShadow,
|
|
179
|
+
setShadowIframeNode,
|
|
180
|
+
beginShadowReload,
|
|
181
|
+
resetPreviewSlots,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -1,105 +1,25 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { act } from "react";
|
|
5
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
-
import { useTimelinePlayer } from "./useTimelinePlayer";
|
|
5
|
+
import type { useTimelinePlayer } from "./useTimelinePlayer";
|
|
6
|
+
import {
|
|
7
|
+
attachIframeAdapter,
|
|
8
|
+
attachIframeWindow,
|
|
9
|
+
renderTimelinePlayerHarness,
|
|
10
|
+
resetPlayerStore,
|
|
11
|
+
} from "./timelinePlayerTestHarness";
|
|
7
12
|
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
8
13
|
import { setTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
|
9
14
|
|
|
10
15
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
16
|
|
|
12
|
-
function resetPlayerStore() {
|
|
13
|
-
usePlayerStore.getState().reset();
|
|
14
|
-
usePlayerStore.setState({ requestedSeekTime: null });
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function TimelinePlayerHarness({
|
|
18
|
-
onValue,
|
|
19
|
-
}: {
|
|
20
|
-
onValue: (value: ReturnType<typeof useTimelinePlayer>) => void;
|
|
21
|
-
}) {
|
|
22
|
-
const value = useTimelinePlayer();
|
|
23
|
-
useEffect(() => {
|
|
24
|
-
onValue(value);
|
|
25
|
-
}, [onValue, value]);
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function renderTimelinePlayerHarness() {
|
|
30
|
-
let api: ReturnType<typeof useTimelinePlayer> | null = null;
|
|
31
|
-
const host = document.createElement("div");
|
|
32
|
-
document.body.append(host);
|
|
33
|
-
const root = createRoot(host);
|
|
34
|
-
|
|
35
|
-
act(() => {
|
|
36
|
-
root.render(React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }));
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
if (!api) throw new Error("useTimelinePlayer did not mount");
|
|
40
|
-
return { api, root };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
17
|
afterEach(() => {
|
|
44
18
|
setTimelinePerformanceFixtureLease(false);
|
|
45
19
|
document.body.innerHTML = "";
|
|
46
20
|
resetPlayerStore();
|
|
47
21
|
});
|
|
48
22
|
|
|
49
|
-
function attachIframeWindow(
|
|
50
|
-
api: ReturnType<typeof useTimelinePlayer>,
|
|
51
|
-
iframeWindow: Record<string, unknown>,
|
|
52
|
-
): void {
|
|
53
|
-
const iframe = document.createElement("iframe");
|
|
54
|
-
Object.defineProperty(iframe, "contentWindow", {
|
|
55
|
-
value: iframeWindow,
|
|
56
|
-
configurable: true,
|
|
57
|
-
});
|
|
58
|
-
Object.defineProperty(iframe, "contentDocument", {
|
|
59
|
-
value: document.implementation.createHTMLDocument("preview"),
|
|
60
|
-
configurable: true,
|
|
61
|
-
});
|
|
62
|
-
act(() => {
|
|
63
|
-
api.iframeRef.current = iframe;
|
|
64
|
-
api.onIframeLoad();
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function attachIframeAdapter(
|
|
69
|
-
api: ReturnType<typeof useTimelinePlayer>,
|
|
70
|
-
options: {
|
|
71
|
-
postMessage?: (message: unknown, targetOrigin: string) => void;
|
|
72
|
-
timelines?: Record<string, unknown>;
|
|
73
|
-
duration?: number;
|
|
74
|
-
} = {},
|
|
75
|
-
) {
|
|
76
|
-
let currentTime = 0;
|
|
77
|
-
let playing = false;
|
|
78
|
-
const adapter = {
|
|
79
|
-
play: vi.fn(() => {
|
|
80
|
-
playing = true;
|
|
81
|
-
}),
|
|
82
|
-
pause: vi.fn(() => {
|
|
83
|
-
playing = false;
|
|
84
|
-
}),
|
|
85
|
-
seek: (time: number) => {
|
|
86
|
-
currentTime = time;
|
|
87
|
-
},
|
|
88
|
-
getTime: () => currentTime,
|
|
89
|
-
getDuration: () => options.duration ?? 30,
|
|
90
|
-
isPlaying: () => playing,
|
|
91
|
-
};
|
|
92
|
-
attachIframeWindow(api, {
|
|
93
|
-
__player: adapter,
|
|
94
|
-
__timelines: options.timelines,
|
|
95
|
-
postMessage: options.postMessage ?? (() => {}),
|
|
96
|
-
scrollTo: () => {},
|
|
97
|
-
addEventListener: () => {},
|
|
98
|
-
removeEventListener: () => {},
|
|
99
|
-
});
|
|
100
|
-
return adapter;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
23
|
function renderAttachedTimelinePlayer() {
|
|
104
24
|
const { api, root } = renderTimelinePlayerHarness();
|
|
105
25
|
const adapter = attachIframeAdapter(api);
|