@hyperframes/studio 0.8.55 → 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-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
- package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
- package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
- package/dist/index.d.ts +111 -17
- package/dist/index.html +2 -2
- package/dist/index.js +21534 -20683
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +18 -1
- package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
- package/src/components/EditorShell.selectionSync.test.tsx +57 -67
- package/src/components/EditorShell.tsx +62 -51
- package/src/components/EditorShellTestMocks.ts +46 -0
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +19 -55
- package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
- package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
- 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/PropertyPanel.deselect.test.tsx +71 -0
- package/src/components/editor/PropertyPanel.tsx +4 -3
- package/src/components/editor/SnapToolbar.test.tsx +26 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
- package/src/components/editor/previewReadOnlyContext.tsx +59 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
- package/src/components/editor/useDomEditNudge.ts +9 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
- package/src/components/editor/useOffCanvasIndicators.ts +50 -0
- 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.topologyLens.test.tsx +4 -35
- package/src/components/nle/PreviewOverlays.tsx +19 -24
- package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/TimelinePane.tsx +5 -1
- package/src/components/nle/previewReadOnly.test.tsx +129 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
- package/src/components/nle/usePreviewBlockDrop.ts +3 -1
- package/src/hooks/appHotkeysDispatch.ts +16 -1
- package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
- package/src/hooks/timelineAudioGroupCreate.ts +3 -4
- package/src/hooks/timelineTrackVisibility.ts +7 -8
- package/src/hooks/useAppHotkeys.test.ts +71 -3
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +4 -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/useDomEditCommits.test.tsx +1 -0
- package/src/hooks/useDomEditCommits.ts +3 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -0
- package/src/hooks/useDomEditSession.ts +7 -4
- package/src/hooks/useDomEditWiring.ts +40 -40
- package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
- package/src/hooks/useDomGeometryCommits.ts +26 -20
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useGestureCommit.test.tsx +29 -0
- package/src/hooks/useGestureCommit.ts +23 -0
- package/src/hooks/useInlineTextEdit.test.tsx +23 -2
- package/src/hooks/useInlineTextEdit.ts +11 -2
- 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/hooks/useTimelineAssetDropOps.test.tsx +3 -4
- package/src/hooks/useTimelineAssetDropOps.ts +1 -6
- package/src/hooks/useTimelineEditing.test.tsx +10 -7
- package/src/hooks/useTimelineEditing.ts +2 -7
- 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 -573
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineClip.test.tsx +52 -0
- package/src/player/components/TimelineClip.tsx +13 -2
- 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/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineMotionStyles.test.ts +3 -3
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +34 -30
- package/src/player/components/timelineTrackDisplay.ts +1 -4
- 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/components/useTimelineTrackDerivations.ts +2 -2
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
- package/src/player/hooks/timelineSyncHydration.ts +0 -23
- 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 -91
- package/src/player/store/playerStore.ts +0 -4
- package/src/player/store/timelineElement.ts +2 -4
- package/src/player/store/timelineResetState.ts +0 -1
- package/src/styles/hex-baseline.json +3 -4
- package/src/styles/studio.css +7 -7
- package/src/styles/theme.css +29 -0
- 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-BjAGc2ID.js +0 -464
- package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
- package/src/player/hooks/useTimelineRowElements.ts +0 -17
|
@@ -25,7 +25,6 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
|
25
25
|
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
26
26
|
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
|
27
27
|
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
28
|
-
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
29
28
|
import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
|
|
30
29
|
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
|
|
31
30
|
import {
|
|
@@ -77,10 +76,6 @@ export function useTimelineEditing({
|
|
|
77
76
|
const checkEditable = useTimelineEditGate(canEdit, showToast);
|
|
78
77
|
const checkEditableRef = useRef(checkEditable);
|
|
79
78
|
checkEditableRef.current = checkEditable;
|
|
80
|
-
// Same expanded-row source the hide handlers themselves resolve against
|
|
81
|
-
// (timelineTrackVisibility.ts) — a virtual sub-comp child's track/key
|
|
82
|
-
// only exists here, not in the raw store list canEdit would otherwise miss.
|
|
83
|
-
const timelineRowElements = useTimelineRowElements();
|
|
84
79
|
const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
|
|
85
80
|
// Refuses (no call, no write, no history entry) when any target is
|
|
86
81
|
// blocked; otherwise runs fn as before. Cached by fn identity — like
|
|
@@ -514,14 +509,14 @@ export function useTimelineEditing({
|
|
|
514
509
|
handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
|
|
515
510
|
handleToggleTrackHidden: track(
|
|
516
511
|
guard(
|
|
517
|
-
(trackIndex) =>
|
|
512
|
+
(trackIndex) => timelineElements.filter((el) => el.track === trackIndex),
|
|
518
513
|
handleToggleTrackHidden,
|
|
519
514
|
),
|
|
520
515
|
),
|
|
521
516
|
handleToggleElementHidden: track(
|
|
522
517
|
guard((elementKey) => {
|
|
523
518
|
const keys = new Set(Array.isArray(elementKey) ? elementKey : [elementKey]);
|
|
524
|
-
return
|
|
519
|
+
return timelineElements.filter((el) => keys.has(el.key ?? el.id));
|
|
525
520
|
}, handleToggleElementHidden),
|
|
526
521
|
),
|
|
527
522
|
handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,18 @@ export type { TimelineElement, TimelineTimeRange } from "./player";
|
|
|
56
56
|
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
|
|
57
57
|
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
58
58
|
export type { PreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
59
|
+
export {
|
|
60
|
+
PreviewOverlayProvider,
|
|
61
|
+
usePreviewOverlayContext,
|
|
62
|
+
} from "./components/editor/PreviewOverlayProvider";
|
|
63
|
+
export type {
|
|
64
|
+
PreviewOverlayProviderProps,
|
|
65
|
+
PreviewSnapPreferences,
|
|
66
|
+
} from "./components/editor/PreviewOverlayProvider";
|
|
67
|
+
export { PreviewGuides } from "./components/editor/PreviewGuides";
|
|
68
|
+
export { GridOverlay } from "./components/editor/GridOverlay";
|
|
69
|
+
export { SnapToolbar } from "./components/editor/SnapToolbar";
|
|
70
|
+
export { usePreviewGuidesStore } from "./components/editor/previewGuidesStore";
|
|
59
71
|
|
|
60
72
|
// Editor
|
|
61
73
|
export { SourceEditor } from "./components/editor/SourceEditor";
|
|
@@ -54,26 +54,37 @@ afterEach(() => {
|
|
|
54
54
|
document.body.innerHTML = "";
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
type PlayerProps = Parameters<typeof Player>[0];
|
|
58
|
+
|
|
59
|
+
async function mountPlayer(props: Partial<PlayerProps> = {}) {
|
|
58
60
|
const host = document.createElement("div");
|
|
59
61
|
document.body.append(host);
|
|
60
62
|
root = createRoot(host);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
63
|
+
const render = () =>
|
|
64
|
+
act(async () => {
|
|
65
|
+
root?.render(
|
|
66
|
+
createElement(Player, {
|
|
67
|
+
directUrl: "/api/projects/demo/preview",
|
|
68
|
+
onLoad: vi.fn(),
|
|
69
|
+
suppressLoadingOverlay: true,
|
|
70
|
+
...props,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
await Promise.resolve();
|
|
74
|
+
});
|
|
75
|
+
await render();
|
|
71
76
|
|
|
72
77
|
const player = host.querySelector<TestHyperframesPlayer>("hyperframes-player");
|
|
73
78
|
if (!player) throw new Error("player did not mount");
|
|
74
|
-
|
|
79
|
+
const rerender = (next: Partial<PlayerProps>) => {
|
|
80
|
+
Object.assign(props, next);
|
|
81
|
+
return render();
|
|
82
|
+
};
|
|
83
|
+
return { host, player, rerender };
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
const twoFrames = () => act(async () => void (await new Promise((r) => setTimeout(r, 80))));
|
|
87
|
+
|
|
77
88
|
function createAudioIframe() {
|
|
78
89
|
const iframe = document.createElement("iframe");
|
|
79
90
|
document.body.appendChild(iframe);
|
|
@@ -123,6 +134,7 @@ describe("preview errors", () => {
|
|
|
123
134
|
"player:click",
|
|
124
135
|
"player:shadertransitionstate",
|
|
125
136
|
"player:ready",
|
|
137
|
+
"player:painted",
|
|
126
138
|
"player:error",
|
|
127
139
|
]) {
|
|
128
140
|
expect(lifecycleLog.indexOf(listener)).toBeGreaterThan(-1);
|
|
@@ -131,7 +143,10 @@ describe("preview errors", () => {
|
|
|
131
143
|
});
|
|
132
144
|
|
|
133
145
|
it("retries a failed preview with a fresh player URL", async () => {
|
|
134
|
-
const
|
|
146
|
+
const onPainted = vi.fn();
|
|
147
|
+
const { host, player } = await mountPlayer({ onPainted });
|
|
148
|
+
|
|
149
|
+
act(() => void player.dispatchEvent(new Event("painted")));
|
|
135
150
|
|
|
136
151
|
act(() => {
|
|
137
152
|
player.dispatchEvent(
|
|
@@ -152,6 +167,119 @@ describe("preview errors", () => {
|
|
|
152
167
|
const retryUrl = new URL(player.getAttribute("src") ?? "", window.location.origin);
|
|
153
168
|
expect(retryUrl.searchParams.get("_hfStudioRetry")).toBe("1");
|
|
154
169
|
expect(host.querySelector('[data-testid="composition-preview-error"]')).toBeNull();
|
|
170
|
+
|
|
171
|
+
act(() => void player.dispatchEvent(new Event("painted")));
|
|
172
|
+
expect(onPainted.mock.calls.map(([details]) => details.loadId)).toEqual([1, 2]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("callbacks after the player is already mounted", () => {
|
|
177
|
+
it("runs the latest onLoad on a later load, not the one captured at mount", async () => {
|
|
178
|
+
const first = vi.fn();
|
|
179
|
+
const second = vi.fn();
|
|
180
|
+
const { player, rerender } = await mountPlayer({ onLoad: first });
|
|
181
|
+
await rerender({ onLoad: second });
|
|
182
|
+
|
|
183
|
+
act(
|
|
184
|
+
() => void (player as TestHyperframesPlayer).iframeElement.dispatchEvent(new Event("load")),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
188
|
+
expect(first).not.toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("reports the preview error cause", async () => {
|
|
192
|
+
const onPreviewError = vi.fn();
|
|
193
|
+
const { player } = await mountPlayer({ onPreviewError });
|
|
194
|
+
act(() => {
|
|
195
|
+
player.dispatchEvent(new CustomEvent("error", { detail: { message: "boom" } }));
|
|
196
|
+
});
|
|
197
|
+
expect(onPreviewError).toHaveBeenCalledWith("boom");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("ready to show", () => {
|
|
202
|
+
const loadAndReady = (player: TestHyperframesPlayer) =>
|
|
203
|
+
act(() => {
|
|
204
|
+
player.iframeElement.dispatchEvent(new Event("load"));
|
|
205
|
+
player.dispatchEvent(new Event("ready"));
|
|
206
|
+
});
|
|
207
|
+
const shaderState = (player: TestHyperframesPlayer, loading: boolean) =>
|
|
208
|
+
act(() => {
|
|
209
|
+
player.dispatchEvent(
|
|
210
|
+
new CustomEvent("shadertransitionstate", {
|
|
211
|
+
detail: { state: { loading, ready: !loading } },
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const painted = (player: TestHyperframesPlayer) =>
|
|
217
|
+
act(() => void player.dispatchEvent(new Event("painted")));
|
|
218
|
+
|
|
219
|
+
it("promotes only once the player reports painted, not at ready or assetsready", async () => {
|
|
220
|
+
const onReadyToShowChange = vi.fn();
|
|
221
|
+
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
222
|
+
const el = player as TestHyperframesPlayer;
|
|
223
|
+
|
|
224
|
+
loadAndReady(el);
|
|
225
|
+
await twoFrames();
|
|
226
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
227
|
+
|
|
228
|
+
act(() => void el.dispatchEvent(new Event("assetsready")));
|
|
229
|
+
await twoFrames();
|
|
230
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
231
|
+
|
|
232
|
+
painted(el);
|
|
233
|
+
await twoFrames();
|
|
234
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
|
|
235
|
+
|
|
236
|
+
act(() => void el.iframeElement.dispatchEvent(new Event("load")));
|
|
237
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("reports the document start time with the painted iframe", async () => {
|
|
241
|
+
const onPainted = vi.fn();
|
|
242
|
+
const now = vi.spyOn(performance, "now").mockReturnValue(100);
|
|
243
|
+
const { player } = await mountPlayer({ onPainted });
|
|
244
|
+
|
|
245
|
+
painted(player as TestHyperframesPlayer);
|
|
246
|
+
|
|
247
|
+
expect(onPainted).toHaveBeenCalledWith({
|
|
248
|
+
iframe: (player as TestHyperframesPlayer).iframeElement,
|
|
249
|
+
startedAt: 100,
|
|
250
|
+
loadId: 1,
|
|
251
|
+
});
|
|
252
|
+
now.mockRestore();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("holds while the shader transition loader is up and fires once it clears", async () => {
|
|
256
|
+
const onReadyToShowChange = vi.fn();
|
|
257
|
+
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
258
|
+
const el = player as TestHyperframesPlayer;
|
|
259
|
+
|
|
260
|
+
shaderState(el, true);
|
|
261
|
+
loadAndReady(el);
|
|
262
|
+
shaderState(el, true);
|
|
263
|
+
await twoFrames();
|
|
264
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
265
|
+
|
|
266
|
+
shaderState(el, false);
|
|
267
|
+
painted(el);
|
|
268
|
+
await twoFrames();
|
|
269
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
|
|
270
|
+
|
|
271
|
+
shaderState(el, true);
|
|
272
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("does not fire for a document that failed to load", async () => {
|
|
276
|
+
const onReadyToShowChange = vi.fn();
|
|
277
|
+
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
278
|
+
act(() => {
|
|
279
|
+
player.dispatchEvent(new CustomEvent("error", { detail: { message: "boom" } }));
|
|
280
|
+
});
|
|
281
|
+
await twoFrames();
|
|
282
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
155
283
|
});
|
|
156
284
|
});
|
|
157
285
|
|
|
@@ -26,7 +26,11 @@ interface PlayerProps {
|
|
|
26
26
|
projectId?: string;
|
|
27
27
|
directUrl?: string;
|
|
28
28
|
onLoad: () => void;
|
|
29
|
+
/** Fires once the loaded document is painted and every loader (shader, assets) has cleared. */
|
|
30
|
+
onReadyToShowChange?: (ready: boolean) => void;
|
|
31
|
+
onPreviewError?: (message: string) => void;
|
|
29
32
|
onCompositionLoadingChange?: (loading: boolean) => void;
|
|
33
|
+
onPainted?: (details: { iframe: HTMLIFrameElement; startedAt: number; loadId: number }) => void;
|
|
30
34
|
portrait?: boolean;
|
|
31
35
|
style?: React.CSSProperties;
|
|
32
36
|
suppressLoadingOverlay?: boolean;
|
|
@@ -134,7 +138,10 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
134
138
|
projectId,
|
|
135
139
|
directUrl,
|
|
136
140
|
onLoad,
|
|
141
|
+
onReadyToShowChange,
|
|
142
|
+
onPreviewError,
|
|
137
143
|
onCompositionLoadingChange,
|
|
144
|
+
onPainted,
|
|
138
145
|
portrait,
|
|
139
146
|
style,
|
|
140
147
|
suppressLoadingOverlay,
|
|
@@ -146,13 +153,25 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
146
153
|
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
147
154
|
const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
148
155
|
const retryPreviewRef = useRef<(() => void) | null>(null);
|
|
156
|
+
const loadStartedAtRef = useRef(0);
|
|
157
|
+
const loadIdRef = useRef(0);
|
|
149
158
|
const retryCountRef = useRef(0);
|
|
159
|
+
// Read at call time: this element outlives its first props (a shadow preview is
|
|
160
|
+
// promoted in place), so mount-time closures would go stale.
|
|
161
|
+
const onLoadRef = useRef(onLoad);
|
|
162
|
+
onLoadRef.current = onLoad;
|
|
163
|
+
const onReadyToShowChangeRef = useRef(onReadyToShowChange);
|
|
164
|
+
onReadyToShowChangeRef.current = onReadyToShowChange;
|
|
165
|
+
const onPreviewErrorRef = useRef(onPreviewError);
|
|
166
|
+
onPreviewErrorRef.current = onPreviewError;
|
|
167
|
+
const [loaded, setLoaded] = useState(false);
|
|
150
168
|
const [assetsLoading, setAssetsLoading] = useState(false);
|
|
151
169
|
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
|
152
170
|
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
|
153
171
|
const [assetWaitLong, setAssetWaitLong] = useState(false);
|
|
154
172
|
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
|
155
173
|
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
174
|
+
const [painted, setPainted] = useState(false);
|
|
156
175
|
const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
|
|
157
176
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
158
177
|
|
|
@@ -194,6 +213,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
194
213
|
retryUrl.searchParams.set("_hfStudioRetry", String(retryCountRef.current));
|
|
195
214
|
setPreviewError(null);
|
|
196
215
|
setCompositionLoading(true);
|
|
216
|
+
loadStartedAtRef.current = performance.now();
|
|
217
|
+
loadIdRef.current += 1;
|
|
197
218
|
player.setAttribute("src", retryUrl.pathname + retryUrl.search);
|
|
198
219
|
};
|
|
199
220
|
retryPreviewRef.current = retryPreview;
|
|
@@ -207,15 +228,23 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
207
228
|
setPreviewError(null);
|
|
208
229
|
setCompositionLoading(false);
|
|
209
230
|
};
|
|
231
|
+
const handlePainted = () => {
|
|
232
|
+
setPainted(true);
|
|
233
|
+
onPainted?.({ iframe, startedAt: loadStartedAtRef.current, loadId: loadIdRef.current });
|
|
234
|
+
};
|
|
210
235
|
const handleError = (event: Event) => {
|
|
211
|
-
|
|
236
|
+
const message = readPreviewErrorMessage(event);
|
|
237
|
+
onPreviewErrorRef.current?.(message);
|
|
238
|
+
setPreviewError(message);
|
|
212
239
|
setCompositionLoading(false);
|
|
213
240
|
};
|
|
214
241
|
const handleLoad = () => {
|
|
215
242
|
loadCountRef.current++;
|
|
243
|
+
setLoaded(true);
|
|
216
244
|
setPreviewError(null);
|
|
217
245
|
setShaderTransitionLoading(false);
|
|
218
246
|
setCompositionLoading(true);
|
|
247
|
+
setPainted(false);
|
|
219
248
|
// Reveal animation on reload (hot-reload, composition switch)
|
|
220
249
|
if (loadCountRef.current > 1) {
|
|
221
250
|
container.classList.remove("preview-revealing");
|
|
@@ -224,7 +253,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
224
253
|
const onEnd = () => container.classList.remove("preview-revealing");
|
|
225
254
|
container.addEventListener("animationend", onEnd, { once: true });
|
|
226
255
|
}
|
|
227
|
-
|
|
256
|
+
onLoadRef.current();
|
|
228
257
|
|
|
229
258
|
// Show a loading overlay until every `<video>`/`<audio>` and Lottie
|
|
230
259
|
// asset is ready. Without this users can click play before audio has
|
|
@@ -274,6 +303,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
274
303
|
player.addEventListener("click", preventToggle, { capture: true });
|
|
275
304
|
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
276
305
|
player.addEventListener("ready", handleReady);
|
|
306
|
+
player.addEventListener("painted", handlePainted);
|
|
277
307
|
player.addEventListener("error", handleError);
|
|
278
308
|
|
|
279
309
|
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
|
@@ -291,6 +321,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
291
321
|
player.style.height = "100%";
|
|
292
322
|
player.style.display = "block";
|
|
293
323
|
player.style.background = "transparent";
|
|
324
|
+
loadStartedAtRef.current = performance.now();
|
|
325
|
+
loadIdRef.current += 1;
|
|
294
326
|
player.setAttribute("src", src);
|
|
295
327
|
container.appendChild(player);
|
|
296
328
|
|
|
@@ -313,6 +345,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
313
345
|
player.removeEventListener("click", preventToggle, { capture: true });
|
|
314
346
|
player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
315
347
|
player.removeEventListener("ready", handleReady);
|
|
348
|
+
player.removeEventListener("painted", handlePainted);
|
|
316
349
|
player.removeEventListener("error", handleError);
|
|
317
350
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
318
351
|
assetPollRef.current = null;
|
|
@@ -394,6 +427,29 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
394
427
|
setAssetsLoading(false);
|
|
395
428
|
};
|
|
396
429
|
|
|
430
|
+
const readyToShow =
|
|
431
|
+
loaded &&
|
|
432
|
+
painted &&
|
|
433
|
+
!compositionLoading &&
|
|
434
|
+
!shaderTransitionLoading &&
|
|
435
|
+
!assetsLoading &&
|
|
436
|
+
!previewError;
|
|
437
|
+
// `painted` means the player's own loader has finished fading; two frames of grace on top.
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
if (!readyToShow) {
|
|
440
|
+
onReadyToShowChangeRef.current?.(false);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
let second = 0;
|
|
444
|
+
const first = requestAnimationFrame(() => {
|
|
445
|
+
second = requestAnimationFrame(() => onReadyToShowChangeRef.current?.(true));
|
|
446
|
+
});
|
|
447
|
+
return () => {
|
|
448
|
+
cancelAnimationFrame(first);
|
|
449
|
+
cancelAnimationFrame(second);
|
|
450
|
+
};
|
|
451
|
+
}, [readyToShow]);
|
|
452
|
+
|
|
397
453
|
const showCompositionOverlay =
|
|
398
454
|
!suppressLoadingOverlay &&
|
|
399
455
|
!compositionOverlayDeferred &&
|
|
@@ -1,24 +1,18 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import React, { act, Profiler } from "react";
|
|
4
|
-
import {
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
5
|
import { ShortcutsPanel } from "./ShortcutsPanel";
|
|
6
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
7
7
|
|
|
8
8
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
9
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
afterEach(() => {
|
|
13
|
-
for (const root of roots.splice(0)) act(() => root.unmount());
|
|
14
|
-
document.body.innerHTML = "";
|
|
15
|
-
});
|
|
10
|
+
const { mount } = createHappyDomRootHarness();
|
|
16
11
|
|
|
17
12
|
function renderPanel(onRender = vi.fn()) {
|
|
18
13
|
const host = document.createElement("div");
|
|
19
14
|
document.body.append(host);
|
|
20
|
-
const root =
|
|
21
|
-
roots.push(root);
|
|
15
|
+
const root = mount(host);
|
|
22
16
|
act(() => {
|
|
23
17
|
root.render(
|
|
24
18
|
<Profiler id="shortcuts-panel" onRender={onRender}>
|