@hyperframes/studio 0.8.58 → 0.8.60
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-zGyOcwwh.js → hyperframes-player-Di7GyCOP.js} +3 -3
- package/dist/assets/{index-DKJpHYYd.css → index-CBKb_0TI.css} +1 -1
- package/dist/assets/{index-d8ferjRc.js → index-Dpq2lkOm.js} +190 -190
- package/dist/assets/{index-ZJaRCDYV.js → index-Dx_pt9m4.js} +1 -1
- package/dist/assets/{index-BcE2OSVA.js → index-jAjhQTrB.js} +1 -1
- package/dist/index.d.ts +549 -13
- package/dist/index.html +2 -2
- package/dist/index.js +23133 -21941
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/StudioHeader.tsx +2 -13
- package/src/components/TimelineToolbar.test.tsx +42 -0
- package/src/components/TimelineToolbar.tsx +22 -8
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- package/src/components/editor/propertyPanelFill.tsx +3 -2
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
- package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
- package/src/components/editor/useColorGradingController.ts +2 -6
- package/src/components/icons/AudioMetersIcon.tsx +16 -0
- package/src/components/icons/InspectorIcon.tsx +18 -0
- package/src/components/icons/ReverseGradientIcon.tsx +23 -0
- package/src/components/icons/RippleEditIcon.tsx +19 -0
- package/src/components/nle/AudioMeterStrip.test.tsx +395 -0
- package/src/components/nle/AudioMeterStrip.tsx +430 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- package/src/components/ui/Menu.test.tsx +3 -4
- package/src/components/ui/Menu.tsx +18 -5
- package/src/components/ui/Select.tsx +7 -1
- package/src/components/ui/Tooltip.tsx +6 -1
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +16 -0
- package/src/player/components/AudioWaveform.tsx +16 -7
- package/src/player/components/ImageThumbnail.tsx +3 -5
- package/src/player/components/LayerDisclosureRow.tsx +9 -4
- package/src/player/components/PlayheadIndicator.tsx +3 -3
- package/src/player/components/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLane.tsx +5 -5
- package/src/player/components/TimelineCanvas.tsx +28 -71
- package/src/player/components/TimelineClip.test.tsx +41 -4
- package/src/player/components/TimelineClip.tsx +25 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.tsx +2 -2
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
- package/src/player/components/TimelineEmptyState.tsx +1 -1
- package/src/player/components/TimelineFxButton.tsx +10 -8
- package/src/player/components/TimelineGroupHeader.tsx +18 -9
- package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +14 -0
- package/src/player/components/TimelineOverlays.tsx +164 -165
- package/src/player/components/TimelineParts.test.tsx +101 -0
- package/src/player/components/TimelineParts.tsx +74 -0
- package/src/player/components/TimelinePartsParity.test.tsx +58 -0
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +2 -2
- package/src/player/components/TimelineRuler.tsx +3 -2
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- package/src/player/components/TimelineShortcutHint.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +4 -6
- package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
- package/src/player/components/VideoThumbnail.tsx +3 -5
- package/src/player/components/timelineMotionStyles.test.ts +96 -30
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/timelineTheme.test.ts +14 -0
- package/src/player/components/timelineTheme.ts +12 -11
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- package/src/player/lib/timelineElementHelpers.ts +20 -0
- package/src/player/store/timelineElement.ts +5 -0
- package/src/styles/hex-baseline.json +4 -23
- package/src/styles/studio.css +43 -47
- package/src/styles/theme.css +109 -1
- package/src/styles/theme.test.ts +9 -0
- package/src/utils/audioMeterMath.test.ts +44 -0
- package/src/utils/audioMeterMath.ts +79 -0
- package/src/utils/audioMeterVisibility.ts +14 -0
- package/src/utils/studioHelpers.ts +21 -0
- package/src/utils/studioUiPreferences.test.ts +10 -0
- package/src/utils/studioUiPreferences.ts +5 -0
- package/src/utils/timelineAssetDrop.test.ts +32 -0
- package/src/utils/timelineAssetDrop.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.60",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"dockview-react": "8.3.1",
|
|
52
52
|
"gsap": "^3.13.0",
|
|
53
53
|
"mediabunny": "^1.45.3",
|
|
54
|
-
"@hyperframes/core": "0.8.
|
|
55
|
-
"@hyperframes/
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/
|
|
54
|
+
"@hyperframes/core": "0.8.60",
|
|
55
|
+
"@hyperframes/player": "0.8.60",
|
|
56
|
+
"@hyperframes/sdk": "0.8.60",
|
|
57
|
+
"@hyperframes/parsers": "0.8.60",
|
|
58
|
+
"@hyperframes/studio-server": "0.8.60"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"vite": "^6.4.2",
|
|
72
72
|
"vitest": "^4.1.11",
|
|
73
73
|
"zustand": "^5.0.0",
|
|
74
|
-
"@hyperframes/producer": "0.8.
|
|
74
|
+
"@hyperframes/producer": "0.8.60"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"react": "19",
|
|
@@ -5,6 +5,7 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
|
|
5
5
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
6
6
|
import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui";
|
|
7
7
|
import { Dock } from "./dock/Dock";
|
|
8
|
+
import { InspectorIcon } from "./icons/InspectorIcon";
|
|
8
9
|
|
|
9
10
|
export interface StudioHeaderProps {
|
|
10
11
|
captureFrameHref: string;
|
|
@@ -247,19 +248,7 @@ export function StudioHeader({
|
|
|
247
248
|
"h-full rounded-none",
|
|
248
249
|
inspectorButtonActive && "bg-hover text-accent enabled:hover:text-accent",
|
|
249
250
|
)}
|
|
250
|
-
icon={
|
|
251
|
-
<svg
|
|
252
|
-
width="12"
|
|
253
|
-
height="12"
|
|
254
|
-
viewBox="0 0 24 24"
|
|
255
|
-
fill="none"
|
|
256
|
-
stroke="currentColor"
|
|
257
|
-
strokeWidth="2"
|
|
258
|
-
>
|
|
259
|
-
<circle cx="12" cy="12" r="10" />
|
|
260
|
-
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
|
|
261
|
-
</svg>
|
|
262
|
-
}
|
|
251
|
+
icon={<InspectorIcon size={16} />}
|
|
263
252
|
onClick={() => {
|
|
264
253
|
if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) {
|
|
265
254
|
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
|
|
@@ -6,10 +6,14 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
6
6
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
7
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
8
|
import { makeSelection } from "../hooks/domSelectionTestHarness";
|
|
9
|
+
import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
|
|
10
|
+
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
11
|
+
import { AudioMeterStrip } from "./nle/AudioMeterStrip";
|
|
9
12
|
import { TimelineToolbar } from "./TimelineToolbar";
|
|
10
13
|
|
|
11
14
|
vi.mock("../contexts/StudioContext", () => ({
|
|
12
15
|
useStudioShellContext: () => ({
|
|
16
|
+
previewIframeRef: { current: null },
|
|
13
17
|
editHistory: { canUndo: false, canRedo: false },
|
|
14
18
|
handleUndo: vi.fn(),
|
|
15
19
|
handleRedo: vi.fn(),
|
|
@@ -172,3 +176,41 @@ describe("TimelineToolbar — keyframes on audio tracks", () => {
|
|
|
172
176
|
act(() => root.unmount());
|
|
173
177
|
});
|
|
174
178
|
});
|
|
179
|
+
|
|
180
|
+
describe("TimelineToolbar audio meters", () => {
|
|
181
|
+
it("keeps fresh preferences hidden until the user opts in and persists the choice", () => {
|
|
182
|
+
localStorage.clear();
|
|
183
|
+
useAudioMetersVisible.setState(useAudioMetersVisible.getInitialState());
|
|
184
|
+
usePlayerStore.setState({
|
|
185
|
+
elements: [{ id: "music", key: "music", tag: "audio", start: 0, duration: 10, track: 1 }],
|
|
186
|
+
});
|
|
187
|
+
const host = document.createElement("div");
|
|
188
|
+
document.body.append(host);
|
|
189
|
+
const root = createRoot(host);
|
|
190
|
+
try {
|
|
191
|
+
act(() =>
|
|
192
|
+
root.render(
|
|
193
|
+
<>
|
|
194
|
+
<TimelineToolbar />
|
|
195
|
+
<AudioMeterStrip />
|
|
196
|
+
</>,
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
const button = host.querySelector<HTMLButtonElement>(
|
|
200
|
+
'button[aria-label="Toggle audio meters"]',
|
|
201
|
+
);
|
|
202
|
+
expect(readStudioUiPreferences().audioMetersVisible).toBeUndefined();
|
|
203
|
+
expect(button?.getAttribute("aria-pressed")).toBe("false");
|
|
204
|
+
expect(host.querySelector('[data-testid="audio-meter-strip"]')).toBeNull();
|
|
205
|
+
if (!button) throw new Error("audio meter toggle not rendered");
|
|
206
|
+
act(() => button.click());
|
|
207
|
+
expect(button.getAttribute("aria-pressed")).toBe("true");
|
|
208
|
+
expect(host.querySelector('[data-testid="audio-meter-strip"]')).not.toBeNull();
|
|
209
|
+
expect(readStudioUiPreferences().audioMetersVisible).toBe(true);
|
|
210
|
+
} finally {
|
|
211
|
+
act(() => root.unmount());
|
|
212
|
+
useAudioMetersVisible.setState(useAudioMetersVisible.getInitialState());
|
|
213
|
+
localStorage.clear();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
|
-
import {
|
|
3
|
-
Image,
|
|
4
|
-
Magnet,
|
|
5
|
-
MagnifyingGlassMinus,
|
|
6
|
-
MagnifyingGlassPlus,
|
|
7
|
-
Waves,
|
|
8
|
-
} from "@phosphor-icons/react";
|
|
2
|
+
import { Image, Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
|
|
9
3
|
import {
|
|
10
4
|
useEnableKeyframes,
|
|
11
5
|
isPlayheadWithinTween,
|
|
@@ -22,12 +16,16 @@ import {
|
|
|
22
16
|
import { useTimelineZoom } from "../player/components/useTimelineZoom";
|
|
23
17
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
24
18
|
import { Tooltip } from "./ui";
|
|
19
|
+
import { AudioMetersIcon } from "./icons/AudioMetersIcon";
|
|
20
|
+
import { RippleEditIcon } from "./icons/RippleEditIcon";
|
|
25
21
|
import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
|
|
26
22
|
import { TimelineHistoryButtons } from "./TimelineHistoryButtons";
|
|
27
23
|
import { Scissors } from "../icons/SystemIcons";
|
|
28
24
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
29
25
|
import type { DomEditSelection } from "./editor/domEditingTypes";
|
|
30
26
|
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
27
|
+
import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
|
|
28
|
+
import { useProjectHasAudio } from "../utils/audioMeterMath";
|
|
31
29
|
import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
|
|
32
30
|
|
|
33
31
|
interface DomEditSessionSlice extends EnableKeyframesSession {
|
|
@@ -141,6 +139,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
141
139
|
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
|
|
142
140
|
const setThumbnailMode = usePlayerStore((s) => s.setThumbnailMode);
|
|
143
141
|
const thumbnailsVisible = thumbnailMode === "adaptive";
|
|
142
|
+
const audioMetersVisible = useAudioMetersVisible((s) => s.visible);
|
|
143
|
+
const setAudioMetersVisible = useAudioMetersVisible((s) => s.setVisible);
|
|
144
|
+
const projectHasAudio = useProjectHasAudio();
|
|
144
145
|
// Subscribe so the add-beat button reacts to playhead movement and analysis load.
|
|
145
146
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
146
147
|
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
|
|
@@ -246,9 +247,22 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
246
247
|
aria-pressed={rippleEditEnabled}
|
|
247
248
|
className={rippleEditEnabled ? flatActive : flatIdle}
|
|
248
249
|
>
|
|
249
|
-
<
|
|
250
|
+
<RippleEditIcon size={16} />
|
|
250
251
|
</button>
|
|
251
252
|
</Tooltip>
|
|
253
|
+
{projectHasAudio && (
|
|
254
|
+
<Tooltip label={audioMetersVisible ? "Hide audio meters" : "Show audio meters"}>
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
onClick={() => setAudioMetersVisible(!audioMetersVisible)}
|
|
258
|
+
aria-label="Toggle audio meters"
|
|
259
|
+
aria-pressed={audioMetersVisible}
|
|
260
|
+
className={audioMetersVisible ? flatActive : flatIdle}
|
|
261
|
+
>
|
|
262
|
+
<AudioMetersIcon size={16} />
|
|
263
|
+
</button>
|
|
264
|
+
</Tooltip>
|
|
265
|
+
)}
|
|
252
266
|
{/* Always rendered (CapCut-style): with no keyframeable selection the
|
|
253
267
|
button fades to a disabled state instead of unmounting, so the
|
|
254
268
|
toolbar layout never shifts. */}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
|
|
3
|
+
import { RATE_RANGE } from "@hyperframes/core/audio-automation";
|
|
4
|
+
import { parseGainInput, parseRateInput, parseSecondsInput } from "./audioInspectorInput";
|
|
5
|
+
|
|
6
|
+
describe("parseGainInput", () => {
|
|
7
|
+
it("reads decibels as the readout shows them", () => {
|
|
8
|
+
expect(parseGainInput("0")).toBeCloseTo(1, 6);
|
|
9
|
+
expect(parseGainInput("+0.5 dB")).toBeCloseTo(10 ** (0.5 / 20), 6);
|
|
10
|
+
expect(parseGainInput("-6")).toBeCloseTo(10 ** (-6 / 20), 6);
|
|
11
|
+
expect(parseGainInput("-6dB")).toBeCloseTo(10 ** (-6 / 20), 6);
|
|
12
|
+
expect(parseGainInput("-inf")).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
it("also takes a linear multiplier or a percentage", () => {
|
|
15
|
+
expect(parseGainInput("x0.8")).toBeCloseTo(0.8, 6);
|
|
16
|
+
expect(parseGainInput("0.8x")).toBeCloseTo(0.8, 6);
|
|
17
|
+
expect(parseGainInput("80%")).toBeCloseTo(0.8, 6);
|
|
18
|
+
});
|
|
19
|
+
it("clamps to the fader's ceiling and refuses nonsense", () => {
|
|
20
|
+
expect(parseGainInput("+40 dB")).toBeCloseTo(MAX_AUDIO_GAIN, 6);
|
|
21
|
+
expect(parseGainInput("loud")).toBeNull();
|
|
22
|
+
expect(parseGainInput("")).toBeNull();
|
|
23
|
+
expect(parseGainInput("x-1")).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("parseRateInput", () => {
|
|
28
|
+
it("reads a multiplier with or without the x, and a percentage", () => {
|
|
29
|
+
expect(parseRateInput("1.5x")).toBe(1.5);
|
|
30
|
+
expect(parseRateInput("1.5")).toBe(1.5);
|
|
31
|
+
expect(parseRateInput("x2")).toBe(2);
|
|
32
|
+
expect(parseRateInput("150%")).toBe(1.5);
|
|
33
|
+
});
|
|
34
|
+
it("clamps to the rate range and refuses zero or text", () => {
|
|
35
|
+
expect(parseRateInput("100x")).toBe(RATE_RANGE.max);
|
|
36
|
+
expect(parseRateInput("0.001")).toBe(RATE_RANGE.min);
|
|
37
|
+
expect(parseRateInput("0")).toBeNull();
|
|
38
|
+
expect(parseRateInput("fast")).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("parseSecondsInput", () => {
|
|
43
|
+
it("reads seconds, milliseconds and minutes:seconds", () => {
|
|
44
|
+
expect(parseSecondsInput("2.5")).toBe(2.5);
|
|
45
|
+
expect(parseSecondsInput("2.5s")).toBe(2.5);
|
|
46
|
+
expect(parseSecondsInput("500ms")).toBe(0.5);
|
|
47
|
+
expect(parseSecondsInput("1:02.5")).toBe(62.5);
|
|
48
|
+
expect(parseSecondsInput("0")).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
it("refuses negatives and text", () => {
|
|
51
|
+
expect(parseSecondsInput("-1")).toBeNull();
|
|
52
|
+
expect(parseSecondsInput("soon")).toBeNull();
|
|
53
|
+
expect(parseSecondsInput("")).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { audioDbToGain, clampAudioGain } from "@hyperframes/core/audio-gain";
|
|
2
|
+
import { RATE_RANGE } from "@hyperframes/core/audio-automation";
|
|
3
|
+
|
|
4
|
+
const NUMBER = String.raw`[-+]?(?:\d+\.?\d*|\.\d+)`;
|
|
5
|
+
|
|
6
|
+
/** Gain as the readout shows it (dB, "x0.8", "80%"). Returns linear gain, or null to refuse. */
|
|
7
|
+
export function parseGainInput(text: string): number | null {
|
|
8
|
+
const t = text.trim().toLowerCase().replace(/\s+/g, "");
|
|
9
|
+
if (!t) return null;
|
|
10
|
+
if (t === "-inf" || t === "-infinity" || t === "-∞" || t === "silence" || t === "mute") return 0;
|
|
11
|
+
let m = new RegExp(`^(${NUMBER})%$`).exec(t);
|
|
12
|
+
if (m) return finiteGain(Number(m[1]) / 100);
|
|
13
|
+
m = new RegExp(`^(?:x(${NUMBER})|(${NUMBER})x)$`).exec(t);
|
|
14
|
+
if (m) return finiteGain(Number(m[1] ?? m[2]));
|
|
15
|
+
m = new RegExp(`^(${NUMBER})(?:db)?$`).exec(t);
|
|
16
|
+
if (m) return finiteGain(audioDbToGain(Number(m[1])));
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function finiteGain(gain: number): number | null {
|
|
21
|
+
return Number.isFinite(gain) && gain >= 0 ? clampAudioGain(gain) : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Playback rate: "1.5x", "1.5", "150%". Clamped to the rate lane's range. */
|
|
25
|
+
export function parseRateInput(text: string): number | null {
|
|
26
|
+
const t = text.trim().toLowerCase().replace(/\s+/g, "");
|
|
27
|
+
if (!t) return null;
|
|
28
|
+
let value: number;
|
|
29
|
+
let m = new RegExp(`^(${NUMBER})%$`).exec(t);
|
|
30
|
+
if (m) value = Number(m[1]) / 100;
|
|
31
|
+
else {
|
|
32
|
+
m = new RegExp(`^(?:x(${NUMBER})|(${NUMBER})x?)$`).exec(t);
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
value = Number(m[1] ?? m[2]);
|
|
35
|
+
}
|
|
36
|
+
if (!Number.isFinite(value) || value <= 0) return null;
|
|
37
|
+
return Math.min(RATE_RANGE.max, Math.max(RATE_RANGE.min, value));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Non-negative duration or offset: "2.5", "2.5s", "500ms", "1:02.5". Returns seconds, or null to refuse. */
|
|
41
|
+
export function parseSecondsInput(text: string): number | null {
|
|
42
|
+
const t = text.trim().toLowerCase().replace(/\s+/g, "");
|
|
43
|
+
if (!t) return null;
|
|
44
|
+
let m = new RegExp(`^(\\d+):(\\d{1,2}(?:\\.\\d*)?)$`).exec(t);
|
|
45
|
+
if (m) return finiteSeconds(Number(m[1]) * 60 + Number(m[2]));
|
|
46
|
+
m = new RegExp(`^(${NUMBER})ms$`).exec(t);
|
|
47
|
+
if (m) return finiteSeconds(Number(m[1]) / 1000);
|
|
48
|
+
m = new RegExp(`^(${NUMBER})(?:s|sec|secs)?$`).exec(t);
|
|
49
|
+
if (m) return finiteSeconds(Number(m[1]));
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function finiteSeconds(seconds: number): number | null {
|
|
54
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
|
|
55
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
import { useMemo, useRef, useState } from "react";
|
|
3
|
-
import { Plus,
|
|
3
|
+
import { Plus, X } from "../../icons/SystemIcons";
|
|
4
4
|
import {
|
|
5
5
|
buildDefaultGradientModel,
|
|
6
6
|
insertGradientStop,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
serializeGradient,
|
|
9
9
|
type GradientModel,
|
|
10
10
|
} from "./gradientValue";
|
|
11
|
+
import { ReverseGradientIcon } from "../icons/ReverseGradientIcon";
|
|
11
12
|
import { IMAGE_EXT } from "../../utils/mediaTypes";
|
|
12
13
|
import { FIELD, LABEL, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
|
13
14
|
import {
|
|
@@ -358,7 +359,7 @@ export function GradientField({
|
|
|
358
359
|
}}
|
|
359
360
|
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
360
361
|
>
|
|
361
|
-
<
|
|
362
|
+
<ReverseGradientIcon size={16} />
|
|
362
363
|
Reverse
|
|
363
364
|
</button>
|
|
364
365
|
</div>
|
|
@@ -474,3 +474,175 @@ describe("FlatMediaSection — fit/position", () => {
|
|
|
474
474
|
act(() => root.unmount());
|
|
475
475
|
});
|
|
476
476
|
});
|
|
477
|
+
|
|
478
|
+
function makeAudioElement(dataAttributes: Record<string, string> = {}): DomEditSelection {
|
|
479
|
+
const el = document.createElement("audio");
|
|
480
|
+
el.setAttribute("src", "assets/music.wav");
|
|
481
|
+
return makeVideoElement({
|
|
482
|
+
element: el,
|
|
483
|
+
id: "music",
|
|
484
|
+
selector: "#music",
|
|
485
|
+
label: "Music",
|
|
486
|
+
tagName: "audio",
|
|
487
|
+
dataAttributes: { duration: "10", ...dataAttributes },
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function renderWithRate(element: DomEditSelection, onSetAttribute = vi.fn()) {
|
|
492
|
+
const host = document.createElement("div");
|
|
493
|
+
document.body.append(host);
|
|
494
|
+
const root = createRoot(host);
|
|
495
|
+
act(() => {
|
|
496
|
+
root.render(
|
|
497
|
+
<FlatMediaSection
|
|
498
|
+
projectDir={null}
|
|
499
|
+
element={element}
|
|
500
|
+
styles={{}}
|
|
501
|
+
onSetStyle={vi.fn()}
|
|
502
|
+
onSetAttribute={onSetAttribute}
|
|
503
|
+
onSetHtmlAttribute={vi.fn()}
|
|
504
|
+
rate={{
|
|
505
|
+
automated: false,
|
|
506
|
+
automatedValue: undefined,
|
|
507
|
+
onAutomate: vi.fn(),
|
|
508
|
+
onRemoveAutomation: vi.fn(),
|
|
509
|
+
onCommitAt: vi.fn(),
|
|
510
|
+
canApplyPreset: true,
|
|
511
|
+
onApplyPreset: vi.fn(),
|
|
512
|
+
}}
|
|
513
|
+
/>,
|
|
514
|
+
);
|
|
515
|
+
});
|
|
516
|
+
return { host, root, onSetAttribute };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function labelsOf(host: HTMLElement): string[] {
|
|
520
|
+
return [...host.querySelectorAll('[data-flat-slider-track="true"]')].map(
|
|
521
|
+
(track) => track.getAttribute("aria-label") ?? "",
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
describe("FlatMediaSection — audio clips", () => {
|
|
526
|
+
it("offers speed presets on video but not on audio", () => {
|
|
527
|
+
const video = renderWithRate(makeVideoElement({ dataAttributes: { duration: "10" } }));
|
|
528
|
+
expect(video.host.textContent).toContain("Speed preset");
|
|
529
|
+
act(() => video.root.unmount());
|
|
530
|
+
|
|
531
|
+
const audio = renderWithRate(makeAudioElement());
|
|
532
|
+
expect(audio.host.textContent).not.toContain("Speed preset");
|
|
533
|
+
act(() => audio.root.unmount());
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("shows Fade in / Fade out rows for audio, and for video only when it carries audio", () => {
|
|
537
|
+
const audio = renderWithRate(makeAudioElement());
|
|
538
|
+
expect(labelsOf(audio.host)).toEqual(
|
|
539
|
+
expect.arrayContaining(["Volume", "Speed", "Media start", "Fade in", "Fade out"]),
|
|
540
|
+
);
|
|
541
|
+
act(() => audio.root.unmount());
|
|
542
|
+
|
|
543
|
+
const silentVideo = renderWithRate(makeVideoElement({ dataAttributes: { duration: "10" } }));
|
|
544
|
+
expect(labelsOf(silentVideo.host)).not.toContain("Fade in");
|
|
545
|
+
act(() => silentVideo.root.unmount());
|
|
546
|
+
|
|
547
|
+
const audibleVideo = renderWithRate(
|
|
548
|
+
makeVideoElement({ dataAttributes: { duration: "10", "has-audio": "true" } }),
|
|
549
|
+
);
|
|
550
|
+
expect(labelsOf(audibleVideo.host)).toContain("Fade out");
|
|
551
|
+
act(() => audibleVideo.root.unmount());
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("reads the authored fades and writes data-fade-in from the slider, clearing it at zero", () => {
|
|
555
|
+
const { host, root, onSetAttribute } = renderWithRate(
|
|
556
|
+
makeAudioElement({ "fade-in": "0.5", "fade-out": "2" }),
|
|
557
|
+
);
|
|
558
|
+
const readouts = [...host.querySelectorAll('[data-flat-slider-value="true"]')].map(
|
|
559
|
+
(node) => node.textContent,
|
|
560
|
+
);
|
|
561
|
+
expect(readouts).toEqual(expect.arrayContaining(["0.50s", "2.00s"]));
|
|
562
|
+
|
|
563
|
+
const fadeInTrack = host.querySelector<HTMLElement>(
|
|
564
|
+
'[data-flat-slider-track="true"][aria-label="Fade in"]',
|
|
565
|
+
);
|
|
566
|
+
if (!fadeInTrack) throw new Error("expected a Fade in slider");
|
|
567
|
+
Object.defineProperty(fadeInTrack, "getBoundingClientRect", {
|
|
568
|
+
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
|
|
569
|
+
});
|
|
570
|
+
// The other fade reserves 2 s: a quarter of the remaining 8 s is 2 s.
|
|
571
|
+
act(() => {
|
|
572
|
+
fadeInTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 25 }));
|
|
573
|
+
fadeInTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 25 }));
|
|
574
|
+
});
|
|
575
|
+
expect(onSetAttribute).toHaveBeenCalledWith("fade-in", "2");
|
|
576
|
+
act(() => {
|
|
577
|
+
fadeInTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 0 }));
|
|
578
|
+
fadeInTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 0 }));
|
|
579
|
+
});
|
|
580
|
+
// An empty write removes the attribute rather than leaving data-fade-in="0" behind.
|
|
581
|
+
expect(onSetAttribute).toHaveBeenCalledWith("fade-in", "");
|
|
582
|
+
act(() => root.unmount());
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it.each([
|
|
586
|
+
["Media start", "media-start", "45.00", {}, "9999"],
|
|
587
|
+
["Fade in", "fade-in", "10", {}, "9999"],
|
|
588
|
+
["Fade out", "fade-out", "10", {}, "9999"],
|
|
589
|
+
["Fade in", "fade-in", "5", { "fade-out": "5" }, "8"],
|
|
590
|
+
["Fade out", "fade-out", "5", { "fade-in": "5" }, "8"],
|
|
591
|
+
] as const)("bounds typed %s to the slider limit", (label, attribute, expected, fades, typed) => {
|
|
592
|
+
const { host, root, onSetAttribute } = renderWithRate(
|
|
593
|
+
makeAudioElement({ "source-duration": "45", ...fades }),
|
|
594
|
+
);
|
|
595
|
+
const row = host.querySelector<HTMLElement>(
|
|
596
|
+
`[data-flat-slider-track="true"][aria-label="${label}"]`,
|
|
597
|
+
)?.parentElement;
|
|
598
|
+
const readout = row?.querySelector<HTMLElement>('[data-flat-slider-value="true"]');
|
|
599
|
+
if (!readout) throw new Error(`expected ${label} readout`);
|
|
600
|
+
act(() => readout.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
601
|
+
const input = host.querySelector<HTMLInputElement>('[data-flat-slider-input="true"]');
|
|
602
|
+
if (!input) throw new Error(`expected ${label} input`);
|
|
603
|
+
act(() => {
|
|
604
|
+
typeInto(input, typed);
|
|
605
|
+
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
606
|
+
});
|
|
607
|
+
expect(onSetAttribute.mock.calls).toEqual([[attribute, expected]]);
|
|
608
|
+
act(() => root.unmount());
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("commits a typed volume in dB and a typed fade in seconds", () => {
|
|
612
|
+
const { host, root, onSetAttribute } = renderWithRate(makeAudioElement());
|
|
613
|
+
const readouts = host.querySelectorAll<HTMLElement>('[data-flat-slider-value="true"]');
|
|
614
|
+
// Volume is the first slider row.
|
|
615
|
+
act(() => readouts[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
616
|
+
const volumeInput = host.querySelector<HTMLInputElement>('[data-flat-slider-input="true"]');
|
|
617
|
+
if (!volumeInput) throw new Error("expected the volume readout to open for typing");
|
|
618
|
+
act(() => {
|
|
619
|
+
typeInto(volumeInput, "-6 dB");
|
|
620
|
+
volumeInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
621
|
+
});
|
|
622
|
+
const [attr, value] = onSetAttribute.mock.calls.at(-1) ?? [];
|
|
623
|
+
expect(attr).toBe("volume");
|
|
624
|
+
expect(Number(value)).toBeCloseTo(10 ** (-6 / 20), 2);
|
|
625
|
+
|
|
626
|
+
const fadeOutRow = host.querySelector<HTMLElement>(
|
|
627
|
+
'[data-flat-slider-track="true"][aria-label="Fade out"]',
|
|
628
|
+
)?.parentElement;
|
|
629
|
+
const fadeOutReadout = fadeOutRow?.querySelector<HTMLElement>(
|
|
630
|
+
'[data-flat-slider-value="true"]',
|
|
631
|
+
);
|
|
632
|
+
act(() => fadeOutReadout?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
633
|
+
const fadeInput = host.querySelector<HTMLInputElement>('[data-flat-slider-input="true"]');
|
|
634
|
+
if (!fadeInput) throw new Error("expected the fade readout to open for typing");
|
|
635
|
+
act(() => {
|
|
636
|
+
typeInto(fadeInput, "1.25s");
|
|
637
|
+
fadeInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
638
|
+
});
|
|
639
|
+
expect(onSetAttribute).toHaveBeenLastCalledWith("fade-out", "1.25");
|
|
640
|
+
act(() => root.unmount());
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
function typeInto(input: HTMLInputElement, value: string) {
|
|
645
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
646
|
+
setter?.call(input, value);
|
|
647
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
648
|
+
}
|
|
@@ -25,6 +25,14 @@ import {
|
|
|
25
25
|
audioGainToFaderPosition,
|
|
26
26
|
audioGainToText,
|
|
27
27
|
} from "@hyperframes/core/audio-gain";
|
|
28
|
+
import {
|
|
29
|
+
HF_AUDIO_FADE_IN_DATA_KEY,
|
|
30
|
+
HF_AUDIO_FADE_OUT_DATA_KEY,
|
|
31
|
+
clampFadesToDuration,
|
|
32
|
+
formatFadeSeconds,
|
|
33
|
+
readFadeSeconds,
|
|
34
|
+
} from "@hyperframes/core/audio-fade";
|
|
35
|
+
import { parseGainInput, parseRateInput, parseSecondsInput } from "./audioInspectorInput";
|
|
28
36
|
|
|
29
37
|
// fallow-ignore-next-line complexity
|
|
30
38
|
export function FlatMediaSection({
|
|
@@ -92,6 +100,10 @@ export function FlatMediaSection({
|
|
|
92
100
|
(el as HTMLMediaElement).duration ||
|
|
93
101
|
0;
|
|
94
102
|
const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10));
|
|
103
|
+
const fadeIn = readFadeSeconds(element.dataAttributes[HF_AUDIO_FADE_IN_DATA_KEY]);
|
|
104
|
+
const fadeOut = readFadeSeconds(element.dataAttributes[HF_AUDIO_FADE_OUT_DATA_KEY]);
|
|
105
|
+
const clipDuration = Number.parseFloat(element.dataAttributes.duration ?? "") || 0;
|
|
106
|
+
const fadeMax = clipDuration > 0 ? clipDuration : 10;
|
|
95
107
|
const hasLoop = el.hasAttribute("loop");
|
|
96
108
|
const hasMuted = el.hasAttribute("muted");
|
|
97
109
|
const hasAudio = element.dataAttributes["has-audio"] === "true";
|
|
@@ -256,6 +268,13 @@ export function FlatMediaSection({
|
|
|
256
268
|
void onSetAttribute("volume", formatAudioGain(gain));
|
|
257
269
|
}
|
|
258
270
|
}}
|
|
271
|
+
onCommitText={(text) => {
|
|
272
|
+
const gain = parseGainInput(text);
|
|
273
|
+
if (gain === null) return false;
|
|
274
|
+
if (volumeAutomated) onCommitVolumeAt?.(gain);
|
|
275
|
+
else void onSetAttribute("volume", formatAudioGain(gain));
|
|
276
|
+
return true;
|
|
277
|
+
}}
|
|
259
278
|
/>
|
|
260
279
|
</div>
|
|
261
280
|
<AutomationToggle
|
|
@@ -285,6 +304,13 @@ export function FlatMediaSection({
|
|
|
285
304
|
void onSetAttribute("playback-rate", formatNumericValue(speed));
|
|
286
305
|
}
|
|
287
306
|
}}
|
|
307
|
+
onCommitText={(text) => {
|
|
308
|
+
const speed = parseRateInput(text);
|
|
309
|
+
if (speed === null) return false;
|
|
310
|
+
if (rate?.automated) rate.onCommitAt(speed);
|
|
311
|
+
else void onSetAttribute("playback-rate", formatNumericValue(speed));
|
|
312
|
+
return true;
|
|
313
|
+
}}
|
|
288
314
|
/>
|
|
289
315
|
</div>
|
|
290
316
|
<AutomationToggle
|
|
@@ -295,7 +321,10 @@ export function FlatMediaSection({
|
|
|
295
321
|
onRemoveAutomation={rate ? () => rate.onRemoveAutomation() : undefined}
|
|
296
322
|
/>
|
|
297
323
|
</div>
|
|
298
|
-
{
|
|
324
|
+
{/* Speed presets are picture-driven ramps (slow-mo reveals, whip
|
|
325
|
+
speed-ups); on a bare audio clip they only warp pitch, so the
|
|
326
|
+
row is video-only. */}
|
|
327
|
+
{rate?.canApplyPreset && !isAudio && (
|
|
299
328
|
<FlatSelectRow
|
|
300
329
|
label="Speed preset"
|
|
301
330
|
value=""
|
|
@@ -312,7 +341,21 @@ export function FlatMediaSection({
|
|
|
312
341
|
tier={mediaStart === 0 ? "default" : "explicitCustom"}
|
|
313
342
|
displayValue={formatTimingValue(mediaStart)}
|
|
314
343
|
onCommit={(next) => void onSetAttribute("media-start", (next / 100).toFixed(2))}
|
|
344
|
+
onCommitText={(text) => {
|
|
345
|
+
const seconds = parseSecondsInput(text);
|
|
346
|
+
if (seconds === null) return false;
|
|
347
|
+
void onSetAttribute("media-start", Math.min(seconds, mediaStartMax).toFixed(2));
|
|
348
|
+
return true;
|
|
349
|
+
}}
|
|
315
350
|
/>
|
|
351
|
+
{(isAudio || hasAudio) && (
|
|
352
|
+
<MediaFadeSliders
|
|
353
|
+
fadeIn={fadeIn}
|
|
354
|
+
fadeOut={fadeOut}
|
|
355
|
+
fadeMax={fadeMax}
|
|
356
|
+
onSetAttribute={onSetAttribute}
|
|
357
|
+
/>
|
|
358
|
+
)}
|
|
316
359
|
<FlatToggle
|
|
317
360
|
label="Loop"
|
|
318
361
|
checked={hasLoop}
|
|
@@ -371,3 +414,46 @@ export function FlatMediaSection({
|
|
|
371
414
|
</div>
|
|
372
415
|
);
|
|
373
416
|
}
|
|
417
|
+
|
|
418
|
+
function MediaFadeSliders({
|
|
419
|
+
fadeIn,
|
|
420
|
+
fadeOut,
|
|
421
|
+
fadeMax,
|
|
422
|
+
onSetAttribute,
|
|
423
|
+
}: {
|
|
424
|
+
fadeIn: number;
|
|
425
|
+
fadeOut: number;
|
|
426
|
+
fadeMax: number;
|
|
427
|
+
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
428
|
+
}) {
|
|
429
|
+
const fadeText = (seconds: number) => (seconds > 0 ? formatFadeSeconds(seconds) : "");
|
|
430
|
+
return (["in", "out"] as const).map((edge) => {
|
|
431
|
+
const seconds = edge === "in" ? fadeIn : fadeOut;
|
|
432
|
+
const dataKey = edge === "in" ? HF_AUDIO_FADE_IN_DATA_KEY : HF_AUDIO_FADE_OUT_DATA_KEY;
|
|
433
|
+
const edgeMax = Math.max(0, fadeMax - (edge === "in" ? fadeOut : fadeIn));
|
|
434
|
+
return (
|
|
435
|
+
<FlatSlider
|
|
436
|
+
key={edge}
|
|
437
|
+
label={`Fade ${edge}`}
|
|
438
|
+
value={Math.round(seconds * 100)}
|
|
439
|
+
min={0}
|
|
440
|
+
max={Math.round(edgeMax * 100)}
|
|
441
|
+
tier={seconds === 0 ? "default" : "explicitCustom"}
|
|
442
|
+
displayValue={formatTimingValue(seconds)}
|
|
443
|
+
onCommit={(next) => void onSetAttribute(dataKey, fadeText(next / 100))}
|
|
444
|
+
onCommitText={(text) => {
|
|
445
|
+
const parsed = parseSecondsInput(text);
|
|
446
|
+
if (parsed === null) return false;
|
|
447
|
+
const next = Math.min(parsed, edgeMax);
|
|
448
|
+
const bounded = clampFadesToDuration(
|
|
449
|
+
{ fadeIn: edge === "in" ? next : fadeIn, fadeOut: edge === "out" ? next : fadeOut },
|
|
450
|
+
fadeMax,
|
|
451
|
+
);
|
|
452
|
+
void onSetAttribute(dataKey, fadeText(edge === "in" ? bounded.fadeIn : bounded.fadeOut));
|
|
453
|
+
return true;
|
|
454
|
+
}}
|
|
455
|
+
onReset={seconds > 0 ? () => void onSetAttribute(dataKey, "") : undefined}
|
|
456
|
+
/>
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
}
|