@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.
Files changed (93) hide show
  1. package/dist/assets/{hyperframes-player-CJYtikAy.js → hyperframes-player-DvJeHBHi.js} +1 -1
  2. package/dist/assets/{index-CMSgrT2F.js → index--G6c4Q_x.js} +1 -1
  3. package/dist/assets/{index-HTFVHUJ4.js → index-Bt4vDkfw.js} +1 -1
  4. package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-CYP6dMd7.js → index-SFktf7O1.js} +205 -204
  6. package/dist/index.d.ts +22 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5352 -4755
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +8 -8
  12. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  13. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  14. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  15. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  16. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  17. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  19. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  20. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  21. package/src/components/editor/marqueeCommit.ts +16 -8
  22. package/src/components/editor/mediaLinkEdits.test.ts +19 -6
  23. package/src/components/editor/mediaLinkEdits.ts +34 -13
  24. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  25. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  26. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  27. package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
  28. package/src/contexts/TimelineEditContext.tsx +1 -0
  29. package/src/editorGesturesExports.test.tsx +1 -0
  30. package/src/hooks/appHotkeysDispatch.ts +9 -0
  31. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  32. package/src/hooks/timelineElementsAttribute.ts +113 -0
  33. package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
  34. package/src/hooks/timelineLinkEditPlan.ts +6 -1
  35. package/src/hooks/useAppHotkeys.test.ts +34 -0
  36. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  37. package/src/hooks/useRenderClipContent.ts +3 -5
  38. package/src/hooks/useTimelineEditing.ts +13 -0
  39. package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
  40. package/src/hooks/useTimelineLinkEditing.ts +5 -0
  41. package/src/index.ts +5 -0
  42. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  43. package/src/player/components/AudioGainDialog.tsx +226 -0
  44. package/src/player/components/ClipBadges.test.tsx +32 -8
  45. package/src/player/components/ClipBadges.tsx +37 -31
  46. package/src/player/components/ClipContextMenu.groups.test.tsx +1 -1
  47. package/src/player/components/ClipContextMenu.tsx +1 -1
  48. package/src/player/components/ClipPeakMarks.tsx +2 -14
  49. package/src/player/components/Timeline.test.ts +15 -0
  50. package/src/player/components/TimelineClip.test.tsx +41 -0
  51. package/src/player/components/TimelineClip.tsx +3 -2
  52. package/src/player/components/TimelineLanes.test.tsx +45 -2
  53. package/src/player/components/TimelineLanes.tsx +0 -2
  54. package/src/player/components/TimelineOverlays.tsx +2 -0
  55. package/src/player/components/audioClipLink.test.ts +60 -0
  56. package/src/player/components/audioClipLink.ts +77 -0
  57. package/src/player/components/audioGainApply.test.ts +87 -0
  58. package/src/player/components/audioGainApply.ts +57 -0
  59. package/src/player/components/audioGainDialogStore.ts +36 -0
  60. package/src/player/components/audioGainPlan.test.ts +82 -0
  61. package/src/player/components/audioGainPlan.ts +53 -0
  62. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +16 -8
  63. package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
  64. package/src/player/components/clipMenuAudioItems.tsx +16 -37
  65. package/src/player/components/clipMenuLinkItems.test.ts +84 -0
  66. package/src/player/components/clipMenuLinkItems.tsx +54 -9
  67. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  68. package/src/player/components/clipMenuToolItems.tsx +79 -6
  69. package/src/player/components/clipPeakMap.ts +46 -0
  70. package/src/player/components/clipPeakRuns.test.ts +14 -1
  71. package/src/player/components/clipPeakRuns.ts +11 -0
  72. package/src/player/components/clipToolAttrs.test.ts +57 -41
  73. package/src/player/components/clipToolAttrs.ts +48 -45
  74. package/src/player/components/studioShortcuts.ts +2 -0
  75. package/src/player/components/timelineCallbacks.ts +6 -0
  76. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  77. package/src/player/components/timelineClipDragPreview.ts +32 -7
  78. package/src/player/components/timelineClipGestureHandlers.ts +26 -31
  79. package/src/player/components/timelineEditing.test.ts +0 -13
  80. package/src/player/components/timelineEditing.ts +0 -11
  81. package/src/player/components/timelineLaneProps.ts +0 -5
  82. package/src/player/components/timelineLinkSelection.ts +20 -2
  83. package/src/player/components/timelineViewportGeometry.ts +3 -0
  84. package/src/player/components/useClipToolState.ts +0 -5
  85. package/src/player/components/useTimelineClipDrag.ts +12 -5
  86. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  87. package/src/player/components/useTimelineProviderState.tsx +0 -2
  88. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  89. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  90. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  91. package/src/utils/studioPendingEdits.test.ts +18 -0
  92. package/src/utils/typingTarget.test.ts +8 -0
  93. 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: "normalize" | "duck") {
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 Normalize and Duck from the timeline session and the live preview", () => {
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("normalize").textContent).toContain("Normalize loudness");
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 reports through onNotice", async () => {
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 button = render("normalize").querySelector("button");
72
- await act(async () => button?.click());
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
- expect(String(fetchSpy.mock.calls[0]?.[0])).toContain("/api/projects/p1/loudness/normalize");
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: "normalize" | "duck" = "normalize") {
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("normalizes a video with sound by writing data-volume as one edit", async () => {
99
- const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
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("Normalize loudness"),
109
+ b.textContent?.includes("Audio Gain…"),
114
110
  );
111
+ expect(button?.textContent).toBe("Audio Gain…G");
115
112
  await act(async () => button?.click());
116
- await vi.waitFor(() => expect(setQuiet).toHaveBeenCalled());
117
- expect(setQuiet).toHaveBeenCalledWith(element, "data-volume", "2", "Normalize loudness");
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 () => {