@hyperframes/studio 0.8.57 → 0.8.59
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-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
- package/dist/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
- package/dist/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
- package/dist/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
- package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
- package/dist/index.d.ts +577 -8
- package/dist/index.html +2 -2
- package/dist/index.js +22537 -21600
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/TimelineToolbar.tsx +19 -0
- package/src/components/editor/MotionPathOverlay.test.tsx +0 -1
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- 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/nle/AudioMeterStrip.test.tsx +124 -0
- package/src/components/nle/AudioMeterStrip.tsx +213 -0
- package/src/components/nle/NLEContext.tsx +8 -2
- package/src/components/nle/NLEPreview.test.ts +1 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- package/src/components/nle/useCompositionStack.ts +1 -1
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useCompositionDimensions.test.tsx +0 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +28 -0
- package/src/hooks/useSdkSession.ts +31 -4
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +25 -1
- package/src/player/components/LayerDisclosureRow.tsx +3 -3
- package/src/player/components/Player.test.ts +34 -0
- package/src/player/components/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineCanvas.tsx +10 -56
- package/src/player/components/TimelineClip.tsx +14 -1
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- package/src/player/components/TimelineGroupHeader.tsx +4 -6
- 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/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
- package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
- package/src/player/hooks/usePlayerHandle.ts +94 -0
- package/src/player/index.ts +7 -0
- package/src/player/lib/timelineElementHelpers.ts +20 -0
- package/src/player/store/timelineElement.ts +5 -0
- package/src/styles/theme.css +4 -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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { useEffect } from "react";
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { createRoot } from "react-dom/client";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { NLEProvider, useNLEContext, type NLEContextValue } from "../../components/nle/NLEContext";
|
|
8
|
+
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
9
|
+
import { makeAdapterWindow, makeFakeIframe } from "./timelinePlayerTestHarness";
|
|
10
|
+
import { usePlayerHandle, type PlayerHandle } from "./usePlayerHandle";
|
|
11
|
+
|
|
12
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
13
|
+
|
|
14
|
+
function renderHandle() {
|
|
15
|
+
let latest: PlayerHandle | null = null;
|
|
16
|
+
let context: NLEContextValue | null = null;
|
|
17
|
+
let renders = 0;
|
|
18
|
+
const host = document.createElement("div");
|
|
19
|
+
document.body.append(host);
|
|
20
|
+
const root = createRoot(host);
|
|
21
|
+
act(() => {
|
|
22
|
+
root.render(
|
|
23
|
+
<NLEProvider projectId="player-handle-test">
|
|
24
|
+
<HandleHarness
|
|
25
|
+
onContext={(value) => (context = value)}
|
|
26
|
+
onValue={(value) => (latest = value)}
|
|
27
|
+
onRender={() => renders++}
|
|
28
|
+
/>
|
|
29
|
+
</NLEProvider>,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
if (!latest || !context) throw new Error("usePlayerHandle did not mount");
|
|
33
|
+
return {
|
|
34
|
+
root,
|
|
35
|
+
getHandle: () => latest as PlayerHandle,
|
|
36
|
+
getContext: () => context as NLEContextValue,
|
|
37
|
+
getRenderCount: () => renders,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function HandleHarness({
|
|
42
|
+
onContext,
|
|
43
|
+
onValue,
|
|
44
|
+
onRender,
|
|
45
|
+
}: {
|
|
46
|
+
onContext: (value: NLEContextValue) => void;
|
|
47
|
+
onValue: (value: PlayerHandle) => void;
|
|
48
|
+
onRender: () => void;
|
|
49
|
+
}) {
|
|
50
|
+
onRender();
|
|
51
|
+
const context = useNLEContext();
|
|
52
|
+
const handle = usePlayerHandle();
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
onContext(context);
|
|
55
|
+
onValue(handle);
|
|
56
|
+
}, [context, handle, onContext, onValue]);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
usePlayerStore.getState().reset();
|
|
62
|
+
document.body.innerHTML = "";
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("usePlayerHandle", () => {
|
|
66
|
+
it("refuses seek before readiness and notifies structural changes independently", () => {
|
|
67
|
+
const { root, getHandle } = renderHandle();
|
|
68
|
+
const handle = getHandle();
|
|
69
|
+
|
|
70
|
+
expect(handle.ready).toBe(false);
|
|
71
|
+
expect(handle.seek(4)).toBe(false);
|
|
72
|
+
|
|
73
|
+
const structuralListener = vi.fn();
|
|
74
|
+
const unsubscribe = handle.subscribe(structuralListener);
|
|
75
|
+
act(() => usePlayerStore.setState({ timelineReady: true }));
|
|
76
|
+
expect(structuralListener).toHaveBeenCalledTimes(1);
|
|
77
|
+
act(() => usePlayerStore.setState({ duration: 8 }));
|
|
78
|
+
expect(structuralListener).toHaveBeenCalledTimes(2);
|
|
79
|
+
act(() =>
|
|
80
|
+
usePlayerStore.setState({
|
|
81
|
+
elements: [
|
|
82
|
+
{
|
|
83
|
+
id: "clip",
|
|
84
|
+
tag: "div",
|
|
85
|
+
start: 2,
|
|
86
|
+
duration: 3,
|
|
87
|
+
track: 0,
|
|
88
|
+
selector: "#clip",
|
|
89
|
+
sourceFile: "index.html",
|
|
90
|
+
compositionSrc: "compositions/intro.html",
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
expect(structuralListener).toHaveBeenCalledTimes(3);
|
|
96
|
+
expect(getHandle().elements).toEqual([
|
|
97
|
+
{
|
|
98
|
+
id: "clip",
|
|
99
|
+
start: 2,
|
|
100
|
+
end: 5,
|
|
101
|
+
selector: "#clip",
|
|
102
|
+
sourceFile: "index.html",
|
|
103
|
+
compositionSrc: "compositions/intro.html",
|
|
104
|
+
},
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
unsubscribe();
|
|
108
|
+
act(() => root.unmount());
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("uses the shell player to seek a real preview adapter after readiness", async () => {
|
|
112
|
+
const { root, getContext, getHandle } = renderHandle();
|
|
113
|
+
const { adapter, win } = makeAdapterWindow();
|
|
114
|
+
await act(async () => {
|
|
115
|
+
getContext().iframeRef.current = makeFakeIframe(win);
|
|
116
|
+
getContext().onIframeLoad();
|
|
117
|
+
await Promise.resolve();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(usePlayerStore.getState().timelineReady).toBe(true);
|
|
121
|
+
expect(getHandle().seek(4)).toBe(true);
|
|
122
|
+
expect(adapter.getTime()).toBe(4);
|
|
123
|
+
expect(usePlayerStore.getState().currentTime).toBe(4);
|
|
124
|
+
act(() => getHandle().play());
|
|
125
|
+
expect(getHandle().playing).toBe(true);
|
|
126
|
+
|
|
127
|
+
act(() => root.unmount());
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("keeps playback time out of React renders and forwards live time notifications", () => {
|
|
131
|
+
const { root, getHandle, getRenderCount } = renderHandle();
|
|
132
|
+
const initialRenderCount = getRenderCount();
|
|
133
|
+
const timeListener = vi.fn();
|
|
134
|
+
const structuralListener = vi.fn();
|
|
135
|
+
const unsubscribeTime = getHandle().subscribeTime(timeListener);
|
|
136
|
+
const unsubscribe = getHandle().subscribe(structuralListener);
|
|
137
|
+
|
|
138
|
+
act(() => {
|
|
139
|
+
usePlayerStore.setState({ currentTime: 2.5 });
|
|
140
|
+
liveTime.notify(7.25);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(getRenderCount()).toBe(initialRenderCount);
|
|
144
|
+
expect(getHandle().currentTime).toBe(2.5);
|
|
145
|
+
expect(timeListener).toHaveBeenCalledWith(7.25);
|
|
146
|
+
expect(structuralListener).not.toHaveBeenCalled();
|
|
147
|
+
|
|
148
|
+
unsubscribeTime();
|
|
149
|
+
unsubscribe();
|
|
150
|
+
act(() => root.unmount());
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import { liveTime, usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { useNLEContext } from "../../components/nle/NLEContext";
|
|
4
|
+
|
|
5
|
+
export interface PlayerHandleElement {
|
|
6
|
+
id: string;
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
selector?: string;
|
|
10
|
+
sourceFile?: string;
|
|
11
|
+
compositionSrc?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type PlayerHandleListener = () => void;
|
|
15
|
+
export type PlayerHandleTimeListener = (time: number) => void;
|
|
16
|
+
|
|
17
|
+
export interface PlayerHandle {
|
|
18
|
+
ready: boolean;
|
|
19
|
+
playing: boolean;
|
|
20
|
+
seek: (seconds: number) => boolean;
|
|
21
|
+
play: () => void;
|
|
22
|
+
pause: () => void;
|
|
23
|
+
togglePlay: () => void;
|
|
24
|
+
readonly currentTime: number;
|
|
25
|
+
duration: number;
|
|
26
|
+
elements: PlayerHandleElement[];
|
|
27
|
+
subscribe: (listener: PlayerHandleListener) => () => void;
|
|
28
|
+
subscribeTime: (listener: PlayerHandleTimeListener) => () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toHandleElement(element: TimelineElement): PlayerHandleElement {
|
|
32
|
+
return {
|
|
33
|
+
id: element.id,
|
|
34
|
+
start: element.start,
|
|
35
|
+
end: element.start + element.duration,
|
|
36
|
+
selector: element.selector,
|
|
37
|
+
sourceFile: element.sourceFile,
|
|
38
|
+
compositionSrc: element.compositionSrc,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reads and controls the shell player. Call this under EditorShell/NLEProvider;
|
|
44
|
+
* the provider owns the mounted preview iframe and the single player instance.
|
|
45
|
+
*/
|
|
46
|
+
export function usePlayerHandle(): PlayerHandle {
|
|
47
|
+
const { play, pause, togglePlay, seek: seekPlayer } = useNLEContext();
|
|
48
|
+
const ready = usePlayerStore((state) => state.timelineReady);
|
|
49
|
+
const playing = usePlayerStore((state) => state.isPlaying);
|
|
50
|
+
const duration = usePlayerStore((state) => state.duration);
|
|
51
|
+
const elements = usePlayerStore((state) => state.elements);
|
|
52
|
+
|
|
53
|
+
const seek = useCallback(
|
|
54
|
+
(seconds: number) => {
|
|
55
|
+
if (!ready) return false;
|
|
56
|
+
return seekPlayer(seconds);
|
|
57
|
+
},
|
|
58
|
+
[ready, seekPlayer],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const subscribe = useCallback((listener: PlayerHandleListener) => {
|
|
62
|
+
let previous = usePlayerStore.getState();
|
|
63
|
+
return usePlayerStore.subscribe((state) => {
|
|
64
|
+
const structuralChange =
|
|
65
|
+
state.timelineReady !== previous.timelineReady ||
|
|
66
|
+
state.isPlaying !== previous.isPlaying ||
|
|
67
|
+
state.duration !== previous.duration ||
|
|
68
|
+
state.elements !== previous.elements;
|
|
69
|
+
previous = state;
|
|
70
|
+
if (structuralChange) listener();
|
|
71
|
+
});
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const subscribeTime = useCallback((listener: PlayerHandleTimeListener) => {
|
|
75
|
+
return liveTime.subscribe(listener);
|
|
76
|
+
}, []);
|
|
77
|
+
const handleElements = useMemo(() => elements.map(toHandleElement), [elements]);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
ready,
|
|
81
|
+
playing,
|
|
82
|
+
seek,
|
|
83
|
+
play,
|
|
84
|
+
pause,
|
|
85
|
+
togglePlay,
|
|
86
|
+
get currentTime() {
|
|
87
|
+
return usePlayerStore.getState().currentTime;
|
|
88
|
+
},
|
|
89
|
+
duration,
|
|
90
|
+
elements: handleElements,
|
|
91
|
+
subscribe,
|
|
92
|
+
subscribeTime,
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/player/index.ts
CHANGED
|
@@ -7,6 +7,13 @@ export { CompositionThumbnail } from "./components/CompositionThumbnail";
|
|
|
7
7
|
|
|
8
8
|
// Hooks
|
|
9
9
|
export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
|
|
10
|
+
export { usePlayerHandle } from "./hooks/usePlayerHandle";
|
|
11
|
+
export type {
|
|
12
|
+
PlayerHandle,
|
|
13
|
+
PlayerHandleElement,
|
|
14
|
+
PlayerHandleListener,
|
|
15
|
+
PlayerHandleTimeListener,
|
|
16
|
+
} from "./hooks/usePlayerHandle";
|
|
10
17
|
export { resolveIframe } from "./lib/timelineDOM";
|
|
11
18
|
|
|
12
19
|
// Store
|
|
@@ -12,6 +12,7 @@ import type { ClipManifestClip } from "./playbackTypes";
|
|
|
12
12
|
import { isFinitePositive } from "./playbackAdapter";
|
|
13
13
|
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
|
|
14
14
|
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
15
|
+
import { readElementFades } from "@hyperframes/core/audio-fade";
|
|
15
16
|
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// Layer-reveal lift transparency
|
|
@@ -173,8 +174,27 @@ function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element):
|
|
|
173
174
|
}
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
/** Sets or clears an optional field, so a re-parse after the attribute is removed drops it. */
|
|
178
|
+
function setOptional<K extends keyof TimelineElement>(
|
|
179
|
+
entry: TimelineElement,
|
|
180
|
+
key: K,
|
|
181
|
+
value: TimelineElement[K] | undefined,
|
|
182
|
+
): void {
|
|
183
|
+
if (value === undefined) delete entry[key];
|
|
184
|
+
else entry[key] = value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `data-has-audio` and the clip-edge fades: what the mixer hears and how it enters and leaves. */
|
|
188
|
+
function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
189
|
+
setOptional(entry, "hasAudio", el.getAttribute("data-has-audio") === "true" ? true : undefined);
|
|
190
|
+
const fades = readElementFades(el);
|
|
191
|
+
setOptional(entry, "fadeIn", fades.fadeIn > 0 ? fades.fadeIn : undefined);
|
|
192
|
+
setOptional(entry, "fadeOut", fades.fadeOut > 0 ? fades.fadeOut : undefined);
|
|
193
|
+
}
|
|
194
|
+
|
|
176
195
|
export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
177
196
|
applyPlaybackMetadataFromElement(entry, el);
|
|
197
|
+
applyAudioMetadataFromElement(entry, el);
|
|
178
198
|
|
|
179
199
|
const mediaEl = resolveMediaElement(el);
|
|
180
200
|
if (!mediaEl) return;
|
|
@@ -54,6 +54,11 @@ export interface TimelineElement {
|
|
|
54
54
|
playbackRate?: number;
|
|
55
55
|
sourceDuration?: number;
|
|
56
56
|
volume?: number;
|
|
57
|
+
/** `data-has-audio="true"` on a video: its sound is mixed, so it takes audio affordances (fades). */
|
|
58
|
+
hasAudio?: boolean;
|
|
59
|
+
/** Clip-edge fades from `data-fade-in` / `data-fade-out`, seconds; absent means none. */
|
|
60
|
+
fadeIn?: number;
|
|
61
|
+
fadeOut?: number;
|
|
57
62
|
/** Verbatim `data-fx-chain` / `data-automation`; see automationLaneData. */
|
|
58
63
|
fxChain?: string;
|
|
59
64
|
automation?: string;
|
package/src/styles/theme.css
CHANGED
|
@@ -69,6 +69,10 @@
|
|
|
69
69
|
--timeline-clip-border-hover: rgba(60, 230, 172, 0.75);
|
|
70
70
|
--timeline-clip-border-active: rgba(60, 230, 172, 0.55);
|
|
71
71
|
--timeline-handle: rgba(255, 255, 255, 0.55);
|
|
72
|
+
/* Audio clip fades: the shaded wedge over the faded gain, and the corner dots. */
|
|
73
|
+
--timeline-fade-shade: rgba(0, 0, 0, 0.38);
|
|
74
|
+
--timeline-fade-dot-active: rgba(255, 255, 255, 1);
|
|
75
|
+
--timeline-fade-dot-ring: rgba(0, 0, 0, 0.55);
|
|
72
76
|
--timeline-resize-seam: rgba(255, 255, 255, 0.12);
|
|
73
77
|
--timeline-resize-active: rgba(255, 255, 255, 0.24);
|
|
74
78
|
--timeline-track-clip-fill: rgba(255, 255, 255, 0.055);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
levelToFraction,
|
|
4
|
+
markFraction,
|
|
5
|
+
SILENT_CHANNEL,
|
|
6
|
+
stepChannel,
|
|
7
|
+
stepPair,
|
|
8
|
+
} from "./audioMeterMath";
|
|
9
|
+
|
|
10
|
+
describe("levelToFraction", () => {
|
|
11
|
+
it("puts each dB mark on its equally spaced stop", () => {
|
|
12
|
+
[1, 0.8, 0.6, 0.4, 0.2].forEach((f, i) =>
|
|
13
|
+
expect(markFraction([0, -3, -6, -12, -24][i]!)).toBeCloseTo(f, 6),
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
it("clamps silence and overs", () => {
|
|
17
|
+
expect(levelToFraction(0)).toBe(0);
|
|
18
|
+
expect(levelToFraction(4)).toBe(1);
|
|
19
|
+
});
|
|
20
|
+
it("is monotonic between marks", () => {
|
|
21
|
+
expect(levelToFraction(0.5)).toBeGreaterThan(markFraction(-12));
|
|
22
|
+
expect(levelToFraction(0.5)).toBeLessThan(markFraction(-3));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("stepChannel", () => {
|
|
27
|
+
it("attacks instantly, holds the peak, then falls", () => {
|
|
28
|
+
let ch = stepChannel(SILENT_CHANNEL, 1, 0, 16);
|
|
29
|
+
expect(ch.level).toBe(1);
|
|
30
|
+
ch = stepChannel(ch, 0, 500, 16);
|
|
31
|
+
expect(ch.peak).toBe(1);
|
|
32
|
+
expect(ch.level).toBeLessThan(1);
|
|
33
|
+
ch = stepChannel(ch, 0, 2000, 500);
|
|
34
|
+
expect(ch.peak).toBeLessThan(1);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("stepPair", () => {
|
|
39
|
+
it("returns the same pair while silent at rest, and moves on sound", () => {
|
|
40
|
+
const rest = stepPair(undefined, undefined, 0, 16);
|
|
41
|
+
expect(stepPair(rest, { l: 0, r: 0 }, 16, 16)).toBe(rest);
|
|
42
|
+
expect(stepPair(rest, { l: 1, r: 0 }, 32, 16)).not.toBe(rest);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { audioDbToGain, audioGainToDb } from "@hyperframes/core/audio-gain";
|
|
3
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
4
|
+
import { isAudioTimelineElement } from "./timelineInspector";
|
|
5
|
+
|
|
6
|
+
/** dB stops of the meter scale, top to bottom; equal spacing between stops. */
|
|
7
|
+
export const METER_DB_MARKS = [0, -3, -6, -12, -24] as const;
|
|
8
|
+
const FLOOR_DB = -60;
|
|
9
|
+
const STOPS = [...METER_DB_MARKS, FLOOR_DB];
|
|
10
|
+
|
|
11
|
+
const FALL_PER_SECOND = 0.9;
|
|
12
|
+
const HOLD_MS = 1200;
|
|
13
|
+
|
|
14
|
+
/** Linear peak (0..1) to a 0..1 bar fraction along the piecewise-linear dB scale. */
|
|
15
|
+
export function levelToFraction(linear: number): number {
|
|
16
|
+
const db = linear > 0 ? audioGainToDb(linear) : FLOOR_DB;
|
|
17
|
+
if (db >= 0) return 1;
|
|
18
|
+
if (db <= FLOOR_DB) return 0;
|
|
19
|
+
const j = STOPS.findIndex((stop) => db >= stop);
|
|
20
|
+
const hi = STOPS[j - 1] as number;
|
|
21
|
+
const lo = STOPS[j] as number;
|
|
22
|
+
return (STOPS.length - 1 - j + (db - lo) / (hi - lo)) / (STOPS.length - 1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Fraction of the bar height at which a dB mark sits, 0 at the bottom. */
|
|
26
|
+
export function markFraction(db: number): number {
|
|
27
|
+
return levelToFraction(audioDbToGain(db));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MeterChannel {
|
|
31
|
+
level: number;
|
|
32
|
+
peak: number;
|
|
33
|
+
peakAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const SILENT_CHANNEL: MeterChannel = { level: 0, peak: 0, peakAt: 0 };
|
|
37
|
+
|
|
38
|
+
/** Instant attack, steady fall; the peak holds for HOLD_MS, then falls with the bar. */
|
|
39
|
+
export function stepChannel(
|
|
40
|
+
prev: MeterChannel,
|
|
41
|
+
linear: number,
|
|
42
|
+
now: number,
|
|
43
|
+
dtMs: number,
|
|
44
|
+
): MeterChannel {
|
|
45
|
+
const fall = (dtMs / 1000) * FALL_PER_SECOND;
|
|
46
|
+
const target = levelToFraction(linear);
|
|
47
|
+
const level = Math.max(target, prev.level - fall);
|
|
48
|
+
if (target >= prev.peak) return { level, peak: target, peakAt: now };
|
|
49
|
+
if (now - prev.peakAt < HOLD_MS) return { level, peak: prev.peak, peakAt: prev.peakAt };
|
|
50
|
+
return { level, peak: Math.max(target, prev.peak - fall), peakAt: prev.peakAt };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type MeterPair = [MeterChannel, MeterChannel];
|
|
54
|
+
|
|
55
|
+
const atRest = (ch: MeterChannel): boolean => ch.level === 0 && ch.peak === 0;
|
|
56
|
+
|
|
57
|
+
/** Steps both channels; returns `prev` itself while silent and at rest, so callers can skip the paint. */
|
|
58
|
+
export function stepPair(
|
|
59
|
+
prev: MeterPair | undefined,
|
|
60
|
+
raw: { l: number; r: number } | undefined,
|
|
61
|
+
now: number,
|
|
62
|
+
dtMs: number,
|
|
63
|
+
): MeterPair {
|
|
64
|
+
const [l, r] = prev ?? [SILENT_CHANNEL, SILENT_CHANNEL];
|
|
65
|
+
const silent = !raw?.l && !raw?.r;
|
|
66
|
+
if (prev && silent && atRest(l) && atRest(r)) return prev;
|
|
67
|
+
return [stepChannel(l, raw?.l ?? 0, now, dtMs), stepChannel(r, raw?.r ?? 0, now, dtMs)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The meters appear only for a project with at least one audio clip or group. */
|
|
71
|
+
function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
|
|
72
|
+
return elements.some((el) => isAudioTimelineElement(el) || el.audioGroup !== undefined);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Scans once per `elements` change, not on every store update (the playhead ticks each frame). */
|
|
76
|
+
export function useProjectHasAudio(): boolean {
|
|
77
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
78
|
+
return useMemo(() => hasProjectAudio(elements), [elements]);
|
|
79
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
|
|
3
|
+
|
|
4
|
+
/** Whether the audio meter strip is shown; persisted, on unless the user hid it. */
|
|
5
|
+
export const useAudioMetersVisible = create<{
|
|
6
|
+
visible: boolean;
|
|
7
|
+
setVisible: (visible: boolean) => void;
|
|
8
|
+
}>((set) => ({
|
|
9
|
+
visible: readStudioUiPreferences().audioMetersVisible ?? true,
|
|
10
|
+
setVisible: (visible) => {
|
|
11
|
+
writeStudioUiPreferences({ audioMetersVisible: visible });
|
|
12
|
+
set({ visible });
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
@@ -377,6 +377,27 @@ export async function resolveDroppedAssetDuration(
|
|
|
377
377
|
return duration;
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
export function mediaMetadataUrl(projectId: string, assetPath: string): string {
|
|
381
|
+
return `/api/projects/${encodeURIComponent(projectId)}/media/metadata?path=${encodeURIComponent(assetPath)}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Dropped video audio stream from the metadata endpoint. Failure answers false so the drop still lands muted. */
|
|
385
|
+
export async function resolveDroppedAssetHasAudio(
|
|
386
|
+
projectId: string,
|
|
387
|
+
assetPath: string,
|
|
388
|
+
kind: TimelineAssetKind,
|
|
389
|
+
): Promise<boolean> {
|
|
390
|
+
if (kind !== "video") return false;
|
|
391
|
+
try {
|
|
392
|
+
const response = await fetch(mediaMetadataUrl(projectId, assetPath));
|
|
393
|
+
if (!response.ok) return false;
|
|
394
|
+
const data = (await response.json()) as { metadata?: { hasAudio?: boolean } } | null;
|
|
395
|
+
return data?.metadata?.hasAudio === true;
|
|
396
|
+
} catch {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
380
401
|
export async function resolveDroppedAssetDimensions(
|
|
381
402
|
projectId: string,
|
|
382
403
|
assetPath: string,
|
|
@@ -161,3 +161,13 @@ describe("timeline zoom pin persistence", () => {
|
|
|
161
161
|
expect(prefs.timelineManualZoomPercent).toBeUndefined();
|
|
162
162
|
});
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
describe("audioMetersVisible preference", () => {
|
|
166
|
+
it("round-trips and ignores a non-boolean", () => {
|
|
167
|
+
const storage = createStorage();
|
|
168
|
+
writeStudioUiPreferences({ audioMetersVisible: false }, storage);
|
|
169
|
+
expect(readStudioUiPreferences(storage).audioMetersVisible).toBe(false);
|
|
170
|
+
storage.setItem("hf-studio-ui-preferences", JSON.stringify({ audioMetersVisible: "no" }));
|
|
171
|
+
expect(readStudioUiPreferences(storage).audioMetersVisible).toBeUndefined();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -25,6 +25,8 @@ export interface StudioUiPreferences {
|
|
|
25
25
|
snapToGrid?: boolean;
|
|
26
26
|
/** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
|
|
27
27
|
timelineSnapEnabled?: boolean;
|
|
28
|
+
/** Audio level meters at the timeline's right edge; shown unless hidden here. */
|
|
29
|
+
audioMetersVisible?: boolean;
|
|
28
30
|
/** Keeps the main track gapless: deleting a clip closes the gap. Distinct
|
|
29
31
|
* from `timelineSnapEnabled` ("Magnet", drag/trim snapping). */
|
|
30
32
|
rippleEditEnabled?: boolean;
|
|
@@ -140,6 +142,9 @@ function readStorage(storage: Storage | null, key: string): StudioUiPreferences
|
|
|
140
142
|
if (typeof parsed.timelineSnapEnabled === "boolean") {
|
|
141
143
|
preferences.timelineSnapEnabled = parsed.timelineSnapEnabled;
|
|
142
144
|
}
|
|
145
|
+
if (typeof parsed.audioMetersVisible === "boolean") {
|
|
146
|
+
preferences.audioMetersVisible = parsed.audioMetersVisible;
|
|
147
|
+
}
|
|
143
148
|
if (typeof parsed.rippleEditEnabled === "boolean") {
|
|
144
149
|
preferences.rippleEditEnabled = parsed.rippleEditEnabled;
|
|
145
150
|
}
|
|
@@ -198,6 +198,38 @@ describe("insertTimelineAssetIntoSource", () => {
|
|
|
198
198
|
});
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
describe("buildTimelineAssetInsertHtml — video audio", () => {
|
|
202
|
+
const base = {
|
|
203
|
+
id: "clip_asset",
|
|
204
|
+
hfId: "hf-vid-1",
|
|
205
|
+
assetPath: "assets/clip.mp4",
|
|
206
|
+
kind: "video" as const,
|
|
207
|
+
start: 0,
|
|
208
|
+
duration: 8,
|
|
209
|
+
track: 1,
|
|
210
|
+
zIndex: 2,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
it("inserts a video muted when nothing says it carries audio", () => {
|
|
214
|
+
const html = buildTimelineAssetInsertHtml(base);
|
|
215
|
+
expect(html).toContain(" muted ");
|
|
216
|
+
expect(html).not.toContain("data-has-audio");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("inserts a video with an audio stream audible: data-has-audio and no muted", () => {
|
|
220
|
+
const html = buildTimelineAssetInsertHtml({ ...base, hasAudio: true });
|
|
221
|
+
expect(html).toContain('data-has-audio="true"');
|
|
222
|
+
expect(html).not.toContain("muted");
|
|
223
|
+
expect(html).toContain("playsinline");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("keeps a video without an audio stream muted", () => {
|
|
227
|
+
const html = buildTimelineAssetInsertHtml({ ...base, hasAudio: false });
|
|
228
|
+
expect(html).toContain(" muted ");
|
|
229
|
+
expect(html).not.toContain("data-has-audio");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
201
233
|
describe("buildTimelineAssetInsertHtml markup quality", () => {
|
|
202
234
|
const base = {
|
|
203
235
|
id: "clip_1",
|
|
@@ -116,6 +116,8 @@ export function buildTimelineAssetInsertHtml(input: {
|
|
|
116
116
|
track: number;
|
|
117
117
|
zIndex: number;
|
|
118
118
|
geometry?: { left: number; top: number; width: number; height: number };
|
|
119
|
+
/** Video only: true inserts `data-has-audio="true"` with no `muted`. Unknown or false stays muted. */
|
|
120
|
+
hasAudio?: boolean;
|
|
119
121
|
}): string {
|
|
120
122
|
const sharedAttrs = `id="${input.id}" data-hf-id="${input.hfId}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
|
|
121
123
|
const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
|
|
@@ -126,7 +128,10 @@ export function buildTimelineAssetInsertHtml(input: {
|
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
if (input.kind === "video") {
|
|
129
|
-
|
|
131
|
+
// `muted` and `data-has-audio="true"` are mutually exclusive by the lint
|
|
132
|
+
// contract (video_has_audio_but_muted): an audible drop takes the latter.
|
|
133
|
+
const audio = input.hasAudio ? 'data-has-audio="true"' : "muted";
|
|
134
|
+
return `<video ${sharedAttrs} ${audio} playsinline style="${visualStyles}"></video>`;
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
return `<audio ${sharedAttrs} data-volume="1" style="z-index: ${input.zIndex}"></audio>`;
|