@hyperframes/studio 0.8.75 → 0.8.77
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-BSDTJ9EF.js +464 -0
- package/dist/assets/{index-BlUYw1MB.js → index-2GgHjQq-.js} +163 -163
- package/dist/assets/{index-0w6iIlwZ.css → index-BWJqxeaH.css} +1 -1
- package/dist/assets/{index-BiFKcNjs.js → index-CsFCRdMo.js} +1 -1
- package/dist/assets/{index-HjexPsny.js → index-DcxWQ7Za.js} +1 -1
- package/dist/{chunk-7BP32G62.js → chunk-5VANA2TG.js} +12 -1
- package/dist/chunk-5VANA2TG.js.map +1 -0
- package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-IGPLTI5E.js} +4 -2
- package/dist/index.d.ts +34 -69
- package/dist/index.html +2 -2
- package/dist/index.js +5287 -5277
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanels.types.ts +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +5 -0
- package/src/components/editor/LayersPanel.tsx +3 -14
- package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingElement.ts +13 -0
- package/src/components/editor/domEditingLayers.test.ts +27 -0
- package/src/components/editor/domEditingLayers.ts +9 -0
- package/src/components/editor/gsapLivePreview.test.ts +63 -0
- package/src/components/editor/gsapLivePreview.ts +2 -14
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
- package/src/components/editor/overlayFrameLoop.test.ts +16 -0
- package/src/components/editor/overlayFrameLoop.ts +11 -0
- package/src/components/editor/propertyPanelHelpers.ts +4 -6
- package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
- package/src/components/nle/NLEPreview.test.ts +202 -22
- package/src/components/nle/NLEPreview.tsx +65 -46
- package/src/components/nle/PreviewPoster.tsx +99 -0
- package/src/components/nle/PreviewZoomOverlay.tsx +111 -0
- package/src/components/nle/previewZoom.test.ts +42 -0
- package/src/components/nle/previewZoom.ts +33 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
- package/src/components/panels/VariablesPanel.tsx +0 -2
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
- package/src/components/sidebar/CompositionsTab.test.ts +4 -6
- package/src/components/sidebar/CompositionsTab.tsx +28 -21
- package/src/components/studioColorGradingScope.ts +0 -3
- package/src/hooks/domEditDeleteMembers.ts +0 -2
- package/src/hooks/gsapScriptCommitTypes.ts +0 -2
- package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/timelineAudioGroupCreate.ts +0 -1
- package/src/hooks/timelineEditingHelpers.ts +0 -3
- package/src/hooks/timelineTimingSync.ts +0 -1
- package/src/hooks/timelineTrackVisibility.ts +0 -1
- package/src/hooks/useBlockHandlers.ts +0 -2
- package/src/hooks/useClipboard.test.ts +69 -10
- package/src/hooks/useClipboard.ts +28 -19
- package/src/hooks/useDomEditCommits.test.tsx +0 -1
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useDomEditWiring.ts +1 -3
- package/src/hooks/useEditHistoryActions.test.tsx +31 -14
- package/src/hooks/useEditHistoryActions.ts +12 -7
- package/src/hooks/useEditorSave.ts +0 -3
- package/src/hooks/useElementLifecycleOps.ts +0 -1
- package/src/hooks/useEnableKeyframes.test.ts +9 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useGestureRecording.test.tsx +1 -1
- package/src/hooks/useGestureRecording.ts +6 -10
- package/src/hooks/useGroupCommits.ts +0 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -1
- package/src/hooks/useGsapTweenCache.element.test.tsx +39 -0
- package/src/hooks/useGsapTweenCache.ts +5 -13
- package/src/hooks/useLintModal.test.tsx +34 -1
- package/src/hooks/useLintModal.ts +32 -30
- package/src/hooks/usePersistentEditHistory.test.ts +221 -315
- package/src/hooks/usePersistentEditHistory.ts +155 -342
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
- package/src/hooks/usePreviewPersistence.ts +0 -2
- package/src/hooks/useProjectCompositionVariables.ts +1 -3
- package/src/hooks/useRazorSplit.history.test.tsx +46 -67
- package/src/hooks/useRazorSplit.test.ts +17 -47
- package/src/hooks/useRenderClipContent.test.ts +29 -0
- package/src/hooks/useRenderClipContent.ts +6 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
- package/src/hooks/useSdkSession.ts +8 -2
- package/src/hooks/useSlideshowPersist.ts +0 -2
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useTimelineAssetDropOps.ts +0 -1
- package/src/hooks/useTimelineDeleteOps.ts +0 -1
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineEditingTypes.ts +0 -2
- package/src/hostMount.test.tsx +53 -22
- package/src/main.tsx +3 -0
- package/src/player/components/CompositionThumbnail.tsx +14 -0
- package/src/player/components/Player.test.ts +49 -0
- package/src/player/components/Player.tsx +21 -7
- package/src/player/components/timelineClipDragCommit.test.ts +7 -39
- package/src/player/hooks/timelineDomBinding.test.ts +50 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
- package/src/player/hooks/timelineSyncHydration.ts +3 -3
- package/src/player/hooks/useShadowPreviewReload.ts +1 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
- package/src/player/hooks/useTimelinePlayer.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/automationStoreSync.test.ts +24 -0
- package/src/player/lib/automationStoreSync.ts +3 -9
- package/src/player/lib/playbackScrub.ts +1 -1
- package/src/player/lib/timelineDOM.test.ts +28 -0
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineElementHelpers.ts +78 -9
- package/src/player/lib/timelineIframeHelpers.test.ts +49 -5
- package/src/player/lib/timelineIframeHelpers.ts +13 -12
- package/src/player/store/playerStore.test.ts +35 -1
- package/src/player/store/playerStore.ts +29 -0
- package/src/player/store/readinessSlice.ts +34 -6
- package/src/utils/blockInstaller.ts +0 -3
- package/src/utils/previewPrefetch.test.ts +22 -0
- package/src/utils/previewPrefetch.ts +14 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/razorSplitTransaction.ts +1 -1
- package/src/utils/sdkEditTransaction.ts +0 -3
- package/src/utils/studioFileHistory.test.ts +0 -9
- package/src/utils/studioFileHistory.ts +1 -5
- package/src/utils/studioUiPreferences.test.ts +9 -3
- package/src/utils/studioUiPreferences.ts +0 -20
- package/src/utils/timelineCompositionInsert.ts +0 -1
- package/dist/assets/hyperframes-player-WbBVbWuO.js +0 -464
- package/dist/chunk-7BP32G62.js.map +0 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
- package/src/utils/editHistory.test.ts +0 -534
- package/src/utils/editHistory.ts +0 -236
- package/src/utils/editHistoryStorage.test.ts +0 -37
- package/src/utils/editHistoryStorage.ts +0 -99
- /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-IGPLTI5E.js.map} +0 -0
package/src/hostMount.test.tsx
CHANGED
|
@@ -8,8 +8,15 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
8
8
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
9
|
// Backend/storage mocking only, standing in for a host's own server and
|
|
10
10
|
// persistence — not part of the host's integration code under test.
|
|
11
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
createStudioApi,
|
|
16
|
+
openProjectHistory,
|
|
17
|
+
type StudioApiAdapter,
|
|
18
|
+
} from "@hyperframes/studio-server";
|
|
11
19
|
import { studioFileContentVersion } from "./utils/studioFileVersion";
|
|
12
|
-
import { createMemoryEditHistoryStorage } from "./utils/editHistoryStorage";
|
|
13
20
|
import {
|
|
14
21
|
flushStudioPendingEdits,
|
|
15
22
|
useTimelineEditing,
|
|
@@ -27,45 +34,66 @@ import {
|
|
|
27
34
|
const ORIGINAL_HTML = '<div id="clip" data-start="0" data-track-index="0"></div>';
|
|
28
35
|
|
|
29
36
|
let root: Root | null = null;
|
|
30
|
-
|
|
37
|
+
const cleanups: Array<() => unknown> = [];
|
|
38
|
+
afterEach(async () => {
|
|
31
39
|
act(() => root?.unmount());
|
|
32
40
|
document.body.innerHTML = "";
|
|
33
41
|
vi.unstubAllGlobals();
|
|
42
|
+
for (const step of cleanups.splice(0).reverse()) await step();
|
|
34
43
|
});
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
function tempDir(prefix: string): string {
|
|
46
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
47
|
+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A project folder with etag-guarded PUT, faithful enough to exercise a real
|
|
52
|
+
* conflict: a stale If-Match gets a 409 with the current version + content.
|
|
53
|
+
* History requests go to the real history routes over the real engine. */
|
|
54
|
+
async function stubHostProjectDisk(
|
|
39
55
|
initial: Record<string, string>,
|
|
40
56
|
options?: { editElsewhereAfterFirstRead?: boolean },
|
|
41
57
|
) {
|
|
42
|
-
const
|
|
58
|
+
const dir = tempDir("hf-host-mount-");
|
|
59
|
+
for (const [path, text] of Object.entries(initial)) writeFileSync(join(dir, path), text);
|
|
60
|
+
const history = await openProjectHistory({
|
|
61
|
+
projectDir: dir,
|
|
62
|
+
historyRoot: tempDir("hf-host-mount-history-"),
|
|
63
|
+
});
|
|
64
|
+
cleanups.push(() => history.close());
|
|
65
|
+
const api = createStudioApi({
|
|
66
|
+
listProjects: () => [],
|
|
67
|
+
resolveProject: (id: string) => (id === "p1" ? { id, dir } : null),
|
|
68
|
+
history: () => history,
|
|
69
|
+
} as unknown as StudioApiAdapter);
|
|
43
70
|
const readsSeen = new Set<string>();
|
|
71
|
+
const read = (path: string) => readFileSync(join(dir, path), "utf8");
|
|
44
72
|
// The real backend's etag is a content hash (useProjectFileWriter derives
|
|
45
73
|
// If-Match the same way) — an arbitrary counter here would make every
|
|
46
74
|
// write from a host that hasn't just read the file look stale.
|
|
47
|
-
const versionOf = (path: string) => studioFileContentVersion(
|
|
75
|
+
const versionOf = (path: string) => studioFileContentVersion(read(path));
|
|
48
76
|
|
|
49
77
|
async function handlePut(path: string, init: RequestInit | undefined) {
|
|
50
78
|
const ifMatch = new Headers(init?.headers).get("If-Match");
|
|
51
79
|
const currentVersion = await versionOf(path);
|
|
52
80
|
if (ifMatch && ifMatch !== currentVersion) {
|
|
53
|
-
return new Response(JSON.stringify({ currentVersion, currentContent:
|
|
81
|
+
return new Response(JSON.stringify({ currentVersion, currentContent: read(path) }), {
|
|
54
82
|
status: 409,
|
|
55
83
|
});
|
|
56
84
|
}
|
|
57
|
-
|
|
85
|
+
writeFileSync(join(dir, path), String(init?.body ?? ""));
|
|
58
86
|
return new Response(JSON.stringify({ version: await versionOf(path) }), { status: 200 });
|
|
59
87
|
}
|
|
60
88
|
|
|
61
89
|
async function handleGet(path: string) {
|
|
62
|
-
const served =
|
|
90
|
+
const served = read(path);
|
|
63
91
|
// Simulates another writer landing between this read and the caller's
|
|
64
92
|
// own write: content moves on right after being served once, so the
|
|
65
93
|
// caller's hash of what it just read no longer matches disk.
|
|
66
94
|
if (options?.editElsewhereAfterFirstRead && !readsSeen.has(path)) {
|
|
67
95
|
readsSeen.add(path);
|
|
68
|
-
|
|
96
|
+
writeFileSync(join(dir, path), `${served} <!--edited elsewhere-->`);
|
|
69
97
|
}
|
|
70
98
|
return new Response(
|
|
71
99
|
JSON.stringify({ content: served, version: await studioFileContentVersion(served) }),
|
|
@@ -74,12 +102,15 @@ function stubHostProjectDisk(
|
|
|
74
102
|
}
|
|
75
103
|
|
|
76
104
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
77
|
-
const
|
|
78
|
-
|
|
105
|
+
const url = String(input);
|
|
106
|
+
if (url.includes("/history")) return api.request(url.replace(/^\/api/, ""), init);
|
|
107
|
+
const match = url.match(/\/files\/([^?]+)/);
|
|
108
|
+
if (!match) return new Response("{}", { status: 404 });
|
|
109
|
+
const path = decodeURIComponent(match[1]);
|
|
79
110
|
return (init?.method ?? "GET") === "PUT" ? handlePut(path, init) : handleGet(path);
|
|
80
111
|
});
|
|
81
112
|
vi.stubGlobal("fetch", fetchMock);
|
|
82
|
-
return {
|
|
113
|
+
return { read, fetchMock };
|
|
83
114
|
}
|
|
84
115
|
|
|
85
116
|
type TimelineEditingHandle = {
|
|
@@ -108,13 +139,10 @@ function mountHost(
|
|
|
108
139
|
const bannerHost = document.createElement("div");
|
|
109
140
|
document.body.append(bannerHost);
|
|
110
141
|
const bannerRoot = createRoot(bannerHost);
|
|
111
|
-
// Created once: a fresh storage object on every render would change
|
|
112
|
-
// usePersistentEditHistory's effect deps each time and loop forever.
|
|
113
|
-
const storage = createMemoryEditHistoryStorage();
|
|
114
142
|
|
|
115
143
|
function Harness() {
|
|
116
144
|
const writer = useProjectFileWriter({ projectId: "p1" });
|
|
117
|
-
const editHistory = usePersistentEditHistory({ projectId: "p1"
|
|
145
|
+
const editHistory = usePersistentEditHistory({ projectId: "p1" });
|
|
118
146
|
const historyActions = useEditHistoryActions({
|
|
119
147
|
editHistory,
|
|
120
148
|
readOptionalProjectFile: writer.readOptionalProjectFile,
|
|
@@ -170,7 +198,7 @@ function mountHost(
|
|
|
170
198
|
|
|
171
199
|
describe("public export surface: a host mounting hand editing outside EditorShell", () => {
|
|
172
200
|
it("writes a move through the history path and undo restores it", async () => {
|
|
173
|
-
const disk = stubHostProjectDisk({ "index.html": ORIGINAL_HTML });
|
|
201
|
+
const disk = await stubHostProjectDisk({ "index.html": ORIGINAL_HTML });
|
|
174
202
|
const iframe = document.createElement("iframe");
|
|
175
203
|
document.body.append(iframe);
|
|
176
204
|
iframe.contentDocument!.body.innerHTML = ORIGINAL_HTML;
|
|
@@ -187,16 +215,19 @@ describe("public export surface: a host mounting hand editing outside EditorShel
|
|
|
187
215
|
await act(async () => {
|
|
188
216
|
await timelineEditing.handleTimelineElementMove(clip, { start: 5, track: 0 });
|
|
189
217
|
});
|
|
190
|
-
expect(disk.
|
|
218
|
+
expect(disk.read("index.html")).toContain('data-start="5"');
|
|
191
219
|
|
|
192
220
|
await act(async () => {
|
|
193
221
|
await undo();
|
|
194
222
|
});
|
|
195
|
-
expect(disk.
|
|
223
|
+
expect(disk.read("index.html")).toContain('data-start="0"');
|
|
196
224
|
});
|
|
197
225
|
|
|
198
226
|
it("surfaces the conflict banner on a 409", async () => {
|
|
199
|
-
stubHostProjectDisk(
|
|
227
|
+
await stubHostProjectDisk(
|
|
228
|
+
{ "index.html": ORIGINAL_HTML },
|
|
229
|
+
{ editElsewhereAfterFirstRead: true },
|
|
230
|
+
);
|
|
200
231
|
const iframe = document.createElement("iframe");
|
|
201
232
|
document.body.append(iframe);
|
|
202
233
|
iframe.contentDocument!.body.innerHTML = ORIGINAL_HTML;
|
package/src/main.tsx
CHANGED
|
@@ -5,8 +5,11 @@ import { StudioApp } from "./App";
|
|
|
5
5
|
import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
|
|
6
6
|
import { readIconTokens } from "./styles/iconTokens";
|
|
7
7
|
import { trackStudioEvent } from "./utils/studioTelemetry";
|
|
8
|
+
import { prefetchPreviewForHash } from "./utils/previewPrefetch";
|
|
8
9
|
import "./styles/studio.css";
|
|
9
10
|
|
|
11
|
+
prefetchPreviewForHash(window.location.hash);
|
|
12
|
+
window.addEventListener("hashchange", () => prefetchPreviewForHash(window.location.hash));
|
|
10
13
|
trackStudioEvent("session_start");
|
|
11
14
|
|
|
12
15
|
function errorProps(value: unknown): {
|
|
@@ -24,6 +24,20 @@ interface CompositionThumbnailProps {
|
|
|
24
24
|
|
|
25
25
|
const CLIP_HEIGHT = 66;
|
|
26
26
|
const THUMBNAIL_URL_VERSION = "v3";
|
|
27
|
+
export const THUMBNAIL_SEEK_TIME_SECONDS = 3;
|
|
28
|
+
|
|
29
|
+
export function resolveThumbnailSeekTime(durationSeconds: number | null | undefined): number {
|
|
30
|
+
if (
|
|
31
|
+
Number.isFinite(durationSeconds) &&
|
|
32
|
+
durationSeconds != null &&
|
|
33
|
+
durationSeconds > 0 &&
|
|
34
|
+
durationSeconds <= THUMBNAIL_SEEK_TIME_SECONDS
|
|
35
|
+
) {
|
|
36
|
+
return durationSeconds / 2;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return THUMBNAIL_SEEK_TIME_SECONDS;
|
|
40
|
+
}
|
|
27
41
|
|
|
28
42
|
export function buildCompositionThumbnailUrl({
|
|
29
43
|
previewUrl,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
readPreviewErrorMessage,
|
|
10
10
|
shouldShowCompositionLoadingOverlay,
|
|
11
11
|
} from "./Player";
|
|
12
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
12
13
|
|
|
13
14
|
vi.mock("@hyperframes/player", () => ({}));
|
|
14
15
|
|
|
@@ -271,6 +272,54 @@ describe("ready to show", () => {
|
|
|
271
272
|
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
|
|
272
273
|
});
|
|
273
274
|
|
|
275
|
+
it("marks the preview booted when it can show and play, not at ready", async () => {
|
|
276
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
277
|
+
const { player } = await mountPlayer({});
|
|
278
|
+
const el = player as TestHyperframesPlayer;
|
|
279
|
+
|
|
280
|
+
loadAndReady(el);
|
|
281
|
+
act(() => void el.dispatchEvent(new Event("assetsready")));
|
|
282
|
+
await twoFrames();
|
|
283
|
+
expect(usePlayerStore.getState().previewBooted).toBe(false);
|
|
284
|
+
|
|
285
|
+
painted(el);
|
|
286
|
+
await twoFrames();
|
|
287
|
+
expect(usePlayerStore.getState().previewBooted).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("marks the preview booted at its first frame while media is still buffering", async () => {
|
|
291
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
292
|
+
const onReadyToShowChange = vi.fn();
|
|
293
|
+
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
294
|
+
const el = player as TestHyperframesPlayer;
|
|
295
|
+
document.body.appendChild(el.iframeElement);
|
|
296
|
+
const doc = el.iframeElement.contentDocument!;
|
|
297
|
+
const audio = doc.createElement("audio");
|
|
298
|
+
Object.defineProperty(audio, "readyState", { value: 0, configurable: true });
|
|
299
|
+
Object.defineProperty(audio, "networkState", { value: 2, configurable: true });
|
|
300
|
+
doc.body.appendChild(audio);
|
|
301
|
+
|
|
302
|
+
loadAndReady(el);
|
|
303
|
+
painted(el);
|
|
304
|
+
await twoFrames();
|
|
305
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
306
|
+
expect(usePlayerStore.getState().previewBooted).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("stops deferring editing work when the preview never shows", async () => {
|
|
310
|
+
vi.useFakeTimers();
|
|
311
|
+
try {
|
|
312
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
313
|
+
await mountPlayer({});
|
|
314
|
+
act(() => void vi.advanceTimersByTime(4999));
|
|
315
|
+
expect(usePlayerStore.getState().previewBooted).toBe(false);
|
|
316
|
+
act(() => void vi.advanceTimersByTime(1));
|
|
317
|
+
expect(usePlayerStore.getState().previewBooted).toBe(true);
|
|
318
|
+
} finally {
|
|
319
|
+
vi.useRealTimers();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
274
323
|
it("reports the document start time with the painted iframe", async () => {
|
|
275
324
|
const onPainted = vi.fn();
|
|
276
325
|
const now = vi.spyOn(performance, "now").mockReturnValue(100);
|
|
@@ -4,6 +4,7 @@ import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readin
|
|
|
4
4
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
5
5
|
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
|
6
6
|
import { HyperframesLoader } from "../../components/ui";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
7
8
|
// Importing "@hyperframes/player" registers a class extending HTMLElement at
|
|
8
9
|
// module load, which throws under SSR, hence the dynamic import behind a
|
|
9
10
|
// `typeof window` guard. Kicking it here rather than in the mount effect puts
|
|
@@ -57,6 +58,7 @@ function getShaderTransitionLoading(event: Event): boolean | null {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
|
|
61
|
+
const PREVIEW_BOOT_DEADLINE_MS = 5000;
|
|
60
62
|
const DEFAULT_PREVIEW_ERROR = "The composition preview did not become ready.";
|
|
61
63
|
|
|
62
64
|
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
|
|
@@ -427,13 +429,13 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
427
429
|
setAssetsLoading(false);
|
|
428
430
|
};
|
|
429
431
|
|
|
430
|
-
const
|
|
431
|
-
loaded &&
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
432
|
+
const firstFrameShown =
|
|
433
|
+
loaded && painted && !compositionLoading && !shaderTransitionLoading && !previewError;
|
|
434
|
+
const readyToShow = firstFrameShown && !assetsLoading;
|
|
435
|
+
// Work waiting on the boot starts at the first frame, while media still buffers.
|
|
436
|
+
useEffect(() => {
|
|
437
|
+
if (firstFrameShown) usePlayerStore.getState().markPreviewBooted();
|
|
438
|
+
}, [firstFrameShown]);
|
|
437
439
|
// `painted` means the player's own loader has finished fading; two frames of grace on top.
|
|
438
440
|
useEffect(() => {
|
|
439
441
|
if (!readyToShow) {
|
|
@@ -450,6 +452,18 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
450
452
|
};
|
|
451
453
|
}, [readyToShow]);
|
|
452
454
|
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
if (previewError) usePlayerStore.getState().markPreviewBooted();
|
|
457
|
+
}, [previewError]);
|
|
458
|
+
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
const timer = setTimeout(
|
|
461
|
+
() => usePlayerStore.getState().markPreviewBooted(),
|
|
462
|
+
PREVIEW_BOOT_DEADLINE_MS,
|
|
463
|
+
);
|
|
464
|
+
return () => clearTimeout(timer);
|
|
465
|
+
}, [projectId]);
|
|
466
|
+
|
|
453
467
|
const showCompositionOverlay =
|
|
454
468
|
!suppressLoadingOverlay &&
|
|
455
469
|
!compositionOverlayDeferred &&
|
|
@@ -8,11 +8,6 @@ import {
|
|
|
8
8
|
type DragCommitDeps,
|
|
9
9
|
type TimelineMoveEdit,
|
|
10
10
|
} from "./timelineClipDragCommit";
|
|
11
|
-
import {
|
|
12
|
-
buildEditHistoryEntry,
|
|
13
|
-
createEmptyEditHistory,
|
|
14
|
-
pushEditHistoryEntry,
|
|
15
|
-
} from "../../utils/editHistory";
|
|
16
11
|
import { normalizeToZones } from "./timelineZones";
|
|
17
12
|
import { resolveZMirrorLaneMove } from "./timelineZMirror";
|
|
18
13
|
import type { StackingPatch } from "./timelineStackingSync";
|
|
@@ -1151,48 +1146,21 @@ describe("commitDraggedClipMove", () => {
|
|
|
1151
1146
|
return { onMoveElements, onStackingPatches };
|
|
1152
1147
|
};
|
|
1153
1148
|
|
|
1154
|
-
it("threads ONE shared coalesceKey to both the move persist and the z-sync, so the
|
|
1149
|
+
it("threads ONE shared coalesceKey to both the move persist and the z-sync, so the server can fold them into a single undo entry", async () => {
|
|
1155
1150
|
const { onMoveElements, onStackingPatches } = commitLaneChange(overlapping());
|
|
1156
1151
|
await flushMicrotasks();
|
|
1157
1152
|
|
|
1158
|
-
// Both sides receive the SAME non-empty gesture key (second arg).
|
|
1153
|
+
// Both sides receive the SAME non-empty gesture key (second arg). That
|
|
1154
|
+
// shared, non-empty coalesceKey is what lets the server
|
|
1155
|
+
// (projectHistory.ts, "claim: a writer that records after writing")
|
|
1156
|
+
// fold the "Move timeline clips" write and the "Reorder layers" z patch,
|
|
1157
|
+
// both to the same file, into one undo entry — folding itself is the
|
|
1158
|
+
// server's own tested behaviour, not re-proven here via a reducer.
|
|
1159
1159
|
const moveKey = onMoveElements.mock.calls[0][1];
|
|
1160
1160
|
const zKey = onStackingPatches.mock.calls[0][1];
|
|
1161
1161
|
expect(typeof moveKey).toBe("string");
|
|
1162
1162
|
expect(moveKey).not.toBe("");
|
|
1163
1163
|
expect(zKey).toBe(moveKey);
|
|
1164
|
-
|
|
1165
|
-
// With that shared key, editHistory folds the two consecutive records (the
|
|
1166
|
-
// "Move timeline clips" write + the "Reorder layers" z patch, same file,
|
|
1167
|
-
// inside the coalesce window) into ONE undo entry spanning before→after.
|
|
1168
|
-
const now = 1_000;
|
|
1169
|
-
const moveEntry = buildEditHistoryEntry({
|
|
1170
|
-
id: "m",
|
|
1171
|
-
projectId: "p",
|
|
1172
|
-
label: "Move timeline clips",
|
|
1173
|
-
kind: "timeline",
|
|
1174
|
-
coalesceKey: moveKey,
|
|
1175
|
-
now,
|
|
1176
|
-
files: { "index.html": { before: "<v0>", after: "<v1>" } },
|
|
1177
|
-
});
|
|
1178
|
-
const zEntry = buildEditHistoryEntry({
|
|
1179
|
-
id: "z",
|
|
1180
|
-
projectId: "p",
|
|
1181
|
-
label: "Reorder layers",
|
|
1182
|
-
kind: "timeline",
|
|
1183
|
-
coalesceKey: zKey,
|
|
1184
|
-
now: now + 50,
|
|
1185
|
-
files: { "index.html": { before: "<v1>", after: "<v2>" } },
|
|
1186
|
-
});
|
|
1187
|
-
const state = pushEditHistoryEntry(
|
|
1188
|
-
pushEditHistoryEntry(createEmptyEditHistory(), moveEntry),
|
|
1189
|
-
zEntry,
|
|
1190
|
-
);
|
|
1191
|
-
expect(state.undo).toHaveLength(1);
|
|
1192
|
-
expect(state.undo[0].files["index.html"]).toMatchObject({
|
|
1193
|
-
before: "<v0>",
|
|
1194
|
-
after: "<v2>",
|
|
1195
|
-
});
|
|
1196
1164
|
});
|
|
1197
1165
|
|
|
1198
1166
|
it("distinct gestures get distinct keys (independent moves never cross-merge)", async () => {
|
|
@@ -47,6 +47,56 @@ describe("live DOM manifest hydration", () => {
|
|
|
47
47
|
).toHaveLength(2);
|
|
48
48
|
}
|
|
49
49
|
});
|
|
50
|
+
it("binds a root clip to its own element when a mounted sub-composition repeats its id", () => {
|
|
51
|
+
// The sub-composition's copy comes first in document order, so a bare id lookup finds it.
|
|
52
|
+
const doc = documentWith(
|
|
53
|
+
'<div id="strip" data-composition-id="strip" data-composition-src="compositions/strip.html" data-start="0" data-duration="4" data-track-index="1">' +
|
|
54
|
+
'<video id="frame-1" data-hf-id="hf-inner" data-start="0"></video></div>' +
|
|
55
|
+
'<video id="frame-1" data-hf-id="hf-root" data-start="6" data-duration="1" data-track-index="7"></video>',
|
|
56
|
+
);
|
|
57
|
+
const [element] = buildTimelineElementsFromClips(
|
|
58
|
+
[
|
|
59
|
+
clip({
|
|
60
|
+
id: "frame-1",
|
|
61
|
+
tagName: "video",
|
|
62
|
+
kind: "video",
|
|
63
|
+
start: 6,
|
|
64
|
+
duration: 1,
|
|
65
|
+
track: 7,
|
|
66
|
+
compositionAncestors: ["main"],
|
|
67
|
+
}),
|
|
68
|
+
],
|
|
69
|
+
doc,
|
|
70
|
+
);
|
|
71
|
+
expect([element.hfId, element.sourceFile]).toEqual(["hf-root", undefined]);
|
|
72
|
+
});
|
|
73
|
+
it("keeps a unique id bound while its host's composition id is healed after the manifest", () => {
|
|
74
|
+
// The wrapper's data-composition-id arrived after the runtime recorded the clip's chain as ["main"].
|
|
75
|
+
const doc = documentWith(
|
|
76
|
+
'<div id="ho" data-hf-id="ho" data-start="0" data-duration="4"></div>' +
|
|
77
|
+
'<div id="hs-layer" data-composition-id="hs-comp">' +
|
|
78
|
+
'<div id="hs" data-hf-id="hs" data-start="0" data-duration="4"></div></div>',
|
|
79
|
+
);
|
|
80
|
+
const [element] = buildTimelineElementsFromClips(
|
|
81
|
+
[clip({ id: "hs", compositionAncestors: ["main"] })],
|
|
82
|
+
doc,
|
|
83
|
+
);
|
|
84
|
+
expect(element.hfId).toBe("hs");
|
|
85
|
+
});
|
|
86
|
+
it("binds an id-less host by its composition id over a lone id inside that sub-composition", () => {
|
|
87
|
+
const doc = documentWith(
|
|
88
|
+
'<div data-composition-id="card" data-composition-src="compositions/card.html" data-start="0" data-duration="4">' +
|
|
89
|
+
'<div data-hf-inner-root><div id="card" data-start="0" data-duration="4"></div></div></div>',
|
|
90
|
+
);
|
|
91
|
+
const host = doc.querySelector('[data-composition-src="compositions/card.html"]');
|
|
92
|
+
const card = clip({
|
|
93
|
+
id: "card",
|
|
94
|
+
kind: "composition",
|
|
95
|
+
compositionId: "card",
|
|
96
|
+
compositionAncestors: ["main"],
|
|
97
|
+
});
|
|
98
|
+
expect(findTimelineDomNodeForClip(doc, card, 0)).toBe(host);
|
|
99
|
+
});
|
|
50
100
|
it("does not bind a cross-tag direct identity", () => {
|
|
51
101
|
const doc = documentWith('<div id="collision" data-start="0" data-duration="4"></div>');
|
|
52
102
|
expect(
|
|
@@ -43,7 +43,7 @@ function mountScene(): Document {
|
|
|
43
43
|
|
|
44
44
|
// A composition clip is keyed by its ELEMENT id, not its `data-composition-id`
|
|
45
45
|
// (the runtime's clip tree publishes `scene-2-slot`), so the collector resolves
|
|
46
|
-
// the host
|
|
46
|
+
// the host by clip.id in the clip's own composition, exactly as the sibling walk does.
|
|
47
47
|
const sceneClips = [clip({ id: "scene-2-slot", kind: "composition", compositionId: "scene-2" })];
|
|
48
48
|
|
|
49
49
|
describe("collectSubCompositionHostState", () => {
|
|
@@ -83,6 +83,30 @@ describe("collectSubCompositionHostState", () => {
|
|
|
83
83
|
expect(state.get("scene-2-video")?.hidden).toBe(true);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
it("reads its own host when an earlier sub-composition repeats the host's id", () => {
|
|
87
|
+
document.body.innerHTML = `
|
|
88
|
+
<main data-composition-id="main">
|
|
89
|
+
<div id="intro-slot" data-composition-id="intro" data-composition-src="intro.html">
|
|
90
|
+
<div data-hf-inner-root data-composition-id="intro">
|
|
91
|
+
<div id="scene-2-slot"><p id="intro-note" data-hidden></p></div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<div id="scene-2-slot" data-composition-id="scene-2" data-composition-src="scene-2.html">
|
|
95
|
+
<div data-hf-inner-root><div id="scene-2-title" data-hidden></div></div>
|
|
96
|
+
</div>
|
|
97
|
+
</main>`;
|
|
98
|
+
const host = clip({
|
|
99
|
+
id: "scene-2-slot",
|
|
100
|
+
kind: "composition",
|
|
101
|
+
compositionId: "scene-2",
|
|
102
|
+
compositionAncestors: ["main"],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect([...collectSubCompositionHostState(document, [host]).keys()]).toEqual(["scene-2-title"]);
|
|
106
|
+
const rows = collectSubCompositionDomChildren(document, [host], new Map());
|
|
107
|
+
expect(rows.map((child) => child.id)).toEqual(["scene-2-title"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
86
110
|
it("returns empty without a document, rather than throwing", () => {
|
|
87
111
|
expect(collectSubCompositionHostState(null, sceneClips).size).toBe(0);
|
|
88
112
|
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* an argument, so each is callable — and readable — on its own.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { createTimelineDomNodeResolver } from "../lib/timelineElementHelpers";
|
|
12
|
+
import { createTimelineDomNodeResolver, findClipElementById } from "../lib/timelineElementHelpers";
|
|
13
13
|
import { usePlayerStore } from "../store/playerStore";
|
|
14
14
|
import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
|
|
15
15
|
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
|
|
@@ -128,7 +128,7 @@ export function collectSubCompositionDomChildren(
|
|
|
128
128
|
if (!iframeDoc) return out;
|
|
129
129
|
for (const clip of clips) {
|
|
130
130
|
if (clip.kind !== "composition" || !clip.id) continue;
|
|
131
|
-
const hostEl = iframeDoc
|
|
131
|
+
const hostEl = findClipElementById(iframeDoc, clip);
|
|
132
132
|
if (!hostEl) continue;
|
|
133
133
|
const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl;
|
|
134
134
|
collectHostDomChildren(clip.id, innerRoot, clip.id, parentMap, out);
|
|
@@ -172,7 +172,7 @@ export function collectSubCompositionHostState(
|
|
|
172
172
|
if (!iframeDoc) return out;
|
|
173
173
|
for (const clip of clips) {
|
|
174
174
|
if (clip.kind !== "composition" || !clip.id) continue;
|
|
175
|
-
const hostEl = iframeDoc
|
|
175
|
+
const hostEl = findClipElementById(iframeDoc, clip);
|
|
176
176
|
if (!hostEl) continue;
|
|
177
177
|
for (const el of Array.from(hostEl.querySelectorAll("[id]"))) {
|
|
178
178
|
const state = readSubCompositionHostState(el);
|
|
@@ -219,6 +219,7 @@ export function useShadowPreviewReload({
|
|
|
219
219
|
shadowIframeRef.current = null;
|
|
220
220
|
isRefreshingRef.current = false;
|
|
221
221
|
pendingSeekRef.current = null;
|
|
222
|
+
usePlayerStore.getState().setTimelineReady(false);
|
|
222
223
|
setPreviewSlots(planShadowDiscard);
|
|
223
224
|
thumbnailScheduler.setPreviewReloading(false);
|
|
224
225
|
}, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
makeAdapterWindow,
|
|
7
|
+
makeFakeIframe,
|
|
8
|
+
renderTimelinePlayerHarness,
|
|
9
|
+
resetPlayerStore,
|
|
10
|
+
} from "./timelinePlayerTestHarness";
|
|
11
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
12
|
+
|
|
13
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
document.body.innerHTML = "";
|
|
17
|
+
resetPlayerStore();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not start playback before the timeline is ready, so iframe load cannot swallow it", () => {
|
|
21
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
22
|
+
const { adapter, win } = makeAdapterWindow();
|
|
23
|
+
// The runtime has booted (adapter exists) but the iframe's load event has not fired yet.
|
|
24
|
+
act(() => {
|
|
25
|
+
api.iframeRef.current = makeFakeIframe(win);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
act(() => api.play());
|
|
29
|
+
expect(adapter.play).not.toHaveBeenCalled();
|
|
30
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
31
|
+
|
|
32
|
+
act(() => {
|
|
33
|
+
api.onIframeLoad();
|
|
34
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
35
|
+
});
|
|
36
|
+
act(() => api.play());
|
|
37
|
+
expect(adapter.play).toHaveBeenCalledTimes(1);
|
|
38
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
39
|
+
act(() => root.unmount());
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does not start playback while a switched-to composition's preview is still loading", () => {
|
|
43
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
44
|
+
act(() => {
|
|
45
|
+
api.iframeRef.current = makeFakeIframe(makeAdapterWindow().win);
|
|
46
|
+
api.onIframeLoad();
|
|
47
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Drill into another composition: a fresh preview whose runtime is up but whose load step hasn't run.
|
|
51
|
+
const next = makeAdapterWindow();
|
|
52
|
+
act(() => {
|
|
53
|
+
api.resetPreviewSlots();
|
|
54
|
+
api.iframeRef.current = makeFakeIframe(next.win);
|
|
55
|
+
});
|
|
56
|
+
act(() => api.play());
|
|
57
|
+
expect(next.adapter.play).not.toHaveBeenCalled();
|
|
58
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
59
|
+
act(() => root.unmount());
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function postFromRuntime(win: object, data: Record<string, unknown>) {
|
|
63
|
+
act(() => {
|
|
64
|
+
window.dispatchEvent(
|
|
65
|
+
new MessageEvent("message", {
|
|
66
|
+
source: win as unknown as Window,
|
|
67
|
+
data: { source: "hf-preview", ...data },
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function expectPlayStillOff() {
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
75
|
+
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Duration 0: the adapter cannot enable Play itself, so only the message path can, after the load step.
|
|
79
|
+
it.each([30, 0])(
|
|
80
|
+
"waits for the preview's load step before a timeline message enables Play (adapter duration %i)",
|
|
81
|
+
async (duration) => {
|
|
82
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
83
|
+
const { win } = makeAdapterWindow({ duration });
|
|
84
|
+
act(() => {
|
|
85
|
+
api.iframeRef.current = makeFakeIframe(win);
|
|
86
|
+
});
|
|
87
|
+
// The runtime reports its timeline before the document's load event, as a film with large images does.
|
|
88
|
+
const clip = { id: "clip", label: "Clip", start: 0, duration: 1, track: 0, kind: "element" };
|
|
89
|
+
postFromRuntime(win, { type: "timeline", clips: [clip], durationInFrames: 30, fps: 30 });
|
|
90
|
+
await expectPlayStillOff();
|
|
91
|
+
|
|
92
|
+
act(() => api.onIframeLoad());
|
|
93
|
+
await vi.waitFor(() => expect(usePlayerStore.getState().timelineReady).toBe(true));
|
|
94
|
+
act(() => root.unmount());
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
it("does not let the blank page's load step enable Play for the preview that replaces it", async () => {
|
|
99
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
100
|
+
// The iframe's first load is about:blank, before the runtime exists; its load step waits for runtime messages.
|
|
101
|
+
const iframe = makeFakeIframe({ ...makeAdapterWindow().win, __player: undefined });
|
|
102
|
+
act(() => {
|
|
103
|
+
api.iframeRef.current = iframe;
|
|
104
|
+
api.onIframeLoad();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// The same iframe navigates to the preview, whose runtime reports ready before its own load event.
|
|
108
|
+
const { win } = makeAdapterWindow();
|
|
109
|
+
Object.defineProperty(iframe, "contentWindow", { value: win, configurable: true });
|
|
110
|
+
Object.defineProperty(iframe, "contentDocument", {
|
|
111
|
+
value: document.implementation.createHTMLDocument("preview"),
|
|
112
|
+
configurable: true,
|
|
113
|
+
});
|
|
114
|
+
postFromRuntime(win, { type: "state" });
|
|
115
|
+
await expectPlayStillOff();
|
|
116
|
+
|
|
117
|
+
act(() => api.onIframeLoad());
|
|
118
|
+
await vi.waitFor(() => expect(usePlayerStore.getState().timelineReady).toBe(true));
|
|
119
|
+
act(() => root.unmount());
|
|
120
|
+
});
|
|
@@ -180,6 +180,7 @@ export function useTimelinePlayer({
|
|
|
180
180
|
applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
|
|
181
181
|
}, []);
|
|
182
182
|
const play = useCallback(() => {
|
|
183
|
+
if (!usePlayerStore.getState().timelineReady) return;
|
|
183
184
|
stopRAFLoop();
|
|
184
185
|
stopReverseLoop();
|
|
185
186
|
stopScrubPreviewAudio();
|
|
@@ -295,6 +295,8 @@ export function useTimelineSyncCallbacks({
|
|
|
295
295
|
|
|
296
296
|
const onIframeLoad = useCallback(
|
|
297
297
|
(context?: number) => {
|
|
298
|
+
const loadedDoc = safeContentDocument(iframeRef.current);
|
|
299
|
+
if (loadedDoc) usePlayerStore.getState().markPreviewLoadStep(loadedDoc);
|
|
298
300
|
applyPreviewAudioState();
|
|
299
301
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
300
302
|
stopWaitingRef.current?.();
|