@hyperframes/studio 0.8.75 → 0.8.76
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-BkDxgSKG.js +464 -0
- package/dist/assets/{index-BiFKcNjs.js → index-COX0scwJ.js} +1 -1
- package/dist/assets/{index-BlUYw1MB.js → index-DeHir1up.js} +156 -156
- package/dist/assets/{index-HjexPsny.js → index-FmKUmYhC.js} +1 -1
- package/dist/{chunk-7BP32G62.js → chunk-IYDE66BY.js} +6 -1
- package/dist/chunk-IYDE66BY.js.map +1 -0
- package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-2XBFXXSW.js} +4 -2
- package/dist/index.d.ts +34 -69
- package/dist/index.html +1 -1
- package/dist/index.js +4147 -4253
- package/dist/index.js.map +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/domEditingLayers.test.ts +27 -0
- package/src/components/editor/domEditingLayers.ts +9 -0
- 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/usePreviewCompositionRect.test.tsx +6 -1
- package/src/components/nle/NLEPreview.test.ts +94 -17
- package/src/components/nle/NLEPreview.tsx +14 -0
- package/src/components/nle/PreviewPoster.tsx +99 -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/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/useGroupCommits.ts +0 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -1
- 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/timelineElementHelpers.ts +45 -9
- 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/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-2XBFXXSW.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.76",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -52,11 +52,11 @@
|
|
|
52
52
|
"dockview-react": "8.3.1",
|
|
53
53
|
"gsap": "^3.13.0",
|
|
54
54
|
"mediabunny": "^1.45.3",
|
|
55
|
-
"@hyperframes/
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/
|
|
59
|
-
"@hyperframes/
|
|
55
|
+
"@hyperframes/parsers": "0.8.76",
|
|
56
|
+
"@hyperframes/core": "0.8.76",
|
|
57
|
+
"@hyperframes/sdk": "0.8.76",
|
|
58
|
+
"@hyperframes/studio-server": "0.8.76",
|
|
59
|
+
"@hyperframes/player": "0.8.76"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"vite": "^6.4.2",
|
|
73
73
|
"vitest": "^4.1.11",
|
|
74
74
|
"zustand": "^5.0.0",
|
|
75
|
-
"@hyperframes/producer": "0.8.
|
|
75
|
+
"@hyperframes/producer": "0.8.76"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
78
|
"react": "19",
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
import type { StudioEditPersistenceProps } from "./panels/VariablesPanel";
|
|
10
10
|
import type { BlockParam } from "@hyperframes/core/registry";
|
|
11
11
|
import type { Composition } from "@hyperframes/sdk";
|
|
12
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
13
12
|
import type { UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
|
|
14
13
|
import type { AddMediaOverlayHandler } from "./editor/propertyPanelTypes";
|
|
15
14
|
import type { ToggleHiddenHandler } from "../utils/studioHelpers";
|
|
@@ -46,7 +45,6 @@ export interface StudioRightPanelsProps extends StudioEditPersistenceProps {
|
|
|
46
45
|
reloadPreview: () => void;
|
|
47
46
|
recordEdit: (entry: {
|
|
48
47
|
label: string;
|
|
49
|
-
kind: EditHistoryKind;
|
|
50
48
|
files: Record<string, { before: string; after: string }>;
|
|
51
49
|
}) => Promise<void>;
|
|
52
50
|
onToggleElementHidden?: ToggleHiddenHandler;
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
hoverCacheDescribesPoint,
|
|
19
19
|
resolveResizeCenterAnchorOffset,
|
|
20
20
|
} from "./domEditOverlayGestures";
|
|
21
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
21
22
|
|
|
22
23
|
// React 19 warns unless the test environment opts into act().
|
|
23
24
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -242,6 +243,10 @@ function dispatchOverlayPointerDown(target: Element, clientX = 120, clientY = 80
|
|
|
242
243
|
});
|
|
243
244
|
}
|
|
244
245
|
|
|
246
|
+
beforeEach(() => {
|
|
247
|
+
usePlayerStore.setState({ previewBooted: true });
|
|
248
|
+
});
|
|
249
|
+
|
|
245
250
|
describe("focusDomEditOverlayElement", () => {
|
|
246
251
|
it("focuses the canvas overlay without scrolling", () => {
|
|
247
252
|
const calls: Array<FocusOptions | undefined> = [];
|
|
@@ -3,6 +3,7 @@ import { memo, useState, useCallback, useEffect, useRef } from "react";
|
|
|
3
3
|
import {
|
|
4
4
|
collectDomEditLayerItems,
|
|
5
5
|
getDomEditLayerKey,
|
|
6
|
+
liveLayerElement,
|
|
6
7
|
resolveDomEditSelection,
|
|
7
8
|
type DomEditLayerItem,
|
|
8
9
|
} from "./domEditing";
|
|
@@ -190,20 +191,8 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
190
191
|
|
|
191
192
|
const resolveSelection = useCallback(
|
|
192
193
|
(layer: DomEditLayerItem) => {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
let el = layer.element;
|
|
196
|
-
if (!el.isConnected) {
|
|
197
|
-
const iframe = previewIframeRef.current;
|
|
198
|
-
const doc = iframe?.contentDocument;
|
|
199
|
-
if (doc) {
|
|
200
|
-
const found =
|
|
201
|
-
(layer.id ? doc.getElementById(layer.id) : null) ??
|
|
202
|
-
(layer.hfId ? doc.querySelector(`[data-hf-id="${CSS.escape(layer.hfId)}"]`) : null) ??
|
|
203
|
-
doc.getElementById(layer.key);
|
|
204
|
-
if (found instanceof HTMLElement) el = found;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
194
|
+
const doc = previewIframeRef.current?.contentDocument;
|
|
195
|
+
const el = liveLayerElement(layer, doc, activeCompPath);
|
|
207
196
|
return resolveDomEditSelection(el, {
|
|
208
197
|
activeCompositionPath: activeCompPath,
|
|
209
198
|
isMasterView,
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
6
|
import { GridOverlay } from "./GridOverlay";
|
|
7
7
|
import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
|
|
8
8
|
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
|
9
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
9
10
|
|
|
10
11
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
12
|
|
|
@@ -27,6 +28,10 @@ function compositionFrame() {
|
|
|
27
28
|
return iframe;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
usePlayerStore.setState({ previewBooted: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
30
35
|
describe("PreviewOverlayProvider", () => {
|
|
31
36
|
it("derives grid geometry after the iframe prop becomes available", async () => {
|
|
32
37
|
window.localStorage.setItem(
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
buildDomEditPatchTarget,
|
|
7
7
|
buildTextFieldChildLocator,
|
|
8
8
|
readHfId,
|
|
9
|
+
liveLayerElement,
|
|
9
10
|
} from "./domEditingLayers";
|
|
10
11
|
import type { DomEditTextField } from "./domEditingTypes";
|
|
11
12
|
|
|
@@ -305,3 +306,29 @@ describe("collectDomEditLayerItems selector-index cost", () => {
|
|
|
305
306
|
expect(classSelectorQueries(12)).toBe(1);
|
|
306
307
|
});
|
|
307
308
|
});
|
|
309
|
+
|
|
310
|
+
describe("liveLayerElement", () => {
|
|
311
|
+
it("finds a replaced layer again in its own file when a sub-composition repeats its id", () => {
|
|
312
|
+
document.body.innerHTML =
|
|
313
|
+
'<div data-composition-id="main">' +
|
|
314
|
+
'<div data-composition-id="strip" data-composition-src="compositions/strip.html">' +
|
|
315
|
+
'<div id="card-1">strip</div></div><div id="card-1">root</div></div>';
|
|
316
|
+
const stale = document.createElement("div");
|
|
317
|
+
const layer = {
|
|
318
|
+
key: "index.html:card-1:0",
|
|
319
|
+
element: stale,
|
|
320
|
+
label: "card-1",
|
|
321
|
+
tagName: "div",
|
|
322
|
+
depth: 0,
|
|
323
|
+
childCount: 0,
|
|
324
|
+
id: "card-1",
|
|
325
|
+
sourceFile: "index.html",
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
expect(liveLayerElement(layer, document, "index.html").textContent).toBe("root");
|
|
329
|
+
expect(
|
|
330
|
+
liveLayerElement({ ...layer, sourceFile: "compositions/strip.html" }, document, "index.html")
|
|
331
|
+
.textContent,
|
|
332
|
+
).toBe("strip");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -491,6 +491,15 @@ export function collectDomEditLayerItems(
|
|
|
491
491
|
return items;
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
export function liveLayerElement(
|
|
495
|
+
layer: DomEditLayerItem,
|
|
496
|
+
doc: Document | null | undefined,
|
|
497
|
+
activeCompositionPath: string | null,
|
|
498
|
+
): HTMLElement {
|
|
499
|
+
if (layer.element.isConnected || !doc) return layer.element;
|
|
500
|
+
return findElementForSelection(doc, layer, activeCompositionPath) ?? layer.element;
|
|
501
|
+
}
|
|
502
|
+
|
|
494
503
|
// ─── Patch operations ────────────────────────────────────────────────────────
|
|
495
504
|
|
|
496
505
|
export function buildDomEditStylePatchOperation(
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { DomEditOverlay } from "./DomEditOverlay";
|
|
7
7
|
import { RECOMPUTE_INTERVAL_MS } from "./offCanvasIndicatorRefresh";
|
|
8
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
9
|
|
|
9
10
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
10
11
|
|
|
@@ -59,6 +60,30 @@ async function flushAnimationFrames(): Promise<void> {
|
|
|
59
60
|
});
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
// The refresh waits on MutationObserver delivery and then on its own throttle, so no fixed
|
|
64
|
+
// number of frames is guaranteed to be enough on a loaded runner. Flush until the indicator
|
|
65
|
+
// reaches the state under test.
|
|
66
|
+
async function untilIndicator(host: HTMLElement, check: (indicator: Element | null) => void) {
|
|
67
|
+
await vi.waitFor(
|
|
68
|
+
async () => {
|
|
69
|
+
await act(async () => {
|
|
70
|
+
await flushAnimationFrames();
|
|
71
|
+
});
|
|
72
|
+
check(host.querySelector(INDICATOR));
|
|
73
|
+
},
|
|
74
|
+
{ timeout: 4000, interval: 0 },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Delivers each batch of mutation records `ms` late, as a busy runner can.
|
|
79
|
+
function lateObserver(Base: typeof MutationObserver, ms: number): typeof MutationObserver {
|
|
80
|
+
return class extends Base {
|
|
81
|
+
constructor(callback: MutationCallback) {
|
|
82
|
+
super((records, observer) => setTimeout(() => callback(records, observer), ms));
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
62
87
|
interface OverlayHarness {
|
|
63
88
|
host: HTMLElement;
|
|
64
89
|
movedElement: HTMLElement;
|
|
@@ -68,7 +93,7 @@ interface OverlayHarness {
|
|
|
68
93
|
// Mount DomEditOverlay over an iframe whose #headline sits at `initialLeft`, with a
|
|
69
94
|
// getBoundingClientRect stub that reads the element's live inline geometry (so a
|
|
70
95
|
// style mutation moves it) and reports the composition/overlay as 800x450 at origin.
|
|
71
|
-
function mountOverlayWithHeadline(initialLeft: number): OverlayHarness {
|
|
96
|
+
function mountOverlayWithHeadline(initialLeft: number, observerDelayMs = 0): OverlayHarness {
|
|
72
97
|
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
|
|
73
98
|
const host = document.createElement("div");
|
|
74
99
|
document.body.append(host);
|
|
@@ -85,6 +110,9 @@ function mountOverlayWithHeadline(initialLeft: number): OverlayHarness {
|
|
|
85
110
|
`;
|
|
86
111
|
const movedElement = doc.getElementById("headline");
|
|
87
112
|
if (!movedElement) throw new Error("Expected test element");
|
|
113
|
+
const view = doc.defaultView?.MutationObserver ? doc.defaultView : globalThis;
|
|
114
|
+
const RealObserver = view.MutationObserver;
|
|
115
|
+
if (observerDelayMs > 0) view.MutationObserver = lateObserver(RealObserver, observerDelayMs);
|
|
88
116
|
|
|
89
117
|
Element.prototype.getBoundingClientRect = function (): DOMRect {
|
|
90
118
|
if (this === movedElement) {
|
|
@@ -125,58 +153,52 @@ function mountOverlayWithHeadline(initialLeft: number): OverlayHarness {
|
|
|
125
153
|
cleanup: () => {
|
|
126
154
|
act(() => root.unmount());
|
|
127
155
|
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
|
156
|
+
view.MutationObserver = RealObserver;
|
|
128
157
|
iframe.remove();
|
|
129
158
|
host.remove();
|
|
130
159
|
},
|
|
131
160
|
};
|
|
132
161
|
}
|
|
133
162
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
await act(async () => {
|
|
139
|
-
await flushAnimationFrames();
|
|
140
|
-
});
|
|
141
|
-
expect(h.host.querySelector(INDICATOR)).toBeTruthy();
|
|
142
|
-
|
|
143
|
-
act(() => {
|
|
144
|
-
h.movedElement.style.left = "120px";
|
|
145
|
-
});
|
|
146
|
-
await act(async () => {
|
|
147
|
-
await Promise.resolve();
|
|
148
|
-
await flushAnimationFrames();
|
|
149
|
-
});
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
usePlayerStore.setState({ previewBooted: true });
|
|
165
|
+
});
|
|
150
166
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
167
|
+
describe("off-canvas indicator refresh", () => {
|
|
168
|
+
for (const [label, delayMs] of [
|
|
169
|
+
["", 0],
|
|
170
|
+
[" when mutation delivery lags past a fixed wait", 300],
|
|
171
|
+
] as const) {
|
|
172
|
+
it(`removes the indicator when an off-canvas element moves in-canvas (off->on)${label}`, async () => {
|
|
173
|
+
const h = mountOverlayWithHeadline(760, delayMs);
|
|
174
|
+
try {
|
|
175
|
+
await untilIndicator(h.host, (indicator) => expect(indicator).toBeTruthy());
|
|
176
|
+
|
|
177
|
+
act(() => {
|
|
178
|
+
h.movedElement.style.left = "120px";
|
|
179
|
+
});
|
|
180
|
+
await untilIndicator(h.host, (indicator) => expect(indicator).toBeNull());
|
|
181
|
+
} finally {
|
|
182
|
+
h.cleanup();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
156
186
|
|
|
157
187
|
it("tracks the indicator to the new position when it stays off-canvas (off->off)", async () => {
|
|
158
188
|
const h = mountOverlayWithHeadline(760);
|
|
159
189
|
try {
|
|
160
|
-
await
|
|
161
|
-
await flushAnimationFrames();
|
|
162
|
-
});
|
|
190
|
+
await untilIndicator(h.host, (indicator) => expect(indicator).toBeTruthy());
|
|
163
191
|
const before = h.host.querySelector(INDICATOR);
|
|
164
|
-
expect(before).toBeTruthy();
|
|
165
192
|
const leftBefore = (before!.parentElement as HTMLElement).style.left;
|
|
166
193
|
|
|
167
194
|
// Move further off-canvas (still outside the 800px-wide composition).
|
|
168
195
|
act(() => {
|
|
169
196
|
h.movedElement.style.left = "1200px";
|
|
170
197
|
});
|
|
171
|
-
await
|
|
172
|
-
|
|
173
|
-
|
|
198
|
+
await untilIndicator(h.host, (indicator) => {
|
|
199
|
+
expect(indicator).toBeTruthy();
|
|
200
|
+
expect((indicator!.parentElement as HTMLElement).style.left).not.toEqual(leftBefore);
|
|
174
201
|
});
|
|
175
|
-
|
|
176
|
-
const after = h.host.querySelector(INDICATOR);
|
|
177
|
-
expect(after).toBeTruthy();
|
|
178
|
-
const leftAfter = (after!.parentElement as HTMLElement).style.left;
|
|
179
|
-
expect(leftAfter).not.toEqual(leftBefore);
|
|
180
202
|
} finally {
|
|
181
203
|
h.cleanup();
|
|
182
204
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
resetOverlayFrameLoopForTests,
|
|
8
8
|
subscribeOverlayFrame,
|
|
9
9
|
} from "./overlayFrameLoop";
|
|
10
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* The editor's overlay polls run on one shared, parkable frame loop. The two
|
|
@@ -37,6 +38,7 @@ describe("overlay frame loop", () => {
|
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
beforeEach(() => {
|
|
41
|
+
usePlayerStore.setState({ previewBooted: true });
|
|
40
42
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance", "Date"] });
|
|
41
43
|
queued = [];
|
|
42
44
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
|
@@ -55,6 +57,20 @@ describe("overlay frame loop", () => {
|
|
|
55
57
|
window.cancelAnimationFrame = originalCancelRaf;
|
|
56
58
|
});
|
|
57
59
|
|
|
60
|
+
it("does not poll the preview before it boots, and wakes when it does", () => {
|
|
61
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
62
|
+
let runs = 0;
|
|
63
|
+
subscribeOverlayFrame(() => {
|
|
64
|
+
runs += 1;
|
|
65
|
+
});
|
|
66
|
+
framesOver(1000);
|
|
67
|
+
expect(runs).toBe(0);
|
|
68
|
+
|
|
69
|
+
usePlayerStore.getState().markPreviewBooted();
|
|
70
|
+
framesOver(32);
|
|
71
|
+
expect(runs).toBeGreaterThan(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
58
74
|
it("runs every subscriber on one frame, not one frame each", () => {
|
|
59
75
|
const calls: string[] = [];
|
|
60
76
|
subscribeOverlayFrame(() => calls.push("a"));
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
* the next couple of hundred milliseconds, and no further event is dispatched
|
|
26
26
|
* while it does.
|
|
27
27
|
*/
|
|
28
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
29
|
+
|
|
28
30
|
const AWAKE_MS = 400;
|
|
29
31
|
/** How often a parked loop runs one frame anyway, in case a wake was missed. */
|
|
30
32
|
export const IDLE_POLL_MS = 250;
|
|
@@ -35,6 +37,7 @@ let frameId: number | null = null;
|
|
|
35
37
|
let idleTimerId: ReturnType<typeof setTimeout> | null = null;
|
|
36
38
|
let awakeUntil = 0;
|
|
37
39
|
let listenersAttached = false;
|
|
40
|
+
let stopBootWake: (() => void) | null = null;
|
|
38
41
|
|
|
39
42
|
/** Wake sources. All passive reads; none of them can be cancelled by us. */
|
|
40
43
|
const WINDOW_EVENTS = [
|
|
@@ -111,6 +114,9 @@ function runFrame(): void {
|
|
|
111
114
|
// it — and a shared loop makes that failure four overlays wide plus the
|
|
112
115
|
// idle-poll safety net, permanently, rather than one overlay's own problem.
|
|
113
116
|
schedule();
|
|
117
|
+
// Nothing to track until the live preview boots, and a poll before then forces layout
|
|
118
|
+
// on a document that is still loading, on the thread the boot runs on.
|
|
119
|
+
if (!usePlayerStore.getState().previewBooted) return;
|
|
114
120
|
for (const subscriber of subscribers) {
|
|
115
121
|
try {
|
|
116
122
|
subscriber();
|
|
@@ -154,6 +160,9 @@ function attachListeners(): void {
|
|
|
154
160
|
window.addEventListener(type, requestOverlayFrames, { capture: true, passive: true });
|
|
155
161
|
}
|
|
156
162
|
window.addEventListener("message", onPreviewMessage, { capture: true, passive: true });
|
|
163
|
+
stopBootWake = usePlayerStore.subscribe((state, prev) => {
|
|
164
|
+
if (state.previewBooted && !prev.previewBooted) requestOverlayFrames();
|
|
165
|
+
});
|
|
157
166
|
}
|
|
158
167
|
|
|
159
168
|
function detachListeners(): void {
|
|
@@ -163,6 +172,8 @@ function detachListeners(): void {
|
|
|
163
172
|
window.removeEventListener(type, requestOverlayFrames, { capture: true });
|
|
164
173
|
}
|
|
165
174
|
window.removeEventListener("message", onPreviewMessage, { capture: true });
|
|
175
|
+
stopBootWake?.();
|
|
176
|
+
stopBootWake = null;
|
|
166
177
|
}
|
|
167
178
|
|
|
168
179
|
/**
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { act, useRef, useState } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
6
|
import { resetOverlayFrameLoopForTests } from "./overlayFrameLoop";
|
|
7
7
|
import {
|
|
8
8
|
usePreviewCompositionRect,
|
|
9
9
|
type PreviewCompositionRect,
|
|
10
10
|
} from "./usePreviewCompositionRect";
|
|
11
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
11
12
|
|
|
12
13
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
14
|
|
|
@@ -59,6 +60,10 @@ function compositionFrame(box: [number, number, number, number], width: number,
|
|
|
59
60
|
|
|
60
61
|
const settle = () => new Promise((r) => setTimeout(r, 400));
|
|
61
62
|
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
usePlayerStore.setState({ previewBooted: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
62
67
|
describe("usePreviewCompositionRect", () => {
|
|
63
68
|
it("reports the live preview iframe's box relative to the overlay, scaled to the composition", async () => {
|
|
64
69
|
const probe = mountProbe();
|
|
@@ -10,6 +10,7 @@ import { readPreviewComplexity } from "../../player/hooks/usePreviewFirstFrameTe
|
|
|
10
10
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
11
|
|
|
12
12
|
const playerMounts: string[] = [];
|
|
13
|
+
let livePlayerProps: { onReadyToShowChange?: (ready: boolean) => void } = {};
|
|
13
14
|
|
|
14
15
|
vi.mock("../../player", async () => {
|
|
15
16
|
const React = await import("react");
|
|
@@ -18,11 +19,13 @@ vi.mock("../../player", async () => {
|
|
|
18
19
|
Player: React.forwardRef(function MockPlayer(
|
|
19
20
|
props: {
|
|
20
21
|
onLoad?: () => void;
|
|
22
|
+
onReadyToShowChange?: (ready: boolean) => void;
|
|
21
23
|
suppressLoadingOverlay?: boolean;
|
|
22
24
|
style?: React.CSSProperties;
|
|
23
25
|
},
|
|
24
26
|
ref: React.ForwardedRef<HTMLIFrameElement>,
|
|
25
27
|
) {
|
|
28
|
+
if (!props.suppressLoadingOverlay) livePlayerProps = props;
|
|
26
29
|
React.useEffect(() => {
|
|
27
30
|
props.onLoad?.();
|
|
28
31
|
}, [props]);
|
|
@@ -90,23 +93,25 @@ function renderPreview(
|
|
|
90
93
|
document.body.append(host);
|
|
91
94
|
const root = createRoot(host);
|
|
92
95
|
const iframeRef = createRef<HTMLIFrameElement>();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
96
|
+
const render = (directUrl?: string) =>
|
|
97
|
+
act(() => {
|
|
98
|
+
root.render(
|
|
99
|
+
React.createElement(NLEPreview, {
|
|
100
|
+
projectId: "timeline-edit-playground",
|
|
101
|
+
directUrl,
|
|
102
|
+
iframeRef,
|
|
103
|
+
onIframeLoad: () => {},
|
|
104
|
+
previewSlots,
|
|
105
|
+
onShadowIframeLoad: () => {},
|
|
106
|
+
onShadowReadyChange: () => {},
|
|
107
|
+
onShadowError: () => {},
|
|
108
|
+
setShadowIframeNode: () => {},
|
|
109
|
+
resetPreviewSlots: () => {},
|
|
110
|
+
fillBox,
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
render();
|
|
110
115
|
|
|
111
116
|
const viewport = host.querySelector('[aria-label="Composition preview"]') as HTMLDivElement;
|
|
112
117
|
const stage = host.querySelector('[data-testid="preview-zoom-stage"]') as HTMLDivElement;
|
|
@@ -121,6 +126,7 @@ function renderPreview(
|
|
|
121
126
|
return {
|
|
122
127
|
host,
|
|
123
128
|
root,
|
|
129
|
+
render,
|
|
124
130
|
viewport,
|
|
125
131
|
stage,
|
|
126
132
|
cleanup() {
|
|
@@ -283,6 +289,77 @@ describe("NLEPreview", () => {
|
|
|
283
289
|
view.cleanup();
|
|
284
290
|
});
|
|
285
291
|
|
|
292
|
+
it("covers the live preview with the cached frame-0 poster until it is ready to show", () => {
|
|
293
|
+
const view = renderPreview();
|
|
294
|
+
const poster = () =>
|
|
295
|
+
view.stage.querySelector<HTMLImageElement>('[data-testid="preview-poster"]');
|
|
296
|
+
expect(poster()?.getAttribute("src")).toBe(
|
|
297
|
+
"/api/projects/timeline-edit-playground/thumbnail/index.html?t=0&output=source&cached=1",
|
|
298
|
+
);
|
|
299
|
+
expect(poster()?.style.zIndex).toBe("2");
|
|
300
|
+
|
|
301
|
+
act(() => livePlayerProps.onReadyToShowChange?.(false));
|
|
302
|
+
expect(poster()?.hidden).toBe(false);
|
|
303
|
+
act(() => livePlayerProps.onReadyToShowChange?.(true));
|
|
304
|
+
expect(poster()?.hidden).toBe(true);
|
|
305
|
+
act(() => poster()?.dispatchEvent(new Event("load")));
|
|
306
|
+
expect(poster()).toBeNull();
|
|
307
|
+
view.cleanup();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("a missing poster", () => {
|
|
311
|
+
const renderUrl =
|
|
312
|
+
"/api/projects/timeline-edit-playground/thumbnail/index.html?t=0&output=source";
|
|
313
|
+
const fetchSpy = vi.fn(() => Promise.resolve(new Response()));
|
|
314
|
+
beforeEach(() => {
|
|
315
|
+
fetchSpy.mockClear();
|
|
316
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
317
|
+
});
|
|
318
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
319
|
+
|
|
320
|
+
const settle = (
|
|
321
|
+
view: ReturnType<typeof renderPreview>,
|
|
322
|
+
steps: Array<"ready" | "missing" | "loaded">,
|
|
323
|
+
) => {
|
|
324
|
+
for (const step of steps) {
|
|
325
|
+
const poster = view.stage.querySelector('[data-testid="preview-poster"]');
|
|
326
|
+
act(() =>
|
|
327
|
+
step === "ready"
|
|
328
|
+
? livePlayerProps.onReadyToShowChange?.(true)
|
|
329
|
+
: poster?.dispatchEvent(new Event(step === "missing" ? "error" : "load")),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
it("is rendered for the next open when the live frame is ready first", () => {
|
|
335
|
+
const view = renderPreview();
|
|
336
|
+
settle(view, ["ready", "missing"]);
|
|
337
|
+
expect(fetchSpy.mock.calls).toEqual([[renderUrl]]);
|
|
338
|
+
expect(view.stage.querySelector('[data-testid="preview-poster"]')).toBeNull();
|
|
339
|
+
view.cleanup();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("is rendered once the live frame is ready when it is missing first", () => {
|
|
343
|
+
const view = renderPreview();
|
|
344
|
+
settle(view, ["missing"]);
|
|
345
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
346
|
+
settle(view, ["ready", "ready"]);
|
|
347
|
+
expect(fetchSpy.mock.calls).toEqual([[renderUrl]]);
|
|
348
|
+
view.cleanup();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("is rendered on a return from a sub-composition when the live frame is ready first", () => {
|
|
352
|
+
const view = renderPreview();
|
|
353
|
+
settle(view, ["loaded", "ready"]);
|
|
354
|
+
view.render("/api/projects/timeline-edit-playground/preview/comp/compositions/intro.html");
|
|
355
|
+
settle(view, ["ready"]);
|
|
356
|
+
view.render();
|
|
357
|
+
settle(view, ["ready", "missing"]);
|
|
358
|
+
expect(fetchSpy.mock.calls).toEqual([[renderUrl]]);
|
|
359
|
+
view.cleanup();
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
286
363
|
it("mounts the live player once when the composition switches", () => {
|
|
287
364
|
playerMounts.length = 0;
|
|
288
365
|
const Harness = ({ projectId }: { projectId: string }) => {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore";
|
|
20
20
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
21
21
|
import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
|
|
22
|
+
import { PreviewPoster, usePreviewPoster } from "./PreviewPoster";
|
|
22
23
|
interface NLEPreviewProps {
|
|
23
24
|
projectId: string;
|
|
24
25
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
@@ -154,6 +155,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
154
155
|
const liveGenRef = useRef<number | null>(null);
|
|
155
156
|
const reportPreviewFirstFrame = usePreviewFirstFrameTelemetry(previewSlots);
|
|
156
157
|
const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
|
|
158
|
+
const poster = usePreviewPoster(projectId, activeKey, directUrl);
|
|
157
159
|
const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
|
|
158
160
|
const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
|
|
159
161
|
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
|
|
@@ -522,6 +524,8 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
522
524
|
applyInitialZoom();
|
|
523
525
|
}}
|
|
524
526
|
onCompositionLoadingChange={onCompositionLoadingChange}
|
|
527
|
+
onReadyToShowChange={poster.onLiveReadyToShowChange}
|
|
528
|
+
onPreviewError={poster.onPreviewError}
|
|
525
529
|
onPainted={(details) => reportPreviewFirstFrame(slot, details)}
|
|
526
530
|
portrait={portrait}
|
|
527
531
|
suppressLoadingOverlay={suppressLoadingOverlay}
|
|
@@ -547,6 +551,16 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
547
551
|
/>
|
|
548
552
|
),
|
|
549
553
|
)}
|
|
554
|
+
{poster.mountPoster && (
|
|
555
|
+
<PreviewPoster
|
|
556
|
+
key={activeKey}
|
|
557
|
+
projectId={projectId}
|
|
558
|
+
hidden={poster.hidePoster}
|
|
559
|
+
onSize={(size) => setCompositionSize((prev) => prev ?? size)}
|
|
560
|
+
onLoaded={poster.onPosterLoaded}
|
|
561
|
+
onMissing={poster.onPosterMissing}
|
|
562
|
+
/>
|
|
563
|
+
)}
|
|
550
564
|
</div>
|
|
551
565
|
</div>
|
|
552
566
|
<div
|