@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,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
|
-
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
//
|
|
468
|
-
|
|
469
|
-
//
|
|
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
|
-
|
|
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
|
}
|