@hyperframes/studio 0.8.109 → 0.8.111
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-CJYtikAy.js → hyperframes-player-DvJeHBHi.js} +1 -1
- package/dist/assets/{index-CMSgrT2F.js → index--G6c4Q_x.js} +1 -1
- package/dist/assets/{index-HTFVHUJ4.js → index-Bt4vDkfw.js} +1 -1
- package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-CYP6dMd7.js → index-SFktf7O1.js} +205 -204
- package/dist/index.d.ts +22 -6
- package/dist/index.html +2 -2
- package/dist/index.js +5352 -4755
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaLinkEdits.test.ts +19 -6
- package/src/components/editor/mediaLinkEdits.ts +34 -13
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +9 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
- package/src/hooks/timelineLinkEditPlan.ts +6 -1
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useRenderClipContent.ts +3 -5
- package/src/hooks/useTimelineEditing.ts +13 -0
- package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
- package/src/hooks/useTimelineLinkEditing.ts +5 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +1 -1
- package/src/player/components/ClipContextMenu.tsx +1 -1
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +41 -0
- package/src/player/components/TimelineClip.tsx +3 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +60 -0
- package/src/player/components/audioClipLink.ts +77 -0
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +16 -8
- package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
- package/src/player/components/clipMenuAudioItems.tsx +16 -37
- package/src/player/components/clipMenuLinkItems.test.ts +84 -0
- package/src/player/components/clipMenuLinkItems.tsx +54 -9
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +79 -6
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +6 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipGestureHandlers.ts +26 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +20 -2
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipDrag.ts +12 -5
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +0 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/studioPendingEdits.test.ts +18 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
|
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|
|
3
3
|
import {
|
|
4
4
|
audioPillFlags,
|
|
5
5
|
expandToLinkedMembers,
|
|
6
|
+
dropMisalignedTrimPartners,
|
|
6
7
|
linkedGestureKeys,
|
|
7
8
|
linkedMembersOf,
|
|
8
9
|
mediaAssetIdentity,
|
|
@@ -204,6 +205,53 @@ describe("selectClipWithLinks", () => {
|
|
|
204
205
|
});
|
|
205
206
|
});
|
|
206
207
|
|
|
208
|
+
describe("toggleClipWithLinks", () => {
|
|
209
|
+
const clips = [
|
|
210
|
+
{ id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
|
|
211
|
+
{ id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" },
|
|
212
|
+
{ id: "title", tag: "div", start: 0, duration: 4, track: 2 },
|
|
213
|
+
{ id: "music", tag: "audio", start: 0, duration: 4, track: 3 },
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
async function store() {
|
|
217
|
+
const { usePlayerStore } = await import("../store/playerStore");
|
|
218
|
+
const { toggleClipWithLinks } = await import("./timelineLinkSelection");
|
|
219
|
+
usePlayerStore.getState().setElements(clips);
|
|
220
|
+
usePlayerStore.getState().setSelection(["title"], "title");
|
|
221
|
+
return { usePlayerStore, toggle: toggleClipWithLinks };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
it("adds an unlinked clip to the selection and makes it primary", async () => {
|
|
225
|
+
const { usePlayerStore, toggle } = await store();
|
|
226
|
+
expect(toggle("music", false)?.id).toBe("music");
|
|
227
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["title", "music"]));
|
|
228
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("music");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("adding a linked clip adds its partner too", async () => {
|
|
232
|
+
const { usePlayerStore, toggle } = await store();
|
|
233
|
+
toggle("talk-audio", false);
|
|
234
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(
|
|
235
|
+
new Set(["title", "talk", "talk-audio"]),
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("toggling a selected clip removes it and its partner, keeping the rest", async () => {
|
|
240
|
+
const { usePlayerStore, toggle } = await store();
|
|
241
|
+
toggle("talk", false);
|
|
242
|
+
expect(toggle("talk", false)?.id).toBe("title");
|
|
243
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["title"]));
|
|
244
|
+
expect(toggle("title", false)).toBeNull();
|
|
245
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("Alt adds only the clicked member of a linked pair", async () => {
|
|
249
|
+
const { usePlayerStore, toggle } = await store();
|
|
250
|
+
toggle("talk", true);
|
|
251
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["title", "talk"]));
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
207
255
|
describe("Linked Selection off", () => {
|
|
208
256
|
const linkedPair = [
|
|
209
257
|
{ id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
|
|
@@ -229,3 +277,15 @@ describe("Linked Selection off", () => {
|
|
|
229
277
|
}
|
|
230
278
|
});
|
|
231
279
|
});
|
|
280
|
+
|
|
281
|
+
describe("dropMisalignedTrimPartners", () => {
|
|
282
|
+
const v = { id: "v", link: "lk", start: 2, duration: 6 };
|
|
283
|
+
const a = { id: "a", link: "lk", start: 3, duration: 5 };
|
|
284
|
+
const keys = new Set(["v", "a"]);
|
|
285
|
+
it("keeps a partner whose grabbed edge is at the same time", () => {
|
|
286
|
+
expect(dropMisalignedTrimPartners(keys, v, [v, a], "end")).toEqual(keys);
|
|
287
|
+
});
|
|
288
|
+
it("trims only the grabbed clip when the partner's edge is elsewhere", () => {
|
|
289
|
+
expect(dropMisalignedTrimPartners(keys, v, [v, a], "start")).toEqual(new Set(["v"]));
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sameCompositionScope, type TimelineElement } from "../store/timelineElement";
|
|
2
|
+
import { syncPartnerOf } from "./clipSync";
|
|
2
3
|
|
|
3
4
|
type LinkScoped = Pick<TimelineElement, "sourceFile" | "compositionScope">;
|
|
4
5
|
type LinkedElement = Pick<TimelineElement, "id" | "key" | "link"> & LinkScoped;
|
|
@@ -78,3 +79,79 @@ export function linkedGestureKeys(
|
|
|
78
79
|
const base = selected.has(grabbedKey) ? selected : [grabbedKey];
|
|
79
80
|
return expandToLinkedMembers(base, elements, linked);
|
|
80
81
|
}
|
|
82
|
+
|
|
83
|
+
type TimedLinked = LinkedElement & Pick<TimelineElement, "start" | "duration">;
|
|
84
|
+
|
|
85
|
+
const edgeTime = (el: Pick<TimelineElement, "start" | "duration">, edge: "start" | "end") =>
|
|
86
|
+
edge === "start" ? el.start : el.start + el.duration;
|
|
87
|
+
|
|
88
|
+
export function dropMisalignedTrimPartners(
|
|
89
|
+
keys: ReadonlySet<string>,
|
|
90
|
+
grabbed: TimedLinked,
|
|
91
|
+
elements: readonly TimedLinked[],
|
|
92
|
+
edge: "start" | "end",
|
|
93
|
+
): Set<string> {
|
|
94
|
+
const kept = new Set(keys);
|
|
95
|
+
if (!isLinked(grabbed)) return kept;
|
|
96
|
+
for (const el of elements) {
|
|
97
|
+
if (!sharesLinkGroup(el, grabbed) || keyOf(el) === keyOf(grabbed)) continue;
|
|
98
|
+
if (Math.abs(edgeTime(el, edge) - edgeTime(grabbed, edge)) > 1e-3) kept.delete(keyOf(el));
|
|
99
|
+
}
|
|
100
|
+
return kept;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type BoundedElement = Pick<
|
|
104
|
+
TimelineElement,
|
|
105
|
+
| "id"
|
|
106
|
+
| "key"
|
|
107
|
+
| "tag"
|
|
108
|
+
| "start"
|
|
109
|
+
| "duration"
|
|
110
|
+
| "link"
|
|
111
|
+
| "syncOrigin"
|
|
112
|
+
| "playbackStart"
|
|
113
|
+
| "playbackRate"
|
|
114
|
+
| "sourceFile"
|
|
115
|
+
>;
|
|
116
|
+
|
|
117
|
+
const tagOf = (el: Pick<TimelineElement, "tag">) => el.tag.trim().toLowerCase();
|
|
118
|
+
|
|
119
|
+
function partnerVideoBounds(
|
|
120
|
+
audio: BoundedElement,
|
|
121
|
+
elements: readonly BoundedElement[],
|
|
122
|
+
): { videoKey: string; start: number; end: number } | null {
|
|
123
|
+
if (tagOf(audio) !== "audio") return null;
|
|
124
|
+
const linkedVideo = isLinked(audio)
|
|
125
|
+
? elements.find((el) => sharesLinkGroup(el, audio) && tagOf(el) === "video")
|
|
126
|
+
: undefined;
|
|
127
|
+
const video = linkedVideo ?? syncPartnerOf(audio, elements);
|
|
128
|
+
return video
|
|
129
|
+
? { videoKey: keyOf(video), start: video.start, end: video.start + video.duration }
|
|
130
|
+
: null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function heldAudioShiftRange(
|
|
134
|
+
movers: readonly BoundedElement[],
|
|
135
|
+
elements: readonly BoundedElement[],
|
|
136
|
+
moving: ReadonlySet<string>,
|
|
137
|
+
): { min: number; max: number } {
|
|
138
|
+
let min = Number.NEGATIVE_INFINITY;
|
|
139
|
+
let max = Number.POSITIVE_INFINITY;
|
|
140
|
+
for (const mover of movers) {
|
|
141
|
+
const bounds = heldPartnerVideoBounds(mover, elements, moving);
|
|
142
|
+
if (!bounds) continue;
|
|
143
|
+
const low = bounds.start - mover.start;
|
|
144
|
+
min = Math.max(min, low);
|
|
145
|
+
max = Math.min(max, Math.max(low, bounds.end - mover.duration - mover.start));
|
|
146
|
+
}
|
|
147
|
+
return { min, max };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function heldPartnerVideoBounds(
|
|
151
|
+
audio: BoundedElement,
|
|
152
|
+
elements: readonly BoundedElement[],
|
|
153
|
+
gestureKeys: ReadonlySet<string>,
|
|
154
|
+
): { start: number; end: number } | null {
|
|
155
|
+
const bounds = partnerVideoBounds(audio, elements);
|
|
156
|
+
return bounds && !gestureKeys.has(bounds.videoKey) ? bounds : null;
|
|
157
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
4
|
+
import { normalizeClipsLoudness, writeClipGains } from "./audioGainApply";
|
|
5
|
+
|
|
6
|
+
const clip = (id: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
|
|
7
|
+
id,
|
|
8
|
+
domId: id,
|
|
9
|
+
tag: "audio",
|
|
10
|
+
src: `${id}.mp3`,
|
|
11
|
+
start: 0,
|
|
12
|
+
duration: 4,
|
|
13
|
+
track: 0,
|
|
14
|
+
...extra,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const volumeLane = JSON.stringify({
|
|
18
|
+
version: 1,
|
|
19
|
+
lanes: [{ target: "volume", points: [{ t: 0, v: 0.25 }] }],
|
|
20
|
+
});
|
|
21
|
+
const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
|
|
22
|
+
|
|
23
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
24
|
+
|
|
25
|
+
describe("normalizeClipsLoudness", () => {
|
|
26
|
+
it("refuses a clip whose volume lane owns its gain, before measuring or writing", async () => {
|
|
27
|
+
const fetchSpy = vi.fn(async () => Response.json({ plan }));
|
|
28
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
29
|
+
const onSetElementAttributeQuiet = vi.fn(async () => ({ status: "saved" as const }));
|
|
30
|
+
await expect(
|
|
31
|
+
normalizeClipsLoudness("p1", [clip("vo", { automation: volumeLane })], {
|
|
32
|
+
onSetElementAttributeQuiet,
|
|
33
|
+
}),
|
|
34
|
+
).rejects.toThrow(/volume is automated/);
|
|
35
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
36
|
+
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects instead of claiming success when the save fails", async () => {
|
|
40
|
+
vi.stubGlobal(
|
|
41
|
+
"fetch",
|
|
42
|
+
vi.fn(async () => Response.json({ plan })),
|
|
43
|
+
);
|
|
44
|
+
const onSetElementAttributeQuiet = vi.fn(async () => ({
|
|
45
|
+
status: "failed" as const,
|
|
46
|
+
reason: "disk full",
|
|
47
|
+
}));
|
|
48
|
+
await expect(
|
|
49
|
+
normalizeClipsLoudness("p1", [clip("vo")], { onSetElementAttributeQuiet }),
|
|
50
|
+
).rejects.toThrow("disk full");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("writeClipGains", () => {
|
|
55
|
+
it("stops at the first refused single save", async () => {
|
|
56
|
+
const onSetElementAttributeQuiet = vi.fn(async () => ({
|
|
57
|
+
status: "refused" as const,
|
|
58
|
+
reason: "Cannot edit timeline while recording",
|
|
59
|
+
}));
|
|
60
|
+
await expect(
|
|
61
|
+
writeClipGains(
|
|
62
|
+
[
|
|
63
|
+
{ element: clip("a"), gain: 2 },
|
|
64
|
+
{ element: clip("b"), gain: 2 },
|
|
65
|
+
],
|
|
66
|
+
{ onSetElementAttributeQuiet },
|
|
67
|
+
),
|
|
68
|
+
).rejects.toThrow("Cannot edit timeline while recording");
|
|
69
|
+
expect(onSetElementAttributeQuiet).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("surfaces a failed multi-clip save", async () => {
|
|
73
|
+
const onSetElementsAttributeQuiet = vi.fn(async () => ({
|
|
74
|
+
status: "failed" as const,
|
|
75
|
+
reason: "disk full",
|
|
76
|
+
}));
|
|
77
|
+
await expect(
|
|
78
|
+
writeClipGains(
|
|
79
|
+
[
|
|
80
|
+
{ element: clip("a"), gain: 2 },
|
|
81
|
+
{ element: clip("b"), gain: 2 },
|
|
82
|
+
],
|
|
83
|
+
{ onSetElementsAttributeQuiet },
|
|
84
|
+
),
|
|
85
|
+
).rejects.toThrow("disk full");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { formatAudioGain } from "@hyperframes/core/audio-gain";
|
|
2
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
3
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
4
|
+
import {
|
|
5
|
+
normalizeToastText,
|
|
6
|
+
requestNormalizePlan,
|
|
7
|
+
throwUnlessSaved,
|
|
8
|
+
VOLUME_LANE_REFUSAL,
|
|
9
|
+
volumeLaneOwnsGain,
|
|
10
|
+
} from "./clipMenuNormalize";
|
|
11
|
+
|
|
12
|
+
export const AUDIO_GAIN_LABEL = "Audio Gain";
|
|
13
|
+
|
|
14
|
+
type VolumeWriters = Pick<
|
|
15
|
+
TimelineEditCallbacks,
|
|
16
|
+
"onSetElementAttributeQuiet" | "onSetElementsAttributeQuiet"
|
|
17
|
+
>;
|
|
18
|
+
|
|
19
|
+
/** Every clip's new gain as `data-volume`, one undo step when the host can save them together. */
|
|
20
|
+
export async function writeClipGains(
|
|
21
|
+
edits: ReadonlyArray<{ element: TimelineElement; gain: number }>,
|
|
22
|
+
writers: VolumeWriters,
|
|
23
|
+
label = AUDIO_GAIN_LABEL,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
if (edits.some(({ element }) => volumeLaneOwnsGain(element))) {
|
|
26
|
+
throw new Error(VOLUME_LANE_REFUSAL);
|
|
27
|
+
}
|
|
28
|
+
const values = edits.map(({ element, gain }) => ({ element, value: formatAudioGain(gain) }));
|
|
29
|
+
if (values.length > 1 && writers.onSetElementsAttributeQuiet) {
|
|
30
|
+
throwUnlessSaved(await writers.onSetElementsAttributeQuiet(values, "data-volume", label));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const { element, value } of values) {
|
|
34
|
+
throwUnlessSaved(
|
|
35
|
+
await writers.onSetElementAttributeQuiet?.(element, "data-volume", value, label),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Measure each clip against −16 LUFS, write the gains, and say what happened. */
|
|
41
|
+
export async function normalizeClipsLoudness(
|
|
42
|
+
projectId: string,
|
|
43
|
+
elements: readonly TimelineElement[],
|
|
44
|
+
writers: VolumeWriters,
|
|
45
|
+
): Promise<string> {
|
|
46
|
+
if (elements.some(volumeLaneOwnsGain)) throw new Error(VOLUME_LANE_REFUSAL);
|
|
47
|
+
const plans = await Promise.all(elements.map((el) => requestNormalizePlan(projectId, el)));
|
|
48
|
+
await writeClipGains(
|
|
49
|
+
elements.map((element, index) => ({ element, gain: plans[index]?.volume ?? 1 })),
|
|
50
|
+
writers,
|
|
51
|
+
"Normalize loudness",
|
|
52
|
+
);
|
|
53
|
+
const [only] = plans;
|
|
54
|
+
return plans.length === 1 && only
|
|
55
|
+
? normalizeToastText(only)
|
|
56
|
+
: `Normalized ${plans.length} clips to −16 LUFS`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { clipHasSound } from "./clipMenuNormalize";
|
|
4
|
+
|
|
5
|
+
interface AudioGainDialogState {
|
|
6
|
+
targetKeys: readonly string[] | null;
|
|
7
|
+
open: (keys: readonly string[]) => void;
|
|
8
|
+
close: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Which clips the Audio Gain dialog (G) is open for. */
|
|
12
|
+
export const useAudioGainDialogStore = create<AudioGainDialogState>((set) => ({
|
|
13
|
+
targetKeys: null,
|
|
14
|
+
open: (keys) => set({ targetKeys: keys }),
|
|
15
|
+
close: () => set({ targetKeys: null }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
19
|
+
|
|
20
|
+
/** The selected clips with sound, or just `clicked` when it is outside the selection. */
|
|
21
|
+
function audioGainTargetKeys(clicked?: TimelineElement): string[] {
|
|
22
|
+
const { elements, selectedElementId, selectedElementIds } = usePlayerStore.getState();
|
|
23
|
+
const selected = new Set(selectedElementIds);
|
|
24
|
+
if (selectedElementId) selected.add(selectedElementId);
|
|
25
|
+
if (clicked && !selected.has(keyOf(clicked)))
|
|
26
|
+
return clipHasSound(clicked) ? [keyOf(clicked)] : [];
|
|
27
|
+
return elements.filter((el) => selected.has(keyOf(el)) && clipHasSound(el)).map(keyOf);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Open Audio Gain for the selection (or the clicked clip); false when none of it has sound. */
|
|
31
|
+
export function openAudioGainDialog(clicked?: TimelineElement): boolean {
|
|
32
|
+
const keys = audioGainTargetKeys(clicked);
|
|
33
|
+
if (keys.length === 0) return false;
|
|
34
|
+
useAudioGainDialogStore.getState().open(keys);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { audioGainToDb, MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
|
|
3
|
+
import { peakAmplitudeDb, planAudioGain, type AudioGainClip } from "./audioGainPlan";
|
|
4
|
+
|
|
5
|
+
const clip = (key: string, gain: number, sourcePeak: number | null): AudioGainClip => ({
|
|
6
|
+
key,
|
|
7
|
+
gain,
|
|
8
|
+
sourcePeak,
|
|
9
|
+
});
|
|
10
|
+
const db = (gain: number | undefined) => audioGainToDb(gain ?? Number.NaN);
|
|
11
|
+
const gainOf = (plan: ReturnType<typeof planAudioGain>, key: string) =>
|
|
12
|
+
plan.find((edit) => edit.key === key)?.gain;
|
|
13
|
+
|
|
14
|
+
describe("planAudioGain", () => {
|
|
15
|
+
it("Set Gain to puts every clip at the same gain", () => {
|
|
16
|
+
const plan = planAudioGain([clip("a", 2, 0.5), clip("b", 0.5, null)], { mode: "set", db: -6 });
|
|
17
|
+
expect(db(gainOf(plan, "a"))).toBeCloseTo(-6, 6);
|
|
18
|
+
expect(db(gainOf(plan, "b"))).toBeCloseTo(-6, 6);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("Adjust Gain by moves each clip by the same dB from where it is", () => {
|
|
22
|
+
const plan = planAudioGain([clip("a", 2, null), clip("b", 0.5, null)], {
|
|
23
|
+
mode: "adjust",
|
|
24
|
+
db: 3,
|
|
25
|
+
});
|
|
26
|
+
expect(db(gainOf(plan, "a"))).toBeCloseTo(db(2) + 3, 6);
|
|
27
|
+
expect(db(gainOf(plan, "b"))).toBeCloseTo(db(0.5) + 3, 6);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("Normalize Max Peak moves all clips together until the loudest peak hits the target", () => {
|
|
31
|
+
const plan = planAudioGain([clip("loud", 1, 0.5), clip("quiet", 1, 0.25)], {
|
|
32
|
+
mode: "normalize-max",
|
|
33
|
+
db: -1,
|
|
34
|
+
});
|
|
35
|
+
expect(db(0.5 * (gainOf(plan, "loud") ?? 0))).toBeCloseTo(-1, 6);
|
|
36
|
+
expect(db(gainOf(plan, "loud")) - db(gainOf(plan, "quiet"))).toBeCloseTo(0, 6);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("Normalize Max Peak counts each clip's current gain", () => {
|
|
40
|
+
const plan = planAudioGain([clip("a", 0.5, 0.8), clip("b", 2, 0.25)], {
|
|
41
|
+
mode: "normalize-max",
|
|
42
|
+
db: -3,
|
|
43
|
+
});
|
|
44
|
+
expect(db(0.25 * (gainOf(plan, "b") ?? 0))).toBeCloseTo(-3, 6);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("Normalize All Peaks brings each clip's own peak to the target", () => {
|
|
48
|
+
const plan = planAudioGain([clip("a", 1, 0.5), clip("b", 3, 0.25)], {
|
|
49
|
+
mode: "normalize-all",
|
|
50
|
+
db: -3,
|
|
51
|
+
});
|
|
52
|
+
expect(db(0.5 * (gainOf(plan, "a") ?? 0))).toBeCloseTo(-3, 6);
|
|
53
|
+
expect(db(0.25 * (gainOf(plan, "b") ?? 0))).toBeCloseTo(-3, 6);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("leaves clips it could not measure out of a normalize", () => {
|
|
57
|
+
const plan = planAudioGain([clip("a", 1, 0.5), clip("b", 1, null), clip("c", 1, 0)], {
|
|
58
|
+
mode: "normalize-all",
|
|
59
|
+
db: -1,
|
|
60
|
+
});
|
|
61
|
+
expect(plan.map((edit) => edit.key)).toEqual(["a"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("clamps to the +12 dB ceiling and to silence", () => {
|
|
65
|
+
const loud = planAudioGain([clip("a", 1, 0.001)], { mode: "normalize-all", db: 0 });
|
|
66
|
+
expect(gainOf(loud, "a")).toBe(MAX_AUDIO_GAIN);
|
|
67
|
+
expect(gainOf(planAudioGain([clip("a", 1, null)], { mode: "set", db: 40 }), "a")).toBe(
|
|
68
|
+
MAX_AUDIO_GAIN,
|
|
69
|
+
);
|
|
70
|
+
expect(gainOf(planAudioGain([clip("a", 0, null)], { mode: "adjust", db: 6 }), "a")).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("peakAmplitudeDb", () => {
|
|
75
|
+
it("is the loudest peak across the selection at each clip's gain", () => {
|
|
76
|
+
expect(peakAmplitudeDb([clip("a", 1, 0.5), clip("b", 2, 0.5)])).toBeCloseTo(0, 6);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("is null when nothing was measured", () => {
|
|
80
|
+
expect(peakAmplitudeDb([clip("a", 1, null)])).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { audioDbToGain, audioGainToDb, clampAudioGain } from "@hyperframes/core/audio-gain";
|
|
2
|
+
|
|
3
|
+
export type AudioGainMode = "set" | "adjust" | "normalize-max" | "normalize-all";
|
|
4
|
+
|
|
5
|
+
export interface AudioGainChoice {
|
|
6
|
+
mode: AudioGainMode;
|
|
7
|
+
db: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A selected clip: its `data-volume` gain and its source's linear sample peak over the played window. */
|
|
11
|
+
export interface AudioGainClip {
|
|
12
|
+
key: string;
|
|
13
|
+
gain: number;
|
|
14
|
+
sourcePeak: number | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AudioGainEdit {
|
|
18
|
+
key: string;
|
|
19
|
+
gain: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isMeasured(clip: AudioGainClip): clip is AudioGainClip & { sourcePeak: number } {
|
|
23
|
+
return clip.sourcePeak !== null && clip.sourcePeak > 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const heardPeak = (clip: AudioGainClip & { sourcePeak: number }) => clip.sourcePeak * clip.gain;
|
|
27
|
+
|
|
28
|
+
/** Premiere's Audio Gain options as new `data-volume` gains, clamped to silence..+12 dB. */
|
|
29
|
+
export function planAudioGain(
|
|
30
|
+
clips: readonly AudioGainClip[],
|
|
31
|
+
choice: AudioGainChoice,
|
|
32
|
+
): AudioGainEdit[] {
|
|
33
|
+
const target = audioDbToGain(choice.db);
|
|
34
|
+
const edit = (clip: AudioGainClip, gain: number) => ({
|
|
35
|
+
key: clip.key,
|
|
36
|
+
gain: clampAudioGain(gain),
|
|
37
|
+
});
|
|
38
|
+
if (choice.mode === "set") return clips.map((clip) => edit(clip, target));
|
|
39
|
+
if (choice.mode === "adjust") return clips.map((clip) => edit(clip, clip.gain * target));
|
|
40
|
+
const measured = clips.filter(isMeasured);
|
|
41
|
+
if (choice.mode === "normalize-all") {
|
|
42
|
+
return measured.map((clip) => edit(clip, target / clip.sourcePeak));
|
|
43
|
+
}
|
|
44
|
+
const loudest = Math.max(0, ...measured.map(heardPeak));
|
|
45
|
+
if (loudest <= 0) return [];
|
|
46
|
+
return measured.map((clip) => edit(clip, (clip.gain * target) / loudest));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The loudest peak the selection plays at its current gains, in dBFS. */
|
|
50
|
+
export function peakAmplitudeDb(clips: readonly AudioGainClip[]): number | null {
|
|
51
|
+
const loudest = Math.max(0, ...clips.filter(isMeasured).map(heardPeak));
|
|
52
|
+
return loudest > 0 ? audioGainToDb(loudest) : null;
|
|
53
|
+
}
|
|
@@ -3,6 +3,7 @@ import { act } from "react";
|
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
5
5
|
import { ClipMenuAudioItems } from "./clipMenuAudioItems";
|
|
6
|
+
import { AudioGainDialog } from "./AudioGainDialog";
|
|
6
7
|
import { usePlayerStore } from "../store/playerStore";
|
|
7
8
|
import { usePreviewIframeStore } from "../store/previewIframeStore";
|
|
8
9
|
import type { TimelineElement } from "../store/timelineElement";
|
|
@@ -38,7 +39,7 @@ function mountPreview(): void {
|
|
|
38
39
|
usePreviewIframeStore.getState().setIframe(iframe);
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function render(part: "
|
|
42
|
+
function render(part: "gain" | "duck") {
|
|
42
43
|
const host = document.createElement("div");
|
|
43
44
|
document.body.appendChild(host);
|
|
44
45
|
act(() =>
|
|
@@ -55,23 +56,30 @@ afterEach(() => {
|
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
describe("ClipMenuAudioItems in a host without Studio's shell", () => {
|
|
58
|
-
it("offers
|
|
59
|
+
it("offers Audio Gain and Duck from the timeline session and the live preview", () => {
|
|
59
60
|
usePlayerStore.getState().beginTimelineSession("p1");
|
|
60
61
|
mountPreview();
|
|
61
|
-
expect(render("
|
|
62
|
+
expect(render("gain").textContent).toContain("Audio Gain…");
|
|
62
63
|
expect(render("duck").textContent).toContain("Duck under voice");
|
|
63
64
|
});
|
|
64
65
|
|
|
65
|
-
it("normalizes through the session's project and
|
|
66
|
+
it("the dialog's loudness row normalizes through the session's project and onNotice", async () => {
|
|
66
67
|
usePlayerStore.getState().beginTimelineSession("p1");
|
|
67
|
-
mountPreview();
|
|
68
68
|
const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
|
|
69
69
|
const fetchSpy = vi.fn(async (_url: string) => Response.json({ plan }));
|
|
70
70
|
vi.stubGlobal("fetch", fetchSpy);
|
|
71
|
-
const
|
|
72
|
-
|
|
71
|
+
const host = document.createElement("div");
|
|
72
|
+
document.body.appendChild(host);
|
|
73
|
+
act(() => harness.mount(host).render(<AudioGainDialog elements={[tour]} onClose={() => {}} />));
|
|
74
|
+
const loudness = [...document.querySelectorAll("label")].find((l) =>
|
|
75
|
+
l.textContent?.includes("−16 LUFS"),
|
|
76
|
+
);
|
|
77
|
+
act(() => loudness?.querySelector("input")?.click());
|
|
78
|
+
const ok = [...document.querySelectorAll("button")].find((b) => b.textContent === "OK");
|
|
79
|
+
await act(async () => ok?.click());
|
|
73
80
|
await vi.waitFor(() => expect(onNotice).toHaveBeenCalled());
|
|
74
|
-
|
|
81
|
+
const urls = fetchSpy.mock.calls.map((call) => String(call[0]));
|
|
82
|
+
expect(urls).toContain("/api/projects/p1/loudness/normalize");
|
|
75
83
|
expect(setQuiet).toHaveBeenCalledWith(tour, "data-volume", "2", "Normalize loudness");
|
|
76
84
|
expect(onNotice).toHaveBeenCalledWith("Normalized to −16 LUFS (+6.0 dB)", "info");
|
|
77
85
|
});
|
|
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
4
4
|
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
5
5
|
import { ClipMenuAudioItems } from "./clipMenuAudioItems";
|
|
6
6
|
import type { TimelineElement } from "../store/timelineElement";
|
|
7
|
+
import { useAudioGainDialogStore } from "./audioGainDialogStore";
|
|
7
8
|
|
|
8
9
|
const showToast = vi.fn();
|
|
9
10
|
const setQuiet = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => {});
|
|
@@ -27,7 +28,7 @@ vi.mock("../../contexts/TimelineEditContext", () => ({
|
|
|
27
28
|
|
|
28
29
|
const harness = createHappyDomRootHarness();
|
|
29
30
|
|
|
30
|
-
function render(element: TimelineElement, part: "
|
|
31
|
+
function render(element: TimelineElement, part: "gain" | "duck" = "gain") {
|
|
31
32
|
const host = document.createElement("div");
|
|
32
33
|
document.body.appendChild(host);
|
|
33
34
|
act(() =>
|
|
@@ -39,7 +40,6 @@ function render(element: TimelineElement, part: "normalize" | "duck" = "normaliz
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
const base = { start: 0, duration: 4, track: 0 };
|
|
42
|
-
const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
|
|
43
43
|
const clickItem = async (host: HTMLElement, label: string) => {
|
|
44
44
|
const button = [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(label));
|
|
45
45
|
await act(async () => button?.click());
|
|
@@ -95,12 +95,8 @@ describe("ClipMenuAudioItems", () => {
|
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
it("
|
|
99
|
-
|
|
100
|
-
vi.stubGlobal(
|
|
101
|
-
"fetch",
|
|
102
|
-
vi.fn(async () => Response.json({ plan })),
|
|
103
|
-
);
|
|
98
|
+
it("Audio Gain… opens the G dialog for the clicked clip", async () => {
|
|
99
|
+
useAudioGainDialogStore.getState().close();
|
|
104
100
|
const element: TimelineElement = {
|
|
105
101
|
...base,
|
|
106
102
|
id: "a-roll",
|
|
@@ -110,42 +106,12 @@ describe("ClipMenuAudioItems", () => {
|
|
|
110
106
|
};
|
|
111
107
|
const host = render(element);
|
|
112
108
|
const button = [...host.querySelectorAll("button")].find((b) =>
|
|
113
|
-
b.textContent?.includes("
|
|
109
|
+
b.textContent?.includes("Audio Gain…"),
|
|
114
110
|
);
|
|
111
|
+
expect(button?.textContent).toBe("Audio Gain…G");
|
|
115
112
|
await act(async () => button?.click());
|
|
116
|
-
|
|
117
|
-
expect(
|
|
118
|
-
expect(showToast).toHaveBeenCalledWith("Normalized to −16 LUFS (+6.0 dB)", "info");
|
|
119
|
-
vi.unstubAllGlobals();
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("refuses to normalize a clip whose volume lane owns its gain", async () => {
|
|
123
|
-
const fetchSpy = vi.fn(async () => Response.json({ plan }));
|
|
124
|
-
vi.stubGlobal("fetch", fetchSpy);
|
|
125
|
-
const automation = JSON.stringify({
|
|
126
|
-
version: 1,
|
|
127
|
-
lanes: [{ target: "volume", points: [{ t: 0, v: 0.25 }] }],
|
|
128
|
-
});
|
|
129
|
-
const host = render({ ...base, id: "vo", tag: "audio", src: "vo.mp3", automation });
|
|
130
|
-
await clickItem(host, "Normalize loudness");
|
|
131
|
-
await vi.waitFor(() => expect(showToast).toHaveBeenCalled());
|
|
132
|
-
expect(showToast).toHaveBeenCalledWith(expect.stringContaining("volume is automated"), "error");
|
|
133
|
-
expect(setQuiet).not.toHaveBeenCalled();
|
|
134
|
-
expect(fetchSpy).not.toHaveBeenCalled();
|
|
135
|
-
vi.unstubAllGlobals();
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("reports a failed save instead of claiming the clip was normalized", async () => {
|
|
139
|
-
vi.stubGlobal(
|
|
140
|
-
"fetch",
|
|
141
|
-
vi.fn(async () => Response.json({ plan })),
|
|
142
|
-
);
|
|
143
|
-
setQuiet.mockResolvedValue({ status: "failed", reason: "disk full" });
|
|
144
|
-
const host = render({ ...base, id: "vo", tag: "audio", src: "vo.mp3" });
|
|
145
|
-
await clickItem(host, "Normalize loudness");
|
|
146
|
-
await vi.waitFor(() => expect(showToast).toHaveBeenCalledWith("disk full", "error"));
|
|
147
|
-
expect(showToast).not.toHaveBeenCalledWith(expect.stringContaining("Normalized"), "info");
|
|
148
|
-
vi.unstubAllGlobals();
|
|
113
|
+
expect(useAudioGainDialogStore.getState().targetKeys).toEqual(["a-roll"]);
|
|
114
|
+
expect(host.textContent).not.toContain("Normalize loudness");
|
|
149
115
|
});
|
|
150
116
|
|
|
151
117
|
it("stops ducking at the first refused save and says why", async () => {
|