@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.
Files changed (145) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
  6. package/dist/index.d.ts +111 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21534 -20683
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +18 -1
  13. package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
  14. package/src/components/EditorShell.selectionSync.test.tsx +57 -67
  15. package/src/components/EditorShell.tsx +62 -51
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  18. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  19. package/src/components/editor/DomEditOverlay.tsx +19 -55
  20. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  21. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  22. package/src/components/editor/GridOverlay.tsx +12 -23
  23. package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  25. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  26. package/src/components/editor/PreviewGuides.tsx +8 -10
  27. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  28. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  29. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  30. package/src/components/editor/PropertyPanel.tsx +4 -3
  31. package/src/components/editor/SnapToolbar.test.tsx +26 -12
  32. package/src/components/editor/SnapToolbar.tsx +30 -59
  33. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  34. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  35. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  36. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  37. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  38. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  39. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  40. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  41. package/src/components/editor/useDomEditNudge.ts +9 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  43. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  44. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  45. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  46. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  47. package/src/components/editor/useVolumeAutomation.ts +57 -16
  48. package/src/components/nle/NLEContext.tsx +40 -4
  49. package/src/components/nle/NLEPreview.test.ts +66 -1
  50. package/src/components/nle/NLEPreview.tsx +88 -44
  51. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  52. package/src/components/nle/PreviewOverlays.tsx +19 -24
  53. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  54. package/src/components/nle/PreviewPane.tsx +17 -2
  55. package/src/components/nle/TimelinePane.tsx +5 -1
  56. package/src/components/nle/previewReadOnly.test.tsx +129 -0
  57. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  58. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  59. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  60. package/src/hooks/appHotkeysDispatch.ts +16 -1
  61. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  62. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  63. package/src/hooks/timelineTrackVisibility.ts +7 -8
  64. package/src/hooks/useAppHotkeys.test.ts +71 -3
  65. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  66. package/src/hooks/useAppHotkeys.ts +4 -0
  67. package/src/hooks/useCompositionDimensions.test.tsx +38 -0
  68. package/src/hooks/useCompositionDimensions.ts +17 -1
  69. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  70. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  71. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  72. package/src/hooks/useDomEditCommits.ts +3 -0
  73. package/src/hooks/useDomEditSession.test.tsx +1 -0
  74. package/src/hooks/useDomEditSession.ts +7 -4
  75. package/src/hooks/useDomEditWiring.ts +40 -40
  76. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  77. package/src/hooks/useDomGeometryCommits.ts +26 -20
  78. package/src/hooks/useFileTree.test.tsx +56 -0
  79. package/src/hooks/useFileTree.ts +8 -1
  80. package/src/hooks/useGestureCommit.test.tsx +29 -0
  81. package/src/hooks/useGestureCommit.ts +23 -0
  82. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  83. package/src/hooks/useInlineTextEdit.ts +11 -2
  84. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  85. package/src/hooks/useSdkSession.ts +46 -10
  86. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  87. package/src/hooks/useServerConnection.ts +81 -8
  88. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  89. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  90. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  91. package/src/hooks/useTimelineEditing.ts +2 -7
  92. package/src/index.ts +12 -0
  93. package/src/player/components/Player.test.ts +141 -13
  94. package/src/player/components/Player.tsx +58 -2
  95. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  96. package/src/player/components/Timeline.tsx +23 -573
  97. package/src/player/components/TimelineCanvas.tsx +9 -30
  98. package/src/player/components/TimelineClip.test.tsx +52 -0
  99. package/src/player/components/TimelineClip.tsx +13 -2
  100. package/src/player/components/TimelineOverlays.test.ts +67 -32
  101. package/src/player/components/TimelineOverlays.tsx +46 -107
  102. package/src/player/components/TimelineProvider.tsx +191 -0
  103. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  104. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  105. package/src/player/components/testRootHarness.ts +18 -0
  106. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  107. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  108. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  109. package/src/player/components/timelineTheme.test.ts +4 -4
  110. package/src/player/components/timelineTheme.ts +34 -30
  111. package/src/player/components/timelineTrackDisplay.ts +1 -4
  112. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  113. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  114. package/src/player/components/useTimelineProviderState.tsx +583 -0
  115. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  116. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  117. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  118. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  119. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  120. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  121. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  122. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  123. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  124. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
  126. package/src/player/store/playerStore.ts +0 -4
  127. package/src/player/store/timelineElement.ts +2 -4
  128. package/src/player/store/timelineResetState.ts +0 -1
  129. package/src/styles/hex-baseline.json +3 -4
  130. package/src/styles/studio.css +7 -7
  131. package/src/styles/theme.css +29 -0
  132. package/src/telemetry/events.test.ts +18 -0
  133. package/src/telemetry/events.ts +10 -0
  134. package/src/telemetry/system.test.ts +49 -0
  135. package/src/telemetry/system.ts +6 -0
  136. package/src/telemetry/tabId.test.ts +43 -0
  137. package/src/telemetry/tabId.ts +44 -0
  138. package/src/utils/previewCompositionSize.ts +26 -0
  139. package/src/utils/reloadDebug.ts +2 -4
  140. package/src/utils/sourcePatcher.ts +1 -1
  141. package/src/utils/studioTelemetry.test.ts +14 -0
  142. package/src/utils/studioTelemetry.ts +4 -0
  143. package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
  144. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  145. 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) => timelineRowElements.filter((el) => el.track === 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 timelineRowElements.filter((el) => keys.has(el.key ?? el.id));
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
- async function mountPlayer() {
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
- await act(async () => {
62
- root?.render(
63
- createElement(Player, {
64
- directUrl: "/api/projects/demo/preview",
65
- onLoad: vi.fn(),
66
- suppressLoadingOverlay: true,
67
- }),
68
- );
69
- await Promise.resolve();
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
- return { host, player };
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 { host, player } = await mountPlayer();
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
- setPreviewError(readPreviewErrorMessage(event));
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
- onLoad();
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 { createRoot, type Root } from "react-dom/client";
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 roots: Root[] = [];
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 = createRoot(host);
21
- roots.push(root);
15
+ const root = mount(host);
22
16
  act(() => {
23
17
  root.render(
24
18
  <Profiler id="shortcuts-panel" onRender={onRender}>