@hyperframes/studio 0.8.108 → 0.8.110

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +64 -9
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +8216 -6268
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  24. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  25. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  26. package/src/components/editor/InlineTextCaret.tsx +211 -0
  27. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  28. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  29. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  30. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  31. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  32. package/src/components/editor/domEditInlineText.ts +6 -4
  33. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  34. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  35. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  36. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  37. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  38. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  39. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  40. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  41. package/src/components/editor/marqueeCommit.ts +16 -8
  42. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  43. package/src/components/editor/mediaAudioEdits.ts +27 -14
  44. package/src/components/editor/mediaLinkEdits.test.ts +334 -0
  45. package/src/components/editor/mediaLinkEdits.ts +303 -0
  46. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  47. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  48. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  49. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  50. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  51. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  52. package/src/components/editor/useVolumeAutomation.ts +29 -7
  53. package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
  54. package/src/components/ui/Menu.test.tsx +3 -22
  55. package/src/components/ui/Menu.tsx +2 -2
  56. package/src/components/ui/Popover.tsx +2 -2
  57. package/src/components/ui/Select.tsx +2 -2
  58. package/src/components/ui/Tooltip.tsx +2 -2
  59. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  60. package/src/components/ui/valueControls.test.tsx +2 -21
  61. package/src/contexts/TimelineEditContext.tsx +4 -0
  62. package/src/editorGesturesExports.test.tsx +1 -0
  63. package/src/hooks/appHotkeysDispatch.ts +11 -1
  64. package/src/hooks/linkShortcuts.test.ts +70 -0
  65. package/src/hooks/linkShortcuts.ts +41 -0
  66. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  67. package/src/hooks/timelineElementsAttribute.ts +113 -0
  68. package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
  69. package/src/hooks/timelineLinkEditPlan.ts +128 -0
  70. package/src/hooks/timelineMoveAdapter.ts +13 -0
  71. package/src/hooks/timelineTimingSync.test.ts +59 -0
  72. package/src/hooks/timelineTimingSync.ts +22 -3
  73. package/src/hooks/useAppHotkeys.test.ts +34 -0
  74. package/src/hooks/useAppHotkeys.ts +9 -0
  75. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  76. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  77. package/src/hooks/useGsapScriptCommits.ts +23 -3
  78. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  79. package/src/hooks/usePreviewPersistence.ts +15 -0
  80. package/src/hooks/useRazorSplit.test.tsx +48 -0
  81. package/src/hooks/useRazorSplit.ts +12 -1
  82. package/src/hooks/useRenderClipContent.test.ts +6 -4
  83. package/src/hooks/useRenderClipContent.ts +9 -8
  84. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  85. package/src/hooks/useTimelineEditGuard.ts +54 -0
  86. package/src/hooks/useTimelineEditing.ts +49 -55
  87. package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
  88. package/src/hooks/useTimelineLinkEditing.ts +158 -0
  89. package/src/index.ts +5 -0
  90. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  91. package/src/player/components/AudioGainDialog.tsx +226 -0
  92. package/src/player/components/AudioWaveform.test.tsx +30 -3
  93. package/src/player/components/AudioWaveform.tsx +6 -7
  94. package/src/player/components/ClipBadges.test.tsx +32 -8
  95. package/src/player/components/ClipBadges.tsx +37 -31
  96. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  97. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  98. package/src/player/components/ClipContextMenu.tsx +115 -90
  99. package/src/player/components/ClipPeakMarks.tsx +2 -14
  100. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  101. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  102. package/src/player/components/Timeline.test.ts +15 -0
  103. package/src/player/components/TimelineClip.test.tsx +58 -0
  104. package/src/player/components/TimelineClip.tsx +9 -2
  105. package/src/player/components/TimelineLanes.test.tsx +45 -2
  106. package/src/player/components/TimelineLanes.tsx +0 -2
  107. package/src/player/components/TimelineOverlays.tsx +2 -0
  108. package/src/player/components/audioClipLink.test.ts +288 -31
  109. package/src/player/components/audioClipLink.ts +151 -27
  110. package/src/player/components/audioGainApply.test.ts +87 -0
  111. package/src/player/components/audioGainApply.ts +57 -0
  112. package/src/player/components/audioGainDialogStore.ts +36 -0
  113. package/src/player/components/audioGainPlan.test.ts +82 -0
  114. package/src/player/components/audioGainPlan.ts +53 -0
  115. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
  116. package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
  117. package/src/player/components/clipMenuAudioItems.tsx +44 -57
  118. package/src/player/components/clipMenuDuck.ts +5 -0
  119. package/src/player/components/clipMenuLinkItems.test.ts +174 -0
  120. package/src/player/components/clipMenuLinkItems.tsx +166 -0
  121. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  122. package/src/player/components/clipMenuToolItems.tsx +80 -9
  123. package/src/player/components/clipPeakMap.ts +46 -0
  124. package/src/player/components/clipPeakRuns.test.ts +14 -1
  125. package/src/player/components/clipPeakRuns.ts +11 -0
  126. package/src/player/components/clipSync.test.ts +67 -0
  127. package/src/player/components/clipSync.ts +80 -0
  128. package/src/player/components/clipToolAttrs.test.ts +57 -41
  129. package/src/player/components/clipToolAttrs.ts +48 -45
  130. package/src/player/components/linkLabelColor.test.ts +20 -0
  131. package/src/player/components/linkLabelColor.ts +24 -0
  132. package/src/player/components/studioShortcuts.ts +2 -0
  133. package/src/player/components/timelineCallbacks.ts +17 -0
  134. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  135. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  136. package/src/player/components/timelineClipDragPreview.ts +32 -7
  137. package/src/player/components/timelineClipDragTypes.ts +2 -0
  138. package/src/player/components/timelineClipGestureHandlers.ts +29 -31
  139. package/src/player/components/timelineEditing.test.ts +0 -13
  140. package/src/player/components/timelineEditing.ts +0 -11
  141. package/src/player/components/timelineLaneProps.ts +0 -5
  142. package/src/player/components/timelineLinkSelection.ts +41 -0
  143. package/src/player/components/timelineViewportGeometry.ts +3 -0
  144. package/src/player/components/useClipToolState.ts +0 -5
  145. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  146. package/src/player/components/useTimelineClipDrag.ts +28 -0
  147. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  148. package/src/player/components/useTimelineProviderState.tsx +3 -2
  149. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  150. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  151. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  152. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  153. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  154. package/src/player/lib/timelineDOM.test.ts +31 -0
  155. package/src/player/lib/timelineDOM.ts +13 -0
  156. package/src/player/store/timelineElement.ts +12 -0
  157. package/src/styles/components.css +27 -11
  158. package/src/styles/theme.css +0 -1
  159. package/src/utils/gsapSoftReload.test.ts +238 -2
  160. package/src/utils/gsapSoftReload.ts +89 -74
  161. package/src/utils/gsapUndoRestore.test.ts +4 -3
  162. package/src/utils/gsapUndoRestore.ts +15 -0
  163. package/src/utils/linkedClipPreferences.ts +34 -0
  164. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  165. package/src/utils/softReloadTargets.ts +128 -0
  166. package/src/utils/studioUiPreferences.ts +8 -0
  167. package/src/utils/typingTarget.test.ts +8 -0
  168. package/src/utils/typingTarget.ts +1 -0
  169. package/dist/chunk-AOWVEVEU.js.map +0 -1
  170. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -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
