@hyperframes/studio 0.8.136 → 0.8.138
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-ukvUFqdg.js → hyperframes-player-B30apksz.js} +1 -1
- package/dist/assets/{index-C-pD9_xY.js → index-BdjA0CLD.js} +147 -147
- package/dist/assets/{index-syMK0_9C.css → index-CVd4O6eH.css} +1 -1
- package/dist/assets/{index-CRRgIxsS.js → index-DIJuIBwI.js} +1 -1
- package/dist/assets/{index-BY_myArg.js → index-DXVvWfQG.js} +1 -1
- package/dist/{chunk-QRLKWENQ.js → chunk-4S3AYCOG.js} +2 -2
- package/dist/{chunk-NM7BXNZ7.js → chunk-LOVBLSMR.js} +10 -4
- package/dist/chunk-LOVBLSMR.js.map +1 -0
- package/dist/index.html +2 -2
- package/dist/index.js +2852 -2852
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +2 -1
- package/dist/ui.js +1 -1
- package/package.json +9 -8
- package/src/App.tsx +2 -7
- package/src/components/editor/LayersPanel.test.ts +25 -0
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/ui/Tooltip.tsx +10 -3
- package/src/hooks/useConsoleErrorCapture.test.tsx +87 -0
- package/src/hooks/useConsoleErrorCapture.ts +18 -22
- package/src/player/components/ClipPeakMarks.test.tsx +55 -11
- package/src/player/components/ClipPeakMarks.tsx +16 -10
- package/src/player/components/ClipPeakTooltip.tsx +21 -0
- package/src/player/components/Timeline.test.ts +1 -4
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/styles/components.css +5 -1
- package/src/styles/studio.css +9 -0
- package/src/utils/gsapUndoRestore.browser.test.ts +21 -12
- package/dist/chunk-NM7BXNZ7.js.map +0 -1
- /package/dist/{chunk-QRLKWENQ.js.map → chunk-4S3AYCOG.js.map} +0 -0
package/dist/ui.d.ts
CHANGED
|
@@ -93,9 +93,10 @@ interface TooltipProps {
|
|
|
93
93
|
}>;
|
|
94
94
|
/** Hover delay in ms. */
|
|
95
95
|
delay?: number;
|
|
96
|
+
disabled?: boolean;
|
|
96
97
|
side?: "top" | "bottom" | "left" | "right";
|
|
97
98
|
}
|
|
98
|
-
declare function Tooltip({ label, children, delay, side }: TooltipProps): react_jsx_runtime.JSX.Element;
|
|
99
|
+
declare function Tooltip({ label, children, delay, side, disabled, }: TooltipProps): react_jsx_runtime.JSX.Element;
|
|
99
100
|
|
|
100
101
|
/**
|
|
101
102
|
* Studio's tailwind-merge config: teaches it the two scales `theme.css`
|
package/dist/ui.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.138",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/parsers": "0.8.138",
|
|
64
|
+
"@hyperframes/core": "0.8.138",
|
|
65
|
+
"@hyperframes/player": "0.8.138",
|
|
66
|
+
"@hyperframes/sdk": "0.8.138",
|
|
67
|
+
"@hyperframes/shader-transitions": "0.8.138",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.138"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"@types/react-dom": "19",
|
|
74
74
|
"@vitejs/plugin-react": "^4.0.0",
|
|
75
75
|
"chokidar": "^4.0.3",
|
|
76
|
+
"esbuild": "^0.25.12",
|
|
76
77
|
"fake-indexeddb": "^6.2.5",
|
|
77
78
|
"puppeteer-core": "^25.2.1",
|
|
78
79
|
"tailwindcss": "^4.3.3",
|
|
@@ -81,7 +82,7 @@
|
|
|
81
82
|
"vite": "^6.4.2",
|
|
82
83
|
"vitest": "^4.1.11",
|
|
83
84
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
85
|
+
"@hyperframes/producer": "0.8.138"
|
|
85
86
|
},
|
|
86
87
|
"peerDependencies": {
|
|
87
88
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -357,11 +357,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
357
357
|
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
|
|
358
358
|
});
|
|
359
359
|
const frameCapture = useStableHandlers(frameCaptureResult, projectId);
|
|
360
|
-
const {
|
|
361
|
-
consoleErrors,
|
|
362
|
-
setConsoleErrors,
|
|
363
|
-
resetErrors: resetConsoleErrors,
|
|
364
|
-
} = useConsoleErrorCapture(previewIframe);
|
|
360
|
+
const { consoleErrors, setConsoleErrors } = useConsoleErrorCapture(previewIframe);
|
|
365
361
|
const fileDrop = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop);
|
|
366
362
|
const handleToggleRecordingRef = useRef<(method?: "button" | "keyboard") => void>(() => {});
|
|
367
363
|
const domEditSessionRef = useRef(domEditSession);
|
|
@@ -385,10 +381,9 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
385
381
|
previewIframeRef.current = iframe;
|
|
386
382
|
usePreviewIframeStore.getState().setIframe(iframe);
|
|
387
383
|
appHotkeys.syncPreviewHotkeys(iframe);
|
|
388
|
-
resetConsoleErrors();
|
|
389
384
|
refreshPreviewDocumentVersion();
|
|
390
385
|
},
|
|
391
|
-
[appHotkeys,
|
|
386
|
+
[appHotkeys, refreshPreviewDocumentVersion],
|
|
392
387
|
);
|
|
393
388
|
const rightPanel = useRightPanelIntent();
|
|
394
389
|
const { inspectorPanelActive, shouldShowMotionPath, shouldShowSelectedDomBounds } =
|
|
@@ -306,4 +306,29 @@ describe("LayersPanel preview promotion", () => {
|
|
|
306
306
|
expect(collectedRootIds()).toEqual(["a"]);
|
|
307
307
|
fetchSpy.mockRestore();
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
it("a row press starts no text selection, so dragging a row leaves no letters highlighted", () => {
|
|
311
|
+
const doc = new Window().document;
|
|
312
|
+
doc.body.innerHTML = `<div data-composition-id="a"><div id="back"></div><div id="front"></div></div>`;
|
|
313
|
+
const iframe = document.createElement("iframe");
|
|
314
|
+
Object.defineProperty(iframe, "contentDocument", { value: doc });
|
|
315
|
+
const item = (id: string) =>
|
|
316
|
+
({
|
|
317
|
+
key: id,
|
|
318
|
+
element: doc.getElementById(id),
|
|
319
|
+
label: id,
|
|
320
|
+
depth: 0,
|
|
321
|
+
childCount: 0,
|
|
322
|
+
id,
|
|
323
|
+
}) as unknown as DomEditLayerItem;
|
|
324
|
+
mocks.collect.mockReset().mockReturnValue([item("back"), item("front")]);
|
|
325
|
+
mocks.previewIframeRef.current = iframe;
|
|
326
|
+
usePreviewIframeStore.setState({ iframe });
|
|
327
|
+
act(() => root.render(createElement(LayersPanel)));
|
|
328
|
+
act(() => void iframe.dispatchEvent(new Event("load")));
|
|
329
|
+
|
|
330
|
+
const rows = [...host.querySelectorAll("[data-layer-index]")];
|
|
331
|
+
expect(rows).toHaveLength(2);
|
|
332
|
+
expect(rows.every((row) => row.closest(".select-none"))).toBe(true);
|
|
333
|
+
});
|
|
309
334
|
});
|
|
@@ -433,7 +433,7 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
|
|
|
433
433
|
</div>
|
|
434
434
|
<div
|
|
435
435
|
ref={scrollContainerRef}
|
|
436
|
-
className="relative min-h-0 flex-1 overflow-y-auto py-1"
|
|
436
|
+
className="relative min-h-0 flex-1 select-none overflow-y-auto py-1"
|
|
437
437
|
onPointerMove={handleContainerPointerMove}
|
|
438
438
|
onPointerUp={handleContainerPointerUp}
|
|
439
439
|
onPointerCancel={handleContainerPointerUp}
|
|
@@ -14,6 +14,7 @@ interface TooltipProps {
|
|
|
14
14
|
children: ReactElement<{ "aria-describedby"?: string }>;
|
|
15
15
|
/** Hover delay in ms. */
|
|
16
16
|
delay?: number;
|
|
17
|
+
disabled?: boolean;
|
|
17
18
|
side?: "top" | "bottom" | "left" | "right";
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -21,16 +22,22 @@ interface TooltipProps {
|
|
|
21
22
|
const SIDE_OFFSET = 6;
|
|
22
23
|
const VIEWPORT_MARGIN = 8;
|
|
23
24
|
|
|
24
|
-
export function Tooltip({
|
|
25
|
+
export function Tooltip({
|
|
26
|
+
label,
|
|
27
|
+
children,
|
|
28
|
+
delay = 400,
|
|
29
|
+
side = "top",
|
|
30
|
+
disabled = false,
|
|
31
|
+
}: TooltipProps) {
|
|
25
32
|
const [open, setOpen] = useState(false);
|
|
26
33
|
const boxRef = useRef<HTMLSpanElement>(null);
|
|
27
34
|
const tooltipId = useId();
|
|
28
35
|
|
|
29
36
|
return (
|
|
30
|
-
<BaseTooltip.Root open={open} onOpenChange={setOpen}>
|
|
37
|
+
<BaseTooltip.Root open={open} onOpenChange={setOpen} disabled={disabled}>
|
|
31
38
|
<BaseTooltip.Trigger delay={delay} render={<span ref={boxRef} className="contents" />}>
|
|
32
39
|
{cloneElement(children, {
|
|
33
|
-
"aria-describedby": open ? tooltipId : children.props["aria-describedby"],
|
|
40
|
+
"aria-describedby": open && !disabled ? tooltipId : children.props["aria-describedby"],
|
|
34
41
|
})}
|
|
35
42
|
</BaseTooltip.Trigger>
|
|
36
43
|
<BaseTooltip.Portal>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, expect, it } from "vitest";
|
|
6
|
+
import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
|
|
7
|
+
import { useConsoleErrorCapture } from "./useConsoleErrorCapture";
|
|
8
|
+
|
|
9
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
10
|
+
|
|
11
|
+
let capture: ReturnType<typeof useConsoleErrorCapture>;
|
|
12
|
+
function Harness({ iframe }: { iframe: HTMLIFrameElement }) {
|
|
13
|
+
capture = useConsoleErrorCapture(iframe);
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function previewFrame() {
|
|
18
|
+
const previewWindow = Object.assign(new EventTarget(), { console: { error: () => undefined } });
|
|
19
|
+
const iframe = document.createElement("iframe");
|
|
20
|
+
Object.defineProperty(iframe, "contentWindow", { get: () => previewWindow });
|
|
21
|
+
return { iframe, previewWindow };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const shown = () => capture.consoleErrors?.map((finding) => finding.message);
|
|
25
|
+
|
|
26
|
+
let root: Root | null = null;
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await act(async () => root?.unmount());
|
|
29
|
+
root = null;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("shows the errors a preview raised before its load, once each", async () => {
|
|
33
|
+
const raisedBeforeLoad = "Uncaught Error: GSAP could not load from a or b";
|
|
34
|
+
const { iframe, previewWindow } = previewFrame();
|
|
35
|
+
|
|
36
|
+
root = createRoot(document.createElement("div"));
|
|
37
|
+
await act(async () => root?.render(React.createElement(Harness, { iframe })));
|
|
38
|
+
Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: [raisedBeforeLoad] });
|
|
39
|
+
await act(async () => iframe.dispatchEvent(new Event("load")));
|
|
40
|
+
|
|
41
|
+
expect(shown()).toEqual([raisedBeforeLoad]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("shows a loaded preview's errors once when the capture attaches to it twice", async () => {
|
|
45
|
+
const raised = "Uncaught Error: GSAP could not load from a or b";
|
|
46
|
+
const { iframe, previewWindow } = previewFrame();
|
|
47
|
+
Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: [raised] });
|
|
48
|
+
|
|
49
|
+
root = createRoot(document.createElement("div"));
|
|
50
|
+
const harness = React.createElement(Harness, { iframe });
|
|
51
|
+
await act(async () => root?.render(React.createElement(React.StrictMode, null, harness)));
|
|
52
|
+
|
|
53
|
+
expect(shown()).toEqual([raised]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps listening after reading a preview's very long error list", async () => {
|
|
57
|
+
const { iframe, previewWindow } = previewFrame();
|
|
58
|
+
const raised = Array.from({ length: 200_000 }, (_, i) => `Uncaught Error: ${i}`);
|
|
59
|
+
Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: raised });
|
|
60
|
+
|
|
61
|
+
root = createRoot(document.createElement("div"));
|
|
62
|
+
await act(async () => root?.render(React.createElement(Harness, { iframe })));
|
|
63
|
+
const live = new ErrorEvent("error", { message: "Uncaught Error: raised after attach" });
|
|
64
|
+
await act(async () => previewWindow.dispatchEvent(live));
|
|
65
|
+
|
|
66
|
+
expect(shown()?.length).toBe(raised.length + 1);
|
|
67
|
+
expect(shown()?.at(-1)).toBe("Uncaught Error: raised after attach");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("clears a document's errors when its load replaces it, and when the preview goes away", async () => {
|
|
71
|
+
const { iframe, previewWindow } = previewFrame();
|
|
72
|
+
const NullableHarness = ({ frame }: { frame: HTMLIFrameElement | null }) => {
|
|
73
|
+
capture = useConsoleErrorCapture(frame);
|
|
74
|
+
return null;
|
|
75
|
+
};
|
|
76
|
+
root = createRoot(document.createElement("div"));
|
|
77
|
+
await act(async () => root?.render(React.createElement(NullableHarness, { frame: iframe })));
|
|
78
|
+
await act(async () =>
|
|
79
|
+
previewWindow.dispatchEvent(new ErrorEvent("error", { message: "Uncaught Error: old" })),
|
|
80
|
+
);
|
|
81
|
+
Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: ["Uncaught Error: new"] });
|
|
82
|
+
await act(async () => iframe.dispatchEvent(new Event("load")));
|
|
83
|
+
expect(shown()).toEqual(["Uncaught Error: new"]);
|
|
84
|
+
|
|
85
|
+
await act(async () => root?.render(React.createElement(NullableHarness, { frame: null })));
|
|
86
|
+
expect(capture.consoleErrors).toBeNull();
|
|
87
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
|
|
2
3
|
import type { LintFinding } from "../components/LintModal";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -16,6 +17,7 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)
|
|
|
16
17
|
|
|
17
18
|
// eslint-disable-next-line no-restricted-syntax
|
|
18
19
|
useEffect(() => {
|
|
20
|
+
resetErrors();
|
|
19
21
|
if (!previewIframe) return;
|
|
20
22
|
|
|
21
23
|
let patchedWin: (Window & typeof globalThis) | null = null;
|
|
@@ -40,49 +42,43 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)
|
|
|
40
42
|
|
|
41
43
|
const attachErrorCapture = () => {
|
|
42
44
|
detachErrorCapture();
|
|
45
|
+
resetErrors();
|
|
43
46
|
try {
|
|
44
47
|
const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
|
|
45
48
|
if (!win) return;
|
|
46
49
|
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
|
|
47
50
|
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
|
|
48
51
|
patchedWin = win;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
origConsoleError!(...args);
|
|
52
|
-
const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
|
|
53
|
-
if (text.includes("favicon")) return;
|
|
52
|
+
const record = (texts: readonly string[]) => {
|
|
53
|
+
if (texts.length === 0) return;
|
|
54
54
|
consoleErrorsRef.current = [
|
|
55
55
|
...consoleErrorsRef.current,
|
|
56
|
-
{ severity: "error", message
|
|
56
|
+
...texts.map((message) => ({ severity: "error" as const, message })),
|
|
57
57
|
];
|
|
58
58
|
setConsoleErrors([...consoleErrorsRef.current]);
|
|
59
59
|
};
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
];
|
|
66
|
-
setConsoleErrors([...consoleErrorsRef.current]);
|
|
60
|
+
origConsoleError = win.console.error.bind(win.console);
|
|
61
|
+
win.console.error = function (...args: unknown[]) {
|
|
62
|
+
origConsoleError!(...args);
|
|
63
|
+
const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
|
|
64
|
+
if (!text.includes("favicon")) record([text]);
|
|
67
65
|
};
|
|
66
|
+
errorHandler = (e: ErrorEvent) => record([e.message || String(e)]);
|
|
68
67
|
win.addEventListener("error", errorHandler);
|
|
68
|
+
const raised: unknown = Reflect.get(win, STUDIO_PREVIEW_ERRORS);
|
|
69
|
+
if (Array.isArray(raised)) record(raised.map(String));
|
|
69
70
|
} catch {
|
|
70
71
|
/* same-origin only */
|
|
71
72
|
}
|
|
72
73
|
};
|
|
73
74
|
|
|
74
75
|
attachErrorCapture();
|
|
75
|
-
|
|
76
|
-
consoleErrorsRef.current = [];
|
|
77
|
-
setConsoleErrors(null);
|
|
78
|
-
attachErrorCapture();
|
|
79
|
-
};
|
|
80
|
-
previewIframe.addEventListener("load", handleLoad);
|
|
76
|
+
previewIframe.addEventListener("load", attachErrorCapture);
|
|
81
77
|
return () => {
|
|
82
|
-
previewIframe.removeEventListener("load",
|
|
78
|
+
previewIframe.removeEventListener("load", attachErrorCapture);
|
|
83
79
|
detachErrorCapture();
|
|
84
80
|
};
|
|
85
|
-
}, [previewIframe]);
|
|
81
|
+
}, [previewIframe, resetErrors]);
|
|
86
82
|
|
|
87
|
-
return { consoleErrors, setConsoleErrors
|
|
83
|
+
return { consoleErrors, setConsoleErrors };
|
|
88
84
|
}
|
|
@@ -3,6 +3,9 @@ import { act } from "react";
|
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
5
5
|
import { ClipPeakMarks } from "./ClipPeakMarks";
|
|
6
|
+
import { TimelineClip } from "./TimelineClip";
|
|
7
|
+
|
|
8
|
+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
|
6
9
|
|
|
7
10
|
const harness = createHappyDomRootHarness();
|
|
8
11
|
|
|
@@ -13,29 +16,70 @@ async function render(url: string, bins: number[], gain: number) {
|
|
|
13
16
|
);
|
|
14
17
|
const host = document.createElement("div");
|
|
15
18
|
document.body.appendChild(host);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
const root = harness.mount(host);
|
|
20
|
+
const view = (volume: number) => (
|
|
21
|
+
<TimelineClip
|
|
22
|
+
el={{ id: "audio", tag: "audio", start: 0, duration: 2, track: 0 }}
|
|
23
|
+
pps={16}
|
|
24
|
+
clipY={0}
|
|
25
|
+
isSelected={false}
|
|
26
|
+
isHovered={false}
|
|
27
|
+
hasCustomContent
|
|
28
|
+
capabilities={{ canMove: true, canTrimStart: true, canTrimEnd: true }}
|
|
29
|
+
isComposition={false}
|
|
30
|
+
tabIndex={0}
|
|
31
|
+
onHoverStart={() => {}}
|
|
32
|
+
onHoverEnd={() => {}}
|
|
33
|
+
onClick={() => {}}
|
|
34
|
+
onDoubleClick={() => {}}
|
|
35
|
+
>
|
|
36
|
+
<ClipPeakMarks peaksUrl={url} sourceWindow={{ mediaStart: 0, sourceSpan: 2 }} gain={volume}>
|
|
19
37
|
<span>wave</span>
|
|
20
|
-
</ClipPeakMarks
|
|
21
|
-
|
|
22
|
-
|
|
38
|
+
</ClipPeakMarks>
|
|
39
|
+
</TimelineClip>
|
|
40
|
+
);
|
|
41
|
+
await act(async () => root.render(view(gain)));
|
|
23
42
|
await act(async () => {});
|
|
24
43
|
vi.unstubAllGlobals();
|
|
25
|
-
return host;
|
|
44
|
+
return { host, setGain: async (value: number) => act(async () => root.render(view(value))) };
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
describe("ClipPeakMarks", () => {
|
|
29
|
-
it("
|
|
30
|
-
const host = await render("/api/projects/p/peaks/loud.mp4", [0.2, 0.98], 1);
|
|
48
|
+
it("uses a plain warning and describes the focused clip with the numeric peak", async () => {
|
|
49
|
+
const { host } = await render("/api/projects/p/peaks/loud.mp4", [0.2, 0.98], 1);
|
|
31
50
|
expect(host.textContent).toContain("wave");
|
|
32
51
|
expect(host.querySelector("[data-testid=clip-peak-marks]")?.textContent).toContain(
|
|
33
|
-
"▲
|
|
52
|
+
"▲ Too loud",
|
|
34
53
|
);
|
|
54
|
+
expect(host.querySelector("[data-peak-text]")?.textContent).not.toContain("dBFS");
|
|
55
|
+
const clip = host.querySelector("button")!;
|
|
56
|
+
await act(async () => clip.focus());
|
|
57
|
+
await vi.waitFor(() =>
|
|
58
|
+
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(
|
|
59
|
+
"Peaks −0.2 dBFS at this volume; export lowers the whole mix",
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
expect(clip.getAttribute("aria-describedby")).toBe(
|
|
63
|
+
document.querySelector('[role="tooltip"]')?.id,
|
|
64
|
+
);
|
|
65
|
+
expect(host.querySelectorAll("button")).toHaveLength(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("removes the focused warning when volume becomes quiet without replacing the clip", async () => {
|
|
69
|
+
const { host, setGain } = await render("/api/projects/p/peaks/changing.mp4", [1, 1], 1);
|
|
70
|
+
const clip = host.querySelector("button")!;
|
|
71
|
+
await act(async () => clip.focus());
|
|
72
|
+
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull());
|
|
73
|
+
await setGain(0.25);
|
|
74
|
+
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
|
|
75
|
+
expect(clip.getAttribute("aria-describedby")).toBeNull();
|
|
76
|
+
expect(document.activeElement).toBe(clip);
|
|
77
|
+
expect(host.querySelector("button")).toBe(clip);
|
|
78
|
+
expect(host.querySelector("[data-peak-badge]")).toBeNull();
|
|
35
79
|
});
|
|
36
80
|
|
|
37
81
|
it("paints nothing on a quiet clip", async () => {
|
|
38
|
-
const host = await render("/api/projects/p/peaks/quiet.mp4", [0.25, 0.25], 1);
|
|
82
|
+
const { host } = await render("/api/projects/p/peaks/quiet.mp4", [0.25, 0.25], 1);
|
|
39
83
|
expect(host.querySelector("[data-testid=clip-peak-marks]")).toBeNull();
|
|
40
84
|
});
|
|
41
85
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useEffect, useState, type ReactNode } from "react";
|
|
1
|
+
import { useContext, useEffect, useState, type ReactNode } from "react";
|
|
2
2
|
import { clipPeakRuns, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
|
|
3
3
|
import { loadPeakMap } from "./clipPeakMap";
|
|
4
|
+
import { ClipPeakDescriptionContext } from "./ClipPeakTooltip";
|
|
4
5
|
|
|
5
6
|
const dbText = (db: number) => `${db < 0 ? "−" : "+"}${Math.abs(db).toFixed(1)} dBFS`;
|
|
6
7
|
|
|
@@ -30,15 +31,20 @@ export function ClipPeakMarks({
|
|
|
30
31
|
const { runs, peakDbfs } = map
|
|
31
32
|
? clipPeakRuns(map, sourceWindow, gain)
|
|
32
33
|
: { runs: [], peakDbfs: null };
|
|
34
|
+
const detail =
|
|
35
|
+
runs.length > 0 && peakDbfs !== null
|
|
36
|
+
? `Peaks ${dbText(peakDbfs)} at this volume; export lowers the whole mix`
|
|
37
|
+
: null;
|
|
38
|
+
const setDetail = useContext(ClipPeakDescriptionContext);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
setDetail?.(detail);
|
|
41
|
+
return () => setDetail?.(null);
|
|
42
|
+
}, [detail, setDetail]);
|
|
33
43
|
return (
|
|
34
|
-
<div className="relative h-full w-full">
|
|
44
|
+
<div className="relative isolate h-full w-full">
|
|
35
45
|
{children}
|
|
36
|
-
{
|
|
37
|
-
<div
|
|
38
|
-
className="pointer-events-none absolute inset-0"
|
|
39
|
-
data-testid="clip-peak-marks"
|
|
40
|
-
title={`Peaks ${dbText(peakDbfs)} at this volume; export lowers the whole mix`}
|
|
41
|
-
>
|
|
46
|
+
{detail !== null && (
|
|
47
|
+
<div className="pointer-events-none absolute inset-0" data-testid="clip-peak-marks">
|
|
42
48
|
{runs.map((run) => (
|
|
43
49
|
<div
|
|
44
50
|
key={run.from}
|
|
@@ -50,10 +56,10 @@ export function ClipPeakMarks({
|
|
|
50
56
|
/>
|
|
51
57
|
))}
|
|
52
58
|
<span
|
|
53
|
-
className="absolute
|
|
59
|
+
className="pointer-events-auto absolute right-4 bottom-1 font-mono text-[9px] leading-none text-red-400"
|
|
54
60
|
data-peak-badge
|
|
55
61
|
>
|
|
56
|
-
▲<span data-peak-text>
|
|
62
|
+
▲<span data-peak-text> Too loud</span>
|
|
57
63
|
</span>
|
|
58
64
|
</div>
|
|
59
65
|
)}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { cloneElement, createContext, useState, type ReactElement } from "react";
|
|
2
|
+
import { Tooltip } from "../../components/ui/Tooltip";
|
|
3
|
+
|
|
4
|
+
export const ClipPeakDescriptionContext = createContext<((detail: string | null) => void) | null>(
|
|
5
|
+
null,
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
export function ClipPeakTooltip({
|
|
9
|
+
children,
|
|
10
|
+
}: {
|
|
11
|
+
children: ReactElement<{ title?: string; "aria-describedby"?: string }>;
|
|
12
|
+
}) {
|
|
13
|
+
const [detail, setDetail] = useState<string | null>(null);
|
|
14
|
+
return (
|
|
15
|
+
<ClipPeakDescriptionContext.Provider value={setDetail}>
|
|
16
|
+
<Tooltip label={detail ?? ""} disabled={detail === null}>
|
|
17
|
+
{cloneElement(children, { title: detail === null ? children.props.title : undefined })}
|
|
18
|
+
</Tooltip>
|
|
19
|
+
</ClipPeakDescriptionContext.Provider>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -87,11 +87,8 @@ describe("timeline viewport geometry", () => {
|
|
|
87
87
|
});
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
/** The track's content row: a clip's parent, past its `display: contents` wrapper. */
|
|
91
90
|
function trackContentOf(clip: HTMLElement | null | undefined): HTMLElement | null {
|
|
92
|
-
|
|
93
|
-
while (row?.style.display === "contents") row = row.parentElement;
|
|
94
|
-
return row;
|
|
91
|
+
return clip?.closest<HTMLElement>('[role="gridcell"]') ?? null;
|
|
95
92
|
}
|
|
96
93
|
|
|
97
94
|
function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
|
|
@@ -16,6 +16,7 @@ import { ClipBadges } from "./ClipBadges";
|
|
|
16
16
|
import { linkLabelColor } from "./linkLabelColor";
|
|
17
17
|
import { OutOfSyncBadge } from "./OutOfSyncBadge";
|
|
18
18
|
import { clipSpeedSuffix } from "./clipToolAttrs";
|
|
19
|
+
import { ClipPeakTooltip } from "./ClipPeakTooltip";
|
|
19
20
|
|
|
20
21
|
interface TimelineClipProps {
|
|
21
22
|
el: TimelineElement;
|
|
@@ -128,7 +129,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
128
129
|
...passengerStyle,
|
|
129
130
|
};
|
|
130
131
|
|
|
131
|
-
|
|
132
|
+
const clip = (
|
|
132
133
|
<button
|
|
133
134
|
type="button"
|
|
134
135
|
data-clip={isGestureActor ? undefined : "true"}
|
|
@@ -256,4 +257,5 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
256
257
|
)}
|
|
257
258
|
</button>
|
|
258
259
|
);
|
|
260
|
+
return isAudioClip || el.hasAudio ? <ClipPeakTooltip>{clip}</ClipPeakTooltip> : clip;
|
|
259
261
|
});
|
|
@@ -314,14 +314,18 @@
|
|
|
314
314
|
display: none;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
[data-peak-badge] {
|
|
318
|
+
z-index: 20;
|
|
319
|
+
}
|
|
320
|
+
|
|
317
321
|
.audible-video-wave [data-peak-badge] {
|
|
318
322
|
top: 50%;
|
|
323
|
+
bottom: auto;
|
|
319
324
|
right: var(--timeline-clip-radius);
|
|
320
325
|
translate: 0 -50%;
|
|
321
326
|
padding: 1px 3px;
|
|
322
327
|
border-radius: 3px;
|
|
323
328
|
background: var(--timeline-clip-chip-audio-bg);
|
|
324
|
-
z-index: 20;
|
|
325
329
|
}
|
|
326
330
|
|
|
327
331
|
.timeline-clip.is-audio.is-hovered {
|
package/src/styles/studio.css
CHANGED
|
@@ -45,4 +45,13 @@ body {
|
|
|
45
45
|
*/
|
|
46
46
|
height: 100vh;
|
|
47
47
|
height: 100dvh;
|
|
48
|
+
/*
|
|
49
|
+
* Nothing may overflow #root. Dockview parks hidden panel overlays below
|
|
50
|
+
* the dock, which made the document ~2x the viewport tall; body's
|
|
51
|
+
* overflow:hidden only blocks user scrolling, so scrollIntoView, focus()
|
|
52
|
+
* and find-in-page still scrolled the viewport and cut off the header
|
|
53
|
+
* (repro: select a clip, open the Grade group). `clip`, not `hidden`,
|
|
54
|
+
* which is itself programmatically scrollable.
|
|
55
|
+
*/
|
|
56
|
+
overflow: clip;
|
|
48
57
|
}
|
|
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import puppeteer, { type Browser } from "puppeteer-core";
|
|
8
|
-
import { build } from "
|
|
8
|
+
import { build, type Plugin } from "esbuild";
|
|
9
9
|
import { afterAll, beforeAll, expect, it, vi } from "vitest";
|
|
10
10
|
import { findSystemChrome } from "../../vite.browser";
|
|
11
11
|
import { writeFixture } from "../../tests/e2e/edit-accuracy/grid.mjs";
|
|
@@ -18,20 +18,29 @@ let browser: Browser;
|
|
|
18
18
|
let undoBundle: string;
|
|
19
19
|
let softReloadBundle: string;
|
|
20
20
|
|
|
21
|
+
// recast imports these, and its code that uses them is dropped from the bundle. A new
|
|
22
|
+
// built-in fails the build instead of turning into an empty module the way Vite makes it.
|
|
23
|
+
const emptyNodeBuiltins: Plugin = {
|
|
24
|
+
name: "empty-node-builtins",
|
|
25
|
+
setup(build) {
|
|
26
|
+
build.onResolve({ filter: /^(node:)?(fs|os)$/ }, ({ path }) => ({ path, namespace: "empty" }));
|
|
27
|
+
build.onLoad({ filter: /.*/, namespace: "empty" }, () => ({ contents: "module.exports = {}" }));
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
21
31
|
async function bundle(file: string, name: string): Promise<string> {
|
|
22
32
|
const out = await build({
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
},
|
|
33
|
+
entryPoints: [fileURLToPath(new URL(file, import.meta.url))],
|
|
34
|
+
bundle: true,
|
|
35
|
+
write: false,
|
|
36
|
+
platform: "browser",
|
|
37
|
+
format: "iife",
|
|
38
|
+
globalName: name,
|
|
39
|
+
alias: { canvas: fileURLToPath(new URL("../shims/canvasBrowserStub.js", import.meta.url)) },
|
|
40
|
+
plugins: [emptyNodeBuiltins],
|
|
41
|
+
define: { "import.meta.env": "{}" },
|
|
33
42
|
});
|
|
34
|
-
return
|
|
43
|
+
return out.outputFiles[0]!.text;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
beforeAll(async () => {
|