@hyperframes/studio 0.8.51 → 0.8.52
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-Cn_PpHqP.js → hyperframes-player-I5YRzB02.js} +1 -1
- package/dist/assets/{index-Clrb11lj.js → index-BTAfgesM.js} +1 -1
- package/dist/assets/index-CxAu_Okg.css +1 -0
- package/dist/assets/index-DocJlW2v.js +376 -0
- package/dist/assets/{index-CPS3Cvj8.js → index-Du5x7KUR.js} +1 -1
- package/dist/index.d.ts +401 -7
- package/dist/index.html +2 -2
- package/dist/index.js +6545 -5869
- package/dist/index.js.map +1 -1
- package/package.json +9 -7
- package/src/App.tsx +3 -2
- package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
- package/src/captions/components/CaptionOverlay.tsx +8 -4
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/RightPanelTabs.test.tsx +151 -0
- package/src/components/RightPanelTabs.tsx +59 -0
- package/src/components/StudioHeader.dom.test.tsx +181 -0
- package/src/components/StudioHeader.tsx +109 -149
- package/src/components/StudioRightPanel.tsx +47 -35
- package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
- package/src/components/TimelineHistoryButtons.tsx +54 -0
- package/src/components/TimelineToolbar.test.tsx +8 -0
- package/src/components/TimelineToolbar.tsx +3 -7
- package/src/components/dock/Dock.test.tsx +84 -16
- package/src/components/dock/Dock.tsx +19 -2
- package/src/components/dock/dock.css +5 -0
- package/src/components/dock/dockAccessibility.test.ts +166 -0
- package/src/components/dock/dockAccessibility.ts +98 -0
- package/src/components/dock/dockLayout.test.ts +134 -0
- package/src/components/dock/dockLayout.ts +53 -6
- package/src/components/dock/dockTestHarness.ts +11 -0
- package/src/components/editor/LayersPanel.test.ts +97 -1
- package/src/components/editor/LayersPanel.tsx +4 -2
- package/src/components/editor/PreviewGuides.tsx +1 -1
- package/src/components/editor/TopologyLens.test.tsx +28 -0
- package/src/components/editor/TopologyLens.tsx +3 -1
- package/src/components/editor/useColorGradingController.test.ts +38 -0
- package/src/components/editor/useColorGradingController.ts +9 -1
- package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
- package/src/components/editor/useColorGradingPreviews.ts +11 -1
- package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
- package/src/components/editor/usePreviewCompositionRect.ts +21 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewPane.tsx +1 -1
- package/src/components/pointerTargetSize.test.tsx +8 -0
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/timelineToolbarStyles.ts +5 -0
- package/src/components/ui/Button.test.tsx +222 -30
- package/src/components/ui/Button.tsx +88 -125
- package/src/components/ui/IconButton.tsx +38 -0
- package/src/components/ui/Input.tsx +115 -0
- package/src/components/ui/Menu.test.tsx +374 -0
- package/src/components/ui/Menu.tsx +213 -0
- package/src/components/ui/NumberField.tsx +122 -0
- package/src/components/ui/Popover.tsx +74 -0
- package/src/components/ui/Select.tsx +100 -0
- package/src/components/ui/Slider.tsx +153 -0
- package/src/components/ui/Tabs.test.tsx +98 -0
- package/src/components/ui/Tabs.tsx +71 -0
- package/src/components/ui/Toggle.tsx +63 -0
- package/src/components/ui/Tooltip.test.tsx +110 -0
- package/src/components/ui/Tooltip.tsx +38 -112
- package/src/components/ui/cn.test.ts +30 -0
- package/src/components/ui/cn.ts +25 -0
- package/src/components/ui/index.ts +29 -2
- package/src/components/ui/mountHost.testHelpers.ts +24 -0
- package/src/components/ui/valueControls.test.tsx +524 -0
- package/src/hooks/useAppHotkeys.ts +20 -79
- package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
- package/src/hooks/useCaptionDetection.ts +3 -1
- package/src/hooks/useEditHistoryActions.test.tsx +90 -0
- package/src/hooks/useEditHistoryActions.ts +106 -0
- package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
- package/src/hooks/useServerConnection.ts +43 -10
- package/src/index.ts +51 -0
- package/src/player/components/AutomationSelectionMenu.tsx +1 -1
- package/src/player/components/TimelineCanvas.tsx +4 -4
- package/src/player/components/TimelineClip.tsx +0 -1
- package/src/player/lib/playbackShortcuts.ts +2 -0
- package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
- package/src/player/store/previewIframeStore.test.tsx +29 -0
- package/src/player/store/previewIframeStore.ts +25 -0
- package/src/renderQueueExports.test.tsx +58 -0
- package/src/styles/classCandidates.test.ts +105 -0
- package/src/styles/classCandidates.ts +281 -0
- package/src/styles/hex-baseline.json +84 -0
- package/src/styles/hexRatchet.test.ts +201 -0
- package/src/styles/styleSources.test.ts +20 -0
- package/src/styles/styleSources.ts +47 -0
- package/src/styles/theme.css +30 -0
- package/src/styles/theme.test.ts +2 -15
- package/src/styles/tokenGate.test.ts +109 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +11 -1
- package/src/utils/typingTarget.ts +4 -0
- package/dist/assets/index-C05SNrvY.js +0 -360
- package/dist/assets/index-DUR9qzQM.css +0 -1
- package/src/components/PanelTabButton.tsx +0 -31
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createDockview, type DockviewApi } from "dockview-react";
|
|
2
|
+
|
|
3
|
+
/** A real dockview mounted on a fresh host element, with empty panel bodies. */
|
|
4
|
+
export function mountBareDockview(): { host: HTMLElement; api: DockviewApi } {
|
|
5
|
+
const host = document.createElement("div");
|
|
6
|
+
document.body.append(host);
|
|
7
|
+
const api = createDockview(host, {
|
|
8
|
+
createComponent: () => ({ element: document.createElement("div"), init() {}, dispose() {} }),
|
|
9
|
+
});
|
|
10
|
+
return { host, api };
|
|
11
|
+
}
|
|
@@ -1,12 +1,55 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
+
import { createElement, act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
3
5
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
4
6
|
import { Window } from "happy-dom";
|
|
7
|
+
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
|
5
8
|
import type { DomEditLayerItem } from "./domEditingTypes";
|
|
6
|
-
import { createRafThrottle, sortLayersByZIndex } from "./LayersPanel";
|
|
9
|
+
import { createRafThrottle, LayersPanel, sortLayersByZIndex } from "./LayersPanel";
|
|
7
10
|
import { isLayerDraggable } from "./useLayerDrag";
|
|
8
11
|
import { liveTime } from "../../player";
|
|
9
12
|
|
|
13
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
const mocks = vi.hoisted(() => ({
|
|
16
|
+
previewIframeRef: { current: null } as { current: unknown },
|
|
17
|
+
collect: vi.fn(),
|
|
18
|
+
setActiveGroupElement: vi.fn(),
|
|
19
|
+
showToast: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
23
|
+
useStudioShellContext: () => ({
|
|
24
|
+
previewIframeRef: mocks.previewIframeRef,
|
|
25
|
+
activeCompPath: "index.html",
|
|
26
|
+
showToast: mocks.showToast,
|
|
27
|
+
}),
|
|
28
|
+
useStudioPlaybackContext: () => ({
|
|
29
|
+
refreshKey: 0,
|
|
30
|
+
compositionLoading: true,
|
|
31
|
+
timelineElements: [],
|
|
32
|
+
isPlaying: false,
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
vi.mock("../../contexts/DomEditContext", () => ({
|
|
36
|
+
useDomEditContext: () => ({
|
|
37
|
+
domEditSelection: null,
|
|
38
|
+
activeGroupElement: null,
|
|
39
|
+
applyDomSelection: vi.fn(),
|
|
40
|
+
updateDomEditHoverSelection: vi.fn(),
|
|
41
|
+
handleDomZIndexReorderCommit: vi.fn(),
|
|
42
|
+
setActiveGroupElement: mocks.setActiveGroupElement,
|
|
43
|
+
}),
|
|
44
|
+
}));
|
|
45
|
+
vi.mock("../nle/useCanvasZOrderTimelineMirror", () => ({
|
|
46
|
+
useLayerReorderTimelineMirror: () => vi.fn(),
|
|
47
|
+
}));
|
|
48
|
+
vi.mock("./domEditing", async (importOriginal) => ({
|
|
49
|
+
...(await importOriginal<typeof import("./domEditing")>()),
|
|
50
|
+
collectDomEditLayerItems: mocks.collect,
|
|
51
|
+
}));
|
|
52
|
+
|
|
10
53
|
function makeLayer(
|
|
11
54
|
overrides: Partial<DomEditLayerItem> & { zIndex?: string; locked?: boolean },
|
|
12
55
|
): DomEditLayerItem {
|
|
@@ -197,3 +240,56 @@ describe("liveTime subscribe / unsubscribe (LayersPanel scrub contract)", () =>
|
|
|
197
240
|
unsubscribe();
|
|
198
241
|
});
|
|
199
242
|
});
|
|
243
|
+
|
|
244
|
+
describe("LayersPanel preview promotion", () => {
|
|
245
|
+
let root: Root;
|
|
246
|
+
let host: HTMLDivElement;
|
|
247
|
+
|
|
248
|
+
function makeIframe(compId: string): HTMLIFrameElement {
|
|
249
|
+
const iframe = document.createElement("iframe");
|
|
250
|
+
const doc = new Window().document;
|
|
251
|
+
doc.body.innerHTML = `<div data-composition-id="${compId}"></div>`;
|
|
252
|
+
Object.defineProperty(iframe, "contentDocument", { value: doc });
|
|
253
|
+
return iframe;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const collectedRootIds = () =>
|
|
257
|
+
mocks.collect.mock.calls.map((call) => (call[0] as HTMLElement).dataset.compositionId);
|
|
258
|
+
|
|
259
|
+
beforeEach(() => {
|
|
260
|
+
mocks.collect.mockReset().mockReturnValue([]);
|
|
261
|
+
host = document.createElement("div");
|
|
262
|
+
document.body.append(host);
|
|
263
|
+
root = createRoot(host);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
afterEach(() => {
|
|
267
|
+
act(() => root.unmount());
|
|
268
|
+
host.remove();
|
|
269
|
+
usePreviewIframeStore.setState({ iframe: null });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("recollects layers from the promoted document's load, not the replaced one", () => {
|
|
273
|
+
const a = makeIframe("a");
|
|
274
|
+
const b = makeIframe("b");
|
|
275
|
+
mocks.previewIframeRef.current = a;
|
|
276
|
+
usePreviewIframeStore.setState({ iframe: a });
|
|
277
|
+
act(() => root.render(createElement(LayersPanel)));
|
|
278
|
+
|
|
279
|
+
act(() => {
|
|
280
|
+
mocks.previewIframeRef.current = b;
|
|
281
|
+
usePreviewIframeStore.getState().setIframe(b);
|
|
282
|
+
});
|
|
283
|
+
mocks.collect.mockClear();
|
|
284
|
+
|
|
285
|
+
act(() => {
|
|
286
|
+
a.dispatchEvent(new Event("load"));
|
|
287
|
+
});
|
|
288
|
+
expect(mocks.collect).not.toHaveBeenCalled();
|
|
289
|
+
|
|
290
|
+
act(() => {
|
|
291
|
+
b.dispatchEvent(new Event("load"));
|
|
292
|
+
});
|
|
293
|
+
expect(collectedRootIds()).toEqual(["b"]);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
|
1
2
|
import { memo, useState, useCallback, useEffect, useRef } from "react";
|
|
2
3
|
import {
|
|
3
4
|
collectDomEditLayerItems,
|
|
@@ -122,6 +123,7 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
122
123
|
|
|
123
124
|
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
124
125
|
|
|
126
|
+
const livePreviewIframe = useLivePreviewIframe();
|
|
125
127
|
const collectLayers = useCallback(() => {
|
|
126
128
|
const iframe = previewIframeRef.current;
|
|
127
129
|
if (!iframe) return;
|
|
@@ -152,7 +154,7 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
152
154
|
|
|
153
155
|
useEffect(() => {
|
|
154
156
|
collectLayers();
|
|
155
|
-
}, [collectLayers, refreshKey, zEditVersion]);
|
|
157
|
+
}, [collectLayers, refreshKey, zEditVersion, livePreviewIframe]);
|
|
156
158
|
|
|
157
159
|
useEffect(() => {
|
|
158
160
|
const iframe = previewIframeRef.current;
|
|
@@ -163,7 +165,7 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
163
165
|
};
|
|
164
166
|
iframe.addEventListener("load", handleLoad);
|
|
165
167
|
return () => iframe.removeEventListener("load", handleLoad);
|
|
166
|
-
}, [previewIframeRef, collectLayers]);
|
|
168
|
+
}, [previewIframeRef, livePreviewIframe, collectLayers]);
|
|
167
169
|
|
|
168
170
|
useEffect(() => {
|
|
169
171
|
if (!compositionLoading) {
|
|
@@ -8,7 +8,7 @@ interface PreviewGuidesProps {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
const TICKS = Array.from({ length: 11 }, (_, i) => i * 10);
|
|
11
|
-
const INK = "
|
|
11
|
+
const INK = "color-mix(in srgb, white 70%, transparent)";
|
|
12
12
|
|
|
13
13
|
/** Ruler and safe-margin boxes drawn over the preview pane, never inside the composition. */
|
|
14
14
|
export const PreviewGuides = memo(function PreviewGuides({ iframeRef }: PreviewGuidesProps) {
|
|
@@ -4,6 +4,7 @@ import React, { act } from "react";
|
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { studioEditLifecycle, type StudioWriteResult } from "../../webmcp/writeCoordinator";
|
|
7
|
+
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
|
7
8
|
import { TopologyLens } from "./TopologyLens";
|
|
8
9
|
|
|
9
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -291,4 +292,31 @@ describe("TopologyLens", () => {
|
|
|
291
292
|
expect(vi.getTimerCount()).toBe(0);
|
|
292
293
|
expect(studioEditLifecycle.getSnapshot()).toEqual({ phase: "idle" });
|
|
293
294
|
});
|
|
295
|
+
|
|
296
|
+
it("dismisses on the promoted iframe's load, not the replaced one", () => {
|
|
297
|
+
const a = document.createElement("iframe");
|
|
298
|
+
const b = document.createElement("iframe");
|
|
299
|
+
document.body.append(a, b);
|
|
300
|
+
const ref = { current: a as HTMLIFrameElement | null };
|
|
301
|
+
usePreviewIframeStore.setState({ iframe: a });
|
|
302
|
+
window.matchMedia = matchMedia(false);
|
|
303
|
+
host = document.createElement("div");
|
|
304
|
+
document.body.append(host);
|
|
305
|
+
root = createRoot(host);
|
|
306
|
+
act(() => {
|
|
307
|
+
root?.render(<TopologyLens iframeRef={ref} activeCompositionPath="index.html" />);
|
|
308
|
+
});
|
|
309
|
+
begin();
|
|
310
|
+
|
|
311
|
+
act(() => {
|
|
312
|
+
ref.current = b;
|
|
313
|
+
usePreviewIframeStore.getState().setIframe(b);
|
|
314
|
+
});
|
|
315
|
+
act(() => a.dispatchEvent(new Event("load")));
|
|
316
|
+
expect(host.querySelector('[data-topology-lens="hidden"]')).toBeNull();
|
|
317
|
+
|
|
318
|
+
act(() => b.dispatchEvent(new Event("load")));
|
|
319
|
+
expect(host.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
|
|
320
|
+
usePreviewIframeStore.setState({ iframe: null });
|
|
321
|
+
});
|
|
294
322
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
|
1
2
|
import {
|
|
2
3
|
useEffect,
|
|
3
4
|
useLayoutEffect,
|
|
@@ -113,13 +114,14 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
|
|
|
113
114
|
setMeasured(geometry ? { callId, geometry } : null);
|
|
114
115
|
}, [activeCompositionPath, callId, handle, iframeRef, phase]);
|
|
115
116
|
|
|
117
|
+
const livePreviewIframe = useLivePreviewIframe();
|
|
116
118
|
useEffect(() => {
|
|
117
119
|
const iframe = iframeRef.current;
|
|
118
120
|
if (!iframe || !callId) return;
|
|
119
121
|
const dismiss = () => studioEditLifecycle.dismiss(callId);
|
|
120
122
|
iframe.addEventListener("load", dismiss);
|
|
121
123
|
return () => iframe.removeEventListener("load", dismiss);
|
|
122
|
-
}, [callId, iframeRef]);
|
|
124
|
+
}, [callId, iframeRef, livePreviewIframe]);
|
|
123
125
|
|
|
124
126
|
useEffect(() => {
|
|
125
127
|
if (pendingUnmountDismissRef.current !== null) {
|
|
@@ -4,6 +4,8 @@ import React, { act } from "react";
|
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
|
|
7
|
+
import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol";
|
|
8
|
+
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
|
7
9
|
import { useColorGradingController } from "./useColorGradingController";
|
|
8
10
|
import type { DomEditSelection } from "./domEditing";
|
|
9
11
|
|
|
@@ -22,6 +24,7 @@ function cleanStudioGrading() {
|
|
|
22
24
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
23
25
|
|
|
24
26
|
afterEach(() => {
|
|
27
|
+
usePreviewIframeStore.setState({ iframe: null });
|
|
25
28
|
document.body.innerHTML = "";
|
|
26
29
|
});
|
|
27
30
|
|
|
@@ -737,3 +740,38 @@ describe("useColorGradingController", () => {
|
|
|
737
740
|
vi.unstubAllGlobals();
|
|
738
741
|
});
|
|
739
742
|
});
|
|
743
|
+
|
|
744
|
+
describe("useColorGradingController after the preview iframe is replaced", () => {
|
|
745
|
+
it("replays the grade to the new iframe on its ready message and ignores the old one", () => {
|
|
746
|
+
vi.useFakeTimers();
|
|
747
|
+
const a = createPreviewFrame();
|
|
748
|
+
const b = createPreviewFrame();
|
|
749
|
+
const ref = { current: a.iframe as HTMLIFrameElement | null };
|
|
750
|
+
usePreviewIframeStore.getState().setIframe(a.iframe);
|
|
751
|
+
const initialElement = makeElement({
|
|
752
|
+
dataAttributes: { "color-grading": JSON.stringify({ preset: "bright-pop", intensity: 1 }) },
|
|
753
|
+
});
|
|
754
|
+
const { root } = renderHook(vi.fn(), initialElement, ref);
|
|
755
|
+
act(() => vi.advanceTimersByTime(100));
|
|
756
|
+
|
|
757
|
+
const postToA = vi.spyOn(a.contentWindow, "postMessage");
|
|
758
|
+
const postToB = vi.spyOn(b.contentWindow, "postMessage");
|
|
759
|
+
act(() => {
|
|
760
|
+
ref.current = b.iframe;
|
|
761
|
+
usePreviewIframeStore.getState().setIframe(b.iframe);
|
|
762
|
+
});
|
|
763
|
+
const ready = { source: "hf-preview", type: "ready", ...runtimeProtocolMetadata(30) };
|
|
764
|
+
act(() => {
|
|
765
|
+
window.dispatchEvent(new MessageEvent("message", { data: ready, source: a.contentWindow }));
|
|
766
|
+
});
|
|
767
|
+
expect(colorGradingMessages(postToB.mock.calls)).toHaveLength(0);
|
|
768
|
+
act(() => {
|
|
769
|
+
window.dispatchEvent(new MessageEvent("message", { data: ready, source: b.contentWindow }));
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
expect(colorGradingMessages(postToB.mock.calls)).toHaveLength(1);
|
|
773
|
+
expect(postToA).not.toHaveBeenCalled();
|
|
774
|
+
act(() => root.unmount());
|
|
775
|
+
vi.useRealTimers();
|
|
776
|
+
});
|
|
777
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
|
1
2
|
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
|
2
3
|
import {
|
|
3
4
|
HF_COLOR_GRADING_ATTR,
|
|
@@ -430,6 +431,7 @@ export function useColorGradingController({
|
|
|
430
431
|
[previewIframeRef, target],
|
|
431
432
|
);
|
|
432
433
|
|
|
434
|
+
const livePreviewIframe = useLivePreviewIframe();
|
|
433
435
|
useEffect(() => {
|
|
434
436
|
const iframe = previewIframeRef?.current;
|
|
435
437
|
if (!iframe) return;
|
|
@@ -455,7 +457,13 @@ export function useColorGradingController({
|
|
|
455
457
|
window.removeEventListener("message", onMessage);
|
|
456
458
|
window.clearTimeout(timer);
|
|
457
459
|
};
|
|
458
|
-
}, [
|
|
460
|
+
}, [
|
|
461
|
+
postColorGrading,
|
|
462
|
+
postCompare,
|
|
463
|
+
previewIframeRef,
|
|
464
|
+
scheduleRuntimeStatusRefresh,
|
|
465
|
+
livePreviewIframe,
|
|
466
|
+
]);
|
|
459
467
|
|
|
460
468
|
useEffect(
|
|
461
469
|
() => () => {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
|
|
7
|
+
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
|
8
|
+
import { useColorGradingPreviews } from "./useColorGradingPreviews";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
usePreviewIframeStore.setState({ iframe: null });
|
|
14
|
+
document.body.innerHTML = "";
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function createFrame() {
|
|
18
|
+
return document.body.appendChild(document.createElement("iframe"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function installRenderer(iframe: HTMLIFrameElement) {
|
|
22
|
+
const renderPreviews = vi.fn().mockResolvedValue({
|
|
23
|
+
width: 160,
|
|
24
|
+
height: 90,
|
|
25
|
+
images: [{ id: "bright-pop", dataUrl: "data:image/png;base64,x" }],
|
|
26
|
+
});
|
|
27
|
+
(iframe.contentWindow as unknown as { __hf: unknown }).__hf = {
|
|
28
|
+
colorGrading: { renderPreviews },
|
|
29
|
+
};
|
|
30
|
+
return renderPreviews;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("useColorGradingPreviews after the preview iframe is replaced", () => {
|
|
34
|
+
it("requests preset previews from the new iframe's runtime", async () => {
|
|
35
|
+
vi.useFakeTimers();
|
|
36
|
+
const a = createFrame();
|
|
37
|
+
const b = createFrame();
|
|
38
|
+
const renderOnB = installRenderer(b);
|
|
39
|
+
const ref = { current: a as HTMLIFrameElement | null };
|
|
40
|
+
usePreviewIframeStore.getState().setIframe(a);
|
|
41
|
+
const grading = normalizeHfColorGrading({ preset: "neutral" });
|
|
42
|
+
if (!grading) throw new Error("expected neutral grading");
|
|
43
|
+
const gradingRef = { current: grading };
|
|
44
|
+
const target = { selector: "#v" } as never;
|
|
45
|
+
const postColorGrading = vi.fn();
|
|
46
|
+
let requestPresetPreviews: () => void = () => {};
|
|
47
|
+
function Host() {
|
|
48
|
+
requestPresetPreviews = useColorGradingPreviews({
|
|
49
|
+
grading,
|
|
50
|
+
gradingRef,
|
|
51
|
+
identityKey: "k",
|
|
52
|
+
target,
|
|
53
|
+
previewIframeRef: ref,
|
|
54
|
+
postColorGrading,
|
|
55
|
+
}).requestPresetPreviews;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
59
|
+
act(() => root.render(React.createElement(Host)));
|
|
60
|
+
act(() => requestPresetPreviews());
|
|
61
|
+
act(() => vi.advanceTimersByTime(0));
|
|
62
|
+
|
|
63
|
+
act(() => {
|
|
64
|
+
ref.current = b;
|
|
65
|
+
usePreviewIframeStore.getState().setIframe(b);
|
|
66
|
+
});
|
|
67
|
+
act(() => vi.advanceTimersByTime(0));
|
|
68
|
+
await act(async () => {
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(renderOnB).toHaveBeenCalled();
|
|
73
|
+
act(() => root.unmount());
|
|
74
|
+
vi.useRealTimers();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
|
1
2
|
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
|
2
3
|
import {
|
|
3
4
|
HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS,
|
|
@@ -155,6 +156,7 @@ export function useColorGradingPreviews({
|
|
|
155
156
|
}
|
|
156
157
|
const previews = state.identityKey === identityKey ? state.previews : emptyPreviews();
|
|
157
158
|
|
|
159
|
+
const livePreviewIframe = useLivePreviewIframe();
|
|
158
160
|
useEffect(() => {
|
|
159
161
|
if (!request) return;
|
|
160
162
|
const { kind } = request;
|
|
@@ -245,7 +247,15 @@ export function useColorGradingPreviews({
|
|
|
245
247
|
iframe.removeEventListener("load", attempt);
|
|
246
248
|
window.removeEventListener("message", onMessage);
|
|
247
249
|
};
|
|
248
|
-
}, [
|
|
250
|
+
}, [
|
|
251
|
+
grading.effects,
|
|
252
|
+
grading.lut,
|
|
253
|
+
grading.palette,
|
|
254
|
+
previewIframeRef,
|
|
255
|
+
livePreviewIframe,
|
|
256
|
+
request,
|
|
257
|
+
target,
|
|
258
|
+
]);
|
|
249
259
|
|
|
250
260
|
const stopAnimatedPreview = useCallback(() => {
|
|
251
261
|
const session = animatedRef.current;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, useRef } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
|
7
|
+
import {
|
|
8
|
+
usePreviewCompositionRect,
|
|
9
|
+
type PreviewCompositionRect,
|
|
10
|
+
} from "./usePreviewCompositionRect";
|
|
11
|
+
|
|
12
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
usePreviewIframeStore.getState().setIframe(null);
|
|
16
|
+
document.body.innerHTML = "";
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function boxed<T extends HTMLElement>(el: T, box: [number, number, number, number]): T {
|
|
20
|
+
const [left, top, width, height] = box;
|
|
21
|
+
el.getBoundingClientRect = () => new DOMRect(left, top, width, height);
|
|
22
|
+
return el;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mountProbe() {
|
|
26
|
+
let rect: PreviewCompositionRect | undefined;
|
|
27
|
+
function Probe() {
|
|
28
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
29
|
+
rect = usePreviewCompositionRect(ref);
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
ref={(el) => {
|
|
33
|
+
ref.current = el;
|
|
34
|
+
if (el) boxed(el, [10, 20, 1000, 600]);
|
|
35
|
+
}}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const host = document.createElement("div");
|
|
40
|
+
document.body.append(host);
|
|
41
|
+
act(() => createRoot(host).render(<Probe />));
|
|
42
|
+
return () => rect;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function compositionFrame(box: [number, number, number, number], width: number, height: number) {
|
|
46
|
+
const iframe = boxed(document.createElement("iframe"), box);
|
|
47
|
+
document.body.append(iframe);
|
|
48
|
+
const root = iframe.contentDocument?.createElement("div");
|
|
49
|
+
root?.setAttribute("data-composition-id", "main");
|
|
50
|
+
root?.setAttribute("data-width", String(width));
|
|
51
|
+
root?.setAttribute("data-height", String(height));
|
|
52
|
+
if (root) iframe.contentDocument?.body.append(root);
|
|
53
|
+
return iframe;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const settle = () => new Promise((r) => setTimeout(r, 400));
|
|
57
|
+
|
|
58
|
+
describe("usePreviewCompositionRect", () => {
|
|
59
|
+
it("reports the live preview iframe's box relative to the overlay, scaled to the composition", async () => {
|
|
60
|
+
const read = mountProbe();
|
|
61
|
+
const iframe = compositionFrame([110, 70, 960, 540], 1920, 1080);
|
|
62
|
+
await act(async () => {
|
|
63
|
+
usePreviewIframeStore.getState().setIframe(iframe);
|
|
64
|
+
await settle();
|
|
65
|
+
});
|
|
66
|
+
expect(read()).toEqual({
|
|
67
|
+
left: 100,
|
|
68
|
+
top: 50,
|
|
69
|
+
width: 960,
|
|
70
|
+
height: 540,
|
|
71
|
+
scaleX: 0.5,
|
|
72
|
+
scaleY: 0.5,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("follows the iframe when a reload replaces it", async () => {
|
|
77
|
+
const read = mountProbe();
|
|
78
|
+
await act(async () => {
|
|
79
|
+
usePreviewIframeStore.getState().setIframe(compositionFrame([110, 70, 960, 540], 1920, 1080));
|
|
80
|
+
await settle();
|
|
81
|
+
});
|
|
82
|
+
await act(async () => {
|
|
83
|
+
usePreviewIframeStore.getState().setIframe(compositionFrame([60, 40, 500, 500], 1000, 1000));
|
|
84
|
+
await settle();
|
|
85
|
+
});
|
|
86
|
+
expect(read()).toEqual({
|
|
87
|
+
left: 50,
|
|
88
|
+
top: 20,
|
|
89
|
+
width: 500,
|
|
90
|
+
height: 500,
|
|
91
|
+
scaleX: 0.5,
|
|
92
|
+
scaleY: 0.5,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useMemo, type RefObject } from "react";
|
|
2
|
+
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
|
3
|
+
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
|
4
|
+
|
|
5
|
+
export type { DomEditCompositionRect as PreviewCompositionRect } from "./useDomEditCompositionRect";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where the composition sits inside `overlayRef`, in overlay pixels, plus the scale from
|
|
9
|
+
* composition units to pixels. Follows the live preview iframe across reloads.
|
|
10
|
+
*/
|
|
11
|
+
export function usePreviewCompositionRect(overlayRef: RefObject<HTMLDivElement | null>) {
|
|
12
|
+
const iframeRef = useMemo<RefObject<HTMLIFrameElement | null>>(
|
|
13
|
+
() => ({
|
|
14
|
+
get current() {
|
|
15
|
+
return usePreviewIframeStore.getState().iframe;
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
[],
|
|
19
|
+
);
|
|
20
|
+
return useDomEditCompositionRect({ iframeRef, overlayRef });
|
|
21
|
+
}
|
|
@@ -442,7 +442,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
442
442
|
<div className="flex flex-col h-full min-h-0">
|
|
443
443
|
<div
|
|
444
444
|
ref={viewportRef}
|
|
445
|
-
className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-hidden focus:ring-1 focus:ring-studio-accent/40 bg-neutral-950"
|
|
445
|
+
className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-hidden focus:ring-1 focus:ring-studio-accent/40 bg-[var(--studio-preview-bg,var(--color-neutral-950))]"
|
|
446
446
|
tabIndex={0}
|
|
447
447
|
aria-label="Composition preview"
|
|
448
448
|
>
|
|
@@ -110,7 +110,7 @@ export function PreviewPane({
|
|
|
110
110
|
ref={containerRef}
|
|
111
111
|
// Panel chrome (rounded border) is dropped in fullscreen so the preview
|
|
112
112
|
// fills the screen edge-to-edge.
|
|
113
|
-
className={`flex-1 min-h-0 flex flex-col overflow-hidden bg-neutral-950 ${
|
|
113
|
+
className={`flex-1 min-h-0 flex flex-col overflow-hidden bg-[var(--studio-preview-bg,var(--color-neutral-950))] ${
|
|
114
114
|
isFullscreen ? "" : "rounded-lg border border-neutral-800/50"
|
|
115
115
|
}`}
|
|
116
116
|
data-studio-fullscreen-target=""
|
|
@@ -6,6 +6,14 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
6
6
|
import { CompositionsTab } from "./sidebar/CompositionsTab";
|
|
7
7
|
import { TimelineToolbar } from "./TimelineToolbar";
|
|
8
8
|
|
|
9
|
+
vi.mock("../contexts/StudioContext", () => ({
|
|
10
|
+
useStudioShellContext: () => ({
|
|
11
|
+
editHistory: { canUndo: false, canRedo: false },
|
|
12
|
+
handleUndo: vi.fn(),
|
|
13
|
+
handleRedo: vi.fn(),
|
|
14
|
+
}),
|
|
15
|
+
}));
|
|
16
|
+
|
|
9
17
|
// WCAG 2.2 (2.5.8) requires a 24x24 CSS pixel pointer target. happy-dom has no
|
|
10
18
|
// CSS engine, so getBoundingClientRect() is 0x0 for everything here and the size
|
|
11
19
|
// itself cannot be measured — these assert the utility classes that PRODUCE the
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// CapCut-flat icon buttons: a transparent 28px hit area with a subtle hover wash.
|
|
2
|
+
export const flatBtn = "flex h-7 w-7 items-center justify-center rounded-md transition-colors";
|
|
3
|
+
export const flatIdle = `${flatBtn} text-neutral-400 hover:bg-white/6 hover:text-neutral-200 active:scale-[0.98]`;
|
|
4
|
+
export const flatActive = `${flatBtn} bg-white/8 text-neutral-100 active:scale-[0.98]`;
|
|
5
|
+
export const flatDisabled = `${flatBtn} text-neutral-700 cursor-not-allowed`;
|