+ }
@@ -0,0 +1,86 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createHappyDomRootHarness } from "./testRootHarness";
5
+ import { ClipMenuAudioItems } from "./clipMenuAudioItems";
6
+ import { AudioGainDialog } from "./AudioGainDialog";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import { usePreviewIframeStore } from "../store/previewIframeStore";
9
+ import type { TimelineElement } from "../store/timelineElement";
10
+
11
+ const onNotice = vi.fn();
12
+ const setQuiet = vi.fn(async () => {});
13
+
14
+ vi.mock("../../contexts/StudioContext", () => ({
15
+ useStudioShellContextOptional: () => null,
16
+ }));
17
+ vi.mock("../../contexts/TimelineEditContext", () => ({
18
+ useTimelineEditContextOptional: () => ({ onSetElementAttributeQuiet: setQuiet, onNotice }),
19
+ }));
20
+
21
+ const harness = createHappyDomRootHarness();
22
+ const tour: TimelineElement = {
23
+ id: "tour",
24
+ tag: "video",
25
+ start: 0,
26
+ duration: 4,
27
+ track: 0,
28
+ hasAudio: true,
29
+ src: "tour.mp4",
30
+ };
31
+
32
+ function mountPreview(): void {
33
+ const iframe = document.createElement("iframe");
34
+ document.body.appendChild(iframe);
35
+ const doc = iframe.contentDocument;
36
+ if (!doc) throw new Error("iframe has no document");
37
+ doc.body.innerHTML =
38
+ '<video id="tour" src="tour.mp4" data-has-audio="true" data-start="0" data-duration="4"></video><audio id="voiceover" src="vo.wav" data-start="1" data-duration="2"></audio>';
39
+ usePreviewIframeStore.getState().setIframe(iframe);
40
+ }
41
+
42
+ function render(part: "gain" | "duck") {
43
+ const host = document.createElement("div");
44
+ document.body.appendChild(host);
45
+ act(() =>
46
+ harness
47
+ .mount(host)
48
+ .render(<ClipMenuAudioItems part={part} element={tour} onClose={() => {}} />),
49
+ );
50
+ return host;
51
+ }
52
+
53
+ afterEach(() => {
54
+ usePreviewIframeStore.getState().setIframe(null);
55
+ vi.unstubAllGlobals();
56
+ });
57
+
58
+ describe("ClipMenuAudioItems in a host without Studio's shell", () => {
59
+ it("offers Audio Gain and Duck from the timeline session and the live preview", () => {
60
+ usePlayerStore.getState().beginTimelineSession("p1");
61
+ mountPreview();
62
+ expect(render("gain").textContent).toContain("Audio Gain…");
63
+ expect(render("duck").textContent).toContain("Duck under voice");
64
+ });
65
+
66
+ it("the dialog's loudness row normalizes through the session's project and onNotice", async () => {
67
+ usePlayerStore.getState().beginTimelineSession("p1");
68
+ const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
69
+ const fetchSpy = vi.fn(async (_url: string) => Response.json({ plan }));
70
+ vi.stubGlobal("fetch", fetchSpy);
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());
80
+ await vi.waitFor(() => expect(onNotice).toHaveBeenCalled());
81
+ const urls = fetchSpy.mock.calls.map((call) => String(call[0]));
82
+ expect(urls).toContain("/api/projects/p1/loudness/normalize");
83
+ expect(setQuiet).toHaveBeenCalledWith(tour, "data-volume", "2", "Normalize loudness");
84
+ expect(onNotice).toHaveBeenCalledWith("Normalized to −16 LUFS (+6.0 dB)", "info");
85
+ });
86
+ });
@@ -4,10 +4,16 @@ 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 () => {});
10
- const iframe = document.createElement("iframe");
11
+ let previewDoc: Document | null = null;
12
+ const iframe = {
13
+ get contentDocument() {
14
+ return previewDoc;
15
+ },
16
+ };
11
17
 
