@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.
Files changed (82) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-DFlILHJe.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-CxR7F2YQ.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-BlkJ_Axe.js → index-gbOCDCuZ.js} +194 -194
  6. package/dist/index.d.ts +95 -9
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +15573 -14835
  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 +4 -0
  13. package/src/components/EditorShell.tsx +3 -1
  14. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  15. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +0 -15
  17. package/src/components/editor/GridOverlay.tsx +12 -23
  18. package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  20. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  21. package/src/components/editor/PreviewGuides.tsx +8 -10
  22. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  23. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  24. package/src/components/editor/SnapToolbar.test.tsx +25 -12
  25. package/src/components/editor/SnapToolbar.tsx +30 -59
  26. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  27. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  28. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  29. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  30. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  31. package/src/components/editor/useVolumeAutomation.ts +57 -16
  32. package/src/components/nle/NLEContext.tsx +40 -4
  33. package/src/components/nle/NLEPreview.test.ts +66 -1
  34. package/src/components/nle/NLEPreview.tsx +88 -44
  35. package/src/components/nle/PreviewOverlays.tsx +4 -16
  36. package/src/components/nle/PreviewPane.tsx +17 -2
  37. package/src/components/nle/previewReadOnly.test.tsx +1 -0
  38. package/src/hooks/useCompositionDimensions.test.tsx +38 -0
  39. package/src/hooks/useCompositionDimensions.ts +17 -1
  40. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  41. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  42. package/src/hooks/useFileTree.test.tsx +56 -0
  43. package/src/hooks/useFileTree.ts +8 -1
  44. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  45. package/src/hooks/useSdkSession.ts +46 -10
  46. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  47. package/src/hooks/useServerConnection.ts +81 -8
  48. package/src/index.ts +12 -0
  49. package/src/player/components/Player.test.ts +141 -13
  50. package/src/player/components/Player.tsx +58 -2
  51. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  52. package/src/player/components/Timeline.tsx +23 -572
  53. package/src/player/components/TimelineCanvas.tsx +9 -30
  54. package/src/player/components/TimelineOverlays.test.ts +67 -32
  55. package/src/player/components/TimelineOverlays.tsx +46 -107
  56. package/src/player/components/TimelineProvider.tsx +191 -0
  57. package/src/player/components/testRootHarness.ts +18 -0
  58. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  59. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  60. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  61. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  62. package/src/player/components/useTimelineProviderState.tsx +583 -0
  63. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  64. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  65. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  66. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  67. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  68. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  69. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  70. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
  71. package/src/telemetry/events.test.ts +18 -0
  72. package/src/telemetry/events.ts +10 -0
  73. package/src/telemetry/system.test.ts +49 -0
  74. package/src/telemetry/system.ts +6 -0
  75. package/src/telemetry/tabId.test.ts +43 -0
  76. package/src/telemetry/tabId.ts +44 -0
  77. package/src/utils/previewCompositionSize.ts +26 -0
  78. package/src/utils/reloadDebug.ts +2 -4
  79. package/src/utils/sourcePatcher.ts +1 -1
  80. package/src/utils/studioTelemetry.test.ts +14 -0
  81. package/src/utils/studioTelemetry.ts +4 -0
  82. package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