12
18
  vi.mock("../../contexts/StudioContext", () => ({
13
19
  useStudioShellContextOptional: () => ({
@@ -22,17 +28,18 @@ vi.mock("../../contexts/TimelineEditContext", () => ({
22
28
 
23
29
  const harness = createHappyDomRootHarness();
24
30
 
25
- function render(element: TimelineElement) {
31
+ function render(element: TimelineElement, part: "gain" | "duck" = "gain") {
26
32
  const host = document.createElement("div");
27
33
  document.body.appendChild(host);
28
34
  act(() =>
29
- harness.mount(host).render(<ClipMenuAudioItems element={element} onClose={() => {}} />),
35
+ harness
36
+ .mount(host)
37
+ .render(<ClipMenuAudioItems part={part} element={element} onClose={() => {}} />),
30
38
  );
31
39
  return host;
32
40
  }
33
41
 
34
42
  const base = { start: 0, duration: 4, track: 0 };
35
- const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
36
43
  const clickItem = async (host: HTMLElement, label: string) => {
37
44
  const button = [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(label));
38
45
  await act(async () => button?.click());
@@ -50,12 +57,46 @@ describe("ClipMenuAudioItems", () => {
50
57
  expect(host.textContent).toBe("");
51
58
  });
52
59
 
53
- it("normalizes a video with sound by writing data-volume as one edit", async () => {
54
- const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
55
- vi.stubGlobal(
56
- "fetch",
57
- vi.fn(async () => Response.json({ plan })),
58
- );
60
+ describe("Duck under voice", () => {
61
+ const duckLabel = (host: HTMLElement) =>
62
+ [...host.querySelectorAll("button")].find((b) => b.textContent?.includes("Duck under voice"));
63
+ const compose = (body: string) => {
64
+ previewDoc = document.implementation.createHTMLDocument("c");
65
+ previewDoc.body.innerHTML = body;
66
+ };
67
+ const music: TimelineElement = { ...base, id: "music", tag: "audio", src: "music.mp3" };
68
+
69
+ it("is hidden on a lone video with sound", () => {
70
+ compose(
71
+ `<video id="a-roll" src="a.mp4" data-start="0" data-duration="4" data-has-audio="true"></video>`,
72
+ );
73
+ const host = render(
74
+ { ...base, id: "a-roll", tag: "video", hasAudio: true, src: "a.mp4" },
75
+ "duck",
76
+ );
77
+ expect(duckLabel(host)).toBeUndefined();
78
+ });
79
+
80
+ it("shows for a music bed with an overlapping voice", () => {
81
+ compose(`<audio id="music" src="music.mp3" data-start="0" data-duration="4"></audio>
82
+ <audio id="voiceover" src="vo.wav" data-start="1" data-duration="2"></audio>`);
83
+ const host = render(music, "duck");
84
+ expect(duckLabel(host)?.getAttribute("aria-checked")).toBe("false");
85
+ });
86
+
87
+ it("stays available, checked, once ducked even with the voice gone", () => {
88
+ compose(
89
+ `<audio id="music" src="music.mp3" data-start="0" data-duration="4" data-fx-carve='{"enabled":true,"sources":["voiceover"],"strength":0.25}'></audio>`,
90
+ );
91
+ const host = render(music, "duck");
92
+ const item = duckLabel(host);
93
+ expect(item?.getAttribute("aria-checked")).toBe("true");
94
+ expect(item?.textContent).toBe("Duck under voice✓");
95
+ });
96
+ });
97
+
98
+ it("Audio Gain… opens the G dialog for the clicked clip", async () => {
99
+ useAudioGainDialogStore.getState().close();
59
100
  const element: TimelineElement = {
60
101
  ...base,
61
102
  id: "a-roll",
@@ -65,61 +106,29 @@ describe("ClipMenuAudioItems", () => {
65
106
  };
66
107
  const host = render(element);
67
108
  const button = [...host.querySelectorAll("button")].find((b) =>
68
- b.textContent?.includes("Normalize loudness"),
109
+ b.textContent?.includes("Audio Gain…"),
69
110
  );
111
+ expect(button?.textContent).toBe("Audio Gain…G");
70
112
  await act(async () => button?.click());
71
- await vi.waitFor(() => expect(setQuiet).toHaveBeenCalled());
72
- expect(setQuiet).toHaveBeenCalledWith(element, "data-volume", "2", "Normalize loudness");
73
- expect(showToast).toHaveBeenCalledWith("Normalized to −16 LUFS (+6.0 dB)", "info");
74
- vi.unstubAllGlobals();
75
- });
76
-
77
- it("refuses to normalize a clip whose volume lane owns its gain", async () => {
78
- const fetchSpy = vi.fn(async () => Response.json({ plan }));
79
- vi.stubGlobal("fetch", fetchSpy);
80
- const automation = JSON.stringify({
81
- version: 1,
82
- lanes: [{ target: "volume", points: [{ t: 0, v: 0.25 }] }],
83
- });
84
- const host = render({ ...base, id: "vo", tag: "audio", src: "vo.mp3", automation });
85
- await clickItem(host, "Normalize loudness");
86
- await vi.waitFor(() => expect(showToast).toHaveBeenCalled());
87
- expect(showToast).toHaveBeenCalledWith(expect.stringContaining("volume is automated"), "error");
88
- expect(setQuiet).not.toHaveBeenCalled();
89
- expect(fetchSpy).not.toHaveBeenCalled();
90
- vi.unstubAllGlobals();
91
- });
92
-
93
- it("reports a failed save instead of claiming the clip was normalized", async () => {
94
- vi.stubGlobal(
95
- "fetch",
96
- vi.fn(async () => Response.json({ plan })),
97
- );
98
- setQuiet.mockResolvedValue({ status: "failed", reason: "disk full" });
99
- const host = render({ ...base, id: "vo", tag: "audio", src: "vo.mp3" });
100
- await clickItem(host, "Normalize loudness");
101
- await vi.waitFor(() => expect(showToast).toHaveBeenCalledWith("disk full", "error"));
102
- expect(showToast).not.toHaveBeenCalledWith(expect.stringContaining("Normalized"), "info");
103
- vi.unstubAllGlobals();
113
+ expect(useAudioGainDialogStore.getState().targetKeys).toEqual(["a-roll"]);
114
+ expect(host.textContent).not.toContain("Normalize loudness");
104
115
  });
105
116
 
106
117
  it("stops ducking at the first refused save and says why", async () => {
107
- document.body.appendChild(iframe);
108
- const doc = iframe.contentDocument;
109
- if (!doc) throw new Error("fixture");
110
- doc.body.innerHTML = `<audio id="music" src="music.mp3" data-start="0" data-duration="10"></audio>
118
+ previewDoc = document.implementation.createHTMLDocument("c");
119
+ previewDoc.body.innerHTML = `<audio id="music" src="music.mp3" data-start="0" data-duration="10"></audio>
111
120
  <audio id="voiceover" src="vo.wav" data-start="1" data-duration="3"></audio>`;
112
121
  setQuiet.mockResolvedValue({
113
122
  status: "refused",
114
123
  reason: "Cannot edit timeline while recording",
115
124
  });
116
- const host = render({ ...base, id: "music", tag: "audio", src: "music.mp3" });
125
+ const host = render({ ...base, id: "music", tag: "audio", src: "music.mp3" }, "duck");
117
126
  await clickItem(host, "Duck under voice");
118
127
  await vi.waitFor(() =>
119
128
  expect(showToast).toHaveBeenCalledWith("Cannot edit timeline while recording", "error"),
120
129
  );
121
130
  expect(setQuiet).toHaveBeenCalledTimes(1);
122
131
  expect(showToast).not.toHaveBeenCalledWith(expect.stringContaining("Ducks under"), "info");
123
- iframe.remove();
132
+ previewDoc = null;
124
133
  });
125
134
  });