@@ -0,0 +1,457 @@
1
+ // @vitest-environment happy-dom
2
+ // A full-reload edit must never hide the live iframe until the shadow has painted.
3
+
4
+ import React, { act } from "react";
5
+ import { createRoot } from "react-dom/client";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { SHADOW_READY_TIMEOUT_MS } from "./useShadowPreviewReload";
8
+ import { usePlayerStore } from "../store/playerStore";
9
+ import { NLEProvider, useNLEContext, type NLEContextValue } from "../../components/nle/NLEContext";
10
+ import {
11
+ makeAdapterWindow,
12
+ makeFakeIframe,
13
+ renderTimelinePlayerHarness,
14
+ resetPlayerStore,
15
+ } from "./timelinePlayerTestHarness";
16
+
17
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
18
+
19
+ vi.mock("../../utils/gsapSoftReload", async (importOriginal) => ({
20
+ ...(await importOriginal<typeof import("../../utils/gsapSoftReload")>()),
21
+ ensureMotionPathPluginLoaded: vi.fn(),
22
+ }));
23
+
24
+ afterEach(() => {
25
+ vi.useRealTimers();
26
+ vi.restoreAllMocks();
27
+ document.body.innerHTML = "";
28
+ resetPlayerStore();
29
+ });
30
+
31
+ function makeLiveIframe(): HTMLIFrameElement {
32
+ const iframe = makeFakeIframe(makeAdapterWindow().win);
33
+ iframe.src = "http://localhost/api/projects/demo/preview";
34
+ return iframe;
35
+ }
36
+
37
+ function makeShadowWithSpies() {
38
+ const { adapter, win } = makeAdapterWindow();
39
+ adapter.seek = vi.fn(adapter.seek);
40
+ const iframe = makeFakeIframe(win);
41
+ iframe.src = "http://localhost/api/projects/demo/preview?_t=1";
42
+ return { adapter, iframe };
43
+ }
44
+
45
+ describe("useTimelinePlayer shadow reload", () => {
46
+ it("never hides or removes the live iframe before the shadow signals it has painted", () => {
47
+ const { getApi, root } = renderTimelinePlayerHarness();
48
+ const liveIframe = makeLiveIframe();
49
+
50
+ act(() => {
51
+ getApi().iframeRef.current = liveIframe;
52
+ getApi().onIframeLoad();
53
+ });
54
+
55
+ expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
56
+ expect(liveIframe.style.visibility).toBe("");
57
+
58
+ // A lane-move drop persists, then bumps refreshKey -> refreshPlayer().
59
+ act(() => {
60
+ getApi().refreshPlayer();
61
+ });
62
+
63
+ // The live iframe must be untouched: still the ref target, still visible,
64
+ // still in the slot list as "live". A shadow slot appears alongside it —
65
+ // this is the queued background reload, not a swap.
66
+ expect(getApi().iframeRef.current).toBe(liveIframe);
67
+ expect(liveIframe.style.visibility).toBe("");
68
+ expect(getApi().previewSlots).toHaveLength(2);
69
+ const liveSlot = getApi().previewSlots.find((s) => s.role === "live");
70
+ const shadowSlot = getApi().previewSlots.find((s) => s.role === "shadow");
71
+ expect(liveSlot).toEqual({ gen: 0, role: "live" });
72
+ expect(shadowSlot?.role).toBe("shadow");
73
+ expect(shadowSlot?.url).toBeTruthy();
74
+
75
+ // The shadow iframe loads and its restore-seek finds a ready adapter —
76
+ // the readiness signal this whole mechanism hangs off.
77
+ const shadowIframe = makeLiveIframe();
78
+ act(() => {
79
+ getApi().setShadowIframeNode(shadowIframe);
80
+ });
81
+
82
+ // Still untouched right up to the instant before the ready signal.
83
+ expect(getApi().iframeRef.current).toBe(liveIframe);
84
+ expect(liveIframe.style.visibility).toBe("");
85
+
86
+ act(() => {
87
+ getApi().onShadowIframeLoad(shadowSlot!.gen);
88
+ getApi().onShadowReadyChange(shadowSlot!.gen, true);
89
+ });
90
+
91
+ // Promotion: a single atomic swap. Exactly one live slot, pointing at the
92
+ // shadow's iframe; the old live slot is gone, not merely hidden.
93
+ expect(getApi().previewSlots).toEqual([
94
+ { gen: shadowSlot!.gen, role: "live", url: shadowSlot!.url },
95
+ ]);
96
+ expect(getApi().iframeRef.current).toBe(shadowIframe);
97
+
98
+ unmount(root);
99
+ });
100
+
101
+ it("replaces a superseded shadow rather than accumulating extra slots (repeated lane-moves)", () => {
102
+ const { getApi, root } = renderTimelinePlayerHarness();
103
+ const liveIframe = makeLiveIframe();
104
+ act(() => {
105
+ getApi().iframeRef.current = liveIframe;
106
+ getApi().onIframeLoad();
107
+ });
108
+
109
+ act(() => {
110
+ getApi().refreshPlayer();
111
+ });
112
+ const firstShadowGen = getApi().previewSlots.find((s) => s.role === "shadow")?.gen;
113
+ expect(firstShadowGen).toBeDefined();
114
+
115
+ // The first shadow's iframe finishes loading and is fully adapter-ready
116
+ // (a real "would have painted correctly" candidate) before it is
117
+ // superseded.
118
+ const firstShadow = makeShadowWithSpies();
119
+ act(() => {
120
+ getApi().setShadowIframeNode(firstShadow.iframe);
121
+ });
122
+
123
+ // A second edit lands before the first shadow's readiness was consumed.
124
+ act(() => {
125
+ getApi().refreshPlayer();
126
+ });
127
+
128
+ // Never more than one extra (shadow) slot alive at a time.
129
+ expect(getApi().previewSlots).toHaveLength(2);
130
+ const secondShadow = getApi().previewSlots.find((s) => s.role === "shadow");
131
+ expect(secondShadow?.gen).not.toBe(firstShadowGen);
132
+
133
+ // The first shadow's readiness signal now arrives late (its own iframe is
134
+ // still referenced, fully ready). It must NOT promote — that content was
135
+ // replaced before it ever painted, and gen no longer matches the current
136
+ // pending shadow.
137
+ act(() => {
138
+ getApi().onShadowIframeLoad(firstShadowGen!);
139
+ getApi().onShadowReadyChange(firstShadowGen!, true);
140
+ });
141
+ expect(getApi().previewSlots.find((s) => s.role === "live")).toEqual({ gen: 0, role: "live" });
142
+ expect(getApi().iframeRef.current).toBe(liveIframe);
143
+ expect(firstShadow.adapter.seek).not.toHaveBeenCalled();
144
+
145
+ unmount(root);
146
+ });
147
+ });
148
+
149
+ describe("drop that removes a covered clip (reloadPreview -> refreshKey bump)", () => {
150
+ it("swaps in the reloaded document without ever hiding the live iframe", async () => {
151
+ vi.stubGlobal(
152
+ "fetch",
153
+ vi.fn(async () => new Response("{}", { status: 404 })),
154
+ );
155
+ let ctx: NLEContextValue | null = null;
156
+ const Probe = () => {
157
+ ctx = useNLEContext();
158
+ return null;
159
+ };
160
+ const host = document.createElement("div");
161
+ document.body.append(host);
162
+ const root = createRoot(host);
163
+ const render = (refreshKey: number) =>
164
+ act(async () => {
165
+ root.render(
166
+ React.createElement(
167
+ NLEProvider,
168
+ { projectId: "demo", refreshKey },
169
+ React.createElement(Probe),
170
+ ),
171
+ );
172
+ await Promise.resolve();
173
+ });
174
+ await render(0);
175
+ const live = makeLiveIframe();
176
+ await act(async () => {
177
+ ctx!.iframeRef.current = live;
178
+ ctx!.onIframeLoad();
179
+ });
180
+
181
+ // runPlacementSteps ends in exactly one reloadPreview: App bumps refreshKey.
182
+ await render(1);
183
+ expect(live.style.visibility).toBe("");
184
+ expect(ctx!.iframeRef.current).toBe(live);
185
+ const shadow = ctx!.previewSlots.find((s) => s.role === "shadow");
186
+ expect(shadow).toBeDefined();
187
+ expect(ctx!.previewSlots).toHaveLength(2);
188
+
189
+ const shadowIframe = makeLiveIframe();
190
+ await act(async () => {
191
+ ctx!.setShadowIframeNode(shadowIframe);
192
+ ctx!.onShadowIframeLoad(shadow!.gen);
193
+ ctx!.onShadowReadyChange(shadow!.gen, true);
194
+ });
195
+ expect(ctx!.previewSlots).toEqual([{ ...shadow!, role: "live" }]);
196
+ expect(ctx!.iframeRef.current).toBe(shadowIframe);
197
+
198
+ await act(async () => root.unmount());
199
+ vi.unstubAllGlobals();
200
+ });
201
+ });
202
+
203
+ describe("shadow reload readiness and failure", () => {
204
+ function beginReload(options: Parameters<typeof renderTimelinePlayerHarness>[0] = {}) {
205
+ const harness = renderTimelinePlayerHarness(options);
206
+ const { adapter, win } = makeAdapterWindow();
207
+ adapter.seek = vi.fn(adapter.seek);
208
+ const live = makeFakeIframe(win);
209
+ act(() => {
210
+ harness.getApi().iframeRef.current = live;
211
+ harness.getApi().onIframeLoad();
212
+ });
213
+ adapter.pause.mockClear();
214
+ act(() => harness.getApi().refreshPlayer());
215
+ const gen = harness.getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
216
+ return { ...harness, live, liveAdapter: adapter, gen };
217
+ }
218
+
219
+ it("pauses the live adapter when a reload starts", () => {
220
+ const { liveAdapter, root } = beginReload();
221
+ expect(liveAdapter.pause).toHaveBeenCalled();
222
+ unmount(root);
223
+ });
224
+
225
+ it("does not promote a shadow whose loader is still up, and promotes once it clears", () => {
226
+ const { getApi, live, gen, root } = beginReload();
227
+ const shadow = makeShadowWithSpies();
228
+ act(() => {
229
+ getApi().setShadowIframeNode(shadow.iframe);
230
+ getApi().onShadowIframeLoad(gen);
231
+ });
232
+ expect(getApi().iframeRef.current).toBe(live);
233
+ expect(getApi().previewSlots).toHaveLength(2);
234
+
235
+ act(() => getApi().onShadowReadyChange(gen, true));
236
+ expect(getApi().iframeRef.current).toBe(shadow.iframe);
237
+ expect(getApi().previewSlots).toEqual([{ gen, role: "live", url: expect.any(String) }]);
238
+ unmount(root);
239
+ });
240
+
241
+ it("promotes when the loader clears before the adapter is ready", () => {
242
+ const { getApi, gen, root } = beginReload();
243
+ const shadow = makeShadowWithSpies();
244
+ act(() => {
245
+ getApi().setShadowIframeNode(shadow.iframe);
246
+ getApi().onShadowReadyChange(gen, true);
247
+ });
248
+ expect(getApi().previewSlots).toHaveLength(2);
249
+ act(() => getApi().onShadowIframeLoad(gen));
250
+ expect(getApi().iframeRef.current).toBe(shadow.iframe);
251
+ unmount(root);
252
+ });
253
+
254
+ it("drops a shadow that never becomes ready, keeps the live frame and reports why", () => {
255
+ vi.useFakeTimers();
256
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
257
+ const onPreviewReloadFailed = vi.fn();
258
+ const { getApi, live, root } = beginReload({ onPreviewReloadFailed });
259
+ expect(getApi().previewSlots).toHaveLength(2);
260
+
261
+ act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS));
262
+
263
+ expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
264
+ expect(getApi().iframeRef.current).toBe(live);
265
+ expect(live.style.visibility).toBe("");
266
+ expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
267
+ expect(consoleError).toHaveBeenCalled();
268
+ unmount(root);
269
+ });
270
+
271
+ it("keeps a slow shadow pending past 5s and fails it only at the single budget", () => {
272
+ vi.useFakeTimers();
273
+ vi.spyOn(console, "error").mockImplementation(() => {});
274
+ const onPreviewReloadFailed = vi.fn();
275
+ const { getApi, gen, root } = beginReload({ onPreviewReloadFailed });
276
+ const silent = makeFakeIframe({});
277
+ act(() => {
278
+ getApi().setShadowIframeNode(silent);
279
+ getApi().onShadowIframeLoad(gen);
280
+ });
281
+
282
+ act(() => void vi.advanceTimersByTime(5001));
283
+ expect(getApi().previewSlots).toHaveLength(2);
284
+ expect(onPreviewReloadFailed).not.toHaveBeenCalled();
285
+
286
+ act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS));
287
+ expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
288
+ expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
289
+ unmount(root);
290
+ });
291
+
292
+ it("drops a shadow whose document reports an error and reports the cause", () => {
293
+ vi.spyOn(console, "error").mockImplementation(() => {});
294
+ const onPreviewReloadFailed = vi.fn();
295
+ const { getApi, live, gen, root } = beginReload({ onPreviewReloadFailed });
296
+
297
+ act(() => getApi().onShadowError(gen, "Composition timeline not found after 8s"));
298
+
299
+ expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
300
+ expect(getApi().iframeRef.current).toBe(live);
301
+ expect(onPreviewReloadFailed).toHaveBeenCalledWith(
302
+ expect.stringContaining("Composition timeline not found after 8s"),
303
+ );
304
+ unmount(root);
305
+ });
306
+
307
+ it("runs no load side effects for a superseded shadow", () => {
308
+ const { getApi, gen, root } = beginReload();
309
+ usePlayerStore.getState().setCurrentTime(7);
310
+ const stale = makeShadowWithSpies();
311
+ act(() => {
312
+ getApi().setShadowIframeNode(stale.iframe);
313
+ getApi().refreshPlayer();
314
+ });
315
+ act(() => getApi().onShadowIframeLoad(gen));
316
+ expect(stale.adapter.seek).not.toHaveBeenCalled();
317
+ unmount(root);
318
+ });
319
+ });
320
+
321
+ describe("shadow reload store ownership and readiness", () => {
322
+ function setup(options?: Parameters<typeof renderTimelinePlayerHarness>[0]) {
323
+ const harness = renderTimelinePlayerHarness(options);
324
+ const live = makeAdapterWindow({ duration: 10 });
325
+ const liveIframe = makeFakeIframe(live.win);
326
+ act(() => {
327
+ harness.getApi().iframeRef.current = liveIframe;
328
+ harness.getApi().onIframeLoad();
329
+ });
330
+ act(() => harness.getApi().refreshPlayer());
331
+ const gen = harness.getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
332
+ const shadow = makeAdapterWindow({ duration: 42 });
333
+ const shadowIframe = makeFakeIframe(shadow.win);
334
+ act(() => harness.getApi().setShadowIframeNode(shadowIframe));
335
+ return { ...harness, gen, shadowIframe, liveIframe };
336
+ }
337
+
338
+ it("does not promote when the loader re-raises after it had cleared", () => {
339
+ const { getApi, gen, liveIframe, root } = setup();
340
+ act(() => {
341
+ getApi().onShadowReadyChange(gen, true);
342
+ getApi().onShadowReadyChange(gen, false);
343
+ getApi().onShadowIframeLoad(gen);
344
+ });
345
+ expect(getApi().iframeRef.current).toBe(liveIframe);
346
+ act(() => getApi().onShadowReadyChange(gen, true));
347
+ expect(getApi().iframeRef.current).not.toBe(liveIframe);
348
+ unmount(root);
349
+ });
350
+
351
+ it("keeps the old timeline in the store until the shadow is promoted", () => {
352
+ const { getApi, gen, root } = setup();
353
+ const before = usePlayerStore.getState().duration;
354
+ act(() => getApi().onShadowIframeLoad(gen));
355
+ expect(usePlayerStore.getState().duration).toBe(before);
356
+ act(() => getApi().onShadowReadyChange(gen, true));
357
+ expect(usePlayerStore.getState().duration).toBe(42);
358
+ unmount(root);
359
+ });
360
+
361
+ it("never writes a failed shadow's timeline into the store", () => {
362
+ vi.spyOn(console, "error").mockImplementation(() => {});
363
+ const { getApi, gen, root } = setup();
364
+ const before = usePlayerStore.getState().duration;
365
+ act(() => getApi().onShadowIframeLoad(gen));
366
+ act(() => getApi().onShadowError(gen, "boom"));
367
+ expect(usePlayerStore.getState().duration).toBe(before);
368
+ unmount(root);
369
+ });
370
+
371
+ it("a composition switch clears the refresh flags so the next load restores the store playhead", () => {
372
+ const { getApi, liveIframe, root } = setup();
373
+ const seek = vi.fn();
374
+ (liveIframe.contentWindow as unknown as { __player: { seek: unknown } }).__player.seek = seek;
375
+ usePlayerStore.getState().setCurrentTime(7);
376
+ act(() => getApi().resetPreviewSlots());
377
+ act(() => getApi().onIframeLoad());
378
+ expect(seek).toHaveBeenCalledWith(7);
379
+ unmount(root);
380
+ });
381
+
382
+ it("uses the latest failure callback for a timer armed before it changed", () => {
383
+ vi.useFakeTimers();
384
+ vi.spyOn(console, "error").mockImplementation(() => {});
385
+ const first = vi.fn();
386
+ const latest = vi.fn();
387
+ const { getApi, rerender, root } = setup({ onPreviewReloadFailed: first });
388
+ expect(getApi().previewSlots).toHaveLength(2);
389
+ rerender({ onPreviewReloadFailed: latest });
390
+ act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS));
391
+ expect(latest).toHaveBeenCalled();
392
+ expect(first).not.toHaveBeenCalled();
393
+ unmount(root);
394
+ });
395
+ });
396
+
397
+ describe("NLEProvider iframe ref notifications", () => {
398
+ it("tells the consumer about the reloaded iframe once, on promotion", async () => {
399
+ vi.stubGlobal(
400
+ "fetch",
401
+ vi.fn(async () => new Response("{}", { status: 404 })),
402
+ );
403
+ const { ensureMotionPathPluginLoaded } = await import("../../utils/gsapSoftReload");
404
+ const onIframeRef = vi.fn();
405
+ let ctx: NLEContextValue | null = null;
406
+ const Probe = () => {
407
+ ctx = useNLEContext();
408
+ return null;
409
+ };
410
+ const host = document.createElement("div");
411
+ document.body.append(host);
412
+ const root = createRoot(host);
413
+ const render = (refreshKey: number) =>
414
+ act(async () => {
415
+ root.render(
416
+ React.createElement(
417
+ NLEProvider,
418
+ { projectId: "demo", refreshKey, onIframeRef },
419
+ React.createElement(Probe),
420
+ ),
421
+ );
422
+ await Promise.resolve();
423
+ });
424
+ await render(0);
425
+ const live = makeLiveIframe();
426
+ await act(async () => {
427
+ ctx!.iframeRef.current = live;
428
+ ctx!.onIframeLoad();
429
+ });
430
+ onIframeRef.mockClear();
431
+ vi.mocked(ensureMotionPathPluginLoaded).mockClear();
432
+
433
+ await render(1);
434
+ const callsAtBegin = onIframeRef.mock.calls.length;
435
+ expect(onIframeRef.mock.calls.every(([iframe]) => iframe === live)).toBe(true);
436
+
437
+ const shadow = ctx!.previewSlots.find((s) => s.role === "shadow")!;
438
+ const shadowIframe = makeLiveIframe();
439
+ await act(async () => {
440
+ ctx!.setShadowIframeNode(shadowIframe);
441
+ ctx!.onShadowIframeLoad(shadow.gen);
442
+ ctx!.onShadowReadyChange(shadow.gen, true);
443
+ });
444
+ expect(onIframeRef).toHaveBeenCalledTimes(callsAtBegin + 1);
445
+ expect(onIframeRef).toHaveBeenLastCalledWith(shadowIframe);
446
+ expect(ensureMotionPathPluginLoaded).toHaveBeenCalledWith(shadowIframe);
447
+
448
+ await act(async () => root.unmount());
449
+ vi.unstubAllGlobals();
450
+ });
451
+ });
452
+
453
+ function unmount(root: ReturnType<typeof createRoot>) {
454
+ act(() => {
455
+ root.unmount();
456
+ });
457
+ }
@@ -3,6 +3,8 @@ import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerS
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
5
5
  import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
6
+ import { useShadowPreviewReload } from "./useShadowPreviewReload";
7
+ import { resolvePlaybackAdapter } from "./playbackAdapterResolution";
6
8
  import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop";
7
9
  import { logReload } from "../../utils/reloadDebug";
8
10
 
@@ -24,18 +26,8 @@ export {
24
26
  } from "../lib/playbackShortcuts";
25
27
 
26
28
  import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
27
- import {
28
- getAdapterDuration,
29
- wrapTimeline,
30
- getDefaultStaticSeekPlaybackClock,
31
- releaseStaticSeekCache,
32
- resolveStaticSeekFallback,
33
- type StaticSeekCacheEntry,
34
- } from "../lib/playbackAdapter";
35
- import {
36
- readTimelineDurationFromDocument,
37
- mergeTimelineElementsPreservingDowngrades,
38
- } from "../lib/timelineDOM";
29
+ import { releaseStaticSeekCache, type StaticSeekCacheEntry } from "../lib/playbackAdapter";
30
+ import { mergeTimelineElementsPreservingDowngrades } from "../lib/timelineDOM";
39
31
  import { normalizeToZones } from "../components/timelineZones";
40
32
  import { applyPreviewAudioFlags, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
41
33
  import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
@@ -47,7 +39,17 @@ import { createPreviewMessageHandler } from "./previewMessageRouter";
47
39
  import { timelineElementsChanged } from "./timelinePlayerSync";
48
40
  import { safeContentDocument } from "./timelineSyncHydration";
49
41
 
50
- export function useTimelinePlayer() {
42
+ export interface UseTimelinePlayerOptions {
43
+ /** Runs right after a reloaded preview becomes the live iframe. */
44
+ onShadowPromoted?: () => void;
45
+ /** A reload was abandoned (cause in the message); the previous preview is still showing. */
46
+ onPreviewReloadFailed?: (message: string) => void;
47
+ }
48
+
49
+ export function useTimelinePlayer({
50
+ onShadowPromoted,
51
+ onPreviewReloadFailed,
52
+ }: UseTimelinePlayerOptions = {}) {
51
53
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
52
54
  const rafRef = useRef<number>(0);
53
55
  const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
@@ -128,84 +130,23 @@ export function useTimelinePlayer() {
128
130
  [setElements, requestTimelineReady, setDuration],
129
131
  );
130
132
 
131
- // Pre-existing dispatcher complexity — surfaced by this PR's line shifts, not new logic.
132
- // fallow-ignore-next-line complexity
133
- const getAdapter = useCallback((): PlaybackAdapter | null => {
134
- try {
135
- const iframe = iframeRef.current;
136
- const win = iframe?.contentWindow as IframeWindow | null;
137
- if (!iframe || !win) return null;
138
-
139
- const playerAdapter =
140
- win.__player && typeof win.__player.play === "function" ? win.__player : null;
141
- const docDuration = readTimelineDurationFromDocument(iframe.contentDocument);
142
- const adapterDur = getAdapterDuration(playerAdapter);
143
-
144
- if (adapterDur > 0 && docDuration <= adapterDur) {
145
- releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
146
- return playerAdapter;
147
- }
148
-
149
- let timelineAdapter: PlaybackAdapter | null = null;
150
- if (win.__timeline) {
151
- const adapter = wrapTimeline(win.__timeline);
152
- const dur = getAdapterDuration(adapter);
153
- if (dur > 0 && docDuration <= dur) {
154
- releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
155
- return adapter;
156
- }
157
- if (dur > 0) timelineAdapter ??= adapter;
158
- }
159
-
160
- if (win.__timelines) {
161
- const keys = Object.keys(win.__timelines);
162
- if (keys.length > 0) {
163
- // Resolve the root composition id from the DOM — the outermost [data-composition-id]
164
- // is the master; otherwise Object.keys() order lets a sub-composition hijack transport.
165
- const rootId = iframe?.contentDocument
166
- ?.querySelector("[data-composition-id]")
167
- ?.getAttribute("data-composition-id");
168
- const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1];
169
- const adapter = wrapTimeline(win.__timelines[key]);
170
- const dur = getAdapterDuration(adapter);
171
- if (dur > 0 && docDuration <= dur) {
172
- releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
173
- return adapter;
174
- }
175
- if (dur > 0) timelineAdapter ??= adapter;
176
- }
177
- }
178
-
179
- // The document timeline extends past every native adapter's duration.
180
- // Wrap the best available adapter with the effective duration so the
181
- // seek slider, seek clamping, and duration display cover the full range.
182
- const bestAdapter = playerAdapter ?? timelineAdapter;
183
- const effectiveDuration = Math.max(
184
- usePlayerStore.getState().duration,
185
- docDuration,
186
- adapterDur,
187
- );
188
- if (
189
- bestAdapter &&
190
- effectiveDuration > 0 &&
191
- ("renderSeek" in bestAdapter || typeof bestAdapter.seek === "function")
192
- ) {
193
- return resolveStaticSeekFallback({
133
+ const getAdapter = useCallback(
134
+ (overrideIframe?: HTMLIFrameElement | null): PlaybackAdapter | null => {
135
+ try {
136
+ // undefined means "no override"; an explicit null override means "no adapter".
137
+ const iframe = overrideIframe !== undefined ? overrideIframe : iframeRef.current;
138
+ const win = iframe?.contentWindow as IframeWindow | null;
139
+ if (!iframe || !win) return null;
140
+ return resolvePlaybackAdapter(iframe, win, {
194
141
  cache: staticSeekAdapterRef,
195
142
  warned: staticSeekWarnedRef,
196
- bestAdapter,
197
- effectiveDuration,
198
- docDuration,
199
- clock: getDefaultStaticSeekPlaybackClock(win),
200
- getPlaybackRate: () => usePlayerStore.getState().playbackRate,
201
143
  });
144
+ } catch {
145
+ return null;
202
146
  }
203
-
204
- return bestAdapter;
205
- } catch {
206
- return null;
207
- }
208
- }, []);
147
+ },
148
+ [],
149
+ );
209
150
 
210
151
  const { startRAFLoop, stopRAFLoop, stopReverseLoop } = useTimelinePlayerLoop({
211
152
  rafRef,
@@ -435,6 +376,32 @@ export function useTimelinePlayer() {
435
376
  attachIframeShortcutListeners,
436
377
  applyPreviewAudioState,
437
378
  });
379
+
380
+ // Full-reload edits load behind a hidden shadow iframe (useShadowPreviewReload.ts).
381
+ const {
382
+ previewSlots,
383
+ onShadowIframeLoad,
384
+ onShadowReadyChange,
385
+ onShadowError,
386
+ setShadowIframeNode,
387
+ beginShadowReload,
388
+ resetPreviewSlots,
389
+ } = useShadowPreviewReload({
390
+ iframeRef,
391
+ getAdapter,
392
+ pendingSeekRef,
393
+ isRefreshingRef,
394
+ syncTimelineElements,
395
+ setDuration,
396
+ setCurrentTime,
397
+ requestTimelineReady,
398
+ setIsPlaying,
399
+ attachIframeShortcutListeners,
400
+ applyPreviewAudioState,
401
+ onPromoted: onShadowPromoted,
402
+ onReloadFailed: onPreviewReloadFailed,
403
+ });
404
+
438
405
  const saveSeekPosition = useCallback(() => {
439
406
  // Never DEGRADE the saved position. Overlapping reloads (e.g. an external
440
407
  // file drop = upload reload + insert reload back-to-back) call this while
@@ -464,22 +431,15 @@ export function useTimelinePlayer() {
464
431
  if (!iframe) return;
465
432
  logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
466
433
  saveSeekPosition();
467
- // Hide the iframe across the full reload so the user never sees the reloading
468
- // document's RAW DOM (every clip stacked and visible) in the window between the
469
- // new document parsing and the runtime initializing + seeking. initializeAdapter
470
- // reveals it again right after its restore seek renders the correct frame.
471
- // Tradeoff: this shows the parent stage background (a brief "freeze"/blank, on
472
- // the order of the reload time ~100-300ms) INSTEAD of the all-clips flash. A
473
- // blank is far less jarring than a burst of every asset appearing at once.
474
- // Only the FULL-reload edits (drops/inserts) hit this — timing edits now take
475
- // the soft-reload path and never touch refreshPlayer.
476
- iframe.style.visibility = "hidden";
434
+ // The old iframe is no longer navigated away, so stop its playback (and audio) here.
435
+ getAdapter()?.pause();
436
+ // The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
477
437
  const src = iframe.src;
478
438
  const url = new URL(src, window.location.origin);
479
439
  url.searchParams.set("_t", String(Date.now()));
480
440
  applyPreviewVariablesToUrl(url);
481
- iframe.src = url.toString();
482
- }, [saveSeekPosition]);
441
+ beginShadowReload(url.toString());
442
+ }, [saveSeekPosition, getAdapter, beginShadowReload]);
483
443
  const getAdapterRef = useRef(getAdapter);
484
444
  getAdapterRef.current = getAdapter;
485
445
 
@@ -557,5 +517,11 @@ export function useTimelinePlayer() {
557
517
  refreshPlayer,
558
518
  saveSeekPosition,
559
519
  resetPlayer,
520
+ previewSlots,
521
+ onShadowIframeLoad,
522
+ onShadowReadyChange,
523
+ onShadowError,
524
+ setShadowIframeNode,
525
+ resetPreviewSlots,
560
526
  };
561
527
  }